diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index ed1afbd86..c3dd85c99 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -164,11 +164,29 @@ describe("keyboard-shortcuts", () => { action: "workspace.tab.close.current", }, { - name: "matches Mod+W to close current tab on desktop", + name: "matches Cmd+W to close current tab on mac desktop", event: { key: "w", code: "KeyW", metaKey: true }, context: { isMac: true, isDesktop: true }, action: "workspace.tab.close.current", }, + { + name: "matches Ctrl+W to close current tab on non-mac desktop", + event: { key: "w", code: "KeyW", ctrlKey: true }, + context: { isMac: false, isDesktop: true }, + action: "workspace.tab.close.current", + }, + { + name: "matches Ctrl+Shift+O to create new agent on non-mac", + event: { key: "O", code: "KeyO", ctrlKey: true, shiftKey: true }, + context: { isMac: false }, + action: "agent.new", + }, + { + name: "matches Ctrl+K for command center on non-mac", + event: { key: "k", code: "KeyK", ctrlKey: true }, + context: { isMac: false }, + action: "command-center.toggle", + }, { name: "matches Cmd+Backslash to split pane right on macOS", event: { key: "\\", code: "Backslash", metaKey: true }, @@ -268,6 +286,26 @@ describe("keyboard-shortcuts", () => { event: { key: "?", code: "Slash", shiftKey: true }, context: { focusScope: "message-input" }, }, + { + name: "does not close tab with Ctrl+W on mac desktop (Cmd+W only)", + event: { key: "w", code: "KeyW", ctrlKey: true }, + context: { isMac: true, isDesktop: true }, + }, + { + name: "does not close tab with Ctrl+W on non-mac desktop when terminal is focused", + event: { key: "w", code: "KeyW", ctrlKey: true }, + context: { isMac: false, isDesktop: true, focusScope: "terminal" }, + }, + { + name: "does not match Ctrl+T on mac (Cmd only)", + event: { key: "t", code: "KeyT", ctrlKey: true }, + context: { isMac: true }, + }, + { + name: "does not match Ctrl+K for command center on non-mac in terminal", + event: { key: "k", code: "KeyK", ctrlKey: true }, + context: { isMac: false, focusScope: "terminal" }, + }, { name: "does not bind Ctrl+B on non-mac", event: { key: "b", code: "KeyB", ctrlKey: true }, @@ -331,11 +369,18 @@ describe("keyboard-shortcut help sections", () => { "workspace-tab-new": ["mod", "T"], "workspace-jump-index": ["mod", "1-9"], "workspace-tab-jump-index": ["alt", "1-9"], - "workspace-tab-close-current": ["mod", "W"], + "workspace-tab-close-current": ["meta", "W"], "workspace-pane-split-right": ["mod", "\\"], "workspace-pane-close": ["mod", "shift", "W"], }, }, + { + name: "shows Ctrl+W close tab for non-mac desktop", + context: { isMac: false, isDesktop: true }, + expectedKeys: { + "workspace-tab-close-current": ["ctrl", "W"], + }, + }, { name: "uses mod+period as non-mac left sidebar shortcut", context: { isMac: false, isDesktop: false }, diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 399df6723..a43d0c2d0 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -6,6 +6,8 @@ import type { MessageInputKeyboardActionKind, } from "@/keyboard/actions"; +// --- Public types --- + export type KeyboardShortcutContext = { isMac: boolean; isDesktop: boolean; @@ -34,30 +36,66 @@ export type KeyboardShortcutHelpSection = { rows: KeyboardShortcutHelpRow[]; }; +// --- Binding definition types --- + type KeyboardShortcutPlatformContext = { isMac: boolean; isDesktop: boolean; }; -type KeyboardShortcutHelpEntry = { +interface KeyCombo { + /** Event.code to match. Use "Digit" for any digit 1-9. */ + code: string; + /** Optional event.key fallback (case-insensitive). */ + key?: string; + meta?: true; + ctrl?: true; + alt?: true; + shift?: true; + /** Set to false to block key repeat events. */ + repeat?: false; +} + +interface ShortcutWhen { + /** true = mac only, false = non-mac only */ + mac?: boolean; + /** true = desktop only, false = web only */ + desktop?: boolean; + /** false = disabled when terminal is focused */ + terminal?: false; + /** false = disabled when command center is open */ + commandCenter?: false; + /** true = requires a selected agent */ + hasSelectedAgent?: true; + /** Exact focus scope match */ + focusScope?: KeyboardFocusScope; +} + +type ShortcutPayloadDef = + | { type: "index" } + | { type: "delta"; delta: 1 | -1 } + | { type: "message-input"; kind: MessageInputKeyboardActionKind }; + +interface ShortcutHelp { id: string; - section: KeyboardShortcutHelpSection["id"]; + section: "global" | "agent-input"; label: string; keys: ShortcutKey[]; note?: string; - when?: (context: KeyboardShortcutPlatformContext) => boolean; -}; +} -type KeyboardShortcutBinding = { +interface ShortcutBinding { id: string; action: KeyboardActionId; - matches: (event: KeyboardEvent) => boolean; - when: (context: KeyboardShortcutContext) => boolean; - payload?: (event: KeyboardEvent) => KeyboardShortcutPayload; + combo: KeyCombo; + when?: ShortcutWhen; + payload?: ShortcutPayloadDef; preventDefault?: boolean; stopPropagation?: boolean; - help?: KeyboardShortcutHelpEntry; -}; + help?: ShortcutHelp; +} + +// --- Constants --- const SHORTCUT_HELP_SECTION_TITLES: Record< KeyboardShortcutHelpSection["id"], @@ -67,13 +105,595 @@ const SHORTCUT_HELP_SECTION_TITLES: Record< "agent-input": "Agent Input", }; -function isMod(event: KeyboardEvent): boolean { - return event.metaKey || event.ctrlKey; -} +// --- Binding definitions --- -function isMacCommand(event: KeyboardEvent): boolean { - return event.metaKey && !event.ctrlKey; -} +const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ + // --- New agent --- + { + id: "agent-new-cmd-shift-o-mac", + action: "agent.new", + combo: { code: "KeyO", key: "o", meta: true, shift: true }, + when: { mac: true }, + help: { + id: "new-agent", + section: "global", + label: "Open project", + keys: ["mod", "shift", "O"], + }, + }, + { + id: "agent-new-ctrl-shift-o-non-mac", + action: "agent.new", + combo: { code: "KeyO", key: "o", ctrl: true, shift: true }, + when: { mac: false, terminal: false }, + help: { + id: "new-agent", + section: "global", + label: "Open project", + keys: ["mod", "shift", "O"], + }, + }, + + // --- Tab management --- + { + id: "workspace-tab-new-cmd-t-mac", + action: "workspace.tab.new", + combo: { code: "KeyT", key: "t", meta: true }, + when: { mac: true, commandCenter: false }, + help: { + id: "workspace-tab-new", + section: "global", + label: "New agent tab", + keys: ["mod", "T"], + }, + }, + { + id: "workspace-tab-new-ctrl-t-non-mac", + action: "workspace.tab.new", + combo: { code: "KeyT", key: "t", ctrl: true }, + when: { mac: false, commandCenter: false, terminal: false }, + help: { + id: "workspace-tab-new", + section: "global", + label: "New agent tab", + keys: ["mod", "T"], + }, + }, + { + id: "workspace-tab-close-current-cmd-w-mac", + action: "workspace.tab.close.current", + combo: { code: "KeyW", key: "w", meta: true }, + when: { mac: true, desktop: true, commandCenter: false }, + help: { + id: "workspace-tab-close-current", + section: "global", + label: "Close current tab", + keys: ["meta", "W"], + }, + }, + { + id: "workspace-tab-close-current-ctrl-w-non-mac", + action: "workspace.tab.close.current", + combo: { code: "KeyW", key: "w", ctrl: true }, + when: { mac: false, desktop: true, commandCenter: false, terminal: false }, + help: { + id: "workspace-tab-close-current", + section: "global", + label: "Close current tab", + keys: ["ctrl", "W"], + }, + }, + { + id: "workspace-tab-close-current-alt-shift-w-web", + action: "workspace.tab.close.current", + combo: { code: "KeyW", key: "w", alt: true, shift: true }, + when: { desktop: false, commandCenter: false }, + help: { + id: "workspace-tab-close-current", + section: "global", + label: "Close current tab", + keys: ["alt", "shift", "W"], + }, + }, + + // --- Workspace index jump --- + { + id: "workspace-navigate-index-cmd-digit-mac", + action: "workspace.navigate.index", + combo: { code: "Digit", meta: true }, + when: { mac: true, desktop: true, commandCenter: false }, + payload: { type: "index" }, + help: { + id: "workspace-jump-index", + section: "global", + label: "Jump to workspace", + keys: ["mod", "1-9"], + }, + }, + { + id: "workspace-navigate-index-ctrl-digit-non-mac", + action: "workspace.navigate.index", + combo: { code: "Digit", ctrl: true }, + when: { mac: false, desktop: true, commandCenter: false, terminal: false }, + payload: { type: "index" }, + help: { + id: "workspace-jump-index", + section: "global", + label: "Jump to workspace", + keys: ["mod", "1-9"], + }, + }, + { + id: "workspace-navigate-index-alt-digit-web", + action: "workspace.navigate.index", + combo: { code: "Digit", alt: true }, + when: { desktop: false, commandCenter: false }, + payload: { type: "index" }, + help: { + id: "workspace-jump-index", + section: "global", + label: "Jump to workspace", + keys: ["alt", "1-9"], + }, + }, + + // --- Tab index jump --- + { + id: "workspace-tab-navigate-index-alt-digit-desktop", + action: "workspace.tab.navigate.index", + combo: { code: "Digit", alt: true }, + when: { desktop: true, commandCenter: false }, + payload: { type: "index" }, + help: { + id: "workspace-tab-jump-index", + section: "global", + label: "Jump to tab", + keys: ["alt", "1-9"], + }, + }, + { + id: "workspace-tab-navigate-index-alt-shift-digit-web", + action: "workspace.tab.navigate.index", + combo: { code: "Digit", alt: true, shift: true }, + when: { desktop: false, commandCenter: false }, + payload: { type: "index" }, + help: { + id: "workspace-tab-jump-index", + section: "global", + label: "Jump to tab", + keys: ["alt", "shift", "1-9"], + }, + }, + + // --- Workspace relative navigation --- + { + id: "workspace-navigate-relative-cmd-left-mac", + action: "workspace.navigate.relative", + combo: { code: "BracketLeft", key: "[", meta: true }, + when: { mac: true, desktop: true, commandCenter: false }, + payload: { type: "delta", delta: -1 }, + help: { + id: "workspace-prev", + section: "global", + label: "Previous workspace", + keys: ["mod", "["], + }, + }, + { + id: "workspace-navigate-relative-ctrl-left-non-mac", + action: "workspace.navigate.relative", + combo: { code: "BracketLeft", key: "[", ctrl: true }, + when: { mac: false, desktop: true, commandCenter: false, terminal: false }, + payload: { type: "delta", delta: -1 }, + help: { + id: "workspace-prev", + section: "global", + label: "Previous workspace", + keys: ["mod", "["], + }, + }, + { + id: "workspace-navigate-relative-cmd-right-mac", + action: "workspace.navigate.relative", + combo: { code: "BracketRight", key: "]", meta: true }, + when: { mac: true, desktop: true, commandCenter: false }, + payload: { type: "delta", delta: 1 }, + help: { + id: "workspace-next", + section: "global", + label: "Next workspace", + keys: ["mod", "]"], + }, + }, + { + id: "workspace-navigate-relative-ctrl-right-non-mac", + action: "workspace.navigate.relative", + combo: { code: "BracketRight", key: "]", ctrl: true }, + when: { mac: false, desktop: true, commandCenter: false, terminal: false }, + payload: { type: "delta", delta: 1 }, + help: { + id: "workspace-next", + section: "global", + label: "Next workspace", + keys: ["mod", "]"], + }, + }, + { + id: "workspace-navigate-relative-alt-left-web", + action: "workspace.navigate.relative", + combo: { code: "BracketLeft", key: "[", alt: true }, + when: { desktop: false, commandCenter: false }, + payload: { type: "delta", delta: -1 }, + help: { + id: "workspace-prev", + section: "global", + label: "Previous workspace", + keys: ["alt", "["], + }, + }, + { + id: "workspace-navigate-relative-alt-right-web", + action: "workspace.navigate.relative", + combo: { code: "BracketRight", key: "]", alt: true }, + when: { desktop: false, commandCenter: false }, + payload: { type: "delta", delta: 1 }, + help: { + id: "workspace-next", + section: "global", + label: "Next workspace", + keys: ["alt", "]"], + }, + }, + + // --- Tab relative navigation --- + { + id: "workspace-tab-navigate-relative-alt-shift-left", + action: "workspace.tab.navigate.relative", + combo: { code: "BracketLeft", alt: true, shift: true }, + when: { commandCenter: false }, + payload: { type: "delta", delta: -1 }, + help: { + id: "workspace-tab-prev", + section: "global", + label: "Previous tab", + keys: ["alt", "shift", "["], + }, + }, + { + id: "workspace-tab-navigate-relative-alt-shift-right", + action: "workspace.tab.navigate.relative", + combo: { code: "BracketRight", alt: true, shift: true }, + when: { commandCenter: false }, + payload: { type: "delta", delta: 1 }, + help: { + id: "workspace-tab-next", + section: "global", + label: "Next tab", + keys: ["alt", "shift", "]"], + }, + }, + + // --- Pane management (mac only) --- + { + id: "workspace-pane-split-right-cmd-backslash", + action: "workspace.pane.split.right", + combo: { code: "Backslash", meta: true }, + when: { mac: true, terminal: false, commandCenter: false }, + help: { + id: "workspace-pane-split-right", + section: "global", + label: "Split pane right", + keys: ["mod", "\\"], + }, + }, + { + id: "workspace-pane-split-down-cmd-shift-backslash", + action: "workspace.pane.split.down", + combo: { code: "Backslash", meta: true, shift: true }, + when: { mac: true, terminal: false, commandCenter: false }, + help: { + id: "workspace-pane-split-down", + section: "global", + label: "Split pane down", + keys: ["mod", "shift", "\\"], + }, + }, + { + id: "workspace-pane-focus-left-cmd-shift-left", + action: "workspace.pane.focus.left", + combo: { code: "ArrowLeft", meta: true, shift: true }, + when: { mac: true, terminal: false, commandCenter: false }, + help: { + id: "workspace-pane-focus-left", + section: "global", + label: "Focus pane left", + keys: ["mod", "shift", "Left"], + }, + }, + { + id: "workspace-pane-focus-right-cmd-shift-right", + action: "workspace.pane.focus.right", + combo: { code: "ArrowRight", meta: true, shift: true }, + when: { mac: true, terminal: false, commandCenter: false }, + help: { + id: "workspace-pane-focus-right", + section: "global", + label: "Focus pane right", + keys: ["mod", "shift", "Right"], + }, + }, + { + id: "workspace-pane-focus-up-cmd-shift-up", + action: "workspace.pane.focus.up", + combo: { code: "ArrowUp", meta: true, shift: true }, + when: { mac: true, terminal: false, commandCenter: false }, + help: { + id: "workspace-pane-focus-up", + section: "global", + label: "Focus pane up", + keys: ["mod", "shift", "Up"], + }, + }, + { + id: "workspace-pane-focus-down-cmd-shift-down", + action: "workspace.pane.focus.down", + combo: { code: "ArrowDown", meta: true, shift: true }, + when: { mac: true, terminal: false, commandCenter: false }, + help: { + id: "workspace-pane-focus-down", + section: "global", + label: "Focus pane down", + keys: ["mod", "shift", "Down"], + }, + }, + { + id: "workspace-pane-move-tab-left-cmd-shift-alt-left", + action: "workspace.pane.move-tab.left", + combo: { code: "ArrowLeft", meta: true, alt: true, shift: true }, + when: { mac: true, terminal: false, commandCenter: false }, + help: { + id: "workspace-pane-move-tab-left", + section: "global", + label: "Move tab left", + keys: ["mod", "shift", "alt", "Left"], + }, + }, + { + id: "workspace-pane-move-tab-right-cmd-shift-alt-right", + action: "workspace.pane.move-tab.right", + combo: { code: "ArrowRight", meta: true, alt: true, shift: true }, + when: { mac: true, terminal: false, commandCenter: false }, + help: { + id: "workspace-pane-move-tab-right", + section: "global", + label: "Move tab right", + keys: ["mod", "shift", "alt", "Right"], + }, + }, + { + id: "workspace-pane-move-tab-up-cmd-shift-alt-up", + action: "workspace.pane.move-tab.up", + combo: { code: "ArrowUp", meta: true, alt: true, shift: true }, + when: { mac: true, terminal: false, commandCenter: false }, + help: { + id: "workspace-pane-move-tab-up", + section: "global", + label: "Move tab up", + keys: ["mod", "shift", "alt", "Up"], + }, + }, + { + id: "workspace-pane-move-tab-down-cmd-shift-alt-down", + action: "workspace.pane.move-tab.down", + combo: { code: "ArrowDown", meta: true, alt: true, shift: true }, + when: { mac: true, terminal: false, commandCenter: false }, + help: { + id: "workspace-pane-move-tab-down", + section: "global", + label: "Move tab down", + keys: ["mod", "shift", "alt", "Down"], + }, + }, + { + id: "workspace-pane-close-cmd-shift-w", + action: "workspace.pane.close", + combo: { code: "KeyW", key: "w", meta: true, shift: true }, + when: { mac: true, terminal: false, commandCenter: false }, + help: { + id: "workspace-pane-close", + section: "global", + label: "Close pane", + keys: ["mod", "shift", "W"], + }, + }, + + // --- Command center --- + { + id: "command-center-toggle-cmd-k-mac", + action: "command-center.toggle", + combo: { code: "KeyK", key: "k", meta: true }, + when: { mac: true }, + help: { + id: "toggle-command-center", + section: "global", + label: "Toggle command center", + keys: ["mod", "K"], + }, + }, + { + id: "command-center-toggle-ctrl-k-non-mac", + action: "command-center.toggle", + combo: { code: "KeyK", key: "k", ctrl: true }, + when: { mac: false, terminal: false }, + help: { + id: "toggle-command-center", + section: "global", + label: "Toggle command center", + keys: ["mod", "K"], + }, + }, + + // --- Keyboard shortcuts dialog --- + { + id: "shortcuts-dialog-toggle-question-mark", + action: "shortcuts.dialog.toggle", + combo: { code: "Slash", key: "?", shift: true, repeat: false }, + when: { focusScope: "other" }, + help: { + id: "show-shortcuts", + section: "global", + label: "Show keyboard shortcuts", + keys: ["?"], + note: "Available when focus is not in a text field or terminal.", + }, + }, + + // --- Sidebar toggles --- + { + id: "sidebar-toggle-left-mac-cmd-b", + action: "sidebar.toggle.left", + combo: { code: "KeyB", key: "b", meta: true }, + when: { mac: true }, + help: { + id: "toggle-left-sidebar", + section: "global", + label: "Toggle left sidebar", + keys: ["mod", "B"], + }, + }, + { + id: "sidebar-toggle-left-cmd-period-mac", + action: "sidebar.toggle.left", + combo: { code: "Period", key: ".", meta: true }, + when: { mac: true, commandCenter: false }, + }, + { + id: "sidebar-toggle-left-ctrl-period-non-mac", + action: "sidebar.toggle.left", + combo: { code: "Period", key: ".", ctrl: true }, + when: { mac: false, commandCenter: false, terminal: false }, + help: { + id: "toggle-left-sidebar", + section: "global", + label: "Toggle left sidebar", + keys: ["mod", "."], + }, + }, + { + id: "sidebar-toggle-right-cmd-e-mac", + action: "sidebar.toggle.right", + combo: { code: "KeyE", key: "e", meta: true }, + when: { mac: true, hasSelectedAgent: true, commandCenter: false }, + help: { + id: "toggle-right-sidebar", + section: "global", + label: "Toggle right sidebar", + keys: ["mod", "E"], + }, + }, + { + id: "sidebar-toggle-right-ctrl-e-non-mac", + action: "sidebar.toggle.right", + combo: { code: "KeyE", key: "e", ctrl: true }, + when: { mac: false, hasSelectedAgent: true, commandCenter: false, terminal: false }, + help: { + id: "toggle-right-sidebar", + section: "global", + label: "Toggle right sidebar", + keys: ["mod", "E"], + }, + }, + { + id: "sidebar-toggle-right-ctrl-backquote", + action: "sidebar.toggle.right", + combo: { code: "Backquote", key: "`", ctrl: true }, + when: { hasSelectedAgent: true, commandCenter: false }, + }, + + // --- Message input --- + { + id: "message-input-voice-toggle-cmd-shift-d-mac", + action: "message-input.action", + combo: { code: "KeyD", key: "d", meta: true, shift: true, repeat: false }, + when: { mac: true, commandCenter: false, terminal: false }, + payload: { type: "message-input", kind: "voice-toggle" }, + help: { + id: "voice-toggle", + section: "agent-input", + label: "Toggle voice mode", + keys: ["mod", "shift", "D"], + }, + }, + { + id: "message-input-voice-toggle-ctrl-shift-d-non-mac", + action: "message-input.action", + combo: { code: "KeyD", key: "d", ctrl: true, shift: true, repeat: false }, + when: { mac: false, commandCenter: false, terminal: false }, + payload: { type: "message-input", kind: "voice-toggle" }, + help: { + id: "voice-toggle", + section: "agent-input", + label: "Toggle voice mode", + keys: ["mod", "shift", "D"], + }, + }, + { + id: "message-input-dictation-toggle-cmd-d-mac", + action: "message-input.action", + combo: { code: "KeyD", key: "d", meta: true }, + when: { mac: true, commandCenter: false, terminal: false }, + payload: { type: "message-input", kind: "dictation-toggle" }, + help: { + id: "dictation-toggle", + section: "agent-input", + label: "Start/stop dictation", + keys: ["mod", "D"], + }, + }, + { + id: "message-input-dictation-toggle-ctrl-d-non-mac", + action: "message-input.action", + combo: { code: "KeyD", key: "d", ctrl: true }, + when: { mac: false, commandCenter: false, terminal: false }, + payload: { type: "message-input", kind: "dictation-toggle" }, + help: { + id: "dictation-toggle", + section: "agent-input", + label: "Start/stop dictation", + keys: ["mod", "D"], + }, + }, + { + id: "message-input-dictation-cancel", + action: "message-input.action", + combo: { code: "Escape" }, + when: { commandCenter: false, terminal: false }, + payload: { type: "message-input", kind: "dictation-cancel" }, + preventDefault: false, + stopPropagation: false, + help: { + id: "dictation-cancel", + section: "agent-input", + label: "Cancel dictation", + keys: ["Esc"], + }, + }, + { + id: "message-input-voice-mute-toggle", + action: "message-input.action", + combo: { code: "Space", key: " ", repeat: false }, + when: { commandCenter: false, focusScope: "other" }, + payload: { type: "message-input", kind: "voice-mute-toggle" }, + help: { + id: "voice-mute-toggle", + section: "agent-input", + label: "Mute/unmute voice mode", + keys: ["Space"], + }, + }, +]; + +// --- Matching engine --- function parseDigit(event: KeyboardEvent): number | null { const code = event.code ?? ""; @@ -92,654 +712,69 @@ function parseDigit(event: KeyboardEvent): number | null { return null; } -function hasDigit(event: KeyboardEvent): boolean { - return parseDigit(event) !== null; +function matchesCombo(combo: KeyCombo, event: KeyboardEvent): boolean { + if (!!combo.meta !== event.metaKey) return false; + if (!!combo.ctrl !== event.ctrlKey) return false; + if (!!combo.alt !== event.altKey) return false; + if (!!combo.shift !== event.shiftKey) return false; + if (combo.repeat === false && event.repeat) return false; + + if (combo.code === "Digit") { + return parseDigit(event) !== null; + } + + const codeMatch = event.code === combo.code; + const keyMatch = + combo.key !== undefined && + event.key.toLowerCase() === combo.key.toLowerCase(); + return codeMatch || keyMatch; } -function isQuestionMarkShortcut(event: KeyboardEvent): boolean { - return ( - !event.metaKey && - !event.ctrlKey && - !event.altKey && - event.shiftKey && - !event.repeat && - (event.key === "?" || event.code === "Slash") - ); +function matchesWhen( + when: ShortcutWhen | undefined, + context: KeyboardShortcutContext +): boolean { + if (!when) return true; + if (when.mac !== undefined && when.mac !== context.isMac) return false; + if (when.desktop !== undefined && when.desktop !== context.isDesktop) + return false; + if (when.terminal === false && context.focusScope === "terminal") + return false; + if (when.commandCenter === false && context.commandCenterOpen) return false; + if (when.hasSelectedAgent === true && !context.hasSelectedAgent) return false; + if (when.focusScope !== undefined && context.focusScope !== when.focusScope) + return false; + return true; } -function withMessageInputAction( - kind: MessageInputKeyboardActionKind -): (event: KeyboardEvent) => KeyboardShortcutPayload { - return () => ({ kind }); +function resolvePayload( + def: ShortcutPayloadDef | undefined, + event: KeyboardEvent +): KeyboardShortcutPayload { + if (!def) return null; + switch (def.type) { + case "index": { + const index = parseDigit(event); + return index ? { index } : null; + } + case "delta": + return { delta: def.delta }; + case "message-input": + return { kind: def.kind }; + } } -function withIndexPayload(event: KeyboardEvent): KeyboardShortcutPayload { - const index = parseDigit(event); - return index ? { index } : null; +function helpMatchesPlatform( + when: ShortcutWhen | undefined, + context: KeyboardShortcutPlatformContext +): boolean { + if (when?.mac !== undefined && when.mac !== context.isMac) return false; + if (when?.desktop !== undefined && when.desktop !== context.isDesktop) + return false; + return true; } -function withRelativeDelta( - delta: 1 | -1 -): (event: KeyboardEvent) => KeyboardShortcutPayload { - return () => ({ delta }); -} - -const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [ - { - id: "agent-new-mod-shift-o", - action: "agent.new", - matches: (event) => - isMod(event) && - !event.altKey && - event.shiftKey && - (event.code === "KeyO" || event.key.toLowerCase() === "o"), - when: () => true, - help: { - id: "new-agent", - section: "global", - label: "Open project", - keys: ["mod", "shift", "O"], - }, - }, - { - id: "workspace-tab-new-mod-t", - action: "workspace.tab.new", - matches: (event) => - isMod(event) && - !event.altKey && - !event.shiftKey && - (event.code === "KeyT" || event.key.toLowerCase() === "t"), - when: (context) => !context.commandCenterOpen, - help: { - id: "workspace-tab-new", - section: "global", - label: "New agent tab", - keys: ["mod", "T"], - }, - }, - { - id: "workspace-tab-close-current-mod-w-desktop", - action: "workspace.tab.close.current", - matches: (event) => - isMod(event) && - !event.altKey && - !event.shiftKey && - (event.code === "KeyW" || event.key.toLowerCase() === "w"), - when: (context) => context.isDesktop && !context.commandCenterOpen, - help: { - id: "workspace-tab-close-current", - section: "global", - label: "Close current tab", - keys: ["mod", "W"], - when: (context) => context.isDesktop, - }, - }, - { - id: "workspace-tab-close-current-alt-shift-w-web", - action: "workspace.tab.close.current", - matches: (event) => - !event.metaKey && - !event.ctrlKey && - event.altKey && - event.shiftKey && - (event.code === "KeyW" || event.key.toLowerCase() === "w"), - when: (context) => !context.isDesktop && !context.commandCenterOpen, - help: { - id: "workspace-tab-close-current", - section: "global", - label: "Close current tab", - keys: ["alt", "shift", "W"], - when: (context) => !context.isDesktop, - }, - }, - { - id: "workspace-navigate-index-mod-digit-desktop", - action: "workspace.navigate.index", - matches: (event) => - isMod(event) && - !event.altKey && - !event.shiftKey && - hasDigit(event), - payload: withIndexPayload, - when: (context) => context.isDesktop && !context.commandCenterOpen, - help: { - id: "workspace-jump-index", - section: "global", - label: "Jump to workspace", - keys: ["mod", "1-9"], - when: (context) => context.isDesktop, - }, - }, - { - id: "workspace-navigate-index-alt-digit-web", - action: "workspace.navigate.index", - matches: (event) => - !event.metaKey && - !event.ctrlKey && - event.altKey && - !event.shiftKey && - hasDigit(event), - payload: withIndexPayload, - when: (context) => !context.isDesktop && !context.commandCenterOpen, - help: { - id: "workspace-jump-index", - section: "global", - label: "Jump to workspace", - keys: ["alt", "1-9"], - when: (context) => !context.isDesktop, - }, - }, - { - id: "workspace-tab-navigate-index-alt-digit-desktop", - action: "workspace.tab.navigate.index", - matches: (event) => - !event.metaKey && - !event.ctrlKey && - event.altKey && - !event.shiftKey && - hasDigit(event), - payload: withIndexPayload, - when: (context) => context.isDesktop && !context.commandCenterOpen, - help: { - id: "workspace-tab-jump-index", - section: "global", - label: "Jump to tab", - keys: ["alt", "1-9"], - when: (context) => context.isDesktop, - }, - }, - { - id: "workspace-tab-navigate-index-alt-shift-digit-web", - action: "workspace.tab.navigate.index", - matches: (event) => - !event.metaKey && - !event.ctrlKey && - event.altKey && - event.shiftKey && - hasDigit(event), - payload: withIndexPayload, - when: (context) => !context.isDesktop && !context.commandCenterOpen, - help: { - id: "workspace-tab-jump-index", - section: "global", - label: "Jump to tab", - keys: ["alt", "shift", "1-9"], - when: (context) => !context.isDesktop, - }, - }, - { - id: "workspace-navigate-relative-mod-left", - action: "workspace.navigate.relative", - matches: (event) => - isMod(event) && - !event.altKey && - !event.shiftKey && - (event.code === "BracketLeft" || event.key === "["), - payload: withRelativeDelta(-1), - when: (context) => context.isDesktop && !context.commandCenterOpen, - help: { - id: "workspace-prev", - section: "global", - label: "Previous workspace", - keys: ["mod", "["], - when: (context) => context.isDesktop, - }, - }, - { - id: "workspace-navigate-relative-mod-right", - action: "workspace.navigate.relative", - matches: (event) => - isMod(event) && - !event.altKey && - !event.shiftKey && - (event.code === "BracketRight" || event.key === "]"), - payload: withRelativeDelta(1), - when: (context) => context.isDesktop && !context.commandCenterOpen, - help: { - id: "workspace-next", - section: "global", - label: "Next workspace", - keys: ["mod", "]"], - when: (context) => context.isDesktop, - }, - }, - { - id: "workspace-navigate-relative-alt-left", - action: "workspace.navigate.relative", - matches: (event) => - !event.metaKey && - !event.ctrlKey && - event.altKey && - !event.shiftKey && - (event.code === "BracketLeft" || event.key === "["), - payload: withRelativeDelta(-1), - when: (context) => !context.isDesktop && !context.commandCenterOpen, - help: { - id: "workspace-prev", - section: "global", - label: "Previous workspace", - keys: ["alt", "["], - when: (context) => !context.isDesktop, - }, - }, - { - id: "workspace-navigate-relative-alt-right", - action: "workspace.navigate.relative", - matches: (event) => - !event.metaKey && - !event.ctrlKey && - event.altKey && - !event.shiftKey && - (event.code === "BracketRight" || event.key === "]"), - payload: withRelativeDelta(1), - when: (context) => !context.isDesktop && !context.commandCenterOpen, - help: { - id: "workspace-next", - section: "global", - label: "Next workspace", - keys: ["alt", "]"], - when: (context) => !context.isDesktop, - }, - }, - { - id: "workspace-tab-navigate-relative-alt-shift-left", - action: "workspace.tab.navigate.relative", - matches: (event) => - !event.metaKey && - !event.ctrlKey && - event.altKey && - event.shiftKey && - event.code === "BracketLeft", - payload: withRelativeDelta(-1), - when: (context) => !context.commandCenterOpen, - help: { - id: "workspace-tab-prev", - section: "global", - label: "Previous tab", - keys: ["alt", "shift", "["], - }, - }, - { - id: "workspace-tab-navigate-relative-alt-shift-right", - action: "workspace.tab.navigate.relative", - matches: (event) => - !event.metaKey && - !event.ctrlKey && - event.altKey && - event.shiftKey && - event.code === "BracketRight", - payload: withRelativeDelta(1), - when: (context) => !context.commandCenterOpen, - help: { - id: "workspace-tab-next", - section: "global", - label: "Next tab", - keys: ["alt", "shift", "]"], - }, - }, - { - id: "workspace-pane-split-right-cmd-backslash", - action: "workspace.pane.split.right", - matches: (event) => - isMacCommand(event) && - !event.altKey && - !event.shiftKey && - event.code === "Backslash", - when: (context) => - context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen, - help: { - id: "workspace-pane-split-right", - section: "global", - label: "Split pane right", - keys: ["mod", "\\"], - when: (context) => context.isMac, - }, - }, - { - id: "workspace-pane-split-down-cmd-shift-backslash", - action: "workspace.pane.split.down", - matches: (event) => - isMacCommand(event) && - !event.altKey && - event.shiftKey && - event.code === "Backslash", - when: (context) => - context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen, - help: { - id: "workspace-pane-split-down", - section: "global", - label: "Split pane down", - keys: ["mod", "shift", "\\"], - when: (context) => context.isMac, - }, - }, - { - id: "workspace-pane-focus-left-cmd-shift-left", - action: "workspace.pane.focus.left", - matches: (event) => - isMacCommand(event) && - !event.altKey && - event.shiftKey && - event.code === "ArrowLeft", - when: (context) => - context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen, - help: { - id: "workspace-pane-focus-left", - section: "global", - label: "Focus pane left", - keys: ["mod", "shift", "Left"], - when: (context) => context.isMac, - }, - }, - { - id: "workspace-pane-focus-right-cmd-shift-right", - action: "workspace.pane.focus.right", - matches: (event) => - isMacCommand(event) && - !event.altKey && - event.shiftKey && - event.code === "ArrowRight", - when: (context) => - context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen, - help: { - id: "workspace-pane-focus-right", - section: "global", - label: "Focus pane right", - keys: ["mod", "shift", "Right"], - when: (context) => context.isMac, - }, - }, - { - id: "workspace-pane-focus-up-cmd-shift-up", - action: "workspace.pane.focus.up", - matches: (event) => - isMacCommand(event) && - !event.altKey && - event.shiftKey && - event.code === "ArrowUp", - when: (context) => - context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen, - help: { - id: "workspace-pane-focus-up", - section: "global", - label: "Focus pane up", - keys: ["mod", "shift", "Up"], - when: (context) => context.isMac, - }, - }, - { - id: "workspace-pane-focus-down-cmd-shift-down", - action: "workspace.pane.focus.down", - matches: (event) => - isMacCommand(event) && - !event.altKey && - event.shiftKey && - event.code === "ArrowDown", - when: (context) => - context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen, - help: { - id: "workspace-pane-focus-down", - section: "global", - label: "Focus pane down", - keys: ["mod", "shift", "Down"], - when: (context) => context.isMac, - }, - }, - { - id: "workspace-pane-move-tab-left-cmd-shift-alt-left", - action: "workspace.pane.move-tab.left", - matches: (event) => - isMacCommand(event) && - event.altKey && - event.shiftKey && - event.code === "ArrowLeft", - when: (context) => - context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen, - help: { - id: "workspace-pane-move-tab-left", - section: "global", - label: "Move tab left", - keys: ["mod", "shift", "alt", "Left"], - when: (context) => context.isMac, - }, - }, - { - id: "workspace-pane-move-tab-right-cmd-shift-alt-right", - action: "workspace.pane.move-tab.right", - matches: (event) => - isMacCommand(event) && - event.altKey && - event.shiftKey && - event.code === "ArrowRight", - when: (context) => - context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen, - help: { - id: "workspace-pane-move-tab-right", - section: "global", - label: "Move tab right", - keys: ["mod", "shift", "alt", "Right"], - when: (context) => context.isMac, - }, - }, - { - id: "workspace-pane-move-tab-up-cmd-shift-alt-up", - action: "workspace.pane.move-tab.up", - matches: (event) => - isMacCommand(event) && - event.altKey && - event.shiftKey && - event.code === "ArrowUp", - when: (context) => - context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen, - help: { - id: "workspace-pane-move-tab-up", - section: "global", - label: "Move tab up", - keys: ["mod", "shift", "alt", "Up"], - when: (context) => context.isMac, - }, - }, - { - id: "workspace-pane-move-tab-down-cmd-shift-alt-down", - action: "workspace.pane.move-tab.down", - matches: (event) => - isMacCommand(event) && - event.altKey && - event.shiftKey && - event.code === "ArrowDown", - when: (context) => - context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen, - help: { - id: "workspace-pane-move-tab-down", - section: "global", - label: "Move tab down", - keys: ["mod", "shift", "alt", "Down"], - when: (context) => context.isMac, - }, - }, - { - id: "workspace-pane-close-cmd-shift-w", - action: "workspace.pane.close", - matches: (event) => - isMacCommand(event) && - !event.altKey && - event.shiftKey && - (event.code === "KeyW" || event.key.toLowerCase() === "w"), - when: (context) => - context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen, - help: { - id: "workspace-pane-close", - section: "global", - label: "Close pane", - keys: ["mod", "shift", "W"], - when: (context) => context.isMac, - }, - }, - { - id: "command-center-toggle", - action: "command-center.toggle", - matches: (event) => - isMod(event) && - !event.altKey && - !event.shiftKey && - (event.code === "KeyK" || event.key.toLowerCase() === "k"), - when: () => true, - help: { - id: "toggle-command-center", - section: "global", - label: "Toggle command center", - keys: ["mod", "K"], - }, - }, - { - id: "shortcuts-dialog-toggle-question-mark", - action: "shortcuts.dialog.toggle", - matches: isQuestionMarkShortcut, - when: (context) => context.focusScope === "other", - help: { - id: "show-shortcuts", - section: "global", - label: "Show keyboard shortcuts", - keys: ["?"], - note: "Available when focus is not in a text field or terminal.", - }, - }, - { - id: "sidebar-toggle-left-mac-cmd-b", - action: "sidebar.toggle.left", - matches: (event) => - event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey && - (event.code === "KeyB" || event.key.toLowerCase() === "b"), - when: (context) => context.isMac, - help: { - id: "toggle-left-sidebar", - section: "global", - label: "Toggle left sidebar", - keys: ["mod", "B"], - when: (context) => context.isMac, - }, - }, - { - id: "sidebar-toggle-left-mod-period", - action: "sidebar.toggle.left", - matches: (event) => - isMod(event) && - !event.altKey && - !event.shiftKey && - (event.code === "Period" || event.key === "."), - when: (context) => !context.commandCenterOpen, - help: { - id: "toggle-left-sidebar", - section: "global", - label: "Toggle left sidebar", - keys: ["mod", "."], - when: (context) => !context.isMac, - }, - }, - { - id: "sidebar-toggle-right-mod-e", - action: "sidebar.toggle.right", - matches: (event) => - isMod(event) && - !event.altKey && - !event.shiftKey && - (event.code === "KeyE" || event.key.toLowerCase() === "e"), - when: (context) => context.hasSelectedAgent && !context.commandCenterOpen, - help: { - id: "toggle-right-sidebar", - section: "global", - label: "Toggle right sidebar", - keys: ["mod", "E"], - }, - }, - { - id: "sidebar-toggle-right-ctrl-backquote", - action: "sidebar.toggle.right", - matches: (event) => - event.ctrlKey && - !event.metaKey && - !event.altKey && - !event.shiftKey && - (event.code === "Backquote" || event.key === "`"), - when: (context) => context.hasSelectedAgent && !context.commandCenterOpen, - }, - { - id: "message-input-voice-toggle", - action: "message-input.action", - matches: (event) => - isMod(event) && - event.shiftKey && - !event.altKey && - (event.code === "KeyD" || event.key.toLowerCase() === "d") && - !event.repeat, - payload: withMessageInputAction("voice-toggle"), - when: (context) => - !context.commandCenterOpen && context.focusScope !== "terminal", - help: { - id: "voice-toggle", - section: "agent-input", - label: "Toggle voice mode", - keys: ["mod", "shift", "D"], - }, - }, - { - id: "message-input-dictation-toggle", - action: "message-input.action", - matches: (event) => - isMod(event) && - !event.shiftKey && - !event.altKey && - (event.code === "KeyD" || event.key.toLowerCase() === "d"), - payload: withMessageInputAction("dictation-toggle"), - when: (context) => - !context.commandCenterOpen && context.focusScope !== "terminal", - help: { - id: "dictation-toggle", - section: "agent-input", - label: "Start/stop dictation", - keys: ["mod", "D"], - }, - }, - { - id: "message-input-dictation-cancel", - action: "message-input.action", - matches: (event) => event.key === "Escape", - payload: withMessageInputAction("dictation-cancel"), - when: (context) => - !context.commandCenterOpen && context.focusScope !== "terminal", - preventDefault: false, - stopPropagation: false, - help: { - id: "dictation-cancel", - section: "agent-input", - label: "Cancel dictation", - keys: ["Esc"], - }, - }, - { - id: "message-input-voice-mute-toggle", - action: "message-input.action", - matches: (event) => - !event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey && - (event.code === "Space" || event.key === " ") && - !event.repeat, - payload: withMessageInputAction("voice-mute-toggle"), - when: (context) => - !context.commandCenterOpen && context.focusScope === "other", - help: { - id: "voice-mute-toggle", - section: "agent-input", - label: "Mute/unmute voice mode", - keys: ["Space"], - }, - }, -]; +// --- Public API --- export function resolveKeyboardShortcut(input: { event: KeyboardEvent; @@ -747,16 +782,15 @@ export function resolveKeyboardShortcut(input: { }): KeyboardShortcutMatch | null { const { event, context } = input; for (const binding of SHORTCUT_BINDINGS) { - if (!binding.matches(event)) { + if (!matchesCombo(binding.combo, event)) { continue; } - if (!binding.when(context)) { + if (!matchesWhen(binding.when, context)) { continue; } - const payload = binding.payload?.(event) ?? null; return { action: binding.action, - payload, + payload: resolvePayload(binding.payload, event), preventDefault: binding.preventDefault ?? true, stopPropagation: binding.stopPropagation ?? true, }; @@ -768,7 +802,10 @@ export function buildKeyboardShortcutHelpSections( input: KeyboardShortcutPlatformContext ): KeyboardShortcutHelpSection[] { const seenRows = new Set(); - const rowsBySection = new Map([ + const rowsBySection = new Map< + KeyboardShortcutHelpSection["id"], + KeyboardShortcutHelpRow[] + >([ ["global", []], ["agent-input", []], ]); @@ -778,7 +815,7 @@ export function buildKeyboardShortcutHelpSections( if (!help) { continue; } - if (help.when && !help.when(input)) { + if (!helpMatchesPlatform(binding.when, input)) { continue; } const rowKey = `${help.section}:${help.id}`;