pi-studio 0.9.37 → 0.9.39

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
@@ -36,6 +36,13 @@ import {
36
36
  buildStudioMermaidPdfIconContrastCss,
37
37
  ensureStudioMermaidSourceContrast,
38
38
  } from "./shared/studio-mermaid.js";
39
+ import {
40
+ appendStudioQuartoLog,
41
+ buildStudioQuartoPreviewArgs,
42
+ isStudioQuartoDocumentPath,
43
+ parseStudioQuartoInspect,
44
+ parseStudioQuartoPreviewUrl,
45
+ } from "./shared/studio-quarto-preview.js";
39
46
 
40
47
  type Lens = "writing" | "code";
41
48
  type RequestedLens = Lens | "auto";
@@ -50,6 +57,34 @@ type StudioQuizAngle = "general" | "scientist" | "mathematician" | "statistician
50
57
  type StudioQuizScope = "selection" | "editor" | "file" | "folder" | "repo";
51
58
  type StudioQuizThinking = "off" | "minimal" | "low" | "medium" | "high";
52
59
  type StudioPiThinkingLevel = ModelThinkingLevel | "max";
60
+ type StudioQuartoPreviewStatus = "idle" | "starting" | "running" | "stopping" | "stopped" | "error";
61
+
62
+ interface StudioQuartoPreviewContext {
63
+ sourcePath: string;
64
+ requestedSourcePath: string;
65
+ available: boolean;
66
+ reason: "ready" | "not-found" | "invalid-source" | "inspect-error";
67
+ version: string;
68
+ projectRoot: string;
69
+ projectType: string;
70
+ projectLabel: string;
71
+ outputFile: string;
72
+ isProject: boolean;
73
+ error: string | null;
74
+ inspectLog: string;
75
+ }
76
+
77
+ interface StudioQuartoPreviewState {
78
+ status: StudioQuartoPreviewStatus;
79
+ sourcePath: string;
80
+ url: string;
81
+ log: string;
82
+ error: string | null;
83
+ context: StudioQuartoPreviewContext | null;
84
+ startedAt: number | null;
85
+ updatedAt: number;
86
+ actionRequestId: string | null;
87
+ }
53
88
 
54
89
  const STUDIO_CSS_URL = new URL("./client/studio.css", import.meta.url);
55
90
  const STUDIO_ANNOTATION_HELPERS_URL = new URL("./client/studio-annotation-helpers.js", import.meta.url);
@@ -485,6 +520,23 @@ interface GetFromEditorRequestMessage {
485
520
  requestId: string;
486
521
  }
487
522
 
