Files
Codeman/test/terminal-copy-selection.test.ts
T
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

148 lines
6.3 KiB
TypeScript

/**
* Smart-copy chord gate (#211).
*
* `Ctrl+C` has to keep meaning "interrupt" whenever nothing is selected, so the
* decision is split in two: `shouldCopyTerminalSelectionFromShortcut()` only
* answers "did this chord ask to copy", and the caller in the xterm custom key
* handler decides what to do when there is no selection. These tests pin the
* gate itself (registry-aware, keydown-only) plus the static invariants that
* keep the generic capture loop from ever swallowing the interrupt.
*
* Strategy: run terminal-ui.js in a vm with a stub CodemanApp, the same harness
* shape test/command-palette-ui.test.ts uses for panels-ui.js. No DOM, no xterm.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
const APP_SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
type Shortcut = {
id: string;
disabled?: boolean;
bindings?: Array<{ modifiers?: string[]; key?: string; code?: string }>;
};
function loadTerminalHarness(registry?: Shortcut[]) {
const CodemanApp = function CodemanApp(this: unknown) {};
const context = vm.createContext({
CodemanApp,
window: {},
document: { getElementById: () => null, querySelector: () => null },
console,
MobileDetection: { isTouchDevice: () => false, getDeviceType: () => 'desktop' },
Object,
});
const terminalUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
vm.runInContext(terminalUi, context, { filename: 'terminal-ui.js' });
const app = new (CodemanApp as unknown as new () => Record<string, any>)();
if (registry) {
app.getShortcutRegistry = () => registry;
// Real implementation, copied by reference from app.js semantics: ctrl/meta are
// interchangeable, every other modifier must be declared by the binding.
app.matchesShortcutEvent = (e: any, shortcut: Shortcut) => {
if (!shortcut || !Array.isArray(shortcut.bindings)) return false;
return shortcut.bindings.some((binding) => {
const mods = binding.modifiers || [];
const wantsPrimary = mods.includes('ctrl') || mods.includes('meta');
if (wantsPrimary !== !!(e.ctrlKey || e.metaKey)) return false;
if (mods.includes('shift') !== !!e.shiftKey) return false;
if (mods.includes('alt') !== !!e.altKey) return false;
if (binding.code && e.code === binding.code) return true;
if (binding.key && typeof e.key === 'string' && e.key.toLowerCase() === binding.key.toLowerCase()) return true;
return false;
});
};
}
return app;
}
const DEFAULT_REGISTRY: Shortcut[] = [
{
id: 'copy-selection',
bindings: [
{ modifiers: ['ctrl'], key: 'c' },
{ modifiers: ['ctrl', 'shift'], key: 'C' },
],
},
];
function keydown(over: Record<string, unknown> = {}) {
return {
type: 'keydown',
key: 'c',
code: 'KeyC',
ctrlKey: true,
shiftKey: false,
altKey: false,
metaKey: false,
...over,
};
}
describe('terminal smart-copy gate', () => {
it('matches the default Ctrl+C and Ctrl+Shift+C chords', () => {
const app = loadTerminalHarness(DEFAULT_REGISTRY);
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown())).toBe(true);
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown({ key: 'C', shiftKey: true }))).toBe(true);
// Cmd+C on macOS: the registry treats ctrl/meta as interchangeable.
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown({ ctrlKey: false, metaKey: true }))).toBe(true);
});
it('ignores plain typing and unrelated chords', () => {
const app = loadTerminalHarness(DEFAULT_REGISTRY);
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown({ ctrlKey: false }))).toBe(false);
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown({ key: 'k', code: 'KeyK' }))).toBe(false);
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown({ key: 'v', code: 'KeyV' }))).toBe(false);
});
it('only decides on keydown (the handler also runs for keypress and keyup)', () => {
const app = loadTerminalHarness(DEFAULT_REGISTRY);
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown({ type: 'keypress' }))).toBe(false);
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown({ type: 'keyup' }))).toBe(false);
expect(app.shouldCopyTerminalSelectionFromShortcut(null)).toBe(false);
});
it('honors a disabled shortcut so Ctrl+C goes back to being the interrupt', () => {
const app = loadTerminalHarness([{ ...DEFAULT_REGISTRY[0], disabled: true }]);
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown())).toBe(false);
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown({ key: 'C', shiftKey: true }))).toBe(false);
});
it('honors a rebound chord and stops claiming the old one', () => {
const app = loadTerminalHarness([{ id: 'copy-selection', bindings: [{ modifiers: ['alt'], key: 'y' }] }]);
expect(
app.shouldCopyTerminalSelectionFromShortcut(keydown({ ctrlKey: false, altKey: true, key: 'y', code: 'KeyY' }))
).toBe(true);
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown())).toBe(false);
});
it('falls back to the default chord when no registry is available', () => {
const app = loadTerminalHarness(); // no getShortcutRegistry / matchesShortcutEvent
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown())).toBe(true);
expect(app.shouldCopyTerminalSelectionFromShortcut(keydown({ key: 'x', code: 'KeyX' }))).toBe(false);
});
});
describe('smart-copy wiring invariants', () => {
it('registers copy-selection in the shortcut registry', () => {
expect(APP_SOURCE).toContain("id: 'copy-selection'");
expect(APP_SOURCE).toContain("action: 'copyTerminalSelection'");
});
it('keeps copyTerminalSelection OUT of SHORTCUT_ACTIONS', () => {
// The generic capture loop preventDefaults on every match it dispatches. If
// the copy action were reachable from there, Ctrl+C would be swallowed with
// no selection and the user would lose the interrupt key.
const actionsBlock = APP_SOURCE.slice(
APP_SOURCE.indexOf('const SHORTCUT_ACTIONS = {'),
APP_SOURCE.indexOf('// Use capture to handle before terminal')
);
expect(actionsBlock.length).toBeGreaterThan(0);
expect(actionsBlock).not.toContain('copyTerminalSelection');
});
});