Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { useMode } from "@/contexts/ModeContext";
import { ThinkingSliderComponent } from "./ThinkingSlider";
import { Context1MCheckbox } from "./Context1MCheckbox";
import { useSendMessageOptions } from "@/hooks/useSendMessageOptions";
import { getModelKey, getInputKey } from "@/constants/storage";
import { getModelKey, getInputKey, VIM_ENABLED_KEY } from "@/constants/storage";
import {
handleNewCommand,
handleCompactCommand,
Expand Down Expand Up @@ -95,6 +95,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({
const { recentModels, addModel } = useModelLRU();
const commandListId = useId();
const telemetry = useTelemetry();
const [vimEnabled, setVimEnabled] = usePersistedState<boolean>(VIM_ENABLED_KEY, false, {
listener: true,
});

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

// Handle /vim command
if (parsed.type === "vim-toggle") {
setInput(""); // Clear input immediately
setVimEnabled((prev) => !prev);
return;
}

// Handle /telemetry command
if (parsed.type === "telemetry-set") {
setInput(""); // Clear input immediately
Expand Down Expand Up @@ -706,6 +716,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
}
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 (${vimEnabled ? "on" : "off"})`);

return `Type a message... (${hints.join(", ")})`;
})();
Expand Down
15 changes: 13 additions & 2 deletions src/components/VimTextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import * as vim from "@/utils/vim";
import { TooltipWrapper, Tooltip, HelpIndicator } from "./Tooltip";
import { formatKeybind, KEYBINDS } from "@/utils/ui/keybinds";
import { cn } from "@/lib/utils";
import { usePersistedState } from "@/hooks/usePersistedState";
import { VIM_ENABLED_KEY } from "@/constants/storage";

/**
* VimTextArea – minimal Vim-like editing for a textarea.
Expand Down Expand Up @@ -42,8 +44,15 @@ export const VimTextArea = React.forwardRef<HTMLTextAreaElement, VimTextAreaProp
if (typeof ref === "function") ref(textareaRef.current);
else ref.current = textareaRef.current;
}, [ref]);
const [vimEnabled] = usePersistedState(VIM_ENABLED_KEY, false, { listener: true });

const [vimMode, setVimMode] = useState<VimMode>("insert");
useEffect(() => {
if (!vimEnabled) {
setVimMode("insert");
}
}, [vimEnabled]);

const [isFocused, setIsFocused] = useState(false);
const [desiredColumn, setDesiredColumn] = useState<number | null>(null);
const [pendingOp, setPendingOp] = useState<null | {
Expand Down Expand Up @@ -89,6 +98,8 @@ export const VimTextArea = React.forwardRef<HTMLTextAreaElement, VimTextAreaProp
onKeyDown?.(e);
if (e.defaultPrevented) return;

if (!vimEnabled) return;

// If suggestions or external popovers are active, do not intercept navigation keys
if (suppressSet.has(e.key)) return;

Expand Down Expand Up @@ -148,7 +159,7 @@ export const VimTextArea = React.forwardRef<HTMLTextAreaElement, VimTextAreaProp
};

// Build mode indicator content
const showVimMode = vimMode === "normal";
const showVimMode = vimEnabled && vimMode === "normal";
const pendingCommand = showVimMode ? vim.formatPendingCommand(pendingOp) : "";
const showFocusHint = !isFocused;

Expand Down Expand Up @@ -221,7 +232,7 @@ export const VimTextArea = React.forwardRef<HTMLTextAreaElement, VimTextAreaProp
: "caret-white selection:bg-selection"
)}
/>
{vimMode === "normal" && value.length === 0 && (
{vimEnabled && vimMode === "normal" && value.length === 0 && (
<div className="pointer-events-none absolute top-1.5 left-2 h-4 w-2 bg-white/50" />
)}
</div>
Expand Down
6 changes: 6 additions & 0 deletions src/constants/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ export const USE_1M_CONTEXT_KEY = "use1MContext";
*/
export const PREFERRED_COMPACTION_MODEL_KEY = "preferredCompactionModel";

/**
* 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
Expand Down
22 changes: 17 additions & 5 deletions src/hooks/usePersistedState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,25 +33,37 @@ export function readPersistedState<T>(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<T>(key: string, value: T): void {
export function updatePersistedState<T>(
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) {
Expand Down
180 changes: 180 additions & 0 deletions src/utils/slashCommands/parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { describe, it, expect } from "bun:test";
import { parseCommand } from "./parser";

// Test helpers
const expectParse = (input: string, expected: ReturnType<typeof parseCommand>) => {
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" });
});
});
});
18 changes: 18 additions & 0 deletions src/utils/slashCommands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,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",
Expand Down Expand Up @@ -583,6 +600,7 @@ export const SLASH_COMMAND_DEFINITIONS: readonly SlashCommandDefinition[] = [
telemetryCommandDefinition,
forkCommandDefinition,
newCommandDefinition,
vimCommandDefinition,
];

export const SLASH_COMMAND_DEFINITION_MAP = new Map(
Expand Down
1 change: 1 addition & 0 deletions src/utils/slashCommands/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type ParsedCommand =
runtime?: string;
startMessage?: string;
}
| { type: "vim-toggle" }
| { type: "unknown-command"; command: string; subcommand?: string }
| null;

Expand Down
Loading