523
+ interface QuartoPreviewCheckRequestMessage {
524
+ type: "quarto_preview_check_request";
525
+ requestId: string;
526
+ sourcePath: string;
527
+ }
528
+
529
+ interface QuartoPreviewStartRequestMessage {
530
+ type: "quarto_preview_start_request";
531
+ requestId: string;
532
+ sourcePath: string;
533
+ }
534
+
535
+ interface QuartoPreviewStopRequestMessage {
536
+ type: "quarto_preview_stop_request";
537
+ requestId: string;
538
+ }
539
+
488
540
  interface GitChangesRequestMessage {
489
541
  type: "git_changes_request";
490
542
  requestId: string;
@@ -534,6 +586,9 @@ type IncomingStudioMessage =
534
586
  | RefreshFromDiskRequestMessage
535
587
  | SendToEditorRequestMessage
536
588
  | GetFromEditorRequestMessage
589
+ | QuartoPreviewCheckRequestMessage
590
+ | QuartoPreviewStartRequestMessage
591
+ | QuartoPreviewStopRequestMessage
537
592
  | GitChangesRequestMessage
538
593
  | OpenEditorOnlyRequestMessage
539
594
  | CancelRequestMessage;
@@ -564,6 +619,9 @@ const MAX_PREPARED_HTML_EXPORTS = 8;
564
619
  const STUDIO_TRACE_SNAPSHOT_MAX_ENTRIES = 80;
565
620
  const STUDIO_TRACE_SNAPSHOT_MAX_FIELD_CHARS = 20_000;
566
621
  const STUDIO_TRACE_TOOL_ARGS_MAX_CHARS = 20_000;
622
+ const STUDIO_QUARTO_CHECK_TIMEOUT_MS = 30_000;
623
+ const STUDIO_QUARTO_START_TIMEOUT_MS = 3 * 60_000;
624
+ const STUDIO_QUARTO_LOG_MAX_CHARS = 80_000;
567
625
  const STUDIO_TRACE_IMAGE_MAX_COUNT = 8;
568
626
  const STUDIO_TRACE_IMAGE_MAX_BASE64_CHARS = 2_500_000;
569
627
  const STUDIO_TRACE_SNAPSHOT_MAX_IMAGES = 12;
@@ -816,7 +874,7 @@ $if(subtitle)$
816
874
  <p class="subtitle">$subtitle$</p>
817
875
  $endif$
818
876
  $for(author)$
819
- <p class="author">$author$</p>
877
+ <p class="author">$if(author.name)$$author.name$$else$$author$$endif$</p>
820
878
  $endfor$
821
879
  $if(date)$
822
880
  <p class="date">$date$</p>
@@ -5843,14 +5901,14 @@ async function renderStudioMermaidDiagramForPdf(source: string, workDir: string,
5843
5901
  const mermaidTheme = getStudioMermaidPdfTheme();
5844
5902
  const inputPath = join(workDir, `mermaid-diagram-${blockNumber}.mmd`);
5845
5903
  const outputPath = join(workDir, `mermaid-diagram-${blockNumber}.pdf`);
5846
- const iconCssPath = join(workDir, `mermaid-diagram-${blockNumber}.css`);
5904
+ const contrastCssPath = join(workDir, `mermaid-diagram-${blockNumber}.css`);
5847
5905
 
5848
5906
  const preparedSource = ensureStudioMermaidSourceContrast(source);
5849
- const iconContrastCss = buildStudioMermaidPdfIconContrastCss(preparedSource);
5907
+ const contrastCss = buildStudioMermaidPdfIconContrastCss(preparedSource, { theme: mermaidTheme });
5850
5908
  await writeFile(inputPath, preparedSource, "utf-8");
5851
- if (iconContrastCss) await writeFile(iconCssPath, iconContrastCss, "utf-8");
5909
+ if (contrastCss) await writeFile(contrastCssPath, contrastCss, "utf-8");
5852
5910
  const args = ["-i", inputPath, "-o", outputPath, "-t", mermaidTheme, "-f"];
5853
- if (iconContrastCss) args.push("-C", iconCssPath);
5911
+ if (contrastCss) args.push("-C", contrastCssPath);
5854
5912
  args.push(...buildStudioMermaidCliIconArgs(preparedSource));
5855
5913
  const result = await runStudioSubprocess(mermaidCommand, args, {
5856
5914
  timeoutMs: STUDIO_MERMAID_TIMEOUT_MS,
@@ -8713,6 +8771,37 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
8713
8771
  };
8714
8772
  }
8715
8773
 
8774
+ if (
8775
+ msg.type === "quarto_preview_check_request"
8776
+ && typeof msg.requestId === "string"
8777
+ && typeof msg.sourcePath === "string"
8778
+ ) {
8779
+ return {
8780
+ type: "quarto_preview_check_request",
8781
+ requestId: msg.requestId,
8782
+ sourcePath: msg.sourcePath,
8783
+ };
8784
+ }
8785
+
8786
+ if (
8787
+ msg.type === "quarto_preview_start_request"
8788
+ && typeof msg.requestId === "string"
8789
+ && typeof msg.sourcePath === "string"
8790
+ ) {
8791
+ return {
8792
+ type: "quarto_preview_start_request",
8793
+ requestId: msg.requestId,
8794
+ sourcePath: msg.sourcePath,
8795
+ };
8796
+ }
8797
+
8798
+ if (msg.type === "quarto_preview_stop_request" && typeof msg.requestId === "string") {
8799
+ return {
8800
+ type: "quarto_preview_stop_request",
8801
+ requestId: msg.requestId,
8802
+ };
8803
+ }
8804
+
8716
8805
  if (
8717
8806
  msg.type === "git_changes_request"
8718
8807
  && typeof msg.requestId === "string"
@@ -10662,6 +10751,7 @@ ${cssVarsBlock}
10662
10751
  <option value="markdown">Response (Raw)</option>
10663
10752
  <option value="preview" selected>Response (Preview)</option>
10664
10753
  <option value="editor-preview">Editor (Preview)</option>
10754
+ <option value="editor-quarto-preview" hidden>Editor (Quarto Preview)</option>
10665
10755
  <option value="trace">Working</option>
10666
10756
  <option value="changes">Changes</option>
10667
10757
  <option value="files">Files</option>
@@ -10882,6 +10972,22 @@ export default function (pi: ExtensionAPI) {
10882
10972
  let compactInProgress = false;
10883
10973
  let compactRequestId: string | null = null;
10884
10974
  const activeCompletionSuggestions = new Map<string, AbortController>();
10975
+ let studioQuartoPreviewProcess: ReturnType<typeof spawn> | null = null;
10976
+ let studioQuartoPreviewGeneration = 0;
10977
+ let studioQuartoPreviewStartupTimer: NodeJS.Timeout | null = null;
10978
+ let studioQuartoPreviewBroadcastTimer: NodeJS.Timeout | null = null;
10979
+ let studioQuartoPreviewOperation: Promise<void> = Promise.resolve();
10980
+ let studioQuartoPreviewState: StudioQuartoPreviewState = {
10981
+ status: "idle",
10982
+ sourcePath: "",
10983
+ url: "",
10984
+ log: "",
10985
+ error: null,
10986
+ context: null,
10987
+ startedAt: null,
10988
+ updatedAt: Date.now(),
10989
+ actionRequestId: null,
10990
+ };
10885
10991
 
10886
10992
  const selectStudioReplSessionForTool = (params: { sessionName?: string; target?: string }): { session: StudioReplSessionInfo | null; error?: string; sessions: StudioReplSessionInfo[] } => {
10887
10993
  const state = listStudioReplSessions();
@@ -11537,6 +11643,305 @@ export default function (pi: ExtensionAPI) {
11537
11643
  }
11538
11644
  };
11539
11645
 
11646
+ const getStudioQuartoPreviewSnapshot = (): StudioQuartoPreviewState => ({
11647
+ ...studioQuartoPreviewState,
11648
+ context: studioQuartoPreviewState.context ? { ...studioQuartoPreviewState.context } : null,
11649
+ });
11650
+
11651
+ const broadcastStudioQuartoPreviewState = () => {
11652
+ if (studioQuartoPreviewBroadcastTimer) {
11653
+ clearTimeout(studioQuartoPreviewBroadcastTimer);
11654
+ studioQuartoPreviewBroadcastTimer = null;
11655
+ }
11656
+ broadcast({ type: "quarto_preview_state", preview: getStudioQuartoPreviewSnapshot() });
11657
+ };
11658
+
11659
+ const scheduleStudioQuartoPreviewBroadcast = () => {
11660
+ if (studioQuartoPreviewBroadcastTimer) return;
11661
+ studioQuartoPreviewBroadcastTimer = setTimeout(() => {
11662
+ studioQuartoPreviewBroadcastTimer = null;
11663
+ broadcast({ type: "quarto_preview_state", preview: getStudioQuartoPreviewSnapshot() });
11664
+ }, 120);
11665
+ };
11666
+
11667
+ const updateStudioQuartoPreviewState = (
11668
+ patch: Partial<StudioQuartoPreviewState>,
11669
+ options?: { immediate?: boolean },
11670
+ ) => {
11671
+ studioQuartoPreviewState = {
11672
+ ...studioQuartoPreviewState,
11673
+ ...patch,
11674
+ updatedAt: Date.now(),
11675
+ };
11676
+ if (options?.immediate) {
11677
+ broadcastStudioQuartoPreviewState();
11678
+ } else {
11679
+ scheduleStudioQuartoPreviewBroadcast();
11680
+ }
11681
+ };
11682
+
11683
+ const makeUnavailableStudioQuartoContext = (
11684
+ sourcePath: string,
11685
+ reason: StudioQuartoPreviewContext["reason"],
11686
+ error: string,
11687
+ inspectLog = "",
11688
+ ): StudioQuartoPreviewContext => ({
11689
+ sourcePath,
11690
+ requestedSourcePath: sourcePath,
11691
+ available: false,
11692
+ reason,
11693
+ version: "",
11694
+ projectRoot: sourcePath ? dirname(sourcePath) : "",
11695
+ projectType: "",
11696
+ projectLabel: sourcePath ? basename(sourcePath) : "Quarto document",
11697
+ outputFile: "",
11698
+ isProject: false,
11699
+ error,
11700
+ inspectLog,
11701
+ });
11702
+
11703
+ const resolveStudioQuartoSourcePath = (requestedPath: string): { ok: true; path: string } | { ok: false; context: StudioQuartoPreviewContext } => {
11704
+ const rawPath = String(requestedPath || "").trim();
11705
+ if (!rawPath || rawPath.length > 32_000) {
11706
+ return {
11707
+ ok: false,
11708
+ context: makeUnavailableStudioQuartoContext(rawPath, "invalid-source", "Quarto preview requires a file-backed .qmd, .md, or .markdown document."),
11709
+ };
11710
+ }
11711
+ const candidate = resolve(studioCwd, rawPath);
11712
+ if (!isStudioQuartoDocumentPath(candidate)) {
11713
+ return {
11714
+ ok: false,
11715
+ context: makeUnavailableStudioQuartoContext(candidate, "invalid-source", "Quarto preview is currently available only for file-backed .qmd, .md, and .markdown documents."),
11716
+ };
11717
+ }
11718
+ try {
11719
+ const resolvedPath = realpathSync(candidate);
11720
+ if (!statSync(resolvedPath).isFile()) {
11721
+ throw new Error("Path is not a regular file.");
11722
+ }
11723
+ return { ok: true, path: resolvedPath };
11724
+ } catch (error) {
11725
+ return {
11726
+ ok: false,
11727
+ context: makeUnavailableStudioQuartoContext(
11728
+ candidate,
11729
+ "invalid-source",
11730
+ `Quarto source is unavailable on disk: ${error instanceof Error ? error.message : String(error)}`,
11731
+ ),
11732
+ };
11733
+ }
11734
+ };
11735
+
11736
+ const inspectStudioQuartoPreviewContext = async (requestedPath: string): Promise<StudioQuartoPreviewContext> => {
11737
+ const requestedSourcePath = resolve(studioCwd, String(requestedPath || "").trim());
11738
+ const resolved = resolveStudioQuartoSourcePath(requestedPath);
11739
+ if (resolved.ok === false) return resolved.context;
11740
+ const sourcePath = resolved.path;
11741
+ const makeInspectedUnavailableContext = (
11742
+ reason: StudioQuartoPreviewContext["reason"],
11743
+ error: string,
11744
+ inspectLog = "",
11745
+ ): StudioQuartoPreviewContext => ({
11746
+ ...makeUnavailableStudioQuartoContext(sourcePath, reason, error, inspectLog),
11747
+ requestedSourcePath,
11748
+ });
11749
+ let versionResult: StudioSubprocessResult;
11750
+ try {
11751
+ versionResult = await runStudioSubprocess("quarto", ["--version"], {
11752
+ cwd: dirname(sourcePath),
11753
+ timeoutMs: STUDIO_QUARTO_CHECK_TIMEOUT_MS,
11754
+ stdoutMaxBytes: 32_000,
11755
+ stderrMaxBytes: 32_000,
11756
+ label: "Quarto version check",
11757
+ notFoundMessage: "Quarto is not installed or is not available on Studio's PATH.",
11758
+ });
11759
+ } catch (error) {
11760
+ const message = error instanceof Error ? error.message : String(error);
11761
+ return makeInspectedUnavailableContext(
11762
+ /was not found|not installed|ENOENT/i.test(message) ? "not-found" : "inspect-error",
11763
+ message,
11764
+ );
11765
+ }
11766
+ const version = (versionResult.stdout || versionResult.stderr).split(/\r?\n/)[0]?.trim() ?? "";
11767
+ if (versionResult.code !== 0) {
11768
+ const detail = versionResult.stderr || versionResult.stdout || `Quarto exited with code ${versionResult.code}.`;
11769
+ return makeInspectedUnavailableContext("inspect-error", `Quarto version check failed: ${detail}`, detail);
11770
+ }
11771
+
11772
+ let inspectResult: StudioSubprocessResult;
11773
+ try {
11774
+ inspectResult = await runStudioSubprocess("quarto", ["inspect", sourcePath], {
11775
+ cwd: dirname(sourcePath),
11776
+ timeoutMs: STUDIO_QUARTO_CHECK_TIMEOUT_MS,
11777
+ stdoutMaxBytes: 1_000_000,
11778
+ stderrMaxBytes: 200_000,
11779
+ label: "Quarto inspect",
11780
+ notFoundMessage: "Quarto is not installed or is not available on Studio's PATH.",
11781
+ });
11782
+ } catch (error) {
11783
+ const message = error instanceof Error ? error.message : String(error);
11784
+ return makeInspectedUnavailableContext("inspect-error", message);
11785
+ }
11786
+ const inspectLog = inspectResult.stderr.trim();
11787
+ if (inspectResult.code !== 0) {
11788
+ const detail = inspectLog || inspectResult.stdout || `Quarto exited with code ${inspectResult.code}.`;
11789
+ return makeInspectedUnavailableContext("inspect-error", `Quarto could not inspect this document: ${detail}`, detail);
11790
+ }
11791
+ try {
11792
+ const inspected = parseStudioQuartoInspect(inspectResult.stdout, sourcePath, version);
11793
+ return {
11794
+ ...inspected,
11795
+ requestedSourcePath,
11796
+ available: true,
11797
+ reason: "ready",
11798
+ error: null,
11799
+ inspectLog,
11800
+ };
11801
+ } catch (error) {
11802
+ const message = error instanceof Error ? error.message : String(error);
11803
+ return makeInspectedUnavailableContext("inspect-error", message, inspectLog);
11804
+ }
11805
+ };
11806
+
11807
+ const clearStudioQuartoPreviewStartupTimer = () => {
11808
+ if (!studioQuartoPreviewStartupTimer) return;
11809
+ clearTimeout(studioQuartoPreviewStartupTimer);
11810
+ studioQuartoPreviewStartupTimer = null;
11811
+ };
11812
+
11813
+ const terminateStudioQuartoChild = async (child: ReturnType<typeof spawn>): Promise<void> => {
11814
+ if (child.exitCode !== null || child.signalCode !== null) return;
11815
+ await new Promise<void>((resolvePromise) => {
11816
+ let settled = false;
11817
+ let forceTimer: NodeJS.Timeout | null = null;
11818
+ const finish = () => {
11819
+ if (settled) return;
11820
+ settled = true;
11821
+ if (forceTimer) clearTimeout(forceTimer);
11822
+ resolvePromise();
11823
+ };
11824
+ const signalProcess = (signal: NodeJS.Signals) => {
11825
+ try {
11826
+ if (process.platform === "win32" && child.pid && signal === "SIGKILL") {
11827
+ spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", timeout: 2_000 });
11828
+ } else if (process.platform !== "win32" && child.pid) {
11829
+ process.kill(-child.pid, signal);
11830
+ } else {
11831
+ child.kill(signal);
11832
+ }
11833
+ } catch {
11834
+ try { child.kill(signal); } catch {}
11835
+ }
11836
+ };
11837
+ child.once("close", finish);
11838
+ signalProcess("SIGTERM");
11839
+ forceTimer = setTimeout(() => {
11840
+ signalProcess("SIGKILL");
11841
+ setTimeout(finish, 400);
11842
+ }, 2_000);
11843
+ });
11844
+ };
11845
+
11846
+ const stopStudioQuartoPreview = async (
11847
+ actionRequestId: string | null = null,
11848
+ options?: { preserveError?: boolean; quiet?: boolean },
11849
+ ) => {
11850
+ studioQuartoPreviewGeneration += 1;
11851
+ clearStudioQuartoPreviewStartupTimer();
11852
+ const child = studioQuartoPreviewProcess;
11853
+ studioQuartoPreviewProcess = null;
11854
+ if (child && !options?.quiet) {
11855
+ updateStudioQuartoPreviewState({ status: "stopping", actionRequestId }, { immediate: true });
11856
+ }
11857
+ if (child) await terminateStudioQuartoChild(child);
11858
+ updateStudioQuartoPreviewState({
11859
+ status: studioQuartoPreviewState.sourcePath ? "stopped" : "idle",
11860
+ url: "",
11861
+ error: options?.preserveError ? studioQuartoPreviewState.error : null,
11862
+ actionRequestId,
11863
+ }, { immediate: !options?.quiet });
11864
+ };
11865
+
11866
+ const failStudioQuartoPreview = (generation: number, message: string) => {
11867
+ if (generation !== studioQuartoPreviewGeneration) return;
11868
+ clearStudioQuartoPreviewStartupTimer();
11869
+ const child = studioQuartoPreviewProcess;
11870
+ studioQuartoPreviewProcess = null;
11871
+ studioQuartoPreviewGeneration += 1;
11872
+ updateStudioQuartoPreviewState({
11873
+ status: "error",
11874
+ url: "",
11875
+ error: message,
11876
+ log: appendStudioQuartoLog(studioQuartoPreviewState.log, `\n[Studio] ${message}\n`, STUDIO_QUARTO_LOG_MAX_CHARS),
11877
+ }, { immediate: true });
11878
+ if (child) void terminateStudioQuartoChild(child);
11879
+ };
11880
+
11881
+ const startStudioQuartoPreview = async (context: StudioQuartoPreviewContext, actionRequestId: string) => {
11882
+ await stopStudioQuartoPreview(null, { quiet: true });
11883
+ const generation = studioQuartoPreviewGeneration + 1;
11884
+ studioQuartoPreviewGeneration = generation;
11885
+ const args = buildStudioQuartoPreviewArgs(context.sourcePath);
11886
+ updateStudioQuartoPreviewState({
11887
+ status: "starting",
11888
+ sourcePath: context.sourcePath,
11889
+ url: "",
11890
+ log: `[Studio] Starting Quarto ${context.version || "preview"} with computational cell execution disabled (--no-execute).\n`,
11891
+ error: null,
11892
+ context,
11893
+ startedAt: Date.now(),
11894
+ actionRequestId,
11895
+ }, { immediate: true });
11896
+
11897
+ let child: ReturnType<typeof spawn>;
11898
+ try {
11899
+ child = spawn("quarto", args, {
11900
+ cwd: context.projectRoot || dirname(context.sourcePath),
11901
+ stdio: ["ignore", "pipe", "pipe"],
11902
+ detached: process.platform !== "win32",
11903
+ env: { ...process.env, NO_COLOR: "1" },
11904
+ });
11905
+ } catch (error) {
11906
+ failStudioQuartoPreview(generation, `Could not start Quarto: ${error instanceof Error ? error.message : String(error)}`);
11907
+ return;
11908
+ }
11909
+ studioQuartoPreviewProcess = child;
11910
+
11911
+ const handleOutput = (chunk: Buffer | string) => {
11912
+ if (generation !== studioQuartoPreviewGeneration) return;
11913
+ const log = appendStudioQuartoLog(studioQuartoPreviewState.log, chunk, STUDIO_QUARTO_LOG_MAX_CHARS);
11914
+ const previewUrl = studioQuartoPreviewState.url || parseStudioQuartoPreviewUrl(log) || "";
11915
+ if (previewUrl && studioQuartoPreviewState.status === "starting") {
11916
+ clearStudioQuartoPreviewStartupTimer();
11917
+ updateStudioQuartoPreviewState({ status: "running", url: previewUrl, log, error: null }, { immediate: true });
11918
+ return;
11919
+ }
11920
+ updateStudioQuartoPreviewState({ log, url: previewUrl });
11921
+ };
11922
+ child.stdout?.on("data", handleOutput);
11923
+ child.stderr?.on("data", handleOutput);
11924
+ child.once("error", (error) => {
11925
+ failStudioQuartoPreview(generation, `Quarto preview failed to start: ${error.message}`);
11926
+ });
11927
+ child.once("close", (code, signal) => {
11928
+ if (generation !== studioQuartoPreviewGeneration) return;
11929
+ studioQuartoPreviewProcess = null;
11930
+ clearStudioQuartoPreviewStartupTimer();
11931
+ const detail = signal ? `signal ${signal}` : `code ${code ?? "unknown"}`;
11932
+ failStudioQuartoPreview(generation, `Quarto preview exited unexpectedly (${detail}).`);
11933
+ });
11934
+ studioQuartoPreviewStartupTimer = setTimeout(() => {
11935
+ failStudioQuartoPreview(generation, `Quarto did not report a loopback preview URL within ${Math.round(STUDIO_QUARTO_START_TIMEOUT_MS / 1000)} seconds.`);
11936
+ }, STUDIO_QUARTO_START_TIMEOUT_MS);
11937
+ };
11938
+
11939
+ const enqueueStudioQuartoPreviewOperation = (operation: () => Promise<void>): Promise<void> => {
11940
+ const next = studioQuartoPreviewOperation.then(operation, operation);
11941
+ studioQuartoPreviewOperation = next.catch(() => {});
11942
+ return next;
11943
+ };
11944
+
11540
11945
  const sendReplStateToClient = (client: WebSocket, extra?: Record<string, unknown>) => {
11541
11946
  const state = listStudioReplSessions();
11542
11947
  if (studioReplActiveSessionName && !state.sessions.some((session) => session.sessionName === studioReplActiveSessionName)) {
@@ -12227,6 +12632,7 @@ export default function (pi: ExtensionAPI) {
12227
12632
  responseHistory: studioResponseHistory,
12228
12633
  traceState: studioTraceState,
12229
12634
  initialDocument: initialStudioDocument,
12635
+ quartoPreview: getStudioQuartoPreviewSnapshot(),
12230
12636
  });
12231
12637
  return;
12232
12638
  }
@@ -12330,6 +12736,62 @@ export default function (pi: ExtensionAPI) {
12330
12736
  return;
12331
12737
  }
12332
12738
 
12739
+ if (msg.type === "quarto_preview_check_request") {
12740
+ if (!isValidRequestId(msg.requestId)) {
12741
+ sendToClient(client, { type: "quarto_preview_action_error", requestId: msg.requestId, message: "Invalid request ID." });
12742
+ return;
12743
+ }
12744
+ void inspectStudioQuartoPreviewContext(msg.sourcePath)
12745
+ .then((context) => {
12746
+ sendToClient(client, { type: "quarto_preview_context", requestId: msg.requestId, context });
12747
+ })
12748
+ .catch((error) => {
12749
+ sendToClient(client, {
12750
+ type: "quarto_preview_action_error",
12751
+ requestId: msg.requestId,
12752
+ message: `Quarto check failed: ${error instanceof Error ? error.message : String(error)}`,
12753
+ });
12754
+ });
12755
+ return;
12756
+ }
12757
+
12758
+ if (msg.type === "quarto_preview_start_request") {
12759
+ if (!isValidRequestId(msg.requestId)) {
12760
+ sendToClient(client, { type: "quarto_preview_action_error", requestId: msg.requestId, message: "Invalid request ID." });
12761
+ return;
12762
+ }
12763
+ void enqueueStudioQuartoPreviewOperation(async () => {
12764
+ const context = await inspectStudioQuartoPreviewContext(msg.sourcePath);
12765
+ sendToClient(client, { type: "quarto_preview_context", requestId: msg.requestId, context });
12766
+ if (!context.available) return;
12767
+ await startStudioQuartoPreview(context, msg.requestId);
12768
+ }).catch((error) => {
12769
+ sendToClient(client, {
12770
+ type: "quarto_preview_action_error",
12771
+ requestId: msg.requestId,
12772
+ message: `Quarto preview failed: ${error instanceof Error ? error.message : String(error)}`,
12773
+ });
12774
+ });
12775
+ return;
12776
+ }
12777
+
12778
+ if (msg.type === "quarto_preview_stop_request") {
12779
+ if (!isValidRequestId(msg.requestId)) {
12780
+ sendToClient(client, { type: "quarto_preview_action_error", requestId: msg.requestId, message: "Invalid request ID." });
12781
+ return;
12782
+ }
12783
+ void enqueueStudioQuartoPreviewOperation(async () => {
12784
+ await stopStudioQuartoPreview(msg.requestId);
12785
+ }).catch((error) => {
12786
+ sendToClient(client, {
12787
+ type: "quarto_preview_action_error",
12788
+ requestId: msg.requestId,
12789
+ message: `Could not stop Quarto preview: ${error instanceof Error ? error.message : String(error)}`,
12790
+ });
12791
+ });
12792
+ return;
12793
+ }
12794
+
12333
12795
  if (msg.type === "git_changes_request") {
12334
12796
  if (!isValidRequestId(msg.requestId)) {
12335
12797
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
@@ -14517,6 +14979,13 @@ export default function (pi: ExtensionAPI) {
14517
14979
  clearPreparedPdfExports();
14518
14980
  clearPreparedHtmlExports();
14519
14981
  clearCompactionState();
14982
+ await enqueueStudioQuartoPreviewOperation(async () => {
14983
+ await stopStudioQuartoPreview(null, { quiet: true });
14984
+ }).catch(() => {});
14985
+ if (studioQuartoPreviewBroadcastTimer) {
14986
+ clearTimeout(studioQuartoPreviewBroadcastTimer);
14987
+ studioQuartoPreviewBroadcastTimer = null;
14988
+ }
14520
14989
  closeAllClients(1001, "Server shutting down");
14521
14990
 
14522
14991
  const state = serverState;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.37",
3
+ "version": "0.9.39",
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",
@@ -232,12 +232,44 @@ function buildStudioMermaidIconPaintRule(selector, color) {
232
232
  ].join("\n");
233
233
  }
234
234
 
235
- export function buildStudioMermaidPdfIconContrastCss(source) {
235
+ function buildStudioMermaidPdfPrintColorRule() {
236
+ return [
237
+ ".node, .node *, .icon-shape, .icon-shape * {",
238
+ " -webkit-print-color-adjust: exact !important;",
239
+ " print-color-adjust: exact !important;",
240
+ "}",
241
+ ].join("\n");
242
+ }
243
+
244
+ function buildStudioMermaidPdfIconLabelRule(selectors, theme) {
245
+ if (selectors.length === 0) return "";
246
+ const darkTheme = String(theme || "default").toLowerCase() === "dark";
247
+ const background = darkTheme ? "#1f2937" : "#ffffff";
248
+ const foreground = darkTheme ? "#ffffff" : "#000000";
249
+ const backgroundSelectors = selectors.map((selector) => `${selector} .labelBkg`).join(",\n");
250
+ const textSelectors = selectors.flatMap((selector) => [
251
+ `${selector} .nodeLabel`,
252
+ `${selector} .nodeLabel *`,
253
+ ]).join(",\n");
254
+ return [
255
+ `${backgroundSelectors} { background-color: ${background} !important; }`,
256
+ `${textSelectors} {`,
257
+ ` color: ${foreground} !important;`,
258
+ ` fill: ${foreground} !important;`,
259
+ ` -webkit-text-fill-color: ${foreground} !important;`,
260
+ " opacity: 1 !important;",
261
+ "}",
262
+ ].join("\n");
263
+ }
264
+
265
+ export function buildStudioMermaidPdfIconContrastCss(source, options = {}) {
236
266
  const preparedSource = ensureStudioMermaidSourceContrast(source);
237
267
  const { iconNodeIds, classNamesByNode } = collectStudioMermaidIconStyleTargets(preparedSource);
238
- if (iconNodeIds.size === 0) return "";
268
+ const printColorRule = buildStudioMermaidPdfPrintColorRule();
269
+ if (iconNodeIds.size === 0) return printColorRule;
239
270
  const classStyles = collectStudioMermaidStylesByTarget(preparedSource, "classDef");
240
271
  const directStyles = collectStudioMermaidStylesByTarget(preparedSource, "style");
272
+ const iconSelectors = Array.from(iconNodeIds, (iconNodeId) => `.icon-shape[id*="flowchart-${iconNodeId}-"]`);
241
273
  const rulesBySelector = new Map();
242
274
 
243
275
  for (const iconNodeId of iconNodeIds) {
@@ -250,5 +282,9 @@ export function buildStudioMermaidPdfIconContrastCss(source) {
250
282
  if (paint) rulesBySelector.set(`.icon-shape[id*="flowchart-${iconNodeId}-"]`, paint);
251
283
  }
252
284
 
253
- return Array.from(rulesBySelector, ([selector, color]) => buildStudioMermaidIconPaintRule(selector, color)).join("\n");
285
+ return [
286
+ printColorRule,
287
+ buildStudioMermaidPdfIconLabelRule(iconSelectors, options.theme),
288
+ ...Array.from(rulesBySelector, ([selector, color]) => buildStudioMermaidIconPaintRule(selector, color)),
289
+ ].filter(Boolean).join("\n");
254
290
  }