pi-studio 0.8.2 → 0.8.4

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,15 +4,31 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.8.4] — 2026-05-14
8
+
9
+ ### Added
10
+ - Working view now renders image outputs from tools such as `read`, including bounded image previews in saved Working history.
11
+ - Rendered blockquotes now have hover/focus copy buttons that copy the quote text without Markdown `>` markers.
12
+
13
+ ## [0.8.3] — 2026-05-13
14
+
15
+ ### Changed
16
+ - Updated package, README, and UI wording to describe the feature as interactive HTML preview.
17
+
18
+ ### Fixed
19
+ - **Open new editor** now opens a blank companion editor when the current editor is empty.
20
+ - PDF export now preserves Pandoc YAML front matter while applying Studio Markdown transforms and lets Markdown documents with `header-includes` control their own LaTeX preamble, fixing exports that use YAML-defined commands such as `\firstpageletterhead`.
21
+ - Reduced scroll snap-back after using browser Find by preserving Studio pane scroll positions during pane activation.
22
+
7
23
  ## [0.8.2] — 2026-05-13
8
24
 
9
25
  ### Changed
10
- - Improved dark-theme contrast for Studio dropdown arrows and the HTML artifact zoom percentage by using the stronger Studio info text colour token.
26
+ - Improved dark-theme contrast for Studio dropdown arrows and the interactive HTML preview zoom percentage by using the stronger Studio info text colour token.
11
27
 
12
28
  ## [0.8.1] — 2026-05-13
13
29
 
14
30
  ### Added
15
- - Added first-cut HTML artifact preview for straight, unfenced HTML in Studio preview panes, rendered in a sandboxed iframe with inline scripts enabled, network requests blocked by CSP, fit/capped sizing, and toolbar zoom controls; HTML export preserves authored HTML artifacts instead of converting them through Markdown.
31
+ - Added first-cut interactive HTML preview for straight, unfenced HTML in Studio preview panes, rendered in a sandboxed iframe with inline scripts enabled, network requests blocked by CSP, fit/capped sizing, and toolbar zoom controls; HTML export preserves authored HTML previews instead of converting them through Markdown.
16
32
 
17
33
  ## [0.8.0] — 2026-05-12
18
34
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-studio
2
2
 
3
- Extension for [pi](https://pi.dev) that opens a local two-pane browser workspace for working with prompts, responses, live working details, Markdown and LaTeX documents, code files, and other common text-based files side by side. Annotate responses and files, add local comments, write, edit, and run prompts, browse prompt and response history, request critiques, and use live preview for code, Markdown, and LaTeX.
3
+ Extension for [pi](https://pi.dev) that opens a local two-pane browser workspace for working with prompts, responses, live working details, Markdown and LaTeX documents, interactive HTML previews, code files, and other common text-based files side by side. Annotate responses and files, add local comments, write, edit, and run prompts, browse prompt and response history, request critiques, and use live preview for code, Markdown, LaTeX, and interactive HTML.
4
4
 
5
5
  ## Quick demo
6
6
 
@@ -21,7 +21,7 @@ Extension for [pi](https://pi.dev) that opens a local two-pane browser workspace
21
21
  - Opens a two-pane browser workspace: **Editor** (left) + **Response/Working/Editor Preview** (right)
22
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
- - 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; when cycling response history, Working follows saved working details for the selected response when available
24
+ - Includes a live **Working** view for following current model/tool activity, with `All` / `Thinking` / `Tools` filters, image previews for image-producing tool outputs, plus **Load visible into editor** and **Copy visible** actions; when cycling response history, Working follows saved working details for the selected response when available
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
26
26
  - 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
27
27
  - 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, and code/text/diff previews, alongside optional inline `[an: ...]` toggles when you want comments reflected in the document text
@@ -34,11 +34,11 @@ Extension for [pi](https://pi.dev) that opens a local two-pane browser workspace
34
34
  - shows/hides annotation markers in preview
35
35
  - strips markers before send (optional)
36
36
  - saves `.annotated.md`
37
- - Renders Markdown/LaTeX/code previews (math + Mermaid), theme-synced with pi
38
- - Renders straight, unfenced HTML artifacts in preview via a sandboxed browser iframe with zoom controls, while fenced `html` blocks remain source code
37
+ - Renders Markdown/LaTeX/code previews (math + Mermaid), theme-synced with pi, with copy buttons for code blocks and blockquotes
38
+ - Renders straight, unfenced interactive HTML in preview via a sandboxed browser iframe with zoom controls, while fenced `html` blocks remain source code
39
39
  - Embeds local PDFs in Studio Markdown previews via explicit `studio-pdf` fenced blocks
40
40
  - Ships optional `pi-studio-dark` and `pi-studio-light` themes tuned for Studio's browser workspace
41
- - Exports right-pane preview as PDF (pandoc + LaTeX) or standalone HTML, preserving authored HTML artifacts as HTML
41
+ - Exports right-pane preview as PDF (pandoc + LaTeX) or standalone HTML, preserving authored HTML previews as HTML
42
42
  - Exports local files headlessly via `/studio-pdf <path>` to `<name>.studio.pdf` or `/studio-html <path>` to `<name>.studio.html`; without a path, those commands export the last model response to a timestamped file
43
43
  - Shows model/session/context usage in the footer, plus a compact-context action
44
44
 
@@ -214,6 +214,7 @@
214
214
  const traceExpandedOutputs = new Set();
215
215
  const TRACE_OUTPUT_PREVIEW_MAX_LINES = 50;
216
216
  const TRACE_OUTPUT_PREVIEW_MAX_CHARS = 8000;
217
+ const TRACE_IMAGE_SAFE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
217
218
  let studioRunChainActive = false;
218
219
  let queuedSteeringCount = 0;
219
220
  let agentBusyFromServer = false;
@@ -287,6 +288,37 @@
287
288
  : "pending";
288
289
  }
289
290
 
291
+ function normalizeTraceImageMimeType(value) {
292
+ return typeof value === "string" ? value.trim().toLowerCase() : "";
293
+ }
294
+
295
+ function isTraceImageSafeMimeType(mimeType) {
296
+ return TRACE_IMAGE_SAFE_MIME_TYPES.has(normalizeTraceImageMimeType(mimeType));
297
+ }
298
+
299
+ function normalizeTraceImage(image, fallbackIndex) {
300
+ if (!image || typeof image !== "object") return null;
301
+ const mimeType = normalizeTraceImageMimeType(image.mimeType);
302
+ if (!isTraceImageSafeMimeType(mimeType)) return null;
303
+ const data = typeof image.data === "string" ? image.data.replace(/\s+/g, "") : "";
304
+ if (!data || !/^[A-Za-z0-9+/]*={0,2}$/.test(data)) return null;
305
+ const byteLength = parseFiniteNumber(image.byteLength);
306
+ return {
307
+ id: typeof image.id === "string" && image.id.trim() ? image.id.trim() : ("trace-image-" + fallbackIndex),
308
+ mimeType,
309
+ data,
310
+ byteLength: byteLength == null ? estimateTraceImageByteLength(data) : byteLength,
311
+ label: parseNonEmptyString(image.label),
312
+ };
313
+ }
314
+
315
+ function estimateTraceImageByteLength(data) {
316
+ const compact = String(data || "").replace(/\s+/g, "");
317
+ if (!compact) return null;
318
+ const padding = compact.endsWith("==") ? 2 : (compact.endsWith("=") ? 1 : 0);
319
+ return Math.max(0, Math.floor((compact.length * 3) / 4) - padding);
320
+ }
321
+
290
322
  function normalizeTraceEntry(entry, fallbackIndex) {
291
323
  if (!entry || typeof entry !== "object") return null;
292
324
  if (entry.type === "assistant") {
@@ -310,6 +342,9 @@
310
342
  label: parseNonEmptyString(entry.label),
311
343
  argsSummary: parseNonEmptyString(entry.argsSummary),
312
344
  output: typeof entry.output === "string" ? entry.output : "",
345
+ images: Array.isArray(entry.images)
346
+ ? entry.images.map((image, imageIndex) => normalizeTraceImage(image, imageIndex)).filter(Boolean)
347
+ : [],
313
348
  startedAt: parseFiniteNumber(entry.startedAt) || Date.now(),
314
349
  updatedAt: parseFiniteNumber(entry.updatedAt) || Date.now(),
315
350
  status: normalizeTraceEntryStatus(entry.status),
@@ -542,6 +577,22 @@
542
577
  });
543
578
  }
544
579
 
580
+ function formatTraceImageSize(byteLength) {
581
+ if (typeof byteLength !== "number" || !Number.isFinite(byteLength) || byteLength < 0) return "unknown size";
582
+ if (byteLength < 1024) return formatNumber(byteLength) + " B";
583
+ if (byteLength < 1024 * 1024) return (byteLength / 1024).toFixed(byteLength >= 100 * 1024 ? 0 : 1).replace(/\.0$/, "") + " KB";
584
+ return (byteLength / (1024 * 1024)).toFixed(byteLength >= 100 * 1024 * 1024 ? 0 : 1).replace(/\.0$/, "") + " MB";
585
+ }
586
+
587
+ function describeTraceImageForText(image) {
588
+ if (!image || typeof image !== "object") return "";
589
+ const parts = [];
590
+ if (image.label) parts.push(String(image.label));
591
+ parts.push(String(image.mimeType || "image"));
592
+ parts.push(formatTraceImageSize(image.byteLength));
593
+ return parts.filter(Boolean).join(" — ");
594
+ }
595
+
545
596
  function buildVisibleWorkingText(filterOverride) {
546
597
  const filter = normalizeTraceFilter(filterOverride || traceFilter);
547
598
  const entries = getTraceEntriesForFilter(filter);
@@ -576,6 +627,12 @@
576
627
  if (String(entry.output || "").trim()) {
577
628
  parts.push("Output:\n" + String(entry.output || "").trim());
578
629
  }
630
+ const imageSummaries = Array.isArray(entry.images)
631
+ ? entry.images.map(describeTraceImageForText).filter(Boolean)
632
+ : [];
633
+ if (imageSummaries.length) {
634
+ parts.push("Images:\n" + imageSummaries.map((summary) => "- " + summary).join("\n"));
635
+ }
579
636
  return parts.join("\n\n").trim();
580
637
  }).filter(Boolean).join("\n\n---\n\n");
581
638
  }
