pi-studio 0.9.41 → 0.9.43

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/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.43] — 2026-08-11
8
+
9
+ ### Fixed
10
+ - Preserve each Studio tab’s unsaved editor text and selected views when cmux reconstructs a hidden browser surface after switching workspaces, while keeping tab recovery state isolated and bounded in the running Studio server.
11
+
12
+ ## [0.9.42] — 2026-08-11
13
+
14
+ ### Changed
15
+ - Open Studio as a focused cmux browser surface in the caller’s workspace when launched from cmux, with a bounded system-browser fallback when cmux is unavailable or declines the request.
16
+
7
17
  ## [0.9.41] — 2026-08-11
8
18
 
9
19
  ### Fixed
package/README.md CHANGED
@@ -38,7 +38,7 @@ _The video shows an earlier version of the Studio interface. The basic workflow
38
38
  - Includes an optional tmux-backed **REPL** view for Shell, Python, IPython, Julia, R, GHCi, and Clojure sessions, with Raw/Literate send modes, `Cmd/Ctrl+Shift+Enter` **Send to REPL**, session start/stop/interrupt controls, a compact refresh-persistent **Studio REPL Record** of user and Pi-sent code, a secondary raw tmux mirror, agent-facing `studio_repl_status` / `studio_repl_send` tools, and Markdown/PDF/HTML export
39
39
  - Includes a local persistent scratchpad for quick notes you want to keep out of the main editor until you're ready to copy or insert them, with a **Recent…** picker for recovering scratchpads saved under earlier file/draft identities
40
40
  - Includes a docked **Outline** rail for navigating document structure in the current editor text, with clickable entries that jump in the raw editor and reveal matching preview locations when available
41
- - Restores the current browser-tab editor workspace after refresh and provides an explicit **Reset editor** action when you want to discard the restored draft and return the tab to a fresh blank draft without changing responses or saved files
41
+ - Restores each browser tab’s editor workspace after refresh or cmux hidden-surface reconstruction, and provides an explicit **Reset editor** action when you want to discard the restored draft and return the tab to a fresh blank draft without changing responses or saved files
42
42
  - Turns local preview links, including links inside sandboxed HTML previews, into Studio actions: PDFs open in the embedded viewer, images open in a zoomable focus viewer, PDF/image links can open in a new Studio preview tab, text/code/CSV/TSV document links can open in a new editor tab, DOCX/ODT links can be converted to editable Markdown, and right-click menus provide **Open here**, **Reveal in file manager**, and **Copy path** for local resources
43
43
  - Includes local comments anchored to selections/lines, shown in a docked **Comments** rail, with transient **Comment** / **Jump** actions from raw-editor selections plus editor-preview selections for Markdown, LaTeX, code/text/diff previews, and an opt-in comment mode for editor HTML previews; source-anchored comments can be toggled into inline `[an: ...]` annotations when you want comments reflected in the document text
44
44
  - Browses response history (`Prev/Next/Last`) and loads either:
@@ -63,7 +63,7 @@ _The video shows an earlier version of the Studio interface. The basic workflow
63
63
 
64
64
  | Command | Description |
65
65
  |---|---|
66
- | `/studio` | Open with last assistant response (fallback: blank) |
66
+ | `/studio` | Open in cmux when available, otherwise the system browser, with the last assistant response (fallback: blank) |
67
67
  | `/studio <path>` | Open with file preloaded |
68
68
  | `/studio --last` | Force last response |
69
69
  | `/studio --blank` | Force blank editor |
