pi-studio 0.9.23 → 0.9.24

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
@@ -458,8 +458,8 @@ interface GetFromEditorRequestMessage {
458
458
  requestId: string;
459
459
  }
460
460
 
461
- interface LoadGitDiffRequestMessage {
462
- type: "load_git_diff_request";
461
+ interface GitChangesRequestMessage {
462
+ type: "git_changes_request";
463
463
  requestId: string;
464
464
  sourcePath?: string;
465
465
  resourceDir?: string;
@@ -504,7 +504,7 @@ type IncomingStudioMessage =
504
504
  | RefreshFromDiskRequestMessage
505
505
  | SendToEditorRequestMessage
506
506
  | GetFromEditorRequestMessage
507
- | LoadGitDiffRequestMessage
507
+ | GitChangesRequestMessage
508
508
  | OpenEditorOnlyRequestMessage
509
509
  | CancelRequestMessage;
510
510
 
@@ -569,6 +569,46 @@ const STUDIO_REPL_STATUS_TOOL_PARAMS = Type.Object({
569
569
  sessionName: Type.Optional(Type.String({ description: "Exact Studio/pi-repl tmux session name to inspect." })),
570
570
  target: Type.Optional(Type.String({ description: "Optional runtime target: shell, python, ipython, julia, r, ghci, or clojure. If omitted, report all Studio-visible REPL sessions." })),
571
571
  });
572
+ const STUDIO_EXPORT_INPUT_FORMAT_DESCRIPTION = "Optional input format: auto, markdown, or latex. Defaults to auto.";
573
+ const STUDIO_EXPORT_PDF_OPTIONS_TOOL_PARAMS = Type.Object({
574
+ fontsize: Type.Optional(Type.String({ description: "PDF body font size, e.g. 12pt." })),
575
+ margin: Type.Optional(Type.String({ description: "PDF page margin, e.g. 25mm." })),
576
+ marginTop: Type.Optional(Type.String({ description: "PDF top margin, e.g. 30mm." })),
577
+ marginRight: Type.Optional(Type.String({ description: "PDF right margin, e.g. 25mm." })),
578
+ marginBottom: Type.Optional(Type.String({ description: "PDF bottom margin, e.g. 30mm." })),
579
+ marginLeft: Type.Optional(Type.String({ description: "PDF left margin, e.g. 25mm." })),
580
+ footskip: Type.Optional(Type.String({ description: "PDF footer skip, e.g. 12mm." })),
581
+ linestretch: Type.Optional(Type.String({ description: "PDF line stretch, e.g. 1.2." })),
582
+ mainfont: Type.Optional(Type.String({ description: "PDF main font, e.g. TeX Gyre Pagella." })),
583
+ papersize: Type.Optional(Type.String({ description: "PDF paper size, e.g. a4 or letter." })),
584
+ geometry: Type.Optional(Type.String({ description: "Pandoc geometry spec. Use instead of margin fields." })),
585
+ sectionSize: Type.Optional(Type.String({ description: "PDF section heading size, e.g. 24pt." })),
586
+ subsectionSize: Type.Optional(Type.String({ description: "PDF subsection heading size, e.g. 18pt." })),
587
+ subsubsectionSize: Type.Optional(Type.String({ description: "PDF subsubsection heading size, e.g. 14pt." })),
588
+ sectionSpaceBefore: Type.Optional(Type.String({ description: "Space before section headings, e.g. 10mm." })),
589
+ sectionSpaceAfter: Type.Optional(Type.String({ description: "Space after section headings, e.g. 6mm." })),
590
+ subsectionSpaceBefore: Type.Optional(Type.String({ description: "Space before subsection headings, e.g. 8mm." })),
591
+ subsectionSpaceAfter: Type.Optional(Type.String({ description: "Space after subsection headings, e.g. 4mm." })),
592
+ });
593
+ const STUDIO_EXPORT_PDF_TOOL_PARAMS = Type.Object({
594
+ path: Type.Optional(Type.String({ description: "Local Markdown/LaTeX/code file to export. Omit to export markdown or the last model response." })),
595
+ markdown: Type.Optional(Type.String({ description: "Markdown or LaTeX content to export directly. Omit when exporting a file or the last model response." })),
596
+ outputPath: Type.Optional(Type.String({ description: "Output PDF path. Relative paths resolve against the current working directory." })),
597
+ resourceDir: Type.Optional(Type.String({ description: "Base directory for resolving relative images/assets when exporting direct markdown." })),
598
+ title: Type.Optional(Type.String({ description: "Title/source label for direct markdown exports." })),
599
+ inputFormat: Type.Optional(Type.String({ description: STUDIO_EXPORT_INPUT_FORMAT_DESCRIPTION })),
600
+ open: Type.Optional(Type.Boolean({ description: "Open the exported PDF locally after writing it. Defaults to false for tool use." })),
601
+ pdfOptions: Type.Optional(STUDIO_EXPORT_PDF_OPTIONS_TOOL_PARAMS),
602
+ });
603
+ const STUDIO_EXPORT_HTML_TOOL_PARAMS = Type.Object({
604
+ path: Type.Optional(Type.String({ description: "Local Markdown/LaTeX/code file to export. Omit to export markdown or the last model response." })),
605
+ markdown: Type.Optional(Type.String({ description: "Markdown or LaTeX content to export directly. Omit when exporting a file or the last model response." })),
606
+ outputPath: Type.Optional(Type.String({ description: "Output HTML path. Relative paths resolve against the current working directory." })),
607
+ resourceDir: Type.Optional(Type.String({ description: "Base directory for resolving relative images/assets when exporting direct markdown." })),
608
+ title: Type.Optional(Type.String({ description: "HTML document title/source label for direct markdown exports." })),
609
+ inputFormat: Type.Optional(Type.String({ description: STUDIO_EXPORT_INPUT_FORMAT_DESCRIPTION })),
610
+ open: Type.Optional(Type.Boolean({ description: "Open the exported HTML locally after writing it. Defaults to false for tool use." })),
611
+ });
572
612
  const MAX_STUDIO_TRACE_SNAPSHOTS = RESPONSE_HISTORY_LIMIT;
573
613
  const TRANSIENT_STUDIO_DOCUMENT_TTL_MS = 30 * 60 * 1000;
574
614
  const MAX_TRANSIENT_STUDIO_DOCUMENTS = 16;
@@ -2484,6 +2524,62 @@ function buildStudioSyntheticNewFileDiff(filePath: string, content: string): str
2484
2524
  return diffLines.join("\n");
2485
2525
  }
2486
2526
 
2527
+ interface StudioGitChangedFile {
2528
+ path: string;
2529
+ oldPath?: string;
2530
+ status: "modified" | "added" | "deleted" | "renamed" | "untracked" | "binary";
2531
+ additions: number;
2532
+ deletions: number;
2533
+ diff: string;
2534
+ }
2535
+
2536
+ function unquoteStudioGitPath(path: string): string {
2537
+ const value = String(path ?? "").trim();
2538
+ if (!value.startsWith('"') || !value.endsWith('"')) return value;
2539
+ try {
2540
+ return JSON.parse(value) as string;
2541
+ } catch {
2542
+ return value.slice(1, -1);
2543
+ }
2544
+ }
2545
+
2546
+ function summarizeStudioGitDiffFiles(diffText: string, untrackedPaths: Set<string>): StudioGitChangedFile[] {
2547
+ const matches = Array.from(String(diffText ?? "").matchAll(/^diff --git a\/(.*?) b\/(.*?)$/gm));
2548
+ const files: StudioGitChangedFile[] = [];
2549
+ for (let i = 0; i < matches.length; i += 1) {
2550
+ const match = matches[i]!;
2551
+ const start = match.index ?? 0;
2552
+ const end = i + 1 < matches.length ? (matches[i + 1]!.index ?? diffText.length) : diffText.length;
2553
+ const section = diffText.slice(start, end).trimEnd();
2554
+ const oldPath = unquoteStudioGitPath(match[1] ?? "");
2555
+ let path = unquoteStudioGitPath(match[2] ?? oldPath);
2556
+ const renameTo = section.match(/^rename to\s+(.+)$/m);
2557
+ if (renameTo) path = unquoteStudioGitPath(renameTo[1] ?? path);
2558
+ let status: StudioGitChangedFile["status"] = "modified";
2559
+ if (untrackedPaths.has(path)) status = "untracked";
2560
+ else if (/^rename from\s+/m.test(section) || /^rename to\s+/m.test(section)) status = "renamed";
2561
+ else if (/^deleted file mode\s+/m.test(section) || /^\+\+\+ \/dev\/null$/m.test(section)) status = "deleted";
2562
+ else if (/^new file mode\s+/m.test(section) || /^--- \/dev\/null$/m.test(section)) status = "added";
2563
+ else if (/^Binary files\s+/m.test(section)) status = "binary";
2564
+ let additions = 0;
2565
+ let deletions = 0;
2566
+ for (const line of section.split("\n")) {
2567
+ if (line.startsWith("+++") || line.startsWith("---")) continue;
2568
+ if (line.startsWith("+")) additions += 1;
2569
+ else if (line.startsWith("-")) deletions += 1;
2570
+ }
2571
+ files.push({
2572
+ path,
2573
+ oldPath: oldPath && oldPath !== path ? oldPath : undefined,
2574
+ status,
2575
+ additions,
2576
+ deletions,
2577
+ diff: section,
2578
+ });
2579
+ }
2580
+ return files;
2581
+ }
2582
+
2487
2583
  function resolveStudioBaseDir(sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string): string {
2488
2584
  const source = typeof sourcePath === "string" ? sourcePath.trim() : "";
2489
2585
  if (source) {
@@ -4006,7 +4102,7 @@ function injectStudioLatexEquationTags(markdown: string, sourcePath: string | un
4006
4102
  }
4007
4103
 
4008
4104
  function readStudioGitDiff(baseDir: string):
4009
- | { ok: true; text: string; label: string }
4105
+ | { ok: true; text: string; label: string; repoRoot: string; branch: string; hasHead: boolean; files: StudioGitChangedFile[] }
4010
4106
  | { ok: false; level: "info" | "warning" | "error"; message: string } {
4011
4107
  const repoRootArgs = ["rev-parse", "--show-toplevel"];
4012
4108
  const repoRootResult = spawnSync("git", repoRootArgs, {
@@ -4021,11 +4117,24 @@ function readStudioGitDiff(baseDir: string):
4021
4117
  };
4022
4118
  }
4023
4119
  const repoRoot = repoRootResult.stdout.trim();
4120
+ const branchResult = spawnSync("git", ["branch", "--show-current"], {
4121
+ cwd: repoRoot,
4122
+ encoding: "utf-8",
4123
+ });
4124
+ let branch = branchResult.status === 0 ? branchResult.stdout.trim() : "";
4024
4125
 
4025
4126
  const hasHead = spawnSync("git", ["rev-parse", "--verify", "HEAD"], {
4026
4127
  cwd: repoRoot,
4027
4128
  encoding: "utf-8",
4028
4129
  }).status === 0;
4130
+ if (!branch && hasHead) {
4131
+ const revResult = spawnSync("git", ["rev-parse", "--short", "HEAD"], {
4132
+ cwd: repoRoot,
4133
+ encoding: "utf-8",
4134
+ });
4135
+ branch = revResult.status === 0 && revResult.stdout.trim() ? `detached ${revResult.stdout.trim()}` : "detached HEAD";
4136
+ }
4137
+ if (!branch) branch = "unknown branch";
4029
4138
 
4030
4139
  const untrackedArgs = ["ls-files", "--others", "--exclude-standard"];
4031
4140
  const untrackedResult = spawnSync("git", untrackedArgs, {
@@ -4128,7 +4237,8 @@ function readStudioGitDiff(baseDir: string):
4128
4237
 
4129
4238
  const labelBase = hasHead ? "git diff HEAD" : "git diff (no commits yet)";
4130
4239
  const label = summaryParts.length > 0 ? `${labelBase} (${summaryParts.join(", ")})` : labelBase;
4131
- return { ok: true, text: fullDiff, label };
4240
+ const files = summarizeStudioGitDiffFiles(fullDiff, new Set(untrackedPaths));
4241
+ return { ok: true, text: fullDiff, label, repoRoot, branch, hasHead, files };
4132
4242
  }
4133
4243
 
4134
4244
  function isLikelyMathExpression(expr: string): boolean {
@@ -8245,13 +8355,13 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
8245
8355
  }
8246
8356
 
8247
8357
  if (
8248
- msg.type === "load_git_diff_request"
8358
+ msg.type === "git_changes_request"
8249
8359
  && typeof msg.requestId === "string"
8250
8360
  && (msg.sourcePath === undefined || typeof msg.sourcePath === "string")
8251
8361
  && (msg.resourceDir === undefined || typeof msg.resourceDir === "string")
8252
8362
  ) {
8253
8363
  return {
8254
- type: "load_git_diff_request",
8364
+ type: "git_changes_request",
8255
8365
  requestId: msg.requestId,
8256
8366
  sourcePath: typeof msg.sourcePath === "string" ? msg.sourcePath : undefined,
8257
8367
  resourceDir: typeof msg.resourceDir === "string" ? msg.resourceDir : undefined,
@@ -9942,7 +10052,6 @@ ${cssVarsBlock}
9942
10052
  <button id="refreshFromDiskBtn" type="button" title="Reload the current file-backed document from disk.">Refresh from disk</button>
9943
10053
  <button id="clearWorkspaceBtn" type="button" title="Clear editor text and reset this tab to a fresh blank draft. Saved files and responses are not changed.">Reset editor</button>
9944
10054
  <label class="file-label" title="Import a browser-selected text file into the editor as an unsaved copy. It will not be refreshable from disk until you save it.">Import file copy…<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>
9945
- <button id="loadGitDiffBtn" type="button" title="Load the current git diff from the Studio context into the editor.">Load git diff</button>
9946
10055
  <button id="getEditorBtn" type="button" title="Load the current terminal editor draft into Studio.">Load from pi editor</button>
9947
10056
  <button id="zenModeBtn" class="zen-mode-btn" type="button" title="Hide secondary Studio controls. Shortcut: F9.">Zen</button>
9948
10057
  </div>
@@ -10152,6 +10261,7 @@ ${cssVarsBlock}
10152
10261
  <option value="preview" selected>Response (Preview)</option>
10153
10262
  <option value="editor-preview">Editor (Preview)</option>
10154
10263
  <option value="trace">Working</option>
10264
+ <option value="changes">Changes</option>
10155
10265
  <option value="files">Files</option>
10156
10266
  <option value="repl">REPL</option>
10157
10267
  </select>
@@ -10201,10 +10311,10 @@ ${cssVarsBlock}
10201
10311
  </div>
10202
10312
  <div class="response-actions-row history-row">
10203
10313
  <button id="pullLatestBtn" type="button" title="Fetch the latest assistant response when auto-update is off.">Fetch latest response</button>
10204
- <button id="historyPrevBtn" type="button" title="Show previous response in history.">◀ Prev response</button>
10205
- <span id="historyIndexBadge" class="source-badge">History: 0/0</span>
10206
- <button id="historyNextBtn" type="button" title="Show next response in history.">Next response ▶</button>
10207
- <button id="historyLastBtn" type="button" title="Jump to the latest loaded response in history.">Last response ▶|</button>
10314
+ <button id="historyPrevBtn" type="button" title="Show previous response in the current branch history.">◀ Prev response</button>
10315
+ <span id="historyIndexBadge" class="source-badge">Branch history: 0/0</span>
10316
+ <button id="historyNextBtn" type="button" title="Show next response in the current branch history.">Next response ▶</button>
10317
+ <button id="historyLastBtn" type="button" title="Jump to the latest loaded response in the current branch history.">Last response ▶|</button>
10208
10318
  </div>
10209
10319
  <div class="response-actions-row response-result-row">
10210
10320
  <button id="loadResponseBtn" type="button">Load response into editor</button>
@@ -10299,7 +10409,7 @@ ${cssVarsBlock}
10299
10409
  <div class="scratchpad-footer">
10300
10410
  <span id="scratchpadMeta" class="scratchpad-meta">Empty · local only</span>
10301
10411
  <div class="scratchpad-actions">
10302
- <button id="scratchpadRecentBtn" type="button" title="Show recent non-empty scratchpads saved for other files and drafts.">Recent…</button>
10412
+ <button id="scratchpadRecentBtn" type="button" aria-expanded="false" title="Show recent non-empty scratchpads saved for other files and drafts.">Recent…</button>
10303
10413
  <button id="scratchpadInsertBtn" type="button" title="Insert the scratchpad text into the editor at the current selection, or append it if no editor selection is available.">Insert into editor</button>
10304
10414
  <button id="scratchpadCopyBtn" type="button" title="Copy scratchpad text to the clipboard.">Copy</button>
10305
10415
  <button id="scratchpadClearBtn" type="button" title="Clear scratchpad text.">Clear</button>
@@ -10532,6 +10642,48 @@ export default function (pi: ExtensionAPI) {
10532
10642
  },
10533
10643
  });
10534
10644
 
10645
+ pi.registerTool({
10646
+ name: "studio_export_pdf",
10647
+ label: "Studio PDF export",
10648
+ description: "Export Markdown/LaTeX content, a local file, or the last model response to PDF using the Studio PDF pipeline.",
10649
+ promptSnippet: "Export Markdown/LaTeX, a local file, or the last model response to PDF with Studio's PDF pipeline.",
10650
+ promptGuidelines: [
10651
+ "Use studio_export_pdf when the user asks to make/export/render content as a PDF using Studio.",
10652
+ "For remote or Telegram sessions, leave open=false and report the generated file path unless a separate upload/send-file tool is available.",
10653
+ "Pass markdown directly when exporting content composed in the current assistant turn; omit markdown and path only when exporting the previous model response.",
10654
+ ],
10655
+ parameters: STUDIO_EXPORT_PDF_TOOL_PARAMS,
10656
+ executionMode: "sequential",
10657
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
10658
+ const result = await exportStudioPdfForTool(params, ctx);
10659
+ return {
10660
+ content: [{ type: "text", text: formatStudioExportToolText(result) }],
10661
+ details: result as Record<string, unknown>,
10662
+ };
10663
+ },
10664
+ });
10665
+
10666
+ pi.registerTool({
10667
+ name: "studio_export_html",
10668
+ label: "Studio HTML export",
10669
+ description: "Export Markdown/LaTeX content, a local file, or the last model response to standalone HTML using the Studio preview pipeline.",
10670
+ promptSnippet: "Export Markdown/LaTeX, a local file, or the last model response to standalone HTML with Studio's preview pipeline.",
10671
+ promptGuidelines: [
10672
+ "Use studio_export_html when the user asks to make/export/render content as HTML using Studio.",
10673
+ "For remote or Telegram sessions, leave open=false and report the generated file path unless a separate upload/send-file tool is available.",
10674
+ "Pass markdown directly when exporting content composed in the current assistant turn; omit markdown and path only when exporting the previous model response.",
10675
+ ],
10676
+ parameters: STUDIO_EXPORT_HTML_TOOL_PARAMS,
10677
+ executionMode: "sequential",
10678
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
10679
+ const result = await exportStudioHtmlForTool(params, ctx);
10680
+ return {
10681
+ content: [{ type: "text", text: formatStudioExportToolText(result) }],
10682
+ details: result as Record<string, unknown>,
10683
+ };
10684
+ },
10685
+ });
10686
+
10535
10687
  const isStudioDirectRunChainActive = () => Boolean(studioDirectRunChain);
10536
10688
  const getQueuedStudioSteeringCount = () => queuedStudioDirectRequests.length;
10537
10689
  const getStudioClientCounts = (): { full: number; editorOnly: number } => {
@@ -11635,39 +11787,33 @@ export default function (pi: ExtensionAPI) {
11635
11787
  return;
11636
11788
  }
11637
11789
 
11638
- if (msg.type === "load_git_diff_request") {
11790
+ if (msg.type === "git_changes_request") {
11639
11791
  if (!isValidRequestId(msg.requestId)) {
11640
11792
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
11641
11793
  return;
11642
11794
  }
11643
- if (isStudioBusy()) {
11644
- sendToClient(client, { type: "busy", requestId: msg.requestId, message: "Studio is busy." });
11645
- return;
11646
- }
11647
-
11648
11795
  const baseDir = resolveStudioGitDiffBaseDir(msg.sourcePath, msg.resourceDir, studioCwd);
11649
11796
  const diffResult = readStudioGitDiff(baseDir);
11650
11797
  if (diffResult.ok === false) {
11651
11798
  sendToClient(client, {
11652
- type: "info",
11799
+ type: "git_changes_snapshot",
11653
11800
  requestId: msg.requestId,
11801
+ ok: false,
11654
11802
  message: diffResult.message,
11655
11803
  level: diffResult.level,
11656
11804
  });
11657
11805
  return;
11658
11806
  }
11659
-
11660
- initialStudioDocument = {
11661
- text: diffResult.text,
11662
- label: diffResult.label,
11663
- source: "blank",
11664
- };
11665
11807
  sendToClient(client, {
11666
- type: "git_diff_snapshot",
11808
+ type: "git_changes_snapshot",
11667
11809
  requestId: msg.requestId,
11810
+ ok: true,
11668
11811
  content: diffResult.text,
11669
11812
  label: diffResult.label,
11670
- message: "Loaded current git diff into Studio.",
11813
+ repoRoot: diffResult.repoRoot,
11814
+ branch: diffResult.branch,
11815
+ hasHead: diffResult.hasHead,
11816
+ files: diffResult.files,
11671
11817
  });
11672
11818
  return;
11673
11819
  }
@@ -13865,7 +14011,7 @@ export default function (pi: ExtensionAPI) {
13865
14011
  type: "info",
13866
14012
  level: "info",
13867
14013
  message: studioResponseHistory.length > 0
13868
- ? "Pi session tree changed; Studio response history now follows the current branch. Editor text was left unchanged."
14014
+ ? "Pi session tree changed; Studio branch history now follows the current branch. Editor text was left unchanged."
13869
14015
  : "Pi session tree changed; this branch has no assistant responses yet. Editor text was left unchanged.",
13870
14016
  });
13871
14017
  });
@@ -14265,7 +14411,7 @@ export default function (pi: ExtensionAPI) {
14265
14411
  };
14266
14412
  };
14267
14413
 
14268
- const resolveLastModelResponseForExport = (ctx: ExtensionCommandContext): { markdown: string } | null => {
14414
+ const resolveLastModelResponseForExport = (ctx: ExtensionContext): { markdown: string } | null => {
14269
14415
  const branchEntries = ctx.sessionManager.getBranch();
14270
14416
  syncStudioResponseHistory(branchEntries);
14271
14417
  const markdown =
@@ -14276,6 +14422,244 @@ export default function (pi: ExtensionAPI) {
14276
14422
  return markdown.trim() ? { markdown } : null;
14277
14423
  };
14278
14424
 
14425
+ type StudioExportInputFormat = "auto" | "markdown" | "latex";
14426
+ type StudioExportCommonToolParams = {
14427
+ path?: string;
14428
+ markdown?: string;
14429
+ outputPath?: string;
14430
+ resourceDir?: string;
14431
+ title?: string;
14432
+ inputFormat?: string;
14433
+ open?: boolean;
14434
+ };
14435
+ type StudioExportPdfToolParams = StudioExportCommonToolParams & { pdfOptions?: StudioPdfRenderOptions };
14436
+ type StudioExportSource = {
14437
+ text: string;
14438
+ sourceLabel: string;
14439
+ sourcePath?: string;
14440
+ resourcePath: string;
14441
+ outputPath: string;
14442
+ inputFormat: StudioExportInputFormat;
14443
+ };
14444
+ type StudioExportResult = {
14445
+ ok: boolean;
14446
+ format: "pdf" | "html";
14447
+ path?: string;
14448
+ source?: string;
14449
+ bytes?: number;
14450
+ warning?: string;
14451
+ openError?: string;
14452
+ error?: string;
14453
+ };
14454
+
14455
+ const parseStudioExportInputFormat = (value: unknown): StudioExportInputFormat | { error: string } => {
14456
+ const normalized = String(value ?? "auto").trim().toLowerCase();
14457
+ if (!normalized || normalized === "auto") return "auto";
14458
+ if (normalized === "markdown" || normalized === "md") return "markdown";
14459
+ if (normalized === "latex" || normalized === "tex") return "latex";
14460
+ return { error: "Invalid inputFormat. Use auto, markdown, or latex." };
14461
+ };
14462
+
14463
+ const resolveStudioExportOutputPath = (outputPath: string | undefined, cwd: string): string | null => {
14464
+ const raw = typeof outputPath === "string" ? outputPath.trim() : "";
14465
+ if (!raw) return null;
14466
+ const resolved = resolveStudioPath(raw, cwd);
14467
+ return resolved.ok ? resolved.resolved : null;
14468
+ };
14469
+
14470
+ const validateStudioPdfOptionsForTool = (input: StudioPdfRenderOptions | undefined): StudioPdfRenderOptions | { error: string } => {
14471
+ if (!input || typeof input !== "object") return {};
14472
+ const options: StudioPdfRenderOptions = {};
14473
+ const setOption = (key: keyof StudioPdfRenderOptions, value: string) => {
14474
+ (options as Record<string, string>)[key] = value;
14475
+ };
14476
+ const requireLength = (key: keyof StudioPdfRenderOptions, label: string, example: string): { error: string } | null => {
14477
+ const value = String(input[key] ?? "").trim();
14478
+ if (!value) return null;
14479
+ if (!isValidStudioPdfLength(value)) return { error: `Invalid ${label} value. Example: ${example}` };
14480
+ setOption(key, value);
14481
+ return null;
14482
+ };
14483
+ for (const [key, label, example] of [
14484
+ ["fontsize", "fontsize", "12pt"],
14485
+ ["sectionSize", "sectionSize", "24pt"],
14486
+ ["subsectionSize", "subsectionSize", "18pt"],
14487
+ ["subsubsectionSize", "subsubsectionSize", "14pt"],
14488
+ ["sectionSpaceBefore", "sectionSpaceBefore", "10mm"],
14489
+ ["sectionSpaceAfter", "sectionSpaceAfter", "6mm"],
14490
+ ["subsectionSpaceBefore", "subsectionSpaceBefore", "8mm"],
14491
+ ["subsectionSpaceAfter", "subsectionSpaceAfter", "4mm"],
14492
+ ["margin", "margin", "25mm"],
14493
+ ["marginTop", "marginTop", "30mm"],
14494
+ ["marginRight", "marginRight", "25mm"],
14495
+ ["marginBottom", "marginBottom", "30mm"],
14496
+ ["marginLeft", "marginLeft", "25mm"],
14497
+ ["footskip", "footskip", "12mm"],
14498
+ ] as Array<[keyof StudioPdfRenderOptions, string, string]>) {
14499
+ const error = requireLength(key, label, example);
14500
+ if (error) return error;
14501
+ }
14502
+ const linestretch = String(input.linestretch ?? "").trim();
14503
+ if (linestretch) {
14504
+ if (!isValidStudioPdfLineStretch(linestretch)) return { error: "Invalid linestretch value. Example: 1.2" };
14505
+ options.linestretch = linestretch;
14506
+ }
14507
+ const papersize = String(input.papersize ?? "").trim();
14508
+ if (papersize) {
14509
+ if (!isValidStudioPdfPaperSize(papersize)) return { error: "Invalid papersize value. Example: a4" };
14510
+ options.papersize = papersize;
14511
+ }
14512
+ const mainfont = sanitizeStudioPdfFreeformOption(String(input.mainfont ?? ""));
14513
+ if (mainfont) options.mainfont = mainfont;
14514
+ const geometry = sanitizeStudioPdfFreeformOption(String(input.geometry ?? ""));
14515
+ if (geometry) options.geometry = geometry;
14516
+ if (options.geometry && (options.margin || options.marginTop || options.marginRight || options.marginBottom || options.marginLeft || options.footskip)) {
14517
+ return { error: "Use either geometry or the margin/margin*/footskip options, not both." };
14518
+ }
14519
+ return options;
14520
+ };
14521
+
14522
+ const resolveStudioExportSource = (
14523
+ params: StudioExportCommonToolParams,
14524
+ ctx: ExtensionContext,
14525
+ format: "pdf" | "html",
14526
+ ): StudioExportSource | { error: string } => {
14527
+ const inputFormat = parseStudioExportInputFormat(params.inputFormat);
14528
+ if (typeof inputFormat !== "string") return inputFormat;
14529
+ const pathArg = String(params.path ?? "").trim();
14530
+ const directMarkdown = typeof params.markdown === "string" ? params.markdown : "";
14531
+ const hasDirectMarkdown = directMarkdown.trim().length > 0;
14532
+ if (pathArg && hasDirectMarkdown) return { error: "Use either path or markdown, not both." };
14533
+
14534
+ if (hasDirectMarkdown) {
14535
+ const outputPath = resolveStudioExportOutputPath(params.outputPath, ctx.cwd)
14536
+ ?? buildStudioResponseExportOutputPath(ctx.cwd, format);
14537
+ const title = String(params.title ?? "").trim();
14538
+ return {
14539
+ text: directMarkdown,
14540
+ sourceLabel: title || "provided markdown",
14541
+ resourcePath: resolveStudioBaseDir(undefined, params.resourceDir, ctx.cwd),
14542
+ outputPath,
14543
+ inputFormat,
14544
+ };
14545
+ }
14546
+
14547
+ if (pathArg) {
14548
+ const file = readStudioFile(pathArg, ctx.cwd);
14549
+ if (file.ok === false) return { error: file.message };
14550
+ const outputPath = resolveStudioExportOutputPath(params.outputPath, ctx.cwd)
14551
+ ?? (format === "pdf" ? buildStudioPdfOutputPath(file.resolvedPath) : buildStudioHtmlOutputPath(file.resolvedPath));
14552
+ return {
14553
+ text: file.text,
14554
+ sourceLabel: file.label,
14555
+ sourcePath: file.resolvedPath,
14556
+ resourcePath: resolveStudioBaseDir(file.resolvedPath, params.resourceDir, ctx.cwd),
14557
+ outputPath,
14558
+ inputFormat,
14559
+ };
14560
+ }
14561
+
14562
+ const response = resolveLastModelResponseForExport(ctx);
14563
+ if (!response) return { error: "No last model response to export. Provide path or markdown, or run a prompt first." };
14564
+ return {
14565
+ text: response.markdown,
14566
+ sourceLabel: "last model response",
14567
+ resourcePath: resolveStudioBaseDir(undefined, params.resourceDir, ctx.cwd),
14568
+ outputPath: resolveStudioExportOutputPath(params.outputPath, ctx.cwd) ?? buildStudioResponseExportOutputPath(ctx.cwd, format),
14569
+ inputFormat,
14570
+ };
14571
+ };
14572
+
14573
+ const resolveStudioExportLanguage = (source: StudioExportSource): string | undefined => {
14574
+ if (source.inputFormat === "latex") return "latex";
14575
+ if (source.inputFormat === "markdown") return "markdown";
14576
+ return (source.sourcePath ? inferStudioPdfLanguageFromPath(source.sourcePath) : undefined)
14577
+ ?? inferStudioPdfLanguage(source.text);
14578
+ };
14579
+
14580
+ const maybeOpenStudioExportPath = async (path: string, open: boolean | undefined): Promise<string | null> => {
14581
+ if (!open) return null;
14582
+ try {
14583
+ await openPathInDefaultViewer(path);
14584
+ return null;
14585
+ } catch (error) {
14586
+ return error instanceof Error ? error.message : String(error);
14587
+ }
14588
+ };
14589
+
14590
+ const formatStudioExportToolText = (result: StudioExportResult): string => {
14591
+ if (!result.ok) return result.error ?? "Studio export failed.";
14592
+ const lines = [`Exported Studio ${result.format.toUpperCase()}: ${result.path}`];
14593
+ if (result.source) lines.push(`Source: ${result.source}`);
14594
+ if (result.bytes != null) lines.push(`Bytes: ${result.bytes}`);
14595
+ if (result.warning) lines.push(`Warning: ${result.warning}`);
14596
+ if (result.openError) lines.push(`Open warning: ${result.openError}`);
14597
+ return lines.join("\n");
14598
+ };
14599
+
14600
+ const exportStudioPdfForTool = async (params: StudioExportPdfToolParams, ctx: ExtensionContext): Promise<StudioExportResult> => {
14601
+ const source = resolveStudioExportSource(params, ctx, "pdf");
14602
+ if ("error" in source) return { ok: false, format: "pdf", error: source.error };
14603
+ if (source.text.length > PDF_EXPORT_MAX_CHARS) return { ok: false, format: "pdf", error: `PDF export text exceeds ${PDF_EXPORT_MAX_CHARS} characters.` };
14604
+ const pdfOptions = validateStudioPdfOptionsForTool(params.pdfOptions);
14605
+ if ("error" in pdfOptions) return { ok: false, format: "pdf", error: pdfOptions.error };
14606
+ const editorPdfLanguage = resolveStudioExportLanguage(source);
14607
+ const isLatex = editorPdfLanguage === "latex"
14608
+ || (
14609
+ source.inputFormat !== "markdown"
14610
+ && (editorPdfLanguage === undefined || editorPdfLanguage === "markdown")
14611
+ && /\\documentclass\b|\\begin\{document\}/.test(source.text)
14612
+ );
14613
+ try {
14614
+ const { pdf, warning } = await renderStudioPdfWithPandoc(
14615
+ source.text,
14616
+ isLatex,
14617
+ source.resourcePath,
14618
+ editorPdfLanguage,
14619
+ source.sourcePath,
14620
+ pdfOptions,
14621
+ );
14622
+ await writeFile(source.outputPath, pdf);
14623
+ const openError = await maybeOpenStudioExportPath(source.outputPath, params.open);
14624
+ return { ok: true, format: "pdf", path: source.outputPath, source: source.sourceLabel, bytes: pdf.length, warning, openError: openError ?? undefined };
14625
+ } catch (error) {
14626
+ return { ok: false, format: "pdf", error: `Studio PDF export failed: ${error instanceof Error ? error.message : String(error)}` };
14627
+ }
14628
+ };
14629
+
14630
+ const exportStudioHtmlForTool = async (params: StudioExportCommonToolParams, ctx: ExtensionContext): Promise<StudioExportResult> => {
14631
+ const source = resolveStudioExportSource(params, ctx, "html");
14632
+ if ("error" in source) return { ok: false, format: "html", error: source.error };
14633
+ if (source.text.length > HTML_EXPORT_MAX_CHARS) return { ok: false, format: "html", error: `HTML export text exceeds ${HTML_EXPORT_MAX_CHARS} characters.` };
14634
+ const editorHtmlLanguage = resolveStudioExportLanguage(source);
14635
+ const isLatex = editorHtmlLanguage === "latex"
14636
+ || (
14637
+ source.inputFormat !== "markdown"
14638
+ && (editorHtmlLanguage === undefined || editorHtmlLanguage === "markdown")
14639
+ && isLikelyStandaloneLatexPreview(source.text)
14640
+ );
14641
+ try {
14642
+ const themeVars = buildThemeCssVars(getStudioThemeStyle(ctx.ui.theme));
14643
+ const { html, warning } = await renderStudioStandaloneHtmlWithPandoc(
14644
+ source.text,
14645
+ isLatex,
14646
+ source.resourcePath,
14647
+ editorHtmlLanguage,
14648
+ source.sourcePath,
14649
+ {
14650
+ title: String(params.title ?? "").trim() || basename(source.outputPath),
14651
+ sourceLabel: source.sourcePath ?? source.sourceLabel,
14652
+ themeVars,
14653
+ },
14654
+ );
14655
+ await writeFile(source.outputPath, html);
14656
+ const openError = await maybeOpenStudioExportPath(source.outputPath, params.open);
14657
+ return { ok: true, format: "html", path: source.outputPath, source: source.sourceLabel, bytes: html.length, warning, openError: openError ?? undefined };
14658
+ } catch (error) {
14659
+ return { ok: false, format: "html", error: `Studio HTML export failed: ${error instanceof Error ? error.message : String(error)}` };
14660
+ }
14661
+ };
14662
+
14279
14663
  const openStudioView = async (
14280
14664
  trimmed: string,
14281
14665
  ctx: ExtensionCommandContext,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.23",
3
+ "version": "0.9.24",
4
4
  "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, active quiz, prompt/response history, live previews, and tmux-backed REPL/literate REPL workflows",
5
5
  "type": "module",
6
6
  "license": "MIT",