pi-studio 0.5.20 → 0.5.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,13 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.21] — 2026-03-19
8
+
9
+ ### Fixed
10
+ - PDF export now uses a two-step prepare/download flow and opens the generated PDF in the system’s default viewer first, so browser surfaces like cmux do not need to navigate away from the current Studio page.
11
+ - LaTeX preview and PDF export now use the document `.aux` file when available to substitute basic `\eqref{...}`, `\ref{...}`, and `\autoref{...}` values more reliably, and preview decorates block equations with their resolved equation numbers.
12
+ - Upload + working-directory LaTeX workflows now derive the effective source path more reliably, helping Studio find the correct `.aux` file for reference resolution.
13
+
7
14
  ## [0.5.20] — 2026-03-19
8
15
 
9
16
  ### Fixed
@@ -1351,6 +1351,8 @@
1351
1351
 
1352
1352
  let response;
1353
1353
  try {
1354
+ const effectivePath = getEffectiveSavePath();
1355
+ const sourcePath = effectivePath || sourceState.path || "";
1354
1356
  response = await fetch("/render-preview?token=" + encodeURIComponent(token), {
1355
1357
  method: "POST",
1356
1358
  headers: {
@@ -1358,8 +1360,8 @@
1358
1360
  },
1359
1361
  body: JSON.stringify({
1360
1362
  markdown: String(markdown || ""),
1361
- sourcePath: sourceState.path || "",
1362
- resourceDir: (!sourceState.path && resourceDirInput) ? resourceDirInput.value.trim() : "",
1363
+ sourcePath: sourcePath,
1364
+ resourceDir: (!sourcePath && resourceDirInput) ? resourceDirInput.value.trim() : "",
1363
1365
  }),
1364
1366
  signal: controller ? controller.signal : undefined,
1365
1367
  });
@@ -1444,16 +1446,17 @@
1444
1446
  return;
1445
1447
  }
1446
1448
 
1447
- const sourcePath = sourceState.path || "";
1448
- const resourceDir = (!sourceState.path && resourceDirInput) ? resourceDirInput.value.trim() : "";
1449
+ const effectivePath = getEffectiveSavePath();
1450
+ const sourcePath = effectivePath || sourceState.path || "";
1451
+ const resourceDir = (!sourcePath && resourceDirInput) ? resourceDirInput.value.trim() : "";
1449
1452
  const isEditorPreview = rightView === "editor-preview";
1450
1453
  const editorPdfLanguage = isEditorPreview ? normalizeFenceLanguage(editorLanguage || "") : "";
1451
1454
  const isLatex = isEditorPreview
1452
1455
  ? editorPdfLanguage === "latex"
1453
1456
  : /\\documentclass\b|\\begin\{document\}/.test(markdown);
1454
1457
  let filenameHint = isEditorPreview ? "studio-editor-preview.pdf" : "studio-response-preview.pdf";
1455
- if (sourceState.path) {
1456
- const baseName = sourceState.path.split(/[\\/]/).pop() || "studio";
1458
+ if (sourcePath) {
1459
+ const baseName = sourcePath.split(/[\\/]/).pop() || "studio";
1457
1460
  const stem = baseName.replace(/\.[^.]+$/, "") || "studio";
1458
1461
  filenameHint = stem + "-preview.pdf";
1459
1462
  }
@@ -1478,8 +1481,8 @@
1478
1481
  }),
1479
1482
  });
1480
1483
 
