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..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 } @@ -2191,30 +2211,44 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } /** - * Capture the current visible text and SGR styles of a specific pane. + * Capture a pane's text and SGR styles. * - * `capture-pane -e` is sanitized by `formatPaneSnapshot`: SGR color/style - * codes are preserved, while cursor/erase/scroll-region controls are stripped - * before rows are repainted at absolute positions in browser xterm. + * Two modes: + * - Visible (default): `capture-pane -p -e` grabs only the on-screen frame, + * then `formatPaneSnapshot` repaints each row at its absolute position so + * the browser xterm reproduces the live frame. Used for fast tab switches. + * - Full history (`opts.fullHistory`): `capture-pane -p -e -S -` grabs the + * ENTIRE tmux scrollback (COD-47), returned as linear scrollback text with + * SGR codes preserved (NOT repositioned — a multi-screen history can't be + * painted into a single visible frame, so the snapshot repaint is skipped). + * Used for full page reloads so the user gets back their scroll history. + * Caveat: lines tmux has already evicted past its history-limit are gone. */ - capturePaneBuffer(muxName: string, paneTarget: string): string | null { + capturePaneBuffer(muxName: string, paneTarget?: string, opts?: { fullHistory?: boolean }): string | null { if (IS_TEST_MODE) return ''; - if (!isValidMuxName(muxName)) { - console.error('[TmuxManager] Invalid session name in capturePaneBuffer:', muxName); - return null; - } - if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) { - console.error('[TmuxManager] Invalid pane target:', paneTarget); + const target = resolveTmuxPaneTarget(muxName, paneTarget); + if (!target) { + console.error('[TmuxManager] Invalid pane target in capturePaneBuffer:', { muxName, paneTarget }); return null; } - const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`; + const fullHistory = opts?.fullHistory === true; try { - const buffer = execSync(`${this.tmux()} capture-pane -p -e -t ${shellescape(target)}`, { + // `-S -` extends the capture start to the very top of the scrollback. + const captureFlags = fullHistory ? 'capture-pane -p -e -S -' : 'capture-pane -p -e'; + const buffer = execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, }).replace(/\n+$/g, ''); + // Full-history spans many screens — return it as raw linear scrollback + // rather than repainting rows at single-screen absolute positions. tmux + // joins scrollback rows with a bare `\n`; normalize to `\r\n` so a fresh + // xterm (convertEol:false) starts each replayed line at column 0 instead + // of staircasing diagonally (COD-138). + if (fullHistory) { + return normalizeScrollbackEol(buffer); + } try { const cursor = execSync( `${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}'`, @@ -2239,7 +2273,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } catch (cursorErr) { console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr); } - return buffer; + // Cursor query failed or geometry was invalid, so we skip the absolute- + // positioned snapshot repaint and fall back to the raw capture. Normalize + // its bare `\n` line endings to `\r\n` so the replay doesn't staircase + // diagonally in a fresh xterm (COD-138, same reason as the fullHistory path). + return normalizeScrollbackEol(buffer); } catch (err) { console.error('[TmuxManager] Failed to capture pane buffer:', err); return null; @@ -2252,7 +2290,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * Pane ids are not stable across respawns or restores, so callers should not * assume the first pane remains `%0`. */ - captureActivePaneBuffer(muxName: string): string | null { + captureActivePaneBuffer(muxName: string, opts?: { fullHistory?: boolean }): string | null { if (IS_TEST_MODE) return ''; if (!isValidMuxName(muxName)) { console.error('[TmuxManager] Invalid session name in captureActivePaneBuffer:', muxName); @@ -2265,7 +2303,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { timeout: EXEC_TIMEOUT_MS, }).trim(); const target = resolveActivePaneTarget(output); - return target ? this.capturePaneBuffer(muxName, target) : null; + return target ? this.capturePaneBuffer(muxName, target, opts) : null; } catch (err) { console.error('[TmuxManager] Failed to resolve active pane for capture:', err); return null; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 659731ad..70858bff 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; @@ -1012,7 +1026,7 @@ export function registerSessionRoutes( // During long thinking phases, Ink rewrites the same rows thousands of times // (500KB+). Without stripping, tail mode returns only spinner frames and // the terminal appears empty when switching tabs. - let strippedBuffer = stripInkRedrawBloat(rawBuffer); + let strippedBuffer = session.mode === 'shell' ? rawBuffer : stripInkRedrawBloat(rawBuffer); // Strip alt-screen toggles and scrollback-erase from Codex/Claude byte // streams. xterm.js obeys them by switching to its scrollback-less alt @@ -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)'); + }); +}); 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(''); + }); +});