mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
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>
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user