1484
+ const contentType = String(response.headers.get("content-type") || "").toLowerCase();
1481
1485
  if (!response.ok) {
1482
- const contentType = String(response.headers.get("content-type") || "").toLowerCase();
1483
1486
  let message = "PDF export failed with HTTP " + response.status + ".";
1484
1487
  if (contentType.includes("application/json")) {
1485
1488
  const payload = await response.json().catch(() => null);
@@ -1495,6 +1498,53 @@
1495
1498
  throw new Error(message);
1496
1499
  }
1497
1500
 
1501
+ if (contentType.includes("application/json")) {
1502
+ const payload = await response.json().catch(() => null);
1503
+ if (!payload || typeof payload.downloadUrl !== "string") {
1504
+ throw new Error("PDF export prepared successfully, but Studio did not receive a download URL.");
1505
+ }
1506
+
1507
+ const exportWarning = typeof payload.warning === "string" ? payload.warning.trim() : "";
1508
+ const openError = typeof payload.openError === "string" ? payload.openError.trim() : "";
1509
+ const openedExternal = payload.openedExternal === true;
1510
+ let downloadName = typeof payload.filename === "string" && payload.filename.trim()
1511
+ ? payload.filename.trim()
1512
+ : (filenameHint || "studio-preview.pdf");
1513
+ if (!/\.pdf$/i.test(downloadName)) {
1514
+ downloadName += ".pdf";
1515
+ }
1516
+
1517
+ if (openedExternal) {
1518
+ if (exportWarning) {
1519
+ setStatus("Opened PDF in default viewer with warning: " + exportWarning, "warning");
1520
+ } else {
1521
+ setStatus("Opened PDF in default viewer: " + downloadName, "success");
1522
+ }
1523
+ return;
1524
+ }
1525
+
1526
+ const link = document.createElement("a");
1527
+ link.href = payload.downloadUrl;
1528
+ link.download = downloadName;
1529
+ link.rel = "noopener";
1530
+ document.body.appendChild(link);
1531
+ link.click();
1532
+ link.remove();
1533
+
1534
+ if (openError) {
1535
+ if (exportWarning) {
1536
+ setStatus("Opened browser fallback because external viewer failed (" + openError + "). Warning: " + exportWarning, "warning");
1537
+ } else {
1538
+ setStatus("Opened browser fallback because external viewer failed (" + openError + ").", "warning");
1539
+ }
1540
+ } else if (exportWarning) {
1541
+ setStatus("Exported PDF with warning: " + exportWarning, "warning");
1542
+ } else {
1543
+ setStatus("Exported PDF: " + downloadName, "success");
1544
+ }
1545
+ return;
1546
+ }
1547
+
1498
1548
  const exportWarning = String(response.headers.get("x-pi-studio-export-warning") || "").trim();
1499
1549
  const blob = await response.blob();
1500
1550
  const headerFilename = parseContentDispositionFilename(response.headers.get("content-disposition"));
@@ -4030,7 +4080,7 @@
4030
4080
  const text = typeof reader.result === "string" ? reader.result : "";
4031
4081
  setEditorText(text, { preserveScroll: false, preserveSelection: false });
4032
4082
  setSourceState({
4033
- source: "blank",
4083
+ source: "upload",
4034
4084
  label: "upload: " + file.name,
4035
4085
  path: null,
4036
4086
  });
package/client/studio.css CHANGED
@@ -762,6 +762,33 @@
762
762
  font-family: "STIX Two Math", "Cambria Math", "Latin Modern Math", "STIXGeneral", serif;
763
763
  }
764
764
 
