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)'); + }); +});