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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 999 additions and 193 deletions

View File

@ -381,6 +381,12 @@ export function setupGuestShortcutForwarding(args: {
renderer.send('ui:openNewWorkspace')
} else if (action?.type === 'openTasks') {
renderer.send('ui:openTasks')
} else if (action?.type === 'openSettings') {
renderer.send('ui:openSettings')
} else if (action?.type === 'exportPdf') {
renderer.send('export:requestPdf')
} else if (action?.type === 'forceReload') {
renderer.reloadIgnoringCache()
} else if (action?.type === 'jumpToWorktreeIndex') {
renderer.send('ui:jumpToWorktreeIndex', action.index)
} else if (action?.type === 'dictationKeyDown') {

View File

@ -346,7 +346,13 @@ function openMainWindow(): BrowserWindow {
}),
deferLoad: true,
title: devInstanceIdentity.name,
getKeybindings: () => keybindings?.getOverrides()
getKeybindings: () => keybindings?.getOverrides(),
onBeforeReload: ({ ignoreCache, webContentsId }) => {
if (mainWindow?.webContents.id === webContentsId) {
markExpectedRendererReload(webContentsId)
}
recordCrashBreadcrumb('manual_reload_requested', { ignoreCache })
}
})
recordCrashBreadcrumb('main_window_created')

View File