765
+ .rendered-markdown .studio-display-equation {
766
+ position: relative;
767
+ margin: 1em 0;
768
+ }
769
+
770
+ .rendered-markdown .studio-display-equation-body {
771
+ padding-right: 4.5em;
772
+ overflow-x: auto;
773
+ overflow-y: hidden;
774
+ }
775
+
776
+ .rendered-markdown .studio-display-equation-body math[display="block"] {
777
+ margin: 0;
778
+ }
779
+
780
+ .rendered-markdown .studio-display-equation-number {
781
+ position: absolute;
782
+ top: 50%;
783
+ right: 0;
784
+ transform: translateY(-50%);
785
+ color: var(--muted);
786
+ font-size: 0.95em;
787
+ line-height: 1;
788
+ white-space: nowrap;
789
+ font-variant-numeric: tabular-nums;
790
+ }
791
+
765
792
  .rendered-markdown math[display="block"] {
766
793
  display: block;
767
794
  margin: 1em 0;
package/index.ts CHANGED
@@ -56,6 +56,15 @@ interface StudioContextUsageSnapshot {
56
56
  percent: number | null;
57
57
  }
58
58
 
59
+ interface PreparedStudioPdfExport {
60
+ pdf: Buffer;
61
+ filename: string;
62
+ warning?: string;
63
+ createdAt: number;
64
+ filePath?: string;
65
+ tempDirPath?: string;
66
+ }
67
+
59
68
  interface InitialStudioDocument {
60
69
  text: string;
61
70
  label: string;
@@ -158,6 +167,8 @@ const REQUEST_BODY_MAX_BYTES = 1_000_000;
158
167
  const RESPONSE_HISTORY_LIMIT = 30;
159
168
  const UPDATE_CHECK_TIMEOUT_MS = 1800;
160
169
  const CMUX_NOTIFY_TIMEOUT_MS = 1200;
170
+ const PREPARED_PDF_EXPORT_TTL_MS = 5 * 60 * 1000;
171
+ const MAX_PREPARED_PDF_EXPORTS = 8;
161
172
  const STUDIO_TERMINAL_NOTIFY_TITLE = "pi Studio";
162
173
  const CMUX_STUDIO_STATUS_KEY = "pi_studio";
163
174
  const CMUX_STUDIO_STATUS_COLOR_DARK = "#5ea1ff";
@@ -1099,6 +1110,160 @@ function buildStudioPandocBibliographyArgs(markdown: string, isLatex: boolean |
1099
1110
  ];
1100
1111
  }
1101
1112
 
1113
+ function parseStudioAuxTopLevelGroups(input: string): string[] {
1114
+ const groups: string[] = [];
1115
+ let i = 0;
1116
+ while (i < input.length) {
1117
+ while (i < input.length && /\s/.test(input[i]!)) i++;
1118
+ if (i >= input.length) break;
1119
+ if (input[i] !== "{") break;
1120
+ i++;
1121
+ let depth = 1;
1122
+ let current = "";
1123
+ while (i < input.length && depth > 0) {
1124
+ const ch = input[i]!;
1125
+ i++;
1126
+ if (ch === "{") {
1127
+ depth++;
1128
+ current += ch;
1129
+ continue;
1130
+ }
1131
+ if (ch === "}") {
1132
+ depth--;
1133
+ if (depth > 0) current += ch;
1134
+ continue;
1135
+ }
1136
+ current += ch;
1137
+ }
1138
+ groups.push(current);
1139
+ }
1140
+ return groups;
1141
+ }
1142
+
1143
+ function resolveStudioLatexAuxPath(sourcePath: string | undefined, baseDir: string | undefined): string | undefined {
1144
+ const source = typeof sourcePath === "string" ? sourcePath.trim() : "";
1145
+ const workingDir = resolveStudioPandocWorkingDir(baseDir);
1146
+ if (!source) return undefined;
1147
+ const expanded = expandHome(source);
1148
+ const resolvedSource = isAbsolute(expanded)
1149
+ ? expanded
1150
+ : resolve(workingDir || process.cwd(), expanded);
1151
+
1152
+ if (!/\.(tex|latex)$/i.test(resolvedSource)) return undefined;
1153
+ const auxPath = resolvedSource.replace(/\.[^.]+$/i, ".aux");
1154
+ try {
1155
+ return statSync(auxPath).isFile() ? auxPath : undefined;
1156
+ } catch {
1157
+ return undefined;
1158
+ }
1159
+ }
1160
+
1161
+ function readStudioLatexAuxLabels(sourcePath: string | undefined, baseDir: string | undefined): Map<string, { number: string; kind: string }> {
1162
+ const auxPath = resolveStudioLatexAuxPath(sourcePath, baseDir);
1163
+ const labels = new Map<string, { number: string; kind: string }>();
1164
+ if (!auxPath) return labels;
1165
+
1166
+ let text = "";
1167
+ try {
1168
+ text = readFileSync(auxPath, "utf-8");
1169
+ } catch {
1170
+ return labels;
1171
+ }
1172
+
1173
+ for (const line of text.split(/\r?\n/)) {
1174
+ const match = line.match(/^\\newlabel\{([^}]+)\}\{(.*)\}$/);
1175
+ if (!match) continue;
1176
+ const label = match[1] ?? "";
1177
+ if (!label || label.endsWith("@cref")) continue;
1178
+ const groups = parseStudioAuxTopLevelGroups(match[2] ?? "");
1179
+ if (groups.length === 0) continue;
1180
+ const number = String(groups[0] ?? "").trim();
1181
+ if (!number) continue;
1182
+ const rawKind = String(groups[3] ?? "").trim();
1183
+ const kind = rawKind.split(".")[0] || (label.startsWith("eq:") ? "equation" : label.startsWith("fig:") ? "figure" : "ref");
1184
+ labels.set(label, { number, kind });
1185
+ }
1186
+
1187
+ return labels;
1188
+ }
1189
+
1190
+ function formatStudioLatexReference(label: string, referenceType: "eqref" | "ref" | "autoref", labels: Map<string, { number: string; kind: string }>): string | null {
1191
+ const entry = labels.get(label);
1192
+ if (!entry) return null;
1193
+ if (referenceType === "eqref") return `(${entry.number})`;
1194
+ if (referenceType === "autoref") {
1195
+ if (entry.kind === "equation") return `Equation ${entry.number}`;
1196
+ if (entry.kind === "figure") return `Figure ${entry.number}`;
1197
+ if (entry.kind === "section" || entry.kind === "subsection" || entry.kind === "subsubsection") return `Section ${entry.number}`;
1198
+ if (entry.kind === "algorithm") return `Algorithm ${entry.number}`;
1199
+ }
1200
+ return entry.number;
1201
+ }
1202
+
1203
+ function preprocessStudioLatexReferences(markdown: string, sourcePath: string | undefined, baseDir: string | undefined): string {
1204
+ const labels = readStudioLatexAuxLabels(sourcePath, baseDir);
1205
+ if (labels.size === 0) return markdown;
1206
+ let transformed = String(markdown ?? "");
1207
+ transformed = transformed.replace(/\\eqref\s*\{([^}]+)\}/g, (match, label) => formatStudioLatexReference(String(label || "").trim(), "eqref", labels) ?? match);
1208
+ transformed = transformed.replace(/\\autoref\s*\{([^}]+)\}/g, (match, label) => formatStudioLatexReference(String(label || "").trim(), "autoref", labels) ?? match);
1209
+ transformed = transformed.replace(/\\ref\s*\{([^}]+)\}/g, (match, label) => formatStudioLatexReference(String(label || "").trim(), "ref", labels) ?? match);
1210
+ return transformed;
1211
+ }
1212
+
1213
+ function escapeStudioHtmlText(text: string): string {
1214
+ return String(text ?? "")
1215
+ .replace(/&/g, "&amp;")
1216
+ .replace(/</g, "&lt;")
1217
+ .replace(/>/g, "&gt;")
1218
+ .replace(/"/g, "&quot;")
1219
+ .replace(/'/g, "&#39;");
1220
+ }
1221
+
1222
+ function decorateStudioLatexRenderedHtml(html: string, sourcePath: string | undefined, baseDir: string | undefined): string {
1223
+ const labels = readStudioLatexAuxLabels(sourcePath, baseDir);
1224
+ if (labels.size === 0) return html;
1225
+ let transformed = String(html ?? "");
1226
+
1227
+ transformed = transformed.replace(/<a\b([^>]*)>([\s\S]*?)<\/a>/g, (match, attrs) => {
1228
+ const typeMatch = String(attrs ?? "").match(/\bdata-reference-type="([^"]+)"/);
1229
+ const labelMatch = String(attrs ?? "").match(/\bdata-reference="([^"]+)"/);
1230
+ if (!typeMatch || !labelMatch) return match;
1231
+ const referenceTypeRaw = String(typeMatch[1] ?? "").trim();
1232
+ const label = String(labelMatch[1] ?? "").trim();
1233
+ const referenceType =
1234
+ referenceTypeRaw === "eqref" || referenceTypeRaw === "autoref" || referenceTypeRaw === "ref"
1235
+ ? referenceTypeRaw
1236
+ : null;
1237
+ if (!referenceType || !label) return match;
1238
+ const formatted = formatStudioLatexReference(label, referenceType, labels);
1239
+ if (!formatted) return match;
1240
+ return `<a${attrs}>${escapeStudioHtmlText(formatted)}</a>`;
1241
+ });
1242
+
1243
+ transformed = transformed.replace(/<math\b[^>]*display="block"[^>]*>[\s\S]*?<\/math>/g, (block) => {
1244
+ if (/studio-display-equation/.test(block)) return block;
1245
+ const labelMatch = block.match(/\\label\s*\{([^}]+)\}/);
1246
+ if (!labelMatch) return block;
1247
+ const label = String(labelMatch[1] ?? "").trim();
1248
+ if (!label) return block;
1249
+ const formatted = formatStudioLatexReference(label, "eqref", labels);
1250
+ if (!formatted) return block;
1251
+ return `<div class="studio-display-equation"><div class="studio-display-equation-body">${block}</div><div class="studio-display-equation-number">${escapeStudioHtmlText(formatted)}</div></div>`;
1252
+ });
1253
+
1254
+ return transformed;
1255
+ }
1256
+
1257
+ function injectStudioLatexEquationTags(markdown: string, sourcePath: string | undefined, baseDir: string | undefined): string {
1258
+ const labels = readStudioLatexAuxLabels(sourcePath, baseDir);
1259
+ if (labels.size === 0) return markdown;
1260
+ return String(markdown ?? "").replace(/\\label\s*\{([^}]+)\}/g, (match, label) => {
1261
+ const entry = labels.get(String(label || "").trim());
1262
+ if (!entry || entry.kind !== "equation") return match;
1263
+ return `\\tag{${entry.number}}\\label{${String(label || "").trim()}}`;
1264
+ });
1265
+ }
1266
+
1102
1267
  function readStudioGitDiff(baseDir: string):
