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'));
}
/**