mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #118 from aakhter/pr/cod-81-snapshot
feat(terminal): snapshot-replay on tab switches (xterm serialize + live pane capture)
Review fixes (9893a7f): bounded/hardened xterm snapshot persistence — shell-session skip, true LRU eviction, localStorage quota-deadlock fix with evict-and-retry, OSC-strip regex tightened.
This commit is contained in:
Generated
+7
@@ -20,6 +20,7 @@
|
||||
"@fastify/static": "^9.1.3",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-serialize": "^0.14.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
@@ -4525,6 +4526,12 @@
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-serialize": {
|
||||
"version": "0.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz",
|
||||
"integrity": "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-unicode11": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz",
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"@fastify/static": "^9.1.3",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-serialize": "^0.14.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
|
||||
@@ -45,6 +45,7 @@ run('copy template', 'cp src/templates/case-template.md dist/templates/');
|
||||
run('xterm css', 'cp node_modules/@xterm/xterm/css/xterm.css dist/web/public/vendor/');
|
||||
run('xterm js', 'npx esbuild node_modules/@xterm/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js');
|
||||
run('xterm-addon-fit', 'npx esbuild node_modules/@xterm/addon-fit/lib/addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js');
|
||||
run('xterm-addon-serialize', 'npx esbuild node_modules/@xterm/addon-serialize/lib/addon-serialize.js --minify --outfile=dist/web/public/vendor/xterm-addon-serialize.min.js');
|
||||
run('xterm-addon-webgl', 'cp node_modules/@xterm/addon-webgl/lib/addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js');
|
||||
run('xterm-addon-unicode11', 'npx esbuild node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js');
|
||||
run('xterm-zerolag-input', 'npx esbuild packages/xterm-zerolag-input/src/zerolag-input-addon.ts --bundle --minify --format=iife --global-name=XtermZerolagInput --outfile=dist/web/public/vendor/xterm-zerolag-input.js');
|
||||
|
||||
@@ -252,6 +252,7 @@ if (isGlobalInstall) {
|
||||
const require = createRequire(import.meta.url);
|
||||
const xtermDir = join(require.resolve('@xterm/xterm'), '..', '..');
|
||||
const fitDir = join(require.resolve('@xterm/addon-fit'), '..', '..');
|
||||
const serializeDir = join(require.resolve('@xterm/addon-serialize'), '..', '..');
|
||||
const webglDir = join(require.resolve('@xterm/addon-webgl'), '..', '..');
|
||||
const unicode11Dir = join(require.resolve('@xterm/addon-unicode11'), '..', '..');
|
||||
const vendorDir = join(srcDir, 'web', 'public', 'vendor');
|
||||
@@ -264,12 +265,14 @@ if (isGlobalInstall) {
|
||||
try {
|
||||
execSync(`npx esbuild "${join(xtermDir, 'lib', 'xterm.js')}" --minify --outfile="${join(vendorDir, 'xterm.min.js')}"`, { stdio: 'pipe' });
|
||||
execSync(`npx esbuild "${join(fitDir, 'lib', 'addon-fit.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-fit.min.js')}"`, { stdio: 'pipe' });
|
||||
execSync(`npx esbuild "${join(serializeDir, 'lib', 'addon-serialize.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-serialize.min.js')}"`, { stdio: 'pipe' });
|
||||
execSync(`npx esbuild "${join(unicode11Dir, 'lib', 'addon-unicode11.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-unicode11.min.js')}"`, { stdio: 'pipe' });
|
||||
console.log(colors.green('✓ xterm vendor files copied to src/web/public/vendor/'));
|
||||
} catch {
|
||||
// Fallback: copy unminified
|
||||
copyFileSync(join(xtermDir, 'lib', 'xterm.js'), join(vendorDir, 'xterm.min.js'));
|
||||
copyFileSync(join(fitDir, 'lib', 'addon-fit.js'), join(vendorDir, 'xterm-addon-fit.min.js'));
|
||||
copyFileSync(join(serializeDir, 'lib', 'addon-serialize.js'), join(vendorDir, 'xterm-addon-serialize.min.js'));
|
||||
copyFileSync(join(unicode11Dir, 'lib', 'addon-unicode11.js'), join(vendorDir, 'xterm-addon-unicode11.min.js'));
|
||||
console.log(colors.green('✓ xterm vendor files copied') + colors.dim(' (unminified — esbuild not available)'));
|
||||
}
|
||||
|
||||
+6
-1
@@ -426,7 +426,12 @@ export function formatPaneSnapshot(
|
||||
geometry: { cols: number; rows: number; cursorX: number; cursorY: number }
|
||||
): string {
|
||||
const cols = Math.max(1, geometry.cols);
|
||||
const paintCols = Math.max(1, cols - 1);
|
||||
// Paint the full pane width. Earlier this dropped the rightmost column
|
||||
// (cols - 1) out of caution about last-column autowrap, but every painted
|
||||
// row is immediately followed by an absolute cursor-position CSI (the next
|
||||
// row's `\x1b[r;1H`, or the final cursor move), which cancels xterm's
|
||||
// pending-wrap state before any further glyph — so the last column is safe.
|
||||
const paintCols = cols;
|
||||
const rows = Math.max(1, geometry.rows);
|
||||
const parts: string[] = [];
|
||||
for (let row = 0; row < Math.min(lines.length, rows); row++) {
|
||||
|
||||
+169
-4
@@ -2123,6 +2123,7 @@ class CodemanApp {
|
||||
this.ralphStates.clear();
|
||||
this.terminalBuffers.clear();
|
||||
this.terminalBufferCache.clear();
|
||||
this._xtermSnapshots?.clear();
|
||||
this.projectInsights.clear();
|
||||
this.teams.clear();
|
||||
this.teamTasks.clear();
|
||||
@@ -2913,7 +2914,107 @@ class CodemanApp {
|
||||
* terminal write queue, IME composition, and local echo flush.
|
||||
* @param {string} newSessionId - The session being switched TO.
|
||||
*/
|
||||
_isUsableXtermSnapshot(snapshot) {
|
||||
if (!snapshot || typeof snapshot !== 'string' || snapshot.length < 8) return false;
|
||||
const visibleText = snapshot
|
||||
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
||||
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
|
||||
.replace(/\x1b[()][0-2A-Z]/g, '')
|
||||
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
||||
.trim();
|
||||
return visibleText.length >= 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist one xterm snapshot to localStorage, bounded to a fixed key budget
|
||||
* regardless of how many sessions are live, and resilient to quota errors.
|
||||
* The previous inline version only pruned snapshots for sessions that no
|
||||
* longer existed AND pruned only after a successful setItem — so once the
|
||||
* quota filled (e.g. >10 live sessions at the 20-session target) the write
|
||||
* threw before the prune could run, permanently disabling persistence.
|
||||
*/
|
||||
_persistXtermSnapshot(key, snapshot) {
|
||||
const PREFIX = 'codeman-xs-';
|
||||
const MAX_KEYS = 10;
|
||||
const others = () => Object.keys(localStorage).filter((k) => k.startsWith(PREFIX) && k !== key);
|
||||
try {
|
||||
// Evict down to the budget before writing a NEW key, dead sessions first
|
||||
// then oldest. (Overwriting an existing key doesn't grow the key count.)
|
||||
if (localStorage.getItem(key) === null) {
|
||||
const live = new Set(Array.from(this.sessions?.keys?.() || []));
|
||||
const pool = others().sort(
|
||||
(a, b) =>
|
||||
Number(live.has(a.slice(PREFIX.length))) - Number(live.has(b.slice(PREFIX.length)))
|
||||
);
|
||||
while (pool.length >= MAX_KEYS) localStorage.removeItem(pool.shift());
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(key, snapshot);
|
||||
} catch (_quota) {
|
||||
// Quota exceeded: drop other snapshots one at a time and retry so a full
|
||||
// quota can't permanently disable persistence.
|
||||
for (const victim of others()) {
|
||||
localStorage.removeItem(victim);
|
||||
try {
|
||||
localStorage.setItem(key, snapshot);
|
||||
return;
|
||||
} catch (_again) {
|
||||
/* keep evicting */
|
||||
}
|
||||
}
|
||||
try { localStorage.removeItem(key); } catch {}
|
||||
}
|
||||
} catch (_unavailable) {
|
||||
/* localStorage unavailable (Safari private mode / disabled) — in-memory only */
|
||||
}
|
||||
}
|
||||
|
||||
_cleanupPreviousSession(newSessionId) {
|
||||
// Snapshot the OUTGOING session's xterm rendered state (viewport + scrollback +
|
||||
// colors/attrs) before the terminal gets cleared/reset. Lets us restore the
|
||||
// exact view on switch-back rather than replaying codex's byte stream, which
|
||||
// drops earlier conversation from each TUI redraw and ends up showing only
|
||||
// the latest (idle) frame.
|
||||
// Shell sessions are never restored from a snapshot (restore is gated on
|
||||
// mode !== 'shell'), so skip the serialize() + cache slot + localStorage
|
||||
// quota for them. Unknown/undefined mode still snapshots, matching restore.
|
||||
const outgoingSession = this.activeSessionId ? this.sessions?.get?.(this.activeSessionId) : null;
|
||||
if (
|
||||
this.activeSessionId &&
|
||||
outgoingSession?.mode !== 'shell' &&
|
||||
this._serializeAddon &&
|
||||
this._xtermSnapshots
|
||||
) {
|
||||
try {
|
||||
const snapshot = this._serializeAddon.serialize({ scrollback: 1000 });
|
||||
if (this._isUsableXtermSnapshot(snapshot)) {
|
||||
// Delete-before-set so re-touching a session moves it to the end of
|
||||
// the Map's insertion order — otherwise eviction is FIFO and can drop
|
||||
// the most-recently-used session instead of the least.
|
||||
this._xtermSnapshots.delete(this.activeSessionId);
|
||||
this._xtermSnapshots.set(this.activeSessionId, snapshot);
|
||||
// Cap in-memory snapshot cache at 20 entries; evict oldest on overflow.
|
||||
if (this._xtermSnapshots.size > 20) {
|
||||
const oldest = this._xtermSnapshots.keys().next().value;
|
||||
this._xtermSnapshots.delete(oldest);
|
||||
}
|
||||
// Persist to localStorage so the snapshot survives tab discard /
|
||||
// browser reload (Chrome discards inactive tabs after idle periods,
|
||||
// wiping in-memory state). Cap per-snapshot at 256KB; codex
|
||||
// buffer-replay produces a visual mess of stacked banner redraws when
|
||||
// no snapshot exists, so persistence matters more here than for claude.
|
||||
if (snapshot.length < 256 * 1024) {
|
||||
this._persistXtermSnapshot(`codeman-xs-${this.activeSessionId}`, snapshot);
|
||||
}
|
||||
} else {
|
||||
this._xtermSnapshots.delete(this.activeSessionId);
|
||||
try { localStorage.removeItem(`codeman-xs-${this.activeSessionId}`); } catch {}
|
||||
}
|
||||
} catch (_err) {
|
||||
/* Serialize failed — fall back to server buffer replay */
|
||||
}
|
||||
}
|
||||
|
||||
// Close WebSocket for previous session (new one opens after buffer load)
|
||||
this._disconnectWs();
|
||||
|
||||
@@ -3014,6 +3115,8 @@ class CodemanApp {
|
||||
if (this.activeSessionId === sessionId && !forceReload) return;
|
||||
if (this.activeSessionId === sessionId && forceReload) {
|
||||
this.terminalBufferCache?.delete(sessionId);
|
||||
this._xtermSnapshots?.delete(sessionId);
|
||||
try { localStorage.removeItem(`codeman-xs-${sessionId}`); } catch {}
|
||||
this._clearTimer('syncWaitTimeout');
|
||||
this.pendingWrites = [];
|
||||
this.writeFrameScheduled = false;
|
||||
@@ -3134,7 +3237,57 @@ class CodemanApp {
|
||||
return;
|
||||
}
|
||||
|
||||
// xterm snapshot restore: if we have a serialized xterm state from a
|
||||
// previous visit to this session, restore the user's exact prior view
|
||||
// (viewport + scrollback + colors) for an instant first paint. For codex
|
||||
// this is also a correctness fix — its byte-stream replay shows only the
|
||||
// latest TUI frame (the idle welcome banner) because codex doesn't include
|
||||
// earlier conversation in its current redraw. For claude/opencode/gemini
|
||||
// the replay is already complete, so the snapshot is purely a faster,
|
||||
// scroll-preserving first paint before the canonical fetch reconciles.
|
||||
//
|
||||
// Try in-memory first (fast); fall back to localStorage so snapshots
|
||||
// survive tab discards / browser reloads.
|
||||
let snapshot = this._xtermSnapshots?.get(sessionId);
|
||||
if (snapshot && !this._isUsableXtermSnapshot(snapshot)) {
|
||||
this._xtermSnapshots?.delete(sessionId);
|
||||
snapshot = null;
|
||||
}
|
||||
if (!snapshot) {
|
||||
try {
|
||||
const persisted = localStorage.getItem(`codeman-xs-${sessionId}`);
|
||||
if (persisted && this._isUsableXtermSnapshot(persisted)) {
|
||||
snapshot = persisted;
|
||||
// Hoist into in-memory cache for next time (delete-before-set keeps
|
||||
// the Map in LRU order so the just-used session isn't evicted first).
|
||||
this._xtermSnapshots?.delete(sessionId);
|
||||
this._xtermSnapshots?.set(sessionId, persisted);
|
||||
} else if (persisted) {
|
||||
localStorage.removeItem(`codeman-xs-${sessionId}`);
|
||||
}
|
||||
} catch (_e) {
|
||||
/* localStorage unavailable — proceed without snapshot */
|
||||
}
|
||||
}
|
||||
const sessionIsBusy = session && (session.status === 'busy' || session.status === 'working');
|
||||
let restoredSnapshot = false;
|
||||
if (snapshot && !sessionIsBusy && session?.mode !== 'shell') {
|
||||
_crashDiag.log(`SNAPSHOT_RESTORE: ${(snapshot.length/1024).toFixed(0)}KB`);
|
||||
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
|
||||
this._resetTerminalForReplay();
|
||||
await new Promise((resolve) => this.terminal.write(snapshot, resolve));
|
||||
if (this._isStaleSelect(selectGen)) {
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
return;
|
||||
}
|
||||
this.scrollToLastNonEmptyLine();
|
||||
_crashDiag.log('SNAPSHOT_RESTORE_DONE');
|
||||
// Snapshot restore is only first paint. Inactive tabs intentionally
|
||||
// unsubscribe from high-volume terminal output, so they can miss bytes
|
||||
// emitted while away. Keep going and replace the snapshot with the
|
||||
// canonical live tmux pane frame from /terminal.
|
||||
restoredSnapshot = true;
|
||||
}
|
||||
|
||||
// Instant cache restore for IDLE sessions only.
|
||||
// For busy sessions, the cache is always stale — writing it first causes a
|
||||
@@ -3142,7 +3295,8 @@ class CodemanApp {
|
||||
// blank and rewrites with fresh data. Skip the cache and write the fresh
|
||||
// buffer once for a single clean transition.
|
||||
const cachedBuffer = this.terminalBufferCache.get(sessionId);
|
||||
if (cachedBuffer && !sessionIsBusy) {
|
||||
let clearedForBusy = false;
|
||||
if (cachedBuffer && !sessionIsBusy && !restoredSnapshot) {
|
||||
_crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
|
||||
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
|
||||
this._resetTerminalForReplay();
|
||||
@@ -3156,6 +3310,7 @@ class CodemanApp {
|
||||
} else if (sessionIsBusy) {
|
||||
// Clear stale content immediately — fresh buffer is being fetched
|
||||
this._resetTerminalForReplay();
|
||||
clearedForBusy = true;
|
||||
_crashDiag.log('CACHE_SKIP_BUSY');
|
||||
}
|
||||
|
||||
@@ -3186,9 +3341,11 @@ class CodemanApp {
|
||||
// Skip rewrite if fresh buffer matches cache — avoids visible clear+rewrite flash.
|
||||
// On slow connections (mobile 5G), the gap between clear() and chunkedWrite() is
|
||||
// very visible, causing the terminal to flash blank then repaint.
|
||||
// Busy sessions skip cache restore and clear the terminal before fetching,
|
||||
// so they must replay the fetched buffer even when it matches cache.
|
||||
const needsRewrite = sessionIsBusy || data.terminalBuffer !== cachedBuffer;
|
||||
// A snapshot restore or a busy-clear leaves the terminal showing
|
||||
// something other than the cache, so the fetched buffer must be
|
||||
// replayed even when it byte-matches the cache.
|
||||
const needsRewrite =
|
||||
restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer;
|
||||
if (needsRewrite) {
|
||||
_crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`);
|
||||
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
|
||||
@@ -3364,6 +3521,8 @@ class CodemanApp {
|
||||
}
|
||||
this.terminalBuffers.delete(sessionId);
|
||||
this.terminalBufferCache.delete(sessionId);
|
||||
this._xtermSnapshots?.delete(sessionId);
|
||||
try { localStorage.removeItem(`codeman-xs-${sessionId}`); } catch {}
|
||||
|
||||
this._flushedOffsets?.delete(sessionId);
|
||||
this._flushedTexts?.delete(sessionId);
|
||||
@@ -3537,6 +3696,12 @@ class CodemanApp {
|
||||
this.terminalBuffers.clear();
|
||||
this.terminalBufferCache.clear();
|
||||
this.terminalLoadStates.clear();
|
||||
this._xtermSnapshots?.clear();
|
||||
try {
|
||||
for (const k of Object.keys(localStorage)) {
|
||||
if (k.startsWith('codeman-xs-')) localStorage.removeItem(k);
|
||||
}
|
||||
} catch {}
|
||||
this.activeSessionId = null;
|
||||
try { localStorage.removeItem('codeman-active-session'); } catch {}
|
||||
this.respawnStatus = {};
|
||||
|
||||
@@ -30,6 +30,11 @@
|
||||
'defer' preserves execution order (xterm loads before fit addon). -->
|
||||
<script defer src="vendor/xterm.min.js"></script>
|
||||
<script defer src="vendor/xterm-addon-fit.min.js"></script>
|
||||
<!-- SerializeAddon: snapshots xterm state (viewport + scrollback + attrs) for
|
||||
per-session restore on tab switches. Lets codex tabs survive switch-away
|
||||
without codeman having to replay codex's byte stream (which loses earlier
|
||||
conversation because codex's TUI redraws drop it from the viewport). -->
|
||||
<script defer src="vendor/xterm-addon-serialize.min.js"></script>
|
||||
<!-- WebGL addon lazy-loaded by app.js on desktop only (skipped on mobile, saving 244KB) -->
|
||||
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
|
||||
<script defer src="vendor/xterm-zerolag-input.js"></script>
|
||||
|
||||
@@ -84,6 +84,23 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.fitAddon = new FitAddon.FitAddon();
|
||||
this.terminal.loadAddon(this.fitAddon);
|
||||
|
||||
// SerializeAddon: lets us snapshot the xterm rendered state (viewport +
|
||||
// scrollback + colors/attrs) when switching away from a tab and restore
|
||||
// it on switch-back. Needed primarily for codex tabs — codex's TUI drops
|
||||
// earlier conversation from its current frame, so replaying the server
|
||||
// byte buffer on tab-switch shows only the latest (idle) frame. The
|
||||
// snapshot captures what the user was actually looking at.
|
||||
this._xtermSnapshots = new Map(); // Map<sessionId, serialized-string>
|
||||
if (typeof SerializeAddon !== 'undefined') {
|
||||
try {
|
||||
this._serializeAddon = new SerializeAddon.SerializeAddon();
|
||||
this.terminal.loadAddon(this._serializeAddon);
|
||||
} catch (_e) {
|
||||
/* SerializeAddon failed — snapshot/restore disabled, fallback to buffer-fetch */
|
||||
this._serializeAddon = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof Unicode11Addon !== 'undefined') {
|
||||
try {
|
||||
const unicode11Addon = new Unicode11Addon.Unicode11Addon();
|
||||
|
||||
@@ -932,7 +932,24 @@ export function registerSessionRoutes(
|
||||
const query = req.query as { tail?: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
const rawBuffer = session.terminalBuffer;
|
||||
// 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
|
||||
// accumulated buffer alone replays as the idle banner. We clear the viewport
|
||||
// (`\x1b[H\x1b[2J`) between the history and the live pane so they don't
|
||||
// overlap. `captureActivePaneBuffer` is a no-op ('') under test mode and
|
||||
// returns null when unavailable, in which case we fall back to history.
|
||||
const muxName = session.muxName;
|
||||
const liveMuxBuffer =
|
||||
muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
|
||||
? ctx.mux.captureActivePaneBuffer(muxName)
|
||||
: null;
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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 immediately above it.
|
||||
const usabilityGate = source.lastIndexOf('if (this._isUsableXtermSnapshot(snapshot))', save);
|
||||
expect(usabilityGate).toBeGreaterThan(-1);
|
||||
expect(usabilityGate).toBeLessThan(save);
|
||||
// …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