mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
xterm hands back whole screen rows and trims only the cells that were never written to, so the real spaces a full-screen TUI paints across the unused part of a row count as content and reach the clipboard. Measured against Claude Code in a 282-column pane, single lines arrived carrying 138 trailing spaces, and every line carried the two-space transcript indent as well. Windows Terminal, iTerm2 and GNOME Terminal all trim that for you, decideAutoCopy already calls a wall of spaces "never what the gesture meant", and _selectTouchSelectionLine already treats those cells as padding — the mouse and keyboard paths never had the same rule. CodemanCopySelection.clean lives in constants.js beside decideAutoCopy, its pure sibling. It drops the trailing run from each line, and removes the leading run only where every selected row shares one. A selection of a single row keeps its run, because one row shares nothing with anything and stripping it would silently reindent one line of `git log` body text or one line out of `less`. A drag that began inside a row keeps its partial first line untouched and out of the measurement, which otherwise pins the shared run to zero and leaves every following row indented. Every pass over a line is a scan rather than a regex. `/[ \t]+(\r?)$/` is quadratic on a line whose spaces are followed by a non-space character, which is what right-aligned or centred TUI content looks like: measured over 50 000 rows with a 280-column run it took 2.9s, against 1.3ms for the scan, and a 2 000-column run took 16s. The scan is also the faster of the two on an ordinary padded row. cleanedTerminalSelection in terminal-ui.js is the half that needs the live terminal. It returns a COLUMN selection untouched: Alt+drag makes one, and a rectangle's rows lining up is the point of the gesture, so both halves of the clean would destroy it. xterm exposes the mode nowhere public, so the check reads terminal._core._selectionService, the way this file already reads terminal._core for cell dimensions, and cleans normally if a future xterm renames the field. A test pins that assumption against the library rather than against a stub repeating the literal. The Ctrl+C chord decides on the cleaned selection, not the raw one. A drag across the blank part of a row selects real padding spaces, so the raw text is truthy, and testing it would spend that press on a copy of nothing and make the user press again to interrupt. A padding-only selection is now dropped and the press falls through to the PTY, while Ctrl+Shift+C still never falls through. copyTerminalSelection gates on trim() for the same reason, since a multi-row drag across padding cleans to line breaks alone and a bare newline pasted into a chat composer submits it. All four of the main terminal's copy paths go through it: the Ctrl+C chord, right-click, the phone selection button and Auto Copy. The browser's own Edit menu copy, a disabled copy shortcut and the subagent windows still copy raw rows, as they did before, and the invariants doc now says so rather than claiming every copy is cleaned. Auto Copy resolves its own toggle before it reads the selection, since it is off by default and a selection can run to the 50 000-row scrollback ceiling. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
304 lines
13 KiB
TypeScript
304 lines
13 KiB
TypeScript
/**
|
|
* What a copy actually puts on the clipboard.
|
|
*
|
|
* xterm returns whole screen rows and trims only the cells that were never
|
|
* written to, so a full-screen TUI's padding spaces reach the clipboard and the
|
|
* indent it repeats on every row arrives baked into every line. These tests
|
|
* drive the SHIPPED transform (`CodemanCopySelection.clean` in constants.js),
|
|
* the SHIPPED wiring that decides column mode and the mid-row flag, and both
|
|
* SHIPPED copy paths, because the interesting failures live in the paths rather
|
|
* than in the string handling: a padding-only selection must not silently keep
|
|
* the user's Ctrl+C, and must not put a bare newline on the clipboard.
|
|
*
|
|
* Strategy: constants.js and terminal-ui.js in one vm with a stub CodemanApp,
|
|
* the harness shape test/terminal-auto-copy.test.ts uses. No DOM, no xterm.
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import vm from 'node:vm';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
const publicDir = resolve(import.meta.dirname, '../src/web/public');
|
|
const read = (name: string) => readFileSync(resolve(publicDir, name), 'utf8');
|
|
|
|
function loadHarness() {
|
|
const CodemanApp = function CodemanApp(this: unknown) {};
|
|
const windowRef: Record<string, any> = {};
|
|
const context = vm.createContext({
|
|
window: windowRef,
|
|
document: {
|
|
body: { classList: { contains: () => false } },
|
|
getElementById: () => null,
|
|
querySelector: () => null,
|
|
addEventListener: () => {},
|
|
},
|
|
CodemanApp,
|
|
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn() },
|
|
_crashDiag: { log: vi.fn() },
|
|
requestAnimationFrame: () => 1,
|
|
setTimeout: () => 1,
|
|
Blob: function Blob() {},
|
|
URL: { createObjectURL: () => 'blob:yield', revokeObjectURL: () => {} },
|
|
Worker: function Worker(this: any) {
|
|
this.postMessage = () => {};
|
|
},
|
|
MobileDetection: { isTouchDevice: () => false, getDeviceType: () => 'desktop' },
|
|
KeyboardHandler: { keyboardVisible: false },
|
|
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
|
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
|
});
|
|
|
|
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
|
|
vm.runInContext(read('terminal-ui.js'), context, { filename: 'terminal-ui.js' });
|
|
|
|
const app = new (CodemanApp as unknown as new () => Record<string, any>)();
|
|
const toasts: { message: string; type: string }[] = [];
|
|
app.showToast = (message: string, type: string) => toasts.push({ message, type });
|
|
app._copyText = vi.fn(async () => true);
|
|
app.loadAppSettingsFromStorage = () => ({ autoCopySelection: true });
|
|
|
|
const setSelection = (selection: string, { startX = 0, columnMode = false } = {}) => {
|
|
app.terminal = {
|
|
hasSelection: () => !!selection,
|
|
getSelection: vi.fn(() => selection),
|
|
getSelectionPosition: () => ({ start: { x: startX, y: 0 }, end: { x: 0, y: 1 } }),
|
|
clearSelection: vi.fn(),
|
|
focus: vi.fn(),
|
|
_core: { _selectionService: { _activeSelectionMode: columnMode ? 3 : 0 } },
|
|
};
|
|
return app.terminal;
|
|
};
|
|
|
|
return { app, windowRef, toasts, setSelection };
|
|
}
|
|
|
|
const clean = (text: unknown, startedMidRow = false) =>
|
|
loadHarness().windowRef.CodemanCopySelection.clean(text, { startedMidRow });
|
|
|
|
describe('CodemanCopySelection.clean — trailing padding', () => {
|
|
it('drops the padding a full-screen TUI writes across the rest of each row', () => {
|
|
expect(clean('hello \nworld ')).toBe('hello\nworld');
|
|
});
|
|
|
|
it('drops it from a single-row selection too', () => {
|
|
expect(clean('hello ')).toBe('hello');
|
|
});
|
|
|
|
it('keeps the line endings xterm chose, including the Windows \\r\\n', () => {
|
|
expect(clean('hello \r\nworld \r\n')).toBe('hello\r\nworld\r\n');
|
|
});
|
|
|
|
it('drops trailing tabs as well as trailing spaces', () => {
|
|
expect(clean('hello \t \nworld')).toBe('hello\nworld');
|
|
});
|
|
|
|
it('leaves a line that has no padding untouched', () => {
|
|
expect(clean('hello\nworld')).toBe('hello\nworld');
|
|
});
|
|
});
|
|
|
|
describe('CodemanCopySelection.clean — shared leading indent', () => {
|
|
it('removes the indent every selected row shares', () => {
|
|
expect(clean(' first line\n second line')).toBe('first line\nsecond line');
|
|
});
|
|
|
|
it('keeps the relative indentation of anything nested inside the block', () => {
|
|
expect(clean(' outer\n inner\n outer again')).toBe('outer\n inner\nouter again');
|
|
});
|
|
|
|
it('is a no-op when the rows share no indent, as shell output does not', () => {
|
|
expect(clean('$ ls\n indented output\ndone')).toBe('$ ls\n indented output\ndone');
|
|
});
|
|
|
|
it('is not disabled by a blank row in the middle of the block', () => {
|
|
expect(clean(' first \n \n second ')).toBe('first\n\nsecond');
|
|
});
|
|
|
|
it('does not eat a \\r when the row is blank and the shared indent is wider', () => {
|
|
expect(clean(' first\r\n\r\n second\r\n')).toBe('first\r\n\r\nsecond\r\n');
|
|
});
|
|
|
|
it('treats a leading tab as no indent at all, so nothing is stripped', () => {
|
|
expect(clean('\tfirst\n second')).toBe('\tfirst\n second');
|
|
});
|
|
});
|
|
|
|
describe('CodemanCopySelection.clean — one row keeps its own indent', () => {
|
|
// A single row shares its leading run with nothing, so that run is content.
|
|
// Stripping it would silently reindent one line of `git log` body text or one
|
|
// line read out of `less`.
|
|
it('leaves the indent on a single-row selection', () => {
|
|
expect(clean(' hello world ')).toBe(' hello world');
|
|
});
|
|
|
|
it('leaves it on a single row followed by a blank row', () => {
|
|
expect(clean(' hello world\n ')).toBe(' hello world\n');
|
|
});
|
|
|
|
it('strips as soon as a second row carries content', () => {
|
|
expect(clean(' hello\n world')).toBe('hello\nworld');
|
|
});
|
|
});
|
|
|
|
describe('CodemanCopySelection.clean — a drag that began inside a row', () => {
|
|
it('measures the shared indent without the partial first line', () => {
|
|
expect(clean('That sample is clean.\n the next row continues.', true)).toBe(
|
|
'That sample is clean.\nthe next row continues.'
|
|
);
|
|
});
|
|
|
|
it('leaves the partial first line exactly as it is, indent included', () => {
|
|
expect(clean(' already mid-row\n following row\n deeper row', true)).toBe(
|
|
' already mid-row\nfollowing row\n deeper row'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('CodemanCopySelection.clean — nothing to clean', () => {
|
|
it('returns an empty string for an empty selection', () => {
|
|
expect(clean('')).toBe('');
|
|
});
|
|
|
|
it('returns an empty string rather than throwing on a non-string', () => {
|
|
expect(clean(undefined)).toBe('');
|
|
expect(clean(null)).toBe('');
|
|
});
|
|
|
|
it('reduces an all-padding selection to its line breaks alone', () => {
|
|
// The copy paths reject this with trim(); the transform itself only removes
|
|
// whitespace, so the row structure survives here by design.
|
|
expect(clean(' \n \n ')).toBe('\n\n');
|
|
});
|
|
});
|
|
|
|
describe('cleanedTerminalSelection — wiring', () => {
|
|
it('strips the shared indent when the drag began at column 0', () => {
|
|
const { app, setSelection } = loadHarness();
|
|
setSelection(' first\n second');
|
|
expect(app.cleanedTerminalSelection()).toBe('first\nsecond');
|
|
});
|
|
|
|
it('spares the first line when the drag began inside a row', () => {
|
|
const { app, setSelection } = loadHarness();
|
|
setSelection('first\n second', { startX: 6 });
|
|
expect(app.cleanedTerminalSelection()).toBe('first\nsecond');
|
|
});
|
|
|
|
it('uses the text it is given without reading the selection again', () => {
|
|
const { app, setSelection } = loadHarness();
|
|
// The contract is that `text` IS the live selection, so the stub agrees with
|
|
// it; the assertion that carries weight is that getSelection went unread.
|
|
const terminal = setSelection(' given text \n second row ');
|
|
expect(app.cleanedTerminalSelection(' given text \n second row ')).toBe('given text\nsecond row');
|
|
expect(terminal.getSelection).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns an empty string when there is no selection at all', () => {
|
|
const { app, setSelection } = loadHarness();
|
|
setSelection('');
|
|
expect(app.cleanedTerminalSelection()).toBe('');
|
|
});
|
|
|
|
it('leaves a column selection completely untouched', () => {
|
|
// Alt+drag makes a rectangle, and its rows lining up is the whole point:
|
|
// both halves of the clean would destroy that alignment.
|
|
const { app, setSelection } = loadHarness();
|
|
const rect = ' alpha \n beta \n gamma ';
|
|
setSelection(rect, { startX: 40, columnMode: true });
|
|
expect(app.cleanedTerminalSelection()).toBe(rect);
|
|
});
|
|
});
|
|
|
|
describe('the xterm internals the column check depends on', () => {
|
|
// The column check reads a private field and compares it to a literal, because
|
|
// xterm publishes the selection mode nowhere. A rename or a renumber would make
|
|
// every rectangular selection get cleaned with both rules and lose the column
|
|
// alignment the rule exists to protect, and the fallback is silent by design.
|
|
// So the assumption is pinned against the real library rather than only against
|
|
// a stub that repeats it. lib/xterm.js is the esbuild input for the shipped
|
|
// vendor bundle, so it is the file that decides what runs in the browser.
|
|
const xtermLib = readFileSync(resolve(import.meta.dirname, '../node_modules/@xterm/xterm/lib/xterm.js'), 'utf8');
|
|
|
|
it('still branches on _activeSelectionMode === 3 for a column selection', () => {
|
|
expect(xtermLib).toContain('3===this._activeSelectionMode');
|
|
});
|
|
|
|
it('still reaches that field through _selectionService', () => {
|
|
expect(xtermLib).toContain('_selectionService');
|
|
});
|
|
});
|
|
|
|
describe('copyTerminalSelection — what reaches the clipboard', () => {
|
|
it('copies the cleaned text, never the padded rows', () => {
|
|
const { app, setSelection } = loadHarness();
|
|
setSelection(' first line \n second line ');
|
|
return app.copyTerminalSelection().then((ok: boolean) => {
|
|
expect(ok).toBe(true);
|
|
expect(app._copyText).toHaveBeenCalledWith('first line\nsecond line');
|
|
});
|
|
});
|
|
|
|
it('cleans a realistic TUI block on both rules at once', () => {
|
|
const { app, setSelection } = loadHarness();
|
|
const pane = [' That last point is the important one. ', ' Claude Code writes each paragraph. '].join(
|
|
'\n'
|
|
);
|
|
setSelection(pane);
|
|
return app.copyTerminalSelection().then(() => {
|
|
expect(app._copyText).toHaveBeenCalledWith(
|
|
'That last point is the important one.\nClaude Code writes each paragraph.'
|
|
);
|
|
});
|
|
});
|
|
|
|
it('clears a padding-only selection so Ctrl+C goes back to interrupting', () => {
|
|
// The Ctrl+C gate tests the RAW selection. Leaving a padding-only selection
|
|
// set would make every later Ctrl+C copy nothing instead of interrupting.
|
|
const { app, toasts, setSelection } = loadHarness();
|
|
const terminal = setSelection(' ');
|
|
return app.copyTerminalSelection().then((ok: boolean) => {
|
|
expect(ok).toBe(false);
|
|
expect(app._copyText).not.toHaveBeenCalled();
|
|
expect(terminal.clearSelection).toHaveBeenCalledTimes(1);
|
|
expect(toasts).toEqual([{ message: 'Nothing to copy', type: 'warning' }]);
|
|
});
|
|
});
|
|
|
|
it('rejects a multi-row padding selection instead of copying bare newlines', () => {
|
|
const { app, setSelection } = loadHarness();
|
|
const terminal = setSelection(' \n \n ');
|
|
return app.copyTerminalSelection().then((ok: boolean) => {
|
|
expect(ok).toBe(false);
|
|
expect(app._copyText).not.toHaveBeenCalled();
|
|
expect(terminal.clearSelection).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('_flushAutoCopySelection — cleaned text is what Auto Copy handles', () => {
|
|
it('copies the cleaned text and remembers it for the dedupe', () => {
|
|
const { app, setSelection } = loadHarness();
|
|
setSelection(' first line \n second line ');
|
|
app._autoCopyPending = true;
|
|
return app._flushAutoCopySelection().then(() => {
|
|
expect(app._copyText).toHaveBeenCalledWith('first line\nsecond line');
|
|
expect(app._autoCopyLastText).toBe('first line\nsecond line');
|
|
});
|
|
});
|
|
|
|
it('reads nothing at all while the toggle is off', () => {
|
|
// Auto Copy defaults to OFF, and a selection can run to the scrollback
|
|
// ceiling, so the flush must not read or clean before it checks.
|
|
const { app, setSelection } = loadHarness();
|
|
const terminal = setSelection(' first line \n second line ');
|
|
terminal.getSelection = vi.fn(() => ' first line ');
|
|
app.loadAppSettingsFromStorage = () => ({ autoCopySelection: false });
|
|
app._autoCopyPending = true;
|
|
return app._flushAutoCopySelection().then(() => {
|
|
expect(terminal.getSelection).not.toHaveBeenCalled();
|
|
expect(app._copyText).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|