diff --git a/package-lock.json b/package-lock.json
index ca5c673a..37f12b93 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 0a20f8af..bb808c28 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/scripts/build.mjs b/scripts/build.mjs
index 98292ac2..d18a8d99 100644
--- a/scripts/build.mjs
+++ b/scripts/build.mjs
@@ -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');
diff --git a/scripts/postinstall.js b/scripts/postinstall.js
index 71b3da5e..8b56b632 100644
--- a/scripts/postinstall.js
+++ b/scripts/postinstall.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)'));
}
diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts
index e5d4f5f2..74987265 100644
--- a/src/tmux-manager.ts
+++ b/src/tmux-manager.ts
@@ -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++) {
diff --git a/src/web/public/app.js b/src/web/public/app.js
index 74d31a18..29f76167 100644
--- a/src/web/public/app.js
+++ b/src/web/public/app.js
@@ -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 = {};
diff --git a/src/web/public/index.html b/src/web/public/index.html
index 420d64b3..ba30557b 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -30,6 +30,11 @@
'defer' preserves execution order (xterm loads before fit addon). -->
+
+
diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js
index 609b9989..1f8f8577 100644
--- a/src/web/public/terminal-ui.js
+++ b/src/web/public/terminal-ui.js
@@ -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
+ 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();
diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts
index 476c2989..f0425bd5 100644
--- a/src/web/routes/session-routes.ts
+++ b/src/web/routes/session-routes.ts
@@ -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;
diff --git a/test/codex-snapshot-replay.test.ts b/test/codex-snapshot-replay.test.ts
new file mode 100644
index 00000000..4eade8e9
--- /dev/null
+++ b/test/codex-snapshot-replay.test.ts
@@ -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-')");
+ });
+});
diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts
index decf615c..34587ab7 100644
--- a/test/routes/session-routes.test.ts
+++ b/test/routes/session-routes.test.ts
@@ -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 ==========
diff --git a/test/tmux-manager.test.ts b/test/tmux-manager.test.ts
index cd1ad7a0..2fc27c3c 100644
--- a/test/tmux-manager.test.ts
+++ b/test/tmux-manager.test.ts
@@ -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', () => {