feat(terminal): take the transcript gutter off a copy, at the width the CLI declares - #469
Conversation
…he 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. Ark0N#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 Ark0N#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>
|
Thanks for this, and for the write-up. Declaring the gutter width on the CLI registry, treating it as a ceiling rather than an answer, and leaving an unmeasured CLI untouched is the right shape, and recording the two derived versions that looked correct is genuinely useful to whoever touches this next. One blocker and one rewrite before it can go in. Blocker: the Ctrl+C path cleans twice, so it strips twice
Confirmed in chromium against a live server, with a real selection, a real Ctrl+C and a real clipboard read, on a Same for a nested block: an on-screen Right-click, the phone selection button and Auto Copy are all correct, because each of them hands over the raw selection or nothing at all. So it is the one path that is also the most used one, and the case it breaks is the guarantee the PR leads with. The simplest fix keeps the gate on the cleaned string and passes the raw one along: const raw = this.terminal?.hasSelection?.() ? this.terminal.getSelection() : '';
const selection = this.cleanedTerminalSelection(raw);
if (selection.trim()) {
ev.preventDefault();
void this.copyTerminalSelection(raw);Two things belong with it: the idempotence sentence at Please rewrite the toggle description
Smaller things, happy to apply at merge time
Everything else is green here: typecheck, lint, Prettier, frontend-syntax, public assets, and the full gate at 7861 passed and 0 failed, the same number you reported. Fix the double clean and the description and I will merge. |
Review fixes for Ark0N#469. The Ctrl+C branch cleaned the selection to decide whether to copy and then passed that cleaned string to copyTerminalSelection(), which cleans again. The trailing trim is a fixed point, so that was safe until this PR; the margin strip is not, because it takes the lesser of the declared width and the run every line shares, so a second pass takes up to `margin` columns more. The branch now gates on the cleaned string and hands the raw one on. Verified in chromium with a real drag, a real Ctrl+C and a real clipboard read on a live claude pane: an on-screen ` fix(terminal): trim it` reaches the clipboard as ` fix(terminal): trim it`, and reverting the branch reproduces the reported ` fix(terminal): trim it`. Pane B of a split resolves its own width. `_cliGutterColumns()` and `_normalisedSelectionRange()` take the session and the terminal to read, defaulting to the primary pane's, so Pane B looks its own run mode up instead of keeping a margin Pane A drops on the same keystroke. Verified live with two claude panes open side by side. A detached session window (`/session/:id`) receives the gutter map. The injection sat inside the block that skips the run menu's payloads for a solo window, so the toggle worked in the main window and did nothing in the popup on the same device. It needs no availability probe, so it moved below that block and the solo window still carries none of the payloads it skipped before. The settings description said the width is measured and named Codex as exempt. Nothing is measured, and Codex is one of the two panes that are stripped. docs/wiki/Settings-Reference.md gains the row every Terminal and Input toggle carries. CLAUDE.md no longer says the clean touches trailing runs "and nothing else" one sentence before the leading-margin rule, and both it and docs/architecture-invariants.md record that the strip is not idempotent. Two round-trip tests run on a mode that declares a gutter, which the existing copyTerminalSelection cases could not, since they all use the harness default mode that declares none. The Ctrl+C branch itself is pinned at the source, because it lives inside initTerminal's attachCustomKeyEventHandler closure over a real xterm the vm harness cannot build. Both pins fail on the reintroduced bug. Gate: 7865 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
All seven fixed in The double clean. The Ctrl+C branch now reads the selection once, gates on the cleaned copy and passes the raw one to The idempotence sentence at The test. I could not drive the keystroke itself, because the branch sits inside The description no longer claims anything is measured and names Claude Code and Codex as the two panes that are stripped. The smaller things, all four applied rather than left to merge time. The gutter map injection moved below the Gate: 7865 passed, 0 failed, plus typecheck, lint, Prettier, frontend-syntax and public assets. |
- stock.ts: claude is no longer the only entry declaring transcriptGutter; codex declares it too. - architecture-invariants: the strip applies when the session's CLI declares a margin (not detection), and a note that it keys on the session's launch mode, not on what is running in the pane (a claude pane dropped to a shell still loses up to two columns; copyStripMargin is the escape hatch). - render-index-html test: the gutter map is injected for a solo /session/:id render as well. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Merged, thanks @irisitymichaelgrundberg! This ships in 1.32.1. Declaring the gutter on the registry and treating it as a ceiling was the right call, and At merge I fixed the two stale comments (stock.ts said claude was the only entry declaring a gutter; architecture-invariants still described the strip as detected), added a sentence noting the strip keys on the session's launch mode rather than what is running in the pane (so a shell inside a claude-mode pane loses up to two columns, with |
Follow-up to #451, taking up the invitation at the end of your review comment.
The problem
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 and has to be cleaned up by hand.
#451 shipped the trailing trim and left the leading strip out, for the right reason: measuring the width off the selection fires on 73% of ordinary indented text, because a three-row window of nested YAML shares an indent for the same reason a margin does.
What this does
The width is declared by the CLI, not derived from the pane.
capabilities.transcriptGutteris a bounded integer on the CLI registry; claude and codex each declare2, measured on live panes, and no other stock entry declares anything — so a CLI whose transcript layout nobody has measured is never touched. The server publishes the map aswindow.__codemanTranscriptGutter, built by filteringenabledClis()on the capability rather than by listing ids, and the copy path looks the active session's mode up in it. It 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. So a block can only ever shift as a unit, the structure inside a selection survives by construction, and a selection reaching column 0 loses nothing. That is what keeps agit logbody at its own four-space indent while it sits inside an agent's two-column gutter — the case you named as the reason the painted-padding test was not enough on its own.Two derived versions were built first, and both are recorded in the code
I tried your suggested qualification before landing on a declaration, and it is worth reporting what it does, because it looks correct for a long time.
Painted trailing padding behaves exactly as you measured: no false positives, never over-strips. It is also a function of pane width. That 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 rows carrying padding measured:
So at every ordinary window size the strip silently did nothing — while a corpus captured entirely at 282 columns, and a green test suite, both said it worked. My first version of this PR shipped in exactly that state and the bug report came back within the hour.
Taking the narrowest indent on the surrounding rows fixes the width dependence and fires everywhere, but over-strips about 1% of selections, because a file listing inside the transcript can be the narrowest thing on screen.
A declared width does neither.
Measurements
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 — 1,392,281 selections:
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 the six widths above, its indents were 0, 2, 4, 6 and 8 at every one and never 1.The two findings from your review
range.start.x > 0excludes 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 depending on where the click landed.CoreBrowserTerminal.getSelectionPosition()reads_selectionService.selectionStart, whose getter returnsSelectionModel.finalSelectionStart, and that swaps the pair whenareSelectionValuesReversed()says so. Driving a real upward mouse drag through chromium against xterm 6.0 reports the same range as the downward drag of the same rows._normalisedSelectionRange()orders the pair anyway, as a guard: the model one layer down exposes the unnormalised fields under the same two names.Setting
Behind
copyStripMarginin App Settings → Selection & clipboard, per-device, default ON. A display key, deliberately absent from the.strict()SettingsUpdateSchema, read as!== falsebecause the desktop branch ofgetDefaultSettings()returns{}. The toggle is checked before the map is consulted.Verification
test/terminal-copy-clean.test.tsis at 64 tests. Every guard is mutation-tested — removing any one of seven reds at least one test, including declaring the wrong gutter width. A test pins that exactly two stock entries declare a gutter, so a third added without measurement fails there.Ctrl+C, reading what actually reaches the clipboard: Claude and Codex panes paste flush at 123, 198 and 298 columns; a shell pane is untouched at every width. Copying the Codex YAML block gives0/2/4/6for an on-screen2/4/6/8— gutter gone, nesting intact, pastes as valid YAML.Not verified: the browser and mobile suites (
npm run test:browser/test:mobile) were not run — this change touches no mobile-specific path, but I did not diff their FAIL lists against master.