feat(shortcuts): double-tap modifier keybindings (#5516)

* docs: design for double-tap modifier keybindings

* feat(keybindings): parse and normalize DoubleTap+<Mod> grammar

* test(keybindings): cover DoubleTap+Ctrl positive case

* feat(keybindings): match DoubleTap bindings against synthetic input

* test(keybindings): cover Ctrl-on-darwin double-tap miss; document input invariant

* feat(keybindings): format DoubleTap bindings as a doubled glyph

* test(keybindings): cover DoubleTap+Ctrl glyph on mac; note title label semantics

* feat(keybindings): add pure ModifierDoubleTapDetector state machine

* test(keybindings): cover missed-keyup double-tap edge; clarify keyUp guard

* feat(keybindings): capture double-tap gestures into DoubleTap+<Mod>

* test(keybindings): cover Ctrl/linux and Cmd/linux double-tap canonicalization

* feat(keybindings): resolve double-tap input in window shortcut policy

* feat(shortcuts): detect double-tap modifiers in main before-input-event

* docs(shortcuts): note dictation guards live in the renderer

* feat(shortcuts): dispatch double-tap modifiers in the renderer window handler

* fix(shortcuts): clear armed double-tap state on dangling modifier keyup

* feat(settings): record double-tap modifier shortcuts in the editor

* i18n(shortcuts): localize ShortcutKeyCombo double-tap tooltip

* Fix double-tap shortcut review gaps

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

---------

Co-authored-by: Devin345458 <devin@appleidimagination.com>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Devin345458 2026-06-17 21:25:27 -04:00 committed by GitHub
parent 0eec0ad74a
commit 1368ba84b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 2199 additions and 457 deletions

View File

@ -0,0 +1,247 @@
# Double-tap modifier keybindings — design
## Goal
Allow any keybinding action to be bound to a **double-tap of a bare modifier**
(Shift, Cmd/Ctrl, Alt) in Settings → Shortcuts. A double-tap binding is stored,
recorded, formatted, conflict-checked, and matched alongside normal bindings,
and fires everywhere a normal shortcut does — including when a browser guest or
terminal owns focus.
Example: bind `DoubleTap+Shift` to `worktree.quickOpen` ("Go to File"), then
tapping Shift twice opens Go to File (IntelliJ "double-Shift" style).
## Why this is not just another binding
The existing keybinding system is **stateless and per-keydown**: every binding
has at least one modifier and exactly one key, and matching compares a single
`KeyboardEvent`'s modifier state + key against the stored binding string
(`keybindingMatchesInput` in `src/shared/keybindings.ts`). A double-tap is a
**timed sequence of a bare modifier with no key** — press M, release M, press M
again within a short window. It cannot be represented by the current grammar or
matched by the current stateless comparison.
Dispatch is also split across two layers, and both must participate for a
double-tap to work for "any action":
- **Main process**`before-input-event` in
`src/main/window/createMainWindow.ts` matches an explicit allowlist of ~20
actions via `resolveWindowShortcutAction`
(`src/shared/window-shortcut-policy.ts`), calls `preventDefault()`, and
forwards the action to the renderer over IPC. This layer exists so a subset of
shortcuts work even when focus lives in a browser guest `webContents` or a
contentEditable surface that bypasses the renderer's window-level listener.
- **Renderer** — the window `keydown` handler in
`src/renderer/src/App.tsx` matches most actions via `keybindingMatchesAction`
and runs their effects inline.
## Approach (chosen): synthetic input through the existing matchers
Detect the double-tap with a small shared state machine, then represent the
completed gesture as a **synthetic shortcut input** carrying a
`doubleTapModifier` marker and run that input through the *existing* dispatch
chains in both layers. The matcher is extended so a `DoubleTap+<Mod>` binding
matches only that synthetic input (and never a normal keydown, and vice-versa).
Because the existing dispatch chains already call
`keybindingMatchesAction(actionId, input, …)`, every action that is already
wired in those chains gains double-tap support automatically — no per-action
dispatch table to build or keep in sync.
Rejected alternatives:
- **Per-action dispatch registry** — detector resolves action ids and calls a
new `dispatchActionById()` implemented per action. Avoids refactoring the
renderer handler but duplicates action effects that already live there, drifts
over time, and only supports actions we explicitly wire — not "any action".
- **Re-dispatch a synthetic DOM `KeyboardEvent`** — a double-tap can't be
expressed as a standard key event without a key, and re-dispatching risks
event loops.
## Components
### 1. Binding grammar — `src/shared/keybindings.ts`
- New canonical form `DoubleTap+<Mod>` where `<Mod>` is one of `Shift`, `Mod`,
`Cmd`, `Ctrl`, `Alt`. `Mod` resolves to Cmd on macOS and Ctrl on
Windows/Linux, identical to normal bindings.
- `ParsedKeybinding` gains `doubleTapModifier?: ModifierToken` and permits an
empty `key` (only when `doubleTapModifier` is set).
- `parseKeybinding` recognizes a leading `DoubleTap` token followed by exactly
one modifier token and **no** key token. Anything else with `DoubleTap` is
invalid.
- `canonicalizeParsedKeybinding` emits `DoubleTap+<Mod>` (modifier in the same
canonical position rules as today).
- `normalizeKeybindingWithOptions` accepts a well-formed double-tap binding and
rejects malformed ones with clear errors:
- `DoubleTap` + a key (e.g. `DoubleTap+Shift+P`) → invalid.
- `DoubleTap` + two modifiers (e.g. `DoubleTap+Shift+Alt`) → invalid.
- `DoubleTap+Mod+Cmd` (both forms) → reuse the existing "Mod or
platform-specific, not both" error.
- bare `DoubleTap` with no modifier → invalid.
- `formatKeybinding` returns the modifier glyph **twice**: macOS `['⇧','⇧']`,
Windows/Linux `['Shift','Shift']`.
- `ShortcutKeyCombo` renders the two chips. Double-tap is special-cased so the
non-Mac separator reads "Shift Shift" (space), not "Shift+Shift". A
"Double-tap Shift" tooltip clarifies the gesture.
### 2. Detector — new module `src/shared/modifier-double-tap-detector.ts`
A pure, dependency-free state machine. Timestamps are **injected** by the caller
so it is deterministic and unit-testable.
```
class ModifierDoubleTapDetector {
// event: { type: 'keyDown' | 'keyUp', modifier: ModifierToken | null,
// isModifierOnly: boolean, isAutoRepeat: boolean }
process(event, timestampMs): DetectedDoubleTap | null
reset(): void
}
```
State machine:
1. **idle** → on a modifier-only `keyDown` of M that is not autorepeat: remember
M, wait for its release.
2. **down1** → on `keyUp` of M (clean, no other key seen): record release time,
move to **armed(M)** with deadline `releaseTime + WINDOW_MS`.
3. **armed(M)** → on `keyDown` of the same M within the deadline, with no other
modifier held and no intervening non-modifier key: **emit** a double-tap of M
and reset.
Any of these reset to idle: a non-modifier key event at any point, a different
or additional modifier, autorepeat-hold of the modifier, exceeding the window,
or an explicit `reset()` (e.g. on window blur / focus change).
`WINDOW_MS = 300` (internal constant; not user-configurable). A helper derives
`(modifier, isModifierOnly)` from an event's `code`/`key`.
### 3. Matcher extension — `src/shared/keybindings.ts`
- `KeybindingInput` gains `doubleTapModifier?: ModifierToken`.
- `keybindingMatchesInput`: when the parsed binding is a double-tap binding,
match iff `input.doubleTapModifier` equals the binding's modifier, resolved per
platform (`Mod` → meta on macOS, control elsewhere). A double-tap binding never
matches a normal keydown (no `doubleTapModifier`), and a normal binding never
matches a synthetic double-tap input.
- No change to `keybindingMatchesAction` — it already delegates to
`keybindingMatchesInput`, so any action becomes double-tap-capable for free.
### 4. Dispatch wiring — both layers
- **Main** (`src/main/window/createMainWindow.ts`): instantiate a
`ModifierDoubleTapDetector` per window. In `before-input-event`, feed every
`keyDown`/`keyUp` to the detector (it only consumes bare-modifier events). On
emit, build the synthetic input `{ doubleTapModifier: M }`, run the existing
`resolveWindowShortcutAction(syntheticInput, platform, keybindings,
terminalShortcutContext)`, and if an allowlisted action resolves, dispatch via
the current IPC + `preventDefault()` path. `resolveWindowShortcutAction` needs
no per-action change; the implicit numeric-index shortcuts are guarded on
`input.key`, which is undefined for a double-tap input, so they cannot match.
Only the emitting second-keydown event is `preventDefault()`-ed — never the
first tap's down/up (those bare modifiers are harmless and the keyup is needed
by the detector).
- **Renderer** (`src/renderer/src/App.tsx`): extract the body of the window
`onKeyDown` handler into `dispatchShortcutInput(input: ShortcutDispatchInput)`,
where `ShortcutDispatchInput` exposes the modifier/key fields plus
`doubleTapModifier?`, a `preventDefault()` (no-op for synthetic input),
`defaultPrevented`, and the focus/target context. The real listener wraps the
`KeyboardEvent`; a renderer `ModifierDoubleTapDetector` (fed by both a keydown
and a **new** keyup window listener) produces a synthetic input on emit with
`context` derived from `document.activeElement`, and calls
`dispatchShortcutInput`.
#### No double-fire between layers
This reuses the exact disambiguation normal shortcuts already rely on:
- For an **allowlisted** action, main detects the double-tap on the second
modifier keydown, resolves it, and calls `preventDefault()`. That suppresses
the corresponding renderer DOM keydown, so the renderer detector never
completes its second tap → it does not fire. (The renderer detector may have
observed the first tap's down/up. It has no timer: the second-press window is
enforced by comparing the next keydown's timestamp against a deadline. The
suppressed second keydown never arrives, but its keyup still does — a keyup of
the armed modifier with no intervening second keydown clears the armed state,
so a later lone press of the same modifier cannot phantom-complete the gesture.)
- For a **non-allowlisted** action, main's detector still emits but
`resolveWindowShortcutAction` returns `null`, so main does not call
`preventDefault()`. The second-keydown DOM event reaches the renderer, whose
detector completes and fires via `dispatchShortcutInput`.
### 5. Recorder UX — `ShortcutBindingRow.tsx` + `ShortcutsPane.tsx`
The recorder currently captures on the first keydown, which makes a bare
modifier error with "Press a key, not only a modifier." Change the row so that
while recording it runs a `ModifierDoubleTapDetector` fed by the row button's
keydown **and keyup** (the button holds focus during recording, so it receives
both):
- A bare-modifier keydown no longer captures immediately — the detector observes
it.
- A non-modifier keydown (with or without modifiers) captures a normal binding,
exactly as today.
- A completed double-tap captures `DoubleTap+<Mod>`: the row passes
`{ doubleTapModifier: M }` into the capture path, and
`keybindingFromInputWithOptions` short-circuits to build `DoubleTap+<Mod>`
(mapping meta → `Mod` on macOS, etc.) and normalizes it.
- A single lone modifier tap that never completes is ignored — the recorder
keeps listening.
Helper text while recording: *"Press a shortcut, or double-tap a modifier (e.g.
⇧⇧)."* Esc still cancels. The detector is reset when recording stops or the row
loses focus.
### 6. Conflicts & terminal policy
`DoubleTap+Shift` is a canonical binding string, so `findKeybindingConflicts`
compares it like any other binding — two actions sharing a double-tap surface a
conflict in the UI. Terminal-policy gating (`keybindingIsActiveInContext`,
orca-first / terminal-first) applies unchanged. Note that a bare modifier press
emits no terminal bytes, so detecting a double-tap never steals readline input;
policy is still honored for consistency.
## Data flow
- **Record:** row keydown/keyup → row detector → `{ doubleTapModifier: M }`
`keybindingFromInputForAction``DoubleTap+<Mod>` → stored as
`["DoubleTap+Shift"]` in `~/.orca/keybindings.json`.
- **Runtime:** physical modifier taps → main + renderer detectors → synthetic
`{ doubleTapModifier: M }` → existing matchers → action dispatched (main IPC
for allowlisted actions, renderer inline for the rest).
## Behavioral decisions
- **Trigger edge:** fire on the **second modifier keydown** (snappy), not the
second keyup.
- **Window:** `WINDOW_MS = 300`, internal constant, not user-configurable.
- **Modifiers supported:** Shift, Cmd/Ctrl (`Mod`), Alt — any modifier, recorded
as the platform-appropriate token following the existing capture convention.
## Testing
- New `src/shared/modifier-double-tap-detector.test.ts`: completion within
window; timeout past window; reset on intervening non-modifier key; reset on
different/extra modifier; autorepeat-hold is not a tap; wrong-modifier second
tap; `reset()` clears state.
- `src/shared/keybindings.test.ts` additions: parse / normalize / canonicalize /
format for `DoubleTap+*` (incl. malformed-input rejection); platform token
mapping (`DoubleTap+Mod` → Cmd on macOS, Ctrl elsewhere);
`keybindingMatchesInput` with a synthetic `doubleTapModifier` input (positive
and cross-type negatives); conflict detection across two double-tap bindings.
- Manual: record `DoubleTap+Shift` on "Go to File"; confirm it fires globally
including with a browser guest and a focused terminal; confirm normal Shift+key
typing is unaffected; confirm chips and tokens are correct on macOS and
Windows/Linux.
## Risks & edge cases
- **Accidental triggers during fast typing** — mitigated by requiring a clean
down→up→down of the same modifier with no other key, inside a 300ms window.
- **macOS Sticky Keys (press Shift 5×)** — unaffected; the gesture is two taps
within a tight window.
- **Double-fire main vs renderer** — resolved by the
`preventDefault`-on-emit mechanism described in §4.
- **Focus/window changes mid-sequence** — both detectors reset on blur / focus
change (hook into the existing recorder/terminal focus reset paths in the main
process and a window blur listener in the renderer).

View File

