Merge PR #148 from aakhter/cod-164-scrollback-replay-crlf: replay full tmux scrollback on terminal reload + CRLF normalization

Includes review fixes: explicit ?full=1 trigger wired from initial page load, capture maxBuffer sized from config with -S line bound, capture returned alone (no byte-buffer duplication), early byte-cap before normalization.
This commit is contained in:
Codeman maintainer
2026-07-12 20:07:35 +02:00
7 changed files with 631 additions and 34 deletions
+25 -4
View File
@@ -105,6 +105,20 @@ export interface RespawnPaneOptions {
remote?: SessionRemote;
}
/** Options for pane buffer capture (COD-47 full-history mode). */
export interface PaneCaptureOptions {
/** Capture the entire tmux scrollback instead of just the visible frame. */
fullHistory?: boolean;
/** Bound the full-history capture to this many scrollback lines (`-S -<N>`). */
historyLimitLines?: number;
/**
* Byte cap the consumer will keep from the capture. Sizes the child-process
* stdout buffer (with slack) so multi-MB scrollback dumps aren't killed by
* the 1MB execSync default (ENOBUFS).
*/
maxCaptureBytes?: number;
}
/**
* Terminal multiplexer interface.
*
@@ -232,9 +246,16 @@ export interface TerminalMultiplexer extends EventEmitter {
/** Respawn a dead pane with a fresh command. Returns the new PID or null on failure. */
respawnPane(options: RespawnPaneOptions): Promise<number | null>;
/** Capture a pane's current tmux buffer with ANSI escape codes preserved. */
capturePaneBuffer?(muxName: string, paneTarget: string): string | null;
/**
* Capture a pane's current tmux buffer with ANSI escape codes preserved.
* Pass `{ fullHistory: true }` to capture the entire scrollback as linear
* text instead of just the visible single-screen frame (COD-47).
*/
capturePaneBuffer?(muxName: string, paneTarget?: string, opts?: PaneCaptureOptions): string | null;
/** Capture the active pane's current tmux buffer with ANSI escape codes preserved. */
captureActivePaneBuffer?(muxName: string): string | null;
/**
* Capture the active pane's current tmux buffer with ANSI escape codes preserved.
* Pass `{ fullHistory: true }` to capture the entire scrollback (COD-47).
*/
captureActivePaneBuffer?(muxName: string, opts?: PaneCaptureOptions): string | null;
}
+93 -19
View File
@@ -60,6 +60,7 @@ import type {
MuxSessionWithStats,
CreateSessionOptions,
RespawnPaneOptions,
PaneCaptureOptions,
} from './mux-interface.js';
// ============================================================================
@@ -67,7 +68,15 @@ import type {
// ============================================================================
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js';
import { DEFAULT_TMUX_HISTORY_LIMIT, DEFAULT_TERMINAL_BUFFER_MAX_BYTES } from './config/terminal-history.js';
/**
* Extra stdout headroom for the full-history `capture-pane` child process on
* top of the consumer's byte cap: raw scrollback carries per-line SGR/ANSI
* overhead that the route pipeline strips before applying its cap, so the
* capture must be allowed to exceed the final payload size.
*/
const FULL_HISTORY_CAPTURE_SLACK_BYTES = 8 * 1024 * 1024;
/** Delay after tmux session creation — enough for detached tmux to be queryable */
const TMUX_CREATION_WAIT_MS = 100;
@@ -432,6 +441,26 @@ function truncatePaneLineByVisibleColumns(line: string, maxColumns: number): str
return result;
}
/**
* Normalize scrollback line endings to `\r\n` so a fresh xterm replays each line
* at column 0 (COD-138).
*
* `capture-pane -p -e -S -` (full-history capture) joins scrollback rows with a
* BARE `\n`. The browser xterm is created with the default `convertEol: false`
* (correct for the live PTY stream, which already carries real `\r\n`), so a bare
* `\n` drops a row without returning the cursor to column 0. Replaying that raw
* buffer on a full page reload makes every line start one column further right —
* the diagonal "staircase". The visible/tab-switch path avoids this by repainting
* each row with an absolute cursor CSI (`formatPaneSnapshot`); the full-history
* path returns raw scrollback, so it must be CRLF-normalized here.
*
* `\r?\n → \r\n` is idempotent on already-CRLF input and leaves a lone `\r` (an
* intentional in-line column reset / overwrite) untouched.
*/
export function normalizeScrollbackEol(buffer: string): string {
return buffer.replace(/\r?\n/g, '\r\n');
}
export function formatPaneSnapshot(
lines: string[],
geometry: { cols: number; rows: number; cursorX: number; cursorY: number }
@@ -2327,30 +2356,65 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
/**
* Capture the current visible text and SGR styles of a specific pane.
* Capture a pane's text and SGR styles.
*
* `capture-pane -e` is sanitized by `formatPaneSnapshot`: SGR color/style
* codes are preserved, while cursor/erase/scroll-region controls are stripped
* before rows are repainted at absolute positions in browser xterm.
* Two modes:
* - Visible (default): `capture-pane -p -e` grabs only the on-screen frame,
* then `formatPaneSnapshot` repaints each row at its absolute position so
* 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.
* Caveat: lines tmux has already evicted past its history-limit are gone.
*/
capturePaneBuffer(muxName: string, paneTarget: string): string | null {
capturePaneBuffer(muxName: string, paneTarget?: string, opts?: PaneCaptureOptions): string | null {
if (IS_TEST_MODE) return '';
if (!isValidMuxName(muxName)) {
console.error('[TmuxManager] Invalid session name in capturePaneBuffer:', muxName);
return null;
}
if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) {
console.error('[TmuxManager] Invalid pane target:', paneTarget);
const target = resolveTmuxPaneTarget(muxName, paneTarget);
if (!target) {
console.error('[TmuxManager] Invalid pane target in capturePaneBuffer:', { muxName, paneTarget });
return null;
}
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
const fullHistory = opts?.fullHistory === true;
try {
const buffer = execSync(`${this.tmux()} capture-pane -p -e -t ${shellescape(target)}`, {
// `-S -<N>` starts the capture N lines above the visible frame (tmux
// clamps to the top of history), so tmux never serializes more scrollback
// than the configured history limit retains.
const requestedLines = opts?.historyLimitLines;
const historyLines =
typeof requestedLines === 'number' && Number.isFinite(requestedLines) && requestedLines > 0
? Math.trunc(requestedLines)
: DEFAULT_TMUX_HISTORY_LIMIT;
const captureFlags = fullHistory ? `capture-pane -p -e -J -S -${historyLines}` : 'capture-pane -p -e';
// execSync's default maxBuffer (1MB) kills multi-MB scrollback dumps
// (ENOBUFS) and would silently degrade full-history capture to the byte
// buffer for exactly the long sessions it exists for — size it from the
// consumer's byte cap plus ANSI-overhead slack instead.
const execOpts: { encoding: 'utf-8'; timeout: number; maxBuffer?: number } = {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).replace(/\n+$/g, '');
};
if (fullHistory) {
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(
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}'`,
@@ -2375,9 +2439,19 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} catch (cursorErr) {
console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr);
}
return buffer;
// 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
// diagonally in a fresh xterm (COD-138, same reason as the fullHistory path).
return normalizeScrollbackEol(buffer);
} catch (err) {
console.error('[TmuxManager] Failed to capture pane buffer:', err);
// ENOBUFS carries the truncated multi-MB stdout on the error object —
// log a concise line instead of dumping it into the journal.
if ((err as NodeJS.ErrnoException)?.code === 'ENOBUFS') {
console.error('[TmuxManager] Pane capture exceeded maxBuffer (ENOBUFS); falling back to byte history');
} else {
console.error('[TmuxManager] Failed to capture pane buffer:', err);
}
return null;
}
}
@@ -2388,7 +2462,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* Pane ids are not stable across respawns or restores, so callers should not
* assume the first pane remains `%0`.
*/
captureActivePaneBuffer(muxName: string): string | null {
captureActivePaneBuffer(muxName: string, opts?: PaneCaptureOptions): string | null {
if (IS_TEST_MODE) return '';
if (!isValidMuxName(muxName)) {
console.error('[TmuxManager] Invalid session name in captureActivePaneBuffer:', muxName);
@@ -2401,7 +2475,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
timeout: EXEC_TIMEOUT_MS,
}).trim();
const target = resolveActivePaneTarget(output);
return target ? this.capturePaneBuffer(muxName, target) : null;
return target ? this.capturePaneBuffer(muxName, target, opts) : null;
} catch (err) {
console.error('[TmuxManager] Failed to resolve active pane for capture:', err);
return null;
+11 -1
View File
@@ -477,6 +477,7 @@ class CodemanApp {
this._initGeneration = 0; // dedup concurrent handleInit calls
this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive
this._selectGeneration = 0; // cancel stale selectSession loads
this._initialFullBufferLoad = true; // first buffer load after a page load fetches full tmux scrollback (COD-47)
this.terminalLoadStates = new Map(); // Map<sessionId, { generation, phase }>
this.respawnStatus = {};
this.respawnTimers = {}; // Track timed respawn timers
@@ -3911,7 +3912,16 @@ class CodemanApp {
this._setTerminalLoadState(sessionId, selectGen, 'fetching');
_crashDiag.log('FETCH_START');
const res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
// The FIRST buffer load after a page load requests the full tmux scrollback
// (?full=1, COD-47) so history that scrolled off the server's byte buffer
// comes back after a reload. Tab switches keep the fast ?tail= frame path.
const useFullHistory = this._initialFullBufferLoad === true;
this._initialFullBufferLoad = false;
const res = await fetch(
useFullHistory
? `/api/sessions/${sessionId}/terminal?full=1`
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`
);
if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen);
return;
+54 -9
View File
@@ -1016,11 +1016,22 @@ export function registerSessionRoutes(
// Query params:
// tail=<bytes> - Only return last N bytes (faster initial load)
// full=1 - Full page reload: replay the entire tmux scrollback (COD-47)
app.get('/api/sessions/:id/terminal', async (req) => {
const { id } = req.params as { id: string };
const query = req.query as { tail?: string };
const query = req.query as { tail?: string; full?: string };
const session = findSessionOrFail(ctx, id);
// `full=1` is the EXPLICIT full-reload signal (COD-47): the browser reloaded
// the page and wants the whole scroll history back, so we capture the ENTIRE
// tmux scrollback and the user gets back history that scrolled off Codeman's
// byte buffer. Requests WITHOUT it — tab switches (`tail=`) and the legacy
// no-param callers (response-viewer fallback, clearTerminal refresh) — keep
// the fast visible-frame capture.
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
const isFullReload = query.full === '1' || query.full === 'true';
const { tmuxHistoryLimit, terminalBufferMaxBytes } = await ctx.getTerminalHistoryConfig();
// Prepend the live tmux pane buffer so tab-switch replay shows the current
// on-screen frame, not just the accumulated byte history. This matters for
// TUI modes (codex/opencode) that repaint only their latest frame: the
@@ -1031,24 +1042,57 @@ export function registerSessionRoutes(
const muxName = session.muxName;
const liveMuxBuffer =
muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
? ctx.mux.captureActivePaneBuffer(muxName)
? ctx.mux.captureActivePaneBuffer(
muxName,
isFullReload
? { fullHistory: true, historyLimitLines: tmuxHistoryLimit, maxCaptureBytes: terminalBufferMaxBytes }
: undefined
)
: null;
const rawBuffer =
liveMuxBuffer !== null && liveMuxBuffer.length > 0
? session.terminalBufferLength > 0
const hasLiveMuxBuffer = liveMuxBuffer !== null && liveMuxBuffer.length > 0;
const source: 'history' | 'mux-visible' | 'mux-full-history' = hasLiveMuxBuffer
? isFullReload
? 'mux-full-history'
: 'mux-visible'
: 'history';
let rawBuffer: string;
if (liveMuxBuffer !== null && liveMuxBuffer.length > 0) {
// Full-history capture is the RENDERED form of everything already in the
// byte buffer (up to tmux eviction) — return it alone. Prepending the byte
// history would replay the whole conversation twice: `\x1b[2J` clears only
// the viewport, not xterm scrollback. The history+clear+frame concat stays
// for the visible-frame path, where the single pane frame lacks history.
rawBuffer = isFullReload
? liveMuxBuffer
: session.terminalBufferLength > 0
? `${session.terminalBuffer}\x1b[H\x1b[2J${liveMuxBuffer}`
: liveMuxBuffer
: session.terminalBuffer;
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
: liveMuxBuffer;
} else {
rawBuffer = session.terminalBuffer;
}
const fullSize = rawBuffer.length;
let truncated = false;
let cleanBuffer: string;
// Cap the payload EARLY — before the regex normalization passes below run
// over it. A full-history tmux capture can be tens of MB of scrollback;
// normalizing all of it would stall the event loop only to discard most
// bytes anyway. Keep the most RECENT bytes (slice from the end) and align
// to a line boundary so we never start mid-ANSI-escape.
if (terminalBufferMaxBytes > 0 && rawBuffer.length > terminalBufferMaxBytes) {
rawBuffer = rawBuffer.slice(-terminalBufferMaxBytes);
truncated = true;
const capNewline = rawBuffer.indexOf('\n');
if (capNewline > 0 && capNewline < 4096) {
rawBuffer = rawBuffer.slice(capNewline + 1);
}
}
// Strip redundant Ink spinner/status redraws BEFORE tailing.
// 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.
let strippedBuffer = stripInkRedrawBloat(rawBuffer);
let strippedBuffer = session.mode === 'shell' ? 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
@@ -1096,6 +1140,7 @@ export function registerSessionRoutes(
status: session.status,
fullSize,
truncated,
source,
};
});