From 8d92e577f873f6b78244eda75352113f4b56d938 Mon Sep 17 00:00:00 2001 From: timkjr Date: Sun, 11 Oct 2026 18:54:20 -0500 Subject: [PATCH] fix(remote): accept the history marker glued to a row on tmux before 3.2 tmux before 3.2 prints no newline after `capture-pane -p` output, and `-J` leaves the history's last row unterminated when the screen starts inside a wrapped line. The marker row printed next is then glued onto that row (`...first half\n31\n...`). The parser took the marker only as a whole row, so it returned null and every load fell back to the local capture while the screen stayed that way. Debian 11, Ubuntu 20.04 and RHEL 8 ship tmux 3.1c, 3.0a and 2.7. The parser now takes the first `\n` wherever it ends a row (the marker is random per read, so no row the pane holds ends with it) and re-terminates a history that lacks its newline. A tail -c cut still drops only the history's partial first row, now decided on the whole output, so a cut inside the one row the marker is glued to leaves no history instead of losing the marker. dropPartialFirstRow, no longer called, is removed. Docs and the findFrameInScreen JSDoc also say the splice does not place the frame of an agent TUI mid-turn (a live region pinned to the bottom, new rows inserted above it), which falls back to the history-over-frame join. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/remote-sessions.md | 11 +++++- src/remote-pane-history.ts | 46 ++++++++++++----------- src/tmux-manager.ts | 6 +++ test/remote-pane-history.test.ts | 63 ++++++++++++++++++++++++-------- 4 files changed, 88 insertions(+), 38 deletions(-) diff --git a/docs/remote-sessions.md b/docs/remote-sessions.md index f050fed4..8b9587a3 100644 --- a/docs/remote-sessions.md +++ b/docs/remote-sessions.md @@ -290,7 +290,11 @@ would REPEAT the rows between it and the true one, so a shorter overlap (a lone `$` row that happens to match after a spinner row changed) is not trusted. When the frame cannot be placed (more output scrolled than the screen held, or nothing matches), the join is history over frame, as without the screen read. -What is left: when the screen repeats a whole block of rows, the frame can be +That includes an agent TUI mid-turn (Claude Code, codex, opencode): it keeps a +live region (spinner, composer, footer) pinned to the bottom and inserts new +rows above it, so the frame is not a shifted copy of the screen and is not +placed. A plain shell places. What is left: when the screen repeats a whole +block of rows, the frame can be placed at the higher copy, which leaves out the rows between the two copies. The history, the history size and the screen are ONE tmux command list @@ -302,7 +306,10 @@ those milliseconds were in neither capture (measured with output streaming: 1-2 rows per load on a LAN host, every read on a local busy pane). This rests on how tmux's command queue behaves, not on a documented guarantee. A random marker row (`display-message`) separates the history from the size and the screen, since -tmux output cannot carry the NUL markers around it. The remote `tail -c` bound +tmux output cannot carry the NUL markers around it. tmux before 3.2 prints no +newline after `capture-pane -p` output, so when the history ends inside a +wrapped line the marker is glued onto that row; the parser accepts the marker +at the end of a row and re-terminates the history. The remote `tail -c` bound applies to that whole output, history first, and is the configured byte cap plus 1 MiB for the marker, size and screen, so a cut lands in the history; the route trims what it returns to the cap itself. diff --git a/src/remote-pane-history.ts b/src/remote-pane-history.ts index 5f161744..f4a12e48 100644 --- a/src/remote-pane-history.ts +++ b/src/remote-pane-history.ts @@ -28,6 +28,8 @@ * rows). This rests on how tmux's command queue behaves, not on a documented * guarantee. tmux output cannot carry the NUL markers, so a random marker line * printed by `display-message` separates the history from the size and screen. + * tmux before 3.2 prints no newline after `capture-pane -p`, so the marker can be + * glued onto an unterminated last history row; the parser accepts it there too. * * Bounded and fail-soft. Any failure (unreachable host, auth, missing session, odd * output) returns null and the caller keeps the local capture, so a remote load is @@ -156,16 +158,6 @@ export function buildRemoteScrollbackCommand( return ['exec', ...buildSshConnectionArgs(remote), remoteSshTarget(remote), shellescape(script)].join(' '); } -/** - * A `tail -c` cut lands mid-row (possibly mid-escape) whenever it bit, so the - * first, partial row is dropped. Under the bound, the rows are whole. - */ -export function dropPartialFirstRow(scrollback: string, maxBytes: number): string { - if (maxBytes <= 0 || Buffer.byteLength(scrollback, 'utf-8') < maxBytes) return scrollback; - const newline = scrollback.indexOf('\n'); - return newline === -1 ? '' : scrollback.slice(newline + 1); -} - /** What one round trip read from the remote pane: its scrollback rows, then its screen. */ export interface RemoteScrollback { /** History rows above the screen, oldest first; '' when the pane has none. */ @@ -177,7 +169,9 @@ export interface RemoteScrollback { /** * The scrollback and screen rows from the script's stdout, or null when the * output is not the script's or tmux printed nothing usable (no such session, - * or a byte cap so small it cut into the screen). + * or a byte cap so small it cut into the screen). The marker may end an + * unterminated history row (tmux before 3.2); when the output reached + * `tailBytes`, the history's partial first row is dropped. */ export function parseRemoteScrollbackOutput( stdout: string, @@ -188,23 +182,33 @@ export function parseRemoteScrollbackOutput( if (start === -1) return null; const end = stdout.indexOf('\0', start + 1); if (end === -1) return null; - // The marker is a whole row of its own, after the history's last row. + // The marker ends a row, after the history's last row. Usually it is a row of + // its own, but tmux before 3.2 prints no newline after `capture-pane -p`, so + // when the history ends inside a wrapped line (`-J` leaves it unterminated) + // the marker is glued onto that row. It is random per read, so no row the + // pane really holds ends with it. const markerRow = `${marker}\n`; const raw = stdout.slice(start + 1, end); - // A cut that landed exactly on the marker row left it whole: there is no - // partial history row to drop, and dropping the first row would lose it. - const output = raw.startsWith(markerRow) ? raw : dropPartialFirstRow(raw, tailBytes); - const at = output.startsWith(markerRow) ? 0 : output.indexOf(`\n${markerRow}`) + 1; - if (at === 0 && !output.startsWith(markerRow)) return null; + const at = raw.indexOf(markerRow); + if (at === -1) return null; const sizeStart = at + markerRow.length; - const sizeEnd = output.indexOf('\n', sizeStart); + const sizeEnd = raw.indexOf('\n', sizeStart); if (sizeEnd === -1) return null; - const sizeText = output.slice(sizeStart, sizeEnd).trim(); + const sizeText = raw.slice(sizeStart, sizeEnd).trim(); if (!/^\d+$/.test(sizeText)) return null; - const screen = output.slice(sizeEnd + 1); + const screen = raw.slice(sizeEnd + 1); // With no history at all, tmux clamps `-E -1` to the first visible row. if (Number(sizeText) === 0) return { scrollback: '', screen }; - return { scrollback: output.slice(0, at), screen }; + // A tail -c cut lands in the history (the bound leaves room for the rest), + // mid-row and possibly mid-escape, so the first, partial row is dropped. A + // cut exactly on the marker row leaves no history at all. + let scrollback = raw.slice(0, at); + if (tailBytes > 0 && Buffer.byteLength(raw, 'utf-8') >= tailBytes) { + const newline = scrollback.indexOf('\n'); + scrollback = newline === -1 ? '' : scrollback.slice(newline + 1); + } + if (scrollback !== '' && !scrollback.endsWith('\n')) scrollback += '\n'; + return { scrollback, screen }; } /** Runs the ssh command line and resolves its stdout; rejects like `exec` does. */ diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index e409be7b..ab8d3859 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -732,6 +732,12 @@ function capturedRowText(row: string): string { * above it matches. A row that changed between the two reads (a spinner, a * clock) fails the true placement; the minimum keeps a lower one from being * taken on a row or two that happen to be equal. + * + * An agent TUI mid-turn (Claude Code, codex, opencode) is not placed: it keeps + * a live region (spinner, composer, footer) pinned to the bottom and inserts new + * rows above it, so the frame is not a shifted copy of the screen and the + * comparison down to the screen's last content row fails. The caller then falls + * back to the plain join. A plain shell places. */ export function findFrameInScreen(screenRows: string[], frameRows: string[]): number { const screen = screenRows.map(capturedRowText); diff --git a/test/remote-pane-history.test.ts b/test/remote-pane-history.test.ts index 33629f33..b8339f62 100644 --- a/test/remote-pane-history.test.ts +++ b/test/remote-pane-history.test.ts @@ -17,7 +17,6 @@ import { tmpdir } from 'node:os'; import { buildRemoteScrollbackCommand, buildRemoteScrollbackScript, - dropPartialFirstRow, fetchRemoteScrollback, newScrollbackMarker, parseRemoteScrollbackOutput, @@ -98,15 +97,6 @@ describe('newScrollbackMarker', () => { }); }); -describe('dropPartialFirstRow', () => { - it('drops the row a tail -c cut landed in, and only then', () => { - expect(dropPartialFirstRow('ow 1\nrow 2\n', 11)).toBe('row 2\n'); - expect(dropPartialFirstRow('row 1\nrow 2\n', 100)).toBe('row 1\nrow 2\n'); - expect(dropPartialFirstRow('no newline at all', 17)).toBe(''); - expect(dropPartialFirstRow('row 1\nrow 2\n', 0)).toBe('row 1\nrow 2\n'); - }); -}); - describe('parseRemoteScrollbackOutput', () => { const read = (tmuxOutput: string) => `\x00${tmuxOutput}\x00`; @@ -138,6 +128,14 @@ describe('parseRemoteScrollbackOutput', () => { }); }); + it('a history under the byte cap keeps every row', () => { + const output = `row 1\nrow 2\n${MARKER}\n7\nscreen\n`; + expect(parseRemoteScrollbackOutput(read(output), MARKER, Buffer.byteLength(output) + 1)).toEqual({ + scrollback: 'row 1\nrow 2\n', + screen: 'screen\n', + }); + }); + it('a cut that landed exactly on the marker row keeps it', () => { const output = `${MARKER}\n3\nscreen\n`; expect(parseRemoteScrollbackOutput(read(output), MARKER, Buffer.byteLength(output))).toEqual({ @@ -146,10 +144,31 @@ describe('parseRemoteScrollbackOutput', () => { }); }); - it('finds the marker only as a whole row', () => { - const output = `echo ${MARKER}\n${MARKER}\n2\nscreen\n`; + it('tmux before 3.2: a marker glued onto an unterminated wrapped row ends that row', () => { + // No newline after `capture-pane -p` output before 3.2, and -J leaves the + // history's last row unterminated when the screen starts mid-line. + const output = `row 1\nfirst half of long row${MARKER}\n31\nsecond half\n$ `; expect(parseRemoteScrollbackOutput(read(output), MARKER, 0)).toEqual({ - scrollback: `echo ${MARKER}\n`, + scrollback: 'row 1\nfirst half of long row\n', + screen: 'second half\n$ ', + }); + }); + + it('tmux before 3.2: a cut history with the glued marker drops only the partial first row', () => { + const output = `ow 1\nrow 2${MARKER}\n7\nscreen\n`; + expect(parseRemoteScrollbackOutput(read(output), MARKER, Buffer.byteLength(output))).toEqual({ + scrollback: 'row 2\n', + screen: 'screen\n', + }); + // No history: the clamped first visible row the marker is glued to is not kept. + expect(parseRemoteScrollbackOutput(read(`$ prompt${MARKER}\n0\n$ prompt\n`), MARKER, 0)).toEqual({ + scrollback: '', + screen: '$ prompt\n', + }); + // The cut landed in the only history row, the one the marker is glued to. + const single = `alf of long row${MARKER}\n7\nscreen\n`; + expect(parseRemoteScrollbackOutput(read(single), MARKER, Buffer.byteLength(single))).toEqual({ + scrollback: '', screen: 'screen\n', }); }); @@ -178,7 +197,8 @@ describe('the remote script under a real shell', () => { callsFile = join(dir, 'calls'); // Stub tmux: logs its argv (one per line) and runs a `;`-separated command // list in order, as tmux does: the history size, the marker it was given, - // two history rows, one screen row. + // two history rows, one screen row. With PRE32 set it prints like tmux + // before 3.2 with the screen starting mid-line: no newline after the history. writeFileSync( join(dir, 'tmux'), [ @@ -189,7 +209,7 @@ describe('the remote script under a real shell', () => { ' case " $1 " in', ' *" #{history_size} "*) echo 2 ;;', ` *" display-message "*) printf '%s\\n' "$2" ;;`, - ' *" -E -1 "*) printf "older row\\nnewer row\\n" ;;', + ' *" -E -1 "*) if [ -n "$PRE32" ]; then printf "older row\\nfirst half"; else printf "older row\\nnewer row\\n"; fi ;;', ' *" capture-pane "*) printf "screen row\\n" ;;', ' esac', '}', @@ -233,6 +253,19 @@ describe('the remote script under a real shell', () => { ); }); + it('tmux before 3.2: an unterminated last history row still parses, re-terminated', () => { + const script = buildRemoteScrollbackScript('codeman', 'old-host', 500, 0, MARKER); + const stdout = execFileSync('/bin/sh', ['-c', script], { + encoding: 'utf-8', + env: { ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}`, PRE32: '1' }, + }); + expect(stdout).toContain(`first half${MARKER}\n`); + expect(parseRemoteScrollbackOutput(stdout, MARKER, 0)).toEqual({ + scrollback: 'older row\nfirst half\n', + screen: 'screen row\n', + }); + }); + it('prints both NULs when tmux fails, so a missing session reads as the script having run', () => { const failing = mkdtempSync(join(tmpdir(), 'remote-scrollback-fail-')); try {