mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39:42 +02:00
feat(terminal): snapshot-replay on tab switches (xterm serialize + live pane capture)
Switching away from a session and back replayed only the server's byte history. For TUI modes (codex especially) that shows just the latest repaint — the idle banner — because the TUI drops earlier conversation from its current frame. This restores the actual on-screen view. Two complementary mechanisms: - Client: load xterm's SerializeAddon and snapshot the rendered state (viewport + scrollback + colors) per session on switch-away, restoring it for an instant first paint on switch-back. The snapshot is only the first paint — the canonical /terminal frame is still fetched and reconciled (restoredSnapshot/clearedForBusy force the replay). Snapshots are LRU-bounded in memory (<=20) and persisted to localStorage (<=256KB each, <=10 sessions, stale-pruned) so they survive tab discard. - Server: GET /api/sessions/:id/terminal prepends the live tmux pane buffer (via the existing captureActivePaneBuffer) ahead of the byte history, cleared between, so replay reflects the current frame. Also fix formatPaneSnapshot dropping the rightmost column of every captured row: it painted to cols - 1 out of caution about last-column autowrap, but every row is followed by an absolute cursor-position CSI that cancels xterm's pending-wrap, so painting the full width is safe. The SerializeAddon is built from @xterm/addon-serialize (new dependency) into the vendor bundle by postinstall.js (dev) and build.mjs (prod), matching how the other xterm addons are vendored.
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// Structural tests for the xterm snapshot/replay slice (COD-81). app.js has no
|
||||
// bundler and is hard to drive through a real DOM, so — following the repo's
|
||||
// existing pattern for app.js — these assert the source structure that makes
|
||||
// the snapshot first-paint correct rather than executing it.
|
||||
describe('xterm snapshot/replay (codex tab-switch)', () => {
|
||||
const appSource = () => readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
|
||||
it('rejects blank xterm snapshots before saving or restoring them', () => {
|
||||
const source = appSource();
|
||||
const helper = source.indexOf('_isUsableXtermSnapshot(snapshot)');
|
||||
const save = source.indexOf('this._xtermSnapshots.set(this.activeSessionId, snapshot)');
|
||||
const restore = source.indexOf('SNAPSHOT_RESTORE:', save);
|
||||
const restoreBlock = source.slice(save, restore);
|
||||
|
||||
expect(helper).toBeGreaterThan(-1);
|
||||
// The save is gated on a usability check…
|
||||
expect(source.slice(save - 250, save)).toContain('this._isUsableXtermSnapshot(snapshot)');
|
||||
// …and so is each restore path (in-memory + persisted).
|
||||
expect(restoreBlock).toContain('if (snapshot && !this._isUsableXtermSnapshot(snapshot))');
|
||||
expect(restoreBlock).toContain('persisted && this._isUsableXtermSnapshot(persisted)');
|
||||
});
|
||||
|
||||
it('declares the snapshot-restore flag before selectSession uses it', () => {
|
||||
const source = appSource();
|
||||
const selectStart = source.indexOf('async selectSession(sessionId, options = {})');
|
||||
const declaration = source.indexOf('let restoredSnapshot = false;', selectStart);
|
||||
const snapshotBranch = source.indexOf("if (snapshot && !sessionIsBusy && session?.mode !== 'shell')", selectStart);
|
||||
const rewriteDecision = source.indexOf(
|
||||
'restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer',
|
||||
selectStart
|
||||
);
|
||||
|
||||
expect(selectStart).toBeGreaterThan(-1);
|
||||
expect(declaration).toBeGreaterThan(selectStart);
|
||||
expect(declaration).toBeLessThan(snapshotBranch);
|
||||
expect(declaration).toBeLessThan(rewriteDecision);
|
||||
});
|
||||
|
||||
it('uses xterm snapshots as first paint but still fetches the canonical terminal frame', () => {
|
||||
const source = appSource();
|
||||
const snapshotRestore = source.indexOf('SNAPSHOT_RESTORE:');
|
||||
const cacheRestore = source.indexOf('Instant cache restore', snapshotRestore);
|
||||
const fetchStart = source.indexOf("FETCH_START'", snapshotRestore);
|
||||
const needsRewrite = source.indexOf('const needsRewrite', fetchStart);
|
||||
const snapshotBlock = source.slice(snapshotRestore, cacheRestore);
|
||||
const postSnapshotRestore = source.slice(snapshotRestore, needsRewrite + 160);
|
||||
|
||||
expect(snapshotRestore).toBeGreaterThan(-1);
|
||||
expect(cacheRestore).toBeGreaterThan(snapshotRestore);
|
||||
expect(fetchStart).toBeGreaterThan(cacheRestore);
|
||||
expect(needsRewrite).toBeGreaterThan(fetchStart);
|
||||
// Snapshot restore must NOT short-circuit the canonical fetch.
|
||||
expect(snapshotBlock).not.toContain('this._finishBufferLoad();');
|
||||
expect(postSnapshotRestore).toContain('restoredSnapshot');
|
||||
expect(postSnapshotRestore).toContain('restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer');
|
||||
});
|
||||
|
||||
it('forces replay after clearing a busy tab even when the fetched frame matches cache', () => {
|
||||
const source = appSource();
|
||||
const cacheRestore = source.indexOf('Instant cache restore');
|
||||
const busyClear = source.indexOf('CACHE_SKIP_BUSY', cacheRestore);
|
||||
const needsRewrite = source.indexOf('const needsRewrite', busyClear);
|
||||
const replayBlock = source.slice(cacheRestore, needsRewrite + 160);
|
||||
|
||||
expect(cacheRestore).toBeGreaterThan(-1);
|
||||
expect(busyClear).toBeGreaterThan(cacheRestore);
|
||||
expect(needsRewrite).toBeGreaterThan(busyClear);
|
||||
expect(replayBlock).toContain('clearedForBusy');
|
||||
expect(replayBlock).toContain('restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer');
|
||||
});
|
||||
|
||||
it('loads the SerializeAddon and keeps a per-session snapshot map', () => {
|
||||
const terminalSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
|
||||
expect(terminalSource).toContain('this._xtermSnapshots = new Map()');
|
||||
expect(terminalSource).toContain('new SerializeAddon.SerializeAddon()');
|
||||
expect(terminalSource).toContain('this.terminal.loadAddon(this._serializeAddon)');
|
||||
});
|
||||
|
||||
it('evicts the in-memory snapshot cache and persists with a bounded localStorage budget', () => {
|
||||
const source = appSource();
|
||||
// In-memory cache is LRU-bounded…
|
||||
expect(source).toContain('if (this._xtermSnapshots.size > 20)');
|
||||
// …per-snapshot localStorage writes are size-capped…
|
||||
expect(source).toContain('snapshot.length < 256 * 1024');
|
||||
// …and the persisted key set is pruned of dead sessions.
|
||||
expect(source).toContain("k.startsWith('codeman-xs-')");
|
||||
});
|
||||
});
|
||||
@@ -441,6 +441,39 @@ describe('session-routes', () => {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('prepends the live tmux pane buffer (cleared) before the byte history', async () => {
|
||||
harness.ctx._session.terminalBuffer = 'history-bytes';
|
||||
harness.ctx.mux.captureActivePaneBuffer = vi.fn(() => 'LIVE-PANE-FRAME');
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const buf = JSON.parse(res.body).data.terminalBuffer as string;
|
||||
// history, then a viewport clear, then the live pane frame
|
||||
expect(buf).toContain('history-bytes');
|
||||
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);
|
||||
});
|
||||
|
||||
it('falls back to the byte history when no live pane buffer is available', async () => {
|
||||
harness.ctx._session.terminalBuffer = 'history-only';
|
||||
// Empty string (the test-mode return) and null both mean "no live frame".
|
||||
harness.ctx.mux.captureActivePaneBuffer = vi.fn(() => '');
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const buf = JSON.parse(res.body).data.terminalBuffer as string;
|
||||
expect(buf).toContain('history-only');
|
||||
expect(buf).not.toContain('\x1b[H\x1b[2J');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions/:id/run ==========
|
||||
|
||||
+24
-10
@@ -164,10 +164,24 @@ describe('TmuxManager (unit)', () => {
|
||||
cursorY: 1,
|
||||
});
|
||||
|
||||
expect(snapshot).toBe(`\x1b[1;1H${'x'.repeat(9)}\x1b[2;1Hnext line\x1b[2;3H`);
|
||||
// Full pane width is painted (10 cols); autowrap is avoided by the
|
||||
// absolute cursor positioning, not by dropping the last column.
|
||||
expect(snapshot).toBe(`\x1b[1;1H${'x'.repeat(10)}\x1b[2;1Hnext line\x1b[2;3H`);
|
||||
expect(snapshot).not.toContain('\n');
|
||||
});
|
||||
|
||||
it('preserves the rightmost column of each captured row', () => {
|
||||
const snapshot = formatPaneSnapshot(['abcd'], {
|
||||
cols: 4,
|
||||
rows: 1,
|
||||
cursorX: 0,
|
||||
cursorY: 0,
|
||||
});
|
||||
|
||||
// Previously truncated to cols - 1 ('abc'); the full width is now kept.
|
||||
expect(snapshot).toBe('\x1b[1;1Habcd\x1b[1;1H');
|
||||
});
|
||||
|
||||
it('preserves SGR color while stripping non-style pane controls', () => {
|
||||
const snapshot = formatPaneSnapshot(['\x1b[32mgreen\x1b[0m\x1b[2K\x1b[10;20Htail'], {
|
||||
cols: 40,
|
||||
@@ -190,18 +204,18 @@ describe('TmuxManager (unit)', () => {
|
||||
cursorY: 0,
|
||||
});
|
||||
|
||||
expect(snapshot).toBe('\x1b[1;1H\x1b[31mabc\x1b[0m\x1b[1;1H');
|
||||
expect(snapshot).toBe('\x1b[1;1H\x1b[31mabcd\x1b[0m\x1b[1;1H');
|
||||
});
|
||||
|
||||
it('does not let full-width glyphs cross the paint boundary', () => {
|
||||
const snapshot = formatPaneSnapshot(['abc\u754cdef'], {
|
||||
cols: 5,
|
||||
rows: 1,
|
||||
cursorX: 0,
|
||||
cursorY: 0,
|
||||
});
|
||||
|
||||
expect(snapshot).toBe('\x1b[1;1Habc\x1b[1;1H');
|
||||
// cols 5 = 'abc' (3) + full-width \u754c (2) fits exactly; with cols 4 the
|
||||
// wide glyph would straddle the boundary and is dropped.
|
||||
expect(formatPaneSnapshot(['abc\u754cdef'], { cols: 5, rows: 1, cursorX: 0, cursorY: 0 })).toBe(
|
||||
'\x1b[1;1Habc\u754c\x1b[1;1H'
|
||||
);
|
||||
expect(formatPaneSnapshot(['abc\u754cdef'], { cols: 4, rows: 1, cursorX: 0, cursorY: 0 })).toBe(
|
||||
'\x1b[1;1Habc\x1b[1;1H'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps combining marks attached without consuming a terminal column', () => {
|
||||
|
||||
Reference in New Issue
Block a user