From ea58f7d66a02cb55b97a207ef80cbf5bf62433b9 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Sun, 29 Mar 2026 17:49:06 +0200 Subject: [PATCH] feat: add rich keyboard shortcuts with cheat sheet overlay Comprehensive keybindings for power-user navigation: - Ctrl+N: new session, Ctrl+W: archive session - Ctrl+Tab / Ctrl+Shift+Tab: cycle between sessions - Ctrl+,: open settings, Ctrl+/: shortcuts cheat sheet - Ctrl+.: quick-approve pending tool call - Ctrl+L: focus composer, Escape: cancel turn / close overlay Centralized shortcut registry in lib/keyboardShortcuts.ts drives both the cheat sheet panel and command palette shortcut badges. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/renderer/App.tsx | 152 +++++++++++++++++- src/renderer/components/CommandPalette.tsx | 23 ++- .../components/KeyboardShortcutsPanel.tsx | 120 ++++++++++++++ src/renderer/lib/keyboardShortcuts.ts | 45 ++++++ 4 files changed, 334 insertions(+), 6 deletions(-) create mode 100644 src/renderer/components/KeyboardShortcutsPanel.tsx create mode 100644 src/renderer/lib/keyboardShortcuts.ts diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index fc5d316..b4b45d2 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,10 +1,11 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, 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 { KeyboardShortcutsPanel } from '@renderer/components/KeyboardShortcutsPanel'; import { NewSessionModal } from '@renderer/components/NewSessionModal'; import { ProjectSettingsPanel } from '@renderer/components/ProjectSettingsPanel'; import { SettingsPanel } from '@renderer/components/SettingsPanel'; @@ -114,6 +115,7 @@ export default function App() { const [newSessionProjectId, setNewSessionProjectId] = useState(); const [showDiscoveryModal, setShowDiscoveryModal] = useState(false); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); + const [showShortcuts, setShowShortcuts] = useState(false); // Terminal state const [terminalOpen, setTerminalOpen] = useState(false); @@ -266,18 +268,157 @@ export default function App() { if (hasPendingDiscoveries) setShowDiscoveryModal(true); }, [hasPendingDiscoveries]); - // Terminal: Ctrl+` toggle + Command palette: Ctrl+K / Cmd+K + // Keep refs for values the keyboard handler reads — avoids re-registering on every render. + const workspaceRef = useRef(workspace); + workspaceRef.current = workspace; + const showSettingsRef = useRef(showSettings); + showSettingsRef.current = showSettings; + const showShortcutsRef = useRef(showShortcuts); + showShortcutsRef.current = showShortcuts; + const commandPaletteOpenRef = useRef(commandPaletteOpen); + commandPaletteOpenRef.current = commandPaletteOpen; + const projectSettingsIdRef = useRef(projectSettingsId); + projectSettingsIdRef.current = projectSettingsId; + const newSessionProjectIdRef = useRef(newSessionProjectId); + newSessionProjectIdRef.current = newSessionProjectId; + + // ── Global keyboard shortcuts ── useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { + const mod = e.ctrlKey || e.metaKey; + const ws = workspaceRef.current; + + // Ignore keyboard shortcuts while typing in inputs (except our global combos) + const target = e.target as HTMLElement; + const isInput = target.tagName === 'INPUT' + || target.tagName === 'TEXTAREA' + || target.isContentEditable; + + // ── Ctrl+` — Toggle terminal ── if (e.ctrlKey && e.key === '`') { e.preventDefault(); setTerminalOpen((prev) => !prev); + return; } - if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + + // ── Ctrl/Cmd+K — Command palette ── + if (mod && e.key === 'k') { e.preventDefault(); setCommandPaletteOpen((prev) => !prev); + return; + } + + // ── Ctrl/Cmd+/ — Keyboard shortcuts cheat sheet ── + if (mod && e.key === '/') { + e.preventDefault(); + setShowShortcuts((prev) => !prev); + return; + } + + // ── Ctrl/Cmd+, — Open settings ── + if (mod && e.key === ',') { + e.preventDefault(); + setShowSettings(true); + return; + } + + // ── Escape — Close overlays or cancel running turn ── + if (e.key === 'Escape') { + // Close overlays in priority order (command palette and shortcuts use their own capture listeners) + if (projectSettingsIdRef.current) { + e.preventDefault(); + setProjectSettingsId(undefined); + return; + } + if (showSettingsRef.current) { + e.preventDefault(); + setShowSettings(false); + return; + } + if (newSessionProjectIdRef.current) { + e.preventDefault(); + setNewSessionProjectId(undefined); + return; + } + + // If nothing is open, cancel a running turn on the selected session + if (ws) { + const session = ws.sessions.find((s) => s.id === ws.selectedSessionId); + if (session?.status === 'running' && !isInput) { + e.preventDefault(); + void api.cancelSessionTurn({ sessionId: session.id }); + return; + } + } + return; + } + + // Skip remaining shortcuts when focus is in an input field + if (isInput) return; + + // ── Ctrl/Cmd+N — New session ── + if (mod && e.key === 'n') { + e.preventDefault(); + if (ws) { + const defaultProjectId = + ws.selectedProjectId ?? + ws.projects.find((p) => !isScratchpadProject(p))?.id; + if (defaultProjectId) { + setNewSessionProjectId(defaultProjectId); + } + } + return; + } + + // ── Ctrl/Cmd+W — Archive / close current session ── + if (mod && e.key === 'w') { + e.preventDefault(); + if (ws?.selectedSessionId) { + void api.setSessionArchived({ sessionId: ws.selectedSessionId, isArchived: true }); + } + return; + } + + // ── Ctrl+Tab / Ctrl+Shift+Tab — Cycle sessions ── + if (e.ctrlKey && e.key === 'Tab') { + e.preventDefault(); + if (ws) { + const activeSessions = ws.sessions.filter((s) => !s.isArchived); + if (activeSessions.length > 1) { + const currentIdx = activeSessions.findIndex((s) => s.id === ws.selectedSessionId); + const direction = e.shiftKey ? -1 : 1; + const nextIdx = (currentIdx + direction + activeSessions.length) % activeSessions.length; + void api.selectSession(activeSessions[nextIdx].id); + } + } + return; + } + + // ── Ctrl/Cmd+. — Quick approve pending tool call ── + if (mod && e.key === '.') { + e.preventDefault(); + if (ws?.selectedSessionId) { + const session = ws.sessions.find((s) => s.id === ws.selectedSessionId); + if (session?.pendingApproval?.status === 'pending') { + void api.resolveSessionApproval({ + sessionId: session.id, + approvalId: session.pendingApproval.id, + decision: 'approved', + }); + } + } + return; + } + + // ── Ctrl/Cmd+L — Focus the composer ── + if (mod && e.key === 'l') { + e.preventDefault(); + const editor = document.querySelector('.markdown-composer-editable'); + editor?.focus(); + return; } }; + window.addEventListener('keydown', handleKeyDown); // Track terminal running state via exit events @@ -645,8 +786,13 @@ export default function App() { }} onAddProject={() => void api.addProject()} onOpenAppDataFolder={() => void api.openAppDataFolder()} + onShowShortcuts={() => setShowShortcuts(true)} /> )} + + {showShortcuts && ( + setShowShortcuts(false)} /> + )} ); } diff --git a/src/renderer/components/CommandPalette.tsx b/src/renderer/components/CommandPalette.tsx index a7eb650..ec76f30 100644 --- a/src/renderer/components/CommandPalette.tsx +++ b/src/renderer/components/CommandPalette.tsx @@ -4,6 +4,7 @@ import { Copy, FolderOpen, FolderPlus, + Keyboard, MessageSquare, Monitor, Moon, @@ -20,6 +21,7 @@ import { import type { AppearanceTheme } from '@shared/domain/tooling'; import { isScratchpadProject } from '@shared/domain/project'; import type { WorkspaceState } from '@shared/domain/workspace'; +import { shortcutKeys } from '@renderer/lib/keyboardShortcuts'; interface PaletteCommand { id: string; @@ -47,6 +49,7 @@ export interface CommandPaletteProps { onArchiveSession: (sessionId: string, isArchived: boolean) => void; onAddProject: () => void; onOpenAppDataFolder: () => void; + onShowShortcuts: () => void; } /** Score how well `query` matches `text` (and optional `keywords`). 0 = no match. */ @@ -86,6 +89,7 @@ export function CommandPalette({ onArchiveSession, onAddProject, onOpenAppDataFolder, + onShowShortcuts, }: CommandPaletteProps) { const [query, setQuery] = useState(''); const [selectedIndex, setSelectedIndex] = useState(0); @@ -151,6 +155,7 @@ export function CommandPalette({ label: 'New Session', category: 'Actions', keywords: 'create start', + shortcut: shortcutKeys('new-session'), icon: , action: () => onNewSession(defaultProjectId), }); @@ -187,7 +192,8 @@ export function CommandPalette({ id: 'archive-session', label: 'Archive Session', category: 'Session', - keywords: 'archive hide remove', + keywords: 'archive hide remove close', + shortcut: shortcutKeys('close-session'), icon: , action: () => onArchiveSession(selectedSession.id, true), }); @@ -221,6 +227,7 @@ export function CommandPalette({ label: 'Open Settings', category: 'General', keywords: 'preferences config options', + shortcut: shortcutKeys('settings'), icon: , action: onOpenSettings, }); @@ -241,7 +248,7 @@ export function CommandPalette({ label: 'Toggle Terminal', category: 'General', keywords: 'terminal console shell command', - shortcut: 'Ctrl+`', + shortcut: shortcutKeys('toggle-terminal'), icon: , action: onToggleTerminal, }); @@ -255,6 +262,16 @@ export function CommandPalette({ action: onOpenAppDataFolder, }); + cmds.push({ + id: 'keyboard-shortcuts', + label: 'Keyboard Shortcuts', + category: 'General', + keywords: 'keys keybindings hotkeys help cheatsheet', + shortcut: shortcutKeys('shortcut-help'), + icon: , + action: onShowShortcuts, + }); + // ── Theme ── cmds.push({ id: 'theme-dark', @@ -287,7 +304,7 @@ export function CommandPalette({ onSelectSession, onSelectProject, onNewSession, onCreateScratchpad, onOpenSettings, onOpenProjectSettings, onToggleTerminal, onSetTheme, onDuplicateSession, onPinSession, onArchiveSession, onAddProject, - onOpenAppDataFolder, + onOpenAppDataFolder, onShowShortcuts, ]); const filteredCommands = useMemo(() => { diff --git a/src/renderer/components/KeyboardShortcutsPanel.tsx b/src/renderer/components/KeyboardShortcutsPanel.tsx new file mode 100644 index 0000000..3519ddc --- /dev/null +++ b/src/renderer/components/KeyboardShortcutsPanel.tsx @@ -0,0 +1,120 @@ +import { useEffect } from 'react'; +import { Keyboard } from 'lucide-react'; + +import { shortcuts, type ShortcutDefinition } from '@renderer/lib/keyboardShortcuts'; + +interface KeyboardShortcutsPanelProps { + onClose: () => void; +} + +const categoryOrder = ['Navigation', 'Sessions', 'Workspace', 'General'] as const; + +function groupByCategory(defs: ShortcutDefinition[]): Map { + const groups = new Map(); + for (const def of defs) { + const list = groups.get(def.category) ?? []; + list.push(def); + groups.set(def.category, list); + } + return groups; +} + +export function KeyboardShortcutsPanel({ onClose }: KeyboardShortcutsPanelProps) { + // Escape to close — capture phase so it doesn't propagate + 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 grouped = groupByCategory(shortcuts); + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+ +

