pi-studio 0.8.3 → 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,6 +4,12 @@ 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
+
7
13
  ## [0.8.3] — 2026-05-13
8
14
 
9
15
  ### Changed
package/README.md CHANGED
@@ -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,7 +34,7 @@ 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
37
+ - Renders Markdown/LaTeX/code previews (math + Mermaid), theme-synced with pi, with copy buttons for code blocks and blockquotes
38
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
@@ -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
  }
@@ -4267,6 +4324,78 @@
4267
4324
  return String(text || "").replace(/\r\n/g, "\n").replace(/\u200b/g, "");
4268
4325
  }
4269
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
+
4270
4399
  function getCopyablePreviewBlockText(blockEl) {
4271
4400
  if (!blockEl || typeof blockEl.querySelectorAll !== "function") return "";
4272
4401
  if (blockEl.classList && blockEl.classList.contains("preview-code-lines")) {
@@ -4277,6 +4406,10 @@
4277
4406
  );
4278
4407
  }
4279
4408
 
4409
+ if (blockEl.matches && blockEl.matches("blockquote")) {
4410
+ return getCopyableBlockquoteText(blockEl);
4411
+ }
4412
+
4280
4413
  const codeEl = typeof blockEl.querySelector === "function"
4281
4414
  ? blockEl.querySelector("pre code, code")
4282
4415
  : null;
@@ -4334,7 +4467,7 @@
4334
4467
 
4335
4468
  function decorateCopyablePreviewBlocks(targetEl) {
4336
4469
  if (!targetEl || typeof targetEl.querySelectorAll !== "function") return;
4337
- 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"));
4338
4471
  blocks.forEach((blockEl) => {
4339
4472
  if (!blockEl || !(blockEl instanceof Element)) return;
4340
4473
  if (blockEl.dataset && blockEl.dataset.studioCopyDecorated === "1") return;
@@ -4572,6 +4705,23 @@
4572
4705
  + "</div>";
4573
4706
  }
4574
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
+
4575
4725
  function buildTracePanelHtml() {
4576
4726
  const state = traceState || createEmptyTraceState();
4577
4727
  const filter = normalizeTraceFilter(traceFilter);
@@ -4665,8 +4815,12 @@
4665
4815
  const argsSummary = entry.argsSummary
4666
4816
  ? "<div class='trace-section'><div class='trace-section-label'>Input</div>" + renderTraceOutput(entry.argsSummary, entry.id + ":input") + "</div>"
4667
4817
  : "";
4668
- const output = entry.output
4669
- ? "<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>"
4670
4824
  : "<div class='trace-empty-inline'>No output yet.</div>";
4671
4825
  const toolStatusLabel = entry.isError
4672
4826
  ? "Error"
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;
@@ -6649,9 +6663,44 @@ function truncateStudioTraceSnapshotText(text: string, maxChars = STUDIO_TRACE_S
6649
6663
  };
6650
6664
  }
6651
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
+
6652
6697
  function createStudioTraceSnapshot(source: StudioTraceState): { traceState: StudioTraceState; truncated: boolean } {
6653
6698
  let truncated = false;
6654
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
+ };
6655
6704
  const entries = sourceEntries.slice(-STUDIO_TRACE_SNAPSHOT_MAX_ENTRIES).map((entry) => {
6656
6705
  if (entry.type === "assistant") {
6657
6706
  const thinking = truncateStudioTraceSnapshotText(entry.thinking);
@@ -6665,11 +6714,16 @@ function createStudioTraceSnapshot(source: StudioTraceState): { traceState: Stud
6665
6714
  }
6666
6715
  const argsSummary = truncateStudioTraceSnapshotText(entry.argsSummary ?? "");
6667
6716
  const output = truncateStudioTraceSnapshotText(entry.output);
6668
- 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
+ : "";
6669
6722
  return {
6670
6723
  ...entry,
6671
6724
  argsSummary: argsSummary.text || null,
6672
- output: output.text,
6725
+ output: [output.text, omittedImageNote].filter(Boolean).join("\n"),
6726
+ images: snapshotImages.images,
6673
6727
  };
6674
6728
  });
6675
6729
  if (sourceEntries.length > entries.length) truncated = true;
@@ -6706,29 +6760,102 @@ function sanitizeStudioTraceOutputText(text: string): string {
6706
6760
  .replace(/\b[A-Za-z0-9+/]{3000,}={0,2}\b/g, "[base64 data omitted]");
6707
6761
  }
6708
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
+
6709
6781
  function isStudioTraceImageBlock(block: unknown): boolean {
6710
6782
  if (!block || typeof block !== "object") return false;
6711
6783
  const payload = block as Record<string, unknown>;
6712
6784
  const type = typeof payload.type === "string" ? payload.type.toLowerCase() : "";
6713
6785
  if (type.includes("image")) return true;
6714
- const mime = typeof payload.mimeType === "string"
6715
- ? payload.mimeType
6716
- : (typeof payload.media_type === "string" ? payload.media_type : "");
6717
- 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;
6718
6797
  const source = payload.source && typeof payload.source === "object" ? payload.source as Record<string, unknown> : null;
6719
- const sourceMime = source && typeof source.media_type === "string" ? source.media_type : "";
6720
- 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;
6806
+ }
6807
+
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);
6721
6813
  }
6722
6814
 
6723
- function describeStudioTraceImageBlock(block: unknown): string {
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
+ }
6724
6846
  const payload = (block && typeof block === "object") ? block as Record<string, unknown> : {};
6725
- const source = payload.source && typeof payload.source === "object" ? payload.source as Record<string, unknown> : null;
6726
- const mime = typeof payload.mimeType === "string"
6727
- ? payload.mimeType
6728
- : (typeof payload.media_type === "string"
6729
- ? payload.media_type
6730
- : (source && typeof source.media_type === "string" ? source.media_type : "image"));
6731
- 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 "";
6732
6859
  }
