ai-or-die 0.1.86 → 0.1.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-or-die",
3
- "version": "0.1.86",
3
+ "version": "0.1.87",
4
4
  "description": "Universal AI coding terminal — Claude, Copilot, Gemini & more in your browser",
5
5
  "main": "src/server.js",
6
6
  "bin": {
package/src/public/app.js CHANGED
@@ -284,6 +284,15 @@ class ClaudeCodeWebInterface {
284
284
 
285
285
  // Check if there are existing sessions
286
286
  console.log('[Init] Checking sessions, tabs.size:', this.sessionTabManager.tabs.size);
287
+ // Clean up cached snapshots for sessions that no longer exist (deleted
288
+ // while away). Deferred so the IndexedDB hydrate has completed; reads the
289
+ // live tab set AT prune time (not now) so a tab created during the delay
290
+ // isn't mistaken for an orphan. Best-effort; LRU bounds growth regardless.
291
+ setTimeout(() => {
292
+ try {
293
+ this.snapshotCache?.pruneOrphans(Array.from(this.sessionTabManager.tabs.keys()));
294
+ } catch (_) { /* best-effort */ }
295
+ }, 3000);
287
296
  if (this.sessionTabManager.tabs.size > 0) {
288
297
  console.log('[Init] Found sessions, switching to first tab...');
289
298
  // Sessions exist - switch to the last-active one (persisted across
@@ -671,6 +680,30 @@ class ClaudeCodeWebInterface {
671
680
 
672
681
  this.terminal.open(document.getElementById('terminal'));
673
682
 
683
+ // Faithful per-tab snapshot cache for instant tab-switch repaint.
684
+ // Guarded: if the serialize addon or the cache class failed to load
685
+ // (e.g. CDN blocked), snapshotCache stays null and every call site is
686
+ // ?.-guarded, degrading cleanly to server-only repaint (today's path).
687
+ this.snapshotCache = null;
688
+ if (typeof SerializeAddon !== 'undefined' && typeof TerminalSnapshotCache !== 'undefined') {
689
+ try {
690
+ this._serializeAddon = new SerializeAddon.SerializeAddon();
691
+ this.terminal.loadAddon(this._serializeAddon);
692
+ const snapLines = parseInt((this.loadSettings() || {}).tabSnapshotLines, 10);
693
+ this.snapshotCache = new TerminalSnapshotCache({
694
+ terminal: this.terminal,
695
+ serializeAddon: this._serializeAddon,
696
+ maxLines: Number.isFinite(snapLines) ? snapLines : 500,
697
+ });
698
+ // Fire-and-forget: in-memory tier works immediately; the disk
699
+ // hydrate (for reload-restore) completes asynchronously.
700
+ this.snapshotCache.init();
701
+ } catch (e) {
702
+ this.snapshotCache = null;
703
+ try { console.warn('[snapshot-cache] init failed:', e && e.message); } catch (_) {}
704
+ }
705
+ }
706
+
674
707
  // WebGL renderer: 3-10x faster than DOM (0.7ms vs 5-10ms per frame)
675
708
  this._loadGpuRenderer();
676
709
 
@@ -2327,6 +2360,16 @@ class ClaudeCodeWebInterface {
2327
2360
  }
2328
2361
  }
2329
2362
 
2363
+ // True iff the shared terminal is stably displaying this session — no switch
2364
+ // in flight, and the active tab, the joined session, and `sid` all agree.
2365
+ // Capturing a snapshot is only correctly attributed when this holds.
2366
+ _terminalStablyShowsSession(sid) {
2367
+ if (!sid || this.pendingJoinSessionId) return false;
2368
+ if (this.currentClaudeSessionId !== sid) return false;
2369
+ if (this.sessionTabManager && this.sessionTabManager.activeTabId !== sid) return false;
2370
+ return true;
2371
+ }
2372
+
2330
2373
  handleBinaryOutput(data) {
2331
2374
  const chunk = new Uint8Array(data);
2332
2375
 
@@ -2349,6 +2392,20 @@ class ClaudeCodeWebInterface {
2349
2392
  if (this._planDetectTimer) clearTimeout(this._planDetectTimer);
2350
2393
  this._planDetectTimer = setTimeout(() => this._flushPlanDetection(), 100);
2351
2394
  }
2395
+
2396
+ // 4. Keep the active tab's snapshot fresh after output settles (never
2397
+ // per frame). Debounced so a burst captures once when it quiets. Only
2398
+ // captures while the terminal STABLY shows this session (no switch in
2399
+ // flight; active tab + joined session agree), so a switch can never
2400
+ // store one tab's screen under another tab's id.
2401
+ if (this.snapshotCache && this._terminalStablyShowsSession(this.currentClaudeSessionId)) {
2402
+ const sid = this.currentClaudeSessionId;
2403
+ if (this._snapCaptureTimer) clearTimeout(this._snapCaptureTimer);
2404
+ this._snapCaptureTimer = setTimeout(() => {
2405
+ this._snapCaptureTimer = null;
2406
+ if (this._terminalStablyShowsSession(sid)) this.snapshotCache?.capture(sid);
2407
+ }, 400);
2408
+ }
2352
2409
  }
2353
2410
 
2354
2411
  // Concatenate all queued chunks and write to terminal once per animation frame
@@ -2529,21 +2586,60 @@ class ClaudeCodeWebInterface {
2529
2586
  this.pendingJoinSessionId = null;
2530
2587
  }
2531
2588
 
2532
- // Repaint: prefer the rendered snapshot (clean last screen) so a
2533
- // refresh doesn't blank or show broken control sequences; fall
2534
- // back to raw outputBuffer replay when there's no snapshot.
2535
- if (message.renderedSnapshot) {
2536
- this.terminal.clear();
2537
- this.terminal.write(message.renderedSnapshot.replace(/\n/g, '\r\n'));
2538
- } else if (message.outputBuffer && message.outputBuffer.length > 0) {
2539
- this.terminal.clear();
2540
- message.outputBuffer.forEach(data => {
2541
- // Filter out focus tracking sequences (^[[I and ^[[O)
2542
- const filteredData = data.replace(/\x1b\[\[?[IO]/g, '');
2543
- this.terminal.write(filteredData);
2544
- });
2589
+ // Repaint on (re)join but only if this join is still the tab
2590
+ // the user has selected. A rapid A→B→C switch can deliver a stale
2591
+ // join (in EITHER arrival order); repainting it would paint the
2592
+ // wrong tab. activeTabId is the user's selection and is immune to
2593
+ // the join ordering/timeout races that make pendingJoinSessionId
2594
+ // unreliable here.
2595
+ const targetTabId = this.sessionTabManager ? this.sessionTabManager.activeTabId : message.sessionId;
2596
+ const isStaleJoin = targetTabId && targetTabId !== message.sessionId;
2597
+ if (!isStaleJoin) {
2598
+ // For a LIVE session, replay the raw outputBuffer (real ANSI +
2599
+ // cursor codes) so the agent's live TUI redraw aligns. The
2600
+ // server's renderedSnapshot is rendered PLAIN TEXT (no cursor/
2601
+ // SGR/scroll-region state); writing it under a live TUI leaves
2602
+ // the cursor on the wrong row and the next redraw collides
2603
+ // with stale content (the #131 regression). So the plain-text
2604
+ // snapshot is used ONLY for non-live (exited/idle) sessions.
2605
+ // See join-repaint.js for the decision matrix.
2606
+ const replayBuffer = () => {
2607
+ this.terminal.clear();
2608
+ message.outputBuffer.forEach(data => {
2609
+ // Filter out focus tracking sequences (^[[I and ^[[O)
2610
+ this.terminal.write(data.replace(/\x1b\[\[?[IO]/g, ''));
2611
+ });
2612
+ };
2613
+ const action = chooseJoinRepaint(message);
2614
+ // Did the instant cache paint already show THIS session for
2615
+ // this switch? If so, a 'clear' verdict (the server has nothing
2616
+ // to replay) must NOT blank the good cached view.
2617
+ const cacheAlreadyPainted = this._cachePaintedForSession === message.sessionId;
2618
+ if (action === 'buffer') {
2619
+ replayBuffer();
2620
+ } else if (action === 'snapshot') {
2621
+ this.terminal.clear();
2622
+ this.terminal.write(message.renderedSnapshot.replace(/\n/g, '\r\n'));
2623
+ } else if (!cacheAlreadyPainted) {
2624
+ // Empty-state guard: clear so a cold join never leaves the
2625
+ // previous tab's content lingering on the shared terminal.
2626
+ this.terminal.clear();
2627
+ }
2628
+
2629
+ // Refresh the cache from the authoritative repaint, but only
2630
+ // AFTER xterm has drained the writes above. terminal.write('',
2631
+ // cb) fires its callback once all prior writes are parsed — a
2632
+ // reliable drain barrier (a single rAF is not). Guard against a
2633
+ // re-switch landing before the drain completes.
2634
+ if (this.snapshotCache && message.sessionId && action !== 'clear') {
2635
+ const sid = message.sessionId;
2636
+ this.terminal.write('', () => {
2637
+ if (this._terminalStablyShowsSession(sid)) this.snapshotCache?.capture(sid);
2638
+ });
2639
+ }
2640
+ this._cachePaintedForSession = null;
2545
2641
  }
2546
-
2642
+
2547
2643
  // Show appropriate UI based on session state
2548
2644
  console.log('[session_joined] Checking if should show overlay. Active:', message.active, 'toolStartPending:', !!this._toolStartPending);
2549
2645
  if (this._toolStartPending) {
@@ -2579,7 +2675,17 @@ class ClaudeCodeWebInterface {
2579
2675
  this.currentClaudeSessionId = null;
2580
2676
  this.currentClaudeSessionName = null;
2581
2677
  this.updateSessionButton('Sessions');
2582
- this.terminal.clear();
2678
+ // During a tab switch the server emits session_left (for the
2679
+ // outgoing session) immediately before session_joined (for the
2680
+ // incoming one). Skip the clear ONLY when we already painted the
2681
+ // incoming tab from cache — clearing would wipe that instant
2682
+ // repaint. If there was NO cache paint, the shared terminal is
2683
+ // still showing the OUTGOING session, so we MUST clear it;
2684
+ // otherwise its content lingers into the incoming tab
2685
+ // (cross-session bleed). session_joined repaints authoritatively.
2686
+ if (!this._cachePaintedForSession) {
2687
+ this.terminal.clear();
2688
+ }
2583
2689
 
2584
2690
  // Update tab status
2585
2691
  if (this.sessionTabManager && message.sessionId) {
@@ -4035,6 +4141,8 @@ class ClaudeCodeWebInterface {
4035
4141
  if (cursorBlink) cursorBlink.checked = settings.cursorBlink ?? true;
4036
4142
  const scrollback = document.getElementById('scrollback');
4037
4143
  if (scrollback) scrollback.value = String(settings.scrollback || 1000);
4144
+ const tabSnapshotLines = document.getElementById('tabSnapshotLines');
4145
+ if (tabSnapshotLines) tabSnapshotLines.value = String(settings.tabSnapshotLines ?? 500);
4038
4146
  const terminalPadding = document.getElementById('terminalPadding');
4039
4147
  if (terminalPadding) terminalPadding.value = String(settings.terminalPadding ?? 8);
4040
4148
  const terminalPaddingValue = document.getElementById('terminalPaddingValue');
@@ -4375,7 +4483,8 @@ class ClaudeCodeWebInterface {
4375
4483
  notifSound: true,
4376
4484
  notifVolume: 30,
4377
4485
  notifDesktop: true,
4378
- enableSessionStickyNotes: true
4486
+ enableSessionStickyNotes: true,
4487
+ tabSnapshotLines: 500
4379
4488
  };
4380
4489
  }
4381
4490
 
@@ -4417,7 +4526,8 @@ class ClaudeCodeWebInterface {
4417
4526
  notifSound: document.getElementById('notifSound')?.checked ?? true,
4418
4527
  notifVolume: parseInt(document.getElementById('notifVolume')?.value || '30'),
4419
4528
  notifDesktop: document.getElementById('notifDesktop')?.checked ?? true,
4420
- enableSessionStickyNotes: document.getElementById('enableSessionStickyNotes')?.checked ?? true
4529
+ enableSessionStickyNotes: document.getElementById('enableSessionStickyNotes')?.checked ?? true,
4530
+ tabSnapshotLines: parseInt(document.getElementById('tabSnapshotLines')?.value || '500', 10)
4421
4531
  };
4422
4532
 
4423
4533
  try {
@@ -4466,6 +4576,11 @@ class ClaudeCodeWebInterface {
4466
4576
  this.terminal.options.cursorBlink = settings.cursorBlink ?? true;
4467
4577
  if (settings.scrollback) this.terminal.options.scrollback = settings.scrollback;
4468
4578
 
4579
+ // Push the instant-snapshot line cap to the cache (0 disables capture+paint).
4580
+ if (this.snapshotCache && settings.tabSnapshotLines !== undefined) {
4581
+ this.snapshotCache.setMaxLines(settings.tabSnapshotLines);
4582
+ }
4583
+
4469
4584
  // Apply terminal padding
4470
4585
  const terminalEl = document.getElementById('terminal');
4471
4586
  if (terminalEl) {
@@ -5460,11 +5575,15 @@ class ClaudeCodeWebInterface {
5460
5575
 
5461
5576
  // Set a timeout in case the response never comes
5462
5577
  setTimeout(() => {
5463
- if (this.pendingJoinResolve) {
5578
+ // Only clear if THIS join is still the pending one. A newer
5579
+ // joinSession() may have replaced it; nulling the newer join's
5580
+ // pending state would corrupt the cache/repaint guards that rely
5581
+ // on it. resolve() is idempotent, so always settle this promise.
5582
+ if (this.pendingJoinResolve === resolve) {
5464
5583
  this.pendingJoinResolve = null;
5465
5584
  this.pendingJoinSessionId = null;
5466
- resolve(); // Resolve anyway after timeout
5467
5585
  }
5586
+ resolve(); // Resolve anyway after timeout
5468
5587
  }, 2000);
5469
5588
  });
5470
5589
  }
@@ -101,6 +101,7 @@
101
101
  <script src="https://unpkg.com/xterm-addon-unicode11@0.6.0/lib/xterm-addon-unicode11.js"></script>
102
102
  <script src="https://unpkg.com/xterm-addon-webgl@0.16.0/lib/xterm-addon-webgl.js"></script>
103
103
  <script src="https://unpkg.com/xterm-addon-canvas@0.5.0/lib/xterm-addon-canvas.js"></script>
104
+ <script src="https://unpkg.com/xterm-addon-serialize@0.11.0/lib/xterm-addon-serialize.js"></script>
104
105
  <link rel="stylesheet" href="https://unpkg.com/xterm@5.3.0/css/xterm.css" />
105
106
  <!-- Monaco editor is loaded lazily on first file preview/editor open via
106
107
  file-viewer-monaco.js (see ADR-0016). Preconnect above warms the
@@ -461,6 +462,15 @@
461
462
  <option value="50000">50,000 lines</option>
462
463
  </select>
463
464
  </div>
465
+ <div class="setting-group">
466
+ <label for="tabSnapshotLines">Instant tab snapshot</label>
467
+ <select id="tabSnapshotLines">
468
+ <option value="0">Off</option>
469
+ <option value="200">200 lines</option>
470
+ <option value="500">500 lines</option>
471
+ <option value="2000">2,000 lines</option>
472
+ </select>
473
+ </div>
464
474
  <div class="setting-group">
465
475
  <label for="terminalPadding">Padding</label>
466
476
  <span class="range-field">
@@ -844,6 +854,8 @@
844
854
  <script src="command-palette.js"></script>
845
855
  <script src="extra-keys.js"></script>
846
856
  <script src="input-overlay.js"></script>
857
+ <script src="join-repaint.js"></script>
858
+ <script src="terminal-snapshot-cache.js"></script>
847
859
  <script src="app.js"></script>
848
860
  <ninja-keys class="dark" id="commandPalette" hideBreadcrumbs></ninja-keys>
849
861
 
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * chooseJoinRepaint — decide how to repaint the shared terminal on (re)join.
5
+ *
6
+ * This is the crux of the #131 fix. The server may send a `renderedSnapshot`
7
+ * (rendered PLAIN TEXT via translateToString — no cursor/SGR/scroll state) and
8
+ * the raw `outputBuffer` (the real ANSI byte chunks). Writing the plain-text
9
+ * snapshot under a LIVE agent TUI leaves the cursor on the wrong row and the
10
+ * next incremental redraw collides with stale content (the garble regression).
11
+ *
12
+ * Rules:
13
+ * - LIVE session (active): replay the raw outputBuffer (real ANSI) so the
14
+ * live TUI redraw aligns. NEVER the plain-text snapshot. → 'buffer'
15
+ * (or 'clear' when the buffer is empty — a brand-new just-started session).
16
+ * - NON-live session (exited/idle): the plain-text snapshot is safe (nothing
17
+ * redraws on top) and fixes blank-on-refresh (#131's legitimate goal). →
18
+ * 'snapshot', falling back to 'buffer' then 'clear'.
19
+ *
20
+ * Returns one of: 'buffer' | 'snapshot' | 'clear'.
21
+ */
22
+ (function (global) {
23
+ function chooseJoinRepaint(message) {
24
+ const hasBuffer = !!(message && message.outputBuffer && message.outputBuffer.length > 0);
25
+ const hasSnapshot = !!(message && message.renderedSnapshot);
26
+ if (message && message.active) {
27
+ return hasBuffer ? 'buffer' : 'clear';
28
+ }
29
+ if (hasSnapshot) return 'snapshot';
30
+ if (hasBuffer) return 'buffer';
31
+ return 'clear';
32
+ }
33
+
34
+ global.chooseJoinRepaint = chooseJoinRepaint;
35
+ if (typeof module !== 'undefined' && module.exports) module.exports = { chooseJoinRepaint };
36
+ })(typeof window !== 'undefined' ? window : globalThis);
@@ -725,6 +725,24 @@ class SessionTabManager {
725
725
 
726
726
  const { skipHistoryUpdate = false } = options;
727
727
 
728
+ // Cancel any pending capture-on-settle: the shared terminal is about to
729
+ // repaint to the incoming tab, so a stale timer must not serialize it
730
+ // under the outgoing tab's id.
731
+ if (this.claudeInterface._snapCaptureTimer) {
732
+ clearTimeout(this.claudeInterface._snapCaptureTimer);
733
+ this.claudeInterface._snapCaptureTimer = null;
734
+ }
735
+
736
+ // Snapshot the OUTGOING tab's rendered screen so a later switch back can
737
+ // repaint it instantly — but only when no switch is already in flight,
738
+ // since otherwise the shared terminal may be showing a tab other than
739
+ // activeTabId and we'd cache the wrong content under previousTabId.
740
+ const previousTabId = this.activeTabId;
741
+ if (previousTabId && previousTabId !== sessionId &&
742
+ !this.claudeInterface.pendingJoinSessionId) {
743
+ this.claudeInterface.snapshotCache?.capture(previousTabId);
744
+ }
745
+
728
746
  // Remove active class from all tabs
729
747
  this.tabs.forEach(t => {
730
748
  t.classList.remove('active');
@@ -759,6 +777,20 @@ class SessionTabManager {
759
777
  this.updateOverflowMenu();
760
778
 
761
779
  // If tile view is enabled, tabs target the active pane (VS Code-style)
780
+ // Instant paint from the cache (faithful serialize snapshot) BEFORE the
781
+ // join round-trip, so the switch shows the target tab's last view
782
+ // immediately instead of lingering the previous tab's content. Record
783
+ // that we painted so session_joined's reconcile won't blank it on a
784
+ // 'clear' verdict (see app.js). session_joined repaints authoritatively.
785
+ const cachePainted = this.claudeInterface.snapshotCache?.paintCached(sessionId);
786
+ this.claudeInterface._cachePaintedForSession = cachePainted ? sessionId : null;
787
+ // When we painted the incoming tab from cache, drop any output frames
788
+ // still queued from the OUTGOING session so they can't flush on top of
789
+ // the instant repaint. They belong to the previous session, whose
790
+ // server-side buffer replays them on its next visit.
791
+ if (cachePainted && Array.isArray(this.claudeInterface._pendingWrites)) {
792
+ this.claudeInterface._pendingWrites.length = 0;
793
+ }
762
794
  await this.claudeInterface.joinSession(sessionId);
763
795
  this.updateHeaderInfo(sessionId);
764
796
 
@@ -846,6 +878,7 @@ class SessionTabManager {
846
878
  tab.remove();
847
879
  this.tabs.delete(sessionId);
848
880
  this.activeSessions.delete(sessionId);
881
+ this.claudeInterface.snapshotCache?.evict(sessionId);
849
882
  this.tabOrder = orderedIds.filter(id => id !== sessionId);
850
883
  this.removeFromHistory(sessionId);
851
884
 
@@ -0,0 +1,295 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * TerminalSnapshotCache — instant, faithful per-tab terminal repaint.
5
+ *
6
+ * Problem it solves: there is ONE shared xterm terminal for all tabs. On tab
7
+ * switch the terminal is repainted only by the server round-trip
8
+ * (join_session -> session_joined), so until that lands the previous tab's
9
+ * content lingers. This cache paints the target tab's last rendered view
10
+ * INSTANTLY from a client-side snapshot, decoupled from the round-trip; the
11
+ * server reply then reconciles authoritatively.
12
+ *
13
+ * Fidelity: snapshots are produced by xterm's serialize addon, which emits the
14
+ * already-rendered buffer with absolute SGR + a final cursor-positioning
15
+ * sequence. Writing that into a freshly-cleared terminal reproduces the screen
16
+ * faithfully (cursor + colors), unlike a plain-text dump.
17
+ *
18
+ * Tiers:
19
+ * - Tier 1 (in-memory Map): authoritative for the instant paint, synchronous.
20
+ * - Tier 2 (IndexedDB): survives a page reload. Throttled writes, LRU-bounded.
21
+ *
22
+ * Robustness: every IndexedDB call is guarded; on any failure the cache runs
23
+ * MEMORY-ONLY (instant in-session switching still works, only reload-restore is
24
+ * lost) and never throws into the caller's switch/render path. Setting
25
+ * maxLines to 0 disables capture + paint entirely (a pure pass-through).
26
+ */
27
+ (function (global) {
28
+ const DB_NAME = 'cc-terminal-cache';
29
+ const DB_VERSION = 1;
30
+ const STORE = 'snapshots';
31
+ const PER_RECORD_CAP_BYTES = 256 * 1024; // skip persisting anything larger (keep in memory only)
32
+ const TOTAL_BUDGET_BYTES = 6 * 1024 * 1024; // LRU-evict oldest beyond this
33
+ const PERSIST_DEBOUNCE_MS = 1000;
34
+
35
+ const byteLen = (s) => {
36
+ try { return (typeof TextEncoder !== 'undefined') ? new TextEncoder().encode(s).length : (s ? s.length : 0); }
37
+ catch (_) { return s ? s.length : 0; }
38
+ };
39
+
40
+ class TerminalSnapshotCache {
41
+ constructor({ terminal, serializeAddon, maxLines = 500 } = {}) {
42
+ this.terminal = terminal;
43
+ this.serializeAddon = serializeAddon || null;
44
+ this.maxLines = Number.isFinite(maxLines) ? maxLines : 500;
45
+ this._mem = new Map(); // sessionId -> { text, cols, updatedAt, bytes }
46
+ this._lastPainted = null; // last text written via paintCached/reconcile
47
+ this._db = null;
48
+ this._persistDisabled = false;
49
+ this._dirty = new Set();
50
+ this._persistTimer = null;
51
+ this._ready = false;
52
+ }
53
+
54
+ /** Open IndexedDB and hydrate the in-memory map. Never throws. */
55
+ async init() {
56
+ if (this._ready) return;
57
+ this._ready = true;
58
+ if (typeof indexedDB === 'undefined') { this._persistDisabled = true; return; }
59
+ try {
60
+ this._db = await this._openDb();
61
+ await this._hydrateFromDisk();
62
+ // Persist any snapshots captured before the DB finished opening — their
63
+ // _schedulePersist() no-opped while _db was still null.
64
+ if (this._dirty.size > 0) this._schedulePersist();
65
+ } catch (err) {
66
+ // Private mode / blocked / corrupt — degrade to memory-only.
67
+ this._persistDisabled = true;
68
+ this._db = null;
69
+ try { console.warn('[snapshot-cache] persistence disabled (memory-only):', err && err.message); } catch (_) {}
70
+ }
71
+ }
72
+
73
+ _openDb() {
74
+ return new Promise((resolve, reject) => {
75
+ let req;
76
+ try { req = indexedDB.open(DB_NAME, DB_VERSION); }
77
+ catch (e) { reject(e); return; }
78
+ req.onupgradeneeded = () => {
79
+ const db = req.result;
80
+ if (!db.objectStoreNames.contains(STORE)) {
81
+ db.createObjectStore(STORE, { keyPath: 'sessionId' });
82
+ }
83
+ };
84
+ req.onsuccess = () => resolve(req.result);
85
+ req.onerror = () => reject(req.error || new Error('indexedDB open failed'));
86
+ req.onblocked = () => reject(new Error('indexedDB blocked'));
87
+ });
88
+ }
89
+
90
+ async _hydrateFromDisk() {
91
+ if (!this._db) return;
92
+ const records = await this._txAll();
93
+ for (const r of records) {
94
+ if (r && r.sessionId && typeof r.text === 'string') {
95
+ // Don't let a stale persisted record overwrite a fresher in-memory
96
+ // snapshot that was captured before this async hydrate completed.
97
+ const existing = this._mem.get(r.sessionId);
98
+ if (existing && (existing.updatedAt || 0) >= (r.updatedAt || 0)) continue;
99
+ this._mem.set(r.sessionId, {
100
+ text: r.text,
101
+ cols: r.cols || 0,
102
+ updatedAt: r.updatedAt || 0,
103
+ bytes: r.bytes || byteLen(r.text),
104
+ });
105
+ }
106
+ }
107
+ }
108
+
109
+ _txAll() {
110
+ return new Promise((resolve, reject) => {
111
+ try {
112
+ const tx = this._db.transaction(STORE, 'readonly');
113
+ const req = tx.objectStore(STORE).getAll();
114
+ req.onsuccess = () => resolve(req.result || []);
115
+ req.onerror = () => reject(req.error || new Error('getAll failed'));
116
+ } catch (e) { reject(e); }
117
+ });
118
+ }
119
+
120
+ setMaxLines(n) {
121
+ const v = parseInt(n, 10);
122
+ if (Number.isFinite(v) && v >= 0) this.maxLines = v;
123
+ }
124
+
125
+ /** True if there is a cached snapshot for this session. */
126
+ has(sessionId) {
127
+ return !!sessionId && this._mem.has(sessionId);
128
+ }
129
+
130
+ /**
131
+ * Serialize the CURRENT terminal screen and store it under sessionId.
132
+ * Call this for the tab that is currently shown (active/outgoing), never
133
+ * per output frame — only when the screen has settled or on switch-away.
134
+ */
135
+ capture(sessionId) {
136
+ if (!sessionId || this.maxLines <= 0 || !this.serializeAddon || !this.terminal) return;
137
+ let text;
138
+ try {
139
+ text = this.serializeAddon.serialize({ scrollback: this.maxLines });
140
+ } catch (err) {
141
+ try { console.warn('[snapshot-cache] serialize failed:', err && err.message); } catch (_) {}
142
+ return;
143
+ }
144
+ if (typeof text !== 'string' || text.length === 0) return;
145
+ const bytes = byteLen(text);
146
+ this._mem.set(sessionId, {
147
+ text,
148
+ cols: (this.terminal.cols | 0) || 0,
149
+ updatedAt: this._now(),
150
+ bytes,
151
+ });
152
+ // Bound the in-memory tier in EVERY mode. The persist path's eviction is
153
+ // skipped when persistence is disabled or the DB isn't open yet, so
154
+ // memory-only / private-mode would otherwise grow unbounded.
155
+ this._evictLruOverBudget();
156
+ this._dirty.add(sessionId);
157
+ this._schedulePersist();
158
+ }
159
+
160
+ /**
161
+ * Instantly repaint the terminal from the cached snapshot for sessionId.
162
+ * Returns true if it painted, false if there was no entry (caller then
163
+ * relies on the server repaint). maxLines === 0 disables (returns false).
164
+ */
165
+ paintCached(sessionId) {
166
+ if (!sessionId || this.maxLines <= 0 || !this.terminal) return false;
167
+ const entry = this._mem.get(sessionId);
168
+ if (!entry || !entry.text) return false;
169
+ try {
170
+ this.terminal.clear();
171
+ this.terminal.write(entry.text);
172
+ this._lastPainted = entry.text;
173
+ return true;
174
+ } catch (err) {
175
+ try { console.warn('[snapshot-cache] paint failed:', err && err.message); } catch (_) {}
176
+ return false;
177
+ }
178
+ }
179
+
180
+ /** Drop the cached snapshot for a deleted session (memory + disk). */
181
+ evict(sessionId) {
182
+ if (!sessionId) return;
183
+ this._mem.delete(sessionId);
184
+ this._dirty.delete(sessionId);
185
+ this._deleteFromDisk(sessionId);
186
+ }
187
+
188
+ /** Remove cached snapshots for sessions that no longer exist. */
189
+ pruneOrphans(liveIds) {
190
+ let live;
191
+ try { live = new Set(liveIds || []); } catch (_) { return; }
192
+ for (const id of Array.from(this._mem.keys())) {
193
+ if (!live.has(id)) this.evict(id);
194
+ }
195
+ }
196
+
197
+ // ---- persistence internals (all guarded; never throw to callers) ----
198
+
199
+ _now() {
200
+ // Date.now() is fine in the browser; only the workflow sandbox forbids it.
201
+ try { return Date.now(); } catch (_) { return 0; }
202
+ }
203
+
204
+ _schedulePersist() {
205
+ if (this._persistDisabled || !this._db) return;
206
+ if (this._persistTimer) return;
207
+ this._persistTimer = setTimeout(() => {
208
+ this._persistTimer = null;
209
+ this._persistNow();
210
+ }, PERSIST_DEBOUNCE_MS);
211
+ }
212
+
213
+ async _persistNow() {
214
+ if (this._persistDisabled || !this._db) { this._dirty.clear(); return; }
215
+ // Enforce the total byte budget by evicting oldest entries first.
216
+ this._evictLruOverBudget();
217
+ const ids = Array.from(this._dirty);
218
+ this._dirty.clear();
219
+ for (const id of ids) {
220
+ const entry = this._mem.get(id);
221
+ if (!entry) { this._deleteFromDisk(id); continue; }
222
+ // Too large to persist: keep it in memory, but drop any stale on-disk
223
+ // copy so a reload doesn't hydrate an older snapshot for this session.
224
+ if (entry.bytes > PER_RECORD_CAP_BYTES) { this._deleteFromDisk(id); continue; }
225
+ try { await this._putOnDisk(id, entry); }
226
+ catch (err) {
227
+ // Quota exceeded: evict half by age, retry once, then disable persistence.
228
+ if (err && (err.name === 'QuotaExceededError' || /quota/i.test(err.message || ''))) {
229
+ this._evictLruHalf();
230
+ try { await this._putOnDisk(id, this._mem.get(id)); }
231
+ catch (_) { this._persistDisabled = true; }
232
+ } else {
233
+ try { console.warn('[snapshot-cache] persist failed:', err && err.message); } catch (_) {}
234
+ }
235
+ }
236
+ }
237
+ }
238
+
239
+ _evictLruOverBudget() {
240
+ let total = 0;
241
+ for (const e of this._mem.values()) total += e.bytes || 0;
242
+ if (total <= TOTAL_BUDGET_BYTES) return;
243
+ const byAge = Array.from(this._mem.entries()).sort((a, b) => a[1].updatedAt - b[1].updatedAt);
244
+ for (const [id, e] of byAge) {
245
+ if (total <= TOTAL_BUDGET_BYTES) break;
246
+ total -= e.bytes || 0;
247
+ this._mem.delete(id);
248
+ this._dirty.delete(id);
249
+ this._deleteFromDisk(id);
250
+ }
251
+ }
252
+
253
+ _evictLruHalf() {
254
+ const byAge = Array.from(this._mem.entries()).sort((a, b) => a[1].updatedAt - b[1].updatedAt);
255
+ const drop = Math.ceil(byAge.length / 2);
256
+ for (let i = 0; i < drop; i++) {
257
+ const id = byAge[i][0];
258
+ this._mem.delete(id);
259
+ this._dirty.delete(id);
260
+ this._deleteFromDisk(id);
261
+ }
262
+ }
263
+
264
+ _putOnDisk(sessionId, entry) {
265
+ return new Promise((resolve, reject) => {
266
+ if (!this._db || !entry) { resolve(); return; }
267
+ try {
268
+ const tx = this._db.transaction(STORE, 'readwrite');
269
+ tx.objectStore(STORE).put({
270
+ sessionId,
271
+ text: entry.text,
272
+ cols: entry.cols,
273
+ updatedAt: entry.updatedAt,
274
+ bytes: entry.bytes,
275
+ });
276
+ tx.oncomplete = () => resolve();
277
+ tx.onerror = () => reject(tx.error || new Error('put failed'));
278
+ tx.onabort = () => reject(tx.error || new Error('put aborted'));
279
+ } catch (e) { reject(e); }
280
+ });
281
+ }
282
+
283
+ _deleteFromDisk(sessionId) {
284
+ if (this._persistDisabled || !this._db) return;
285
+ try {
286
+ const tx = this._db.transaction(STORE, 'readwrite');
287
+ tx.objectStore(STORE).delete(sessionId);
288
+ } catch (_) { /* best-effort */ }
289
+ }
290
+ }
291
+
292
+ global.TerminalSnapshotCache = TerminalSnapshotCache;
293
+ // CommonJS export for unit tests (Node/mocha) — harmless in the browser.
294
+ if (typeof module !== 'undefined' && module.exports) module.exports = TerminalSnapshotCache;
295
+ })(typeof window !== 'undefined' ? window : globalThis);