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