pi-studio 0.5.25 → 0.5.26

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,15 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.26] — 2026-03-21
8
+
9
+ ### Added
10
+ - Added a file-based `/studio-pdf <path>` command that exports a local file to `<name>.studio.pdf` using the existing Studio PDF pipeline and opens the result in the default PDF viewer, without requiring the Studio browser UI.
11
+
12
+ ### Fixed
13
+ - Markdown preview/PDF rendering now also allows blockquotes without a preceding blank line, matching the earlier tolerant list parsing and preventing leading `>` quote lines from collapsing into plain paragraph text.
14
+ - Studio browser preview now keeps the existing MathML rendering for ordinary equations but falls back to MathJax for pandoc-unsupported math blocks, improving advanced LaTeX matrix/array preview cases without switching all preview math to MathJax.
15
+
7
16
  ## [0.5.25] — 2026-03-21
8
17
 
9
18
  ### Fixed
package/README.md CHANGED
@@ -28,6 +28,7 @@ Experimental extension for [pi](https://github.com/badlogic/pi-mono) that opens
28
28
  - saves `.annotated.md`
29
29
  - Renders Markdown/LaTeX/code previews (math + Mermaid), theme-synced with pi
30
30
  - Exports right-pane preview as PDF (pandoc + LaTeX)
31
+ - Exports local files headlessly via `/studio-pdf <path>` to `<name>.studio.pdf`
31
32
  - Shows model/session/context usage in the footer, plus a compact-context action
32
33
 
33
34
  ## Commands
@@ -41,6 +42,7 @@ Experimental extension for [pi](https://github.com/badlogic/pi-mono) that opens
41
42
  | `/studio --status` | Show studio server status |
42
43
  | `/studio --stop` | Stop studio server |
43
44
  | `/studio --help` | Show help |
45
+ | `/studio-pdf <path>` | Export a local file to `<name>.studio.pdf` via the Studio PDF pipeline |
44
46
 
45
47
  ## Install
46
48
 
@@ -223,6 +223,7 @@
223
223
  const ANNOTATION_MARKER_REGEX = /\[an:\s*([^\]]+?)\]/gi;
224
224
  const EMPTY_OVERLAY_LINE = "\u200b";
225
225
  const MERMAID_CDN_URL = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
226
+ const MATHJAX_CDN_URL = "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js";
226
227
  const BOOT = (typeof window.__PI_STUDIO_BOOT__ === "object" && window.__PI_STUDIO_BOOT__)
227
228
  ? window.__PI_STUDIO_BOOT__
228
229
  : {};
@@ -231,8 +232,11 @@
231
232
  : {};
232
233
  const MERMAID_UNAVAILABLE_MESSAGE = "Mermaid renderer unavailable. Showing mermaid blocks as code.";
233
234
  const MERMAID_RENDER_FAIL_MESSAGE = "Mermaid render failed. Showing diagram source text.";
235
+ const MATHJAX_UNAVAILABLE_MESSAGE = "Math fallback unavailable. Some unsupported equations may remain as raw TeX.";
236
+ const MATHJAX_RENDER_FAIL_MESSAGE = "Math fallback could not render some unsupported equations.";
234
237
  let mermaidModulePromise = null;
235
238
  let mermaidInitialized = false;
239
+ let mathJaxPromise = null;
236
240
 
237
241
  const DEBUG_ENABLED = (() => {
238
242
  try {
@@ -1137,6 +1141,171 @@
1137
1141
  return buildPreviewErrorHtml("Preview sanitizer unavailable. Showing plain markdown.", markdown);
1138
1142
  }
1139
1143
 
1144
+ function appendMathFallbackNotice(targetEl, message) {
1145
+ if (!targetEl || typeof targetEl.querySelector !== "function" || typeof targetEl.appendChild !== "function") {
1146
+ return;
1147
+ }
1148
+
1149
+ if (targetEl.querySelector(".preview-math-warning")) {
1150
+ return;
1151
+ }
1152
+
1153
+ const warningEl = document.createElement("div");
1154
+ warningEl.className = "preview-warning preview-math-warning";
1155
+ warningEl.textContent = String(message || MATHJAX_UNAVAILABLE_MESSAGE);
1156
+ targetEl.appendChild(warningEl);
1157
+ }
1158
+
1159
+ function extractMathFallbackTex(text, displayMode) {
1160
+ const source = typeof text === "string" ? text.trim() : "";
1161
+ if (!source) return "";
1162
+
1163
+ if (displayMode) {
1164
+ if (source.startsWith("$$") && source.endsWith("$$") && source.length >= 4) {
1165
+ return source.slice(2, -2).replace(/^\s+|\s+$/g, "");
1166
+ }
1167
+ if (source.startsWith("\\[") && source.endsWith("\\]") && source.length >= 4) {
1168
+ return source.slice(2, -2).replace(/^\s+|\s+$/g, "");
1169
+ }
1170
+ return source;
1171
+ }
1172
+
1173
+ if (source.startsWith("\\(") && source.endsWith("\\)") && source.length >= 4) {
1174
+ return source.slice(2, -2).trim();
1175
+ }
1176
+ if (source.startsWith("$") && source.endsWith("$") && source.length >= 2) {
1177
+ return source.slice(1, -1).trim();
1178
+ }
1179
+ return source;
1180
+ }
1181
+
1182
+ function collectMathFallbackTargets(targetEl) {
1183
+ if (!targetEl || typeof targetEl.querySelectorAll !== "function") return [];
1184
+
1185
+ const nodes = Array.from(targetEl.querySelectorAll(".math.display, .math.inline"));
1186
+ const targets = [];
1187
+ const seenTargets = new Set();
1188
+
1189
+ nodes.forEach((node) => {
1190
+ if (!node || !node.classList) return;
1191
+ const displayMode = node.classList.contains("display");
1192
+ const rawText = typeof node.textContent === "string" ? node.textContent : "";
1193
+ const tex = extractMathFallbackTex(rawText, displayMode);
1194
+ if (!tex) return;
1195
+
1196
+ let renderTarget = node;
1197
+ if (displayMode) {
1198
+ const parent = node.parentElement;
1199
+ const parentText = parent && typeof parent.textContent === "string" ? parent.textContent.trim() : "";
1200
+ if (parent && parent.tagName === "P" && parentText === rawText.trim()) {
1201
+ renderTarget = parent;
1202
+ }
1203
+ }
1204
+
1205
+ if (seenTargets.has(renderTarget)) return;
1206
+ seenTargets.add(renderTarget);
1207
+ targets.push({ node, renderTarget, displayMode, tex });
1208
+ });
1209
+
1210
+ return targets;
1211
+ }
1212
+
1213
+ function ensureMathJax() {
1214
+ if (window.MathJax && typeof window.MathJax.typesetPromise === "function") {
1215
+ return Promise.resolve(window.MathJax);
1216
+ }
1217
+
1218
+ if (mathJaxPromise) {
1219
+ return mathJaxPromise;
1220
+ }
1221
+
1222
+ mathJaxPromise = new Promise((resolve, reject) => {
1223
+ const globalMathJax = (window.MathJax && typeof window.MathJax === "object") ? window.MathJax : {};
1224
+ const texConfig = (globalMathJax.tex && typeof globalMathJax.tex === "object") ? globalMathJax.tex : {};
1225
+ const loaderConfig = (globalMathJax.loader && typeof globalMathJax.loader === "object") ? globalMathJax.loader : {};
1226
+ const startupConfig = (globalMathJax.startup && typeof globalMathJax.startup === "object") ? globalMathJax.startup : {};
1227
+ const optionsConfig = (globalMathJax.options && typeof globalMathJax.options === "object") ? globalMathJax.options : {};
1228
+ const loaderEntries = Array.isArray(loaderConfig.load) ? loaderConfig.load.slice() : [];
1229
+ ["[tex]/ams", "[tex]/noerrors", "[tex]/noundefined"].forEach((entry) => {
1230
+ if (loaderEntries.indexOf(entry) === -1) loaderEntries.push(entry);
1231
+ });
1232
+
1233
+ window.MathJax = Object.assign({}, globalMathJax, {
1234
+ loader: Object.assign({}, loaderConfig, {
1235
+ load: loaderEntries,
1236
+ }),
1237
+ tex: Object.assign({}, texConfig, {
1238
+ inlineMath: [["\\(", "\\)"], ["$", "$"]],
1239
+ displayMath: [["\\[", "\\]"], ["$$", "$$"]],
1240
+ packages: Object.assign({}, texConfig.packages || {}, { "[+]": ["ams", "noerrors", "noundefined"] }),
1241
+ }),
1242
+ options: Object.assign({}, optionsConfig, {
1243
+ skipHtmlTags: ["script", "noscript", "style", "textarea", "pre", "code"],
1244
+ }),
1245
+ startup: Object.assign({}, startupConfig, {
1246
+ typeset: false,
1247
+ }),
1248
+ });
1249
+
1250
+ const script = document.createElement("script");
1251
+ script.src = MATHJAX_CDN_URL;
1252
+ script.async = true;
1253
+ script.dataset.piStudioMathjax = "1";
1254
+ script.onload = () => {
1255
+ const api = window.MathJax;
1256
+ if (api && api.startup && api.startup.promise && typeof api.startup.promise.then === "function") {
1257
+ api.startup.promise.then(() => resolve(api)).catch(reject);
1258
+ return;
1259
+ }
1260
+ if (api && typeof api.typesetPromise === "function") {
1261
+ resolve(api);
1262
+ return;
1263
+ }
1264
+ reject(new Error("MathJax did not initialize."));
1265
+ };
1266
+ script.onerror = () => {
1267
+ reject(new Error("Failed to load MathJax."));
1268
+ };
1269
+ document.head.appendChild(script);
1270
+ }).catch((error) => {
1271
+ mathJaxPromise = null;
1272
+ throw error;
1273
+ });
1274
+
1275
+ return mathJaxPromise;
1276
+ }
1277
+
1278
+ async function renderMathFallbackInElement(targetEl) {
1279
+ const fallbackTargets = collectMathFallbackTargets(targetEl);
1280
+ if (fallbackTargets.length === 0) return;
1281
+
1282
+ fallbackTargets.forEach((entry) => {
1283
+ entry.renderTarget.classList.add("studio-mathjax-fallback");
1284
+ if (entry.displayMode) {
1285
+ entry.renderTarget.classList.add("studio-mathjax-fallback-display");
1286
+ entry.renderTarget.textContent = "\\[\n" + entry.tex + "\n\\]";
1287
+ } else {
1288
+ entry.renderTarget.textContent = "\\(" + entry.tex + "\\)";
1289
+ }
1290
+ });
1291
+
1292
+ let mathJax;
1293
+ try {
1294
+ mathJax = await ensureMathJax();
1295
+ } catch (error) {
1296
+ console.error("MathJax load failed:", error);
1297
+ appendMathFallbackNotice(targetEl, MATHJAX_UNAVAILABLE_MESSAGE);
1298
+ return;
1299
+ }
1300
+
1301
+ try {
1302
+ await mathJax.typesetPromise(fallbackTargets.map((entry) => entry.renderTarget));
1303
+ } catch (error) {
1304
+ console.error("MathJax fallback render failed:", error);
1305
+ appendMathFallbackNotice(targetEl, MATHJAX_RENDER_FAIL_MESSAGE);
1306
+ }
1307
+ }
1308
+
1140
1309
  function applyAnnotationMarkersToElement(targetEl, mode) {
1141
1310
  if (!targetEl || mode === "none") return;
1142
1311
  if (typeof document.createTreeWalker !== "function") return;
@@ -1596,6 +1765,7 @@
1596
1765
  : "none";
1597
1766
  applyAnnotationMarkersToElement(targetEl, annotationMode);
1598
1767
  await renderMermaidInElement(targetEl);
1768
+ await renderMathFallbackInElement(targetEl);
1599
1769
 
1600
1770
  // Warn if relative images are present but unlikely to resolve (non-file-backed content)
1601
1771
  if (!sourceState.path && !(resourceDirInput && resourceDirInput.value.trim())) {
package/client/studio.css CHANGED
@@ -860,7 +860,8 @@
860
860
  overflow-wrap: anywhere;
861
861
  }
862
862
 
863
- .rendered-markdown .studio-algorithm-line-content math {
863
+ .rendered-markdown .studio-algorithm-line-content math,
864
+ .rendered-markdown .studio-algorithm-line-content mjx-container {
864
865
  font-size: 1em;
865
866
  }
866
867
 
@@ -868,6 +869,14 @@
868
869
  font-family: "STIX Two Math", "Cambria Math", "Latin Modern Math", "STIXGeneral", serif;
869
870
  }
870
871
 
872
+ .rendered-markdown mjx-container[display="true"] {
873
+ display: block;
874
+ margin: 1em 0;
875
+ text-align: center;
876
+ overflow-x: auto;
877
+ overflow-y: hidden;
878
+ }
879
+
871
880
  .rendered-markdown .studio-display-equation {
872
881
  position: relative;
873
882
  margin: 1em 0;
@@ -879,7 +888,8 @@
879
888
  overflow-y: hidden;
880
889
  }
881
890
 
882
- .rendered-markdown .studio-display-equation-body math[display="block"] {
891
+ .rendered-markdown .studio-display-equation-body math[display="block"],
892
+ .rendered-markdown .studio-display-equation-body mjx-container[display="true"] {
883
893
  margin: 0;
884
894
  }
885
895
 
package/index.ts CHANGED
@@ -5,7 +5,7 @@ import { readFileSync, statSync, writeFileSync } from "node:fs";
5
5
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
6
6
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
7
7
  import { homedir, tmpdir } from "node:os";
8
- import { basename, dirname, isAbsolute, join, resolve } from "node:path";
8
+ import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
9
9
  import { URL, pathToFileURL } from "node:url";
10
10
  import { WebSocketServer, WebSocket, type RawData } from "ws";
11
11
 
@@ -927,6 +927,23 @@ function readStudioFile(pathArg: string, cwd: string):
927
927
  }
928
928
  }
929
929
 
930
+ function inferStudioPdfLanguageFromPath(pathInput: string): string | undefined {
931
+ const extension = extname(pathInput).toLowerCase();
932
+ if (extension === ".tex" || extension === ".latex") return "latex";
933
+ if (extension === ".md" || extension === ".markdown" || extension === ".mdx") return "markdown";
934
+ if (extension === ".diff" || extension === ".patch") return "diff";
935
+ return undefined;
936
+ }
937
+
938
+ function buildStudioPdfOutputPath(sourcePath: string): string {
939
+ const sourceDir = dirname(sourcePath);
940
+ const sourceName = basename(sourcePath);
941
+ const sourceExt = extname(sourceName);
942
+ const sourceStem = sourceExt ? sourceName.slice(0, -sourceExt.length) : sourceName;
943
+ const outputStem = sourceStem || sourceName || "studio-export";
944
+ return join(sourceDir, `${outputStem}.studio.pdf`);
945
+ }
946
+
930
947
  function writeStudioFile(pathArg: string, cwd: string, content: string):
931
948
  | { ok: true; label: string; resolvedPath: string }
932
949
  | { ok: false; message: string } {
@@ -2785,7 +2802,7 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
2785
2802
  const sourceWithResolvedRefs = isLatex
2786
2803
  ? preprocessStudioLatexReferences(latexAlgorithmPreviewTransform.markdown, sourcePath, resourcePath)
2787
2804
  : markdown;
2788
- 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";
2805
+ const inputFormat = isLatex ? "latex" : "markdown+lists_without_preceding_blankline-blank_before_blockquote+tex_math_dollars+tex_math_single_backslash+tex_math_double_backslash+autolink_bare_uris-raw_html";
2789
2806
  const bibliographyArgs = buildStudioPandocBibliographyArgs(markdown, isLatex, resourcePath);
2790
2807
  const args = ["-f", inputFormat, "-t", "html5", "--mathml", "--wrap=none", ...bibliographyArgs];
2791
2808
  if (resourcePath) {
@@ -3191,7 +3208,7 @@ async function renderStudioPdfWithPandoc(
3191
3208
  }
3192
3209
 
3193
3210
  if (!isLatex && effectiveEditorLanguage === "diff") {
3194
- const inputFormat = "markdown+lists_without_preceding_blankline+tex_math_dollars+autolink_bare_uris+superscript+subscript-raw_html";
3211
+ const inputFormat = "markdown+lists_without_preceding_blankline-blank_before_blockquote+tex_math_dollars+autolink_bare_uris+superscript+subscript-raw_html";
3195
3212
  const diffMarkdown = prepareStudioPdfMarkdown(markdown, false, effectiveEditorLanguage);
3196
3213
  try {
3197
3214
  return await runPandocPdfExport(inputFormat, diffMarkdown);
@@ -3207,7 +3224,7 @@ async function renderStudioPdfWithPandoc(
3207
3224
 
3208
3225
  const inputFormat = isLatex
3209
3226
  ? "latex"
3210
- : "markdown+lists_without_preceding_blankline+tex_math_dollars+tex_math_single_backslash+tex_math_double_backslash+autolink_bare_uris+superscript+subscript-raw_html";
3227
+ : "markdown+lists_without_preceding_blankline-blank_before_blockquote+tex_math_dollars+tex_math_single_backslash+tex_math_double_backslash+autolink_bare_uris+superscript+subscript-raw_html";
3211
3228
  const normalizedMarkdown = prepareStudioPdfMarkdown(sourceWithResolvedRefs, isLatex, effectiveEditorLanguage);
3212
3229
 
3213
3230
  const tempDir = join(tmpdir(), `pi-studio-pdf-${Date.now()}-${randomUUID()}`);
@@ -6250,7 +6267,8 @@ export default function (pi: ExtensionAPI) {
6250
6267
  + " /studio --last Open with last model response\n"
6251
6268
  + " /studio --status Show studio status\n"
6252
6269
  + " /studio --stop Stop studio server\n"
6253
- + " /studio-current <path> Load a file into currently open Studio tab(s)",
6270
+ + " /studio-current <path> Load a file into currently open Studio tab(s)\n"
6271
+ + " /studio-pdf <path> Export a file to <name>.studio.pdf via Studio PDF",
6254
6272
  "info",
6255
6273
  );
6256
6274
  return;
@@ -6368,6 +6386,86 @@ export default function (pi: ExtensionAPI) {
6368
6386
  },
6369
6387
  });
6370
6388
 
6389
+ pi.registerCommand("studio-pdf", {
6390
+ description: "Export a file to PDF via the Studio PDF pipeline (/studio-pdf <file>)",
6391
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
6392
+ const trimmed = args.trim();
6393
+ if (!trimmed || trimmed === "help" || trimmed === "--help" || trimmed === "-h") {
6394
+ ctx.ui.notify(
6395
+ "Usage: /studio-pdf <path>\n"
6396
+ + " Export a local Markdown/LaTeX file to <name>.studio.pdf using the Studio PDF pipeline.",
6397
+ "info",
6398
+ );
6399
+ return;
6400
+ }
6401
+
6402
+ if (trimmed.startsWith("-")) {
6403
+ ctx.ui.notify(`Unknown flag: ${trimmed}. Use /studio-pdf --help`, "error");
6404
+ return;
6405
+ }
6406
+
6407
+ const pathArg = parsePathArgument(trimmed);
6408
+ if (!pathArg) {
6409
+ ctx.ui.notify("Invalid file path argument.", "error");
6410
+ return;
6411
+ }
6412
+
6413
+ const file = readStudioFile(pathArg, ctx.cwd);
6414
+ if (file.ok === false) {
6415
+ ctx.ui.notify(file.message, "error");
6416
+ return;
6417
+ }
6418
+
6419
+ if (file.text.length > PDF_EXPORT_MAX_CHARS) {
6420
+ ctx.ui.notify(`PDF export text exceeds ${PDF_EXPORT_MAX_CHARS} characters.`, "error");
6421
+ return;
6422
+ }
6423
+
6424
+ await ctx.waitForIdle();
6425
+ const pathPdfLanguage = inferStudioPdfLanguageFromPath(file.resolvedPath);
6426
+ const editorPdfLanguage = pathPdfLanguage ?? inferStudioPdfLanguage(file.text);
6427
+ const isLatex = editorPdfLanguage === "latex"
6428
+ || (
6429
+ !pathPdfLanguage
6430
+ && (editorPdfLanguage === undefined || editorPdfLanguage === "markdown")
6431
+ && /\\documentclass\b|\\begin\{document\}/.test(file.text)
6432
+ );
6433
+ const resourcePath = resolveStudioBaseDir(file.resolvedPath, undefined, ctx.cwd);
6434
+ const outputPath = buildStudioPdfOutputPath(file.resolvedPath);
6435
+
6436
+ try {
6437
+ const { pdf, warning } = await renderStudioPdfWithPandoc(
6438
+ file.text,
6439
+ isLatex,
6440
+ resourcePath,
6441
+ editorPdfLanguage,
6442
+ file.resolvedPath,
6443
+ );
6444
+ await writeFile(outputPath, pdf);
6445
+
6446
+ let openError: string | null = null;
6447
+ try {
6448
+ await openPathInDefaultViewer(outputPath);
6449
+ } catch (error) {
6450
+ openError = error instanceof Error ? error.message : String(error);
6451
+ }
6452
+
6453
+ ctx.ui.notify(`Exported Studio PDF: ${outputPath}`, "info");
6454
+ if (warning) {
6455
+ ctx.ui.notify(warning, "warning");
6456
+ }
6457
+ if (openError) {
6458
+ ctx.ui.notify(`PDF was exported but could not be opened automatically: ${openError}`, "warning");
6459
+ }
6460
+ } catch (error) {
6461
+ ctx.ui.notify(
6462
+ `Studio PDF export failed for ${file.label}: ${error instanceof Error ? error.message : String(error)}`,
6463
+ "error",
6464
+ );
6465
+ }
6466
+ },
6467
+ });
6468
+
6371
6469
  pi.registerCommand("studio-current", {
6372
6470
  description: "Load a file into current open Studio tab(s) without opening a new browser session",
6373
6471
  handler: async (args: string, ctx: ExtensionCommandContext) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.5.25",
3
+ "version": "0.5.26",
4
4
  "description": "Browser GUI for structured critique workflows in pi",
5
5
  "type": "module",
6
6
  "license": "MIT",