diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 8c5c515..f7ed7db 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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(); const [newSessionProjectId, setNewSessionProjectId] = useState(); 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 && ( + 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()} + /> + )} ); } diff --git a/src/renderer/components/CommandPalette.tsx b/src/renderer/components/CommandPalette.tsx new file mode 100644 index 0000000..a7eb650 --- /dev/null +++ b/src/renderer/components/CommandPalette.tsx @@ -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(null); + const listRef = useRef(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(() => { + 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: , + 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: , + action: () => onNewSession(defaultProjectId), + }); + } + + cmds.push({ + id: 'new-scratchpad', + label: 'Quick Scratchpad', + category: 'Actions', + keywords: 'create new scratch quick note', + icon: , + action: onCreateScratchpad, + }); + + // ── Current session ── + if (selectedSession) { + cmds.push({ + id: 'duplicate-session', + label: 'Duplicate Session', + category: 'Session', + keywords: 'copy clone', + 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 ? : , + action: () => onPinSession(selectedSession.id, !selectedSession.isPinned), + }); + cmds.push({ + id: 'archive-session', + label: 'Archive Session', + category: 'Session', + keywords: 'archive hide remove', + 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: , + action: () => onSelectProject(p.id), + }); + } + cmds.push({ + id: 'add-project', + label: 'Add Project', + category: 'Projects', + keywords: 'folder new open browse', + icon: , + action: onAddProject, + }); + + // ── General ── + cmds.push({ + id: 'settings', + label: 'Open Settings', + category: 'General', + keywords: 'preferences config options', + 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: , + action: () => onOpenProjectSettings(selectedProject.id), + }); + } + + cmds.push({ + id: 'toggle-terminal', + label: 'Toggle Terminal', + category: 'General', + keywords: 'terminal console shell command', + shortcut: 'Ctrl+`', + icon: , + action: onToggleTerminal, + }); + + cmds.push({ + id: 'app-data', + label: 'Open App Data Folder', + category: 'General', + keywords: 'data storage files folder workspace', + icon: , + action: onOpenAppDataFolder, + }); + + // ── Theme ── + cmds.push({ + id: 'theme-dark', + label: 'Dark Theme', + category: 'Theme', + keywords: 'appearance dark mode night', + icon: , + action: () => onSetTheme('dark'), + }); + cmds.push({ + id: 'theme-light', + label: 'Light Theme', + category: 'Theme', + keywords: 'appearance light mode day', + icon: , + action: () => onSetTheme('light'), + }); + cmds.push({ + id: 'theme-system', + label: 'System Theme', + category: 'Theme', + keywords: 'appearance auto system follow', + 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(); + 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 ( +
+
e.stopPropagation()} + onKeyDown={handleKeyDown} + > + {/* Search input */} +
+ + setQuery(e.target.value)} + aria-label="Search commands" + autoComplete="off" + spellCheck={false} + /> + {query && ( + + )} +
+ + {/* Results */} +
+ {groupedCommands.length === 0 ? ( +
+ No matching commands +
+ ) : ( + groupedCommands.map((group) => ( +
+
+ {group.category} +
+ {group.commands.map((cmd) => { + const index = flatIndex++; + const isSelected = index === selectedIndex; + return ( + + ); + })} +
+ )) + )} +
+ + {/* Footer hints */} +
+ + + ↑↓ + + navigate + + + + ↵ + + select + + + + esc + + close + +
+
+
+ ); +} diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 02816a8..9888f4f 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -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 {