mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
COD-47 replay full tmux scrollback on terminal reload
A full page reload (GET /api/sessions/:id/terminal with no ?tail=) now captures
the ENTIRE tmux scrollback via capture-pane -p -e -S -, so users get back history
that scrolled off Codeman's byte buffer. Tab switches (?tail=N) keep the fast
visible-frame capture.
- tmux-manager capturePaneBuffer/captureActivePaneBuffer take { fullHistory }:
full-history returns raw linear scrollback (skips the single-screen
formatPaneSnapshot repaint, which would clip multi-screen history).
- /terminal selects full-history on full reload, visible on tail; caps the
payload at the configured terminalBufferMaxBytes (keeps most-recent bytes,
line-aligned) and returns source/fullSize/truncated metadata.
Verified: tsc 0, tmux-capture-full-history 5/5, session-routes 68/68.
Caveat: lines tmux already evicted past its history-limit can't be recovered.
This commit is contained in:
+11
-4
@@ -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<number | null>;
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
+24
-8
@@ -2191,13 +2191,20 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the current visible text and SGR styles of a specific pane.
|
||||
* Capture a pane's text and SGR styles.
|
||||
*
|
||||
* `capture-pane -e` is sanitized by `formatPaneSnapshot`: SGR color/style
|
||||
* codes are preserved, while cursor/erase/scroll-region controls are stripped
|
||||
* before rows are repainted at absolute positions in browser xterm.
|
||||
* Two modes:
|
||||
* - Visible (default): `capture-pane -p -e` grabs only the on-screen frame,
|
||||
* then `formatPaneSnapshot` repaints each row at its absolute position so
|
||||
* the browser xterm reproduces the live frame. Used for fast tab switches.
|
||||
* - Full history (`opts.fullHistory`): `capture-pane -p -e -S -` grabs the
|
||||
* ENTIRE tmux scrollback (COD-47), returned as linear scrollback text with
|
||||
* SGR codes preserved (NOT repositioned — a multi-screen history can't be
|
||||
* painted into a single visible frame, so the snapshot repaint is skipped).
|
||||
* Used for full page reloads so the user gets back their scroll history.
|
||||
* Caveat: lines tmux has already evicted past its history-limit are gone.
|
||||
*/
|
||||
capturePaneBuffer(muxName: string, paneTarget: string): string | null {
|
||||
capturePaneBuffer(muxName: string, paneTarget?: string, opts?: { fullHistory?: boolean }): string | null {
|
||||
if (IS_TEST_MODE) return '';
|
||||
if (!isValidMuxName(muxName)) {
|
||||
console.error('[TmuxManager] Invalid session name in capturePaneBuffer:', muxName);
|
||||
@@ -2210,11 +2217,20 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
|
||||
|
||||
const fullHistory = opts?.fullHistory === true;
|
||||
|
||||
try {
|
||||
const buffer = execSync(`${this.tmux()} capture-pane -p -e -t ${shellescape(target)}`, {
|
||||
// `-S -` extends the capture start to the very top of the scrollback.
|
||||
const captureFlags = fullHistory ? 'capture-pane -p -e -S -' : 'capture-pane -p -e';
|
||||
const buffer = execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).replace(/\n+$/g, '');
|
||||
// Full-history spans many screens — return it as raw linear scrollback
|
||||
// rather than repainting rows at single-screen absolute positions.
|
||||
if (fullHistory) {
|
||||
return buffer;
|
||||
}
|
||||
try {
|
||||
const cursor = execSync(
|
||||
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}'`,
|
||||
@@ -2252,7 +2268,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
* Pane ids are not stable across respawns or restores, so callers should not
|
||||
* assume the first pane remains `%0`.
|
||||
*/
|
||||
captureActivePaneBuffer(muxName: string): string | null {
|
||||
captureActivePaneBuffer(muxName: string, opts?: { fullHistory?: boolean }): string | null {
|
||||
if (IS_TEST_MODE) return '';
|
||||
if (!isValidMuxName(muxName)) {
|
||||
console.error('[TmuxManager] Invalid session name in captureActivePaneBuffer:', muxName);
|
||||
@@ -2265,7 +2281,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
const target = resolveActivePaneTarget(output);
|
||||
return target ? this.capturePaneBuffer(muxName, target) : null;
|
||||
return target ? this.capturePaneBuffer(muxName, target, opts) : null;
|
||||
} catch (err) {
|
||||
console.error('[TmuxManager] Failed to resolve active pane for capture:', err);
|
||||
return null;
|
||||
|
||||
@@ -985,6 +985,15 @@ export function registerSessionRoutes(
|
||||
const query = req.query as { tail?: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
// A request WITHOUT a `tail` param is a FULL RELOAD (the browser reloaded the
|
||||
// page and needs the whole scroll history back). A request WITH `tail` is a
|
||||
// tab switch — only the recent tail matters and speed wins. On a full reload
|
||||
// we capture the ENTIRE tmux scrollback (`-S -`, COD-47) so the user gets
|
||||
// back history that scrolled off Codeman's byte buffer; on a tab switch we
|
||||
// capture only the visible frame, which stays fast.
|
||||
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
|
||||
const isFullReload = tailBytes <= 0;
|
||||
|
||||
// Prepend the live tmux pane buffer so tab-switch replay shows the current
|
||||
// on-screen frame, not just the accumulated byte history. This matters for
|
||||
// TUI modes (codex/opencode) that repaint only their latest frame: the
|
||||
@@ -995,15 +1004,20 @@ export function registerSessionRoutes(
|
||||
const muxName = session.muxName;
|
||||
const liveMuxBuffer =
|
||||
muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
|
||||
? ctx.mux.captureActivePaneBuffer(muxName)
|
||||
? ctx.mux.captureActivePaneBuffer(muxName, isFullReload ? { fullHistory: true } : undefined)
|
||||
: null;
|
||||
const source: 'history' | 'mux-visible' | 'mux-full-history' =
|
||||
liveMuxBuffer !== null && liveMuxBuffer.length > 0
|
||||
? isFullReload
|
||||
? 'mux-full-history'
|
||||
: 'mux-visible'
|
||||
: 'history';
|
||||
const rawBuffer =
|
||||
liveMuxBuffer !== null && liveMuxBuffer.length > 0
|
||||
? session.terminalBufferLength > 0
|
||||
? `${session.terminalBuffer}\x1b[H\x1b[2J${liveMuxBuffer}`
|
||||
: liveMuxBuffer
|
||||
: session.terminalBuffer;
|
||||
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
|
||||
const fullSize = rawBuffer.length;
|
||||
let truncated = false;
|
||||
let cleanBuffer: string;
|
||||
@@ -1055,11 +1069,26 @@ export function registerSessionRoutes(
|
||||
// Remove Ctrl+L and leading whitespace (cheap on tailed subset)
|
||||
cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, '').replace(LEADING_WHITESPACE_PATTERN, '');
|
||||
|
||||
// Cap the payload at the configured terminal buffer limit. Full-history
|
||||
// tmux capture (`-S -`) can be tens of MB of scrollback; shipping all of it
|
||||
// would freeze the browser xterm. Keep the most RECENT bytes (slice from the
|
||||
// end) and align to a line boundary so we never start mid-ANSI-escape.
|
||||
const { terminalBufferMaxBytes } = await ctx.getTerminalHistoryConfig();
|
||||
if (terminalBufferMaxBytes > 0 && cleanBuffer.length > terminalBufferMaxBytes) {
|
||||
cleanBuffer = cleanBuffer.slice(-terminalBufferMaxBytes);
|
||||
truncated = true;
|
||||
const firstNewline = cleanBuffer.indexOf('\n');
|
||||
if (firstNewline > 0 && firstNewline < 4096) {
|
||||
cleanBuffer = cleanBuffer.slice(firstNewline + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
terminalBuffer: cleanBuffer,
|
||||
status: session.status,
|
||||
fullSize,
|
||||
truncated,
|
||||
source,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user