Skip to content
1 change: 1 addition & 0 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ This project uses **Make** as the primary build orchestrator. See `Makefile` for

**TDD is the preferred development style for agents.**

- **When asked to do TDD, write tests in the repository** - Create proper test files (e.g., `src/utils/foo.test.ts`) that run with `bun test` or `jest`, not temporary scripts in `/tmp`. Tests should be committed with the implementation.
- Prefer relocated complex logic into places where they're easily tested
- E.g. pure functions in `utils` are easier to test than complex logic in a React component
- Strive for broad coverage with minimal tests
Expand Down
10 changes: 10 additions & 0 deletions docs/keybinds.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,16 @@ When documentation shows `Ctrl`, it means:
| Open command palette | `Ctrl+Shift+P` |
| Toggle sidebar | `Ctrl+P` |

### Command Palette

The command palette (`Ctrl+Shift+P`) has two modes:

- **Default (no prefix)**: Workspace switcher - shows only switching commands
- **`>` prefix**: Command mode - shows all other commands (create/delete/rename workspaces, navigation, chat, modes, projects, etc.)
- **`/` prefix**: Slash commands - shows slash command suggestions for inserting into chat

This separation keeps the switcher clean and fast while making all other commands easily accessible via `>`.

## Tips

- **Vim-inspired navigation**: We use `J`/`K` for next/previous navigation, similar to Vim
Expand Down
28 changes: 17 additions & 11 deletions src/components/CommandPalette.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const mockCommands: CommandAction[] = [
id: "workspace.create",
title: "Create New Workspace",
subtitle: "Start a new workspace in this project",
section: "Workspace",
section: "Workspaces",
keywords: ["new", "add", "workspace"],
shortcutHint: "⌘N",
run: () => action("command-executed")("workspace.create"),
Expand All @@ -21,7 +21,7 @@ const mockCommands: CommandAction[] = [
id: "workspace.switch",
title: "Switch Workspace",
subtitle: "Navigate to a different workspace",
section: "Workspace",
section: "Workspaces",
keywords: ["change", "go to", "workspace"],
shortcutHint: "⌘P",
run: () => action("command-executed")("workspace.switch"),
Expand All @@ -30,7 +30,7 @@ const mockCommands: CommandAction[] = [
id: "workspace.delete",
title: "Delete Workspace",
subtitle: "Remove the current workspace",
section: "Workspace",
section: "Workspaces",
keywords: ["remove", "delete", "workspace"],
run: () => action("command-executed")("workspace.delete"),
},
Expand Down Expand Up @@ -183,18 +183,24 @@ export const Default: Story = {
reopen it.
<br />
<br />
<strong>Features:</strong>
<strong>Two Modes:</strong>
<br />• <strong>Default</strong>: Workspace switcher (only shows switching commands)
<br />•{" "}
<strong>
Type <kbd>&gt;</kbd>
</strong>
: Command mode (shows all other commands)
<br />•{" "}
<strong>
Type <kbd>/</kbd>
</strong>
: Slash commands for chat input
<br />
• Type to filter commands by title, subtitle, or keywords
<br />
• Use ↑↓ arrow keys to navigate
<br />
• Press Enter to execute a command
• Use ↑↓ arrow keys to navigate, Enter to execute
<br />
• Press Escape to close
<br />• Start with <kbd>/</kbd> to see slash commands
<br />• Commands are organized into sections (Workspace, Chat, Mode, Settings, Project,
Help)
<br />• Commands organized into sections (Workspaces, Chat, Mode, Settings, Project, Help)
</div>
<PaletteDemo />
</div>
Expand Down
127 changes: 127 additions & 0 deletions src/components/CommandPalette.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { describe, expect, test } from "bun:test";
import { filterCommandsByPrefix } from "@/utils/commandPaletteFiltering";
import { CommandIds, CommandIdMatchers } from "@/utils/commandIds";

/**
* Tests for command palette filtering logic
* Property-based tests that verify behavior regardless of specific command data
*/

describe("CommandPalette filtering", () => {
describe("property: default mode shows only ws:switch:* commands", () => {
test("all results start with ws:switch:", () => {
const actions = [
{ id: CommandIds.workspaceSwitch("1") },
{ id: CommandIds.workspaceSwitch("2") },
{ id: CommandIds.workspaceNew() },
{ id: CommandIds.navToggleSidebar() },
];

const result = filterCommandsByPrefix("", actions);

expect(result.every((a) => CommandIdMatchers.isWorkspaceSwitch(a.id))).toBe(true);
});

test("excludes all non-switching commands", () => {
const actions = [
{ id: CommandIds.workspaceSwitch("1") },
{ id: CommandIds.workspaceNew() },
{ id: CommandIds.workspaceRemove() },
{ id: CommandIds.navToggleSidebar() },
];

const result = filterCommandsByPrefix("", actions);

expect(result.some((a) => !CommandIdMatchers.isWorkspaceSwitch(a.id))).toBe(false);
});
});

describe("property: > mode shows all EXCEPT ws:switch:* commands", () => {
test("no results start with ws:switch:", () => {
const actions = [
{ id: CommandIds.workspaceSwitch("1") },
{ id: CommandIds.workspaceNew() },
{ id: CommandIds.navToggleSidebar() },
{ id: CommandIds.chatClear() },
];

const result = filterCommandsByPrefix(">", actions);

expect(result.every((a) => !CommandIdMatchers.isWorkspaceSwitch(a.id))).toBe(true);
});

test("includes all non-switching commands", () => {
const actions = [
{ id: CommandIds.workspaceSwitch("1") },
{ id: CommandIds.workspaceNew() },
{ id: CommandIds.workspaceRemove() },
{ id: CommandIds.navToggleSidebar() },
];

const result = filterCommandsByPrefix(">", actions);

// Should include workspace mutations
expect(result.some((a) => a.id === CommandIds.workspaceNew())).toBe(true);
expect(result.some((a) => a.id === CommandIds.workspaceRemove())).toBe(true);
// Should include navigation
expect(result.some((a) => a.id === CommandIds.navToggleSidebar())).toBe(true);
// Should NOT include switching
expect(result.some((a) => a.id === CommandIds.workspaceSwitch("1"))).toBe(false);
});
});

describe("property: modes partition the command space", () => {
test("default + > modes cover all commands (no overlap, no gaps)", () => {
const actions = [
{ id: CommandIds.workspaceSwitch("1") },
{ id: CommandIds.workspaceSwitch("2") },
{ id: CommandIds.workspaceNew() },
{ id: CommandIds.workspaceRemove() },
{ id: CommandIds.navToggleSidebar() },
{ id: CommandIds.chatClear() },
];

const defaultResult = filterCommandsByPrefix("", actions);
const commandResult = filterCommandsByPrefix(">", actions);

// No overlap - disjoint sets
const defaultIds = new Set(defaultResult.map((a) => a.id));
const commandIds = new Set(commandResult.map((a) => a.id));
const intersection = [...defaultIds].filter((id) => commandIds.has(id));
expect(intersection).toHaveLength(0);

// No gaps - covers everything
expect(defaultResult.length + commandResult.length).toBe(actions.length);
});
});

describe("property: / prefix always returns empty", () => {
test("returns empty array regardless of actions", () => {
const actions = [
{ id: CommandIds.workspaceSwitch("1") },
{ id: CommandIds.workspaceNew() },
{ id: CommandIds.navToggleSidebar() },
];

expect(filterCommandsByPrefix("/", actions)).toHaveLength(0);
expect(filterCommandsByPrefix("/help", actions)).toHaveLength(0);
expect(filterCommandsByPrefix("/ ", actions)).toHaveLength(0);
});
});

describe("property: query with > prefix applies to all non-switching", () => {
test(">text shows same set as > (cmdk filters further)", () => {
const actions = [
{ id: CommandIds.workspaceSwitch("1") },
{ id: CommandIds.workspaceNew() },
{ id: CommandIds.navToggleSidebar() },
];

// Our filter doesn't care about text after >, just the prefix
const resultEmpty = filterCommandsByPrefix(">", actions);
const resultWithText = filterCommandsByPrefix(">abc", actions);

expect(resultEmpty).toEqual(resultWithText);
});
});
});
48 changes: 32 additions & 16 deletions src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { CommandAction } from "@/contexts/CommandRegistryContext";
import { formatKeybind, KEYBINDS, isEditableElement, matchesKeybind } from "@/utils/ui/keybinds";
import { getSlashCommandSuggestions } from "@/utils/slashCommands/suggestions";
import { CUSTOM_EVENTS, createCustomEvent } from "@/constants/events";
import { filterCommandsByPrefix } from "@/utils/commandPaletteFiltering";

interface CommandPaletteProps {
getSlashContext?: () => { providerNames: string[]; workspaceId?: string };
Expand Down Expand Up @@ -42,32 +43,34 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({ getSlashContext
}>(null);
const [promptError, setPromptError] = useState<string | null>(null);

const resetPaletteState = useCallback(() => {
setActivePrompt(null);
setPromptError(null);
setQuery("");
}, []);

// Close palette with Escape
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (matchesKeybind(e, KEYBINDS.CANCEL) && isOpen) {
e.preventDefault();
setActivePrompt(null);
setPromptError(null);
setQuery("");
resetPaletteState();
close();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [isOpen, close]);
}, [isOpen, close, resetPaletteState]);

// Reset state whenever palette visibility changes
useEffect(() => {
if (!isOpen) {
setActivePrompt(null);
setPromptError(null);
setQuery("");
resetPaletteState();
} else {
setPromptError(null);
setQuery("");
}
}, [isOpen]);
}, [isOpen, resetPaletteState]);

const rawActions = getActions();

Expand Down Expand Up @@ -200,7 +203,10 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({ getSlashContext
} satisfies { groups: PaletteGroup[]; emptyText: string | undefined };
}

const filtered = [...rawActions].sort((a, b) => {
// Filter actions based on prefix (extracted to utility for testing)
const actionsToShow = filterCommandsByPrefix(q, rawActions);

const filtered = [...actionsToShow].sort((a, b) => {
const ai = recentIndex.has(a.id) ? recentIndex.get(a.id)! : 9999;
const bi = recentIndex.has(b.id) ? recentIndex.get(b.id)! : 9999;
if (ai !== bi) return ai - bi;
Expand Down Expand Up @@ -298,6 +304,8 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({ getSlashContext
}, [currentField, activePrompt]);

const isSlashQuery = !currentField && query.trim().startsWith("/");
const isCommandQuery = !currentField && query.trim().startsWith(">");
// Enable cmdk filtering for all cases except slash queries (which we handle manually)
const shouldUseCmdkFilter = currentField ? currentField.type === "select" : !isSlashQuery;

let groups: PaletteGroup[] = generalResults.groups;
Expand Down Expand Up @@ -355,16 +363,26 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({ getSlashContext
<div
className="fixed inset-0 z-[2000] flex items-start justify-center bg-black/40 pt-[10vh]"
onMouseDown={() => {
setActivePrompt(null);
setPromptError(null);
setQuery("");
resetPaletteState();
close();
}}
>
<Command
className="bg-separator border-border text-lighter font-primary w-[min(720px,92vw)] overflow-hidden rounded-lg border shadow-[0_10px_40px_rgba(0,0,0,0.4)]"
onMouseDown={(e: React.MouseEvent) => e.stopPropagation()}
shouldFilter={shouldUseCmdkFilter}
filter={(value, search) => {
// When using ">" prefix, filter using the text after ">"
if (isCommandQuery && search.startsWith(">")) {
const actualSearch = search.slice(1).trim().toLowerCase();
if (!actualSearch) return 1;
if (value.toLowerCase().includes(actualSearch)) return 1;
return 0;
}
// Default cmdk filtering for other cases
if (value.toLowerCase().includes(search.toLowerCase())) return 1;
return 0;
}}
>
<Command.Input
className="bg-darker text-lighter border-hover w-full border-b border-none px-3.5 py-3 text-sm outline-none"
Expand All @@ -375,7 +393,7 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({ getSlashContext
? currentField.type === "text"
? (currentField.placeholder ?? "Type value…")
: (currentField.placeholder ?? "Search options…")
: `Type a command… (${formatKeybind(KEYBINDS.CANCEL)} to close, ${formatKeybind(KEYBINDS.SEND_MESSAGE)} to send in chat)`
: `Switch workspaces or type > for all commands, / for slash commands…`
}
autoFocus
onKeyDown={(e: React.KeyboardEvent) => {
Expand All @@ -389,9 +407,7 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({ getSlashContext
} else if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
setActivePrompt(null);
setPromptError(null);
setQuery("");
resetPaletteState();
close();
}
return;
Expand Down
Loading