pi-studio 0.7.0 → 0.8.0

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
- import type { ExtensionAPI, ExtensionCommandContext, SessionEntry, Theme } from "@mariozechner/pi-coding-agent";
2
- import { getAgentDir } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionCommandContext, SessionEntry, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
3
3
  import { spawn, spawnSync } from "node:child_process";
4
- 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 } {
@@ -3593,7 +3729,7 @@ interface StudioPdfRenderOptions {
3593
3729
  }
3594
3730
 
3595
3731
  interface StudioParsedPdfCommandArgs {
3596
- pathArg: string;
3732
+ pathArg: string | null;
3597
3733
  options: StudioPdfRenderOptions;
3598
3734
  }
3599
3735
 
@@ -3925,7 +4061,6 @@ function parseStudioPdfCommandArgs(args: string): StudioParsedPdfCommandArgs | {
3925
4061
  const parsed = tokenizeStudioCommandArgs(args);
3926
4062
  if (parsed.error) return { error: parsed.error };
3927
4063
  const tokens = parsed.tokens;
3928
- if (tokens.length === 0) return { error: "Missing file path." };
3929
4064
 
3930
4065
  const options: StudioPdfRenderOptions = {};
3931
4066
  let pathArg: string | null = null;
@@ -4031,7 +4166,6 @@ function parseStudioPdfCommandArgs(args: string): StudioParsedPdfCommandArgs | {
4031
4166
  }
4032
4167
  }
4033
4168
 
4034
- if (!pathArg) return { error: "Missing file path." };
4035
4169
  if (options.geometry && (options.margin || options.marginTop || options.marginRight || options.marginBottom || options.marginLeft || options.footskip)) {
4036
4170
  return { error: "Use either --geometry or the --margin/--margin-*/--footskip flags, not both." };
4037
4171
  }
@@ -4489,6 +4623,365 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
4489
4623
  return renderedHtml;
4490
4624
  }
4491
4625
 
