feat: add command palette (Ctrl+K / Cmd+K)

Fuzzy-searchable command palette accessible from anywhere via Ctrl+K
(Windows/Linux) or Cmd+K (macOS). Supports keyboard navigation with
arrow keys, Enter to select, and Escape to close.

Available commands:
- Switch between sessions and projects
- Create new sessions and scratchpads
- Pin, archive, and duplicate the current session
- Open settings, project settings, and app data folder
- Toggle terminal
- Switch theme (dark/light/system)
- Add new projects

Implementation:
- New CommandPalette component with glass surface and glow border
- Palette entry/exit CSS animations
- Capture-phase Escape handler to prevent leaking to other overlays
- Commands built dynamically from workspace state
- Results grouped by category with fuzzy scoring

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-29 17:30:47 +02:00
co-authored by Copilot
parent be3f8d4cb3
commit 395965c639
3 changed files with 528 additions and 1 deletions
+39 -1
View File
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { AppShell } from '@renderer/components/AppShell';
import { ActivityPanel } from '@renderer/components/ActivityPanel';
import { ChatPane } from '@renderer/components/ChatPane';
import { CommandPalette } from '@renderer/components/CommandPalette';
import { DiscoveredToolingModal } from '@renderer/components/DiscoveredToolingModal';
import { NewSessionModal } from '@renderer/components/NewSessionModal';
import { ProjectSettingsPanel } from '@renderer/components/ProjectSettingsPanel';
@@ -112,6 +113,7 @@ export default function App() {
const [projectSettingsId, setProjectSettingsId] = useState<string>();
const [newSessionProjectId, setNewSessionProjectId] = useState<string>();
const [showDiscoveryModal, setShowDiscoveryModal] = useState(false);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
// Terminal state
const [terminalOpen, setTerminalOpen] = useState(false);
@@ -264,13 +266,17 @@ export default function App() {
if (hasPendingDiscoveries) setShowDiscoveryModal(true);
}, [hasPendingDiscoveries]);
// Terminal: Ctrl+` toggle + track running state
// Terminal: Ctrl+` toggle + Command palette: Ctrl+K / Cmd+K
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === '`') {
e.preventDefault();
setTerminalOpen((prev) => !prev);
}
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
setCommandPaletteOpen((prev) => !prev);
}
};
window.addEventListener('keydown', handleKeyDown);
@@ -607,6 +613,38 @@ export default function App() {
}}
/>
)}
{commandPaletteOpen && workspace && (
<CommandPalette
workspace={workspace}
onClose={() => setCommandPaletteOpen(false)}
onSelectSession={(sessionId) => {
void api.selectSession(sessionId);
}}
onSelectProject={(projectId) => {
void api.selectProject(projectId);
}}
onNewSession={(projectId) => {
setNewSessionProjectId(projectId);
}}
onCreateScratchpad={handleCreateScratchpad}
onOpenSettings={() => setShowSettings(true)}
onOpenProjectSettings={(projectId) => setProjectSettingsId(projectId)}
onToggleTerminal={handleTerminalToggle}
onSetTheme={(theme) => void api.setTheme(theme)}
onDuplicateSession={(sessionId) => {
void api.duplicateSession({ sessionId });
}}
onPinSession={(sessionId, isPinned) => {
void api.setSessionPinned({ sessionId, isPinned });
}}
onArchiveSession={(sessionId, isArchived) => {
void api.setSessionArchived({ sessionId, isArchived });
}}
onAddProject={() => void api.addProject()}
onOpenAppDataFolder={() => void api.openAppDataFolder()}
/>
)}
</>
);
}
+468
View File
@@ -0,0 +1,468 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Archive,
Copy,
FolderOpen,
FolderPlus,
MessageSquare,
Monitor,
Moon,
Pin,
PinOff,
Plus,
Search,
Settings,
Sparkles,
Sun,
Terminal,
} from 'lucide-react';
import type { AppearanceTheme } from '@shared/domain/tooling';
import { isScratchpadProject } from '@shared/domain/project';
import type { WorkspaceState } from '@shared/domain/workspace';
interface PaletteCommand {
id: string;
label: string;
category: string;
keywords?: string;
shortcut?: string;
icon: React.ReactNode;
action: () => void;
}
export interface CommandPaletteProps {
workspace: WorkspaceState;
onClose: () => void;
onSelectSession: (sessionId: string) => void;
onSelectProject: (projectId: string) => void;
onNewSession: (projectId: string) => void;
onCreateScratchpad: () => void;
onOpenSettings: () => void;
onOpenProjectSettings: (projectId: string) => void;
onToggleTerminal: () => void;
onSetTheme: (theme: AppearanceTheme) => void;
onDuplicateSession: (sessionId: string) => void;
onPinSession: (sessionId: string, isPinned: boolean) => void;
onArchiveSession: (sessionId: string, isArchived: boolean) => void;
onAddProject: () => void;
onOpenAppDataFolder: () => void;
}
/** Score how well `query` matches `text` (and optional `keywords`). 0 = no match. */
function matchScore(query: string, text: string, keywords?: string): number {
if (!query) return 1;
const q = query.toLowerCase();
const t = text.toLowerCase();
if (t.startsWith(q)) return 4;
if (t.split(/\s+/).some((w) => w.startsWith(q))) return 3;
if (t.includes(q)) return 2;
if (keywords?.toLowerCase().includes(q)) return 1.5;
const tokens = q.split(/\s+/).filter(Boolean);
if (tokens.length > 1) {
const combined = `${t} ${keywords?.toLowerCase() ?? ''}`;
if (tokens.every((tok) => combined.includes(tok))) return 1;
}
return 0;
}
const ICON = 'size-4';
export function CommandPalette({
workspace,
onClose,
onSelectSession,
onSelectProject,
onNewSession,
onCreateScratchpad,
onOpenSettings,
onOpenProjectSettings,
onToggleTerminal,
onSetTheme,
onDuplicateSession,
onPinSession,
onArchiveSession,
onAddProject,
onOpenAppDataFolder,
}: CommandPaletteProps) {
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
// Intercept Escape in capture phase so it doesn't leak to other overlays
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopImmediatePropagation();
onClose();
}
};
document.addEventListener('keydown', handleEscape, true);
return () => document.removeEventListener('keydown', handleEscape, true);
}, [onClose]);
const selectedSession = useMemo(() => {
const id = workspace.selectedSessionId;
return id ? workspace.sessions.find((s) => s.id === id) : undefined;
}, [workspace.sessions, workspace.selectedSessionId]);
const selectedProject = useMemo(() => {
const id = workspace.selectedProjectId;
return id ? workspace.projects.find((p) => p.id === id) : undefined;
}, [workspace.projects, workspace.selectedProjectId]);
const commands = useMemo<PaletteCommand[]>(() => {
const cmds: PaletteCommand[] = [];
// ── Sessions ──
const sessions = workspace.sessions
.filter((s) => !s.isArchived)
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
for (const s of sessions) {
const project = workspace.projects.find((p) => p.id === s.projectId);
const isCurrent = s.id === workspace.selectedSessionId;
cmds.push({
id: `session-${s.id}`,
label: `${s.title}${isCurrent ? ' (current)' : ''}`,
category: 'Sessions',
keywords: `switch ${project?.name ?? ''} ${s.status}`,
icon: <MessageSquare className={ICON} />,
action: () => onSelectSession(s.id),
});
}
// ── Actions ──
const defaultProjectId =
workspace.selectedProjectId ??
workspace.projects.find((p) => !isScratchpadProject(p))?.id;
if (defaultProjectId) {
cmds.push({
id: 'new-session',
label: 'New Session',
category: 'Actions',
keywords: 'create start',
icon: <Plus className={ICON} />,
action: () => onNewSession(defaultProjectId),
});
}
cmds.push({
id: 'new-scratchpad',
label: 'Quick Scratchpad',
category: 'Actions',
keywords: 'create new scratch quick note',
icon: <Sparkles className={ICON} />,
action: onCreateScratchpad,
});
// ── Current session ──
if (selectedSession) {
cmds.push({
id: 'duplicate-session',
label: 'Duplicate Session',
category: 'Session',
keywords: 'copy clone',
icon: <Copy className={ICON} />,
action: () => onDuplicateSession(selectedSession.id),
});
cmds.push({
id: 'pin-session',
label: selectedSession.isPinned ? 'Unpin Session' : 'Pin Session',
category: 'Session',
keywords: 'pin unpin sticky',
icon: selectedSession.isPinned ? <PinOff className={ICON} /> : <Pin className={ICON} />,
action: () => onPinSession(selectedSession.id, !selectedSession.isPinned),
});
cmds.push({
id: 'archive-session',
label: 'Archive Session',
category: 'Session',
keywords: 'archive hide remove',
icon: <Archive className={ICON} />,
action: () => onArchiveSession(selectedSession.id, true),
});
}
// ── Projects ──
const userProjects = workspace.projects.filter((p) => !isScratchpadProject(p));
for (const p of userProjects) {
const isCurrent = p.id === workspace.selectedProjectId;
cmds.push({
id: `project-${p.id}`,
label: `${p.name}${isCurrent ? ' (current)' : ''}`,
category: 'Projects',
keywords: `switch folder ${p.path}`,
icon: <FolderOpen className={ICON} />,
action: () => onSelectProject(p.id),
});
}
cmds.push({
id: 'add-project',
label: 'Add Project',
category: 'Projects',
keywords: 'folder new open browse',
icon: <FolderPlus className={ICON} />,
action: onAddProject,
});
// ── General ──
cmds.push({
id: 'settings',
label: 'Open Settings',
category: 'General',
keywords: 'preferences config options',
icon: <Settings className={ICON} />,
action: onOpenSettings,
});
if (selectedProject && !isScratchpadProject(selectedProject)) {
cmds.push({
id: 'project-settings',
label: `Project Settings — ${selectedProject.name}`,
category: 'General',
keywords: 'project config options customization',
icon: <Settings className={ICON} />,
action: () => onOpenProjectSettings(selectedProject.id),
});
}
cmds.push({
id: 'toggle-terminal',
label: 'Toggle Terminal',
category: 'General',
keywords: 'terminal console shell command',
shortcut: 'Ctrl+`',
icon: <Terminal className={ICON} />,
action: onToggleTerminal,
});
cmds.push({
id: 'app-data',
label: 'Open App Data Folder',
category: 'General',
keywords: 'data storage files folder workspace',
icon: <FolderOpen className={ICON} />,
action: onOpenAppDataFolder,
});
// ── Theme ──
cmds.push({
id: 'theme-dark',
label: 'Dark Theme',
category: 'Theme',
keywords: 'appearance dark mode night',
icon: <Moon className={ICON} />,
action: () => onSetTheme('dark'),
});
cmds.push({
id: 'theme-light',
label: 'Light Theme',
category: 'Theme',
keywords: 'appearance light mode day',
icon: <Sun className={ICON} />,
action: () => onSetTheme('light'),
});
cmds.push({
id: 'theme-system',
label: 'System Theme',
category: 'Theme',
keywords: 'appearance auto system follow',
icon: <Monitor className={ICON} />,
action: () => onSetTheme('system'),
});
return cmds;
}, [
workspace, selectedSession, selectedProject,
onSelectSession, onSelectProject, onNewSession, onCreateScratchpad,
onOpenSettings, onOpenProjectSettings, onToggleTerminal, onSetTheme,
onDuplicateSession, onPinSession, onArchiveSession, onAddProject,
onOpenAppDataFolder,
]);
const filteredCommands = useMemo(() => {
if (!query.trim()) return commands;
return commands
.map((cmd) => ({ cmd, score: matchScore(query, cmd.label, cmd.keywords) }))
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score)
.map(({ cmd }) => cmd);
}, [commands, query]);
const groupedCommands = useMemo(() => {
const groups: { category: string; commands: PaletteCommand[] }[] = [];
const seen = new Set<string>();
for (const cmd of filteredCommands) {
if (!seen.has(cmd.category)) {
seen.add(cmd.category);
groups.push({ category: cmd.category, commands: [] });
}
groups.find((g) => g.category === cmd.category)!.commands.push(cmd);
}
return groups;
}, [filteredCommands]);
useEffect(() => {
setSelectedIndex(0);
}, [query]);
const executeCommand = useCallback(
(cmd: PaletteCommand) => {
onClose();
cmd.action();
},
[onClose],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
if (filteredCommands.length > 0) {
setSelectedIndex((i) => Math.min(i + 1, filteredCommands.length - 1));
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
const cmd = filteredCommands[selectedIndex];
if (cmd) executeCommand(cmd);
}
},
[filteredCommands, selectedIndex, executeCommand],
);
useEffect(() => {
const item = listRef.current?.querySelector(`[data-palette-index="${selectedIndex}"]`);
item?.scrollIntoView({ block: 'nearest' });
}, [selectedIndex]);
let flatIndex = 0;
return (
<div
className="palette-backdrop-enter fixed inset-0 z-[60] flex justify-center bg-[#07080e]/80 pt-[18vh] backdrop-blur-sm"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label="Command palette"
>
<div
className="palette-enter glow-border flex h-fit max-h-[min(420px,60vh)] w-full max-w-xl flex-col overflow-hidden rounded-xl bg-[var(--color-surface-1)] shadow-[0_16px_64px_rgba(0,0,0,0.5)]"
onClick={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
>
{/* Search input */}
<div className="flex items-center gap-3 border-b border-[var(--color-border)] px-4">
<Search className="size-4 shrink-0 text-[var(--color-text-muted)]" />
<input
ref={inputRef}
type="text"
className="flex-1 bg-transparent py-3.5 text-[14px] text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-muted)]"
placeholder="Type a command…"
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label="Search commands"
autoComplete="off"
spellCheck={false}
/>
{query && (
<button
className="shrink-0 rounded px-1.5 py-0.5 text-[11px] text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
onClick={() => setQuery('')}
type="button"
>
Clear
</button>
)}
</div>
{/* Results */}
<div ref={listRef} className="flex-1 overflow-y-auto py-1.5" role="listbox">
{groupedCommands.length === 0 ? (
<div className="px-4 py-8 text-center text-[13px] text-[var(--color-text-muted)]">
No matching commands
</div>
) : (
groupedCommands.map((group) => (
<div key={group.category}>
<div className="px-4 pb-1 pt-2.5 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
{group.category}
</div>
{group.commands.map((cmd) => {
const index = flatIndex++;
const isSelected = index === selectedIndex;
return (
<button
key={cmd.id}
data-palette-index={index}
className={`flex w-full items-center gap-3 px-4 py-2 text-left text-[13px] transition-colors ${
isSelected
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-glass-hover)] hover:text-[var(--color-text-primary)]'
}`}
onClick={() => executeCommand(cmd)}
onMouseEnter={() => setSelectedIndex(index)}
role="option"
aria-selected={isSelected}
type="button"
>
<span
className={
isSelected
? 'text-[var(--color-text-accent)]'
: 'text-[var(--color-text-muted)]'
}
>
{cmd.icon}
</span>
<span className="flex-1 truncate">{cmd.label}</span>
{cmd.shortcut && (
<kbd className="rounded border border-[var(--color-border)] bg-[var(--color-surface-0)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--color-text-muted)]">
{cmd.shortcut}
</kbd>
)}
</button>
);
})}
</div>
))
)}
</div>
{/* Footer hints */}
<div className="flex items-center gap-4 border-t border-[var(--color-border)] px-4 py-2 text-[11px] text-[var(--color-text-muted)]">
<span className="flex items-center gap-1">
<kbd className="rounded border border-[var(--color-border-subtle)] px-1 font-mono text-[10px]">
</kbd>
navigate
</span>
<span className="flex items-center gap-1">
<kbd className="rounded border border-[var(--color-border-subtle)] px-1 font-mono text-[10px]">
</kbd>
select
</span>
<span className="flex items-center gap-1">
<kbd className="rounded border border-[var(--color-border-subtle)] px-1 font-mono text-[10px]">
esc
</kbd>
close
</span>
</div>
</div>
</div>
);
}
+21
View File
@@ -521,6 +521,27 @@ body {
animation: ambient-pulse 6s ease-in-out infinite;
}
/* ── Command palette ──────────────────────────────────────────── */
@keyframes palette-enter {
from {
opacity: 0;
transform: translateY(-8px) scale(0.98);
}
}
.palette-enter {
animation: palette-enter 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes palette-backdrop-enter {
from { opacity: 0; }
}
.palette-backdrop-enter {
animation: palette-backdrop-enter 0.15s ease-out;
}
/* ── Markdown prose styles ───────────────────────────────────── */
.markdown-content {