@@ -1954,6 +2011,51 @@
1954
2011
  updatePaneFocusButtons();
1955
2012
  }
1956
2013
 
2014
+ function snapshotStudioScrollablePositions() {
2015
+ return [sourceTextEl, sourcePreviewEl, critiqueViewEl]
2016
+ .filter((el) => el && typeof el.scrollTop === "number" && typeof el.scrollLeft === "number")
2017
+ .map((el) => ({ el, top: el.scrollTop, left: el.scrollLeft }));
2018
+ }
2019
+
2020
+ function restoreStudioScrollablePositions(snapshot) {
2021
+ if (!Array.isArray(snapshot)) return;
2022
+ snapshot.forEach((entry) => {
2023
+ const el = entry && entry.el;
2024
+ if (!el || !el.isConnected) return;
2025
+ if (typeof entry.top === "number") el.scrollTop = entry.top;
2026
+ if (typeof entry.left === "number") el.scrollLeft = entry.left;
2027
+ });
2028
+ syncEditorHighlightScroll();
2029
+ }
2030
+
2031
+ function scheduleStudioScrollablePositionRestore(snapshot) {
2032
+ if (!Array.isArray(snapshot) || snapshot.length === 0) return;
2033
+ const schedule = typeof window.requestAnimationFrame === "function"
2034
+ ? window.requestAnimationFrame.bind(window)
2035
+ : (cb) => window.setTimeout(cb, 16);
2036
+ window.setTimeout(() => restoreStudioScrollablePositions(snapshot), 0);
2037
+ schedule(() => {
2038
+ restoreStudioScrollablePositions(snapshot);
2039
+ schedule(() => restoreStudioScrollablePositions(snapshot));
2040
+ });
2041
+ }
2042
+
2043
+ function shouldPreserveScrollForPaneActivationEvent(event) {
2044
+ const target = event && event.target;
2045
+ if (!(target instanceof Element)) return true;
2046
+ if (target.closest("button, select, input, a, [role='button'], .studio-copy-block-btn, .preview-comment-add, .preview-comment-jump")) {
2047
+ return false;
2048
+ }
2049
+ return true;
2050
+ }
2051
+
2052
+ function activatePaneFromInteraction(nextPane, event) {
2053
+ const shouldPreserveScroll = shouldPreserveScrollForPaneActivationEvent(event);
2054
+ const snapshot = shouldPreserveScroll ? snapshotStudioScrollablePositions() : [];
2055
+ setActivePane(nextPane);
2056
+ if (shouldPreserveScroll) scheduleStudioScrollablePositionRestore(snapshot);
2057
+ }
2058
+
1957
2059
  function setActivePane(nextPane) {
1958
2060
  activePane = nextPane === "right" ? "right" : "left";
1959
2061
 
@@ -2711,7 +2813,7 @@
2711
2813
  record.shell.classList.toggle("is-height-capped", capped);
2712
2814
  }
2713
2815
  if (record.detail) {
2714
- record.detail.textContent = "HTML artifact";
2816
+ record.detail.textContent = "HTML preview";
2715
2817
  }
2716
2818
  }
2717
2819
 
@@ -2719,7 +2821,7 @@
2719
2821
 
2720
2822
  function renderHtmlArtifactPreview(targetEl, html, pane, options) {
2721
2823
  if (!targetEl) return;
2722
- const title = options && options.title ? String(options.title) : "HTML artifact preview";
2824
+ const title = options && options.title ? String(options.title) : "HTML preview";
2723
2825
  const previewId = "html_artifact_" + Date.now().toString(36) + "_" + Math.random().toString(36).slice(2, 10);
2724
2826
  pruneDisconnectedHtmlArtifactFrames();
2725
2827
  clearPreviewJumpHighlight(targetEl);
@@ -2736,7 +2838,7 @@
2736
2838
  label.textContent = title;
2737
2839
  const detail = document.createElement("span");
2738
2840
  detail.className = "studio-html-artifact-detail";
2739
- detail.textContent = "HTML artifact";
2841
+ detail.textContent = "HTML preview";
2740
2842
 
2741
2843
  const tools = document.createElement("span");
2742
2844
  tools.className = "studio-html-artifact-tools";
@@ -2783,10 +2885,10 @@
2783
2885
  });
2784
2886
  return button;
2785
2887
  };
2786
- const zoomOutBtn = makeZoomButton("−", "Zoom out HTML artifact", () => setArtifactZoom(artifactZoom - HTML_ARTIFACT_ZOOM_STEP));
2787
- const zoomResetBtn = makeZoomButton("100%", "Reset HTML artifact zoom", () => setArtifactZoom(1));
2888
+ const zoomOutBtn = makeZoomButton("−", "Zoom out HTML preview", () => setArtifactZoom(artifactZoom - HTML_ARTIFACT_ZOOM_STEP));
2889
+ const zoomResetBtn = makeZoomButton("100%", "Reset HTML preview zoom", () => setArtifactZoom(1));
2788
2890
  zoomResetBtn.classList.add("studio-html-artifact-zoom-reset");
