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>
This commit is contained in:
David Kaya
2026-03-29 17:49:06 +02:00
co-authored by Copilot
parent bb713f61be
commit ea58f7d66a
4 changed files with 334 additions and 6 deletions
+149 -3
View File
@@ -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<string>();
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<HTMLElement>('.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 && (
<KeyboardShortcutsPanel onClose={() => setShowShortcuts(false)} />
)}
</>
);
}
+20 -3
View File
@@ -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: <Plus className={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: <Archive className={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: <Settings className={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: <Terminal className={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: <Keyboard className={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(() => {
@@ -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<string, ShortcutDefinition[]> {
const groups = new Map<string, ShortcutDefinition[]>();
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 (
<div
className="palette-backdrop-enter fixed inset-0 z-[70] flex items-center justify-center bg-[#07080e]/80 backdrop-blur-sm"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-labelledby="shortcuts-title"
>
<div
className="palette-enter w-full max-w-lg overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-1)] shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center gap-3 border-b border-[var(--color-border)] px-5 py-3.5">
<Keyboard className="size-4 text-[var(--color-text-accent)]" />
<h2
id="shortcuts-title"
className="font-display text-[14px] font-semibold text-[var(--color-text-primary)]"
>
Keyboard Shortcuts
</h2>
<span className="ml-auto text-[11px] text-[var(--color-text-muted)]">
Press <Kbd>Esc</Kbd> to close
</span>
</div>
{/* Body — two-column grid of categories */}
<div className="grid grid-cols-2 gap-x-6 gap-y-5 px-5 py-4">
{categoryOrder.map((cat) => {
const items = grouped.get(cat);
if (!items?.length) return null;
return (
<div key={cat}>
<h3 className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
{cat}
</h3>
<ul className="space-y-1.5">
{items.map((item) => (
<li
key={item.id}
className="flex items-center justify-between gap-3 text-[12.5px]"
>
<span className="truncate text-[var(--color-text-secondary)]">
{item.label}
</span>
<ShortcutBadge keys={item.keys} />
</li>
))}
</ul>
</div>
);
})}
</div>
{/* Footer */}
<div className="border-t border-[var(--color-border)] px-5 py-2.5 text-[11px] text-[var(--color-text-muted)]">
Tip: Use the command palette for even more actions
</div>
</div>
</div>
);
}
/** Inline keyboard key cap. */
function Kbd({ children }: { children: React.ReactNode }) {
return (
<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)]">
{children}
</kbd>
);
}
/** Renders a compound shortcut like "Ctrl+Shift+Tab" as joined key caps. */
function ShortcutBadge({ keys }: { keys: string }) {
const parts = keys.split('+');
return (
<span className="flex shrink-0 items-center gap-0.5">
{parts.map((part, i) => (
<Kbd key={i}>{part}</Kbd>
))}
</span>
);
}
+45
View File
@@ -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;
}