feat(terminal): snapshot-replay on tab switches (xterm serialize + live pane capture)

Switching away from a session and back replayed only the server's byte
history. For TUI modes (codex especially) that shows just the latest
repaint — the idle banner — because the TUI drops earlier conversation
from its current frame. This restores the actual on-screen view.

Two complementary mechanisms:

- Client: load xterm's SerializeAddon and snapshot the rendered state
  (viewport + scrollback + colors) per session on switch-away, restoring
  it for an instant first paint on switch-back. The snapshot is only the
  first paint — the canonical /terminal frame is still fetched and
  reconciled (restoredSnapshot/clearedForBusy force the replay). Snapshots
  are LRU-bounded in memory (<=20) and persisted to localStorage
  (<=256KB each, <=10 sessions, stale-pruned) so they survive tab discard.

- Server: GET /api/sessions/:id/terminal prepends the live tmux pane
  buffer (via the existing captureActivePaneBuffer) ahead of the byte
  history, cleared between, so replay reflects the current frame.

Also fix formatPaneSnapshot dropping the rightmost column of every
captured row: it painted to cols - 1 out of caution about last-column
autowrap, but every row is followed by an absolute cursor-position CSI
that cancels xterm's pending-wrap, so painting the full width is safe.

The SerializeAddon is built from @xterm/addon-serialize (new dependency)
into the vendor bundle by postinstall.js (dev) and build.mjs (prod),
matching how the other xterm addons are vendored.
This commit is contained in:
Aamer Akhter
2026-06-10 19:56:30 -04:00
parent aa84447899
commit 5b2da424a1
12 changed files with 333 additions and 16 deletions
+6 -1
View File
@@ -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++) {
+126 -4
View File
@@ -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 = {};
+5
View File
@@ -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>
+17
View File
@@ -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();
+18 -1
View File
@@ -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;