Merge pull request #451

fix(terminal): trim the padding and shared indent out of a copied selection
This commit is contained in:
Ark0N
2026-09-19 12:18:08 +02:00
committed by GitHub
7 changed files with 505 additions and 9 deletions
@@ -0,0 +1,31 @@
---
"aicodeman": patch
---
fix(terminal): trim the padding and shared indent out of a copied selection
Copying several rows out of a pane put a wall of spaces on the clipboard and
repeated the program's own margin on every line. xterm hands back whole screen
rows and trims only the cells that were never written to, so the real spaces a
full-screen TUI paints across the unused part of a row count as content:
measured against Claude Code in a 282-column pane, single lines arrived
carrying 138 trailing spaces on top of a two-space transcript indent. Pasting
that into a chat client or an editor meant deleting the whitespace by hand,
while Windows Terminal, iTerm2 and GNOME Terminal all trim it for you.
A copy now drops the trailing run from every line. It also removes the leading
run, but only the one that every selected row shares and only when the
selection covers more than one row, so shell output is untouched, a single line
keeps the indentation you can see, and anything nested inside a copied block
keeps its relative indentation. A drag that starts inside a row keeps that
partial first line exactly as it was. An `Alt+drag` rectangular selection is
copied verbatim, because its columns lining up is the point of that gesture.
The main terminal's four copy paths go through it: the Ctrl+C chord,
right-click, the phone selection button and Auto Copy. The browser's own
Edit menu copy, a copy shortcut you disabled in settings, and the subagent
windows still copy the raw rows, as they did before. A selection holding nothing but padding is now
refused rather than copied as bare line breaks, and it clears the selection on
the way out so Ctrl+C goes straight back to interrupting. Auto Copy reads its
own toggle before it reads the selection, so a pane nobody is copying from
costs nothing on mouseup.
+10
View File
@@ -302,6 +302,14 @@ Invariants:
Copy goes through `_copyText()` (Clipboard API, then hidden-textarea + `execCommand`), not raw `navigator.clipboard`, because `install.sh`'s LAN option serves plain HTTP where `navigator.clipboard` is undefined; the fallback steals focus, so the terminal is refocused afterwards. Related: xterm registers its own `copy` listener on the terminal element gated on `hasSelection()`, which is why right-click → Copy has always worked. Selection itself is unavailable on touch devices by design (`user-select: none` on the terminal subtree), and in `shell`/`opencode`/`antigravity` tabs the TUI owns the mouse, so selecting there needs Shift+drag. Tests: `test/terminal-copy-selection.test.ts` (gate + wiring invariants), `test/terminal-copy-shortcut.test.ts` (browser, real key presses).
**The main terminal's four copy paths clean the selection first** (`CodemanCopySelection.clean` in constants.js, pure; `cleanedTerminalSelection()` in terminal-ui.js is the half that reads the live terminal). Those four are the `Ctrl+C` chord, right-click, the phone selection button and Auto Copy. ⚠️ Three routes still copy the RAW padded rows, all of them predating the clean: the browser's own Edit → Copy, which xterm's own `copy` listener on the terminal element serves with `selectionText` directly; a `copy-selection` shortcut the user disabled in App Settings, where nothing calls `preventDefault()` and that native listener runs; and the subagent/teammate windows, which build their own `Terminal` in panels-ui.js with no copy wiring at all. xterm hands back whole screen ROWS and its own trim drops only cells that were never written to, so the real spaces a full-screen TUI paints across the unused part of a row count as content and reach the clipboard. Measured against Claude Code in a 282-column pane, single lines arrived carrying 138 trailing spaces on top of the two-space transcript indent. The clean drops each line's trailing run, and removes the leading run only when every selected row shares one, which makes it a no-op for shell output. Three rules keep it honest:
1. **A one-row selection keeps its leading run.** One row shares nothing with anything, so that run is content — stripping it would silently reindent a single line of `git log` body text or one line out of `less`. ⚠️ A drag that STARTED mid-row lowers that bar to one measured row, because the partial first line is counted toward the two-row total and then skipped by the measurement. That is deliberate: both rows of a wrapped paragraph wear the TUI's margin and the drag only hid the first one's, so the second row's margin still goes. The cost is that a two-row mid-row drag over genuinely indented content loses that indent.
2. **A COLUMN selection is returned untouched.** Alt+drag makes one (xterm's `shouldColumnSelect` keys on `altKey` alone, and neither `Terminal` Codeman builds passes the one option, `macOptionClickForcesSelection`, that would disable it), and a rectangle's rows lining up is the whole point of the gesture. xterm exposes the mode nowhere public, so the check reads `terminal._core._selectionService._activeSelectionMode` (`SelectionMode.COLUMN` is 3) and cleans normally if a future xterm renames it.
3. **The emptiness gate is `trim()`, not truthiness, and it still clears the selection.** A multi-row drag across padding cleans to line breaks alone, which are truthy, and a bare newline pasted into a chat composer submits it. ⚠️ Returning without `clearSelection()` there would leave the raw selection set, and since the `Ctrl+C` gate above tests the RAW selection, every later `Ctrl+C` would copy nothing instead of interrupting — the exact way rule 1 of smart copy gets lost.
All four paths go through it: the `Ctrl+C` chord, right-click, the phone selection button and Auto Copy. Tests: `test/terminal-copy-clean.test.ts`.
### Auto Copy (copy-on-select)
**Auto Copy** (`autoCopySelection`, per-device, default OFF) puts a finished terminal selection on the clipboard without a keystroke. It is a thin layer over the smart-copy machinery above and shares `_copyText()` with it, but the two paths differ in every decision that matters:
@@ -312,6 +320,8 @@ Copy goes through `_copyText()` (Clipboard API, then hidden-textarea + `execComm
- **Touch has its own entry point.** `_endTouchSelectionGesture()` and `_selectTouchSelectionLine()` call the flush directly, because the touch path `preventDefault()`s its touchend (that is what stops the compat mouse pair from stealing the selection back), so no mouseup ever reaches the document there. Without those two calls the toggle is simply dead on a phone.
- **It must NOT do what `copyTerminalSelection()` does.** That one clears the selection (so a second `Ctrl+C` is an interrupt) and focuses the terminal. Clearing would make text vanish from under the cursor that just highlighted it, and focusing opens the on-screen keyboard over it on a phone. Focus is instead RESTORED to whatever held it before the copy, which only matters for the `execCommand` fallback (it focuses a temp textarea on the way through); the Clipboard API path never moves focus at all.
⚠️ **The toggle is read before the selection is.** `_flushAutoCopySelection()` resolves `_autoCopySelectionEnabled()` first and only then reads and cleans, because Auto Copy is OFF by default and a selection can run to the 50 000-row scrollback ceiling; cleaning ahead of the check would spend that work on every mouseup on the page. The clean runs before `decideAutoCopy()` so its dedupe and its cap both measure the text that actually reaches the clipboard.
`decideAutoCopy()` (constants.js, pure) holds the guards: setting off, blank or whitespace-only text (what a drag across empty cells produces), and a `AUTO_COPY_MAX_CHARS` (1M) cap. ⚠️ The cap is not decoration: a drag off the top of the viewport autoscrolls, so one gesture can sweep the whole 50k-line scrollback. Past it the copy is REFUSED rather than truncated, with a toast pointing at `Ctrl+C`, which still copies everything through the explicit path.
⚠️ **Two dedupe rules, and both earn their place.** A genuine selection change (`pending`) always copies, so re-selecting the same text after copying something else in between still works. Otherwise only text differing from the last auto-copy does, which is what stops an unrelated mouseup from re-copying a stale selection AND what makes the first copy of a drag work at all: xterm fires `onSelectionChange` from its own document `mouseup` handler, and listener order between the two is registration order, not something this code controls. Gating on `pending` alone silently drops that first copy.
+2
View File
@@ -34,6 +34,8 @@ Press `Ctrl+?` in the app for the same list in a floating overlay.
| Right-click | Copy the selection. With nothing selected the native menu is left alone. |
| `Ctrl+Z` | Swallowed in agent sessions so a running CLI cannot be suspended. Normal job control in a shell. |
Anything you copy is cleaned on the way to the clipboard. Each line loses the padding spaces a full-screen program paints across the rest of the row, and a selection covering several rows also loses the indent every one of those rows shares, which is usually the program's own margin rather than your text. A one-row selection keeps its indent, and an `Alt+drag` rectangular selection is copied exactly as it looks, so its columns stay lined up.
## Everything else
| Shortcut | Action |
+91
View File
@@ -806,6 +806,94 @@ function decideAutoCopy({ enabled, text, lastCopied, pending } = {}) {
return 'copy';
}
// The text a copy should put on the clipboard, given xterm's raw selection.
// Pure: the caller reads the selection and decides the mode, this transforms.
//
// xterm hands back whole screen ROWS, and its own trim only drops cells that
// were never written to. A full-screen TUI writes real spaces across the part
// of a row it is not using, so that padding counts as content and rides along
// to the clipboard: measured against Claude Code in a 282-column pane, single
// lines arrived carrying 138 trailing spaces. Native terminals trim it on copy
// (Windows Terminal, iTerm2 and GNOME Terminal all do), decideAutoCopy above
// already calls a wall of spaces "never what the gesture meant", and
// _selectTouchSelectionLine already treats those cells as padding. This is that
// same rule for the mouse and keyboard paths, which never had it.
//
// The leading run is the other half, and it applies ACROSS ROWS ONLY. A TUI
// that indents its whole transcript repeats the indent on every row, so a
// multi-row selection arrives with the chrome baked into each line; only the
// run every selected row shares is removed, which is a no-op for shell output
// and keeps the relative indentation of anything nested inside. A selection of
// ONE row shares nothing with anything, so its leading spaces are content and
// stay put — otherwise a single line of `git log` body text, or one line out of
// `less`, would silently lose its indentation.
//
// ⚠ The trailing trim takes spaces AND tabs while the leading run counts spaces
// only, so one tab-led row disables the strip for its whole block. Terminals
// expand tabs into cells, so a tab should never reach either rule; the
// asymmetry is deliberate caution rather than an oversight.
//
// ⚠ A WRAPPED logical line keeps its continuation indent. xterm appends a
// wrapped row to the previous entry instead of starting a new line, so rows
// 2..n of one wrapped line sit mid-string where no line rule can see them. That
// is inherent to cleaning xterm's output rather than a gap to fix here.
function cleanCopiedSelection(text, { startedMidRow = false } = {}) {
if (typeof text !== 'string' || !text) return '';
// Split on \n and leave any \r in place: xterm joins rows with \r\n on
// Windows, and the clipboard should keep the endings xterm chose.
// Scanned rather than matched, throughout. A selection can run to the 50 000-row
// scrollback ceiling, and `/[ \t]+(\r?)$/` is QUADRATIC on a line whose spaces
// are followed by any non-space character, which is what right-aligned or
// centred TUI content looks like: the engine retries the run from every
// whitespace position and backtracks over it. Measured over 50 000 rows with a
// 280-column run, that regex took 2.9s against 1.3ms for the scan below, and a
// 2 000-column run took 16s. It is also the faster of the two on an ordinary
// padded row. A length is returned rather than a trimmed string so a
// \r-terminated line costs no substring either.
const trimEnd = (line) => {
let end = line.length;
if (end > 0 && line[end - 1] === '\r') end--;
let cut = end;
while (cut > 0 && (line[cut - 1] === ' ' || line[cut - 1] === '\t')) cut--;
return cut === end ? line : line.slice(0, cut) + line.slice(end);
};
const lines = text.split('\n').map(trimEnd);
const bareLen = (line) => (line.endsWith('\r') ? line.length - 1 : line.length);
const leadingRun = (line) => {
let n = 0;
while (n < line.length && line[n] === ' ') n++;
return n;
};
// ⚠ Counted over EVERY line, including a partial first line the loop below
// skips. A mid-row drag across two rows therefore measures one row and strips
// it. That is deliberate: both rows of a wrapped paragraph wear the TUI's
// margin, and the drag only hid the first one's. The cost is that a two-row
// mid-row drag over genuinely indented content loses that indent.
let contentRows = 0;
for (const line of lines) if (bareLen(line)) contentRows++;
if (contentRows < 2) return lines.join('\n');
// startedMidRow keeps the first line out of the measurement. A drag that
// begins inside a row gives a first line with no leading run at all, which
// would otherwise pin the shared run to zero and leave every row after it
// still wearing the indent. That partial line is never stripped either.
let shared = Infinity;
for (let i = startedMidRow ? 1 : 0; i < lines.length; i++) {
// A row left blank by the trailing trim says nothing about the indent, and
// counting it as zero would disable the strip for the whole block.
if (!bareLen(lines[i])) continue;
shared = Math.min(shared, leadingRun(lines[i]));
if (shared === 0) break;
}
if (!shared || shared === Infinity) return lines.join('\n');
// The clamp is what keeps a blank row's lone \r intact when the shared run is
// wider than that row is long.
return lines
.map((line, i) => (startedMidRow && i === 0 ? line : line.slice(Math.min(shared, leadingRun(line)))))
.join('\n');
}
if (typeof window !== 'undefined') {
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
@@ -854,6 +942,9 @@ if (typeof window !== 'undefined') {
decide: decideAutoCopy,
MAX_CHARS: AUTO_COPY_MAX_CHARS,
};
window.CodemanCopySelection = {
clean: cleanCopiedSelection,
};
window.CodemanTerminalFont = {
DEFAULT_STACK: TERMINAL_FONT_DEFAULT_STACK,
resolve: resolveTerminalFontFamily,
+62 -6
View File
@@ -364,12 +364,23 @@ Object.assign(CodemanApp.prototype, {
// this handler before its own cancel()), so preventDefault is explicit:
// without it the browser runs its native copy on top of ours.
if (this.shouldCopyTerminalSelectionFromShortcut?.(ev)) {
const selection = this.terminal.hasSelection?.() ? this.terminal.getSelection() : '';
if (selection) {
// The CLEANED selection decides, not the raw one. A drag across the blank
// 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();
if (selection.trim()) {
ev.preventDefault();
void this.copyTerminalSelection(selection);
return false;
}
// Nothing worth copying. Drop a padding-only selection first, or it would
// intercept every following press too, then fall through exactly as an
// empty selection does so this press still reaches the PTY as 0x03.
if (this.terminal?.hasSelection?.()) {
this.terminal.clearSelection?.();
this.showToast('Nothing to copy', 'warning');
}
if (ev.shiftKey) {
ev.preventDefault();
return false;
@@ -4141,12 +4152,51 @@ Object.assign(CodemanApp.prototype, {
return !ev.altKey && (ev.key || '').toLowerCase() === 'c';
},
/**
* xterm's current selection, cleaned for the clipboard. The transform itself
* is CodemanCopySelection.clean in constants.js, beside decideAutoCopy; this
* is the half that needs the live terminal.
*
* `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. It must be the selection xterm holds RIGHT NOW, because the
* mid-row flag below comes from the live selection rather than from `text`.
*
* A COLUMN selection comes back untouched. Alt+drag makes one — xterm's
* shouldColumnSelect keys on altKey alone, and Codeman sets neither of the
* terminals it creates with the one option that would disable it — and a rectangle's whole point is that its rows line
* up, which both halves of the clean would destroy. xterm exposes the mode
* nowhere public, so this reads the private field the way this file already
* reads terminal._core for cell dimensions, and falls back to cleaning
* normally if a future xterm renames it. SelectionMode.COLUMN is 3.
*/
cleanedTerminalSelection(text) {
const raw = text ?? (this.terminal?.hasSelection?.() ? this.terminal.getSelection() : '');
if (!raw) return '';
if (this.terminal?._core?._selectionService?._activeSelectionMode === 3) return raw;
const clean = window.CodemanCopySelection?.clean;
if (!clean) return raw;
const start = this.terminal?.getSelectionPosition?.()?.start;
return clean(raw, { startedMidRow: !!start && start.x > 0 });
},
// Copy the current terminal selection. Goes through _copyText (Clipboard API,
// then a hidden-textarea + execCommand fallback) because install.sh's LAN
// option serves plain HTTP, where navigator.clipboard is undefined.
async copyTerminalSelection(text) {
const selection = text ?? (this.terminal.hasSelection?.() ? this.terminal.getSelection() : '');
if (!selection) return false;
const selection = this.cleanedTerminalSelection(text);
// trim(), not emptiness: a multi-row drag across padding cleans to newlines
// alone, which are truthy, and a bare newline pasted into a chat composer
// or a shell submits the line. decideAutoCopy applies the same rule.
if (!selection.trim()) {
// Clearing matters as much as the toast. The Ctrl+C gate tests the RAW
// selection, so a padding-only selection left set would make every later
// Ctrl+C copy nothing instead of interrupting — the exact failure
// docs/architecture-invariants.md warns about under Terminal smart copy.
this.terminal?.clearSelection?.();
this.showToast('Nothing to copy', 'warning');
return false;
}
const ok = await this._copyText(selection);
if (ok) {
// Clearing is what makes a second Ctrl+C an interrupt (and xterm already
@@ -4195,9 +4245,15 @@ Object.assign(CodemanApp.prototype, {
async _flushAutoCopySelection() {
const decide = window.CodemanAutoCopy?.decide;
if (!decide || !this.terminal) return;
const text = this.terminal.hasSelection?.() ? this.terminal.getSelection() : '';
// The toggle is read FIRST because Auto Copy is off by default: reading and
// cleaning a selection that can run to the 50 000-row scrollback ceiling
// costs real time on a phone, and every mouseup would pay it for nothing.
// Cleaning before decide() then means its dedupe and size cap both measure
// the text that actually reaches the clipboard, not the padded rows behind.
const enabled = this._autoCopySelectionEnabled();
const text = enabled ? this.cleanedTerminalSelection() : '';
const verdict = decide({
enabled: this._autoCopySelectionEnabled(),
enabled,
text,
lastCopied: this._autoCopyLastText,
pending: !!this._autoCopyPending,
+6 -3
View File
@@ -63,9 +63,12 @@ describe('keyboard shortcuts', () => {
// The xterm handler owns this decision, and the no-selection path must fall
// through with NO preventDefault so xterm still evaluates Ctrl+C into 0x03.
expect(terminalUiSource).toContain('this.shouldCopyTerminalSelectionFromShortcut?.(ev)');
expect(terminalUiSource).toMatch(
/const selection = this\.terminal\.hasSelection\?\.\(\) \? this\.terminal\.getSelection\(\) : '';/
);
// The CLEANED selection is what decides. A drag across the blank part of a row
// selects real padding spaces, so the raw text is truthy and testing it would
// spend the press on a copy of nothing — the same lost interrupt this test
// guards, reached by a different door.
expect(terminalUiSource).toMatch(/const selection = this\.cleanedTerminalSelection\(\);/);
expect(terminalUiSource).toMatch(/if \(selection\.trim\(\)\) \{/);
expect(terminalUiSource).toContain('void this.copyTerminalSelection(selection);');
expect(appSource).toContain("id: 'copy-selection'");
});
+303
View File
@@ -0,0 +1,303 @@
/**
* What a copy actually puts on the clipboard.
*
* xterm returns whole screen rows and trims only the cells that were never
* written to, so a full-screen TUI's padding spaces reach the clipboard and the
* indent it repeats on every row arrives baked into every line. These tests
* drive the SHIPPED transform (`CodemanCopySelection.clean` in constants.js),
* the SHIPPED wiring that decides column mode and the mid-row flag, and both
* SHIPPED copy paths, because the interesting failures live in the paths rather
* than in the string handling: a padding-only selection must not silently keep
* the user's Ctrl+C, and must not put a bare newline on the clipboard.
*
* Strategy: constants.js and terminal-ui.js in one vm with a stub CodemanApp,
* the harness shape test/terminal-auto-copy.test.ts uses. No DOM, no xterm.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const read = (name: string) => readFileSync(resolve(publicDir, name), 'utf8');
function loadHarness() {
const CodemanApp = function CodemanApp(this: unknown) {};
const windowRef: Record<string, any> = {};
const context = vm.createContext({
window: windowRef,
document: {
body: { classList: { contains: () => false } },
getElementById: () => null,
querySelector: () => null,
addEventListener: () => {},
},
CodemanApp,
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn() },
_crashDiag: { log: vi.fn() },
requestAnimationFrame: () => 1,
setTimeout: () => 1,
Blob: function Blob() {},
URL: { createObjectURL: () => 'blob:yield', revokeObjectURL: () => {} },
Worker: function Worker(this: any) {
this.postMessage = () => {};
},
MobileDetection: { isTouchDevice: () => false, getDeviceType: () => 'desktop' },
KeyboardHandler: { keyboardVisible: false },
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
TERMINAL_CHUNK_SIZE: 32 * 1024,
});
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
vm.runInContext(read('terminal-ui.js'), context, { filename: 'terminal-ui.js' });
const app = new (CodemanApp as unknown as new () => Record<string, any>)();
const toasts: { message: string; type: string }[] = [];
app.showToast = (message: string, type: string) => toasts.push({ message, type });
app._copyText = vi.fn(async () => true);
app.loadAppSettingsFromStorage = () => ({ autoCopySelection: true });
const setSelection = (selection: string, { startX = 0, columnMode = false } = {}) => {
app.terminal = {
hasSelection: () => !!selection,
getSelection: vi.fn(() => selection),
getSelectionPosition: () => ({ start: { x: startX, y: 0 }, end: { x: 0, y: 1 } }),
clearSelection: vi.fn(),
focus: vi.fn(),
_core: { _selectionService: { _activeSelectionMode: columnMode ? 3 : 0 } },
};
return app.terminal;
};
return { app, windowRef, toasts, setSelection };
}
const clean = (text: unknown, startedMidRow = false) =>
loadHarness().windowRef.CodemanCopySelection.clean(text, { startedMidRow });
describe('CodemanCopySelection.clean — trailing padding', () => {
it('drops the padding a full-screen TUI writes across the rest of each row', () => {
expect(clean('hello \nworld ')).toBe('hello\nworld');
});
it('drops it from a single-row selection too', () => {
expect(clean('hello ')).toBe('hello');
});
it('keeps the line endings xterm chose, including the Windows \\r\\n', () => {
expect(clean('hello \r\nworld \r\n')).toBe('hello\r\nworld\r\n');
});
it('drops trailing tabs as well as trailing spaces', () => {
expect(clean('hello \t \nworld')).toBe('hello\nworld');
});
it('leaves a line that has no padding untouched', () => {
expect(clean('hello\nworld')).toBe('hello\nworld');
});
});
describe('CodemanCopySelection.clean — shared leading indent', () => {
it('removes the indent every selected row shares', () => {
expect(clean(' first line\n second line')).toBe('first line\nsecond line');
});
it('keeps the relative indentation of anything nested inside the block', () => {
expect(clean(' outer\n inner\n outer again')).toBe('outer\n inner\nouter again');
});
it('is a no-op when the rows share no indent, as shell output does not', () => {
expect(clean('$ ls\n indented output\ndone')).toBe('$ ls\n indented output\ndone');
});
it('is not disabled by a blank row in the middle of the block', () => {
expect(clean(' first \n \n second ')).toBe('first\n\nsecond');
});
it('does not eat a \\r when the row is blank and the shared indent is wider', () => {
expect(clean(' first\r\n\r\n second\r\n')).toBe('first\r\n\r\nsecond\r\n');
});
it('treats a leading tab as no indent at all, so nothing is stripped', () => {
expect(clean('\tfirst\n second')).toBe('\tfirst\n second');
});
});
describe('CodemanCopySelection.clean — one row keeps its own indent', () => {
// A single row shares its leading run with nothing, so that run is content.
// Stripping it would silently reindent one line of `git log` body text or one
// line read out of `less`.
it('leaves the indent on a single-row selection', () => {
expect(clean(' hello world ')).toBe(' hello world');
});
it('leaves it on a single row followed by a blank row', () => {
expect(clean(' hello world\n ')).toBe(' hello world\n');
});
it('strips as soon as a second row carries content', () => {
expect(clean(' hello\n world')).toBe('hello\nworld');
});
});
describe('CodemanCopySelection.clean — a drag that began inside a row', () => {
it('measures the shared indent without the partial first line', () => {
expect(clean('That sample is clean.\n the next row continues.', true)).toBe(
'That sample is clean.\nthe next row continues.'
);
});
it('leaves the partial first line exactly as it is, indent included', () => {
expect(clean(' already mid-row\n following row\n deeper row', true)).toBe(
' already mid-row\nfollowing row\n deeper row'
);
});
});
describe('CodemanCopySelection.clean — nothing to clean', () => {
it('returns an empty string for an empty selection', () => {
expect(clean('')).toBe('');
});
it('returns an empty string rather than throwing on a non-string', () => {
expect(clean(undefined)).toBe('');
expect(clean(null)).toBe('');
});
it('reduces an all-padding selection to its line breaks alone', () => {
// The copy paths reject this with trim(); the transform itself only removes
// whitespace, so the row structure survives here by design.
expect(clean(' \n \n ')).toBe('\n\n');
});
});
describe('cleanedTerminalSelection — wiring', () => {
it('strips the shared indent when the drag began at column 0', () => {
const { app, setSelection } = loadHarness();
setSelection(' first\n second');
expect(app.cleanedTerminalSelection()).toBe('first\nsecond');
});
it('spares the first line when the drag began inside a row', () => {
const { app, setSelection } = loadHarness();
setSelection('first\n second', { startX: 6 });
expect(app.cleanedTerminalSelection()).toBe('first\nsecond');
});
it('uses the text it is given without reading the selection again', () => {
const { app, setSelection } = loadHarness();
// The contract is that `text` IS the live selection, so the stub agrees with
// it; the assertion that carries weight is that getSelection went unread.
const terminal = setSelection(' given text \n second row ');
expect(app.cleanedTerminalSelection(' given text \n second row ')).toBe('given text\nsecond row');
expect(terminal.getSelection).not.toHaveBeenCalled();
});
it('returns an empty string when there is no selection at all', () => {
const { app, setSelection } = loadHarness();
setSelection('');
expect(app.cleanedTerminalSelection()).toBe('');
});
it('leaves a column selection completely untouched', () => {
// Alt+drag makes a rectangle, and its rows lining up is the whole point:
// both halves of the clean would destroy that alignment.
const { app, setSelection } = loadHarness();
const rect = ' alpha \n beta \n gamma ';
setSelection(rect, { startX: 40, columnMode: true });
expect(app.cleanedTerminalSelection()).toBe(rect);
});
});
describe('the xterm internals the column check depends on', () => {
// The column check reads a private field and compares it to a literal, because
// xterm publishes the selection mode nowhere. A rename or a renumber would make
// every rectangular selection get cleaned with both rules and lose the column
// alignment the rule exists to protect, and the fallback is silent by design.
// So the assumption is pinned against the real library rather than only against
// a stub that repeats it. lib/xterm.js is the esbuild input for the shipped
// vendor bundle, so it is the file that decides what runs in the browser.
const xtermLib = readFileSync(resolve(import.meta.dirname, '../node_modules/@xterm/xterm/lib/xterm.js'), 'utf8');
it('still branches on _activeSelectionMode === 3 for a column selection', () => {
expect(xtermLib).toContain('3===this._activeSelectionMode');
});
it('still reaches that field through _selectionService', () => {
expect(xtermLib).toContain('_selectionService');
});
});
describe('copyTerminalSelection — what reaches the clipboard', () => {
it('copies the cleaned text, never the padded rows', () => {
const { app, setSelection } = loadHarness();
setSelection(' first line \n second line ');
return app.copyTerminalSelection().then((ok: boolean) => {
expect(ok).toBe(true);
expect(app._copyText).toHaveBeenCalledWith('first line\nsecond line');
});
});
it('cleans a realistic TUI block on both rules at once', () => {
const { app, setSelection } = loadHarness();
const pane = [' That last point is the important one. ', ' Claude Code writes each paragraph. '].join(
'\n'
);
setSelection(pane);
return app.copyTerminalSelection().then(() => {
expect(app._copyText).toHaveBeenCalledWith(
'That last point is the important one.\nClaude Code writes each paragraph.'
);
});
});
it('clears a padding-only selection so Ctrl+C goes back to interrupting', () => {
// The Ctrl+C gate tests the RAW selection. Leaving a padding-only selection
// set would make every later Ctrl+C copy nothing instead of interrupting.
const { app, toasts, setSelection } = loadHarness();
const terminal = setSelection(' ');
return app.copyTerminalSelection().then((ok: boolean) => {
expect(ok).toBe(false);
expect(app._copyText).not.toHaveBeenCalled();
expect(terminal.clearSelection).toHaveBeenCalledTimes(1);
expect(toasts).toEqual([{ message: 'Nothing to copy', type: 'warning' }]);
});
});
it('rejects a multi-row padding selection instead of copying bare newlines', () => {
const { app, setSelection } = loadHarness();
const terminal = setSelection(' \n \n ');
return app.copyTerminalSelection().then((ok: boolean) => {
expect(ok).toBe(false);
expect(app._copyText).not.toHaveBeenCalled();
expect(terminal.clearSelection).toHaveBeenCalledTimes(1);
});
});
});
describe('_flushAutoCopySelection — cleaned text is what Auto Copy handles', () => {
it('copies the cleaned text and remembers it for the dedupe', () => {
const { app, setSelection } = loadHarness();
setSelection(' first line \n second line ');
app._autoCopyPending = true;
return app._flushAutoCopySelection().then(() => {
expect(app._copyText).toHaveBeenCalledWith('first line\nsecond line');
expect(app._autoCopyLastText).toBe('first line\nsecond line');
});
});
it('reads nothing at all while the toggle is off', () => {
// Auto Copy defaults to OFF, and a selection can run to the scrollback
// ceiling, so the flush must not read or clean before it checks.
const { app, setSelection } = loadHarness();
const terminal = setSelection(' first line \n second line ');
terminal.getSelection = vi.fn(() => ' first line ');
app.loadAppSettingsFromStorage = () => ({ autoCopySelection: false });
app._autoCopyPending = true;
return app._flushAutoCopySelection().then(() => {
expect(terminal.getSelection).not.toHaveBeenCalled();
expect(app._copyText).not.toHaveBeenCalled();
});
});
});