From 81fcfea9650237c9fb349eefba5cc3bc8ae6d595 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 21 May 2026 23:57:53 -0700 Subject: [PATCH] Add terminal shortcut routing policy (#2610) --- src/main/browser/browser-guest-ui.ts | 6 + src/main/index.ts | 8 +- src/main/menu/register-app-menu.test.ts | 29 ++-- src/main/menu/register-app-menu.ts | 18 +-- src/main/persistence.test.ts | 25 ++++ src/main/persistence.ts | 9 ++ src/main/window/createMainWindow.test.ts | 127 ++++++++++++++++ src/main/window/createMainWindow.ts | 103 +++++++++++-- src/preload/api-types.ts | 4 + src/preload/index.ts | 13 ++ src/renderer/src/App.tsx | 108 ++++++-------- src/renderer/src/components/Terminal.tsx | 121 ++++++++-------- .../settings/ShortcutBindingRow.tsx | 25 +++- .../src/components/settings/ShortcutsPane.tsx | 106 +++++++++++++- .../components/terminal-pane/TerminalPane.tsx | 50 ++++++- .../terminal-pane/keyboard-handlers.test.ts | 11 ++ .../terminal-pane/keyboard-handlers.ts | 18 ++- src/renderer/src/hooks/useIpcEvents.ts | 23 +++ ...terminal-shortcut-capture-notification.tsx | 71 +++++++++ src/renderer/src/web/web-preload-api.ts | 2 + src/shared/constants.ts | 3 + src/shared/keybindings.test.ts | 43 +++++- src/shared/keybindings.ts | 57 +++++++- src/shared/types.ts | 5 +- src/shared/window-shortcut-policy.test.ts | 70 +++++++++ src/shared/window-shortcut-policy.ts | 137 +++++++++++++++--- 26 files changed, 999 insertions(+), 193 deletions(-) create mode 100644 src/renderer/src/lib/terminal-shortcut-capture-notification.tsx diff --git a/src/main/browser/browser-guest-ui.ts b/src/main/browser/browser-guest-ui.ts index 54bbf9e3e..b710a4ac4 100644 --- a/src/main/browser/browser-guest-ui.ts +++ b/src/main/browser/browser-guest-ui.ts @@ -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') { diff --git a/src/main/index.ts b/src/main/index.ts index d3e7cfc2d..56d26e079 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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') diff --git a/src/main/menu/register-app-menu.test.ts b/src/main/menu/register-app-menu.test.ts index a834e3e9a..c234dcee4 100644 --- a/src/main/menu/register-app-menu.test.ts +++ b/src/main/menu/register-app-menu.test.ts @@ -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']) diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts index 63f3beefd..a67693bfd 100644 --- a/src/main/menu/register-app-menu.ts +++ b/src/main/menu/register-app-menu.ts @@ -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' }, diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 9b1bd83c8..ffc449fee 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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', diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 3378c1310..cf99006a6 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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 diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 4823ec422..9cc84b370 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -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 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 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 diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 047fc1218..3c3a80c46 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -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 diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 093d428a7..ba666d15c 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 diff --git a/src/preload/index.ts b/src/preload/index.ts index 3f1bbc31b..452e9e1cd 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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) }, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index be0be33c0..bc6f77352 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 ]) diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index b3fe190c0..6ed29b175 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -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 Code–style). - 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 diff --git a/src/renderer/src/components/settings/ShortcutBindingRow.tsx b/src/renderer/src/components/settings/ShortcutBindingRow.tsx index 5ebfa3b3f..6f6e235ae 100644 --- a/src/renderer/src/components/settings/ShortcutBindingRow.tsx +++ b/src/renderer/src/components/settings/ShortcutBindingRow.tsx @@ -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 ) : null} + {terminalStatus ? ( + + + + + {terminalStatus.label} + + + + {terminalStatus.description} + + + ) : null}
({ 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 {

+ {matchesSettingsSearch(searchQuery, TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY) ? ( + +
+ +

+ Orca first keeps app shortcuts active in TUIs. Terminal first lets shell shortcuts + win unless a shortcut is marked terminal-active. +

+
+ +
+ ) : null} + {matchesSettingsSearch(searchQuery, CTRL_TAB_BEHAVIOR_SEARCH_ENTRY) ? ( 0 + ) return ( { setRecordingActionId(actionId) clearError(actionId) diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 552071718..d8a07be77 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -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 diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts index 31b3d2231..b4afacfb8 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts @@ -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', { diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index 786333ea0..41314c774 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -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, 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 macOptionAsAltRef: React.RefObject 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 ]) } diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index e8cb49cd6..46221c63a 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -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() diff --git a/src/renderer/src/lib/terminal-shortcut-capture-notification.tsx b/src/renderer/src/lib/terminal-shortcut-capture-notification.tsx new file mode 100644 index 000000000..541fb4a33 --- /dev/null +++ b/src/renderer/src/lib/terminal-shortcut-capture-notification.tsx @@ -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: , + action: { + label: 'Open Shortcuts', + onClick: openShortcutSettings + } + }) +} diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 71fdddd4c..da617f123 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1155,6 +1155,7 @@ function createWebUiApi(): NonNullable['ui']> { onToggleRightSidebar: () => noopUnsubscribe, onToggleWorktreePalette: () => noopUnsubscribe, onToggleFloatingTerminal: () => noopUnsubscribe, + onTerminalShortcutCaptured: () => noopUnsubscribe, onOpenQuickOpen: () => noopUnsubscribe, onOpenTasks: () => noopUnsubscribe, onOpenNewWorkspace: () => noopUnsubscribe, @@ -1202,6 +1203,7 @@ function createWebUiApi(): NonNullable['ui']> { onFileDrop: () => noopUnsubscribe, syncTrafficLights: () => {}, setMarkdownEditorFocused: () => {}, + setTerminalInputFocused: () => {}, setFloatingTerminalInputFocused: () => {}, onRichMarkdownContextCommand: () => noopUnsubscribe, onFullscreenChanged: () => noopUnsubscribe, diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 24830e0e8..0da10ab0c 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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: '~', diff --git a/src/shared/keybindings.test.ts b/src/shared/keybindings.test.ts index dc2ef5af6..2e81a33c1 100644 --- a/src/shared/keybindings.test.ts +++ b/src/shared/keybindings.test.ts @@ -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) }) diff --git a/src/shared/keybindings.ts b/src/shared/keybindings.ts index 84f3734fe..aaec4c6dd 100644 --- a/src/shared/keybindings.ts +++ b/src/shared/keybindings.ts @@ -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', diff --git a/src/shared/types.ts b/src/shared/types.ts index dba8844f0..d383ce028 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 diff --git a/src/shared/window-shortcut-policy.test.ts b/src/shared/window-shortcut-policy.test.ts index 238d4c124..5cbe7e433 100644 --- a/src/shared/window-shortcut-policy.test.ts +++ b/src/shared/window-shortcut-policy.test.ts @@ -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( diff --git a/src/shared/window-shortcut-policy.ts b/src/shared/window-shortcut-policy.ts index ebaf4d8bd..e889e84e3 100644 --- a/src/shared/window-shortcut-policy.ts +++ b/src/shared/window-shortcut-policy.ts @@ -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, 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) +}