mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 08:02:32 +00:00
Add terminal shortcut routing policy (#2610)
This commit is contained in:
@@ -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: '~',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user