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 18:25:27 -07:00
committed by GitHub
co-authored by Orca Devin345458 Jinwoo-H
parent 0eec0ad74a
commit 1368ba84b8
34 changed files with 2199 additions and 457 deletions
+180 -145
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