mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
fix(review): wire full-scrollback replay to an explicit ?full=1, dedup + bound the capture (PR #148)
- 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 -<N> 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 -<N> flags and explicit maxBuffer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+18
-4
@@ -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 -<N>`). */
|
||||
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;
|
||||
}
|
||||
|
||||
+48
-12
@@ -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 -<N>` 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 -<N>` 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);
|
||||
|
||||
+11
-1
@@ -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<sessionId, { generation, phase }>
|
||||
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;
|
||||
|
||||
@@ -980,19 +980,21 @@ export function registerSessionRoutes(
|
||||
|
||||
// Query params:
|
||||
// tail=<bytes> - 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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 -<N>` (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 -<N>)', () => {
|
||||
// `-S -<N>` (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);
|
||||
|
||||
Reference in New Issue
Block a user