Files
Codeman/.changeset/f51b4ef6.md
T
Michael GrundbergandClaude Opus 5 ac6236b268 fix(terminal): clean a copy once, and reach every pane that copies
Review fixes for #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>
2026-09-22 19:02:55 +02:00

4.5 KiB
Raw Blame History

aicodeman
aicodeman
patch

Terminal copy: take the transcript gutter off the clipboard, using the width the CLI declares.

Copying a paragraph out of a Claude Code pane put the pane's own two-column transcript gutter on the clipboard, so every pasted line arrived indented. #451 shipped the trailing half of the copy clean and deliberately 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 now 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 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. 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.

Two derived versions were built and measured first, and both are worth recording 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 — has no false positives and never over-stripped, and is 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 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. Taking the narrowest indent on the surrounding rows fires at every width and over-strips about 1%, 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 at 123, 160, 198, 235 and 298 columns: a Claude pane pastes flush at every one, a shell pane is untouched at every one.

Codex was measured the same way and gets the same two columns. It renders nothing like Claude — it draws boxes narrower than the pane and pushes its transcript into ordinary scrollback — so its layout was checked on its own live answer: the •/›/⚠ 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 and never 1. Copying that YAML out of a live Codex pane now yields 0/2/4/6: the gutter gone, the block's own nesting intact and paste-ready.

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

The mid-row flag is back and governs one line rather than the whole block. It reads an ordered range, so neither end of a drag can move the result, and range.start.x > 0 excludes only the first line — the one the mousedown genuinely cut the margin off.

Both panes of a split strip the width their own CLI declares. _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.

A detached session window (/session/:id) receives the gutter map too. It runs a terminal and so copies through the same path, and the injection needs no availability probe, so it sits outside the block that skips the run menu's payloads for a solo window.