Merge pull request #609: fix(remote): close the row gaps at the join in a remote history load

Conflict resolved in tmux-manager.ts: #609's screen-aware
joinScrollbackAndFrame keeps #602's merge-time SGR reset before the frame,
and #609's join tests expect it. The #601/#602 route test now mocks the
remote read in #609's { scrollback, screen } shape.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-13 18:04:05 +02:00
commit 239f15437b
7 files changed
+555 -107

No files matched your search

+84 -6
View File
@@ -694,16 +694,93 @@ export function normalizeScrollbackEol(buffer: string): string {
return buffer.replace(/\r?\n/g, '\r\n');
}
/** `capture-pane -p` text as rows: tmux ends it with one `\n`, and '' has none. */
function captureRows(text: string): string[] {
return text === '' ? [] : text.replace(/\n$/, '').split('\n');
}
/**
* Least overlap that places a frame in a screen: rows compared, and how many
* of them hold text. A placement too LOW repeats the rows between the true one
* and it, so a short overlap (one `$` or `❯` row, a rule line) is not trusted:
* past this, the plain join, which can only lose rows, is the safer answer.
*/
const FRAME_MATCH_MIN_ROWS = 3;
const FRAME_MATCH_MIN_CONTENT_ROWS = 2;
/**
* A captured row as plain text: escape sequences (SGR, OSC 8 links) and
* trailing blanks dropped. Two tmuxes render the same cell content, but need
* not encode its attributes the same way.
*/
function capturedRowText(row: string): string {
// eslint-disable-next-line no-control-regex
return row.replace(/\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]/g, '').trimEnd();
}
/**
* Where a later frame of the same pane begins among the rows of an earlier
* screen capture, or -1 when that cannot be told.
*
* The frame's top rows must equal the screen's rows from that index on, as
* text. Two rows are left out of the comparison: the screen's trailing blank
* rows (output after the screen read fills them in the frame) and its last
* content row (output in flight may have finished it since). Every compared
* row must match, the overlap must reach FRAME_MATCH_MIN_ROWS rows with
* FRAME_MATCH_MIN_CONTENT_ROWS of them non-blank, and the SMALLEST index wins,
* so a block that merely repeats further down is only chosen when nothing
* 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);
const frame = frameRows.map(capturedRowText);
let lastContent = screen.length - 1;
while (lastContent >= 0 && screen[lastContent] === '') lastContent--;
for (let start = 0; start < lastContent; start++) {
const overlap = Math.min(lastContent - start, frame.length);
if (overlap < FRAME_MATCH_MIN_ROWS) break; // only shrinks from here on
let matches = true;
let contentRows = 0;
for (let k = 0; k < overlap && matches; k++) {
if (screen[start + k] !== frame[k]) matches = false;
else if (frame[k] !== '') contentRows++;
}
if (matches && contentRows >= FRAME_MATCH_MIN_CONTENT_ROWS) return start;
}
return -1;
}
/**
* Put scrollback rows read elsewhere (`PaneCaptureOptions.scrollbackOverride`)
* above a visible-frame capture, as one capture in tmux's own shape: rows joined
* by a bare `\n`. Undefined or empty scrollback leaves the frame as it is.
*
* `screen` is that other pane's screen, read right after its scrollback. The
* frame is captured later, so rows can have scrolled out of it in between, and
* a frame showing part of a taller screen starts below that screen's top.
* Either way the screen rows above the frame's first row are in neither the
* scrollback nor the frame, so they go between the two. When the frame cannot
* be placed in the screen ({@link findFrameInScreen}), the join is the plain
* scrollback-over-frame one.
*/
export function joinScrollbackAndFrame(scrollback: string | undefined, frame: string): string {
if (!scrollback) return frame;
// Two separate captures: tmux encodes the frame relative to the DEFAULT
// state, so the scrollback's last row must not leave its attributes active.
return `${scrollback.replace(/\n$/, '')}\x1b[0m\n${frame}`;
export function joinScrollbackAndFrame(scrollback: string | undefined, frame: string, screen?: string): string {
let above = scrollback ? captureRows(scrollback) : [];
if (screen) {
const screenRows = captureRows(screen);
const frameStart = findFrameInScreen(screenRows, captureRows(frame));
if (frameStart > 0) above = above.concat(screenRows.slice(0, frameStart));
}
// Separate captures: tmux encodes the frame relative to the DEFAULT state, so
// the rows above it must not leave their attributes active.
return above.length > 0 ? `${above.join('\n')}\x1b[0m\n${frame}` : frame;
}
/** Pane geometry and caret position, as `display-message` reports them. */
@@ -3933,7 +4010,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
const rawCapture = joinScrollbackAndFrame(
scrollbackOverride,
execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts)
execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts),
scrollbackOverride !== undefined ? opts?.scrollbackOverrideScreen : undefined
);
// Query the cursor BEFORE deciding anything else. On the full-history path
// it settles both how the capture is trimmed and whether a cursor move is