Skip to content
Open
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
24 changes: 23 additions & 1 deletion src/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -696,6 +696,27 @@ export const ChatInput: React.FC<ChatInputProps> = ({
return;
}

// Handle /vim command
if (parsed.type === "vim-toggle") {
setInput(""); // Clear input immediately
let newState = false;
updatePersistedState<boolean>(
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
Expand Down Expand Up @@ -958,6 +979,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");

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 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.
Expand Down Expand Up @@ -123,8 +125,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 @@ -170,6 +179,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 @@ -229,7 +240,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 @@ -287,7 +298,7 @@ export const VimTextArea = React.forwardRef<HTMLTextAreaElement, VimTextAreaProp
spellCheck={false}
{...rest}
/>
{vimMode === "normal" && value.length === 0 && <EmptyCursor />}
{vimEnabled && vimMode === "normal" && value.length === 0 && <EmptyCursor />}
</div>
</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 @@ -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
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
223 changes: 223 additions & 0 deletions src/utils/slashCommands/parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import { describe, it, expect } from "bun:test";
import { parseCommand, setNestedProperty } 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" });
});
});

describe("setNestedProperty", () => {
it("should set simple property", () => {
const obj: Record<string, unknown> = {};
setNestedProperty(obj, ["apiKey"], "sk-12345");
expect(obj).toEqual({ apiKey: "sk-12345" });
});

it("should set nested property", () => {
const obj: Record<string, unknown> = {};
setNestedProperty(obj, ["baseUrl", "scheme"], "https");
expect(obj).toEqual({
baseUrl: {
scheme: "https",
},
});
});

it("should create nested objects as needed", () => {
const obj: Record<string, unknown> = { 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<string, unknown> = { apiKey: "old" };
setNestedProperty(obj, ["apiKey"], "new");
expect(obj).toEqual({ apiKey: "new" });
});

it("should handle empty keyPath", () => {
const obj: Record<string, unknown> = { existing: "value" };
setNestedProperty(obj, [], "ignored");
expect(obj).toEqual({ existing: "value" });
});
});
});
Loading