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>
This commit is contained in:
Michael Grundberg
2026-09-22 19:02:55 +02:00
co-authored by Claude Opus 5
parent ce80b7a212
commit ac6236b268
11 changed files with 138 additions and 43 deletions
+21 -14
View File
@@ -1682,21 +1682,28 @@ export class WebServer extends EventEmitter {
'</head>',
() => `<script>window.__codemanCustomModelClis=${customModelClisJson};</script>\n</head>`
);
// How many columns each run mode indents its transcript by, so a copy can drop
// that much. Read off `capabilities` like the payload above and never as an id
// list here, so a CLI that declares a gutter later needs no frontend change.
// Ids and small integers only, no user-settable strings, so JSON.stringify
// alone is enough (same reasoning as __codemanCliAvailable's booleans).
const gutterClis: Record<string, number> = {};
for (const entry of enabledClis()) {
const columns = entry.capabilities.transcriptGutter;
if (typeof columns === 'number') gutterClis[entry.id] = columns;
}
html = html.replace(
'</head>',
() => `<script>window.__codemanTranscriptGutter=${JSON.stringify(gutterClis)};</script>\n</head>`
);
}
// How many columns each run mode indents its transcript by, so a copy can drop
// that much. Read off `capabilities` like the payload above and never as an id
// list here, so a CLI that declares a gutter later needs no frontend change.
// Ids and small integers only, no user-settable strings, so JSON.stringify
// alone is enough (same reasoning as __codemanCliAvailable's booleans).
//
// ⚠️ Outside the `if (!soloSessionId)` block above, unlike every other payload
// here: a detached session window (`/session/:id`) runs a terminal, so Ctrl+C
// copies there, and an absent map reads as "no session gets a strip". The
// toggle used to work in the main window and do nothing in the popup on the
// same device. This needs no availability probe, so it costs a solo window
// nothing that the run menu's own payloads would have cost it.
const gutterClis: Record<string, number> = {};
for (const entry of enabledClis()) {
const columns = entry.capabilities.transcriptGutter;
if (typeof columns === 'number') gutterClis[entry.id] = columns;
}
html = html.replace(
'</head>',
() => `<script>window.__codemanTranscriptGutter=${JSON.stringify(gutterClis)};</script>\n</head>`
);
if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') {
html = html.replace('</head>', () => `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
if (settings.gestureControlEnabled === true) {