mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 22:49:41 +02:00
Merge pull request #469 from irisitymichaelgrundberg/feat/copy-dedent-pane-margin
feat(terminal): take the transcript gutter off a copy, at the width the CLI declares
This commit is contained in:
@@ -67,9 +67,14 @@ describe('keyboard shortcuts', () => {
|
||||
// selects real padding spaces, so the raw text is truthy and testing it would
|
||||
// spend the press on a copy of nothing — the same lost interrupt this test
|
||||
// guards, reached by a different door.
|
||||
expect(terminalUiSource).toMatch(/const selection = this\.cleanedTerminalSelection\(\);/);
|
||||
//
|
||||
// The RAW selection is what travels on, because copyTerminalSelection cleans
|
||||
// again on its own and the margin strip is not idempotent. Passing the
|
||||
// cleaned string dedented every claude and codex copy twice; see
|
||||
// test/terminal-copy-clean.test.ts for the branch's own pin.
|
||||
expect(terminalUiSource).toMatch(/const selection = this\.cleanedTerminalSelection\(raw\);/);
|
||||
expect(terminalUiSource).toMatch(/if \(selection\.trim\(\)\) \{/);
|
||||
expect(terminalUiSource).toContain('void this.copyTerminalSelection(selection);');
|
||||
expect(terminalUiSource).toContain('void this.copyTerminalSelection(raw);');
|
||||
expect(appSource).toContain("id: 'copy-selection'");
|
||||
});
|
||||
|
||||
|
||||
@@ -96,9 +96,9 @@ describe('WebServer index.html <title> templating (#82)', () => {
|
||||
|
||||
it('only substitutes the <title> tag — the rest of the template is identical (modulo asset cache-busting)', async () => {
|
||||
// renderIndexHtml also appends ?v=<mtime> cache-bust params to same-origin
|
||||
// .js/.css refs, and injects the CLI-availability flags plus the custom-model
|
||||
// Run-menu picker's CLI list before </head>; strip all so the title remains
|
||||
// the only other change.
|
||||
// .js/.css refs, and injects the CLI-availability flags, the custom-model
|
||||
// Run-menu picker's CLI list and the transcript-gutter widths before </head>;
|
||||
// strip all so the title remains the only other change.
|
||||
//
|
||||
// The flag strips are what keep this test environment-independent. The
|
||||
// CLI-availability one used to pass here by luck: that script was injected
|
||||
@@ -109,7 +109,10 @@ describe('WebServer index.html <title> templating (#82)', () => {
|
||||
const html = (await render('laptop'))
|
||||
.replace(/(\.(?:js|css))\?v=[^"]*/g, '$1')
|
||||
.replace(/<script>window\.__codemanCliAvailable=\{.*?\};<\/script>\n/, '')
|
||||
.replace(/<script>window\.__codemanCustomModelClis=\[.*?\];<\/script>\n/, '');
|
||||
.replace(/<script>window\.__codemanCustomModelClis=\[.*?\];<\/script>\n/, '')
|
||||
// Injected unconditionally as an object keyed by run mode, empty when no
|
||||
// enabled CLI declares a gutter, so it needs stripping on every machine.
|
||||
.replace(/<script>window\.__codemanTranscriptGutter=\{.*?\};<\/script>\n/, '');
|
||||
const beforeTitle = rawTemplate.split('<title>Codeman</title>')[0];
|
||||
const afterTitle = rawTemplate.split('<title>Codeman</title>')[1];
|
||||
expect(html.startsWith(beforeTitle)).toBe(true);
|
||||
|
||||
@@ -9,9 +9,11 @@
|
||||
* 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.
|
||||
*
|
||||
* A shared LEADING indent is deliberately left alone. The block below pins that
|
||||
* as a contract rather than an accident, because stripping it was built and
|
||||
* dropped before merge: see the rule in docs/architecture-invariants.md.
|
||||
* A shared LEADING indent is stripped only when a caller has measured a MARGIN
|
||||
* off the pane and passed it in. The transform called with text alone still
|
||||
* touches trailing padding and nothing else, and the block below pins that,
|
||||
* because measuring the indent off the SELECTION was built and dropped before
|
||||
* #451 merged: see the rule in docs/architecture-invariants.md.
|
||||
*
|
||||
* 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.
|
||||
@@ -25,9 +27,19 @@ 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() {
|
||||
/**
|
||||
* The map the server derives from the `transcriptGutter` CAPABILITY and injects
|
||||
* at render, keyed by run mode. Claude is the only stock entry that declares one.
|
||||
*/
|
||||
const STOCK_GUTTERS = { claude: 2, codex: 2 };
|
||||
|
||||
function loadHarness(
|
||||
settingsOverride?: Record<string, unknown>,
|
||||
gutters: Record<string, number> | null = STOCK_GUTTERS
|
||||
) {
|
||||
const CodemanApp = function CodemanApp(this: unknown) {};
|
||||
const windowRef: Record<string, any> = {};
|
||||
if (gutters) windowRef.__codemanTranscriptGutter = gutters;
|
||||
const context = vm.createContext({
|
||||
window: windowRef,
|
||||
document: {
|
||||
@@ -59,13 +71,23 @@ function loadHarness() {
|
||||
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 });
|
||||
app.loadAppSettingsFromStorage = () => ({ autoCopySelection: true, ...(settingsOverride ?? {}) });
|
||||
|
||||
const setSelection = (selection: string, { startX = 0, columnMode = false } = {}) => {
|
||||
// `mode` is what decides the strip: the session's run mode is looked up in the
|
||||
// injected gutter map. No buffer is involved, because the width is declared
|
||||
// rather than measured off the pane. The default is a mode that declares NO
|
||||
// gutter, so a test about the trailing trim keeps its exact meaning, and only a
|
||||
// test asking for `mode: 'claude'` gets a leading strip at all.
|
||||
const setSelection = (
|
||||
selection: string,
|
||||
{ startX = 0, columnMode = false, mode = 'shell', from = 0, to = 1 } = {}
|
||||
) => {
|
||||
app.activeSessionId = 'S1';
|
||||
app.sessions = new Map([['S1', { id: 'S1', mode }]]);
|
||||
app.terminal = {
|
||||
hasSelection: () => !!selection,
|
||||
getSelection: vi.fn(() => selection),
|
||||
getSelectionPosition: () => ({ start: { x: startX, y: 0 }, end: { x: 0, y: 1 } }),
|
||||
getSelectionPosition: vi.fn(() => ({ start: { x: startX, y: from }, end: { x: 0, y: to } })),
|
||||
clearSelection: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
_core: { _selectionService: { _activeSelectionMode: columnMode ? 3 : 0 } },
|
||||
@@ -100,11 +122,12 @@ describe('CodemanCopySelection.clean — trailing padding', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodemanCopySelection.clean — a shared leading indent is kept', () => {
|
||||
describe('CodemanCopySelection.clean — a shared leading indent is kept without a margin', () => {
|
||||
// Measured over 401,445 three-row windows across 1,010 tracked files, stripping
|
||||
// the run every row shares fired on 73% of them, and the transform cannot tell
|
||||
// a TUI margin from content. These are the cases that settled it: each one is
|
||||
// real output a user copies, and each one loses information if this changes.
|
||||
// a TUI margin from content by looking at the selection. These are the cases
|
||||
// that settled it, and each one still loses information if the transform ever
|
||||
// strips an indent nobody measured off the pane.
|
||||
it('keeps the indent every selected row shares', () => {
|
||||
expect(clean(' first line\n second line')).toBe(' first line\n second line');
|
||||
});
|
||||
@@ -176,15 +199,10 @@ describe('cleanedTerminalSelection — wiring', () => {
|
||||
expect(app.cleanedTerminalSelection()).toBe(' first\n second');
|
||||
});
|
||||
|
||||
it('does not read the selection position at all', () => {
|
||||
// The mid-row flag is gone. It read getSelectionPosition().start, which is
|
||||
// xterm's mousedown ANCHOR and is never normalised, so an upward drag read
|
||||
// it off the bottom row of the selection.
|
||||
it('strips nothing for a run mode that declares no gutter', () => {
|
||||
const { app, setSelection } = loadHarness();
|
||||
const terminal = setSelection(' first\n second');
|
||||
terminal.getSelectionPosition = vi.fn(() => ({ start: { x: 6, y: 0 }, end: { x: 0, y: 1 } }));
|
||||
setSelection(' first\n second');
|
||||
expect(app.cleanedTerminalSelection()).toBe(' first\n second');
|
||||
expect(terminal.getSelectionPosition).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the text it is given without reading the selection again', () => {
|
||||
@@ -212,6 +230,318 @@ describe('cleanedTerminalSelection — wiring', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodemanCopySelection.clean — the margin is a ceiling, never the answer', () => {
|
||||
const clean2 = (text: string, options: Record<string, unknown>) =>
|
||||
loadHarness().windowRef.CodemanCopySelection.clean(text, options);
|
||||
|
||||
it('strips a measured margin', () => {
|
||||
expect(clean2(' first\n second', { margin: 2 })).toBe('first\nsecond');
|
||||
});
|
||||
|
||||
it('strips the margin and no more from a block indented past it', () => {
|
||||
// Four of these six columns are the git log body's own, and they stay.
|
||||
expect(clean2(' fix(terminal): trim it\n xterm hands back rows', { margin: 2 })).toBe(
|
||||
' fix(terminal): trim it\n xterm hands back rows'
|
||||
);
|
||||
});
|
||||
|
||||
it('strips nothing when any selected line sits at column 0', () => {
|
||||
// Selecting a marker row along with the prose under it means the block's
|
||||
// own shared indent is zero, and the block shifts as a unit or not at all.
|
||||
expect(clean2('● Creating a job\n Intent. A daily check.', { margin: 2 })).toBe(
|
||||
'● Creating a job\n Intent. A daily check.'
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps every line's indentation relative to every other", () => {
|
||||
const before = ' name: CI\n on:\n push:\n branches: [master]';
|
||||
expect(clean2(before, { margin: 2 })).toBe('name: CI\non:\n push:\n branches: [master]');
|
||||
});
|
||||
|
||||
it('leaves the first line alone when the selection began mid-row', () => {
|
||||
// That line never carried the margin: the mousedown cut it off.
|
||||
expect(clean2('rst line\n second\n third', { margin: 2, firstLinePartial: true })).toBe(
|
||||
'rst line\nsecond\nthird'
|
||||
);
|
||||
});
|
||||
|
||||
it('still trims trailing padding while it dedents', () => {
|
||||
expect(clean2(' first \n \n second ', { margin: 2 })).toBe('first\n\nsecond');
|
||||
});
|
||||
|
||||
it('keeps the Windows line join intact on both blank and content rows', () => {
|
||||
expect(clean2(' first\r\n\r\n second\r\n', { margin: 2 })).toBe('first\r\n\r\nsecond\r\n');
|
||||
});
|
||||
|
||||
it('treats a margin it cannot use as no margin at all', () => {
|
||||
expect(clean2(' first\n second', { margin: 0 })).toBe(' first\n second');
|
||||
expect(clean2(' first\n second', { margin: -4 })).toBe(' first\n second');
|
||||
expect(clean2(' first\n second', { margin: 'two' as never })).toBe(' first\n second');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanedTerminalSelection — the two bugs that kept this out of #451', () => {
|
||||
// Both were real, and both came from the mid-row flag reading the selection's
|
||||
// own geometry to decide how much every row lost. The width is declared by the
|
||||
// CLI now, so neither end of a drag can move it, and the flag governs one line.
|
||||
const ROWS = [
|
||||
' Intent. A daily check tells you when it publishes.',
|
||||
' Scope. One recurring routine and nothing else.',
|
||||
' Risks. Three are worth naming here.',
|
||||
];
|
||||
const body =
|
||||
' Intent. A daily check tells you when it publishes.\n Scope. One recurring routine and nothing else.\n Risks. Three are worth naming here.';
|
||||
const dedented =
|
||||
'Intent. A daily check tells you when it publishes.\nScope. One recurring routine and nothing else.\nRisks. Three are worth naming here.';
|
||||
|
||||
it('survives a reversed range, whatever end xterm reports first', () => {
|
||||
// xterm 6.0 orders the pair itself, verified by driving a real upward drag
|
||||
// through chromium, so this pins the guard rather than a live bug: an
|
||||
// unordered pair would put the mid-row flag on the wrong end of the drag.
|
||||
const from = 401;
|
||||
const down = loadHarness();
|
||||
down.setSelection(body, { mode: 'claude', from, to: from + 2 });
|
||||
const up = loadHarness();
|
||||
up.setSelection(body, { mode: 'claude', from, to: from + 2 });
|
||||
up.app.terminal.getSelectionPosition = () => ({ start: { x: 0, y: from + 2 }, end: { x: 0, y: from } });
|
||||
expect(down.app.cleanedTerminalSelection()).toBe(dedented);
|
||||
expect(up.app.cleanedTerminalSelection()).toBe(dedented);
|
||||
});
|
||||
|
||||
it('reads the mid-row flag off the earlier end of the range, not the later one', () => {
|
||||
// A drag between column 9 on the first row and column 0 on the last leaves
|
||||
// the FIRST line partial. Read off the wrong end the flag says the block is
|
||||
// flush, and that partial line loses two characters of its own content.
|
||||
const { app, setSelection } = loadHarness();
|
||||
setSelection(`nt. A daily check tells you when it publishes.\n${ROWS[1]}`, { mode: 'claude', from: 1, to: 2 });
|
||||
app.terminal.getSelectionPosition = () => ({ start: { x: 0, y: 2 }, end: { x: 9, y: 1 } });
|
||||
expect(app.cleanedTerminalSelection()).toBe(
|
||||
'nt. A daily check tells you when it publishes.\nScope. One recurring routine and nothing else.'
|
||||
);
|
||||
});
|
||||
|
||||
it('gives the rows below the first one the same result whatever column the mousedown hit', () => {
|
||||
// Three rows used to produce three different clipboards depending on where
|
||||
// the click landed, which the user never sees. Only the partial first line
|
||||
// may differ now, and it differs because it is different text.
|
||||
const tails = [0, 1, 2, 7].map((startX) => {
|
||||
const h = loadHarness();
|
||||
h.setSelection(`${ROWS[0].slice(startX)}\n${ROWS[1]}\n${ROWS[2]}`, { mode: 'claude', from: 1, to: 3, startX });
|
||||
return h.app.cleanedTerminalSelection().split('\n').slice(1).join('\n');
|
||||
});
|
||||
expect(new Set(tails).size).toBe(1);
|
||||
expect(tails[0]).toBe('Scope. One recurring routine and nothing else.\nRisks. Three are worth naming here.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanedTerminalSelection — the cases the margin has to get right', () => {
|
||||
it('takes the gutter off Claude Code prose, which is what people copy', () => {
|
||||
const { app, setSelection } = loadHarness();
|
||||
setSelection(
|
||||
' Intent. A daily check tells you when it publishes.\n Scope. One recurring routine on your account.',
|
||||
{ mode: 'claude', from: 1, to: 2 }
|
||||
);
|
||||
expect(app.cleanedTerminalSelection()).toBe(
|
||||
'Intent. A daily check tells you when it publishes.\nScope. One recurring routine on your account.'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a git log body at its four-space indent inside an agent gutter', () => {
|
||||
// This is the case that kept the painted-padding gate out on its own: the
|
||||
// pane is a TUI, so that gate says yes, and the body shares six columns.
|
||||
const { app, setSelection } = loadHarness();
|
||||
setSelection(
|
||||
' fix(terminal): trim the padding a TUI paints\n xterm hands back whole rows and trims only the\n cells nothing ever wrote to.',
|
||||
{ mode: 'claude', from: 4, to: 6 }
|
||||
);
|
||||
expect(app.cleanedTerminalSelection()).toBe(
|
||||
' fix(terminal): trim the padding a TUI paints\n xterm hands back whole rows and trims only the\n cells nothing ever wrote to.'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps YAML nesting inside an agent gutter', () => {
|
||||
const { app, setSelection } = loadHarness();
|
||||
setSelection(' build:\n steps:\n - run: npm ci', { mode: 'claude', from: 2, to: 4 });
|
||||
expect(app.cleanedTerminalSelection()).toBe(' build:\n steps:\n - run: npm ci');
|
||||
});
|
||||
|
||||
it('leaves indented Python alone in a shell pane, where the indent is semantic', () => {
|
||||
const { app, setSelection } = loadHarness();
|
||||
setSelection(' if event.ready:\n run(event)', { from: 2, to: 3 });
|
||||
expect(app.cleanedTerminalSelection()).toBe(' if event.ready:\n run(event)');
|
||||
});
|
||||
|
||||
it('leaves git diff context rows alone, where the leading space is the marker', () => {
|
||||
const { app, setSelection } = loadHarness();
|
||||
setSelection(' const x = 1;\n const y = 2;\n }', { from: 2, to: 4 });
|
||||
expect(app.cleanedTerminalSelection()).toBe(' const x = 1;\n const y = 2;\n }');
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyStripMargin — the per-device toggle', () => {
|
||||
const body = ' Intent. A daily check tells you when it publishes.\n Scope. One recurring routine and nothing else.';
|
||||
const select = (h: ReturnType<typeof loadHarness>) => h.setSelection(body, { mode: 'claude', from: 1, to: 2 });
|
||||
|
||||
it('strips the margin when the device has never stored a value, because it defaults ON', () => {
|
||||
const h = loadHarness({});
|
||||
select(h);
|
||||
expect(h.app.cleanedTerminalSelection()).toBe(
|
||||
'Intent. A daily check tells you when it publishes.\nScope. One recurring routine and nothing else.'
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the margin alone when the device turned it off', () => {
|
||||
const h = loadHarness({ copyStripMargin: false });
|
||||
select(h);
|
||||
expect(h.app.cleanedTerminalSelection()).toBe(body);
|
||||
});
|
||||
|
||||
it('still trims trailing padding while the strip is off', () => {
|
||||
const h = loadHarness({ copyStripMargin: false });
|
||||
h.setSelection(' first \n second ', { mode: 'claude', from: 1, to: 2 });
|
||||
expect(h.app.cleanedTerminalSelection()).toBe(' first\n second');
|
||||
});
|
||||
|
||||
it('never consults the gutter map while it is off', () => {
|
||||
// The lookup is the whole cost now, and it runs on every Ctrl+C, so the
|
||||
// toggle is checked first. There is no buffer to read: the width is declared.
|
||||
const h = loadHarness({ copyStripMargin: false });
|
||||
select(h);
|
||||
expect(h.app._cliGutterColumns()).toBe(0);
|
||||
expect(h.app.cleanedTerminalSelection()).toBe(body);
|
||||
});
|
||||
|
||||
it('treats an unreadable settings store as ON, matching the default', () => {
|
||||
const h = loadHarness();
|
||||
h.app.loadAppSettingsFromStorage = () => {
|
||||
throw new Error('localStorage unavailable');
|
||||
};
|
||||
select(h);
|
||||
expect(h.app.cleanedTerminalSelection()).toBe(
|
||||
'Intent. A daily check tells you when it publishes.\nScope. One recurring routine and nothing else.'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the toggle per-device: display key, stripped from the PUT, absent from the schema', () => {
|
||||
const settingsUi = read('settings-ui.js');
|
||||
const schemas = readFileSync(resolve(import.meta.dirname, '../src/web/schemas.ts'), 'utf8');
|
||||
const displayKeys = settingsUi.slice(
|
||||
settingsUi.indexOf('const displayKeys = new Set(['),
|
||||
settingsUi.indexOf('])', settingsUi.indexOf('const displayKeys = new Set(['))
|
||||
);
|
||||
expect(displayKeys).toContain("'copyStripMargin'");
|
||||
// SettingsUpdateSchema is .strict(), so a key it does not declare 400s the
|
||||
// whole settings PUT if the client sends it.
|
||||
expect(settingsUi).toContain('copyStripMargin: _csm,');
|
||||
expect(schemas).not.toContain('copyStripMargin');
|
||||
});
|
||||
|
||||
it('keeps the control loadable and savable by id', () => {
|
||||
const settingsUi = read('settings-ui.js');
|
||||
expect(read('index.html')).toContain('id="appSettingsCopyStripMargin"');
|
||||
// `!== false`, because this one defaults ON and the desktop branch of
|
||||
// getDefaultSettings returns {}.
|
||||
expect(settingsUi).toContain(
|
||||
"document.getElementById('appSettingsCopyStripMargin').checked = settings.copyStripMargin !== false;"
|
||||
);
|
||||
expect(settingsUi).toContain("copyStripMargin: document.getElementById('appSettingsCopyStripMargin').checked,");
|
||||
});
|
||||
});
|
||||
|
||||
describe('the gutter is DECLARED by the CLI, never measured off the pane', () => {
|
||||
const body = ' Intent. A daily check tells you when it publishes.\n Scope. One recurring routine.';
|
||||
const flush = 'Intent. A daily check tells you when it publishes.\nScope. One recurring routine.';
|
||||
|
||||
it('takes the declared width off a mode that declares one', () => {
|
||||
const { app, setSelection } = loadHarness();
|
||||
setSelection(body, { mode: 'claude', from: 1, to: 2 });
|
||||
expect(app._cliGutterColumns()).toBe(2);
|
||||
expect(app.cleanedTerminalSelection()).toBe(flush);
|
||||
});
|
||||
|
||||
it('takes the two columns off Codex as well, and keeps the nesting under them', () => {
|
||||
// Measured on a live codex-cli 0.154.0 answer: its •/›/⚠ markers sit in the
|
||||
// gutter, prose continuations sit at 2, and a nested YAML block the model
|
||||
// wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at six widths the
|
||||
// indents were 0, 2, 4, 6 and 8 at every one, never 1.
|
||||
const { app, setSelection } = loadHarness();
|
||||
setSelection(' terminal:\n pane:\n gutter:\n width: 1', { mode: 'codex', from: 1, to: 4 });
|
||||
expect(app._cliGutterColumns()).toBe(2);
|
||||
expect(app.cleanedTerminalSelection()).toBe('terminal:\n pane:\n gutter:\n width: 1');
|
||||
});
|
||||
|
||||
it('leaves a mode nobody has measured alone, because it declares none', () => {
|
||||
const { app, setSelection } = loadHarness();
|
||||
setSelection(' build:\n steps:', { mode: 'opencode', from: 1, to: 2 });
|
||||
expect(app._cliGutterColumns()).toBe(0);
|
||||
expect(app.cleanedTerminalSelection()).toBe(' build:\n steps:');
|
||||
});
|
||||
|
||||
it('leaves a shell alone for the same reason', () => {
|
||||
const { app, setSelection } = loadHarness();
|
||||
setSelection(' if event.ready:\n run(event)', { mode: 'shell', from: 1, to: 2 });
|
||||
expect(app.cleanedTerminalSelection()).toBe(' if event.ready:\n run(event)');
|
||||
});
|
||||
|
||||
it('strips nothing at all when the server injected no map', () => {
|
||||
// A page served before the capability existed, or a render path that skips
|
||||
// the injection: no session gets a strip rather than every session guessing.
|
||||
const { app, setSelection } = loadHarness(undefined, null);
|
||||
setSelection(body, { mode: 'claude', from: 1, to: 2 });
|
||||
expect(app._cliGutterColumns()).toBe(0);
|
||||
expect(app.cleanedTerminalSelection()).toBe(body);
|
||||
});
|
||||
|
||||
it('ignores a width that is not a positive whole number', () => {
|
||||
for (const bad of [0, -2, 2.5, '2', null] as unknown[]) {
|
||||
const { app, setSelection } = loadHarness(undefined, { claude: bad } as Record<string, number>);
|
||||
setSelection(body, { mode: 'claude', from: 1, to: 2 });
|
||||
expect(app._cliGutterColumns()).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('reads no terminal buffer on the copy path at all', () => {
|
||||
// The old version scanned up to ~240 rows per Ctrl+C to measure a width that
|
||||
// the CLI can simply state. A buffer here would be a regression to that.
|
||||
const { app, setSelection } = loadHarness();
|
||||
const terminal = setSelection(body, { mode: 'claude', from: 1, to: 2 });
|
||||
const getLine = vi.fn(() => undefined);
|
||||
terminal.buffer = { active: { length: 0, getLine } };
|
||||
expect(app.cleanedTerminalSelection()).toBe(flush);
|
||||
expect(getLine).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the transcriptGutter capability, as the registry and server carry it', () => {
|
||||
const registryDir = resolve(import.meta.dirname, '../src/config/cli-registry');
|
||||
const readSrc = (n: string) => readFileSync(resolve(registryDir, n), 'utf8');
|
||||
|
||||
it('is a bounded integer in the schema, so a clis.json cannot declare a huge one', () => {
|
||||
expect(readSrc('schema.ts')).toContain('transcriptGutter: z.number().int().min(1).max(8).optional()');
|
||||
});
|
||||
|
||||
it('is declared by claude and codex, and by nothing else in the stock registry', () => {
|
||||
const stock = readSrc('stock.ts');
|
||||
expect(stock.match(/transcriptGutter: 2,/g)).toHaveLength(2);
|
||||
// Exactly the two whose transcript layout has been measured on a live pane.
|
||||
expect(stock.match(/transcriptGutter:/g)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('reaches the page off the capability rather than as an id list', () => {
|
||||
const server = readFileSync(resolve(import.meta.dirname, '../src/web/server.ts'), 'utf8');
|
||||
expect(server).toContain('entry.capabilities.transcriptGutter');
|
||||
expect(server).toContain('window.__codemanTranscriptGutter=');
|
||||
// The frontend looks the mode up in that map; the helper holds no id itself.
|
||||
const terminalUi = read('terminal-ui.js');
|
||||
const helper = terminalUi.slice(
|
||||
terminalUi.indexOf('_cliGutterColumns(sessionId) {'),
|
||||
terminalUi.indexOf('async copyTerminalSelection')
|
||||
);
|
||||
expect(helper).toContain('window.__codemanTranscriptGutter');
|
||||
expect(helper).not.toMatch(/'claude'/);
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
@@ -276,6 +606,56 @@ describe('copyTerminalSelection — what reaches the clipboard', () => {
|
||||
expect(terminal.clearSelection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Every case above runs on the harness default mode, which declares no gutter,
|
||||
// so none of them can see a margin stripped twice. These two run on a mode that
|
||||
// declares one.
|
||||
it('takes the declared width off a claude pane exactly once', () => {
|
||||
const { app, setSelection } = loadHarness();
|
||||
setSelection(' fix(terminal): trim it', { mode: 'claude', from: 1, to: 2 });
|
||||
return app.copyTerminalSelection().then(() => {
|
||||
// 2 gutter columns off a body that carries 4 of its own.
|
||||
expect(app._copyText).toHaveBeenCalledWith(' fix(terminal): trim it');
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a nested block's own indentation on a claude pane", () => {
|
||||
const { app, setSelection } = loadHarness();
|
||||
setSelection(' build:\n steps:\n - run: npm ci', { mode: 'claude', from: 1, to: 4 });
|
||||
return app.copyTerminalSelection().then(() => {
|
||||
expect(app._copyText).toHaveBeenCalledWith(' build:\n steps:\n - run: npm ci');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('the margin strip is not idempotent, so no caller may clean twice', () => {
|
||||
// The trailing trim is a fixed point, and copyTerminalSelection leaned on that
|
||||
// by re-cleaning whatever it was handed. The margin strip broke it: it takes
|
||||
// the narrower of the declared width and the run every line shares, so a
|
||||
// second pass takes up to `margin` columns more. Ctrl+C cleaned to decide
|
||||
// whether to copy and then passed the CLEANED string on, which dedented every
|
||||
// claude and codex copy twice on the most-used copy path of the four.
|
||||
it('takes more off a block that has already been stripped', () => {
|
||||
const h = loadHarness();
|
||||
const clean = h.windowRef.CodemanCopySelection.clean;
|
||||
const once = clean(' fix(terminal): trim it', { margin: 2 });
|
||||
expect(once).toBe(' fix(terminal): trim it');
|
||||
expect(clean(once, { margin: 2 })).toBe(' fix(terminal): trim it');
|
||||
});
|
||||
|
||||
it('is pinned in the Ctrl+C branch, which gates on the clean and copies the raw', () => {
|
||||
// The branch lives inside initTerminal's attachCustomKeyEventHandler closure,
|
||||
// over a real xterm this harness cannot build, so the rule is pinned at the
|
||||
// source rather than driven by a keystroke.
|
||||
const terminalUi = read('terminal-ui.js');
|
||||
const branch = terminalUi.slice(
|
||||
terminalUi.indexOf('if (this.shouldCopyTerminalSelectionFromShortcut?.(ev)) {'),
|
||||
terminalUi.indexOf('// Session-sidebar toggle chord')
|
||||
);
|
||||
expect(branch).toContain('const selection = this.cleanedTerminalSelection(raw);');
|
||||
expect(branch).toContain('void this.copyTerminalSelection(raw);');
|
||||
expect(branch).not.toContain('this.copyTerminalSelection(selection)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('_flushAutoCopySelection — cleaned text is what Auto Copy handles', () => {
|
||||
|
||||
Reference in New Issue
Block a user