mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19: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;
|
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.
|
* Terminal multiplexer interface.
|
||||||
*
|
*
|
||||||
@@ -227,14 +241,14 @@ export interface TerminalMultiplexer extends EventEmitter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Capture a pane's current tmux buffer with ANSI escape codes preserved.
|
* Capture a pane's current tmux buffer with ANSI escape codes preserved.
|
||||||
* Pass `{ fullHistory: true }` to capture the entire scrollback (`-S -`)
|
* Pass `{ fullHistory: true }` to capture the entire scrollback as linear
|
||||||
* as linear text instead of just the visible single-screen frame (COD-47).
|
* 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.
|
* Capture the active pane's current tmux buffer with ANSI escape codes preserved.
|
||||||
* Pass `{ fullHistory: true }` to capture the entire scrollback (COD-47).
|
* 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,
|
MuxSessionWithStats,
|
||||||
CreateSessionOptions,
|
CreateSessionOptions,
|
||||||
RespawnPaneOptions,
|
RespawnPaneOptions,
|
||||||
|
PaneCaptureOptions,
|
||||||
} from './mux-interface.js';
|
} from './mux-interface.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -65,7 +66,15 @@ import type {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
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 */
|
/** Delay after tmux session creation — enough for detached tmux to be queryable */
|
||||||
const TMUX_CREATION_WAIT_MS = 100;
|
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,
|
* - Visible (default): `capture-pane -p -e` grabs only the on-screen frame,
|
||||||
* then `formatPaneSnapshot` repaints each row at its absolute position so
|
* then `formatPaneSnapshot` repaints each row at its absolute position so
|
||||||
* the browser xterm reproduces the live frame. Used for fast tab switches.
|
* the browser xterm reproduces the live frame. Used for fast tab switches.
|
||||||
* - Full history (`opts.fullHistory`): `capture-pane -p -e -S -` grabs the
|
* - Full history (`opts.fullHistory`): `capture-pane -p -e -J -S -<N>` grabs
|
||||||
* ENTIRE tmux scrollback (COD-47), returned as linear scrollback text with
|
* the tmux scrollback (COD-47, bounded to the configured history limit),
|
||||||
* SGR codes preserved (NOT repositioned — a multi-screen history can't be
|
* returned as linear scrollback text with SGR codes preserved (NOT
|
||||||
* painted into a single visible frame, so the snapshot repaint is skipped).
|
* 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.
|
* 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.
|
* 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 '';
|
if (IS_TEST_MODE) return '';
|
||||||
const target = resolveTmuxPaneTarget(muxName, paneTarget);
|
const target = resolveTmuxPaneTarget(muxName, paneTarget);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
@@ -2235,12 +2246,31 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
const fullHistory = opts?.fullHistory === true;
|
const fullHistory = opts?.fullHistory === true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// `-S -` extends the capture start to the very top of the scrollback.
|
// `-S -<N>` starts the capture N lines above the visible frame (tmux
|
||||||
const captureFlags = fullHistory ? 'capture-pane -p -e -S -' : 'capture-pane -p -e';
|
// clamps to the top of history), so tmux never serializes more scrollback
|
||||||
const buffer = execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, {
|
// 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',
|
encoding: 'utf-8',
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
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
|
// Full-history spans many screens — return it as raw linear scrollback
|
||||||
// rather than repainting rows at single-screen absolute positions. tmux
|
// rather than repainting rows at single-screen absolute positions. tmux
|
||||||
// joins scrollback rows with a bare `\n`; normalize to `\r\n` so a fresh
|
// 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).
|
// diagonally in a fresh xterm (COD-138, same reason as the fullHistory path).
|
||||||
return normalizeScrollbackEol(buffer);
|
return normalizeScrollbackEol(buffer);
|
||||||
} catch (err) {
|
} 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;
|
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
|
* Pane ids are not stable across respawns or restores, so callers should not
|
||||||
* assume the first pane remains `%0`.
|
* 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 (IS_TEST_MODE) return '';
|
||||||
if (!isValidMuxName(muxName)) {
|
if (!isValidMuxName(muxName)) {
|
||||||
console.error('[TmuxManager] Invalid session name in captureActivePaneBuffer:', 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._initGeneration = 0; // dedup concurrent handleInit calls
|
||||||
this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive
|
this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive
|
||||||
this._selectGeneration = 0; // cancel stale selectSession loads
|
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.terminalLoadStates = new Map(); // Map<sessionId, { generation, phase }>
|
||||||
this.respawnStatus = {};
|
this.respawnStatus = {};
|
||||||
this.respawnTimers = {}; // Track timed respawn timers
|
this.respawnTimers = {}; // Track timed respawn timers
|
||||||
@@ -3705,7 +3706,16 @@ class CodemanApp {
|
|||||||
|
|
||||||
this._setTerminalLoadState(sessionId, selectGen, 'fetching');
|
this._setTerminalLoadState(sessionId, selectGen, 'fetching');
|
||||||
_crashDiag.log('FETCH_START');
|
_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)) {
|
if (this._isStaleSelect(selectGen)) {
|
||||||
this._clearTerminalLoadState(sessionId, selectGen);
|
this._clearTerminalLoadState(sessionId, selectGen);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -980,19 +980,21 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
// Query params:
|
// Query params:
|
||||||
// tail=<bytes> - Only return last N bytes (faster initial load)
|
// 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) => {
|
app.get('/api/sessions/:id/terminal', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
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);
|
const session = findSessionOrFail(ctx, id);
|
||||||
|
|
||||||
// A request WITHOUT a `tail` param is a FULL RELOAD (the browser reloaded the
|
// `full=1` is the EXPLICIT full-reload signal (COD-47): the browser reloaded
|
||||||
// page and needs the whole scroll history back). A request WITH `tail` is a
|
// the page and wants the whole scroll history back, so we capture the ENTIRE
|
||||||
// tab switch — only the recent tail matters and speed wins. On a full reload
|
// tmux scrollback and the user gets back history that scrolled off Codeman's
|
||||||
// we capture the ENTIRE tmux scrollback (`-S -`, COD-47) so the user gets
|
// byte buffer. Requests WITHOUT it — tab switches (`tail=`) and the legacy
|
||||||
// back history that scrolled off Codeman's byte buffer; on a tab switch we
|
// no-param callers (response-viewer fallback, clearTerminal refresh) — keep
|
||||||
// capture only the visible frame, which stays fast.
|
// the fast visible-frame capture.
|
||||||
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
|
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
|
// 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
|
// on-screen frame, not just the accumulated byte history. This matters for
|
||||||
@@ -1004,24 +1006,52 @@ export function registerSessionRoutes(
|
|||||||
const muxName = session.muxName;
|
const muxName = session.muxName;
|
||||||
const liveMuxBuffer =
|
const liveMuxBuffer =
|
||||||
muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
|
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;
|
: null;
|
||||||
const source: 'history' | 'mux-visible' | 'mux-full-history' =
|
const hasLiveMuxBuffer = liveMuxBuffer !== null && liveMuxBuffer.length > 0;
|
||||||
liveMuxBuffer !== null && liveMuxBuffer.length > 0
|
const source: 'history' | 'mux-visible' | 'mux-full-history' = hasLiveMuxBuffer
|
||||||
? isFullReload
|
? isFullReload
|
||||||
? 'mux-full-history'
|
? 'mux-full-history'
|
||||||
: 'mux-visible'
|
: 'mux-visible'
|
||||||
: 'history';
|
: 'history';
|
||||||
const rawBuffer =
|
let rawBuffer: string;
|
||||||
liveMuxBuffer !== null && liveMuxBuffer.length > 0
|
if (liveMuxBuffer !== null && liveMuxBuffer.length > 0) {
|
||||||
? session.terminalBufferLength > 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}`
|
? `${session.terminalBuffer}\x1b[H\x1b[2J${liveMuxBuffer}`
|
||||||
: liveMuxBuffer
|
: liveMuxBuffer;
|
||||||
: session.terminalBuffer;
|
} else {
|
||||||
|
rawBuffer = session.terminalBuffer;
|
||||||
|
}
|
||||||
const fullSize = rawBuffer.length;
|
const fullSize = rawBuffer.length;
|
||||||
let truncated = false;
|
let truncated = false;
|
||||||
let cleanBuffer: string;
|
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.
|
// Strip redundant Ink spinner/status redraws BEFORE tailing.
|
||||||
// During long thinking phases, Ink rewrites the same rows thousands of times
|
// During long thinking phases, Ink rewrites the same rows thousands of times
|
||||||
// (500KB+). Without stripping, tail mode returns only spinner frames and
|
// (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)
|
// Remove Ctrl+L and leading whitespace (cheap on tailed subset)
|
||||||
cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, '').replace(LEADING_WHITESPACE_PATTERN, '');
|
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 {
|
return {
|
||||||
terminalBuffer: cleanBuffer,
|
terminalBuffer: cleanBuffer,
|
||||||
status: session.status,
|
status: session.status,
|
||||||
|
|||||||
@@ -521,13 +521,12 @@ describe('session-routes', () => {
|
|||||||
expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan(
|
expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan(
|
||||||
body.data.terminalBuffer.indexOf('visible tmux pane only')
|
body.data.terminalBuffer.indexOf('visible tmux pane only')
|
||||||
);
|
);
|
||||||
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, {
|
// No ?full=1 → visible-frame capture (no fullHistory opts).
|
||||||
fullHistory: true,
|
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── COD-47: full tmux scrollback replay on full page reload ──
|
// ── 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.
|
// A realistic scrollback-length capture: ~5000 lines, well past one screen.
|
||||||
const firstLine = 'SCROLLBACK_FIRST_LINE_0001';
|
const firstLine = 'SCROLLBACK_FIRST_LINE_0001';
|
||||||
const lastLine = 'SCROLLBACK_LAST_LINE_5000';
|
const lastLine = 'SCROLLBACK_LAST_LINE_5000';
|
||||||
@@ -547,13 +546,21 @@ describe('session-routes', () => {
|
|||||||
|
|
||||||
const res = await harness.app.inject({
|
const res = await harness.app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1`,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
const body = JSON.parse(res.body);
|
const body = JSON.parse(res.body);
|
||||||
// Full reload asked tmux for the entire scrollback.
|
// Full reload asked tmux for the entire scrollback, with the configured
|
||||||
expect(captureSpy).toHaveBeenCalledWith(harness.ctx._session.muxName, { fullHistory: true });
|
// 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.
|
// Both boundary markers survived the capture → route pipeline.
|
||||||
expect(body.data.terminalBuffer).toContain(firstLine);
|
expect(body.data.terminalBuffer).toContain(firstLine);
|
||||||
expect(body.data.terminalBuffer).toContain(lastLine);
|
expect(body.data.terminalBuffer).toContain(lastLine);
|
||||||
@@ -561,6 +568,74 @@ describe('session-routes', () => {
|
|||||||
expect(typeof body.data.fullSize).toBe('number');
|
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 () => {
|
it('tab switch (with tail) uses the visible frame, not full history', async () => {
|
||||||
harness.ctx._session.mode = 'codex';
|
harness.ctx._session.mode = 'codex';
|
||||||
harness.ctx._session.terminalBuffer = 'accumulated history';
|
harness.ctx._session.terminalBuffer = 'accumulated history';
|
||||||
@@ -603,7 +678,7 @@ describe('session-routes', () => {
|
|||||||
|
|
||||||
const res = await harness.app.inject({
|
const res = await harness.app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1`,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
@@ -637,9 +712,7 @@ describe('session-routes', () => {
|
|||||||
expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan(
|
expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan(
|
||||||
body.data.terminalBuffer.indexOf('visible tmux pane only')
|
body.data.terminalBuffer.indexOf('visible tmux pane only')
|
||||||
);
|
);
|
||||||
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, {
|
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
|
||||||
fullHistory: true,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves one-time OAuth authorization URLs in Codex TUI replay history', async () => {
|
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(
|
expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan(
|
||||||
body.data.terminalBuffer.indexOf('visible tmux pane only')
|
body.data.terminalBuffer.indexOf('visible tmux pane only')
|
||||||
);
|
);
|
||||||
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, {
|
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
|
||||||
fullHistory: true,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses live mux pane capture only when the accumulated buffer is empty', async () => {
|
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);
|
const body = JSON.parse(res.body);
|
||||||
expect(body.data.terminalBuffer).toContain('visible restored tmux pane');
|
expect(body.data.terminalBuffer).toContain('visible restored tmux pane');
|
||||||
expect(body.data.terminalBuffer).toContain('› current prompt');
|
expect(body.data.terminalBuffer).toContain('› current prompt');
|
||||||
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, {
|
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
|
||||||
fullHistory: true,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns error for unknown session', async () => {
|
it('returns error for unknown session', async () => {
|
||||||
@@ -764,9 +833,7 @@ describe('session-routes', () => {
|
|||||||
expect(buf).toContain('\x1b[H\x1b[2J');
|
expect(buf).toContain('\x1b[H\x1b[2J');
|
||||||
expect(buf).toContain('LIVE-PANE-FRAME');
|
expect(buf).toContain('LIVE-PANE-FRAME');
|
||||||
expect(buf.indexOf('history-bytes')).toBeLessThan(buf.indexOf('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, undefined);
|
||||||
fullHistory: true,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to the byte history when no live pane buffer is available', async () => {
|
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
|
* 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
|
* real tmux. Instead we assert the capture-arg construction directly from
|
||||||
* source (same approach as tmux-capture-color.test.ts): a full-history capture
|
* 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`.
|
* repaint, while the visible capture keeps `capture-pane -p -e`.
|
||||||
*/
|
*/
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
@@ -13,16 +14,27 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
describe('tmux full-history pane capture (COD-47)', () => {
|
describe('tmux full-history pane capture (COD-47)', () => {
|
||||||
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
|
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', () => {
|
it('capturePaneBuffer accepts pane-capture options with a fullHistory flag', () => {
|
||||||
const sig = source.indexOf('capturePaneBuffer(muxName: string');
|
expect(methodStart).toBeGreaterThan(-1);
|
||||||
expect(sig).toBeGreaterThan(-1);
|
// The method signature must carry the opts channel...
|
||||||
// The method signature must carry the opts.fullHistory channel.
|
expect(source.slice(methodStart, methodStart + 160)).toContain('PaneCaptureOptions');
|
||||||
expect(source.slice(sig, sig + 160)).toContain('fullHistory');
|
// ...and the body must branch on opts.fullHistory.
|
||||||
|
expect(methodBody).toContain('opts?.fullHistory === true');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('full-history mode requests the entire scrollback with -S -', () => {
|
it('full-history mode captures scrollback bounded to the configured history limit (-J -S -<N>)', () => {
|
||||||
expect(source).toContain('capture-pane -p -e -S -');
|
// `-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', () => {
|
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)', () => {
|
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
|
// When fullHistory, return the raw buffer BEFORE the formatPaneSnapshot
|
||||||
// repaint (which is single-screen and would clip a multi-screen history).
|
// repaint (which is single-screen and would clip a multi-screen history).
|
||||||
const earlyReturn = body.indexOf('if (fullHistory)');
|
const earlyReturn = methodBody.indexOf('return normalizeScrollbackEol(buffer);');
|
||||||
const snapshot = body.indexOf('formatPaneSnapshot(');
|
const snapshot = methodBody.indexOf('formatPaneSnapshot(');
|
||||||
expect(earlyReturn).toBeGreaterThan(-1);
|
expect(earlyReturn).toBeGreaterThan(-1);
|
||||||
expect(snapshot).toBeGreaterThan(-1);
|
expect(snapshot).toBeGreaterThan(-1);
|
||||||
expect(earlyReturn).toBeLessThan(snapshot);
|
expect(earlyReturn).toBeLessThan(snapshot);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('captureActivePaneBuffer forwards the fullHistory option', () => {
|
it('captureActivePaneBuffer forwards the capture options', () => {
|
||||||
const sig = source.indexOf('captureActivePaneBuffer(muxName: string');
|
const sig = source.indexOf('captureActivePaneBuffer(muxName: string');
|
||||||
expect(sig).toBeGreaterThan(-1);
|
expect(sig).toBeGreaterThan(-1);
|
||||||
const body = source.slice(sig, sig + 800);
|
const body = source.slice(sig, sig + 800);
|
||||||
|
|||||||
Reference in New Issue
Block a user