Skip to content

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

Merged
Ark0N merged 2 commits into
Ark0N:masterfrom
irisitymichaelgrundberg:feat/copy-dedent-pane-margin
Sep 23, 2026
Merged

Ark0N merged 2 commits into
Ark0N:masterfrom
irisitymichaelgrundberg:feat/copy-dedent-pane-margin

Conversation

@irisitymichaelgrundberg

Copy link
Copy Markdown
Contributor

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.transcriptGutter is a bounded integer on the CLI registry; claude and codex each declare 2, 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 as window.__codemanTranscriptGutter, built by filtering enabledClis() 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 a git log body 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:

Terminal 123 cols 160 198 235 298
Rows with painted padding 44% 6% 6% 7% 87%

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:

  • over-strips: 0
  • relative indents broken: 0
  • text altered: 0
  • served: 100% of the selections whose own indent covers the 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 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

  • 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 depending on where the click landed.
  • The reversed-drag finding does not reproduce on the pinned xterm, and I think the note is out of date rather than wrong at the time. CoreBrowserTerminal.getSelectionPosition() reads _selectionService.selectionStart, whose getter returns SelectionModel.finalSelectionStart, and that swaps the pair when areSelectionValuesReversed() 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 copyStripMargin in App Settings → Selection & clipboard, per-device, default ON. A display key, deliberately absent from the .strict() SettingsUpdateSchema, read as !== false because the desktop branch of getDefaultSettings() returns {}. The toggle is checked before the map is consulted.

Verification

  • Full suite green: 7,861 passed, 0 failed. Typecheck, lint, Prettier, frontend-syntax and public-asset checks pass.
  • test/terminal-copy-clean.test.ts is 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.
  • Driven end to end in a browser against a live instance with a real mouse drag and a real 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 gives 0/2/4/6 for an on-screen 2/4/6/8 — gutter gone, nesting intact, pastes as valid YAML.
  • Both modes were also exercised by hand, by a human, in a browser before this PR was opened.

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.

…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>
@irisitymichaelgrundberg
irisitymichaelgrundberg marked this pull request as ready for review September 22, 2026 14:31
@Ark0N

Ark0N commented Sep 22, 2026

Copy link
Copy Markdown
Owner

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

src/web/public/terminal-ui.js:371-374 passes the already-cleaned string into copyTerminalSelection(), and copyTerminalSelection() cleans it again at src/web/public/terminal-ui.js:4261. That was fine while the transform only trimmed trailing padding (the comment at line 4173 states the idempotence it relied on), but the margin strip is not idempotent: the second pass takes up to margin more columns off whatever the first pass left.

Confirmed in chromium against a live server, with a real selection, a real Ctrl+C and a real clipboard read, on a mode: 'claude' session:

on screen : "      fix(terminal): trim it"   (2 gutter + the git log body's own 4)
expected  : "    fix(terminal): trim it"
clipboard : "  fix(terminal): trim it"

Same for a nested block: an on-screen build: / steps: / - run: npm ci reaches the clipboard as build: / steps: / - run: npm ci instead of build: / steps: / - run: npm ci.

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 terminal-ui.js:4173 is no longer true, and the suite needs a case that drives this round trip. All 64 tests in test/terminal-copy-clean.test.ts pass straight through the bug, because every copyTerminalSelection test runs on the harness default mode: 'shell', which declares no gutter.

Please rewrite the toggle description

src/web/public/index.html:1797 still describes the derived version: "Measured off rows you did not select" (nothing is measured now) and "Panes that paint no margin, such as a shell or Codex, are left alone" (Codex declares 2 in src/config/cli-registry/stock.ts:531 in this same commit, and is one of exactly two panes that are stripped).

Smaller things, happy to apply at merge time

  • src/web/server.ts:1685 sits inside the if (!soloSessionId) block at line 1617, so a detached session window (/session/:id) never receives the map and the toggle is inert there while it works in the main window on the same device. enabledClis is already a top-level import at line 74, so the block can move below that if.
  • docs/wiki/Settings-Reference.md:48 carries a row per Terminal and Input toggle, Auto Copy included, and needs one for this. CI mirrors that directory to the wiki on every master push.
  • src/web/public/terminal-split.js:191 calls clean(raw) with no options, so Pane B of a split keeps the gutter while Pane A drops it. Pane B knows its own this.sessionId, so it can look the mode up the same way; otherwise it is worth a line in the split-pane note.
  • CLAUDE.md:325 keeps "The clean drops each line's TRAILING run and nothing else" one sentence before "A LEADING margin is stripped only when the CLI DECLARES one".
  • src/web/public/constants.js:776 gained two stray blank lines (the file is prettier-ignored, so nothing catches them).

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>
@irisitymichaelgrundberg

irisitymichaelgrundberg commented Sep 23, 2026

Copy link
Copy Markdown
Contributor Author

All seven fixed in ac6236b2.

The double clean. The Ctrl+C branch now reads the selection once, gates on the cleaned copy and passes the raw one to copyTerminalSelection(). Reproduced your exact case in chromium first with a real drag, a real Ctrl+C and a real clipboard read on a live Claude pane, then confirmed the fix: fix(terminal): trim it on screen reaches the clipboard as fix(terminal): trim it, and your nested block as build: / steps: / - run: npm ci. Putting the old line back reproduces the build: / steps: / - run: npm ci you reported.

The idempotence sentence at cleanedTerminalSelection is corrected, and CLAUDE.md plus docs/architecture-invariants.md now record the rule.

The test. I could not drive the keystroke itself, because the branch sits inside initTerminal's attachCustomKeyEventHandler closure over a real xterm the vm harness cannot build. Instead there are two round-trip cases on a mode that declares a gutter (the gap you spotted — every existing copyTerminalSelection case runs on the harness default that declares none), one case proving the strip is not idempotent, and a source-level pin on the branch. Both pins fail on the reintroduced bug. test/keyboard-shortcuts.test.ts pinned the old two lines and is updated in step.

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 if (!soloSessionId) block, verified with curl that /session/:id now carries it and still carries none of the availability payloads it skipped before. docs/wiki/Settings-Reference.md has its row. Pane B resolves its own width — _cliGutterColumns() and _normalisedSelectionRange() now take the session and terminal to read — verified live with two Claude panes side by side, and the gutter stays on Pane B without it. CLAUDE.md's "TRAILING run and nothing else" is corrected, and the two blank lines are gone.

Gate: 7865 passed, 0 failed, plus typecheck, lint, Prettier, frontend-syntax and public assets.

@Ark0N
Ark0N merged commit 94b093b into Ark0N:master Sep 23, 2026
2 checks passed
Ark0N pushed a commit that referenced this pull request Sep 23, 2026
- 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>
@Ark0N

Ark0N commented Sep 23, 2026

Copy link
Copy Markdown
Owner

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 ac6236b2 covered all seven review items, including the double clean with a test. The measurement write-up (1.39M selections, and why both derived versions looked right and were not) is the kind of record that stops someone re-deriving it in six months.

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 copyStripMargin as the escape hatch), and added a render test that the gutter map is injected for a solo /session/:id window. The split-pane copy path still has no behavioural test; a follow-up there would be welcome.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants