Skip to content
Open
2 changes: 2 additions & 0 deletions apps/emdash-desktop/src/renderer/app/workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -22,6 +23,7 @@ export function Workspace() {
<BrowserAppShortcutEvents />
<CommandShortcutBinder />
<MonacoKeyboardBridge />
<TerminalKeyboardBridge />
<WorkspaceLayout
leftSidebar={<LeftSidebar />}
mainContent={
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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+<key> 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<string>();
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;
}
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
});
});
Loading
Loading