4626
+ function escapeStudioRegExpLiteral(text: string): string {
4627
+ return String(text ?? "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4628
+ }
4629
+
4630
+ function parseStudioHtmlPdfBlockOptions(body: string): StudioHtmlPdfBlockOptions {
4631
+ const options: StudioHtmlPdfBlockOptions = { path: "", title: "", caption: "", page: "", height: "" };
4632
+ String(body ?? "").split(/\r?\n/).forEach((line) => {
4633
+ const raw = String(line ?? "").trim();
4634
+ if (!raw || raw.startsWith("#")) return;
4635
+ const match = raw.match(/^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*([\s\S]*)$/);
4636
+ if (match) {
4637
+ const key = String(match[1] ?? "").toLowerCase();
4638
+ const value = stripMatchingPathQuotes(String(match[2] ?? ""));
4639
+ if (key === "path" || key === "src" || key === "file") options.path = value;
4640
+ else if (key === "title") options.title = value;
4641
+ else if (key === "caption") options.caption = value;
4642
+ else if (key === "page") options.page = value;
4643
+ else if (key === "height") options.height = value;
4644
+ return;
4645
+ }
4646
+ if (!options.path) options.path = stripMatchingPathQuotes(raw);
4647
+ });
4648
+ return options;
4649
+ }
4650
+
4651
+ function prepareStudioPdfBlocksForHtml(markdown: string): { markdown: string; blocks: StudioHtmlPdfBlock[] } {
4652
+ const blocks: StudioHtmlPdfBlock[] = [];
4653
+ const prefix = `PISTUDIOHTMLPDF${Date.now().toString(36)}${randomUUID().replace(/-/g, "")}TOKEN`;
4654
+ const source = String(markdown ?? "");
4655
+ const blockPattern = /(^|\n)([ \t]{0,3})(`{3,}|~{3,})[ \t]*studio-pdf[^\n]*\n([\s\S]*?)\n[ \t]*\3[ \t]*(?=\n|$)/g;
4656
+ const nextMarkdown = source.replace(blockPattern, (_match, leadingNewline: string, _indent: string, _fence: string, body: string) => {
4657
+ const placeholder = `${prefix}${blocks.length}`;
4658
+ blocks.push({ placeholder, options: parseStudioHtmlPdfBlockOptions(body) });
4659
+ return `${String(leadingNewline ?? "")}${placeholder}\n`;
4660
+ });
4661
+ return { markdown: nextMarkdown, blocks };
4662
+ }
4663
+
4664
+ function prepareStudioAnnotationMarkersForHtml(markdown: string): { markdown: string; placeholders: StudioHtmlAnnotationPlaceholder[] } {
4665
+ const placeholders: StudioHtmlAnnotationPlaceholder[] = [];
4666
+ if (!hasStudioMarkdownAnnotationMarkers(markdown)) return { markdown: String(markdown ?? ""), placeholders };
4667
+
4668
+ const prefix = `PISTUDIOHTMLANNOT${Date.now().toString(36)}${randomUUID().replace(/-/g, "")}TOKEN`;
4669
+ const prepared = transformStudioMarkdownOutsideFences(markdown, (segment: string) => replaceStudioInlineAnnotationMarkers(segment, (marker: { body?: unknown }) => {
4670
+ const label = normalizeStudioAnnotationText(String(marker.body ?? ""));
4671
+ if (!label) return "";
4672
+ const token = `${prefix}${placeholders.length}`;
4673
+ placeholders.push({ token, text: label, title: `[an: ${label}]` });
4674
+ return token;
4675
+ }));
4676
+ return { markdown: prepared, placeholders };
4677
+ }
4678
+
4679
+ function applyStudioAnnotationPlaceholdersToHtml(html: string, placeholders: StudioHtmlAnnotationPlaceholder[]): string {
4680
+ let transformed = String(html ?? "");
4681
+ for (const placeholder of placeholders) {
4682
+ const tokenPattern = new RegExp(escapeStudioRegExpLiteral(placeholder.token), "g");
4683
+ const markerHtml = `<span class="annotation-preview-marker" title="${escapeStudioHtmlText(placeholder.title)}">${escapeStudioHtmlText(placeholder.text)}</span>`;
4684
+ transformed = transformed.replace(tokenPattern, markerHtml);
4685
+ }
4686
+ return transformed;
4687
+ }
4688
+
4689
+ function normalizeStudioHtmlPdfHeight(value: string): number {
4690
+ const parsed = Number.parseInt(String(value || ""), 10);
4691
+ if (!Number.isFinite(parsed)) return 680;
4692
+ return Math.max(240, Math.min(1400, parsed));
4693
+ }
4694
+
4695
+ function normalizeStudioHtmlPdfPage(value: string): number {
4696
+ const parsed = Number.parseInt(String(value || ""), 10);
4697
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
4698
+ }
4699
+
4700
+ function renderStudioHtmlPdfBlock(block: StudioHtmlPdfBlock, sourcePath: string | undefined, resourcePath: string | undefined): string {
4701
+ const options = block.options;
4702
+ const pdfPath = String(options.path || "").trim();
4703
+ const title = String(options.title || pdfPath || "Embedded PDF").trim();
4704
+ const caption = String(options.caption || "").trim();
4705
+ const height = normalizeStudioHtmlPdfHeight(options.height);
4706
+ const page = normalizeStudioHtmlPdfPage(options.page);
4707
+
4708
+ let iframeSrc = "";
4709
+ let linkHref = "";
4710
+ let error = "";
4711
+ if (!pdfPath) {
4712
+ error = "PDF block needs a local path.";
4713
+ } else {
4714
+ try {
4715
+ const baseDir = resourcePath || (sourcePath ? dirname(sourcePath) : process.cwd());
4716
+ const resolvedPath = resolveStudioPdfResourceFile(pdfPath, baseDir);
4717
+ const pdfBuffer = readFileSync(resolvedPath);
4718
+ iframeSrc = `data:application/pdf;base64,${pdfBuffer.toString("base64")}`;
4719
+ linkHref = pathToFileURL(resolvedPath).href;
4720
+ } catch (readError) {
4721
+ error = `PDF resource unavailable: ${readError instanceof Error ? readError.message : String(readError)}`;
4722
+ }
4723
+ }
4724
+
4725
+ const viewerSrc = iframeSrc && page ? `${iframeSrc}#page=${encodeURIComponent(String(page))}` : iframeSrc;
4726
+ const openLink = linkHref
4727
+ ? `<a class="studio-pdf-card-link" href="${escapeStudioHtmlText(linkHref)}" target="_blank" rel="noopener noreferrer">Open PDF</a>`
4728
+ : "";
4729
+ const captionHtml = caption
4730
+ ? `<div class="studio-pdf-card-caption">${escapeStudioHtmlText(caption)}</div>`
4731
+ : "";
4732
+ const bodyHtml = viewerSrc
4733
+ ? `<iframe class="studio-pdf-frame" src="${escapeStudioHtmlText(viewerSrc)}" title="${escapeStudioHtmlText(title)}" loading="lazy" style="height: ${height}px"></iframe>`
4734
+ : `<div class="studio-pdf-card-error">${escapeStudioHtmlText(error || "PDF resource unavailable.")}</div>`;
4735
+
4736
+ 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>`;
4737
+ }
4738
+
4739
+ function renderStudioPdfBlocksInHtml(html: string, blocks: StudioHtmlPdfBlock[], sourcePath: string | undefined, resourcePath: string | undefined): string {
4740
+ let transformed = String(html ?? "");
4741
+ for (const block of blocks) {
4742
+ const replacement = renderStudioHtmlPdfBlock(block, sourcePath, resourcePath);
4743
+ const paragraphPattern = new RegExp(`<p>\\s*${escapeStudioRegExpLiteral(block.placeholder)}\\s*<\\/p>`, "g");
4744
+ transformed = transformed.replace(paragraphPattern, replacement);
4745
+ transformed = transformed.replace(new RegExp(escapeStudioRegExpLiteral(block.placeholder), "g"), replacement);
4746
+ }
4747
+ return transformed;
4748
+ }
4749
+
4750
+ function parseStudioThemeVarsJson(json: string | undefined): Record<string, string> | null {
4751
+ const raw = String(json ?? "").trim();
4752
+ if (!raw) return null;
4753
+ try {
4754
+ const parsed = JSON.parse(raw) as unknown;
4755
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
4756
+ const vars: Record<string, string> = {};
4757
+ for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
4758
+ if ((key === "color-scheme" || key.startsWith("--")) && typeof value === "string") {
4759
+ vars[key] = value;
4760
+ }
4761
+ }
4762
+ return Object.keys(vars).length > 0 ? vars : null;
4763
+ } catch {
4764
+ return null;
4765
+ }
4766
+ }
4767
+
4768
+ function buildStudioCssVarsBlock(vars: Record<string, string>): string {
4769
+ return Object.entries(vars).map(([key, value]) => ` ${key}: ${value};`).join("\n");
4770
+ }
4771
+
4772
+ function buildStudioHtmlExportBaseHref(resourcePath: string | undefined): string {
4773
+ const base = String(resourcePath ?? "").trim();
4774
+ if (!base) return "";
4775
+ try {
4776
+ return pathToFileURL(base.endsWith("/") ? base : `${base}/`).href;
4777
+ } catch {
4778
+ return "";
4779
+ }
4780
+ }
4781
+
4782
+ function buildStudioHtmlMermaidConfig(vars: Record<string, string>): Record<string, unknown> {
4783
+ return {
4784
+ startOnLoad: false,
4785
+ theme: "base",
4786
+ fontFamily: vars["--font-mono"] ?? "ui-monospace, SFMono-Regular, Menlo, monospace",
4787
+ flowchart: {
4788
+ curve: "basis",
4789
+ },
4790
+ themeVariables: {
4791
+ background: vars["--bg"] ?? "#ffffff",
4792
+ primaryColor: vars["--panel-2"] ?? "#f6f8fa",
4793
+ primaryTextColor: vars["--text"] ?? "#111827",
4794
+ primaryBorderColor: vars["--md-codeblock-border"] ?? vars["--border"] ?? "#d0d7de",
4795
+ secondaryColor: vars["--panel"] ?? "#ffffff",
4796
+ secondaryTextColor: vars["--text"] ?? "#111827",
4797
+ secondaryBorderColor: vars["--md-codeblock-border"] ?? vars["--border"] ?? "#d0d7de",
4798
+ tertiaryColor: vars["--panel"] ?? "#ffffff",
4799
+ tertiaryTextColor: vars["--text"] ?? "#111827",
4800
+ tertiaryBorderColor: vars["--md-codeblock-border"] ?? vars["--border"] ?? "#d0d7de",
4801
+ lineColor: vars["--md-quote"] ?? vars["--text"] ?? "#111827",
4802
+ textColor: vars["--text"] ?? "#111827",
4803
+ edgeLabelBackground: vars["--panel-2"] ?? "#f6f8fa",
4804
+ nodeBorder: vars["--md-codeblock-border"] ?? vars["--border"] ?? "#d0d7de",
4805
+ clusterBkg: vars["--panel"] ?? "#ffffff",
4806
+ clusterBorder: vars["--md-codeblock-border"] ?? vars["--border"] ?? "#d0d7de",
4807
+ titleColor: vars["--md-heading"] ?? vars["--text"] ?? "#111827",
4808
+ },
4809
+ };
4810
+ }
4811
+
4812
+ function buildStudioStandaloneHtmlMermaidScript(vars: Record<string, string>): string {
4813
+ const mermaidConfigJson = JSON.stringify(buildStudioHtmlMermaidConfig(vars)).replace(/</g, "\\u003c");
4814
+ return `<script>
4815
+ (() => {
4816
+ const MERMAID_CDN_URL = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
4817
+ const MERMAID_CONFIG = ${mermaidConfigJson};
4818
+
4819
+ function appendMermaidWarning(message) {
4820
+ const documentEl = document.querySelector(".studio-export-document") || document.body;
4821
+ if (!documentEl || documentEl.querySelector(".preview-mermaid-warning")) return;
4822
+ const warningEl = document.createElement("div");
4823
+ warningEl.className = "preview-warning preview-mermaid-warning";
4824
+ warningEl.textContent = message || "Mermaid renderer unavailable. Showing mermaid blocks as code.";
4825
+ documentEl.appendChild(warningEl);
4826
+ }
4827
+
4828
+ function prepareMermaidBlocks() {
4829
+ const preBlocks = Array.from(document.querySelectorAll("pre.mermaid"));
4830
+ preBlocks.forEach((preEl) => {
4831
+ const source = preEl.querySelector("code") ? preEl.querySelector("code").textContent : preEl.textContent;
4832
+ const wrapper = document.createElement("div");
4833
+ wrapper.className = "mermaid-container";
4834
+ const diagramEl = document.createElement("div");
4835
+ diagramEl.className = "mermaid";
4836
+ diagramEl.textContent = source || "";
4837
+ wrapper.appendChild(diagramEl);
4838
+ preEl.replaceWith(wrapper);
4839
+ });
4840
+ return Array.from(document.querySelectorAll(".mermaid"));
4841
+ }
4842
+
4843
+ async function renderMermaid() {
4844
+ const nodes = prepareMermaidBlocks();
4845
+ if (nodes.length === 0) return;
4846
+ try {
4847
+ const module = await import(MERMAID_CDN_URL);
4848
+ const mermaidApi = module && module.default ? module.default : null;
4849
+ if (!mermaidApi) throw new Error("Mermaid module did not expose a default export.");
4850
+ mermaidApi.initialize(MERMAID_CONFIG);
4851
+ await mermaidApi.run({ nodes });
4852
+ } catch (error) {
4853
+ console.error("Mermaid render failed:", error);
4854
+ appendMermaidWarning("Mermaid renderer unavailable. Showing mermaid source text.");
4855
+ }
4856
+ }
4857
+
4858
+ if (document.readyState === "loading") {
4859
+ document.addEventListener("DOMContentLoaded", () => { void renderMermaid(); }, { once: true });
4860
+ } else {
4861
+ void renderMermaid();
4862
+ }
4863
+ })();
4864
+ </script>`;
4865
+ }
4866
+
4867
+ function buildStudioStandaloneHtmlDocument(contentHtml: string, resourcePath: string | undefined, options?: StudioHtmlRenderOptions): string {
4868
+ const title = String(options?.title || "pi Studio preview").trim() || "pi Studio preview";
4869
+ const vars = options?.themeVars ?? buildThemeCssVars(getStudioThemeStyle());
4870
+ const cssVarsBlock = buildStudioCssVarsBlock(vars);
4871
+ const stylesheet = readFileSync(STUDIO_CSS_URL, "utf-8");
4872
+ const mermaidScript = buildStudioStandaloneHtmlMermaidScript(vars);
4873
+ const baseHref = buildStudioHtmlExportBaseHref(resourcePath);
4874
+ const baseTag = baseHref ? ` <base href="${escapeStudioHtmlText(baseHref)}" />\n` : "";
4875
+ const generatedAt = new Date().toISOString();
4876
+ const sourceLabel = String(options?.sourceLabel || "").trim();
4877
+ const sourceMeta = sourceLabel ? ` data-source-label="${escapeStudioHtmlText(sourceLabel)}"` : "";
4878
+ const exportCss = `
4879
+ body.studio-html-export {
4880
+ display: block;
4881
+ min-height: 100%;
4882
+ padding: 0;
4883
+ background: var(--bg);
4884
+ color: var(--text);
4885
+ }
4886
+ body.studio-html-export .studio-export-shell {
4887
+ display: block;
4888
+ flex: none;
4889
+ width: 100%;
4890
+ max-width: 1180px;
4891
+ min-height: auto;
4892
+ margin: 0 auto;
4893
+ padding: 32px clamp(16px, 4vw, 48px) 56px;
4894
+ }
4895
+ body.studio-html-export .studio-export-document {
4896
+ display: block;
4897
+ width: 100%;
4898
+ overflow: visible;
4899
+ padding: 28px;
4900
+ border: 1px solid var(--panel-border);
4901
+ border-radius: 14px;
4902
+ background: var(--panel);
4903
+ box-shadow: var(--panel-shadow);
4904
+ }
4905
+ body.studio-html-export .studio-export-document > :first-child {
4906
+ margin-top: 0;
4907
+ }
4908
+ body.studio-html-export .studio-export-document > :last-child {
4909
+ margin-bottom: 0;
4910
+ }
4911
+ body.studio-html-export .preview-selection-actions,
4912
+ body.studio-html-export .studio-copy-block-btn {
4913
+ display: none !important;
4914
+ }
4915
+ @media print {
4916
+ body.studio-html-export {
4917
+ background: #fff;
4918
+ color: #111;
4919
+ }
4920
+ body.studio-html-export .studio-export-shell {
4921
+ max-width: none;
4922
+ padding: 0;
4923
+ }
4924
+ body.studio-html-export .studio-export-document {
4925
+ border: 0;
4926
+ border-radius: 0;
4927
+ box-shadow: none;
4928
+ padding: 0;
4929
+ }
4930
+ }
4931
+ `;
4932
+
4933
+ return `<!doctype html>
4934
+ <html>
4935
+ <head>
4936
+ <meta charset="utf-8" />
4937
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
4938
+ <meta name="generator" content="pi Studio" />
4939
+ <meta name="pi-studio-exported-at" content="${escapeStudioHtmlText(generatedAt)}" />
4940
+ ${baseTag} <title>${escapeStudioHtmlText(title)}</title>
4941
+ <style>
4942
+ :root {
4943
+ ${cssVarsBlock}
4944
+ }
4945
+ ${stylesheet}
4946
+ ${exportCss}
4947
+ </style>
4948
+ </head>
4949
+ <body class="studio-html-export"${sourceMeta}>
4950
+ <main class="studio-export-shell">
4951
+ <article class="panel-scroll rendered-markdown studio-export-document">
4952
+ ${contentHtml}
4953
+ </article>
4954
+ </main>
4955
+ ${mermaidScript}
4956
+ </body>
4957
+ </html>`;
4958
+ }
4959
+
4960
+ async function renderStudioStandaloneHtmlWithPandoc(
4961
+ markdown: string,
4962
+ isLatex?: boolean,
4963
+ resourcePath?: string,
4964
+ editorLanguage?: string,
4965
+ sourcePath?: string,
4966
+ options?: StudioHtmlRenderOptions,
4967
+ ): Promise<{ html: Buffer; warning?: string }> {
4968
+ const effectiveEditorLanguage = inferStudioPdfLanguage(markdown, editorLanguage);
4969
+ const source = !isLatex
4970
+ && effectiveEditorLanguage
4971
+ && effectiveEditorLanguage !== "markdown"
4972
+ && effectiveEditorLanguage !== "latex"
4973
+ && !isStudioSingleFencedCodeBlock(markdown)
4974
+ ? wrapStudioCodeAsMarkdown(markdown, effectiveEditorLanguage)
4975
+ : markdown;
4976
+ const annotationPrepared = prepareStudioAnnotationMarkersForHtml(source);
4977
+ const pdfPrepared = prepareStudioPdfBlocksForHtml(annotationPrepared.markdown);
4978
+ let renderedHtml = await renderStudioMarkdownWithPandoc(pdfPrepared.markdown, isLatex, resourcePath, sourcePath);
4979
+ renderedHtml = renderStudioPdfBlocksInHtml(renderedHtml, pdfPrepared.blocks, sourcePath, resourcePath);
4980
+ renderedHtml = applyStudioAnnotationPlaceholdersToHtml(renderedHtml, annotationPrepared.placeholders);
4981
+ const standaloneHtml = buildStudioStandaloneHtmlDocument(renderedHtml, resourcePath, options);
4982
+ return { html: Buffer.from(standaloneHtml, "utf-8") };
4983
+ }
4984
+
4492
4985
  async function renderStudioLiteralTextPdf(text: string, title = "Studio export", options?: StudioPdfRenderOptions): Promise<Buffer> {
4493
4986
  const pdfEngine = process.env.PANDOC_PDF_ENGINE?.trim() || "xelatex";
4494
4987
  const tempDir = join(tmpdir(), `pi-studio-text-pdf-${Date.now()}-${randomUUID()}`);
@@ -5667,8 +6160,23 @@ function parseEntryTimestamp(timestamp: unknown): number {
5667
6160
  return Date.now();
5668
6161
  }
5669
6162
 
6163
+ function getStudioResponseHistoryContentHash(markdown: string): string {
6164
+ return createHash("sha256").update(String(markdown ?? ""), "utf-8").digest("hex").slice(0, 16);
6165
+ }
6166
+
6167
+ function buildStudioResponseHistoryId(contentHash: string, occurrenceIndex: number): string {
6168
+ return `response-${contentHash}-${String(Math.max(0, occurrenceIndex) + 1).padStart(3, "0")}`;
6169
+ }
6170
+
6171
+ function buildNextStudioResponseHistoryId(markdown: string, existingItems: StudioResponseHistoryItem[]): string {
6172
+ const contentHash = getStudioResponseHistoryContentHash(markdown);
6173
+ const occurrenceIndex = existingItems.filter((item) => getStudioResponseHistoryContentHash(item.markdown) === contentHash).length;
6174
+ return buildStudioResponseHistoryId(contentHash, occurrenceIndex);
6175
+ }
6176
+
5670
6177
  function buildResponseHistoryFromEntries(entries: SessionEntry[], limit = RESPONSE_HISTORY_LIMIT): StudioResponseHistoryItem[] {
5671
6178
  const history: StudioResponseHistoryItem[] = [];
6179
+ const occurrenceCountsByHash = new Map<string, number>();
5672
6180
  let lastUserPrompt: string | null = null;
5673
6181
  let pendingPromptDescriptor: StudioPromptDescriptor | null = null;
5674
6182
 
@@ -5700,8 +6208,11 @@ function buildResponseHistoryFromEntries(entries: SessionEntry[], limit = RESPON
5700
6208
  if (!markdown) continue;
5701
6209
  const thinking = extractAssistantThinking(message);
5702
6210
  const promptDescriptor = pendingPromptDescriptor ?? buildStudioPromptDescriptor(lastUserPrompt);
6211
+ const contentHash = getStudioResponseHistoryContentHash(markdown);
6212
+ const occurrenceIndex = occurrenceCountsByHash.get(contentHash) ?? 0;
6213
+ occurrenceCountsByHash.set(contentHash, occurrenceIndex + 1);
5703
6214
  history.push({
5704
- id: typeof (entry as { id?: unknown }).id === "string" ? (entry as { id: string }).id : randomUUID(),
6215
+ id: buildStudioResponseHistoryId(contentHash, occurrenceIndex),
5705
6216
  markdown,
5706
6217
  thinking,
5707
6218
  timestamp: parseEntryTimestamp((entry as { timestamp?: unknown }).timestamp),
@@ -5769,6 +6280,12 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
5769
6280
  if (msg.type === "hello") return { type: "hello" };
5770
6281
  if (msg.type === "ping") return { type: "ping" };
5771
6282
  if (msg.type === "get_latest_response") return { type: "get_latest_response" };
6283
+ if (msg.type === "get_trace_snapshot" && typeof msg.responseHistoryId === "string") {
6284
+ return {
6285
+ type: "get_trace_snapshot",
6286
+ responseHistoryId: msg.responseHistoryId,
6287
+ };
6288
+ }
5772
6289
 
5773
6290
  if (
5774
6291
  msg.type === "critique_request" &&
@@ -6040,6 +6557,68 @@ function createEmptyStudioTraceState(): StudioTraceState {
6040
6557
  };
6041
6558
  }
6042
6559
 
6560
+ function truncateStudioTraceSnapshotText(text: string, maxChars = STUDIO_TRACE_SNAPSHOT_MAX_FIELD_CHARS): { text: string; truncated: boolean } {
6561
+ const value = String(text ?? "");
6562
+ if (value.length <= maxChars) return { text: value, truncated: false };
6563
+ const keepHead = Math.max(0, Math.floor(maxChars * 0.62));
6564
+ const keepTail = Math.max(0, maxChars - keepHead);
6565
+ const omitted = value.length - keepHead - keepTail;
6566
+ return {
6567
+ text: `${value.slice(0, keepHead)}\n\n… ${omitted} chars omitted from saved Working view …\n\n${value.slice(value.length - keepTail)}`,
6568
+ truncated: true,
6569
+ };
6570
+ }
6571
+
6572
+ function createStudioTraceSnapshot(source: StudioTraceState): { traceState: StudioTraceState; truncated: boolean } {
6573
+ let truncated = false;
6574
+ const sourceEntries = Array.isArray(source.entries) ? source.entries : [];
6575
+ const entries = sourceEntries.slice(-STUDIO_TRACE_SNAPSHOT_MAX_ENTRIES).map((entry) => {
6576
+ if (entry.type === "assistant") {
6577
+ const thinking = truncateStudioTraceSnapshotText(entry.thinking);
6578
+ const text = truncateStudioTraceSnapshotText(entry.text);
6579
+ truncated = truncated || thinking.truncated || text.truncated;
6580
+ return {
6581
+ ...entry,
6582
+ thinking: thinking.text,
6583
+ text: text.text,
6584
+ };
6585
+ }
6586
+ const argsSummary = truncateStudioTraceSnapshotText(entry.argsSummary ?? "");
6587
+ const output = truncateStudioTraceSnapshotText(entry.output);
6588
+ truncated = truncated || argsSummary.truncated || output.truncated;
6589
+ return {
6590
+ ...entry,
6591
+ argsSummary: argsSummary.text || null,
6592
+ output: output.text,
6593
+ };
6594
+ });
6595
+ if (sourceEntries.length > entries.length) truncated = true;
6596
+
6597
+ return {
6598
+ traceState: {
6599
+ runId: source.runId,
6600
+ requestId: source.requestId,
6601
+ requestKind: source.requestKind,
6602
+ status: source.status,
6603
+ startedAt: source.startedAt,
6604
+ updatedAt: source.updatedAt,
6605
+ entries,
6606
+ },
6607
+ truncated,
6608
+ };
6609
+ }
6610
+
6611
+ function summarizeStudioTraceSnapshot(traceState: StudioTraceState, truncated = false): StudioTraceSnapshotSummary {
6612
+ return {
6613
+ hasTrace: Array.isArray(traceState.entries) && traceState.entries.length > 0,
6614
+ entryCount: Array.isArray(traceState.entries) ? traceState.entries.length : 0,
6615
+ startedAt: traceState.startedAt,
6616
+ updatedAt: traceState.updatedAt,
6617
+ status: traceState.status,
6618
+ truncated,
6619
+ };
6620
+ }
6621
+
6043
6622
  function sanitizeStudioTraceOutputText(text: string): string {
6044
6623
  return String(text || "")
6045
6624
  .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 +6893,23 @@ function sanitizePdfFilename(input: string | undefined): string {
6314
6893
  return `${ensuredExt.slice(0, 156)}.pdf`;
6315
6894
  }
6316
6895
 
6896
+ function sanitizeHtmlFilename(input: string | undefined): string {
6897
+ const fallback = "studio-preview.html";
6898
+ const raw = String(input ?? "").trim();
6899
+ if (!raw) return fallback;
6900
+
6901
+ const noPath = raw.split(/[\\/]/).pop() ?? raw;
6902
+ const cleaned = noPath
6903
+ .replace(/[\x00-\x1f\x7f]+/g, "")
6904
+ .replace(/[<>:"|?*]+/g, "-")
6905
+ .trim();
6906
+ if (!cleaned) return fallback;
6907
+
6908
+ const ensuredExt = /\.html?$/i.test(cleaned) ? cleaned : `${cleaned}.html`;
6909
+ if (ensuredExt.length <= 160) return ensuredExt;
6910
+ return `${ensuredExt.slice(0, 155)}.html`;
6911
+ }
6912
+
6317
6913
  function buildThemeCssVars(style: StudioThemeStyle): Record<string, string> {
6318
6914
  const shadowColor = style.mode === "light"
6319
6915
  ? withAlpha(style.palette.text, 0.10, "rgba(15, 23, 42, 0.08)")
@@ -6753,7 +7349,13 @@ ${cssVarsBlock}
6753
7349
  </div>
6754
7350
  <div class="section-header-actions">
6755
7351
  <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>
7352
+ <span id="exportPreviewControls" class="export-preview-controls">
7353
+ <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>
7354
+ <div id="exportPreviewMenu" class="export-preview-menu" role="menu" hidden>
7355
+ <button id="exportPreviewPdfBtn" type="button" role="menuitem" data-export-preview-format="pdf">Export as PDF</button>
7356
+ <button id="exportPreviewHtmlBtn" type="button" role="menuitem" data-export-preview-format="html">Export as HTML</button>
7357
+ </div>
7358
+ </span>
6757
7359
  </div>
6758
7360
  </div>
6759
7361
  <div class="reference-meta">
@@ -6852,6 +7454,7 @@ export default function (pi: ExtensionAPI) {
6852
7454
  let pendingStudioPromptMetadata: StudioPromptDescriptor | null = null;
6853
7455
  let lastStudioResponse: LastStudioResponse | null = null;
6854
7456
  let preparedPdfExports = new Map<string, PreparedStudioPdfExport>();
7457
+ let preparedHtmlExports = new Map<string, PreparedStudioHtmlExport>();
6855
7458
  let initialStudioDocument: InitialStudioDocument | null = null;
6856
7459
  let studioCwd = process.cwd();
6857
7460
  let lastCommandCtx: ExtensionCommandContext | null = null;
@@ -6871,6 +7474,7 @@ export default function (pi: ExtensionAPI) {
6871
7474
  let latestSessionUserPrompt: string | null = null;
6872
7475
  let pendingTurnPrompt: string | null = null;
6873
7476
  let studioTraceState: StudioTraceState = createEmptyStudioTraceState();
7477
+ let studioTraceHistory = new Map<string, { traceState: StudioTraceState; summary: StudioTraceSnapshotSummary }>();
6874
7478
  let activeStudioTraceAssistantEntryId: string | null = null;
6875
7479
  const studioTraceToolEntryIds = new Map<string, string>();
6876
7480
  let contextUsageSnapshot: StudioContextUsageSnapshot = {
@@ -7222,6 +7826,36 @@ export default function (pi: ExtensionAPI) {
7222
7826
  notifyStudioTerminal(message, "info");
7223
7827
  };
7224
7828
 
7829
+ const attachStudioTraceSummariesToHistory = (items: StudioResponseHistoryItem[]): StudioResponseHistoryItem[] => items.map((item) => {
7830
+ const stored = studioTraceHistory.get(item.id);
7831
+ return stored ? { ...item, traceSummary: stored.summary } : item;
7832
+ });
7833
+
7834
+ const pruneStudioTraceHistory = () => {
7835
+ const liveIds = new Set(studioResponseHistory.map((item) => item.id));
7836
+ for (const key of Array.from(studioTraceHistory.keys())) {
7837
+ if (!liveIds.has(key)) studioTraceHistory.delete(key);
7838
+ }
7839
+ while (studioTraceHistory.size > MAX_STUDIO_TRACE_SNAPSHOTS) {
7840
+ const oldestKey = studioTraceHistory.keys().next().value;
7841
+ if (!oldestKey) break;
7842
+ studioTraceHistory.delete(oldestKey);
7843
+ }
7844
+ };
7845
+
7846
+ const storeStudioTraceSnapshotForResponse = (responseHistoryId: string | null | undefined): StudioTraceSnapshotSummary | null => {
7847
+ const id = String(responseHistoryId ?? "").trim();
7848
+ if (!id) return null;
7849
+ if (!Array.isArray(studioTraceState.entries) || studioTraceState.entries.length === 0) return null;
7850
+ const snapshot = createStudioTraceSnapshot(studioTraceState);
7851
+ const summary = summarizeStudioTraceSnapshot(snapshot.traceState, snapshot.truncated);
7852
+ if (!summary.hasTrace) return null;
7853
+ studioTraceHistory.set(id, { traceState: snapshot.traceState, summary });
7854
+ studioResponseHistory = studioResponseHistory.map((item) => item.id === id ? { ...item, traceSummary: summary } : item);
7855
+ pruneStudioTraceHistory();
7856
+ return summary;
7857
+ };
7858
+
7225
7859
  const refreshContextUsage = (
7226
7860
  ctx?: { getContextUsage(): { tokens: number | null; contextWindow: number; percent: number | null } | undefined },
7227
7861
  ): StudioContextUsageSnapshot => {
@@ -7239,7 +7873,8 @@ export default function (pi: ExtensionAPI) {
7239
7873
 
7240
7874
  const syncStudioResponseHistory = (entries: SessionEntry[]) => {
7241
7875
  latestSessionUserPrompt = findLatestUserPrompt(entries);
7242
- studioResponseHistory = buildResponseHistoryFromEntries(entries, RESPONSE_HISTORY_LIMIT);
7876
+ studioResponseHistory = attachStudioTraceSummariesToHistory(buildResponseHistoryFromEntries(entries, RESPONSE_HISTORY_LIMIT));
7877
+ pruneStudioTraceHistory();
7243
7878
  const latest = studioResponseHistory[studioResponseHistory.length - 1];
7244
7879
  if (!latest) {
7245
7880
  lastStudioResponse = null;
@@ -7865,6 +8500,18 @@ export default function (pi: ExtensionAPI) {
7865
8500
  return;
7866
8501
  }
7867
8502
 
8503
+ if (msg.type === "get_trace_snapshot") {
8504
+ const responseHistoryId = String(msg.responseHistoryId ?? "").trim();
8505
+ const stored = responseHistoryId ? studioTraceHistory.get(responseHistoryId) : null;
8506
+ sendToClient(client, {
8507
+ type: "trace_snapshot",
8508
+ responseHistoryId,
8509
+ traceState: stored?.traceState ?? createEmptyStudioTraceState(),
8510
+ summary: stored?.summary ?? summarizeStudioTraceSnapshot(createEmptyStudioTraceState()),
8511
+ });
8512
+ return;
8513
+ }
8514
+
7868
8515
  if (msg.type === "load_git_diff_request") {
7869
8516
  if (!isValidRequestId(msg.requestId)) {
7870
8517
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
@@ -8467,6 +9114,100 @@ export default function (pi: ExtensionAPI) {
8467
9114
  res.end(prepared.pdf);
8468
9115
  };
8469
9116
 
9117
+ const disposePreparedHtmlExport = (entry: PreparedStudioHtmlExport | null | undefined) => {
9118
+ if (!entry?.tempDirPath) return;
9119
+ void rm(entry.tempDirPath, { recursive: true, force: true }).catch(() => undefined);
9120
+ };
9121
+
9122
+ const clearPreparedHtmlExports = () => {
9123
+ for (const entry of preparedHtmlExports.values()) {
9124
+ disposePreparedHtmlExport(entry);
9125
+ }
9126
+ preparedHtmlExports.clear();
9127
+ };
9128
+
9129
+ const prunePreparedHtmlExports = () => {
9130
+ const now = Date.now();
9131
+ for (const [id, entry] of preparedHtmlExports) {
9132
+ if (entry.createdAt + PREPARED_HTML_EXPORT_TTL_MS <= now) {
9133
+ preparedHtmlExports.delete(id);
9134
+ disposePreparedHtmlExport(entry);
9135
+ }
9136
+ }
9137
+ while (preparedHtmlExports.size > MAX_PREPARED_HTML_EXPORTS) {
9138
+ const oldestKey = preparedHtmlExports.keys().next().value;
9139
+ if (!oldestKey) break;
9140
+ const oldestEntry = preparedHtmlExports.get(oldestKey);
9141
+ preparedHtmlExports.delete(oldestKey);
9142
+ disposePreparedHtmlExport(oldestEntry);
9143
+ }
9144
+ };
9145
+
9146
+ const storePreparedHtmlExport = (html: Buffer, filename: string, warning?: string): string => {
9147
+ prunePreparedHtmlExports();
9148
+ const exportId = randomUUID();
9149
+ preparedHtmlExports.set(exportId, {
9150
+ html,
9151
+ filename,
9152
+ warning,
9153
+ createdAt: Date.now(),
9154
+ });
9155
+ return exportId;
9156
+ };
9157
+
9158
+ const ensurePreparedHtmlExportFile = async (exportId: string): Promise<PreparedStudioHtmlExport | null> => {
9159
+ prunePreparedHtmlExports();
9160
+ const entry = preparedHtmlExports.get(exportId);
9161
+ if (!entry) return null;
9162
+ if (entry.filePath && entry.tempDirPath) return entry;
9163
+
9164
+ const tempDirPath = join(tmpdir(), `pi-studio-prepared-html-${Date.now()}-${randomUUID()}`);
9165
+ const filePath = join(tempDirPath, sanitizeHtmlFilename(entry.filename));
9166
+ await mkdir(tempDirPath, { recursive: true });
9167
+ await writeFile(filePath, entry.html);
9168
+ entry.tempDirPath = tempDirPath;
9169
+ entry.filePath = filePath;
9170
+ preparedHtmlExports.set(exportId, entry);
9171
+ return entry;
9172
+ };
9173
+
9174
+ const getPreparedHtmlExport = (exportId: string): PreparedStudioHtmlExport | null => {
9175
+ prunePreparedHtmlExports();
9176
+ return preparedHtmlExports.get(exportId) ?? null;
9177
+ };
9178
+
9179
+ const handlePreparedHtmlDownloadRequest = (requestUrl: URL, res: ServerResponse) => {
9180
+ const exportId = requestUrl.searchParams.get("id") ?? "";
9181
+ if (!exportId) {
9182
+ respondText(res, 400, "Missing HTML export id.");
9183
+ return;
9184
+ }
9185
+
9186
+ const prepared = getPreparedHtmlExport(exportId);
9187
+ if (!prepared) {
9188
+ respondText(res, 404, "HTML export is no longer available. Re-export the document.");
9189
+ return;
9190
+ }
9191
+
9192
+ const safeAsciiName = prepared.filename
9193
+ .replace(/[\x00-\x1f\x7f]/g, "")
9194
+ .replace(/[;"\\]/g, "_")
9195
+ .replace(/\s+/g, " ")
9196
+ .trim() || "studio-preview.html";
9197
+
9198
+ const headers: Record<string, string> = {
9199
+ "Content-Type": "text/html; charset=utf-8",
9200
+ "Cache-Control": "no-store",
9201
+ "X-Content-Type-Options": "nosniff",
9202
+ "Content-Disposition": `inline; filename="${safeAsciiName}"; filename*=UTF-8''${encodeURIComponent(prepared.filename)}`,
9203
+ "Content-Length": String(prepared.html.length),
9204
+ };
9205
+ if (prepared.warning) headers["X-Pi-Studio-Export-Warning"] = prepared.warning;
9206
+
9207
+ res.writeHead(200, headers);
9208
+ res.end(prepared.html);
9209
+ };
9210
+
8470
9211
  const handleScratchpadStateRequest = async (req: IncomingMessage, res: ServerResponse, requestUrl: URL) => {
8471
9212
  const method = (req.method ?? "GET").toUpperCase();
8472
9213
  if (method === "GET") {
@@ -8784,6 +9525,117 @@ export default function (pi: ExtensionAPI) {
8784
9525
  }
8785
9526
  };
8786
9527
 
9528
+ const handleExportHtmlRequest = async (req: IncomingMessage, res: ServerResponse) => {
9529
+ let rawBody = "";
9530
+ try {
9531
+ rawBody = await readRequestBody(req, REQUEST_BODY_MAX_BYTES);
9532
+ } catch (error) {
9533
+ const message = error instanceof Error ? error.message : String(error);
9534
+ const status = message.includes("exceeds") ? 413 : 400;
9535
+ respondJson(res, status, { ok: false, error: message });
9536
+ return;
9537
+ }
9538
+
9539
+ let parsedBody: unknown;
9540
+ try {
9541
+ parsedBody = rawBody ? JSON.parse(rawBody) : {};
9542
+ } catch {
9543
+ respondJson(res, 400, { ok: false, error: "Invalid JSON body." });
9544
+ return;
9545
+ }
9546
+
9547
+ const markdown =
9548
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { markdown?: unknown }).markdown === "string"
9549
+ ? (parsedBody as { markdown: string }).markdown
9550
+ : null;
9551
+ if (markdown === null) {
9552
+ respondJson(res, 400, { ok: false, error: "Missing markdown string in request body." });
9553
+ return;
9554
+ }
9555
+
9556
+ if (markdown.length > HTML_EXPORT_MAX_CHARS) {
9557
+ respondJson(res, 413, {
9558
+ ok: false,
9559
+ error: `HTML export text exceeds ${HTML_EXPORT_MAX_CHARS} characters.`,
9560
+ });
9561
+ return;
9562
+ }
9563
+
9564
+ const sourcePath =
9565
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { sourcePath?: unknown }).sourcePath === "string"
9566
+ ? (parsedBody as { sourcePath: string }).sourcePath
9567
+ : "";
9568
+ const userResourceDir =
9569
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { resourceDir?: unknown }).resourceDir === "string"
9570
+ ? (parsedBody as { resourceDir: string }).resourceDir
9571
+ : "";
9572
+ const resourcePath = resolveStudioBaseDir(sourcePath || undefined, userResourceDir || undefined, studioCwd);
9573
+ const requestedIsLatex =
9574
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { isLatex?: unknown }).isLatex === "boolean"
9575
+ ? (parsedBody as { isLatex: boolean }).isLatex
9576
+ : null;
9577
+ const requestedFilename =
9578
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { filenameHint?: unknown }).filenameHint === "string"
9579
+ ? (parsedBody as { filenameHint: string }).filenameHint
9580
+ : "";
9581
+ const requestedTitle =
9582
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { title?: unknown }).title === "string"
9583
+ ? (parsedBody as { title: string }).title
9584
+ : "";
9585
+ const requestedEditorHtmlLanguage =
9586
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { editorHtmlLanguage?: unknown }).editorHtmlLanguage === "string"
9587
+ ? (parsedBody as { editorHtmlLanguage: string }).editorHtmlLanguage
9588
+ : "";
9589
+ const editorHtmlLanguage = inferStudioPdfLanguage(markdown, requestedEditorHtmlLanguage);
9590
+ const isLatex = editorHtmlLanguage === "latex"
9591
+ || (
9592
+ (editorHtmlLanguage === undefined || editorHtmlLanguage === "markdown")
9593
+ && (requestedIsLatex ?? isLikelyStandaloneLatexPreview(markdown))
9594
+ );
9595
+ const filename = sanitizeHtmlFilename(requestedFilename || (isLatex ? "studio-latex-preview.html" : "studio-preview.html"));
9596
+ const themeVars = parseStudioThemeVarsJson(lastThemeVarsJson) ?? buildThemeCssVars(getStudioThemeStyle(lastCommandCtx?.ui?.theme));
9597
+
9598
+ try {
9599
+ const { html, warning } = await renderStudioStandaloneHtmlWithPandoc(
9600
+ markdown,
9601
+ isLatex,
9602
+ resourcePath,
9603
+ editorHtmlLanguage,
9604
+ sourcePath || undefined,
9605
+ {
9606
+ title: requestedTitle || filename,
9607
+ sourceLabel: sourcePath || userResourceDir || "right preview",
9608
+ themeVars,
9609
+ },
9610
+ );
9611
+ const exportId = storePreparedHtmlExport(html, filename, warning);
9612
+ const token = serverState?.token ?? "";
9613
+ let openedExternal = false;
9614
+ let openError: string | null = null;
9615
+ try {
9616
+ const prepared = await ensurePreparedHtmlExportFile(exportId);
9617
+ if (!prepared?.filePath) {
9618
+ throw new Error("Prepared HTML file was not available for external open.");
9619
+ }
9620
+ await openPathInDefaultViewer(prepared.filePath);
9621
+ openedExternal = true;
9622
+ } catch (viewerError) {
9623
+ openError = viewerError instanceof Error ? viewerError.message : String(viewerError);
9624
+ }
9625
+ respondJson(res, 200, {
9626
+ ok: true,
9627
+ filename,
9628
+ warning: warning ?? null,
9629
+ openedExternal,
9630
+ openError,
9631
+ downloadUrl: `/export-html?token=${encodeURIComponent(token)}&id=${encodeURIComponent(exportId)}`,
9632
+ });
9633
+ } catch (error) {
9634
+ const message = error instanceof Error ? error.message : String(error);
9635
+ respondJson(res, 500, { ok: false, error: `HTML export failed: ${message}` });
9636
+ }
9637
+ };
9638
+
8787
9639
  const handleHttpRequest = (req: IncomingMessage, res: ServerResponse) => {
8788
9640
  if (!serverState) {
8789
9641
  respondText(res, 503, "Studio server not ready");
@@ -8975,6 +9827,38 @@ export default function (pi: ExtensionAPI) {
8975
9827
  return;
8976
9828
  }
8977
9829
 
9830
+ if (requestUrl.pathname === "/export-html") {
9831
+ const token = requestUrl.searchParams.get("token") ?? "";
9832
+ if (token !== serverState.token) {
9833
+ const method = (req.method ?? "GET").toUpperCase();
9834
+ if (method === "GET") {
9835
+ respondText(res, 403, "Invalid or expired studio token. Re-run /studio.");
9836
+ } else {
9837
+ respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
9838
+ }
9839
+ return;
9840
+ }
9841
+
9842
+ const method = (req.method ?? "GET").toUpperCase();
9843
+ if (method === "GET") {
9844
+ handlePreparedHtmlDownloadRequest(requestUrl, res);
9845
+ return;
9846
+ }
9847
+ if (method !== "POST") {
9848
+ res.setHeader("Allow", "GET, POST");
9849
+ respondJson(res, 405, { ok: false, error: "Method not allowed. Use GET or POST." });
9850
+ return;
9851
+ }
9852
+
9853
+ void handleExportHtmlRequest(req, res).catch((error) => {
9854
+ respondJson(res, 500, {
9855
+ ok: false,
9856
+ error: `HTML export failed: ${error instanceof Error ? error.message : String(error)}`,
9857
+ });
9858
+ });
9859
+ return;
9860
+ }
9861
+
8978
9862
  if (requestUrl.pathname === "/pdf-resource") {
8979
9863
  const token = requestUrl.searchParams.get("token") ?? "";
8980
9864
  if (token !== serverState.token) {
@@ -9174,6 +10058,7 @@ export default function (pi: ExtensionAPI) {
9174
10058
  clearActiveRequest();
9175
10059
  clearPendingStudioCompletion();
9176
10060
  clearPreparedPdfExports();
10061
+ clearPreparedHtmlExports();
9177
10062
  clearCompactionState();
9178
10063
  closeAllClients(1001, "Server shutting down");
9179
10064
 
@@ -9193,14 +10078,21 @@ export default function (pi: ExtensionAPI) {
9193
10078
  syncStudioResponseHistory(entries);
9194
10079
  };
9195
10080
 
9196
- pi.on("session_start", async (_event, ctx) => {
10081
+ pi.on("session_start", async (event, ctx) => {
10082
+ const isSessionReplacement = event.reason === "new" || event.reason === "resume" || event.reason === "fork";
9197
10083
  pendingTurnPrompt = null;
9198
10084
  clearStudioDirectRunState();
10085
+ if (isSessionReplacement) {
10086
+ clearActiveRequest({ notify: "Session switched. Studio request state cleared.", level: "warning" });
10087
+ studioTraceHistory.clear();
10088
+ lastCommandCtx = null;
10089
+ }
9199
10090
  hydrateLatestAssistant(ctx.sessionManager.getBranch());
9200
10091
  clearCompactionState();
9201
10092
  agentBusy = false;
9202
10093
  clearPendingStudioCompletion();
9203
10094
  clearPreparedPdfExports();
10095
+ clearPreparedHtmlExports();
9204
10096
  refreshRuntimeMetadata({ cwd: ctx.cwd, model: ctx.model });
9205
10097
  refreshContextUsage(ctx);
9206
10098
  emitDebugEvent("session_start", {
@@ -9212,26 +10104,6 @@ export default function (pi: ExtensionAPI) {
9212
10104
  broadcastResponseHistory();
9213
10105
  });
9214
10106
 
9215
- pi.on("session_switch", async (_event, ctx) => {
9216
- clearStudioDirectRunState();
9217
- clearActiveRequest({ notify: "Session switched. Studio request state cleared.", level: "warning" });
9218
- clearCompactionState();
9219
- pendingTurnPrompt = null;
9220
- lastCommandCtx = null;
9221
- hydrateLatestAssistant(ctx.sessionManager.getBranch());
9222
- agentBusy = false;
9223
- clearPendingStudioCompletion();
9224
- clearPreparedPdfExports();
9225
- refreshRuntimeMetadata({ cwd: ctx.cwd, model: ctx.model });
9226
- refreshContextUsage(ctx);
9227
- emitDebugEvent("session_switch", {
9228
- entryCount: ctx.sessionManager.getBranch().length,
9229
- modelLabel: currentModelLabel,
9230
- terminalSessionLabel,
9231
- });
9232
- setTerminalActivity("idle");
9233
- broadcastResponseHistory();
9234
- });
9235
10107
 
9236
10108
  pi.on("session_tree", async (_event, ctx) => {
9237
10109
  hydrateLatestAssistant(ctx.sessionManager.getBranch());
@@ -9397,7 +10269,7 @@ export default function (pi: ExtensionAPI) {
9397
10269
  ? getPromptDescriptorForActiveRequest(activeRequest)
9398
10270
  : buildStudioPromptDescriptor(pendingTurnPrompt ?? latestSessionUserPrompt ?? null);
9399
10271
  const fallbackHistoryItem: StudioResponseHistoryItem = {
9400
- id: randomUUID(),
10272
+ id: buildNextStudioResponseHistoryId(markdown, studioResponseHistory),
9401
10273
  markdown,
9402
10274
  thinking,
9403
10275
  timestamp: Date.now(),
@@ -9416,6 +10288,10 @@ export default function (pi: ExtensionAPI) {
9416
10288
  const responseTimestamp = latestItem?.timestamp ?? Date.now();
9417
10289
  const responseThinking = latestItem?.thinking ?? thinking ?? null;
9418
10290
  pendingTurnPrompt = null;
10291
+ setStudioTraceRunStatus("complete");
10292
+ if (latestItem) {
10293
+ storeStudioTraceSnapshotForResponse(latestItem.id);
10294
+ }
9419
10295
 
9420
10296
  if (activeRequest) {
9421
10297
  const requestId = activeRequest.id;
@@ -9513,6 +10389,8 @@ export default function (pi: ExtensionAPI) {
9513
10389
  clearStudioDirectRunState();
9514
10390
  clearPendingStudioCompletion();
9515
10391
  clearPreparedPdfExports();
10392
+ clearPreparedHtmlExports();
10393
+ studioTraceHistory.clear();
9516
10394
  transientStudioDocuments.clear();
9517
10395
  clearCompactionState();
9518
10396
  clearStudioTrace();
@@ -9609,6 +10487,17 @@ export default function (pi: ExtensionAPI) {
9609
10487
  };
9610
10488
  };
9611
10489
 
10490
+ const resolveLastModelResponseForExport = (ctx: ExtensionCommandContext): { markdown: string } | null => {
10491
+ const branchEntries = ctx.sessionManager.getBranch();
10492
+ syncStudioResponseHistory(branchEntries);
10493
+ const markdown =
10494
+ extractLatestAssistantFromEntries(branchEntries)
10495
+ ?? extractLatestAssistantFromEntries(ctx.sessionManager.getEntries())
10496
+ ?? lastStudioResponse?.markdown
10497
+ ?? "";
10498
+ return markdown.trim() ? { markdown } : null;
10499
+ };
10500
+
9612
10501
  const openStudioView = async (
9613
10502
  trimmed: string,
9614
10503
  ctx: ExtensionCommandContext,
@@ -9715,7 +10604,8 @@ export default function (pi: ExtensionAPI) {
9715
10604
  + " /studio-replace [path] Replace the current full Studio view with a new one\n"
9716
10605
  + " /studio-editor-only [path] Open another Studio tab in editor-only mode\n"
9717
10606
  + " /studio-current <path> Load a file into currently open Studio tab(s)\n"
9718
- + " /studio-pdf <path> Export a file to <name>.studio.pdf via Studio PDF",
10607
+ + " /studio-pdf [path] Export a file or last response via Studio PDF\n"
10608
+ + " /studio-html [path] Export a file or last response via Studio preview HTML",
9719
10609
  "info",
9720
10610
  );
9721
10611
  return;
@@ -9772,13 +10662,14 @@ export default function (pi: ExtensionAPI) {
9772
10662
  });
9773
10663
 
9774
10664
  pi.registerCommand("studio-pdf", {
9775
- description: "Export a file to PDF via the Studio PDF pipeline (/studio-pdf <file>)",
10665
+ description: "Export a file or the last model response to PDF via the Studio PDF pipeline (/studio-pdf [file])",
9776
10666
  handler: async (args: string, ctx: ExtensionCommandContext) => {
9777
10667
  const trimmed = args.trim();
9778
- if (!trimmed || trimmed === "help" || trimmed === "--help" || trimmed === "-h") {
10668
+ if (trimmed === "help" || trimmed === "--help" || trimmed === "-h") {
9779
10669
  ctx.ui.notify(
9780
- "Usage: /studio-pdf <path> [options]\n"
9781
- + " Export a local Markdown/LaTeX file to <name>.studio.pdf using the Studio PDF pipeline.\n"
10670
+ "Usage: /studio-pdf [path] [options]\n"
10671
+ + " Without a path, export the last model response to studio-response-<timestamp>.studio.pdf.\n"
10672
+ + " With a path, export a local Markdown/LaTeX/code file to <name>.studio.pdf using the Studio PDF pipeline.\n"
9782
10673
  + "Options:\n"
9783
10674
  + " --fontsize <value> e.g. 12pt\n"
9784
10675
  + " --section-size <value> e.g. 24pt\n"
@@ -9811,6 +10702,61 @@ export default function (pi: ExtensionAPI) {
9811
10702
  }
9812
10703
  const { pathArg, options: pdfOptions } = parsedArgs;
9813
10704
 
10705
+ if (!pathArg) {
10706
+ await ctx.waitForIdle();
10707
+ const response = resolveLastModelResponseForExport(ctx);
10708
+ if (!response) {
10709
+ ctx.ui.notify("No last model response to export. Use /studio-pdf <path> or run a prompt first.", "warning");
10710
+ return;
10711
+ }
10712
+ if (response.markdown.length > PDF_EXPORT_MAX_CHARS) {
10713
+ ctx.ui.notify(`PDF export text exceeds ${PDF_EXPORT_MAX_CHARS} characters.`, "error");
10714
+ return;
10715
+ }
10716
+
10717
+ const editorPdfLanguage = inferStudioPdfLanguage(response.markdown);
10718
+ const isLatex = editorPdfLanguage === "latex"
10719
+ || (
10720
+ (editorPdfLanguage === undefined || editorPdfLanguage === "markdown")
10721
+ && /\\documentclass\b|\\begin\{document\}/.test(response.markdown)
10722
+ );
10723
+ const resourcePath = resolveStudioBaseDir(undefined, undefined, ctx.cwd);
10724
+ const outputPath = buildStudioResponseExportOutputPath(ctx.cwd, "pdf");
10725
+
10726
+ try {
10727
+ const { pdf, warning } = await renderStudioPdfWithPandoc(
10728
+ response.markdown,
10729
+ isLatex,
10730
+ resourcePath,
10731
+ editorPdfLanguage,
10732
+ undefined,
10733
+ pdfOptions,
10734
+ );
10735
+ await writeFile(outputPath, pdf);
10736
+
10737
+ let openError: string | null = null;
10738
+ try {
10739
+ await openPathInDefaultViewer(outputPath);
10740
+ } catch (error) {
10741
+ openError = error instanceof Error ? error.message : String(error);
10742
+ }
10743
+
10744
+ ctx.ui.notify(`Exported last response Studio PDF: ${outputPath}`, "info");
10745
+ if (warning) {
10746
+ ctx.ui.notify(warning, "warning");
10747
+ }
10748
+ if (openError) {
10749
+ ctx.ui.notify(`PDF was exported but could not be opened automatically: ${openError}`, "warning");
10750
+ }
10751
+ } catch (error) {
10752
+ ctx.ui.notify(
10753
+ `Studio PDF export failed for last response: ${error instanceof Error ? error.message : String(error)}`,
10754
+ "error",
10755
+ );
10756
+ }
10757
+ return;
10758
+ }
10759
+
9814
10760
  const file = readStudioFile(pathArg, ctx.cwd);
9815
10761
  if (file.ok === false) {
9816
10762
  ctx.ui.notify(file.message, "error");
@@ -9868,6 +10814,148 @@ export default function (pi: ExtensionAPI) {
9868
10814
  },
9869
10815
  });
9870
10816
 
10817
+ pi.registerCommand("studio-html", {
10818
+ description: "Export a file or the last model response to standalone HTML via the Studio preview pipeline (/studio-html [file])",
10819
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
10820
+ const trimmed = args.trim();
10821
+ if (trimmed === "help" || trimmed === "--help" || trimmed === "-h") {
10822
+ ctx.ui.notify(
10823
+ "Usage: /studio-html [path]\n"
10824
+ + " Without a path, export the last model response to studio-response-<timestamp>.studio.html.\n"
10825
+ + " With a path, export a local Markdown/LaTeX/code file to <name>.studio.html using the Studio preview HTML pipeline.",
10826
+ "info",
10827
+ );
10828
+ return;
10829
+ }
10830
+
10831
+ if (!trimmed) {
10832
+ await ctx.waitForIdle();
10833
+ const response = resolveLastModelResponseForExport(ctx);
10834
+ if (!response) {
10835
+ ctx.ui.notify("No last model response to export. Use /studio-html <path> or run a prompt first.", "warning");
10836
+ return;
10837
+ }
10838
+ if (response.markdown.length > HTML_EXPORT_MAX_CHARS) {
10839
+ ctx.ui.notify(`HTML export text exceeds ${HTML_EXPORT_MAX_CHARS} characters.`, "error");
10840
+ return;
10841
+ }
10842
+
10843
+ const editorHtmlLanguage = inferStudioPdfLanguage(response.markdown);
10844
+ const isLatex = editorHtmlLanguage === "latex"
10845
+ || (
10846
+ (editorHtmlLanguage === undefined || editorHtmlLanguage === "markdown")
10847
+ && isLikelyStandaloneLatexPreview(response.markdown)
10848
+ );
10849
+ const resourcePath = resolveStudioBaseDir(undefined, undefined, ctx.cwd);
10850
+ const outputPath = buildStudioResponseExportOutputPath(ctx.cwd, "html");
10851
+ const themeVars = buildThemeCssVars(getStudioThemeStyle(ctx.ui.theme));
10852
+
10853
+ try {
10854
+ const { html, warning } = await renderStudioStandaloneHtmlWithPandoc(
10855
+ response.markdown,
10856
+ isLatex,
10857
+ resourcePath,
10858
+ editorHtmlLanguage,
10859
+ undefined,
10860
+ {
10861
+ title: basename(outputPath),
10862
+ sourceLabel: "last model response",
10863
+ themeVars,
10864
+ },
10865
+ );
10866
+ await writeFile(outputPath, html);
10867
+
10868
+ let openError: string | null = null;
10869
+ try {
10870
+ await openPathInDefaultViewer(outputPath);
10871
+ } catch (error) {
10872
+ openError = error instanceof Error ? error.message : String(error);
10873
+ }
10874
+
10875
+ ctx.ui.notify(`Exported last response Studio HTML: ${outputPath}`, "info");
10876
+ if (warning) {
10877
+ ctx.ui.notify(warning, "warning");
10878
+ }
10879
+ if (openError) {
10880
+ ctx.ui.notify(`HTML was exported but could not be opened automatically: ${openError}`, "warning");
10881
+ }
10882
+ } catch (error) {
10883
+ ctx.ui.notify(
10884
+ `Studio HTML export failed for last response: ${error instanceof Error ? error.message : String(error)}`,
10885
+ "error",
10886
+ );
10887
+ }
10888
+ return;
10889
+ }
10890
+
10891
+ const pathArg = parsePathArgument(trimmed);
10892
+ if (!pathArg) {
10893
+ ctx.ui.notify("Invalid file path argument.", "error");
10894
+ return;
10895
+ }
10896
+
10897
+ const file = readStudioFile(pathArg, ctx.cwd);
10898
+ if (file.ok === false) {
10899
+ ctx.ui.notify(file.message, "error");
10900
+ return;
10901
+ }
10902
+
10903
+ if (file.text.length > HTML_EXPORT_MAX_CHARS) {
10904
+ ctx.ui.notify(`HTML export text exceeds ${HTML_EXPORT_MAX_CHARS} characters.`, "error");
10905
+ return;
10906
+ }
10907
+
10908
+ await ctx.waitForIdle();
10909
+ const pathHtmlLanguage = inferStudioPdfLanguageFromPath(file.resolvedPath);
10910
+ const editorHtmlLanguage = pathHtmlLanguage ?? inferStudioPdfLanguage(file.text);
10911
+ const isLatex = editorHtmlLanguage === "latex"
10912
+ || (
10913
+ !pathHtmlLanguage
10914
+ && (editorHtmlLanguage === undefined || editorHtmlLanguage === "markdown")
10915
+ && isLikelyStandaloneLatexPreview(file.text)
10916
+ );
10917
+ const resourcePath = resolveStudioBaseDir(file.resolvedPath, undefined, ctx.cwd);
10918
+ const outputPath = buildStudioHtmlOutputPath(file.resolvedPath);
10919
+ const themeVars = buildThemeCssVars(getStudioThemeStyle(ctx.ui.theme));
10920
+
10921
+ try {
10922
+ const { html, warning } = await renderStudioStandaloneHtmlWithPandoc(
10923
+ file.text,
10924
+ isLatex,
10925
+ resourcePath,
10926
+ editorHtmlLanguage,
10927
+ file.resolvedPath,
10928
+ {
10929
+ title: basename(outputPath),
10930
+ sourceLabel: file.resolvedPath,
10931
+ themeVars,
10932
+ },
10933
+ );
10934
+ await writeFile(outputPath, html);
10935
+
10936
+ let openError: string | null = null;
10937
+ try {
10938
+ await openPathInDefaultViewer(outputPath);
10939
+ } catch (error) {
10940
+ openError = error instanceof Error ? error.message : String(error);
10941
+ }
10942
+
10943
+ ctx.ui.notify(`Exported Studio HTML: ${outputPath}`, "info");
10944
+ if (warning) {
10945
+ ctx.ui.notify(warning, "warning");
10946
+ }
10947
+ if (openError) {
10948
+ ctx.ui.notify(`HTML was exported but could not be opened automatically: ${openError}`, "warning");
10949
+ }
10950
+ } catch (error) {
10951
+ ctx.ui.notify(
10952
+ `Studio HTML export failed for ${file.label}: ${error instanceof Error ? error.message : String(error)}`,
10953
+ "error",
10954
+ );
10955
+ }
10956
+ },
10957
+ });
10958
+
9871
10959
  pi.registerCommand("studio-current", {
9872
10960
  description: "Load a file into current open Studio tab(s) without opening a new browser session",
9873
10961
  handler: async (args: string, ctx: ExtensionCommandContext) => {