Skip to content

Commit 91af608

Browse files
authored
🤖 Add vim toggle command and new Vim edits (#301)
## Summary - add /vim slash command that toggles the global vim preference via persisted state - gate VimTextArea behaviors behind the persisted toggle and reset when disabled - implement vim 's' and '~' actions with integration tests - allow updatePersistedState to accept functional updaters to avoid race conditions _Generated with `cmux`_
1 parent 90af509 commit 91af608

File tree

9 files changed

+330
-8
lines changed

9 files changed

+330
-8
lines changed

src/components/ChatInput.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { useMode } from "@/contexts/ModeContext";
1010
import { ThinkingSliderComponent } from "./ThinkingSlider";
1111
import { Context1MCheckbox } from "./Context1MCheckbox";
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 {
1515
handleNewCommand,
1616
handleCompactCommand,
@@ -95,6 +95,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({
9595
const { recentModels, addModel } = useModelLRU();
9696
const commandListId = useId();
9797
const telemetry = useTelemetry();
98+
const [vimEnabled, setVimEnabled] = usePersistedState<boolean>(VIM_ENABLED_KEY, false, {
99+
listener: true,
100+
});
98101

99102
// Get current send message options from shared hook (must be at component top level)
100103
const sendMessageOptions = useSendMessageOptions(workspaceId);
@@ -423,6 +426,13 @@ export const ChatInput: React.FC<ChatInputProps> = ({
423426
return;
424427
}
425428

429+
// Handle /vim command
430+
if (parsed.type === "vim-toggle") {
431+
setInput(""); // Clear input immediately
432+
setVimEnabled((prev) => !prev);
433+
return;
434+
}
435+
426436
// Handle /telemetry command
427437
if (parsed.type === "telemetry-set") {
428438
setInput(""); // Clear input immediately
@@ -706,6 +716,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
706716
}
707717
hints.push(`${formatKeybind(KEYBINDS.SEND_MESSAGE)} to send`);
708718
hints.push(`${formatKeybind(KEYBINDS.OPEN_MODEL_SELECTOR)} to change model`);
719+
hints.push(`/vim to toggle Vim mode (${vimEnabled ? "on" : "off"})`);
709720

710721
return `Type a message... (${hints.join(", ")})`;
711722
})();

src/components/VimTextArea.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import * as vim from "@/utils/vim";
44
import { TooltipWrapper, Tooltip, HelpIndicator } from "./Tooltip";
55
import { formatKeybind, KEYBINDS } from "@/utils/ui/keybinds";
66
import { cn } from "@/lib/utils";
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.
@@ -42,8 +44,15 @@ export const VimTextArea = React.forwardRef<HTMLTextAreaElement, VimTextAreaProp
4244
if (typeof ref === "function") ref(textareaRef.current);
4345
else ref.current = textareaRef.current;
4446
}, [ref]);
47+
const [vimEnabled] = usePersistedState(VIM_ENABLED_KEY, false, { listener: true });
4548

4649
const [vimMode, setVimMode] = useState<VimMode>("insert");
50+
useEffect(() => {
51+
if (!vimEnabled) {
52+
setVimMode("insert");
53+
}
54+
}, [vimEnabled]);
55+
4756
const [isFocused, setIsFocused] = useState(false);
4857
const [desiredColumn, setDesiredColumn] = useState<number | null>(null);
4958
const [pendingOp, setPendingOp] = useState<null | {
@@ -89,6 +98,8 @@ export const VimTextArea = React.forwardRef<HTMLTextAreaElement, VimTextAreaProp
8998
onKeyDown?.(e);
9099
if (e.defaultPrevented) return;
91100

101+
if (!vimEnabled) return;
102+
92103
// If suggestions or external popovers are active, do not intercept navigation keys
93104
if (suppressSet.has(e.key)) return;
94105

@@ -148,7 +159,7 @@ export const VimTextArea = React.forwardRef<HTMLTextAreaElement, VimTextAreaProp
148159
};
149160

150161
// Build mode indicator content
151-
const showVimMode = vimMode === "normal";
162+
const showVimMode = vimEnabled && vimMode === "normal";
152163
const pendingCommand = showVimMode ? vim.formatPendingCommand(pendingOp) : "";
153164
const showFocusHint = !isFocused;
154165

@@ -221,7 +232,7 @@ export const VimTextArea = React.forwardRef<HTMLTextAreaElement, VimTextAreaProp
221232
: "caret-white selection:bg-selection"
222233
)}
223234
/>
224-
{vimMode === "normal" && value.length === 0 && (
235+
{vimEnabled && vimMode === "normal" && value.length === 0 && (
225236
<div className="pointer-events-none absolute top-1.5 left-2 h-4 w-2 bg-white/50" />
226237
)}
227238
</div>

src/constants/storage.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,12 @@ export const USE_1M_CONTEXT_KEY = "use1MContext";
8484
*/
8585
export const PREFERRED_COMPACTION_MODEL_KEY = "preferredCompactionModel";
8686

87+
/**
88+
* Get the localStorage key for vim mode preference (global)
89+
* Format: "vimEnabled"
90+
*/
91+
export const VIM_ENABLED_KEY = "vimEnabled";
92+
8793
/**
8894
* Get the localStorage key for the compact continue message for a workspace
8995
* 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) {
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { describe, it, expect } from "bun:test";
2+
import { parseCommand } from "./parser";
3+
4+
// Test helpers
5+
const expectParse = (input: string, expected: ReturnType<typeof parseCommand>) => {
6+
expect(parseCommand(input)).toEqual(expected);
7+
};
8+
9+
const expectProvidersSet = (input: string, provider: string, keyPath: string[], value: string) => {
10+
expectParse(input, { type: "providers-set", provider, keyPath, value });
11+
};
12+
13+
const expectModelSet = (input: string, modelString: string) => {
14+
expectParse(input, { type: "model-set", modelString });
15+
};
16+
17+
describe("commandParser", () => {
18+
describe("parseCommand", () => {
19+
it("should return null for non-command input", () => {
20+
expect(parseCommand("hello world")).toBeNull();
21+
expect(parseCommand("")).toBeNull();
22+
expect(parseCommand(" ")).toBeNull();
23+
});
24+
25+
it("should parse /clear command", () => {
26+
expectParse("/clear", { type: "clear" });
27+
});
28+
29+
it("should parse /providers help when no subcommand", () => {
30+
expectParse("/providers", { type: "providers-help" });
31+
});
32+
33+
it("should parse /providers with invalid subcommand", () => {
34+
expectParse("/providers invalid", {
35+
type: "providers-invalid-subcommand",
36+
subcommand: "invalid",
37+
});
38+
});
39+
40+
it("should parse /providers set with missing args", () => {
41+
const missingArgsCases = [
42+
{ input: "/providers set", argCount: 0 },
43+
{ input: "/providers set anthropic", argCount: 1 },
44+
{ input: "/providers set anthropic apiKey", argCount: 2 },
45+
];
46+
47+
missingArgsCases.forEach(({ input, argCount }) => {
48+
expectParse(input, {
49+
type: "providers-missing-args",
50+
subcommand: "set",
51+
argCount,
52+
});
53+
});
54+
});
55+
56+
it("should parse /providers set with all arguments", () => {
57+
expectProvidersSet(
58+
"/providers set anthropic apiKey sk-123",
59+
"anthropic",
60+
["apiKey"],
61+
"sk-123"
62+
);
63+
});
64+
65+
it("should handle quoted arguments", () => {
66+
expectProvidersSet(
67+
'/providers set anthropic apiKey "my key with spaces"',
68+
"anthropic",
69+
["apiKey"],
70+
"my key with spaces"
71+
);
72+
});
73+
74+
it("should handle multiple spaces in value", () => {
75+
expectProvidersSet(
76+
"/providers set anthropic apiKey My Anthropic API",
77+
"anthropic",
78+
["apiKey"],
79+
"My Anthropic API"
80+
);
81+
});
82+
83+
it("should handle nested key paths", () => {
84+
expectProvidersSet(
85+
"/providers set anthropic baseUrl.scheme https",
86+
"anthropic",
87+
["baseUrl", "scheme"],
88+
"https"
89+
);
90+
});
91+
92+
it("should parse unknown commands", () => {
93+
expectParse("/foo", {
94+
type: "unknown-command",
95+
command: "foo",
96+
subcommand: undefined,
97+
});
98+
99+
expectParse("/foo bar", {
100+
type: "unknown-command",
101+
command: "foo",
102+
subcommand: "bar",
103+
});
104+
});
105+
106+
it("should handle multiple spaces between arguments", () => {
107+
expectProvidersSet(
108+
"/providers set anthropic apiKey sk-12345",
109+
"anthropic",
110+
["apiKey"],
111+
"sk-12345"
112+
);
113+
});
114+
115+
it("should handle quoted URL values", () => {
116+
expectProvidersSet(
117+
'/providers set anthropic baseUrl "https://api.anthropic.com/v1"',
118+
"anthropic",
119+
["baseUrl"],
120+
"https://api.anthropic.com/v1"
121+
);
122+
});
123+
124+
it("should parse /model with abbreviation", () => {
125+
expectModelSet("/model opus", "anthropic:claude-opus-4-1");
126+
});
127+
128+
it("should parse /model with full provider:model format", () => {
129+
expectModelSet("/model anthropic:claude-sonnet-4-5", "anthropic:claude-sonnet-4-5");
130+
});
131+
132+
it("should parse /model help when no args", () => {
133+
expectParse("/model", { type: "model-help" });
134+
});
135+
136+
it("should handle unknown abbreviation as full model string", () => {
137+
expectModelSet("/model custom:model-name", "custom:model-name");
138+
});
139+
140+
it("should reject /model with too many arguments", () => {
141+
expectParse("/model anthropic claude extra", {
142+
type: "unknown-command",
143+
command: "model",
144+
subcommand: "claude",
145+
});
146+
});
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+
});
159+
160+
it("should parse /fork command with name only", () => {
161+
expectParse("/fork feature-branch", {
162+
type: "fork",
163+
newName: "feature-branch",
164+
startMessage: undefined,
165+
});
166+
});
167+
168+
it("should parse /fork command with start message", () => {
169+
expectParse("/fork feature-branch let's go", {
170+
type: "fork",
171+
newName: "feature-branch",
172+
startMessage: "let's go",
173+
});
174+
});
175+
176+
it("should show /fork help when missing args", () => {
177+
expectParse("/fork", { type: "fork-help" });
178+
});
179+
});
180+
});

src/utils/slashCommands/registry.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,23 @@ const modelCommandDefinition: SlashCommandDefinition = {
417417
},
418418
};
419419

420+
const vimCommandDefinition: SlashCommandDefinition = {
421+
key: "vim",
422+
description: "Toggle Vim mode for the chat input",
423+
appendSpace: false,
424+
handler: ({ cleanRemainingTokens }): ParsedCommand => {
425+
if (cleanRemainingTokens.length > 0) {
426+
return {
427+
type: "unknown-command",
428+
command: "vim",
429+
subcommand: cleanRemainingTokens[0],
430+
};
431+
}
432+
433+
return { type: "vim-toggle" };
434+
},
435+
};
436+
420437
const telemetryCommandDefinition: SlashCommandDefinition = {
421438
key: "telemetry",
422439
description: "Enable or disable telemetry",
@@ -583,6 +600,7 @@ export const SLASH_COMMAND_DEFINITIONS: readonly SlashCommandDefinition[] = [
583600
telemetryCommandDefinition,
584601
forkCommandDefinition,
585602
newCommandDefinition,
603+
vimCommandDefinition,
586604
];
587605

588606
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
@@ -30,6 +30,7 @@ export type ParsedCommand =
3030
runtime?: string;
3131
startMessage?: string;
3232
}
33+
| { type: "vim-toggle" }
3334
| { type: "unknown-command"; command: string; subcommand?: string }
3435
| null;
3536

0 commit comments

Comments
 (0)