From f98d29b323853120f2267cce0f1a7c24c1db33b1 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 12 Jul 2026 18:33:18 +0200 Subject: [PATCH] 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 requests the entire scrollback with -S -', () => { - expect(source).toContain('capture-pane -p -e -S -'); + 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 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);