1703 lines
46 KiB
TypeScript
1703 lines
46 KiB
TypeScript
/* 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'
|
|
| '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.focusWorktreeList'
|
|
| 'floatingTerminal.toggle'
|
|
| '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'
|
|
| 'browser.find'
|
|
| 'browser.back'
|
|
| 'browser.forward'
|
|
| 'browser.reload'
|
|
| 'browser.hardReload'
|
|
| 'browser.focusAddressBar'
|
|
| 'browser.grabElement'
|
|
| 'editor.find'
|
|
| 'editor.save'
|
|
| 'editor.markdownPreview'
|
|
| 'editor.copyContext'
|
|
| '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.closePane'
|
|
| 'terminal.splitRight'
|
|
| 'terminal.splitDown'
|
|
|
|
export type KeybindingOverrides = Partial<Record<KeybindingActionId, string[]>>
|
|
|
|
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<Record<KeybindingPlatform, KeybindingOverrides>>
|
|
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 KeybindingInput = {
|
|
key?: string
|
|
code?: string
|
|
alt?: boolean
|
|
meta?: boolean
|
|
control?: boolean
|
|
shift?: boolean
|
|
altKey?: boolean
|
|
metaKey?: boolean
|
|
ctrlKey?: boolean
|
|
shiftKey?: boolean
|
|
}
|
|
|
|
type ParsedKeybinding = {
|
|
mod: boolean
|
|
meta: boolean
|
|
control: boolean
|
|
alt: boolean
|
|
shift: boolean
|
|
key: string
|
|
}
|
|
|
|
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<KeybindingActionId>
|
|
}
|
|
|
|
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: '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.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: '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: '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.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'])
|
|
},
|
|
{
|
|
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.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<KeybindingActionId, KeybindingDefinition>(
|
|
KEYBINDING_DEFINITIONS.map((definition) => [definition.id, definition])
|
|
)
|
|
|
|
const DEFINITION_IDS = new Set<KeybindingActionId>(
|
|
KEYBINDING_DEFINITIONS.map((definition) => definition.id)
|
|
)
|
|
|
|
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 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
|
|
}
|
|
|
|
const simple: Record<string, string> = {
|
|
'[': '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 parseKeybinding(binding: string): ParsedKeybinding | null {
|
|
const rawParts = binding
|
|
.split('+')
|
|
.map((part) => part.trim())
|
|
.filter(Boolean)
|
|
if (rawParts.length === 0) {
|
|
return null
|
|
}
|
|
|
|
const parsed: ParsedKeybinding = {
|
|
mod: false,
|
|
meta: false,
|
|
control: false,
|
|
alt: false,
|
|
shift: false,
|
|
key: ''
|
|
}
|
|
|
|
for (const rawPart of rawParts) {
|
|
const part = rawPart.toLowerCase()
|
|
if (part === 'mod' || part === 'cmdorctrl' || part === 'commandorcontrol') {
|
|
parsed.mod = true
|
|
continue
|
|
}
|
|
if (part === 'cmd' || part === 'command' || part === 'meta' || rawPart === '⌘') {
|
|
parsed.meta = true
|
|
continue
|
|
}
|
|
if (part === 'ctrl' || part === 'control' || rawPart === '⌃') {
|
|
parsed.control = true
|
|
continue
|
|
}
|
|
if (part === 'alt' || part === 'option' || part === 'opt' || rawPart === '⌥') {
|
|
parsed.alt = true
|
|
continue
|
|
}
|
|
if (part === 'shift' || rawPart === '⇧') {
|
|
parsed.shift = true
|
|
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 {
|
|
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.shift || parsed.mod || parsed.meta || parsed.control || parsed.alt) {
|
|
return false
|
|
}
|
|
return [
|
|
'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.' }
|
|
}
|
|
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)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
export function normalizeKeybindingListForAction(
|
|
actionId: KeybindingActionId,
|
|
input: string
|
|
): KeybindingValidationResult | string[] {
|
|
return normalizeKeybindingListWithOptions(input, normalizeOptionsForAction(actionId))
|
|
}
|
|
|
|
export function normalizeKeybindingArrayForAction(
|
|
actionId: KeybindingActionId,
|
|
input: readonly string[]
|
|
): KeybindingValidationResult | string[] {
|
|
return 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<string, string> = {
|
|
'<': '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 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)
|
|
) {
|
|
return null
|
|
}
|
|
return physicalCodeKeyTokenFromInput(input)
|
|
}
|
|
|
|
function keybindingFromInputWithOptions(
|
|
input: KeybindingInput,
|
|
platform: NodeJS.Platform,
|
|
options: NormalizeKeybindingOptions = {}
|
|
): KeybindingValidationResult {
|
|
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 {
|
|
return keybindingFromInputWithOptions(input, platform, normalizeOptionsForAction(actionId))
|
|
}
|
|
|
|
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)) {
|
|
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 (
|
|
(canUsePhysicalCodeFallback(input) ||
|
|
shouldUseMacOptionLetterPhysicalFallback(parsed, input, platform)) &&
|
|
input.code === `Key${letter.toUpperCase()}`
|
|
)
|
|
}
|
|
|
|
function digitKeyMatches(input: KeybindingInput, digit: string): boolean {
|
|
const logicalKey = logicalKeyTokenFromInput(input)
|
|
if (logicalKey && logicalKey.length === 1 && logicalKey >= '0' && logicalKey <= '9') {
|
|
return logicalKey === digit
|
|
}
|
|
return canUsePhysicalCodeFallback(input) && 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)
|
|
}
|
|
|
|
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 (
|
|
(canUsePhysicalCodeFallback(input) ||
|
|
shouldUseMacOptionPunctuationPhysicalFallback(parsed, input, platform)) &&
|
|
physicalPunctuationKey(input) === parsedKey
|
|
)
|
|
}
|
|
|
|
const logicalKey = logicalKeyTokenFromInput(input)
|
|
if (logicalKey !== null) {
|
|
return logicalKey === parsedKey
|
|
}
|
|
return canUsePhysicalCodeFallback(input) && physicalCodeKeyTokenFromInput(input) === parsedKey
|
|
}
|
|
|
|
export function keybindingMatchesInput(
|
|
binding: string,
|
|
input: KeybindingInput,
|
|
platform: NodeJS.Platform
|
|
): boolean {
|
|
const parsed = parseKeybinding(binding)
|
|
if (!parsed) {
|
|
return false
|
|
}
|
|
return (
|
|
modifierStateMatches(parsed, input, platform) && keyMatches(parsed.key, input, parsed, 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)
|
|
)
|
|
}
|
|
|
|
export function formatKeybinding(binding: string, platform: NodeJS.Platform): string[] {
|
|
const parsed = parseKeybinding(binding)
|
|
if (!parsed) {
|
|
return [binding]
|
|
}
|
|
const isMac = platform === 'darwin'
|
|
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) => formatKeybinding(binding, platform).join(platform === 'darwin' ? '' : '+'))
|
|
.join(', ')
|
|
}
|
|
|
|
function formatKeyToken(token: string): string {
|
|
const labels: Record<string, string> = {
|
|
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<string, KeybindingActionId[]>()
|
|
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) {
|
|
const conflictKey = `${group}\u0000${binding}`
|
|
const current = owners.get(conflictKey) ?? []
|
|
current.push(definition.id)
|
|
owners.set(conflictKey, current)
|
|
}
|
|
}
|
|
}
|
|
|
|
return Array.from(owners.entries())
|
|
.filter(
|
|
([, actionIds]) =>
|
|
actionIds.length > 1 && actionIds.some((actionId) => customizedActions.has(actionId))
|
|
)
|
|
.map(([conflictKey, actionIds]) => ({
|
|
binding: conflictKey.slice(conflictKey.indexOf('\u0000') + 1),
|
|
actionIds
|
|
}))
|
|
}
|