pi-studio 0.7.1 → 0.8.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/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext, SessionEntry, Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
3
3
  import { spawn, spawnSync } from "node:child_process";
4
- import { randomUUID } from "node:crypto";
4
+ import { createHash, randomUUID } from "node:crypto";
5
5
  import { readFileSync, statSync, writeFileSync } from "node:fs";
6
6
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
7
7
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
@@ -71,12 +71,22 @@ interface LastStudioResponse {
71
71
  kind: StudioRequestKind;
72
72
  }
73
73
 
74
+ interface StudioTraceSnapshotSummary {
75
+ hasTrace: boolean;
76
+ entryCount: number;
77
+ startedAt: number | null;
78
+ updatedAt: number | null;
79
+ status: StudioTraceRunStatus;
80
+ truncated: boolean;
81
+ }
82
+
74
83
  interface StudioResponseHistoryItem extends StudioPromptDescriptor {
75
84
  id: string;
76
85
  markdown: string;
77
86
  thinking: string | null;
78
87
  timestamp: number;
79
88
  kind: StudioRequestKind;
89
+ traceSummary?: StudioTraceSnapshotSummary;
80
90
  }
81
91
 
82
92
  interface StudioDirectRunChain {
@@ -110,6 +120,40 @@ interface PreparedStudioPdfExport {
110
120
  tempDirPath?: string;
111
121
  }
112
122
 
123
+ interface PreparedStudioHtmlExport {
124
+ html: Buffer;
125
+ filename: string;
126
+ warning?: string;
127
+ createdAt: number;
128
+ filePath?: string;
129
+ tempDirPath?: string;
130
+ }
131
+
132
+ interface StudioHtmlAnnotationPlaceholder {
133
+ token: string;
134
+ text: string;
135
+ title: string;
136
+ }
137
+
138
+ interface StudioHtmlPdfBlockOptions {
139
+ path: string;
140
+ title: string;
141
+ caption: string;
142
+ page: string;
143
+ height: string;
144
+ }
145
+
146
+ interface StudioHtmlPdfBlock {
147
+ placeholder: string;
148
+ options: StudioHtmlPdfBlockOptions;
149
+ }
150
+
151
+ interface StudioHtmlRenderOptions {
152
+ title?: string;
153
+ sourceLabel?: string;
154
+ themeVars?: Record<string, string>;
155
+ }
156
+
113
157
  interface InitialStudioDocument {
114
158
  text: string;
115
159
  label: string;
@@ -190,6 +234,11 @@ interface GetLatestResponseMessage {
190
234
  type: "get_latest_response";
191
235
  }
192
236
 
237
+ interface GetTraceSnapshotMessage {
238
+ type: "get_trace_snapshot";
239
+ responseHistoryId: string;
240
+ }
241
+
193
242
  interface CritiqueRequestMessage {
194
243
  type: "critique_request";
195
244
  requestId: string;
@@ -269,6 +318,7 @@ type IncomingStudioMessage =
269
318
  | HelloMessage
270
319
  | PingMessage
271
320
  | GetLatestResponseMessage
321
+ | GetTraceSnapshotMessage
272
322
  | CritiqueRequestMessage
273
323
  | AnnotationRequestMessage
274
324
  | SendRunRequestMessage
@@ -285,11 +335,17 @@ type IncomingStudioMessage =
285
335
  const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
286
336
  const PREVIEW_RENDER_MAX_CHARS = 400_000;
287
337
  const PDF_EXPORT_MAX_CHARS = 400_000;
338
+ const HTML_EXPORT_MAX_CHARS = 400_000;
288
339
  const REQUEST_BODY_MAX_BYTES = 1_000_000;
289
340
  const RESPONSE_HISTORY_LIMIT = 30;
290
341
  const CMUX_NOTIFY_TIMEOUT_MS = 1200;
291
342
  const PREPARED_PDF_EXPORT_TTL_MS = 5 * 60 * 1000;
343
+ const PREPARED_HTML_EXPORT_TTL_MS = 5 * 60 * 1000;
292
344
  const MAX_PREPARED_PDF_EXPORTS = 8;
345
+ const MAX_PREPARED_HTML_EXPORTS = 8;
346
+ const STUDIO_TRACE_SNAPSHOT_MAX_ENTRIES = 80;
347
+ const STUDIO_TRACE_SNAPSHOT_MAX_FIELD_CHARS = 20_000;
348
+ const MAX_STUDIO_TRACE_SNAPSHOTS = RESPONSE_HISTORY_LIMIT;
293
349
  const TRANSIENT_STUDIO_DOCUMENT_TTL_MS = 30 * 60 * 1000;
294
350
  const MAX_TRANSIENT_STUDIO_DOCUMENTS = 16;
295
351
  const STUDIO_TERMINAL_NOTIFY_TITLE = "pi Studio";
@@ -1538,10 +1594,64 @@ function readStudioFile(pathArg: string, cwd: string):
1538
1594
 
1539
1595
  function inferStudioPdfLanguageFromPath(pathInput: string): string | undefined {
1540
1596
  const extension = extname(pathInput).toLowerCase();
1541
- if (extension === ".tex" || extension === ".latex") return "latex";
1542
- if (extension === ".md" || extension === ".markdown" || extension === ".mdx" || extension === ".qmd") return "markdown";
1543
- if (extension === ".diff" || extension === ".patch") return "diff";
1544
- return undefined;
1597
+ const languageByExtension: Record<string, string> = {
1598
+ ".md": "markdown",
1599
+ ".markdown": "markdown",
1600
+ ".mdx": "markdown",
1601
+ ".qmd": "markdown",
1602
+ ".tex": "latex",
1603
+ ".latex": "latex",
1604
+ ".diff": "diff",
1605
+ ".patch": "diff",
1606
+ ".js": "javascript",
1607
+ ".mjs": "javascript",
1608
+ ".cjs": "javascript",
1609
+ ".jsx": "javascript",
1610
+ ".ts": "typescript",
1611
+ ".mts": "typescript",
1612
+ ".cts": "typescript",
1613
+ ".tsx": "typescript",
1614
+ ".py": "python",
1615
+ ".pyw": "python",
1616
+ ".sh": "bash",
1617
+ ".bash": "bash",
1618
+ ".zsh": "bash",
1619
+ ".json": "json",
1620
+ ".jsonc": "json",
1621
+ ".json5": "json",
1622
+ ".rs": "rust",
1623
+ ".c": "c",
1624
+ ".h": "c",
1625
+ ".cpp": "cpp",
1626
+ ".cxx": "cpp",
1627
+ ".cc": "cpp",
1628
+ ".hpp": "cpp",
1629
+ ".hxx": "cpp",
1630
+ ".jl": "julia",
1631
+ ".f90": "fortran",
1632
+ ".f95": "fortran",
1633
+ ".f03": "fortran",
1634
+ ".f": "fortran",
1635
+ ".for": "fortran",
1636
+ ".r": "r",
1637
+ ".m": "matlab",
1638
+ ".java": "java",
1639
+ ".go": "go",
1640
+ ".rb": "ruby",
1641
+ ".swift": "swift",
1642
+ ".html": "html",
1643
+ ".htm": "html",
1644
+ ".css": "css",
1645
+ ".xml": "xml",
1646
+ ".yaml": "yaml",
1647
+ ".yml": "yaml",
1648
+ ".toml": "toml",
1649
+ ".lua": "lua",
1650
+ ".txt": "text",
1651
+ ".rst": "text",
1652
+ ".adoc": "text",
1653
+ };
1654
+ return languageByExtension[extension];
1545
1655
  }
1546
1656
 
1547
1657
  function buildStudioPdfOutputPath(sourcePath: string): string {
@@ -1553,6 +1663,32 @@ function buildStudioPdfOutputPath(sourcePath: string): string {
1553
1663
  return join(sourceDir, `${outputStem}.studio.pdf`);
1554
1664
  }
1555
1665
 
1666
+ function buildStudioHtmlOutputPath(sourcePath: string): string {
1667
+ const sourceDir = dirname(sourcePath);
1668
+ const sourceName = basename(sourcePath);
1669
+ const sourceExt = extname(sourceName);
1670
+ const sourceStem = sourceExt ? sourceName.slice(0, -sourceExt.length) : sourceName;
1671
+ const outputStem = sourceStem || sourceName || "studio-export";
1672
+ return join(sourceDir, `${outputStem}.studio.html`);
1673
+ }
1674
+
1675
+ function formatStudioExportTimestamp(date = new Date()): string {
1676
+ const pad = (value: number) => String(value).padStart(2, "0");
1677
+ return [
1678
+ String(date.getFullYear()),
1679
+ pad(date.getMonth() + 1),
1680
+ pad(date.getDate()),
1681
+ "-",
1682
+ pad(date.getHours()),
1683
+ pad(date.getMinutes()),
1684
+ pad(date.getSeconds()),
1685
+ ].join("");
1686
+ }
1687
+
1688
+ function buildStudioResponseExportOutputPath(cwd: string, extension: "pdf" | "html"): string {
1689
+ return join(cwd || process.cwd(), `studio-response-${formatStudioExportTimestamp()}.studio.${extension}`);
1690
+ }
1691
+
1556
1692
  function writeStudioFile(pathArg: string, cwd: string, content: string):
1557
1693
  | { ok: true; label: string; resolvedPath: string }
1558
1694
  | { ok: false; message: string } {
@@ -3160,6 +3296,33 @@ function normalizeStudioEditorLanguage(language: string | undefined): string | u
3160
3296
  return trimmed;
3161
3297
  }
3162
3298
 
3299
+ function stripLeadingStudioHtmlTrivia(text: string): string {
3300
+ let source = String(text ?? "").replace(/^\uFEFF/, "").trimStart();
3301
+ let previous = "";
3302
+ while (source && source !== previous) {
3303
+ previous = source;
3304
+ source = source.replace(/^<!--[\s\S]*?-->\s*/, "").trimStart();
3305
+ }
3306
+ return source;
3307
+ }
3308
+
3309
+ function isStudioHtmlMarkup(text: string): boolean {
3310
+ return /<[A-Za-z][A-Za-z0-9:-]*(?:\s[^<>]*)?>/.test(String(text ?? ""));
3311
+ }
3312
+
3313
+ function isLikelyStandaloneStudioHtml(text: string, editorLanguage?: string): boolean {
3314
+ const source = String(text ?? "");
3315
+ if (!source.trim()) return false;
3316
+ if (parseStudioSingleFencedCodeBlock(source)) return false;
3317
+
3318
+ const leading = stripLeadingStudioHtmlTrivia(source);
3319
+ if (/^<!doctype\s+html\b/i.test(leading)) return true;
3320
+ if (/^<html(?:\s|>|$)/i.test(leading)) return true;
3321
+ if (/^<body(?:\s|>|$)/i.test(leading) && /<\/body\s*>/i.test(leading)) return true;
3322
+
3323
+ return normalizeStudioEditorLanguage(editorLanguage) === "html" && isStudioHtmlMarkup(source);
3324
+ }
3325
+
3163
3326
  function parseStudioSingleFencedCodeBlock(markdown: string): { info: string; content: string } | null {
3164
3327
  const trimmed = markdown.trim();
3165
3328
  if (!trimmed) return null;
@@ -3332,6 +3495,7 @@ function isLikelyRawStudioGitDiff(markdown: string): boolean {
3332
3495
  function inferStudioPdfLanguage(markdown: string, editorLanguage?: string): string | undefined {
3333
3496
  const normalizedEditorLanguage = normalizeStudioEditorLanguage(editorLanguage);
3334
3497
  if (normalizedEditorLanguage) return normalizedEditorLanguage;
3498
+ if (isLikelyStandaloneStudioHtml(markdown)) return "html";
3335
3499
 
3336
3500
  const fenced = parseStudioSingleFencedCodeBlock(markdown);
3337
3501
  if (fenced) {
@@ -3593,7 +3757,7 @@ interface StudioPdfRenderOptions {
3593
3757
  }
3594
3758
 
3595
3759
  interface StudioParsedPdfCommandArgs {
3596
- pathArg: string;
3760
+ pathArg: string | null;
3597
3761
  options: StudioPdfRenderOptions;
3598
3762
  }
3599
3763
 
@@ -3925,7 +4089,6 @@ function parseStudioPdfCommandArgs(args: string): StudioParsedPdfCommandArgs | {
3925
4089
  const parsed = tokenizeStudioCommandArgs(args);
3926
4090
  if (parsed.error) return { error: parsed.error };
3927
4091
  const tokens = parsed.tokens;
3928
- if (tokens.length === 0) return { error: "Missing file path." };
3929
4092
 
3930
4093
  const options: StudioPdfRenderOptions = {};
3931
4094
  let pathArg: string | null = null;
@@ -4031,7 +4194,6 @@ function parseStudioPdfCommandArgs(args: string): StudioParsedPdfCommandArgs | {
4031
4194
  }
4032
4195
  }
4033
4196
 
4034
- if (!pathArg) return { error: "Missing file path." };
4035
4197
  if (options.geometry && (options.margin || options.marginTop || options.marginRight || options.marginBottom || options.marginLeft || options.footskip)) {
4036
4198
  return { error: "Use either --geometry or the --margin/--margin-*/--footskip flags, not both." };
4037
4199
  }
@@ -4489,6 +4651,368 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
4489
4651
  return renderedHtml;
4490
4652
  }
4491
4653
 
4654
+ function escapeStudioRegExpLiteral(text: string): string {
4655
+ return String(text ?? "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4656
+ }
4657
+
4658
+ function parseStudioHtmlPdfBlockOptions(body: string): StudioHtmlPdfBlockOptions {
4659
+ const options: StudioHtmlPdfBlockOptions = { path: "", title: "", caption: "", page: "", height: "" };
4660
+ String(body ?? "").split(/\r?\n/).forEach((line) => {
4661
+ const raw = String(line ?? "").trim();
4662
+ if (!raw || raw.startsWith("#")) return;
4663
+ const match = raw.match(/^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*([\s\S]*)$/);
4664
+ if (match) {
4665
+ const key = String(match[1] ?? "").toLowerCase();
4666
+ const value = stripMatchingPathQuotes(String(match[2] ?? ""));
4667
+ if (key === "path" || key === "src" || key === "file") options.path = value;
4668
+ else if (key === "title") options.title = value;
4669
+ else if (key === "caption") options.caption = value;
4670
+ else if (key === "page") options.page = value;
4671
+ else if (key === "height") options.height = value;
4672
+ return;
4673
+ }
4674
+ if (!options.path) options.path = stripMatchingPathQuotes(raw);
4675
+ });
4676
+ return options;
4677
+ }
4678
+
4679
+ function prepareStudioPdfBlocksForHtml(markdown: string): { markdown: string; blocks: StudioHtmlPdfBlock[] } {
4680
+ const blocks: StudioHtmlPdfBlock[] = [];
4681
+ const prefix = `PISTUDIOHTMLPDF${Date.now().toString(36)}${randomUUID().replace(/-/g, "")}TOKEN`;
4682
+ const source = String(markdown ?? "");
4683
+ const blockPattern = /(^|\n)([ \t]{0,3})(`{3,}|~{3,})[ \t]*studio-pdf[^\n]*\n([\s\S]*?)\n[ \t]*\3[ \t]*(?=\n|$)/g;
4684
+ const nextMarkdown = source.replace(blockPattern, (_match, leadingNewline: string, _indent: string, _fence: string, body: string) => {
4685
+ const placeholder = `${prefix}${blocks.length}`;
4686
+ blocks.push({ placeholder, options: parseStudioHtmlPdfBlockOptions(body) });
4687
+ return `${String(leadingNewline ?? "")}${placeholder}\n`;
4688
+ });
4689
+ return { markdown: nextMarkdown, blocks };
4690
+ }
4691
+
4692
+ function prepareStudioAnnotationMarkersForHtml(markdown: string): { markdown: string; placeholders: StudioHtmlAnnotationPlaceholder[] } {
4693
+ const placeholders: StudioHtmlAnnotationPlaceholder[] = [];
4694
+ if (!hasStudioMarkdownAnnotationMarkers(markdown)) return { markdown: String(markdown ?? ""), placeholders };
4695
+
4696
+ const prefix = `PISTUDIOHTMLANNOT${Date.now().toString(36)}${randomUUID().replace(/-/g, "")}TOKEN`;
4697
+ const prepared = transformStudioMarkdownOutsideFences(markdown, (segment: string) => replaceStudioInlineAnnotationMarkers(segment, (marker: { body?: unknown }) => {
4698
+ const label = normalizeStudioAnnotationText(String(marker.body ?? ""));
4699
+ if (!label) return "";
4700
+ const token = `${prefix}${placeholders.length}`;
4701
+ placeholders.push({ token, text: label, title: `[an: ${label}]` });
4702
+ return token;
4703
+ }));
4704
+ return { markdown: prepared, placeholders };
4705
+ }
4706
+
4707
+ function applyStudioAnnotationPlaceholdersToHtml(html: string, placeholders: StudioHtmlAnnotationPlaceholder[]): string {
4708
+ let transformed = String(html ?? "");
4709
+ for (const placeholder of placeholders) {
4710
+ const tokenPattern = new RegExp(escapeStudioRegExpLiteral(placeholder.token), "g");
4711
+ const markerHtml = `<span class="annotation-preview-marker" title="${escapeStudioHtmlText(placeholder.title)}">${escapeStudioHtmlText(placeholder.text)}</span>`;
4712
+ transformed = transformed.replace(tokenPattern, markerHtml);
4713
+ }
4714
+ return transformed;
4715
+ }
4716
+
4717
+ function normalizeStudioHtmlPdfHeight(value: string): number {
4718
+ const parsed = Number.parseInt(String(value || ""), 10);
4719
+ if (!Number.isFinite(parsed)) return 680;
4720
+ return Math.max(240, Math.min(1400, parsed));
4721
+ }
4722
+
4723
+ function normalizeStudioHtmlPdfPage(value: string): number {
4724
+ const parsed = Number.parseInt(String(value || ""), 10);
4725
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
4726
+ }
4727
+
4728
+ function renderStudioHtmlPdfBlock(block: StudioHtmlPdfBlock, sourcePath: string | undefined, resourcePath: string | undefined): string {
4729
+ const options = block.options;
4730
+ const pdfPath = String(options.path || "").trim();
4731
+ const title = String(options.title || pdfPath || "Embedded PDF").trim();
4732
+ const caption = String(options.caption || "").trim();
4733
+ const height = normalizeStudioHtmlPdfHeight(options.height);
4734
+ const page = normalizeStudioHtmlPdfPage(options.page);
4735
+
4736
+ let iframeSrc = "";
4737
+ let linkHref = "";
4738
+ let error = "";
4739
+ if (!pdfPath) {
4740
+ error = "PDF block needs a local path.";
4741
+ } else {
4742
+ try {
4743
+ const baseDir = resourcePath || (sourcePath ? dirname(sourcePath) : process.cwd());
4744
+ const resolvedPath = resolveStudioPdfResourceFile(pdfPath, baseDir);
4745
+ const pdfBuffer = readFileSync(resolvedPath);
4746
+ iframeSrc = `data:application/pdf;base64,${pdfBuffer.toString("base64")}`;
4747
+ linkHref = pathToFileURL(resolvedPath).href;
4748
+ } catch (readError) {
4749
+ error = `PDF resource unavailable: ${readError instanceof Error ? readError.message : String(readError)}`;
4750
+ }
4751
+ }
4752
+
4753
+ const viewerSrc = iframeSrc && page ? `${iframeSrc}#page=${encodeURIComponent(String(page))}` : iframeSrc;
4754
+ const openLink = linkHref
4755
+ ? `<a class="studio-pdf-card-link" href="${escapeStudioHtmlText(linkHref)}" target="_blank" rel="noopener noreferrer">Open PDF</a>`
4756
+ : "";
4757
+ const captionHtml = caption
4758
+ ? `<div class="studio-pdf-card-caption">${escapeStudioHtmlText(caption)}</div>`
4759
+ : "";
4760
+ const bodyHtml = viewerSrc
4761
+ ? `<iframe class="studio-pdf-frame" src="${escapeStudioHtmlText(viewerSrc)}" title="${escapeStudioHtmlText(title)}" loading="lazy" style="height: ${height}px"></iframe>`
4762
+ : `<div class="studio-pdf-card-error">${escapeStudioHtmlText(error || "PDF resource unavailable.")}</div>`;
4763
+
4764
+ return `<figure class="studio-pdf-card"><figcaption class="studio-pdf-card-header"><div class="studio-pdf-card-title">${escapeStudioHtmlText(title)}</div>${openLink}</figcaption>${captionHtml}${bodyHtml}</figure>`;
4765
+ }
4766
+
4767
+ function renderStudioPdfBlocksInHtml(html: string, blocks: StudioHtmlPdfBlock[], sourcePath: string | undefined, resourcePath: string | undefined): string {
4768
+ let transformed = String(html ?? "");
4769
+ for (const block of blocks) {
4770
+ const replacement = renderStudioHtmlPdfBlock(block, sourcePath, resourcePath);
4771
+ const paragraphPattern = new RegExp(`<p>\\s*${escapeStudioRegExpLiteral(block.placeholder)}\\s*<\\/p>`, "g");
4772
+ transformed = transformed.replace(paragraphPattern, replacement);
4773
+ transformed = transformed.replace(new RegExp(escapeStudioRegExpLiteral(block.placeholder), "g"), replacement);
4774
+ }
4775
+ return transformed;
4776
+ }
4777
+
4778
+ function parseStudioThemeVarsJson(json: string | undefined): Record<string, string> | null {
4779
+ const raw = String(json ?? "").trim();
4780
+ if (!raw) return null;
4781
+ try {
4782
+ const parsed = JSON.parse(raw) as unknown;
4783
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
4784
+ const vars: Record<string, string> = {};
4785
+ for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
4786
+ if ((key === "color-scheme" || key.startsWith("--")) && typeof value === "string") {
4787
+ vars[key] = value;
4788
+ }
4789
+ }
4790
+ return Object.keys(vars).length > 0 ? vars : null;
4791
+ } catch {
4792
+ return null;
4793
+ }
4794
+ }
4795
+
4796
+ function buildStudioCssVarsBlock(vars: Record<string, string>): string {
4797
+ return Object.entries(vars).map(([key, value]) => ` ${key}: ${value};`).join("\n");
4798
+ }
4799
+
4800
+ function buildStudioHtmlExportBaseHref(resourcePath: string | undefined): string {
4801
+ const base = String(resourcePath ?? "").trim();
4802
+ if (!base) return "";
4803
+ try {
4804
+ return pathToFileURL(base.endsWith("/") ? base : `${base}/`).href;
4805
+ } catch {
4806
+ return "";
4807
+ }
4808
+ }
4809
+
4810
+ function buildStudioHtmlMermaidConfig(vars: Record<string, string>): Record<string, unknown> {
4811
+ return {
4812
+ startOnLoad: false,
4813
+ theme: "base",
4814
+ fontFamily: vars["--font-mono"] ?? "ui-monospace, SFMono-Regular, Menlo, monospace",
4815
+ flowchart: {
4816
+ curve: "basis",
4817
+ },
4818
+ themeVariables: {
4819
+ background: vars["--bg"] ?? "#ffffff",
4820
+ primaryColor: vars["--panel-2"] ?? "#f6f8fa",
4821
+ primaryTextColor: vars["--text"] ?? "#111827",
4822
+ primaryBorderColor: vars["--md-codeblock-border"] ?? vars["--border"] ?? "#d0d7de",
4823
+ secondaryColor: vars["--panel"] ?? "#ffffff",
4824
+ secondaryTextColor: vars["--text"] ?? "#111827",
4825
+ secondaryBorderColor: vars["--md-codeblock-border"] ?? vars["--border"] ?? "#d0d7de",
4826
+ tertiaryColor: vars["--panel"] ?? "#ffffff",
4827
+ tertiaryTextColor: vars["--text"] ?? "#111827",
4828
+ tertiaryBorderColor: vars["--md-codeblock-border"] ?? vars["--border"] ?? "#d0d7de",
4829
+ lineColor: vars["--md-quote"] ?? vars["--text"] ?? "#111827",
4830
+ textColor: vars["--text"] ?? "#111827",
4831
+ edgeLabelBackground: vars["--panel-2"] ?? "#f6f8fa",
4832
+ nodeBorder: vars["--md-codeblock-border"] ?? vars["--border"] ?? "#d0d7de",
4833
+ clusterBkg: vars["--panel"] ?? "#ffffff",
4834
+ clusterBorder: vars["--md-codeblock-border"] ?? vars["--border"] ?? "#d0d7de",
4835
+ titleColor: vars["--md-heading"] ?? vars["--text"] ?? "#111827",
4836
+ },
4837
+ };
4838
+ }
4839
+
4840
+ function buildStudioStandaloneHtmlMermaidScript(vars: Record<string, string>): string {
4841
+ const mermaidConfigJson = JSON.stringify(buildStudioHtmlMermaidConfig(vars)).replace(/</g, "\\u003c");
4842
+ return `<script>
4843
+ (() => {
4844
+ const MERMAID_CDN_URL = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
4845
+ const MERMAID_CONFIG = ${mermaidConfigJson};
4846
+
4847
+ function appendMermaidWarning(message) {
4848
+ const documentEl = document.querySelector(".studio-export-document") || document.body;
4849
+ if (!documentEl || documentEl.querySelector(".preview-mermaid-warning")) return;
4850
+ const warningEl = document.createElement("div");
4851
+ warningEl.className = "preview-warning preview-mermaid-warning";
4852
+ warningEl.textContent = message || "Mermaid renderer unavailable. Showing mermaid blocks as code.";
4853
+ documentEl.appendChild(warningEl);
4854
+ }
4855
+
4856
+ function prepareMermaidBlocks() {
4857
+ const preBlocks = Array.from(document.querySelectorAll("pre.mermaid"));
4858
+ preBlocks.forEach((preEl) => {
4859
+ const source = preEl.querySelector("code") ? preEl.querySelector("code").textContent : preEl.textContent;
4860
+ const wrapper = document.createElement("div");
4861
+ wrapper.className = "mermaid-container";
4862
+ const diagramEl = document.createElement("div");
4863
+ diagramEl.className = "mermaid";
4864
+ diagramEl.textContent = source || "";
4865
+ wrapper.appendChild(diagramEl);
4866
+ preEl.replaceWith(wrapper);
4867
+ });
4868
+ return Array.from(document.querySelectorAll(".mermaid"));
4869
+ }
4870
+
4871
+ async function renderMermaid() {
4872
+ const nodes = prepareMermaidBlocks();
4873
+ if (nodes.length === 0) return;
4874
+ try {
4875
+ const module = await import(MERMAID_CDN_URL);
4876
+ const mermaidApi = module && module.default ? module.default : null;
4877
+ if (!mermaidApi) throw new Error("Mermaid module did not expose a default export.");
4878
+ mermaidApi.initialize(MERMAID_CONFIG);
4879
+ await mermaidApi.run({ nodes });
4880
+ } catch (error) {
4881
+ console.error("Mermaid render failed:", error);
4882
+ appendMermaidWarning("Mermaid renderer unavailable. Showing mermaid source text.");
4883
+ }
4884
+ }
4885
+
4886
+ if (document.readyState === "loading") {
4887
+ document.addEventListener("DOMContentLoaded", () => { void renderMermaid(); }, { once: true });
4888
+ } else {
4889
+ void renderMermaid();
4890
+ }
4891
+ })();
4892
+ </script>`;
4893
+ }
4894
+
4895
+ function buildStudioStandaloneHtmlDocument(contentHtml: string, resourcePath: string | undefined, options?: StudioHtmlRenderOptions): string {
4896
+ const title = String(options?.title || "pi Studio preview").trim() || "pi Studio preview";
4897
+ const vars = options?.themeVars ?? buildThemeCssVars(getStudioThemeStyle());
4898
+ const cssVarsBlock = buildStudioCssVarsBlock(vars);
4899
+ const stylesheet = readFileSync(STUDIO_CSS_URL, "utf-8");
4900
+ const mermaidScript = buildStudioStandaloneHtmlMermaidScript(vars);
4901
+ const baseHref = buildStudioHtmlExportBaseHref(resourcePath);
4902
+ const baseTag = baseHref ? ` <base href="${escapeStudioHtmlText(baseHref)}" />\n` : "";
4903
+ const generatedAt = new Date().toISOString();
4904
+ const sourceLabel = String(options?.sourceLabel || "").trim();
4905
+ const sourceMeta = sourceLabel ? ` data-source-label="${escapeStudioHtmlText(sourceLabel)}"` : "";
4906
+ const exportCss = `
4907
+ body.studio-html-export {
4908
+ display: block;
4909
+ min-height: 100%;
4910
+ padding: 0;
4911
+ background: var(--bg);
4912
+ color: var(--text);
4913
+ }
4914
+ body.studio-html-export .studio-export-shell {
4915
+ display: block;
4916
+ flex: none;
4917
+ width: 100%;
4918
+ max-width: 1180px;
4919
+ min-height: auto;
4920
+ margin: 0 auto;
4921
+ padding: 32px clamp(16px, 4vw, 48px) 56px;
4922
+ }
4923
+ body.studio-html-export .studio-export-document {
4924
+ display: block;
4925
+ width: 100%;
4926
+ overflow: visible;
4927
+ padding: 28px;
4928
+ border: 1px solid var(--panel-border);
4929
+ border-radius: 14px;
4930
+ background: var(--panel);
4931
+ box-shadow: var(--panel-shadow);
4932
+ }
4933
+ body.studio-html-export .studio-export-document > :first-child {
4934
+ margin-top: 0;
4935
+ }
4936
+ body.studio-html-export .studio-export-document > :last-child {
4937
+ margin-bottom: 0;
4938
+ }
4939
+ body.studio-html-export .preview-selection-actions,
4940
+ body.studio-html-export .studio-copy-block-btn {
4941
+ display: none !important;
4942
+ }
4943
+ @media print {
4944
+ body.studio-html-export {
4945
+ background: #fff;
4946
+ color: #111;
4947
+ }
4948
+ body.studio-html-export .studio-export-shell {
4949
+ max-width: none;
4950
+ padding: 0;
4951
+ }
4952
+ body.studio-html-export .studio-export-document {
4953
+ border: 0;
4954
+ border-radius: 0;
4955
+ box-shadow: none;
4956
+ padding: 0;
4957
+ }
4958
+ }
4959
+ `;
4960
+
4961
+ return `<!doctype html>
4962
+ <html>
4963
+ <head>
4964
+ <meta charset="utf-8" />
4965
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
4966
+ <meta name="generator" content="pi Studio" />
4967
+ <meta name="pi-studio-exported-at" content="${escapeStudioHtmlText(generatedAt)}" />
4968
+ ${baseTag} <title>${escapeStudioHtmlText(title)}</title>
4969
+ <style>
4970
+ :root {
4971
+ ${cssVarsBlock}
4972
+ }
4973
+ ${stylesheet}
4974
+ ${exportCss}
4975
+ </style>
4976
+ </head>
4977
+ <body class="studio-html-export"${sourceMeta}>
4978
+ <main class="studio-export-shell">
4979
+ <article class="panel-scroll rendered-markdown studio-export-document">
4980
+ ${contentHtml}
4981
+ </article>
4982
+ </main>
4983
+ ${mermaidScript}
4984
+ </body>
4985
+ </html>`;
4986
+ }
4987
+
4988
+ async function renderStudioStandaloneHtmlWithPandoc(
4989
+ markdown: string,
4990
+ isLatex?: boolean,
4991
+ resourcePath?: string,
4992
+ editorLanguage?: string,
4993
+ sourcePath?: string,
4994
+ options?: StudioHtmlRenderOptions,
4995
+ ): Promise<{ html: Buffer; warning?: string }> {
4996
+ const effectiveEditorLanguage = inferStudioPdfLanguage(markdown, editorLanguage);
4997
+ if (!isLatex && isLikelyStandaloneStudioHtml(markdown, effectiveEditorLanguage)) {
4998
+ return { html: Buffer.from(String(markdown ?? ""), "utf-8") };
4999
+ }
5000
+ const source = !isLatex
5001
+ && effectiveEditorLanguage
5002
+ && effectiveEditorLanguage !== "markdown"
5003
+ && effectiveEditorLanguage !== "latex"
5004
+ && !isStudioSingleFencedCodeBlock(markdown)
5005
+ ? wrapStudioCodeAsMarkdown(markdown, effectiveEditorLanguage)
5006
+ : markdown;
5007
+ const annotationPrepared = prepareStudioAnnotationMarkersForHtml(source);
5008
+ const pdfPrepared = prepareStudioPdfBlocksForHtml(annotationPrepared.markdown);
5009
+ let renderedHtml = await renderStudioMarkdownWithPandoc(pdfPrepared.markdown, isLatex, resourcePath, sourcePath);
5010
+ renderedHtml = renderStudioPdfBlocksInHtml(renderedHtml, pdfPrepared.blocks, sourcePath, resourcePath);
5011
+ renderedHtml = applyStudioAnnotationPlaceholdersToHtml(renderedHtml, annotationPrepared.placeholders);
5012
+ const standaloneHtml = buildStudioStandaloneHtmlDocument(renderedHtml, resourcePath, options);
5013
+ return { html: Buffer.from(standaloneHtml, "utf-8") };
5014
+ }
5015
+
4492
5016
  async function renderStudioLiteralTextPdf(text: string, title = "Studio export", options?: StudioPdfRenderOptions): Promise<Buffer> {
4493
5017
  const pdfEngine = process.env.PANDOC_PDF_ENGINE?.trim() || "xelatex";
4494
5018
  const tempDir = join(tmpdir(), `pi-studio-text-pdf-${Date.now()}-${randomUUID()}`);
@@ -5667,8 +6191,23 @@ function parseEntryTimestamp(timestamp: unknown): number {
5667
6191
  return Date.now();
5668
6192
  }
5669
6193
 
6194
+ function getStudioResponseHistoryContentHash(markdown: string): string {
6195
+ return createHash("sha256").update(String(markdown ?? ""), "utf-8").digest("hex").slice(0, 16);
6196
+ }
6197
+
6198
+ function buildStudioResponseHistoryId(contentHash: string, occurrenceIndex: number): string {
6199
+ return `response-${contentHash}-${String(Math.max(0, occurrenceIndex) + 1).padStart(3, "0")}`;
6200
+ }
6201
+
6202
+ function buildNextStudioResponseHistoryId(markdown: string, existingItems: StudioResponseHistoryItem[]): string {
6203
+ const contentHash = getStudioResponseHistoryContentHash(markdown);
6204
+ const occurrenceIndex = existingItems.filter((item) => getStudioResponseHistoryContentHash(item.markdown) === contentHash).length;
6205
+ return buildStudioResponseHistoryId(contentHash, occurrenceIndex);
6206
+ }
6207
+
5670
6208
  function buildResponseHistoryFromEntries(entries: SessionEntry[], limit = RESPONSE_HISTORY_LIMIT): StudioResponseHistoryItem[] {
5671
6209
  const history: StudioResponseHistoryItem[] = [];
6210
+ const occurrenceCountsByHash = new Map<string, number>();
5672
6211
  let lastUserPrompt: string | null = null;
5673
6212
  let pendingPromptDescriptor: StudioPromptDescriptor | null = null;
5674
6213
 
@@ -5700,8 +6239,11 @@ function buildResponseHistoryFromEntries(entries: SessionEntry[], limit = RESPON
5700
6239
  if (!markdown) continue;
5701
6240
  const thinking = extractAssistantThinking(message);
5702
6241
  const promptDescriptor = pendingPromptDescriptor ?? buildStudioPromptDescriptor(lastUserPrompt);
6242
+ const contentHash = getStudioResponseHistoryContentHash(markdown);
6243
+ const occurrenceIndex = occurrenceCountsByHash.get(contentHash) ?? 0;
6244
+ occurrenceCountsByHash.set(contentHash, occurrenceIndex + 1);
5703
6245
  history.push({
5704
- id: typeof (entry as { id?: unknown }).id === "string" ? (entry as { id: string }).id : randomUUID(),
6246
+ id: buildStudioResponseHistoryId(contentHash, occurrenceIndex),
5705
6247
  markdown,
5706
6248
  thinking,
5707
6249
  timestamp: parseEntryTimestamp((entry as { timestamp?: unknown }).timestamp),
@@ -5769,6 +6311,12 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
5769
6311
  if (msg.type === "hello") return { type: "hello" };
5770
6312
  if (msg.type === "ping") return { type: "ping" };
5771
6313
  if (msg.type === "get_latest_response") return { type: "get_latest_response" };
6314
+ if (msg.type === "get_trace_snapshot" && typeof msg.responseHistoryId === "string") {
6315
+ return {
6316
+ type: "get_trace_snapshot",
6317
+ responseHistoryId: msg.responseHistoryId,
6318
+ };
6319
+ }
5772
6320
 
5773
6321
  if (
5774
6322
  msg.type === "critique_request" &&
@@ -6040,6 +6588,68 @@ function createEmptyStudioTraceState(): StudioTraceState {
6040
6588
  };
6041
6589
  }
6042
6590
 
6591
+ function truncateStudioTraceSnapshotText(text: string, maxChars = STUDIO_TRACE_SNAPSHOT_MAX_FIELD_CHARS): { text: string; truncated: boolean } {
6592
+ const value = String(text ?? "");
6593
+ if (value.length <= maxChars) return { text: value, truncated: false };
6594
+ const keepHead = Math.max(0, Math.floor(maxChars * 0.62));
6595
+ const keepTail = Math.max(0, maxChars - keepHead);
6596
+ const omitted = value.length - keepHead - keepTail;
6597
+ return {
6598
+ text: `${value.slice(0, keepHead)}\n\n… ${omitted} chars omitted from saved Working view …\n\n${value.slice(value.length - keepTail)}`,
6599
+ truncated: true,
6600
+ };
6601
+ }
6602
+
6603
+ function createStudioTraceSnapshot(source: StudioTraceState): { traceState: StudioTraceState; truncated: boolean } {
6604
+ let truncated = false;
6605
+ const sourceEntries = Array.isArray(source.entries) ? source.entries : [];
6606
+ const entries = sourceEntries.slice(-STUDIO_TRACE_SNAPSHOT_MAX_ENTRIES).map((entry) => {
6607
+ if (entry.type === "assistant") {
6608
+ const thinking = truncateStudioTraceSnapshotText(entry.thinking);
6609
+ const text = truncateStudioTraceSnapshotText(entry.text);
6610
+ truncated = truncated || thinking.truncated || text.truncated;
6611
+ return {
6612
+ ...entry,
6613
+ thinking: thinking.text,
6614
+ text: text.text,
6615
+ };
6616
+ }
6617
+ const argsSummary = truncateStudioTraceSnapshotText(entry.argsSummary ?? "");
6618
+ const output = truncateStudioTraceSnapshotText(entry.output);
6619
+ truncated = truncated || argsSummary.truncated || output.truncated;
6620
+ return {
6621
+ ...entry,
6622
+ argsSummary: argsSummary.text || null,
6623
+ output: output.text,
6624
+ };
6625
+ });
6626
+ if (sourceEntries.length > entries.length) truncated = true;
6627
+
6628
+ return {
6629
+ traceState: {
6630
+ runId: source.runId,
6631
+ requestId: source.requestId,
6632
+ requestKind: source.requestKind,
6633
+ status: source.status,
6634
+ startedAt: source.startedAt,
6635
+ updatedAt: source.updatedAt,
6636
+ entries,
6637
+ },
6638
+ truncated,
6639
+ };
6640
+ }
6641
+
6642
+ function summarizeStudioTraceSnapshot(traceState: StudioTraceState, truncated = false): StudioTraceSnapshotSummary {
6643
+ return {
6644
+ hasTrace: Array.isArray(traceState.entries) && traceState.entries.length > 0,
6645
+ entryCount: Array.isArray(traceState.entries) ? traceState.entries.length : 0,
6646
+ startedAt: traceState.startedAt,
6647
+ updatedAt: traceState.updatedAt,
6648
+ status: traceState.status,
6649
+ truncated,
6650
+ };
6651
+ }
6652
+
6043
6653
  function sanitizeStudioTraceOutputText(text: string): string {
6044
6654
  return String(text || "")
6045
6655
  .replace(/data:image\/([a-zA-Z0-9.+-]+);base64,[A-Za-z0-9+/=\r\n]+/g, (_match, subtype: string) => `[Image: image/${subtype || "unknown"} data omitted]`)
@@ -6314,6 +6924,23 @@ function sanitizePdfFilename(input: string | undefined): string {
6314
6924
  return `${ensuredExt.slice(0, 156)}.pdf`;
6315
6925
  }
6316
6926
 
6927
+ function sanitizeHtmlFilename(input: string | undefined): string {
6928
+ const fallback = "studio-preview.html";
6929
+ const raw = String(input ?? "").trim();
6930
+ if (!raw) return fallback;
6931
+
6932
+ const noPath = raw.split(/[\\/]/).pop() ?? raw;
6933
+ const cleaned = noPath
6934
+ .replace(/[\x00-\x1f\x7f]+/g, "")
6935
+ .replace(/[<>:"|?*]+/g, "-")
6936
+ .trim();
6937
+ if (!cleaned) return fallback;
6938
+
6939
+ const ensuredExt = /\.html?$/i.test(cleaned) ? cleaned : `${cleaned}.html`;
6940
+ if (ensuredExt.length <= 160) return ensuredExt;
6941
+ return `${ensuredExt.slice(0, 155)}.html`;
6942
+ }
6943
+
6317
6944
  function buildThemeCssVars(style: StudioThemeStyle): Record<string, string> {
6318
6945
  const shadowColor = style.mode === "light"
6319
6946
  ? withAlpha(style.palette.text, 0.10, "rgba(15, 23, 42, 0.08)")
@@ -6753,7 +7380,13 @@ ${cssVarsBlock}
6753
7380
  </div>
6754
7381
  <div class="section-header-actions">
6755
7382
  <button id="rightFocusBtn" class="pane-focus-btn" type="button" title="Show only the response pane. Shortcut: F10 or Cmd/Ctrl+Esc.">Focus pane</button>
6756
- <button id="exportPdfBtn" type="button" title="Export the current right-pane preview as PDF via pandoc + xelatex.">Export right preview as PDF</button>
7383
+ <span id="exportPreviewControls" class="export-preview-controls">
7384
+ <button id="exportPdfBtn" class="export-preview-trigger" type="button" aria-haspopup="menu" aria-expanded="false" title="Choose a format and export the current right-pane preview.">Export right preview</button>
7385
+ <div id="exportPreviewMenu" class="export-preview-menu" role="menu" hidden>
7386
+ <button id="exportPreviewPdfBtn" type="button" role="menuitem" data-export-preview-format="pdf">Export as PDF</button>
7387
+ <button id="exportPreviewHtmlBtn" type="button" role="menuitem" data-export-preview-format="html">Export as HTML</button>
7388
+ </div>
7389
+ </span>
6757
7390
  </div>
6758
7391
  </div>
6759
7392
  <div class="reference-meta">
@@ -6852,6 +7485,7 @@ export default function (pi: ExtensionAPI) {
6852
7485
  let pendingStudioPromptMetadata: StudioPromptDescriptor | null = null;
6853
7486
  let lastStudioResponse: LastStudioResponse | null = null;
6854
7487
  let preparedPdfExports = new Map<string, PreparedStudioPdfExport>();
7488
+ let preparedHtmlExports = new Map<string, PreparedStudioHtmlExport>();
6855
7489
  let initialStudioDocument: InitialStudioDocument | null = null;
6856
7490
  let studioCwd = process.cwd();
6857
7491
  let lastCommandCtx: ExtensionCommandContext | null = null;
@@ -6871,6 +7505,7 @@ export default function (pi: ExtensionAPI) {
6871
7505
  let latestSessionUserPrompt: string | null = null;
6872
7506
  let pendingTurnPrompt: string | null = null;
6873
7507
  let studioTraceState: StudioTraceState = createEmptyStudioTraceState();
7508
+ let studioTraceHistory = new Map<string, { traceState: StudioTraceState; summary: StudioTraceSnapshotSummary }>();
6874
7509
  let activeStudioTraceAssistantEntryId: string | null = null;
6875
7510
  const studioTraceToolEntryIds = new Map<string, string>();
6876
7511
  let contextUsageSnapshot: StudioContextUsageSnapshot = {
@@ -7222,6 +7857,36 @@ export default function (pi: ExtensionAPI) {
7222
7857
  notifyStudioTerminal(message, "info");
7223
7858
  };
7224
7859
 
7860
+ const attachStudioTraceSummariesToHistory = (items: StudioResponseHistoryItem[]): StudioResponseHistoryItem[] => items.map((item) => {
7861
+ const stored = studioTraceHistory.get(item.id);
7862
+ return stored ? { ...item, traceSummary: stored.summary } : item;
7863
+ });
7864
+
7865
+ const pruneStudioTraceHistory = () => {
7866
+ const liveIds = new Set(studioResponseHistory.map((item) => item.id));
7867
+ for (const key of Array.from(studioTraceHistory.keys())) {
7868
+ if (!liveIds.has(key)) studioTraceHistory.delete(key);
7869
+ }
7870
+ while (studioTraceHistory.size > MAX_STUDIO_TRACE_SNAPSHOTS) {
7871
+ const oldestKey = studioTraceHistory.keys().next().value;
7872
+ if (!oldestKey) break;
7873
+ studioTraceHistory.delete(oldestKey);
7874
+ }
7875
+ };
7876
+
7877
+ const storeStudioTraceSnapshotForResponse = (responseHistoryId: string | null | undefined): StudioTraceSnapshotSummary | null => {
7878
+ const id = String(responseHistoryId ?? "").trim();
7879
+ if (!id) return null;
7880
+ if (!Array.isArray(studioTraceState.entries) || studioTraceState.entries.length === 0) return null;
7881
+ const snapshot = createStudioTraceSnapshot(studioTraceState);
7882
+ const summary = summarizeStudioTraceSnapshot(snapshot.traceState, snapshot.truncated);
7883
+ if (!summary.hasTrace) return null;
7884
+ studioTraceHistory.set(id, { traceState: snapshot.traceState, summary });
7885
+ studioResponseHistory = studioResponseHistory.map((item) => item.id === id ? { ...item, traceSummary: summary } : item);
7886
+ pruneStudioTraceHistory();
7887
+ return summary;
7888
+ };
7889
+
7225
7890
  const refreshContextUsage = (
7226
7891
  ctx?: { getContextUsage(): { tokens: number | null; contextWindow: number; percent: number | null } | undefined },
7227
7892
  ): StudioContextUsageSnapshot => {
@@ -7239,7 +7904,8 @@ export default function (pi: ExtensionAPI) {
7239
7904
 
7240
7905
  const syncStudioResponseHistory = (entries: SessionEntry[]) => {
7241
7906
  latestSessionUserPrompt = findLatestUserPrompt(entries);
7242
- studioResponseHistory = buildResponseHistoryFromEntries(entries, RESPONSE_HISTORY_LIMIT);
7907
+ studioResponseHistory = attachStudioTraceSummariesToHistory(buildResponseHistoryFromEntries(entries, RESPONSE_HISTORY_LIMIT));
7908
+ pruneStudioTraceHistory();
7243
7909
  const latest = studioResponseHistory[studioResponseHistory.length - 1];
7244
7910
  if (!latest) {
7245
7911
  lastStudioResponse = null;
@@ -7865,6 +8531,18 @@ export default function (pi: ExtensionAPI) {
7865
8531
  return;
7866
8532
  }
7867
8533
 
8534
+ if (msg.type === "get_trace_snapshot") {
8535
+ const responseHistoryId = String(msg.responseHistoryId ?? "").trim();
8536
+ const stored = responseHistoryId ? studioTraceHistory.get(responseHistoryId) : null;
8537
+ sendToClient(client, {
8538
+ type: "trace_snapshot",
8539
+ responseHistoryId,
8540
+ traceState: stored?.traceState ?? createEmptyStudioTraceState(),
8541
+ summary: stored?.summary ?? summarizeStudioTraceSnapshot(createEmptyStudioTraceState()),
8542
+ });
8543
+ return;
8544
+ }
8545
+
7868
8546
  if (msg.type === "load_git_diff_request") {
7869
8547
  if (!isValidRequestId(msg.requestId)) {
7870
8548
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
@@ -8467,6 +9145,100 @@ export default function (pi: ExtensionAPI) {
8467
9145
  res.end(prepared.pdf);
8468
9146
  };
8469
9147
 
9148
+ const disposePreparedHtmlExport = (entry: PreparedStudioHtmlExport | null | undefined) => {
9149
+ if (!entry?.tempDirPath) return;
9150
+ void rm(entry.tempDirPath, { recursive: true, force: true }).catch(() => undefined);
9151
+ };
9152
+
9153
+ const clearPreparedHtmlExports = () => {
9154
+ for (const entry of preparedHtmlExports.values()) {
9155
+ disposePreparedHtmlExport(entry);
9156
+ }
9157
+ preparedHtmlExports.clear();
9158
+ };
9159
+
9160
+ const prunePreparedHtmlExports = () => {
9161
+ const now = Date.now();
9162
+ for (const [id, entry] of preparedHtmlExports) {
9163
+ if (entry.createdAt + PREPARED_HTML_EXPORT_TTL_MS <= now) {
9164
+ preparedHtmlExports.delete(id);
9165
+ disposePreparedHtmlExport(entry);
9166
+ }
9167
+ }
9168
+ while (preparedHtmlExports.size > MAX_PREPARED_HTML_EXPORTS) {
9169
+ const oldestKey = preparedHtmlExports.keys().next().value;
9170
+ if (!oldestKey) break;
9171
+ const oldestEntry = preparedHtmlExports.get(oldestKey);
9172
+ preparedHtmlExports.delete(oldestKey);
9173
+ disposePreparedHtmlExport(oldestEntry);
9174
+ }
9175
+ };
9176
+
9177
+ const storePreparedHtmlExport = (html: Buffer, filename: string, warning?: string): string => {
9178
+ prunePreparedHtmlExports();
9179
+ const exportId = randomUUID();
9180
+ preparedHtmlExports.set(exportId, {
9181
+ html,
9182
+ filename,
9183
+ warning,
9184
+ createdAt: Date.now(),
9185
+ });
9186
+ return exportId;
9187
+ };
9188
+
9189
+ const ensurePreparedHtmlExportFile = async (exportId: string): Promise<PreparedStudioHtmlExport | null> => {
9190
+ prunePreparedHtmlExports();
9191
+ const entry = preparedHtmlExports.get(exportId);
9192
+ if (!entry) return null;
9193
+ if (entry.filePath && entry.tempDirPath) return entry;
9194
+
9195
+ const tempDirPath = join(tmpdir(), `pi-studio-prepared-html-${Date.now()}-${randomUUID()}`);
9196
+ const filePath = join(tempDirPath, sanitizeHtmlFilename(entry.filename));
9197
+ await mkdir(tempDirPath, { recursive: true });
9198
+ await writeFile(filePath, entry.html);
9199
+ entry.tempDirPath = tempDirPath;
9200
+ entry.filePath = filePath;
9201
+ preparedHtmlExports.set(exportId, entry);
9202
+ return entry;
9203
+ };
9204
+
9205
+ const getPreparedHtmlExport = (exportId: string): PreparedStudioHtmlExport | null => {
9206
+ prunePreparedHtmlExports();
9207
+ return preparedHtmlExports.get(exportId) ?? null;
9208
+ };
9209
+
9210
+ const handlePreparedHtmlDownloadRequest = (requestUrl: URL, res: ServerResponse) => {
9211
+ const exportId = requestUrl.searchParams.get("id") ?? "";
9212
+ if (!exportId) {
9213
+ respondText(res, 400, "Missing HTML export id.");
9214
+ return;
9215
+ }
9216
+
9217
+ const prepared = getPreparedHtmlExport(exportId);
9218
+ if (!prepared) {
9219
+ respondText(res, 404, "HTML export is no longer available. Re-export the document.");
9220
+ return;
9221
+ }
9222
+
9223
+ const safeAsciiName = prepared.filename
9224
+ .replace(/[\x00-\x1f\x7f]/g, "")
9225
+ .replace(/[;"\\]/g, "_")
9226
+ .replace(/\s+/g, " ")
9227
+ .trim() || "studio-preview.html";
9228
+
9229
+ const headers: Record<string, string> = {
9230
+ "Content-Type": "text/html; charset=utf-8",
9231
+ "Cache-Control": "no-store",
9232
+ "X-Content-Type-Options": "nosniff",
9233
+ "Content-Disposition": `inline; filename="${safeAsciiName}"; filename*=UTF-8''${encodeURIComponent(prepared.filename)}`,
9234
+ "Content-Length": String(prepared.html.length),
9235
+ };
9236
+ if (prepared.warning) headers["X-Pi-Studio-Export-Warning"] = prepared.warning;
9237
+
9238
+ res.writeHead(200, headers);
9239
+ res.end(prepared.html);
9240
+ };
9241
+
8470
9242
  const handleScratchpadStateRequest = async (req: IncomingMessage, res: ServerResponse, requestUrl: URL) => {
8471
9243
  const method = (req.method ?? "GET").toUpperCase();
8472
9244
  if (method === "GET") {
@@ -8784,6 +9556,117 @@ export default function (pi: ExtensionAPI) {
8784
9556
  }
8785
9557
  };
8786
9558
 
9559
+ const handleExportHtmlRequest = async (req: IncomingMessage, res: ServerResponse) => {
9560
+ let rawBody = "";
9561
+ try {
9562
+ rawBody = await readRequestBody(req, REQUEST_BODY_MAX_BYTES);
9563
+ } catch (error) {
9564
+ const message = error instanceof Error ? error.message : String(error);
9565
+ const status = message.includes("exceeds") ? 413 : 400;
9566
+ respondJson(res, status, { ok: false, error: message });
9567
+ return;
9568
+ }
9569
+
9570
+ let parsedBody: unknown;
9571
+ try {
9572
+ parsedBody = rawBody ? JSON.parse(rawBody) : {};
9573
+ } catch {
9574
+ respondJson(res, 400, { ok: false, error: "Invalid JSON body." });
9575
+ return;
9576
+ }
9577
+
9578
+ const markdown =
9579
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { markdown?: unknown }).markdown === "string"
9580
+ ? (parsedBody as { markdown: string }).markdown
9581
+ : null;
9582
+ if (markdown === null) {
9583
+ respondJson(res, 400, { ok: false, error: "Missing markdown string in request body." });
9584
+ return;
9585
+ }
9586
+
9587
+ if (markdown.length > HTML_EXPORT_MAX_CHARS) {
9588
+ respondJson(res, 413, {
9589
+ ok: false,
9590
+ error: `HTML export text exceeds ${HTML_EXPORT_MAX_CHARS} characters.`,
9591
+ });
9592
+ return;
9593
+ }
9594
+
9595
+ const sourcePath =
9596
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { sourcePath?: unknown }).sourcePath === "string"
9597
+ ? (parsedBody as { sourcePath: string }).sourcePath
9598
+ : "";
9599
+ const userResourceDir =
9600
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { resourceDir?: unknown }).resourceDir === "string"
9601
+ ? (parsedBody as { resourceDir: string }).resourceDir
9602
+ : "";
9603
+ const resourcePath = resolveStudioBaseDir(sourcePath || undefined, userResourceDir || undefined, studioCwd);
9604
+ const requestedIsLatex =
9605
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { isLatex?: unknown }).isLatex === "boolean"
9606
+ ? (parsedBody as { isLatex: boolean }).isLatex
9607
+ : null;
9608
+ const requestedFilename =
9609
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { filenameHint?: unknown }).filenameHint === "string"
9610
+ ? (parsedBody as { filenameHint: string }).filenameHint
9611
+ : "";
9612
+ const requestedTitle =
9613
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { title?: unknown }).title === "string"
9614
+ ? (parsedBody as { title: string }).title
9615
+ : "";
9616
+ const requestedEditorHtmlLanguage =
9617
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { editorHtmlLanguage?: unknown }).editorHtmlLanguage === "string"
9618
+ ? (parsedBody as { editorHtmlLanguage: string }).editorHtmlLanguage
9619
+ : "";
9620
+ const editorHtmlLanguage = inferStudioPdfLanguage(markdown, requestedEditorHtmlLanguage);
9621
+ const isLatex = editorHtmlLanguage === "latex"
9622
+ || (
9623
+ (editorHtmlLanguage === undefined || editorHtmlLanguage === "markdown")
9624
+ && (requestedIsLatex ?? isLikelyStandaloneLatexPreview(markdown))
9625
+ );
9626
+ const filename = sanitizeHtmlFilename(requestedFilename || (isLatex ? "studio-latex-preview.html" : "studio-preview.html"));
9627
+ const themeVars = parseStudioThemeVarsJson(lastThemeVarsJson) ?? buildThemeCssVars(getStudioThemeStyle(lastCommandCtx?.ui?.theme));
9628
+
9629
+ try {
9630
+ const { html, warning } = await renderStudioStandaloneHtmlWithPandoc(
9631
+ markdown,
9632
+ isLatex,
9633
+ resourcePath,
9634
+ editorHtmlLanguage,
9635
+ sourcePath || undefined,
9636
+ {
9637
+ title: requestedTitle || filename,
9638
+ sourceLabel: sourcePath || userResourceDir || "right preview",
9639
+ themeVars,
9640
+ },
9641
+ );
9642
+ const exportId = storePreparedHtmlExport(html, filename, warning);
9643
+ const token = serverState?.token ?? "";
9644
+ let openedExternal = false;
9645
+ let openError: string | null = null;
9646
+ try {
9647
+ const prepared = await ensurePreparedHtmlExportFile(exportId);
9648
+ if (!prepared?.filePath) {
9649
+ throw new Error("Prepared HTML file was not available for external open.");
9650
+ }
9651
+ await openPathInDefaultViewer(prepared.filePath);
9652
+ openedExternal = true;
9653
+ } catch (viewerError) {
9654
+ openError = viewerError instanceof Error ? viewerError.message : String(viewerError);
9655
+ }
9656
+ respondJson(res, 200, {
9657
+ ok: true,
9658
+ filename,
9659
+ warning: warning ?? null,
9660
+ openedExternal,
9661
+ openError,
9662
+ downloadUrl: `/export-html?token=${encodeURIComponent(token)}&id=${encodeURIComponent(exportId)}`,
9663
+ });
9664
+ } catch (error) {
9665
+ const message = error instanceof Error ? error.message : String(error);
9666
+ respondJson(res, 500, { ok: false, error: `HTML export failed: ${message}` });
9667
+ }
9668
+ };
9669
+
8787
9670
  const handleHttpRequest = (req: IncomingMessage, res: ServerResponse) => {
8788
9671
  if (!serverState) {
8789
9672
  respondText(res, 503, "Studio server not ready");
@@ -8975,6 +9858,38 @@ export default function (pi: ExtensionAPI) {
8975
9858
  return;
8976
9859
  }
8977
9860
 
9861
+ if (requestUrl.pathname === "/export-html") {
9862
+ const token = requestUrl.searchParams.get("token") ?? "";
9863
+ if (token !== serverState.token) {
9864
+ const method = (req.method ?? "GET").toUpperCase();
9865
+ if (method === "GET") {
9866
+ respondText(res, 403, "Invalid or expired studio token. Re-run /studio.");
9867
+ } else {
9868
+ respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
9869
+ }
9870
+ return;
9871
+ }
9872
+
9873
+ const method = (req.method ?? "GET").toUpperCase();
9874
+ if (method === "GET") {
9875
+ handlePreparedHtmlDownloadRequest(requestUrl, res);
9876
+ return;
9877
+ }
9878
+ if (method !== "POST") {
9879
+ res.setHeader("Allow", "GET, POST");
9880
+ respondJson(res, 405, { ok: false, error: "Method not allowed. Use GET or POST." });
9881
+ return;
9882
+ }
9883
+
9884
+ void handleExportHtmlRequest(req, res).catch((error) => {
9885
+ respondJson(res, 500, {
9886
+ ok: false,
9887
+ error: `HTML export failed: ${error instanceof Error ? error.message : String(error)}`,
9888
+ });
9889
+ });
9890
+ return;
9891
+ }
9892
+
8978
9893
  if (requestUrl.pathname === "/pdf-resource") {
8979
9894
  const token = requestUrl.searchParams.get("token") ?? "";
8980
9895
  if (token !== serverState.token) {
@@ -9174,6 +10089,7 @@ export default function (pi: ExtensionAPI) {
9174
10089
  clearActiveRequest();
9175
10090
  clearPendingStudioCompletion();
9176
10091
  clearPreparedPdfExports();
10092
+ clearPreparedHtmlExports();
9177
10093
  clearCompactionState();
9178
10094
  closeAllClients(1001, "Server shutting down");
9179
10095
 
@@ -9199,6 +10115,7 @@ export default function (pi: ExtensionAPI) {
9199
10115
  clearStudioDirectRunState();
9200
10116
  if (isSessionReplacement) {
9201
10117
  clearActiveRequest({ notify: "Session switched. Studio request state cleared.", level: "warning" });
10118
+ studioTraceHistory.clear();
9202
10119
  lastCommandCtx = null;
9203
10120
  }
9204
10121
  hydrateLatestAssistant(ctx.sessionManager.getBranch());
@@ -9206,6 +10123,7 @@ export default function (pi: ExtensionAPI) {
9206
10123
  agentBusy = false;
9207
10124
  clearPendingStudioCompletion();
9208
10125
  clearPreparedPdfExports();
10126
+ clearPreparedHtmlExports();
9209
10127
  refreshRuntimeMetadata({ cwd: ctx.cwd, model: ctx.model });
9210
10128
  refreshContextUsage(ctx);
9211
10129
  emitDebugEvent("session_start", {
@@ -9382,7 +10300,7 @@ export default function (pi: ExtensionAPI) {
9382
10300
  ? getPromptDescriptorForActiveRequest(activeRequest)
9383
10301
  : buildStudioPromptDescriptor(pendingTurnPrompt ?? latestSessionUserPrompt ?? null);
9384
10302
  const fallbackHistoryItem: StudioResponseHistoryItem = {
9385
- id: randomUUID(),
10303
+ id: buildNextStudioResponseHistoryId(markdown, studioResponseHistory),
9386
10304
  markdown,
9387
10305
  thinking,
9388
10306
  timestamp: Date.now(),
@@ -9401,6 +10319,10 @@ export default function (pi: ExtensionAPI) {
9401
10319
  const responseTimestamp = latestItem?.timestamp ?? Date.now();
9402
10320
  const responseThinking = latestItem?.thinking ?? thinking ?? null;
9403
10321
  pendingTurnPrompt = null;
10322
+ setStudioTraceRunStatus("complete");
10323
+ if (latestItem) {
10324
+ storeStudioTraceSnapshotForResponse(latestItem.id);
10325
+ }
9404
10326
 
9405
10327
  if (activeRequest) {
9406
10328
  const requestId = activeRequest.id;
@@ -9498,6 +10420,8 @@ export default function (pi: ExtensionAPI) {
9498
10420
  clearStudioDirectRunState();
9499
10421
  clearPendingStudioCompletion();
9500
10422
  clearPreparedPdfExports();
10423
+ clearPreparedHtmlExports();
10424
+ studioTraceHistory.clear();
9501
10425
  transientStudioDocuments.clear();
9502
10426
  clearCompactionState();
9503
10427
  clearStudioTrace();
@@ -9594,6 +10518,17 @@ export default function (pi: ExtensionAPI) {
9594
10518
  };
9595
10519
  };
9596
10520
 
10521
+ const resolveLastModelResponseForExport = (ctx: ExtensionCommandContext): { markdown: string } | null => {
10522
+ const branchEntries = ctx.sessionManager.getBranch();
10523
+ syncStudioResponseHistory(branchEntries);
10524
+ const markdown =
10525
+ extractLatestAssistantFromEntries(branchEntries)
10526
+ ?? extractLatestAssistantFromEntries(ctx.sessionManager.getEntries())
10527
+ ?? lastStudioResponse?.markdown
10528
+ ?? "";
10529
+ return markdown.trim() ? { markdown } : null;
10530
+ };
10531
+
9597
10532
  const openStudioView = async (
9598
10533
  trimmed: string,
9599
10534
  ctx: ExtensionCommandContext,
@@ -9700,7 +10635,8 @@ export default function (pi: ExtensionAPI) {
9700
10635
  + " /studio-replace [path] Replace the current full Studio view with a new one\n"
9701
10636
  + " /studio-editor-only [path] Open another Studio tab in editor-only mode\n"
9702
10637
  + " /studio-current <path> Load a file into currently open Studio tab(s)\n"
9703
- + " /studio-pdf <path> Export a file to <name>.studio.pdf via Studio PDF",
10638
+ + " /studio-pdf [path] Export a file or last response via Studio PDF\n"
10639
+ + " /studio-html [path] Export a file or last response via Studio preview HTML",
9704
10640
  "info",
9705
10641
  );
9706
10642
  return;
@@ -9757,13 +10693,14 @@ export default function (pi: ExtensionAPI) {
9757
10693
  });
9758
10694
 
9759
10695
  pi.registerCommand("studio-pdf", {
9760
- description: "Export a file to PDF via the Studio PDF pipeline (/studio-pdf <file>)",
10696
+ description: "Export a file or the last model response to PDF via the Studio PDF pipeline (/studio-pdf [file])",
9761
10697
  handler: async (args: string, ctx: ExtensionCommandContext) => {
9762
10698
  const trimmed = args.trim();
9763
- if (!trimmed || trimmed === "help" || trimmed === "--help" || trimmed === "-h") {
10699
+ if (trimmed === "help" || trimmed === "--help" || trimmed === "-h") {
9764
10700
  ctx.ui.notify(
9765
- "Usage: /studio-pdf <path> [options]\n"
9766
- + " Export a local Markdown/LaTeX file to <name>.studio.pdf using the Studio PDF pipeline.\n"
10701
+ "Usage: /studio-pdf [path] [options]\n"
10702
+ + " Without a path, export the last model response to studio-response-<timestamp>.studio.pdf.\n"
10703
+ + " With a path, export a local Markdown/LaTeX/code file to <name>.studio.pdf using the Studio PDF pipeline.\n"
9767
10704
  + "Options:\n"
9768
10705
  + " --fontsize <value> e.g. 12pt\n"
9769
10706
  + " --section-size <value> e.g. 24pt\n"
@@ -9796,6 +10733,61 @@ export default function (pi: ExtensionAPI) {
9796
10733
  }
9797
10734
  const { pathArg, options: pdfOptions } = parsedArgs;
9798
10735
 
10736
+ if (!pathArg) {
10737
+ await ctx.waitForIdle();
10738
+ const response = resolveLastModelResponseForExport(ctx);
10739
+ if (!response) {
10740
+ ctx.ui.notify("No last model response to export. Use /studio-pdf <path> or run a prompt first.", "warning");
10741
+ return;
10742
+ }
10743
+ if (response.markdown.length > PDF_EXPORT_MAX_CHARS) {
10744
+ ctx.ui.notify(`PDF export text exceeds ${PDF_EXPORT_MAX_CHARS} characters.`, "error");
10745
+ return;
10746
+ }
10747
+
10748
+ const editorPdfLanguage = inferStudioPdfLanguage(response.markdown);
10749
+ const isLatex = editorPdfLanguage === "latex"
10750
+ || (
10751
+ (editorPdfLanguage === undefined || editorPdfLanguage === "markdown")
10752
+ && /\\documentclass\b|\\begin\{document\}/.test(response.markdown)
10753
+ );
10754
+ const resourcePath = resolveStudioBaseDir(undefined, undefined, ctx.cwd);
10755
+ const outputPath = buildStudioResponseExportOutputPath(ctx.cwd, "pdf");
10756
+
10757
+ try {
10758
+ const { pdf, warning } = await renderStudioPdfWithPandoc(
10759
+ response.markdown,
10760
+ isLatex,
10761
+ resourcePath,
10762
+ editorPdfLanguage,
10763
+ undefined,
10764
+ pdfOptions,
10765
+ );
10766
+ await writeFile(outputPath, pdf);
10767
+
10768
+ let openError: string | null = null;
10769
+ try {
10770
+ await openPathInDefaultViewer(outputPath);
10771
+ } catch (error) {
10772
+ openError = error instanceof Error ? error.message : String(error);
10773
+ }
10774
+
10775
+ ctx.ui.notify(`Exported last response Studio PDF: ${outputPath}`, "info");
10776
+ if (warning) {
10777
+ ctx.ui.notify(warning, "warning");
10778
+ }
10779
+ if (openError) {
10780
+ ctx.ui.notify(`PDF was exported but could not be opened automatically: ${openError}`, "warning");
10781
+ }
10782
+ } catch (error) {
10783
+ ctx.ui.notify(
10784
+ `Studio PDF export failed for last response: ${error instanceof Error ? error.message : String(error)}`,
10785
+ "error",
10786
+ );
10787
+ }
10788
+ return;
10789
+ }
10790
+
9799
10791
  const file = readStudioFile(pathArg, ctx.cwd);
9800
10792
  if (file.ok === false) {
9801
10793
  ctx.ui.notify(file.message, "error");
@@ -9853,6 +10845,148 @@ export default function (pi: ExtensionAPI) {
9853
10845
  },
9854
10846
  });
9855
10847
 
10848
+ pi.registerCommand("studio-html", {
10849
+ description: "Export a file or the last model response to standalone HTML via the Studio preview pipeline (/studio-html [file])",
10850
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
10851
+ const trimmed = args.trim();
10852
+ if (trimmed === "help" || trimmed === "--help" || trimmed === "-h") {
10853
+ ctx.ui.notify(
10854
+ "Usage: /studio-html [path]\n"
10855
+ + " Without a path, export the last model response to studio-response-<timestamp>.studio.html.\n"
10856
+ + " With a path, export a local Markdown/LaTeX/code file to <name>.studio.html using the Studio preview HTML pipeline.",
10857
+ "info",
10858
+ );
10859
+ return;
10860
+ }
10861
+
10862
+ if (!trimmed) {
10863
+ await ctx.waitForIdle();
10864
+ const response = resolveLastModelResponseForExport(ctx);
10865
+ if (!response) {
10866
+ ctx.ui.notify("No last model response to export. Use /studio-html <path> or run a prompt first.", "warning");
10867
+ return;
10868
+ }
10869
+ if (response.markdown.length > HTML_EXPORT_MAX_CHARS) {
10870
+ ctx.ui.notify(`HTML export text exceeds ${HTML_EXPORT_MAX_CHARS} characters.`, "error");
10871
+ return;
10872
+ }
10873
+
10874
+ const editorHtmlLanguage = inferStudioPdfLanguage(response.markdown);
10875
+ const isLatex = editorHtmlLanguage === "latex"
10876
+ || (
10877
+ (editorHtmlLanguage === undefined || editorHtmlLanguage === "markdown")
10878
+ && isLikelyStandaloneLatexPreview(response.markdown)
10879
+ );
10880
+ const resourcePath = resolveStudioBaseDir(undefined, undefined, ctx.cwd);
10881
+ const outputPath = buildStudioResponseExportOutputPath(ctx.cwd, "html");
10882
+ const themeVars = buildThemeCssVars(getStudioThemeStyle(ctx.ui.theme));
10883
+
10884
+ try {
10885
+ const { html, warning } = await renderStudioStandaloneHtmlWithPandoc(
10886
+ response.markdown,
10887
+ isLatex,
10888
+ resourcePath,
10889
+ editorHtmlLanguage,
10890
+ undefined,
10891
+ {
10892
+ title: basename(outputPath),
10893
+ sourceLabel: "last model response",
10894
+ themeVars,
10895
+ },
10896
+ );
10897
+ await writeFile(outputPath, html);
10898
+
10899
+ let openError: string | null = null;
10900
+ try {
10901
+ await openPathInDefaultViewer(outputPath);
10902
+ } catch (error) {
10903
+ openError = error instanceof Error ? error.message : String(error);
10904
+ }
10905
+
10906
+ ctx.ui.notify(`Exported last response Studio HTML: ${outputPath}`, "info");
10907
+ if (warning) {
10908
+ ctx.ui.notify(warning, "warning");
10909
+ }
10910
+ if (openError) {
10911
+ ctx.ui.notify(`HTML was exported but could not be opened automatically: ${openError}`, "warning");
10912
+ }
10913
+ } catch (error) {
10914
+ ctx.ui.notify(
10915
+ `Studio HTML export failed for last response: ${error instanceof Error ? error.message : String(error)}`,
10916
+ "error",
10917
+ );
10918
+ }
10919
+ return;
10920
+ }
10921
+
10922
+ const pathArg = parsePathArgument(trimmed);
10923
+ if (!pathArg) {
10924
+ ctx.ui.notify("Invalid file path argument.", "error");
10925
+ return;
10926
+ }
10927
+
10928
+ const file = readStudioFile(pathArg, ctx.cwd);
10929
+ if (file.ok === false) {
10930
+ ctx.ui.notify(file.message, "error");
10931
+ return;
10932
+ }
10933
+
10934
+ if (file.text.length > HTML_EXPORT_MAX_CHARS) {
10935
+ ctx.ui.notify(`HTML export text exceeds ${HTML_EXPORT_MAX_CHARS} characters.`, "error");
10936
+ return;
10937
+ }
10938
+
10939
+ await ctx.waitForIdle();
10940
+ const pathHtmlLanguage = inferStudioPdfLanguageFromPath(file.resolvedPath);
10941
+ const editorHtmlLanguage = pathHtmlLanguage ?? inferStudioPdfLanguage(file.text);
10942
+ const isLatex = editorHtmlLanguage === "latex"
10943
+ || (
10944
+ !pathHtmlLanguage
10945
+ && (editorHtmlLanguage === undefined || editorHtmlLanguage === "markdown")
10946
+ && isLikelyStandaloneLatexPreview(file.text)
10947
+ );
10948
+ const resourcePath = resolveStudioBaseDir(file.resolvedPath, undefined, ctx.cwd);
10949
+ const outputPath = buildStudioHtmlOutputPath(file.resolvedPath);
10950
+ const themeVars = buildThemeCssVars(getStudioThemeStyle(ctx.ui.theme));
10951
+
10952
+ try {
10953
+ const { html, warning } = await renderStudioStandaloneHtmlWithPandoc(
10954
+ file.text,
10955
+ isLatex,
10956
+ resourcePath,
10957
+ editorHtmlLanguage,
10958
+ file.resolvedPath,
10959
+ {
10960
+ title: basename(outputPath),
10961
+ sourceLabel: file.resolvedPath,
10962
+ themeVars,
10963
+ },
10964
+ );
10965
+ await writeFile(outputPath, html);
10966
+
10967
+ let openError: string | null = null;
10968
+ try {
10969
+ await openPathInDefaultViewer(outputPath);
10970
+ } catch (error) {
10971
+ openError = error instanceof Error ? error.message : String(error);
10972
+ }
10973
+
10974
+ ctx.ui.notify(`Exported Studio HTML: ${outputPath}`, "info");
10975
+ if (warning) {
10976
+ ctx.ui.notify(warning, "warning");
10977
+ }
10978
+ if (openError) {
10979
+ ctx.ui.notify(`HTML was exported but could not be opened automatically: ${openError}`, "warning");
10980
+ }
10981
+ } catch (error) {
10982
+ ctx.ui.notify(
10983
+ `Studio HTML export failed for ${file.label}: ${error instanceof Error ? error.message : String(error)}`,
10984
+ "error",
10985
+ );
10986
+ }
10987
+ },
10988
+ });
10989
+
9856
10990
  pi.registerCommand("studio-current", {
9857
10991
  description: "Load a file into current open Studio tab(s) without opening a new browser session",
9858
10992
  handler: async (args: string, ctx: ExtensionCommandContext) => {