feat(terminal): take the transcript gutter off a copy, at the width the CLI declares

Copying a paragraph out of a Claude Code or Codex pane puts that pane's own
two-column transcript gutter on the clipboard, so every pasted line arrives
indented. #451 shipped the trailing half of the copy clean and left the leading
half out, because deriving the width from the selection fires on 73% of ordinary
indented text and cannot tell a margin from content.

The width is DECLARED rather than derived. `capabilities.transcriptGutter` on
the CLI registry is a bounded integer; claude and codex each declare 2, measured
on live panes, and no other stock entry declares any, so a CLI whose transcript
layout nobody has measured is never touched. The server publishes the map as
`window.__codemanTranscriptGutter`, built by filtering `enabledClis()` on the
capability rather than by listing ids, and `_activeCliGutterColumns()` looks the
active session's mode up in it. The copy path reads no terminal buffer at all.

The declared width is a CEILING, not the answer: `clean()` strips the lesser of
it and the run every selected line shares. A block can therefore only shift as a
unit, the structure inside a selection survives by construction, and a selection
reaching column 0 loses nothing. That is what keeps a `git log` body at its own
four-space indent inside an agent's two-column gutter.

Codex was measured separately, because it renders nothing like Claude: it draws
boxes narrower than the pane and pushes its transcript into ordinary scrollback.
On a live 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 100, 120, 160, 198, 235 and 282 columns
its indents were 0, 2, 4, 6 and 8 at every one, never 1. Copying that YAML out
of a live Codex pane now yields 0/2/4/6: gutter gone, nesting intact.

Two derived versions were built and measured first, and both are recorded in the
code because both looked correct:

- Painted trailing padding — a full-screen TUI writes real spaces across the
  unused part of a row, a shell leaves them never-written for xterm to trim —
  has no false positives and never over-stripped. It is also a function of pane
  WIDTH: the padding exists only while a rendered line stops short of the CLI's
  own layout width, and Claude's prose wraps to fill it. Dragging the same two
  prose rows of one live transcript at five window sizes, the share of padded
  rows ran 44%, 6%, 6%, 7% and 87% at 123, 160, 198, 235 and 298 columns, so the
  strip silently did nothing at every ordinary size while a corpus captured
  entirely at 282 columns said it worked.
- Taking the narrowest indent on the rows around the selection fires at every
  width and over-strips about 1% of selections, because a file listing inside
  the transcript can be the narrowest thing on screen.

Measured over 1,392,281 selections — every 1, 2, 3, 5, 10 and 20-row window of
real Claude screens replayed from live PTY streams at 100, 120, 160, 198, 235
and 282 columns — the declared width over-strips none, breaks no relative indent
and alters no text, and serves 100% of the selections whose own indent covers
the gutter. Verified end to end in a browser with a real mouse drag and a real
Ctrl+C: Claude and Codex panes paste flush at 123, 198 and 298 columns, a shell
pane is untouched at every one.

The strip sits behind `copyStripMargin` (App Settings, Selection & clipboard),
per-device and default ON: a display key, absent from the .strict()
SettingsUpdateSchema, read as `!== false` because the desktop branch of
getDefaultSettings() returns {}. The toggle is checked before the map.

Two review findings from #451, handled:

- The mid-row flag governs ONE line now. `range.start.x > 0` excludes only the
  first selected line, the one whose margin the mousedown genuinely cut off, so
  the same three rows no longer produce three different clipboard results.
- The reversed-drag finding does not reproduce on the pinned xterm.
  `getSelectionPosition()` reads `_selectionService.selectionStart`, whose
  getter returns `SelectionModel.finalSelectionStart`, and that swaps the pair
  when `areSelectionValuesReversed()` says so. A real upward mouse drag through
  chromium against xterm 6.0 reports the same range as the downward drag.
  `_normalisedSelectionRange()` keeps the ordering as a guard, because the model
  one layer down exposes the unnormalised fields under the same two names.

Tests: test/terminal-copy-clean.test.ts (64, up from 31), plus the injected
script stripped in test/server-index-title.test.ts. Every guard is pinned:
removing any one of seven reds at least one test, including declaring the wrong
gutter width. Full suite green, 7,861 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Grundberg
2026-09-22 13:33:50 +02:00
co-authored by Claude Opus 5
parent 9466acfc1a
commit ce80b7a212
13 changed files with 591 additions and 51 deletions
+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);
+347 -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._activeCliGutterColumns()).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._activeCliGutterColumns()).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._activeCliGutterColumns()).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._activeCliGutterColumns()).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._activeCliGutterColumns()).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._activeCliGutterColumns()).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('_activeCliGutterColumns() {'),
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