@ -414,6 +414,14 @@ describe('setupGuestShortcutForwarding', () => {
return preventDefault
}
function triggerGuestBlur(): void {
const handler = guestOnMock.mock.calls.find((call) => call[0] === 'blur')?.[1] as
| (() => void)
| undefined
expect(handler).toBeTypeOf('function')
handler!()
}
beforeEach(() => {
rendererSendMock = vi.fn()
guestOnMock = vi.fn()
@ -552,4 +560,85 @@ describe('setupGuestShortcutForwarding', () => {
expect(customPreventDefault).toHaveBeenCalledTimes(1)
expect(rendererSendMock).toHaveBeenCalledWith('ui:zoomBrowserPage', 'in')
})
it('forwards double-tap window shortcuts from focused guest pages', () => {
setupGuestShortcutForwarding({
browserTabId,
guest: makeGuest(),
resolveRenderer: () => makeRenderer(),
getKeybindings: () => ({
'worktree.quickOpen': ['DoubleTap+Shift']
})
})
const modifierInput = {
code: 'ShiftLeft',
key: 'Shift',
shift: true,
meta: false,
control: false,
alt: false
}
const firstDownPreventDefault = triggerBeforeInput(modifierInput)
const firstUpPreventDefault = triggerBeforeInput({ ...modifierInput, type: 'keyUp' })
const secondDownPreventDefault = triggerBeforeInput(modifierInput)
expect(firstDownPreventDefault).not.toHaveBeenCalled()
expect(firstUpPreventDefault).not.toHaveBeenCalled()
expect(secondDownPreventDefault).toHaveBeenCalledTimes(1)
expect(rendererSendMock).toHaveBeenCalledWith('ui:openQuickOpen')
})
it('forwards double-tap tab shortcuts from focused guest pages', () => {
setupGuestShortcutForwarding({
browserTabId,
guest: makeGuest(),
resolveRenderer: () => makeRenderer(),
getKeybindings: () => ({
'tab.newBrowser': ['DoubleTap+Shift']
})
})
const modifierInput = {
code: 'ShiftLeft',
key: 'Shift',
shift: true,
meta: false,
control: false,
alt: false
}
triggerBeforeInput(modifierInput)
triggerBeforeInput({ ...modifierInput, type: 'keyUp' })
const secondDownPreventDefault = triggerBeforeInput(modifierInput)
expect(secondDownPreventDefault).toHaveBeenCalledTimes(1)
expect(rendererSendMock).toHaveBeenCalledWith('ui:newBrowserTab')
})
it('resets guest double-tap detection on blur', () => {
setupGuestShortcutForwarding({
browserTabId,
guest: makeGuest(),
resolveRenderer: () => makeRenderer(),
getKeybindings: () => ({
'worktree.quickOpen': ['DoubleTap+Shift']
})
})
const modifierInput = {
code: 'ShiftLeft',
key: 'Shift',
shift: true,
meta: false,
control: false,
alt: false
}
triggerBeforeInput(modifierInput)
triggerBeforeInput({ ...modifierInput, type: 'keyUp' })
triggerGuestBlur()
const nextDownPreventDefault = triggerBeforeInput(modifierInput)
expect(nextDownPreventDefault).not.toHaveBeenCalled()
expect(rendererSendMock).not.toHaveBeenCalledWith('ui:openQuickOpen')
})
})

View File

@ -11,11 +11,16 @@ import {
import {
isRecentTabSwitcherCommitRelease,
matchesRecentTabSwitcherChord,
resolveWindowShortcutAction
resolveWindowShortcutAction,
type WindowShortcutInput
} from '../../shared/window-shortcut-policy'
import { readGuestNavigationState } from './browser-guest-navigation-state'
import { keybindingMatchesAction, type KeybindingOverrides } from '../../shared/keybindings'
import type { BrowserPageZoomDirection } from '../../shared/browser-page-zoom'
import {
ModifierDoubleTapDetector,
toModifierDoubleTapEvent
} from '../../shared/modifier-double-tap-detector'
type ResolveRenderer = (browserTabId: string) => Electron.WebContents | null
type ShouldForwardDictationShortcut = () => boolean
@ -263,49 +268,30 @@ export function setupGuestShortcutForwarding(args: {
getKeybindings
} = args
let ctrlTabSwitching = false
const handler = (event: Electron.Event, input: Electron.Input): void => {
const doubleTapDetector = new ModifierDoubleTapDetector()
const resetDoubleTapDetector = (): void => doubleTapDetector.reset()
type GuestShortcutInput = WindowShortcutInput & { isAutoRepeat?: boolean }
const forwardShortcutInput = (
event: Electron.Event,
input: GuestShortcutInput,
action = resolveWindowShortcutAction(input, process.platform, getKeybindings?.())
): boolean => {
const keybindings = getKeybindings?.()
if (
input.type === 'keyDown' &&
matchesRecentTabSwitcherChord(input, process.platform, keybindings)
) {
event.preventDefault()
ctrlTabSwitching = true
const renderer = resolveRenderer(browserTabId)
renderer?.send('ui:ctrlTabKeyDown', { shiftKey: input.shift === true })
return
}
if (ctrlTabSwitching && isRecentTabSwitcherCommitRelease(input)) {
event.preventDefault()
ctrlTabSwitching = false
const renderer = resolveRenderer(browserTabId)
renderer?.send('ui:ctrlTabKeyUp')
return
}
if (input.type !== 'keyDown') {
return
}
// Why: resolve the policy action once per keystroke. The history-navigate
// chord (Cmd/Ctrl+Alt+Arrow) is the only allowlisted chord that carries
// Alt and must be handled before the generic modifier-chord gate below,
// which rejects Alt. Every other chord handled further down can reuse
// the same `action` rather than re-running the full predicate chain.
const action = resolveWindowShortcutAction(input, process.platform, keybindings)
if (action?.type === 'zoom') {
// Why: browser page zoom must consume repeats and teardown races before
// Chromium or the guest page can apply its own shortcut behavior.
event.preventDefault()
const renderer = resolveRenderer(browserTabId)
renderer?.send('ui:zoomBrowserPage', action.direction)
return
return true
}
if (input.isAutoRepeat) {
if (action?.type === 'dictationKeyDown' && shouldForwardDictationShortcut?.()) {
event.preventDefault()
return true
}
return
return false
}
if (action?.type === 'worktreeHistoryNavigate') {
// Why: preventDefault unconditionally — if we cannot resolve the
@ -317,14 +303,14 @@ export function setupGuestShortcutForwarding(args: {
event.preventDefault()
const renderer = resolveRenderer(browserTabId)
renderer?.send('ui:worktreeHistoryNavigate', action.direction)
return
return true
}
if (action?.type === 'toggleFloatingTerminal') {
event.preventDefault()
const renderer = resolveRenderer(browserTabId)
renderer?.send('ui:toggleFloatingTerminal')
return
return true
}
// Why: Cmd/Ctrl+Alt+[ / ] cycles across every tab type. Handled before
@ -344,14 +330,14 @@ export function setupGuestShortcutForwarding(args: {
event.preventDefault()
const renderer = resolveRenderer(browserTabId)
renderer?.send('ui:switchTabAcrossAllTypes', switchAllTypesDirection)
return
return true
}
if (keybindingMatchesAction('tab.previousRecent', input, process.platform, keybindings)) {
event.preventDefault()
const renderer = resolveRenderer(browserTabId)
renderer?.send('ui:switchRecentTab')
return
return true
}
// Why: terminal-only tab switching defaults to Ctrl+PageUp/PageDown on every
@ -370,12 +356,12 @@ export function setupGuestShortcutForwarding(args: {
event.preventDefault()
const renderer = resolveRenderer(browserTabId)
renderer?.send('ui:switchTerminalTab', terminalTabDirection)
return
return true
}
const renderer = resolveRenderer(browserTabId)
if (!renderer) {
return
return false
}
if (keybindingMatchesAction('tab.newBrowser', input, process.platform, keybindings)) {
renderer.send('ui:newBrowserTab')
@ -456,21 +442,84 @@ export function setupGuestShortcutForwarding(args: {
renderer.send('ui:jumpToTabIndex', action.index)
} else if (action?.type === 'dictationKeyDown') {
if (!shouldForwardDictationShortcut?.()) {
return
return false
}
renderer.send('ui:dictationKeyDown')
} else {
return
return false
}
// Why: preventDefault stops the guest page from also processing the chord
// (e.g. Cmd+T opening a browser-internal new-tab page).
event.preventDefault()
return true
}
const handler = (event: Electron.Event, input: Electron.Input): void => {
const keybindings = getKeybindings?.()
if (
input.type === 'keyDown' &&
matchesRecentTabSwitcherChord(input, process.platform, keybindings)
) {
event.preventDefault()
ctrlTabSwitching = true
const renderer = resolveRenderer(browserTabId)
renderer?.send('ui:ctrlTabKeyDown', { shiftKey: input.shift === true })
return
}
if (ctrlTabSwitching && isRecentTabSwitcherCommitRelease(input)) {
event.preventDefault()
ctrlTabSwitching = false
const renderer = resolveRenderer(browserTabId)
renderer?.send('ui:ctrlTabKeyUp')
return
}
if (input.type === 'keyDown' || input.type === 'keyUp') {
const detected = doubleTapDetector.process(
toModifierDoubleTapEvent({
type: input.type,
code: input.code,
key: input.key,
shift: input.shift,
control: input.control,
alt: input.alt,
meta: input.meta,
isAutoRepeat: input.isAutoRepeat
}),
Date.now()
)
if (detected) {
const doubleTapInput: GuestShortcutInput = { doubleTapModifier: detected.modifier }
forwardShortcutInput(
event,
doubleTapInput,
resolveWindowShortcutAction(doubleTapInput, process.platform, keybindings, {
context: 'app'
})
)
return
}
}
if (input.type !== 'keyDown') {
return
}
// Why: resolve the policy action once per keystroke. The history-navigate
// chord (Cmd/Ctrl+Alt+Arrow) is the only allowlisted chord that carries
// Alt and must be handled before the generic modifier-chord gate below,
// which rejects Alt. Every other chord handled further down can reuse
// the same `action` rather than re-running the full predicate chain.
const action = resolveWindowShortcutAction(input, process.platform, keybindings)
forwardShortcutInput(event, input, action)
}
guest.on('before-input-event', handler)
guest.on('blur', resetDoubleTapDetector)
return () => {
try {
guest.off('before-input-event', handler)
guest.off('blur', resetDoubleTapDetector)
} catch {
// Why: best-effort — guest may already be destroyed during teardown.
}

View File

@ -665,6 +665,100 @@ describe('createMainWindow', () => {
expect(webContents.send).not.toHaveBeenCalled()
})
it('only intercepts double-tap dictation when enabled toggle mode can handle it', () => {
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
})
const voice: { enabled: boolean; sttModel: string; dictationMode: 'toggle' | 'hold' } = {
enabled: false,
sttModel: '',
dictationMode: 'toggle'
}
createMainWindow(
{
getUI: () => ({}),
getSettings: () => ({ windowBackgroundBlur: false, voice }) as never,
updateUI: vi.fn()
} as never,
{
getKeybindings: () => ({ 'voice.dictation': ['DoubleTap+Shift'] })
}
)
const triggerDoubleTapShift = (): ReturnType<typeof vi.fn> => {
const modifierInput = {
code: 'ShiftLeft',
key: 'Shift',
shift: true,
meta: false,
control: false,
alt: false
}
windowHandlers['before-input-event'](
{ preventDefault: vi.fn() } as never,
{ ...modifierInput, type: 'keyDown' } as never
)
windowHandlers['before-input-event'](
{ preventDefault: vi.fn() } as never,
{ ...modifierInput, type: 'keyUp' } as never
)
const preventDefault = vi.fn()
windowHandlers['before-input-event'](
{ preventDefault } as never,
{ ...modifierInput, type: 'keyDown' } as never
)
windowHandlers['before-input-event'](
{ preventDefault: vi.fn() } as never,
{ ...modifierInput, type: 'keyUp' } as never
)
return preventDefault
}
const disabledPreventDefault = triggerDoubleTapShift()
expect(disabledPreventDefault).not.toHaveBeenCalled()
expect(webContents.send).not.toHaveBeenCalledWith('ui:dictationKeyDown')
voice.enabled = true
voice.sttModel = 'test-model'
voice.dictationMode = 'hold'
const holdPreventDefault = triggerDoubleTapShift()
expect(holdPreventDefault).not.toHaveBeenCalled()
expect(webContents.send).not.toHaveBeenCalledWith('ui:dictationKeyDown')
voice.dictationMode = 'toggle'
const togglePreventDefault = triggerDoubleTapShift()
expect(togglePreventDefault).toHaveBeenCalledTimes(1)
expect(webContents.send).toHaveBeenCalledWith('ui:dictationKeyDown')
})
it('forwards ctrl/cmd+j to the worktree palette toggle event', () => {
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const webContents = {
@ -792,6 +886,85 @@ describe('createMainWindow', () => {
expect(webContents.send).not.toHaveBeenCalled()
})
it('allows double-tap shortcuts while terminal input is focused with Terminal-first policy', () => {
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const webContents = {
on: vi.fn((event, handler) => {
windowHandlers[event] = handler
}),
setZoomLevel: vi.fn(),
setBackgroundThrottling: vi.fn(),
invalidate: vi.fn(),
setWindowOpenHandler: vi.fn(),
send: vi.fn(),
isDevToolsOpened: vi.fn(),
openDevTools: vi.fn(),
closeDevTools: vi.fn()
}
const browserWindowInstance = {
webContents,
on: vi.fn(),
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => true),
isFullScreen: vi.fn(() => false),
getSize: vi.fn(() => [1200, 800]),
setSize: vi.fn(),
maximize: vi.fn(),
show: vi.fn(),
loadFile: vi.fn(),
loadURL: vi.fn()
}
browserWindowMock.mockImplementation(function () {
return browserWindowInstance
})
createMainWindow(
{
getUI: () => ({}),
getSettings: () => ({ terminalShortcutPolicy: 'terminal-first' })
} as never,
{
getKeybindings: () => ({ 'worktree.quickOpen': ['DoubleTap+Shift'] })
}
)
const setFocusedListener = vi
.mocked(ipcMain.on)
.mock.calls.find(([channel]) => channel === 'ui:setTerminalInputFocused')?.[1]
expect(setFocusedListener).toBeTypeOf('function')
setFocusedListener?.({ sender: webContents } as never, true)
const modifierInput = {
code: 'ShiftLeft',
key: 'Shift',
shift: true,
meta: false,
control: false,
alt: false
}
const firstDownPreventDefault = vi.fn()
windowHandlers['before-input-event'](
{ preventDefault: firstDownPreventDefault } as never,
{ ...modifierInput, type: 'keyDown' } as never
)
const firstUpPreventDefault = vi.fn()
windowHandlers['before-input-event'](
{ preventDefault: firstUpPreventDefault } as never,
{ ...modifierInput, type: 'keyUp' } as never
)
const secondDownPreventDefault = vi.fn()
windowHandlers['before-input-event'](
{ preventDefault: secondDownPreventDefault } as never,
{ ...modifierInput, type: 'keyDown' } as never
)
expect(firstDownPreventDefault).not.toHaveBeenCalled()
expect(firstUpPreventDefault).not.toHaveBeenCalled()
expect(secondDownPreventDefault).toHaveBeenCalledTimes(1)
expect(webContents.send).toHaveBeenCalledTimes(1)
expect(webContents.send).toHaveBeenCalledWith('ui:openQuickOpen')
})
it('notifies before Orca-first captures a risky terminal-focused shortcut', () => {
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const webContents = {
@ -857,6 +1030,83 @@ describe('createMainWindow', () => {
expect(webContents.send).toHaveBeenNthCalledWith(2, 'ui:toggleWorktreePalette')
})
it('notifies before Orca-first captures a terminal-focused double-tap shortcut', () => {
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const webContents = {
on: vi.fn((event, handler) => {
windowHandlers[event] = handler
}),
setZoomLevel: vi.fn(),
setBackgroundThrottling: vi.fn(),
invalidate: vi.fn(),
setWindowOpenHandler: vi.fn(),
send: vi.fn(),
isDevToolsOpened: vi.fn(),
openDevTools: vi.fn(),
closeDevTools: vi.fn()
}
const browserWindowInstance = {
webContents,
on: vi.fn(),
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => true),
isFullScreen: vi.fn(() => false),
getSize: vi.fn(() => [1200, 800]),
setSize: vi.fn(),
maximize: vi.fn(),
show: vi.fn(),
loadFile: vi.fn(),
loadURL: vi.fn()
}
browserWindowMock.mockImplementation(function () {
return browserWindowInstance
})
createMainWindow(
{
getUI: () => ({}),
getSettings: () => ({ terminalShortcutPolicy: 'orca-first' })
} as never,
{
getKeybindings: () => ({ 'worktree.quickOpen': ['DoubleTap+Shift'] })
}
)
const setFocusedListener = vi
.mocked(ipcMain.on)
.mock.calls.find(([channel]) => channel === 'ui:setTerminalInputFocused')?.[1]
expect(setFocusedListener).toBeTypeOf('function')
setFocusedListener?.({ sender: webContents } as never, true)
const modifierInput = {
code: 'ShiftLeft',
key: 'Shift',
shift: true,
meta: false,
control: false,
alt: false
}
windowHandlers['before-input-event'](
{ preventDefault: vi.fn() } as never,
{ ...modifierInput, type: 'keyDown' } as never
)
windowHandlers['before-input-event'](
{ preventDefault: vi.fn() } as never,
{ ...modifierInput, type: 'keyUp' } as never
)
const preventDefault = vi.fn()
windowHandlers['before-input-event'](
{ preventDefault } as never,
{ ...modifierInput, type: 'keyDown' } as never
)
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(webContents.send).toHaveBeenNthCalledWith(1, 'ui:terminalShortcutCaptured', {
actionId: 'worktree.quickOpen'
})
expect(webContents.send).toHaveBeenNthCalledWith(2, 'ui:openQuickOpen')
})
it('forwards the configured workspace delete shortcut while terminal input is focused', () => {
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const webContents = {

View File

@ -15,8 +15,13 @@ import {
getWindowShortcutActionId,
matchesRecentTabSwitcherChord,
resolveWindowShortcutAction,
windowShortcutActionCapturesTerminal
windowShortcutActionCapturesTerminal,
type WindowShortcutAction
} from '../../shared/window-shortcut-policy'
import {
ModifierDoubleTapDetector,
toModifierDoubleTapEvent
} from '../../shared/modifier-double-tap-detector'
import {
keybindingMatchesAction,
normalizeTerminalShortcutPolicy,
@ -655,6 +660,127 @@ export function createMainWindow(
clearRendererRecoveryTimer()
})
const doubleTapDetector = new ModifierDoubleTapDetector()
// Why: one place maps a resolved window-shortcut action to its IPC/side effect,
// reused by the normal keydown path and the double-tap path so they cannot drift.
const sendResolvedWindowShortcutAction = (action: WindowShortcutAction): void => {
switch (action.type) {
// The renderer's DictationController re-checks enabled/sttModel and ignores
// hold mode, so this path needs no voice guards.
case 'dictationKeyDown':
mainWindow.webContents.send('ui:dictationKeyDown')
return
case 'zoom':
mainWindow.webContents.send('terminal:zoom', action.direction)
return
case 'openSettings':
mainWindow.webContents.send('ui:openSettings')
return
case 'forceReload':
opts?.onBeforeReload?.({ ignoreCache: true, webContentsId: mainWindow.webContents.id })
mainWindow.webContents.reloadIgnoringCache()
return
case 'toggleLeftSidebar':
mainWindow.webContents.send('ui:toggleLeftSidebar')
return
case 'toggleRightSidebar':
mainWindow.webContents.send('ui:toggleRightSidebar')
return
case 'toggleWorktreePalette':
mainWindow.webContents.send('ui:toggleWorktreePalette')
return
case 'toggleFloatingTerminal':
mainWindow.webContents.send('ui:toggleFloatingTerminal')
return
case 'openQuickOpen':
mainWindow.webContents.send('ui:openQuickOpen')
return
case 'openNewWorkspace':
mainWindow.webContents.send('ui:openNewWorkspace')
return
case 'deleteCurrentWorkspace':
mainWindow.webContents.send('ui:deleteCurrentWorkspace')
return
case 'openWorkspaceBoard':
mainWindow.webContents.send('ui:openWorkspaceBoard')
return
case 'openTasks':
mainWindow.webContents.send('ui:openTasks')
return
case 'switchRecentTab':
mainWindow.webContents.send('ui:switchRecentTab')
return
case 'jumpToWorktreeIndex':
mainWindow.webContents.send('ui:jumpToWorktreeIndex', action.index)
return
case 'jumpToTabIndex':
mainWindow.webContents.send('ui:jumpToTabIndex', action.index)
return
case 'worktreeHistoryNavigate':
mainWindow.webContents.send('ui:worktreeHistoryNavigate', action.direction)
}
}
const dispatchResolvedWindowShortcutAction = (
event: Electron.Event,
action: WindowShortcutAction,
options: {
isAutoRepeat: boolean
focusedShortcutContext: KeybindingMatchOptions
}
): boolean => {
const { focusedShortcutContext, isAutoRepeat } = options
if (
floatingTerminalInputFocused &&
(action.type === 'toggleLeftSidebar' || action.type === 'toggleRightSidebar')
) {
return false
}
const capturedTerminalActionId =
focusedShortcutContext.context === 'terminal' &&
focusedShortcutContext.terminalShortcutPolicy === 'orca-first' &&
windowShortcutActionCapturesTerminal(action)
? getWindowShortcutActionId(action)
: null
// Why: hold-mode dictation needs renderer keyup events, so the main process
// may only consume shortcuts that toggle dictation from a single keydown.
if (action.type === 'dictationKeyDown') {
const voiceSettings = store?.getSettings().voice
if (!voiceSettings?.enabled || !voiceSettings.sttModel) {
return false
}
const dictationMode = voiceSettings.dictationMode ?? 'toggle'
if (dictationMode === 'hold') {
return false
}
if (isAutoRepeat) {
event.preventDefault()
return true
}
event.preventDefault()
if (capturedTerminalActionId) {
mainWindow.webContents.send('ui:terminalShortcutCaptured', {
actionId: capturedTerminalActionId
})
}
mainWindow.webContents.send('ui:dictationKeyDown')
return true
}
event.preventDefault()
if (capturedTerminalActionId) {
mainWindow.webContents.send('ui:terminalShortcutCaptured', {
actionId: capturedTerminalActionId
})
}
sendResolvedWindowShortcutAction(action)
return true
}
mainWindow.webContents.on('before-input-event', (event, input) => {
if (shortcutRecorderFocused) {
return
@ -677,6 +803,51 @@ export function createMainWindow(
store?.getSettings().terminalShortcutPolicy
)
}
const appShortcutContext: KeybindingMatchOptions = {
context: 'app',
terminalShortcutPolicy: terminalShortcutContext.terminalShortcutPolicy
}
// Why: detect double-tap-modifier gestures on the raw key stream. A bare
// modifier emits no terminal bytes, so this never steals readline input.
if (input.type === 'keyDown' || input.type === 'keyUp') {
const detected = doubleTapDetector.process(
toModifierDoubleTapEvent({
type: input.type,
code: input.code,
key: input.key,
shift: input.shift,
control: input.control,
alt: input.alt,
meta: input.meta,
isAutoRepeat: input.isAutoRepeat
}),
Date.now()
)
if (detected) {
const doubleTapAction = resolveWindowShortcutAction(
{ type: 'keyDown', doubleTapModifier: detected.modifier },
process.platform,
keybindings,
appShortcutContext
)
if (
doubleTapAction &&
dispatchResolvedWindowShortcutAction(event, doubleTapAction, {
isAutoRepeat: false,
focusedShortcutContext: terminalShortcutContext
})
) {
// Only preventDefault the emitting keydown — never the first tap's
// down/up. This suppresses the renderer DOM keydown so the renderer
// detector cannot also fire for the same gesture.
return
}
// No allowlisted action: let the keydown reach the renderer, whose
// detector completes and dispatches inline.
}
}
if (
input.type === 'keyDown' &&
matchesRecentTabSwitcherChord(input, process.platform, keybindings, terminalShortcutContext)
@ -715,156 +886,20 @@ export function createMainWindow(
return
}
// Why: keep global app routing for non-terminal actions, but let floating
// xterm own shell control chars that overlap sidebar chrome shortcuts.
if (
floatingTerminalInputFocused &&
(action.type === 'toggleLeftSidebar' || action.type === 'toggleRightSidebar')
) {
return
}
if (input.type !== 'keyDown') {
return
}
const capturedTerminalActionId =
terminalShortcutContext.context === 'terminal' &&
terminalShortcutContext.terminalShortcutPolicy === 'orca-first' &&
windowShortcutActionCapturesTerminal(action)
? getWindowShortcutActionId(action)
: null
// Why: in hold mode, Cmd+E must NOT be intercepted here. Calling
// preventDefault() in before-input-event suppresses ALL subsequent DOM
// events for the key combo — including the keyUp the renderer needs to
// detect release. By letting the event through, the renderer's
// capture-phase DOM listeners handle both keydown and keyup normally.
// Toggle mode still uses the IPC path since it doesn't need keyUp.
if (action.type === 'dictationKeyDown') {
const voiceSettings = store?.getSettings().voice
if (!voiceSettings?.enabled || !voiceSettings.sttModel) {
return
}
const dictationMode = voiceSettings.dictationMode ?? 'toggle'
if (dictationMode === 'hold') {
return
}
if (input.isAutoRepeat) {
event.preventDefault()
return
}
event.preventDefault()
if (capturedTerminalActionId) {
mainWindow.webContents.send('ui:terminalShortcutCaptured', {
actionId: capturedTerminalActionId
})
}
mainWindow.webContents.send('ui:dictationKeyDown')
return
}
event.preventDefault()
if (capturedTerminalActionId) {
mainWindow.webContents.send('ui:terminalShortcutCaptured', {
actionId: capturedTerminalActionId
})
}
if (action.type === 'zoom') {
mainWindow.webContents.send('terminal:zoom', action.direction)
return
}
if (action.type === 'openSettings') {
mainWindow.webContents.send('ui:openSettings')
return
}
if (action.type === 'forceReload') {
opts?.onBeforeReload?.({
ignoreCache: true,
webContentsId: mainWindow.webContents.id
})
mainWindow.webContents.reloadIgnoringCache()
return
}
if (action.type === 'toggleLeftSidebar') {
mainWindow.webContents.send('ui:toggleLeftSidebar')
return
}
if (action.type === 'toggleRightSidebar') {
mainWindow.webContents.send('ui:toggleRightSidebar')
return
}
if (action.type === 'toggleWorktreePalette') {
// Why: embedded browser guests can keep keyboard focus inside Chromium's
// guest webContents, which bypasses the renderer's window-level keydown
// listener. Forward the worktree-switch shortcut through the main window
// so Cmd+J (macOS) or Ctrl+Shift+J (Win/Linux) works consistently from browser tabs too.
mainWindow.webContents.send('ui:toggleWorktreePalette')
return
}
if (action.type === 'toggleFloatingTerminal') {
mainWindow.webContents.send('ui:toggleFloatingTerminal')
return
}
if (action.type === 'openQuickOpen') {
mainWindow.webContents.send('ui:openQuickOpen')
return
}
if (action.type === 'openNewWorkspace') {
// Why: routed through the main process so focus contexts that bypass
// the renderer's window-level keydown (contentEditable markdown editor,
// browser-guest webContents) still reach the new-workspace composer.
mainWindow.webContents.send('ui:openNewWorkspace')
return
}
if (action.type === 'deleteCurrentWorkspace') {
mainWindow.webContents.send('ui:deleteCurrentWorkspace')
return
}
if (action.type === 'openWorkspaceBoard') {
mainWindow.webContents.send('ui:openWorkspaceBoard')
return
}
if (action.type === 'openTasks') {
mainWindow.webContents.send('ui:openTasks')
return
}
if (action.type === 'switchRecentTab') {
mainWindow.webContents.send('ui:switchRecentTab')
return
}
if (action.type === 'jumpToWorktreeIndex') {
mainWindow.webContents.send('ui:jumpToWorktreeIndex', action.index)
return
}
if (action.type === 'jumpToTabIndex') {
mainWindow.webContents.send('ui:jumpToTabIndex', action.index)
return
}
if (action.type === 'worktreeHistoryNavigate') {
// Why: routed through main so the chord reaches the renderer even when
// a terminal (xterm.js) or a browser guest has focus — both surfaces
// otherwise absorb Arrow keys before the renderer's window listener.
mainWindow.webContents.send('ui:worktreeHistoryNavigate', action.direction)
}
dispatchResolvedWindowShortcutAction(event, action, {
isAutoRepeat: Boolean(input.isAutoRepeat),
focusedShortcutContext: terminalShortcutContext
})
})
// Why: a mid-gesture focus loss must not leave the detector armed so the next
// unrelated modifier press completes a phantom double-tap.
mainWindow.on('blur', () => doubleTapDetector.reset())
mainWindow.webContents.on('zoom-changed', (event, zoomDirection) => {
// Why: Some keyboard layouts/platforms consume Ctrl/Cmd+Minus before
// before-input-event fires, but still emit Electron's zoom command. Keep

View File

@ -136,8 +136,13 @@ import {
import {
keybindingMatchesAction,
type KeybindingActionId,
type KeybindingContext
type KeybindingContext,
type PhysicalModifierToken
} from '../../shared/keybindings'
import {
ModifierDoubleTapDetector,
toModifierDoubleTapEvent
} from '../../shared/modifier-double-tap-detector'
import { isGitRepoKind } from '../../shared/repo-kind'
import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification'
import { resolveMountedLazyModalIds, type LazyModalId } from './lazy-modal-mount-state'
@ -153,6 +158,22 @@ function getKeybindingContext(target: EventTarget | null): KeybindingContext {
: 'app'
}
// Abstraction over a real KeyboardEvent and a synthetic double-tap gesture so a
// single dispatch path serves both. KeybindingInput-compatible (key/code +
// modifier flags) so it flows straight into keybindingMatchesAction.
type ShortcutDispatchInput = {
key?: string
code?: string
altKey?: boolean
metaKey?: boolean
ctrlKey?: boolean
shiftKey?: boolean
doubleTapModifier?: PhysicalModifierToken
target: EventTarget | null
defaultPrevented: boolean
preventDefault: () => void
}
// Why: 'hidden' titleBarStyle on Windows removes the native OS title bar,
// so we render our own minimize/maximize/close buttons. These SVG icons match
// the Fluent/Win11 style: thin 10×10 paths on a 40×30 hit area.
@ -1347,26 +1368,25 @@ function App(): React.JSX.Element {
}
useEffect(() => {
const onKeyDown = (e: KeyboardEvent): void => {
if (e.repeat) {
return
}
const doubleTapDetector = new ModifierDoubleTapDetector()
const dispatchShortcutInput = (input: ShortcutDispatchInput): void => {
// Why: child-component handlers (e.g. terminal search Cmd+G / Cmd+Shift+G)
// register on the same window capture phase and fire first. If they already
// called preventDefault, this handler must not also act on the event —
// otherwise both actions execute (e.g. search navigation AND sidebar open).
if (e.defaultPrevented) {
if (input.defaultPrevented) {
return
}
// Why: the Settings recorder intentionally captures existing app
// shortcuts, so global handlers must not fire while its button has focus.
if (
e.target instanceof Element &&
e.target.closest('[data-shortcut-recorder-active]') !== null
input.target instanceof Element &&
input.target.closest('[data-shortcut-recorder-active]') !== null
) {
return
}
const context = getKeybindingContext(e.target)
const context = getKeybindingContext(input.target)
// Note: some app-level shortcuts are also intercepted via
// before-input-event in createMainWindow.ts so they still work when a
@ -1374,7 +1394,7 @@ function App(): React.JSX.Element {
// local-focus cases and to preserve the same guards in one place.
const matchShortcut = (actionId: KeybindingActionId): boolean =>
keybindingMatchesAction(actionId, e, shortcutPlatform, keybindings, {
keybindingMatchesAction(actionId, input, shortcutPlatform, keybindings, {
context,
terminalShortcutPolicy: settings?.terminalShortcutPolicy
})
@ -1407,7 +1427,7 @@ function App(): React.JSX.Element {
? selectedExplorerFolderRelativePath(document.activeElement)
: null
if (selectedFolderRelativePath !== null && activeWorktreeId) {
e.preventDefault()
input.preventDefault()
notifyTerminalCapture('sidebar.search.toggle')
actions.showRightSidebarSearch({
includePattern: folderRelativePathToIncludeGlob(selectedFolderRelativePath)
@ -1417,7 +1437,7 @@ function App(): React.JSX.Element {
const selectedText = getSelectedTextForFileSearch()
if (selectedText) {
e.preventDefault()
input.preventDefault()
notifyTerminalCapture('sidebar.search.toggle')
openSearchSidebar(selectedText)
return
@ -1427,7 +1447,7 @@ function App(): React.JSX.Element {
// Why: an empty floating workspace has no tab to close; Cmd/Ctrl+W
// should hide that transient overlay before underlying app surfaces act.
if (
keybindingMatchesAction('tab.close', e, shortcutPlatform, keybindings, {
keybindingMatchesAction('tab.close', input, shortcutPlatform, keybindings, {
context: 'app'
}) &&
shouldMinimizeFloatingWorkspacePanelOnCloseShortcut({
@ -1435,7 +1455,7 @@ function App(): React.JSX.Element {
floatingVisibleTabCount
})
) {
e.preventDefault()
input.preventDefault()
setFloatingTerminalOpenWithFocus(false)
return
}
@ -1446,14 +1466,14 @@ function App(): React.JSX.Element {
// Cmd+B for the markdown editor (see createMainWindow.ts +
// docs/markdown-cmd-b-bold-design.md), but this renderer-side fallback
// still covers the blur→press IPC race and any non-carved editable surface.
if (isEditableTarget(e.target)) {
if (isEditableTarget(input.target)) {
return
}
// Why: xterm's helper textarea is intentionally not a generic editable
// target, but floating-terminal SSH/tmux control chords must still reach
// the terminal instead of app-level chrome shortcuts.
if (isFloatingWorkspaceTerminalInputTarget(e.target)) {
if (isFloatingWorkspaceTerminalInputTarget(input.target)) {
return
}
@ -1466,7 +1486,7 @@ function App(): React.JSX.Element {
if (creationLayoutActive || !shouldShowWorktreeHistoryControls(activeView)) {
return
}
e.preventDefault()
input.preventDefault()
const store = useAppStore.getState()
if (matchShortcut('worktree.history.back')) {
store.goBackWorktree()
@ -1484,7 +1504,7 @@ function App(): React.JSX.Element {
const floatingWorkspaceFocused = isFloatingWorkspacePanelFocused()
if (floatingWorkspaceFocused) {
if (
isFloatingWorkspacePanelShortcut(e, shortcutPlatform, null, keybindings, {
isFloatingWorkspacePanelShortcut(input, shortcutPlatform, null, keybindings, {
context,
terminalShortcutPolicy: settings?.terminalShortcutPolicy
})
@ -1495,7 +1515,7 @@ function App(): React.JSX.Element {
// Cmd/Ctrl+B — toggle left sidebar
if (matchShortcut('sidebar.left.toggle')) {
e.preventDefault()
input.preventDefault()
notifyTerminalCapture('sidebar.left.toggle')
actions.toggleSidebar()
return
@ -1508,7 +1528,7 @@ function App(): React.JSX.Element {
if (workspaceChromeActive && !floatingWorkspaceFocused && matchShortcut('tab.rename')) {
const store = useAppStore.getState()
if (store.activeTabType === 'terminal' && store.activeTabId) {
e.preventDefault()
input.preventDefault()
notifyTerminalCapture('tab.rename')
store.setRenamingTabId(store.activeTabId)
return
@ -1524,7 +1544,7 @@ function App(): React.JSX.Element {
matchShortcut('workspace.rename') &&
activeWorktreeId
) {
e.preventDefault()
input.preventDefault()
notifyTerminalCapture('workspace.rename')
const store = useAppStore.getState()
store.setSidebarOpen(true)
@ -1533,7 +1553,7 @@ function App(): React.JSX.Element {
}
if (matchShortcut('workspace.openBoard') && activeView !== 'settings') {
e.preventDefault()
input.preventDefault()
notifyTerminalCapture('workspace.openBoard')
const store = useAppStore.getState()
store.setSidebarOpen(true)
@ -1552,7 +1572,7 @@ function App(): React.JSX.Element {
if (matchShortcut('view.tasks') && activeView !== 'settings') {
const store = useAppStore.getState()
if (store.repos.some((repo) => isGitRepoKind(repo))) {
e.preventDefault()
input.preventDefault()
notifyTerminalCapture('view.tasks')
store.openTaskPage()
}
@ -1565,7 +1585,7 @@ function App(): React.JSX.Element {
// Cmd/Ctrl+L — toggle right sidebar
if (matchShortcut('sidebar.right.toggle')) {
e.preventDefault()
input.preventDefault()
notifyTerminalCapture('sidebar.right.toggle')
actions.toggleRightSidebar()
return
@ -1573,7 +1593,7 @@ function App(): React.JSX.Element {
// Cmd/Ctrl+Shift+E — toggle right sidebar / explorer tab
if (matchShortcut('sidebar.explorer.toggle')) {
e.preventDefault()
input.preventDefault()
notifyTerminalCapture('sidebar.explorer.toggle')
actions.showRightSidebarFiles()
return
@ -1581,7 +1601,7 @@ function App(): React.JSX.Element {
// Cmd/Ctrl+Shift+F — toggle right sidebar / search tab
if (matchShortcut('sidebar.search.toggle')) {
e.preventDefault()
input.preventDefault()
notifyTerminalCapture('sidebar.search.toggle')
openSearchSidebar(null)
return
@ -1596,7 +1616,7 @@ function App(): React.JSX.Element {
if (document.querySelector('[data-terminal-search-root]')) {
return
}
e.preventDefault()
input.preventDefault()
notifyTerminalCapture('sidebar.sourceControl.toggle')
actions.setRightSidebarTab('source-control')
actions.setRightSidebarOpen(true)
@ -1604,7 +1624,7 @@ function App(): React.JSX.Element {
}
if (matchShortcut('sidebar.checks.toggle')) {
e.preventDefault()
input.preventDefault()
notifyTerminalCapture('sidebar.checks.toggle')
actions.setRightSidebarTab('checks')
actions.setRightSidebarOpen(true)
@ -1615,15 +1635,79 @@ function App(): React.JSX.Element {
// Why: Ctrl+Shift+I is the built-in DevTools accelerator on Windows/Linux;
// intercepting it would break an essential developer tool.
if (matchShortcut('sidebar.ports.toggle')) {
e.preventDefault()
input.preventDefault()
notifyTerminalCapture('sidebar.ports.toggle')
actions.setRightSidebarTab('ports')
actions.setRightSidebarOpen(true)
}
}
const onKeyDown = (e: KeyboardEvent): void => {
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 (e.repeat) {
return
}
if (detected) {
// Synthetic input: no key/modifier flags, so only DoubleTap bindings match.
dispatchShortcutInput({
doubleTapModifier: detected.modifier,
target: e.target,
defaultPrevented: e.defaultPrevented,
preventDefault: () => e.preventDefault()
})
return
}
dispatchShortcutInput({
key: e.key,
code: e.code,
altKey: e.altKey,
metaKey: e.metaKey,
ctrlKey: e.ctrlKey,
shiftKey: e.shiftKey,
target: e.target,
defaultPrevented: e.defaultPrevented,
preventDefault: () => e.preventDefault()
})
}
const onKeyUp = (e: KeyboardEvent): void => {
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()
)
}
// Why: a window blur mid-gesture must not leave the detector armed.
const onBlur = (): void => doubleTapDetector.reset()
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('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,
activeWorktreeId,

View File

@ -4,14 +4,14 @@ import { cn } from '../lib/utils'
import { useAppStore } from '../store'
import { isGitRepoKind } from '../../../shared/repo-kind'
import { ShortcutKeyCombo } from './ShortcutKeyCombo'
import { useShortcutKeys } from '@/hooks/useShortcutLabel'
import { useShortcutKeyDetails, type ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
import { useMountedRef } from '@/hooks/useMountedRef'
import logo from '../../../../resources/logo.svg'
import { translate } from '@/i18n/i18n'
type ShortcutItem = {
id: string
keys: string[]
shortcut: ShortcutKeyComboDetails
action: string
}
@ -285,20 +285,20 @@ export default function Landing(): React.JSX.Element {
}
}, [preflightIssues.length])
const createWorktreeKeys = useShortcutKeys('workspace.create')
const previousWorktreeKeys = useShortcutKeys('worktree.navigateUp')
const nextWorktreeKeys = useShortcutKeys('worktree.navigateDown')
const createWorktreeShortcut = useShortcutKeyDetails('workspace.create')
const previousWorktreeShortcut = useShortcutKeyDetails('worktree.navigateUp')
const nextWorktreeShortcut = useShortcutKeyDetails('worktree.navigateDown')
const shortcuts = useMemo<ShortcutItem[]>(() => {
return [
{
id: 'create',
keys: createWorktreeKeys,
shortcut: createWorktreeShortcut,
action: `Create ${createTargetLabel.toLowerCase()}`
},
{ id: 'up', keys: previousWorktreeKeys, action: 'Move up workspace' },
{ id: 'down', keys: nextWorktreeKeys, action: 'Move down workspace' }
{ id: 'up', shortcut: previousWorktreeShortcut, action: 'Move up workspace' },
{ id: 'down', shortcut: nextWorktreeShortcut, action: 'Move down workspace' }
]
}, [createTargetLabel, createWorktreeKeys, nextWorktreeKeys, previousWorktreeKeys])
}, [createTargetLabel, createWorktreeShortcut, nextWorktreeShortcut, previousWorktreeShortcut])
return (
<div className="absolute inset-0 flex items-center justify-center bg-background">
@ -359,7 +359,8 @@ export default function Landing(): React.JSX.Element {
<div key={shortcut.id} className="grid grid-cols-[1fr_auto] items-center gap-3">
<span className="text-sm text-muted-foreground">{shortcut.action}</span>
<ShortcutKeyCombo
keys={shortcut.keys}
keys={shortcut.shortcut.keys}
doubleTap={shortcut.shortcut.doubleTap}
separatorClassName="mx-0.5 text-[10px] text-muted-foreground"
/>
</div>

View File

@ -1,5 +1,6 @@
import React from 'react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
function KeyCap({ label, className }: { label: string; className?: string }): React.JSX.Element {
return (
@ -20,24 +21,33 @@ type ShortcutKeyComboProps = {
separatorClassName?: string
// Override cap colors when chips sit on a non-default surface (e.g. a filled primary card).
keyCapClassName?: string
// When true the chips render a double-tap gesture: no "+" separator (reads
// "Shift Shift"), with a title clarifying the gesture. Note: the title uses
// the displayed label, so on Mac it reads as the glyph (e.g. 'Double-tap ⇧').
doubleTap?: boolean
}
export function ShortcutKeyCombo({
keys,
className,
separatorClassName,
keyCapClassName
keyCapClassName,
doubleTap = false
}: ShortcutKeyComboProps): React.JSX.Element {
const isMac = navigator.userAgent.includes('Mac')
return (
<span className={cn('inline-flex items-center gap-1', className)}>
<span
className={cn('inline-flex items-center gap-1', className)}
title={doubleTap && keys.length > 0 ? translate("auto.components.ShortcutKeyCombo.07eb4985a1", "Double-tap {{value0}}", { value0: keys[0] }) : undefined}
>
{keys.map((key, index) => (
<React.Fragment key={`${key}-${index}`}>
<KeyCap label={key} className={keyCapClassName} />
{/* Why: Orca renders Mac shortcuts as adjacent glyphs, but Windows/Linux
shortcuts read more naturally with explicit "+" separators. */}
{!isMac && index < keys.length - 1 ? (
shortcuts read more naturally with explicit "+" separators. A
double-tap reads as the same key twice, so it gets a space, not "+". */}
{!isMac && !doubleTap && index < keys.length - 1 ? (
<span className={separatorClassName ?? 'mx-0.5 text-xs text-muted-foreground'}>+</span>
) : null}
</React.Fragment>

View File

@ -49,7 +49,7 @@ import {
} from '@/components/ui/dialog'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
import { useShortcutKeys } from '@/hooks/useShortcutLabel'
import { useShortcutKeyDetails, type ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
import { registerPendingEditorFlush } from './editor-pending-flush'
import { editorShortcutMatches, installEditorSaveShortcut } from './editor-shortcuts'
import MonacoCodeExcerpt from './MonacoCodeExcerpt'
@ -264,13 +264,13 @@ function NotebookCellHeader({
function NotebookHeaderButton({
label,
disabled = false,
shortcutKeys,
shortcut,
onClick,
children
}: {
label: string
disabled?: boolean
shortcutKeys?: string[]
shortcut?: ShortcutKeyComboDetails
onClick: () => void
children: React.ReactNode
}): React.JSX.Element {
@ -292,7 +292,9 @@ function NotebookHeaderButton({
<TooltipContent>
<span className="flex items-center gap-2">
<span>{label}</span>
{shortcutKeys ? <ShortcutKeyCombo keys={shortcutKeys} /> : null}
{shortcut && shortcut.keys.length > 0 ? (
<ShortcutKeyCombo keys={shortcut.keys} doubleTap={shortcut.doubleTap} />
) : null}
</span>
</TooltipContent>
</Tooltip>
@ -729,7 +731,7 @@ export default function IpynbViewer({
const latestContent = flushSourceDrafts()
await onSave(latestContent)
}, [flushSourceDrafts, onSave])
const saveShortcutKeys = useShortcutKeys('editor.save')
const saveShortcut = useShortcutKeyDetails('editor.save')
const handleNotebookKeyDownCapture = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>): void => {
@ -885,7 +887,7 @@ export default function IpynbViewer({
<div className="ml-auto flex items-center gap-2">
<NotebookHeaderButton
label={translate('auto.components.editor.IpynbViewer.15ec40a735', 'Save notebook')}
shortcutKeys={saveShortcutKeys}
shortcut={saveShortcut}
onClick={() => void saveNotebook()}
>
<Save className="size-3.5" />

View File

@ -7,16 +7,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { CmdJPaletteFeatureTipVisual } from './CmdJPaletteFeatureTipVisual'
const prefersReducedMotionMock = vi.hoisted(() => vi.fn(() => false))
const shortcutKeysMock = vi.hoisted(() => vi.fn(() => ['⌘', 'J']))
const formatShortcutKeysMock = vi.hoisted(() => vi.fn(() => ['⌘', 'J']))
const shortcutMock = vi.hoisted(() => vi.fn(() => ({ keys: ['⌘', 'J'], doubleTap: false })))
const formatShortcutMock = vi.hoisted(() => vi.fn(() => [{ keys: ['⌘', 'J'], doubleTap: false }]))
vi.mock('@/components/feature-wall/feature-wall-modal-helpers', () => ({
usePrefersReducedMotion: prefersReducedMotionMock
}))
vi.mock('@/hooks/useShortcutLabel', () => ({
useShortcutKeys: shortcutKeysMock,
formatShortcutKeys: formatShortcutKeysMock
useShortcutKeyDetails: shortcutMock,
formatShortcutKeyComboDetails: formatShortcutMock
}))
async function renderVisual(): Promise<{ container: HTMLDivElement; root: Root }> {
@ -34,8 +34,8 @@ async function renderVisual(): Promise<{ container: HTMLDivElement; root: Root }
describe('CmdJPaletteFeatureTipVisual', () => {
beforeEach(() => {
prefersReducedMotionMock.mockReturnValue(false)
shortcutKeysMock.mockReturnValue(['⌘', 'J'])
formatShortcutKeysMock.mockReturnValue(['⌘', 'J'])
shortcutMock.mockReturnValue({ keys: ['⌘', 'J'], doubleTap: false })
formatShortcutMock.mockReturnValue([{ keys: ['⌘', 'J'], doubleTap: false }])
})
afterEach(() => {
@ -110,12 +110,12 @@ describe('CmdJPaletteFeatureTipVisual', () => {
})
it('falls back to default per-key chips when the live binding is unassigned', () => {
shortcutKeysMock.mockReturnValue([])
formatShortcutKeysMock.mockReturnValue(['Ctrl', 'Shift', 'J'])
shortcutMock.mockReturnValue({ keys: [], doubleTap: false })
formatShortcutMock.mockReturnValue([{ keys: ['Ctrl', 'Shift', 'J'], doubleTap: false }])
const html = renderToStaticMarkup(<CmdJPaletteFeatureTipVisual />)
expect(formatShortcutKeysMock).toHaveBeenCalledWith('worktree.palette')
expect(formatShortcutMock).toHaveBeenCalledWith('worktree.palette')
expect(html).toContain('Ctrl')
expect(html).toContain('Shift')
expect(html).toContain('J')
@ -130,7 +130,7 @@ describe('CmdJPaletteFeatureTipVisual', () => {
})
it('renders the live binding as separate shortcut key chips with plus separators', () => {
shortcutKeysMock.mockReturnValue(['⌘', 'J'])
shortcutMock.mockReturnValue({ keys: ['⌘', 'J'], doubleTap: false })
const html = renderToStaticMarkup(<CmdJPaletteFeatureTipVisual />)
@ -138,4 +138,13 @@ describe('CmdJPaletteFeatureTipVisual', () => {
expect(html).toContain('J')
expect(html).toContain('+')
})
it('renders double-tap shortcut chips without plus separators', () => {
shortcutMock.mockReturnValue({ keys: ['⇧', '⇧'], doubleTap: true })
const html = renderToStaticMarkup(<CmdJPaletteFeatureTipVisual />)
expect(html).toContain('⇧')
expect(html).not.toContain('+')
})
})

View File

@ -1,7 +1,7 @@
import { Fragment, useEffect, useState, type JSX } from 'react'
import { Plus, Search } from 'lucide-react'
import { usePrefersReducedMotion } from '@/components/feature-wall/feature-wall-modal-helpers'
import { formatShortcutKeys, useShortcutKeys } from '@/hooks/useShortcutLabel'
import { formatShortcutKeyComboDetails, useShortcutKeyDetails } from '@/hooks/useShortcutLabel'
import { translate } from '@/i18n/i18n'
const TYPED_QUERY = 'auth'
@ -49,11 +49,13 @@ export function CmdJPaletteFeatureTipVisual(): JSX.Element {
const reducedMotion = usePrefersReducedMotion()
// Why: render the live binding so the cue stays correct after a rebind and on
// platforms where Cmd+J is not the default (Linux/Windows use Ctrl+Shift+J).
const shortcutKeys = useShortcutKeys('worktree.palette')
const shortcut = useShortcutKeyDetails('worktree.palette')
// Why: the press animation staggers per-key chips (⌘ then J); fall back to the
// platform default when the user disables the binding.
const displayShortcutKeys =
shortcutKeys.length > 0 ? shortcutKeys : formatShortcutKeys('worktree.palette')
const displayShortcut =
shortcut.keys.length > 0 ? shortcut : formatShortcutKeyComboDetails('worktree.palette')[0]
const displayShortcutKeys = displayShortcut?.keys ?? []
const displayShortcutDoubleTap = displayShortcut?.doubleTap === true
const [phase, setPhase] = useState<CyclePhase>('idle')
const [typedLength, setTypedLength] = useState(0)
@ -135,7 +137,7 @@ export function CmdJPaletteFeatureTipVisual(): JSX.Element {
<div className="inline-flex items-center gap-1.5">
{displayShortcutKeys.map((key, index) => (
<Fragment key={`${key}-${index}`}>
{index > 0 ? (
{index > 0 && !displayShortcutDoubleTap ? (
<span className="text-xs text-muted-foreground" aria-hidden="true">
+
</span>

View File

@ -3,6 +3,7 @@
* asserted without mounting the full Electron renderer. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import type { KeybindingOverrides } from '../../../../shared/keybindings'
import type { BrowserTab, Tab, TabGroup, TerminalTab } from '../../../../shared/types'
import type { OpenFile } from '@/store/slices/editor'
import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown'
@ -61,6 +62,7 @@ type FloatingPanelStoreState = {
pinFile: (fileId: string, tabId?: string) => void
openFile: (file: unknown, options?: unknown) => void
browserDefaultUrl: string
keybindings?: KeybindingOverrides
tabBarOrderByWorktree: Record<string, string[]>
settings: { activeRuntimeEnvironmentId?: string | null; floatingTerminalCwd?: string }
}
@ -430,6 +432,7 @@ function resetStore(tabs: TerminalTab[] = []): void {
setTabColor: mocks.setTabColor,
setTabPaneExpanded: mocks.setTabPaneExpanded,
browserDefaultUrl: 'about:blank',
keybindings: {},
tabBarOrderByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: tabs.map((tab) => tab.id) },
settings: { floatingTerminalCwd: '' }
} satisfies FloatingPanelStoreState
@ -1162,6 +1165,143 @@ describe('FloatingTerminalPanel close behavior', () => {
expect(mocks.pickFloatingMarkdownDocument).toHaveBeenCalledWith()
})
it('routes focused floating terminal double-tap shortcuts to the floating workspace', async () => {
setFloatingTabs([makeTab({ id: 'tab-1' })])
;(storeBox.state as FloatingPanelStoreState).keybindings = {
'tab.newTerminal': ['DoubleTap+Shift']
}
const element = await renderPanel(true)
const panel = findByProp(element, 'data-floating-terminal-panel')
const panelElement = { contains: vi.fn().mockReturnValue(true), focus: vi.fn() }
const target = {
classList: { contains: vi.fn((token: string) => token === 'xterm-helper-textarea') },
closest: vi.fn((selector: string) =>
selector === '[data-floating-terminal-panel]' ? panelElement : null
)
}
Object.setPrototypeOf(target, HTMLElement.prototype)
attachRef(panel.props.ref, panelElement)
vi.stubGlobal('document', {
activeElement: target,
addEventListener: vi.fn(),
removeEventListener: vi.fn()
})
runEffects()
const keydownListener = vi
.mocked(window.addEventListener)
.mock.calls.find(([type]) => type === 'keydown')?.[1] as
| ((event: unknown) => void)
| undefined
const keyupListener = vi
.mocked(window.addEventListener)
.mock.calls.find(([type]) => type === 'keyup')?.[1] as ((event: unknown) => void) | undefined
if (!keydownListener || !keyupListener) {
throw new Error('keyboard listeners not registered')
}
const modifierEvent = {
altKey: false,
code: 'ShiftLeft',
ctrlKey: false,
defaultPrevented: false,
key: 'Shift',
metaKey: false,
repeat: false,
shiftKey: true,
target
}
const firstPreventDefault = vi.fn()
keydownListener({ ...modifierEvent, preventDefault: firstPreventDefault })
keyupListener({ ...modifierEvent })
const preventDefault = vi.fn()
const stopPropagation = vi.fn()
const stopImmediatePropagation = vi.fn()
keydownListener({
...modifierEvent,
preventDefault,
stopImmediatePropagation,
stopPropagation
})
await flushAsyncWork()
expect(firstPreventDefault).not.toHaveBeenCalled()
expect(preventDefault).toHaveBeenCalledWith()
expect(stopPropagation).toHaveBeenCalledWith()
expect(stopImmediatePropagation).toHaveBeenCalledWith()
expect(mocks.createTab).toHaveBeenCalledTimes(1)
expect(mocks.createTab).toHaveBeenCalledWith(
FLOATING_TERMINAL_WORKTREE_ID,
'floating-group',
undefined,
{ activate: false }
)
expect(mocks.activateTab).toHaveBeenCalledWith('created-tab')
})
it('resets focused floating terminal double-tap detection on window blur', async () => {
setFloatingTabs([makeTab({ id: 'tab-1' })])
;(storeBox.state as FloatingPanelStoreState).keybindings = {
'tab.newTerminal': ['DoubleTap+Shift']
}
const element = await renderPanel(true)
const panel = findByProp(element, 'data-floating-terminal-panel')
const panelElement = { contains: vi.fn().mockReturnValue(true), focus: vi.fn() }
const target = {
classList: { contains: vi.fn((token: string) => token === 'xterm-helper-textarea') },
closest: vi.fn((selector: string) =>
selector === '[data-floating-terminal-panel]' ? panelElement : null
)
}
Object.setPrototypeOf(target, HTMLElement.prototype)
attachRef(panel.props.ref, panelElement)
vi.stubGlobal('document', {
activeElement: target,
addEventListener: vi.fn(),
removeEventListener: vi.fn()
})
runEffects()
const keydownListener = vi
.mocked(window.addEventListener)
.mock.calls.find(([type]) => type === 'keydown')?.[1] as
| ((event: unknown) => void)
| undefined
const keyupListener = vi
.mocked(window.addEventListener)
.mock.calls.find(([type]) => type === 'keyup')?.[1] as ((event: unknown) => void) | undefined
const blurListener = vi
.mocked(window.addEventListener)
.mock.calls.find(([type]) => type === 'blur')?.[1] as (() => void) | undefined
if (!keydownListener || !keyupListener || !blurListener) {
throw new Error('keyboard listeners not registered')
}
const modifierEvent = {
altKey: false,
code: 'ShiftLeft',
ctrlKey: false,
defaultPrevented: false,
key: 'Shift',
metaKey: false,
repeat: false,
shiftKey: true,
target
}
keydownListener({ ...modifierEvent, preventDefault: vi.fn() })
keyupListener({ ...modifierEvent })
blurListener()
const preventDefault = vi.fn()
keydownListener({
...modifierEvent,
preventDefault,
stopImmediatePropagation: vi.fn(),
stopPropagation: vi.fn()
})
await flushAsyncWork()
expect(preventDefault).not.toHaveBeenCalled()
expect(mocks.createTab).not.toHaveBeenCalled()
})
it('routes focused floating tab switch shortcuts to the floating workspace', async () => {
setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })])
const element = await renderPanel(true)

View File

@ -22,7 +22,7 @@ import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-v
import TerminalPane from '@/components/terminal-pane/TerminalPane'
import { Button } from '@/components/ui/button'
import { useMountedRef } from '@/hooks/useMountedRef'
import { useShortcutKeys } from '@/hooks/useShortcutLabel'
import { useShortcutKeyDetails, type ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
import {
Dialog,
DialogContent,
@ -67,8 +67,13 @@ import {
keybindingMatchesAction,
type KeybindingActionId,
type KeybindingContext,
type KeybindingMatchOptions
type KeybindingMatchOptions,
type PhysicalModifierToken
} from '../../../../shared/keybindings'
import {
ModifierDoubleTapDetector,
toModifierDoubleTapEvent
} from '../../../../shared/modifier-double-tap-detector'
import type {
BrowserTab as BrowserTabState,
Tab,
@ -116,6 +121,11 @@ type FloatingWorkspaceTourInteractionSnapshot = {
recordFeatureInteractionForTour: boolean
}
type FloatingPanelShortcutInput = Partial<
Pick<KeyboardEvent, 'altKey' | 'code' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>
> &
Pick<KeyboardEvent, 'target'> & { doubleTapModifier?: PhysicalModifierToken }
const FLOATING_TERMINAL_NO_DRAG_SELECTOR =
'button,input,textarea,select,[role="menuitem"],[data-testid="sortable-tab"],[data-floating-terminal-no-drag]'
const FLOATING_TERMINAL_SHORTCUT_SURFACE_SELECTOR = '[data-floating-terminal-shortcut-surface]'
@ -194,11 +204,11 @@ export function FloatingTerminalPanel({
const browserDefaultUrl = useAppStore((s) => s.browserDefaultUrl)
const floatingTerminalCwd = useAppStore((s) => s.settings?.floatingTerminalCwd ?? '')
const generatedTabTitlesEnabled = useAppStore((s) => s.settings?.tabAutoGenerateTitle === true)
const newTerminalShortcutKeys = useShortcutKeys('tab.newTerminal')
const newBrowserShortcutKeys = useShortcutKeys('tab.newBrowser')
const newMarkdownShortcutKeys = useShortcutKeys('tab.newMarkdown')
const openMarkdownShortcutKeys = useShortcutKeys('tab.openMarkdown')
const closeShortcutKeys = useShortcutKeys('tab.close')
const newTerminalShortcut = useShortcutKeyDetails('tab.newTerminal')
const newBrowserShortcut = useShortcutKeyDetails('tab.newBrowser')
const newMarkdownShortcut = useShortcutKeyDetails('tab.newMarkdown')
const openMarkdownShortcut = useShortcutKeyDetails('tab.openMarkdown')
const closeShortcut = useShortcutKeyDetails('tab.close')
const [cwd, setCwd] = useState<string | null>(null)
const [markdownCwd, setMarkdownCwd] = useState<string | null>(null)
@ -228,6 +238,10 @@ export function FloatingTerminalPanel({
const pendingEditorCloseQueueRef = useRef<string[]>([])
const saveDialogFileIdRef = useRef<string | null>(null)
const panelRef = useRef<HTMLDivElement | null>(null)
const doubleTapDetectorRef = useRef<ModifierDoubleTapDetector | null>(null)
if (!doubleTapDetectorRef.current) {
doubleTapDetectorRef.current = new ModifierDoubleTapDetector()
}
const shortcutFocusFrameRef = useRef<number | null>(null)
const shortcutFocusTimeoutRef = useRef<number | null>(null)
const mountedRef = useMountedRef()
@ -926,6 +940,71 @@ export function FloatingTerminalPanel({
setFloatingTerminalInputFocusedInMain(isFloatingWorkspaceTerminalInputTarget(target))
}, [])
const handleFloatingPanelShortcutAction = useCallback(
(input: FloatingPanelShortcutInput, consume: () => void): boolean => {
const state = useAppStore.getState()
const platform = getShortcutPlatform()
const context: KeybindingContext = input.doubleTapModifier
? 'app'
: isFloatingWorkspaceTerminalInputTarget(input.target)
? 'terminal'
: 'app'
const matchOptions: KeybindingMatchOptions = {
context,
terminalShortcutPolicy: state.settings?.terminalShortcutPolicy
}
const matches = (actionId: KeybindingActionId): boolean =>
keybindingMatchesAction(actionId, input, platform, state.keybindings, matchOptions)
if (matches('tab.newTerminal')) {
consume()
createFloatingTerminalTab()
return true
}
if (matches('tab.newBrowser')) {
consume()
createFloatingBrowserTab()
return true
}
if (matches('tab.newMarkdown')) {
consume()
createFloatingMarkdownTab()
return true
}
if (matches('tab.openMarkdown')) {
consume()
openFloatingMarkdownTab()
return true
}
if (matches('tab.close')) {
consume()
if (activeClosableTab) {
closeFloatingItem(activeClosableTab.id)
if (visibleFloatingItemCount <= 1) {
// Why: closing the final xterm removes the focused textarea; keep
// the empty floating workspace as the owner for the next Cmd/Ctrl+T.
focusPanelForShortcutsAfterClose()
}
} else {
onOpenChange(false)
}
return true
}
return false
},
[
activeClosableTab,
closeFloatingItem,
createFloatingBrowserTab,
createFloatingMarkdownTab,
createFloatingTerminalTab,
focusPanelForShortcutsAfterClose,
onOpenChange,
openFloatingMarkdownTab,
visibleFloatingItemCount
]
)
const handleShortcutSurfaceKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (!open || event.defaultPrevented || event.repeat) {
@ -950,8 +1029,6 @@ export function FloatingTerminalPanel({
terminalShortcutPolicy: state.settings?.terminalShortcutPolicy
}
const nativeEvent = event.nativeEvent
const matches = (actionId: KeybindingActionId): boolean =>
keybindingMatchesAction(actionId, nativeEvent, platform, state.keybindings, matchOptions)
if (
!isFloatingWorkspacePanelShortcut(
@ -965,52 +1042,9 @@ export function FloatingTerminalPanel({
return
}
if (matches('tab.newTerminal')) {
event.preventDefault()
createFloatingTerminalTab()
return
}
if (matches('tab.newBrowser')) {
event.preventDefault()
createFloatingBrowserTab()
return
}
if (matches('tab.newMarkdown')) {
event.preventDefault()
createFloatingMarkdownTab()
return
}
if (matches('tab.openMarkdown')) {
event.preventDefault()
openFloatingMarkdownTab()
return
}
if (matches('tab.close')) {
event.preventDefault()
if (activeClosableTab) {
closeFloatingItem(activeClosableTab.id)
if (visibleFloatingItemCount <= 1) {
// Why: closing the final xterm removes the focused textarea; keep
// the empty floating workspace as the owner for the next Cmd/Ctrl+T.
focusPanelForShortcutsAfterClose()
}
} else {
onOpenChange(false)
}
}
handleFloatingPanelShortcutAction(nativeEvent, () => event.preventDefault())
},
[
activeClosableTab,
closeFloatingItem,
createFloatingBrowserTab,
createFloatingMarkdownTab,
createFloatingTerminalTab,
focusPanelForShortcutsAfterClose,
onOpenChange,
openFloatingMarkdownTab,
open,
visibleFloatingItemCount
]
[handleFloatingPanelShortcutAction, open]
)
useEffect(() => {
@ -1018,13 +1052,35 @@ export function FloatingTerminalPanel({
return
}
const handleFloatingPanelKeyDown = (event: KeyboardEvent): void => {
if (event.defaultPrevented || event.repeat) {
return
}
const isPanelFocused = (): boolean => {
const panel = panelRef.current
const active = document.activeElement
if (!panel || !(active instanceof HTMLElement) || !panel.contains(active)) {
return Boolean(panel && active instanceof HTMLElement && panel.contains(active))
}
const handleFloatingPanelKeyDown = (event: KeyboardEvent): void => {
if (event.defaultPrevented) {
return
}
if (!isPanelFocused()) {
doubleTapDetectorRef.current?.reset()
return
}
const detected = doubleTapDetectorRef.current?.process(
toModifierDoubleTapEvent({
type: 'keyDown',
code: event.code,
key: event.key,
shift: event.shiftKey,
control: event.ctrlKey,
alt: event.altKey,
meta: event.metaKey,
isAutoRepeat: event.repeat
}),
Date.now()
)
if (event.repeat) {
return
}
@ -1043,38 +1099,17 @@ export function FloatingTerminalPanel({
event.stopImmediatePropagation()
}
if (matches('tab.newTerminal')) {
consume()
createFloatingTerminalTab()
if (
detected &&
handleFloatingPanelShortcutAction(
{ doubleTapModifier: detected.modifier, target: event.target },
consume
)
) {
return
}
if (matches('tab.newBrowser')) {
consume()
createFloatingBrowserTab()
return
}
if (matches('tab.newMarkdown')) {
consume()
createFloatingMarkdownTab()
return
}
if (matches('tab.openMarkdown')) {
consume()
openFloatingMarkdownTab()
return
}
if (matches('tab.close')) {
consume()
if (activeClosableTab) {
closeFloatingItem(activeClosableTab.id)
if (visibleFloatingItemCount <= 1) {
// Why: closing the final xterm removes the focused textarea; keep
// the empty floating workspace as the owner for the next Cmd/Ctrl+T.
focusPanelForShortcutsAfterClose()
}
} else {
onOpenChange(false)
}
if (handleFloatingPanelShortcutAction(event, consume)) {
return
}
@ -1109,23 +1144,39 @@ export function FloatingTerminalPanel({
}
}
const handleFloatingPanelKeyUp = (event: KeyboardEvent): void => {
if (!isPanelFocused()) {
doubleTapDetectorRef.current?.reset()
return
}
doubleTapDetectorRef.current?.process(
toModifierDoubleTapEvent({
type: 'keyUp',
code: event.code,
key: event.key,
shift: event.shiftKey,
control: event.ctrlKey,
alt: event.altKey,
meta: event.metaKey
}),
Date.now()
)
}
const handleFloatingPanelBlur = (): void => doubleTapDetectorRef.current?.reset()
// Why: the main Terminal view is not mounted on Landing/Settings, but the
// floating workspace must still own its tab shortcuts while it has focus.
window.addEventListener('keydown', handleFloatingPanelKeyDown, { capture: true })
return () =>
window.addEventListener('keyup', handleFloatingPanelKeyUp, { capture: true })
window.addEventListener('blur', handleFloatingPanelBlur)
return () => {
window.removeEventListener('keydown', handleFloatingPanelKeyDown, { capture: true })
}, [
activeClosableTab,
closeFloatingItem,
createFloatingBrowserTab,
createFloatingMarkdownTab,
createFloatingTerminalTab,
focusPanelForShortcutsAfterClose,
onOpenChange,
openFloatingMarkdownTab,
open,
visibleFloatingItemCount
])
window.removeEventListener('keyup', handleFloatingPanelKeyUp, { capture: true })
window.removeEventListener('blur', handleFloatingPanelBlur)
doubleTapDetectorRef.current?.reset()
}
}, [handleFloatingPanelShortcutAction, open])
useEffect(() => {
if (!open) {
@ -1451,11 +1502,11 @@ export function FloatingTerminalPanel({
onNewBrowser={createFloatingBrowserTab}
onClose={() => onOpenChange(false)}
onFocusPanel={focusPanelForShortcuts}
newTerminalShortcutKeys={newTerminalShortcutKeys}
newBrowserShortcutKeys={newBrowserShortcutKeys}
newMarkdownShortcutKeys={newMarkdownShortcutKeys}
openMarkdownShortcutKeys={openMarkdownShortcutKeys}
closeShortcutKeys={closeShortcutKeys}
newTerminalShortcut={newTerminalShortcut}
newBrowserShortcut={newBrowserShortcut}
newMarkdownShortcut={newMarkdownShortcut}
openMarkdownShortcut={openMarkdownShortcut}
closeShortcut={closeShortcut}
/>
) : null}
</div>
@ -1593,11 +1644,11 @@ function FloatingTerminalEmptyState({
onNewBrowser,
onClose,
onFocusPanel,
newTerminalShortcutKeys,
newBrowserShortcutKeys,
newMarkdownShortcutKeys,
openMarkdownShortcutKeys,
closeShortcutKeys
newTerminalShortcut,
newBrowserShortcut,
newMarkdownShortcut,
openMarkdownShortcut,
closeShortcut
}: {
onNewTerminal: () => void
onNewMarkdown: () => void
@ -1605,11 +1656,11 @@ function FloatingTerminalEmptyState({
onNewBrowser: () => void
onClose: () => void
onFocusPanel: () => void
newTerminalShortcutKeys: string[]
newBrowserShortcutKeys: string[]
newMarkdownShortcutKeys: string[]
openMarkdownShortcutKeys: string[]
closeShortcutKeys: string[]
newTerminalShortcut: ShortcutKeyComboDetails
newBrowserShortcut: ShortcutKeyComboDetails
newMarkdownShortcut: ShortcutKeyComboDetails
openMarkdownShortcut: ShortcutKeyComboDetails
closeShortcut: ShortcutKeyComboDetails
}): React.JSX.Element {
return (
<div
@ -1633,7 +1684,7 @@ function FloatingTerminalEmptyState({
'New Terminal'
)}
</span>
<FloatingEmptyStateShortcut keys={newTerminalShortcutKeys} />
<FloatingEmptyStateShortcut shortcut={newTerminalShortcut} />
</Button>
<Button
type="button"
@ -1649,7 +1700,7 @@ function FloatingTerminalEmptyState({
'New Markdown Note'
)}
</span>
<FloatingEmptyStateShortcut keys={newMarkdownShortcutKeys} />
<FloatingEmptyStateShortcut shortcut={newMarkdownShortcut} />
</Button>
<Button
type="button"
@ -1664,7 +1715,7 @@ function FloatingTerminalEmptyState({
'Open Markdown Note'
)}
</span>
<FloatingEmptyStateShortcut keys={openMarkdownShortcutKeys} />
<FloatingEmptyStateShortcut shortcut={openMarkdownShortcut} />
</Button>
<Button
type="button"
@ -1679,7 +1730,7 @@ function FloatingTerminalEmptyState({
'New Browser'
)}
</span>
<FloatingEmptyStateShortcut keys={newBrowserShortcutKeys} />
<FloatingEmptyStateShortcut shortcut={newBrowserShortcut} />
</Button>
<Button
type="button"
@ -1694,20 +1745,25 @@ function FloatingTerminalEmptyState({
'Minimize'
)}
</span>
<FloatingEmptyStateShortcut keys={closeShortcutKeys} />
<FloatingEmptyStateShortcut shortcut={closeShortcut} />
</Button>
</div>
</div>
)
}
function FloatingEmptyStateShortcut({ keys }: { keys: string[] }): React.JSX.Element {
if (keys.length === 0) {
function FloatingEmptyStateShortcut({
shortcut
}: {
shortcut: ShortcutKeyComboDetails
}): React.JSX.Element {
if (shortcut.keys.length === 0) {
return <span aria-hidden />
}
return (
<ShortcutKeyCombo
keys={keys}
keys={shortcut.keys}
doubleTap={shortcut.doubleTap}
className="self-center justify-self-end opacity-90 [&>span]:text-foreground"
separatorClassName="mx-0 text-[9px] text-foreground"
/>

View File

@ -22,7 +22,7 @@ vi.mock('../../store', () => ({
}))
vi.mock('@/hooks/useShortcutLabel', () => ({
useShortcutKeyCombos: () => []
useShortcutKeyComboDetails: () => []
}))
vi.mock('../status-bar/use-available-status-bar-toggles', () => ({

View File

@ -10,7 +10,7 @@ import { UIZoomControl } from './UIZoomControl'
import { SearchableSetting } from './SearchableSetting'
import { matchesSettingsSearch } from './settings-search'
import { useAppStore } from '../../store'
import { useShortcutKeyCombos } from '@/hooks/useShortcutLabel'
import { useShortcutKeyComboDetails, type ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
import { ShortcutKeyCombo } from '../ShortcutKeyCombo'
import {
FontAutocomplete,
@ -64,7 +64,7 @@ type AppearancePaneProps = {
warpThemes: UseWarpThemeImportReturn
}
function ShortcutHintList({ combos }: { combos: string[][] }): React.JSX.Element {
function ShortcutHintList({ combos }: { combos: ShortcutKeyComboDetails[] }): React.JSX.Element {
if (combos.length === 0) {
return (
<span className="text-xs text-muted-foreground">
@ -75,10 +75,11 @@ function ShortcutHintList({ combos }: { combos: string[][] }): React.JSX.Element
return (
<span className="inline-flex flex-wrap items-center gap-1 align-middle">
{combos.map((keys) => (
{combos.map((combo) => (
<ShortcutKeyCombo
key={keys.join('-')}
keys={keys}
key={combo.keys.join('-')}
keys={combo.keys}
doubleTap={combo.doubleTap}
className="inline-flex gap-0.5"
separatorClassName="text-[10px] text-muted-foreground"
/>
@ -98,8 +99,8 @@ export function AppearancePane({
warpThemes
}: AppearancePaneProps): React.JSX.Element {
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const zoomInKeyCombos = useShortcutKeyCombos('zoom.in')
const zoomOutKeyCombos = useShortcutKeyCombos('zoom.out')
const zoomInKeyCombos = useShortcutKeyComboDetails('zoom.in')
const zoomOutKeyCombos = useShortcutKeyComboDetails('zoom.out')
const statusBarItems = useAppStore((state) => state.statusBarItems)
const toggleStatusBarItem = useAppStore((state) => state.toggleStatusBarItem)
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)

View File

@ -13,7 +13,7 @@ const mocks = vi.hoisted(() => ({
vi.mock('@/hooks/useShortcutLabel', () => ({
useShortcutLabel: () => '⌘F',
useShortcutKeyCombos: () => [['⌘', 'F']]
useShortcutKeyComboDetails: () => [{ keys: ['⌘', 'F'], doubleTap: false }]
}))
vi.mock('./settings-setup-guide-progress', () => ({

View File

@ -4,7 +4,7 @@ import { ArrowLeft, Search, Server } from 'lucide-react'
import type { RepoIcon } from '../../../../shared/repo-icon'
import type { SettingsNavIcon, SettingsNavInstallStatus } from '@/lib/settings-navigation-types'
import type { GitHubRepositoryIdentity, GlobalSettings } from '../../../../shared/types'
import { useShortcutKeyCombos } from '@/hooks/useShortcutLabel'
import { useShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
import { ShortcutKeyCombo } from '../ShortcutKeyCombo'
import { cn } from '@/lib/utils'
import { RepoIconGlyph } from '../repo/repo-icon'
@ -138,7 +138,7 @@ export function SettingsSidebar({
// Settings should remain a stable place to reopen the checklist.
const showSetupGuideTopRow =
setupGuideProgress.ready && setupGuideProgress.doneCount < setupGuideProgress.total
const searchShortcutCombos = useShortcutKeyCombos('settings.search')
const searchShortcutCombos = useShortcutKeyComboDetails('settings.search')
const navItemClassName = (isActive: boolean): string =>
cn(
'flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left text-[13px] outline-none transition-colors duration-150 focus-visible:ring-[3px] focus-visible:ring-worktree-sidebar-ring/50',
@ -201,10 +201,11 @@ export function SettingsSidebar({
/>
{searchQuery === '' ? (
<span className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center">
{searchShortcutCombos.map((keys) => (
{searchShortcutCombos.map((combo) => (
<ShortcutKeyCombo
key={keys.join('-')}
keys={keys}
key={combo.keys.join('-')}
keys={combo.keys}
doubleTap={combo.doubleTap}
className="inline-flex gap-0.5"
separatorClassName="text-[10px] text-muted-foreground"
/>

View File

@ -2,10 +2,16 @@ import React, { useEffect, useRef } from 'react'
import { Ban, Plus, RotateCcw, Terminal } from 'lucide-react'
import {
formatKeybinding,
isDoubleTapBinding,
type KeybindingActionId,
type KeybindingDefinition,
type KeybindingInput
} from '../../../../shared/keybindings'
import {
ModifierDoubleTapDetector,
modifierFromKeyEvent,
toModifierDoubleTapEvent
} from '../../../../shared/modifier-double-tap-detector'
import { cn } from '../../lib/utils'
import { ShortcutKeyCombo } from '../ShortcutKeyCombo'
import { Badge } from '../ui/badge'
@ -55,17 +61,31 @@ export function ShortcutBindingRow({
onReset
}: ShortcutBindingRowProps): React.JSX.Element {
const recordButtonRef = useRef<HTMLButtonElement | null>(null)
const doubleTapDetectorRef = useRef<ModifierDoubleTapDetector | null>(null)
if (!doubleTapDetectorRef.current) {
doubleTapDetectorRef.current = new ModifierDoubleTapDetector()
}
useEffect(() => {
if (recording) {
recordButtonRef.current?.focus()
} else {
// Stale taps mustn't survive into the next recording session.
doubleTapDetectorRef.current?.reset()
}
window.api.ui.setShortcutRecorderFocused(recording)
return () => window.api.ui.setShortcutRecorderFocused(false)
}, [recording])
const statusMessage = error ?? (warnings.length > 0 ? warnings.join(' ') : '')
const recordingMessage = recording ? 'Listening for shortcut. Esc cancels recording.' : ''
const doubleTapHint = platform === 'darwin' ? '⇧⇧' : 'Shift Shift'
const recordingMessage = recording
? translate(
'auto.components.settings.ShortcutBindingRow.a98d551407',
'Press a shortcut, or double-tap a modifier (e.g. {{value0}}). Esc cancels.',
{ value0: doubleTapHint }
)
: ''
const helperMessage = statusMessage || recordingMessage
const hasBinding = effective.length > 0
@ -82,11 +102,37 @@ export function ShortcutBindingRow({
event.stopPropagation()
if (event.key === 'Escape') {
doubleTapDetectorRef.current?.reset()
onClearError(item.id)
onCancelRecording()
return
}
// A modifier press never captures on its own — the detector decides whether
// it completes a double-tap, leaving normal chords to capture on their key.
if (modifierFromKeyEvent(event.code, event.key) !== null) {
const detected = doubleTapDetectorRef.current?.process(
toModifierDoubleTapEvent({
type: 'keyDown',
code: event.code,
key: event.key,
shift: event.shiftKey,
control: event.ctrlKey,
alt: event.altKey,
meta: event.metaKey,
isAutoRepeat: event.repeat
}),
Date.now()
)
if (detected) {
onClearError(item.id)
onCapture(item.id, { doubleTapModifier: detected.modifier })
doubleTapDetectorRef.current?.reset()
}
return
}
doubleTapDetectorRef.current?.reset()
onClearError(item.id)
onCapture(item.id, {
key: event.key,
@ -98,6 +144,26 @@ export function ShortcutBindingRow({
})
}
const handleRecordKeyUp = (event: React.KeyboardEvent<HTMLButtonElement>): void => {
if (!recording) {
return
}
event.preventDefault()
event.stopPropagation()
doubleTapDetectorRef.current?.process(
toModifierDoubleTapEvent({
type: 'keyUp',
code: event.code,
key: event.key,
shift: event.shiftKey,
control: event.ctrlKey,
alt: event.altKey,
meta: event.metaKey
}),
Date.now()
)
}
// Why: the recorder is the row's primary control — clicking the keys (or the
// "Add shortcut" placeholder) records a new binding in place, so the whole
// affordance lives inline rather than in a detached popover.
@ -230,6 +296,7 @@ export function ShortcutBindingRow({
}
}}
onKeyDown={handleRecordKeyDown}
onKeyUp={handleRecordKeyUp}
className={cn(
'flex min-h-7 min-w-[5.5rem] items-center justify-center gap-1.5 rounded-md border px-2 py-1 text-xs outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50',
recording
@ -249,7 +316,11 @@ export function ShortcutBindingRow({
) : hasBinding ? (
<span className="flex flex-wrap items-center justify-end gap-1.5">
{effective.map((binding) => (
<ShortcutKeyCombo key={binding} keys={formatKeybinding(binding, platform)} />
<ShortcutKeyCombo
key={binding}
keys={formatKeybinding(binding, platform)}
doubleTap={isDoubleTapBinding(binding)}
/>
))}
</span>
) : (

View File

@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => ({
appRestart: vi.fn(),
updaterCheck: vi.fn(),
shellOpenUrl: vi.fn(),
useShortcutKeys: vi.fn(),
useShortcutKeyDetails: vi.fn(),
setupProgress: {
ready: true,
coreDoneCount: 2,
@ -32,7 +32,7 @@ vi.mock('@/store', () => ({
}))
vi.mock('@/hooks/useShortcutLabel', () => ({
useShortcutKeys: mocks.useShortcutKeys
useShortcutKeyDetails: mocks.useShortcutKeyDetails
}))
vi.mock('@/hooks/useMountedRef', () => ({
@ -99,7 +99,7 @@ vi.mock('./SidebarFeedbackDialog', () => ({
describe('SidebarSettingsHelpMenu', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.useShortcutKeys.mockReturnValue(['⌘', ','])
mocks.useShortcutKeyDetails.mockReturnValue({ keys: ['⌘', ','], doubleTap: false })
updateStatus = { state: 'idle' }
mocks.setupProgress = {
ready: true,

View File

@ -26,7 +26,7 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useMountedRef } from '@/hooks/useMountedRef'
import { useShortcutKeys } from '@/hooks/useShortcutLabel'
import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel'
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
import { showOnboardingFromRenderer } from '../onboarding/show-onboarding-event'
import { SetupGuideProgressRing } from '../setup-guide/SetupGuideProgressRing'
@ -85,7 +85,7 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element {
const updateStatus = useAppStore((s) => s.updateStatus)
const setupProgress = useSetupGuideProgress(true, false, false)
const settingsShortcutKeys = useShortcutKeys('app.settings')
const settingsShortcut = useShortcutKeyDetails('app.settings')
const [menuOpen, setMenuOpen] = useState(false)
const [feedbackOpen, setFeedbackOpen] = useState(false)
const [showAdminOptions, setShowAdminOptions] = useState(false)
@ -177,9 +177,10 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element {
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4} className="flex items-center gap-1.5">
{translate('auto.components.sidebar.SidebarSettingsHelpMenu.a428c25998', 'Settings')}
{settingsShortcutKeys.length > 0 ? (
{settingsShortcut.keys.length > 0 ? (
<ShortcutKeyCombo
keys={settingsShortcutKeys}
keys={settingsShortcut.keys}
doubleTap={settingsShortcut.doubleTap}
className="gap-0.5"
keyCapClassName="min-w-0 border-background/20 bg-background/10 px-1 py-0 text-[10px] text-background shadow-none"
separatorClassName="text-[10px] text-background/70"

View File

@ -2,6 +2,7 @@ import {
formatKeybinding,
formatKeybindingList,
getEffectiveKeybindingsForAction,
isDoubleTapBinding,
type KeybindingActionId,
type KeybindingOverrides
} from '../../../shared/keybindings'
@ -10,6 +11,11 @@ import { getShortcutPlatform } from '../lib/shortcut-platform'
export { getShortcutPlatform }
export type ShortcutKeyComboDetails = {
keys: string[]
doubleTap: boolean
}
export function formatShortcutLabel(
actionId: KeybindingActionId,
overrides?: KeybindingOverrides
@ -30,9 +36,7 @@ export function formatShortcutKeys(
actionId: KeybindingActionId,
overrides?: KeybindingOverrides
): string[] {
const platform = getShortcutPlatform()
const binding = getEffectiveKeybindingsForAction(actionId, platform, overrides)[0]
return binding ? formatKeybinding(binding, platform) : []
return formatShortcutKeyComboDetails(actionId, overrides)[0]?.keys ?? []
}
export function useShortcutKeys(actionId: KeybindingActionId): string[] {
@ -40,17 +44,35 @@ export function useShortcutKeys(actionId: KeybindingActionId): string[] {
return formatShortcutKeys(actionId, keybindings)
}
export function formatShortcutKeyComboDetails(
actionId: KeybindingActionId,
overrides?: KeybindingOverrides
): ShortcutKeyComboDetails[] {
const platform = getShortcutPlatform()
return getEffectiveKeybindingsForAction(actionId, platform, overrides).map((binding) => ({
keys: formatKeybinding(binding, platform),
doubleTap: isDoubleTapBinding(binding)
}))
}
export function useShortcutKeyComboDetails(
actionId: KeybindingActionId
): ShortcutKeyComboDetails[] {
const keybindings = useAppStore((state) => state.keybindings)
return formatShortcutKeyComboDetails(actionId, keybindings)
}
export function useShortcutKeyDetails(actionId: KeybindingActionId): ShortcutKeyComboDetails {
return useShortcutKeyComboDetails(actionId)[0] ?? { keys: [], doubleTap: false }
}
export function formatShortcutKeyCombos(
actionId: KeybindingActionId,
overrides?: KeybindingOverrides
): string[][] {
const platform = getShortcutPlatform()
return getEffectiveKeybindingsForAction(actionId, platform, overrides).map((binding) =>
formatKeybinding(binding, platform)
)
return formatShortcutKeyComboDetails(actionId, overrides).map((combo) => combo.keys)
}
export function useShortcutKeyCombos(actionId: KeybindingActionId): string[][] {
const keybindings = useAppStore((state) => state.keybindings)
return formatShortcutKeyCombos(actionId, keybindings)
return useShortcutKeyComboDetails(actionId).map((combo) => combo.keys)
}

View File

@ -5791,7 +5791,8 @@
"97dccee14e": "Modified",
"3b11ef3a43": "{{value0}} shortcut",
"f6579be67b": "Change shortcut",
"6a7848fdac": "Listening for shortcut"
"6a7848fdac": "Listening for shortcut",
"a98d551407": "Press a shortcut, or double-tap a modifier (e.g. {{value0}}). Esc cancels."
},
"ShortcutFilterRail": {
"28b63545bf": "Status",
@ -11451,6 +11452,9 @@
"sourceUnavailable": "{{value0}} source unavailable: {{value1}}",
"someSourceHostsUnavailable": "Some {{value0}} source hosts unavailable: {{value1}}",
"reconnectOrUpdateTitle": "Reconnect or update {{value0}} to load this source."
},
"ShortcutKeyCombo": {
"07eb4985a1": "Double-tap {{value0}}"
}
},
"i18n": {

View File

@ -5754,7 +5754,8 @@
"97dccee14e": "Modificado",
"3b11ef3a43": "{{value0}} atajo",
"f6579be67b": "Cambiar acceso directo",
"6a7848fdac": "Escuchando atajos"
"6a7848fdac": "Escuchando atajos",
"a98d551407": "Pulsa un atajo o toca dos veces una tecla modificadora (p. ej., {{value0}}). Esc cancela."
},
"ShortcutFilterRail": {
"28b63545bf": "Estado",
@ -11451,6 +11452,9 @@
"sourceUnavailable": "{{value0}} source unavailable: {{value1}}",
"someSourceHostsUnavailable": "Some {{value0}} source hosts unavailable: {{value1}}",
"reconnectOrUpdateTitle": "Reconnect or update {{value0}} to load this source."
},
"ShortcutKeyCombo": {
"07eb4985a1": "Double-tap {{value0}}"
}
},
"i18n": {

View File

@ -5776,7 +5776,8 @@
"97dccee14e": "修正済み",
"3b11ef3a43": "{{value0}} ショートカット",
"f6579be67b": "ショートカットを変更する",
"6a7848fdac": "ショートカットを記録中"
"6a7848fdac": "ショートカットを記録中",
"a98d551407": "ショートカットを押すか、修飾キーをダブルタップしてください(例: {{value0}}。Esc でキャンセルします。"
},
"ShortcutFilterRail": {
"28b63545bf": "状態",
@ -11451,6 +11452,9 @@
"sourceUnavailable": "{{value0}} source unavailable: {{value1}}",
"someSourceHostsUnavailable": "Some {{value0}} source hosts unavailable: {{value1}}",
"reconnectOrUpdateTitle": "Reconnect or update {{value0}} to load this source."
},
"ShortcutKeyCombo": {
"07eb4985a1": "Double-tap {{value0}}"
}
},
"i18n": {

View File

@ -5739,7 +5739,8 @@
"97dccee14e": "수정됨",
"3b11ef3a43": "{{value0}} 바로가기",
"f6579be67b": "바로가기 변경",
"6a7848fdac": "단축키 입력 대기 중"
"6a7848fdac": "단축키 입력 대기 중",
"a98d551407": "단축키를 누르거나 보조 키를 두 번 탭하세요(예: {{value0}}). Esc로 취소합니다."
},
"ShortcutFilterRail": {
"28b63545bf": "상태",
@ -11451,6 +11452,9 @@
"sourceUnavailable": "{{value0}} source unavailable: {{value1}}",
"someSourceHostsUnavailable": "Some {{value0}} source hosts unavailable: {{value1}}",
"reconnectOrUpdateTitle": "Reconnect or update {{value0}} to load this source."
},
"ShortcutKeyCombo": {
"07eb4985a1": "Double-tap {{value0}}"
}
},
"i18n": {

View File

@ -5739,7 +5739,8 @@
"97dccee14e": "修改的",
"3b11ef3a43": "{{value0}} 快捷方式",
"f6579be67b": "更改快捷方式",
"6a7848fdac": "正在录制快捷键"
"6a7848fdac": "正在录制快捷键",
"a98d551407": "按下快捷键,或双击一个修饰键(例如 {{value0}})。按 Esc 取消。"
},
"ShortcutFilterRail": {
"28b63545bf": "状态",
@ -11451,6 +11452,9 @@
"sourceUnavailable": "{{value0}} source unavailable: {{value1}}",
"someSourceHostsUnavailable": "Some {{value0}} source hosts unavailable: {{value1}}",
"reconnectOrUpdateTitle": "Reconnect or update {{value0}} to load this source."
},
"ShortcutKeyCombo": {
"07eb4985a1": "Double-tap {{value0}}"
}
},
"i18n": {

View File

@ -2,13 +2,16 @@ import {
keybindingMatchesAction,
type KeybindingActionId,
type KeybindingMatchOptions,
type KeybindingOverrides
type KeybindingOverrides,
type PhysicalModifierToken
} from '../../../shared/keybindings'
type FloatingWorkspaceShortcutEvent = Pick<
KeyboardEvent,
'altKey' | 'code' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey' | 'target'
>
// Partial<> on the key/modifier fields so a synthetic double-tap input (which
// carries no key/modifier flags) satisfies this shape; target stays required.
type FloatingWorkspaceShortcutEvent = Partial<
Pick<KeyboardEvent, 'altKey' | 'code' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>
> &
Pick<KeyboardEvent, 'target'> & { doubleTapModifier?: PhysicalModifierToken }
const FLOATING_WORKSPACE_SHORTCUT_SURFACE_SELECTOR = '[data-floating-terminal-shortcut-surface]'
const FLOATING_WORKSPACE_PANEL_SHORTCUT_ACTIONS: readonly KeybindingActionId[] = [

View File

@ -282,6 +282,17 @@ describe('isFloatingWorkspacePanelShortcut', () => {
).toBe(false)
})
it('claims customized double-tap shortcuts for the floating panel surface', () => {
const event = shortcutSurfaceEvent({}) as KeyboardEvent & { doubleTapModifier: 'Shift' }
event.doubleTapModifier = 'Shift'
expect(
isFloatingWorkspacePanelShortcut(event, 'linux', null, {
'tab.newTerminal': ['DoubleTap+Shift']
})
).toBe(true)
})
it('does not claim shortcuts with Alt or the wrong platform modifier', () => {
expect(
isFloatingWorkspacePanelShortcut(shortcutSurfaceEvent({ key: 't', metaKey: true }), false)

View File

@ -6,8 +6,10 @@ import {
agentTabActionId,
getKeybindingDefinition,
findKeybindingConflicts,
formatKeybinding,
formatKeybindingList,
getEffectiveKeybindingsForAction,
isDoubleTapBinding,
keybindingFromInput,
keybindingFromInputForAction,
keybindingMatchesAction,
@ -36,6 +38,34 @@ describe('keybindings', () => {
expect(normalizeKeybinding('Ctrl+Nope')).toMatchObject({ ok: false })
})
it('parses, normalizes, and rejects double-tap modifier bindings', () => {
expect(normalizeKeybinding('DoubleTap+Shift')).toEqual({ ok: true, value: 'DoubleTap+Shift' })
expect(normalizeKeybinding(' doubletap + shift ')).toEqual({
ok: true,
value: 'DoubleTap+Shift'
})
expect(normalizeKeybinding('DoubleTap+Mod')).toEqual({ ok: true, value: 'DoubleTap+Mod' })
expect(normalizeKeybinding('DoubleTap+Cmd')).toEqual({ ok: true, value: 'DoubleTap+Cmd' })
expect(normalizeKeybinding('DoubleTap+Alt')).toEqual({ ok: true, value: 'DoubleTap+Alt' })
expect(normalizeKeybinding('DoubleTap+Ctrl')).toEqual({ ok: true, value: 'DoubleTap+Ctrl' })
// A key after DoubleTap is invalid.
expect(normalizeKeybinding('DoubleTap+Shift+P')).toMatchObject({ ok: false })
// Two modifiers is invalid.
expect(normalizeKeybinding('DoubleTap+Shift+Alt')).toMatchObject({ ok: false })
// Mod + platform-specific reuses the shared error.
expect(normalizeKeybinding('DoubleTap+Mod+Cmd')).toEqual({
ok: false,
error: 'Use either Mod or a platform-specific modifier, not both.'
})
// Bare DoubleTap is invalid.
expect(normalizeKeybinding('DoubleTap')).toMatchObject({ ok: false })
expect(isDoubleTapBinding('DoubleTap+Shift')).toBe(true)
expect(isDoubleTapBinding('Mod+P')).toBe(false)
expect(isDoubleTapBinding('not-a-binding')).toBe(false)
})
it('allows safe bare keys only for scoped actions that opt in', () => {
expect(normalizeKeybinding('Delete')).toMatchObject({ ok: false })
expect(normalizeKeybindingListForAction('fileExplorer.delete', 'Delete')).toEqual(['Delete'])
@ -813,6 +843,95 @@ describe('keybindings', () => {
).toBe(true)
})
it('matches double-tap bindings only against synthetic double-tap input', () => {
expect(
keybindingMatchesInput('DoubleTap+Shift', { doubleTapModifier: 'Shift' }, 'darwin')
).toBe(true)
// Mod resolves per platform: meta on macOS, control elsewhere.
expect(keybindingMatchesInput('DoubleTap+Mod', { doubleTapModifier: 'Cmd' }, 'darwin')).toBe(
true
)
expect(keybindingMatchesInput('DoubleTap+Mod', { doubleTapModifier: 'Ctrl' }, 'win32')).toBe(
true
)
expect(keybindingMatchesInput('DoubleTap+Mod', { doubleTapModifier: 'Cmd' }, 'win32')).toBe(
false
)
expect(keybindingMatchesInput('DoubleTap+Mod', { doubleTapModifier: 'Ctrl' }, 'darwin')).toBe(
false
)
expect(keybindingMatchesInput('DoubleTap+Shift', { doubleTapModifier: 'Alt' }, 'darwin')).toBe(
false
)
// Cross-type negatives: a double-tap binding never matches a normal keydown,
// and a normal binding never matches a synthetic double-tap input.
expect(
keybindingMatchesInput('DoubleTap+Shift', { key: 'A', code: 'KeyA', shift: true }, 'darwin')
).toBe(false)
expect(keybindingMatchesInput('Mod+P', { doubleTapModifier: 'Cmd' }, 'darwin')).toBe(false)
// Action-level matching works through user overrides, for free.
expect(
keybindingMatchesAction('worktree.quickOpen', { doubleTapModifier: 'Shift' }, 'darwin', {
'worktree.quickOpen': ['DoubleTap+Shift']
})
).toBe(true)
expect(
keybindingMatchesAction('worktree.quickOpen', { doubleTapModifier: 'Alt' }, 'darwin', {
'worktree.quickOpen': ['DoubleTap+Shift']
})
).toBe(false)
})
it('captures double-tap gestures into canonical bindings', () => {
expect(keybindingFromInput({ doubleTapModifier: 'Shift' }, 'darwin')).toEqual({
ok: true,
value: 'DoubleTap+Shift'
})
// The platform primary modifier canonicalizes to Mod, matching normal capture.
expect(keybindingFromInput({ doubleTapModifier: 'Cmd' }, 'darwin')).toEqual({
ok: true,
value: 'DoubleTap+Mod'
})
expect(keybindingFromInput({ doubleTapModifier: 'Ctrl' }, 'win32')).toEqual({
ok: true,
value: 'DoubleTap+Mod'
})
// A non-primary modifier keeps its explicit token.
expect(keybindingFromInput({ doubleTapModifier: 'Ctrl' }, 'darwin')).toEqual({
ok: true,
value: 'DoubleTap+Ctrl'
})
expect(keybindingFromInput({ doubleTapModifier: 'Alt' }, 'linux')).toEqual({
ok: true,
value: 'DoubleTap+Alt'
})
// Ctrl is the primary modifier on Linux too, so it canonicalizes to Mod.
expect(keybindingFromInput({ doubleTapModifier: 'Ctrl' }, 'linux')).toEqual({
ok: true,
value: 'DoubleTap+Mod'
})
// Cmd is not the primary modifier off-mac, so it stays explicit.
expect(keybindingFromInput({ doubleTapModifier: 'Cmd' }, 'linux')).toEqual({
ok: true,
value: 'DoubleTap+Cmd'
})
})
it('formats double-tap bindings as the modifier glyph twice', () => {
expect(formatKeybinding('DoubleTap+Shift', 'darwin')).toEqual(['⇧', '⇧'])
expect(formatKeybinding('DoubleTap+Shift', 'linux')).toEqual(['Shift', 'Shift'])
expect(formatKeybinding('DoubleTap+Mod', 'darwin')).toEqual(['⌘', '⌘'])
expect(formatKeybinding('DoubleTap+Mod', 'win32')).toEqual(['Ctrl', 'Ctrl'])
expect(formatKeybinding('DoubleTap+Cmd', 'win32')).toEqual(['Cmd', 'Cmd'])
expect(formatKeybinding('DoubleTap+Alt', 'darwin')).toEqual(['⌥', '⌥'])
// Ctrl's glyph ⌃ diverges from Mod's ⌘ on Mac, so cover it explicitly.
expect(formatKeybinding('DoubleTap+Ctrl', 'darwin')).toEqual(['⌃', '⌃'])
expect(formatKeybindingList(['DoubleTap+Shift'], 'darwin')).toBe('⇧ ⇧')
expect(formatKeybindingList(['DoubleTap+Shift'], 'linux')).toBe('Shift Shift')
})
it('matches macOS Option-composed bracket shortcuts for all-type tab switching', () => {
const macOptionLeftBracket = {
key: '\u201c',
@ -840,4 +959,52 @@ describe('keybindings', () => {
false
)
})
it('reports conflicts across two double-tap bindings', () => {
// Both actions share the same DoubleTap+Shift binding via overrides, so both
// are in customizedActions and the conflict detector must flag them.
const conflicts = findKeybindingConflicts('darwin', {
'worktree.quickOpen': ['DoubleTap+Shift'],
'view.tasks': ['DoubleTap+Shift']
})
expect(conflicts).toContainEqual({
binding: 'DoubleTap+Shift',
actionIds: expect.arrayContaining(['worktree.quickOpen', 'view.tasks'])
})
})
it('reports conflicts across platform-primary double-tap aliases', () => {
expect(
findKeybindingConflicts('darwin', {
'worktree.quickOpen': ['DoubleTap+Mod'],
'view.tasks': ['DoubleTap+Cmd']
})
).toContainEqual({
binding: 'DoubleTap+Mod',
actionIds: expect.arrayContaining(['worktree.quickOpen', 'view.tasks'])
})
expect(
findKeybindingConflicts('linux', {
'worktree.quickOpen': ['DoubleTap+Mod'],
'view.tasks': ['DoubleTap+Ctrl']
})
).toContainEqual({
binding: 'DoubleTap+Mod',
actionIds: expect.arrayContaining(['worktree.quickOpen', 'view.tasks'])
})
})
it('does not report a conflict when one action lists double-tap aliases for itself', () => {
expect(
findKeybindingConflicts('darwin', {
'worktree.quickOpen': ['DoubleTap+Mod', 'DoubleTap+Cmd']
})
).toEqual([])
expect(
findKeybindingConflicts('linux', {
'worktree.quickOpen': ['DoubleTap+Mod', 'DoubleTap+Ctrl']
})
).toEqual([])
})
})

View File

@ -138,6 +138,9 @@ export type KeybindingDefinition = {
conflictGroup?: string
}
export type ModifierToken = 'Mod' | 'Cmd' | 'Ctrl' | 'Alt' | 'Shift'
export type PhysicalModifierToken = Exclude<ModifierToken, 'Mod'>
export type KeybindingInput = {
key?: string
code?: string
@ -149,6 +152,8 @@ export type KeybindingInput = {
metaKey?: boolean
ctrlKey?: boolean
shiftKey?: boolean
// Set only by the double-tap detector; always a physical token (never 'Mod').
doubleTapModifier?: PhysicalModifierToken
}
type ParsedKeybinding = {
@ -158,6 +163,7 @@ type ParsedKeybinding = {
alt: boolean
shift: boolean
key: string
doubleTapModifier?: ModifierToken
}
type NormalizeKeybindingOptions = {
@ -982,6 +988,84 @@ function normalizeKeyToken(token: string): string | null {
return simple[upper] ?? null
}
function parseModifierToken(rawPart: string): ModifierToken | null {
const part = rawPart.toLowerCase()
if (part === 'mod' || part === 'cmdorctrl' || part === 'commandorcontrol') {
return 'Mod'
}
if (part === 'cmd' || part === 'command' || part === 'meta' || rawPart === '⌘') {
return 'Cmd'
}
if (part === 'ctrl' || part === 'control' || rawPart === '⌃') {
return 'Ctrl'
}
if (part === 'alt' || part === 'option' || part === 'opt' || rawPart === '⌥') {
return 'Alt'
}
if (part === 'shift' || rawPart === '⇧') {
return 'Shift'
}
return null
}
function applyModifierToken(parsed: ParsedKeybinding, modifier: ModifierToken): void {
if (modifier === 'Mod') {
parsed.mod = true
} else if (modifier === 'Cmd') {
parsed.meta = true
} else if (modifier === 'Ctrl') {
parsed.control = true
} else if (modifier === 'Alt') {
parsed.alt = true
} else {
parsed.shift = true
}
}
function emptyParsedKeybinding(): ParsedKeybinding {
return { mod: false, meta: false, control: false, alt: false, shift: false, key: '' }
}
// Why: a double-tap is a bare modifier with no key, so it cannot reuse the
// normal "one key required" parse path; validation of conflicting/extra
// modifiers is deferred to normalizeKeybindingWithOptions for shared errors.
function parseDoubleTapKeybinding(rawParts: string[]): ParsedKeybinding | null {
const modifiers: ModifierToken[] = []
let sawDoubleTap = false
for (const rawPart of rawParts) {
if (rawPart.toLowerCase() === 'doubletap') {
if (sawDoubleTap) {
return null
}
sawDoubleTap = true
continue
}
const modifier = parseModifierToken(rawPart)
if (!modifier) {
return null
}
modifiers.push(modifier)
}
if (modifiers.length === 0) {
return null
}
const parsed = emptyParsedKeybinding()
for (const modifier of modifiers) {
applyModifierToken(parsed, modifier)
}
// Mod combined with a platform-specific modifier: keep both flags so normalize
// emits the shared "Mod or platform-specific, not both" error.
if (parsed.mod && (parsed.meta || parsed.control)) {
parsed.doubleTapModifier = 'Mod'
return parsed
}
if (modifiers.length > 1) {
return null
}
parsed.doubleTapModifier = modifiers[0]
return parsed
}
function parseKeybinding(binding: string): ParsedKeybinding | null {
const rawParts = binding
.split('+')
@ -991,35 +1075,15 @@ function parseKeybinding(binding: string): ParsedKeybinding | null {
return null
}
const parsed: ParsedKeybinding = {
mod: false,
meta: false,
control: false,
alt: false,
shift: false,
key: ''
if (rawParts.some((part) => part.toLowerCase() === 'doubletap')) {
return parseDoubleTapKeybinding(rawParts)
}
const parsed = emptyParsedKeybinding()
for (const rawPart of rawParts) {
const part = rawPart.toLowerCase()
if (part === 'mod' || part === 'cmdorctrl' || part === 'commandorcontrol') {
parsed.mod = true
continue
}
if (part === 'cmd' || part === 'command' || part === 'meta' || rawPart === '⌘') {
parsed.meta = true
continue
}
if (part === 'ctrl' || part === 'control' || rawPart === '⌃') {
parsed.control = true
continue
}
if (part === 'alt' || part === 'option' || part === 'opt' || rawPart === '⌥') {
parsed.alt = true
continue
}
if (part === 'shift' || rawPart === '⇧') {
parsed.shift = true
const modifier = parseModifierToken(rawPart)
if (modifier) {
applyModifierToken(parsed, modifier)
continue
}
if (parsed.key) {
@ -1036,6 +1100,9 @@ function parseKeybinding(binding: string): ParsedKeybinding | null {
}
function canonicalizeParsedKeybinding(parsed: ParsedKeybinding): string {
if (parsed.doubleTapModifier) {
return `DoubleTap+${parsed.doubleTapModifier}`
}
const parts: string[] = []
if (parsed.mod) {
parts.push('Mod')
@ -1086,6 +1153,9 @@ function normalizeKeybindingWithOptions(
if (parsed.mod && (parsed.meta || parsed.control)) {
return { ok: false, error: 'Use either Mod or a platform-specific modifier, not both.' }
}
if (parsed.doubleTapModifier) {
return { ok: true, value: canonicalizeParsedKeybinding(parsed) }
}
const isShiftInsert = parsed.shift && parsed.key === 'Insert'
const isBareAllowed = options.allowBareKeybindings === true && isSafeBareKey(parsed)
if (
@ -1105,6 +1175,10 @@ export function normalizeKeybinding(binding: string): KeybindingValidationResult
return normalizeKeybindingWithOptions(binding)
}
export function isDoubleTapBinding(binding: string): boolean {
return Boolean(parseKeybinding(binding)?.doubleTapModifier)
}
function normalizeKeybindingListWithOptions(
input: string,
options: NormalizeKeybindingOptions = {}
@ -1291,11 +1365,33 @@ function keyTokenFromInput(input: KeybindingInput, platform: NodeJS.Platform): s
return physicalCodeKeyTokenFromInput(input)
}
// Why: the platform primary modifier canonicalizes to Mod, mirroring normal
// capture where Cmd on macOS / Ctrl elsewhere both become Mod.
function canonicalDoubleTapToken(
modifier: PhysicalModifierToken,
platform: NodeJS.Platform
): ModifierToken {
const isMac = platform === 'darwin'
if (modifier === 'Cmd' && isMac) {
return 'Mod'
}
if (modifier === 'Ctrl' && !isMac) {
return 'Mod'
}
return modifier
}
function keybindingFromInputWithOptions(
input: KeybindingInput,
platform: NodeJS.Platform,
options: NormalizeKeybindingOptions = {}
): KeybindingValidationResult {
if (input.doubleTapModifier) {
return normalizeKeybindingWithOptions(
`DoubleTap+${canonicalDoubleTapToken(input.doubleTapModifier, platform)}`,
options
)
}
const key = keyTokenFromInput(input, platform)
if (!key) {
return { ok: false, error: 'Press a key, not only a modifier.' }
@ -1564,6 +1660,24 @@ function keyMatches(
return canUsePhysicalCodeFallback(input) && physicalCodeKeyTokenFromInput(input) === parsedKey
}
function resolveModifierToken(
modifier: ModifierToken,
platform: NodeJS.Platform
): 'meta' | 'control' | 'alt' | 'shift' {
switch (modifier) {
case 'Mod':
return platform === 'darwin' ? 'meta' : 'control'
case 'Cmd':
return 'meta'
case 'Ctrl':
return 'control'
case 'Alt':
return 'alt'
case 'Shift':
return 'shift'
}
}
export function keybindingMatchesInput(
binding: string,
input: KeybindingInput,
@ -1573,11 +1687,31 @@ export function keybindingMatchesInput(
if (!parsed) {
return false
}
// A double-tap binding matches only a synthetic double-tap input, resolved per
// platform; a normal binding never matches a synthetic input, and vice-versa.
if (parsed.doubleTapModifier) {
return (
input.doubleTapModifier !== undefined &&
resolveModifierToken(parsed.doubleTapModifier, platform) ===
resolveModifierToken(input.doubleTapModifier, platform)
)
}
if (input.doubleTapModifier !== undefined) {
return false
}
return (
modifierStateMatches(parsed, input, platform) && keyMatches(parsed.key, input, parsed, platform)
)
}
function keybindingConflictIdentity(binding: string, platform: NodeJS.Platform): string {
const parsed = parseKeybinding(binding)
if (!parsed?.doubleTapModifier) {
return binding
}
return `DoubleTap:${resolveModifierToken(parsed.doubleTapModifier, platform)}`
}
export function keybindingMatchesAction(
actionId: KeybindingActionId,
input: KeybindingInput,
@ -1597,12 +1731,31 @@ export function keybindingMatchesAction(
)
}
function formatModifierGlyph(modifier: ModifierToken, isMac: boolean): string {
switch (modifier) {
case 'Mod':
return isMac ? '⌘' : 'Ctrl'
case 'Cmd':
return isMac ? '⌘' : 'Cmd'
case 'Ctrl':
return isMac ? '⌃' : 'Ctrl'
case 'Alt':
return isMac ? '⌥' : 'Alt'
case 'Shift':
return isMac ? '⇧' : 'Shift'
}
}
export function formatKeybinding(binding: string, platform: NodeJS.Platform): string[] {
const parsed = parseKeybinding(binding)
if (!parsed) {
return [binding]
}
const isMac = platform === 'darwin'
if (parsed.doubleTapModifier) {
const glyph = formatModifierGlyph(parsed.doubleTapModifier, isMac)
return [glyph, glyph]
}
const parts: string[] = []
if (parsed.mod) {
parts.push(isMac ? '⌘' : 'Ctrl')
@ -1631,7 +1784,10 @@ export function formatKeybindingList(
return 'Unassigned'
}
return bindings
.map((binding) => formatKeybinding(binding, platform).join(platform === 'darwin' ? '' : '+'))
.map((binding) => {
const separator = isDoubleTapBinding(binding) ? ' ' : platform === 'darwin' ? '' : '+'
return formatKeybinding(binding, platform).join(separator)
})
.join(', ')
}
@ -1674,7 +1830,7 @@ export function findKeybindingConflicts(
overrides?: KeybindingOverrides,
options: FindKeybindingConflictOptions = {}
): KeybindingConflict[] {
const owners = new Map<string, KeybindingActionId[]>()
const owners = new Map<string, { binding: string; actionIds: Set<KeybindingActionId> }>()
const ignoredActionIds = new Set(options.ignoredActionIds ?? [])
const customizedActions = new Set(
Object.keys(overrides ?? {}).filter(
@ -1694,21 +1850,27 @@ export function findKeybindingConflicts(
groups.add(definition.scope)
}
for (const group of groups) {
const conflictKey = `${group}\u0000${binding}`
const current = owners.get(conflictKey) ?? []
current.push(definition.id)
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)
}
}
}
return Array.from(owners.entries())
.filter(
([, actionIds]) =>
actionIds.length > 1 && actionIds.some((actionId) => customizedActions.has(actionId))
)
.map(([conflictKey, actionIds]) => ({
binding: conflictKey.slice(conflictKey.indexOf('\u0000') + 1),
actionIds
return Array.from(owners.values())
.filter(({ actionIds }) => actionIds.size > 1 && setIntersects(actionIds, customizedActions))
.map(({ binding, actionIds }) => ({
binding,
actionIds: Array.from(actionIds)
}))
}
function setIntersects<T>(left: ReadonlySet<T>, right: ReadonlySet<T>): boolean {
for (const value of left) {
if (right.has(value)) {
return true
}
}
return false
}

View File

@ -0,0 +1,135 @@
import { describe, expect, it } from 'vitest'
import {
ModifierDoubleTapDetector,
modifierFromKeyEvent,
toModifierDoubleTapEvent,
type ModifierDoubleTapEvent
} from './modifier-double-tap-detector'
function down(
modifier: ModifierDoubleTapEvent['modifier'],
overrides: Partial<ModifierDoubleTapEvent> = {}
): ModifierDoubleTapEvent {
return { type: 'keyDown', modifier, isModifierOnly: true, isAutoRepeat: false, ...overrides }
}
function up(
modifier: ModifierDoubleTapEvent['modifier'],
overrides: Partial<ModifierDoubleTapEvent> = {}
): ModifierDoubleTapEvent {
return { type: 'keyUp', modifier, isModifierOnly: true, isAutoRepeat: false, ...overrides }
}
const otherKey: ModifierDoubleTapEvent = {
type: 'keyDown',
modifier: null,
isModifierOnly: false,
isAutoRepeat: false
}
describe('ModifierDoubleTapDetector', () => {
it('emits when the second press lands inside the window', () => {
const d = new ModifierDoubleTapDetector()
expect(d.process(down('Shift'), 0)).toBeNull()
expect(d.process(up('Shift'), 10)).toBeNull()
expect(d.process(down('Shift'), 200)).toEqual({ modifier: 'Shift' })
})
it('does not emit when the second press is past the window', () => {
const d = new ModifierDoubleTapDetector()
d.process(down('Shift'), 0)
d.process(up('Shift'), 10)
expect(d.process(down('Shift'), 400)).toBeNull()
})
it('resets on an intervening non-modifier key', () => {
const d = new ModifierDoubleTapDetector()
d.process(down('Shift'), 0)
d.process(up('Shift'), 10)
expect(d.process(otherKey, 20)).toBeNull()
expect(d.process(down('Shift'), 100)).toBeNull()
})
it('treats a different modifier as a fresh gesture, not a completion', () => {
const d = new ModifierDoubleTapDetector()
d.process(down('Shift'), 0)
d.process(up('Shift'), 10)
// Wrong modifier: no emit, but it begins a new first tap.
expect(d.process(down('Alt'), 100)).toBeNull()
expect(d.process(up('Alt'), 110)).toBeNull()
expect(d.process(down('Alt'), 150)).toEqual({ modifier: 'Alt' })
})
it('does not treat an auto-repeat hold as a tap', () => {
const d = new ModifierDoubleTapDetector()
d.process(down('Shift'), 0)
// Holding the key emits auto-repeat keyDowns — this must cancel the gesture.
expect(d.process(down('Shift', { isAutoRepeat: true }), 30)).toBeNull()
d.process(up('Shift'), 500)
expect(d.process(down('Shift'), 520)).toBeNull()
})
it('does not emit when another modifier is held (isModifierOnly false)', () => {
const d = new ModifierDoubleTapDetector()
expect(d.process(down('Shift', { isModifierOnly: false }), 0)).toBeNull()
d.process(up('Shift'), 10)
expect(d.process(down('Shift'), 100)).toBeNull()
})
it('handles a second keyDown of the same modifier without an intervening keyUp', () => {
const d = new ModifierDoubleTapDetector()
d.process(down('Shift'), 0)
// Missed keyUp — a fresh (non-repeat) keyDown for the same modifier just
// restarts the first tap rather than emitting.
d.process(down('Shift'), 50)
d.process(up('Shift'), 60)
// The next press within the window still completes the gesture.
expect(d.process(down('Shift'), 200)).toEqual({ modifier: 'Shift' })
})
it('clears armed state when the second keydown was suppressed (allowlisted path)', () => {
const d = new ModifierDoubleTapDetector()
d.process(down('Shift'), 0) // first tap down → down1
d.process(up('Shift'), 10) // first tap up → armed
// The main process suppressed the second keydown (an allowlisted action fired
// there), but the second tap's keyup still reaches this detector.
d.process(up('Shift'), 20)
// A later lone Shift press (e.g. typing a capital) must NOT phantom-complete
// a double-tap from the stale armed state.
expect(d.process(down('Shift'), 200)).toBeNull()
})
it('clears state on reset()', () => {
const d = new ModifierDoubleTapDetector()
d.process(down('Shift'), 0)
d.process(up('Shift'), 10)
d.reset()
expect(d.process(down('Shift'), 100)).toBeNull()
})
it('normalizes platform key events', () => {
expect(modifierFromKeyEvent('ShiftLeft', 'Shift')).toBe('Shift')
expect(modifierFromKeyEvent('MetaRight', 'Meta')).toBe('Cmd')
expect(modifierFromKeyEvent('ControlLeft', 'Control')).toBe('Ctrl')
expect(modifierFromKeyEvent('KeyA', 'a')).toBeNull()
expect(
toModifierDoubleTapEvent({ type: 'keyDown', code: 'ShiftLeft', key: 'Shift', shift: true })
).toEqual({ type: 'keyDown', modifier: 'Shift', isModifierOnly: true, isAutoRepeat: false })
// Another modifier held → not a bare modifier event.
expect(
toModifierDoubleTapEvent({
type: 'keyDown',
code: 'ShiftLeft',
key: 'Shift',
shift: true,
meta: true
})
).toMatchObject({ modifier: 'Shift', isModifierOnly: false })
expect(
toModifierDoubleTapEvent({ type: 'keyDown', code: 'KeyA', key: 'a' })
).toMatchObject({ modifier: null, isModifierOnly: false })
})
})

View File

@ -0,0 +1,153 @@
import type { PhysicalModifierToken } from './keybindings'
// Why: max gap between the first release and the second press. Internal — not
// user-configurable — and tight enough that normal fast typing never triggers.
const DOUBLE_TAP_WINDOW_MS = 300
export type ModifierDoubleTapEventType = 'keyDown' | 'keyUp'
// A keyboard event normalized to just what the detector needs.
export type ModifierDoubleTapEvent = {
type: ModifierDoubleTapEventType
// Which physical modifier this event is about, or null for any other key.
modifier: PhysicalModifierToken | null
// True only for a bare modifier press/release with no OTHER modifier held.
isModifierOnly: boolean
isAutoRepeat: boolean
}
export type DetectedDoubleTap = { modifier: PhysicalModifierToken }
export type ModifierKeyEventLike = {
type: ModifierDoubleTapEventType
code?: string
key?: string
shift?: boolean
control?: boolean
alt?: boolean
meta?: boolean
isAutoRepeat?: boolean
}
const MODIFIER_BY_CODE: Record<string, PhysicalModifierToken> = {
ShiftLeft: 'Shift',
ShiftRight: 'Shift',
ControlLeft: 'Ctrl',
ControlRight: 'Ctrl',
AltLeft: 'Alt',
AltRight: 'Alt',
MetaLeft: 'Cmd',
MetaRight: 'Cmd'
}
const MODIFIER_BY_KEY: Record<string, PhysicalModifierToken> = {
Shift: 'Shift',
Control: 'Ctrl',
Alt: 'Alt',
Meta: 'Cmd'
}
// Maps a physical key event to the modifier it represents, or null for any
// non-modifier key. Detector output is always a physical token (never 'Mod').
export function modifierFromKeyEvent(
code: string | undefined,
key: string | undefined
): PhysicalModifierToken | null {
if (code && MODIFIER_BY_CODE[code]) {
return MODIFIER_BY_CODE[code]
}
return key ? (MODIFIER_BY_KEY[key] ?? null) : null
}
function otherModifierHeld(event: ModifierKeyEventLike, modifier: PhysicalModifierToken): boolean {
if (modifier !== 'Shift' && event.shift) {
return true
}
if (modifier !== 'Ctrl' && event.control) {
return true
}
if (modifier !== 'Alt' && event.alt) {
return true
}
if (modifier !== 'Cmd' && event.meta) {
return true
}
return false
}
// Normalizes a platform key event (DOM or Electron) into the detector input.
export function toModifierDoubleTapEvent(event: ModifierKeyEventLike): ModifierDoubleTapEvent {
const modifier = modifierFromKeyEvent(event.code, event.key)
return {
type: event.type,
modifier,
isModifierOnly: modifier !== null && !otherModifierHeld(event, modifier),
isAutoRepeat: Boolean(event.isAutoRepeat)
}
}
type DetectorState =
| { phase: 'idle' }
| { phase: 'down1'; modifier: PhysicalModifierToken }
| { phase: 'armed'; modifier: PhysicalModifierToken; deadlineMs: number }
export class ModifierDoubleTapDetector {
private state: DetectorState = { phase: 'idle' }
process(event: ModifierDoubleTapEvent, timestampMs: number): DetectedDoubleTap | null {
// A non-modifier key, or a modifier chorded with another, breaks the gesture.
// (On keyUp, isModifierOnly:false means another modifier is still held — the
// gesture was already reset at that modifier's keyDown.)
if (event.modifier === null || !event.isModifierOnly) {
this.state = { phase: 'idle' }
return null
}
if (event.type === 'keyUp') {
this.onModifierUp(event.modifier, timestampMs)
return null
}
return this.onModifierDown(event.modifier, event.isAutoRepeat, timestampMs)
}
reset(): void {
this.state = { phase: 'idle' }
}
private onModifierDown(
modifier: PhysicalModifierToken,
isAutoRepeat: boolean,
timestampMs: number
): DetectedDoubleTap | null {
if (
this.state.phase === 'armed' &&
this.state.modifier === modifier &&
!isAutoRepeat &&
timestampMs <= this.state.deadlineMs
) {
this.state = { phase: 'idle' }
return { modifier }
}
// Auto-repeat means the key is being held, not tapped.
if (isAutoRepeat) {
this.state = { phase: 'idle' }
return null
}
// Any other fresh bare-modifier press (re)starts from the first tap.
this.state = { phase: 'down1', modifier }
return null
}
private onModifierUp(modifier: PhysicalModifierToken, timestampMs: number): void {
if (this.state.phase === 'down1' && this.state.modifier === modifier) {
this.state = { phase: 'armed', modifier, deadlineMs: timestampMs + DOUBLE_TAP_WINDOW_MS }
return
}
// Why: a keyup of the armed modifier with no intervening second keydown means
// the second press was consumed elsewhere (the main process suppresses it for
// an allowlisted action). Clear armed so a later lone press of the same
// modifier can't phantom-complete a double-tap.
if (this.state.phase === 'armed' && this.state.modifier === modifier) {
this.state = { phase: 'idle' }
}
}
}

View File

@ -763,4 +763,21 @@ describe('resolveWindowShortcutAction', () => {
false
)
})
it('resolves an allowlisted action from a synthetic double-tap input', () => {
// (a) A synthetic DoubleTap+Shift input resolves the overridden action.
const overrides: KeybindingOverrides = { 'worktree.quickOpen': ['DoubleTap+Shift'] }
expect(
resolveWindowShortcutAction({ doubleTapModifier: 'Shift' }, 'darwin', overrides)
).toEqual({ type: 'openQuickOpen' })
// (b) A different modifier does not resolve it.
expect(
resolveWindowShortcutAction({ doubleTapModifier: 'Alt' }, 'darwin', overrides)
).toBeNull()
// (c) Implicit numeric shortcuts are guarded on input.key, which a double-tap
// input never has, so they cannot accidentally match a double-tap event.
expect(resolveWindowShortcutAction({ doubleTapModifier: 'Cmd' }, 'darwin')).toBeNull()
})
})

View File

@ -6,7 +6,8 @@ import {
normalizeTerminalShortcutPolicy,
type KeybindingActionId,
type KeybindingMatchOptions,
type KeybindingOverrides
type KeybindingOverrides,
type PhysicalModifierToken
} from './keybindings'
export type WindowShortcutInput = {
@ -21,6 +22,9 @@ export type WindowShortcutInput = {
metaKey?: boolean
ctrlKey?: boolean
shiftKey?: boolean
// Set only by the double-tap detector; threads the synthetic input through
// the main-process resolver so allowlisted actions can fire on double-tap.
doubleTapModifier?: PhysicalModifierToken
}
export type WindowShortcutAction =