/* eslint-disable max-lines -- Why: the central shortcut registry, parser, * formatter, and conflict detector must stay in one shared module so main, * renderer, browser guests, and Settings cannot drift apart. */ import type { TuiAgent } from './types' import { ALL_TUI_AGENTS, TUI_AGENT_DISPLAY_NAMES } from './tui-agent-display-names' export type KeybindingScope = | 'global' | 'tabs' | 'terminal' | 'browser' | 'editor' | 'fileExplorer' | 'composer' | 'settings' export type KeybindingContext = 'app' | 'terminal' | 'browser' export type KeybindingPlatform = 'darwin' | 'linux' | 'win32' export type TerminalShortcutPolicy = 'orca-first' | 'terminal-first' export type KeybindingMatchOptions = { context?: KeybindingContext terminalShortcutPolicy?: TerminalShortcutPolicy } export type AgentTabActionId = `tab.newAgent.${TuiAgent}` export type KeybindingActionId = | 'worktree.quickOpen' | 'worktree.palette' | 'worktree.navigateUp' | 'worktree.navigateDown' | 'app.settings' | 'app.forceReload' | 'workspace.create' | 'workspace.rename' | 'workspace.delete' | 'workspace.openBoard' | 'workspace.selectByIndex' | 'voice.dictation' | 'view.tasks' | 'sidebar.left.toggle' | 'sidebar.right.toggle' | 'sidebar.explorer.toggle' | 'sidebar.search.toggle' | 'sidebar.sourceControl.toggle' | 'sidebar.checks.toggle' | 'sidebar.ports.toggle' | 'sidebar.sleepingWorkspaces.toggle' | 'sidebar.focusWorktreeList' | 'floatingTerminal.toggle' | 'floatingWorkspace.maximize' | 'floatingWorkspace.minimize' | 'zoom.in' | 'zoom.out' | 'zoom.reset' | 'worktree.history.back' | 'worktree.history.forward' | 'tab.newTerminal' | 'tab.newAgent' | AgentTabActionId | 'tab.newBrowser' | 'tab.newSimulator' | 'tab.newMarkdown' | 'tab.openMarkdown' | 'tab.close' | 'tab.closeAll' | 'tab.rename' | 'tab.reopenClosed' | 'tab.nextSameType' | 'tab.previousSameType' | 'tab.nextAllTypes' | 'tab.previousAllTypes' | 'tab.previousRecent' | 'tab.nextTerminal' | 'tab.previousTerminal' | 'tab.selectByIndex' | 'tab.openQuickCommandsMenu' | 'browser.find' | 'browser.back' | 'browser.forward' | 'browser.reload' | 'browser.hardReload' | 'browser.focusAddressBar' | 'browser.grabElement' | 'editor.find' | 'editor.replace' | 'editor.save' | 'editor.markdownPreview' | 'editor.copyContext' | 'editor.previousChange' | 'editor.nextChange' | 'fileExplorer.undo' | 'fileExplorer.redo' | 'fileExplorer.copyPath' | 'fileExplorer.copyRelativePath' | 'fileExplorer.delete' | 'settings.search' | 'terminal.copySelection' | 'terminal.paste' | 'terminal.search' | 'terminal.clear' | 'terminal.focusNextPane' | 'terminal.focusPreviousPane' | 'terminal.equalizePaneSizes' | 'terminal.expandPane' | 'terminal.setTitle' | 'terminal.clearPaneTitle' | 'terminal.closePane' | 'terminal.splitRight' | 'terminal.splitDown' export type KeybindingOverrides = Partial> export type KeybindingFileDiagnostic = { severity: 'warning' | 'error' message: string actionId?: string section?: string } export type KeybindingFileSnapshot = { path: string platform: KeybindingPlatform exists: boolean overrides: KeybindingOverrides commonOverrides: KeybindingOverrides platformOverrides: Partial> diagnostics: KeybindingFileDiagnostic[] } type PlatformBindings = { darwin: readonly string[] linux: readonly string[] win32: readonly string[] } export type KeybindingDefinition = { id: KeybindingActionId title: string group: string scope: KeybindingScope searchKeywords: readonly string[] defaultBindings: PlatformBindings allowInTerminal?: boolean allowBareKeybindings?: boolean conflictGroup?: string } export type ModifierToken = 'Mod' | 'Cmd' | 'Ctrl' | 'Alt' | 'Shift' export type PhysicalModifierToken = Exclude export type KeybindingInput = { key?: string code?: string alt?: boolean meta?: boolean control?: boolean shift?: boolean altKey?: boolean metaKey?: boolean ctrlKey?: boolean shiftKey?: boolean // Set only by the double-tap detector; always a physical token (never 'Mod'). doubleTapModifier?: PhysicalModifierToken } type ParsedKeybinding = { mod: boolean meta: boolean control: boolean alt: boolean shift: boolean key: string doubleTapModifier?: ModifierToken } type NormalizeKeybindingOptions = { allowBareKeybindings?: boolean } export type KeybindingValidationResult = { ok: true; value: string } | { ok: false; error: string } export type KeybindingConflict = { binding: string actionIds: KeybindingActionId[] } export type FindKeybindingConflictOptions = { ignoredActionIds?: Iterable } export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [ { id: 'worktree.quickOpen', title: 'Go to File', group: 'Global', scope: 'global', searchKeywords: ['shortcut', 'global', 'file', 'quick open'], defaultBindings: platformBindings(['Mod+P']) }, { id: 'app.settings', title: 'Open Settings', group: 'Global', scope: 'global', searchKeywords: ['shortcut', 'settings', 'preferences'], defaultBindings: platformBindings(['Mod+Comma']), conflictGroup: 'menu' }, { id: 'app.forceReload', title: 'Force Reload', group: 'Global', scope: 'global', searchKeywords: ['shortcut', 'reload', 'refresh', 'force'], defaultBindings: platformBindings(['Mod+Shift+R']), conflictGroup: 'menu' }, { id: 'worktree.palette', title: 'Switch worktree', group: 'Global', scope: 'global', searchKeywords: ['shortcut', 'global', 'worktree', 'switch', 'jump'], defaultBindings: { darwin: ['Mod+J'], linux: ['Mod+Shift+J'], win32: ['Mod+Shift+J'] } }, { id: 'worktree.navigateUp', title: 'Previous worktree', group: 'Global', scope: 'global', searchKeywords: ['shortcut', 'global', 'worktree', 'previous', 'up'], defaultBindings: platformBindings(['Mod+Shift+ArrowUp']) }, { id: 'worktree.navigateDown', title: 'Next worktree', group: 'Global', scope: 'global', searchKeywords: ['shortcut', 'global', 'worktree', 'next', 'down'], defaultBindings: platformBindings(['Mod+Shift+ArrowDown']) }, { id: 'workspace.create', title: 'Create worktree', group: 'Global', scope: 'global', searchKeywords: ['shortcut', 'global', 'worktree', 'create', 'new workspace'], defaultBindings: platformBindings(['Mod+N', 'Mod+Shift+N']) }, { id: 'workspace.rename', title: 'Rename worktree', group: 'Global', scope: 'global', conflictGroup: 'workspace-shell', searchKeywords: ['shortcut', 'global', 'worktree', 'rename', 'workspace', 'title'], // Why: macOS only. On Windows/Linux Ctrl+Alt+R has no safe default, and the // chord families there (Ctrl+R reverse-search, Ctrl+Shift+R reload) are // taken, so users bind it explicitly in Settings. defaultBindings: { darwin: ['Mod+Alt+R'], linux: [], win32: [] } }, { id: 'workspace.delete', title: 'Delete Workspace', group: 'Global', scope: 'global', searchKeywords: [ 'shortcut', 'global', 'workspace', 'current workspace', 'worktree', 'delete', 'remove', 'trash' ], // Why: ship the command now without claiming a default chord; user // overrides still win automatically when a future default is assigned. defaultBindings: platformBindings([]), allowInTerminal: true }, { id: 'workspace.openBoard', title: 'Open Workspace Board', group: 'Global', scope: 'global', searchKeywords: ['shortcut', 'global', 'workspace', 'board', 'kanban', 'worktree'], // Why: make the command configurable without taking a global chord from // terminal/browser/editor users by default. defaultBindings: platformBindings([]), allowInTerminal: true }, { id: 'workspace.selectByIndex', title: 'Select Workspace 1–9', group: 'Global', scope: 'global', searchKeywords: [ 'shortcut', 'global', 'workspace', 'worktree', 'select', 'switch', 'number', 'digit', '1-9', 'index' ], // Why: one remappable row for the whole 1-9 range. The stored chord is a // representative — its digit normalizes to 1, but the modifier set is what // matters and any of 1-9 fires it. mac Cmd+1-9, Windows/Linux Ctrl+1-9 → Mod+1. defaultBindings: platformBindings(['Mod+1']) }, { id: 'voice.dictation', title: 'Dictation', group: 'Global', scope: 'global', searchKeywords: ['shortcut', 'dictation', 'voice', 'speech', 'microphone'], defaultBindings: platformBindings(['Mod+E']) }, { id: 'view.tasks', title: 'Open Tasks', group: 'Global', scope: 'global', searchKeywords: ['shortcut', 'tasks', 'github issues', 'linear'], defaultBindings: platformBindings([]) }, { id: 'sidebar.left.toggle', title: 'Toggle Sidebar', group: 'Global', scope: 'global', searchKeywords: ['shortcut', 'sidebar', 'left'], defaultBindings: platformBindings(['Mod+B']) }, { id: 'sidebar.right.toggle', title: 'Toggle Right Sidebar', group: 'Global', scope: 'global', searchKeywords: ['shortcut', 'sidebar', 'right'], defaultBindings: platformBindings(['Mod+L']) }, { id: 'sidebar.explorer.toggle', title: 'Show Explorer', group: 'Global', scope: 'global', searchKeywords: ['shortcut', 'sidebar', 'explorer', 'files'], defaultBindings: platformBindings(['Mod+Shift+E']) }, { id: 'sidebar.search.toggle', title: 'Show Search', group: 'Global', scope: 'global', searchKeywords: ['shortcut', 'sidebar', 'search'], defaultBindings: platformBindings(['Mod+Shift+F']) }, { id: 'sidebar.sourceControl.toggle', title: 'Show Source Control', group: 'Global', scope: 'global', searchKeywords: ['shortcut', 'sidebar', 'source control', 'git'], defaultBindings: platformBindings(['Mod+Shift+G']) }, { id: 'sidebar.checks.toggle', title: 'Show Checks', group: 'Global', scope: 'global', searchKeywords: ['shortcut', 'sidebar', 'checks', 'ci'], defaultBindings: platformBindings([]) }, { id: 'sidebar.ports.toggle', title: 'Show Ports', group: 'Global', scope: 'global', searchKeywords: ['shortcut', 'sidebar', 'ports'], defaultBindings: { darwin: ['Mod+Shift+I'], linux: [], win32: [] } }, { id: 'sidebar.sleepingWorkspaces.toggle', title: 'Toggle Sleeping Workspaces', group: 'Global', scope: 'global', searchKeywords: [ 'shortcut', 'sidebar', 'sleeping', 'asleep', 'workspaces', 'worktree', 'filter', 'show', 'hide' ], // Why: ship unbound — issue #5209 asks to "assign a shortcut", so we avoid // claiming a cross-platform chord and let users bind it in Settings. defaultBindings: platformBindings([]) }, { id: 'sidebar.focusWorktreeList', title: 'Focus worktree list', group: 'Global', scope: 'global', searchKeywords: ['shortcut', 'sidebar', 'worktree', 'focus'], defaultBindings: platformBindings(['Mod+0']) }, { id: 'floatingTerminal.toggle', title: 'Toggle Floating Terminal', group: 'Global', scope: 'global', searchKeywords: ['shortcut', 'floating terminal', 'terminal'], defaultBindings: platformBindings(['Mod+Alt+A']), allowInTerminal: true }, { id: 'floatingWorkspace.maximize', title: 'Maximize Floating Workspace Panel', group: 'Global', scope: 'global', searchKeywords: [ 'shortcut', 'floating', 'workspace', 'panel', 'floating workspace', 'workspace panel', 'maximize', 'expand' ], // Why: pairs with the floatingTerminal.toggle chord (Cmd+Opt+A) so // maximize/restore lives on the same key anchor and stays one-handed, // instead of the two-hand reach to Cmd+Opt+ArrowUp. macOS-only default; // Linux/Windows stay unbound for users to assign. defaultBindings: { darwin: ['Mod+Alt+Shift+A'], linux: [], win32: [] }, allowInTerminal: true }, { id: 'floatingWorkspace.minimize', title: 'Minimize Floating Workspace Panel', group: 'Global', scope: 'global', searchKeywords: [ 'shortcut', 'floating', 'workspace', 'panel', 'floating workspace', 'workspace panel', 'minimize', 'hide' ], // Why: intentionally unbound on every platform. floatingTerminal.toggle // already owns the default show/hide chord; this action exists only so // users can bind an explicit "hide the focused panel" shortcut in Settings. defaultBindings: { darwin: [], linux: [], win32: [] }, allowInTerminal: true }, { id: 'zoom.in', title: 'Zoom In', group: 'Global', scope: 'global', searchKeywords: ['shortcut', 'zoom', 'in', 'scale'], defaultBindings: platformBindings(['Mod+Equal', 'Mod+Shift+Plus', 'Mod+NumpadAdd']) }, { id: 'zoom.out', title: 'Zoom Out', group: 'Global', scope: 'global', searchKeywords: ['shortcut', 'zoom', 'out', 'scale'], defaultBindings: platformBindings(['Mod+Minus', 'Mod+NumpadSubtract']) }, { id: 'zoom.reset', title: 'Reset Size', group: 'Global', scope: 'global', searchKeywords: ['shortcut', 'zoom', 'reset', 'size', 'actual'], defaultBindings: platformBindings(['Mod+0']) }, { id: 'worktree.history.back', title: 'Worktree History Back', group: 'Global', scope: 'global', searchKeywords: ['shortcut', 'worktree', 'history', 'back'], defaultBindings: platformBindings(['Mod+Alt+ArrowLeft']), allowInTerminal: true }, { id: 'worktree.history.forward', title: 'Worktree History Forward', group: 'Global', scope: 'global', searchKeywords: ['shortcut', 'worktree', 'history', 'forward'], defaultBindings: platformBindings(['Mod+Alt+ArrowRight']), allowInTerminal: true }, { id: 'tab.newTerminal', title: 'New terminal tab', group: 'Tabs', scope: 'tabs', searchKeywords: ['shortcut', 'tab', 'terminal', 'new'], defaultBindings: platformBindings(['Mod+T']) }, { id: 'tab.newAgent', title: 'New agent tab (default agent)', group: 'Tabs', scope: 'tabs', searchKeywords: ['shortcut', 'tab', 'agent', 'new', 'default', 'launch'], // Why: macOS only. On Windows Ctrl+Alt is AltGr on many layouts, and on // Linux Ctrl+Alt+T is the desktop-level "open terminal" shortcut, so // there is no safe default chord there; users bind it in Settings. defaultBindings: { darwin: ['Mod+Alt+T'], linux: [], win32: [] } }, { id: 'tab.newBrowser', title: 'New browser tab', group: 'Tabs', scope: 'tabs', searchKeywords: ['shortcut', 'tab', 'browser', 'new'], defaultBindings: platformBindings(['Mod+Shift+B']) }, { id: 'tab.newSimulator', title: 'New mobile emulator tab', group: 'Tabs', scope: 'tabs', searchKeywords: ['shortcut', 'tab', 'simulator', 'emulator', 'mobile', 'ios', 'new'], defaultBindings: { darwin: ['Mod+Shift+E'], linux: [], win32: [] } }, { id: 'tab.newMarkdown', title: 'New markdown tab', group: 'Tabs', scope: 'tabs', searchKeywords: ['shortcut', 'tab', 'markdown', 'file', 'new'], defaultBindings: platformBindings(['Mod+Shift+M']) }, { id: 'tab.openMarkdown', title: 'Open markdown tab', group: 'Tabs', scope: 'tabs', searchKeywords: ['shortcut', 'tab', 'markdown', 'file', 'open'], defaultBindings: platformBindings(['Mod+Shift+O']) }, { id: 'tab.close', title: 'Close active tab', group: 'Tabs', scope: 'tabs', searchKeywords: ['shortcut', 'close', 'tab', 'pane'], defaultBindings: platformBindings(['Mod+W']) }, { id: 'tab.closeAll', title: 'Close all editor tabs', group: 'Tabs', scope: 'tabs', searchKeywords: ['shortcut', 'close', 'all', 'tabs', 'files', 'editors'], defaultBindings: platformBindings(['Mod+Alt+W']) }, { id: 'tab.rename', title: 'Rename active tab', group: 'Tabs', scope: 'tabs', conflictGroup: 'workspace-shell', searchKeywords: ['shortcut', 'tab', 'rename', 'title', 'label'], // Why: macOS only. Cmd+R is free in the app/terminal focus zone (the // browser pane owns its own Cmd+R reload). On Windows/Linux Ctrl+R is the // shell reverse-search, so it is left unbound for explicit user binding. defaultBindings: { darwin: ['Mod+R'], linux: [], win32: [] } }, { id: 'tab.reopenClosed', title: 'Reopen closed tab', group: 'Tabs', scope: 'tabs', searchKeywords: ['shortcut', 'tab', 'reopen', 'restore', 'closed'], defaultBindings: platformBindings(['Mod+Shift+T']) }, { id: 'tab.nextSameType', title: 'Next tab (same type)', group: 'Tab Navigation', scope: 'tabs', searchKeywords: ['shortcut', 'tab', 'next', 'switch', 'cycle'], defaultBindings: platformBindings(['Mod+Shift+BracketRight']) }, { id: 'tab.previousSameType', title: 'Previous tab (same type)', group: 'Tab Navigation', scope: 'tabs', searchKeywords: ['shortcut', 'tab', 'previous', 'switch', 'cycle'], defaultBindings: platformBindings(['Mod+Shift+BracketLeft']) }, { id: 'tab.nextAllTypes', title: 'Next tab (all types)', group: 'Tab Navigation', scope: 'tabs', searchKeywords: ['shortcut', 'tab', 'next', 'switch', 'cycle', 'all', 'any'], defaultBindings: platformBindings(['Mod+Alt+BracketRight']) }, { id: 'tab.previousAllTypes', title: 'Previous tab (all types)', group: 'Tab Navigation', scope: 'tabs', searchKeywords: ['shortcut', 'tab', 'previous', 'switch', 'cycle', 'all', 'any'], defaultBindings: platformBindings(['Mod+Alt+BracketLeft']) }, { id: 'tab.previousRecent', title: 'Previous recent tab', group: 'Tab Navigation', scope: 'tabs', searchKeywords: ['shortcut', 'tab', 'recent', 'mru', 'switch', 'last used'], defaultBindings: platformBindings(['Ctrl+Tab']), allowInTerminal: true }, { id: 'tab.nextTerminal', title: 'Next terminal tab', group: 'Tab Navigation', scope: 'tabs', searchKeywords: ['shortcut', 'tab', 'terminal', 'next', 'switch'], defaultBindings: platformBindings(['Ctrl+PageDown']), allowInTerminal: true }, { id: 'tab.previousTerminal', title: 'Previous terminal tab', group: 'Tab Navigation', scope: 'tabs', searchKeywords: ['shortcut', 'tab', 'terminal', 'previous', 'switch'], defaultBindings: platformBindings(['Ctrl+PageUp']), allowInTerminal: true }, { id: 'tab.selectByIndex', title: 'Select Tab 1–9', group: 'Tab Navigation', scope: 'tabs', // Why: deliberately no shared conflictGroup with workspace.selectByIndex. // They live in different scopes, so swapping their modifiers (the headline // use case) is never blocked as a false conflict; runtime stays deterministic // because resolveWindowShortcutAction checks the workspace range first. searchKeywords: ['shortcut', 'tab', 'select', 'switch', 'number', 'digit', '1-9', 'index'], // Why: representative chord for the 1-9 range (see workspace.selectByIndex). // mac Ctrl+1-9 (Cmd+1-9 is the workspace jump); Windows/Linux Alt+1-9 // (Ctrl+1-9 is the workspace jump), so each platform gets a free chord. defaultBindings: { darwin: ['Ctrl+1'], linux: ['Alt+1'], win32: ['Alt+1'] } }, { id: 'tab.openQuickCommandsMenu', title: 'Toggle Quick Commands menu', group: 'Quick Commands', scope: 'tabs', // Why: this tab-scoped action is also routed through the main window // shortcut allowlist, so Settings must warn when it shadows global chords. conflictGroup: 'global', searchKeywords: ['shortcut', 'quick', 'command', 'menu', 'tab', 'group', 'toggle'], defaultBindings: platformBindings([]) }, { id: 'browser.find', title: 'Find in Browser', group: 'Browser', scope: 'browser', searchKeywords: ['shortcut', 'browser', 'find', 'search'], defaultBindings: platformBindings(['Mod+F']) }, { id: 'browser.back', title: 'Go Back in Browser', group: 'Browser', scope: 'browser', searchKeywords: ['shortcut', 'browser', 'history', 'back', 'previous'], defaultBindings: { darwin: ['Mod+BracketLeft'], linux: ['Alt+ArrowLeft'], win32: ['Alt+ArrowLeft'] } }, { id: 'browser.forward', title: 'Go Forward in Browser', group: 'Browser', scope: 'browser', searchKeywords: ['shortcut', 'browser', 'history', 'forward', 'next'], defaultBindings: { darwin: ['Mod+BracketRight'], linux: ['Alt+ArrowRight'], win32: ['Alt+ArrowRight'] } }, { id: 'browser.reload', title: 'Reload Browser Page', group: 'Browser', scope: 'browser', searchKeywords: ['shortcut', 'browser', 'reload', 'refresh'], defaultBindings: platformBindings(['Mod+R']) }, { id: 'browser.hardReload', title: 'Hard Reload Browser Page', group: 'Browser', scope: 'browser', searchKeywords: ['shortcut', 'browser', 'reload', 'refresh', 'cache'], defaultBindings: platformBindings(['Mod+Shift+R']) }, { id: 'browser.focusAddressBar', title: 'Focus Browser Address Bar', group: 'Browser', scope: 'browser', searchKeywords: ['shortcut', 'browser', 'address', 'url', 'location'], defaultBindings: platformBindings(['Mod+L']) }, { id: 'browser.grabElement', title: 'Grab Page Element', group: 'Browser', scope: 'browser', searchKeywords: ['shortcut', 'browser', 'grab', 'copy', 'element'], defaultBindings: platformBindings(['Mod+C']) }, { id: 'editor.find', title: 'Find in editor', group: 'Editors', scope: 'editor', searchKeywords: ['shortcut', 'editor', 'find', 'search'], defaultBindings: platformBindings(['Mod+F']) }, { id: 'editor.replace', title: 'Replace in editor', group: 'Editors', scope: 'editor', searchKeywords: ['shortcut', 'editor', 'replace', 'find', 'search'], // Why: match the source editor's native replace shortcut — Cmd+Alt+F on // macOS, Ctrl+H on Linux/Windows. defaultBindings: { darwin: ['Mod+Alt+F'], linux: ['Mod+H'], win32: ['Mod+H'] } }, { id: 'editor.save', title: 'Save File', group: 'Editors', scope: 'editor', searchKeywords: ['shortcut', 'editor', 'save'], defaultBindings: platformBindings(['Mod+S']) }, { id: 'editor.markdownPreview', title: 'Show Markdown Preview', group: 'Editors', scope: 'editor', searchKeywords: ['shortcut', 'editor', 'markdown', 'preview'], defaultBindings: platformBindings(['Mod+Shift+V']) }, { id: 'editor.copyContext', title: 'Copy Context', group: 'Editors', scope: 'editor', searchKeywords: ['shortcut', 'editor', 'copy', 'context'], defaultBindings: platformBindings(['Mod+Alt+C']) }, // Why: F7 / Shift+F7 gives VS Code / JetBrains diff-change navigation. Function // keys are safe as bare / Shift bindings, so both opt into allowBareKeybindings. { id: 'editor.previousChange', title: 'Go to Previous Change', group: 'Editors', scope: 'editor', searchKeywords: ['shortcut', 'editor', 'diff', 'change', 'hunk', 'previous'], defaultBindings: platformBindings(['Shift+F7']), allowBareKeybindings: true }, { id: 'editor.nextChange', title: 'Go to Next Change', group: 'Editors', scope: 'editor', searchKeywords: ['shortcut', 'editor', 'diff', 'change', 'hunk', 'next'], defaultBindings: platformBindings(['F7']), allowBareKeybindings: true }, { id: 'fileExplorer.undo', title: 'Undo file operation', group: 'File Explorer', scope: 'fileExplorer', searchKeywords: ['shortcut', 'file explorer', 'undo'], defaultBindings: platformBindings(['Mod+Z']) }, { id: 'fileExplorer.redo', title: 'Redo file operation', group: 'File Explorer', scope: 'fileExplorer', searchKeywords: ['shortcut', 'file explorer', 'redo'], defaultBindings: { darwin: ['Mod+Shift+Z'], linux: ['Mod+Shift+Z', 'Ctrl+Y'], win32: ['Mod+Shift+Z', 'Ctrl+Y'] } }, { id: 'fileExplorer.copyPath', title: 'Copy file path', group: 'File Explorer', scope: 'fileExplorer', searchKeywords: ['shortcut', 'file explorer', 'copy', 'path'], defaultBindings: { darwin: ['Mod+Alt+C'], linux: ['Alt+Shift+C'], win32: ['Alt+Shift+C'] } }, { id: 'fileExplorer.copyRelativePath', title: 'Copy relative file path', group: 'File Explorer', scope: 'fileExplorer', searchKeywords: ['shortcut', 'file explorer', 'copy', 'relative', 'path'], defaultBindings: platformBindings(['Mod+Alt+Shift+C']) }, { id: 'fileExplorer.delete', title: 'Delete file', group: 'File Explorer', scope: 'fileExplorer', searchKeywords: ['shortcut', 'file explorer', 'delete', 'remove', 'trash'], defaultBindings: { darwin: ['Mod+Backspace', 'Delete'], linux: ['Delete'], win32: ['Delete'] }, allowBareKeybindings: true }, { id: 'settings.search', title: 'Search Settings', group: 'Settings', scope: 'settings', searchKeywords: ['shortcut', 'settings', 'search', 'find'], defaultBindings: platformBindings(['Mod+F']) }, { id: 'terminal.copySelection', title: 'Copy terminal selection', group: 'Terminal Panes', scope: 'terminal', searchKeywords: ['shortcut', 'terminal', 'copy', 'selection'], defaultBindings: platformBindings(['Mod+Shift+C']) }, { id: 'terminal.paste', title: 'Paste into terminal', group: 'Terminal Panes', scope: 'terminal', searchKeywords: ['shortcut', 'terminal', 'paste', 'clipboard'], defaultBindings: { darwin: ['Mod+V'], linux: ['Ctrl+V', 'Ctrl+Shift+V', 'Shift+Insert'], win32: ['Ctrl+V', 'Ctrl+Shift+V', 'Shift+Insert'] } }, { id: 'terminal.search', title: 'Search active pane', group: 'Terminal Panes', scope: 'terminal', searchKeywords: ['shortcut', 'terminal', 'search', 'find'], defaultBindings: platformBindings(['Mod+F']) }, { id: 'terminal.clear', title: 'Clear active pane', group: 'Terminal Panes', scope: 'terminal', searchKeywords: ['shortcut', 'pane', 'clear'], defaultBindings: platformBindings(['Mod+K']) }, { id: 'terminal.focusNextPane', title: 'Focus next pane', group: 'Terminal Panes', scope: 'terminal', searchKeywords: ['shortcut', 'pane', 'focus', 'next'], defaultBindings: platformBindings(['Mod+BracketRight']) }, { id: 'terminal.focusPreviousPane', title: 'Focus previous pane', group: 'Terminal Panes', scope: 'terminal', searchKeywords: ['shortcut', 'pane', 'focus', 'previous'], defaultBindings: platformBindings(['Mod+BracketLeft']) }, { id: 'terminal.equalizePaneSizes', title: 'Equalize pane sizes', group: 'Terminal Panes', scope: 'terminal', searchKeywords: ['shortcut', 'pane', 'split', 'equalize', 'resize', 'balance', 'size'], defaultBindings: platformBindings([]) }, { id: 'terminal.expandPane', title: 'Expand / collapse pane', group: 'Terminal Panes', scope: 'terminal', searchKeywords: ['shortcut', 'pane', 'expand', 'collapse'], defaultBindings: platformBindings(['Mod+Shift+Enter']) }, { id: 'terminal.setTitle', title: 'Set Title…', group: 'Terminal Panes', scope: 'terminal', searchKeywords: ['shortcut', 'terminal', 'pane', 'set title', 'title', 'rename'], defaultBindings: platformBindings([]) }, { id: 'terminal.clearPaneTitle', title: 'Clear Pane Title', group: 'Terminal Panes', scope: 'terminal', searchKeywords: ['shortcut', 'terminal', 'pane', 'clear title', 'remove title', 'title'], defaultBindings: platformBindings([]) }, { id: 'terminal.closePane', title: 'Close active pane', group: 'Terminal Panes', scope: 'terminal', searchKeywords: ['shortcut', 'pane', 'close'], defaultBindings: platformBindings(['Mod+W']) }, { id: 'terminal.splitRight', title: 'Split terminal right', group: 'Terminal Panes', scope: 'terminal', searchKeywords: ['shortcut', 'pane', 'split', 'right'], defaultBindings: { darwin: ['Mod+D'], linux: ['Mod+Shift+D'], win32: ['Mod+Shift+D'] } }, { id: 'terminal.splitDown', title: 'Split terminal down', group: 'Terminal Panes', scope: 'terminal', searchKeywords: ['shortcut', 'pane', 'split', 'down'], defaultBindings: { darwin: ['Mod+Shift+D'], linux: ['Alt+Shift+D'], win32: ['Alt+Shift+D'] } }, ...buildAgentTabKeybindingDefinitions() ] export function agentTabActionId(agent: TuiAgent): AgentTabActionId { return `tab.newAgent.${agent}` } // Why: one bindable action per agent so users can put each enabled agent on // its own chord. All ship unassigned — `tab.newAgent` covers the default // agent — and Settings → Shortcuts hides rows for disabled agents. function buildAgentTabKeybindingDefinitions(): KeybindingDefinition[] { return ALL_TUI_AGENTS.map((agent) => ({ id: agentTabActionId(agent), title: `New ${TUI_AGENT_DISPLAY_NAMES[agent]} tab`, group: 'Agents', scope: 'tabs', searchKeywords: [ 'shortcut', 'tab', 'agent', 'new', 'launch', agent, TUI_AGENT_DISPLAY_NAMES[agent].toLowerCase() ], defaultBindings: platformBindings([]) })) } const DEFINITIONS_BY_ID = new Map( KEYBINDING_DEFINITIONS.map((definition) => [definition.id, definition]) ) const DEFINITION_IDS = new Set( KEYBINDING_DEFINITIONS.map((definition) => definition.id) ) // Why: "Select Tab 1-9" / "Select Workspace 1-9" are single remappable rows // whose chord is a representative — the digit is canonicalized to 1, but the // binding fires for any of 1-9. These ids opt into that range behavior. export const DIGIT_INDEX_ACTION_IDS: readonly KeybindingActionId[] = [ 'tab.selectByIndex', 'workspace.selectByIndex' ] const DIGIT_INDEX_ACTION_ID_SET = new Set(DIGIT_INDEX_ACTION_IDS) // The representative key for a digit-index chord is a single 1-9 number key. const DIGIT_INDEX_KEY_PATTERN = /^[1-9]$/ export function isDigitIndexActionId(actionId: KeybindingActionId): boolean { return DIGIT_INDEX_ACTION_ID_SET.has(actionId) } function platformBindings(bindings: readonly string[]): PlatformBindings { return { darwin: bindings, linux: bindings, win32: bindings } } export function getKeybindingPlatform(platform: NodeJS.Platform): KeybindingPlatform { return platform === 'darwin' ? 'darwin' : platform === 'win32' ? 'win32' : 'linux' } export function isKeybindingActionId(value: string): value is KeybindingActionId { return DEFINITION_IDS.has(value as KeybindingActionId) } function hasModifier( input: KeybindingInput, modifier: 'alt' | 'meta' | 'control' | 'shift' ): boolean { if (modifier === 'alt') { return Boolean(input.alt ?? input.altKey) } if (modifier === 'meta') { return Boolean(input.meta ?? input.metaKey) } if (modifier === 'control') { return Boolean(input.control ?? input.ctrlKey) } return Boolean(input.shift ?? input.shiftKey) } function isFunctionKeyToken(key: string): boolean { return /^F([1-9]|1[0-9]|2[0-4])$/.test(key) } function normalizeKeyToken(token: string): string | null { if (token === ' ') { return 'Space' } const trimmed = token.trim() if (!trimmed) { return null } const upper = trimmed.toUpperCase() if (upper.length === 1 && upper >= 'A' && upper <= 'Z') { return upper } if (upper.length === 1 && upper >= '0' && upper <= '9') { return upper } // Function keys F1–F24 (event.key/event.code report them verbatim, e.g. F7). if (isFunctionKeyToken(upper)) { return upper } const simple: Record = { '[': 'BracketLeft', ']': 'BracketRight', '{': 'BracketLeft', '}': 'BracketRight', '-': 'Minus', _: 'Underscore', '=': 'Equal', '+': 'Plus', ',': 'Comma', '.': 'Period', '/': 'Slash', '\\': 'Backslash', ';': 'Semicolon', "'": 'Quote', '`': 'Backquote', RETURN: 'Enter', ESC: 'Escape', SPACEBAR: 'Space', PGUP: 'PageUp', PGDN: 'PageDown', PLUS: 'Plus', MINUS: 'Minus', EQUAL: 'Equal', UNDERSCORE: 'Underscore', ARROWLEFT: 'ArrowLeft', LEFT: 'ArrowLeft', ARROWRIGHT: 'ArrowRight', RIGHT: 'ArrowRight', ARROWUP: 'ArrowUp', UP: 'ArrowUp', ARROWDOWN: 'ArrowDown', DOWN: 'ArrowDown', PAGEUP: 'PageUp', PAGEDOWN: 'PageDown', BACKSPACE: 'Backspace', DELETE: 'Delete', DEL: 'Delete', INSERT: 'Insert', INS: 'Insert', ENTER: 'Enter', TAB: 'Tab', ESCAPE: 'Escape', SPACE: 'Space', BRACKETLEFT: 'BracketLeft', BRACKETRIGHT: 'BracketRight', NUMPADADD: 'NumpadAdd', NUMPADSUBTRACT: 'NumpadSubtract', ADD: 'NumpadAdd', SUBTRACT: 'NumpadSubtract', COMMA: 'Comma', PERIOD: 'Period', SLASH: 'Slash', BACKSLASH: 'Backslash', SEMICOLON: 'Semicolon', QUOTE: 'Quote', BACKQUOTE: 'Backquote' } return simple[upper] ?? null } function parseModifierToken(rawPart: string): ModifierToken | null { const part = rawPart.toLowerCase() if (part === 'mod' || part === 'cmdorctrl' || part === 'commandorcontrol') { return 'Mod' } if (part === 'cmd' || part === 'command' || part === 'meta' || rawPart === '⌘') { return 'Cmd' } if (part === 'ctrl' || part === 'control' || rawPart === '⌃') { return 'Ctrl' } if (part === 'alt' || part === 'option' || part === 'opt' || rawPart === '⌥') { return 'Alt' } if (part === 'shift' || rawPart === '⇧') { return 'Shift' } return null } function applyModifierToken(parsed: ParsedKeybinding, modifier: ModifierToken): void { if (modifier === 'Mod') { parsed.mod = true } else if (modifier === 'Cmd') { parsed.meta = true } else if (modifier === 'Ctrl') { parsed.control = true } else if (modifier === 'Alt') { parsed.alt = true } else { parsed.shift = true } } function emptyParsedKeybinding(): ParsedKeybinding { return { mod: false, meta: false, control: false, alt: false, shift: false, key: '' } } // Why: a double-tap is a bare modifier with no key, so it cannot reuse the // normal "one key required" parse path; validation of conflicting/extra // modifiers is deferred to normalizeKeybindingWithOptions for shared errors. function parseDoubleTapKeybinding(rawParts: string[]): ParsedKeybinding | null { const modifiers: ModifierToken[] = [] let sawDoubleTap = false for (const rawPart of rawParts) { if (rawPart.toLowerCase() === 'doubletap') { if (sawDoubleTap) { return null } sawDoubleTap = true continue } const modifier = parseModifierToken(rawPart) if (!modifier) { return null } modifiers.push(modifier) } if (modifiers.length === 0) { return null } const parsed = emptyParsedKeybinding() for (const modifier of modifiers) { applyModifierToken(parsed, modifier) } // Mod combined with a platform-specific modifier: keep both flags so normalize // emits the shared "Mod or platform-specific, not both" error. if (parsed.mod && (parsed.meta || parsed.control)) { parsed.doubleTapModifier = 'Mod' return parsed } if (modifiers.length > 1) { return null } parsed.doubleTapModifier = modifiers[0] return parsed } function parseKeybinding(binding: string): ParsedKeybinding | null { const rawParts = binding .split('+') .map((part) => part.trim()) .filter(Boolean) if (rawParts.length === 0) { return null } if (rawParts.some((part) => part.toLowerCase() === 'doubletap')) { return parseDoubleTapKeybinding(rawParts) } const parsed = emptyParsedKeybinding() for (const rawPart of rawParts) { const modifier = parseModifierToken(rawPart) if (modifier) { applyModifierToken(parsed, modifier) continue } if (parsed.key) { return null } const key = normalizeKeyToken(rawPart) if (!key) { return null } parsed.key = key } return parsed.key ? parsed : null } function canonicalizeParsedKeybinding(parsed: ParsedKeybinding): string { if (parsed.doubleTapModifier) { return `DoubleTap+${parsed.doubleTapModifier}` } const parts: string[] = [] if (parsed.mod) { parts.push('Mod') } if (parsed.meta) { parts.push('Cmd') } if (parsed.control) { parts.push('Ctrl') } if (parsed.alt) { parts.push('Alt') } if (parsed.shift) { parts.push('Shift') } parts.push(parsed.key) return parts.join('+') } function isSafeBareKey(parsed: ParsedKeybinding): boolean { if (parsed.mod || parsed.meta || parsed.control || parsed.alt) { return false } // Function keys produce no text, so they're safe standalone or with Shift // (e.g. F7 / Shift+F7 for diff-change navigation). Shift is only permitted // alongside a function key — Shift+letter stays unsafe. if (parsed.shift) { return isFunctionKeyToken(parsed.key) } return ( isFunctionKeyToken(parsed.key) || [ 'Backspace', 'Delete', 'Enter', 'Escape', 'Tab', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'PageUp', 'PageDown' ].includes(parsed.key) ) } function normalizeKeybindingWithOptions( binding: string, options: NormalizeKeybindingOptions = {} ): KeybindingValidationResult { const parsed = parseKeybinding(binding) if (!parsed) { return { ok: false, error: 'Use a shortcut like Ctrl+Shift+P or Cmd+K.' } } if (parsed.mod && (parsed.meta || parsed.control)) { return { ok: false, error: 'Use either Mod or a platform-specific modifier, not both.' } } if (parsed.doubleTapModifier) { return { ok: true, value: canonicalizeParsedKeybinding(parsed) } } const isShiftInsert = parsed.shift && parsed.key === 'Insert' const isBareAllowed = options.allowBareKeybindings === true && isSafeBareKey(parsed) if ( !parsed.mod && !parsed.meta && !parsed.control && !parsed.alt && !isShiftInsert && !isBareAllowed ) { return { ok: false, error: 'Include at least one modifier key.' } } return { ok: true, value: canonicalizeParsedKeybinding(parsed) } } export function normalizeKeybinding(binding: string): KeybindingValidationResult { return normalizeKeybindingWithOptions(binding) } export function isDoubleTapBinding(binding: string): boolean { return Boolean(parseKeybinding(binding)?.doubleTapModifier) } function normalizeKeybindingListWithOptions( input: string, options: NormalizeKeybindingOptions = {} ): KeybindingValidationResult | string[] { const trimmed = input.trim() if (!trimmed) { return [] } const normalized: string[] = [] for (const piece of trimmed.split(',')) { const result = normalizeKeybindingWithOptions(piece, options) if (!result.ok) { return result } if (!normalized.includes(result.value)) { normalized.push(result.value) } } return normalized } export function normalizeKeybindingList(input: string): KeybindingValidationResult | string[] { return normalizeKeybindingListWithOptions(input) } function normalizeKeybindingArrayWithOptions( input: readonly string[], options: NormalizeKeybindingOptions = {} ): KeybindingValidationResult | string[] { const normalized: string[] = [] for (const binding of input) { const piece = normalizeKeybindingListWithOptions(binding, options) if (!Array.isArray(piece)) { return piece } for (const normalizedBinding of piece) { if (!normalized.includes(normalizedBinding)) { normalized.push(normalizedBinding) } } } return normalized } function normalizeOptionsForAction(actionId: KeybindingActionId): NormalizeKeybindingOptions { return { allowBareKeybindings: DEFINITIONS_BY_ID.get(actionId)?.allowBareKeybindings === true } } // Why: a digit-index row stores one representative chord. Rewrite the key to 1 // so display and conflict detection stay stable across the 1-9 range, and // reject anything that is not a number key 1-9. Extra modifiers (e.g. Shift) are // intentionally allowed — only the key must be a digit; parseKeybinding has // already enforced that at least one modifier is present. function canonicalizeDigitIndexBinding(binding: string): KeybindingValidationResult { const parsed = parseKeybinding(binding) if (!parsed || parsed.doubleTapModifier || !DIGIT_INDEX_KEY_PATTERN.test(parsed.key)) { return { ok: false, error: 'Pick a number key 1–9 with a modifier, like Cmd+1 or Ctrl+1.' } } return { ok: true, value: canonicalizeParsedKeybinding({ ...parsed, key: '1' }) } } function finalizeDigitIndexBindings( actionId: KeybindingActionId, result: KeybindingValidationResult | string[] ): KeybindingValidationResult | string[] { if (!isDigitIndexActionId(actionId) || !Array.isArray(result)) { return result } const canonical: string[] = [] for (const binding of result) { const normalized = canonicalizeDigitIndexBinding(binding) if (!normalized.ok) { return normalized } if (!canonical.includes(normalized.value)) { canonical.push(normalized.value) } } return canonical } export function normalizeKeybindingListForAction( actionId: KeybindingActionId, input: string ): KeybindingValidationResult | string[] { return finalizeDigitIndexBindings( actionId, normalizeKeybindingListWithOptions(input, normalizeOptionsForAction(actionId)) ) } export function normalizeKeybindingArrayForAction( actionId: KeybindingActionId, input: readonly string[] ): KeybindingValidationResult | string[] { return finalizeDigitIndexBindings( actionId, normalizeKeybindingArrayWithOptions(input, normalizeOptionsForAction(actionId)) ) } const MODIFIER_KEYS = new Set([ 'Alt', 'AltGraph', 'Control', 'Meta', 'Shift', 'OS', 'Fn', 'FnLock', 'Hyper', 'Super', 'Symbol', 'SymbolLock' ]) const PUNCTUATION_KEY_TOKENS = new Set([ 'BracketLeft', 'BracketRight', 'Minus', 'Underscore', 'Equal', 'Plus', 'Comma', 'Period', 'Slash', 'Backslash', 'Semicolon', 'Quote', 'Backquote' ]) const PHYSICAL_CODE_FALLBACK_KEYS = new Set(['', 'Dead', 'Unidentified']) const SHIFTED_PUNCTUATION_KEY_TOKENS: Record = { '<': 'Comma', '>': 'Period', '?': 'Slash', '|': 'Backslash', ':': 'Semicolon', '"': 'Quote', '~': 'Backquote' } function logicalKeyTokenFromInput(input: KeybindingInput): string | null { const key = input.key ?? '' if (MODIFIER_KEYS.has(key)) { return null } const normalizedKey = normalizeKeyToken(key) if (normalizedKey) { return normalizedKey } if (hasModifier(input, 'shift')) { return SHIFTED_PUNCTUATION_KEY_TOKENS[key] ?? null } return null } function canUsePhysicalCodeFallback(input: KeybindingInput): boolean { // Why: layout-aware shortcuts must trust real logical keys; physical code is // only a fallback when the platform cannot report the produced key. return PHYSICAL_CODE_FALLBACK_KEYS.has(input.key ?? '') } function isLatinShortcutKey(key: string): boolean { // Why: A-Z / 0-9 are the only single chars a Latin shortcut token names; any // other produced character (Cyrillic с, Greek π, ...) cannot be a deliberate // remap of a Latin chord, so it is safe to fall back to the physical code. if (key.length !== 1) { return false } const upper = key.toUpperCase() return (upper >= 'A' && upper <= 'Z') || (key >= '0' && key <= '9') } function shouldUseNonLatinShortcutPhysicalFallback( input: KeybindingInput, platform: NodeJS.Platform ): boolean { // Why: non-Latin layouts (Cyrillic, Greek, ...) report a non-Latin logical key // for physical letter keys (issue #6274), so Ctrl/Meta shortcuts never match. // Fall back to the physical code, but only when no logical shortcut token // exists, a real primary modifier (Ctrl or Meta) is held, and this is not an // AltGr (Ctrl+Alt) text-composition event. macOS is excluded — it already has // dedicated Option-composed fallbacks and Cmd shortcuts are layout-stable. if (getKeybindingPlatform(platform) === 'darwin') { return false } const hasPrimaryModifier = hasModifier(input, 'control') || hasModifier(input, 'meta') if (!hasPrimaryModifier) { return false } // AltGr surfaces as Ctrl+Alt on Windows/Linux; treat it as text, not a chord. if (hasModifier(input, 'control') && hasModifier(input, 'alt')) { return false } if (logicalKeyTokenFromInput(input) !== null) { return false } const key = input.key ?? '' return key !== '' && !MODIFIER_KEYS.has(key) && !isLatinShortcutKey(key) } function canFallBackToPhysicalCode(input: KeybindingInput, platform: NodeJS.Platform): boolean { return ( canUsePhysicalCodeFallback(input) || shouldUseNonLatinShortcutPhysicalFallback(input, platform) ) } function physicalCodeKeyTokenFromInput(input: KeybindingInput): string | null { const code = input.code ?? '' if (code.startsWith('Key') && code.length === 4) { return code.slice(3).toUpperCase() } if (code.startsWith('Digit') && code.length === 6) { return code.slice(5) } return normalizeKeyToken(code) } function numpadCodeKeyTokenFromInput(input: KeybindingInput): string | null { const code = input.code ?? '' return code === 'NumpadAdd' || code === 'NumpadSubtract' ? normalizeKeyToken(code) : null } function shouldUseMacOptionComposedCaptureFallback( input: KeybindingInput, platform: NodeJS.Platform ): boolean { // Why: macOS Option+key reports composed characters (Option+C -> ç), so // capturing Alt shortcuts needs the same physical-code fallback as matching. if ( getKeybindingPlatform(platform) !== 'darwin' || !hasModifier(input, 'alt') || MODIFIER_KEYS.has(input.key ?? '') ) { return false } const physicalToken = physicalCodeKeyTokenFromInput(input) if (!physicalToken) { return false } return ( (physicalToken.length === 1 && physicalToken >= 'A' && physicalToken <= 'Z') || isPunctuationKeyToken(physicalToken) ) } function keyTokenFromInput(input: KeybindingInput, platform: NodeJS.Platform): string | null { const numpadKey = numpadCodeKeyTokenFromInput(input) if (numpadKey) { return numpadKey } const logicalKey = logicalKeyTokenFromInput(input) if (logicalKey) { return logicalKey } if ( !canUsePhysicalCodeFallback(input) && !shouldUseMacOptionComposedCaptureFallback(input, platform) && !shouldUseNonLatinShortcutPhysicalFallback(input, platform) ) { return null } return physicalCodeKeyTokenFromInput(input) } // Why: the platform primary modifier canonicalizes to Mod, mirroring normal // capture where Cmd on macOS / Ctrl elsewhere both become Mod. function canonicalDoubleTapToken( modifier: PhysicalModifierToken, platform: NodeJS.Platform ): ModifierToken { const isMac = platform === 'darwin' if (modifier === 'Cmd' && isMac) { return 'Mod' } if (modifier === 'Ctrl' && !isMac) { return 'Mod' } return modifier } function keybindingFromInputWithOptions( input: KeybindingInput, platform: NodeJS.Platform, options: NormalizeKeybindingOptions = {} ): KeybindingValidationResult { if (input.doubleTapModifier) { return normalizeKeybindingWithOptions( `DoubleTap+${canonicalDoubleTapToken(input.doubleTapModifier, platform)}`, options ) } const key = keyTokenFromInput(input, platform) if (!key) { return { ok: false, error: 'Press a key, not only a modifier.' } } const isMac = getKeybindingPlatform(platform) === 'darwin' const parts: string[] = [] const primaryModifierPressed = isMac ? hasModifier(input, 'meta') : hasModifier(input, 'control') if (primaryModifierPressed) { parts.push('Mod') } if (isMac && hasModifier(input, 'control')) { parts.push('Ctrl') } if (!isMac && hasModifier(input, 'meta')) { parts.push('Cmd') } if (hasModifier(input, 'alt')) { parts.push('Alt') } if (hasModifier(input, 'shift')) { parts.push('Shift') } parts.push(key) return normalizeKeybindingWithOptions(parts.join('+'), options) } export function keybindingFromInput( input: KeybindingInput, platform: NodeJS.Platform ): KeybindingValidationResult { return keybindingFromInputWithOptions(input, platform) } export function keybindingFromInputForAction( actionId: KeybindingActionId, input: KeybindingInput, platform: NodeJS.Platform ): KeybindingValidationResult { const result = keybindingFromInputWithOptions( input, platform, normalizeOptionsForAction(actionId) ) if (!result.ok || !isDigitIndexActionId(actionId)) { return result } return canonicalizeDigitIndexBinding(result.value) } function getDefaultBindings(definition: KeybindingDefinition, platform: NodeJS.Platform): string[] { return definition.defaultBindings[getKeybindingPlatform(platform)].map((binding) => { const normalized = normalizeKeybindingWithOptions(binding, { allowBareKeybindings: definition.allowBareKeybindings === true }) return normalized.ok ? normalized.value : binding }) } export function getEffectiveKeybindingsForAction( actionId: KeybindingActionId, platform: NodeJS.Platform, overrides?: KeybindingOverrides ): string[] { const definition = DEFINITIONS_BY_ID.get(actionId) if (!definition) { return [] } const override = overrides?.[actionId] if (Array.isArray(override)) { // Why: digit-index overrides resolve to their canonical +1 representative // (deduped) so effective bindings stay consistent for display and conflict // detection even if a hand-edited file stored a different digit. if (isDigitIndexActionId(actionId)) { const canonical: string[] = [] for (const binding of override) { const normalized = canonicalizeDigitIndexBinding(binding) if (normalized.ok && !canonical.includes(normalized.value)) { canonical.push(normalized.value) } } return canonical } return override.flatMap((binding) => { const normalized = normalizeKeybindingWithOptions( binding, normalizeOptionsForAction(actionId) ) return normalized.ok ? [normalized.value] : [] }) } return getDefaultBindings(definition, platform) } export function getKeybindingDefinition(actionId: KeybindingActionId): KeybindingDefinition | null { return DEFINITIONS_BY_ID.get(actionId) ?? null } export function normalizeTerminalShortcutPolicy( policy: TerminalShortcutPolicy | null | undefined ): TerminalShortcutPolicy { return policy === 'terminal-first' ? 'terminal-first' : 'orca-first' } export function isKeybindingAllowedInTerminal(definition: KeybindingDefinition): boolean { return definition.scope === 'terminal' || definition.allowInTerminal === true } export function isKeybindingPotentialTerminalConflict(definition: KeybindingDefinition): boolean { return definition.scope !== 'terminal' && definition.allowInTerminal !== true } export function keybindingIsActiveInContext( definition: KeybindingDefinition, options: KeybindingMatchOptions = {} ): boolean { if (options.context !== 'terminal') { return true } // Why: Orca-first preserves existing app shortcut behavior inside terminals. // Terminal-first is the explicit escape hatch for shells and TUIs. if (normalizeTerminalShortcutPolicy(options.terminalShortcutPolicy) === 'orca-first') { return true } return isKeybindingAllowedInTerminal(definition) } function platformModifiers( parsed: ParsedKeybinding, platform: NodeJS.Platform ): { meta: boolean; control: boolean; alt: boolean; shift: boolean } { const isMac = platform === 'darwin' return { meta: parsed.meta || (parsed.mod && isMac), control: parsed.control || (parsed.mod && !isMac), alt: parsed.alt, shift: parsed.shift } } function modifierStateMatches( parsed: ParsedKeybinding, input: KeybindingInput, platform: NodeJS.Platform ): boolean { const expected = platformModifiers(parsed, platform) return ( hasModifier(input, 'meta') === expected.meta && hasModifier(input, 'control') === expected.control && hasModifier(input, 'alt') === expected.alt && hasModifier(input, 'shift') === expected.shift ) } function shouldUseMacOptionLetterPhysicalFallback( parsed: ParsedKeybinding, input: KeybindingInput, platform: NodeJS.Platform ): boolean { // Why: macOS Option+letter can report composed characters (Option+A -> å), // leaving no logical Latin key for app shortcuts that intentionally use Alt. return ( getKeybindingPlatform(platform) === 'darwin' && parsed.alt && hasModifier(input, 'alt') && logicalKeyTokenFromInput(input) === null ) } function shouldUseMacOptionPunctuationPhysicalFallback( parsed: ParsedKeybinding, input: KeybindingInput, platform: NodeJS.Platform ): boolean { // Why: macOS Option+punctuation can report composed quote/dead-key values, // leaving no logical bracket token for app shortcuts that intentionally use Alt. return ( getKeybindingPlatform(platform) === 'darwin' && parsed.alt && hasModifier(input, 'alt') && logicalKeyTokenFromInput(input) === null ) } function letterKeyMatches( input: KeybindingInput, letter: string, parsed: ParsedKeybinding, platform: NodeJS.Platform ): boolean { const logicalKey = logicalKeyTokenFromInput(input) if (logicalKey && logicalKey.length === 1 && logicalKey >= 'A' && logicalKey <= 'Z') { return logicalKey === letter.toUpperCase() } return ( (canFallBackToPhysicalCode(input, platform) || shouldUseMacOptionLetterPhysicalFallback(parsed, input, platform)) && input.code === `Key${letter.toUpperCase()}` ) } function digitKeyMatches( input: KeybindingInput, digit: string, platform: NodeJS.Platform ): boolean { const logicalKey = logicalKeyTokenFromInput(input) if (logicalKey && logicalKey.length === 1 && logicalKey >= '0' && logicalKey <= '9') { return logicalKey === digit } return canFallBackToPhysicalCode(input, platform) && input.code === `Digit${digit}` } function isPunctuationKeyToken(token: string | null): token is string { return token !== null && PUNCTUATION_KEY_TOKENS.has(token) } function semanticPunctuationKey(input: KeybindingInput): string | null { const logicalKey = logicalKeyTokenFromInput(input) return isPunctuationKeyToken(logicalKey) ? logicalKey : null } function physicalPunctuationKey(input: KeybindingInput): string | null { const physicalKey = physicalCodeKeyTokenFromInput(input) return isPunctuationKeyToken(physicalKey) ? physicalKey : null } function shouldUseSemanticPunctuation( parsed: ParsedKeybinding, input: KeybindingInput, platform: NodeJS.Platform ): boolean { // Why: Windows/Linux often expose AltGr as Ctrl+Alt. Do not turn ordinary // international text input into Mod+Alt app shortcuts. if ( getKeybindingPlatform(platform) !== 'darwin' && parsed.mod && parsed.alt && hasModifier(input, 'control') && hasModifier(input, 'alt') && !hasModifier(input, 'meta') && physicalPunctuationKey(input) === null ) { return false } return true } function keyMatches( parsedKey: string, input: KeybindingInput, parsed: ParsedKeybinding, platform: NodeJS.Platform ): boolean { if (parsedKey.length === 1 && parsedKey >= 'A' && parsedKey <= 'Z') { return letterKeyMatches(input, parsedKey, parsed, platform) } if (parsedKey.length === 1 && parsedKey >= '0' && parsedKey <= '9') { return digitKeyMatches(input, parsedKey, platform) } if (parsedKey === 'NumpadAdd' || parsedKey === 'NumpadSubtract') { return ( numpadCodeKeyTokenFromInput(input) === parsedKey || logicalKeyTokenFromInput(input) === parsedKey ) } if (isPunctuationKeyToken(parsedKey)) { // Why: shortcut labels name logical punctuation, but international // layouts can report the same character from different physical codes. const semanticKey = semanticPunctuationKey(input) if (semanticKey !== null) { if (!shouldUseSemanticPunctuation(parsed, input, platform)) { return false } return semanticKey === parsedKey } return ( (canFallBackToPhysicalCode(input, platform) || shouldUseMacOptionPunctuationPhysicalFallback(parsed, input, platform)) && physicalPunctuationKey(input) === parsedKey ) } const logicalKey = logicalKeyTokenFromInput(input) if (logicalKey !== null) { return logicalKey === parsedKey } return ( canFallBackToPhysicalCode(input, platform) && physicalCodeKeyTokenFromInput(input) === parsedKey ) } function resolveModifierToken( modifier: ModifierToken, platform: NodeJS.Platform ): 'meta' | 'control' | 'alt' | 'shift' { switch (modifier) { case 'Mod': return platform === 'darwin' ? 'meta' : 'control' case 'Cmd': return 'meta' case 'Ctrl': return 'control' case 'Alt': return 'alt' case 'Shift': return 'shift' } } export function keybindingMatchesInput( binding: string, input: KeybindingInput, platform: NodeJS.Platform ): boolean { const parsed = parseKeybinding(binding) if (!parsed) { return false } // A double-tap binding matches only a synthetic double-tap input, resolved per // platform; a normal binding never matches a synthetic input, and vice-versa. if (parsed.doubleTapModifier) { return ( input.doubleTapModifier !== undefined && resolveModifierToken(parsed.doubleTapModifier, platform) === resolveModifierToken(input.doubleTapModifier, platform) ) } if (input.doubleTapModifier !== undefined) { return false } return ( modifierStateMatches(parsed, input, platform) && keyMatches(parsed.key, input, parsed, platform) ) } function keybindingConflictIdentityForParsed( parsed: ParsedKeybinding, platform: NodeJS.Platform ): string { if (parsed.doubleTapModifier) { return `DoubleTap:${resolveModifierToken(parsed.doubleTapModifier, platform)}` } const modifiers = platformModifiers(parsed, platform) return [ modifiers.meta ? 'Meta' : '', modifiers.control ? 'Control' : '', modifiers.alt ? 'Alt' : '', modifiers.shift ? 'Shift' : '', parsed.key ].join('+') } function keybindingConflictIdentity(binding: string, platform: NodeJS.Platform): string { const parsed = parseKeybinding(binding) return parsed ? keybindingConflictIdentityForParsed(parsed, platform) : binding } function keybindingConflictIdentities( actionId: KeybindingActionId, binding: string, platform: NodeJS.Platform ): readonly string[] { const exact = keybindingConflictIdentity(binding, platform) if (!isDigitIndexActionId(actionId)) { return [exact] } const parsed = parseKeybinding(binding) if (!parsed || parsed.doubleTapModifier || !DIGIT_INDEX_KEY_PATTERN.test(parsed.key)) { return [exact] } return Array.from({ length: 9 }, (_, index) => keybindingConflictIdentityForParsed({ ...parsed, key: String(index + 1) }, platform) ) } export function keybindingMatchesAction( actionId: KeybindingActionId, input: KeybindingInput, platform: NodeJS.Platform, overrides?: KeybindingOverrides, options: KeybindingMatchOptions = {} ): boolean { const definition = DEFINITIONS_BY_ID.get(actionId) if (!definition) { return false } if (!keybindingIsActiveInContext(definition, options)) { return false } return getEffectiveKeybindingsForAction(actionId, platform, overrides).some((binding) => keybindingMatchesInput(binding, input, platform) ) } function digitFromInput(input: KeybindingInput, platform: NodeJS.Platform): string | null { for (let value = 1; value <= 9; value++) { const digit = String(value) if (digitKeyMatches(input, digit, platform)) { return digit } } return null } // Why: digit-index rows bind a representative chord but fire for 1-9. Reuse the // representative's modifier set with the pressed digit, then match it through the // normal input matcher so Mod/Cmd resolution and layout fallbacks stay shared. // Honors keybindingIsActiveInContext, so terminal-first focus disables the range // just like the scope-based gating for every other shortcut. export function matchKeybindingDigitIndex( actionId: KeybindingActionId, input: KeybindingInput, platform: NodeJS.Platform, overrides?: KeybindingOverrides, options: KeybindingMatchOptions = {} ): number | null { const definition = DEFINITIONS_BY_ID.get(actionId) if (!definition || !keybindingIsActiveInContext(definition, options)) { return null } const digit = digitFromInput(input, platform) if (!digit) { return null } for (const binding of getEffectiveKeybindingsForAction(actionId, platform, overrides)) { const parsed = parseKeybinding(binding) if (!parsed || parsed.doubleTapModifier || !DIGIT_INDEX_KEY_PATTERN.test(parsed.key)) { continue } const candidate = canonicalizeParsedKeybinding({ ...parsed, key: digit }) if (keybindingMatchesInput(candidate, input, platform)) { return Number(digit) - 1 } } return null } function formatModifierGlyph(modifier: ModifierToken, isMac: boolean): string { switch (modifier) { case 'Mod': return isMac ? '⌘' : 'Ctrl' case 'Cmd': return isMac ? '⌘' : 'Cmd' case 'Ctrl': return isMac ? '⌃' : 'Ctrl' case 'Alt': return isMac ? '⌥' : 'Alt' case 'Shift': return isMac ? '⇧' : 'Shift' } } export function formatKeybinding(binding: string, platform: NodeJS.Platform): string[] { const parsed = parseKeybinding(binding) if (!parsed) { return [binding] } const isMac = platform === 'darwin' if (parsed.doubleTapModifier) { const glyph = formatModifierGlyph(parsed.doubleTapModifier, isMac) return [glyph, glyph] } const parts: string[] = [] if (parsed.mod) { parts.push(isMac ? '⌘' : 'Ctrl') } if (parsed.meta) { parts.push(isMac ? '⌘' : 'Cmd') } if (parsed.control) { parts.push(isMac ? '⌃' : 'Ctrl') } if (parsed.alt) { parts.push(isMac ? '⌥' : 'Alt') } if (parsed.shift) { parts.push(isMac ? '⇧' : 'Shift') } parts.push(formatKeyToken(parsed.key)) return parts } export function formatKeybindingList( bindings: readonly string[], platform: NodeJS.Platform ): string { if (bindings.length === 0) { return 'Unassigned' } return bindings .map((binding) => { const separator = isDoubleTapBinding(binding) ? ' ' : platform === 'darwin' ? '' : '+' return formatKeybinding(binding, platform).join(separator) }) .join(', ') } function formatKeyToken(token: string): string { const labels: Record = { BracketLeft: '[', BracketRight: ']', Minus: '-', Underscore: '_', Equal: '=', Plus: '+', ArrowLeft: '←', ArrowRight: '→', ArrowUp: '↑', ArrowDown: '↓', PageUp: 'PageUp', PageDown: 'PageDown', NumpadAdd: 'Numpad +', NumpadSubtract: 'Numpad -', Comma: ',', Period: '.', Slash: '/', Backslash: '\\', Semicolon: ';', Quote: "'", Backquote: '`', Enter: 'Enter', Backspace: 'Backspace', Delete: 'Delete', Insert: 'Insert', Tab: 'Tab', Escape: 'Esc', Space: 'Space' } return labels[token] ?? token } export function findKeybindingConflicts( platform: NodeJS.Platform, overrides?: KeybindingOverrides, options: FindKeybindingConflictOptions = {} ): KeybindingConflict[] { const owners = new Map }>() const ignoredActionIds = new Set(options.ignoredActionIds ?? []) const customizedActions = new Set( Object.keys(overrides ?? {}).filter( (actionId): actionId is KeybindingActionId => isKeybindingActionId(actionId) && !ignoredActionIds.has(actionId) ) ) for (const definition of KEYBINDING_DEFINITIONS) { if (ignoredActionIds.has(definition.id)) { continue } for (const binding of getEffectiveKeybindingsForAction(definition.id, platform, overrides)) { const groups = new Set([definition.conflictGroup ?? definition.scope]) if (definition.conflictGroup) { // Why: native menu accelerators can still consume global chords, so custom // renderer bindings must be checked against both the menu bucket and scope. groups.add(definition.scope) } for (const group of groups) { for (const identity of keybindingConflictIdentities(definition.id, binding, platform)) { const conflictKey = `${group}\u0000${identity}` const current = owners.get(conflictKey) ?? { binding, actionIds: new Set() } if ( !isDigitIndexActionId(definition.id) && Array.from(current.actionIds).some((actionId) => isDigitIndexActionId(actionId)) ) { current.binding = binding } current.actionIds.add(definition.id) owners.set(conflictKey, current) } } } } const seenConflictKeys = new Set() return Array.from(owners.values()) .filter(({ actionIds }) => actionIds.size > 1 && setIntersects(actionIds, customizedActions)) .map(({ binding, actionIds }) => ({ binding, actionIds: Array.from(actionIds) })) .filter((conflict) => { const key = `${conflict.binding}\u0000${conflict.actionIds.join('\u0000')}` if (seenConflictKeys.has(key)) { return false } seenConflictKeys.add(key) return true }) } function setIntersects(left: ReadonlySet, right: ReadonlySet): boolean { for (const value of left) { if (right.has(value)) { return true } } return false }