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/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'));
}
/**
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..9607703d99
--- /dev/null
+++ b/apps/emdash-desktop/src/renderer/lib/components/terminal-keyboard-bridge.tsx
@@ -0,0 +1,76 @@
+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 Boolean(document.activeElement?.closest('.xterm'));
+}
+
+/**
+ * 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: '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.preventDefault();
+ 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..e28592b231
--- /dev/null
+++ b/apps/emdash-desktop/src/renderer/tests/browser/terminal-keyboard-bridge.test.tsx
@@ -0,0 +1,218 @@
+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';
+
+// 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 }),
+}));
+
+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';
+
+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,
+ });
+ 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: RegisterableHotkey, 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,
+ conflictBehavior: 'allow',
+ })
+ );
+ }
+
+ 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();
+ const xtermSpy = vi.fn((event: KeyboardEvent) => event.stopPropagation());
+ registerHotkey('Mod+K', paletteSpy);
+ xtermInput.addEventListener('keydown', xtermSpy);
+ document.addEventListener('keydown', bubbleSpy);
+
+ xtermInput.focus();
+ expect(document.activeElement).toBe(xtermInput);
+ 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();
+ expect(bubbleSpy).not.toHaveBeenCalled();
+
+ xtermInput.removeEventListener('keydown', xtermSpy);
+ 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('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('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();
+ 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();
+ 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 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);
+ });
+});
diff --git a/apps/emdash-desktop/src/shared/shortcuts.ts b/apps/emdash-desktop/src/shared/shortcuts.ts
index 3cea74e59b..c56ef65d35 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,12 +110,14 @@ 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+,',
label: 'Settings',
description: 'Open application settings',
category: 'Navigation',
+ overrideTerminalFocus: true,
},
library: {
defaultHotkey: 'Mod+L',
@@ -125,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',
@@ -150,6 +162,7 @@ export const APP_SHORTCUTS = defineShortcuts({
label: 'New Project',
description: 'Create a new project',
category: 'Navigation',
+ overrideTerminalFocus: true,
},
openInEditor: {
defaultHotkey: 'Mod+O',
@@ -162,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',
@@ -181,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',
@@ -188,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',
@@ -195,6 +213,7 @@ export const APP_SHORTCUTS = defineShortcuts({
description: 'Switch to the next task',
category: 'Task View',
ignoreWhenMonacoFocused: true,
+ overrideTerminalFocus: true,
},
taskPrev: {
defaultHotkey: 'Mod+Alt+ArrowUp',
@@ -202,6 +221,7 @@ export const APP_SHORTCUTS = defineShortcuts({
description: 'Switch to the previous task',
category: 'Task View',
ignoreWhenMonacoFocused: true,
+ overrideTerminalFocus: true,
},
tabClose: {
defaultHotkey: 'Mod+W',
@@ -216,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',
@@ -223,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',
@@ -241,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',