1103
1268
  | { ok: true; text: string; label: string }
1104
1269
  | { ok: false; level: "info" | "warning" | "error"; message: string } {
@@ -1681,17 +1846,18 @@ async function preprocessStudioMermaidForPdf(markdown: string, workDir: string):
1681
1846
  };
1682
1847
  }
1683
1848
 
1684
- async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolean, resourcePath?: string): Promise<string> {
1849
+ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolean, resourcePath?: string, sourcePath?: string): Promise<string> {
1685
1850
  const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
1851
+ const sourceWithResolvedRefs = isLatex ? preprocessStudioLatexReferences(markdown, sourcePath, resourcePath) : markdown;
1686
1852
  const inputFormat = isLatex ? "latex" : "markdown+lists_without_preceding_blankline+tex_math_dollars+tex_math_single_backslash+tex_math_double_backslash+autolink_bare_uris-raw_html";
1687
- const bibliographyArgs = buildStudioPandocBibliographyArgs(markdown, isLatex, resourcePath);
1853
+ const bibliographyArgs = buildStudioPandocBibliographyArgs(sourceWithResolvedRefs, isLatex, resourcePath);
1688
1854
  const args = ["-f", inputFormat, "-t", "html5", "--mathml", "--wrap=none", ...bibliographyArgs];
1689
1855
  if (resourcePath) {
1690
1856
  args.push(`--resource-path=${resourcePath}`);
1691
1857
  // Embed images as data URIs so they render in the browser preview
1692
1858
  args.push("--embed-resources", "--standalone");
1693
1859
  }
1694
- const normalizedMarkdown = isLatex ? markdown : normalizeObsidianImages(normalizeMathDelimiters(markdown));
1860
+ const normalizedMarkdown = isLatex ? sourceWithResolvedRefs : normalizeObsidianImages(normalizeMathDelimiters(sourceWithResolvedRefs));
1695
1861
  const pandocWorkingDir = resolveStudioPandocWorkingDir(resourcePath);
1696
1862
 
1697
1863
  return await new Promise<string>((resolve, reject) => {
@@ -1737,6 +1903,9 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
1737
1903
  const bodyMatch = renderedHtml.match(/<body[^>]*>([\s\S]*)<\/body>/i);
1738
1904
  if (bodyMatch) renderedHtml = bodyMatch[1];
1739
1905
  }
1906
+ if (isLatex) {
1907
+ renderedHtml = decorateStudioLatexRenderedHtml(renderedHtml, sourcePath, resourcePath);
1908
+ }
1740
1909
  succeed(stripMathMlAnnotationTags(renderedHtml));
1741
1910
  return;
1742
1911
  }
@@ -1832,12 +2001,16 @@ async function renderStudioPdfWithPandoc(
1832
2001
  isLatex?: boolean,
1833
2002
  resourcePath?: string,
1834
2003
  editorPdfLanguage?: string,
2004
+ sourcePath?: string,
1835
2005
  ): Promise<{ pdf: Buffer; warning?: string }> {
1836
2006
  const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
1837
2007
  const pdfEngine = process.env.PANDOC_PDF_ENGINE?.trim() || "xelatex";
1838
- const effectiveEditorLanguage = inferStudioPdfLanguage(markdown, editorPdfLanguage);
2008
+ const sourceWithResolvedRefs = isLatex
2009
+ ? injectStudioLatexEquationTags(preprocessStudioLatexReferences(markdown, sourcePath, resourcePath), sourcePath, resourcePath)
2010
+ : markdown;
2011
+ const effectiveEditorLanguage = inferStudioPdfLanguage(sourceWithResolvedRefs, editorPdfLanguage);
1839
2012
  const pandocWorkingDir = resolveStudioPandocWorkingDir(resourcePath);
1840
- const bibliographyArgs = buildStudioPandocBibliographyArgs(markdown, isLatex, resourcePath);
2013
+ const bibliographyArgs = buildStudioPandocBibliographyArgs(sourceWithResolvedRefs, isLatex, resourcePath);
1841
2014
 
1842
2015
  const runPandocPdfExport = async (
1843
2016
  inputFormat: string,
@@ -1934,7 +2107,7 @@ async function renderStudioPdfWithPandoc(
1934
2107
  const inputFormat = isLatex
1935
2108
  ? "latex"
1936
2109
  : "markdown+lists_without_preceding_blankline+tex_math_dollars+tex_math_single_backslash+tex_math_double_backslash+autolink_bare_uris+superscript+subscript-raw_html";
1937
- const normalizedMarkdown = prepareStudioPdfMarkdown(markdown, isLatex, effectiveEditorLanguage);
2110
+ const normalizedMarkdown = prepareStudioPdfMarkdown(sourceWithResolvedRefs, isLatex, effectiveEditorLanguage);
1938
2111
 
1939
2112
  const tempDir = join(tmpdir(), `pi-studio-pdf-${Date.now()}-${randomUUID()}`);
1940
2113
  const preamblePath = join(tempDir, "_pdf_preamble.tex");
@@ -2095,6 +2268,27 @@ function openUrlInDefaultBrowser(url: string): Promise<void> {
2095
2268
  });
2096
2269
  }
2097
2270
 
2271
+ function openPathInDefaultViewer(path: string): Promise<void> {
2272
+ const openCommand =
2273
+ process.platform === "darwin"
2274
+ ? { command: "open", args: [path] }
2275
+ : process.platform === "win32"
2276
+ ? { command: "cmd", args: ["/c", "start", "", path] }
2277
+ : { command: "xdg-open", args: [path] };
2278
+
2279
+ return new Promise<void>((resolve, reject) => {
2280
+ const child = spawn(openCommand.command, openCommand.args, {
2281
+ stdio: "ignore",
2282
+ detached: true,
2283
+ });
2284
+ child.once("error", reject);
2285
+ child.once("spawn", () => {
2286
+ child.unref();
2287
+ resolve();
2288
+ });
2289
+ });
2290
+ }
2291
+
2098
2292
  function detectLensFromText(text: string): Lens {
2099
2293
  const lines = text.split("\n");
2100
2294
  const fencedCodeBlocks = (text.match(/```[\w-]*\n[\s\S]*?```/g) ?? []).length;
@@ -3044,6 +3238,7 @@ export default function (pi: ExtensionAPI) {
3044
3238
  let serverState: StudioServerState | null = null;
3045
3239
  let activeRequest: ActiveStudioRequest | null = null;
3046
3240
  let lastStudioResponse: LastStudioResponse | null = null;
3241
+ let preparedPdfExports = new Map<string, PreparedStudioPdfExport>();
3047
3242
  let initialStudioDocument: InitialStudioDocument | null = null;
3048
3243
  let studioCwd = process.cwd();
3049
3244
  let lastCommandCtx: ExtensionCommandContext | null = null;
@@ -4105,6 +4300,100 @@ export default function (pi: ExtensionAPI) {
4105
4300
  }
4106
4301
  };
4107
4302
 
4303
+ const disposePreparedPdfExport = (entry: PreparedStudioPdfExport | null | undefined) => {
4304
+ if (!entry?.tempDirPath) return;
4305
+ void rm(entry.tempDirPath, { recursive: true, force: true }).catch(() => undefined);
4306
+ };
4307
+
4308
+ const clearPreparedPdfExports = () => {
4309
+ for (const entry of preparedPdfExports.values()) {
4310
+ disposePreparedPdfExport(entry);
4311
+ }
4312
+ preparedPdfExports.clear();
4313
+ };
4314
+
4315
+ const prunePreparedPdfExports = () => {
4316
+ const now = Date.now();
4317
+ for (const [id, entry] of preparedPdfExports) {
4318
+ if (entry.createdAt + PREPARED_PDF_EXPORT_TTL_MS <= now) {
4319
+ preparedPdfExports.delete(id);
4320
+ disposePreparedPdfExport(entry);
4321
+ }
4322
+ }
4323
+ while (preparedPdfExports.size > MAX_PREPARED_PDF_EXPORTS) {
4324
+ const oldestKey = preparedPdfExports.keys().next().value;
4325
+ if (!oldestKey) break;
4326
+ const oldestEntry = preparedPdfExports.get(oldestKey);
4327
+ preparedPdfExports.delete(oldestKey);
4328
+ disposePreparedPdfExport(oldestEntry);
4329
+ }
4330
+ };
4331
+
4332
+ const storePreparedPdfExport = (pdf: Buffer, filename: string, warning?: string): string => {
4333
+ prunePreparedPdfExports();
4334
+ const exportId = randomUUID();
4335
+ preparedPdfExports.set(exportId, {
4336
+ pdf,
4337
+ filename,
4338
+ warning,
4339
+ createdAt: Date.now(),
4340
+ });
4341
+ return exportId;
4342
+ };
4343
+
4344
+ const ensurePreparedPdfExportFile = async (exportId: string): Promise<PreparedStudioPdfExport | null> => {
4345
+ prunePreparedPdfExports();
4346
+ const entry = preparedPdfExports.get(exportId);
4347
+ if (!entry) return null;
4348
+ if (entry.filePath && entry.tempDirPath) return entry;
4349
+
4350
+ const tempDirPath = join(tmpdir(), `pi-studio-prepared-pdf-${Date.now()}-${randomUUID()}`);
4351
+ const filePath = join(tempDirPath, sanitizePdfFilename(entry.filename));
4352
+ await mkdir(tempDirPath, { recursive: true });
4353
+ await writeFile(filePath, entry.pdf);
4354
+ entry.tempDirPath = tempDirPath;
4355
+ entry.filePath = filePath;
4356
+ preparedPdfExports.set(exportId, entry);
4357
+ return entry;
4358
+ };
4359
+
4360
+ const getPreparedPdfExport = (exportId: string): PreparedStudioPdfExport | null => {
4361
+ prunePreparedPdfExports();
4362
+ return preparedPdfExports.get(exportId) ?? null;
4363
+ };
4364
+
4365
+ const handlePreparedPdfDownloadRequest = (requestUrl: URL, res: ServerResponse) => {
4366
+ const exportId = requestUrl.searchParams.get("id") ?? "";
4367
+ if (!exportId) {
4368
+ respondText(res, 400, "Missing PDF export id.");
4369
+ return;
4370
+ }
4371
+
4372
+ const prepared = getPreparedPdfExport(exportId);
4373
+ if (!prepared) {
4374
+ respondText(res, 404, "PDF export is no longer available. Re-export the document.");
4375
+ return;
4376
+ }
4377
+
4378
+ const safeAsciiName = prepared.filename
4379
+ .replace(/[\x00-\x1f\x7f]/g, "")
4380
+ .replace(/[;"\\]/g, "_")
4381
+ .replace(/\s+/g, " ")
4382
+ .trim() || "studio-preview.pdf";
4383
+
4384
+ const headers: Record<string, string> = {
4385
+ "Content-Type": "application/pdf",
4386
+ "Cache-Control": "no-store",
4387
+ "X-Content-Type-Options": "nosniff",
4388
+ "Content-Disposition": `inline; filename="${safeAsciiName}"; filename*=UTF-8''${encodeURIComponent(prepared.filename)}`,
4389
+ "Content-Length": String(prepared.pdf.length),
4390
+ };
4391
+ if (prepared.warning) headers["X-Pi-Studio-Export-Warning"] = prepared.warning;
4392
+
4393
+ res.writeHead(200, headers);
4394
+ res.end(prepared.pdf);
4395
+ };
4396
+
4108
4397
  const handleRenderPreviewRequest = async (req: IncomingMessage, res: ServerResponse) => {
4109
4398
  let rawBody = "";
4110
4399
  try {
@@ -4153,7 +4442,7 @@ export default function (pi: ExtensionAPI) {
4153
4442
  : "";
4154
4443
  const resourcePath = resolveStudioBaseDir(sourcePath || undefined, userResourceDir || undefined, studioCwd);
4155
4444
  const isLatex = /\\documentclass\b|\\begin\{document\}/.test(markdown);
4156
- const html = await renderStudioMarkdownWithPandoc(markdown, isLatex, resourcePath);
4445
+ const html = await renderStudioMarkdownWithPandoc(markdown, isLatex, resourcePath, sourcePath || undefined);
4157
4446
  respondJson(res, 200, { ok: true, html, renderer: "pandoc" });
4158
4447
  } catch (error) {
4159
4448
  const message = error instanceof Error ? error.message : String(error);
@@ -4227,24 +4516,29 @@ export default function (pi: ExtensionAPI) {
4227
4516
  const filename = sanitizePdfFilename(requestedFilename || (isLatex ? "studio-latex-preview.pdf" : "studio-preview.pdf"));
4228
4517
 
4229
4518
  try {
4230
- const { pdf, warning } = await renderStudioPdfWithPandoc(markdown, isLatex, resourcePath, editorPdfLanguage);
4231
- const safeAsciiName = filename
4232
- .replace(/[\x00-\x1f\x7f]/g, "")
4233
- .replace(/[;"\\]/g, "_")
4234
- .replace(/\s+/g, " ")
4235
- .trim() || "studio-preview.pdf";
4236
-
4237
- const headers: Record<string, string> = {
4238
- "Content-Type": "application/pdf",
4239
- "Cache-Control": "no-store",
4240
- "X-Content-Type-Options": "nosniff",
4241
- "Content-Disposition": `attachment; filename="${safeAsciiName}"; filename*=UTF-8''${encodeURIComponent(filename)}`,
4242
- "Content-Length": String(pdf.length),
4243
- };
4244
- if (warning) headers["X-Pi-Studio-Export-Warning"] = warning;
4245
-
4246
- res.writeHead(200, headers);
4247
- res.end(pdf);
4519
+ const { pdf, warning } = await renderStudioPdfWithPandoc(markdown, isLatex, resourcePath, editorPdfLanguage, sourcePath || undefined);
4520
+ const exportId = storePreparedPdfExport(pdf, filename, warning);
4521
+ const token = serverState?.token ?? "";
4522
+ let openedExternal = false;
4523
+ let openError: string | null = null;
4524
+ try {
4525
+ const prepared = await ensurePreparedPdfExportFile(exportId);
4526
+ if (!prepared?.filePath) {
4527
+ throw new Error("Prepared PDF file was not available for external open.");
4528
+ }
4529
+ await openPathInDefaultViewer(prepared.filePath);
4530
+ openedExternal = true;
4531
+ } catch (viewerError) {
4532
+ openError = viewerError instanceof Error ? viewerError.message : String(viewerError);
4533
+ }
4534
+ respondJson(res, 200, {
4535
+ ok: true,
4536
+ filename,
4537
+ warning: warning ?? null,
4538
+ openedExternal,
4539
+ openError,
4540
+ downloadUrl: `/export-pdf?token=${encodeURIComponent(token)}&id=${encodeURIComponent(exportId)}`,
4541
+ });
4248
4542
  } catch (error) {
4249
4543
  const message = error instanceof Error ? error.message : String(error);
4250
4544
  respondJson(res, 500, { ok: false, error: `PDF export failed: ${message}` });
@@ -4361,14 +4655,23 @@ export default function (pi: ExtensionAPI) {
4361
4655
  if (requestUrl.pathname === "/export-pdf") {
4362
4656
  const token = requestUrl.searchParams.get("token") ?? "";
4363
4657
  if (token !== serverState.token) {
4364
- respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
4658
+ const method = (req.method ?? "GET").toUpperCase();
4659
+ if (method === "GET") {
4660
+ respondText(res, 403, "Invalid or expired studio token. Re-run /studio.");
4661
+ } else {
4662
+ respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
4663
+ }
4365
4664
  return;
4366
4665
  }
4367
4666
 
4368
4667
  const method = (req.method ?? "GET").toUpperCase();
4668
+ if (method === "GET") {
4669
+ handlePreparedPdfDownloadRequest(requestUrl, res);
4670
+ return;
4671
+ }
4369
4672
  if (method !== "POST") {
4370
- res.setHeader("Allow", "POST");
4371
- respondJson(res, 405, { ok: false, error: "Method not allowed. Use POST." });
4673
+ res.setHeader("Allow", "GET, POST");
4674
+ respondJson(res, 405, { ok: false, error: "Method not allowed. Use GET or POST." });
4372
4675
  return;
4373
4676
  }
4374
4677
 
@@ -4534,6 +4837,7 @@ export default function (pi: ExtensionAPI) {
4534
4837
  if (!serverState) return;
4535
4838
  clearActiveRequest();
4536
4839
  clearPendingStudioCompletion();
4840
+ clearPreparedPdfExports();
4537
4841
  clearCompactionState();
4538
4842
  closeAllClients(1001, "Server shutting down");
4539
4843
 
@@ -4552,6 +4856,7 @@ export default function (pi: ExtensionAPI) {
4552
4856
  const rotateToken = () => {
4553
4857
  if (!serverState) return;
4554
4858
  serverState.token = createSessionToken();
4859
+ clearPreparedPdfExports();
4555
4860
  closeAllClients(4001, "Session invalidated");
4556
4861
  broadcastState();
4557
4862
  };
@@ -4566,6 +4871,7 @@ export default function (pi: ExtensionAPI) {
4566
4871
  clearCompactionState();
4567
4872
  agentBusy = false;
4568
4873
  clearPendingStudioCompletion();
4874
+ clearPreparedPdfExports();
4569
4875
  refreshRuntimeMetadata({ cwd: ctx.cwd, model: ctx.model });
4570
4876
  refreshContextUsage(ctx);
4571
4877
  emitDebugEvent("session_start", {
@@ -4585,6 +4891,7 @@ export default function (pi: ExtensionAPI) {
4585
4891
  hydrateLatestAssistant(ctx.sessionManager.getBranch());
4586
4892
  agentBusy = false;
4587
4893
  clearPendingStudioCompletion();
4894
+ clearPreparedPdfExports();
4588
4895
  refreshRuntimeMetadata({ cwd: ctx.cwd, model: ctx.model });
4589
4896
  refreshContextUsage(ctx);
4590
4897
  emitDebugEvent("session_switch", {
@@ -4805,6 +5112,7 @@ export default function (pi: ExtensionAPI) {
4805
5112
  lastCommandCtx = null;
4806
5113
  agentBusy = false;
4807
5114
  clearPendingStudioCompletion();
5115
+ clearPreparedPdfExports();
4808
5116
  clearCompactionState();
4809
5117
  setTerminalActivity("idle");
4810
5118
  await stopServer();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.5.20",
3
+ "version": "0.5.21",
4
4
  "description": "Browser GUI for structured critique workflows in pi",
5
5
  "type": "module",
6
6
  "license": "MIT",