Files
Codeman/test/terminal-copy-shortcut.test.ts
Codeman maintainer d9123de9eb feat(terminal): Ctrl+C copies the selection, interrupts when nothing is selected
Closes #211. Copying from the terminal only worked through the browser
context menu, because xterm turns Ctrl+C into 0x03 and cancels the keydown,
so the muscle-memory copy failed silently and read as "no copy-paste at all".

With a selection, Ctrl+C now copies it, toasts, clears the selection and
sends nothing to the PTY. With no selection it falls through unchanged, so
the interrupt is intact. Ctrl+Shift+C is an explicit copy chord that never
falls through: an explicit copy that interrupts a running agent because the
selection happened to be empty would be a footgun.

Three details that keep the interrupt safe:

- The decision lives in attachCustomKeyEventHandler (terminal-ui.js) and the
  no-selection path returns true WITHOUT preventDefault. xterm calls the
  custom handler before its own cancel(), so returning false alone does not
  cancel the event; the copy path therefore calls preventDefault explicitly,
  or the browser would run its native copy on top of ours.
- copy-selection is a full registry entry (rebindable and disableable in App
  Settings) whose action is deliberately absent from SHORTCUT_ACTIONS, the
  same trick command-palette uses: the generic capture loop preventDefaults
  every match it dispatches, which would cost the user the interrupt key.
- The gate is keydown-only, since the custom handler also runs for keypress
  and keyup.

Copy goes through _copyText (Clipboard API, then hidden-textarea +
execCommand) rather than raw navigator.clipboard, because install.sh's LAN
option serves plain HTTP where navigator.clipboard is undefined; the
fallback steals focus, so the terminal is refocused afterwards.

Tests: test/terminal-copy-selection.test.ts pins the gate and the
SHORTCUT_ACTIONS invariant; test/terminal-copy-shortcut.test.ts drives real
key presses in chromium and asserts on the clipboard plus the bytes xterm
emitted (browser-driven, so excluded from test:ci like the other Playwright
suites). Verified manually on an isolated beta instance before landing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 02:38:57 +02:00

167 lines
6.6 KiB
TypeScript

