Files
orca/src/shared/keybindings.ts
T
Brennan Benson 7aae88cd21 Fix terminal select-all and copy in Kitty-enabled TUIs (#13388)
* fix(terminal): handle select-all in Kitty TUIs

* fix(terminal): preserve popout native selection
2026-08-09 16:52:13 -07:00

2400 lines
69 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable max-lines -- Why: keep the shortcut registry, parser, formatter, and conflict detector in one shared module so main/renderer/browser/Settings can't drift. */
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 PluginKeybindingActionId = `plugin:${string}`
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.toggleWordWrap'
| 'editor.copyContext'
| 'editor.previousChange'
| 'editor.nextChange'
| 'editor.addReviewNote'
| 'sourceControl.sendReviewNotes'
| 'fileExplorer.undo'
| 'fileExplorer.redo'
| 'fileExplorer.copyPath'
| 'fileExplorer.copyRelativePath'
| 'fileExplorer.delete'
| 'settings.search'
| 'terminal.copySelection'
| 'terminal.selectAll'
| 'terminal.paste'
| 'terminal.search'
| 'terminal.clear'
| 'terminal.focusNextPane'
| 'terminal.focusPreviousPane'
| 'terminal.equalizePaneSizes'
| 'terminal.expandPane'
| 'terminal.setTitle'
| 'terminal.clearPaneTitle'
| 'terminal.closePane'
| 'terminal.splitRight'
| 'terminal.splitDown'
| 'terminal.switchInputSource'
| PluginKeybindingActionId
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
allowShiftOnlyKeybindings?: boolean
conflictGroup?: string
}
export type ModifierToken = 'Mod' | 'Cmd' | 'Ctrl' | 'Alt' | 'Shift'
export type PhysicalModifierToken = Exclude<ModifierToken, 'Mod'>
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
allowShiftOnlyKeybindings?: 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>
relevantActionIds?: 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 — Windows/Linux Ctrl+Alt+R has no safe default (Ctrl+R reverse-search, Ctrl+Shift+R reload are taken).
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 now without a default chord; user overrides still win 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: configurable but unbound by default, to not take a global chord from terminal/browser/editor users.
defaultBindings: platformBindings([]),
allowInTerminal: true
},
{
id: 'workspace.selectByIndex',
title: 'Select Workspace 19',
group: 'Global',
scope: 'global',
searchKeywords: [
'shortcut',
'global',
'workspace',
'worktree',
'select',
'switch',
'number',
'digit',
'1-9',
'index'
],
// Why: one remappable row covers the whole 1-9 range (stored chord is a representative; any of 1-9 fires it).
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 users to assign it), avoiding a claimed cross-platform chord.
defaultBindings: platformBindings([])
},
{
id: 'sidebar.focusWorktreeList',
title: 'Focus worktree list',
group: 'Global',
scope: 'global',
searchKeywords: ['shortcut', 'sidebar', 'worktree', 'focus'],
// Why: keep zoom.reset on the browser-standard Mod+0; this chord was unreachable while it shared that default (#8584).
defaultBindings: platformBindings(['Mod+Shift+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 floatingTerminal.toggle (Cmd+Opt+A) so maximize stays one-handed; macOS-only, Linux/Windows unbound.
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: unbound everywhere since floatingTerminal.toggle owns show/hide; this exists only for an explicit user-bound "hide panel" shortcut.
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 — Windows Ctrl+Alt is AltGr and Linux Ctrl+Alt+T is the desktop "open terminal", so no safe default there.
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'],
// Why: keep explorer on Mod+Shift+E (VS Code muscle memory); emulator is macOS-only and less common, so it yields to a free chord (#8533).
defaultBindings: {
darwin: ['Mod+Alt+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 app/terminal focus; on Windows/Linux Ctrl+R is shell reverse-search, so left unbound.
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'],
// Why: the widespread "switch tab" chord (Mod+Shift+Bracket) now drives all-types cycling; same-type moved to Mod+Alt for new installs.
defaultBindings: platformBindings(['Mod+Alt+BracketRight'])
},
{
id: 'tab.previousSameType',
title: 'Previous tab (same type)',
group: 'Tab Navigation',
scope: 'tabs',
searchKeywords: ['shortcut', 'tab', 'previous', 'switch', 'cycle'],
defaultBindings: platformBindings(['Mod+Alt+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+Shift+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+Shift+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 19',
group: 'Tab Navigation',
scope: 'tabs',
// Why: no shared conflictGroup with workspace.selectByIndex so swapping their modifiers isn't a false conflict; safe 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); each platform avoids the workspace-jump chord (Mod+1-9).
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 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 per platform.
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.toggleWordWrap',
title: 'Toggle Word Wrap',
group: 'Editors',
scope: 'editor',
searchKeywords: ['shortcut', 'editor', 'word wrap', 'wrap', 'long lines', 'soft wrap'],
// Why: Alt+Z matches VS Code; bare Alt+letter is not AltGr, so it stays cross-platform (#9974).
defaultBindings: platformBindings(['Alt+Z'])
},
{
id: 'editor.copyContext',
title: 'Copy Context',
group: 'Editors',
scope: 'editor',
searchKeywords: ['shortcut', 'editor', 'copy', 'context'],
defaultBindings: platformBindings(['Mod+Alt+C'])
},
// Why: F7 / Shift+F7 mirror VS Code / JetBrains diff-change nav; function keys are safe bare/Shift, 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: 'editor.addReviewNote',
title: 'Add Review Note',
group: 'Editors',
scope: 'editor',
searchKeywords: ['shortcut', 'editor', 'markdown', 'note', 'comment', 'annotation', 'review'],
// Why: Ctrl+Alt+letter is AltGr text input on Windows/Linux, so an editor default must not reserve chars like Polish `ń`.
defaultBindings: platformBindings(['Mod+Shift+A'])
},
{
id: 'sourceControl.sendReviewNotes',
title: 'Send Review Notes to Agent',
group: 'Global',
scope: 'global',
// Why: fires from the global capture handler even while the editor is focused, so Settings must warn on collisions with editor chords (e.g. Add Review Note) too, not just global ones.
conflictGroup: 'editor',
searchKeywords: [
'shortcut',
'source control',
'diff',
'notes',
'send',
'agent',
'review',
'annotate'
],
// Why: unbound by default so it never collides with existing chords; users opt in via Settings.
defaultBindings: platformBindings([])
},
{
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: {
darwin: ['Mod+C'],
linux: ['Ctrl+Shift+C', 'Ctrl+C'],
win32: ['Ctrl+Shift+C', 'Ctrl+C']
}
},
{
id: 'terminal.selectAll',
title: 'Select all terminal text',
group: 'Terminal Panes',
scope: 'terminal',
searchKeywords: ['shortcut', 'terminal', 'select', 'all'],
defaultBindings: {
darwin: ['Mod+A'],
linux: ['Ctrl+Shift+A'],
win32: ['Ctrl+Shift+A']
}
},
{
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']
}
},
{
id: 'terminal.switchInputSource',
title: 'Switch input source / language (native)',
group: 'Terminal Panes',
scope: 'terminal',
searchKeywords: [
'shortcut',
'input',
'source',
'language',
'korean',
'english',
'ime',
'switch',
'hangul',
'layout'
],
defaultBindings: {
darwin: [],
linux: [],
win32: []
},
// Why: macOS uses Shift+Space as an input-source shortcut; Orca otherwise rejects Shift-only bindings to avoid stealing typed text.
allowShiftOnlyKeybindings: true
},
...buildAgentTabKeybindingDefinitions()
]
/** Pre-swap tab-switch bindings; a one-time migration pins these for pre-release installs so upgrading users keep the shortcuts they learned. */
export const LEGACY_TAB_SWITCH_BINDINGS: Readonly<Partial<Record<KeybindingActionId, string[]>>> = {
'tab.nextSameType': ['Mod+Shift+BracketRight'],
'tab.previousSameType': ['Mod+Shift+BracketLeft'],
'tab.nextAllTypes': ['Mod+Alt+BracketRight'],
'tab.previousAllTypes': ['Mod+Alt+BracketLeft']
}
export function agentTabActionId(agent: TuiAgent): AgentTabActionId {
return `tab.newAgent.${agent}`
}
// Why: one bindable action per agent; all ship unassigned since tab.newAgent covers the default, and Settings hides 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)
)
// Why: these ids are single remappable rows whose chord is a representative — the digit canonicalizes to 1 but the binding fires for any 1-9.
export const DIGIT_INDEX_ACTION_IDS: readonly KeybindingActionId[] = [
'tab.selectByIndex',
'workspace.selectByIndex'
]
const DIGIT_INDEX_ACTION_ID_SET = new Set<KeybindingActionId>(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) || isPluginKeybindingActionId(value)
}
export function isPluginKeybindingActionId(value: string): value is PluginKeybindingActionId {
return (
value.length <= 400 &&
/^plugin:[a-z0-9]+(?:-[a-z0-9]+)*\.[a-z0-9]+(?:-[a-z0-9]+)*\/[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*$/.test(
value
)
)
}
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 F1F24 (event.key/event.code report them verbatim, e.g. F7).
if (isFunctionKeyToken(upper)) {
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 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 can't use the normal parse path; modifier validation is deferred to normalize.
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)
}
// Keep both flags when Mod is combined with a platform modifier, 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 bare or with Shift (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)
const isShiftOnlyAllowed =
options.allowShiftOnlyKeybindings === true &&
parsed.shift &&
!parsed.mod &&
!parsed.meta &&
!parsed.control &&
!parsed.alt
if (
!parsed.mod &&
!parsed.meta &&
!parsed.control &&
!parsed.alt &&
!isShiftInsert &&
!isBareAllowed &&
!isShiftOnlyAllowed
) {
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 {
const definition = DEFINITIONS_BY_ID.get(actionId)
return {
allowBareKeybindings: definition?.allowBareKeybindings === true,
allowShiftOnlyKeybindings: definition?.allowShiftOnlyKeybindings === true
}
}
// Why: rewrite a digit-index chord's key to 1 so display and conflict detection stay stable across the 1-9 range; reject any non 1-9 key.
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 19 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<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 trust real logical keys; physical code is only a fallback when the platform can't 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 chars a Latin shortcut names; a non-Latin char (Cyrillic с, Greek π) is never a Latin remap, so physical-code fallback is safe.
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 report non-Latin logical keys for physical letters (#6274), breaking Ctrl/Meta shortcuts; fall back to the physical code.
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 physical-code fallback.
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 (Cmd on macOS / Ctrl elsewhere), mirroring normal capture.
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,
allowShiftOnlyKeybindings: definition.allowShiftOnlyKeybindings === 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)
const override = overrides?.[actionId]
if (Array.isArray(override)) {
// Why: canonicalize digit-index overrides to <mods>+1 so display/conflict stay consistent 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 definition ? getDefaultBindings(definition, platform) : []
}
export function getEffectiveKeybindingsForDefinition(
definition: KeybindingDefinition,
platform: NodeJS.Platform,
overrides?: KeybindingOverrides
): string[] {
const override = overrides?.[definition.id]
if (Array.isArray(override)) {
return getEffectiveKeybindingsForAction(definition.id, platform, overrides)
}
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 keeps app shortcuts inside terminals; terminal-first is the 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 reports composed characters (Option+A -> å), leaving no logical Latin key for Alt shortcuts.
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 reports composed dead-key values, leaving no logical bracket token for Alt shortcuts.
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 expose AltGr as Ctrl+Alt; don't turn 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 it 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 (and vice-versa), resolved per platform.
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('+')
}
export function getKeybindingConflictIdentity(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 = getKeybindingConflictIdentity(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: a digit-index row's representative chord fires for any 1-9 — reuse its modifiers with the pressed digit via the normal matcher.
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(', ')
}
export function findKeybindingActionsForBinding(
binding: string,
platform: NodeJS.Platform,
overrides?: KeybindingOverrides,
scopes: readonly KeybindingScope[] = ['global', 'tabs']
): KeybindingActionId[] {
const identity = getKeybindingConflictIdentity(binding, platform)
const allowedScopes = new Set(scopes)
return KEYBINDING_DEFINITIONS.filter(
(definition) =>
allowedScopes.has(definition.scope) &&
getEffectiveKeybindingsForAction(definition.id, platform, overrides).some((candidate) =>
keybindingConflictIdentities(definition.id, candidate, platform).includes(identity)
)
).map((definition) => definition.id)
}
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[] {
return findKeybindingConflictsForDefinitions(KEYBINDING_DEFINITIONS, platform, overrides, options)
}
export function findKeybindingConflictsForDefinitions(
definitions: readonly KeybindingDefinition[],
platform: NodeJS.Platform,
overrides?: KeybindingOverrides,
options: FindKeybindingConflictOptions = {}
): KeybindingConflict[] {
const owners = new Map<string, { binding: string; actionIds: Set<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 actionId of options.relevantActionIds ?? []) {
if (!ignoredActionIds.has(actionId)) {
customizedActions.add(actionId)
}
}
for (const definition of definitions) {
if (ignoredActionIds.has(definition.id)) {
continue
}
for (const binding of getEffectiveKeybindingsForDefinition(definition, platform, overrides)) {
const groups = new Set([definition.conflictGroup ?? definition.scope])
if (definition.conflictGroup) {
// Why: native menu accelerators can consume global chords, so check custom bindings 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<string>()
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<T>(left: ReadonlySet<T>, right: ReadonlySet<T>): boolean {
for (const value of left) {
if (right.has(value)) {
return true
}
}
return false
}