Add terminal shortcut routing policy (#2610)

This commit is contained in:
Neil
2026-05-21 23:57:53 -07:00
committed by GitHub
parent 31859d47fa
commit 81fcfea965
26 changed files with 999 additions and 193 deletions
+48 -60
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
])
+59 -62
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 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
@@ -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(
@@ -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)
@@ -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
@@ -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', {
@@ -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
])
}
+23
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()
@@ -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
}
})
}
+2
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,