From 8be83cd5856bb0b4cc2899e7068595257e651b34 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Fri, 19 Jun 2026 12:00:35 -0400 Subject: [PATCH 1/3] 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. --- src/mux-interface.ts | 15 +- src/tmux-manager.ts | 32 ++- src/web/routes/session-routes.ts | 33 +++- test/routes/session-routes.test.ts | 262 ++++++++++++++++++++++++- test/tmux-capture-full-history.test.ts | 51 +++++ 5 files changed, 378 insertions(+), 15 deletions(-) create mode 100644 test/tmux-capture-full-history.test.ts diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 8c053c84..390e25fd 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -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; - /** 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; } diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 0af5caa8..42106e54 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -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; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 659731ad..bc823b43 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -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, }; }); diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index 7f0d8a39..b7569129 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -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 () => { diff --git a/test/tmux-capture-full-history.test.ts b/test/tmux-capture-full-history.test.ts new file mode 100644 index 00000000..5c4567ff --- /dev/null +++ b/test/tmux-capture-full-history.test.ts @@ -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)'); + }); +}); From c7967d4b55edab724dc74643c6d13ac03d4de9ac Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 20 Jun 2026 09:43:05 -0400 Subject: [PATCH 2/3] COD-138 normalize shell scrollback to CRLF so replay doesn't staircase A shell terminal could render output diagonally (each line shifted one column right) after a full page reload or a cursor-query-failure replay. Root cause: capturePaneBuffer's full-history path (capture-pane -p -e -S -) and its cursor-query-failure fallback returned raw scrollback, which tmux joins with a BARE \n. The browser xterm uses convertEol:false (correct for the live PTY stream, which carries real \r\n), so each bare \n dropped a row without returning the cursor to column 0 -> staircase. The visible / tab-switch path (formatPaneSnapshot) was immune because it repaints each row with an absolute cursor CSI. Fix: new pure helper normalizeScrollbackEol() (\r?\n -> \r\n, idempotent on CRLF, leaves lone \r overwrites untouched, adds/removes no rows) applied at both raw-return seams. The absolute-positioned snapshot path is unchanged. Tests: test/tmux-scrollback-eol.test.ts pins the invariant (no LF without a preceding CR) + CRLF idempotency + lone-CR preservation. 136/136 across tmux-scrollback-eol + tmux-capture-full-history + tmux-manager + routes/session-routes; build, tsc, prettier, frontend-syntax clean. --- src/tmux-manager.ts | 44 ++++++++++++++++++------ src/web/routes/session-routes.ts | 2 +- test/tmux-scrollback-eol.test.ts | 59 ++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 12 deletions(-) create mode 100644 test/tmux-scrollback-eol.test.ts diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 42106e54..055964ca 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -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 } @@ -2206,16 +2226,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { */ 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); + const target = resolveTmuxPaneTarget(muxName, paneTarget); + if (!target) { + console.error('[TmuxManager] Invalid pane target in capturePaneBuffer:', { muxName, paneTarget }); return null; } - if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) { - console.error('[TmuxManager] Invalid pane target:', paneTarget); - return null; - } - - const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`; const fullHistory = opts?.fullHistory === true; @@ -2227,9 +2242,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { 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. + // 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 buffer; + return normalizeScrollbackEol(buffer); } try { const cursor = execSync( @@ -2255,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; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index bc823b43..70858bff 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -1026,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 diff --git a/test/tmux-scrollback-eol.test.ts b/test/tmux-scrollback-eol.test.ts new file mode 100644 index 00000000..8d9c1bdd --- /dev/null +++ b/test/tmux-scrollback-eol.test.ts @@ -0,0 +1,59 @@ +/** + * COD-138: shell terminal staircase / diagonal replay after reload. + * + * Root cause: the full-history tmux capture (`capture-pane -p -e -S -`) returns + * scrollback lines joined by a BARE `\n` (no `\r`). The visible/tab-switch path + * repaints each row with an absolute cursor CSI via `formatPaneSnapshot`, so it + * never staircases — but the full-history path returns the raw buffer. The + * browser xterm is created with the default `convertEol: false` (correct for the + * live PTY stream, which carries real `\r\n`), so on a full page reload each + * bare `\n` drops a row WITHOUT returning the cursor to column 0. Every replayed + * line then starts one column further right → the diagonal staircase. + * + * Fix: normalize the full-history scrollback to `\r\n` line endings before it is + * shipped to the browser, so a fresh xterm starts every replayed line at col 0. + * + * This exercises the pure transform (`normalizeScrollbackEol`). Under VITEST, + * TmuxManager no-ops execSync (IS_TEST_MODE), so the real capture path can't be + * driven end-to-end here; the transform is the load-bearing seam. + */ +import { describe, expect, it } from 'vitest'; +import { normalizeScrollbackEol } from '../src/tmux-manager.js'; + +describe('normalizeScrollbackEol (COD-138 staircase fix)', () => { + it('adds carriage returns so bare-LF scrollback lines start at column 0', () => { + // tmux capture-pane joins rows with bare \n. Without a preceding \r, xterm + // (convertEol:false) keeps the column → staircase. + const raw = 'line one\nline two\nline three'; + expect(normalizeScrollbackEol(raw)).toBe('line one\r\nline two\r\nline three'); + }); + + it('every newline in the result is preceded by a carriage return', () => { + const raw = 'a\nb\nc\nd'; + const out = normalizeScrollbackEol(raw); + // The staircase invariant: no LF may appear without a CR immediately before it. + expect(/(? { + const raw = 'line one\r\nline two\r\nline three'; + expect(normalizeScrollbackEol(raw)).toBe('line one\r\nline two\r\nline three'); + }); + + it('normalizes a mix of CRLF and bare LF to uniform CRLF', () => { + const raw = 'crlf\r\nbare\ncrlf2\r\nbare2'; + expect(normalizeScrollbackEol(raw)).toBe('crlf\r\nbare\r\ncrlf2\r\nbare2'); + }); + + it('preserves a lone trailing carriage return (in-line overwrite, not an EOL)', () => { + // A bare \r not followed by \n is a column-0 reset the TUI emitted on purpose; + // it must survive untouched so we do not corrupt an overwrite. + const raw = 'progress\rdone'; + expect(normalizeScrollbackEol(raw)).toBe('progress\rdone'); + }); + + it('is a no-op on content without newlines', () => { + expect(normalizeScrollbackEol('single frame')).toBe('single frame'); + expect(normalizeScrollbackEol('')).toBe(''); + }); +}); From f98d29b323853120f2267cce0f1a7c24c1db33b1 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 12 Jul 2026 18:33:18 +0200 Subject: [PATCH 3/3] fix(review): wire full-scrollback replay to an explicit ?full=1, dedup + bound the capture (PR #148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the 'missing ?tail means reload' overload with an explicit ?full=1 query param: the frontend's first buffer load after a page load (selectSession) now requests full=1, tab switches keep ?tail=, and the legacy no-param callers (response-viewer fallback, clearTerminal refresh) keep the cheap visible-frame path — the COD-47 feature was previously unreachable from a real reload. - When the full-history capture succeeds, return it ALONE instead of prepending the byte buffer + \x1b[H\x1b[2J: the capture is the rendered superset of the byte history, and ED2 clears only the viewport so the concat replayed the whole conversation twice in xterm scrollback. The history+clear+frame concat stays for the visible-frame/tab-switch path. - Pass an explicit execSync maxBuffer for the full-history capture (configured terminalBufferMaxBytes + slack) — the 1MB Node default ENOBUFS-killed exactly the multi-MB captures the feature exists for; log ENOBUFS concisely instead of dumping the truncated stdout. - Bound the capture itself via -S - derived from the configured tmux history limit (was unbounded -S -), and add -J so lines hard-wrapped at the capture-time pane width reflow in the browser xterm. - Cap the concatenated buffer to terminalBufferMaxBytes EARLY (before the regex normalization passes) so multi-MB captures don't stall the event loop normalizing bytes that get sliced away. - Tests: route tests updated for ?full=1 semantics (capture-alone response, config-forwarded capture bounds, byte-history fallback, no-param requests stay on the visible-frame path); source-scan tests cover the bounded -J -S - flags and explicit maxBuffer. Co-Authored-By: Claude Fable 5 --- src/mux-interface.ts | 22 ++++- src/tmux-manager.ts | 60 +++++++++++--- src/web/public/app.js | 12 ++- src/web/routes/session-routes.ts | 84 +++++++++++-------- test/routes/session-routes.test.ts | 107 ++++++++++++++++++++----- test/tmux-capture-full-history.test.ts | 36 ++++++--- 6 files changed, 237 insertions(+), 84 deletions(-) diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 390e25fd..62416a09 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -98,6 +98,20 @@ export interface RespawnPaneOptions { historyLimit?: number; } +/** 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 -`). */ + 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. * @@ -227,14 +241,14 @@ export interface TerminalMultiplexer extends EventEmitter { /** * 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). + * 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?: { fullHistory?: boolean }): string | null; + capturePaneBuffer?(muxName: string, paneTarget?: string, opts?: PaneCaptureOptions): 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; + captureActivePaneBuffer?(muxName: string, opts?: PaneCaptureOptions): string | null; } diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 055964ca..6b880526 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -58,6 +58,7 @@ import type { MuxSessionWithStats, CreateSessionOptions, RespawnPaneOptions, + PaneCaptureOptions, } from './mux-interface.js'; // ============================================================================ @@ -65,7 +66,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; @@ -2217,14 +2226,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * - 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). + * - Full history (`opts.fullHistory`): `capture-pane -p -e -J -S -` 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, opts?: { fullHistory?: boolean }): string | null { + capturePaneBuffer(muxName: string, paneTarget?: string, opts?: PaneCaptureOptions): string | null { if (IS_TEST_MODE) return ''; const target = resolveTmuxPaneTarget(muxName, paneTarget); if (!target) { @@ -2235,12 +2246,31 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const fullHistory = opts?.fullHistory === true; try { - // `-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)}`, { + // `-S -` 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 @@ -2279,7 +2309,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // 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; } } @@ -2290,7 +2326,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, opts?: { fullHistory?: boolean }): 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); diff --git a/src/web/public/app.js b/src/web/public/app.js index c63f12be..24f578e9 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -324,6 +324,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 this.respawnStatus = {}; this.respawnTimers = {}; // Track timed respawn timers @@ -3705,7 +3706,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; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 70858bff..cdc61924 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -980,19 +980,21 @@ export function registerSessionRoutes( // Query params: // tail= - 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); - // 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. + // `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 = tailBytes <= 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 @@ -1004,24 +1006,52 @@ export function registerSessionRoutes( const muxName = session.muxName; const liveMuxBuffer = muxName && typeof ctx.mux.captureActivePaneBuffer === 'function' - ? ctx.mux.captureActivePaneBuffer(muxName, isFullReload ? { fullHistory: true } : undefined) + ? ctx.mux.captureActivePaneBuffer( + muxName, + isFullReload + ? { fullHistory: true, historyLimitLines: tmuxHistoryLimit, maxCaptureBytes: terminalBufferMaxBytes } + : 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 + 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; + : 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 @@ -1069,20 +1099,6 @@ 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, diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index b7569129..cb1432a2 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -521,13 +521,12 @@ describe('session-routes', () => { 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, - }); + // No ?full=1 → visible-frame capture (no fullHistory opts). + expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined); }); // ── COD-47: full tmux scrollback replay on full page reload ── - it('full reload (no tail) requests full tmux history and replays boundary markers', async () => { + it('full reload (?full=1) 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'; @@ -547,13 +546,21 @@ describe('session-routes', () => { const res = await harness.app.inject({ method: 'GET', - url: `/api/sessions/${harness.ctx._sessionId}/terminal`, + url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1`, }); 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 }); + // Full reload asked tmux for the entire scrollback, with the configured + // capture bounds (history-line limit + byte cap for exec maxBuffer). + expect(captureSpy).toHaveBeenCalledWith( + harness.ctx._session.muxName, + expect.objectContaining({ + fullHistory: true, + historyLimitLines: expect.any(Number), + maxCaptureBytes: expect.any(Number), + }) + ); // Both boundary markers survived the capture → route pipeline. expect(body.data.terminalBuffer).toContain(firstLine); expect(body.data.terminalBuffer).toContain(lastLine); @@ -561,6 +568,74 @@ describe('session-routes', () => { expect(typeof body.data.fullSize).toBe('number'); }); + it('full reload (?full=1) returns the tmux capture ALONE — byte history is not duplicated', async () => { + // The full-history capture is the rendered form of everything already in + // the byte buffer; prepending the byte history would replay the whole + // conversation twice (\x1b[2J clears the viewport, not xterm scrollback). + harness.ctx._session.mode = 'claude'; + harness.ctx._session.terminalBuffer = 'BYTE_BUFFER_COPY of the conversation'; + const rendered = 'BYTE_BUFFER_COPY of the conversation\r\nplus older scrollback\r\n› prompt'; + const captureSpy = vi.fn((_name: string, opts?: { fullHistory?: boolean }) => + opts?.fullHistory ? rendered : 'visible frame only' + ); + (harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy; + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1`, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.data.source).toBe('mux-full-history'); + expect(body.data.terminalBuffer).toContain('plus older scrollback'); + // Capture alone: no history+clear-viewport concat, and the byte-buffer + // content appears exactly once (from the capture, not a duplicate prepend). + expect(body.data.terminalBuffer).not.toContain('\x1b[H\x1b[2J'); + expect(body.data.terminalBuffer.indexOf('BYTE_BUFFER_COPY')).toBe( + body.data.terminalBuffer.lastIndexOf('BYTE_BUFFER_COPY') + ); + }); + + it('full reload (?full=1) falls back to the byte history when the capture is unavailable', async () => { + harness.ctx._session.mode = 'claude'; + harness.ctx._session.terminalBuffer = 'byte history survives'; + (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?full=1`, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.data.source).toBe('history'); + expect(body.data.terminalBuffer).toContain('byte history survives'); + }); + + it('full reload forwards the configured history-line limit and byte cap to the capture', async () => { + harness.ctx.getTerminalHistoryConfig = vi.fn(async () => ({ + terminalScrollbackLines: 60_000, + tmuxHistoryLimit: 123_456, + terminalBufferMaxBytes: 5 * 1024 * 1024, + terminalBufferTrimBytes: 4 * 1024 * 1024, + })); + const captureSpy = vi.fn(() => 'full scrollback'); + (harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy; + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1`, + }); + + expect(res.statusCode).toBe(200); + expect(captureSpy).toHaveBeenCalledWith(harness.ctx._session.muxName, { + fullHistory: true, + historyLimitLines: 123_456, + maxCaptureBytes: 5 * 1024 * 1024, + }); + }); + it('tab switch (with tail) uses the visible frame, not full history', async () => { harness.ctx._session.mode = 'codex'; harness.ctx._session.terminalBuffer = 'accumulated history'; @@ -603,7 +678,7 @@ describe('session-routes', () => { const res = await harness.app.inject({ method: 'GET', - url: `/api/sessions/${harness.ctx._sessionId}/terminal`, + url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1`, }); expect(res.statusCode).toBe(200); @@ -637,9 +712,7 @@ describe('session-routes', () => { 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, - }); + expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined); }); it('preserves one-time OAuth authorization URLs in Codex TUI replay history', async () => { @@ -713,9 +786,7 @@ describe('session-routes', () => { 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, - }); + expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined); }); it('uses live mux pane capture only when the accumulated buffer is empty', async () => { @@ -734,9 +805,7 @@ describe('session-routes', () => { 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, - }); + expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined); }); it('returns error for unknown session', async () => { @@ -764,9 +833,7 @@ 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, { - fullHistory: true, - }); + expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined); }); it('falls back to the byte history when no live pane buffer is available', async () => { diff --git a/test/tmux-capture-full-history.test.ts b/test/tmux-capture-full-history.test.ts index 5c4567ff..641f7c39 100644 --- a/test/tmux-capture-full-history.test.ts +++ b/test/tmux-capture-full-history.test.ts @@ -4,7 +4,8 @@ * 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 + * must use `capture-pane -p -e -J -S -` (bounded to the configured history + * limit, with an explicit exec maxBuffer) and skip the single-screen snapshot * repaint, while the visible capture keeps `capture-pane -p -e`. */ import { readFileSync } from 'node:fs'; @@ -13,16 +14,27 @@ 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'); + const methodStart = source.indexOf('capturePaneBuffer(muxName: string'); + const methodBody = source.slice(methodStart, methodStart + 4000); - 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('capturePaneBuffer accepts pane-capture options with a fullHistory flag', () => { + expect(methodStart).toBeGreaterThan(-1); + // The method signature must carry the opts channel... + expect(source.slice(methodStart, methodStart + 160)).toContain('PaneCaptureOptions'); + // ...and the body must branch on opts.fullHistory. + expect(methodBody).toContain('opts?.fullHistory === true'); + }); + + it('full-history mode captures scrollback bounded to the configured history limit (-J -S -)', () => { + // `-S -` (not unbounded `-S -`) keeps tmux from serializing more + // scrollback than the configured history limit retains; `-J` re-joins + // lines hard-wrapped at the capture-time pane width. + expect(source).toContain('capture-pane -p -e -J -S -${historyLines}'); }); - it('full-history mode requests the entire scrollback with -S -', () => { - expect(source).toContain('capture-pane -p -e -S -'); + it('full-history exec sets an explicit maxBuffer (default 1MB would ENOBUFS multi-MB dumps)', () => { + expect(methodBody).toContain('maxBuffer'); + expect(methodBody).toContain('FULL_HISTORY_CAPTURE_SLACK_BYTES'); }); it('still offers the visible single-screen capture for fast tab switches', () => { @@ -30,18 +42,16 @@ describe('tmux full-history pane capture (COD-47)', () => { }); 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('); + const earlyReturn = methodBody.indexOf('return normalizeScrollbackEol(buffer);'); + const snapshot = methodBody.indexOf('formatPaneSnapshot('); expect(earlyReturn).toBeGreaterThan(-1); expect(snapshot).toBeGreaterThan(-1); expect(earlyReturn).toBeLessThan(snapshot); }); - it('captureActivePaneBuffer forwards the fullHistory option', () => { + it('captureActivePaneBuffer forwards the capture options', () => { const sig = source.indexOf('captureActivePaneBuffer(muxName: string'); expect(sig).toBeGreaterThan(-1); const body = source.slice(sig, sig + 800);