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', () => {