From 5b2da424a180b214b4c8b686d5d0d10866c48200 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Wed, 10 Jun 2026 19:56:20 -0400 Subject: [PATCH 1/2] feat(terminal): snapshot-replay on tab switches (xterm serialize + live pane capture) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- package-lock.json | 7 ++ package.json | 1 + scripts/build.mjs | 1 + scripts/postinstall.js | 3 + src/tmux-manager.ts | 7 +- src/web/public/app.js | 130 ++++++++++++++++++++++++++++- src/web/public/index.html | 5 ++ src/web/public/terminal-ui.js | 17 ++++ src/web/routes/session-routes.ts | 19 ++++- test/codex-snapshot-replay.test.ts | 92 ++++++++++++++++++++ test/routes/session-routes.test.ts | 33 ++++++++ test/tmux-manager.test.ts | 34 +++++--- 12 files changed, 333 insertions(+), 16 deletions(-) create mode 100644 test/codex-snapshot-replay.test.ts 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..2d35cf9b 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,66 @@ 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]*(?:\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; + } + _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. + if (this.activeSessionId && this._serializeAddon && this._xtermSnapshots) { + try { + const snapshot = this._serializeAddon.serialize({ scrollback: 1000 }); + if (this._isUsableXtermSnapshot(snapshot)) { + 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 and limit total + // to 10 sessions; 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) { + try { + localStorage.setItem(`codeman-xs-${this.activeSessionId}`, snapshot); + // LRU eviction: keep at most 10 snapshot keys + const keys = Object.keys(localStorage).filter((k) => k.startsWith('codeman-xs-')); + if (keys.length > 10) { + // Drop ones not in the current sessions map (stale) + const liveIds = new Set(Array.from(this.sessions?.keys?.() || [])); + for (const k of keys) { + if (!liveIds.has(k.slice('codeman-xs-'.length))) { + localStorage.removeItem(k); + } + } + } + } catch (_e) { + /* localStorage quota exceeded — silently fall through */ + } + } + } 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 +3074,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 +3196,55 @@ 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 + 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 +3252,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 +3267,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 +3298,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 +3478,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 +3653,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..06c328fb --- /dev/null +++ b/test/codex-snapshot-replay.test.ts @@ -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-')"); + }); +}); 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', () => { From 9893a7f64abbdc8e13dfbb615af53963aae40385 Mon Sep 17 00:00:00 2001 From: arkon Date: Thu, 11 Jun 2026 10:16:47 +0200 Subject: [PATCH 2/2] fix(terminal): bound + harden xterm snapshot persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Skip snapshot save for shell sessions (restore is gated on mode!=='shell', so they only burned a serialize() + cache slot + localStorage quota). - In-memory cache: delete-before-set so eviction is true LRU, not FIFO that could drop the most-recently-used session. - localStorage: extract _persistXtermSnapshot — evict to a fixed key budget regardless of session liveness (the old prune only dropped dead keys, so >10 live sessions at the 20-session target deadlocked the quota) and evict-and-retry on quota errors (the old prune ran only after a successful setItem, so a full quota permanently disabled persistence). - Tighten the OSC-strip regex in _isUsableXtermSnapshot to stop at ST. - Update the structural test's usability-gate assertion to not depend on a fixed byte window. --- src/web/public/app.js | 89 ++++++++++++++++++++++-------- test/codex-snapshot-replay.test.ts | 6 +- 2 files changed, 70 insertions(+), 25 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index 2d35cf9b..29f76167 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2917,7 +2917,7 @@ class CodemanApp { _isUsableXtermSnapshot(snapshot) { if (!snapshot || typeof snapshot !== 'string' || snapshot.length < 8) return false; const visibleText = snapshot - .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '') + .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, '') @@ -2925,16 +2925,73 @@ class CodemanApp { 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. - if (this.activeSessionId && this._serializeAddon && this._xtermSnapshots) { + // 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) { @@ -2943,27 +3000,11 @@ class CodemanApp { } // 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 and limit total - // to 10 sessions; codex buffer-replay produces a visual mess of - // stacked banner redraws when no snapshot exists, so persistence - // matters more here than for claude. + // 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) { - try { - localStorage.setItem(`codeman-xs-${this.activeSessionId}`, snapshot); - // LRU eviction: keep at most 10 snapshot keys - const keys = Object.keys(localStorage).filter((k) => k.startsWith('codeman-xs-')); - if (keys.length > 10) { - // Drop ones not in the current sessions map (stale) - const liveIds = new Set(Array.from(this.sessions?.keys?.() || [])); - for (const k of keys) { - if (!liveIds.has(k.slice('codeman-xs-'.length))) { - localStorage.removeItem(k); - } - } - } - } catch (_e) { - /* localStorage quota exceeded — silently fall through */ - } + this._persistXtermSnapshot(`codeman-xs-${this.activeSessionId}`, snapshot); } } else { this._xtermSnapshots.delete(this.activeSessionId); @@ -3217,7 +3258,9 @@ class CodemanApp { const persisted = localStorage.getItem(`codeman-xs-${sessionId}`); if (persisted && this._isUsableXtermSnapshot(persisted)) { snapshot = persisted; - // Hoist into in-memory cache for next time + // 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}`); diff --git a/test/codex-snapshot-replay.test.ts b/test/codex-snapshot-replay.test.ts index 06c328fb..4eade8e9 100644 --- a/test/codex-snapshot-replay.test.ts +++ b/test/codex-snapshot-replay.test.ts @@ -17,8 +17,10 @@ describe('xterm snapshot/replay (codex tab-switch)', () => { 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)'); + // 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)');