+ Keyboard Shortcuts +

+ + Press Esc to close + +
+ + {/* Body — two-column grid of categories */} +
+ {categoryOrder.map((cat) => { + const items = grouped.get(cat); + if (!items?.length) return null; + return ( +
+

+ {cat} +

+
    + {items.map((item) => ( +
  • + + {item.label} + + +
  • + ))} +
+
+ ); + })} +
+ + {/* Footer */} +
+ Tip: Use the command palette for even more actions +
+
+
+ ); +} + +/** Inline keyboard key cap. */ +function Kbd({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +/** Renders a compound shortcut like "Ctrl+Shift+Tab" as joined key caps. */ +function ShortcutBadge({ keys }: { keys: string }) { + const parts = keys.split('+'); + return ( + + {parts.map((part, i) => ( + {part} + ))} + + ); +} diff --git a/src/renderer/lib/keyboardShortcuts.ts b/src/renderer/lib/keyboardShortcuts.ts new file mode 100644 index 0000000..4f65924 --- /dev/null +++ b/src/renderer/lib/keyboardShortcuts.ts @@ -0,0 +1,45 @@ +const isMac = navigator.platform.startsWith('Mac'); + +/** Platform-aware modifier key label. */ +export const MOD = isMac ? '⌘' : 'Ctrl'; + +export interface ShortcutDefinition { + id: string; + /** Human-readable label for the shortcut cheat sheet. */ + label: string; + /** Display string for the keybinding. */ + keys: string; + /** Grouping category. */ + category: 'Navigation' | 'Sessions' | 'Workspace' | 'General'; +} + +/** Canonical shortcut registry used for the cheat sheet and command palette badges. */ +export const shortcuts: ShortcutDefinition[] = [ + // ── Navigation ── + { id: 'command-palette', label: 'Command palette', keys: `${MOD}+K`, category: 'Navigation' }, + { id: 'settings', label: 'Open settings', keys: `${MOD}+,`, category: 'Navigation' }, + { id: 'toggle-terminal', label: 'Toggle terminal', keys: 'Ctrl+`', category: 'Navigation' }, + { id: 'shortcut-help', label: 'Keyboard shortcuts', keys: `${MOD}+/`, category: 'Navigation' }, + + // ── Sessions ── + { id: 'new-session', label: 'New session', keys: `${MOD}+N`, category: 'Sessions' }, + { id: 'close-session', label: 'Close / archive', keys: `${MOD}+W`, category: 'Sessions' }, + { id: 'next-session', label: 'Next session', keys: 'Ctrl+Tab', category: 'Sessions' }, + { id: 'prev-session', label: 'Previous session', keys: 'Ctrl+Shift+Tab', category: 'Sessions' }, + + // ── Workspace ── + { id: 'quick-approve', label: 'Quick approve', keys: `${MOD}+.`, category: 'Workspace' }, + { id: 'cancel-turn', label: 'Cancel running turn', keys: 'Escape', category: 'Workspace' }, + { id: 'focus-composer', label: 'Focus composer', keys: `${MOD}+L`, category: 'Workspace' }, + + // ── General ── + { id: 'close-overlay', label: 'Close overlay', keys: 'Escape', category: 'General' }, +]; + +/** + * Look up the display-string for a shortcut by its id. + * Returns `undefined` if not found. + */ +export function shortcutKeys(id: string): string | undefined { + return shortcuts.find((s) => s.id === id)?.keys; +}