/**
* Smart copy in a real browser (#211).
*
* The gate itself is unit-tested in test/terminal-copy-selection.test.ts. What
* can only be proven in a browser is the half that decides whether the PTY sees
* an interrupt: xterm calls the custom key handler BEFORE its own cancel(), so
* returning false does not preventDefault, and a synthetic KeyboardEvent never
* triggers a browser default action. Both facts mean the copy/interrupt split
* has to be driven with real key presses.
*
* Assertions are on real state: what landed on the clipboard, and what xterm
* emitted through onData (the bytes that would reach the PTY).
*
* Browser-driven, so it is excluded from `npm run test:ci` like the other
* Playwright suites. Run locally: npm test -- test/terminal-copy-shortcut.test.ts
*
* Port: 3174 (per MEMORY.md, ports 3150+ for tests)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3174;
const BASE_URL = `http://localhost:${PORT}`;
describe('terminal Ctrl+C smart copy', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
beforeAll(async () => {
server = new WebServer(PORT, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ permissions: ['clipboard-read', 'clipboard-write'] });
page = await context.newPage();
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
// The first write after load can be dropped while the app finishes wiring
// its render pipeline, so poll until one really lands in the buffer.
await page.waitForFunction(
async () => {
const term = (window as any).app.terminal;
await new Promise((r) => term.write('\r\nWARMUP\r\n', r));
const buf = term.buffer.active;
for (let i = 0; i < buf.length; i++) {
if (buf.getLine(i)?.translateToString(true).includes('WARMUP')) return true;
}
return false;
},
null,
{ timeout: 20000, polling: 500 }
);
}, 90000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
}, 60000);
/** Write a marker line, optionally select it, and reset the capture state. */
async function setup(line: string, select: boolean, overrides: Record<string, unknown> = {}) {
await page.evaluate(
async ({ line, select, overrides }) => {
const app = (window as any).app;
const term = app.terminal;
const settings = app.loadAppSettingsFromStorage();
settings.shortcutOverrides = overrides;
app.saveAppSettingsToStorage(settings);
(window as any).__data = [];
if (!(window as any).__dataHooked) {
term.onData((d: string) => (window as any).__data.push(d));
(window as any).__dataHooked = true;
}
await new Promise((r) => term.write('\r\n' + line + '\r\n', r));
term.clearSelection();
if (select) {
const buf = term.buffer.active;
let row = -1;
for (let i = 0; i < buf.length; i++) {
if (buf.getLine(i)?.translateToString(true).includes(line)) row = i;
}
if (row === -1) throw new Error('marker line not found in buffer');
term.select(0, row, line.length);
if (!(term.getSelection() || '').trim()) throw new Error('selection is empty');
}
document.querySelector('.xterm-helper-textarea')!.dispatchEvent(new Event('focus'));
(document.querySelector('.xterm-helper-textarea') as HTMLElement).focus();
await navigator.clipboard.writeText('SENTINEL');
},
{ line, select, overrides }
);
}
async function outcome() {
await page.waitForTimeout(350);
return page.evaluate(async () => ({
data: (window as any).__data as string[],
clipboard: (await navigator.clipboard.readText()).trim(),
hasSelection: (window as any).app.terminal.hasSelection(),
}));
}
it('copies the selection and sends nothing to the PTY', async () => {
await setup('COPY-CASE-SELECTED', true);
await page.keyboard.press('Control+c');
const res = await outcome();
expect(res.clipboard).toBe('COPY-CASE-SELECTED');
expect(res.data).toEqual([]);
expect(res.hasSelection).toBe(false); // cleared, so a second Ctrl+C interrupts
});
it('still interrupts when nothing is selected', async () => {
await setup('COPY-CASE-UNSELECTED', false);
await page.keyboard.press('Control+c');
const res = await outcome();
expect(res.data).toEqual(['\x03']);
expect(res.clipboard).toBe('SENTINEL');
});
it('copies on the explicit Ctrl+Shift+C chord', async () => {
await setup('COPY-CASE-EXPLICIT', true);
await page.keyboard.press('Control+Shift+C');
const res = await outcome();
expect(res.clipboard).toBe('COPY-CASE-EXPLICIT');
expect(res.data).toEqual([]);
});
it('never interrupts on Ctrl+Shift+C with an empty selection', async () => {
await setup('COPY-CASE-EXPLICIT-EMPTY', false);
await page.keyboard.press('Control+Shift+C');
const res = await outcome();
expect(res.data).toEqual([]);
expect(res.clipboard).toBe('SENTINEL');
});
it('restores the plain interrupt when the shortcut is disabled', async () => {
await setup('COPY-CASE-DISABLED', true, { 'copy-selection': { disabled: true } });
await page.keyboard.press('Control+c');
const res = await outcome();
expect(res.data).toEqual(['\x03']);
expect(res.clipboard).toBe('SENTINEL');
});
it('follows a rebind, and Ctrl+C goes back to pure interrupt', async () => {
await setup('COPY-CASE-REBOUND', true, { 'copy-selection': { bindings: [{ modifiers: ['alt'], key: 'y' }] } });
await page.keyboard.press('Alt+y');
const rebound = await outcome();
expect(rebound.clipboard).toBe('COPY-CASE-REBOUND');
expect(rebound.data).toEqual([]);
await setup('COPY-CASE-REBOUND-2', true, { 'copy-selection': { bindings: [{ modifiers: ['alt'], key: 'y' }] } });
await page.keyboard.press('Control+c');
const res = await outcome();
expect(res.data).toEqual(['\x03']);
});
it('leaves Ctrl+V on the paste trap', async () => {
await setup('COPY-CASE-PASTE', false);
await page.keyboard.press('Control+v');
const res = await outcome();
expect(res.data.join('')).toContain('SENTINEL'); // pasted text, not ^V
expect(res.data.join('')).not.toContain('\x16');
});
});