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:
Codeman maintainer
2026-09-23 11:32:02 +02:00
16 changed files with 696 additions and 61 deletions
+7 -2
View File
@@ -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'");
});
+7 -4
View File
@@ -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);
+397 -17
View File
@@ -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', () => {