feat(tab-bar): add shortcut to open commands for active tab group (#6325)

* Add keyboard shortcut to toggle the Quick Commands menu

- New `tab.openQuickCommandsMenu` keybinding action (no default binding)
- TabBarQuickCommandsMenu listens for the binding and toggles open/closed
- Scoped to the active tab group naturally since the component only mounts when its group is focused

* Show keyboard shortcut in Quick Commands menu trigger tooltip

* Add tests

* expand tests

* Expand keyboard toggle to call handleOpenChange and skip repeated keys

- Replace `setMenuOpen` toggle with `handleOpenChange(!menuOpen)` so closing
  via keyboard runs the same reset logic (query, focus frame, value override)
- Guard against key-repeat events to prevent rapid toggling on held key
- Wrap `handleOpenChange` in `useCallback` so it's stable enough to include
  in the `useEffect` dependency array without causing spurious re-registrations
- Update tests to reflect that re-running the effect between presses is
  required for the close path, and add a repeat-event test

* Add docstring to withShortcutHint func

* review: harden quick commands menu shortcut

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Agustina Chaer 2026-06-30 22:21:37 -03:00 committed by GitHub
parent 2b04c81c87
commit cbd06a7671
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1088 additions and 106 deletions

View File

@ -530,6 +530,29 @@ describe('setupGuestShortcutForwarding', () => {
expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'ui:browserHistoryNavigate', 'forward')
})
it('forwards quick-command menu shortcuts from focused guest pages', () => {
setupGuestShortcutForwarding({
browserTabId,
guest: makeGuest(),
resolveRenderer: () => makeRenderer(),
getKeybindings: () => ({
'tab.openQuickCommandsMenu': ['Mod+Shift+Q']
})
})
const isMac = process.platform === 'darwin'
const preventDefault = triggerBeforeInput({
code: 'KeyQ',
key: 'q',
meta: isMac,
control: !isMac,
shift: true
})
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(rendererSendMock).toHaveBeenCalledWith('ui:toggleQuickCommandsMenu')
})
it('consumes guest zoom shortcuts even when the renderer is unavailable', () => {
setupGuestShortcutForwarding({
browserTabId,

View File

@ -426,6 +426,8 @@ export function setupGuestShortcutForwarding(args: {
renderer.send('ui:toggleWorktreePalette')
} else if (action?.type === 'openQuickOpen') {
renderer.send('ui:openQuickOpen')
} else if (action?.type === 'toggleQuickCommandsMenu') {
renderer.send('ui:toggleQuickCommandsMenu')
} else if (action?.type === 'openNewWorkspace') {
renderer.send('ui:openNewWorkspace')
} else if (action?.type === 'openWorkspaceBoard') {

View File

@ -167,8 +167,7 @@ describe('ClaudeAgentTeamsService', () => {
await request(['set-option', '-p', '-t', '%2', 'remain-on-exit', 'failed'])
const teammateCommand =
'cd /repo && env CLAUDECODE=1 claude --agent-id a --teammate-mode auto'
const teammateCommand = 'cd /repo && env CLAUDECODE=1 claude --agent-id a --teammate-mode auto'
await expect(
request(['respawn-pane', '-k', '-t', '%2', '--', teammateCommand])
).resolves.toMatchObject({ stdout: '', exitCode: 0 })
@ -224,10 +223,19 @@ describe('ClaudeAgentTeamsService', () => {
await expect(
service.handleTmuxCompat(
{ teamId, token, envPane: leaderPane, argv: ['respawn-pane', '-k', '-t', leaderPane, '--', 'cat'] },
{
teamId,
token,
envPane: leaderPane,
argv: ['respawn-pane', '-k', '-t', leaderPane, '--', 'cat']
},
api
)
).resolves.toMatchObject({ ok: false, exitCode: 1, stderr: 'tmux: refusing to respawn leader pane\n' })
).resolves.toMatchObject({
ok: false,
exitCode: 1,
stderr: 'tmux: refusing to respawn leader pane\n'
})
})
it('rejects stale or unauthorized shim calls', async () => {

View File

@ -899,6 +899,70 @@ describe('createMainWindow', () => {
expect(webContents.send).toHaveBeenNthCalledWith(2, 'ui:toggleWorktreePalette')
})
it('suppresses auto-repeat quick-command menu toggles from before-input-event', () => {
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const webContents = {
on: vi.fn((event, handler) => {
windowHandlers[event] = handler
}),
setZoomLevel: vi.fn(),
setBackgroundThrottling: vi.fn(),
invalidate: vi.fn(),
setWindowOpenHandler: vi.fn(),
send: vi.fn(),
isDevToolsOpened: vi.fn(),
openDevTools: vi.fn(),
closeDevTools: vi.fn()
}
const browserWindowInstance = {
webContents,
on: vi.fn(),
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => true),
isFullScreen: vi.fn(() => false),
getSize: vi.fn(() => [1200, 800]),
setSize: vi.fn(),
maximize: vi.fn(),
show: vi.fn(),
loadFile: vi.fn(),
loadURL: vi.fn()
}
browserWindowMock.mockImplementation(function () {
return browserWindowInstance
})
createMainWindow(null, {
getKeybindings: () => ({
'tab.openQuickCommandsMenu': ['Mod+Shift+Q']
})
})
const isDarwin = process.platform === 'darwin'
const input = {
type: 'keyDown',
code: 'KeyQ',
key: 'q',
meta: isDarwin,
control: !isDarwin,
alt: false,
shift: true
}
const firstPreventDefault = vi.fn()
windowHandlers['before-input-event']({ preventDefault: firstPreventDefault } as never, input)
expect(firstPreventDefault).toHaveBeenCalledTimes(1)
expect(webContents.send).toHaveBeenCalledWith('ui:toggleQuickCommandsMenu')
webContents.send.mockClear()
const repeatPreventDefault = vi.fn()
windowHandlers['before-input-event']({ preventDefault: repeatPreventDefault } as never, {
...input,
isAutoRepeat: true
})
expect(repeatPreventDefault).toHaveBeenCalledTimes(1)
expect(webContents.send).not.toHaveBeenCalled()
})
it('lets Terminal-first pass risky app shortcuts through when terminal input is focused', () => {
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const webContents = {

View File

@ -788,6 +788,9 @@ export function createMainWindow(
case 'openQuickOpen':
mainWindow.webContents.send('ui:openQuickOpen')
return
case 'toggleQuickCommandsMenu':
mainWindow.webContents.send('ui:toggleQuickCommandsMenu')
return
case 'openNewWorkspace':
mainWindow.webContents.send('ui:openNewWorkspace')
return
@ -862,6 +865,11 @@ export function createMainWindow(
return true
}
if (action.type === 'toggleQuickCommandsMenu' && isAutoRepeat) {
event.preventDefault()
return true
}
event.preventDefault()
if (capturedTerminalActionId) {
mainWindow.webContents.send('ui:terminalShortcutCaptured', {

View File

@ -2515,6 +2515,7 @@ export type PreloadApi = {
callback: (data: { actionId: KeybindingActionId }) => void
) => () => void
onOpenQuickOpen: (callback: () => void) => () => void
onToggleQuickCommandsMenu: (callback: () => void) => () => void
onOpenNewWorkspace: (callback: () => void) => () => void
onDeleteCurrentWorkspace: (callback: () => void) => () => void
onOpenWorkspaceBoard: (callback: () => void) => () => void

View File

@ -2951,6 +2951,11 @@ const api = {
ipcRenderer.on('ui:openQuickOpen', listener)
return () => ipcRenderer.removeListener('ui:openQuickOpen', listener)
},
onToggleQuickCommandsMenu: (callback: () => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent) => callback()
ipcRenderer.on('ui:toggleQuickCommandsMenu', listener)
return () => ipcRenderer.removeListener('ui:toggleQuickCommandsMenu', listener)
},
onOpenNewWorkspace: (callback: () => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent) => callback()
ipcRenderer.on('ui:openNewWorkspace', listener)

View File

@ -0,0 +1,80 @@
import { Pencil, Play, Trash2 } from 'lucide-react'
import { CommandItem } from '@/components/ui/command'
import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands'
import type { TerminalQuickCommand } from '../../../../shared/types'
import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog'
import { translate } from '@/i18n/i18n'
type TabBarQuickCommandItemProps = {
command: TerminalQuickCommand
onRun: () => void
onEdit: () => void
onDelete: () => void
}
export function TabBarQuickCommandItem({
command,
onRun,
onEdit,
onDelete
}: TabBarQuickCommandItemProps): React.JSX.Element {
return (
<CommandItem
value={command.id}
onSelect={onRun}
className="group/qc mx-1 my-0.5 items-center gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground"
>
{isTerminalAgentQuickCommand(command) ? (
<span className="shrink-0 text-muted-foreground">
<AgentIcon agent={command.agent} size={12} />
</span>
) : (
<Play
className="size-3 shrink-0 text-muted-foreground"
fill="currentColor"
strokeWidth={0}
/>
)}
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-foreground">{command.label}</span>
<span className="block truncate font-mono text-[11px] text-muted-foreground">
{isTerminalAgentQuickCommand(command)
? `${getAgentLabel(command.agent)}: ${command.prompt}`
: command.command}
</span>
</span>
<span className="flex shrink-0 items-center gap-0.5 can-hover:opacity-0 transition-opacity group-hover/qc:opacity-100 group-data-[selected=true]/qc:opacity-100">
<button
type="button"
onClick={(event) => {
event.stopPropagation()
onEdit()
}}
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label={translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.15529ede69',
'Edit {{value0}}',
{ value0: command.label }
)}
>
<Pencil className="size-3" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation()
onDelete()
}}
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-destructive"
aria-label={translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.196593b6a9',
'Remove {{value0}}',
{ value0: command.label }
)}
>
<Trash2 className="size-3" />
</button>
</span>
</CommandItem>
)
}

View File

@ -0,0 +1,385 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Capture window keydown listeners so tests can fire them directly.
const windowListeners = vi.hoisted(() => new Map<string, (e: KeyboardEvent) => void>())
const keybindingsMock = vi.hoisted(() => ({
matchAction: vi.fn().mockReturnValue(false)
}))
const appStoreMock = vi.hoisted(() => ({
state: {
activeView: 'terminal' as 'terminal' | 'settings',
keybindings: {} as Record<string, string[]>,
settings: {
terminalShortcutPolicy: 'orca-first' as 'orca-first' | 'terminal-first'
}
}
}))
// Minimal React hook runtime: track useState values and useEffect callbacks.
const reactRuntime = vi.hoisted(() => ({
states: [] as unknown[],
index: 0,
effects: [] as (() => void | (() => void))[]
}))
vi.mock('react', async () => {
const actual = await vi.importActual<typeof import('react')>('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
return {
...actual,
useState<T>(initial: T | (() => T)) {
const i = reactRuntime.index++
if (!(i in reactRuntime.states)) {
reactRuntime.states[i] = typeof initial === 'function' ? (initial as () => T)() : initial
}
const setState = (next: T | ((prev: T) => T)): void => {
reactRuntime.states[i] =
typeof next === 'function' ? (next as (p: T) => T)(reactRuntime.states[i] as T) : next
}
return [reactRuntime.states[i] as T, setState] as const
},
useEffect(effect: () => void | (() => void)) {
reactRuntime.effects.push(effect)
},
useCallback<T>(fn: T): T {
return fn
},
useMemo<T>(fn: () => T): T {
return fn()
},
useRef<T>(init: T) {
return { current: init }
}
}
})
vi.mock('../../../../shared/keybindings', () => ({
keybindingMatchesAction: keybindingsMock.matchAction
}))
vi.mock('@/lib/shortcut-platform', () => ({
getShortcutPlatform: () => 'darwin' as const
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (s: typeof appStoreMock.state) => unknown) => selector(appStoreMock.state)
}))
vi.mock('@/hooks/useShortcutLabel', () => ({
useShortcutKeyComboDetails: () => []
}))
vi.mock('@/lib/terminal-quick-command-search', () => ({
searchTerminalQuickCommands: (_cmds: unknown[], _q: string) => [],
getTerminalQuickCommandPickerValue: () => null
}))
vi.mock('../../../../shared/terminal-quick-commands', () => ({
isTerminalAgentQuickCommand: () => false,
getTerminalQuickCommandBody: () => ''
}))
vi.mock('@/lib/agent-catalog', () => ({
getAgentLabel: () => '',
AgentIcon: () => null
}))
vi.mock('./TabBarQuickCommandItem', () => ({
TabBarQuickCommandItem: () => null
}))
vi.mock('@/lib/utils', () => ({
cn: (...args: string[]) => args.filter(Boolean).join(' ')
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
vi.mock('lucide-react', () => ({
ChevronDown: () => null,
Play: () => null,
Plus: () => null
}))
vi.mock('@/components/ui/command', () => ({
Command: () => null,
CommandEmpty: () => null,
CommandInput: () => null,
CommandList: () => null,
CommandSeparator: () => null
}))
vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenu: () => null,
DropdownMenuContent: () => null,
DropdownMenuTrigger: () => null
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: () => null,
TooltipContent: () => null,
TooltipTrigger: () => null
}))
function makeProps() {
return {
repoCommands: [] as never[],
globalCommands: [] as never[],
mostRecent: null,
onAddCommand: vi.fn(),
onDeleteCommand: vi.fn(),
onEditCommand: vi.fn(),
onRunCommand: vi.fn()
}
}
function makeKeyEvent(overrides: Partial<KeyboardEvent> = {}): KeyboardEvent {
return {
preventDefault: vi.fn(),
stopImmediatePropagation: vi.fn(),
key: 'q',
code: 'KeyQ',
metaKey: true,
ctrlKey: false,
altKey: false,
shiftKey: true,
...overrides
} as unknown as KeyboardEvent
}
beforeEach(() => {
reactRuntime.states = []
reactRuntime.index = 0
reactRuntime.effects = []
windowListeners.clear()
keybindingsMock.matchAction.mockClear()
keybindingsMock.matchAction.mockReturnValue(false)
appStoreMock.state.activeView = 'terminal'
appStoreMock.state.keybindings = {}
appStoreMock.state.settings.terminalShortcutPolicy = 'orca-first'
vi.stubGlobal('window', {
addEventListener: vi.fn((type: string, handler: (e: KeyboardEvent) => void) => {
windowListeners.set(type, handler)
}),
removeEventListener: vi.fn()
})
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0)
return 1
})
vi.stubGlobal('cancelAnimationFrame', vi.fn())
})
describe('TabBarQuickCommandsMenu keyboard shortcut', () => {
it('registers a capturing keydown listener on mount', async () => {
reactRuntime.index = 0
const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu')
TabBarQuickCommandsMenu(makeProps())
// Effect index 0 is the keyboard toggle effect.
reactRuntime.effects[0]()
expect(window.addEventListener).toHaveBeenCalledWith('keydown', expect.any(Function), {
capture: true
})
expect(window.addEventListener).toHaveBeenCalledWith('keyup', expect.any(Function), {
capture: true
})
})
it('does not register keyboard listeners while the terminal workbench is hidden', async () => {
appStoreMock.state.activeView = 'settings'
reactRuntime.index = 0
const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu')
TabBarQuickCommandsMenu(makeProps())
reactRuntime.effects[0]()
expect(window.addEventListener).not.toHaveBeenCalledWith('keydown', expect.any(Function), {
capture: true
})
expect(window.addEventListener).not.toHaveBeenCalledWith('keyup', expect.any(Function), {
capture: true
})
})
it('toggles menuOpen to true when a matching key is pressed', async () => {
reactRuntime.index = 0
const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu')
TabBarQuickCommandsMenu(makeProps())
reactRuntime.effects[0]()
keybindingsMock.matchAction.mockReturnValue(true)
const handler = windowListeners.get('keydown')
expect(handler).toBeDefined()
handler!(makeKeyEvent())
// menuOpen is useState index 0, initial false → toggled to true.
expect(reactRuntime.states[0]).toBe(true)
})
it('toggles menuOpen closed when pressed again', async () => {
reactRuntime.index = 0
const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu')
TabBarQuickCommandsMenu(makeProps())
reactRuntime.effects[0]()
keybindingsMock.matchAction.mockReturnValue(true)
windowListeners.get('keydown')!(makeKeyEvent())
expect(reactRuntime.states[0]).toBe(true)
// Simulate React re-running the effect after menuOpen changed to true,
// so the handler closes over the updated value.
reactRuntime.index = 0
reactRuntime.effects = []
TabBarQuickCommandsMenu(makeProps())
reactRuntime.effects[0]()
windowListeners.get('keydown')!(makeKeyEvent())
expect(reactRuntime.states[0]).toBe(false)
})
it('does not toggle when the event is a repeat (key held down)', async () => {
reactRuntime.index = 0
const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu')
TabBarQuickCommandsMenu(makeProps())
reactRuntime.effects[0]()
keybindingsMock.matchAction.mockReturnValue(true)
const handler = windowListeners.get('keydown')!
handler(makeKeyEvent({ repeat: true }))
expect(reactRuntime.states[0]).toBe(false)
})
it('does not toggle when the key does not match the action', async () => {
reactRuntime.index = 0
const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu')
TabBarQuickCommandsMenu(makeProps())
reactRuntime.effects[0]()
keybindingsMock.matchAction.mockReturnValue(false)
const handler = windowListeners.get('keydown')!
handler(makeKeyEvent())
expect(reactRuntime.states[0]).toBe(false)
})
it('prevents default and stops propagation for matching keys', async () => {
reactRuntime.index = 0
const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu')
TabBarQuickCommandsMenu(makeProps())
reactRuntime.effects[0]()
keybindingsMock.matchAction.mockReturnValue(true)
const event = makeKeyEvent()
windowListeners.get('keydown')!(event)
expect(event.preventDefault).toHaveBeenCalled()
expect(event.stopImmediatePropagation).toHaveBeenCalled()
})
it('passes terminal context and terminal-first policy to shortcut matching', async () => {
appStoreMock.state.settings.terminalShortcutPolicy = 'terminal-first'
reactRuntime.index = 0
const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu')
TabBarQuickCommandsMenu(makeProps())
reactRuntime.effects[0]()
const terminalTarget = {
classList: { contains: (className: string) => className === 'xterm-helper-textarea' },
closest: () => null
} as unknown as EventTarget
const handler = windowListeners.get('keydown')!
handler(makeKeyEvent({ target: terminalTarget }))
expect(keybindingsMock.matchAction).toHaveBeenCalledWith(
'tab.openQuickCommandsMenu',
expect.objectContaining({ key: 'q', code: 'KeyQ' }),
'darwin',
appStoreMock.state.keybindings,
{ context: 'terminal', terminalShortcutPolicy: 'terminal-first' }
)
expect(reactRuntime.states[0]).toBe(false)
})
it('ignores shortcut presses while the shortcut recorder is active', async () => {
reactRuntime.index = 0
const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu')
TabBarQuickCommandsMenu(makeProps())
reactRuntime.effects[0]()
const recorderTarget = {
closest: (selector: string) => (selector === '[data-shortcut-recorder-active]' ? {} : null)
} as unknown as EventTarget
const handler = windowListeners.get('keydown')!
handler(makeKeyEvent({ target: recorderTarget }))
expect(keybindingsMock.matchAction).not.toHaveBeenCalled()
expect(reactRuntime.states[0]).toBe(false)
})
it('toggles from a matching double-tap binding in the renderer path', async () => {
reactRuntime.index = 0
const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu')
TabBarQuickCommandsMenu(makeProps())
reactRuntime.effects[0]()
keybindingsMock.matchAction.mockImplementation(
(_actionId, input: { doubleTapModifier?: string }) => input.doubleTapModifier === 'Shift'
)
const keyDown = windowListeners.get('keydown')!
const keyUp = windowListeners.get('keyup')!
const firstDown = makeKeyEvent({
key: 'Shift',
code: 'ShiftLeft',
metaKey: false,
shiftKey: true
})
const firstUp = makeKeyEvent({
key: 'Shift',
code: 'ShiftLeft',
metaKey: false,
shiftKey: true
})
const secondDown = makeKeyEvent({
key: 'Shift',
code: 'ShiftLeft',
metaKey: false,
shiftKey: true
})
keyDown(firstDown)
keyUp(firstUp)
keyDown(secondDown)
expect(reactRuntime.states[0]).toBe(true)
expect(secondDown.preventDefault).toHaveBeenCalled()
expect(secondDown.stopImmediatePropagation).toHaveBeenCalled()
})
it('removes the listener when the effect is cleaned up', async () => {
reactRuntime.index = 0
const { TabBarQuickCommandsMenu } = await import('./TabBarQuickCommandsMenu')
TabBarQuickCommandsMenu(makeProps())
const cleanup = reactRuntime.effects[0]()
cleanup?.()
expect(window.removeEventListener).toHaveBeenCalledWith('keydown', expect.any(Function), {
capture: true
})
expect(window.removeEventListener).toHaveBeenCalledWith('keyup', expect.any(Function), {
capture: true
})
})
})

View File

@ -1,10 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, Pencil, Play, Plus, Trash2 } from 'lucide-react'
import { ChevronDown, Play, Plus } from 'lucide-react'
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
CommandSeparator
} from '@/components/ui/command'
@ -14,18 +13,22 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
import {
getTerminalQuickCommandBody,
isTerminalAgentQuickCommand
} from '../../../../shared/terminal-quick-commands'
import type { TerminalQuickCommand } from '../../../../shared/types'
import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog'
import { getAgentLabel } from '@/lib/agent-catalog'
import { TabBarQuickCommandItem } from './TabBarQuickCommandItem'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import {
getTerminalQuickCommandPickerValue,
searchTerminalQuickCommands
} from '@/lib/terminal-quick-command-search'
import { useShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
import { useTabBarQuickCommandsShortcut } from './tab-bar-quick-commands-shortcut'
type TabBarQuickCommandsMenuProps = {
repoCommands: readonly TerminalQuickCommand[]
globalCommands: readonly TerminalQuickCommand[]
@ -35,6 +38,7 @@ type TabBarQuickCommandsMenuProps = {
onEditCommand: (command: TerminalQuickCommand) => void
onRunCommand: (command: TerminalQuickCommand) => void
}
export function TabBarQuickCommandsMenu({
repoCommands,
globalCommands,
@ -44,12 +48,17 @@ export function TabBarQuickCommandsMenu({
onEditCommand,
onRunCommand
}: TabBarQuickCommandsMenuProps): React.JSX.Element {
const openMenuShortcutCombos = useShortcutKeyComboDetails('tab.openQuickCommandsMenu')
const [menuOpen, setMenuOpen] = useState(false)
const [moreCommandsTooltipOpen, setMoreCommandsTooltipOpen] = useState(false)
const [query, setQuery] = useState('')
const [commandValueOverride, setCommandValueOverride] = useState<string | null>(null)
const searchInputRef = useRef<HTMLInputElement | null>(null)
const commandListRef = useRef<HTMLDivElement | null>(null)
const focusFrameRef = useRef<number | null>(null)
// Why: closing restores focus to the chevron for accessibility, but that
// focus restoration should not immediately reopen its tooltip.
const suppressMoreCommandsTooltipRef = useRef(false)
const totalVisible = repoCommands.length + globalCommands.length
const showSearch = totalVisible > 1
const filteredRepoCommands = useMemo(
@ -101,16 +110,36 @@ export function TabBarQuickCommandsMenu({
searchInput.setSelectionRange(end, end)
})
}, [cancelFocusFrame])
const handleOpenChange = (next: boolean): void => {
setMenuOpen(next)
if (next) {
setCommandValueOverride(null)
const handleMoreCommandsTooltipOpenChange = useCallback((next: boolean): void => {
if (next && suppressMoreCommandsTooltipRef.current) {
return
}
cancelFocusFrame()
setQuery('')
setCommandValueOverride(null)
}
setMoreCommandsTooltipOpen(next)
}, [])
const allowMoreCommandsTooltip = useCallback((): void => {
suppressMoreCommandsTooltipRef.current = false
}, [])
const handleOpenChange = useCallback(
(next: boolean): void => {
setMenuOpen(next)
if (next) {
suppressMoreCommandsTooltipRef.current = false
setMoreCommandsTooltipOpen(false)
setCommandValueOverride(null)
return
}
suppressMoreCommandsTooltipRef.current = true
setMoreCommandsTooltipOpen(false)
cancelFocusFrame()
setQuery('')
setCommandValueOverride(null)
},
[cancelFocusFrame]
)
const closeMenu = useCallback((): void => {
handleOpenChange(false)
}, [handleOpenChange])
useTabBarQuickCommandsShortcut({ menuOpen, onOpenChange: handleOpenChange })
useEffect(() => {
if (!menuOpen || !showSearch) {
return
@ -122,10 +151,10 @@ export function TabBarQuickCommandsMenu({
}, [cancelFocusFrame, focusSearchInput, menuOpen, showSearch])
const runAndClose = useCallback(
(command: TerminalQuickCommand): void => {
setMenuOpen(false)
closeMenu()
onRunCommand(command)
},
[onRunCommand]
[closeMenu, onRunCommand]
)
const handleSearchKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
@ -170,72 +199,14 @@ export function TabBarQuickCommandsMenu({
},
[commandValue, filteredVisibleCommands, runAndClose, selectedCommand]
)
const moreCommandsLabel = translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.b82e237a4b',
'More quick commands'
)
const splitButtonClass =
'my-auto flex h-7 shrink-0 items-stretch overflow-hidden rounded-md border border-border/60 text-muted-foreground'
const innerButtonBase =
'flex items-center bg-transparent leading-none text-muted-foreground hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent'
const renderItem = (command: TerminalQuickCommand): React.JSX.Element => (
<CommandItem
key={command.id}
value={command.id}
onSelect={() => runAndClose(command)}
className="group/qc mx-1 my-0.5 items-center gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground"
>
{isTerminalAgentQuickCommand(command) ? (
<span className="shrink-0 text-muted-foreground">
<AgentIcon agent={command.agent} size={12} />
</span>
) : (
<Play
className="size-3 shrink-0 text-muted-foreground"
fill="currentColor"
strokeWidth={0}
/>
)}
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-foreground">{command.label}</span>
<span className="block truncate font-mono text-[11px] text-muted-foreground">
{isTerminalAgentQuickCommand(command)
? `${getAgentLabel(command.agent)}: ${command.prompt}`
: command.command}
</span>
</span>
<span className="flex shrink-0 items-center gap-0.5 can-hover:opacity-0 transition-opacity group-hover/qc:opacity-100 group-data-[selected=true]/qc:opacity-100">
<button
type="button"
onClick={(event) => {
event.stopPropagation()
setMenuOpen(false)
onEditCommand(command)
}}
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label={translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.15529ede69',
'Edit {{value0}}',
{ value0: command.label }
)}
>
<Pencil className="size-3" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation()
setMenuOpen(false)
onDeleteCommand(command)
}}
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-destructive"
aria-label={translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.196593b6a9',
'Remove {{value0}}',
{ value0: command.label }
)}
>
<Trash2 className="size-3" />
</button>
</span>
</CommandItem>
)
return (
<div className={splitButtonClass}>
<Tooltip>
@ -288,21 +259,39 @@ export function TabBarQuickCommandsMenu({
</TooltipContent>
</Tooltip>
<DropdownMenu modal={false} open={menuOpen} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
innerButtonBase,
'justify-center rounded-l-none rounded-r-md border-l border-border/60 px-1'
)}
aria-label={translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.b82e237a4b',
'More quick commands'
)}
>
<ChevronDown className="size-3" strokeWidth={2.5} />
</button>
</DropdownMenuTrigger>
<Tooltip open={moreCommandsTooltipOpen} onOpenChange={handleMoreCommandsTooltipOpenChange}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
innerButtonBase,
'justify-center rounded-l-none rounded-r-md border-l border-border/60 px-1'
)}
aria-label={moreCommandsLabel}
onPointerEnter={allowMoreCommandsTooltip}
onBlur={allowMoreCommandsTooltip}
>
<ChevronDown className="size-3" strokeWidth={2.5} />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
<span className="inline-flex items-center gap-1.5">
<span>{moreCommandsLabel}</span>
{openMenuShortcutCombos.map((shortcut, index) => (
<ShortcutKeyCombo
key={`${shortcut.keys.join('-')}-${index}`}
keys={shortcut.keys}
doubleTap={shortcut.doubleTap}
className="gap-0.5"
keyCapClassName="min-w-0 border-background/30 bg-background/10 px-1 py-0 text-[10px] text-background shadow-none"
separatorClassName="mx-0 text-[10px] text-background/70"
/>
))}
</span>
</TooltipContent>
</Tooltip>
<DropdownMenuContent
align="end"
side="bottom"
@ -358,17 +347,45 @@ export function TabBarQuickCommandsMenu({
)}
</CommandEmpty>
) : null}
{filteredRepoCommands.map(renderItem)}
{filteredRepoCommands.map((command) => (
<TabBarQuickCommandItem
key={command.id}
command={command}
onRun={() => runAndClose(command)}
onEdit={() => {
closeMenu()
onEditCommand(command)
}}
onDelete={() => {
closeMenu()
onDeleteCommand(command)
}}
/>
))}
{filteredRepoCommands.length > 0 && filteredGlobalCommands.length > 0 ? (
<CommandSeparator className="my-1" />
) : null}
{filteredGlobalCommands.map(renderItem)}
{filteredGlobalCommands.map((command) => (
<TabBarQuickCommandItem
key={command.id}
command={command}
onRun={() => runAndClose(command)}
onEdit={() => {
closeMenu()
onEditCommand(command)
}}
onDelete={() => {
closeMenu()
onDeleteCommand(command)
}}
/>
))}
</CommandList>
<div className="border-t border-border/50 p-1">
<button
type="button"
onClick={() => {
setMenuOpen(false)
closeMenu()
onAddCommand()
}}
className="flex w-full items-center gap-2 rounded-[5px] px-2 py-1.5 text-[12px] text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"

View File

@ -0,0 +1,150 @@
import { useEffect } from 'react'
import {
keybindingMatchesAction,
type KeybindingContext,
type KeybindingInput
} from '../../../../shared/keybindings'
import {
ModifierDoubleTapDetector,
toModifierDoubleTapEvent
} from '../../../../shared/modifier-double-tap-detector'
import { getShortcutPlatform } from '@/lib/shortcut-platform'
import { TOGGLE_QUICK_COMMANDS_MENU_EVENT } from '@/lib/quick-commands-menu-events'
import { useAppStore } from '@/store'
type UseTabBarQuickCommandsShortcutParams = {
menuOpen: boolean
onOpenChange: (next: boolean) => void
}
function targetHasClass(target: EventTarget | null, className: string): boolean {
const classList = (target as { classList?: { contains?: (value: string) => boolean } } | null)
?.classList
return typeof classList?.contains === 'function' && classList.contains(className)
}
function targetMatchesClosest(target: EventTarget | null, selector: string): boolean {
const closest = (target as { closest?: (value: string) => unknown } | null)?.closest
return typeof closest === 'function' && Boolean(closest.call(target, selector))
}
function getQuickCommandsShortcutContext(target: EventTarget | null): KeybindingContext {
return targetHasClass(target, 'xterm-helper-textarea') ? 'terminal' : 'app'
}
export function useTabBarQuickCommandsShortcut({
menuOpen,
onOpenChange
}: UseTabBarQuickCommandsShortcutParams): void {
const keybindings = useAppStore((s) => s.keybindings)
const terminalShortcutPolicy = useAppStore(
(s) => s.settings?.terminalShortcutPolicy ?? 'orca-first'
)
const activeView = useAppStore((s) => s.activeView)
// Why: this hook only runs in the focused tab group's menu component, so the
// listener naturally scopes to the active group with no extra coordination.
useEffect(() => {
if (activeView !== 'terminal') {
return
}
const platform = getShortcutPlatform()
const doubleTapDetector = new ModifierDoubleTapDetector()
const matchesShortcut = (input: KeybindingInput, target: EventTarget | null): boolean => {
const context = getQuickCommandsShortcutContext(target)
return keybindingMatchesAction('tab.openQuickCommandsMenu', input, platform, keybindings, {
context,
terminalShortcutPolicy
})
}
const toggleMenu = (e: KeyboardEvent): void => {
e.preventDefault()
e.stopImmediatePropagation()
onOpenChange(!menuOpen)
}
const onKeyDown = (e: KeyboardEvent): void => {
if (targetMatchesClosest(e.target, '[data-shortcut-recorder-active]')) {
doubleTapDetector.reset()
return
}
const detected = doubleTapDetector.process(
toModifierDoubleTapEvent({
type: 'keyDown',
code: e.code,
key: e.key,
shift: e.shiftKey,
control: e.ctrlKey,
alt: e.altKey,
meta: e.metaKey,
isAutoRepeat: e.repeat
}),
Date.now()
)
if (detected) {
if (matchesShortcut({ doubleTapModifier: detected.modifier }, e.target)) {
toggleMenu(e)
}
return
}
if (e.repeat) {
return
}
if (
!matchesShortcut(
{
key: e.key,
code: e.code,
altKey: e.altKey,
metaKey: e.metaKey,
ctrlKey: e.ctrlKey,
shiftKey: e.shiftKey
},
e.target
)
) {
return
}
toggleMenu(e)
}
const onKeyUp = (e: KeyboardEvent): void => {
if (targetMatchesClosest(e.target, '[data-shortcut-recorder-active]')) {
doubleTapDetector.reset()
return
}
doubleTapDetector.process(
toModifierDoubleTapEvent({
type: 'keyUp',
code: e.code,
key: e.key,
shift: e.shiftKey,
control: e.ctrlKey,
alt: e.altKey,
meta: e.metaKey
}),
Date.now()
)
}
const onBlur = (): void => doubleTapDetector.reset()
window.addEventListener('keydown', onKeyDown, { capture: true })
window.addEventListener('keyup', onKeyUp, { capture: true })
window.addEventListener('blur', onBlur)
return () => {
window.removeEventListener('keydown', onKeyDown, { capture: true })
window.removeEventListener('keyup', onKeyUp, { capture: true })
window.removeEventListener('blur', onBlur)
}
}, [activeView, keybindings, menuOpen, onOpenChange, terminalShortcutPolicy])
useEffect(() => {
if (activeView !== 'terminal') {
return
}
const onToggleQuickCommandsMenu = (): void => {
onOpenChange(!menuOpen)
}
window.addEventListener(TOGGLE_QUICK_COMMANDS_MENU_EVENT, onToggleQuickCommandsMenu)
return () => {
window.removeEventListener(TOGGLE_QUICK_COMMANDS_MENU_EVENT, onToggleQuickCommandsMenu)
}
}, [activeView, menuOpen, onOpenChange])
}

View File

@ -125,7 +125,10 @@ export function resyncTerminalFocusForWindowFocus(args: {
const schedule = args.scheduleRefocus ?? scheduleNextFrame
schedule(() => {
const active = reclaimedHelper.ownerDocument.activeElement
if (active === reclaimedHelper || isDocumentBodyOrNull(active, reclaimedHelper.ownerDocument)) {
if (
active === reclaimedHelper ||
isDocumentBodyOrNull(active, reclaimedHelper.ownerDocument)
) {
reclaimedHelper.focus()
}
})
@ -144,7 +147,10 @@ export function resyncTerminalFocusForWindowFocus(args: {
// Why: only reclaim focus if nothing else grabbed it during the frame, so
// a click into another field mid-reactivation isn't yanked back.
const active = reclaimedHelper.ownerDocument.activeElement
if (active === reclaimedHelper || isDocumentBodyOrNull(active, reclaimedHelper.ownerDocument)) {
if (
active === reclaimedHelper ||
isDocumentBodyOrNull(active, reclaimedHelper.ownerDocument)
) {
reclaimedHelper.focus()
}
})

View File

@ -948,6 +948,7 @@ describe('useIpcEvents browser tab create routing', () => {
onToggleWorktreePalette: () => () => {},
onToggleFloatingTerminal: () => () => {},
onOpenQuickOpen: () => () => {},
onToggleQuickCommandsMenu: () => () => {},
onOpenNewWorkspace: () => () => {},
onOpenTasks: () => () => {},
onJumpToWorktreeIndex: () => () => {},
@ -1167,6 +1168,7 @@ describe('useIpcEvents updater integration', () => {
onToggleWorktreePalette: () => () => {},
onToggleFloatingTerminal: () => () => {},
onOpenQuickOpen: () => () => {},
onToggleQuickCommandsMenu: () => () => {},
onOpenNewWorkspace: () => () => {},
onOpenTasks: () => () => {},
onJumpToWorktreeIndex: () => () => {},
@ -1409,6 +1411,7 @@ describe('useIpcEvents updater integration', () => {
onToggleWorktreePalette: () => () => {},
onToggleFloatingTerminal: () => () => {},
onOpenQuickOpen: () => () => {},
onToggleQuickCommandsMenu: () => () => {},
onOpenNewWorkspace: () => () => {},
onOpenTasks: () => () => {},
onJumpToWorktreeIndex: () => () => {},
@ -1840,6 +1843,7 @@ describe('useIpcEvents updater integration', () => {
onToggleWorktreePalette: () => () => {},
onToggleFloatingTerminal: () => () => {},
onOpenQuickOpen: () => () => {},
onToggleQuickCommandsMenu: () => () => {},
onOpenNewWorkspace: () => () => {},
onOpenTasks: () => () => {},
onJumpToWorktreeIndex: () => () => {},
@ -2731,6 +2735,7 @@ describe('useIpcEvents browser tab close routing', () => {
onToggleWorktreePalette: () => () => {},
onToggleFloatingTerminal: () => () => {},
onOpenQuickOpen: () => () => {},
onToggleQuickCommandsMenu: () => () => {},
onOpenNewWorkspace: () => () => {},
onOpenTasks: () => () => {},
onJumpToWorktreeIndex: () => () => {},
@ -3218,6 +3223,7 @@ describe('useIpcEvents browser tab close routing', () => {
onToggleWorktreePalette: () => () => {},
onToggleFloatingTerminal: () => () => {},
onOpenQuickOpen: () => () => {},
onToggleQuickCommandsMenu: () => () => {},
onOpenNewWorkspace: () => () => {},
onOpenTasks: () => () => {},
onJumpToWorktreeIndex: () => () => {},
@ -3433,6 +3439,7 @@ describe('useIpcEvents browser tab close routing', () => {
onToggleWorktreePalette: () => () => {},
onToggleFloatingTerminal: () => () => {},
onOpenQuickOpen: () => () => {},
onToggleQuickCommandsMenu: () => () => {},
onOpenNewWorkspace: () => () => {},
onOpenTasks: () => () => {},
onJumpToWorktreeIndex: () => () => {},
@ -3643,6 +3650,7 @@ describe('useIpcEvents browser tab close routing', () => {
onToggleWorktreePalette: () => () => {},
onToggleFloatingTerminal: () => () => {},
onOpenQuickOpen: () => () => {},
onToggleQuickCommandsMenu: () => () => {},
onOpenNewWorkspace: () => () => {},
onOpenTasks: () => () => {},
onJumpToWorktreeIndex: () => () => {},
@ -3871,6 +3879,7 @@ describe('useIpcEvents CLI-created worktree activation', () => {
onToggleWorktreePalette: () => () => {},
onToggleFloatingTerminal: () => () => {},
onOpenQuickOpen: () => () => {},
onToggleQuickCommandsMenu: () => () => {},
onOpenNewWorkspace: () => () => {},
onOpenTasks: () => () => {},
onJumpToWorktreeIndex: () => () => {},
@ -4125,6 +4134,7 @@ describe('useIpcEvents CLI-created worktree activation', () => {
onToggleWorktreePalette: () => () => {},
onToggleFloatingTerminal: () => () => {},
onOpenQuickOpen: () => () => {},
onToggleQuickCommandsMenu: () => () => {},
onOpenNewWorkspace: () => () => {},
onOpenTasks: () => () => {},
onJumpToWorktreeIndex: () => () => {},
@ -4356,6 +4366,7 @@ describe('useIpcEvents agent status snapshot integration', () => {
onToggleWorktreePalette: () => () => {},
onToggleFloatingTerminal: () => () => {},
onOpenQuickOpen: () => () => {},
onToggleQuickCommandsMenu: () => () => {},
onOpenNewWorkspace: () => () => {},
onOpenTasks: () => () => {},
onJumpToWorktreeIndex: () => () => {},

View File

@ -63,6 +63,7 @@ import {
} from '../../../shared/agent-status-identity'
import { isGitRepoKind } from '../../../shared/repo-kind'
import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal'
import { TOGGLE_QUICK_COMMANDS_MENU_EVENT } from '@/lib/quick-commands-menu-events'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
import { focusRuntimeTerminalSurface } from '@/runtime/sync-runtime-graph'
@ -1230,6 +1231,12 @@ export function useIpcEvents(): void {
})
)
unsubs.push(
window.api.ui.onToggleQuickCommandsMenu(() => {
window.dispatchEvent(new CustomEvent(TOGGLE_QUICK_COMMANDS_MENU_EVENT))
})
)
unsubs.push(
window.api.ui.onOpenNewWorkspace(() => {
const store = useAppStore.getState()

View File

@ -0,0 +1 @@
export const TOGGLE_QUICK_COMMANDS_MENU_EVENT = 'orca:toggleQuickCommandsMenu'

View File

@ -2170,6 +2170,7 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
onToggleFloatingTerminal: () => noopUnsubscribe,
onTerminalShortcutCaptured: () => noopUnsubscribe,
onOpenQuickOpen: () => noopUnsubscribe,
onToggleQuickCommandsMenu: () => noopUnsubscribe,
onOpenTasks: () => noopUnsubscribe,
onOpenNewWorkspace: () => noopUnsubscribe,
onDeleteCurrentWorkspace: () => noopUnsubscribe,

View File

@ -224,6 +224,71 @@ describe('keybindings', () => {
})
})
it('reports quick-command menu conflicts with global shortcuts and digit ranges', () => {
expect(
findKeybindingConflicts('darwin', {
'tab.openQuickCommandsMenu': ['Mod+P']
})
).toContainEqual({
binding: 'Mod+P',
actionIds: expect.arrayContaining(['worktree.quickOpen', 'tab.openQuickCommandsMenu'])
})
expect(
findKeybindingConflicts('darwin', {
'tab.openQuickCommandsMenu': ['Cmd+P']
})
).toContainEqual({
binding: 'Mod+P',
actionIds: expect.arrayContaining(['worktree.quickOpen', 'tab.openQuickCommandsMenu'])
})
expect(
findKeybindingConflicts('linux', {
'tab.openQuickCommandsMenu': ['Ctrl+P']
})
).toContainEqual({
binding: 'Mod+P',
actionIds: expect.arrayContaining(['worktree.quickOpen', 'tab.openQuickCommandsMenu'])
})
expect(
findKeybindingConflicts('darwin', {
'tab.openQuickCommandsMenu': ['Mod+3']
})
).toContainEqual({
binding: 'Mod+3',
actionIds: expect.arrayContaining(['workspace.selectByIndex', 'tab.openQuickCommandsMenu'])
})
expect(
findKeybindingConflicts('darwin', {
'tab.openQuickCommandsMenu': ['Cmd+3']
})
).toContainEqual({
binding: 'Cmd+3',
actionIds: expect.arrayContaining(['workspace.selectByIndex', 'tab.openQuickCommandsMenu'])
})
expect(
findKeybindingConflicts('linux', {
'tab.openQuickCommandsMenu': ['Ctrl+3']
})
).toContainEqual({
binding: 'Ctrl+3',
actionIds: expect.arrayContaining(['workspace.selectByIndex', 'tab.openQuickCommandsMenu'])
})
expect(
findKeybindingConflicts('linux', {
'tab.openQuickCommandsMenu': ['Alt+4']
})
).toContainEqual({
binding: 'Alt+4',
actionIds: expect.arrayContaining(['tab.selectByIndex', 'tab.openQuickCommandsMenu'])
})
})
it('defines macOS-only rename shortcuts that stay conflict-free', () => {
expect(getEffectiveKeybindingsForAction('tab.rename', 'darwin')).toEqual(['Mod+R'])
expect(getEffectiveKeybindingsForAction('tab.rename', 'linux')).toEqual([])
@ -494,6 +559,38 @@ describe('keybindings', () => {
)
})
it('keeps the quick commands menu toggle unassigned until users customize it', () => {
const platforms: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32']
for (const platform of platforms) {
expect(getEffectiveKeybindingsForAction('tab.openQuickCommandsMenu', platform)).toEqual([])
}
const binding = {
key: 'q',
code: 'KeyQ',
control: true,
meta: false,
alt: false,
shift: true
}
expect(keybindingMatchesAction('tab.openQuickCommandsMenu', binding, 'linux')).toBe(false)
expect(
keybindingMatchesAction('tab.openQuickCommandsMenu', binding, 'linux', {
'tab.openQuickCommandsMenu': ['Mod+Shift+Q']
})
).toBe(true)
const definition = getKeybindingDefinition('tab.openQuickCommandsMenu')
expect(definition?.title).toBe('Toggle Quick Commands menu')
expect(definition?.group).toBe('Quick Commands')
expect(definition?.scope).toBe('tabs')
expect(definition?.searchKeywords).toEqual(
expect.arrayContaining(['shortcut', 'quick', 'command', 'menu', 'tab'])
)
})
it('keeps the sleeping-workspaces toggle unassigned until users customize it', () => {
const binding = {
key: 's',

View File

@ -77,6 +77,7 @@ export type KeybindingActionId =
| 'tab.nextTerminal'
| 'tab.previousTerminal'
| 'tab.selectByIndex'
| 'tab.openQuickCommandsMenu'
| 'browser.find'
| 'browser.back'
| 'browser.forward'
@ -704,6 +705,17 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [
win32: ['Alt+1']
}
},
{
id: 'tab.openQuickCommandsMenu',
title: 'Toggle Quick Commands menu',
group: 'Quick Commands',
scope: 'tabs',
// Why: this tab-scoped action is also routed through the main window
// shortcut allowlist, so Settings must warn when it shadows global chords.
conflictGroup: 'global',
searchKeywords: ['shortcut', 'quick', 'command', 'menu', 'tab', 'group', 'toggle'],
defaultBindings: platformBindings([])
},
{
id: 'browser.find',
title: 'Find in Browser',
@ -1986,12 +1998,44 @@ export function keybindingMatchesInput(
)
}
function keybindingConflictIdentityForParsed(
parsed: ParsedKeybinding,
platform: NodeJS.Platform
): string {
if (parsed.doubleTapModifier) {
return `DoubleTap:${resolveModifierToken(parsed.doubleTapModifier, platform)}`
}
const modifiers = platformModifiers(parsed, platform)
return [
modifiers.meta ? 'Meta' : '',
modifiers.control ? 'Control' : '',
modifiers.alt ? 'Alt' : '',
modifiers.shift ? 'Shift' : '',
parsed.key
].join('+')
}
function keybindingConflictIdentity(binding: string, platform: NodeJS.Platform): string {
const parsed = parseKeybinding(binding)
if (!parsed?.doubleTapModifier) {
return binding
return parsed ? keybindingConflictIdentityForParsed(parsed, platform) : binding
}
function keybindingConflictIdentities(
actionId: KeybindingActionId,
binding: string,
platform: NodeJS.Platform
): readonly string[] {
const exact = keybindingConflictIdentity(binding, platform)
if (!isDigitIndexActionId(actionId)) {
return [exact]
}
return `DoubleTap:${resolveModifierToken(parsed.doubleTapModifier, platform)}`
const parsed = parseKeybinding(binding)
if (!parsed || parsed.doubleTapModifier || !DIGIT_INDEX_KEY_PATTERN.test(parsed.key)) {
return [exact]
}
return Array.from({ length: 9 }, (_, index) =>
keybindingConflictIdentityForParsed({ ...parsed, key: String(index + 1) }, platform)
)
}
export function keybindingMatchesAction(
@ -2175,20 +2219,37 @@ export function findKeybindingConflicts(
groups.add(definition.scope)
}
for (const group of groups) {
const conflictKey = `${group}\u0000${keybindingConflictIdentity(binding, platform)}`
const current = owners.get(conflictKey) ?? { binding, actionIds: new Set() }
current.actionIds.add(definition.id)
owners.set(conflictKey, current)
for (const identity of keybindingConflictIdentities(definition.id, binding, platform)) {
const conflictKey = `${group}\u0000${identity}`
const current = owners.get(conflictKey) ?? { binding, actionIds: new Set() }
if (
!isDigitIndexActionId(definition.id) &&
Array.from(current.actionIds).some((actionId) => isDigitIndexActionId(actionId))
) {
current.binding = binding
}
current.actionIds.add(definition.id)
owners.set(conflictKey, current)
}
}
}
}
const seenConflictKeys = new Set<string>()
return Array.from(owners.values())
.filter(({ actionIds }) => actionIds.size > 1 && setIntersects(actionIds, customizedActions))
.map(({ binding, actionIds }) => ({
binding,
actionIds: Array.from(actionIds)
}))
.filter((conflict) => {
const key = `${conflict.binding}\u0000${conflict.actionIds.join('\u0000')}`
if (seenConflictKeys.has(key)) {
return false
}
seenConflictKeys.add(key)
return true
})
}
function setIntersects<T>(left: ReadonlySet<T>, right: ReadonlySet<T>): boolean {

View File

@ -104,6 +104,54 @@ describe('resolveWindowShortcutAction', () => {
).toBeNull()
})
it('resolves customized quick-command menu shortcuts with terminal policy gating', () => {
const input: WindowShortcutInput = {
code: 'KeyQ',
key: 'q',
meta: false,
control: true,
alt: false,
shift: true
}
const overrides: KeybindingOverrides = {
'tab.openQuickCommandsMenu': ['Mod+Shift+Q']
}
expect(resolveWindowShortcutAction(input, 'linux', overrides)).toEqual({
type: 'toggleQuickCommandsMenu'
})
expect(
resolveWindowShortcutAction(input, 'linux', overrides, {
context: 'terminal',
terminalShortcutPolicy: 'terminal-first'
})
).toBeNull()
expect(
resolveWindowShortcutAction(input, 'linux', overrides, {
context: 'terminal',
terminalShortcutPolicy: 'orca-first'
})
).toEqual({ type: 'toggleQuickCommandsMenu' })
})
it('keeps digit-index navigation ahead of customized quick-command shortcuts', () => {
expect(
resolveWindowShortcutAction(
{ code: 'Digit3', key: '3', meta: true, control: false, alt: false, shift: false },
'darwin',
{ 'tab.openQuickCommandsMenu': ['Mod+3'] }
)
).toEqual({ type: 'jumpToWorktreeIndex', index: 2 })
expect(
resolveWindowShortcutAction(
{ code: 'Digit4', key: '4', meta: false, control: false, alt: true, shift: false },
'linux',
{ 'tab.openQuickCommandsMenu': ['Alt+4'] }
)
).toEqual({ type: 'jumpToTabIndex', index: 3 })
})
it('honors remapped tab/workspace number ranges, including swapping the modifiers', () => {
// Swap on macOS: tab now uses Cmd+1-9, workspace uses Ctrl+1-9.
const swapped: KeybindingOverrides = {

View File

@ -36,6 +36,7 @@ export type WindowShortcutAction =
| { type: 'toggleLeftSidebar' }
| { type: 'toggleRightSidebar' }
| { type: 'openQuickOpen' }
| { type: 'toggleQuickCommandsMenu' }
| { type: 'openNewWorkspace' }
| { type: 'deleteCurrentWorkspace' }
| { type: 'openWorkspaceBoard' }
@ -248,6 +249,10 @@ export function resolveWindowShortcutAction(
return { type: 'jumpToTabIndex', index: tabIndex }
}
if (actionMatches('tab.openQuickCommandsMenu', input, platform, keybindings, options)) {
return { type: 'toggleQuickCommandsMenu' }
}
// Why: this helper is the explicit allowlist for main-process interception.
// Anything not listed here must keep flowing to the renderer/PTTY so readline
// chords like Ctrl+R, Ctrl+U, and Ctrl+E are not accidentally stolen while
@ -277,6 +282,8 @@ export function getWindowShortcutActionId(action: WindowShortcutAction): Keybind
return 'sidebar.right.toggle'
case 'openQuickOpen':
return 'worktree.quickOpen'
case 'toggleQuickCommandsMenu':
return 'tab.openQuickCommandsMenu'
case 'openNewWorkspace':
return 'workspace.create'
case 'deleteCurrentWorkspace':