diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index a5f0523d7..c8e3f28ba 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -10,7 +10,7 @@ import { usePersistedState, updatePersistedState } from "@/hooks/usePersistedSta import { useMode } from "@/contexts/ModeContext"; import { ChatToggles } from "./ChatToggles"; import { useSendMessageOptions } from "@/hooks/useSendMessageOptions"; -import { getModelKey, getInputKey } from "@/constants/storage"; +import { getModelKey, getInputKey, VIM_ENABLED_KEY } from "@/constants/storage"; import { forkWorkspace } from "@/utils/workspaceFork"; import { ToggleGroup } from "./ToggleGroup"; import { CUSTOM_EVENTS } from "@/constants/events"; @@ -696,6 +696,27 @@ export const ChatInput: React.FC = ({ return; } + // Handle /vim command + if (parsed.type === "vim-toggle") { + setInput(""); // Clear input immediately + let newState = false; + updatePersistedState( + VIM_ENABLED_KEY, + (prev) => { + const next = !(prev ?? false); + newState = next; + return next; + }, + false + ); + setToast({ + id: Date.now().toString(), + type: "success", + message: newState ? "Vim mode enabled" : "Vim mode disabled", + }); + return; + } + // Handle /telemetry command if (parsed.type === "telemetry-set") { setInput(""); // Clear input immediately @@ -958,6 +979,7 @@ export const ChatInput: React.FC = ({ } hints.push(`${formatKeybind(KEYBINDS.SEND_MESSAGE)} to send`); hints.push(`${formatKeybind(KEYBINDS.OPEN_MODEL_SELECTOR)} to change model`); + hints.push("/vim to toggle Vim mode"); return `Type a message... (${hints.join(", ")})`; })(); diff --git a/src/components/VimTextArea.tsx b/src/components/VimTextArea.tsx index e9cf204b9..30ef3b74b 100644 --- a/src/components/VimTextArea.tsx +++ b/src/components/VimTextArea.tsx @@ -4,6 +4,8 @@ import type { UIMode } from "@/types/mode"; import * as vim from "@/utils/vim"; import { TooltipWrapper, Tooltip, HelpIndicator } from "./Tooltip"; import { formatKeybind, KEYBINDS } from "@/utils/ui/keybinds"; +import { usePersistedState } from "@/hooks/usePersistedState"; +import { VIM_ENABLED_KEY } from "@/constants/storage"; /** * VimTextArea – minimal Vim-like editing for a textarea. @@ -123,8 +125,15 @@ export const VimTextArea = React.forwardRef("insert"); + useEffect(() => { + if (!vimEnabled) { + setVimMode("insert"); + } + }, [vimEnabled]); + const [isFocused, setIsFocused] = useState(false); const [desiredColumn, setDesiredColumn] = useState(null); const [pendingOp, setPendingOp] = useState - {vimMode === "normal" && value.length === 0 && } + {vimEnabled && vimMode === "normal" && value.length === 0 && } ); diff --git a/src/constants/storage.ts b/src/constants/storage.ts index 504fd2eeb..23a1629d2 100644 --- a/src/constants/storage.ts +++ b/src/constants/storage.ts @@ -61,6 +61,12 @@ export function getModeKey(workspaceId: string): string { */ export const USE_1M_CONTEXT_KEY = "use1MContext"; +/** + * Get the localStorage key for vim mode preference (global) + * Format: "vimEnabled" + */ +export const VIM_ENABLED_KEY = "vimEnabled"; + /** * Get the localStorage key for the compact continue message for a workspace * Temporarily stores the continuation prompt for the current compaction diff --git a/src/hooks/usePersistedState.ts b/src/hooks/usePersistedState.ts index 64de17f8e..c3c27bd6b 100644 --- a/src/hooks/usePersistedState.ts +++ b/src/hooks/usePersistedState.ts @@ -33,25 +33,37 @@ export function readPersistedState(key: string, defaultValue: T): T { * This is useful when you need to update state from a different component/context * that doesn't have access to the setter (e.g., command palette updating workspace state). * + * Supports functional updates to avoid races when toggling values. + * * @param key - The same localStorage key used in usePersistedState - * @param value - The new value to set + * @param value - The new value to set, or a functional updater + * @param defaultValue - Optional default value when reading existing state for functional updates */ -export function updatePersistedState(key: string, value: T): void { +export function updatePersistedState( + key: string, + value: T | ((prev: T) => T), + defaultValue?: T +): void { if (typeof window === "undefined" || !window.localStorage) { return; } try { - if (value === undefined || value === null) { + const newValue: T | null | undefined = + typeof value === "function" + ? (value as (prev: T) => T)(readPersistedState(key, defaultValue as T)) + : value; + + if (newValue === undefined || newValue === null) { window.localStorage.removeItem(key); } else { - window.localStorage.setItem(key, JSON.stringify(value)); + window.localStorage.setItem(key, JSON.stringify(newValue)); } // Dispatch custom event for same-tab synchronization // No origin since this is an external update - all listeners should receive it const customEvent = new CustomEvent(getStorageChangeEvent(key), { - detail: { key, newValue: value }, + detail: { key, newValue }, }); window.dispatchEvent(customEvent); } catch (error) { diff --git a/src/utils/slashCommands/parser.test.ts b/src/utils/slashCommands/parser.test.ts new file mode 100644 index 000000000..6d582a2cd --- /dev/null +++ b/src/utils/slashCommands/parser.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect } from "bun:test"; +import { parseCommand, setNestedProperty } from "./parser"; + +// Test helpers +const expectParse = (input: string, expected: ReturnType) => { + expect(parseCommand(input)).toEqual(expected); +}; + +const expectProvidersSet = (input: string, provider: string, keyPath: string[], value: string) => { + expectParse(input, { type: "providers-set", provider, keyPath, value }); +}; + +const expectModelSet = (input: string, modelString: string) => { + expectParse(input, { type: "model-set", modelString }); +}; + +describe("commandParser", () => { + describe("parseCommand", () => { + it("should return null for non-command input", () => { + expect(parseCommand("hello world")).toBeNull(); + expect(parseCommand("")).toBeNull(); + expect(parseCommand(" ")).toBeNull(); + }); + + it("should parse /clear command", () => { + expectParse("/clear", { type: "clear" }); + }); + + it("should parse /providers help when no subcommand", () => { + expectParse("/providers", { type: "providers-help" }); + }); + + it("should parse /providers with invalid subcommand", () => { + expectParse("/providers invalid", { + type: "providers-invalid-subcommand", + subcommand: "invalid", + }); + }); + + it("should parse /providers set with missing args", () => { + const missingArgsCases = [ + { input: "/providers set", argCount: 0 }, + { input: "/providers set anthropic", argCount: 1 }, + { input: "/providers set anthropic apiKey", argCount: 2 }, + ]; + + missingArgsCases.forEach(({ input, argCount }) => { + expectParse(input, { + type: "providers-missing-args", + subcommand: "set", + argCount, + }); + }); + }); + + it("should parse /providers set with all arguments", () => { + expectProvidersSet( + "/providers set anthropic apiKey sk-123", + "anthropic", + ["apiKey"], + "sk-123" + ); + }); + + it("should handle quoted arguments", () => { + expectProvidersSet( + '/providers set anthropic apiKey "my key with spaces"', + "anthropic", + ["apiKey"], + "my key with spaces" + ); + }); + + it("should handle multiple spaces in value", () => { + expectProvidersSet( + "/providers set anthropic apiKey My Anthropic API", + "anthropic", + ["apiKey"], + "My Anthropic API" + ); + }); + + it("should handle nested key paths", () => { + expectProvidersSet( + "/providers set anthropic baseUrl.scheme https", + "anthropic", + ["baseUrl", "scheme"], + "https" + ); + }); + + it("should parse unknown commands", () => { + expectParse("/foo", { + type: "unknown-command", + command: "foo", + subcommand: undefined, + }); + + expectParse("/foo bar", { + type: "unknown-command", + command: "foo", + subcommand: "bar", + }); + }); + + it("should handle multiple spaces between arguments", () => { + expectProvidersSet( + "/providers set anthropic apiKey sk-12345", + "anthropic", + ["apiKey"], + "sk-12345" + ); + }); + + it("should handle quoted URL values", () => { + expectProvidersSet( + '/providers set anthropic baseUrl "https://api.anthropic.com/v1"', + "anthropic", + ["baseUrl"], + "https://api.anthropic.com/v1" + ); + }); + + it("should parse /model with abbreviation", () => { + expectModelSet("/model opus", "anthropic:claude-opus-4-1"); + }); + + it("should parse /model with full provider:model format", () => { + expectModelSet("/model anthropic:claude-sonnet-4-5", "anthropic:claude-sonnet-4-5"); + }); + + it("should parse /model help when no args", () => { + expectParse("/model", { type: "model-help" }); + }); + + it("should handle unknown abbreviation as full model string", () => { + expectModelSet("/model custom:model-name", "custom:model-name"); + }); + + it("should reject /model with too many arguments", () => { + expectParse("/model anthropic claude extra", { + type: "unknown-command", + command: "model", + subcommand: "claude", + }); + }); + + it("should parse /vim command", () => { + expectParse("/vim", { type: "vim-toggle" }); + }); + + it("should reject /vim with arguments", () => { + expectParse("/vim enable", { + type: "unknown-command", + command: "vim", + subcommand: "enable", + }); + }); + + it("should parse /fork command with name only", () => { + expectParse("/fork feature-branch", { + type: "fork", + newName: "feature-branch", + startMessage: undefined, + }); + }); + + it("should parse /fork command with start message", () => { + expectParse("/fork feature-branch let's go", { + type: "fork", + newName: "feature-branch", + startMessage: "let's go", + }); + }); + + it("should show /fork help when missing args", () => { + expectParse("/fork", { type: "fork-help" }); + }); + }); + + describe("setNestedProperty", () => { + it("should set simple property", () => { + const obj: Record = {}; + setNestedProperty(obj, ["apiKey"], "sk-12345"); + expect(obj).toEqual({ apiKey: "sk-12345" }); + }); + + it("should set nested property", () => { + const obj: Record = {}; + setNestedProperty(obj, ["baseUrl", "scheme"], "https"); + expect(obj).toEqual({ + baseUrl: { + scheme: "https", + }, + }); + }); + + it("should create nested objects as needed", () => { + const obj: Record = { existing: "value" }; + setNestedProperty(obj, ["deep", "nested", "key"], "value"); + expect(obj).toEqual({ + existing: "value", + deep: { + nested: { + key: "value", + }, + }, + }); + }); + + it("should overwrite existing values", () => { + const obj: Record = { apiKey: "old" }; + setNestedProperty(obj, ["apiKey"], "new"); + expect(obj).toEqual({ apiKey: "new" }); + }); + + it("should handle empty keyPath", () => { + const obj: Record = { existing: "value" }; + setNestedProperty(obj, [], "ignored"); + expect(obj).toEqual({ existing: "value" }); + }); + }); +}); diff --git a/src/utils/slashCommands/registry.ts b/src/utils/slashCommands/registry.ts index 74395bf76..f39ca0b28 100644 --- a/src/utils/slashCommands/registry.ts +++ b/src/utils/slashCommands/registry.ts @@ -396,6 +396,23 @@ const modelCommandDefinition: SlashCommandDefinition = { }, }; +const vimCommandDefinition: SlashCommandDefinition = { + key: "vim", + description: "Toggle Vim mode for the chat input", + appendSpace: false, + handler: ({ cleanRemainingTokens }): ParsedCommand => { + if (cleanRemainingTokens.length > 0) { + return { + type: "unknown-command", + command: "vim", + subcommand: cleanRemainingTokens[0], + }; + } + + return { type: "vim-toggle" }; + }, +}; + const telemetryCommandDefinition: SlashCommandDefinition = { key: "telemetry", description: "Enable or disable telemetry", @@ -475,6 +492,7 @@ export const SLASH_COMMAND_DEFINITIONS: readonly SlashCommandDefinition[] = [ providersCommandDefinition, telemetryCommandDefinition, forkCommandDefinition, + vimCommandDefinition, ]; export const SLASH_COMMAND_DEFINITION_MAP = new Map( diff --git a/src/utils/slashCommands/types.ts b/src/utils/slashCommands/types.ts index 304017055..567ba707b 100644 --- a/src/utils/slashCommands/types.ts +++ b/src/utils/slashCommands/types.ts @@ -16,6 +16,7 @@ export type ParsedCommand = | { type: "telemetry-help" } | { type: "fork"; newName: string; startMessage?: string } | { type: "fork-help" } + | { type: "vim-toggle" } | { type: "unknown-command"; command: string; subcommand?: string } | null; diff --git a/src/utils/vim.test.ts b/src/utils/vim.test.ts index 84029bd7c..e39fa698f 100644 --- a/src/utils/vim.test.ts +++ b/src/utils/vim.test.ts @@ -224,6 +224,62 @@ describe("Vim Command Integration Tests", () => { expect(state.text).toBe("heXXllo"); expect(state.cursor).toBe(2); }); + + test("s substitutes character under cursor", () => { + const state = executeVimCommands( + { ...initialState, text: "hello", cursor: 1, mode: "normal" }, + ["s"] + ); + expect(state.text).toBe("hllo"); + expect(state.cursor).toBe(1); + expect(state.mode).toBe("insert"); + expect(state.yankBuffer).toBe("e"); + }); + + test("s at end of text does nothing", () => { + const state = executeVimCommands( + { ...initialState, text: "hello", cursor: 5, mode: "normal" }, + ["s"] + ); + expect(state.text).toBe("hello"); + expect(state.mode).toBe("normal"); + }); + + test("~ toggles case of character under cursor", () => { + const state = executeVimCommands( + { ...initialState, text: "HeLLo", cursor: 0, mode: "normal" }, + ["~"] + ); + expect(state.text).toBe("heLLo"); + expect(state.cursor).toBe(1); + }); + + test("~ toggles case and moves through word", () => { + const state = executeVimCommands( + { ...initialState, text: "HeLLo", cursor: 0, mode: "normal" }, + ["~", "~", "~"] + ); + expect(state.text).toBe("hElLo"); + expect(state.cursor).toBe(3); + }); + + test("~ on non-letter does nothing but advances cursor", () => { + const state = executeVimCommands( + { ...initialState, text: "a 1 b", cursor: 1, mode: "normal" }, + ["~"] + ); + expect(state.text).toBe("a 1 b"); + expect(state.cursor).toBe(2); + }); + + test("~ at end of text does not advance cursor", () => { + const state = executeVimCommands( + { ...initialState, text: "hello", cursor: 4, mode: "normal" }, + ["~"] + ); + expect(state.text).toBe("hellO"); + expect(state.cursor).toBe(4); + }); }); describe("Line Operations", () => { diff --git a/src/utils/vim.ts b/src/utils/vim.ts index 763a454dd..6a23cb6b9 100644 --- a/src/utils/vim.ts +++ b/src/utils/vim.ts @@ -821,6 +821,33 @@ function tryHandleEdit(state: VimState, key: string): VimKeyResult | null { desiredColumn: null, }); } + + case "s": { + if (cursor >= text.length) return null; + const result = deleteCharUnderCursor(text, cursor, yankBuffer); + return handleKey(state, { + text: result.text, + cursor: result.cursor, + yankBuffer: result.yankBuffer, + mode: "insert", + desiredColumn: null, + pendingOp: null, + }); + } + + case "~": { + if (cursor >= text.length) return null; + const char = text[cursor]; + const toggled = char === char.toUpperCase() ? char.toLowerCase() : char.toUpperCase(); + const newText = text.slice(0, cursor) + toggled + text.slice(cursor + 1); + const newCursor = Math.min(cursor + 1, Math.max(0, newText.length - 1)); + return handleKey(state, { + text: newText, + cursor: newCursor, + desiredColumn: null, + pendingOp: null, + }); + } } return null;