mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
co-authored by
Claude Opus 5
parent
ce80b7a212
commit
ac6236b268
@@ -774,8 +774,6 @@ function resolveTerminalFontWeights(settings) {
|
||||
*/
|
||||
const AUTO_COPY_MAX_CHARS = 1_000_000;
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* What an auto-copy attempt should do at the end of a selection gesture.
|
||||
*
|
||||
|
||||
@@ -1794,7 +1794,7 @@
|
||||
<div class="set-row" data-search="copy indent margin gutter dedent trim leading whitespace paste">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Trim the pane margin on copy</span>
|
||||
<span class="set-row-desc">Drop the left margin a full-screen agent CLI paints down its own edge, so copied text pastes flush instead of indented. Measured off rows you did not select, and never wider than the indent every selected line shares, so nesting inside the selection is kept. Panes that paint no margin, such as a shell or Codex, are left alone.</span>
|
||||
<span class="set-row-desc">Drop the left margin a full-screen agent CLI paints down its own edge, so copied text pastes flush instead of indented. Each CLI declares its own width, and the strip is never wider than the indent every selected line shares, so nesting inside the selection is kept. Claude Code and Codex declare a margin today; a shell, and any CLI that declares none, is left alone.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsCopyStripMargin"><span class="slider"></span></label>
|
||||
</div>
|
||||
|
||||
@@ -188,7 +188,18 @@
|
||||
if (ev.type === 'keydown' && global.app?.shouldCopyTerminalSelectionFromShortcut?.(ev)) {
|
||||
const raw = this.terminal?.getSelection?.() || '';
|
||||
const isColumnSelection = this.terminal?._core?._selectionService?._activeSelectionMode === 3;
|
||||
const selection = isColumnSelection ? raw : (global.CodemanCopySelection?.clean?.(raw) ?? raw);
|
||||
// Both clean options are read for THIS pane, never the primary one:
|
||||
// the gutter width comes from this.sessionId's own run mode, and the
|
||||
// partial-first-line flag from this terminal's own selection range.
|
||||
// Passing neither left Pane B keeping a margin Pane A dropped, on the
|
||||
// same split and the same keystroke.
|
||||
const range = global.app?._normalisedSelectionRange?.(this.terminal);
|
||||
const selection = isColumnSelection
|
||||
? raw
|
||||
: (global.CodemanCopySelection?.clean?.(raw, {
|
||||
margin: global.app?._cliGutterColumns?.(this.sessionId) ?? 0,
|
||||
firstLinePartial: !!range && range.start.x > 0,
|
||||
}) ?? raw);
|
||||
if (selection.trim()) {
|
||||
ev.preventDefault();
|
||||
void global.app._copyText?.(selection).then((ok) => {
|
||||
|
||||
@@ -368,10 +368,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
// part of a row selects real padding spaces, which are truthy, so testing
|
||||
// the raw text would spend this press on a copy of nothing and make the
|
||||
// user press again to interrupt.
|
||||
const selection = this.cleanedTerminalSelection();
|
||||
//
|
||||
// ⚠️ The gate cleans, and the copy is handed the RAW selection, because
|
||||
// copyTerminalSelection cleans again on its own. The margin strip is not
|
||||
// idempotent: a second pass takes up to `margin` more columns off what
|
||||
// the first pass left, so passing the cleaned string through dedented a
|
||||
// Claude or Codex copy twice. Every other copy path already hands over
|
||||
// the raw selection or reads it live.
|
||||
const raw = this.terminal?.hasSelection?.() ? this.terminal.getSelection() : '';
|
||||
const selection = this.cleanedTerminalSelection(raw);
|
||||
if (selection.trim()) {
|
||||
ev.preventDefault();
|
||||
void this.copyTerminalSelection(selection);
|
||||
void this.copyTerminalSelection(raw);
|
||||
return false;
|
||||
}
|
||||
// Nothing worth copying. The clear is for feedback, not for the
|
||||
@@ -4170,11 +4178,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
*
|
||||
* `text` is for the callers that already read the selection to decide whether
|
||||
* to copy at all (the Ctrl+C gate and the right-click handler), so the read is
|
||||
* not repeated. The transform is idempotent on xterm output, so an
|
||||
* already-cleaned string is an acceptable argument: a CR is consumed by the
|
||||
* parser as a cursor move and never stored in a cell, so the only \r the
|
||||
* selection can carry is the Windows line join, and that is what makes the
|
||||
* trailing scan a fixed point. Fuzzed over 300 000 realistic selections.
|
||||
* not repeated.
|
||||
*
|
||||
* ⚠️ **Pass the RAW selection, never an already-cleaned one.** The trailing
|
||||
* trim alone is a fixed point, because a CR is consumed by the parser as a
|
||||
* cursor move and never stored in a cell, so the only \r the selection can
|
||||
* carry is the Windows line join. The MARGIN strip is not: it takes the
|
||||
* narrower of the declared width and the run every line shares, so a second
|
||||
* pass over an already-stripped block takes up to `margin` columns more. A
|
||||
* caller that cleans to decide whether to copy must still hand the raw text
|
||||
* to copyTerminalSelection, which cleans once on its own.
|
||||
*
|
||||
* A COLUMN selection comes back untouched. Alt+drag makes one (xterm's
|
||||
* shouldColumnSelect keys on altKey alone, and Codeman sets neither of the
|
||||
@@ -4193,7 +4206,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!clean) return raw;
|
||||
const range = this._normalisedSelectionRange();
|
||||
return clean(raw, {
|
||||
margin: this._activeCliGutterColumns(),
|
||||
margin: this._cliGutterColumns(),
|
||||
firstLinePartial: !!range && range.start.x > 0,
|
||||
});
|
||||
},
|
||||
@@ -4210,9 +4223,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
* UNNORMALISED fields under the same two names, and a reversed pair would
|
||||
* make the row window below run backwards and collapse, which would report
|
||||
* no margin at all for every upward drag in a deep buffer.
|
||||
*
|
||||
* `terminal` names which xterm to read, defaulting to the primary pane's.
|
||||
* Pane B of a split owns a second terminal and passes it, because this file's
|
||||
* `this` is always the primary pane.
|
||||
*/
|
||||
_normalisedSelectionRange() {
|
||||
const range = this.terminal?.getSelectionPosition?.();
|
||||
_normalisedSelectionRange(terminal) {
|
||||
const range = (terminal ?? this.terminal)?.getSelectionPosition?.();
|
||||
if (!range?.start || !range?.end) return null;
|
||||
const { start, end } = range;
|
||||
const reversed = end.y < start.y || (end.y === start.y && end.x < start.x);
|
||||
@@ -4220,8 +4237,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/**
|
||||
* How many columns to take off a copy from the active session's pane: the
|
||||
* transcript gutter its CLI declares, or 0 when it declares none.
|
||||
* How many columns to take off a copy from one session's pane: the transcript
|
||||
* gutter its CLI declares, or 0 when it declares none. `sessionId` defaults to
|
||||
* the active session, and Pane B of a split passes its own, so both panes of a
|
||||
* split strip the width their own CLI declares rather than Pane A's.
|
||||
*
|
||||
* ⚠️ Read from `window.__codemanTranscriptGutter`, the map the server derives
|
||||
* from the `transcriptGutter` CAPABILITY at render time — never an id literal
|
||||
@@ -4245,11 +4264,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
* A missing map means no session gets a strip, the same direction an
|
||||
* unmeasured CLI takes by declaring nothing.
|
||||
*/
|
||||
_activeCliGutterColumns() {
|
||||
_cliGutterColumns(sessionId) {
|
||||
if (!this._copyStripMarginEnabled()) return 0;
|
||||
const byMode = window.__codemanTranscriptGutter;
|
||||
if (!byMode || typeof byMode !== 'object') return 0;
|
||||
const mode = this.sessions?.get(this.activeSessionId)?.mode;
|
||||
const mode = this.sessions?.get(sessionId ?? this.activeSessionId)?.mode;
|
||||
const columns = mode ? byMode[mode] : 0;
|
||||
return Number.isInteger(columns) && columns > 0 ? columns : 0;
|
||||
},
|
||||
|
||||
+21
-14
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user