2789
- const zoomInBtn = makeZoomButton("+", "Zoom in HTML artifact", () => setArtifactZoom(artifactZoom + HTML_ARTIFACT_ZOOM_STEP));
2891
+ const zoomInBtn = makeZoomButton("+", "Zoom in HTML preview", () => setArtifactZoom(artifactZoom + HTML_ARTIFACT_ZOOM_STEP));
2790
2892
  zoomControls.appendChild(zoomOutBtn);
2791
2893
  zoomControls.appendChild(zoomResetBtn);
2792
2894
  zoomControls.appendChild(zoomInBtn);
@@ -3882,7 +3984,7 @@
3882
3984
 
3883
3985
  const htmlArtifactSource = getRightPaneHtmlArtifactSource();
3884
3986
  if (htmlArtifactSource) {
3885
- setStatus("PDF export does not support HTML artifacts yet. Export as HTML or use the browser print dialog inside the artifact.", "warning");
3987
+ setStatus("PDF export does not support interactive HTML previews yet. Export as HTML or use the browser print dialog inside the preview.", "warning");
3886
3988
  return;
3887
3989
  }
3888
3990
 
@@ -4222,6 +4324,78 @@
4222
4324
  return String(text || "").replace(/\r\n/g, "\n").replace(/\u200b/g, "");
4223
4325
  }
4224
4326
 
4327
+ function getCopyableBlockquoteText(blockEl) {
4328
+ const clone = blockEl && typeof blockEl.cloneNode === "function" ? blockEl.cloneNode(true) : null;
4329
+ const sourceEl = clone && typeof clone.querySelectorAll === "function" ? clone : blockEl;
4330
+ if (!sourceEl) return "";
4331
+ if (typeof sourceEl.querySelectorAll === "function") {
4332
+ Array.from(sourceEl.querySelectorAll(".studio-copy-block-btn")).forEach((buttonEl) => {
4333
+ if (buttonEl && buttonEl.parentNode) buttonEl.parentNode.removeChild(buttonEl);
4334
+ });
4335
+ }
4336
+
4337
+ const blockTags = new Set(["ADDRESS", "ARTICLE", "ASIDE", "BLOCKQUOTE", "DIV", "FIGCAPTION", "FIGURE", "FOOTER", "H1", "H2", "H3", "H4", "H5", "H6", "HEADER", "LI", "OL", "P", "PRE", "SECTION", "TABLE", "TBODY", "TD", "TH", "THEAD", "TR", "UL"]);
4338
+ const isElementBlock = (node) => node && node.nodeType === 1 && blockTags.has(String(node.tagName || "").toUpperCase());
4339
+
4340
+ const collectInlineText = (node) => {
4341
+ if (!node) return "";
4342
+ if (node.nodeType === Node.TEXT_NODE) return node.nodeValue || "";
4343
+ if (node.nodeType !== Node.ELEMENT_NODE) return "";
4344
+ const tag = String(node.tagName || "").toUpperCase();
4345
+ if (tag === "SCRIPT" || tag === "STYLE" || tag === "BUTTON") return "";
4346
+ if (tag === "BR") return "\n";
4347
+ const childText = Array.from(node.childNodes || []).map(collectInlineText).join("");
4348
+ return isElementBlock(node) ? childText.trim() : childText;
4349
+ };
4350
+
4351
+ const collectBlocks = (node) => {
4352
+ if (!node) return [];
4353
+ const parts = [];
4354
+ let inlineBuffer = "";
4355
+ const flushInline = () => {
4356
+ const text = inlineBuffer.replace(/[ \t]+\n/g, "\n").trim();
4357
+ if (text) parts.push(text);
4358
+ inlineBuffer = "";
4359
+ };
4360
+
4361
+ Array.from(node.childNodes || []).forEach((child) => {
4362
+ if (child.nodeType === Node.TEXT_NODE) {
4363
+ inlineBuffer += child.nodeValue || "";
4364
+ return;
4365
+ }
4366
+ if (child.nodeType !== Node.ELEMENT_NODE) return;
4367
+ const tag = String(child.tagName || "").toUpperCase();
4368
+ if (tag === "SCRIPT" || tag === "STYLE" || tag === "BUTTON") return;
4369
+ if (tag === "BR") {
4370
+ inlineBuffer += "\n";
4371
+ return;
4372
+ }
4373
+ if (isElementBlock(child)) {
4374
+ flushInline();
4375
+ if (tag === "UL" || tag === "OL") {
4376
+ Array.from(child.children || []).forEach((item, itemIndex) => {
4377
+ if (!item || String(item.tagName || "").toUpperCase() !== "LI") return;
4378
+ const prefix = tag === "OL" ? (String(itemIndex + 1) + ". ") : "- ";
4379
+ const itemText = collectInlineText(item).trim();
4380
+ if (itemText) parts.push(prefix + itemText);
4381
+ });
4382
+ return;
4383
+ }
4384
+ const blockText = tag === "BLOCKQUOTE"
4385
+ ? collectBlocks(child).join("\n\n").trim()
4386
+ : collectInlineText(child).trim();
4387
+ if (blockText) parts.push(blockText);
4388
+ return;
4389
+ }
4390
+ inlineBuffer += collectInlineText(child);
4391
+ });
4392
+ flushInline();
4393
+ return parts;
4394
+ };
4395
+
4396
+ return normalizeCopyableBlockText(collectBlocks(sourceEl).join("\n\n")).trim();
4397
+ }
4398
+
4225
4399
  function getCopyablePreviewBlockText(blockEl) {
4226
4400
  if (!blockEl || typeof blockEl.querySelectorAll !== "function") return "";
4227
4401
  if (blockEl.classList && blockEl.classList.contains("preview-code-lines")) {
@@ -4232,6 +4406,10 @@
4232
4406
  );
4233
4407
  }
4234
4408
 
4409
+ if (blockEl.matches && blockEl.matches("blockquote")) {
4410
+ return getCopyableBlockquoteText(blockEl);
4411
+ }
4412
+
4235
4413
  const codeEl = typeof blockEl.querySelector === "function"
4236
4414
  ? blockEl.querySelector("pre code, code")
4237
4415
  : null;
@@ -4289,7 +4467,7 @@
4289
4467
 
