COD-47 replay full tmux scrollback on terminal reload

A full page reload (GET /api/sessions/:id/terminal with no ?tail=) now captures
the ENTIRE tmux scrollback via capture-pane -p -e -S -, so users get back history
that scrolled off Codeman's byte buffer. Tab switches (?tail=N) keep the fast
visible-frame capture.

- tmux-manager capturePaneBuffer/captureActivePaneBuffer take { fullHistory }:
  full-history returns raw linear scrollback (skips the single-screen
  formatPaneSnapshot repaint, which would clip multi-screen history).
- /terminal selects full-history on full reload, visible on tail; caps the
  payload at the configured terminalBufferMaxBytes (keeps most-recent bytes,
  line-aligned) and returns source/fullSize/truncated metadata.

Verified: tsc 0, tmux-capture-full-history 5/5, session-routes 68/68.
Caveat: lines tmux already evicted past its history-limit can't be recovered.
This commit is contained in:
Aamer Akhter
2026-07-10 11:08:57 -04:00
parent 1fa88cd187
commit 8be83cd585
5 changed files with 378 additions and 15 deletions
+11 -4
View File
@@ -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;
}
+24 -8
View File
@@ -2191,13 +2191,20 @@ 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);
@@ -2210,11 +2217,20 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
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.
if (fullHistory) {
return buffer;
}
try {
const cursor = execSync(
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}'`,
@@ -2252,7 +2268,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);
@@ -2265,7 +2281,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;
+31 -2
View File
@@ -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
@@ -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;
@@ -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,
};
});
+261 -1
View File
@@ -481,6 +481,264 @@ describe('session-routes', () => {
expect(body.data.terminalBuffer).toBeDefined();
});
it('does not strip VPA-like shell scrollback as Ink redraw bloat', async () => {
const shellHistory = Array.from(
{ length: 3000 },
(_, index) => `SHELL_SCROLLBACK_${String(index + 1).padStart(6, '0')} payload payload payload \x1b[1d`
).join('\n');
harness.ctx._session.terminalBuffer = shellHistory;
harness.ctx._session.mode = 'shell';
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(() => null);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.terminalBuffer).toContain('SHELL_SCROLLBACK_000001');
expect(body.data.terminalBuffer).toContain('SHELL_SCROLLBACK_003000');
});
it('preserves accumulated history before the live mux pane snapshot for Codex TUI replay', async () => {
harness.ctx._session.terminalBuffer = 'hello world\nlater accumulated history';
harness.ctx._session.mode = 'codex';
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
() => 'visible tmux pane only\n› current prompt'
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.terminalBuffer).toContain('hello world');
expect(body.data.terminalBuffer).toContain('later accumulated history');
expect(body.data.terminalBuffer).toContain('\x1b[H\x1b[2Jvisible tmux pane only');
expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan(
body.data.terminalBuffer.indexOf('visible tmux pane only')
);
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, {
fullHistory: true,
});
});
// ── COD-47: full tmux scrollback replay on full page reload ──
it('full reload (no tail) requests full tmux history and replays boundary markers', async () => {
// A realistic scrollback-length capture: ~5000 lines, well past one screen.
const firstLine = 'SCROLLBACK_FIRST_LINE_0001';
const lastLine = 'SCROLLBACK_LAST_LINE_5000';
const lines: string[] = [firstLine];
for (let i = 2; i <= 4999; i++) {
lines.push(`scrollback line ${String(i).padStart(4, '0')} lorem ipsum payload`);
}
lines.push(lastLine);
const fullHistoryCapture = lines.join('\n');
harness.ctx._session.mode = 'shell';
harness.ctx._session.terminalBuffer = '';
const captureSpy = vi.fn((_name: string, opts?: { fullHistory?: boolean }) =>
opts?.fullHistory ? fullHistoryCapture : 'only the visible frame'
);
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy;
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
// Full reload asked tmux for the entire scrollback.
expect(captureSpy).toHaveBeenCalledWith(harness.ctx._session.muxName, { fullHistory: true });
// Both boundary markers survived the capture → route pipeline.
expect(body.data.terminalBuffer).toContain(firstLine);
expect(body.data.terminalBuffer).toContain(lastLine);
expect(body.data.source).toBe('mux-full-history');
expect(typeof body.data.fullSize).toBe('number');
});
it('tab switch (with tail) uses the visible frame, not full history', async () => {
harness.ctx._session.mode = 'codex';
harness.ctx._session.terminalBuffer = 'accumulated history';
const captureSpy = vi.fn((_name: string, opts?: { fullHistory?: boolean }) =>
opts?.fullHistory ? 'FULL_HISTORY_SHOULD_NOT_APPEAR' : 'visible frame only\n› prompt'
);
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy;
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal?tail=65536`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
// Tail/tab-switch must NOT request fullHistory (undefined opts).
expect(captureSpy).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
expect(body.data.terminalBuffer).toContain('visible frame only');
expect(body.data.terminalBuffer).not.toContain('FULL_HISTORY_SHOULD_NOT_APPEAR');
expect(body.data.source).toBe('mux-visible');
});
it('caps huge full-history at the configured terminal buffer limit and marks truncated', async () => {
// Shrink the cap so the test can exceed it without allocating 32MB.
harness.ctx.getTerminalHistoryConfig = vi.fn(async () => ({
terminalScrollbackLines: 100_000,
tmuxHistoryLimit: 100_000,
terminalBufferMaxBytes: 4096,
terminalBufferTrimBytes: 4096,
}));
harness.ctx._session.mode = 'shell';
harness.ctx._session.terminalBuffer = '';
const oldestMarker = 'OLDEST_EVICTED_MARKER';
const newestMarker = 'NEWEST_KEPT_MARKER';
const filler = Array.from({ length: 400 }, (_, i) => `line ${i} ${'x'.repeat(30)}`).join('\n');
const huge = `${oldestMarker}\n${filler}\n${newestMarker}`;
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
(_name: string, opts?: { fullHistory?: boolean }) => (opts?.fullHistory ? huge : 'visible')
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.truncated).toBe(true);
expect(body.data.fullSize).toBeGreaterThan(4096);
expect(body.data.terminalBuffer.length).toBeLessThanOrEqual(4096);
// Cap keeps the most RECENT bytes: newest marker survives, oldest is dropped.
expect(body.data.terminalBuffer).toContain(newestMarker);
expect(body.data.terminalBuffer).not.toContain(oldestMarker);
});
it('treats stale Codex scrollback config as TUI replay', async () => {
harness.ctx._session.terminalBuffer = 'hello world\nlater accumulated history';
harness.ctx._session.mode = 'codex';
harness.ctx._session.codexConfig = { renderMode: 'scrollback' } as any;
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
() => 'visible tmux pane only\n› current prompt'
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.terminalBuffer).toContain('hello world');
expect(body.data.terminalBuffer).toContain('later accumulated history');
expect(body.data.terminalBuffer).toContain('\x1b[H\x1b[2Jvisible tmux pane only');
expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan(
body.data.terminalBuffer.indexOf('visible tmux pane only')
);
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, {
fullHistory: true,
});
});
it('preserves one-time OAuth authorization URLs in Codex TUI replay history', async () => {
const authUrl =
'https://auth.atlassian.com/authorize?response_type=code&client_id=abc&redirect_uri=http%3A%2F%2F127.0.0.1%3A35547%2Fcallback%2Fxyz';
harness.ctx._session.terminalBuffer =
'Authorize `atlassian` by opening this URL in your browser:\n' +
authUrl +
'\n(Browser launch failed; please copy the URL above manually.)\n';
harness.ctx._session.mode = 'codex';
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
() => 'visible tmux pane only\n› current prompt'
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.terminalBuffer).toContain(authUrl);
expect(body.data.terminalBuffer).toContain('visible tmux pane only');
expect(body.data.terminalBuffer.indexOf(authUrl)).toBeLessThan(
body.data.terminalBuffer.indexOf('visible tmux pane only')
);
});
it('preserves incidental OAuth URL mentions as ordinary Codex TUI history', async () => {
const authUrl =
'https://auth.atlassian.com/authorize?response_type=code&client_id=abc&redirect_uri=http%3A%2F%2F127.0.0.1%3A35547%2Fcallback%2Fxyz';
harness.ctx._session.terminalBuffer =
'Root cause: URLs like ' +
authUrl +
' could be present in history but missing from browser-rendered terminal replay.\n' +
"+ 'Authorize `atlassian` by opening this URL in your browser:\\n' +\n";
harness.ctx._session.mode = 'codex';
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
() => 'visible tmux pane only\n› current prompt'
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.terminalBuffer).toContain(authUrl);
expect(body.data.terminalBuffer).toContain('visible tmux pane only');
});
it('preserves accumulated history before a live mux pane snapshot for non-Codex sessions', async () => {
harness.ctx._session.terminalBuffer = 'hello world\nlater accumulated history';
harness.ctx._session.mode = 'claude';
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
() => 'visible tmux pane only\n› current prompt'
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.terminalBuffer).toContain('hello world');
expect(body.data.terminalBuffer).toContain('later accumulated history');
expect(body.data.terminalBuffer).toContain('visible tmux pane only');
expect(body.data.terminalBuffer).toContain('\x1b[H\x1b[2Jvisible tmux pane only');
expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan(
body.data.terminalBuffer.indexOf('visible tmux pane only')
);
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, {
fullHistory: true,
});
});
it('uses live mux pane capture only when the accumulated buffer is empty', async () => {
harness.ctx._session.terminalBuffer = '';
harness.ctx._session.mode = 'codex';
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
() => 'visible restored tmux pane\n› current prompt'
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.terminalBuffer).toContain('visible restored tmux pane');
expect(body.data.terminalBuffer).toContain('› current prompt');
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, {
fullHistory: true,
});
});
it('returns error for unknown session', async () => {
const res = await harness.app.inject({
method: 'GET',
@@ -506,7 +764,9 @@ describe('session-routes', () => {
expect(buf).toContain('\x1b[H\x1b[2J');
expect(buf).toContain('LIVE-PANE-FRAME');
expect(buf.indexOf('history-bytes')).toBeLessThan(buf.indexOf('LIVE-PANE-FRAME'));
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName);
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, {
fullHistory: true,
});
});
it('falls back to the byte history when no live pane buffer is available', async () => {
+51
View File
@@ -0,0 +1,51 @@
/**
* COD-47: full tmux scrollback replay on reload.
*
* Under VITEST, TmuxManager no-ops execSync (IS_TEST_MODE), so we can't drive
* real tmux. Instead we assert the capture-arg construction directly from
* source (same approach as tmux-capture-color.test.ts): a full-history capture
* must use `capture-pane -p -e -S -` and skip the single-screen snapshot
* repaint, while the visible capture keeps `capture-pane -p -e`.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('tmux full-history pane capture (COD-47)', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
it('capturePaneBuffer accepts a fullHistory option', () => {
const sig = source.indexOf('capturePaneBuffer(muxName: string');
expect(sig).toBeGreaterThan(-1);
// The method signature must carry the opts.fullHistory channel.
expect(source.slice(sig, sig + 160)).toContain('fullHistory');
});
it('full-history mode requests the entire scrollback with -S -', () => {
expect(source).toContain('capture-pane -p -e -S -');
});
it('still offers the visible single-screen capture for fast tab switches', () => {
expect(source).toContain("'capture-pane -p -e'");
});
it('returns full-history capture as raw scrollback (skips the single-screen repaint)', () => {
const sig = source.indexOf('capturePaneBuffer(muxName: string');
const body = source.slice(sig, sig + 2400);
// When fullHistory, return the raw buffer BEFORE the formatPaneSnapshot
// repaint (which is single-screen and would clip a multi-screen history).
const earlyReturn = body.indexOf('if (fullHistory)');
const snapshot = body.indexOf('formatPaneSnapshot(');
expect(earlyReturn).toBeGreaterThan(-1);
expect(snapshot).toBeGreaterThan(-1);
expect(earlyReturn).toBeLessThan(snapshot);
});
it('captureActivePaneBuffer forwards the fullHistory option', () => {
const sig = source.indexOf('captureActivePaneBuffer(muxName: string');
expect(sig).toBeGreaterThan(-1);
const body = source.slice(sig, sig + 800);
expect(body).toContain('opts');
expect(body).toContain('this.capturePaneBuffer(muxName, target, opts)');
});
});