diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts index de9fceaa..b636a323 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts @@ -89,6 +89,37 @@ describe('resolveTerminalShortcutAction', () => { }) }) + it('translates Cmd+←/→ on macOS to readline start/end-of-line (Ctrl+A/E)', () => { + expect( + resolveTerminalShortcutAction( + event({ key: 'ArrowLeft', code: 'ArrowLeft', metaKey: true }), + true + ) + ).toEqual({ type: 'sendInput', data: '\x01' }) + expect( + resolveTerminalShortcutAction( + event({ key: 'ArrowRight', code: 'ArrowRight', metaKey: true }), + true + ) + ).toEqual({ type: 'sendInput', data: '\x05' }) + + // Cmd+Shift+Arrow is a different chord (selection) — don't intercept. + expect( + resolveTerminalShortcutAction( + event({ key: 'ArrowLeft', code: 'ArrowLeft', metaKey: true, shiftKey: true }), + true + ) + ).toBeNull() + + // Non-Mac Ctrl+Arrow must pass through unchanged (readline's word-nav there). + expect( + resolveTerminalShortcutAction( + event({ key: 'ArrowLeft', code: 'ArrowLeft', ctrlKey: true }), + false + ) + ).toBeNull() + }) + it('uses ctrl as the non-mac pane modifier but still requires shift for tab-safe chords', () => { expect( resolveTerminalShortcutAction(event({ key: 'f', code: 'KeyF', ctrlKey: true }), false) diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts index 4c5d5c44..5c36c80c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts @@ -138,6 +138,16 @@ export function resolveTerminalShortcutAction( if (event.key === 'Delete') { return { type: 'sendInput', data: '\x0b' } } + // Why: Cmd+←/→ on macOS conventionally moves to start/end of line in + // terminals (iTerm2, Ghostty). xterm.js has no default mapping for + // Cmd+Arrow, so we translate to readline's Ctrl+A (\x01) / Ctrl+E (\x05), + // which work universally across bash/zsh/fish and most TUI editors. + if (event.key === 'ArrowLeft') { + return { type: 'sendInput', data: '\x01' } + } + if (event.key === 'ArrowRight') { + return { type: 'sendInput', data: '\x05' } + } } if ( diff --git a/tests/e2e/terminal-shortcuts.spec.ts b/tests/e2e/terminal-shortcuts.spec.ts new file mode 100644 index 00000000..093d5ff0 --- /dev/null +++ b/tests/e2e/terminal-shortcuts.spec.ts @@ -0,0 +1,241 @@ +/** + * E2E test for terminal keyboard shortcuts. + * + * Verifies every chord resolved by resolveTerminalShortcutAction end-to-end: + * real DOM keydown → window capture handler → policy → transport → IPC. + * + * sendInput chords are verified by intercepting pty:write in the Electron main + * process so the test proves the bytes actually leave the renderer, without + * depending on the shell's readline behaving identically across OSes. Action + * chords (split, close, search, clear) are verified via their user-visible + * side effect (pane count, search overlay, terminal buffer). + * + * Platform-specific chords (Cmd+Arrow, Cmd+Backspace on macOS only) are + * skipped on the other platform since they'd never fire there at runtime. + */ + +import { test, expect } from './helpers/orca-app' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { + discoverActivePtyId, + execInTerminal, + countVisibleTerminalPanes, + waitForActiveTerminalManager, + waitForTerminalOutput, + waitForPaneCount, + getTerminalContent +} from './helpers/terminal' +import { waitForSessionReady, waitForActiveWorktree, ensureTerminalVisible } from './helpers/store' + +// Why: contextBridge freezes window.api so the renderer cannot spy on +// pty.write directly. Intercept in the main process instead — pty:write is an +// ipcMain.on listener, so prepending a listener lets us capture every call +// without disturbing the real handler. +async function installMainProcessPtyWriteSpy(app: ElectronApplication): Promise { + await app.evaluate(({ ipcMain }) => { + const g = globalThis as unknown as { + __ptyWriteLog?: { id: string; data: string }[] + __ptyWriteSpyInstalled?: boolean + } + if (g.__ptyWriteSpyInstalled) { + return + } + g.__ptyWriteLog = [] + g.__ptyWriteSpyInstalled = true + ipcMain.prependListener('pty:write', (_event: unknown, args: { id: string; data: string }) => { + g.__ptyWriteLog!.push({ id: args.id, data: args.data }) + }) + }) +} + +async function clearPtyWriteLog(app: ElectronApplication): Promise { + await app.evaluate(() => { + const g = globalThis as unknown as { __ptyWriteLog?: { id: string; data: string }[] } + if (g.__ptyWriteLog) { + g.__ptyWriteLog.length = 0 + } + }) +} + +async function getPtyWrites(app: ElectronApplication): Promise { + return app.evaluate(() => { + const g = globalThis as unknown as { __ptyWriteLog?: { id: string; data: string }[] } + return (g.__ptyWriteLog ?? []).map((e) => e.data) + }) +} + +// 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. +async function focusActiveTerminal(page: Page): Promise { + await page.evaluate(() => { + const textarea = document.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement | null + textarea?.focus() + }) +} + +async function pressAndExpectWrite( + page: Page, + app: ElectronApplication, + chord: string, + expectedData: string +): Promise { + await clearPtyWriteLog(app) + await focusActiveTerminal(page) + await page.keyboard.press(chord) + + // Why: assert exact equality, not substring match. Short control codes like + // \x01 (Ctrl+A) and \x05 (Ctrl+E) are single bytes that can appear inside + // unrelated writes (shell prompt redraws, bracketed-paste sequences), so a + // substring match would produce false positives. + await expect + .poll(async () => (await getPtyWrites(app)).some((w) => w === expectedData), { + timeout: 5_000, + message: `Expected chord "${chord}" to write ${JSON.stringify(expectedData)}` + }) + .toBe(true) +} + +const isMac = process.platform === 'darwin' +const mod = isMac ? 'Meta' : 'Control' + +// Why: split chords differ by platform. On macOS Cmd+D splits vertically and +// Cmd+Shift+D horizontally. On Linux/Windows Ctrl+D is reserved for EOF +// (see terminal-shortcut-policy.ts and #586), so vertical is Ctrl+Shift+D +// and horizontal is Alt+Shift+D (Windows Terminal convention). +const splitVerticalChord = isMac ? `${mod}+d` : `${mod}+Shift+d` +const splitHorizontalChord = isMac ? `${mod}+Shift+d` : 'Alt+Shift+d' + +// Why: serial mode is load-bearing. Tests mutate shared Electron app state +// (pane layout, terminal buffer, expand toggle) and the pty:write spy log is +// a single main-process singleton. Parallel execution would interleave chord +// effects and corrupt assertions. +test.describe.configure({ mode: 'serial' }) +test.describe('Terminal Shortcuts', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + const hasPaneManager = await waitForActiveTerminalManager(orcaPage, 30_000) + .then(() => true) + .catch(() => false) + test.skip( + !hasPaneManager, + 'Electron automation in this environment never mounts the live TerminalPane manager.' + ) + await waitForPaneCount(orcaPage, 1, 30_000) + }) + + test('all terminal chords reach the PTY or fire their action', async ({ + orcaPage, + electronApp + }) => { + await installMainProcessPtyWriteSpy(electronApp) + + // Seed the buffer so Cmd+K has something to clear. + const ptyId = await discoverActivePtyId(orcaPage) + const marker = `SHORTCUT_TEST_${Date.now()}` + await execInTerminal(orcaPage, ptyId, `echo ${marker}`) + await waitForTerminalOutput(orcaPage, marker) + + // --- send-input chords (platform-agnostic) --- + + // Alt+←/→ → readline backward-word / forward-word (\eb / \ef). + await pressAndExpectWrite(orcaPage, electronApp, 'Alt+ArrowLeft', '\x1bb') + await pressAndExpectWrite(orcaPage, electronApp, 'Alt+ArrowRight', '\x1bf') + + // Alt+Backspace → Esc+DEL (readline backward-kill-word). + await pressAndExpectWrite(orcaPage, electronApp, 'Alt+Backspace', '\x1b\x7f') + + // Ctrl+Backspace → \x17 (unix-word-rubout). + await pressAndExpectWrite(orcaPage, electronApp, 'Control+Backspace', '\x17') + + // Shift+Enter → CSI-u so agents can distinguish from plain Enter. + await pressAndExpectWrite(orcaPage, electronApp, 'Shift+Enter', '\x1b[13;2u') + + // --- send-input chords (macOS-only) --- + + if (isMac) { + // Cmd+←/→ → Ctrl+A / Ctrl+E (beginning/end of line). + await pressAndExpectWrite(orcaPage, electronApp, 'Meta+ArrowLeft', '\x01') + await pressAndExpectWrite(orcaPage, electronApp, 'Meta+ArrowRight', '\x05') + + // Cmd+Backspace → Ctrl+U (kill line). Cmd+Delete → Ctrl+K (kill to EOL). + await pressAndExpectWrite(orcaPage, electronApp, 'Meta+Backspace', '\x15') + await pressAndExpectWrite(orcaPage, electronApp, 'Meta+Delete', '\x0b') + } + + // --- action chords (no PTY byte; assert via visible effect) --- + + // Cmd/Ctrl+K clears the pane. + await focusActiveTerminal(orcaPage) + await orcaPage.keyboard.press(`${mod}+k`) + await expect + .poll(async () => (await getTerminalContent(orcaPage)).includes(marker), { + timeout: 5_000, + message: 'Cmd+K did not clear the terminal buffer' + }) + .toBe(false) + + // Split vertically (chord varies by platform — see splitVerticalChord). + const panesBeforeSplit = await countVisibleTerminalPanes(orcaPage) + await focusActiveTerminal(orcaPage) + await orcaPage.keyboard.press(splitVerticalChord) + await waitForPaneCount(orcaPage, panesBeforeSplit + 1) + + // Cmd/Ctrl+] and Cmd/Ctrl+[ cycle focus (no pane-count change). + await focusActiveTerminal(orcaPage) + await orcaPage.keyboard.press(`${mod}+BracketRight`) + await focusActiveTerminal(orcaPage) + await orcaPage.keyboard.press(`${mod}+BracketLeft`) + expect(await countVisibleTerminalPanes(orcaPage)).toBe(panesBeforeSplit + 1) + + // Cmd/Ctrl+Shift+Enter toggles expand on the active pane. Requires >1 pane, + // so it runs while the vertical split from above is still open. + const readExpanded = async (): Promise => + orcaPage.evaluate(() => { + const state = window.__store?.getState() + const tabId = state?.activeTabId + if (!state || !tabId) { + return false + } + return state.expandedPaneByTabId[tabId] === true + }) + expect(await readExpanded()).toBe(false) + await focusActiveTerminal(orcaPage) + await orcaPage.keyboard.press(`${mod}+Shift+Enter`) + await expect + .poll(readExpanded, { timeout: 3_000, message: 'Cmd+Shift+Enter did not expand pane' }) + .toBe(true) + await focusActiveTerminal(orcaPage) + await orcaPage.keyboard.press(`${mod}+Shift+Enter`) + await expect + .poll(readExpanded, { timeout: 3_000, message: 'Cmd+Shift+Enter did not collapse pane' }) + .toBe(false) + + // Cmd/Ctrl+W closes the active split pane (not the whole tab: >1 pane). + await focusActiveTerminal(orcaPage) + await orcaPage.keyboard.press(`${mod}+w`) + await waitForPaneCount(orcaPage, panesBeforeSplit) + + // Split horizontally (chord varies by platform — see splitHorizontalChord). + const panesBeforeHSplit = await countVisibleTerminalPanes(orcaPage) + await focusActiveTerminal(orcaPage) + await orcaPage.keyboard.press(splitHorizontalChord) + await waitForPaneCount(orcaPage, panesBeforeHSplit + 1) + await focusActiveTerminal(orcaPage) + await orcaPage.keyboard.press(`${mod}+w`) + await waitForPaneCount(orcaPage, panesBeforeHSplit) + + // Cmd/Ctrl+F toggles the search overlay. + await focusActiveTerminal(orcaPage) + await orcaPage.keyboard.press(`${mod}+f`) + await expect(orcaPage.locator('[data-terminal-search-root]').first()).toBeVisible({ + timeout: 3_000 + }) + await orcaPage.keyboard.press('Escape') + await expect(orcaPage.locator('[data-terminal-search-root]').first()).toBeHidden({ + timeout: 3_000 + }) + }) +})