@ -64,21 +64,22 @@ describe('registerAppMenu', () => {
buildFromTemplateMock.mockImplementation((template) => ({ template }))
})
it('uses a reload menu item without a ctrl/cmd+r accelerator', () => {
it('shows reload shortcuts as policy-routed menu hints', () => {
registerAppMenu(buildMenuOptions())
expect(buildFromTemplateMock).toHaveBeenCalledTimes(1)
const viewSubmenu = getSubmenu(getTemplate(), 'View')
const expectedForceReloadLabel = `Force Reload\t${isMac ? '⌘⇧R' : 'Ctrl+Shift+R'}`
expect(viewSubmenu).toEqual(
expect.arrayContaining([
expect.objectContaining({ label: 'Reload' }),
expect.objectContaining({ label: 'Force Reload', accelerator: 'CmdOrCtrl+Shift+R' })
])
expect.arrayContaining([expect.objectContaining({ label: 'Reload' })])
)
const reloadItem = viewSubmenu.find((item) => item.label === 'Reload')
expect(reloadItem?.accelerator).toBeUndefined()
const forceReloadItem = viewSubmenu.find((item) => item.label === expectedForceReloadLabel)
expect(forceReloadItem).toBeDefined()
expect(forceReloadItem?.accelerator).toBeUndefined()
})
it('reloads the focused window from the view menu', () => {
@ -119,8 +120,8 @@ describe('registerAppMenu', () => {
registerAppMenu(options)
const forceReloadItem = getSubmenu(getTemplate(), 'View').find(
(item) => item.label === 'Force Reload'
const forceReloadItem = getSubmenu(getTemplate(), 'View').find((item) =>
item.label?.startsWith('Force Reload\t')
)
forceReloadItem?.click?.({} as never, {} as never, {} as never)
@ -179,7 +180,13 @@ describe('registerAppMenu', () => {
expect(template.find((item) => item.label === 'Orca')).toBeUndefined()
const fileLabels = getSubmenu(template, 'File').map((item) => item.label)
expect(fileLabels).toEqual(expect.arrayContaining(['Export as PDF...', 'Settings', 'Exit']))
expect(fileLabels).toEqual(
expect.arrayContaining([
`Export as PDF...\t${isMac ? '⌘⇧E' : 'Ctrl+Shift+E'}`,
`Settings\t${isMac ? '⌘,' : 'Ctrl+,'}`,
'Exit'
])
)
const helpLabels = getSubmenu(template, 'Help').map((item) => item.label)
expect(helpLabels).toEqual(
@ -193,11 +200,13 @@ describe('registerAppMenu', () => {
const template = getTemplate()
const appSubmenu = getSubmenu(template, 'Orca')
const appLabels = appSubmenu.map((item) => item.label)
expect(appLabels).toEqual(expect.arrayContaining(['Check for Updates...', 'Settings']))
expect(appLabels).toEqual(
expect.arrayContaining(['Check for Updates...', `Settings\t${isMac ? '⌘,' : 'Ctrl+,'}`])
)
// Why: on macOS File should NOT duplicate Settings/Exit — those live in
// the system app menu, so only Export belongs under File.
const fileLabels = getSubmenu(template, 'File').map((item) => item.label)
expect(fileLabels).not.toContain('Settings')
expect(fileLabels).not.toContain(`Settings\t${isMac ? '⌘,' : 'Ctrl+,'}`)
expect(fileLabels).not.toContain('Exit')
const helpLabels = getSubmenu(template, 'Help').map((item) => item.label)
expect(helpLabels).toEqual(['Report Crash...', undefined, 'Feature tour'])

View File

@ -1,6 +1,5 @@
import { BrowserWindow, Menu, app } from 'electron'
import {
formatElectronAccelerator,
formatKeybindingList,
getEffectiveKeybindingsForAction,
type KeybindingActionId,
@ -58,14 +57,6 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void {
)
return formatKeybindingList(bindings, process.platform)
}
const shortcutAccelerator = (actionId: KeybindingActionId): string | undefined => {
const binding = getEffectiveKeybindingsForAction(
actionId,
process.platform,
getKeybindings?.()
)[0]
return binding ? (formatElectronAccelerator(binding) ?? undefined) : undefined
}
const reloadFocusedWindow = (ignoreCache: boolean): void => {
const webContents = BrowserWindow.getFocusedWindow()?.webContents
@ -101,8 +92,7 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void {
}
const settingsItem: Electron.MenuItemConstructorOptions = {
label: 'Settings',
accelerator: shortcutAccelerator('app.settings'),
label: `Settings\t${shortcutLabel('app.settings')}`,
click: () => onOpenSettings()
}
@ -117,8 +107,7 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void {
}
const exportPdfItem: Electron.MenuItemConstructorOptions = {
label: 'Export as PDF...',
accelerator: shortcutAccelerator('file.exportPdf'),
label: `Export as PDF...\t${shortcutLabel('file.exportPdf')}`,
click: () => {
// Why: fire a one-way event into the focused renderer. The renderer
// owns the knowledge of whether a markdown surface is active and
@ -240,8 +229,7 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void {
click: () => reloadFocusedWindow(false)
},
{
label: 'Force Reload',
accelerator: shortcutAccelerator('app.forceReload'),
label: `Force Reload\t${shortcutLabel('app.forceReload')}`,
click: () => reloadFocusedWindow(true)
},
{ role: 'toggleDevTools' },

View File

@ -766,6 +766,21 @@ describe('Store', () => {
expect(store.getSettings().visibleTaskProviders).toEqual(['gitlab'])
})
it('normalizes malformed terminal shortcut policy on load', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: { terminalShortcutPolicy: 'terminal-maybe' },
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(store.getSettings().terminalShortcutPolicy).toBe('orca-first')
})
it('repairs drifted task provider defaults on load', async () => {
writeDataFile({
schemaVersion: 1,
@ -1499,6 +1514,16 @@ describe('Store', () => {
expect(store.getSettings().sourceControlViewMode).toBe('tree')
})
it('updateSettings normalizes terminal shortcut policy', async () => {
const store = await createStore()
store.updateSettings({ terminalShortcutPolicy: 'terminal-first' })
expect(store.getSettings().terminalShortcutPolicy).toBe('terminal-first')
store.updateSettings({ terminalShortcutPolicy: 'terminal-maybe' as never })
expect(store.getSettings().terminalShortcutPolicy).toBe('orca-first')
})
it('reloads sourceControlViewMode from global settings without touching workspace state', async () => {
const workspaceSession = {
activeRepoId: 'r1',

View File

@ -81,6 +81,7 @@ import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId } from '../share
import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-commands'
import { normalizeTaskProviderSettings } from '../shared/task-providers'
import { normalizeOpenInApplications } from '../shared/open-in-applications'
import { normalizeTerminalShortcutPolicy } from '../shared/keybindings'
import {
DEFAULT_WORKSPACE_STATUS_ID,
clampWorkspaceBoardColumnWidth,
@ -1450,6 +1451,9 @@ export class Store {
),
defaultTaskSource: taskProviderSettings.defaultTaskSource,
visibleTaskProviders: taskProviderSettings.visibleTaskProviders,
terminalShortcutPolicy: normalizeTerminalShortcutPolicy(
parsed.settings?.terminalShortcutPolicy
),
openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications),
notifications: normalizeNotificationSettings(parsed.settings?.notifications),
voice: {
@ -2344,6 +2348,11 @@ export class Store {
if ('openInApplications' in updates) {
sanitizedUpdates.openInApplications = normalizeOpenInApplications(updates.openInApplications)
}
if ('terminalShortcutPolicy' in updates) {
sanitizedUpdates.terminalShortcutPolicy = normalizeTerminalShortcutPolicy(
updates.terminalShortcutPolicy
)
}
// Why: `telemetry` is deep-merged for the same reason `notifications` is —
// partial updates from the Privacy pane / consent flow (e.g., flipping
// only `optedIn`) must not clobber sibling fields like `installId` or

View File

@ -708,6 +708,133 @@ describe('createMainWindow', () => {
expect(webContents.send).toHaveBeenNthCalledWith(2, 'ui:toggleWorktreePalette')
})
it('lets Terminal-first pass risky app shortcuts through when terminal input is focused', () => {
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const webContents = {
on: vi.fn((event, handler) => {
windowHandlers[event] = handler
}),
setZoomLevel: vi.fn(),
setBackgroundThrottling: vi.fn(),
invalidate: vi.fn(),
setWindowOpenHandler: vi.fn(),
send: vi.fn(),
isDevToolsOpened: vi.fn(),
openDevTools: vi.fn(),
closeDevTools: vi.fn()
}
const browserWindowInstance = {
webContents,
on: vi.fn(),
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => true),
isFullScreen: vi.fn(() => false),
getSize: vi.fn(() => [1200, 800]),
setSize: vi.fn(),
maximize: vi.fn(),
show: vi.fn(),
loadFile: vi.fn(),
loadURL: vi.fn()
}
browserWindowMock.mockImplementation(function () {
return browserWindowInstance
})
createMainWindow({
getUI: () => ({}),
getSettings: () => ({ terminalShortcutPolicy: 'terminal-first' })
} as never)
const setFocusedListener = vi
.mocked(ipcMain.on)
.mock.calls.find(([channel]) => channel === 'ui:setTerminalInputFocused')?.[1]
expect(setFocusedListener).toBeTypeOf('function')
setFocusedListener?.({ sender: webContents } as never, true)
const preventDefault = vi.fn()
const isDarwin = process.platform === 'darwin'
windowHandlers['before-input-event'](
{ preventDefault } as never,
{
type: 'keyDown',
code: 'KeyJ',
key: 'j',
meta: isDarwin,
control: !isDarwin,
alt: false,
shift: !isDarwin
} as never
)
expect(preventDefault).not.toHaveBeenCalled()
expect(webContents.send).not.toHaveBeenCalled()
})
it('notifies before Orca-first captures a risky terminal-focused shortcut', () => {
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const webContents = {
on: vi.fn((event, handler) => {
windowHandlers[event] = handler
}),
setZoomLevel: vi.fn(),
setBackgroundThrottling: vi.fn(),
invalidate: vi.fn(),
setWindowOpenHandler: vi.fn(),
send: vi.fn(),
isDevToolsOpened: vi.fn(),
openDevTools: vi.fn(),
closeDevTools: vi.fn()
}
const browserWindowInstance = {
webContents,
on: vi.fn(),
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => true),
isFullScreen: vi.fn(() => false),
getSize: vi.fn(() => [1200, 800]),
setSize: vi.fn(),
maximize: vi.fn(),
show: vi.fn(),
loadFile: vi.fn(),
loadURL: vi.fn()
}
browserWindowMock.mockImplementation(function () {
return browserWindowInstance
})
createMainWindow({
getUI: () => ({}),
getSettings: () => ({ terminalShortcutPolicy: 'orca-first' })
} as never)
const setFocusedListener = vi
.mocked(ipcMain.on)
.mock.calls.find(([channel]) => channel === 'ui:setTerminalInputFocused')?.[1]
expect(setFocusedListener).toBeTypeOf('function')
setFocusedListener?.({ sender: webContents } as never, true)
const preventDefault = vi.fn()
const isDarwin = process.platform === 'darwin'
windowHandlers['before-input-event'](
{ preventDefault } as never,
{
type: 'keyDown',
code: 'KeyJ',
key: 'j',
meta: isDarwin,
control: !isDarwin,
alt: false,
shift: !isDarwin
} as never
)
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(webContents.send).toHaveBeenNthCalledWith(1, 'ui:terminalShortcutCaptured', {
actionId: 'worktree.palette'
})
expect(webContents.send).toHaveBeenNthCalledWith(2, 'ui:toggleWorktreePalette')
})
it('toggles devtools on F12 in development', () => {
isMock.dev = true

View File

@ -13,10 +13,17 @@ import {
} from '../../shared/browser-url'
import { isCrashReportReason } from '../../shared/crash-reporting'
import {
getWindowShortcutActionId,
matchesRecentTabSwitcherChord,
resolveWindowShortcutAction
resolveWindowShortcutAction,
windowShortcutActionCapturesTerminal
} from '../../shared/window-shortcut-policy'
import { keybindingMatchesAction, type KeybindingOverrides } from '../../shared/keybindings'
import {
keybindingMatchesAction,
normalizeTerminalShortcutPolicy,
type KeybindingMatchOptions,
type KeybindingOverrides
} from '../../shared/keybindings'
import { getMainE2EConfig } from '../e2e-config'
import { buildEditableContextMenuTemplate } from './editable-context-menu'
@ -44,7 +51,8 @@ function isControlKeyRelease(input: Electron.Input): boolean {
function nativeZoomCommandMatchesKeybindings(
direction: 'in' | 'out',
platform: NodeJS.Platform,
keybindings?: KeybindingOverrides
keybindings?: KeybindingOverrides,
options: KeybindingMatchOptions = {}
): boolean {
const primary =
platform === 'darwin' ? { meta: true, control: false } : { meta: false, control: true }
@ -66,7 +74,8 @@ function nativeZoomCommandMatchesKeybindings(
actionId,
{ ...primary, alt: false, ...candidate },
platform,
keybindings
keybindings,
options
)
)
}
@ -122,6 +131,7 @@ type CreateMainWindowOptions = {
deferLoad?: boolean
title?: string
getKeybindings?: () => KeybindingOverrides | undefined
onBeforeReload?: (options: { ignoreCache: boolean; webContentsId: number }) => void
}
export function loadMainWindow(mainWindow: BrowserWindow): void {
@ -496,6 +506,7 @@ export function createMainWindow(
// Cmd+B so browser guests and other editable surfaces keep the existing
// global shortcut behavior.
let markdownEditorFocused = false
let terminalInputFocused = false
let floatingTerminalInputFocused = false
const markdownFocusChannel = 'ui:setMarkdownEditorFocused'
@ -512,13 +523,20 @@ export function createMainWindow(
markdownEditorFocused = focused === true
}
ipcMain.on(markdownFocusChannel, onMarkdownEditorFocused)
const terminalInputFocusChannel = 'ui:setTerminalInputFocused'
// Why: before-input-event resolves shortcuts before renderer keydown. Mirror
// regular xterm focus so Terminal-first can let shells/TUIs own app chords.
const onTerminalInputFocused = (event: Electron.IpcMainEvent, focused: unknown): void => {
if (event.sender !== mainWindow.webContents) {
return
}
terminalInputFocused = focused === true
}
ipcMain.on(terminalInputFocusChannel, onTerminalInputFocused)
const floatingTerminalInputFocusChannel = 'ui:setFloatingTerminalInputFocused'
// Why: main before-input-event runs before renderer keydown handlers. Mirror
// floating xterm focus so Ctrl+B/L and related shell chords can reach SSH/tmux.
const onFloatingTerminalInputFocused = (
event: Electron.IpcMainEvent,
focused: unknown
): void => {
const onFloatingTerminalInputFocused = (event: Electron.IpcMainEvent, focused: unknown): void => {
if (event.sender !== mainWindow.webContents) {
return
}
@ -544,6 +562,9 @@ export function createMainWindow(
const resetMarkdownEditorFocus = (): void => {
markdownEditorFocused = false
}
const resetTerminalInputFocus = (): void => {
terminalInputFocused = false
}
const resetFloatingTerminalInputFocus = (): void => {
floatingTerminalInputFocused = false
}
@ -586,6 +607,7 @@ export function createMainWindow(
mainWindow.webContents.on('render-process-gone', (_event, details) => {
rendererProcessGone = true
resetMarkdownEditorFocus()
resetTerminalInputFocus()
resetFloatingTerminalInputFocus()
if (opts?.shouldRecordRendererCrash?.(details, rendererWebContentsId) !== false) {
opts?.onRendererProcessGone?.(details, rendererWebContentsId)
@ -595,11 +617,13 @@ export function createMainWindow(
})
mainWindow.webContents.on('destroyed', () => {
resetMarkdownEditorFocus()
resetTerminalInputFocus()
resetFloatingTerminalInputFocus()
})
mainWindow.webContents.on('did-start-navigation', (_e, _url, _isInPlace, isMainFrame) => {
if (isMainFrame) {
resetMarkdownEditorFocus()
resetTerminalInputFocus()
resetFloatingTerminalInputFocus()
}
})
@ -621,7 +645,15 @@ export function createMainWindow(
}
const keybindings = opts?.getKeybindings?.()
if (matchesRecentTabSwitcherChord(input, process.platform, keybindings)) {
const terminalShortcutContext: KeybindingMatchOptions = {
context: terminalInputFocused || floatingTerminalInputFocused ? 'terminal' : 'app',
terminalShortcutPolicy: normalizeTerminalShortcutPolicy(
store?.getSettings().terminalShortcutPolicy
)
}
if (
matchesRecentTabSwitcherChord(input, process.platform, keybindings, terminalShortcutContext)
) {
// Why: Ctrl+Tab is a held-key interaction. Route both press and release
// through IPC so renderer keyup suppression from preventDefault cannot
// leave the switcher overlay stranded.
@ -659,7 +691,12 @@ export function createMainWindow(
// Why: keep the main-process interception surface as an explicit allowlist.
// Anything outside this helper must continue to the renderer/PTTY so
// readline control chords are not silently stolen above the terminal.
const action = resolveWindowShortcutAction(input, process.platform, keybindings)
const action = resolveWindowShortcutAction(
input,
process.platform,
keybindings,
terminalShortcutContext
)
if (!action) {
return
}
@ -677,6 +714,13 @@ export function createMainWindow(
return
}
const capturedTerminalActionId =
terminalShortcutContext.context === 'terminal' &&
terminalShortcutContext.terminalShortcutPolicy === 'orca-first' &&
windowShortcutActionCapturesTerminal(action)
? getWindowShortcutActionId(action)
: null
// Why: in hold mode, Cmd+E must NOT be intercepted here. Calling
// preventDefault() in before-input-event suppresses ALL subsequent DOM
// events for the key combo — including the keyUp the renderer needs to
@ -697,17 +741,46 @@ export function createMainWindow(
return
}
event.preventDefault()
if (capturedTerminalActionId) {
mainWindow.webContents.send('ui:terminalShortcutCaptured', {
actionId: capturedTerminalActionId
})
}
mainWindow.webContents.send('ui:dictationKeyDown')
return
}
event.preventDefault()
if (capturedTerminalActionId) {
mainWindow.webContents.send('ui:terminalShortcutCaptured', {
actionId: capturedTerminalActionId
})
}
if (action.type === 'zoom') {
mainWindow.webContents.send('terminal:zoom', action.direction)
return
}
if (action.type === 'openSettings') {
mainWindow.webContents.send('ui:openSettings')
return
}
if (action.type === 'exportPdf') {
mainWindow.webContents.send('export:requestPdf')
return
}
if (action.type === 'forceReload') {
opts?.onBeforeReload?.({
ignoreCache: true,
webContentsId: mainWindow.webContents.id
})
mainWindow.webContents.reloadIgnoringCache()
return
}
if (action.type === 'toggleLeftSidebar') {
mainWindow.webContents.send('ui:toggleLeftSidebar')
return
@ -779,7 +852,13 @@ export function createMainWindow(
!nativeZoomCommandMatchesKeybindings(
zoomDirection,
process.platform,
opts?.getKeybindings?.()
opts?.getKeybindings?.(),
{
context: terminalInputFocused || floatingTerminalInputFocused ? 'terminal' : 'app',
terminalShortcutPolicy: normalizeTerminalShortcutPolicy(
store?.getSettings().terminalShortcutPolicy
)
}
)
) {
return
@ -907,6 +986,7 @@ export function createMainWindow(
// stale-true flag can't leak past subsequent state transitions. Paired
// with the webContents lifecycle resets above.
markdownEditorFocused = false
terminalInputFocused = false
floatingTerminalInputFocused = false
clearRendererRecoveryTimer()
ipcMain.removeListener(trafficLightChannel, onSyncTrafficLights)
@ -918,6 +998,7 @@ export function createMainWindow(
ipcMain.removeHandler(isMaximizedChannel)
ipcMain.removeListener(confirmCloseChannel, onConfirmClose)
ipcMain.removeListener(markdownFocusChannel, onMarkdownEditorFocused)
ipcMain.removeListener(terminalInputFocusChannel, onTerminalInputFocused)
ipcMain.removeListener(floatingTerminalInputFocusChannel, onFloatingTerminalInputFocused)
// Why: on updater-triggered shutdown, BrowserWindow can emit `closed`
// after its webContents has already been destroyed. The destroyed

View File

@ -1694,6 +1694,9 @@ export type PreloadApi = {
onToggleRightSidebar: (callback: () => void) => () => void
onToggleWorktreePalette: (callback: () => void) => () => void
onToggleFloatingTerminal: (callback: () => void) => () => void
onTerminalShortcutCaptured: (
callback: (data: { actionId: KeybindingActionId }) => void
) => () => void
onOpenQuickOpen: (callback: () => void) => () => void
onOpenNewWorkspace: (callback: () => void) => () => void
onOpenTasks: (callback: () => void) => () => void
@ -1842,6 +1845,7 @@ export type PreloadApi = {
setZoomLevel: (level: number) => void
syncTrafficLights: (zoomFactor: number) => void
setMarkdownEditorFocused: (focused: boolean) => void
setTerminalInputFocused: (focused: boolean) => void
setFloatingTerminalInputFocused: (focused: boolean) => void
onRichMarkdownContextCommand: (
callback: (payload: RichMarkdownContextMenuCommandPayload) => void

View File

@ -2253,6 +2253,16 @@ const api = {
ipcRenderer.on('ui:toggleFloatingTerminal', listener)
return () => ipcRenderer.removeListener('ui:toggleFloatingTerminal', listener)
},
onTerminalShortcutCaptured: (
callback: (data: { actionId: KeybindingActionId }) => void
): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
data: { actionId: KeybindingActionId }
) => callback(data)
ipcRenderer.on('ui:terminalShortcutCaptured', listener)
return () => ipcRenderer.removeListener('ui:terminalShortcutCaptured', listener)
},
onOpenQuickOpen: (callback: () => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent) => callback()
ipcRenderer.on('ui:openQuickOpen', listener)
@ -2663,6 +2673,9 @@ const api = {
setMarkdownEditorFocused: (focused: boolean): void => {
ipcRenderer.send('ui:setMarkdownEditorFocused', focused)
},
setTerminalInputFocused: (focused: boolean): void => {
ipcRenderer.send('ui:setTerminalInputFocused', focused)
},
setFloatingTerminalInputFocused: (focused: boolean): void => {
ipcRenderer.send('ui:setFloatingTerminalInputFocused', focused)
},

View File

@ -114,8 +114,13 @@ import type { RemoteWorkspacePatchResult } from '../../shared/remote-workspace-t
import type { OnboardingState } from '../../shared/types'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
import { getFeatureTipsAppOpenDecision } from './components/feature-tips/feature-tip-startup-gate'
import { keybindingMatchesAction, type KeybindingContext } from '../../shared/keybindings'
import {
keybindingMatchesAction,
type KeybindingActionId,
type KeybindingContext
} from '../../shared/keybindings'
import { isGitRepoKind } from '../../shared/repo-kind'
import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification'
const isMac = navigator.userAgent.includes('Mac')
const isWindows = !isMac && navigator.userAgent.includes('Windows')
@ -1023,6 +1028,25 @@ function App(): React.JSX.Element {
// browser guest has focus. The renderer keeps matching handlers for
// local-focus cases and to preserve the same guards in one place.
const matchShortcut = (actionId: KeybindingActionId): boolean =>
keybindingMatchesAction(actionId, e, shortcutPlatform, keybindings, {
context,
terminalShortcutPolicy: settings?.terminalShortcutPolicy
})
const notifyTerminalCapture = (actionId: KeybindingActionId): void => {
if (
context !== 'terminal' ||
(settings?.terminalShortcutPolicy ?? 'orca-first') !== 'orca-first'
) {
return
}
showTerminalShortcutCaptureNotification({
actionId,
platform: shortcutPlatform,
keybindings
})
}
const canRevealRightSidebar =
activeView !== 'tasks' &&
activeView !== 'activity' &&
@ -1038,12 +1062,7 @@ function App(): React.JSX.Element {
actions.setRightSidebarOpen(true)
}
if (
keybindingMatchesAction('sidebar.search.toggle', e, shortcutPlatform, keybindings, {
context
}) &&
canRevealRightSidebar
) {
if (matchShortcut('sidebar.search.toggle') && canRevealRightSidebar) {
// Why: when focus is inside the file explorer and a folder is selected,
// Cmd/Ctrl+Shift+F means "Find in Folder" — seed the include pattern
// with that folder instead of treating the chord as a text-search seed.
@ -1053,6 +1072,7 @@ function App(): React.JSX.Element {
: null
if (selectedFolderRelativePath !== null && activeWorktreeId) {
e.preventDefault()
notifyTerminalCapture('sidebar.search.toggle')
actions.seedFileSearchIncludePattern(
activeWorktreeId,
folderRelativePathToIncludeGlob(selectedFolderRelativePath)
@ -1065,6 +1085,7 @@ function App(): React.JSX.Element {
const selectedText = getSelectedTextForFileSearch()
if (selectedText) {
e.preventDefault()
notifyTerminalCapture('sidebar.search.toggle')
openSearchSidebar(selectedText)
return
}
@ -1089,14 +1110,7 @@ function App(): React.JSX.Element {
// Cmd/Ctrl+Alt+Arrow — worktree history back/forward. This stays before
// right-sidebar shortcuts because it is navigation, not sidebar reveal.
if (
keybindingMatchesAction('worktree.history.back', e, shortcutPlatform, keybindings, {
context
}) ||
keybindingMatchesAction('worktree.history.forward', e, shortcutPlatform, keybindings, {
context
})
) {
if (matchShortcut('worktree.history.back') || matchShortcut('worktree.history.forward')) {
// Why: Back/Forward traverse mixed worktree + page visits, so the
// shortcut is active wherever the titlebar button cluster is (terminal
// or stack-backed pages). Still suppressed in Settings.
@ -1105,11 +1119,7 @@ function App(): React.JSX.Element {
}
e.preventDefault()
const store = useAppStore.getState()
if (
keybindingMatchesAction('worktree.history.back', e, shortcutPlatform, keybindings, {
context
})
) {
if (matchShortcut('worktree.history.back')) {
store.goBackWorktree()
} else {
store.goForwardWorktree()
@ -1131,7 +1141,7 @@ function App(): React.JSX.Element {
// Why: after the last floating tab is closed, the empty overlay has no
// pane-level handler; Cmd/Ctrl+W should minimize only that landing state.
if (
keybindingMatchesAction('tab.close', e, shortcutPlatform, keybindings, { context }) &&
matchShortcut('tab.close') &&
shouldMinimizeFloatingWorkspacePanelOnCloseShortcut({
activeView,
activeWorktreeId,
@ -1145,12 +1155,9 @@ function App(): React.JSX.Element {
}
// Cmd/Ctrl+B — toggle left sidebar
if (
keybindingMatchesAction('sidebar.left.toggle', e, shortcutPlatform, keybindings, {
context
})
) {
if (matchShortcut('sidebar.left.toggle')) {
e.preventDefault()
notifyTerminalCapture('sidebar.left.toggle')
actions.toggleSidebar()
return
}
@ -1163,13 +1170,11 @@ function App(): React.JSX.Element {
// Why: full-page navigation surfaces should not reveal the right sidebar;
// they are designed as distraction-free content areas.
if (
keybindingMatchesAction('view.tasks', e, shortcutPlatform, keybindings, { context }) &&
activeView !== 'settings'
) {
if (matchShortcut('view.tasks') && activeView !== 'settings') {
const store = useAppStore.getState()
if (store.repos.some((repo) => isGitRepoKind(repo))) {
e.preventDefault()
notifyTerminalCapture('view.tasks')
store.openTaskPage()
}
return
@ -1180,35 +1185,26 @@ function App(): React.JSX.Element {
}
// Cmd/Ctrl+L — toggle right sidebar
if (
keybindingMatchesAction('sidebar.right.toggle', e, shortcutPlatform, keybindings, {
context
})
) {
if (matchShortcut('sidebar.right.toggle')) {
e.preventDefault()
notifyTerminalCapture('sidebar.right.toggle')
actions.toggleRightSidebar()
return
}
// Cmd/Ctrl+Shift+E — toggle right sidebar / explorer tab
if (
keybindingMatchesAction('sidebar.explorer.toggle', e, shortcutPlatform, keybindings, {
context
})
) {
if (matchShortcut('sidebar.explorer.toggle')) {
e.preventDefault()
notifyTerminalCapture('sidebar.explorer.toggle')
actions.setRightSidebarTab('explorer')
actions.setRightSidebarOpen(true)
return
}
// Cmd/Ctrl+Shift+F — toggle right sidebar / search tab
if (
keybindingMatchesAction('sidebar.search.toggle', e, shortcutPlatform, keybindings, {
context
})
) {
if (matchShortcut('sidebar.search.toggle')) {
e.preventDefault()
notifyTerminalCapture('sidebar.search.toggle')
openSearchSidebar(null)
return
}
@ -1218,26 +1214,20 @@ function App(): React.JSX.Element {
// in that context (handled by keyboard-handlers.ts). Both listeners share
// the window capture phase and registration order can vary with React
// effect re-runs, so a DOM check is the reliable coordination mechanism.
if (
keybindingMatchesAction('sidebar.sourceControl.toggle', e, shortcutPlatform, keybindings, {
context
})
) {
if (matchShortcut('sidebar.sourceControl.toggle')) {
if (document.querySelector('[data-terminal-search-root]')) {
return
}
e.preventDefault()
notifyTerminalCapture('sidebar.sourceControl.toggle')
actions.setRightSidebarTab('source-control')
actions.setRightSidebarOpen(true)
return
}
if (
keybindingMatchesAction('sidebar.checks.toggle', e, shortcutPlatform, keybindings, {
context
})
) {
if (matchShortcut('sidebar.checks.toggle')) {
e.preventDefault()
notifyTerminalCapture('sidebar.checks.toggle')
actions.setRightSidebarTab('checks')
actions.setRightSidebarOpen(true)
return
@ -1246,12 +1236,9 @@ function App(): React.JSX.Element {
// Cmd+Shift+I — toggle right sidebar / ports tab (macOS only).
// Why: Ctrl+Shift+I is the built-in DevTools accelerator on Windows/Linux;
// intercepting it would break an essential developer tool.
if (
keybindingMatchesAction('sidebar.ports.toggle', e, shortcutPlatform, keybindings, {
context
})
) {
if (matchShortcut('sidebar.ports.toggle')) {
e.preventDefault()
notifyTerminalCapture('sidebar.ports.toggle')
actions.setRightSidebarTab('ports')
actions.setRightSidebarOpen(true)
}
@ -1266,6 +1253,7 @@ function App(): React.JSX.Element {
floatingTerminalOpen,
floatingUnifiedTabCount,
keybindings,
settings?.terminalShortcutPolicy,
setFloatingTerminalOpenWithFocus
])

View File

@ -78,8 +78,13 @@ import {
createFloatingWorkspaceTerminalTab,
isFloatingWorkspacePanelVisible
} from '@/lib/floating-workspace-terminal-actions'
import { keybindingMatchesAction, type KeybindingContext } from '../../../shared/keybindings'
import {
keybindingMatchesAction,
type KeybindingActionId,
type KeybindingContext
} from '../../../shared/keybindings'
import { matchesRecentTabSwitcherChord } from '../../../shared/window-shortcut-policy'
import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification'
const EditorPanel = lazy(() => import('./editor/EditorPanel'))
@ -119,6 +124,9 @@ function Terminal(): React.JSX.Element | null {
const activeBrowserTabId = useAppStore((s) => s.activeBrowserTabId)
const activeTabType = useAppStore((s) => s.activeTabType)
const keybindings = useAppStore((s) => s.keybindings)
const terminalShortcutPolicy = useAppStore(
(s) => s.settings?.terminalShortcutPolicy ?? 'orca-first'
)
const setActiveTabType = useAppStore((s) => s.setActiveTabType)
const setActiveFile = useAppStore((s) => s.setActiveFile)
const openFile = useAppStore((s) => s.openFile)
@ -1086,15 +1094,28 @@ function Terminal(): React.JSX.Element | null {
: 'linux'
const onKeyDown = (e: KeyboardEvent): void => {
const context = getKeybindingContext(e.target)
const matchShortcut = (actionId: KeybindingActionId): boolean =>
keybindingMatchesAction(actionId, e, shortcutPlatform, keybindings, {
context,
terminalShortcutPolicy
})
const notifyTerminalCapture = (actionId: KeybindingActionId): void => {
if (context !== 'terminal' || terminalShortcutPolicy !== 'orca-first') {
return
}
showTerminalShortcutCaptureNotification({
actionId,
platform: shortcutPlatform,
keybindings
})
}
// Why: Cmd/Ctrl+T always opens a new terminal, regardless of which
// surface is active. Browser-tab creation has its own shortcut
// (Cmd/Ctrl+Shift+B) so users have a predictable way to spawn a
// terminal from anywhere in the central pane.
if (
!e.repeat &&
keybindingMatchesAction('tab.newTerminal', e, shortcutPlatform, keybindings, { context })
) {
if (!e.repeat && matchShortcut('tab.newTerminal')) {
e.preventDefault()
notifyTerminalCapture('tab.newTerminal')
if (isFloatingWorkspacePanelVisible()) {
void createFloatingWorkspaceTerminalTab(useAppStore.getState())
return
@ -1105,11 +1126,9 @@ function Terminal(): React.JSX.Element | null {
// Cmd/Ctrl+Shift+T — reopen closed browser tab when browser is active,
// otherwise reopen the most recently closed editor tab (VS Codestyle).
if (
!e.repeat &&
keybindingMatchesAction('tab.reopenClosed', e, shortcutPlatform, keybindings, { context })
) {
if (!e.repeat && matchShortcut('tab.reopenClosed')) {
e.preventDefault()
notifyTerminalCapture('tab.reopenClosed')
const state = useAppStore.getState()
if (state.activeTabType === 'browser') {
const restored = state.reopenClosedBrowserTab(activeWorktreeId)
@ -1123,11 +1142,9 @@ function Terminal(): React.JSX.Element | null {
}
// Cmd/Ctrl+Shift+B - new browser tab
if (
!e.repeat &&
keybindingMatchesAction('tab.newBrowser', e, shortcutPlatform, keybindings, { context })
) {
if (!e.repeat && matchShortcut('tab.newBrowser')) {
e.preventDefault()
notifyTerminalCapture('tab.newBrowser')
handleNewBrowserTab()
return
}
@ -1136,10 +1153,7 @@ function Terminal(): React.JSX.Element | null {
// outside the editor content area, e.g. on the tab bar or sidebar).
// When the editor itself has focus, editor-local handlers own the save
// shortcut, so we skip this when the target is editable.
if (
!e.repeat &&
keybindingMatchesAction('editor.save', e, shortcutPlatform, keybindings, { context })
) {
if (!e.repeat && matchShortcut('editor.save')) {
const target = e.target as HTMLElement | null
const inEditor =
target?.closest('.monaco-editor, [contenteditable]') !== null ||
@ -1148,6 +1162,7 @@ function Terminal(): React.JSX.Element | null {
const state = useAppStore.getState()
if (state.activeTabType === 'editor' && state.activeFileId) {
e.preventDefault()
notifyTerminalCapture('editor.save')
window.dispatchEvent(new Event(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT))
return
}
@ -1155,11 +1170,9 @@ function Terminal(): React.JSX.Element | null {
}
// Cmd/Ctrl+Shift+M - new markdown file
if (
!e.repeat &&
keybindingMatchesAction('tab.newMarkdown', e, shortcutPlatform, keybindings, { context })
) {
if (!e.repeat && matchShortcut('tab.newMarkdown')) {
e.preventDefault()
notifyTerminalCapture('tab.newMarkdown')
void handleNewFile()
return
}
@ -1169,15 +1182,13 @@ function Terminal(): React.JSX.Element | null {
// in keyboard-handlers.ts so it can close individual split panes and
// show a confirmation dialog. We still preventDefault here so Electron
// doesn't close the window as its default Cmd+W action.
if (
!e.repeat &&
keybindingMatchesAction('tab.close', e, shortcutPlatform, keybindings, { context })
) {
if (!e.repeat && matchShortcut('tab.close')) {
const state = useAppStore.getState()
if (state.activeTabType === 'terminal' && context === 'terminal') {
return
}
e.preventDefault()
notifyTerminalCapture('tab.close')
if (state.activeTabType === 'editor' && state.activeFileId) {
handleCloseFile(state.activeFileId)
} else if (state.activeTabType === 'browser' && state.activeBrowserTabId) {
@ -1187,15 +1198,15 @@ function Terminal(): React.JSX.Element | null {
}
// Ctrl+Tab - quick-toggle to the previously focused tab in this group.
if (matchesRecentTabSwitcherChord(e, shortcutPlatform, keybindings)) {
return
}
if (
!e.repeat &&
keybindingMatchesAction('tab.previousRecent', e, shortcutPlatform, keybindings, {
context
matchesRecentTabSwitcherChord(e, shortcutPlatform, keybindings, {
context,
terminalShortcutPolicy
})
) {
return
}
if (!e.repeat && matchShortcut('tab.previousRecent')) {
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
@ -1211,30 +1222,14 @@ function Terminal(): React.JSX.Element | null {
// as the key value (the shifted character), not '['. Option+[ also
// composes to dead-key / punctuation on many layouts, so matching on
// event.key would miss the chord entirely on non-US layouts.
const switchSameTypeDirection = keybindingMatchesAction(
'tab.nextSameType',
e,
shortcutPlatform,
keybindings,
{ context }
)
const switchSameTypeDirection = matchShortcut('tab.nextSameType')
? 1
: keybindingMatchesAction('tab.previousSameType', e, shortcutPlatform, keybindings, {
context
})
: matchShortcut('tab.previousSameType')
? -1
: null
const switchAllTypesDirection = keybindingMatchesAction(
'tab.nextAllTypes',
e,
shortcutPlatform,
keybindings,
{ context }
)
const switchAllTypesDirection = matchShortcut('tab.nextAllTypes')
? 1
: keybindingMatchesAction('tab.previousAllTypes', e, shortcutPlatform, keybindings, {
context
})
: matchShortcut('tab.previousAllTypes')
? -1
: null
if (!e.repeat && (switchSameTypeDirection !== null || switchAllTypesDirection !== null)) {
@ -1246,6 +1241,15 @@ function Terminal(): React.JSX.Element | null {
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
notifyTerminalCapture(
switchAllTypesDirection !== null
? switchAllTypesDirection === 1
? 'tab.nextAllTypes'
: 'tab.previousAllTypes'
: switchSameTypeDirection === 1
? 'tab.nextSameType'
: 'tab.previousSameType'
)
if (switchAllTypesDirection !== null) {
handleSwitchTabAcrossAllTypes(switchAllTypesDirection)
} else {
@ -1260,17 +1264,9 @@ function Terminal(): React.JSX.Element | null {
// for focused terminal / editor consumers and matches the unshifted
// predicate in browser-guest-ui.ts and the chord advertised in
// ShortcutsPane.
const terminalTabDirection = keybindingMatchesAction(
'tab.nextTerminal',
e,
shortcutPlatform,
keybindings,
{ context }
)
const terminalTabDirection = matchShortcut('tab.nextTerminal')
? 1
: keybindingMatchesAction('tab.previousTerminal', e, shortcutPlatform, keybindings, {
context
})
: matchShortcut('tab.previousTerminal')
? -1
: null
if (!e.repeat && terminalTabDirection !== null) {
@ -1302,7 +1298,8 @@ function Terminal(): React.JSX.Element | null {
handleCloseBrowserTab,
closeBrowserTab,
handleCloseFile,
keybindings
keybindings,
terminalShortcutPolicy
])
// Warn on window close if there are unsaved editor files

View File

@ -1,5 +1,5 @@
import React, { useEffect, useRef } from 'react'
import { Ban, Keyboard, RotateCcw } from 'lucide-react'
import { Ban, Keyboard, RotateCcw, Terminal } from 'lucide-react'
import {
formatKeybinding,
type KeybindingActionId,
@ -22,6 +22,7 @@ type ShortcutBindingRowProps = {
error?: string
warnings: readonly string[]
recording: boolean
terminalStatus?: ShortcutTerminalStatus
onStartRecording: (actionId: KeybindingActionId) => void
onCancelRecording: () => void
onCapture: (actionId: KeybindingActionId, input: KeybindingInput) => void
@ -30,6 +31,11 @@ type ShortcutBindingRowProps = {
onReset: (actionId: KeybindingActionId) => void
}
export type ShortcutTerminalStatus = {
label: string
description: string
}
function BindingPreview({
bindings,
platform
@ -62,6 +68,7 @@ export function ShortcutBindingRow({
error,
warnings,
recording,
terminalStatus,
onStartRecording,
onCancelRecording,
onCapture,
@ -125,6 +132,22 @@ export function ShortcutBindingRow({
Modified
</Badge>
) : null}
{terminalStatus ? (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="shrink-0 gap-1 border-border/70 text-[11px] text-muted-foreground"
>
<Terminal className="size-3" />
{terminalStatus.label}
</Badge>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{terminalStatus.description}
</TooltipContent>
</Tooltip>
) : null}
</div>
<div
className={cn(

View File

@ -6,19 +6,23 @@ import {
formatKeybindingList,
getEffectiveKeybindingsForAction,
getKeybindingDefinition,
isKeybindingAllowedInTerminal,
isKeybindingPotentialTerminalConflict,
keybindingFromInputForAction,
keybindingIsActiveInContext,
normalizeKeybindingListForAction,
type KeybindingActionId,
type KeybindingDefinition,
type KeybindingInput,
type KeybindingOverrides
type KeybindingOverrides,
type TerminalShortcutPolicy
} from '../../../../shared/keybindings'
import { useAppStore } from '../../store'
import { Label } from '../ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { KeybindingsFileActions } from './KeybindingsFileActions'
import { SearchableSetting } from './SearchableSetting'
import { ShortcutBindingRow } from './ShortcutBindingRow'
import { ShortcutBindingRow, type ShortcutTerminalStatus } from './ShortcutBindingRow'
import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search'
type ShortcutGroup = {
@ -39,12 +43,29 @@ const CTRL_TAB_BEHAVIOR_SEARCH_ENTRY: SettingsSearchEntry = {
keywords: ['shortcut', 'tab', 'ctrl', 'control', 'recent', 'mru', 'sequential', 'switch']
}
const TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY: SettingsSearchEntry = {
title: 'Shortcuts in Terminal',
description: 'Choose whether Orca or the focused terminal wins when shortcuts overlap.',
keywords: [
'shortcut',
'keyboard',
'terminal',
'tui',
'shell',
'agent',
'conflict',
'orca first',
'terminal first'
]
}
export const SHORTCUTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
...KEYBINDING_DEFINITIONS.map((item) => ({
title: item.title,
description: `${item.group} shortcut`,
keywords: [...item.searchKeywords]
})),
TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY,
CTRL_TAB_BEHAVIOR_SEARCH_ENTRY
]
@ -83,9 +104,50 @@ function hasCommonBindingOverride(
return hasOwnBindingOverride(snapshot?.commonOverrides ?? {}, actionId)
}
function getShortcutTerminalStatus(
definition: KeybindingDefinition,
terminalShortcutPolicy: TerminalShortcutPolicy,
hasEffectiveBinding: boolean
): ShortcutTerminalStatus | undefined {
if (!hasEffectiveBinding) {
return undefined
}
if (definition.scope === 'terminal') {
return {
label: 'Terminal',
description: 'Runs from terminal panes.'
}
}
if (isKeybindingAllowedInTerminal(definition)) {
return {
label: 'Terminal active',
description: 'Still runs while a terminal has keyboard focus.'
}
}
if (!isKeybindingPotentialTerminalConflict(definition)) {
return undefined
}
const activeInTerminal = keybindingIsActiveInContext(definition, {
context: 'terminal',
terminalShortcutPolicy
})
return activeInTerminal
? {
label: 'Orca first',
description: 'Also runs while a terminal or TUI has keyboard focus.'
}
: {
label: 'Terminal first',
description: 'Disabled while a terminal or TUI has keyboard focus.'
}
}
export function ShortcutsPane(): React.JSX.Element {
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const ctrlTabOrderMode = useAppStore((state) => state.settings?.ctrlTabOrderMode ?? 'mru')
const terminalShortcutPolicy = useAppStore(
(state) => state.settings?.terminalShortcutPolicy ?? 'orca-first'
)
const updateSettings = useAppStore((state) => state.updateSettings)
const keybindings = useAppStore((state) => state.keybindings)
const keybindingSnapshot = useAppStore((state) => state.keybindingSnapshot)
@ -235,6 +297,40 @@ export function ShortcutsPane(): React.JSX.Element {
</p>
</div>
{matchesSettingsSearch(searchQuery, TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY) ? (
<SearchableSetting
id="terminal-shortcut-policy"
title="Shortcuts in Terminal"
description="Choose whether Orca or the focused terminal wins when shortcuts overlap."
keywords={TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY.keywords}
className="flex items-center justify-between gap-4 px-1 py-2"
>
<div className="space-y-0.5">
<Label>Shortcuts in Terminal</Label>
<p className="text-xs text-muted-foreground">
Orca first keeps app shortcuts active in TUIs. Terminal first lets shell shortcuts
win unless a shortcut is marked terminal-active.
</p>
</div>
<Select
value={terminalShortcutPolicy}
onValueChange={(value) =>
void updateSettings({
terminalShortcutPolicy: value as TerminalShortcutPolicy
})
}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="orca-first">Orca first</SelectItem>
<SelectItem value="terminal-first">Terminal first</SelectItem>
</SelectContent>
</Select>
</SearchableSetting>
) : null}
{matchesSettingsSearch(searchQuery, CTRL_TAB_BEHAVIOR_SEARCH_ENTRY) ? (
<SearchableSetting
title="Recent Tab Order"
@ -284,6 +380,11 @@ export function ShortcutsPane(): React.JSX.Element {
)
const modified = hasOwnBindingOverride(keybindings, item.id)
const warnings = conflictByAction.get(item.id) ?? []
const terminalStatus = getShortcutTerminalStatus(
item,
terminalShortcutPolicy,
effective.length > 0
)
return (
<ShortcutBindingRow
@ -296,6 +397,7 @@ export function ShortcutsPane(): React.JSX.Element {
error={errors[item.id]}
warnings={warnings}
recording={recordingActionId === item.id}
terminalStatus={terminalStatus}
onStartRecording={(actionId) => {
setRecordingActionId(actionId)
clearError(actionId)

View File

@ -102,6 +102,10 @@ function formatClipboardImagePasteError(error: unknown): string {
return `Image paste failed: ${detail}`
}
function isXtermHelperTextarea(target: EventTarget | null): target is HTMLElement {
return target instanceof HTMLElement && target.classList.contains('xterm-helper-textarea')
}
export default function TerminalPane({
tabId,
worktreeId,
@ -970,7 +974,8 @@ export default function TerminalPane({
searchOpenRef,
searchStateRef,
macOptionAsAltRef,
keybindings
keybindings,
terminalShortcutPolicy: settings?.terminalShortcutPolicy ?? 'orca-first'
})
useTerminalPaneGlobalEffects({
@ -993,6 +998,49 @@ export default function TerminalPane({
toggleExpandPane
})
useEffect(() => {
const container = containerRef.current
if (!container) {
return
}
const syncFocused = (focused: boolean): void => {
window.api.ui.setTerminalInputFocused?.(focused)
}
const onFocusIn = (event: FocusEvent): void => {
if (isXtermHelperTextarea(event.target)) {
syncFocused(true)
}
}
const onFocusOut = (event: FocusEvent): void => {
if (!isXtermHelperTextarea(event.target)) {
return
}
if (isXtermHelperTextarea(event.relatedTarget)) {
return
}
syncFocused(false)
}
if (
isXtermHelperTextarea(document.activeElement) &&
container.contains(document.activeElement)
) {
syncFocused(true)
}
container.addEventListener('focusin', onFocusIn)
container.addEventListener('focusout', onFocusOut)
return () => {
container.removeEventListener('focusin', onFocusIn)
container.removeEventListener('focusout', onFocusOut)
if (
isXtermHelperTextarea(document.activeElement) &&
container.contains(document.activeElement)
) {
syncFocused(false)
}
}
}, [])
// Intercept paste at the keydown level (configurable terminal paste chords)
// AND as a fallback
// on the paste event. We must handle keydown because Chromium does not fire

View File

@ -125,6 +125,17 @@ describe('matchFileSearchShortcut', () => {
).toBe(false)
})
it('lets terminal-first pass the file-search shortcut through to the terminal', () => {
expect(
matchFileSearchShortcut(
makeKeyEvent({ key: 'F', metaKey: true, shiftKey: true }),
'darwin',
undefined,
'terminal-first'
)
).toBe(false)
})
it('does not match when file search is disabled', () => {
expect(
matchFileSearchShortcut(makeKeyEvent({ key: 'F', metaKey: true, shiftKey: true }), 'darwin', {

View File

@ -9,7 +9,8 @@ import type { MacOptionAsAlt } from './terminal-shortcut-policy'
import {
keybindingMatchesAction,
type KeybindingOverrides,
type KeybindingPlatform
type KeybindingPlatform,
type TerminalShortcutPolicy
} from '../../../../shared/keybindings'
import { resolveSplitCwd, type PaneCwdMap } from './resolve-split-cwd'
import { keyboardEventBelongsToScope } from './terminal-keyboard-scope'
@ -77,13 +78,15 @@ export function matchSearchNavigate(
export function matchFileSearchShortcut(
e: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'shiftKey' | 'altKey' | 'repeat'>,
platform: KeybindingPlatform,
keybindings?: KeybindingOverrides
keybindings?: KeybindingOverrides,
terminalShortcutPolicy: TerminalShortcutPolicy = 'orca-first'
): boolean {
if (e.repeat) {
return false
}
return keybindingMatchesAction('sidebar.search.toggle', e, platform, keybindings, {
context: 'terminal'
context: 'terminal',
terminalShortcutPolicy
})
}
@ -108,6 +111,7 @@ type KeyboardHandlersDeps = {
searchStateRef: React.RefObject<SearchState>
macOptionAsAltRef: React.RefObject<MacOptionAsAlt>
keybindings?: KeybindingOverrides
terminalShortcutPolicy?: TerminalShortcutPolicy
}
export function useTerminalKeyboardShortcuts({
@ -129,7 +133,8 @@ export function useTerminalKeyboardShortcuts({
searchOpenRef,
searchStateRef,
macOptionAsAltRef,
keybindings
keybindings,
terminalShortcutPolicy = 'orca-first'
}: KeyboardHandlersDeps): void {
useEffect(() => {
if (!isActive) {
@ -166,7 +171,7 @@ export function useTerminalKeyboardShortcuts({
return
}
if (matchFileSearchShortcut(e, shortcutPlatform, keybindings)) {
if (matchFileSearchShortcut(e, shortcutPlatform, keybindings, terminalShortcutPolicy)) {
const pane = manager.getActivePane() ?? manager.getPanes()[0]
const selectedText = normalizeSelectedTextForFileSearch(pane?.terminal.getSelection())
if (selectedText) {
@ -402,6 +407,7 @@ export function useTerminalKeyboardShortcuts({
searchOpenRef,
searchStateRef,
macOptionAsAltRef,
keybindings
keybindings,
terminalShortcutPolicy
])
}

View File

@ -78,6 +78,17 @@ import {
resetAgentHookCompletionNotificationCoordinators,
syncAgentHookCompletionNotificationSettings
} from './agent-hook-completion-notifications'
import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification'
function getShortcutPlatform(): NodeJS.Platform {
if (navigator.userAgent.includes('Mac')) {
return 'darwin'
}
if (navigator.userAgent.includes('Windows')) {
return 'win32'
}
return 'linux'
}
export { resolveZoomTarget } from './resolve-zoom-target'
@ -660,6 +671,18 @@ export function useIpcEvents(): void {
})
)
if (window.api.ui.onTerminalShortcutCaptured) {
unsubs.push(
window.api.ui.onTerminalShortcutCaptured(({ actionId }) => {
showTerminalShortcutCaptureNotification({
actionId,
platform: getShortcutPlatform(),
keybindings: useAppStore.getState().keybindings
})
})
)
}
unsubs.push(
window.api.ui.onOpenQuickOpen(() => {
const store = useAppStore.getState()

View File

@ -0,0 +1,71 @@
import { Keyboard } from 'lucide-react'
import { toast } from 'sonner'
import {
formatKeybindingList,
getEffectiveKeybindingsForAction,
getKeybindingDefinition,
isKeybindingPotentialTerminalConflict,
type KeybindingActionId,
type KeybindingOverrides
} from '../../../shared/keybindings'
import { useAppStore } from '../store'
const STORAGE_PREFIX = 'orca.terminalShortcutCapturedNotice.'
function hasShownNotice(actionId: KeybindingActionId): boolean {
try {
return localStorage.getItem(`${STORAGE_PREFIX}${actionId}`) === 'true'
} catch {
return false
}
}
function markNoticeShown(actionId: KeybindingActionId): void {
try {
localStorage.setItem(`${STORAGE_PREFIX}${actionId}`, 'true')
} catch {
// Ignore storage failures; the notification still gives the user the path.
}
}
function openShortcutSettings(): void {
const store = useAppStore.getState()
store.openSettingsPage()
store.openSettingsTarget({
pane: 'shortcuts',
repoId: null,
sectionId: 'terminal-shortcut-policy'
})
}
export function showTerminalShortcutCaptureNotification({
actionId,
platform,
keybindings
}: {
actionId: KeybindingActionId
platform: NodeJS.Platform
keybindings?: KeybindingOverrides
}): void {
const definition = getKeybindingDefinition(actionId)
if (!definition || !isKeybindingPotentialTerminalConflict(definition)) {
return
}
if (hasShownNotice(actionId)) {
return
}
markNoticeShown(actionId)
const bindingLabel = formatKeybindingList(
getEffectiveKeybindingsForAction(actionId, platform, keybindings),
platform
)
toast.message('Orca handled a terminal shortcut', {
description: `${definition.title} (${bindingLabel}) can be changed in Keyboard Shortcuts.`,
icon: <Keyboard className="size-4 text-muted-foreground" />,
action: {
label: 'Open Shortcuts',
onClick: openShortcutSettings
}
})
}

View File

@ -1155,6 +1155,7 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
onToggleRightSidebar: () => noopUnsubscribe,
onToggleWorktreePalette: () => noopUnsubscribe,
onToggleFloatingTerminal: () => noopUnsubscribe,
onTerminalShortcutCaptured: () => noopUnsubscribe,
onOpenQuickOpen: () => noopUnsubscribe,
onOpenTasks: () => noopUnsubscribe,
onOpenNewWorkspace: () => noopUnsubscribe,
@ -1202,6 +1203,7 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
onFileDrop: () => noopUnsubscribe,
syncTrafficLights: () => {},
setMarkdownEditorFocused: () => {},
setTerminalInputFocused: () => {},
setFloatingTerminalInputFocused: () => {},
onRichMarkdownContextCommand: () => noopUnsubscribe,
onFullscreenChanged: () => noopUnsubscribe,

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: '~',

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)
})

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',

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

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(

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)
}