4290
4468
  function decorateCopyablePreviewBlocks(targetEl) {
4291
4469
  if (!targetEl || typeof targetEl.querySelectorAll !== "function") return;
4292
- const blocks = Array.from(targetEl.querySelectorAll("div.sourceCode, pre, .preview-code-lines"));
4470
+ const blocks = Array.from(targetEl.querySelectorAll("div.sourceCode, pre, .preview-code-lines, blockquote"));
4293
4471
  blocks.forEach((blockEl) => {
4294
4472
  if (!blockEl || !(blockEl instanceof Element)) return;
4295
4473
  if (blockEl.dataset && blockEl.dataset.studioCopyDecorated === "1") return;
@@ -4400,7 +4578,7 @@
4400
4578
  if (editorView !== "preview") return;
4401
4579
  const text = prepareEditorTextForPreview(sourceTextEl.value || "");
4402
4580
  if (isHtmlArtifactPreviewText(text, editorLanguage)) {
4403
- renderHtmlArtifactPreview(sourcePreviewEl, text, "source", { title: "Editor HTML artifact preview" });
4581
+ renderHtmlArtifactPreview(sourcePreviewEl, text, "source", { title: "Editor HTML preview" });
4404
4582
  return;
4405
4583
  }
4406
4584
  if (supportsCodePreviewCommentsForCurrentEditor()) {
@@ -4527,6 +4705,23 @@
4527
4705
  + "</div>";
4528
4706
  }
4529
4707
 
4708
+ function renderTraceImages(images) {
4709
+ const normalizedImages = Array.isArray(images)
4710
+ ? images.map((image, index) => normalizeTraceImage(image, index)).filter(Boolean)
4711
+ : [];
4712
+ if (!normalizedImages.length) return "";
4713
+ const cards = normalizedImages.map((image) => {
4714
+ const src = "data:" + image.mimeType + ";base64," + image.data;
4715
+ const caption = describeTraceImageForText(image);
4716
+ const alt = image.label || ("Working output image: " + image.mimeType);
4717
+ return "<figure class='trace-image-card'>"
4718
+ + "<img src='" + escapeHtml(src) + "' alt='" + escapeHtml(alt) + "' loading='lazy' decoding='async' />"
4719
+ + "<figcaption class='trace-image-caption'>" + escapeHtml(caption) + "</figcaption>"
4720
+ + "</figure>";
4721
+ }).join("");
4722
+ return "<div class='trace-image-gallery'>" + cards + "</div>";
4723
+ }
4724
+
4530
4725
  function buildTracePanelHtml() {
4531
4726
  const state = traceState || createEmptyTraceState();
4532
4727
  const filter = normalizeTraceFilter(traceFilter);
@@ -4620,8 +4815,12 @@
4620
4815
  const argsSummary = entry.argsSummary
4621
4816
  ? "<div class='trace-section'><div class='trace-section-label'>Input</div>" + renderTraceOutput(entry.argsSummary, entry.id + ":input") + "</div>"
4622
4817
  : "";
4623
- const output = entry.output
4624
- ? "<div class='trace-section'><div class='trace-section-label'>Output</div>" + renderTraceOutput(entry.output, entry.id + ":output") + "</div>"
4818
+ const imageOutput = renderTraceImages(entry.images);
4819
+ const outputPieces = [];
4820
+ if (entry.output) outputPieces.push(renderTraceOutput(entry.output, entry.id + ":output"));
4821
+ if (imageOutput) outputPieces.push(imageOutput);
4822
+ const output = outputPieces.length
4823
+ ? "<div class='trace-section'><div class='trace-section-label'>Output</div>" + outputPieces.join("") + "</div>"
4625
4824
  : "<div class='trace-empty-inline'>No output yet.</div>";
4626
4825
  const toolStatusLabel = entry.isError
4627
4826
  ? "Error"
@@ -4672,7 +4871,7 @@
4672
4871
  return;
4673
4872
  }
4674
4873
  if (isHtmlArtifactPreviewText(editorText, editorLanguage)) {
4675
- renderHtmlArtifactPreview(critiqueViewEl, editorText, "response", { title: "Editor HTML artifact preview" });
4874
+ renderHtmlArtifactPreview(critiqueViewEl, editorText, "response", { title: "Editor HTML preview" });
4676
4875
  return;
4677
4876
  }
4678
4877
  if (supportsCodePreviewCommentsForCurrentEditor()) {
@@ -4696,7 +4895,7 @@
4696
4895
 
4697
4896
  if (rightView === "preview") {
4698
4897
  if (isHtmlArtifactPreviewText(markdown, "")) {
4699
- renderHtmlArtifactPreview(critiqueViewEl, markdown, "response", { title: "Response HTML artifact preview" });
4898
+ renderHtmlArtifactPreview(critiqueViewEl, markdown, "response", { title: "Response HTML preview" });
4700
4899
  return;
4701
4900
  }
4702
4901
  const nonce = ++responsePreviewRenderNonce;
@@ -4777,7 +4976,7 @@
4777
4976
  } else if (!canExportPreview) {
4778
4977
  exportPdfBtn.title = "Nothing to export yet.";
4779
4978
  } else if (isHtmlArtifactPreview) {
4780
- exportPdfBtn.title = "This is an HTML artifact preview. Export as HTML; PDF export is not available yet.";
4979
+ exportPdfBtn.title = "This is an interactive HTML preview. Export as HTML; PDF export is not available yet.";
4781
4980
  } else {
4782
4981
  exportPdfBtn.title = "Choose PDF or HTML and export the current right-pane preview.";
4783
4982
  }
@@ -4785,18 +4984,18 @@
4785
4984
  if (exportPreviewPdfBtn) {
4786
4985
  exportPreviewPdfBtn.disabled = uiBusy || previewExportInProgress || !canExportPreview || isHtmlArtifactPreview;
4787
4986
  exportPreviewPdfBtn.title = isHtmlArtifactPreview
4788
- ? "HTML artifact PDF export is not available yet."
4987
+ ? "Interactive HTML preview PDF export is not available yet."
4789
4988
  : "Export the current right-pane preview as PDF.";
4790
4989
  }
4791
4990
  if (exportPreviewHtmlBtn) {
4792
4991
  exportPreviewHtmlBtn.disabled = uiBusy || previewExportInProgress || !canExportPreview;
4793
4992
  exportPreviewHtmlBtn.title = isHtmlArtifactPreview
4794
- ? "Export the authored HTML artifact."
4993
+ ? "Export the authored HTML preview."
4795
4994
  : "Export the current right-pane preview as standalone HTML.";
4796
4995
  }
4797
4996
  if (exportPreviewControlsEl) {
4798
4997
  exportPreviewControlsEl.title = canExportPreview
4799
- ? (isHtmlArtifactPreview ? "Export this HTML artifact." : "Choose a format and export the current right-pane preview.")
4998
+ ? (isHtmlArtifactPreview ? "Export this HTML preview." : "Choose a format and export the current right-pane preview.")
4800
4999
  : "Switch right pane to a non-empty preview before exporting.";
4801
5000
  }
4802
5001
  if (!canExportPreview || previewExportInProgress) {
@@ -11114,9 +11313,12 @@
11114
11313
  setWsState("Ready");
11115
11314
  const targetUrl = resolveCompanionEditorTargetUrl(message);
11116
11315
  const opened = navigatePendingCompanionWindow(responseRequestId, targetUrl);
11316
+ const readyMessage = typeof message.message === "string" && message.message.trim()
11317
+ ? message.message.trim()
11318
+ : "Opened companion editor with a detached copy of the current editor text.";
11117
11319
  setStatus(
11118
11320
  opened
11119
- ? "Opened companion editor with a detached copy of the current editor text."
11321
+ ? readyMessage
11120
11322
  : (targetUrl ? "Companion editor ready: " + targetUrl : "Companion editor is ready, but Studio did not receive a URL."),
11121
11323
  opened ? "success" : "warning",
11122
11324
  );
@@ -11578,13 +11780,13 @@
11578
11780
  }
11579
11781
 
11580
11782
  if (leftPaneEl) {
11581
- leftPaneEl.addEventListener("mousedown", () => setActivePane("left"));
11582
- leftPaneEl.addEventListener("focusin", () => setActivePane("left"));
11783
+ leftPaneEl.addEventListener("mousedown", (event) => activatePaneFromInteraction("left", event));
11784
+ leftPaneEl.addEventListener("focusin", (event) => activatePaneFromInteraction("left", event));
11583
11785
  }
11584
11786
 
11585
11787
  if (rightPaneEl) {
11586
- rightPaneEl.addEventListener("mousedown", () => setActivePane("right"));
11587
- rightPaneEl.addEventListener("focusin", () => setActivePane("right"));
11788
+ rightPaneEl.addEventListener("mousedown", (event) => activatePaneFromInteraction("right", event));
11789
+ rightPaneEl.addEventListener("focusin", (event) => activatePaneFromInteraction("right", event));
11588
11790
  }
11589
11791
 
11590
11792
  if (leftFocusBtn) {
@@ -12074,10 +12276,6 @@
12074
12276
  if (openCompanionBtn) {
12075
12277
  openCompanionBtn.addEventListener("click", () => {
12076
12278
  const content = sourceTextEl.value;
12077
- if (!content.trim()) {
12078
- setStatus("Editor is empty. Nothing to copy into a companion view.", "warning");
12079
- return;
12080
- }
12081
12279
 
12082
12280
  const requestId = beginUiAction("open_editor_only");
12083
12281
  if (!requestId) return;
package/client/studio.css CHANGED
@@ -1232,6 +1232,10 @@
1232
1232
  position: relative;
1233
1233
  }
1234
1234
 
1235
+ .rendered-markdown blockquote.studio-copyable-block {
1236
+ padding-right: 3.6rem;
1237
+ }
1238
+
1235
1239
  .rendered-markdown .studio-copy-block-btn {
1236
1240
  position: absolute;
1237
1241
  top: 8px;
@@ -2088,6 +2092,48 @@
2088
2092
  background: var(--panel);
2089
2093
  }
2090
2094
 
2095
+ .trace-image-gallery {
2096
+ display: grid;
2097
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr));
2098
+ gap: 8px;
2099
+ padding: 8px;
2100
+ border: 1px solid var(--panel-border);
2101
+ border-radius: 8px;
2102
+ background: var(--panel);
2103
+ }
2104
+
2105
+ .trace-output-wrap + .trace-image-gallery,
2106
+ .trace-output + .trace-image-gallery {
2107
+ margin-top: 4px;
2108
+ }
2109
+
2110
+ .trace-image-card {
2111
+ display: flex;
2112
+ flex-direction: column;
2113
+ gap: 5px;
2114
+ min-width: 0;
2115
+ margin: 0;
2116
+ padding: 7px;
2117
+ border: 1px solid var(--border-subtle);
2118
+ border-radius: 7px;
2119
+ background: var(--panel-2);
2120
+ }
2121
+
2122
+ .trace-image-card img {
2123
+ display: block;
2124
+ width: 100%;
2125
+ max-height: 520px;
2126
+ object-fit: contain;
2127
+ border-radius: 5px;
2128
+ background: var(--panel);
2129
+ }
2130
+
2131
+ .trace-image-caption {
2132
+ color: var(--muted);
2133
+ font-size: 11px;
2134
+ overflow-wrap: anywhere;
2135
+ }
2136
+
2091
2137
  .trace-output-truncation {
2092
2138
  display: flex;
2093
2139
  align-items: center;
package/index.ts CHANGED
@@ -196,6 +196,14 @@ interface StudioTraceAssistantEntry {
196
196
  stopReason: string | null;
197
197
  }
198
198
 
199
+ interface StudioTraceImage {
200
+ id: string;
201
+ mimeType: string;
202
+ data: string;
203
+ byteLength: number | null;
204
+ label: string | null;
205
+ }
206
+
199
207
  interface StudioTraceToolEntry {
200
208
  id: string;
201
209
  type: "tool";
@@ -204,6 +212,7 @@ interface StudioTraceToolEntry {
204
212
  label: string | null;
205
213
  argsSummary: string | null;
206
214
  output: string;
215
+ images: StudioTraceImage[];
207
216
  startedAt: number;
208
217
  updatedAt: number;
209
218
  status: StudioTraceEntryStatus;
@@ -345,6 +354,11 @@ const MAX_PREPARED_PDF_EXPORTS = 8;
345
354
  const MAX_PREPARED_HTML_EXPORTS = 8;
346
355
  const STUDIO_TRACE_SNAPSHOT_MAX_ENTRIES = 80;
347
356
  const STUDIO_TRACE_SNAPSHOT_MAX_FIELD_CHARS = 20_000;
357
+ const STUDIO_TRACE_IMAGE_MAX_COUNT = 8;
358
+ const STUDIO_TRACE_IMAGE_MAX_BASE64_CHARS = 2_500_000;
359
+ const STUDIO_TRACE_SNAPSHOT_MAX_IMAGES = 12;
360
+ const STUDIO_TRACE_SNAPSHOT_MAX_IMAGE_BASE64_CHARS = 6_000_000;
361
+ const STUDIO_TRACE_IMAGE_SAFE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
348
362
  const MAX_STUDIO_TRACE_SNAPSHOTS = RESPONSE_HISTORY_LIMIT;
349
363
  const TRANSIENT_STUDIO_DOCUMENT_TTL_MS = 30 * 60 * 1000;
350
364
  const MAX_TRANSIENT_STUDIO_DOCUMENTS = 16;
@@ -3457,6 +3471,49 @@ function normalizeStudioMarkdownFencedBlocks(markdown: string): string {
3457
3471
  return out.join("\n");
3458
3472
  }
3459
3473
 
3474
+ interface StudioYamlFrontMatterSplit {
3475
+ frontMatter: string;
3476
+ body: string;
3477
+ }
3478
+
3479
+ function splitStudioYamlFrontMatter(markdown: string): StudioYamlFrontMatterSplit | null {
3480
+ const source = String(markdown ?? "");
3481
+ const match = source.match(/^(\uFEFF?---[ \t]*(?:\r?\n)[\s\S]*?(?:\r?\n)---[ \t]*(?:\r?\n|$))([\s\S]*)$/);
3482
+ if (!match) return null;
3483
+ return {
3484
+ frontMatter: match[1] ?? "",
3485
+ body: match[2] ?? "",
3486
+ };
3487
+ }
3488
+
3489
+ function mapStudioMarkdownBodyPreservingYamlFrontMatter(markdown: string, transformBody: (body: string) => string): string {
3490
+ const source = String(markdown ?? "");
3491
+ const split = splitStudioYamlFrontMatter(source);
3492
+ if (!split) return transformBody(source);
3493
+ return `${split.frontMatter}${transformBody(split.body)}`;
3494
+ }
3495
+
3496
+ function stripStudioMarkdownHtmlCommentsPreservingYamlFrontMatter(markdown: string): string {
3497
+ return mapStudioMarkdownBodyPreservingYamlFrontMatter(markdown, (body) => stripStudioMarkdownHtmlComments(body));
3498
+ }
3499
+
3500
+ function hasStudioYamlHeaderIncludes(markdown: string): boolean {
3501
+ const split = splitStudioYamlFrontMatter(markdown);
3502
+ if (!split) return false;
3503
+ return /^\s*header-includes\s*:/im.test(split.frontMatter);
3504
+ }
3505
+
3506
+ function prepareStudioMarkdownForPandoc(markdown: string, options?: { preserveLiteralLatexCommands?: boolean }): string {
3507
+ const shouldPreserveLiteralLatexCommands = options?.preserveLiteralLatexCommands !== false;
3508
+ return mapStudioMarkdownBodyPreservingYamlFrontMatter(markdown, (body) => {
3509
+ const normalizedMath = normalizeMathDelimiters(body);
3510
+ const latexReady = shouldPreserveLiteralLatexCommands
3511
+ ? preserveLiteralLatexCommandsInMarkdown(normalizedMath)
3512
+ : normalizedMath;
3513
+ return normalizeObsidianImages(latexReady);
3514
+ });
3515
+ }
3516
+
3460
3517
  function hasStudioMarkdownDiffFence(markdown: string): boolean {
3461
3518
  const lines = String(markdown ?? "").replace(/\r\n/g, "\n").split("\n");
3462
3519
 
@@ -4300,8 +4357,10 @@ function prepareStudioPdfMarkdown(markdown: string, isLatex?: boolean, editorLan
4300
4357
  const annotationReadySource = !effectiveEditorLanguage || effectiveEditorLanguage === "markdown" || effectiveEditorLanguage === "latex"
4301
4358
  ? replaceStudioAnnotationMarkersForPdf(source)
4302
4359
  : source;
4303
- const commentStrippedSource = stripStudioMarkdownHtmlComments(annotationReadySource);
4304
- return normalizeObsidianImages(preserveLiteralLatexCommandsInMarkdown(normalizeMathDelimiters(commentStrippedSource)));
4360
+ const commentStrippedSource = stripStudioMarkdownHtmlCommentsPreservingYamlFrontMatter(annotationReadySource);
4361
+ return prepareStudioMarkdownForPandoc(commentStrippedSource, {
4362
+ preserveLiteralLatexCommands: !hasStudioYamlHeaderIncludes(annotationReadySource),
4363
+ });
4305
4364
  }
4306
4365
 
4307
4366
  function stripMathMlAnnotationTags(html: string): string {
@@ -4559,7 +4618,7 @@ function decorateStudioPandocSyntaxHtml(html: string): string {
4559
4618
 
4560
4619
  async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolean, resourcePath?: string, sourcePath?: string): Promise<string> {
4561
4620
  const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
4562
- const markdownWithoutHtmlComments = isLatex ? markdown : stripStudioMarkdownHtmlComments(markdown);
4621
+ const markdownWithoutHtmlComments = isLatex ? markdown : stripStudioMarkdownHtmlCommentsPreservingYamlFrontMatter(markdown);
4563
4622
  const markdownWithPreviewPageBreaks = isLatex ? markdownWithoutHtmlComments : replaceStudioPreviewPageBreakCommands(markdownWithoutHtmlComments);
4564
4623
  const latexSubfigurePreviewTransform = isLatex
4565
4624
  ? preprocessStudioLatexSubfiguresForPreview(markdownWithPreviewPageBreaks)
@@ -4580,7 +4639,7 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
4580
4639
  }
4581
4640
  const normalizedMarkdown = isLatex
4582
4641
  ? sourceWithResolvedRefs
4583
- : normalizeStudioMarkdownFencedBlocks(normalizeObsidianImages(preserveLiteralLatexCommandsInMarkdown(normalizeMathDelimiters(sourceWithResolvedRefs))));
4642
+ : normalizeStudioMarkdownFencedBlocks(prepareStudioMarkdownForPandoc(sourceWithResolvedRefs));
4584
4643
  const pandocWorkingDir = resolveStudioPandocWorkingDir(resourcePath);
4585
4644
 
4586
4645
  let renderedHtml = await new Promise<string>((resolve, reject) => {
@@ -5501,6 +5560,8 @@ async function renderStudioPdfWithPandoc(
5501
5560
  await mkdir(tempDir, { recursive: true });
5502
5561
  await writeFile(preamblePath, buildStudioPdfPreamble(pdfOptions), "utf-8");
5503
5562
 
5563
+ const hasYamlHeaderIncludesForPdf = inputFormat !== "latex" && hasStudioYamlHeaderIncludes(markdownForPdf);
5564
+ const headerIncludeArgs = hasYamlHeaderIncludesForPdf ? [] : ["--include-in-header", preamblePath];
5504
5565
  const args = [
5505
5566
  "-f", inputFormat,
5506
5567
  "-o", outputPath,
@@ -5508,7 +5569,7 @@ async function renderStudioPdfWithPandoc(
5508
5569
  ...buildStudioPdfPandocVariableArgs(pdfOptions, inputFormat !== "latex"),
5509
5570
  "-V", "urlcolor=blue",
5510
5571
  "-V", "linkcolor=blue",
5511
- "--include-in-header", preamblePath,
5572
+ ...headerIncludeArgs,
5512
5573
  ...bibliographyArgs,
5513
5574
  ];
5514
5575
  if (resourcePath) args.push(`--resource-path=${resourcePath}`);
@@ -5652,6 +5713,8 @@ async function renderStudioPdfWithPandoc(
5652
5713
  return { pdf: rendered.pdf, warning: mermaidPrepared.warning ?? rendered.warning };
5653
5714
  }
5654
5715
 
5716
+ const hasYamlHeaderIncludesForPdf = !isLatex && hasStudioYamlHeaderIncludes(markdownForPdf);
5717
+ const headerIncludeArgs = hasYamlHeaderIncludesForPdf ? [] : ["--include-in-header", preamblePath];
5655
5718
  const args = [
5656
5719
  "-f", inputFormat,
5657
5720
  "-o", outputPath,
@@ -5659,7 +5722,7 @@ async function renderStudioPdfWithPandoc(
5659
5722
  ...buildStudioPdfPandocVariableArgs(pdfOptions, !isLatex),
5660
5723
  "-V", "urlcolor=blue",
5661
5724
  "-V", "linkcolor=blue",
5662
- "--include-in-header", preamblePath,
5725
+ ...headerIncludeArgs,
5663
5726
  ...bibliographyArgs,
5664
5727
  ];
5665
5728
  if (resourcePath) args.push(`--resource-path=${resourcePath}`);
@@ -6600,9 +6663,44 @@ function truncateStudioTraceSnapshotText(text: string, maxChars = STUDIO_TRACE_S
6600
6663
  };
6601
6664
  }
6602
6665
 
6666
+ function copyStudioTraceImagesForSnapshot(
6667
+ images: StudioTraceImage[] | undefined,
6668
+ budget: { remainingImages: number; remainingBase64Chars: number },
6669
+ ): { images: StudioTraceImage[]; omitted: number } {
6670
+ const copied: StudioTraceImage[] = [];
6671
+ let omitted = 0;
6672
+ for (const image of Array.isArray(images) ? images : []) {
6673
+ if (!image || typeof image !== "object") continue;
6674
+ const mimeType = normalizeStudioTraceImageMimeType(image.mimeType);
6675
+ const data = typeof image.data === "string" ? image.data : "";
6676
+ if (!data || !isStudioTraceSafeImageMimeType(mimeType)) {
6677
+ omitted += 1;
6678
+ continue;
6679
+ }
6680
+ if (budget.remainingImages <= 0 || data.length > budget.remainingBase64Chars) {
6681
+ omitted += 1;
6682
+ continue;
6683
+ }
6684
+ copied.push({
6685
+ id: typeof image.id === "string" && image.id.trim() ? image.id : `trace-image-snapshot-${copied.length + 1}`,
6686
+ mimeType,
6687
+ data,
6688
+ byteLength: typeof image.byteLength === "number" && Number.isFinite(image.byteLength) ? image.byteLength : estimateStudioTraceBase64ByteLength(data),
6689
+ label: typeof image.label === "string" && image.label.trim() ? image.label : null,
6690
+ });
6691
+ budget.remainingImages -= 1;
6692
+ budget.remainingBase64Chars -= data.length;
6693
+ }
6694
+ return { images: copied, omitted };
6695
+ }
6696
+
6603
6697
  function createStudioTraceSnapshot(source: StudioTraceState): { traceState: StudioTraceState; truncated: boolean } {
6604
6698
  let truncated = false;
6605
6699
  const sourceEntries = Array.isArray(source.entries) ? source.entries : [];
6700
+ const imageBudget = {
6701
+ remainingImages: STUDIO_TRACE_SNAPSHOT_MAX_IMAGES,
6702
+ remainingBase64Chars: STUDIO_TRACE_SNAPSHOT_MAX_IMAGE_BASE64_CHARS,
6703
+ };
6606
6704
  const entries = sourceEntries.slice(-STUDIO_TRACE_SNAPSHOT_MAX_ENTRIES).map((entry) => {
6607
6705
  if (entry.type === "assistant") {
6608
6706
  const thinking = truncateStudioTraceSnapshotText(entry.thinking);
@@ -6616,11 +6714,16 @@ function createStudioTraceSnapshot(source: StudioTraceState): { traceState: Stud
6616
6714
  }
6617
6715
  const argsSummary = truncateStudioTraceSnapshotText(entry.argsSummary ?? "");
6618
6716
  const output = truncateStudioTraceSnapshotText(entry.output);
6619
- truncated = truncated || argsSummary.truncated || output.truncated;
6717
+ const snapshotImages = copyStudioTraceImagesForSnapshot(entry.images, imageBudget);
6718
+ truncated = truncated || argsSummary.truncated || output.truncated || snapshotImages.omitted > 0;
6719
+ const omittedImageNote = snapshotImages.omitted > 0
6720
+ ? `[${snapshotImages.omitted} image preview${snapshotImages.omitted === 1 ? "" : "s"} omitted from saved Working view to keep history bounded.]`
6721
+ : "";
6620
6722
  return {
6621
6723
  ...entry,
6622
6724
  argsSummary: argsSummary.text || null,
6623
- output: output.text,
6725
+ output: [output.text, omittedImageNote].filter(Boolean).join("\n"),
6726
+ images: snapshotImages.images,
6624
6727
  };
6625
6728
  });
6626
6729
  if (sourceEntries.length > entries.length) truncated = true;
@@ -6657,29 +6760,102 @@ function sanitizeStudioTraceOutputText(text: string): string {
6657
6760
  .replace(/\b[A-Za-z0-9+/]{3000,}={0,2}\b/g, "[base64 data omitted]");
6658
6761
  }
6659
6762
 
6763
+ function normalizeStudioTraceImageMimeType(value: unknown): string {
6764
+ return typeof value === "string" ? value.trim().toLowerCase() : "";
6765
+ }
6766
+
6767
+ function getStudioTraceImageMimeType(block: unknown): string {
6768
+ if (!block || typeof block !== "object") return "";
6769
+ const payload = block as Record<string, unknown>;
6770
+ const source = payload.source && typeof payload.source === "object" ? payload.source as Record<string, unknown> : null;
6771
+ return normalizeStudioTraceImageMimeType(
6772
+ payload.mimeType
6773
+ ?? payload.mediaType
6774
+ ?? payload.media_type
6775
+ ?? source?.mimeType
6776
+ ?? source?.mediaType
6777
+ ?? source?.media_type,
6778
+ );
6779
+ }
6780
+
6660
6781
  function isStudioTraceImageBlock(block: unknown): boolean {
6661
6782
  if (!block || typeof block !== "object") return false;
6662
6783
  const payload = block as Record<string, unknown>;
6663
6784
  const type = typeof payload.type === "string" ? payload.type.toLowerCase() : "";
6664
6785
  if (type.includes("image")) return true;
6665
- const mime = typeof payload.mimeType === "string"
6666
- ? payload.mimeType
6667
- : (typeof payload.media_type === "string" ? payload.media_type : "");
6668
- if (mime.toLowerCase().startsWith("image/")) return true;
6786
+ return getStudioTraceImageMimeType(block).startsWith("image/");
6787
+ }
6788
+
6789
+ function isStudioTraceSafeImageMimeType(mimeType: string): boolean {
6790
+ return STUDIO_TRACE_IMAGE_SAFE_MIME_TYPES.has(normalizeStudioTraceImageMimeType(mimeType));
6791
+ }
6792
+
6793
+ function getStudioTraceImageData(block: unknown): string | null {
6794
+ if (!block || typeof block !== "object") return null;
6795
+ const payload = block as Record<string, unknown>;
6796
+ if (typeof payload.data === "string") return payload.data;
6669
6797
  const source = payload.source && typeof payload.source === "object" ? payload.source as Record<string, unknown> : null;
6670
- const sourceMime = source && typeof source.media_type === "string" ? source.media_type : "";
6671
- return sourceMime.toLowerCase().startsWith("image/");
6798
+ if (source && typeof source.data === "string") return source.data;
6799
+ return null;
6800
+ }
6801
+
6802
+ function normalizeStudioTraceBase64Data(data: string): string | null {
6803
+ const compact = String(data || "").replace(/\s+/g, "");
6804
+ if (!compact || !/^[A-Za-z0-9+/]*={0,2}$/.test(compact)) return null;
6805
+ return compact;
6672
6806
  }
6673
6807
 
6674
- function describeStudioTraceImageBlock(block: unknown): string {
6808
+ function estimateStudioTraceBase64ByteLength(data: string): number | null {
6809
+ const compact = normalizeStudioTraceBase64Data(data);
6810
+ if (!compact) return null;
6811
+ const padding = compact.endsWith("==") ? 2 : (compact.endsWith("=") ? 1 : 0);
6812
+ return Math.max(0, Math.floor((compact.length * 3) / 4) - padding);
6813
+ }
6814
+
6815
+ function formatStudioTraceByteSize(bytes: number | null): string {
6816
+ if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes < 0) return "unknown size";
6817
+ if (bytes < 1024) return `${Math.round(bytes)} B`;
6818
+ const kib = bytes / 1024;
6819
+ if (kib < 1024) return `${kib.toFixed(kib >= 100 ? 0 : 1).replace(/\.0$/, "")} KB`;
6820
+ const mib = kib / 1024;
6821
+ return `${mib.toFixed(mib >= 100 ? 0 : 1).replace(/\.0$/, "")} MB`;
6822
+ }
6823
+
6824
+ function describeStudioTraceImageBlock(block: unknown, reason?: string): string {
6825
+ const mime = getStudioTraceImageMimeType(block) || "image";
6826
+ return `[Image: ${mime}${reason ? ` ${reason}` : ""}]`;
6827
+ }
6828
+
6829
+ function collectStudioTraceImageBlock(block: unknown, images: StudioTraceImage[]): string {
6830
+ const mimeType = getStudioTraceImageMimeType(block) || "image/unknown";
6831
+ if (!isStudioTraceSafeImageMimeType(mimeType)) {
6832
+ return describeStudioTraceImageBlock(block, "omitted from Working view: unsupported image type");
6833
+ }
6834
+ if (images.length >= STUDIO_TRACE_IMAGE_MAX_COUNT) {
6835
+ return describeStudioTraceImageBlock(block, "omitted from Working view: image count limit reached");
6836
+ }
6837
+ const data = getStudioTraceImageData(block);
6838
+ const normalizedData = data ? normalizeStudioTraceBase64Data(data) : null;
6839
+ if (!normalizedData) {
6840
+ return describeStudioTraceImageBlock(block, "omitted from Working view: no base64 data");
6841
+ }
6842
+ if (normalizedData.length > STUDIO_TRACE_IMAGE_MAX_BASE64_CHARS) {
6843
+ const estimatedBytes = estimateStudioTraceBase64ByteLength(normalizedData);
6844
+ return describeStudioTraceImageBlock(block, `omitted from Working view: ${formatStudioTraceByteSize(estimatedBytes)} exceeds image preview limit`);
6845
+ }
6675
6846
  const payload = (block && typeof block === "object") ? block as Record<string, unknown> : {};
6676
- const source = payload.source && typeof payload.source === "object" ? payload.source as Record<string, unknown> : null;
6677
- const mime = typeof payload.mimeType === "string"
6678
- ? payload.mimeType
6679
- : (typeof payload.media_type === "string"
6680
- ? payload.media_type
6681
- : (source && typeof source.media_type === "string" ? source.media_type : "image"));
6682
- return `[Image: ${mime || "image"} output omitted from Working view]`;
6847
+ const hash = createHash("sha256").update(mimeType).update(normalizedData).digest("hex").slice(0, 16);
6848
+ const image: StudioTraceImage = {
6849
+ id: `trace-image-${hash}-${images.length + 1}`,
6850
+ mimeType,
6851
+ data: normalizedData,
6852
+ byteLength: estimateStudioTraceBase64ByteLength(normalizedData),
6853
+ label: typeof payload.label === "string" && payload.label.trim()
6854
+ ? payload.label.trim()
6855
+ : (typeof payload.alt === "string" && payload.alt.trim() ? payload.alt.trim() : null),
6856
+ };
6857
+ images.push(image);
6858
+ return "";
6683
6859
  }
6684
6860
 
6685
6861
  function stringifyStudioTraceObject(value: unknown): string {
@@ -6696,19 +6872,19 @@ function stringifyStudioTraceObject(value: unknown): string {
6696
6872
  }
6697
6873
  }
6698
6874
 
6699
- function formatStudioTraceOutput(result: unknown): string {
6875
+ function formatStudioTraceOutputPart(result: unknown, images: StudioTraceImage[]): string {
6700
6876
  if (result == null) return "";
6701
6877
  if (typeof result === "string") return sanitizeStudioTraceOutputText(result);
6702
6878
  if (Array.isArray(result)) {
6703
- return result.map((item) => formatStudioTraceOutput(item)).filter(Boolean).join("\n");
6879
+ return result.map((item) => formatStudioTraceOutputPart(item, images)).filter(Boolean).join("\n");
6704
6880
  }
6705
6881
  if (typeof result === "object") {
6706
- if (isStudioTraceImageBlock(result)) return describeStudioTraceImageBlock(result);
6882
+ if (isStudioTraceImageBlock(result)) return collectStudioTraceImageBlock(result, images);
6707
6883
  const payload = result as { content?: Array<{ type?: string; text?: string }> };
6708
6884
  if (Array.isArray(payload.content)) {
6709
6885
  return payload.content
6710
6886
  .map((block) => {
6711
- if (isStudioTraceImageBlock(block)) return describeStudioTraceImageBlock(block);
6887
+ if (isStudioTraceImageBlock(block)) return collectStudioTraceImageBlock(block, images);
6712
6888
  if (block && block.type === "text" && typeof block.text === "string") return sanitizeStudioTraceOutputText(block.text);
6713
6889
  return stringifyStudioTraceObject(block);
6714
6890
  })
@@ -6720,6 +6896,18 @@ function formatStudioTraceOutput(result: unknown): string {
6720
6896
  return sanitizeStudioTraceOutputText(String(result));
6721
6897
  }
6722
6898
 
6899
+ function formatStudioTraceToolResult(result: unknown): { output: string; images: StudioTraceImage[] } {
6900
+ const images: StudioTraceImage[] = [];
6901
+ return {
6902
+ output: formatStudioTraceOutputPart(result, images),
6903
+ images,
6904
+ };
6905
+ }
6906
+
6907
+ function formatStudioTraceOutput(result: unknown): string {
6908
+ return formatStudioTraceToolResult(result).output;
6909
+ }
6910
+
6723
6911
  function summarizeStudioTraceToolArgs(toolName: string, args: unknown): string | null {
6724
6912
  const normalizedTool = String(toolName || "").trim().toLowerCase();
6725
6913
  const payload = (args && typeof args === "object") ? (args as Record<string, unknown>) : {};
@@ -8102,6 +8290,7 @@ export default function (pi: ExtensionAPI) {
8102
8290
  label: deriveToolActivityLabel(toolName, args),
8103
8291
  argsSummary: summarizeStudioTraceToolArgs(toolName, args),
8104
8292
  output: "",
8293
+ images: [],
8105
8294
  startedAt: now,
8106
8295
  updatedAt: now,
8107
8296
  status: "pending",
@@ -8119,9 +8308,11 @@ export default function (pi: ExtensionAPI) {
8119
8308
  output: string,
8120
8309
  status: StudioTraceEntryStatus,
8121
8310
  isError: boolean,
8311
+ images?: StudioTraceImage[],
8122
8312
  ) => {
8123
8313
  const entry = ensureStudioTraceToolEntry(toolCallId, toolName, args);
8124
8314
  entry.output = output;
8315
+ if (Array.isArray(images)) entry.images = images;
8125
8316
  entry.status = status;
8126
8317
  entry.isError = isError;
8127
8318
  entry.updatedAt = Date.now();
@@ -8599,9 +8790,10 @@ export default function (pi: ExtensionAPI) {
8599
8790
  }
8600
8791
 
8601
8792
  const resourceDir = resolveStudioCompanionResourceDir(msg.path, msg.resourceDir, studioCwd);
8793
+ const hasContent = msg.content.trim().length > 0;
8602
8794
  const document: InitialStudioDocument = {
8603
8795
  text: msg.content,
8604
- label: buildStudioCompanionLabel(msg.label),
8796
+ label: hasContent ? buildStudioCompanionLabel(msg.label) : "blank companion editor",
8605
8797
  source: "blank",
8606
8798
  draftId: createStudioDraftId(),
8607
8799
  resourceDir,
@@ -8614,7 +8806,9 @@ export default function (pi: ExtensionAPI) {
8614
8806
  requestId: msg.requestId,
8615
8807
  url,
8616
8808
  relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
8617
- message: "Companion editor is ready with a detached copy of the current editor text.",
8809
+ message: hasContent
8810
+ ? "Companion editor is ready with a detached copy of the current editor text."
8811
+ : "Blank companion editor is ready.",
8618
8812
  });
8619
8813
  return;
8620
8814
  }
@@ -10181,25 +10375,29 @@ export default function (pi: ExtensionAPI) {
10181
10375
 
10182
10376
  pi.on("tool_execution_update", async (event) => {
10183
10377
  if (!agentBusy) return;
10378
+ const formatted = formatStudioTraceToolResult(event.partialResult);
10184
10379
  updateStudioTraceToolEntry(
10185
10380
  event.toolCallId,
10186
10381
  event.toolName,
10187
10382
  event.args,
10188
- formatStudioTraceOutput(event.partialResult),
10383
+ formatted.output,
10189
10384
  "streaming",
10190
10385
  false,
10386
+ formatted.images,
10191
10387
  );
10192
10388
  });
10193
10389
 
10194
10390
  pi.on("tool_execution_end", async (event) => {
10195
10391
  if (!agentBusy) return;
10392
+ const formatted = formatStudioTraceToolResult(event.result);
10196
10393
  updateStudioTraceToolEntry(
10197
10394
  event.toolCallId,
10198
10395
  event.toolName,
10199
10396
  undefined,
10200
- formatStudioTraceOutput(event.result),
10397
+ formatted.output,
10201
10398
  event.isError ? "error" : "complete",
10202
10399
  Boolean(event.isError),
10400
+ formatted.images,
10203
10401
  );
10204
10402
  emitDebugEvent("tool_execution_end", { toolName: event.toolName, activeRequestId: activeRequest?.id ?? null, activeRequestKind: activeRequest?.kind ?? null });
10205
10403
  // Keep tool phase visible until the next tool call, assistant response phase,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.8.2",
4
- "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, prompt/response history, and live Markdown/LaTeX/code preview",
3
+ "version": "0.8.4",
4
+ "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, prompt/response history, and live Markdown/LaTeX/code/interactive HTML preview",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {