2026-06-19 12:00:35 -04:00
|
|
|
/**
|
|
|
|
|
* 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
|
2026-07-12 18:33:18 +02:00
|
|
|
* 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
|
2026-06-19 12:00:35 -04:00
|
|
|
* 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';
|
2026-10-10 05:56:37 +02:00
|
|
|
import { formatCursorRestore, formatPaneSnapshot, hasVisibleContent, queryPaneCursor } from '../src/tmux-manager.js';
|
2026-06-19 12:00:35 -04:00
|
|
|
|
|
|
|
|
describe('tmux full-history pane capture (COD-47)', () => {
|
|
|
|
|
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
|
2026-07-12 18:33:18 +02:00
|
|
|
const methodStart = source.indexOf('capturePaneBuffer(muxName: string');
|
2026-09-09 15:13:15 +02:00
|
|
|
// Bounded at the next method so `methodBody` really is one method: the
|
|
|
|
|
// ordering assertions below would otherwise be satisfiable by a neighbour.
|
|
|
|
|
const methodEnd = source.indexOf('captureActivePaneBuffer(muxName: string', methodStart);
|
|
|
|
|
const methodBody = source.slice(methodStart, methodEnd);
|
2026-06-19 12:00:35 -04:00
|
|
|
|
2026-07-12 18:33:18 +02:00
|
|
|
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');
|
2026-06-19 12:00:35 -04:00
|
|
|
});
|
|
|
|
|
|
2026-07-12 18:33:18 +02:00
|
|
|
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}');
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-10 23:28:25 -05:00
|
|
|
it('a full capture with a scrollbackOverride (remote history) reads the visible frame only (-J -S 0)', () => {
|
|
|
|
|
// The remote tmux supplied the history; reading local history too would
|
|
|
|
|
// splice two histories above one frame.
|
|
|
|
|
expect(methodBody).toContain('scrollbackOverride !== undefined');
|
|
|
|
|
expect(methodBody).toContain("'capture-pane -p -e -J -S 0'");
|
|
|
|
|
expect(methodBody).toContain('joinScrollbackAndFrame(');
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-12 18:33:18 +02:00
|
|
|
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');
|
2026-06-19 12:00:35 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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)', () => {
|
2026-09-09 15:13:15 +02:00
|
|
|
// The fullHistory branch returns before the formatPaneSnapshot repaint,
|
|
|
|
|
// which is single-screen and would clip a multi-screen history.
|
|
|
|
|
const branch = methodBody.indexOf('if (fullHistory) {\n // Without geometry');
|
2026-07-12 18:33:18 +02:00
|
|
|
const snapshot = methodBody.indexOf('formatPaneSnapshot(');
|
2026-09-09 15:13:15 +02:00
|
|
|
expect(branch).toBeGreaterThan(-1);
|
2026-06-19 12:00:35 -04:00
|
|
|
expect(snapshot).toBeGreaterThan(-1);
|
2026-09-09 15:13:15 +02:00
|
|
|
expect(branch).toBeLessThan(snapshot);
|
|
|
|
|
// …and what it returns is normalized linear scrollback, not a repaint.
|
|
|
|
|
expect(methodBody.slice(branch, snapshot)).toContain('normalizeScrollbackEol(');
|
2026-09-09 10:47:51 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('appends the pane cursor to the full-history capture', () => {
|
|
|
|
|
// A linear replay leaves the caret wherever the last character landed — the
|
|
|
|
|
// status line, for an agent CLI — and every cursor-relative update the CLI
|
|
|
|
|
// sends afterwards is then measured from the wrong row.
|
2026-09-09 15:13:15 +02:00
|
|
|
const restore = methodBody.indexOf('formatCursorRestore(geometry)');
|
2026-09-09 10:47:51 +02:00
|
|
|
const snapshot = methodBody.indexOf('formatPaneSnapshot(');
|
|
|
|
|
expect(restore).toBeGreaterThan(-1);
|
|
|
|
|
expect(restore).toBeLessThan(snapshot);
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-09 15:13:15 +02:00
|
|
|
it('keeps the trailing rows only when a cursor move will follow', () => {
|
|
|
|
|
// Trailing blank rows are the bottom of the screen and the cursor move counts
|
|
|
|
|
// up from them, so the two decisions travel together: no geometry, no move,
|
|
|
|
|
// and the old trim applies instead.
|
|
|
|
|
expect(methodBody).toContain("rawCapture.replace(/\\n$/, '')");
|
2026-10-14 18:07:32 +00:00
|
|
|
expect(methodBody).toContain("const trimmed = rawCapture.replace(/\\n+$/g, '')");
|
2026-09-09 15:13:15 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('defers to the byte history when the pane holds nothing visible', () => {
|
|
|
|
|
expect(methodBody).toContain("if (!hasVisibleContent(trimmed)) return ''");
|
2026-06-19 12:00:35 -04:00
|
|
|
});
|
|
|
|
|
|
2026-07-12 18:33:18 +02:00
|
|
|
it('captureActivePaneBuffer forwards the capture options', () => {
|
2026-06-19 12:00:35 -04:00
|
|
|
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)');
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-09-09 15:13:15 +02:00
|
|
|
|
|
|
|
|
describe('full-history cursor restore', () => {
|
|
|
|
|
it('counts up from the last replayed row rather than down from the top', () => {
|
|
|
|
|
// Relative, not `CUP`: absolute row addressing is only correct while the
|
|
|
|
|
// browser's row count equals the pane's, and resizeWindow does not wait for
|
|
|
|
|
// tmux, so a capture can be taken before a requested resize has applied.
|
|
|
|
|
expect(formatCursorRestore({ cols: 80, rows: 24, cursorX: 2, cursorY: 20 })).toBe('\x1b[3A\r\x1b[2C');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('emits no row move when the caret is already on the last row', () => {
|
|
|
|
|
expect(formatCursorRestore({ cols: 80, rows: 24, cursorX: 5, cursorY: 23 })).toBe('\r\x1b[5C');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('emits no column move for column zero', () => {
|
|
|
|
|
expect(formatCursorRestore({ cols: 80, rows: 10, cursorX: 0, cursorY: 0 })).toBe('\x1b[9A\r');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('hasVisibleContent', () => {
|
|
|
|
|
it('is false for a pane of blank rows', () => {
|
|
|
|
|
expect(hasVisibleContent('\n'.repeat(23))).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('is false for blank rows carrying only SGR attributes', () => {
|
|
|
|
|
// `capture-pane -e` styles every row, so an all-blank pane is not an empty
|
|
|
|
|
// string. Treating it as content would replace the byte history with a
|
|
|
|
|
// blank screen.
|
|
|
|
|
expect(hasVisibleContent('\x1b[m \x1b[0m\n\x1b[m \x1b[0m')).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('is true as soon as one row carries a character', () => {
|
|
|
|
|
expect(hasVisibleContent('\x1b[m \x1b[0m\n\x1b[m x \x1b[0m')).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-09-15 12:42:55 +02:00
|
|
|
|
|
|
|
|
describe('the geometry a capture reports back', () => {
|
|
|
|
|
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
|
|
|
|
|
const methodStart = source.indexOf('capturePaneBuffer(muxName: string');
|
|
|
|
|
const methodEnd = source.indexOf('captureActivePaneBuffer(muxName: string', methodStart);
|
|
|
|
|
const methodBody = source.slice(methodStart, methodEnd);
|
|
|
|
|
|
|
|
|
|
it('writes the pane size onto the caller options before either replay path returns', () => {
|
|
|
|
|
// IS_TEST_MODE no-ops execSync, so assert from source (same approach as the
|
|
|
|
|
// capture-flag tests above). The write must precede the fullHistory branch:
|
|
|
|
|
// both paths return from inside it, and a caller that got no geometry
|
|
|
|
|
// cannot tell a mismatched frame from a matching one.
|
|
|
|
|
const write = methodBody.indexOf('opts.capturedGeometry = { cols: geometry.cols, rows: geometry.rows }');
|
|
|
|
|
// Anchor on the REPLAY branch, not the earlier `if (fullHistory)` that only
|
|
|
|
|
// sizes the exec buffer.
|
2026-10-14 18:07:32 +00:00
|
|
|
const replayBranch = methodBody.indexOf('if (!geometry) {');
|
2026-09-15 12:42:55 +02:00
|
|
|
const visibleReturn = methodBody.indexOf('if (geometry) return formatPaneSnapshot(');
|
|
|
|
|
expect(write).toBeGreaterThan(-1);
|
|
|
|
|
expect(replayBranch).toBeGreaterThan(-1);
|
|
|
|
|
expect(visibleReturn).toBeGreaterThan(-1);
|
|
|
|
|
expect(write).toBeLessThan(replayBranch);
|
|
|
|
|
expect(write).toBeLessThan(visibleReturn);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('reports nothing when the cursor query gave no geometry', () => {
|
|
|
|
|
// `queryPaneCursor` returns null on a failed or nonsensical query, and the
|
|
|
|
|
// snapshot repaint is skipped in that case. Reporting a size anyway would
|
|
|
|
|
// describe a frame that was never positioned.
|
|
|
|
|
expect(methodBody).toContain('if (opts && geometry)');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-10 05:56:37 +02:00
|
|
|
describe('the capture reports how much scrollback tmux holds (#{history_size})', () => {
|
|
|
|
|
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
|
|
|
|
|
const methodStart = source.indexOf('capturePaneBuffer(muxName: string');
|
|
|
|
|
const methodBody = source.slice(methodStart, source.indexOf('captureActivePaneBuffer(muxName: string', methodStart));
|
|
|
|
|
|
|
|
|
|
it('reads it from the one cursor query the capture already makes', () => {
|
|
|
|
|
// No extra tmux call: every capture is a synchronous exec on the server.
|
2026-10-13 17:59:07 +02:00
|
|
|
expect(methodBody).toContain(
|
|
|
|
|
"'#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'"
|
|
|
|
|
);
|
2026-10-10 05:56:37 +02:00
|
|
|
expect(methodBody.match(/display-message/g)).toHaveLength(1);
|
|
|
|
|
expect(methodBody).toContain('opts.capturedHistoryLines = geometry.historyLines');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('parses the fifth field as historyLines', () => {
|
|
|
|
|
expect(queryPaneCursor(() => '3 5 120 40 812\n')).toEqual({
|
|
|
|
|
cursorX: 3,
|
|
|
|
|
cursorY: 5,
|
|
|
|
|
cols: 120,
|
|
|
|
|
rows: 40,
|
|
|
|
|
historyLines: 812,
|
|
|
|
|
});
|
|
|
|
|
// A pane in the alternate screen (fullscreen claude) holds none.
|
|
|
|
|
expect(queryPaneCursor(() => '0 31 187 32 0')?.historyLines).toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('never lets a missing or bad history field void the caret', () => {
|
|
|
|
|
for (const raw of ['3 5 120 40', '3 5 120 40 ', '3 5 120 40 x', '3 5 120 40 -1']) {
|
|
|
|
|
const geometry = queryPaneCursor(() => raw);
|
|
|
|
|
expect(geometry).toEqual({ cursorX: 3, cursorY: 5, cols: 120, rows: 40 });
|
|
|
|
|
expect(geometry && 'historyLines' in geometry).toBe(false);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('still returns null when the geometry itself is bad', () => {
|
|
|
|
|
expect(queryPaneCursor(() => 'x 5 120 40 812')).toBeNull();
|
|
|
|
|
expect(
|
|
|
|
|
queryPaneCursor(() => {
|
|
|
|
|
throw new Error('no pane');
|
|
|
|
|
})
|
|
|
|
|
).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-15 12:42:55 +02:00
|
|
|
describe('why a capture has to report its height', () => {
|
|
|
|
|
it('a snapshot addresses rows the receiving terminal may not have', () => {
|
|
|
|
|
// formatPaneSnapshot positions every row absolutely. A terminal shorter
|
|
|
|
|
// than the pane clamps each address past its own height onto its last
|
|
|
|
|
// line, so the overflow rows overwrite one another and the rows underneath
|
|
|
|
|
// are lost. Nothing in the escape sequence tells the client this happened —
|
|
|
|
|
// hence captureRows on the response.
|
|
|
|
|
const lines = Array.from({ length: 50 }, (_, i) => `row-${i + 1}`);
|
|
|
|
|
// cursorX 5 keeps the trailing cursor-restore move (`\x1b[50;6H`) out of the
|
|
|
|
|
// `;1H` row-paint match below, so the count is row paints alone.
|
|
|
|
|
const snapshot = formatPaneSnapshot(lines, { cols: 100, rows: 50, cursorX: 5, cursorY: 49 });
|
|
|
|
|
const addressed = [...snapshot.matchAll(/\x1b\[(\d+);1H/g)].map((m) => Number(m[1]));
|
|
|
|
|
|
|
|
|
|
expect(Math.max(...addressed)).toBe(50);
|
|
|
|
|
// A 30-row terminal cannot honour 20 of those addresses.
|
|
|
|
|
expect(addressed.filter((row) => row > 30)).toHaveLength(20);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-10-09 19:18:05 -05:00
|
|
|
|
|
|
|
|
describe('queryPaneCursor history depth', () => {
|
|
|
|
|
it('reads tmux history_size as an optional fifth field', () => {
|
|
|
|
|
expect(queryPaneCursor(() => '4 22 133 24 25000\n')).toEqual({
|
|
|
|
|
cols: 133,
|
|
|
|
|
rows: 24,
|
|
|
|
|
cursorX: 4,
|
|
|
|
|
cursorY: 22,
|
2026-10-13 17:59:07 +02:00
|
|
|
historyLines: 25000,
|
2026-10-09 19:18:05 -05:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('keeps the geometry and omits the depth when the fifth field is missing or invalid', () => {
|
|
|
|
|
for (const raw of ['4 22 133 24', '4 22 133 24 abc', '4 22 133 24 -1']) {
|
|
|
|
|
const geometry = queryPaneCursor(() => raw);
|
|
|
|
|
expect(geometry, raw).toEqual({ cols: 133, rows: 24, cursorX: 4, cursorY: 22 });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-10 23:29:19 -05:00
|
|
|
it('reads tmux alternate_on as an optional sixth field', () => {
|
|
|
|
|
expect(queryPaneCursor(() => '4 22 133 24 29 1\n')).toEqual({
|
|
|
|
|
cols: 133,
|
|
|
|
|
rows: 24,
|
|
|
|
|
cursorX: 4,
|
|
|
|
|
cursorY: 22,
|
2026-10-13 17:59:07 +02:00
|
|
|
historyLines: 29,
|
2026-10-10 23:29:19 -05:00
|
|
|
alternateOn: true,
|
|
|
|
|
});
|
|
|
|
|
expect(queryPaneCursor(() => '4 22 133 24 29 0')?.alternateOn).toBe(false);
|
|
|
|
|
for (const raw of ['4 22 133 24 29', '4 22 133 24 29 2', '4 22 133 24 29 x']) {
|
|
|
|
|
expect(
|
|
|
|
|
queryPaneCursor(() => raw),
|
|
|
|
|
raw
|
|
|
|
|
).not.toHaveProperty('alternateOn');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('asks tmux for the history depth and alternate screen in the same display-message as the cursor', () => {
|
2026-10-09 19:18:05 -05:00
|
|
|
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
|
2026-10-10 23:29:19 -05:00
|
|
|
expect(source).toContain("'#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'");
|
2026-10-09 19:18:05 -05:00
|
|
|
});
|
|
|
|
|
});
|