pi-studio 0.6.10 → 0.7.1

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.7.0] — 2026-05-06
8
+
9
+ ### Added
10
+ - Added explicit `studio-pdf` fenced blocks that render token-protected local PDFs in Studio preview cards.
11
+ - Added an **Open new editor** action that opens a detached copy of the current editor text in a new editor-only Studio view.
12
+ - Documented Studio Markdown asset paths and `studio-pdf` syntax in the README.
13
+
14
+ ### Changed
15
+ - Hid response-sync badges in editor-only Studio views, simplified editor action labels, and slightly strengthened refreshed-layout focus icons.
16
+
7
17
  ## [0.6.10] — 2026-05-04
8
18
 
9
19
  ### Fixed
package/README.md CHANGED
@@ -19,7 +19,7 @@ Extension for [pi](https://pi.dev) that opens a local two-pane browser workspace
19
19
  ## What it does
20
20
 
21
21
  - Opens a two-pane browser workspace: **Editor** (left) + **Response/Working/Editor Preview** (right)
22
- - Supports one canonical full Studio view per Pi session, plus additional editor-only companion views when you just want extra editing/preview surfaces
22
+ - Supports one canonical full Studio view per Pi session, plus additional editor-only companion views when you just want extra editing/preview surfaces; the editor toolbar can open a detached copy of the current editor text as a companion view
23
23
  - Runs editor text directly, or asks for structured critique (auto/writing/code focus)
24
24
  - Includes a live **Working** view for following current model/tool activity, with `All` / `Thinking` / `Tools` filters plus **Load visible into editor** and **Copy visible** actions
25
25
  - 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
@@ -35,6 +35,7 @@ Extension for [pi](https://pi.dev) that opens a local two-pane browser workspace
35
35
  - strips markers before send (optional)
36
36
  - saves `.annotated.md`
37
37
  - Renders Markdown/LaTeX/code previews (math + Mermaid), theme-synced with pi
38
+ - Embeds local PDFs in Studio Markdown previews via explicit `studio-pdf` fenced blocks
38
39
  - Ships optional `pi-studio-dark` and `pi-studio-light` themes tuned for Studio's browser workspace
39
40
  - Exports right-pane preview as PDF (pandoc + LaTeX)
40
41
  - Exports local files headlessly via `/studio-pdf <path>` to `<name>.studio.pdf`
@@ -72,6 +73,28 @@ Run once without installing:
72
73
  pi -e https://github.com/omaclaren/pi-studio
73
74
  ```
74
75
 
76
+ ## Studio Markdown extras
77
+
78
+ Studio previews standard Markdown, code fences, display math, Mermaid, and local images. When adding companion files such as generated plots or PDFs, prefer the project's existing folder convention. If there is no convention, `attachments/` is a reasonable default for newly generated assets. Use relative paths from the opened Markdown file or Studio working/resource directory, and wrap paths in angle brackets when spaces are possible:
79
+
80
+ ```md
81
+ ![Short descriptive caption](<attachments/plot.png>)
82
+ ```
83
+
84
+ Local PDFs can be embedded with an explicit Studio-only fenced block:
85
+
86
+ ````md
87
+ ```studio-pdf
88
+ path: attachments/paper.pdf
89
+ title: Optional title
90
+ page: 3
91
+ height: 760
92
+ caption: Optional caption
93
+ ```
94
+ ````
95
+
96
+ `path` must point to a local `.pdf` within the current Studio resource directory. Relative paths resolve from the opened document's directory, or from Studio's working dir for non-file-backed content. `page` is an initial page hint for the browser PDF viewer, and `height` controls the embedded frame height in pixels. Use normal Markdown links for PDFs when embedding is not useful.
97
+
75
98
  ## Notes
76
99
 
77
100
  - Local-only server (`127.0.0.1`) with tokenized Studio URLs.
@@ -98,6 +98,7 @@
98
98
  const saveOverBtn = document.getElementById("saveOverBtn");
99
99
  const refreshFromDiskBtn = document.getElementById("refreshFromDiskBtn");
100
100
  const sendEditorBtn = document.getElementById("sendEditorBtn");
101
+ const openCompanionBtn = document.getElementById("openCompanionBtn");
101
102
  const getEditorBtn = document.getElementById("getEditorBtn");
102
103
  const loadGitDiffBtn = document.getElementById("loadGitDiffBtn");
103
104
  const sendRunBtn = document.getElementById("sendRunBtn");
@@ -149,7 +150,8 @@
149
150
  const isEditorOnlyMode = studioMode === "editor-only";
150
151
 
151
152
  const initialQueryParams = new URLSearchParams(window.location.search || "");
152
- const explicitDocumentIdentityFromUrl = initialQueryParams.has("docSource")
153
+ const explicitDocumentIdentityFromUrl = initialQueryParams.has("docId")
154
+ || initialQueryParams.has("docSource")
153
155
  || initialQueryParams.has("docLabel")
154
156
  || initialQueryParams.has("docPath")
155
157
  || initialQueryParams.has("draftId");
@@ -163,6 +165,8 @@
163
165
  draftId: initialQueryParams.get("draftId")
164
166
  || ((document.body && document.body.dataset && document.body.dataset.initialDraftId) || null),
165
167
  };
168
+ const initialResourceDir = initialQueryParams.get("resourceDir")
169
+ || ((document.body && document.body.dataset && document.body.dataset.initialResourceDir) || "");
166
170
 
167
171
  let ws = null;
168
172
  let wsState = "Connecting";
@@ -173,6 +177,7 @@
173
177
  let pendingRequestId = null;
174
178
  let pendingKind = null;
175
179
  let stickyStudioKind = null;
180
+ const pendingCompanionWindows = new Map();
176
181
  let initialDocumentApplied = false;
177
182
  function getInitialRightView(source) {
178
183
  if (isEditorOnlyMode) return "editor-preview";
@@ -1041,6 +1046,7 @@
1041
1046
  if (!isEditorOnlyMode && queueSteerBtn) actionLineOneEl.appendChild(queueSteerBtn);
1042
1047
  const actionLineTwoEl = makeStudioUiRefreshElement("div", "studio-refresh-action-line");
1043
1048
  actionLineTwoEl.appendChild(copyDraftBtn);
1049
+ if (openCompanionBtn) actionLineTwoEl.appendChild(openCompanionBtn);
1044
1050
  if (!isEditorOnlyMode && sendEditorBtn) actionLineTwoEl.appendChild(sendEditorBtn);
1045
1051
  if (actionLineOneEl.childNodes.length > 0) actionsEl.appendChild(actionLineOneEl);
1046
1052
  actionsEl.appendChild(actionLineTwoEl);
@@ -1266,6 +1272,7 @@
1266
1272
  if (kind === "send_to_editor") return "sending to pi editor";
1267
1273
  if (kind === "get_from_editor") return "loading from pi editor";
1268
1274
  if (kind === "load_git_diff") return "loading git diff";
1275
+ if (kind === "open_editor_only") return "opening companion editor";
1269
1276
  if (kind === "refresh_from_disk") return "refreshing from disk";
1270
1277
  if (kind === "save_as" || kind === "save_over") return "saving editor text";
1271
1278
  return "submitting request";
@@ -2301,6 +2308,12 @@
2301
2308
  function updateSyncBadge(normalizedEditorText) {
2302
2309
  if (!syncBadgeEl) return;
2303
2310
 
2311
+ if (isEditorOnlyMode) {
2312
+ syncBadgeEl.hidden = true;
2313
+ syncBadgeEl.classList.remove("sync");
2314
+ return;
2315
+ }
2316
+
2304
2317
  if (rightView === "trace") {
2305
2318
  syncBadgeEl.hidden = true;
2306
2319
  syncBadgeEl.classList.remove("sync");
@@ -2339,6 +2352,148 @@
2339
2352
  return "<div class='preview-error'>" + escapeHtml(String(message || "Preview rendering failed.")) + "</div>" + buildPlainMarkdownHtml(markdown, options);
2340
2353
  }
2341
2354
 
2355
+ function stripMatchingQuotes(value) {
2356
+ const text = String(value || "").trim();
2357
+ if (text.length >= 2) {
2358
+ const first = text[0];
2359
+ const last = text[text.length - 1];
2360
+ if ((first === "\"" && last === "\"") || (first === "'" && last === "'")) {
2361
+ return text.slice(1, -1).trim();
2362
+ }
2363
+ }
2364
+ return text;
2365
+ }
2366
+
2367
+ function parseStudioPdfBlockOptions(body) {
2368
+ const options = { path: "", title: "", caption: "", page: "", height: "" };
2369
+ String(body || "").split(/\r?\n/).forEach((line) => {
2370
+ const raw = String(line || "").trim();
2371
+ if (!raw || raw.startsWith("#")) return;
2372
+ const match = raw.match(/^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*([\s\S]*)$/);
2373
+ if (match) {
2374
+ const key = String(match[1] || "").toLowerCase();
2375
+ const value = stripMatchingQuotes(match[2] || "");
2376
+ if (key === "path" || key === "src" || key === "file") options.path = value;
2377
+ else if (key === "title") options.title = value;
2378
+ else if (key === "caption") options.caption = value;
2379
+ else if (key === "page") options.page = value;
2380
+ else if (key === "height") options.height = value;
2381
+ return;
2382
+ }
2383
+ if (!options.path) options.path = stripMatchingQuotes(raw);
2384
+ });
2385
+ return options;
2386
+ }
2387
+
2388
+ function prepareStudioPdfBlocksForPreview(markdown) {
2389
+ const blocks = [];
2390
+ const prefix = "STUDIO_PDF_BLOCK_" + Date.now().toString(36) + "_" + Math.random().toString(36).slice(2) + "_";
2391
+ const source = String(markdown || "");
2392
+ const blockPattern = /(^|\n)([ \t]{0,3})(`{3,}|~{3,})[ \t]*studio-pdf[^\n]*\n([\s\S]*?)\n[ \t]*\3[ \t]*(?=\n|$)/g;
2393
+ const nextMarkdown = source.replace(blockPattern, (match, leadingNewline, _indent, _fence, body) => {
2394
+ const placeholder = prefix + blocks.length;
2395
+ blocks.push({ placeholder, options: parseStudioPdfBlockOptions(body) });
2396
+ return String(leadingNewline || "") + placeholder + "\n";
2397
+ });
2398
+ return { markdown: nextMarkdown, blocks };
2399
+ }
2400
+
2401
+ function normalizeStudioPdfHeight(value) {
2402
+ const parsed = Number.parseInt(String(value || ""), 10);
2403
+ if (!Number.isFinite(parsed)) return 680;
2404
+ return Math.max(240, Math.min(1400, parsed));
2405
+ }
2406
+
2407
+ function normalizeStudioPdfPage(value) {
2408
+ const parsed = Number.parseInt(String(value || ""), 10);
2409
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
2410
+ }
2411
+
2412
+ function buildStudioPdfResourceUrl(options) {
2413
+ const token = getToken();
2414
+ if (!token) return "";
2415
+ const pdfPath = String(options && options.path ? options.path : "").trim();
2416
+ if (!pdfPath) return "";
2417
+ const effectivePath = getEffectiveSavePath();
2418
+ const sourcePath = effectivePath || sourceState.path || "";
2419
+ const params = new URLSearchParams({ token, path: pdfPath });
2420
+ if (sourcePath) {
2421
+ params.set("sourcePath", sourcePath);
2422
+ } else if (resourceDirInput && resourceDirInput.value.trim()) {
2423
+ params.set("resourceDir", resourceDirInput.value.trim());
2424
+ }
2425
+ return "/pdf-resource?" + params.toString();
2426
+ }
2427
+
2428
+ function createStudioPdfCard(block) {
2429
+ const options = block && block.options ? block.options : {};
2430
+ const path = String(options.path || "").trim();
2431
+ const title = String(options.title || path || "Embedded PDF").trim();
2432
+ const caption = String(options.caption || "").trim();
2433
+ const height = normalizeStudioPdfHeight(options.height);
2434
+ const page = normalizeStudioPdfPage(options.page);
2435
+ const resourceUrl = buildStudioPdfResourceUrl(options);
2436
+ const viewerUrl = resourceUrl && page ? resourceUrl + "#page=" + encodeURIComponent(String(page)) : resourceUrl;
2437
+
2438
+ const card = document.createElement("figure");
2439
+ card.className = "studio-pdf-card";
2440
+
2441
+ const header = document.createElement("figcaption");
2442
+ header.className = "studio-pdf-card-header";
2443
+ const label = document.createElement("div");
2444
+ label.className = "studio-pdf-card-title";
2445
+ label.textContent = title;
2446
+ header.appendChild(label);
2447
+
2448
+ if (resourceUrl) {
2449
+ const openLink = document.createElement("a");
2450
+ openLink.className = "studio-pdf-card-link";
2451
+ openLink.href = viewerUrl;
2452
+ openLink.target = "_blank";
2453
+ openLink.rel = "noopener noreferrer";
2454
+ openLink.textContent = "Open PDF";
2455
+ header.appendChild(openLink);
2456
+ }
2457
+ card.appendChild(header);
2458
+
2459
+ if (caption) {
2460
+ const captionEl = document.createElement("div");
2461
+ captionEl.className = "studio-pdf-card-caption";
2462
+ captionEl.textContent = caption;
2463
+ card.appendChild(captionEl);
2464
+ }
2465
+
2466
+ if (!resourceUrl) {
2467
+ const errorEl = document.createElement("div");
2468
+ errorEl.className = "studio-pdf-card-error";
2469
+ errorEl.textContent = "PDF block needs a local path.";
2470
+ card.appendChild(errorEl);
2471
+ return card;
2472
+ }
2473
+
2474
+ const iframe = document.createElement("iframe");
2475
+ iframe.className = "studio-pdf-frame";
2476
+ iframe.src = viewerUrl;
2477
+ iframe.title = title;
2478
+ iframe.loading = "lazy";
2479
+ iframe.style.height = height + "px";
2480
+ card.appendChild(iframe);
2481
+ return card;
2482
+ }
2483
+
2484
+ function renderStudioPdfBlocksInElement(targetEl, blocks) {
2485
+ if (!targetEl || !Array.isArray(blocks) || blocks.length === 0) return;
2486
+ const candidates = Array.from(targetEl.querySelectorAll("p, pre, div"));
2487
+ blocks.forEach((block) => {
2488
+ const placeholder = block && block.placeholder ? block.placeholder : "";
2489
+ if (!placeholder) return;
2490
+ const match = candidates.find((el) => String(el.textContent || "").trim() === placeholder);
2491
+ if (match && match.parentNode) {
2492
+ match.replaceWith(createStudioPdfCard(block));
2493
+ }
2494
+ });
2495
+ }
2496
+
2342
2497
  function sanitizeRenderedHtml(html, markdown, options) {
2343
2498
  const rawHtml = typeof html === "string" ? html : "";
2344
2499
  const mathAnnotationPreserved = rawHtml.replace(/<math\b([^>]*)>([\s\S]*?)<\/math>/gi, (match, attrs, inner) => {
@@ -3499,9 +3654,10 @@
3499
3654
  const previewFallbackOptions = {
3500
3655
  stripMarkdownHtmlComments: !previewingEditorText || editorLanguage !== "latex",
3501
3656
  };
3657
+ const pdfPrepared = prepareStudioPdfBlocksForPreview(previewPrepared.markdown);
3502
3658
 
3503
3659
  try {
3504
- const renderedHtml = await renderMarkdownWithPandoc(previewPrepared.markdown, {
3660
+ const renderedHtml = await renderMarkdownWithPandoc(pdfPrepared.markdown, {
3505
3661
  includeEditorLanguage: pane === "source" || rightView === "editor-preview",
3506
3662
  });
3507
3663
 
@@ -3514,6 +3670,7 @@
3514
3670
  clearPreviewJumpHighlight(targetEl);
3515
3671
  finishPreviewRender(targetEl);
3516
3672
  targetEl.innerHTML = sanitizeRenderedHtml(renderedHtml, markdown, previewFallbackOptions);
3673
+ renderStudioPdfBlocksInElement(targetEl, pdfPrepared.blocks);
3517
3674
  applyPreviewAnnotationPlaceholdersToElement(targetEl, previewPrepared.placeholders);
3518
3675
  await renderAnnotationMathInElement(targetEl);
3519
3676
  decoratePdfEmbeds(targetEl);
@@ -4002,6 +4159,7 @@
4002
4159
  if (loadGitDiffBtn) loadGitDiffBtn.disabled = uiBusy;
4003
4160
  syncRunAndCritiqueButtons();
4004
4161
  copyDraftBtn.disabled = uiBusy;
4162
+ if (openCompanionBtn) openCompanionBtn.disabled = uiBusy || wsState !== "Ready";
4005
4163
  if (highlightSelect) highlightSelect.disabled = uiBusy;
4006
4164
  if (lineNumbersSelect) lineNumbersSelect.disabled = uiBusy;
4007
4165
  if (annotationModeSelect) annotationModeSelect.disabled = uiBusy;
@@ -10201,6 +10359,27 @@
10201
10359
  return;
10202
10360
  }
10203
10361
 
10362
+ if (message.type === "editor_only_ready") {
10363
+ const responseRequestId = typeof message.requestId === "string" ? message.requestId : "";
10364
+ if (responseRequestId && pendingRequestId === responseRequestId) {
10365
+ pendingRequestId = null;
10366
+ pendingKind = null;
10367
+ clearArmedTitleAttention(responseRequestId);
10368
+ stickyStudioKind = null;
10369
+ }
10370
+ setBusy(false);
10371
+ setWsState("Ready");
10372
+ const targetUrl = resolveCompanionEditorTargetUrl(message);
10373
+ const opened = navigatePendingCompanionWindow(responseRequestId, targetUrl);
10374
+ setStatus(
10375
+ opened
10376
+ ? "Opened companion editor with a detached copy of the current editor text."
10377
+ : (targetUrl ? "Companion editor ready: " + targetUrl : "Companion editor is ready, but Studio did not receive a URL."),
10378
+ opened ? "success" : "warning",
10379
+ );
10380
+ return;
10381
+ }
10382
+
10204
10383
  if (message.type === "studio_state") {
10205
10384
  const busy = Boolean(message.busy);
10206
10385
  agentBusyFromServer = Boolean(message.agentBusy);
@@ -10267,6 +10446,9 @@
10267
10446
  }
10268
10447
 
10269
10448
  if (message.type === "busy") {
10449
+ if (typeof message.requestId === "string") {
10450
+ closePendingCompanionWindow(message.requestId);
10451
+ }
10270
10452
  if (message.requestId && pendingRequestId === message.requestId) {
10271
10453
  if (pendingKind === "compact") {
10272
10454
  compactInProgress = false;
@@ -10285,6 +10467,9 @@
10285
10467
  }
10286
10468
 
10287
10469
  if (message.type === "error") {
10470
+ if (typeof message.requestId === "string") {
10471
+ closePendingCompanionWindow(message.requestId);
10472
+ }
10288
10473
  if (message.requestId && pendingRequestId === message.requestId) {
10289
10474
  if (pendingKind === "compact") {
10290
10475
  compactInProgress = false;
@@ -10494,6 +10679,69 @@
10494
10679
  return requestId;
10495
10680
  }
10496
10681
 
10682
+ function openPendingCompanionWindow(requestId) {
10683
+ if (!requestId) return null;
10684
+ let companionWindow = null;
10685
+ try {
10686
+ companionWindow = window.open("", "_blank");
10687
+ if (companionWindow && companionWindow.document && companionWindow.document.body) {
10688
+ companionWindow.document.title = "Opening companion editor…";
10689
+ companionWindow.document.body.innerHTML = "<p style=\"font: 13px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 16px;\">Opening companion editor…</p>";
10690
+ }
10691
+ } catch {
10692
+ companionWindow = null;
10693
+ }
10694
+ if (companionWindow) {
10695
+ pendingCompanionWindows.set(requestId, companionWindow);
10696
+ }
10697
+ return companionWindow;
10698
+ }
10699
+
10700
+ function takePendingCompanionWindow(requestId) {
10701
+ if (!requestId || !pendingCompanionWindows.has(requestId)) return null;
10702
+ const companionWindow = pendingCompanionWindows.get(requestId);
10703
+ pendingCompanionWindows.delete(requestId);
10704
+ return companionWindow || null;
10705
+ }
10706
+
10707
+ function closePendingCompanionWindow(requestId) {
10708
+ const companionWindow = takePendingCompanionWindow(requestId);
10709
+ if (!companionWindow || companionWindow.closed) return;
10710
+ try {
10711
+ companionWindow.close();
10712
+ } catch {}
10713
+ }
10714
+
10715
+ function resolveCompanionEditorTargetUrl(message) {
10716
+ const relativeUrl = message && typeof message.relativeUrl === "string" ? message.relativeUrl : "";
10717
+ if (relativeUrl) {
10718
+ try {
10719
+ return new URL(relativeUrl, window.location.href).href;
10720
+ } catch {}
10721
+ }
10722
+ return message && typeof message.url === "string" ? message.url : "";
10723
+ }
10724
+
10725
+ function navigatePendingCompanionWindow(requestId, targetUrl) {
10726
+ if (!targetUrl) {
10727
+ closePendingCompanionWindow(requestId);
10728
+ return false;
10729
+ }
10730
+ const companionWindow = takePendingCompanionWindow(requestId);
10731
+ if (companionWindow && !companionWindow.closed) {
10732
+ try {
10733
+ companionWindow.opener = null;
10734
+ companionWindow.location.href = targetUrl;
10735
+ return true;
10736
+ } catch {}
10737
+ }
10738
+ try {
10739
+ return Boolean(window.open(targetUrl, "_blank", "noopener"));
10740
+ } catch {
10741
+ return false;
10742
+ }
10743
+ }
10744
+
10497
10745
  function describeSourceForAnnotation() {
10498
10746
  if (sourceState.source === "file" && sourceState.label) {
10499
10747
  return "file " + sourceState.label;
@@ -11056,6 +11304,38 @@
11056
11304
  }
11057
11305
  });
11058
11306
 
11307
+ if (openCompanionBtn) {
11308
+ openCompanionBtn.addEventListener("click", () => {
11309
+ const content = sourceTextEl.value;
11310
+ if (!content.trim()) {
11311
+ setStatus("Editor is empty. Nothing to copy into a companion view.", "warning");
11312
+ return;
11313
+ }
11314
+
11315
+ const requestId = beginUiAction("open_editor_only");
11316
+ if (!requestId) return;
11317
+ openPendingCompanionWindow(requestId);
11318
+
11319
+ const sent = sendMessage({
11320
+ type: "open_editor_only_request",
11321
+ requestId,
11322
+ content,
11323
+ label: sourceState && sourceState.label ? sourceState.label : "current editor",
11324
+ path: sourceState && sourceState.path ? sourceState.path : undefined,
11325
+ resourceDir: resourceDirInput && resourceDirInput.value.trim()
11326
+ ? resourceDirInput.value.trim()
11327
+ : undefined,
11328
+ });
11329
+
11330
+ if (!sent) {
11331
+ closePendingCompanionWindow(requestId);
11332
+ pendingRequestId = null;
11333
+ pendingKind = null;
11334
+ setBusy(false);
11335
+ }
11336
+ });
11337
+ }
11338
+
11059
11339
  if (getEditorBtn) {
11060
11340
  getEditorBtn.addEventListener("click", () => {
11061
11341
  const requestId = beginUiAction("get_from_editor");
@@ -11158,7 +11438,7 @@
11158
11438
 
11159
11439
  try {
11160
11440
  await writeTextToClipboard(content);
11161
- setStatus("Copied editor text.", "success");
11441
+ setStatus("Copied text.", "success");
11162
11442
  } catch (error) {
11163
11443
  setStatus("Clipboard write failed.", "warning");
11164
11444
  }
@@ -11540,6 +11820,9 @@
11540
11820
  const initialResponseFontSize = readStoredFontSize(RESPONSE_FONT_SIZE_STORAGE_KEY, RESPONSE_FONT_SIZE_OPTIONS, DEFAULT_RESPONSE_FONT_SIZE);
11541
11821
  setResponseFontSize(initialResponseFontSize, { persist: false });
11542
11822
 
11823
+ if (resourceDirInput && initialResourceDir) {
11824
+ resourceDirInput.value = initialResourceDir;
11825
+ }
11543
11826
  setSourceState(initialSourceState);
11544
11827
  refreshResponseUi();
11545
11828
  updateAnnotatedReplyHeaderButton();
package/client/studio.css CHANGED
@@ -1393,6 +1393,62 @@
1393
1393
  max-width: 100%;
1394
1394
  }
1395
1395
 
1396
+ .rendered-markdown .studio-pdf-card {
1397
+ margin: 1.15em 0;
1398
+ border: 1px solid var(--panel-border);
1399
+ border-radius: 12px;
1400
+ background: var(--panel);
1401
+ overflow: hidden;
1402
+ box-shadow: 0 1px 2px var(--shadow-color);
1403
+ }
1404
+
1405
+ .rendered-markdown .studio-pdf-card-header {
1406
+ display: flex;
1407
+ align-items: center;
1408
+ justify-content: space-between;
1409
+ gap: 10px;
1410
+ padding: 8px 10px;
1411
+ border-bottom: 1px solid var(--border-subtle);
1412
+ background: var(--panel-2);
1413
+ color: var(--studio-info-text, var(--muted));
1414
+ font-size: 12px;
1415
+ }
1416
+
1417
+ .rendered-markdown .studio-pdf-card-title {
1418
+ min-width: 0;
1419
+ overflow: hidden;
1420
+ text-overflow: ellipsis;
1421
+ white-space: nowrap;
1422
+ font-weight: 600;
1423
+ color: var(--text);
1424
+ }
1425
+
1426
+ .rendered-markdown .studio-pdf-card-link {
1427
+ flex: 0 0 auto;
1428
+ font-size: 12px;
1429
+ font-weight: 600;
1430
+ }
1431
+
1432
+ .rendered-markdown .studio-pdf-card-caption {
1433
+ padding: 8px 10px 0;
1434
+ color: var(--studio-info-text, var(--muted));
1435
+ font-size: 12px;
1436
+ }
1437
+
1438
+ .rendered-markdown .studio-pdf-card-error {
1439
+ padding: 12px;
1440
+ color: var(--warn);
1441
+ font-size: 12px;
1442
+ }
1443
+
1444
+ .rendered-markdown .studio-pdf-frame {
1445
+ display: block;
1446
+ width: 100%;
1447
+ min-height: 240px;
1448
+ border: 0;
1449
+ background: #fff;
1450
+ }
1451
+
1396
1452
  .rendered-markdown .studio-subfigure-group {
1397
1453
  margin: 1.25em auto;
1398
1454
  }
@@ -2579,7 +2635,7 @@
2579
2635
  width: 15px;
2580
2636
  height: 15px;
2581
2637
  stroke: currentColor;
2582
- stroke-width: 1.85;
2638
+ stroke-width: 2;
2583
2639
  stroke-linecap: round;
2584
2640
  stroke-linejoin: round;
2585
2641
  fill: none;
@@ -2629,11 +2685,23 @@
2629
2685
  min-width: 29px;
2630
2686
  min-height: 29px;
2631
2687
  padding: 0;
2632
- color: var(--muted);
2688
+ color: var(--studio-info-text, var(--muted));
2633
2689
  align-items: center;
2634
2690
  justify-content: center;
2635
2691
  }
2636
2692
 
2693
+ body.studio-ui-refresh #leftFocusBtn:not(:disabled):hover,
2694
+ body.studio-ui-refresh #rightFocusBtn:not(:disabled):hover {
2695
+ color: var(--text);
2696
+ }
2697
+
2698
+ body.studio-ui-refresh #leftFocusBtn.is-active,
2699
+ body.studio-ui-refresh #rightFocusBtn.is-active {
2700
+ background: var(--accent);
2701
+ border-color: var(--accent);
2702
+ color: var(--accent-contrast);
2703
+ }
2704
+
2637
2705
  body.studio-ui-refresh #sourceBadge,
2638
2706
  body.studio-ui-refresh #resourceDirBtn,
2639
2707
  body.studio-ui-refresh #resourceDirLabel {
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { ExtensionAPI, ExtensionCommandContext, SessionEntry, Theme } from "@mariozechner/pi-coding-agent";
2
- import { getAgentDir } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionCommandContext, SessionEntry, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
3
3
  import { spawn, spawnSync } from "node:child_process";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { readFileSync, statSync, writeFileSync } from "node:fs";
@@ -25,6 +25,7 @@ import {
25
25
  preserveLiteralLatexCommandsInMarkdown,
26
26
  } from "./shared/studio-markdown-latex-literals.js";
27
27
  import { escapeStudioPdfLatexTextFragment } from "./shared/studio-pdf-escape.js";
28
+ import { resolveStudioPdfResourceFile } from "./shared/studio-pdf-resource.js";
28
29
 
29
30
  type Lens = "writing" | "code";
30
31
  type RequestedLens = Lens | "auto";
@@ -115,6 +116,7 @@ interface InitialStudioDocument {
115
116
  source: StudioSourceKind;
116
117
  path?: string;
117
118
  draftId?: string;
119
+ resourceDir?: string;
118
120
  }
119
121
 
120
122
  interface PersistedStudioReviewNote {
@@ -249,6 +251,15 @@ interface LoadGitDiffRequestMessage {
249
251
  resourceDir?: string;
250
252
  }
251
253
 
254
+ interface OpenEditorOnlyRequestMessage {
255
+ type: "open_editor_only_request";
256
+ requestId: string;
257
+ content: string;
258
+ label?: string;
259
+ path?: string;
260
+ resourceDir?: string;
261
+ }
262
+
252
263
  interface CancelRequestMessage {
253
264
  type: "cancel_request";
254
265
  requestId: string;
@@ -268,6 +279,7 @@ type IncomingStudioMessage =
268
279
  | SendToEditorRequestMessage
269
280
  | GetFromEditorRequestMessage
270
281
  | LoadGitDiffRequestMessage
282
+ | OpenEditorOnlyRequestMessage
271
283
  | CancelRequestMessage;
272
284
 
273
285
  const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
@@ -278,6 +290,8 @@ const RESPONSE_HISTORY_LIMIT = 30;
278
290
  const CMUX_NOTIFY_TIMEOUT_MS = 1200;
279
291
  const PREPARED_PDF_EXPORT_TTL_MS = 5 * 60 * 1000;
280
292
  const MAX_PREPARED_PDF_EXPORTS = 8;
293
+ const TRANSIENT_STUDIO_DOCUMENT_TTL_MS = 30 * 60 * 1000;
294
+ const MAX_TRANSIENT_STUDIO_DOCUMENTS = 16;
281
295
  const STUDIO_TERMINAL_NOTIFY_TITLE = "pi Studio";
282
296
  const CMUX_STUDIO_STATUS_KEY = "pi_studio";
283
297
  const CMUX_STUDIO_STATUS_COLOR_DARK = "#5ea1ff";
@@ -289,6 +303,7 @@ const STUDIO_PERSISTENT_STATE_PATH = join(STUDIO_PERSISTENT_STATE_DIR, "local-st
289
303
 
290
304
  let studioPersistentStateCache: StudioPersistentState | null = null;
291
305
  let studioPersistentStateQueue: Promise<void> = Promise.resolve();
306
+ let transientStudioDocuments: Map<string, { document: InitialStudioDocument; createdAt: number }> = new Map();
292
307
 
293
308
  function createEmptyStudioPersistentState(): StudioPersistentState {
294
309
  return {
@@ -1638,6 +1653,31 @@ function resolveStudioGitDiffBaseDir(sourcePath: string | undefined, resourceDir
1638
1653
  return resolveStudioBaseDir(sourcePath, resourceDir, fallbackCwd);
1639
1654
  }
1640
1655
 
1656
+ function resolveStudioCompanionResourceDir(sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string): string | undefined {
1657
+ const explicitResource = typeof resourceDir === "string" ? resourceDir.trim() : "";
1658
+ if (explicitResource) {
1659
+ const expanded = expandHome(explicitResource);
1660
+ return isAbsolute(expanded) ? expanded : resolve(fallbackCwd, expanded);
1661
+ }
1662
+
1663
+ const source = typeof sourcePath === "string" ? sourcePath.trim() : "";
1664
+ if (source) {
1665
+ const expanded = expandHome(source);
1666
+ return dirname(isAbsolute(expanded) ? expanded : resolve(fallbackCwd, expanded));
1667
+ }
1668
+
1669
+ return undefined;
1670
+ }
1671
+
1672
+ function buildStudioCompanionLabel(_label: string | undefined): string {
1673
+ return "copy of editor text";
1674
+ }
1675
+
1676
+ function resolveStudioPdfResourcePath(pdfPath: string | undefined, sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string): string {
1677
+ const baseDir = resolveStudioBaseDir(sourcePath, resourceDir, fallbackCwd);
1678
+ return resolveStudioPdfResourceFile(pdfPath, baseDir);
1679
+ }
1680
+
1641
1681
  function resolveStudioPandocWorkingDir(baseDir: string | undefined): string | undefined {
1642
1682
  const normalized = typeof baseDir === "string" ? baseDir.trim() : "";
1643
1683
  if (!normalized) return undefined;
@@ -5213,6 +5253,26 @@ function respondText(res: ServerResponse, status: number, text: string): void {
5213
5253
  res.end(text);
5214
5254
  }
5215
5255
 
5256
+ function respondPdfFile(req: IncomingMessage, res: ServerResponse, filePath: string): void {
5257
+ const method = (req.method ?? "GET").toUpperCase();
5258
+ if (method !== "GET" && method !== "HEAD") {
5259
+ res.setHeader("Allow", "GET, HEAD");
5260
+ respondText(res, 405, "Method not allowed. Use GET.");
5261
+ return;
5262
+ }
5263
+
5264
+ const pdf = readFileSync(filePath);
5265
+ res.writeHead(200, {
5266
+ "Content-Type": "application/pdf",
5267
+ "Content-Length": String(pdf.length),
5268
+ "Content-Disposition": `inline; filename="${basename(filePath).replace(/["\\]/g, "") || "document.pdf"}"`,
5269
+ "Cache-Control": "no-store",
5270
+ "X-Content-Type-Options": "nosniff",
5271
+ "Cross-Origin-Resource-Policy": "same-origin",
5272
+ });
5273
+ res.end(method === "HEAD" ? undefined : pdf);
5274
+ }
5275
+
5216
5276
  function openUrlInDefaultBrowser(url: string): Promise<void> {
5217
5277
  const openCommand =
5218
5278
  process.platform === "darwin"
@@ -5810,6 +5870,24 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
5810
5870
  };
5811
5871
  }
5812
5872
 
5873
+ if (
5874
+ msg.type === "open_editor_only_request"
5875
+ && typeof msg.requestId === "string"
5876
+ && typeof msg.content === "string"
5877
+ && (msg.label === undefined || typeof msg.label === "string")
5878
+ && (msg.path === undefined || typeof msg.path === "string")
5879
+ && (msg.resourceDir === undefined || typeof msg.resourceDir === "string")
5880
+ ) {
5881
+ return {
5882
+ type: "open_editor_only_request",
5883
+ requestId: msg.requestId,
5884
+ content: msg.content,
5885
+ label: typeof msg.label === "string" ? msg.label : undefined,
5886
+ path: typeof msg.path === "string" ? msg.path : undefined,
5887
+ resourceDir: typeof msg.resourceDir === "string" ? msg.resourceDir : undefined,
5888
+ };
5889
+ }
5890
+
5813
5891
  if (msg.type === "cancel_request" && typeof msg.requestId === "string") {
5814
5892
  return {
5815
5893
  type: "cancel_request",
@@ -6067,18 +6145,52 @@ function normalizeStudioUiMode(raw: string | null | undefined): StudioUiMode {
6067
6145
  return raw === "editor-only" ? "editor-only" : "full";
6068
6146
  }
6069
6147
 
6148
+ function cleanupTransientStudioDocuments(now = Date.now()): void {
6149
+ for (const [id, entry] of transientStudioDocuments) {
6150
+ if (now - entry.createdAt > TRANSIENT_STUDIO_DOCUMENT_TTL_MS) {
6151
+ transientStudioDocuments.delete(id);
6152
+ }
6153
+ }
6154
+
6155
+ while (transientStudioDocuments.size > MAX_TRANSIENT_STUDIO_DOCUMENTS) {
6156
+ const oldest = transientStudioDocuments.keys().next().value;
6157
+ if (!oldest) break;
6158
+ transientStudioDocuments.delete(oldest);
6159
+ }
6160
+ }
6161
+
6162
+ function storeTransientStudioDocument(document: InitialStudioDocument): string {
6163
+ cleanupTransientStudioDocuments();
6164
+ const id = randomUUID();
6165
+ transientStudioDocuments.set(id, {
6166
+ document: { ...document },
6167
+ createdAt: Date.now(),
6168
+ });
6169
+ cleanupTransientStudioDocuments();
6170
+ return id;
6171
+ }
6172
+
6173
+ function readTransientStudioDocument(id: string): InitialStudioDocument | null {
6174
+ cleanupTransientStudioDocuments();
6175
+ const entry = transientStudioDocuments.get(id);
6176
+ return entry ? { ...entry.document } : null;
6177
+ }
6178
+
6070
6179
  function buildStudioUrl(
6071
6180
  port: number,
6072
6181
  token: string,
6073
6182
  mode: StudioUiMode = "full",
6074
6183
  doc?: InitialStudioDocument | null,
6184
+ docId?: string,
6075
6185
  ): string {
6076
6186
  const params = new URLSearchParams({ token });
6077
6187
  if (mode !== "full") params.set("mode", mode);
6188
+ if (docId) params.set("docId", docId);
6078
6189
  if (doc?.source) params.set("docSource", doc.source);
6079
6190
  if (doc?.label) params.set("docLabel", doc.label);
6080
6191
  if (doc?.path) params.set("docPath", doc.path);
6081
6192
  if (doc?.draftId) params.set("draftId", doc.draftId);
6193
+ if (doc?.resourceDir) params.set("resourceDir", doc.resourceDir);
6082
6194
  return `http://127.0.0.1:${port}/?${params.toString()}`;
6083
6195
  }
6084
6196
 
@@ -6099,10 +6211,17 @@ function resolveRequestedStudioDocumentFromUrl(
6099
6211
  studioCwd: string,
6100
6212
  latestResponse?: LastStudioResponse | null,
6101
6213
  ): InitialStudioDocument | null {
6214
+ const requestedDocId = (requestUrl.searchParams.get("docId") ?? "").trim();
6215
+ if (requestedDocId) {
6216
+ const transientDocument = readTransientStudioDocument(requestedDocId);
6217
+ if (transientDocument) return transientDocument;
6218
+ }
6219
+
6102
6220
  const requestedPath = (requestUrl.searchParams.get("docPath") ?? "").trim();
6103
6221
  const requestedSourceRaw = (requestUrl.searchParams.get("docSource") ?? "").trim();
6104
6222
  const requestedLabel = (requestUrl.searchParams.get("docLabel") ?? "").trim();
6105
6223
  const requestedDraftId = (requestUrl.searchParams.get("draftId") ?? "").trim();
6224
+ const requestedResourceDir = (requestUrl.searchParams.get("resourceDir") ?? "").trim();
6106
6225
 
6107
6226
  if (requestedPath) {
6108
6227
  const file = readStudioFile(requestedPath, studioCwd);
@@ -6112,6 +6231,7 @@ function resolveRequestedStudioDocumentFromUrl(
6112
6231
  label: requestedLabel || file.label,
6113
6232
  source: "file",
6114
6233
  path: file.resolvedPath,
6234
+ resourceDir: requestedResourceDir || undefined,
6115
6235
  };
6116
6236
  }
6117
6237
  }
@@ -6122,6 +6242,7 @@ function resolveRequestedStudioDocumentFromUrl(
6122
6242
  label: requestedLabel || "last model response",
6123
6243
  source: "last-response",
6124
6244
  draftId: requestedDraftId || undefined,
6245
+ resourceDir: requestedResourceDir || undefined,
6125
6246
  };
6126
6247
  }
6127
6248
 
@@ -6131,6 +6252,7 @@ function resolveRequestedStudioDocumentFromUrl(
6131
6252
  label: requestedLabel || requestedSourceRaw || "blank",
6132
6253
  source: "blank",
6133
6254
  draftId: requestedDraftId || undefined,
6255
+ resourceDir: requestedResourceDir || fallback?.resourceDir || undefined,
6134
6256
  };
6135
6257
  }
6136
6258
 
@@ -6370,6 +6492,7 @@ function buildStudioHtml(
6370
6492
  const initialLabel = escapeHtmlForInline(initialDocument?.label ?? "blank");
6371
6493
  const initialPath = escapeHtmlForInline(initialDocument?.path ?? "");
6372
6494
  const initialDraftId = escapeHtmlForInline(initialDocument?.draftId ?? "");
6495
+ const initialResourceDir = escapeHtmlForInline(initialDocument?.resourceDir ?? "");
6373
6496
  const initialModel = escapeHtmlForInline(initialModelLabel ?? "none");
6374
6497
  const initialTerminal = escapeHtmlForInline(initialTerminalLabel ?? "unknown");
6375
6498
  const initialTerminalDetailAttr = escapeHtmlForInline(initialTerminalDetail ?? initialTerminalLabel ?? "unknown");
@@ -6439,7 +6562,7 @@ ${cssVarsBlock}
6439
6562
  </style>
6440
6563
  <link rel="stylesheet" href="${stylesheetHref}" />
6441
6564
  </head>
6442
- <body data-initial-source="${initialSource}" data-initial-label="${initialLabel}" data-initial-path="${initialPath}" data-initial-draft-id="${initialDraftId}" data-model-label="${initialModel}" data-terminal-label="${initialTerminal}" data-terminal-detail="${initialTerminalDetailAttr}" data-context-tokens="${initialContextTokens}" data-context-window="${initialContextWindow}" data-context-percent="${initialContextPercent}" data-studio-mode="${studioMode}">
6565
+ <body data-initial-source="${initialSource}" data-initial-label="${initialLabel}" data-initial-path="${initialPath}" data-initial-draft-id="${initialDraftId}" data-initial-resource-dir="${initialResourceDir}" data-model-label="${initialModel}" data-terminal-label="${initialTerminal}" data-terminal-detail="${initialTerminalDetailAttr}" data-context-tokens="${initialContextTokens}" data-context-window="${initialContextWindow}" data-context-percent="${initialContextPercent}" data-studio-mode="${studioMode}">
6443
6566
  <header>
6444
6567
  <h1><span class="app-logo" aria-hidden="true">π</span> Studio <span class="app-subtitle">${appSubtitle}</span></h1>
6445
6568
  <div class="controls">
@@ -6484,7 +6607,8 @@ ${cssVarsBlock}
6484
6607
  <div class="source-actions-row">
6485
6608
  <button id="sendRunBtn" type="button" title="Run editor text. While a direct run is active, this button becomes Stop. Cmd/Ctrl+Enter queues steering from the current editor text. Stop the active request with Esc.">Run editor text</button>
6486
6609
  <button id="queueSteerBtn" type="button" title="Queue steering is available while Run editor text is active." disabled>Queue steering</button>
6487
- <button id="copyDraftBtn" type="button">Copy editor text</button>
6610
+ <button id="copyDraftBtn" type="button" title="Copy the current editor text to the clipboard.">Copy text</button>
6611
+ <button id="openCompanionBtn" type="button" title="Open a detached copy of the current editor text in a new editor-only Studio tab.">Open new editor</button>
6488
6612
  <button id="sendEditorBtn" type="button">Send to pi editor</button>
6489
6613
  </div>
6490
6614
  <div class="source-actions-row">
@@ -7778,6 +7902,45 @@ export default function (pi: ExtensionAPI) {
7778
7902
  return;
7779
7903
  }
7780
7904
 
7905
+ if (msg.type === "open_editor_only_request") {
7906
+ if (!isValidRequestId(msg.requestId)) {
7907
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
7908
+ return;
7909
+ }
7910
+ if (!serverState) {
7911
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Studio server is not running." });
7912
+ return;
7913
+ }
7914
+ if (msg.content.length > PREVIEW_RENDER_MAX_CHARS) {
7915
+ sendToClient(client, {
7916
+ type: "error",
7917
+ requestId: msg.requestId,
7918
+ message: `Editor text is too large to copy into a companion view (${PREVIEW_RENDER_MAX_CHARS} character limit).`,
7919
+ });
7920
+ return;
7921
+ }
7922
+
7923
+ const resourceDir = resolveStudioCompanionResourceDir(msg.path, msg.resourceDir, studioCwd);
7924
+ const document: InitialStudioDocument = {
7925
+ text: msg.content,
7926
+ label: buildStudioCompanionLabel(msg.label),
7927
+ source: "blank",
7928
+ draftId: createStudioDraftId(),
7929
+ resourceDir,
7930
+ };
7931
+ const docId = storeTransientStudioDocument(document);
7932
+ const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId);
7933
+ const parsedUrl = new URL(url);
7934
+ sendToClient(client, {
7935
+ type: "editor_only_ready",
7936
+ requestId: msg.requestId,
7937
+ url,
7938
+ relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
7939
+ message: "Companion editor is ready with a detached copy of the current editor text.",
7940
+ });
7941
+ return;
7942
+ }
7943
+
7781
7944
  if (msg.type === "cancel_request") {
7782
7945
  if (!isValidRequestId(msg.requestId)) {
7783
7946
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
@@ -8812,6 +8975,27 @@ export default function (pi: ExtensionAPI) {
8812
8975
  return;
8813
8976
  }
8814
8977
 
8978
+ if (requestUrl.pathname === "/pdf-resource") {
8979
+ const token = requestUrl.searchParams.get("token") ?? "";
8980
+ if (token !== serverState.token) {
8981
+ respondText(res, 403, "Invalid or expired studio token. Re-run /studio.");
8982
+ return;
8983
+ }
8984
+
8985
+ try {
8986
+ const filePath = resolveStudioPdfResourcePath(
8987
+ requestUrl.searchParams.get("path") ?? "",
8988
+ requestUrl.searchParams.get("sourcePath") ?? undefined,
8989
+ requestUrl.searchParams.get("resourceDir") ?? undefined,
8990
+ studioCwd,
8991
+ );
8992
+ respondPdfFile(req, res, filePath);
8993
+ } catch (error) {
8994
+ respondText(res, 404, `PDF resource unavailable: ${error instanceof Error ? error.message : String(error)}`);
8995
+ }
8996
+ return;
8997
+ }
8998
+
8815
8999
  if (requestUrl.pathname !== "/") {
8816
9000
  respondText(res, 404, "Not found");
8817
9001
  return;
@@ -9009,9 +9193,14 @@ export default function (pi: ExtensionAPI) {
9009
9193
  syncStudioResponseHistory(entries);
9010
9194
  };
9011
9195
 
9012
- pi.on("session_start", async (_event, ctx) => {
9196
+ pi.on("session_start", async (event, ctx) => {
9197
+ const isSessionReplacement = event.reason === "new" || event.reason === "resume" || event.reason === "fork";
9013
9198
  pendingTurnPrompt = null;
9014
9199
  clearStudioDirectRunState();
9200
+ if (isSessionReplacement) {
9201
+ clearActiveRequest({ notify: "Session switched. Studio request state cleared.", level: "warning" });
9202
+ lastCommandCtx = null;
9203
+ }
9015
9204
  hydrateLatestAssistant(ctx.sessionManager.getBranch());
9016
9205
  clearCompactionState();
9017
9206
  agentBusy = false;
@@ -9028,26 +9217,6 @@ export default function (pi: ExtensionAPI) {
9028
9217
  broadcastResponseHistory();
9029
9218
  });
9030
9219
 
9031
- pi.on("session_switch", async (_event, ctx) => {
9032
- clearStudioDirectRunState();
9033
- clearActiveRequest({ notify: "Session switched. Studio request state cleared.", level: "warning" });
9034
- clearCompactionState();
9035
- pendingTurnPrompt = null;
9036
- lastCommandCtx = null;
9037
- hydrateLatestAssistant(ctx.sessionManager.getBranch());
9038
- agentBusy = false;
9039
- clearPendingStudioCompletion();
9040
- clearPreparedPdfExports();
9041
- refreshRuntimeMetadata({ cwd: ctx.cwd, model: ctx.model });
9042
- refreshContextUsage(ctx);
9043
- emitDebugEvent("session_switch", {
9044
- entryCount: ctx.sessionManager.getBranch().length,
9045
- modelLabel: currentModelLabel,
9046
- terminalSessionLabel,
9047
- });
9048
- setTerminalActivity("idle");
9049
- broadcastResponseHistory();
9050
- });
9051
9220
 
9052
9221
  pi.on("session_tree", async (_event, ctx) => {
9053
9222
  hydrateLatestAssistant(ctx.sessionManager.getBranch());
@@ -9329,6 +9498,7 @@ export default function (pi: ExtensionAPI) {
9329
9498
  clearStudioDirectRunState();
9330
9499
  clearPendingStudioCompletion();
9331
9500
  clearPreparedPdfExports();
9501
+ transientStudioDocuments.clear();
9332
9502
  clearCompactionState();
9333
9503
  clearStudioTrace();
9334
9504
  setTerminalActivity("idle");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.6.10",
3
+ "version": "0.7.1",
4
4
  "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, prompt/response history, and live Markdown/LaTeX/code preview",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -40,15 +40,15 @@
40
40
  ]
41
41
  },
42
42
  "peerDependencies": {
43
- "@mariozechner/pi-coding-agent": "*"
43
+ "@earendil-works/pi-coding-agent": "*"
44
44
  },
45
45
  "dependencies": {
46
46
  "ws": "^8.18.0"
47
47
  },
48
48
  "devDependencies": {
49
- "@mariozechner/pi-coding-agent": "^0.64.0",
50
49
  "@types/node": "^24.3.0",
51
50
  "@types/ws": "^8.18.1",
52
- "typescript": "^5.7.3"
51
+ "typescript": "^5.7.3",
52
+ "@earendil-works/pi-coding-agent": "^0.74.0"
53
53
  }
54
54
  }
@@ -0,0 +1,39 @@
1
+ import { realpathSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { extname, isAbsolute, relative, resolve } from "node:path";
4
+
5
+ export function expandStudioPdfResourcePath(input) {
6
+ const value = String(input || "").trim();
7
+ if (value === "~") return homedir();
8
+ if (value.startsWith("~/") || value.startsWith("~\\")) {
9
+ return resolve(homedir(), value.slice(2));
10
+ }
11
+ return value;
12
+ }
13
+
14
+ export function resolveStudioPdfResourceFile(pdfPath, baseDir) {
15
+ const rawPath = typeof pdfPath === "string" ? pdfPath.trim() : "";
16
+ if (!rawPath) throw new Error("Missing PDF path.");
17
+ if (/\0/.test(rawPath)) throw new Error("Invalid PDF path.");
18
+ if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
19
+ throw new Error("Only local PDF paths are supported.");
20
+ }
21
+
22
+ const rawBaseDir = typeof baseDir === "string" ? baseDir.trim() : "";
23
+ if (!rawBaseDir) throw new Error("Missing Studio resource directory.");
24
+
25
+ const expandedPath = expandStudioPdfResourcePath(rawPath);
26
+ const candidate = isAbsolute(expandedPath) ? expandedPath : resolve(rawBaseDir, expandedPath);
27
+ if (extname(candidate).toLowerCase() !== ".pdf") throw new Error("Only .pdf files can be embedded.");
28
+
29
+ const baseReal = realpathSync(rawBaseDir);
30
+ const candidateReal = realpathSync(candidate);
31
+ const rel = relative(baseReal, candidateReal);
32
+ if (rel.startsWith("..") || isAbsolute(rel)) {
33
+ throw new Error("PDF path must stay within the current Studio resource directory.");
34
+ }
35
+
36
+ const stat = statSync(candidateReal);
37
+ if (!stat.isFile()) throw new Error("PDF path does not refer to a file.");
38
+ return candidateReal;
39
+ }