6733
6860
 
6734
6861
  function stringifyStudioTraceObject(value: unknown): string {
@@ -6745,19 +6872,19 @@ function stringifyStudioTraceObject(value: unknown): string {
6745
6872
  }
6746
6873
  }
6747
6874
 
6748
- function formatStudioTraceOutput(result: unknown): string {
6875
+ function formatStudioTraceOutputPart(result: unknown, images: StudioTraceImage[]): string {
6749
6876
  if (result == null) return "";
6750
6877
  if (typeof result === "string") return sanitizeStudioTraceOutputText(result);
6751
6878
  if (Array.isArray(result)) {
6752
- return result.map((item) => formatStudioTraceOutput(item)).filter(Boolean).join("\n");
6879
+ return result.map((item) => formatStudioTraceOutputPart(item, images)).filter(Boolean).join("\n");
6753
6880
  }
6754
6881
  if (typeof result === "object") {
6755
- if (isStudioTraceImageBlock(result)) return describeStudioTraceImageBlock(result);
6882
+ if (isStudioTraceImageBlock(result)) return collectStudioTraceImageBlock(result, images);
6756
6883
  const payload = result as { content?: Array<{ type?: string; text?: string }> };
6757
6884
  if (Array.isArray(payload.content)) {
6758
6885
  return payload.content
6759
6886
  .map((block) => {
6760
- if (isStudioTraceImageBlock(block)) return describeStudioTraceImageBlock(block);
6887
+ if (isStudioTraceImageBlock(block)) return collectStudioTraceImageBlock(block, images);
6761
6888
  if (block && block.type === "text" && typeof block.text === "string") return sanitizeStudioTraceOutputText(block.text);
6762
6889
  return stringifyStudioTraceObject(block);
6763
6890
  })
@@ -6769,6 +6896,18 @@ function formatStudioTraceOutput(result: unknown): string {
6769
6896
  return sanitizeStudioTraceOutputText(String(result));
6770
6897
  }
6771
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
+
6772
6911
  function summarizeStudioTraceToolArgs(toolName: string, args: unknown): string | null {
6773
6912
  const normalizedTool = String(toolName || "").trim().toLowerCase();
6774
6913
  const payload = (args && typeof args === "object") ? (args as Record<string, unknown>) : {};
@@ -8151,6 +8290,7 @@ export default function (pi: ExtensionAPI) {
8151
8290
  label: deriveToolActivityLabel(toolName, args),
8152
8291
  argsSummary: summarizeStudioTraceToolArgs(toolName, args),
8153
8292
  output: "",
8293
+ images: [],
8154
8294
  startedAt: now,
8155
8295
  updatedAt: now,
8156
8296
  status: "pending",
@@ -8168,9 +8308,11 @@ export default function (pi: ExtensionAPI) {
8168
8308
  output: string,
8169
8309
  status: StudioTraceEntryStatus,
8170
8310
  isError: boolean,
8311
+ images?: StudioTraceImage[],
8171
8312
  ) => {
8172
8313
  const entry = ensureStudioTraceToolEntry(toolCallId, toolName, args);
8173
8314
  entry.output = output;
8315
+ if (Array.isArray(images)) entry.images = images;
8174
8316
  entry.status = status;
8175
8317
  entry.isError = isError;
8176
8318
  entry.updatedAt = Date.now();
@@ -10233,25 +10375,29 @@ export default function (pi: ExtensionAPI) {
10233
10375
 
10234
10376
  pi.on("tool_execution_update", async (event) => {
10235
10377
  if (!agentBusy) return;
10378
+ const formatted = formatStudioTraceToolResult(event.partialResult);
10236
10379
  updateStudioTraceToolEntry(
10237
10380
  event.toolCallId,
10238
10381
  event.toolName,
10239
10382
  event.args,
10240
- formatStudioTraceOutput(event.partialResult),
10383
+ formatted.output,
10241
10384
  "streaming",
10242
10385
  false,
10386
+ formatted.images,
10243
10387
  );
10244
10388
  });
10245
10389
 
10246
10390
  pi.on("tool_execution_end", async (event) => {
10247
10391
  if (!agentBusy) return;
10392
+ const formatted = formatStudioTraceToolResult(event.result);
10248
10393
  updateStudioTraceToolEntry(
10249
10394
  event.toolCallId,
10250
10395
  event.toolName,
10251
10396
  undefined,
10252
- formatStudioTraceOutput(event.result),
10397
+ formatted.output,
10253
10398
  event.isError ? "error" : "complete",
10254
10399
  Boolean(event.isError),
10400
+ formatted.images,
10255
10401
  );
10256
10402
  emitDebugEvent("tool_execution_end", { toolName: event.toolName, activeRequestId: activeRequest?.id ?? null, activeRequestKind: activeRequest?.kind ?? null });
10257
10403
  // Keep tool phase visible until the next tool call, assistant response phase,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
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",