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',