diff --git a/apps/desktop/src/lib/keybinds/combo.test.ts b/apps/desktop/src/lib/keybinds/combo.test.ts index 0e30d92534..35165651a6 100644 --- a/apps/desktop/src/lib/keybinds/combo.test.ts +++ b/apps/desktop/src/lib/keybinds/combo.test.ts @@ -137,16 +137,36 @@ describe('formatCombo — honest Control labels', () => { }) describe('actionAllowedInInput', () => { - it('keeps only explicit text-entry-safe global actions active while typing', async () => { + it('keeps primary-modifier chords global while typing, gating bare/Shift combos to the allowlist', async () => { const { actionAllowedInInput } = await loadCombo('MacIntel') + // Mod/Ctrl chords are deliberate two-key gestures — they fire even with + // focus in the composer (⌘N, ⌘T, ⌘⇧N, ⌘K, ⌃Tab, …), matching every browser + // and chat app and the pre-#86586 behavior. + expect(actionAllowedInInput('session.new', 'mod+n')).toBe(true) + expect(actionAllowedInInput('session.newTab', 'mod+t')).toBe(true) + expect(actionAllowedInInput('session.newWindow', 'mod+shift+n')).toBe(true) expect(actionAllowedInInput('session.next', 'ctrl+tab')).toBe(true) expect(actionAllowedInInput('session.prev', 'ctrl+shift+tab')).toBe(true) expect(actionAllowedInInput('nav.commandPalette', 'mod+k')).toBe(true) expect(actionAllowedInInput('view.findInPage', 'mod+f')).toBe(true) - expect(actionAllowedInInput('nav.skills', 'mod+k')).toBe(false) - expect(actionAllowedInInput('view.showTerminal', 'ctrl+`')).toBe(false) - expect(actionAllowedInInput('profile.next', 'mod+shift+]')).toBe(false) + expect(actionAllowedInInput('nav.skills', 'mod+k')).toBe(true) + expect(actionAllowedInInput('view.showTerminal', 'ctrl+`')).toBe(true) + expect(actionAllowedInInput('profile.next', 'mod+shift+]')).toBe(true) + + // A global action rebound onto a text-editing chord (Ctrl+A select-all, + // Ctrl+E line-end, …) fires deliberately while typing — mod-chords are + // two-key gestures, the pre-#86586 semantic. Unbound chords (the shipped + // default for a/e/u/backspace) never reach this function: the dispatcher + // returns before the gate when no action is bound, so the input keeps its + // native editing behavior out of the box. An editing-chord exclusion set + // would have to dodge shipped defaults (⌘K, ⌘W, ⌘D, ⌘F, ⌘B) or re-break them. + expect(actionAllowedInInput('session.new', 'ctrl+a')).toBe(true) + + // Bare/Shift-only combos must never hijack typing: a rebound ⌘N → 'n' + // (or the pre-#76185 'shift+n') must not fire while the user types N. + expect(actionAllowedInInput('session.new', 'n')).toBe(false) + expect(actionAllowedInInput('session.new', 'shift+n')).toBe(false) }) it('leaves text navigation chords with the focused input even when rebound to an allowed action', async () => { diff --git a/apps/desktop/src/lib/keybinds/combo.ts b/apps/desktop/src/lib/keybinds/combo.ts index 4409142cb4..7f091f803b 100644 --- a/apps/desktop/src/lib/keybinds/combo.ts +++ b/apps/desktop/src/lib/keybinds/combo.ts @@ -260,9 +260,13 @@ const INPUT_SAFE_ACTIONS = new Set([ const TEXT_NAVIGATION_KEYS = new Set(['up', 'down', 'left', 'right', 'home', 'end', 'pageup', 'pagedown']) -// Only explicit text-entry-safe actions fire while typing. Editing/navigation -// chords such as Ctrl+Arrow/PageUp must stay with the input even if a user -// rebinds them to a global navigation action. +// Only explicit text-entry-safe actions fire while typing. A primary-modifier +// chord (Cmd/Ctrl) is a deliberate two-key gesture that every browser and chat +// app fires even with focus in a text field (⌘N, ⌘T, ⌘K, ⌃Tab…), so those stay +// global — restoring the pre-#86586 behavior. Editing/navigation chords such +// as Ctrl+Arrow/PageUp must stay with the input even if a user rebinds them to +// a global navigation action, and bare/Shift-only combos (typed letters) are +// gated by the allowlist so they never hijack normal typing. export function actionAllowedInInput(actionId: string, combo: string): boolean { const base = combo.split('+').pop() @@ -270,5 +274,9 @@ export function actionAllowedInInput(actionId: string, combo: string): boolean { return false } + if (/^(?:mod|ctrl)(?:\+|$)/.test(combo)) { + return true + } + return INPUT_SAFE_ACTIONS.has(actionId) }