From e46da10bb2da1ff271827c9c392a8c81e7abbfad Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:05:42 +0200 Subject: [PATCH 1/8] fix(shortcuts): handle command palette in terminal --- .../src/renderer/app/workspace.tsx | 2 + .../components/terminal-keyboard-bridge.tsx | 73 ++++++++++ .../browser/terminal-keyboard-bridge.test.tsx | 132 ++++++++++++++++++ apps/emdash-desktop/src/shared/shortcuts.ts | 10 ++ 4 files changed, 217 insertions(+) create mode 100644 apps/emdash-desktop/src/renderer/lib/components/terminal-keyboard-bridge.tsx create mode 100644 apps/emdash-desktop/src/renderer/tests/browser/terminal-keyboard-bridge.test.tsx diff --git a/apps/emdash-desktop/src/renderer/app/workspace.tsx b/apps/emdash-desktop/src/renderer/app/workspace.tsx index 50040ccee3..c462fe3ceb 100644 --- a/apps/emdash-desktop/src/renderer/app/workspace.tsx +++ b/apps/emdash-desktop/src/renderer/app/workspace.tsx @@ -3,6 +3,7 @@ import { CommandShortcutBinder } from '@renderer/lib/commands/command-shortcut-b import { AppKeyboardShortcuts } from '@renderer/lib/components/app-keyboard-shortcuts'; import { BrowserAppShortcutEvents } from '@renderer/lib/components/browser-app-shortcut-events'; import { MonacoKeyboardBridge } from '@renderer/lib/components/monaco-keyboard-bridge'; +import { TerminalKeyboardBridge } from '@renderer/lib/components/terminal-keyboard-bridge'; import { useTheme } from '@renderer/lib/hooks/useTheme'; import { useWorkspaceSlots, @@ -22,6 +23,7 @@ export function Workspace() { + } mainContent={ diff --git a/apps/emdash-desktop/src/renderer/lib/components/terminal-keyboard-bridge.tsx b/apps/emdash-desktop/src/renderer/lib/components/terminal-keyboard-bridge.tsx new file mode 100644 index 0000000000..b0be240760 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/lib/components/terminal-keyboard-bridge.tsx @@ -0,0 +1,73 @@ +import { detectPlatform, normalizeHotkey } from '@tanstack/hotkeys'; +import { useLayoutEffect, useMemo } from 'react'; +import { useAppSettingsKey } from '@renderer/features/settings/use-app-settings-key'; +import { + APP_SHORTCUTS, + getEffectiveHotkey, + type ShortcutSettingsKey, +} from '@renderer/lib/hooks/useKeyboardShortcuts'; +import { dispatchMatchingHotkeys } from '@renderer/lib/hotkeys/dispatch-matching-hotkeys'; + +function isTerminalFocused(): boolean { + return document.activeElement?.closest('.xterm') !== null; +} + +/** + * Fires the small allowlist of app shortcuts flagged `overrideTerminalFocus` + * (e.g. the command palette) when an xterm terminal has focus. + * + * xterm.js maps Ctrl+ combinations to terminal control codes on + * Windows/Linux and calls stopPropagation() on the keydown, so TanStack's + * document-level (bubbling phase) listeners never see them — which is why + * Ctrl+K does nothing from inside a terminal on Windows while Cmd+K works on + * macOS (xterm lets Cmd combos through). This bridge listens at capture phase, + * which runs before xterm's textarea handler, so it cannot be blocked by it. + * + * Only the flagged shortcuts are intercepted; every other key still reaches the + * terminal so essential control keys (Ctrl+C, Ctrl+D, Ctrl+L, …) keep working. + * When no terminal is focused the handler returns immediately and normal + * TanStack bubbling-phase handling takes over unchanged. + */ +export function TerminalKeyboardBridge() { + const { value: keyboard } = useAppSettingsKey('keyboard'); + const overrideHotkeys = useMemo(() => { + const platform = detectPlatform(); + const next = new Set(); + const shortcuts = Object.entries(APP_SHORTCUTS) as [ + ShortcutSettingsKey, + (typeof APP_SHORTCUTS)[ShortcutSettingsKey], + ][]; + + for (const [key, def] of shortcuts) { + if (!def.overrideTerminalFocus) continue; + const hotkey = getEffectiveHotkey(key, keyboard); + if (hotkey !== null) next.add(normalizeHotkey(hotkey, platform)); + } + + return next; + }, [keyboard]); + + useLayoutEffect(() => { + if (overrideHotkeys.size === 0) return; + const platform = detectPlatform(); + + const handler = (e: KeyboardEvent) => { + if (!isTerminalFocused()) return; + + const handled = dispatchMatchingHotkeys(e, { + dispatch: 'first', + filter: (registration) => + overrideHotkeys.has(normalizeHotkey(registration.hotkey, platform)), + }); + + // Prevent the event from reaching xterm and skip the TanStack bubbling + // listener, which would otherwise double-dispatch the same shortcut. + if (handled) e.stopImmediatePropagation(); + }; + + document.addEventListener('keydown', handler, { capture: true }); + return () => document.removeEventListener('keydown', handler, { capture: true }); + }, [overrideHotkeys]); + + return null; +} diff --git a/apps/emdash-desktop/src/renderer/tests/browser/terminal-keyboard-bridge.test.tsx b/apps/emdash-desktop/src/renderer/tests/browser/terminal-keyboard-bridge.test.tsx new file mode 100644 index 0000000000..cf828826d9 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/tests/browser/terminal-keyboard-bridge.test.tsx @@ -0,0 +1,132 @@ +import { detectPlatform, getHotkeyManager, type HotkeyRegistrationHandle } from '@tanstack/hotkeys'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// The bridge reads the command-palette hotkey from app settings; with no +// overrides it falls back to the 'Mod+K' default. +vi.mock('@renderer/features/settings/use-app-settings-key', () => ({ + useAppSettingsKey: () => ({ value: undefined }), +})); + +import { TerminalKeyboardBridge } from '@renderer/lib/components/terminal-keyboard-bridge'; + +// 'Mod' resolves to Cmd on macOS and Ctrl on Windows/Linux. This test is the +// non-mac case (xterm only swallows Ctrl combos), but stay portable so it also +// passes on a macOS CI runner. +const PRIMARY_MODIFIER: 'metaKey' | 'ctrlKey' = detectPlatform() === 'mac' ? 'metaKey' : 'ctrlKey'; + +function pressKey(target: EventTarget, key: string): KeyboardEvent { + const event = new KeyboardEvent('keydown', { + key, + [PRIMARY_MODIFIER]: true, + bubbles: true, + cancelable: true, + }); + target.dispatchEvent(event); + return event; +} + +function flush(): Promise { + return new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + ); +} + +describe('TerminalKeyboardBridge', () => { + let root: Root; + let rootContainer: HTMLDivElement; + let xtermHost: HTMLDivElement; + let xtermInput: HTMLTextAreaElement; + let outsideInput: HTMLInputElement; + const handles: HotkeyRegistrationHandle[] = []; + + function registerHotkey(hotkey: string, callback: () => void): void { + handles.push( + getHotkeyManager().register(hotkey, () => callback(), { + // Disable the manager's own preventDefault/stopPropagation so a + // document bubble-phase spy can observe whether the bridge stopped the + // event during the capture phase. + preventDefault: false, + stopPropagation: false, + }) + ); + } + + beforeEach(async () => { + rootContainer = document.createElement('div'); + document.body.appendChild(rootContainer); + root = createRoot(rootContainer); + root.render(createElement(TerminalKeyboardBridge)); + await flush(); + + xtermHost = document.createElement('div'); + xtermHost.className = 'xterm'; + xtermInput = document.createElement('textarea'); + xtermHost.appendChild(xtermInput); + document.body.appendChild(xtermHost); + + outsideInput = document.createElement('input'); + document.body.appendChild(outsideInput); + }); + + afterEach(() => { + for (const handle of handles.splice(0)) handle.unregister(); + root.unmount(); + rootContainer.remove(); + xtermHost.remove(); + outsideInput.remove(); + vi.restoreAllMocks(); + }); + + it('fires the command palette shortcut when a terminal is focused', async () => { + const paletteSpy = vi.fn(); + const bubbleSpy = vi.fn(); + registerHotkey('Mod+K', paletteSpy); + document.addEventListener('keydown', bubbleSpy); + + xtermInput.focus(); + expect(document.activeElement).toBe(xtermInput); + pressKey(xtermInput, 'k'); + + expect(paletteSpy).toHaveBeenCalledTimes(1); + // The bridge stops propagation so the manager's bubble listener doesn't + // double-dispatch and xterm never consumes the key. + expect(bubbleSpy).not.toHaveBeenCalled(); + + document.removeEventListener('keydown', bubbleSpy); + }); + + it('stays out of the way when no terminal is focused', async () => { + const paletteSpy = vi.fn(); + const bubbleSpy = vi.fn(); + registerHotkey('Mod+K', paletteSpy); + document.addEventListener('keydown', bubbleSpy); + + outsideInput.focus(); + pressKey(outsideInput, 'k'); + + // The bridge is inert: the event propagates normally to document listeners. + expect(paletteSpy).toHaveBeenCalledTimes(1); + expect(bubbleSpy).toHaveBeenCalledTimes(1); + + document.removeEventListener('keydown', bubbleSpy); + }); + + it('lets non-flagged shortcuts reach the terminal so control keys keep working', async () => { + const drawerSpy = vi.fn(); + const bubbleSpy = vi.fn(); + // Mod+J (terminal drawer) is NOT flagged overrideTerminalFocus, so the + // bridge must not intercept it — Ctrl+J stays available to the shell. + registerHotkey('Mod+J', drawerSpy); + document.addEventListener('keydown', bubbleSpy); + + xtermInput.focus(); + pressKey(xtermInput, 'j'); + + // Bridge does not stop the event, so it propagates to the bubble listener. + expect(bubbleSpy).toHaveBeenCalledTimes(1); + + document.removeEventListener('keydown', bubbleSpy); + }); +}); diff --git a/apps/emdash-desktop/src/shared/shortcuts.ts b/apps/emdash-desktop/src/shared/shortcuts.ts index 3cea74e59b..1fe8851c04 100644 --- a/apps/emdash-desktop/src/shared/shortcuts.ts +++ b/apps/emdash-desktop/src/shared/shortcuts.ts @@ -14,6 +14,15 @@ export interface AppShortcutDef { hideFromSettings?: boolean; conflictBehavior?: 'prevent' | 'allow'; ignoreWhenMonacoFocused?: boolean; + /** + * Fire this shortcut even while an xterm terminal is focused. On Windows/Linux + * xterm consumes Ctrl+ combos (mapping them to terminal control codes) and + * stops their propagation, so document-level hotkeys never see them. Flag the + * few app shortcuts that should win over the terminal here; everything else + * keeps reaching the terminal so essential control keys (Ctrl+C/D/L/…) work. + * See `TerminalKeyboardBridge`. + */ + overrideTerminalFocus?: boolean; } export type TabNavigationDirection = 'next' | 'previous'; @@ -101,6 +110,7 @@ export const APP_SHORTCUTS = defineShortcuts({ label: 'Command Palette', description: 'Open the command palette to quickly search and navigate', category: 'Navigation', + overrideTerminalFocus: true, }, settings: { defaultHotkey: 'Mod+,', From 44f71a6ecf7300b90b13550a2c9c6bcc5794b359 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:23:59 +0200 Subject: [PATCH 2/8] test(shortcuts): cover xterm keyboard propagation --- .../browser/terminal-keyboard-bridge.test.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/emdash-desktop/src/renderer/tests/browser/terminal-keyboard-bridge.test.tsx b/apps/emdash-desktop/src/renderer/tests/browser/terminal-keyboard-bridge.test.tsx index cf828826d9..dff02c3047 100644 --- a/apps/emdash-desktop/src/renderer/tests/browser/terminal-keyboard-bridge.test.tsx +++ b/apps/emdash-desktop/src/renderer/tests/browser/terminal-keyboard-bridge.test.tsx @@ -82,7 +82,9 @@ describe('TerminalKeyboardBridge', () => { it('fires the command palette shortcut when a terminal is focused', async () => { const paletteSpy = vi.fn(); const bubbleSpy = vi.fn(); + const xtermSpy = vi.fn((event: KeyboardEvent) => event.stopPropagation()); registerHotkey('Mod+K', paletteSpy); + xtermInput.addEventListener('keydown', xtermSpy); document.addEventListener('keydown', bubbleSpy); xtermInput.focus(); @@ -92,8 +94,10 @@ describe('TerminalKeyboardBridge', () => { expect(paletteSpy).toHaveBeenCalledTimes(1); // The bridge stops propagation so the manager's bubble listener doesn't // double-dispatch and xterm never consumes the key. + expect(xtermSpy).not.toHaveBeenCalled(); expect(bubbleSpy).not.toHaveBeenCalled(); + xtermInput.removeEventListener('keydown', xtermSpy); document.removeEventListener('keydown', bubbleSpy); }); @@ -116,17 +120,23 @@ describe('TerminalKeyboardBridge', () => { it('lets non-flagged shortcuts reach the terminal so control keys keep working', async () => { const drawerSpy = vi.fn(); const bubbleSpy = vi.fn(); + const xtermSpy = vi.fn((event: KeyboardEvent) => event.stopPropagation()); // Mod+J (terminal drawer) is NOT flagged overrideTerminalFocus, so the // bridge must not intercept it — Ctrl+J stays available to the shell. registerHotkey('Mod+J', drawerSpy); + xtermInput.addEventListener('keydown', xtermSpy); document.addEventListener('keydown', bubbleSpy); xtermInput.focus(); pressKey(xtermInput, 'j'); - // Bridge does not stop the event, so it propagates to the bubble listener. - expect(bubbleSpy).toHaveBeenCalledTimes(1); + // Bridge does not stop the event, so xterm receives and consumes it before + // TanStack's document-level bubble listener can dispatch the shortcut. + expect(xtermSpy).toHaveBeenCalledTimes(1); + expect(drawerSpy).not.toHaveBeenCalled(); + expect(bubbleSpy).not.toHaveBeenCalled(); + xtermInput.removeEventListener('keydown', xtermSpy); document.removeEventListener('keydown', bubbleSpy); }); }); From 7c4250a29b7e87fdec4a6a6b299041a8f1085baf Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:32:32 +0200 Subject: [PATCH 3/8] fix(shortcuts): type terminal keyboard test hotkey --- .../tests/browser/terminal-keyboard-bridge.test.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/emdash-desktop/src/renderer/tests/browser/terminal-keyboard-bridge.test.tsx b/apps/emdash-desktop/src/renderer/tests/browser/terminal-keyboard-bridge.test.tsx index dff02c3047..521fb81939 100644 --- a/apps/emdash-desktop/src/renderer/tests/browser/terminal-keyboard-bridge.test.tsx +++ b/apps/emdash-desktop/src/renderer/tests/browser/terminal-keyboard-bridge.test.tsx @@ -1,4 +1,9 @@ -import { detectPlatform, getHotkeyManager, type HotkeyRegistrationHandle } from '@tanstack/hotkeys'; +import { + detectPlatform, + getHotkeyManager, + type HotkeyRegistrationHandle, + type RegisterableHotkey, +} from '@tanstack/hotkeys'; import { createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -41,7 +46,7 @@ describe('TerminalKeyboardBridge', () => { let outsideInput: HTMLInputElement; const handles: HotkeyRegistrationHandle[] = []; - function registerHotkey(hotkey: string, callback: () => void): void { + function registerHotkey(hotkey: RegisterableHotkey, callback: () => void): void { handles.push( getHotkeyManager().register(hotkey, () => callback(), { // Disable the manager's own preventDefault/stopPropagation so a From cd52da4f3e5b90c779da4cc05a9b249c98fa385a Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:36:21 +0200 Subject: [PATCH 4/8] fix(shortcuts): preserve terminal hotkey dispatch behavior --- .../components/terminal-keyboard-bridge.tsx | 7 ++++-- .../browser/terminal-keyboard-bridge.test.tsx | 22 ++++++++++++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/apps/emdash-desktop/src/renderer/lib/components/terminal-keyboard-bridge.tsx b/apps/emdash-desktop/src/renderer/lib/components/terminal-keyboard-bridge.tsx index b0be240760..ba4ed64bb3 100644 --- a/apps/emdash-desktop/src/renderer/lib/components/terminal-keyboard-bridge.tsx +++ b/apps/emdash-desktop/src/renderer/lib/components/terminal-keyboard-bridge.tsx @@ -55,14 +55,17 @@ export function TerminalKeyboardBridge() { if (!isTerminalFocused()) return; const handled = dispatchMatchingHotkeys(e, { - dispatch: 'first', + dispatch: 'all', filter: (registration) => overrideHotkeys.has(normalizeHotkey(registration.hotkey, platform)), }); // Prevent the event from reaching xterm and skip the TanStack bubbling // listener, which would otherwise double-dispatch the same shortcut. - if (handled) e.stopImmediatePropagation(); + if (handled) { + e.preventDefault(); + e.stopImmediatePropagation(); + } }; document.addEventListener('keydown', handler, { capture: true }); diff --git a/apps/emdash-desktop/src/renderer/tests/browser/terminal-keyboard-bridge.test.tsx b/apps/emdash-desktop/src/renderer/tests/browser/terminal-keyboard-bridge.test.tsx index 521fb81939..859d130b40 100644 --- a/apps/emdash-desktop/src/renderer/tests/browser/terminal-keyboard-bridge.test.tsx +++ b/apps/emdash-desktop/src/renderer/tests/browser/terminal-keyboard-bridge.test.tsx @@ -54,6 +54,7 @@ describe('TerminalKeyboardBridge', () => { // event during the capture phase. preventDefault: false, stopPropagation: false, + conflictBehavior: 'allow', }) ); } @@ -94,9 +95,10 @@ describe('TerminalKeyboardBridge', () => { xtermInput.focus(); expect(document.activeElement).toBe(xtermInput); - pressKey(xtermInput, 'k'); + const event = pressKey(xtermInput, 'k'); expect(paletteSpy).toHaveBeenCalledTimes(1); + expect(event.defaultPrevented).toBe(true); // The bridge stops propagation so the manager's bubble listener doesn't // double-dispatch and xterm never consumes the key. expect(xtermSpy).not.toHaveBeenCalled(); @@ -106,6 +108,24 @@ describe('TerminalKeyboardBridge', () => { document.removeEventListener('keydown', bubbleSpy); }); + it('fires every matching override shortcut registration from a focused terminal', async () => { + const firstSpy = vi.fn(); + const secondSpy = vi.fn(); + const xtermSpy = vi.fn((event: KeyboardEvent) => event.stopPropagation()); + registerHotkey('Mod+K', firstSpy); + registerHotkey('Mod+K', secondSpy); + xtermInput.addEventListener('keydown', xtermSpy); + + xtermInput.focus(); + pressKey(xtermInput, 'k'); + + expect(firstSpy).toHaveBeenCalledTimes(1); + expect(secondSpy).toHaveBeenCalledTimes(1); + expect(xtermSpy).not.toHaveBeenCalled(); + + xtermInput.removeEventListener('keydown', xtermSpy); + }); + it('stays out of the way when no terminal is focused', async () => { const paletteSpy = vi.fn(); const bubbleSpy = vi.fn(); From 70396772ce04ed2f1dee878800b13083481bb757 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:21:08 +0200 Subject: [PATCH 5/8] fix(shortcuts): allow task navigation in terminal --- .../browser/terminal-keyboard-bridge.test.tsx | 34 +++++++++++++++++-- apps/emdash-desktop/src/shared/shortcuts.ts | 2 ++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/apps/emdash-desktop/src/renderer/tests/browser/terminal-keyboard-bridge.test.tsx b/apps/emdash-desktop/src/renderer/tests/browser/terminal-keyboard-bridge.test.tsx index 859d130b40..c88c0e0024 100644 --- a/apps/emdash-desktop/src/renderer/tests/browser/terminal-keyboard-bridge.test.tsx +++ b/apps/emdash-desktop/src/renderer/tests/browser/terminal-keyboard-bridge.test.tsx @@ -8,8 +8,8 @@ import { createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -// The bridge reads the command-palette hotkey from app settings; with no -// overrides it falls back to the 'Mod+K' default. +// The bridge reads app-shortcut hotkeys from app settings; with no overrides it +// falls back to the defaults from APP_SHORTCUTS. vi.mock('@renderer/features/settings/use-app-settings-key', () => ({ useAppSettingsKey: () => ({ value: undefined }), })); @@ -21,10 +21,17 @@ import { TerminalKeyboardBridge } from '@renderer/lib/components/terminal-keyboa // passes on a macOS CI runner. const PRIMARY_MODIFIER: 'metaKey' | 'ctrlKey' = detectPlatform() === 'mac' ? 'metaKey' : 'ctrlKey'; -function pressKey(target: EventTarget, key: string): KeyboardEvent { +interface PressKeyOptions { + altKey?: boolean; + shiftKey?: boolean; +} + +function pressKey(target: EventTarget, key: string, options: PressKeyOptions = {}): KeyboardEvent { const event = new KeyboardEvent('keydown', { key, [PRIMARY_MODIFIER]: true, + altKey: options.altKey ?? false, + shiftKey: options.shiftKey ?? false, bubbles: true, cancelable: true, }); @@ -126,6 +133,27 @@ describe('TerminalKeyboardBridge', () => { xtermInput.removeEventListener('keydown', xtermSpy); }); + it('fires task navigation shortcuts when a terminal is focused', async () => { + const nextTaskSpy = vi.fn(); + const prevTaskSpy = vi.fn(); + const xtermSpy = vi.fn((event: KeyboardEvent) => event.stopPropagation()); + registerHotkey('Mod+Alt+ArrowDown', nextTaskSpy); + registerHotkey('Mod+Alt+ArrowUp', prevTaskSpy); + xtermInput.addEventListener('keydown', xtermSpy); + + xtermInput.focus(); + const nextEvent = pressKey(xtermInput, 'ArrowDown', { altKey: true }); + const prevEvent = pressKey(xtermInput, 'ArrowUp', { altKey: true }); + + expect(nextTaskSpy).toHaveBeenCalledTimes(1); + expect(prevTaskSpy).toHaveBeenCalledTimes(1); + expect(nextEvent.defaultPrevented).toBe(true); + expect(prevEvent.defaultPrevented).toBe(true); + expect(xtermSpy).not.toHaveBeenCalled(); + + xtermInput.removeEventListener('keydown', xtermSpy); + }); + it('stays out of the way when no terminal is focused', async () => { const paletteSpy = vi.fn(); const bubbleSpy = vi.fn(); diff --git a/apps/emdash-desktop/src/shared/shortcuts.ts b/apps/emdash-desktop/src/shared/shortcuts.ts index 1fe8851c04..23a9b13a88 100644 --- a/apps/emdash-desktop/src/shared/shortcuts.ts +++ b/apps/emdash-desktop/src/shared/shortcuts.ts @@ -205,6 +205,7 @@ export const APP_SHORTCUTS = defineShortcuts({ description: 'Switch to the next task', category: 'Task View', ignoreWhenMonacoFocused: true, + overrideTerminalFocus: true, }, taskPrev: { defaultHotkey: 'Mod+Alt+ArrowUp', @@ -212,6 +213,7 @@ export const APP_SHORTCUTS = defineShortcuts({ description: 'Switch to the previous task', category: 'Task View', ignoreWhenMonacoFocused: true, + overrideTerminalFocus: true, }, tabClose: { defaultHotkey: 'Mod+W', From 9679f4064185023c8a1c7a57b94d2b7d2de4dab4 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:24:19 +0200 Subject: [PATCH 6/8] fix(shortcuts): preserve app navigation in terminal --- .../browser/terminal-keyboard-bridge.test.tsx | 23 +++++++++++++++++++ apps/emdash-desktop/src/shared/shortcuts.ts | 12 ++++++++++ 2 files changed, 35 insertions(+) diff --git a/apps/emdash-desktop/src/renderer/tests/browser/terminal-keyboard-bridge.test.tsx b/apps/emdash-desktop/src/renderer/tests/browser/terminal-keyboard-bridge.test.tsx index c88c0e0024..e28592b231 100644 --- a/apps/emdash-desktop/src/renderer/tests/browser/terminal-keyboard-bridge.test.tsx +++ b/apps/emdash-desktop/src/renderer/tests/browser/terminal-keyboard-bridge.test.tsx @@ -154,6 +154,29 @@ describe('TerminalKeyboardBridge', () => { xtermInput.removeEventListener('keydown', xtermSpy); }); + it('fires additional app navigation shortcuts when a terminal is focused', async () => { + const settingsSpy = vi.fn(); + const nextTabSpy = vi.fn(); + const filesSpy = vi.fn(); + const xtermSpy = vi.fn((event: KeyboardEvent) => event.stopPropagation()); + registerHotkey('Mod+,', settingsSpy); + registerHotkey('Mod+Alt+ArrowRight', nextTabSpy); + registerHotkey('Mod+Shift+2', filesSpy); + xtermInput.addEventListener('keydown', xtermSpy); + + xtermInput.focus(); + pressKey(xtermInput, ','); + pressKey(xtermInput, 'ArrowRight', { altKey: true }); + pressKey(xtermInput, '2', { shiftKey: true }); + + expect(settingsSpy).toHaveBeenCalledTimes(1); + expect(nextTabSpy).toHaveBeenCalledTimes(1); + expect(filesSpy).toHaveBeenCalledTimes(1); + expect(xtermSpy).not.toHaveBeenCalled(); + + xtermInput.removeEventListener('keydown', xtermSpy); + }); + it('stays out of the way when no terminal is focused', async () => { const paletteSpy = vi.fn(); const bubbleSpy = vi.fn(); diff --git a/apps/emdash-desktop/src/shared/shortcuts.ts b/apps/emdash-desktop/src/shared/shortcuts.ts index 23a9b13a88..c56ef65d35 100644 --- a/apps/emdash-desktop/src/shared/shortcuts.ts +++ b/apps/emdash-desktop/src/shared/shortcuts.ts @@ -117,6 +117,7 @@ export const APP_SHORTCUTS = defineShortcuts({ label: 'Settings', description: 'Open application settings', category: 'Navigation', + overrideTerminalFocus: true, }, library: { defaultHotkey: 'Mod+L', @@ -135,6 +136,7 @@ export const APP_SHORTCUTS = defineShortcuts({ label: 'Toggle Right Sidebar', description: 'Show or hide the right sidebar', category: 'View', + overrideTerminalFocus: true, }, closeModal: { defaultHotkey: 'Escape', @@ -160,6 +162,7 @@ export const APP_SHORTCUTS = defineShortcuts({ label: 'New Project', description: 'Create a new project', category: 'Navigation', + overrideTerminalFocus: true, }, openInEditor: { defaultHotkey: 'Mod+O', @@ -172,18 +175,21 @@ export const APP_SHORTCUTS = defineShortcuts({ label: 'View Changes', description: 'Open the right sidebar to the Changes panel', category: 'Task View', + overrideTerminalFocus: true, }, sidebarConversations: { defaultHotkey: 'Mod+Shift+3', label: 'View Conversations', description: 'Open the right sidebar to the Conversations panel', category: 'Task View', + overrideTerminalFocus: true, }, sidebarFiles: { defaultHotkey: 'Mod+Shift+2', label: 'View Files', description: 'Open the right sidebar to the Files panel', category: 'Task View', + overrideTerminalFocus: true, }, tabNext: { defaultHotkey: 'Mod+Alt+ArrowRight', @@ -191,6 +197,7 @@ export const APP_SHORTCUTS = defineShortcuts({ description: 'Switch to the next tab', category: 'Tab Navigation', conflictBehavior: 'allow', + overrideTerminalFocus: true, }, tabPrev: { defaultHotkey: 'Mod+Alt+ArrowLeft', @@ -198,6 +205,7 @@ export const APP_SHORTCUTS = defineShortcuts({ description: 'Switch to the previous tab', category: 'Tab Navigation', conflictBehavior: 'allow', + overrideTerminalFocus: true, }, taskNext: { defaultHotkey: 'Mod+Alt+ArrowDown', @@ -228,6 +236,7 @@ export const APP_SHORTCUTS = defineShortcuts({ description: 'Reopen the most recently closed tab', category: 'Tab Navigation', conflictBehavior: 'allow', + overrideTerminalFocus: true, }, tabRename: { defaultHotkey: 'Mod+Shift+R', @@ -235,6 +244,7 @@ export const APP_SHORTCUTS = defineShortcuts({ description: 'Rename the active tab (when supported)', category: 'Tab Navigation', conflictBehavior: 'allow', + overrideTerminalFocus: true, }, newConversation: { defaultHotkey: 'Mod+T', @@ -253,12 +263,14 @@ export const APP_SHORTCUTS = defineShortcuts({ label: 'New Terminal', description: 'Create a new terminal in the current task', category: 'Task View', + overrideTerminalFocus: true, }, openBrowser: { defaultHotkey: 'Mod+Shift+B', label: 'Open Browser', description: 'Open an in-app browser in the current task', category: 'Task View', + overrideTerminalFocus: true, }, browserCopyUrl: { defaultHotkey: 'Mod+Shift+C', From 84d8945c69f5065489d726d09fd8847f4f8b2ff0 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:06:03 +0200 Subject: [PATCH 7/8] fix(shortcuts): guard terminal focus check against null activeElement --- .../src/renderer/lib/components/terminal-keyboard-bridge.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/emdash-desktop/src/renderer/lib/components/terminal-keyboard-bridge.tsx b/apps/emdash-desktop/src/renderer/lib/components/terminal-keyboard-bridge.tsx index ba4ed64bb3..9607703d99 100644 --- a/apps/emdash-desktop/src/renderer/lib/components/terminal-keyboard-bridge.tsx +++ b/apps/emdash-desktop/src/renderer/lib/components/terminal-keyboard-bridge.tsx @@ -9,7 +9,7 @@ import { import { dispatchMatchingHotkeys } from '@renderer/lib/hotkeys/dispatch-matching-hotkeys'; function isTerminalFocused(): boolean { - return document.activeElement?.closest('.xterm') !== null; + return Boolean(document.activeElement?.closest('.xterm')); } /** From 3617ef56ff6758e993c8c968ae97c537929f9ff6 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:07:34 +0200 Subject: [PATCH 8/8] fix(shortcuts): guard monaco focus check against null activeElement --- .../src/renderer/lib/components/monaco-keyboard-bridge.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/emdash-desktop/src/renderer/lib/components/monaco-keyboard-bridge.tsx b/apps/emdash-desktop/src/renderer/lib/components/monaco-keyboard-bridge.tsx index 778806a7ba..260b21db8b 100644 --- a/apps/emdash-desktop/src/renderer/lib/components/monaco-keyboard-bridge.tsx +++ b/apps/emdash-desktop/src/renderer/lib/components/monaco-keyboard-bridge.tsx @@ -9,7 +9,7 @@ import { import { dispatchMatchingHotkeys } from '@renderer/lib/hotkeys/dispatch-matching-hotkeys'; function isMonacoFocused(): boolean { - return document.activeElement?.closest('.monaco-editor') !== null; + return Boolean(document.activeElement?.closest('.monaco-editor')); } /**