Add terminal shortcut routing policy (#2610)

This commit is contained in:
Neil
2026-05-21 23:57:53 -07:00
committed by GitHub
parent 31859d47fa
commit 81fcfea965
26 changed files with 999 additions and 193 deletions
+3
View File
@@ -217,6 +217,9 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
showTitlebarAppName: true,
showTasksButton: true,
ctrlTabOrderMode: 'mru',
// Why: switching worktrees and opening command surfaces from a focused
// terminal is a core Orca workflow; users who prefer TUI ownership opt in.
terminalShortcutPolicy: 'orca-first',
floatingTerminalEnabled: true,
floatingTerminalDefaultedForAllUsers: true,
floatingTerminalCwd: '~',
+40 -3
View File
@@ -132,7 +132,7 @@ describe('keybindings', () => {
})
})
it('matches shortcuts from the same defaults regardless of caller context', () => {
it('keeps Orca-first terminal context backward compatible', () => {
const ctrlP = {
key: 'p',
code: 'KeyP',
@@ -145,16 +145,53 @@ describe('keybindings', () => {
expect(keybindingMatchesAction('worktree.quickOpen', ctrlP, 'linux')).toBe(true)
expect(
keybindingMatchesAction('worktree.quickOpen', ctrlP, 'linux', undefined, {
context: 'terminal'
context: 'terminal',
terminalShortcutPolicy: 'orca-first'
})
).toBe(true)
expect(
keybindingMatchesAction('worktree.quickOpen', ctrlP, 'linux', undefined, {
context: 'terminal',
terminalShortcutPolicy: 'terminal-first'
})
).toBe(false)
expect(
keybindingMatchesAction(
'terminal.search',
{ key: 'f', code: 'KeyF', control: true, meta: false, alt: false, shift: false },
'linux',
undefined,
{ context: 'terminal' }
{ context: 'terminal', terminalShortcutPolicy: 'terminal-first' }
)
).toBe(true)
})
it('keeps terminal-allowed app shortcuts active in terminal-first mode', () => {
expect(
keybindingMatchesAction(
'floatingTerminal.toggle',
{ key: 't', code: 'KeyT', control: true, meta: false, alt: true, shift: false },
'linux',
undefined,
{ context: 'terminal', terminalShortcutPolicy: 'terminal-first' }
)
).toBe(true)
expect(
keybindingMatchesAction(
'tab.previousRecent',
{ key: 'Tab', code: 'Tab', control: true, meta: false, alt: false, shift: false },
'linux',
undefined,
{ context: 'terminal', terminalShortcutPolicy: 'terminal-first' }
)
).toBe(true)
expect(
keybindingMatchesAction(
'worktree.palette',
{ key: 'j', code: 'KeyJ', control: false, meta: true, alt: false, shift: false },
'darwin',
undefined,
{ context: 'app', terminalShortcutPolicy: 'terminal-first' }
)
).toBe(true)
})
+55 -2
View File
@@ -16,6 +16,13 @@ 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 KeybindingActionId =
| 'worktree.quickOpen'
| 'worktree.palette'
@@ -787,7 +794,14 @@ function normalizeKeyToken(token: string): string | null {
BRACKETLEFT: 'BracketLeft',
BRACKETRIGHT: 'BracketRight',
NUMPADADD: 'NumpadAdd',
NUMPADSUBTRACT: 'NumpadSubtract'
NUMPADSUBTRACT: 'NumpadSubtract',
COMMA: 'Comma',
PERIOD: 'Period',
SLASH: 'Slash',
BACKSLASH: 'Backslash',
SEMICOLON: 'Semicolon',
QUOTE: 'Quote',
BACKQUOTE: 'Backquote'
}
return simple[upper] ?? null
@@ -1101,6 +1115,35 @@ export function getKeybindingDefinition(actionId: KeybindingActionId): Keybindin
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
@@ -1189,12 +1232,15 @@ export function keybindingMatchesAction(
input: KeybindingInput,
platform: NodeJS.Platform,
overrides?: KeybindingOverrides,
_options: { context?: KeybindingContext } = {}
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)
)
@@ -1279,6 +1325,13 @@ function formatKeyToken(token: string): string {
PageDown: 'PageDown',
NumpadAdd: 'Numpad +',
NumpadSubtract: 'Numpad -',
Comma: ',',
Period: '.',
Slash: '/',
Backslash: '\\',
Semicolon: ';',
Quote: "'",
Backquote: '`',
Enter: 'Enter',
Backspace: 'Backspace',
Delete: 'Delete',
+4 -1
View File
@@ -14,7 +14,7 @@ import type { GitLabProjectSettings } from './gitlab-types'
import type { TaskProvider } from './task-providers'
import type { FeatureTipId } from './feature-tips'
import type { GitBranchChangeStatus } from './git-status-types'
import type { KeybindingOverrides } from './keybindings'
import type { KeybindingOverrides, TerminalShortcutPolicy } from './keybindings'
// Re-exported for backward compat with renderer call sites that import
// `WorkspaceCreateTelemetrySource` from '../../../shared/types'.
@@ -1627,6 +1627,9 @@ export type GlobalSettings = {
/** Controls how Ctrl+Tab chooses the next visible tab. Optional for
* profiles saved before this setting existed; readers default to MRU. */
ctrlTabOrderMode?: CtrlTabOrderMode
/** Why: Orca-first preserves fast workspace/app control from agent TUIs.
* Terminal-first is opt-in for users who want shell/TUI bindings to win. */
terminalShortcutPolicy?: TerminalShortcutPolicy
/** Why: Floating Workspace is the default global surface so users can
* reach terminal, browser, and markdown tabs outside repo/worktree context. */
floatingTerminalEnabled: boolean
+70
View File
@@ -44,6 +44,13 @@ describe('resolveWindowShortcutAction', () => {
})
it('resolves the explicit window shortcut allowlist on macOS', () => {
expect(
resolveWindowShortcutAction(
{ code: 'Comma', key: ',', meta: true, control: false, alt: false, shift: false },
'darwin'
)
).toEqual({ type: 'openSettings' })
expect(
resolveWindowShortcutAction(
{ code: 'KeyJ', key: 'j', meta: true, control: false, alt: false, shift: false },
@@ -66,6 +73,69 @@ describe('resolveWindowShortcutAction', () => {
).toEqual({ type: 'jumpToWorktreeIndex', index: 2 })
})
it('keeps Orca-first active in terminal context but lets Terminal-first pass risky app chords', () => {
const macWorktreePalette = {
code: 'KeyJ',
key: 'j',
meta: true,
control: false,
alt: false,
shift: false
}
expect(
resolveWindowShortcutAction(macWorktreePalette, 'darwin', undefined, {
context: 'terminal',
terminalShortcutPolicy: 'orca-first'
})
).toEqual({ type: 'toggleWorktreePalette' })
expect(
resolveWindowShortcutAction(macWorktreePalette, 'darwin', undefined, {
context: 'terminal',
terminalShortcutPolicy: 'terminal-first'
})
).toBeNull()
expect(
resolveWindowShortcutAction(
{ code: 'Digit3', key: '3', meta: true, control: false, alt: false, shift: false },
'darwin',
undefined,
{ context: 'terminal', terminalShortcutPolicy: 'terminal-first' }
)
).toBeNull()
expect(
resolveWindowShortcutAction(
{ code: 'Tab', key: 'Tab', meta: false, control: true, alt: false, shift: false },
'linux',
undefined,
{ context: 'terminal', terminalShortcutPolicy: 'terminal-first' }
)
).toEqual({ type: 'switchRecentTab' })
})
it('routes menu-backed actions through the same window shortcut policy', () => {
expect(
resolveWindowShortcutAction(
{ code: 'KeyE', key: 'e', meta: true, control: false, alt: false, shift: true },
'darwin'
)
).toEqual({ type: 'exportPdf' })
expect(
resolveWindowShortcutAction(
{ code: 'KeyR', key: 'r', meta: false, control: true, alt: false, shift: true },
'linux'
)
).toEqual({ type: 'forceReload' })
expect(
resolveWindowShortcutAction(
{ code: 'KeyR', key: 'r', meta: false, control: true, alt: false, shift: true },
'linux',
undefined,
{ context: 'terminal', terminalShortcutPolicy: 'terminal-first' }
)
).toBeNull()
})
it('requires shift for the non-mac worktree palette shortcut', () => {
expect(
resolveWindowShortcutAction(
+119 -18
View File
@@ -1,4 +1,13 @@
import { keybindingMatchesAction, type KeybindingOverrides } from './keybindings'
import {
getKeybindingDefinition,
isKeybindingAllowedInTerminal,
isKeybindingPotentialTerminalConflict,
keybindingMatchesAction,
normalizeTerminalShortcutPolicy,
type KeybindingActionId,
type KeybindingMatchOptions,
type KeybindingOverrides
} from './keybindings'
export type WindowShortcutInput = {
type?: string
@@ -16,6 +25,9 @@ export type WindowShortcutInput = {
export type WindowShortcutAction =
| { type: 'zoom'; direction: 'in' | 'out' | 'reset' }
| { type: 'openSettings' }
| { type: 'exportPdf' }
| { type: 'forceReload' }
| { type: 'toggleWorktreePalette' }
| { type: 'toggleFloatingTerminal' }
| { type: 'toggleLeftSidebar' }
@@ -28,6 +40,8 @@ export type WindowShortcutAction =
| { type: 'worktreeHistoryNavigate'; direction: 'back' | 'forward' }
| { type: 'dictationKeyDown' }
type WindowShortcutResolveOptions = KeybindingMatchOptions
function platformPrimaryModifier(
input: Pick<WindowShortcutInput, 'meta' | 'control'>,
platform: NodeJS.Platform
@@ -45,7 +59,8 @@ export function isWindowShortcutModifierChord(
export function matchesRecentTabSwitcherChord(
input: WindowShortcutInput,
platform: NodeJS.Platform,
keybindings?: KeybindingOverrides
keybindings?: KeybindingOverrides,
options: WindowShortcutResolveOptions = {}
): boolean {
const control = Boolean(input.control ?? input.ctrlKey)
const meta = Boolean(input.meta ?? input.metaKey)
@@ -70,58 +85,89 @@ export function matchesRecentTabSwitcherChord(
shiftKey: false
},
platform,
keybindings
keybindings,
options
)
}
function actionMatches(
actionId: KeybindingActionId,
input: WindowShortcutInput,
platform: NodeJS.Platform,
keybindings: KeybindingOverrides | undefined,
options: WindowShortcutResolveOptions
): boolean {
return keybindingMatchesAction(actionId, input, platform, keybindings, options)
}
function implicitWorktreeIndexShortcutAllowed(options: WindowShortcutResolveOptions): boolean {
if (options.context !== 'terminal') {
return true
}
return normalizeTerminalShortcutPolicy(options.terminalShortcutPolicy) === 'orca-first'
}
export function resolveWindowShortcutAction(
input: WindowShortcutInput,
platform: NodeJS.Platform,
keybindings?: KeybindingOverrides
keybindings?: KeybindingOverrides,
options: WindowShortcutResolveOptions = {}
): WindowShortcutAction | null {
if (keybindingMatchesAction('worktree.history.back', input, platform, keybindings)) {
if (actionMatches('worktree.history.back', input, platform, keybindings, options)) {
return {
type: 'worktreeHistoryNavigate',
direction: 'back'
}
}
if (keybindingMatchesAction('worktree.history.forward', input, platform, keybindings)) {
if (actionMatches('worktree.history.forward', input, platform, keybindings, options)) {
return {
type: 'worktreeHistoryNavigate',
direction: 'forward'
}
}
if (keybindingMatchesAction('floatingTerminal.toggle', input, platform, keybindings)) {
if (actionMatches('floatingTerminal.toggle', input, platform, keybindings, options)) {
return { type: 'toggleFloatingTerminal' }
}
if (keybindingMatchesAction('zoom.in', input, platform, keybindings)) {
if (actionMatches('zoom.in', input, platform, keybindings, options)) {
return { type: 'zoom', direction: 'in' }
}
if (keybindingMatchesAction('zoom.out', input, platform, keybindings)) {
if (actionMatches('zoom.out', input, platform, keybindings, options)) {
return { type: 'zoom', direction: 'out' }
}
if (keybindingMatchesAction('zoom.reset', input, platform, keybindings)) {
if (actionMatches('zoom.reset', input, platform, keybindings, options)) {
return { type: 'zoom', direction: 'reset' }
}
if (keybindingMatchesAction('worktree.palette', input, platform, keybindings)) {
if (actionMatches('app.settings', input, platform, keybindings, options)) {
return { type: 'openSettings' }
}
if (actionMatches('file.exportPdf', input, platform, keybindings, options)) {
return { type: 'exportPdf' }
}
if (actionMatches('app.forceReload', input, platform, keybindings, options)) {
return { type: 'forceReload' }
}
if (actionMatches('worktree.palette', input, platform, keybindings, options)) {
return { type: 'toggleWorktreePalette' }
}
if (keybindingMatchesAction('sidebar.left.toggle', input, platform, keybindings)) {
if (actionMatches('sidebar.left.toggle', input, platform, keybindings, options)) {
return { type: 'toggleLeftSidebar' }
}
if (keybindingMatchesAction('sidebar.right.toggle', input, platform, keybindings)) {
if (actionMatches('sidebar.right.toggle', input, platform, keybindings, options)) {
return { type: 'toggleRightSidebar' }
}
if (keybindingMatchesAction('worktree.quickOpen', input, platform, keybindings)) {
if (actionMatches('worktree.quickOpen', input, platform, keybindings, options)) {
return { type: 'openQuickOpen' }
}
@@ -131,23 +177,24 @@ export function resolveWindowShortcutAction(
// webContents, both of which bypass the renderer's window-level keydown.
// Shift is accepted for compatibility with the former Create-from shortcut;
// the unified composer now exposes source switching inside the name field.
if (keybindingMatchesAction('workspace.create', input, platform, keybindings)) {
if (actionMatches('workspace.create', input, platform, keybindings, options)) {
return { type: 'openNewWorkspace' }
}
if (keybindingMatchesAction('voice.dictation', input, platform, keybindings)) {
if (actionMatches('voice.dictation', input, platform, keybindings, options)) {
return { type: 'dictationKeyDown' }
}
if (keybindingMatchesAction('view.tasks', input, platform, keybindings)) {
if (actionMatches('view.tasks', input, platform, keybindings, options)) {
return { type: 'openTasks' }
}
if (keybindingMatchesAction('tab.previousRecent', input, platform, keybindings)) {
if (actionMatches('tab.previousRecent', input, platform, keybindings, options)) {
return { type: 'switchRecentTab' }
}
if (
implicitWorktreeIndexShortcutAllowed(options) &&
platformPrimaryModifier(input, platform) &&
!input.alt &&
!input.shift &&
@@ -164,3 +211,57 @@ export function resolveWindowShortcutAction(
// terminals own focus.
return null
}
export function getWindowShortcutActionId(action: WindowShortcutAction): KeybindingActionId | null {
switch (action.type) {
case 'zoom':
return action.direction === 'in'
? 'zoom.in'
: action.direction === 'out'
? 'zoom.out'
: 'zoom.reset'
case 'openSettings':
return 'app.settings'
case 'exportPdf':
return 'file.exportPdf'
case 'forceReload':
return 'app.forceReload'
case 'toggleWorktreePalette':
return 'worktree.palette'
case 'toggleFloatingTerminal':
return 'floatingTerminal.toggle'
case 'toggleLeftSidebar':
return 'sidebar.left.toggle'
case 'toggleRightSidebar':
return 'sidebar.right.toggle'
case 'openQuickOpen':
return 'worktree.quickOpen'
case 'openNewWorkspace':
return 'workspace.create'
case 'openTasks':
return 'view.tasks'
case 'switchRecentTab':
return 'tab.previousRecent'
case 'worktreeHistoryNavigate':
return action.direction === 'back' ? 'worktree.history.back' : 'worktree.history.forward'
case 'dictationKeyDown':
return 'voice.dictation'
case 'jumpToWorktreeIndex':
return null
}
}
export function windowShortcutActionCapturesTerminal(action: WindowShortcutAction): boolean {
if (action.type === 'jumpToWorktreeIndex') {
return true
}
const actionId = getWindowShortcutActionId(action)
if (!actionId) {
return false
}
const definition = getKeybindingDefinition(actionId)
if (!definition || isKeybindingAllowedInTerminal(definition)) {
return false
}
return isKeybindingPotentialTerminalConflict(definition)
}