Merge pull request #395 from irisitymichaelgrundberg/fix/full-history-replay-row-alignment

fix(terminal): keep row alignment in the full-history pane replay
This commit is contained in:
Ark0N
2026-09-10 02:58:42 +02:00
committed by GitHub
7 changed files with 287 additions and 51 deletions
+6 -1
View File
@@ -137,7 +137,12 @@ export interface RespawnPaneOptions {
/** Options for pane buffer capture (COD-47 full-history mode). */
export interface PaneCaptureOptions {
/** Capture the entire tmux scrollback instead of just the visible frame. */
/**
* Capture the entire scrollback instead of just the visible frame, as linear
* text ending with a cursor move back to the pane's caret position. An
* implementation returns '' when the pane holds nothing visible, which the
* caller reads as "nothing to replay" and keeps its existing history.
*/
fullHistory?: boolean;
/** Bound the full-history capture to this many scrollback lines (`-S -<N>`). */
historyLimitLines?: number;
+108 -39
View File
@@ -528,6 +528,73 @@ export function normalizeScrollbackEol(buffer: string): string {
return buffer.replace(/\r?\n/g, '\r\n');
}
/** Pane geometry and caret position, as `display-message` reports them. */
interface PaneCursorGeometry {
cols: number;
rows: number;
cursorX: number;
cursorY: number;
}
/**
* Read the pane's cursor and size, or null when tmux cannot say.
*
* Every field is validated together: a caller that gets a value back can place
* a caret with it, and one that gets null must not try.
*/
export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
let raw: string;
try {
raw = run().trim();
} catch (cursorErr) {
console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr);
return null;
}
const [cursorX, cursorY, cols, rows] = raw.split(/\s+/).map((value) => parseInt(value, 10));
if (
!Number.isFinite(cursorX) ||
!Number.isFinite(cursorY) ||
!Number.isFinite(cols) ||
!Number.isFinite(rows) ||
cursorX < 0 ||
cursorY < 0 ||
cols <= 0 ||
rows <= 0
) {
return null;
}
return { cols, rows, cursorX, cursorY };
}
/** SGR attributes, which is all `capture-pane -e` emits. */
// eslint-disable-next-line no-control-regex
const CAPTURE_STYLE_SEQUENCE = /\x1b\[[0-9;:]*m/g;
/** Whether a capture holds anything a reader would see, styles discounted. */
export function hasVisibleContent(capture: string): boolean {
return /\S/.test(capture.replace(CAPTURE_STYLE_SEQUENCE, ''));
}
/**
* Put the caret back where the pane has it, counting UP from the bottom of what
* was just replayed.
*
* Relative rather than absolute (`CUP`) on purpose. `\x1b[<row>;<col>H` numbers
* rows from the top of the browser's screen, so it only lands correctly while
* the browser's row count equals the pane's — and it need not, because
* `resizeWindow` fires its tmux resize without waiting, so a capture can be
* taken before a requested resize has been applied. Counting up from the last
* replayed row is anchored to the content instead, which is the thing both ends
* genuinely share.
*/
export function formatCursorRestore(geometry: PaneCursorGeometry): string {
const up = Math.max(0, geometry.rows - 1 - geometry.cursorY);
const right = Math.max(0, geometry.cursorX);
// `\r` first so the column is known: the replay leaves the caret wherever the
// last row's text ended.
return `${up > 0 ? `\x1b[${up}A` : ''}\r${right > 0 ? `\x1b[${right}C` : ''}`;
}
export function formatPaneSnapshot(
lines: string[],
geometry: { cols: number; rows: number; cursorX: number; cursorY: number }
@@ -3199,11 +3266,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* the browser xterm reproduces the live frame. Used for fast tab switches.
* - Full history (`opts.fullHistory`): `capture-pane -p -e -J -S -<N>` grabs
* the tmux scrollback (COD-47, bounded to the configured history limit),
* returned as linear scrollback text with SGR codes preserved (NOT
* repositioned — a multi-screen history can't be painted into a single
* visible frame, so the snapshot repaint is skipped). `-J` re-joins lines
* hard-wrapped at the pane width so they reflow in the browser xterm.
* Used for full page reloads so the user gets back their scroll history.
* returned as linear scrollback text with SGR codes preserved. Rows are not
* repainted at absolute positions — a multi-screen history can't be painted
* into a single visible frame — but the capture DOES end with a cursor move
* putting the caret back where the pane has it, counted up from the last
* replayed row. `-J` re-joins lines hard-wrapped at the pane width so they
* reflow in the browser xterm. Used for full page reloads so the user gets
* back their scroll history. Returns '' for a pane holding nothing visible,
* so the caller keeps whatever history it already had.
* Caveat: lines tmux has already evicted past its history-limit are gone.
*/
/**
@@ -3262,42 +3332,41 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
execOpts.maxBuffer =
(opts?.maxCaptureBytes ?? DEFAULT_TERMINAL_BUFFER_MAX_BYTES) + FULL_HISTORY_CAPTURE_SLACK_BYTES;
}
const buffer = execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts).replace(
/\n+$/g,
''
);
// Full-history spans many screens — return it as raw linear scrollback
// rather than repainting rows at single-screen absolute positions. tmux
// joins scrollback rows with a bare `\n`; normalize to `\r\n` so a fresh
// xterm (convertEol:false) starts each replayed line at column 0 instead
// of staircasing diagonally (COD-138).
if (fullHistory) {
return normalizeScrollbackEol(buffer);
}
try {
const cursor = execSync(
const rawCapture = execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts);
// 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
// appended, and those two have to agree: trailing blank rows are only safe
// to keep when a move follows to put the caret back above them.
const geometry = queryPaneCursor(() =>
execSync(
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}'`,
{
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}
).trim();
const [cursorX, cursorY, cols, rows] = cursor.split(/\s+/).map((value) => parseInt(value, 10));
if (
Number.isFinite(cursorX) &&
Number.isFinite(cursorY) &&
Number.isFinite(cols) &&
Number.isFinite(rows) &&
cursorX >= 0 &&
cursorY >= 0 &&
cols > 0 &&
rows > 0
) {
return formatPaneSnapshot(buffer.split('\n'), { cols, rows, cursorX, cursorY });
}
} catch (cursorErr) {
console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr);
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
)
);
if (fullHistory) {
// Without geometry there is no cursor move, so fall back to the old trim.
// Keeping the blank rows here would park the caret at the bottom of the
// pane with nothing to correct it — worse than not trying at all.
if (!geometry) return normalizeScrollbackEol(rawCapture.replace(/\n+$/g, ''));
// Take the line terminator off and nothing else. The trailing blank rows
// that remain are the real bottom of the screen, and the cursor move
// below counts up from it. tmux joins rows with a bare `\n`; normalize to
// `\r\n` so a fresh xterm (convertEol:false) starts each replayed line at
// column 0 instead of staircasing diagonally (COD-138).
const trimmed = rawCapture.replace(/\n$/, '');
// An all-blank pane has to keep reading as "nothing to replay". The caller
// treats an empty string as "capture unavailable" and keeps the byte
// history; blank rows plus a cursor move are not empty, so without this a
// blank pane REPLACES that history with a blank screen — the downgrade
// `_replayWouldShrinkBuffer` exists to refuse, arriving from the server
// side where that guard cannot see it.
if (!hasVisibleContent(trimmed)) return '';
return `${normalizeScrollbackEol(trimmed)}${formatCursorRestore(geometry)}`;
}
const buffer = rawCapture.replace(/\n+$/g, '');
if (geometry) return formatPaneSnapshot(buffer.split('\n'), geometry);
// Cursor query failed or geometry was invalid, so we skip the absolute-
// positioned snapshot repaint and fall back to the raw capture. Normalize
// its bare `\n` line endings to `\r\n` so the replay doesn't staircase
+24 -3
View File
@@ -2603,6 +2603,12 @@ export function registerSessionRoutes(
? 'mux-full-history'
: 'mux-visible'
: 'history';
// What the three row-preserving skips below must key on. `isFullReload` is
// only what the CLIENT ASKED FOR: when the capture comes back null — ENOBUFS,
// a timeout, a vanished pane, or a session with no mux at all — rawBuffer
// falls back to the byte history, which is a stream of successive frames with
// no row alignment to protect and every reason to be stripped.
const isFullCapture = isFullReload && hasLiveMuxBuffer;
let rawBuffer: string;
if (liveMuxBuffer !== null && liveMuxBuffer.length > 0) {
// Full-history capture is the RENDERED form of everything already in the
@@ -2649,8 +2655,16 @@ export function registerSessionRoutes(
// During long thinking phases, Ink rewrites the same rows thousands of times
// (500KB+). Without stripping, tail mode returns only spinner frames and
// the terminal appears empty when switching tabs.
// A full reload's buffer IS the rendered pane, one line per screen row, and
// it ends with an absolute cursor move back to the pane's own position.
// Every transform below that can DELETE A LINE would shift the rows out from
// under that position, leaving the caret a row off — on the composer's
// border rather than its input line. Redraw-bloat stripping exists for a
// byte stream of successive frames; a capture holds no successive frames.
let strippedBuffer =
getCli(session.mode)?.capabilities.stripInkBloat === false ? rawBuffer : stripInkRedrawBloat(rawBuffer);
isFullCapture || getCli(session.mode)?.capabilities.stripInkBloat === false
? rawBuffer
: stripInkRedrawBloat(rawBuffer);
// Strip alt-screen toggles and scrollback-erase from Codex/Claude byte
// streams. xterm.js obeys them by switching to its scrollback-less alt
@@ -2688,7 +2702,10 @@ export function registerSessionRoutes(
cleanBuffer = strippedBuffer;
// Find where Claude banner starts (has color codes before "Claude")
const claudeMatch = cleanBuffer.match(CLAUDE_BANNER_PATTERN);
// Skipped for a full reload: the banner sits at whatever row the pane has
// it, and cutting to it would drop the blank rows above and move every
// row up by that many.
const claudeMatch = isFullCapture ? null : cleanBuffer.match(CLAUDE_BANNER_PATTERN);
if (claudeMatch && claudeMatch.index !== undefined && claudeMatch.index > 0) {
let lineStart = claudeMatch.index;
while (lineStart > 0 && cleanBuffer[lineStart - 1] !== '\n') {
@@ -2699,7 +2716,11 @@ export function registerSessionRoutes(
}
// Remove Ctrl+L and leading whitespace (cheap on tailed subset)
cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, '').replace(LEADING_WHITESPACE_PATTERN, '');
// Leading whitespace goes too, except on a full reload where a leading
// blank line is the pane's own first row and dropping it shifts every row
// up by one.
cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, '');
if (!isFullCapture) cleanBuffer = cleanBuffer.replace(LEADING_WHITESPACE_PATTERN, '');
const finishedAt = performance.now();
reply.header(