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<marker>\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 `<marker>\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) <noreply@anthropic.com>
This commit is contained in:
timkjrandClaude Opus 5.5 committed 2026-10-11 18:54:20 -05:00
1 parent 8e2594a274
commit 8d92e577f8
4 files changed
+88 -38

No files matched your search

+9 -2
View File
@@ -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. `$` 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 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. 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. 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 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 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 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 (`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 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 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. route trims what it returns to the cap itself.
+25 -21
View File
@@ -28,6 +28,8 @@
* rows). This rests on how tmux's command queue behaves, not on a documented * 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 * 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. * 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 * 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 * 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(' '); 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. */ /** What one round trip read from the remote pane: its scrollback rows, then its screen. */
export interface RemoteScrollback { export interface RemoteScrollback {
/** History rows above the screen, oldest first; '' when the pane has none. */ /** 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 * 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, * 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( export function parseRemoteScrollbackOutput(
stdout: string, stdout: string,
@@ -188,23 +182,33 @@ export function parseRemoteScrollbackOutput(
if (start === -1) return null; if (start === -1) return null;
const end = stdout.indexOf('\0', start + 1); const end = stdout.indexOf('\0', start + 1);
if (end === -1) return null; 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 markerRow = `${marker}\n`;
const raw = stdout.slice(start + 1, end); const raw = stdout.slice(start + 1, end);
// A cut that landed exactly on the marker row left it whole: there is no const at = raw.indexOf(markerRow);
// partial history row to drop, and dropping the first row would lose it. if (at === -1) return null;
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 sizeStart = at + markerRow.length; const sizeStart = at + markerRow.length;
const sizeEnd = output.indexOf('\n', sizeStart); const sizeEnd = raw.indexOf('\n', sizeStart);
if (sizeEnd === -1) return null; 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; 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. // With no history at all, tmux clamps `-E -1` to the first visible row.
if (Number(sizeText) === 0) return { scrollback: '', screen }; 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. */ /** Runs the ssh command line and resolves its stdout; rejects like `exec` does. */
+6
View File
@@ -732,6 +732,12 @@ function capturedRowText(row: string): string {
* above it matches. A row that changed between the two reads (a spinner, a * 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 * clock) fails the true placement; the minimum keeps a lower one from being
* taken on a row or two that happen to be equal. * 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 { export function findFrameInScreen(screenRows: string[], frameRows: string[]): number {
const screen = screenRows.map(capturedRowText); const screen = screenRows.map(capturedRowText);
+48 -15
View File
@@ -17,7 +17,6 @@ import { tmpdir } from 'node:os';
import { import {
buildRemoteScrollbackCommand, buildRemoteScrollbackCommand,
buildRemoteScrollbackScript, buildRemoteScrollbackScript,
dropPartialFirstRow,
fetchRemoteScrollback, fetchRemoteScrollback,
newScrollbackMarker, newScrollbackMarker,
parseRemoteScrollbackOutput, 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', () => { describe('parseRemoteScrollbackOutput', () => {
const read = (tmuxOutput: string) => `\x00${tmuxOutput}\x00`; 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', () => { it('a cut that landed exactly on the marker row keeps it', () => {
const output = `${MARKER}\n3\nscreen\n`; const output = `${MARKER}\n3\nscreen\n`;
expect(parseRemoteScrollbackOutput(read(output), MARKER, Buffer.byteLength(output))).toEqual({ expect(parseRemoteScrollbackOutput(read(output), MARKER, Buffer.byteLength(output))).toEqual({
@@ -146,10 +144,31 @@ describe('parseRemoteScrollbackOutput', () => {
}); });
}); });
it('finds the marker only as a whole row', () => { it('tmux before 3.2: a marker glued onto an unterminated wrapped row ends that row', () => {
const output = `echo ${MARKER}\n${MARKER}\n2\nscreen\n`; // 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({ 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', screen: 'screen\n',
}); });
}); });
@@ -178,7 +197,8 @@ describe('the remote script under a real shell', () => {
callsFile = join(dir, 'calls'); callsFile = join(dir, 'calls');
// Stub tmux: logs its argv (one per line) and runs a `;`-separated command // 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, // 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( writeFileSync(
join(dir, 'tmux'), join(dir, 'tmux'),
[ [
@@ -189,7 +209,7 @@ describe('the remote script under a real shell', () => {
' case " $1 " in', ' case " $1 " in',
' *" #{history_size} "*) echo 2 ;;', ' *" #{history_size} "*) echo 2 ;;',
` *" display-message "*) printf '%s\\n' "$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" ;;', ' *" capture-pane "*) printf "screen row\\n" ;;',
' esac', ' 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', () => { 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-')); const failing = mkdtempSync(join(tmpdir(), 'remote-scrollback-fail-'));
try { try {