@@ -150,6 +150,7 @@ Studio only passes icon-pack arguments when a diagram actually references `lucid
150
150
  ## Notes
151
151
 
152
152
  - Local-only server (`127.0.0.1`) with tokenized Studio URLs.
153
+ - When Pi runs inside cmux, Studio opens as a focused cmux browser surface in the caller’s workspace. If cmux is unavailable or declines the request, Studio falls back to the system browser.
153
154
  - For remote SSH sessions, keep Studio bound to localhost and use SSH local port forwarding; `/studio` and `/studio --status` print the full tokenized localhost URL. The SSH hint repeats the full URL so it is visible even if your terminal only shows the latest notification. Open that URL through the tunnel, preserving the `?token=...` parameter. If SSH is not auto-detected, use `/studio --no-browser`; for stable forwarding, use `/studio --port <port>` or combine them, e.g. `/studio --no-browser --port 3417`.
154
155
  - Full Studio is a singleton per Pi session: use `/studio` to open it, `/studio-replace` to explicitly replace it, and `/studio-editor-only` for extra editing/preview tabs that do not take over the full Studio session view.
155
156
  - Studio is designed as a complement to terminal pi, not a replacement.
@@ -1,4 +1,4 @@
1
- (() => {
1
+ (async () => {
2
2
  const statusLineEl = document.getElementById("statusLine");
3
3
  const statusEl = document.getElementById("status");
4
4
  const statusSpinnerEl = document.getElementById("statusSpinner");
@@ -209,9 +209,17 @@
209
209
  };
210
210
 
211
211
  const navigationHelpers = globalThis.PiStudioNavigationHelpers;
212
- if (!navigationHelpers || typeof navigationHelpers.readPaneFocusTarget !== "function") {
212
+ if (
213
+ !navigationHelpers
214
+ || typeof navigationHelpers.readPaneFocusTarget !== "function"
215
+ || typeof navigationHelpers.ensureStudioTabStateId !== "function"
216
+ || typeof navigationHelpers.readStudioWorkspaceState !== "function"
217
+ || typeof navigationHelpers.persistStudioWorkspaceState !== "function"
218
+ || typeof navigationHelpers.clearStudioWorkspaceState !== "function"
219
+ ) {
213
220
  throw new Error("Studio navigation helpers failed to load.");
214
221
  }
222
+ const studioTabStateId = navigationHelpers.ensureStudioTabStateId(window);
215
223
  const initialQueryParams = new URLSearchParams(window.location.search || "");
216
224
  const initialPaneFocusTarget = navigationHelpers.readPaneFocusTarget(window.location);
217
225
  const skipInitialWorkspaceRestore = initialQueryParams.get("skipWorkspaceRestore") === "1";
@@ -2059,8 +2067,9 @@
2059
2067
  const PANE_SPLIT_MIN_PERCENT = 20;
2060
2068
  const PANE_SPLIT_MAX_PERCENT = 80;
2061
2069
  const PANE_SPLIT_SNAP_TO_CENTER_PERCENT = 1;
2062
- const STUDIO_WORKSPACE_STORAGE_KEY = "piStudio.workspaceState.v1";
2063
2070
  const STUDIO_WORKSPACE_MAX_TEXT_CHARS = 900_000;
2071
+ const STUDIO_WORKSPACE_PERSIST_INTERVAL_MS = 300;
2072
+ const STUDIO_WORKSPACE_RECOVERY_FETCH_TIMEOUT_MS = 1_500;
2064
2073
  const EDITOR_HIGHLIGHT_MAX_CHARS = 100_000;
2065
2074
  const EDITOR_HIGHLIGHT_STORAGE_KEY = "piStudio.editorHighlightEnabled";
2066
2075
  const EDITOR_LANGUAGE_STORAGE_KEY = "piStudio.editorLanguage";
@@ -2217,6 +2226,8 @@
2217
2226
  let workspacePersistenceReady = false;
2218
2227
  let workspacePersistTimer = null;
2219
2228
  let workspaceRestoredFromBrowser = false;
2229
+ let workspaceServerPersistenceBlocked = false;
2230
+ let lastWorkspacePersistenceSavedAt = 0;
2220
2231
  let suppressedEditorSelectionStart = null;
2221
2232
  let suppressedEditorSelectionEnd = null;
2222
2233
  const previewJumpHighlightState = new WeakMap();
@@ -3605,11 +3616,13 @@
3605
3616
  });
3606
3617
 
3607
3618
  document.addEventListener("visibilitychange", () => {
3608
- if (!document.hidden) {
3609
- windowHasFocus = typeof document.hasFocus === "function" ? document.hasFocus() : windowHasFocus;
3610
- if (windowHasFocus) {
3611
- clearTitleAttention();
3612
- }
3619
+ if (document.hidden) {
3620
+ flushWorkspacePersistence({ beacon: false });
3621
+ return;
3622
+ }
3623
+ windowHasFocus = typeof document.hasFocus === "function" ? document.hasFocus() : windowHasFocus;
3624
+ if (windowHasFocus) {
3625
+ clearTitleAttention();
3613
3626
  }
3614
3627
  });
3615
3628
 
@@ -11377,34 +11390,46 @@
11377
11390
  return "source:" + normalized.source + ":" + normalized.label;
11378
11391
  }
11379
11392
 
11380
- function getWorkspacePersistenceStorage() {
11393
+ function readPersistedWorkspaceState() {
11381
11394
  try {
11382
- return window.sessionStorage || null;
11395
+ return navigationHelpers.readStudioWorkspaceState(window, studioTabStateId);
11383
11396
  } catch {
11384
11397
  return null;
11385
11398
  }
11386
11399
  }
11387
11400
 
11388
- function clearLegacyWorkspacePersistenceStorage() {
11389
- try {
11390
- if (window.localStorage) window.localStorage.removeItem(STUDIO_WORKSPACE_STORAGE_KEY);
11391
- } catch {}
11392
- }
11393
-
11394
- function readPersistedWorkspaceState() {
11401
+ async function readServerWorkspaceRecoveryState() {
11402
+ if (skipInitialWorkspaceRestore) return { status: "skipped", state: null };
11403
+ const controller = typeof AbortController === "function" ? new AbortController() : null;
11404
+ const timer = controller
11405
+ ? window.setTimeout(() => controller.abort(), STUDIO_WORKSPACE_RECOVERY_FETCH_TIMEOUT_MS)
11406
+ : null;
11395
11407
  try {
11396
- const storage = getWorkspacePersistenceStorage();
11397
- const raw = storage ? storage.getItem(STUDIO_WORKSPACE_STORAGE_KEY) : null;
11398
- if (!raw) return null;
11399
- const parsed = JSON.parse(raw);
11400
- if (!parsed || typeof parsed !== "object" || parsed.version !== 1) return null;
11401
- if (typeof parsed.text !== "string") return null;
11402
- return parsed;
11408
+ const payload = await fetchStudioJson("/tab-workspace-state", {
11409
+ query: { tabStateId: studioTabStateId },
11410
+ signal: controller ? controller.signal : undefined,
11411
+ });
11412
+ const state = payload && payload.state && typeof payload.state === "object" ? payload.state : null;
11413
+ return { status: state ? "found" : "empty", state };
11403
11414
  } catch {
11404
- return null;
11415
+ return { status: "unavailable", state: null };
11416
+ } finally {
11417
+ if (timer !== null) window.clearTimeout(timer);
11405
11418
  }
11406
11419
  }
11407
11420
 
11421
+ function getWorkspaceStateSavedAt(state) {
11422
+ return state && typeof state.savedAt === "number" && Number.isFinite(state.savedAt)
11423
+ ? state.savedAt
11424
+ : 0;
11425
+ }
11426
+
11427
+ function chooseNewestRestorableWorkspaceState(states) {
11428
+ return states
11429
+ .filter((state) => shouldRestorePersistedWorkspaceState(state))
11430
+ .sort((a, b) => getWorkspaceStateSavedAt(b) - getWorkspaceStateSavedAt(a))[0] || null;
11431
+ }
11432
+
11408
11433
  function shouldRestorePersistedWorkspaceState(state) {
11409
11434
  if (skipInitialWorkspaceRestore) return false;
11410
11435
  if (!state || typeof state.text !== "string") return false;
@@ -11417,9 +11442,10 @@
11417
11442
  }
11418
11443
 
11419
11444
  function buildWorkspacePersistencePayload() {
11445
+ lastWorkspacePersistenceSavedAt = Math.max(Date.now(), lastWorkspacePersistenceSavedAt + 1);
11420
11446
  return {
11421
11447
  version: 1,
11422
- savedAt: Date.now(),
11448
+ savedAt: lastWorkspacePersistenceSavedAt,
11423
11449
  sourceState: normalizeWorkspaceSourceState(sourceState),
11424
11450
  resourceDir: getCurrentResourceDirValue(),
11425
11451
  editorView,
@@ -11434,38 +11460,63 @@
11434
11460
  };
11435
11461
  }
11436
11462
 
11437
- function persistWorkspaceStateNow() {
11463
+ function sendServerWorkspaceRecoveryState(payload, options) {
11464
+ if ((options && options.skipServer) || (workspaceServerPersistenceBlocked && !(options && options.forceServer))) return;
11465
+ const body = { tabStateId: studioTabStateId, state: payload };
11466
+ if (ws && ws.readyState === WebSocket.OPEN) {
11467
+ try {
11468
+ ws.send(JSON.stringify({ type: "workspace_state_update", ...body }));
11469
+ return;
11470
+ } catch {
11471
+ // Fall through to the authenticated HTTP path.
11472
+ }
11473
+ }
11474
+ if (options && options.beacon && trySendStudioJsonBeacon("/tab-workspace-state", body)) return;
11475
+ void fetchStudioJson("/tab-workspace-state", {
11476
+ method: "POST",
11477
+ body: JSON.stringify(body),
11478
+ keepalive: Boolean(options && options.beacon),
11479
+ }).catch(() => {
11480
+ // Session storage remains the in-page fallback if server recovery fails.
11481
+ });
11482
+ }
11483
+
11484
+ function persistWorkspaceStateNow(options) {
11438
11485
  if (!workspacePersistenceReady) return;
11439
11486
  try {
11440
- const storage = getWorkspacePersistenceStorage();
11441
- if (!storage) return;
11442
- clearLegacyWorkspacePersistenceStorage();
11443
11487
  const payload = buildWorkspacePersistencePayload();
11444
11488
  if (payload.text.length > STUDIO_WORKSPACE_MAX_TEXT_CHARS) {
11445
- storage.removeItem(STUDIO_WORKSPACE_STORAGE_KEY);
11489
+ navigationHelpers.clearStudioWorkspaceState(window, studioTabStateId);
11490
+ sendServerWorkspaceRecoveryState({
11491
+ ...payload,
11492
+ sourceState: { source: "recovery-omitted", label: "oversized editor", path: null, draftId: null },
11493
+ resourceDir: "",
11494
+ rightView: "editor-preview",
11495
+ text: "",
11496
+ }, { ...(options || {}), forceServer: true });
11446
11497
  return;
11447
11498
  }
11448
- storage.setItem(STUDIO_WORKSPACE_STORAGE_KEY, JSON.stringify(payload));
11499
+ navigationHelpers.persistStudioWorkspaceState(window, studioTabStateId, payload);
11500
+ sendServerWorkspaceRecoveryState(payload, options);
11449
11501
  } catch {
11450
- // Ignore browser storage failures and quota limits.
11502
+ // Ignore browser storage, serialization, and recovery-request failures.
11451
11503
  }
11452
11504
  }
11453
11505
 
11454
11506
  function scheduleWorkspacePersistence() {
11455
- if (!workspacePersistenceReady) return;
11456
- if (workspacePersistTimer !== null) window.clearTimeout(workspacePersistTimer);
11507
+ if (!workspacePersistenceReady || workspacePersistTimer !== null) return;
11457
11508
  workspacePersistTimer = window.setTimeout(() => {
11458
11509
  workspacePersistTimer = null;
11459
11510
  persistWorkspaceStateNow();
11460
- }, 160);
11511
+ }, STUDIO_WORKSPACE_PERSIST_INTERVAL_MS);
11461
11512
  }
11462
11513
 
11463
- function flushWorkspacePersistence() {
11514
+ function flushWorkspacePersistence(options) {
11464
11515
  if (workspacePersistTimer !== null) {
11465
11516
  window.clearTimeout(workspacePersistTimer);
11466
11517
  workspacePersistTimer = null;
11467
11518
  }
11468
- persistWorkspaceStateNow();
11519
+ persistWorkspaceStateNow(options || { beacon: true });
11469
11520
  }
11470
11521
 
11471
11522
  function clearPersistedWorkspaceState() {
@@ -11474,10 +11525,16 @@
11474
11525
  workspacePersistTimer = null;
11475
11526
  }
11476
11527
  try {
11477
- const storage = getWorkspacePersistenceStorage();
11478
- if (storage) storage.removeItem(STUDIO_WORKSPACE_STORAGE_KEY);
11528
+ navigationHelpers.clearStudioWorkspaceState(window, studioTabStateId);
11529
+ const marker = buildWorkspacePersistencePayload();
11530
+ sendServerWorkspaceRecoveryState({
11531
+ ...marker,
11532
+ sourceState: { source: "recovery-cleared", label: "cleared editor", path: null, draftId: null },
11533
+ resourceDir: "",
11534
+ rightView: "editor-preview",
11535
+ text: "",
11536
+ }, { beacon: true, forceServer: true });
11479
11537
  } catch {}
11480
- clearLegacyWorkspacePersistenceStorage();
11481
11538
  }
11482
11539
 
11483
11540
  function applyPersistedWorkspaceState(state) {
@@ -12555,6 +12612,8 @@
12555
12612
  headers,
12556
12613
  body: init.body,
12557
12614
  cache: "no-store",
12615
+ signal: init.signal,
12616
+ keepalive: init.keepalive === true,
12558
12617
  });
12559
12618
  let payload = null;
12560
12619
  try {
@@ -21892,13 +21951,26 @@
21892
21951
  setAnnotationsEnabled(initialAnnotationsEnabled, { silent: true });
21893
21952
  setReplSendMode(replSendMode);
21894
21953
 
21895
- const persistedWorkspaceState = readPersistedWorkspaceState();
21954
+ const sessionWorkspaceState = readPersistedWorkspaceState();
21955
+ const serverWorkspaceRecovery = await readServerWorkspaceRecoveryState();
21956
+ workspaceServerPersistenceBlocked = serverWorkspaceRecovery.status === "unavailable";
21957
+ const serverWorkspaceState = serverWorkspaceRecovery.state;
21958
+ lastWorkspacePersistenceSavedAt = Math.max(
21959
+ lastWorkspacePersistenceSavedAt,
21960
+ getWorkspaceStateSavedAt(sessionWorkspaceState),
21961
+ getWorkspaceStateSavedAt(serverWorkspaceState),
21962
+ );
21963
+ const persistedWorkspaceState = chooseNewestRestorableWorkspaceState([
21964
+ sessionWorkspaceState,
21965
+ serverWorkspaceState,
21966
+ ]);
21896
21967
  applyPersistedWorkspaceState(persistedWorkspaceState);
21897
21968
 
21898
21969
  setEditorView(editorView);
21899
21970
  setRightView(rightView);
21900
21971
  renderSourcePreview();
21901
21972
  workspacePersistenceReady = true;
21973
+ persistWorkspaceStateNow({ skipServer: serverWorkspaceRecovery.status === "unavailable" });
21902
21974
  if (workspaceRestoredFromBrowser) {
21903
21975
  setStatus("Restored editor workspace from this browser tab. Use Reset editor to discard it.", "success");
21904
21976
  }
@@ -2,6 +2,10 @@
2
2
  const PANE_FOCUS_PARAM = "paneFocus";
3
3
  const PANE_FOCUS_OFF = "off";
4
4
  const PANE_FOCUS_TARGETS = Object.freeze(["left", "right"]);
5
+ const STUDIO_TAB_STATE_PARAM = "studioTabState";
6
+ const STUDIO_TAB_STATE_ID_PATTERN = /^[a-zA-Z0-9_-]{20,128}$/;
7
+ const STUDIO_WORKSPACE_STORAGE_PREFIX = "piStudio.workspaceState.v2:";
8
+ const STUDIO_WORKSPACE_LEGACY_STORAGE_KEY = "piStudio.workspaceState.v1";
5
9
  const STUDIO_LAUNCH_PROTOCOL_VERSION = 1;
6
10
  const STUDIO_LAUNCH_CHANNEL_PREFIX = "pi-studio-launch-v1:";
7
11
  const STUDIO_PENDING_KINDS = Object.freeze(["document", "preview", "export"]);
@@ -48,6 +52,98 @@
48
52
  return true;
49
53
  }
50
54
 
55
+ function isValidStudioTabStateId(value) {
56
+ return typeof value === "string" && STUDIO_TAB_STATE_ID_PATTERN.test(value);
57
+ }
58
+
59
+ function makeStudioTabStateId(cryptoLike) {
60
+ try {
61
+ const launchId = makeStudioLaunchId(cryptoLike);
62
+ const candidate = "tab_" + launchId;
63
+ if (isValidStudioTabStateId(candidate)) return candidate;
64
+ } catch {
65
+ // This ID isolates local browser state; it is not an authentication secret.
66
+ }
67
+ const candidate = "tab_" + Date.now().toString(36) + "_" + Math.random().toString(36).slice(2).padEnd(16, "0");
68
+ if (isValidStudioTabStateId(candidate)) return candidate;
69
+ throw new Error("Could not create a Studio tab-state ID.");
70
+ }
71
+
72
+ function ensureStudioTabStateId(windowLike) {
73
+ if (!windowLike || !windowLike.location) throw new Error("Studio tab-state URL is unavailable.");
74
+ const currentHref = String(windowLike.location.href || "");
75
+ const url = new URL(currentHref);
76
+ const existing = url.searchParams.getAll(STUDIO_TAB_STATE_PARAM);
77
+ if (existing.length === 1 && isValidStudioTabStateId(existing[0])) return existing[0];
78
+
79
+ const tabStateId = makeStudioTabStateId(windowLike.crypto);
80
+ url.searchParams.delete(STUDIO_TAB_STATE_PARAM);
81
+ url.searchParams.set(STUDIO_TAB_STATE_PARAM, tabStateId);
82
+ if (windowLike.history && typeof windowLike.history.replaceState === "function") {
83
+ windowLike.history.replaceState(windowLike.history.state, "", url.toString());
84
+ }
85
+ return tabStateId;
86
+ }
87
+
88
+ function getStudioWorkspaceStorageKey(tabStateId) {
89
+ if (!isValidStudioTabStateId(tabStateId)) throw new Error("Invalid Studio tab-state ID.");
90
+ return STUDIO_WORKSPACE_STORAGE_PREFIX + tabStateId;
91
+ }
92
+
93
+ function getStudioSessionStorage(windowLike) {
94
+ try {
95
+ return windowLike && windowLike.sessionStorage ? windowLike.sessionStorage : null;
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ function parseStudioWorkspaceState(raw) {
102
+ try {
103
+ const parsed = JSON.parse(String(raw || ""));
104
+ if (!parsed || typeof parsed !== "object" || parsed.version !== 1 || typeof parsed.text !== "string") return null;
105
+ return parsed;
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ function readStudioWorkspaceState(windowLike, tabStateId) {
112
+ const key = getStudioWorkspaceStorageKey(tabStateId);
113
+ const sessionStorage = getStudioSessionStorage(windowLike);
114
+ if (!sessionStorage || typeof sessionStorage.getItem !== "function") return null;
115
+ try {
116
+ const current = parseStudioWorkspaceState(sessionStorage.getItem(key));
117
+ if (current) return current;
118
+ return parseStudioWorkspaceState(sessionStorage.getItem(STUDIO_WORKSPACE_LEGACY_STORAGE_KEY));
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ function persistStudioWorkspaceState(windowLike, tabStateId, state) {
125
+ const key = getStudioWorkspaceStorageKey(tabStateId);
126
+ const sessionStorage = getStudioSessionStorage(windowLike);
127
+ if (!sessionStorage || typeof sessionStorage.setItem !== "function") return false;
128
+ try {
129
+ sessionStorage.setItem(key, JSON.stringify(state));
130
+ sessionStorage.removeItem(STUDIO_WORKSPACE_LEGACY_STORAGE_KEY);
131
+ return true;
132
+ } catch {
133
+ return false;
134
+ }
135
+ }
136
+
137
+ function clearStudioWorkspaceState(windowLike, tabStateId) {
138
+ const key = getStudioWorkspaceStorageKey(tabStateId);
139
+ const sessionStorage = getStudioSessionStorage(windowLike);
140
+ if (!sessionStorage || typeof sessionStorage.removeItem !== "function") return;
141
+ try {
142
+ sessionStorage.removeItem(key);
143
+ sessionStorage.removeItem(STUDIO_WORKSPACE_LEGACY_STORAGE_KEY);
144
+ } catch {}
145
+ }
146
+
51
147
  function normalizeStudioPendingKind(value) {
52
148
  return STUDIO_PENDING_KINDS.includes(value) ? value : null;
53
149
  }
@@ -490,6 +586,9 @@
490
586
  PANE_FOCUS_OFF,
491
587
  PANE_FOCUS_PARAM,
492
588
  PANE_FOCUS_TARGETS,
589
+ STUDIO_TAB_STATE_PARAM,
590
+ STUDIO_WORKSPACE_LEGACY_STORAGE_KEY,
591
+ STUDIO_WORKSPACE_STORAGE_PREFIX,
493
592
  STUDIO_LAUNCH_CHANNEL_PREFIX,
494
593
  STUDIO_LAUNCH_DELIVERY_TIMEOUT_MS,
495
594
  STUDIO_LAUNCH_MESSAGE_MAX_CHARS,
@@ -501,14 +600,21 @@
501
600
  buildPendingStudioUrl,
502
601
  canOpenPendingStudioLaunch,
503
602
  createPendingStudioLaunch,
603
+ clearStudioWorkspaceState,
604
+ ensureStudioTabStateId,
605
+ getStudioWorkspaceStorageKey,
504
606
  isValidStudioLaunchId,
607
+ isValidStudioTabStateId,
505
608
  makeStudioLaunchId,
609
+ makeStudioTabStateId,
506
610
  normalizePaneFocusTarget,
507
611
  normalizeStudioLaunchMessage,
508
612
  normalizeStudioPendingKind,
509
613
  normalizeStudioRelativeTarget,
510
614
  openStudioTabDirect,
615
+ persistStudioWorkspaceState,
511
616
  readPaneFocusTarget,
617
+ readStudioWorkspaceState,
512
618
  replacePaneFocusUrlState,
513
619
  startStudioPendingPage,
514
620
  studioLaunchChannelName,
package/index.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  } from "./shared/studio-markdown-latex-literals.js";
30
30
  import { escapeStudioPdfLatexTextFragment } from "./shared/studio-pdf-escape.js";
31
31
  import { resolveStudioPdfResourceFile } from "./shared/studio-pdf-resource.js";
32
+ import { isStudioCmuxSession, openStudioUrlInBrowser } from "./shared/studio-browser-launcher.js";
32
33
  import { buildStudioReplTmuxStartArgs } from "./shared/studio-repl-tmux.js";
33
34
  import { buildStudioForwardingHint, buildStudioSshTunnelHint, isStudioSshSession as isSshSession } from "./shared/studio-ssh-hint.js";
34
35
  import {
@@ -37,6 +38,11 @@ import {
37
38
  isValidStudioLaunchId,
38
39
  normalizeStudioPendingKind,
39
40
  } from "./shared/studio-tab-launcher.js";
41
+ import {
42
+ createStudioWorkspaceStateStore,
43
+ isValidStudioTabStateId,
44
+ normalizeStudioWorkspaceRecoveryState,
45
+ } from "./shared/studio-workspace-state.js";
40
46
  import { renderStudioAnnotationInlineHtml } from "./shared/studio-annotation-render.js";
41
47
  import {
42
48
  buildStudioMermaidCliIconArgs,
@@ -566,6 +572,12 @@ interface CancelRequestMessage {
566
572
  requestId: string;
567
573
  }
568
574
 
575
+ interface WorkspaceStateUpdateMessage {
576
+ type: "workspace_state_update";
577
+ tabStateId: string;
578
+ state: ReturnType<typeof normalizeStudioWorkspaceRecoveryState>;
579
+ }
580
+
569
581
  type IncomingStudioMessage =
570
582
  | HelloMessage
571
583
  | PingMessage
@@ -599,7 +611,8 @@ type IncomingStudioMessage =
599
611
  | QuartoPreviewStopRequestMessage
600
612
  | GitChangesRequestMessage
601
613
  | OpenEditorOnlyRequestMessage
602
- | CancelRequestMessage;
614
+ | CancelRequestMessage
615
+ | WorkspaceStateUpdateMessage;
603
616
 
604
617
  const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
605
618
  const PREVIEW_RENDER_MAX_CHARS = 400_000;
@@ -618,6 +631,7 @@ const STUDIO_QUIZ_CONTEXT_MAX_FILES = 18;
618
631
  const STUDIO_QUIZ_SNIPPET_MAX_CHARS = 8_000;
619
632
  const STUDIO_QUIZ_DISCUSSION_MAX_CHARS = 6_000;
620
633
  const REQUEST_BODY_MAX_BYTES = 1_000_000;
634
+ const STUDIO_WORKSPACE_STATE_REQUEST_MAX_BYTES = 4_000_000;
621
635
  const RESPONSE_HISTORY_LIMIT = 30;
622
636
  const CMUX_NOTIFY_TIMEOUT_MS = 1200;
623
637
  const PREPARED_PDF_EXPORT_TTL_MS = 5 * 60 * 1000;
@@ -7544,27 +7558,6 @@ async function handleRevealLocalPreviewResourceRequest(req: IncomingMessage, res
7544
7558
  }
7545
7559
  }
7546
7560
 
7547
- function openUrlInDefaultBrowser(url: string): Promise<void> {
7548
- const openCommand =
7549
- process.platform === "darwin"
7550
- ? { command: "open", args: [url] }
7551
- : process.platform === "win32"
7552
- ? { command: "cmd", args: ["/c", "start", "", url] }
7553
- : { command: "xdg-open", args: [url] };
7554
-
7555
- return new Promise<void>((resolve, reject) => {
7556
- const child = spawn(openCommand.command, openCommand.args, {
7557
- stdio: "ignore",
7558
- detached: true,
7559
- });
7560
- child.once("error", reject);
7561
- child.once("spawn", () => {
7562
- child.unref();
7563
- resolve();
7564
- });
7565
- });
7566
- }
7567
-
7568
7561
  function openPathInDefaultViewer(path: string): Promise<void> {
7569
7562
  const openCommand =
7570
7563
  process.platform === "darwin"
@@ -8518,6 +8511,10 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
8518
8511
 
8519
8512
  if (msg.type === "hello") return { type: "hello" };
8520
8513
  if (msg.type === "ping") return { type: "ping" };
8514
+ if (msg.type === "workspace_state_update" && typeof msg.tabStateId === "string" && isValidStudioTabStateId(msg.tabStateId)) {
8515
+ const state = normalizeStudioWorkspaceRecoveryState(msg.state);
8516
+ if (state) return { type: "workspace_state_update", tabStateId: msg.tabStateId, state };
8517
+ }
8521
8518
  if (msg.type === "get_latest_response") return { type: "get_latest_response" };
8522
8519
  if (msg.type === "get_trace_snapshot" && typeof msg.responseHistoryId === "string") {
8523
8520
  return {
@@ -10958,6 +10955,7 @@ ${cssVarsBlock}
10958
10955
 
10959
10956
  export default function (pi: ExtensionAPI) {
10960
10957
  let serverState: StudioServerState | null = null;
10958
+ const studioWorkspaceStateStore = createStudioWorkspaceStateStore();
10961
10959
  let activeRequest: ActiveStudioRequest | null = null;
10962
10960
  let studioDirectRunChain: StudioDirectRunChain | null = null;
10963
10961
  let queuedStudioDirectRequests: QueuedStudioDirectRequest[] = [];
@@ -11328,14 +11326,7 @@ export default function (pi: ExtensionAPI) {
11328
11326
  return null;
11329
11327
  };
11330
11328
 
11331
- const isProbablyCmuxSession = (): boolean => {
11332
- const workspaceId = String(process.env.CMUX_WORKSPACE_ID ?? "").trim();
11333
- if (workspaceId) return true;
11334
- const termProgram = String(process.env.TERM_PROGRAM ?? "").trim().toLowerCase();
11335
- if (termProgram === "cmux") return true;
11336
- const term = String(process.env.TERM ?? "").trim().toLowerCase();
11337
- return term.includes("cmux");
11338
- };
11329
+ const isProbablyCmuxSession = (): boolean => isStudioCmuxSession(process.env);
11339
11330
 
11340
11331
  const sanitizeTerminalNotificationText = (value: string, maxLength = 240): string => {
11341
11332
  const sanitized = String(value)
@@ -12618,6 +12609,10 @@ export default function (pi: ExtensionAPI) {
12618
12609
  sendToClient(client, { type: "pong", timestamp: Date.now() });
12619
12610
  return;
12620
12611
  }
12612
+ if (msg.type === "workspace_state_update") {
12613
+ studioWorkspaceStateStore.set(msg.tabStateId, msg.state);
12614
+ return;
12615
+ }
12621
12616
 
12622
12617
  emitDebugEvent("studio_message", {
12623
12618
  type: msg.type,
@@ -14580,6 +14575,66 @@ export default function (pi: ExtensionAPI) {
14580
14575
  return;
14581
14576
  }
14582
14577
 
14578
+ if (requestUrl.pathname === "/tab-workspace-state") {
14579
+ const token = requestUrl.searchParams.get("token") ?? "";
14580
+ if (token !== serverState.token) {
14581
+ respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
14582
+ return;
14583
+ }
14584
+
14585
+ void (async () => {
14586
+ const method = (req.method ?? "GET").toUpperCase();
14587
+ if (method === "GET") {
14588
+ const tabStateId = requestUrl.searchParams.get("tabStateId") ?? "";
14589
+ if (!isValidStudioTabStateId(tabStateId)) {
14590
+ respondJson(res, 400, { ok: false, error: "Invalid Studio tab-state ID." });
14591
+ return;
14592
+ }
14593
+ respondJson(res, 200, { ok: true, state: studioWorkspaceStateStore.get(tabStateId) });
14594
+ return;
14595
+ }
14596
+
14597
+ if (method !== "POST") {
14598
+ res.setHeader("Allow", "GET, POST");
14599
+ respondJson(res, 405, { ok: false, error: "Method not allowed. Use GET or POST." });
14600
+ return;
14601
+ }
14602
+
14603
+ let rawBody = "";
14604
+ try {
14605
+ rawBody = await readRequestBody(req, STUDIO_WORKSPACE_STATE_REQUEST_MAX_BYTES);
14606
+ } catch (error) {
14607
+ respondJson(res, 413, { ok: false, error: error instanceof Error ? error.message : String(error) });
14608
+ return;
14609
+ }
14610
+ let payload: Record<string, unknown> = {};
14611
+ try {
14612
+ payload = rawBody ? JSON.parse(rawBody) as Record<string, unknown> : {};
14613
+ } catch {
14614
+ respondJson(res, 400, { ok: false, error: "Invalid JSON body." });
14615
+ return;
14616
+ }
14617
+ const tabStateId = typeof payload.tabStateId === "string" ? payload.tabStateId : "";
14618
+ if (!isValidStudioTabStateId(tabStateId)) {
14619
+ respondJson(res, 400, { ok: false, error: "Invalid Studio tab-state ID." });
14620
+ return;
14621
+ }
14622
+ const state = normalizeStudioWorkspaceRecoveryState(payload.state);
14623
+ if (!state) {
14624
+ respondJson(res, 400, { ok: false, error: "Invalid or oversized Studio workspace state." });
14625
+ return;
14626
+ }
14627
+ const stored = studioWorkspaceStateStore.set(tabStateId, state);
14628
+ respondJson(res, 200, { ok: true, stored });
14629
+ })().catch((error) => {
14630
+ respondJson(res, 500, {
14631
+ ok: false,
14632
+ error: `Studio workspace recovery failed: ${error instanceof Error ? error.message : String(error)}`,
14633
+ });
14634
+ });
14635
+ return;
14636
+ }
14637
+
14583
14638
  if (requestUrl.pathname === "/scratchpad-state") {
14584
14639
  const token = requestUrl.searchParams.get("token") ?? "";
14585
14640
  if (token !== serverState.token) {
@@ -15056,6 +15111,7 @@ export default function (pi: ExtensionAPI) {
15056
15111
  await new Promise<void>((resolve) => {
15057
15112
  state.server.close(() => resolve());
15058
15113
  });
15114
+ studioWorkspaceStateStore.clear();
15059
15115
  };
15060
15116
 
15061
15117
  const hydrateLatestAssistant = (entries: SessionEntry[]) => {
@@ -15836,7 +15892,7 @@ export default function (pi: ExtensionAPI) {
15836
15892
  const skipReason = launchOpenFlags.noBrowser ? "--no-browser was used" : "SSH was detected";
15837
15893
  ctx.ui.notify(`${openedLabel} is ready. Browser auto-open was skipped because ${skipReason}.`, "info");
15838
15894
  } else {
15839
- await openUrlInDefaultBrowser(url);
15895
+ await openStudioUrlInBrowser(url);
15840
15896
  if (selected.source === "file") {
15841
15897
  ctx.ui.notify(`Opened ${openedLabel} with file loaded: ${selected.label}`, "info");
15842
15898
  } else if (selected.source === "last-response") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.41",
3
+ "version": "0.9.43",
4
4
  "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, active quiz, prompt/response history, live previews, and tmux-backed REPL/literate REPL workflows",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,139 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ export const STUDIO_CMUX_BROWSER_OPEN_TIMEOUT_MS = 5_000;
4
+
5
+ /**
6
+ * Detect whether the current process is running inside cmux.
7
+ *
8
+ * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
9
+ * @returns {boolean}
10
+ */
11
+ export function isStudioCmuxSession(env = process.env) {
12
+ const workspaceId = String(env.CMUX_WORKSPACE_ID ?? "").trim();
13
+ const termProgram = String(env.TERM_PROGRAM ?? "").trim().toLowerCase();
14
+ const term = String(env.TERM ?? "").trim().toLowerCase();
15
+ const bundleId = String(env.CMUX_BUNDLE_ID ?? "").trim().toLowerCase();
16
+ return Boolean(workspaceId || termProgram === "cmux" || term.includes("cmux") || bundleId.includes("cmux"));
17
+ }
18
+
19
+ /**
20
+ * Build the cmux CLI invocation for opening Studio in the caller's workspace.
21
+ *
22
+ * @param {string} target
23
+ * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
24
+ * @returns {{ command: string, args: string[] } | undefined}
25
+ */
26
+ export function getStudioCmuxBrowserOpenCommand(target, env = process.env) {
27
+ if (!isStudioCmuxSession(env)) return undefined;
28
+
29
+ const workspaceId = String(env.CMUX_WORKSPACE_ID ?? "").trim();
30
+ const command = String(env.CMUX_BUNDLED_CLI_PATH ?? "").trim() || "cmux";
31
+ const args = ["browser", "open", target];
32
+ if (workspaceId) args.push("--workspace", workspaceId);
33
+ args.push("--focus", "true");
34
+ return { command, args };
35
+ }
36
+
37
+ /**
38
+ * Build the platform-native system-browser invocation.
39
+ *
40
+ * @param {string} target
41
+ * @param {NodeJS.Platform} [platform]
42
+ * @returns {{ command: string, args: string[] }}
43
+ */
44
+ export function getStudioDefaultBrowserOpenCommand(target, platform = process.platform) {
45
+ if (platform === "darwin") return { command: "open", args: [target] };
46
+ if (platform === "win32") return { command: "cmd", args: ["/c", "start", "", target] };
47
+ return { command: "xdg-open", args: [target] };
48
+ }
49
+
50
+ /**
51
+ * @param {{ command: string, args: string[] }} openCommand
52
+ * @param {typeof spawn} spawnProcess
53
+ * @returns {Promise<void>}
54
+ */
55
+ function spawnDetachedBrowser(openCommand, spawnProcess) {
56
+ return new Promise((resolve, reject) => {
57
+ let child;
58
+ try {
59
+ child = spawnProcess(openCommand.command, openCommand.args, {
60
+ stdio: "ignore",
61
+ detached: true,
62
+ });
63
+ } catch (error) {
64
+ reject(error);
65
+ return;
66
+ }
67
+ child.once("error", reject);
68
+ child.once("spawn", () => {
69
+ child.unref();
70
+ resolve();
71
+ });
72
+ });
73
+ }
74
+
75
+ /**
76
+ * Try to open Studio in a focused cmux browser surface.
77
+ *
78
+ * @param {string} target
79
+ * @param {{
80
+ * env?: NodeJS.ProcessEnv | Record<string, string | undefined>,
81
+ * spawnProcess?: typeof spawn,
82
+ * timeoutMs?: number,
83
+ * }} [options]
84
+ * @returns {Promise<boolean>}
85
+ */
86
+ export async function tryOpenStudioUrlInCmuxBrowser(target, options = {}) {
87
+ const openCommand = getStudioCmuxBrowserOpenCommand(target, options.env ?? process.env);
88
+ if (!openCommand) return false;
89
+
90
+ const spawnProcess = options.spawnProcess ?? spawn;
91
+ const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs >= 0
92
+ ? options.timeoutMs
93
+ : STUDIO_CMUX_BROWSER_OPEN_TIMEOUT_MS;
94
+
95
+ return await new Promise((resolve) => {
96
+ let settled = false;
97
+ let child;
98
+ const finish = (opened) => {
99
+ if (settled) return;
100
+ settled = true;
101
+ clearTimeout(timeout);
102
+ resolve(opened);
103
+ };
104
+ const timeout = setTimeout(() => {
105
+ child?.kill();
106
+ finish(false);
107
+ }, timeoutMs);
108
+ timeout.unref?.();
109
+
110
+ try {
111
+ child = spawnProcess(openCommand.command, openCommand.args, { stdio: "ignore" });
112
+ } catch {
113
+ finish(false);
114
+ return;
115
+ }
116
+ child.once("error", () => finish(false));
117
+ child.once("close", (code) => finish(code === 0));
118
+ });
119
+ }
120
+
121
+ /**
122
+ * Open Studio in cmux when available, falling back to the system browser.
123
+ *
124
+ * @param {string} target
125
+ * @param {{
126
+ * env?: NodeJS.ProcessEnv | Record<string, string | undefined>,
127
+ * platform?: NodeJS.Platform,
128
+ * spawnProcess?: typeof spawn,
129
+ * timeoutMs?: number,
130
+ * }} [options]
131
+ * @returns {Promise<"cmux" | "system">}
132
+ */
133
+ export async function openStudioUrlInBrowser(target, options = {}) {
134
+ if (await tryOpenStudioUrlInCmuxBrowser(target, options)) return "cmux";
135
+
136
+ const openCommand = getStudioDefaultBrowserOpenCommand(target, options.platform ?? process.platform);
137
+ await spawnDetachedBrowser(openCommand, options.spawnProcess ?? spawn);
138
+ return "system";
139
+ }
@@ -0,0 +1,118 @@
1
+ export const STUDIO_TAB_STATE_ID_PATTERN = /^[a-zA-Z0-9_-]{20,128}$/;
2
+ export const STUDIO_WORKSPACE_STATE_MAX_TEXT_CHARS = 900_000;
3
+ export const STUDIO_WORKSPACE_STATE_MAX_ENTRIES = 16;
4
+ export const STUDIO_WORKSPACE_STATE_MAX_TOTAL_TEXT_CHARS = 3_000_000;
5
+ export const STUDIO_WORKSPACE_STATE_TTL_MS = 24 * 60 * 60 * 1000;
6
+
7
+ export function isValidStudioTabStateId(value) {
8
+ return typeof value === "string" && STUDIO_TAB_STATE_ID_PATTERN.test(value);
9
+ }
10
+
11
+ function boundedString(value, maxLength) {
12
+ return typeof value === "string" ? value.slice(0, maxLength) : "";
13
+ }
14
+
15
+ function finiteNumber(value, fallback = 0) {
16
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
17
+ }
18
+
19
+ export function normalizeStudioWorkspaceRecoveryState(value) {
20
+ if (!value || typeof value !== "object" || value.version !== 1 || typeof value.text !== "string") return null;
21
+ if (value.text.length > STUDIO_WORKSPACE_STATE_MAX_TEXT_CHARS) return null;
22
+ const sourceState = value.sourceState && typeof value.sourceState === "object" ? value.sourceState : {};
23
+ return {
24
+ version: 1,
25
+ savedAt: Math.max(0, finiteNumber(value.savedAt)),
26
+ sourceState: {
27
+ source: boundedString(sourceState.source, 100),
28
+ label: boundedString(sourceState.label, 4_000),
29
+ path: boundedString(sourceState.path, 16_384) || null,
30
+ draftId: boundedString(sourceState.draftId, 256) || null,
31
+ },
32
+ resourceDir: boundedString(value.resourceDir, 16_384),
33
+ editorView: boundedString(value.editorView, 100),
34
+ rightView: boundedString(value.rightView, 100),
35
+ editorLanguage: boundedString(value.editorLanguage, 100),
36
+ followLatest: value.followLatest === true,
37
+ responseHistoryIndex: Math.floor(finiteNumber(value.responseHistoryIndex, -1)),
38
+ selectionStart: Math.max(0, Math.floor(finiteNumber(value.selectionStart))),
39
+ selectionEnd: Math.max(0, Math.floor(finiteNumber(value.selectionEnd))),
40
+ scrollTop: Math.max(0, finiteNumber(value.scrollTop)),
41
+ text: value.text,
42
+ };
43
+ }
44
+
45
+ export function createStudioWorkspaceStateStore(options = {}) {
46
+ const maxEntries = Math.max(1, Math.floor(Number(options.maxEntries) || STUDIO_WORKSPACE_STATE_MAX_ENTRIES));
47
+ const maxTotalTextChars = Math.max(1, Math.floor(Number(options.maxTotalTextChars) || STUDIO_WORKSPACE_STATE_MAX_TOTAL_TEXT_CHARS));
48
+ const ttlMs = Math.max(1, Math.floor(Number(options.ttlMs) || STUDIO_WORKSPACE_STATE_TTL_MS));
49
+ const now = typeof options.now === "function" ? options.now : Date.now;
50
+ const entries = new Map();
51
+ let totalTextChars = 0;
52
+
53
+ function remove(tabStateId) {
54
+ const existing = entries.get(tabStateId);
55
+ if (!existing) return false;
56
+ entries.delete(tabStateId);
57
+ totalTextChars = Math.max(0, totalTextChars - existing.state.text.length);
58
+ return true;
59
+ }
60
+
61
+ function cleanup() {
62
+ const currentTime = now();
63
+ for (const [tabStateId, entry] of entries) {
64
+ if (currentTime - entry.storedAt > ttlMs) remove(tabStateId);
65
+ }
66
+ }
67
+
68
+ function evictOldest(excludedTabStateId) {
69
+ let oldestId = null;
70
+ let oldestStoredAt = Number.POSITIVE_INFINITY;
71
+ for (const [tabStateId, entry] of entries) {
72
+ if (tabStateId === excludedTabStateId) continue;
73
+ if (entry.storedAt < oldestStoredAt) {
74
+ oldestId = tabStateId;
75
+ oldestStoredAt = entry.storedAt;
76
+ }
77
+ }
78
+ return oldestId ? remove(oldestId) : false;
79
+ }
80
+
81
+ return Object.freeze({
82
+ get(tabStateId) {
83
+ if (!isValidStudioTabStateId(tabStateId)) return null;
84
+ cleanup();
85
+ return entries.get(tabStateId)?.state ?? null;
86
+ },
87
+ set(tabStateId, rawState) {
88
+ if (!isValidStudioTabStateId(tabStateId)) return false;
89
+ const state = normalizeStudioWorkspaceRecoveryState(rawState);
90
+ if (!state || state.text.length > maxTotalTextChars) return false;
91
+ cleanup();
92
+ const existing = entries.get(tabStateId);
93
+ if (existing && existing.state.savedAt > state.savedAt) return false;
94
+ if (existing) remove(tabStateId);
95
+ while (entries.size >= maxEntries || totalTextChars + state.text.length > maxTotalTextChars) {
96
+ if (!evictOldest(tabStateId)) return false;
97
+ }
98
+ entries.set(tabStateId, { state, storedAt: now() });
99
+ totalTextChars += state.text.length;
100
+ return true;
101
+ },
102
+ delete(tabStateId) {
103
+ return isValidStudioTabStateId(tabStateId) ? remove(tabStateId) : false;
104
+ },
105
+ clear() {
106
+ entries.clear();
107
+ totalTextChars = 0;
108
+ },
109
+ get size() {
110
+ cleanup();
111
+ return entries.size;
112
+ },
113
+ get totalTextChars() {
114
+ cleanup();
115
+ return totalTextChars;
116
+ },
117
+ });
118
+ }