Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions src/mux-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,9 +225,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 (`-S -`)
* as linear text instead of just the visible single-screen frame (COD-47).
*/
capturePaneBuffer?(muxName: string, paneTarget?: string, opts?: { fullHistory?: boolean }): 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?: { fullHistory?: boolean }): string | null;
}
70 changes: 54 additions & 16 deletions src/tmux-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,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 }
Expand Down Expand Up @@ -2191,30 +2211,44 @@ 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 -S -` grabs the
* ENTIRE tmux scrollback (COD-47), 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).
* 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?: { fullHistory?: boolean }): 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 -` extends the capture start to the very top of the scrollback.
const captureFlags = fullHistory ? 'capture-pane -p -e -S -' : 'capture-pane -p -e';
const buffer = execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).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}'`,
Expand All @@ -2239,7 +2273,11 @@ 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);
return null;
Expand All @@ -2252,7 +2290,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?: { fullHistory?: boolean }): string | null {
if (IS_TEST_MODE) return '';
if (!isValidMuxName(muxName)) {
console.error('[TmuxManager] Invalid session name in captureActivePaneBuffer:', muxName);
Expand All @@ -2265,7 +2303,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;
Expand Down
35 changes: 32 additions & 3 deletions src/web/routes/session-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -985,6 +985,15 @@ export function registerSessionRoutes(
const query = req.query as { tail?: string };
const session = findSessionOrFail(ctx, id);

// A request WITHOUT a `tail` param is a FULL RELOAD (the browser reloaded the
// page and needs the whole scroll history back). A request WITH `tail` is a
// tab switch — only the recent tail matters and speed wins. On a full reload
// we capture the ENTIRE tmux scrollback (`-S -`, COD-47) so the user gets
// back history that scrolled off Codeman's byte buffer; on a tab switch we
// capture only the visible frame, which stays fast.
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
const isFullReload = tailBytes <= 0;

// 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
Expand All @@ -995,15 +1004,20 @@ 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 } : undefined)
: null;
const source: 'history' | 'mux-visible' | 'mux-full-history' =
liveMuxBuffer !== null && liveMuxBuffer.length > 0
? isFullReload
? 'mux-full-history'
: 'mux-visible'
: 'history';
const rawBuffer =
liveMuxBuffer !== null && liveMuxBuffer.length > 0
? session.terminalBufferLength > 0
? `${session.terminalBuffer}\x1b[H\x1b[2J${liveMuxBuffer}`
: liveMuxBuffer
: session.terminalBuffer;
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
const fullSize = rawBuffer.length;
let truncated = false;
let cleanBuffer: string;
Expand All @@ -1012,7 +1026,7 @@ 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.
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
Expand Down Expand Up @@ -1055,11 +1069,26 @@ export function registerSessionRoutes(
// Remove Ctrl+L and leading whitespace (cheap on tailed subset)
cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, '').replace(LEADING_WHITESPACE_PATTERN, '');

// Cap the payload at the configured terminal buffer limit. Full-history
// tmux capture (`-S -`) can be tens of MB of scrollback; shipping all of it
// would freeze the browser xterm. Keep the most RECENT bytes (slice from the
// end) and align to a line boundary so we never start mid-ANSI-escape.
const { terminalBufferMaxBytes } = await ctx.getTerminalHistoryConfig();
if (terminalBufferMaxBytes > 0 && cleanBuffer.length > terminalBufferMaxBytes) {
cleanBuffer = cleanBuffer.slice(-terminalBufferMaxBytes);
truncated = true;
const firstNewline = cleanBuffer.indexOf('\n');
if (firstNewline > 0 && firstNewline < 4096) {
cleanBuffer = cleanBuffer.slice(firstNewline + 1);
}
}

return {
terminalBuffer: cleanBuffer,
status: session.status,
fullSize,
truncated,
source,
};
});

Expand Down
Loading