diff --git a/docs/terminal-shortcuts.md b/docs/terminal-shortcuts.md index c587bb41..c8fd6a93 100644 --- a/docs/terminal-shortcuts.md +++ b/docs/terminal-shortcuts.md @@ -65,4 +65,6 @@ These fall through to xterm.js and the shell regardless of platform: - `Ctrl+Z` — SIGTSTP - `Ctrl+\` — SIGQUIT - `Home` / `End` — start/end of line (xterm's terminfo sequences) -- `Ctrl+R` / `Ctrl+U` / `Ctrl+A` / `Ctrl+E` / `Ctrl+K` / `Ctrl+W` — readline +- `Ctrl+R` / `Ctrl+U` / `Ctrl+A` / `Ctrl+E` — readline + +Note: on Linux/Windows, `Ctrl+K` and `Ctrl+W` ARE intercepted by the app (they are `Mod+K` / `Mod+W` — see the Windows/Linux table above). On macOS, `Mod` is Cmd, so `Ctrl+K` and `Ctrl+W` fall through to readline there. diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-table.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-table.ts index 78fed311..940ba130 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-table.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-table.ts @@ -9,8 +9,12 @@ import type { TerminalShortcutAction } from './terminal-shortcut-policy-types' * - `nonMac: true` → entry applies on Windows/Linux (mod = Ctrl) * - both → platform-agnostic chord * - * Ordering matters: the first matching row wins. More specific chords (those - * requiring Shift) must come before the base variant or they'll never fire. + * Entries are mutually exclusive by construction: `chordMatches` (in + * terminal-shortcut-policy.ts) rejects any event whose modifier set isn't + * exactly what the entry requires, so a base chord (e.g. Mod+D) cannot match + * a Shift-variant event regardless of where it sits in this array. Ordering + * is not load-bearing for correctness — keep entries grouped by feature for + * readability only. * * Chords that require dynamic runtime state (Mac Option-as-Alt composition, * which branches on `optionKeyLocation` and the `macOptionAsAlt` setting) are diff --git a/tests/e2e/terminal-shortcuts.spec.ts b/tests/e2e/terminal-shortcuts.spec.ts index 093d5ff0..3fb79625 100644 --- a/tests/e2e/terminal-shortcuts.spec.ts +++ b/tests/e2e/terminal-shortcuts.spec.ts @@ -64,6 +64,52 @@ async function getPtyWrites(app: ElectronApplication): Promise { }) } +// Why: copySelection writes through the `clipboard:writeText` ipcMain.handle +// channel. Replace the handler (instead of spying on Electron's clipboard +// object) so the test both captures the call and avoids mutating the real +// system clipboard on headful runs. +async function installMainProcessClipboardSpy(app: ElectronApplication): Promise { + await app.evaluate(({ ipcMain }) => { + const g = globalThis as unknown as { + __clipboardTextLog?: string[] + __clipboardSpyInstalled?: boolean + } + if (g.__clipboardSpyInstalled) { + return + } + g.__clipboardTextLog = [] + g.__clipboardSpyInstalled = true + ipcMain.removeHandler('clipboard:writeText') + ipcMain.handle('clipboard:writeText', (_event, text: string) => { + g.__clipboardTextLog!.push(text) + }) + }) +} + +async function getClipboardWrites(app: ElectronApplication): Promise { + return app.evaluate(() => { + const g = globalThis as unknown as { __clipboardTextLog?: string[] } + return g.__clipboardTextLog ?? [] + }) +} + +async function selectAllActiveTerminal(page: Page): Promise { + await page.evaluate(() => { + const paneManagers = window.__paneManagers + if (!paneManagers) { + return + } + const state = window.__store?.getState() + const tabId = state?.activeTabId + if (!tabId) { + return + } + const manager = paneManagers.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + pane?.terminal.selectAll() + }) +} + // Why: the window-level keydown handler is gated on non-editable targets; the // xterm helper textarea is treated as non-editable on purpose. Focusing it // guarantees each chord reaches the shortcut policy through the real DOM path. @@ -131,6 +177,7 @@ test.describe('Terminal Shortcuts', () => { electronApp }) => { await installMainProcessPtyWriteSpy(electronApp) + await installMainProcessClipboardSpy(electronApp) // Seed the buffer so Cmd+K has something to clear. const ptyId = await discoverActivePtyId(orcaPage) @@ -138,6 +185,20 @@ test.describe('Terminal Shortcuts', () => { await execInTerminal(orcaPage, ptyId, `echo ${marker}`) await waitForTerminalOutput(orcaPage, marker) + // --- copy-selection chord (Mod+Shift+C) --- + // Why: must run before Cmd+K clears the buffer; selectAll + Mod+Shift+C + // must route through the shortcut policy and write the selection through + // the clipboard:writeText IPC (which the spy captured). + await selectAllActiveTerminal(orcaPage) + await focusActiveTerminal(orcaPage) + await orcaPage.keyboard.press(`${mod}+Shift+c`) + await expect + .poll(async () => (await getClipboardWrites(electronApp)).some((t) => t.includes(marker)), { + timeout: 5_000, + message: 'Mod+Shift+C did not write the terminal selection to the clipboard' + }) + .toBe(true) + // --- send-input chords (platform-agnostic) --- // Alt+←/→ → readline backward-word / forward-word (\eb / \ef).