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 +1 -1
- package/src/public/app.js +138 -19
- package/src/public/index.html +12 -0
- package/src/public/join-repaint.js +36 -0
- package/src/public/session-manager.js +33 -0
- package/src/public/terminal-snapshot-cache.js +295 -0
package/package.json
CHANGED
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
|
|
2533
|
-
//
|
|
2534
|
-
//
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/src/public/index.html
CHANGED
|
@@ -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);
|