Skip to content

Commit 2bc35e4

Browse files
committed
Add vim toggle command and new Vim edits
1 parent 67a80d8 commit 2bc35e4

File tree

9 files changed

+172
-8
lines changed

9 files changed

+172
-8
lines changed

src/components/ChatInput.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { usePersistedState, updatePersistedState } from "@/hooks/usePersistedSta
1010
import { useMode } from "@/contexts/ModeContext";
1111
import { ChatToggles } from "./ChatToggles";
1212
import { useSendMessageOptions } from "@/hooks/useSendMessageOptions";
13-
import { getModelKey, getInputKey } from "@/constants/storage";
13+
import { getModelKey, getInputKey, VIM_ENABLED_KEY } from "@/constants/storage";
1414
import { ToggleGroup } from "./ToggleGroup";
1515
import { CUSTOM_EVENTS } from "@/constants/events";
1616
import type { UIMode } from "@/types/mode";
@@ -675,6 +675,27 @@ export const ChatInput: React.FC<ChatInputProps> = ({
675675
return;
676676
}
677677

678+
// Handle /vim command
679+
if (parsed.type === "vim-toggle") {
680+
setInput(""); // Clear input immediately
681+
let newState = false;
682+
updatePersistedState<boolean>(
683+
VIM_ENABLED_KEY,
684+
(prev) => {
685+
const next = !(prev ?? false);
686+
newState = next;
687+
return next;
688+
},
689+
false
690+
);
691+
setToast({
692+
id: Date.now().toString(),
693+
type: "success",
694+
message: newState ? "Vim mode enabled" : "Vim mode disabled",
695+
});
696+
return;
697+
}
698+
678699
// Handle /telemetry command
679700
if (parsed.type === "telemetry-set") {
680701
setInput(""); // Clear input immediately

src/components/VimTextArea.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import type { UIMode } from "@/types/mode";
44
import * as vim from "@/utils/vim";
55
import { TooltipWrapper, Tooltip, HelpIndicator } from "./Tooltip";
66
import { formatKeybind, KEYBINDS } from "@/utils/ui/keybinds";
7+
import { usePersistedState } from "@/hooks/usePersistedState";
8+
import { VIM_ENABLED_KEY } from "@/constants/storage";
79

810
/**
911
* VimTextArea – minimal Vim-like editing for a textarea.
@@ -123,8 +125,15 @@ export const VimTextArea = React.forwardRef<HTMLTextAreaElement, VimTextAreaProp
123125
if (typeof ref === "function") ref(textareaRef.current);
124126
else ref.current = textareaRef.current;
125127
}, [ref]);
128+
const [vimEnabled] = usePersistedState(VIM_ENABLED_KEY, false, { listener: true });
126129

127130
const [vimMode, setVimMode] = useState<VimMode>("insert");
131+
useEffect(() => {
132+
if (!vimEnabled) {
133+
setVimMode("insert");
134+
}
135+
}, [vimEnabled]);
136+
128137
const [isFocused, setIsFocused] = useState(false);
129138
const [desiredColumn, setDesiredColumn] = useState<number | null>(null);
130139
const [pendingOp, setPendingOp] = useState<null | {
@@ -170,6 +179,8 @@ export const VimTextArea = React.forwardRef<HTMLTextAreaElement, VimTextAreaProp
170179
onKeyDown?.(e);
171180
if (e.defaultPrevented) return;
172181

182+
if (!vimEnabled) return;
183+
173184
// If suggestions or external popovers are active, do not intercept navigation keys
174185
if (suppressSet.has(e.key)) return;
175186

@@ -229,7 +240,7 @@ export const VimTextArea = React.forwardRef<HTMLTextAreaElement, VimTextAreaProp
229240
};
230241

231242
// Build mode indicator content
232-
const showVimMode = vimMode === "normal";
243+
const showVimMode = vimEnabled && vimMode === "normal";
233244
const pendingCommand = showVimMode ? vim.formatPendingCommand(pendingOp) : "";
234245
const showFocusHint = !isFocused;
235246

@@ -287,7 +298,7 @@ export const VimTextArea = React.forwardRef<HTMLTextAreaElement, VimTextAreaProp
287298
spellCheck={false}
288299
{...rest}
289300
/>
290-
{vimMode === "normal" && value.length === 0 && <EmptyCursor />}
301+
{vimEnabled && vimMode === "normal" && value.length === 0 && <EmptyCursor />}
291302
</div>
292303
</div>
293304
);

src/constants/storage.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ export function getModeKey(workspaceId: string): string {
6161
*/
6262
export const USE_1M_CONTEXT_KEY = "use1MContext";
6363

64+
/**
65+
* Get the localStorage key for vim mode preference (global)
66+
* Format: "vimEnabled"
67+
*/
68+
export const VIM_ENABLED_KEY = "vimEnabled";
69+
6470
/**
6571
* Get the localStorage key for the compact continue message for a workspace
6672
* Temporarily stores the continuation prompt for the current compaction

src/hooks/usePersistedState.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,25 +33,37 @@ export function readPersistedState<T>(key: string, defaultValue: T): T {
3333
* This is useful when you need to update state from a different component/context
3434
* that doesn't have access to the setter (e.g., command palette updating workspace state).
3535
*
36+
* Supports functional updates to avoid races when toggling values.
37+
*
3638
* @param key - The same localStorage key used in usePersistedState
37-
* @param value - The new value to set
39+
* @param value - The new value to set, or a functional updater
40+
* @param defaultValue - Optional default value when reading existing state for functional updates
3841
*/
39-
export function updatePersistedState<T>(key: string, value: T): void {
42+
export function updatePersistedState<T>(
43+
key: string,
44+
value: T | ((prev: T) => T),
45+
defaultValue?: T
46+
): void {
4047
if (typeof window === "undefined" || !window.localStorage) {
4148
return;
4249
}
4350

4451
try {
45-
if (value === undefined || value === null) {
52+
const newValue: T | null | undefined =
53+
typeof value === "function"
54+
? (value as (prev: T) => T)(readPersistedState(key, defaultValue as T))
55+
: value;
56+
57+
if (newValue === undefined || newValue === null) {
4658
window.localStorage.removeItem(key);
4759
} else {
48-
window.localStorage.setItem(key, JSON.stringify(value));
60+
window.localStorage.setItem(key, JSON.stringify(newValue));
4961
}
5062

5163
// Dispatch custom event for same-tab synchronization
5264
// No origin since this is an external update - all listeners should receive it
5365
const customEvent = new CustomEvent(getStorageChangeEvent(key), {
54-
detail: { key, newValue: value },
66+
detail: { key, newValue },
5567
});
5668
window.dispatchEvent(customEvent);
5769
} catch (error) {

src/utils/slashCommands/parser.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,18 @@ describe("commandParser", () => {
144144
subcommand: "claude",
145145
});
146146
});
147+
148+
it("should parse /vim command", () => {
149+
expectParse("/vim", { type: "vim-toggle" });
150+
});
151+
152+
it("should reject /vim with arguments", () => {
153+
expectParse("/vim enable", {
154+
type: "unknown-command",
155+
command: "vim",
156+
subcommand: "enable",
157+
});
158+
});
147159
});
148160

149161
describe("setNestedProperty", () => {

src/utils/slashCommands/registry.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,23 @@ const modelCommandDefinition: SlashCommandDefinition = {
396396
},
397397
};
398398

399+
const vimCommandDefinition: SlashCommandDefinition = {
400+
key: "vim",
401+
description: "Toggle Vim mode for the chat input",
402+
appendSpace: false,
403+
handler: ({ cleanRemainingTokens }): ParsedCommand => {
404+
if (cleanRemainingTokens.length > 0) {
405+
return {
406+
type: "unknown-command",
407+
command: "vim",
408+
subcommand: cleanRemainingTokens[0],
409+
};
410+
}
411+
412+
return { type: "vim-toggle" };
413+
},
414+
};
415+
399416
const telemetryCommandDefinition: SlashCommandDefinition = {
400417
key: "telemetry",
401418
description: "Enable or disable telemetry",
@@ -443,6 +460,7 @@ export const SLASH_COMMAND_DEFINITIONS: readonly SlashCommandDefinition[] = [
443460
modelCommandDefinition,
444461
providersCommandDefinition,
445462
telemetryCommandDefinition,
463+
vimCommandDefinition,
446464
];
447465

448466
export const SLASH_COMMAND_DEFINITION_MAP = new Map(

src/utils/slashCommands/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export type ParsedCommand =
1414
| { type: "compact"; maxOutputTokens?: number; continueMessage?: string; model?: string }
1515
| { type: "telemetry-set"; enabled: boolean }
1616
| { type: "telemetry-help" }
17+
| { type: "vim-toggle" }
1718
| { type: "unknown-command"; command: string; subcommand?: string }
1819
| null;
1920

src/utils/vim.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,62 @@ describe("Vim Command Integration Tests", () => {
224224
expect(state.text).toBe("heXXllo");
225225
expect(state.cursor).toBe(2);
226226
});
227+
228+
test("s substitutes character under cursor", () => {
229+
const state = executeVimCommands(
230+
{ ...initialState, text: "hello", cursor: 1, mode: "normal" },
231+
["s"]
232+
);
233+
expect(state.text).toBe("hllo");
234+
expect(state.cursor).toBe(1);
235+
expect(state.mode).toBe("insert");
236+
expect(state.yankBuffer).toBe("e");
237+
});
238+
239+
test("s at end of text does nothing", () => {
240+
const state = executeVimCommands(
241+
{ ...initialState, text: "hello", cursor: 5, mode: "normal" },
242+
["s"]
243+
);
244+
expect(state.text).toBe("hello");
245+
expect(state.mode).toBe("normal");
246+
});
247+
248+
test("~ toggles case of character under cursor", () => {
249+
const state = executeVimCommands(
250+
{ ...initialState, text: "HeLLo", cursor: 0, mode: "normal" },
251+
["~"]
252+
);
253+
expect(state.text).toBe("heLLo");
254+
expect(state.cursor).toBe(1);
255+
});
256+
257+
test("~ toggles case and moves through word", () => {
258+
const state = executeVimCommands(
259+
{ ...initialState, text: "HeLLo", cursor: 0, mode: "normal" },
260+
["~", "~", "~"]
261+
);
262+
expect(state.text).toBe("hElLo");
263+
expect(state.cursor).toBe(3);
264+
});
265+
266+
test("~ on non-letter does nothing but advances cursor", () => {
267+
const state = executeVimCommands(
268+
{ ...initialState, text: "a 1 b", cursor: 1, mode: "normal" },
269+
["~"]
270+
);
271+
expect(state.text).toBe("a 1 b");
272+
expect(state.cursor).toBe(2);
273+
});
274+
275+
test("~ at end of text does not advance cursor", () => {
276+
const state = executeVimCommands(
277+
{ ...initialState, text: "hello", cursor: 4, mode: "normal" },
278+
["~"]
279+
);
280+
expect(state.text).toBe("hellO");
281+
expect(state.cursor).toBe(4);
282+
});
227283
});
228284

229285
describe("Line Operations", () => {

src/utils/vim.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -821,6 +821,33 @@ function tryHandleEdit(state: VimState, key: string): VimKeyResult | null {
821821
desiredColumn: null,
822822
});
823823
}
824+
825+
case "s": {
826+
if (cursor >= text.length) return null;
827+
const result = deleteCharUnderCursor(text, cursor, yankBuffer);
828+
return handleKey(state, {
829+
text: result.text,
830+
cursor: result.cursor,
831+
yankBuffer: result.yankBuffer,
832+
mode: "insert",
833+
desiredColumn: null,
834+
pendingOp: null,
835+
});
836+
}
837+
838+
case "~": {
839+
if (cursor >= text.length) return null;
840+
const char = text[cursor];
841+
const toggled = char === char.toUpperCase() ? char.toLowerCase() : char.toUpperCase();
842+
const newText = text.slice(0, cursor) + toggled + text.slice(cursor + 1);
843+
const newCursor = Math.min(cursor + 1, Math.max(0, newText.length - 1));
844+
return handleKey(state, {
845+
text: newText,
846+
cursor: newCursor,
847+
desiredColumn: null,
848+
pendingOp: null,
849+
});
850+
}
824851
}
825852

826853
return null;

0 commit comments

Comments
 (0)