mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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>
167 lines
6.6 KiB
TypeScript
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');
|
|
});
|
|
});
|