pi-studio 0.9.1 → 0.9.3

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
@@ -415,6 +415,11 @@ const STUDIO_REPL_SEND_MAX_CHARS = 200_000;
415
415
  const STUDIO_REPL_SEND_DEFAULT_TIMEOUT_MS = 20_000;
416
416
  const STUDIO_REPL_SEND_MAX_TIMEOUT_MS = 120_000;
417
417
  const STUDIO_REPL_CONTROL_ROOT = join(tmpdir(), "pi-studio-repl");
418
+ const STUDIO_SUBPROCESS_OUTPUT_MAX_BYTES = 2_000_000;
419
+ const STUDIO_PANDOC_TIMEOUT_MS = readStudioPositiveEnvMs("PI_STUDIO_PANDOC_TIMEOUT_MS", 120_000, 5_000, 15 * 60_000);
420
+ const STUDIO_LATEX_TIMEOUT_MS = readStudioPositiveEnvMs("PI_STUDIO_LATEX_TIMEOUT_MS", 120_000, 5_000, 15 * 60_000);
421
+ const STUDIO_MERMAID_TIMEOUT_MS = readStudioPositiveEnvMs("PI_STUDIO_MERMAID_TIMEOUT_MS", 60_000, 5_000, 10 * 60_000);
422
+ const STUDIO_HTML_RENDER_OUTPUT_MAX_BYTES = readStudioPositiveEnvMs("PI_STUDIO_HTML_RENDER_OUTPUT_MAX_BYTES", 50_000_000, 1_000_000, 500_000_000);
418
423
  const STUDIO_REPL_RUNTIME_LABELS: Record<StudioReplRuntime, string> = {
419
424
  shell: "Shell",
420
425
  python: "Python",
@@ -446,6 +451,151 @@ const STUDIO_DEFAULT_SCRATCHPAD_DOCUMENT_KEY = "doc:blank:blank";
446
451
  const STUDIO_PERSISTENT_STATE_DIR = join(getAgentDir(), "pi-studio");
447
452
  const STUDIO_PERSISTENT_STATE_PATH = join(STUDIO_PERSISTENT_STATE_DIR, "local-state.json");
448
453
 
454
+ type StudioSubprocessResult = {
455
+ code: number | null;
456
+ signal: NodeJS.Signals | null;
457
+ stdout: string;
458
+ stderr: string;
459
+ stdoutTruncated: boolean;
460
+ stderrTruncated: boolean;
461
+ };
462
+
463
+ type StudioSubprocessOptions = {
464
+ cwd?: string;
465
+ input?: string;
466
+ timeoutMs?: number;
467
+ stdoutMaxBytes?: number;
468
+ stderrMaxBytes?: number;
469
+ notFoundMessage?: string;
470
+ notFoundError?: () => Error;
471
+ label?: string;
472
+ };
473
+
474
+ function readStudioPositiveEnvMs(name: string, fallback: number, min: number, max: number): number {
475
+ const parsed = Number.parseInt(String(process.env[name] ?? ""), 10);
476
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
477
+ return Math.max(min, Math.min(max, parsed));
478
+ }
479
+
480
+ function appendStudioSubprocessChunk(chunks: Buffer[], chunk: Buffer | string, state: { bytes: number; truncated: boolean }, maxBytes = STUDIO_SUBPROCESS_OUTPUT_MAX_BYTES): void {
481
+ if (state.bytes >= maxBytes) {
482
+ state.truncated = true;
483
+ return;
484
+ }
485
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
486
+ const remaining = maxBytes - state.bytes;
487
+ if (buffer.length <= remaining) {
488
+ chunks.push(buffer);
489
+ state.bytes += buffer.length;
490
+ return;
491
+ }
492
+ chunks.push(buffer.subarray(0, remaining));
493
+ state.bytes += remaining;
494
+ state.truncated = true;
495
+ }
496
+
497
+ function finalizeStudioSubprocessOutput(chunks: Buffer[], truncated: boolean): string {
498
+ const value = Buffer.concat(chunks).toString("utf-8").trim();
499
+ return truncated ? `${value}\n[output truncated by Studio]`.trim() : value;
500
+ }
501
+
502
+ function runStudioSubprocess(command: string, args: string[], options: StudioSubprocessOptions = {}): Promise<StudioSubprocessResult> {
503
+ return new Promise((resolvePromise, rejectPromise) => {
504
+ const timeoutMs = Math.max(1_000, Math.floor(options.timeoutMs ?? STUDIO_PANDOC_TIMEOUT_MS));
505
+ const child = spawn(command, args, {
506
+ cwd: options.cwd,
507
+ stdio: [typeof options.input === "string" ? "pipe" : "ignore", "pipe", "pipe"],
508
+ });
509
+ const stdoutChunks: Buffer[] = [];
510
+ const stderrChunks: Buffer[] = [];
511
+ const stdoutMaxBytes = Math.max(1, Math.floor(options.stdoutMaxBytes ?? STUDIO_SUBPROCESS_OUTPUT_MAX_BYTES));
512
+ const stderrMaxBytes = Math.max(1, Math.floor(options.stderrMaxBytes ?? STUDIO_SUBPROCESS_OUTPUT_MAX_BYTES));
513
+ const stdoutState = { bytes: 0, truncated: false };
514
+ const stderrState = { bytes: 0, truncated: false };
515
+ let settled = false;
516
+ let timedOut = false;
517
+ let killTimer: NodeJS.Timeout | null = null;
518
+
519
+ const cleanup = () => {
520
+ clearTimeout(timeoutTimer);
521
+ if (killTimer) clearTimeout(killTimer);
522
+ };
523
+ const fail = (error: Error) => {
524
+ if (settled) return;
525
+ settled = true;
526
+ cleanup();
527
+ rejectPromise(error);
528
+ };
529
+ const succeed = (result: StudioSubprocessResult) => {
530
+ if (settled) return;
531
+ settled = true;
532
+ cleanup();
533
+ resolvePromise(result);
534
+ };
535
+ const label = options.label || basename(command) || command;
536
+ const timeoutTimer = setTimeout(() => {
537
+ timedOut = true;
538
+ try { child.kill("SIGTERM"); } catch {}
539
+ killTimer = setTimeout(() => {
540
+ try { child.kill("SIGKILL"); } catch {}
541
+ }, 2_000);
542
+ }, timeoutMs);
543
+
544
+ child.stdout?.on("data", (chunk: Buffer | string) => appendStudioSubprocessChunk(stdoutChunks, chunk, stdoutState, stdoutMaxBytes));
545
+ child.stderr?.on("data", (chunk: Buffer | string) => appendStudioSubprocessChunk(stderrChunks, chunk, stderrState, stderrMaxBytes));
546
+
547
+ child.once("error", (error) => {
548
+ const errno = error as NodeJS.ErrnoException;
549
+ if (errno.code === "ENOENT") {
550
+ fail(options.notFoundError ? options.notFoundError() : new Error(options.notFoundMessage || `${command} was not found.`));
551
+ return;
552
+ }
553
+ fail(error);
554
+ });
555
+
556
+ child.once("close", (code, signal) => {
557
+ if (timedOut) {
558
+ fail(new Error(`${label} timed out after ${Math.round(timeoutMs / 1000)}s.`));
559
+ return;
560
+ }
561
+ succeed({
562
+ code,
563
+ signal,
564
+ stdout: finalizeStudioSubprocessOutput(stdoutChunks, stdoutState.truncated),
565
+ stderr: finalizeStudioSubprocessOutput(stderrChunks, stderrState.truncated),
566
+ stdoutTruncated: stdoutState.truncated,
567
+ stderrTruncated: stderrState.truncated,
568
+ });
569
+ });
570
+
571
+ if (typeof options.input === "string") {
572
+ child.stdin?.end(options.input);
573
+ }
574
+ });
575
+ }
576
+
577
+ function buildStudioPandocPdfEngineOptArgs(pdfEngine: string): string[] {
578
+ const engineName = basename(String(pdfEngine || "")).toLowerCase();
579
+ if (!/^(?:pdf|xe|lua)?latex$/.test(engineName)) return [];
580
+ return [
581
+ "--pdf-engine-opt=-interaction=nonstopmode",
582
+ "--pdf-engine-opt=-halt-on-error",
583
+ "--pdf-engine-opt=-file-line-error",
584
+ ];
585
+ }
586
+
587
+ const STUDIO_PANDOC_HTML_FRAGMENT_TEMPLATE = `<!doctype html>
588
+ <html>
589
+ <head>
590
+ <meta charset="utf-8" />
591
+ <title>pi Studio preview</title>
592
+ </head>
593
+ <body>
594
+ $body$
595
+ </body>
596
+ </html>
597
+ `;
598
+
449
599
  let studioPersistentStateCache: StudioPersistentState | null = null;
450
600
  let studioPersistentStateQueue: Promise<void> = Promise.resolve();
451
601
  let transientStudioDocuments: Map<string, { document: InitialStudioDocument; createdAt: number }> = new Map();
@@ -4484,46 +4634,17 @@ async function renderStudioMermaidDiagramForPdf(source: string, workDir: string,
4484
4634
  const outputPath = join(workDir, `mermaid-diagram-${blockNumber}.pdf`);
4485
4635
 
4486
4636
  await writeFile(inputPath, source, "utf-8");
4487
- await new Promise<void>((resolve, reject) => {
4488
- const args = ["-i", inputPath, "-o", outputPath, "-t", mermaidTheme, "-f"];
4489
- const child = spawn(mermaidCommand, args, { stdio: ["ignore", "ignore", "pipe"] });
4490
- const stderrChunks: Buffer[] = [];
4491
- let settled = false;
4492
-
4493
- const fail = (error: Error) => {
4494
- if (settled) return;
4495
- settled = true;
4496
- reject(error);
4497
- };
4498
-
4499
- child.stderr.on("data", (chunk: Buffer | string) => {
4500
- stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
4501
- });
4502
-
4503
- child.once("error", (error) => {
4504
- const errno = error as NodeJS.ErrnoException;
4505
- if (errno.code === "ENOENT") {
4506
- fail(
4507
- new MermaidCliMissingError(
4508
- "Mermaid CLI (mmdc) not found. Install with `npm install -g @mermaid-js/mermaid-cli` or set MERMAID_CLI_PATH.",
4509
- ),
4510
- );
4511
- return;
4512
- }
4513
- fail(error);
4514
- });
4515
-
4516
- child.once("close", (code) => {
4517
- if (settled) return;
4518
- settled = true;
4519
- if (code === 0) {
4520
- resolve();
4521
- return;
4522
- }
4523
- const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
4524
- reject(new Error(`Mermaid CLI failed with exit code ${code}${stderr ? `: ${stderr}` : ""}`));
4525
- });
4637
+ const args = ["-i", inputPath, "-o", outputPath, "-t", mermaidTheme, "-f"];
4638
+ const result = await runStudioSubprocess(mermaidCommand, args, {
4639
+ timeoutMs: STUDIO_MERMAID_TIMEOUT_MS,
4640
+ label: "Mermaid CLI",
4641
+ notFoundError: () => new MermaidCliMissingError(
4642
+ "Mermaid CLI (mmdc) not found. Install with `npm install -g @mermaid-js/mermaid-cli` or set MERMAID_CLI_PATH.",
4643
+ ),
4526
4644
  });
4645
+ if (result.code !== 0) {
4646
+ throw new Error(`Mermaid CLI failed with exit code ${result.code}${result.stderr ? `: ${result.stderr}` : ""}`);
4647
+ }
4527
4648
 
4528
4649
  return outputPath;
4529
4650
  }
@@ -4708,82 +4829,67 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
4708
4829
  const inputFormat = isLatex ? "latex" : "markdown+lists_without_preceding_blankline-blank_before_blockquote-blank_before_header+tex_math_dollars+tex_math_single_backslash+tex_math_double_backslash+autolink_bare_uris-raw_html";
4709
4830
  const bibliographyArgs = buildStudioPandocBibliographyArgs(markdown, isLatex, resourcePath);
4710
4831
  const args = ["-f", inputFormat, "-t", "html5", "--mathml", "--wrap=none", ...bibliographyArgs];
4832
+ let htmlTemplateDir: string | null = null;
4711
4833
  if (resourcePath) {
4712
4834
  args.push(`--resource-path=${resourcePath}`);
4713
- // Embed images as data URIs so they render in the browser preview
4714
- args.push("--embed-resources", "--standalone");
4835
+ // Embed images as data URIs so browser previews and exported HTML keep local figures.
4836
+ // A minimal template prevents Pandoc's standalone default CSS/title block from leaking
4837
+ // into Studio's own standalone export wrapper.
4838
+ htmlTemplateDir = join(tmpdir(), `pi-studio-pandoc-html-${randomUUID()}`);
4839
+ await mkdir(htmlTemplateDir, { recursive: true });
4840
+ const htmlTemplatePath = join(htmlTemplateDir, "template.html");
4841
+ await writeFile(htmlTemplatePath, STUDIO_PANDOC_HTML_FRAGMENT_TEMPLATE, "utf-8");
4842
+ args.push("--embed-resources", "--standalone", `--template=${htmlTemplatePath}`);
4715
4843
  }
4716
4844
  const normalizedMarkdown = isLatex
4717
4845
  ? sourceWithResolvedRefs
4718
4846
  : normalizeStudioMarkdownFencedBlocks(prepareStudioMarkdownForPandoc(sourceWithResolvedRefs));
4719
4847
  const pandocWorkingDir = resolveStudioPandocWorkingDir(resourcePath);
4720
4848
 
4721
- let renderedHtml = await new Promise<string>((resolve, reject) => {
4722
- const child = spawn(pandocCommand, args, { stdio: ["pipe", "pipe", "pipe"], cwd: pandocWorkingDir });
4723
- const stdoutChunks: Buffer[] = [];
4724
- const stderrChunks: Buffer[] = [];
4725
- let settled = false;
4726
-
4727
- const fail = (error: Error) => {
4728
- if (settled) return;
4729
- settled = true;
4730
- reject(error);
4731
- };
4732
-
4733
- const succeed = (html: string) => {
4734
- if (settled) return;
4735
- settled = true;
4736
- resolve(html);
4737
- };
4738
-
4739
- child.stdout.on("data", (chunk: Buffer | string) => {
4740
- stdoutChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
4741
- });
4742
- child.stderr.on("data", (chunk: Buffer | string) => {
4743
- stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
4744
- });
4745
-
4746
- child.once("error", (error) => {
4747
- const errno = error as NodeJS.ErrnoException;
4748
- if (errno.code === "ENOENT") {
4749
- fail(new Error("pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary."));
4750
- return;
4751
- }
4752
- fail(error);
4753
- });
4754
-
4755
- child.once("close", (code) => {
4756
- if (settled) return;
4757
- if (code === 0) {
4758
- let html = Buffer.concat(stdoutChunks).toString("utf-8");
4759
- // When --standalone was used, extract only the <body> content
4760
- if (resourcePath) {
4761
- const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
4762
- if (bodyMatch) html = bodyMatch[1];
4763
- }
4764
- if (isLatex) {
4765
- html = decorateStudioLatexRenderedHtml(
4766
- html,
4767
- sourcePath,
4768
- resourcePath,
4769
- latexSubfigurePreviewTransform.subfigureGroups,
4770
- latexAlgorithmPreviewTransform.algorithmBlocks,
4771
- );
4772
- } else {
4773
- html = decorateStudioPreviewPageBreakHtml(html);
4774
- }
4775
- html = decorateStudioPandocSyntaxHtml(html);
4776
- succeed(stripMathMlAnnotationTags(html));
4777
- return;
4778
- }
4779
- const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
4780
- fail(new Error(`pandoc failed with exit code ${code}${stderr ? `: ${stderr}` : ""}`));
4849
+ let pandocResult: StudioSubprocessResult;
4850
+ try {
4851
+ pandocResult = await runStudioSubprocess(pandocCommand, args, {
4852
+ cwd: pandocWorkingDir,
4853
+ input: normalizedMarkdown,
4854
+ timeoutMs: STUDIO_PANDOC_TIMEOUT_MS,
4855
+ stdoutMaxBytes: STUDIO_HTML_RENDER_OUTPUT_MAX_BYTES,
4856
+ label: "pandoc HTML render",
4857
+ notFoundMessage: "pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary.",
4781
4858
  });
4859
+ } finally {
4860
+ if (htmlTemplateDir) {
4861
+ await rm(htmlTemplateDir, { recursive: true, force: true }).catch(() => undefined);
4862
+ }
4863
+ }
4864
+ if (pandocResult.code !== 0) {
4865
+ throw new Error(`pandoc failed with exit code ${pandocResult.code}${pandocResult.stderr ? `: ${pandocResult.stderr}` : ""}`);
4866
+ }
4867
+ if (pandocResult.stdoutTruncated) {
4868
+ throw new Error(`pandoc HTML output exceeded ${Math.round(STUDIO_HTML_RENDER_OUTPUT_MAX_BYTES / 1_000_000)} MB. Reduce embedded assets or set PI_STUDIO_HTML_RENDER_OUTPUT_MAX_BYTES higher.`);
4869
+ }
4782
4870
 
4783
- child.stdin.end(normalizedMarkdown);
4784
- });
4785
-
4786
- return renderedHtml;
4871
+ let renderedHtml = pandocResult.stdout;
4872
+ // When --standalone was used for --embed-resources, extract only the <body> content.
4873
+ if (resourcePath) {
4874
+ const bodyMatch = renderedHtml.match(/<body[^>]*>([\s\S]*)<\/body>/i);
4875
+ if (!bodyMatch) {
4876
+ throw new Error("pandoc HTML render did not include a complete body element.");
4877
+ }
4878
+ renderedHtml = bodyMatch[1];
4879
+ }
4880
+ if (isLatex) {
4881
+ renderedHtml = decorateStudioLatexRenderedHtml(
4882
+ renderedHtml,
4883
+ sourcePath,
4884
+ resourcePath,
4885
+ latexSubfigurePreviewTransform.subfigureGroups,
4886
+ latexAlgorithmPreviewTransform.algorithmBlocks,
4887
+ );
4888
+ } else {
4889
+ renderedHtml = decorateStudioPreviewPageBreakHtml(renderedHtml);
4890
+ }
4891
+ renderedHtml = decorateStudioPandocSyntaxHtml(renderedHtml);
4892
+ return stripMathMlAnnotationTags(renderedHtml);
4787
4893
  }
4788
4894
 
4789
4895
  function escapeStudioRegExpLiteral(text: string): string {
@@ -5175,54 +5281,22 @@ ${literalPdfConfig.fontSizeCommand}\\section*{${title.replace(/[{}\\]/g, "").tri
5175
5281
  await writeFile(texPath, texDocument, "utf-8");
5176
5282
 
5177
5283
  try {
5178
- await new Promise<void>((resolve, reject) => {
5179
- const child = spawn(pdfEngine, [
5180
- "-interaction=nonstopmode",
5181
- "-halt-on-error",
5182
- "input.tex",
5183
- ], { stdio: ["ignore", "pipe", "pipe"], cwd: tempDir });
5184
- const stdoutChunks: Buffer[] = [];
5185
- const stderrChunks: Buffer[] = [];
5186
- let settled = false;
5187
-
5188
- const fail = (error: Error) => {
5189
- if (settled) return;
5190
- settled = true;
5191
- reject(error);
5192
- };
5193
-
5194
- child.stdout.on("data", (chunk: Buffer | string) => {
5195
- stdoutChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
5196
- });
5197
- child.stderr.on("data", (chunk: Buffer | string) => {
5198
- stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
5199
- });
5200
-
5201
- child.once("error", (error) => {
5202
- const errno = error as NodeJS.ErrnoException;
5203
- if (errno.code === "ENOENT") {
5204
- fail(new Error(
5205
- `${pdfEngine} was not found. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE.`,
5206
- ));
5207
- return;
5208
- }
5209
- fail(error);
5210
- });
5211
-
5212
- child.once("close", (code) => {
5213
- if (settled) return;
5214
- if (code === 0) {
5215
- settled = true;
5216
- resolve();
5217
- return;
5218
- }
5219
- const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
5220
- const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
5221
- const errorMatch = stdout.match(/^! .+$/m);
5222
- const hint = errorMatch ? `: ${errorMatch[0]}` : (stderr ? `: ${stderr}` : "");
5223
- fail(new Error(`${pdfEngine} literal-text PDF export failed with exit code ${code}${hint}`));
5224
- });
5284
+ const latexResult = await runStudioSubprocess(pdfEngine, [
5285
+ "-interaction=nonstopmode",
5286
+ "-halt-on-error",
5287
+ "-file-line-error",
5288
+ "input.tex",
5289
+ ], {
5290
+ cwd: tempDir,
5291
+ timeoutMs: STUDIO_LATEX_TIMEOUT_MS,
5292
+ label: `${pdfEngine} literal-text PDF export`,
5293
+ notFoundMessage: `${pdfEngine} was not found. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE.`,
5225
5294
  });
5295
+ if (latexResult.code !== 0) {
5296
+ const errorMatch = latexResult.stdout.match(/^! .+$/m);
5297
+ const hint = errorMatch ? `: ${errorMatch[0]}` : (latexResult.stderr ? `: ${latexResult.stderr}` : "");
5298
+ throw new Error(`${pdfEngine} literal-text PDF export failed with exit code ${latexResult.code}${hint}`);
5299
+ }
5226
5300
 
5227
5301
  return await readFile(outputPath);
5228
5302
  } finally {
@@ -5484,46 +5558,18 @@ async function renderStudioPdfFromGeneratedLatex(
5484
5558
  const pandocSource = inputFormat === "latex" ? markdown : normalizeStudioMarkdownFencedBlocks(markdown);
5485
5559
 
5486
5560
  try {
5487
- await new Promise<void>((resolve, reject) => {
5488
- const child = spawn(pandocCommand, pandocArgs, { stdio: ["pipe", "pipe", "pipe"], cwd: pandocWorkingDir });
5489
- const stderrChunks: Buffer[] = [];
5490
- let settled = false;
5491
-
5492
- const fail = (error: Error) => {
5493
- if (settled) return;
5494
- settled = true;
5495
- reject(error);
5496
- };
5497
-
5498
- child.stderr.on("data", (chunk: Buffer | string) => {
5499
- stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
5500
- });
5501
-
5502
- child.once("error", (error) => {
5503
- const errno = error as NodeJS.ErrnoException;
5504
- if (errno.code === "ENOENT") {
5505
- const commandHint = pandocCommand === "pandoc"
5506
- ? "pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary."
5507
- : `${pandocCommand} was not found. Check PANDOC_PATH.`;
5508
- fail(new Error(commandHint));
5509
- return;
5510
- }
5511
- fail(error);
5512
- });
5513
-
5514
- child.once("close", (code) => {
5515
- if (settled) return;
5516
- if (code === 0) {
5517
- settled = true;
5518
- resolve();
5519
- return;
5520
- }
5521
- const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
5522
- fail(new Error(`pandoc LaTeX generation failed with exit code ${code}${stderr ? `: ${stderr}` : ""}`));
5523
- });
5524
-
5525
- child.stdin.end(pandocSource);
5561
+ const pandocResult = await runStudioSubprocess(pandocCommand, pandocArgs, {
5562
+ cwd: pandocWorkingDir,
5563
+ input: pandocSource,
5564
+ timeoutMs: STUDIO_PANDOC_TIMEOUT_MS,
5565
+ label: "pandoc LaTeX generation",
5566
+ notFoundMessage: pandocCommand === "pandoc"
5567
+ ? "pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary."
5568
+ : `${pandocCommand} was not found. Check PANDOC_PATH.`,
5526
5569
  });
5570
+ if (pandocResult.code !== 0) {
5571
+ throw new Error(`pandoc LaTeX generation failed with exit code ${pandocResult.code}${pandocResult.stderr ? `: ${pandocResult.stderr}` : ""}`);
5572
+ }
5527
5573
 
5528
5574
  const generatedLatex = await readFile(latexPath, "utf-8");
5529
5575
  const injectedLatex = injectStudioLatexPdfSubfigureBlocks(generatedLatex, subfigureGroups, sourcePath, resourcePath);
@@ -5534,55 +5580,23 @@ async function renderStudioPdfFromGeneratedLatex(
5534
5580
  const normalizedLatex = normalizeStudioGeneratedFigureCaptions(alignedReadyLatex);
5535
5581
  await writeFile(latexPath, normalizedLatex, "utf-8");
5536
5582
 
5537
- await new Promise<void>((resolve, reject) => {
5538
- const child = spawn(pdfEngine, [
5539
- "-interaction=nonstopmode",
5540
- "-halt-on-error",
5541
- `-output-directory=${tempDir}`,
5542
- latexPath,
5543
- ], { stdio: ["ignore", "pipe", "pipe"], cwd: pandocWorkingDir });
5544
- const stdoutChunks: Buffer[] = [];
5545
- const stderrChunks: Buffer[] = [];
5546
- let settled = false;
5547
-
5548
- const fail = (error: Error) => {
5549
- if (settled) return;
5550
- settled = true;
5551
- reject(error);
5552
- };
5553
-
5554
- child.stdout.on("data", (chunk: Buffer | string) => {
5555
- stdoutChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
5556
- });
5557
- child.stderr.on("data", (chunk: Buffer | string) => {
5558
- stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
5559
- });
5560
-
5561
- child.once("error", (error) => {
5562
- const errno = error as NodeJS.ErrnoException;
5563
- if (errno.code === "ENOENT") {
5564
- fail(new Error(
5565
- `${pdfEngine} was not found. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE.`,
5566
- ));
5567
- return;
5568
- }
5569
- fail(error);
5570
- });
5571
-
5572
- child.once("close", (code) => {
5573
- if (settled) return;
5574
- if (code === 0) {
5575
- settled = true;
5576
- resolve();
5577
- return;
5578
- }
5579
- const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
5580
- const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
5581
- const errorMatch = stdout.match(/^! .+$/m);
5582
- const hint = errorMatch ? `: ${errorMatch[0]}` : (stderr ? `: ${stderr}` : "");
5583
- fail(new Error(`${pdfEngine} PDF export failed with exit code ${code}${hint}`));
5584
- });
5583
+ const latexResult = await runStudioSubprocess(pdfEngine, [
5584
+ "-interaction=nonstopmode",
5585
+ "-halt-on-error",
5586
+ "-file-line-error",
5587
+ `-output-directory=${tempDir}`,
5588
+ latexPath,
5589
+ ], {
5590
+ cwd: pandocWorkingDir,
5591
+ timeoutMs: STUDIO_LATEX_TIMEOUT_MS,
5592
+ label: `${pdfEngine} PDF export`,
5593
+ notFoundMessage: `${pdfEngine} was not found. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE.`,
5585
5594
  });
5595
+ if (latexResult.code !== 0) {
5596
+ const errorMatch = latexResult.stdout.match(/^! .+$/m);
5597
+ const hint = errorMatch ? `: ${errorMatch[0]}` : (latexResult.stderr ? `: ${latexResult.stderr}` : "");
5598
+ throw new Error(`${pdfEngine} PDF export failed with exit code ${latexResult.code}${hint}`);
5599
+ }
5586
5600
 
5587
5601
  return { pdf: await readFile(outputPath) };
5588
5602
  } finally {
@@ -5642,6 +5656,7 @@ async function renderStudioPdfWithPandoc(
5642
5656
  "-f", inputFormat,
5643
5657
  "-o", outputPath,
5644
5658
  `--pdf-engine=${pdfEngine}`,
5659
+ ...buildStudioPandocPdfEngineOptArgs(pdfEngine),
5645
5660
  ...buildStudioPdfPandocVariableArgs(pdfOptions, inputFormat !== "latex"),
5646
5661
  "-V", "urlcolor=blue",
5647
5662
  "-V", "linkcolor=blue",
@@ -5651,49 +5666,22 @@ async function renderStudioPdfWithPandoc(
5651
5666
  if (resourcePath) args.push(`--resource-path=${resourcePath}`);
5652
5667
 
5653
5668
  try {
5654
- await new Promise<void>((resolve, reject) => {
5655
- const child = spawn(pandocCommand, args, { stdio: ["pipe", "pipe", "pipe"], cwd: pandocWorkingDir });
5656
- const stderrChunks: Buffer[] = [];
5657
- let settled = false;
5658
-
5659
- const fail = (error: Error) => {
5660
- if (settled) return;
5661
- settled = true;
5662
- reject(error);
5663
- };
5664
-
5665
- child.stderr.on("data", (chunk: Buffer | string) => {
5666
- stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
5667
- });
5668
-
5669
- child.once("error", (error) => {
5670
- const errno = error as NodeJS.ErrnoException;
5671
- if (errno.code === "ENOENT") {
5672
- const commandHint = pandocCommand === "pandoc"
5673
- ? "pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary."
5674
- : `${pandocCommand} was not found. Check PANDOC_PATH.`;
5675
- fail(new Error(commandHint));
5676
- return;
5677
- }
5678
- fail(error);
5679
- });
5680
-
5681
- child.once("close", (code) => {
5682
- if (settled) return;
5683
- if (code === 0) {
5684
- settled = true;
5685
- resolve();
5686
- return;
5687
- }
5688
- const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
5689
- const hint = stderr.includes("not found") || stderr.includes("xelatex") || stderr.includes("pdflatex")
5690
- ? "\nPDF export requires a LaTeX engine. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE."
5691
- : "";
5692
- fail(new Error(`pandoc PDF export failed with exit code ${code}${stderr ? `: ${stderr}` : ""}${hint}`));
5693
- });
5694
-
5695
- child.stdin.end(pandocSource);
5669
+ const pandocResult = await runStudioSubprocess(pandocCommand, args, {
5670
+ cwd: pandocWorkingDir,
5671
+ input: pandocSource,
5672
+ timeoutMs: STUDIO_PANDOC_TIMEOUT_MS,
5673
+ label: "pandoc PDF export",
5674
+ notFoundMessage: pandocCommand === "pandoc"
5675
+ ? "pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary."
5676
+ : `${pandocCommand} was not found. Check PANDOC_PATH.`,
5696
5677
  });
5678
+ if (pandocResult.code !== 0) {
5679
+ const stderr = pandocResult.stderr;
5680
+ const hint = stderr.includes("not found") || stderr.includes("xelatex") || stderr.includes("pdflatex")
5681
+ ? "\nPDF export requires a LaTeX engine. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE."
5682
+ : "";
5683
+ throw new Error(`pandoc PDF export failed with exit code ${pandocResult.code}${stderr ? `: ${stderr}` : ""}${hint}`);
5684
+ }
5697
5685
 
5698
5686
  return { pdf: await readFile(outputPath), warning };
5699
5687
  } finally {
@@ -5795,6 +5783,7 @@ async function renderStudioPdfWithPandoc(
5795
5783
  "-f", inputFormat,
5796
5784
  "-o", outputPath,
5797
5785
  `--pdf-engine=${pdfEngine}`,
5786
+ ...buildStudioPandocPdfEngineOptArgs(pdfEngine),
5798
5787
  ...buildStudioPdfPandocVariableArgs(pdfOptions, !isLatex),
5799
5788
  "-V", "urlcolor=blue",
5800
5789
  "-V", "linkcolor=blue",
@@ -5805,49 +5794,22 @@ async function renderStudioPdfWithPandoc(
5805
5794
  const pandocSource = isLatex ? markdownForPdf : normalizeStudioMarkdownFencedBlocks(markdownForPdf);
5806
5795
 
5807
5796
  try {
5808
- await new Promise<void>((resolve, reject) => {
5809
- const child = spawn(pandocCommand, args, { stdio: ["pipe", "pipe", "pipe"], cwd: pandocWorkingDir });
5810
- const stderrChunks: Buffer[] = [];
5811
- let settled = false;
5812
-
5813
- const fail = (error: Error) => {
5814
- if (settled) return;
5815
- settled = true;
5816
- reject(error);
5817
- };
5818
-
5819
- child.stderr.on("data", (chunk: Buffer | string) => {
5820
- stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
5821
- });
5822
-
5823
- child.once("error", (error) => {
5824
- const errno = error as NodeJS.ErrnoException;
5825
- if (errno.code === "ENOENT") {
5826
- const commandHint = pandocCommand === "pandoc"
5827
- ? "pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary."
5828
- : `${pandocCommand} was not found. Check PANDOC_PATH.`;
5829
- fail(new Error(commandHint));
5830
- return;
5831
- }
5832
- fail(error);
5833
- });
5834
-
5835
- child.once("close", (code) => {
5836
- if (settled) return;
5837
- if (code === 0) {
5838
- settled = true;
5839
- resolve();
5840
- return;
5841
- }
5842
- const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
5843
- const hint = stderr.includes("not found") || stderr.includes("xelatex") || stderr.includes("pdflatex")
5844
- ? "\nPDF export requires a LaTeX engine. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE."
5845
- : "";
5846
- fail(new Error(`pandoc PDF export failed with exit code ${code}${stderr ? `: ${stderr}` : ""}${hint}`));
5847
- });
5848
-
5849
- child.stdin.end(pandocSource);
5797
+ const pandocResult = await runStudioSubprocess(pandocCommand, args, {
5798
+ cwd: pandocWorkingDir,
5799
+ input: pandocSource,
5800
+ timeoutMs: STUDIO_PANDOC_TIMEOUT_MS,
5801
+ label: "pandoc PDF export",
5802
+ notFoundMessage: pandocCommand === "pandoc"
5803
+ ? "pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary."
5804
+ : `${pandocCommand} was not found. Check PANDOC_PATH.`,
5850
5805
  });
5806
+ if (pandocResult.code !== 0) {
5807
+ const stderr = pandocResult.stderr;
5808
+ const hint = stderr.includes("not found") || stderr.includes("xelatex") || stderr.includes("pdflatex")
5809
+ ? "\nPDF export requires a LaTeX engine. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE."
5810
+ : "";
5811
+ throw new Error(`pandoc PDF export failed with exit code ${pandocResult.code}${stderr ? `: ${stderr}` : ""}${hint}`);
5812
+ }
5851
5813
 
5852
5814
  return { pdf: await readFile(outputPath), warning: mermaidPrepared.warning };
5853
5815
  } finally {
@@ -8006,6 +7968,7 @@ ${cssVarsBlock}
8006
7968
  <label class="file-label" title="Load a local file into editor text.">Load file content<input id="fileInput" type="file" accept=".md,.markdown,.mdx,.qmd,.js,.mjs,.cjs,.jsx,.ts,.mts,.cts,.tsx,.py,.pyw,.sh,.bash,.zsh,.json,.jsonc,.json5,.rs,.c,.h,.cpp,.cxx,.cc,.hpp,.hxx,.jl,.f90,.f95,.f03,.f,.for,.r,.R,.m,.tex,.latex,.diff,.patch,.java,.go,.rb,.swift,.html,.htm,.css,.xml,.yaml,.yml,.toml,.lua,.txt,.rst,.adoc" /></label>
8007
7969
  <button id="loadGitDiffBtn" type="button" title="Load the current git diff from the Studio context into the editor.">Load git diff</button>
8008
7970
  <button id="getEditorBtn" type="button" title="Load the current terminal editor draft into Studio.">Load from pi editor</button>
7971
+ <button id="zenModeBtn" class="zen-mode-btn" type="button" title="Hide secondary Studio controls.">Zen</button>
8009
7972
  </div>
8010
7973
  </header>
8011
7974
 
@@ -11864,6 +11827,7 @@ export default function (pi: ExtensionAPI) {
11864
11827
  const outputPath = buildStudioResponseExportOutputPath(ctx.cwd, "pdf");
11865
11828
 
11866
11829
  try {
11830
+ ctx.ui.notify("Exporting last response Studio PDF…", "info");
11867
11831
  const { pdf, warning } = await renderStudioPdfWithPandoc(
11868
11832
  response.markdown,
11869
11833
  isLatex,
@@ -11921,6 +11885,7 @@ export default function (pi: ExtensionAPI) {
11921
11885
  const outputPath = buildStudioPdfOutputPath(file.resolvedPath);
11922
11886
 
11923
11887
  try {
11888
+ ctx.ui.notify(`Exporting Studio PDF: ${outputPath}`, "info");
11924
11889
  const { pdf, warning } = await renderStudioPdfWithPandoc(
11925
11890
  file.text,
11926
11891
  isLatex,