pi-studio 0.9.47 → 0.9.49

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
@@ -28,8 +28,10 @@ import {
28
28
  preserveLiteralLatexCommandsInMarkdown,
29
29
  } from "./shared/studio-markdown-latex-literals.js";
30
30
  import { escapeStudioPdfLatexTextFragment } from "./shared/studio-pdf-escape.js";
31
+ import { parseStudioLocalPreviewPage, parseStudioPdfLaunchTarget } from "./shared/studio-local-preview-path.js";
31
32
  import { resolveStudioPdfResourceFile } from "./shared/studio-pdf-resource.js";
32
33
  import { createStudioPandocHtmlResourceFlagResolver } from "./shared/studio-pandoc-resource-flag.js";
34
+ import { prepareStudioLatexForPandoc } from "./shared/studio-latex-pandoc-compat.js";
33
35
  import { isStudioCmuxSession, openStudioUrlInBrowser } from "./shared/studio-browser-launcher.js";
34
36
  import { buildStudioReplTmuxStartArgs } from "./shared/studio-repl-tmux.js";
35
37
  import { buildStudioForwardingHint, buildStudioSshTunnelHint, isStudioSshSession as isSshSession } from "./shared/studio-ssh-hint.js";
@@ -275,6 +277,21 @@ interface InitialStudioDocument {
275
277
  resourceDir?: string;
276
278
  }
277
279
 
280
+ interface StudioLaunchSelection {
281
+ document: InitialStudioDocument;
282
+ kind: "document" | "pdf-preview";
283
+ mode?: StudioUiMode;
284
+ transient?: boolean;
285
+ skipWorkspaceRestore?: boolean;
286
+ paneFocus?: "left" | "right";
287
+ resourcePath?: string;
288
+ }
289
+
290
+ interface StudioUrlOptions {
291
+ skipWorkspaceRestore?: boolean;
292
+ paneFocus?: "left" | "right";
293
+ }
294
+
278
295
  type PersistedStudioReviewNoteAnchorKind = "source" | "html-selection" | "html-element" | "html-page";
279
296
 
280
297
  interface PersistedStudioReviewNote {
@@ -648,6 +665,7 @@ const STUDIO_QUIZ_CONTEXT_MAX_FILES = 18;
648
665
  const STUDIO_QUIZ_SNIPPET_MAX_CHARS = 8_000;
649
666
  const STUDIO_QUIZ_DISCUSSION_MAX_CHARS = 6_000;
650
667
  const REQUEST_BODY_MAX_BYTES = 1_000_000;
668
+ const STUDIO_IMPORT_FILE_MAX_BYTES = 10_000_000;
651
669
  const STUDIO_WORKSPACE_STATE_REQUEST_MAX_BYTES = 4_000_000;
652
670
  const RESPONSE_HISTORY_LIMIT = 30;
653
671
  const CMUX_NOTIFY_TIMEOUT_MS = 1200;
@@ -2947,35 +2965,6 @@ function decodeStudioHtmlPreviewResourcePath(resourcePath: string): string {
2947
2965
  }
2948
2966
  }
2949
2967
 
2950
- function parseStudioLocalPreviewResourcePage(resourcePath: string): number | null {
2951
- const raw = String(resourcePath || "");
2952
- const parts: string[] = [];
2953
- const queryIndex = raw.indexOf("?");
2954
- if (queryIndex >= 0) {
2955
- const queryEnd = raw.indexOf("#", queryIndex);
2956
- parts.push(raw.slice(queryIndex + 1, queryEnd >= 0 ? queryEnd : raw.length));
2957
- }
2958
- const hashIndex = raw.indexOf("#");
2959
- if (hashIndex >= 0) parts.push(raw.slice(hashIndex + 1));
2960
- for (const part of parts) {
2961
- try {
2962
- const params = new URLSearchParams(part);
2963
- const rawPage = params.get("page") || params.get("p");
2964
- if (rawPage) {
2965
- const page = Number.parseInt(rawPage, 10);
2966
- if (Number.isFinite(page) && page > 0) return page;
2967
- }
2968
- } catch {
2969
- const match = part.match(/(?:^|[&;])page=(\d+)/i) || part.match(/^page=(\d+)$/i);
2970
- if (match && match[1]) {
2971
- const page = Number.parseInt(match[1], 10);
2972
- if (Number.isFinite(page) && page > 0) return page;
2973
- }
2974
- }
2975
- }
2976
- return null;
2977
- }
2978
-
2979
2968
  function getStudioLocalPreviewResourceKind(extension: string, filePathOrName?: string): StudioLocalPreviewResourceKind {
2980
2969
  const ext = extension.toLowerCase();
2981
2970
  const name = basename(String(filePathOrName || "")).toLowerCase();
@@ -3020,7 +3009,7 @@ function resolveStudioLocalPreviewResourcePath(
3020
3009
  label: rel && rel !== "" ? rel : basename(candidateReal),
3021
3010
  extension,
3022
3011
  kind: getStudioLocalPreviewResourceKind(extension, candidateReal),
3023
- page: parseStudioLocalPreviewResourcePage(rawPath),
3012
+ page: parseStudioLocalPreviewPage(rawPath),
3024
3013
  resourceDir: boundaryReal,
3025
3014
  };
3026
3015
  }
@@ -6127,6 +6116,31 @@ function decorateStudioPandocSyntaxHtml(html: string): string {
6127
6116
  );
6128
6117
  }
6129
6118
 
6119
+ function buildStudioLatexPandocCompatibilityWarning(omittedPackages: Array<{ name: string }>): string | undefined {
6120
+ const packageNames = Array.from(new Set(
6121
+ omittedPackages
6122
+ .map((entry) => String(entry?.name ?? "").trim())
6123
+ .filter(Boolean),
6124
+ ));
6125
+ if (packageNames.length === 0) return undefined;
6126
+ const packageLabel = packageNames
6127
+ .map((name) => name.toLowerCase().endsWith(".sty") ? name : `${name}.sty`)
6128
+ .join(", ");
6129
+ const noun = packageNames.length === 1 ? "package" : "packages";
6130
+ const subject = packageNames.length === 1 ? "it redefines" : "they redefine";
6131
+ return `Studio omitted local LaTeX ${noun} ${packageLabel} from this Pandoc rendering because ${subject} document startup. Package-specific layout may be absent; compile the source directly with LaTeX for authoritative output.`;
6132
+ }
6133
+
6134
+ function combineStudioWarnings(...warnings: Array<string | undefined>): string | undefined {
6135
+ const messages = Array.from(new Set(warnings.map((warning) => String(warning ?? "").trim()).filter(Boolean)));
6136
+ return messages.length > 0 ? messages.join(" ") : undefined;
6137
+ }
6138
+
6139
+ function renderStudioLatexPandocCompatibilityWarningHtml(warning: string | undefined): string {
6140
+ if (!warning) return "";
6141
+ return `<div class="preview-warning studio-latex-compatibility-warning" role="note">${escapeStudioHtmlText(warning)}</div>`;
6142
+ }
6143
+
6130
6144
  const resolveStudioPandocHtmlResourceFlag = createStudioPandocHtmlResourceFlagResolver(async (pandocCommand: string) => {
6131
6145
  const result = await runStudioSubprocess(pandocCommand, ["--help"], {
6132
6146
  timeoutMs: 5_000,
@@ -6150,8 +6164,15 @@ function preprocessStudioLatexFootnotemarksForPreview(latex: string): string {
6150
6164
 
6151
6165
  async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolean, resourcePath?: string, sourcePath?: string): Promise<string> {
6152
6166
  const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
6153
- const latexPreviewSource = isLatex ? preprocessStudioLatexFootnotemarksForPreview(markdown) : markdown;
6154
- const markdownWithNormalizedFences = isLatex ? latexPreviewSource : normalizeStudioMarkdownSmartFences(markdown);
6167
+ const pandocWorkingDir = resolveStudioPandocWorkingDir(resourcePath)
6168
+ ?? resolveStudioPandocWorkingDir(sourcePath ? dirname(sourcePath) : undefined);
6169
+ const latexPandocCompatibility = isLatex
6170
+ ? prepareStudioLatexForPandoc(markdown, pandocWorkingDir)
6171
+ : { source: markdown, omittedPackages: [] as Array<{ name: string; path: string }> };
6172
+ const latexPandocCompatibilityWarning = buildStudioLatexPandocCompatibilityWarning(latexPandocCompatibility.omittedPackages);
6173
+ const pandocInputSource = latexPandocCompatibility.source;
6174
+ const latexPreviewSource = isLatex ? preprocessStudioLatexFootnotemarksForPreview(pandocInputSource) : pandocInputSource;
6175
+ const markdownWithNormalizedFences = isLatex ? latexPreviewSource : normalizeStudioMarkdownSmartFences(pandocInputSource);
6155
6176
  const markdownWithoutHtmlComments = isLatex ? markdownWithNormalizedFences : stripStudioMarkdownHtmlCommentsPreservingYamlFrontMatter(markdownWithNormalizedFences);
6156
6177
  const markdownWithPreviewPageBreaks = isLatex ? markdownWithoutHtmlComments : replaceStudioPreviewPageBreakCommands(markdownWithoutHtmlComments);
6157
6178
  const latexSubfigurePreviewTransform = isLatex
@@ -6164,7 +6185,7 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
6164
6185
  ? preprocessStudioLatexReferences(latexAlgorithmPreviewTransform.markdown, sourcePath, resourcePath)
6165
6186
  : markdownWithPreviewPageBreaks;
6166
6187
  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";
6167
- const bibliographyArgs = buildStudioPandocBibliographyArgs(markdown, isLatex, resourcePath);
6188
+ const bibliographyArgs = buildStudioPandocBibliographyArgs(pandocInputSource, isLatex, resourcePath);
6168
6189
  const args = ["-f", inputFormat, "-t", "html5", "--mathml", "--wrap=none", ...bibliographyArgs];
6169
6190
  let htmlTemplateDir: string | null = null;
6170
6191
  const useStudioHtmlTemplate = Boolean(resourcePath || isLatex);
@@ -6185,7 +6206,6 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
6185
6206
  const normalizedMarkdown = isLatex
6186
6207
  ? sourceWithResolvedRefs
6187
6208
  : normalizeStudioMarkdownFencedBlocks(prepareStudioMarkdownForPandoc(sourceWithResolvedRefs));
6188
- const pandocWorkingDir = resolveStudioPandocWorkingDir(resourcePath);
6189
6209
 
6190
6210
  let pandocResult: StudioSubprocessResult;
6191
6211
  try {
@@ -6230,6 +6250,7 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
6230
6250
  renderedHtml = decorateStudioPreviewPageBreakHtml(renderedHtml);
6231
6251
  }
6232
6252
  renderedHtml = decorateStudioPandocSyntaxHtml(renderedHtml);
6253
+ renderedHtml = renderStudioLatexPandocCompatibilityWarningHtml(latexPandocCompatibilityWarning) + renderedHtml;
6233
6254
  return stripMathMlAnnotationTags(renderedHtml);
6234
6255
  }
6235
6256
 
@@ -6616,8 +6637,15 @@ async function renderStudioStandaloneHtmlWithPandoc(
6616
6637
  sourcePath?: string,
6617
6638
  options?: StudioHtmlRenderOptions,
6618
6639
  ): Promise<{ html: Buffer; warning?: string }> {
6619
- const delimitedMarkdown = isLatex ? null : formatStudioDelimitedTextAsMarkdown(markdown, editorLanguage);
6620
- const input = delimitedMarkdown ?? markdown;
6640
+ const pandocWorkingDir = resolveStudioPandocWorkingDir(resourcePath)
6641
+ ?? resolveStudioPandocWorkingDir(sourcePath ? dirname(sourcePath) : undefined);
6642
+ const latexPandocCompatibility = isLatex
6643
+ ? prepareStudioLatexForPandoc(markdown, pandocWorkingDir)
6644
+ : { source: markdown, omittedPackages: [] as Array<{ name: string; path: string }> };
6645
+ const latexPandocCompatibilityWarning = buildStudioLatexPandocCompatibilityWarning(latexPandocCompatibility.omittedPackages);
6646
+ const pandocCompatibleMarkdown = latexPandocCompatibility.source;
6647
+ const delimitedMarkdown = isLatex ? null : formatStudioDelimitedTextAsMarkdown(pandocCompatibleMarkdown, editorLanguage);
6648
+ const input = delimitedMarkdown ?? pandocCompatibleMarkdown;
6621
6649
  const effectiveEditorLanguage = delimitedMarkdown ? "markdown" : inferStudioPdfLanguage(input, editorLanguage);
6622
6650
  if (!isLatex && isLikelyStandaloneStudioHtml(input, effectiveEditorLanguage)) {
6623
6651
  return { html: Buffer.from(String(input ?? ""), "utf-8") };
@@ -6634,8 +6662,12 @@ async function renderStudioStandaloneHtmlWithPandoc(
6634
6662
  let renderedHtml = await renderStudioMarkdownWithPandoc(pdfPrepared.markdown, isLatex, resourcePath, sourcePath);
6635
6663
  renderedHtml = renderStudioPdfBlocksInHtml(renderedHtml, pdfPrepared.blocks, sourcePath, resourcePath);
6636
6664
  renderedHtml = applyStudioAnnotationPlaceholdersToHtml(renderedHtml, annotationPrepared.placeholders);
6665
+ renderedHtml = renderStudioLatexPandocCompatibilityWarningHtml(latexPandocCompatibilityWarning) + renderedHtml;
6637
6666
  const standaloneHtml = buildStudioStandaloneHtmlDocument(renderedHtml, resourcePath, options);
6638
- return { html: Buffer.from(standaloneHtml, "utf-8") };
6667
+ return {
6668
+ html: Buffer.from(standaloneHtml, "utf-8"),
6669
+ warning: latexPandocCompatibilityWarning,
6670
+ };
6639
6671
  }
6640
6672
 
6641
6673
  async function renderStudioLiteralTextPdf(text: string, title = "Studio export", options?: StudioPdfRenderOptions): Promise<Buffer> {
@@ -7024,19 +7056,26 @@ async function renderStudioPdfWithPandoc(
7024
7056
  ): Promise<{ pdf: Buffer; warning?: string }> {
7025
7057
  const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
7026
7058
  const pdfEngine = process.env.PANDOC_PDF_ENGINE?.trim() || "xelatex";
7059
+ const pandocWorkingDir = resolveStudioPandocWorkingDir(resourcePath)
7060
+ ?? resolveStudioPandocWorkingDir(sourcePath ? dirname(sourcePath) : undefined);
7061
+ const latexPandocCompatibility = isLatex
7062
+ ? prepareStudioLatexForPandoc(markdown, pandocWorkingDir)
7063
+ : { source: markdown, omittedPackages: [] as Array<{ name: string; path: string }> };
7064
+ const latexPandocCompatibilityWarning = buildStudioLatexPandocCompatibilityWarning(latexPandocCompatibility.omittedPackages);
7065
+ const pandocInputSource = latexPandocCompatibility.source;
7027
7066
  const latexSubfigurePdfTransform = isLatex
7028
- ? preprocessStudioLatexSubfiguresForPdf(markdown)
7029
- : { markdown, groups: [] };
7067
+ ? preprocessStudioLatexSubfiguresForPdf(pandocInputSource)
7068
+ : { markdown: pandocInputSource, groups: [] };
7030
7069
  const latexPdfSource = isLatex
7031
7070
  ? preprocessStudioLatexAlgorithmsForPdf(
7032
7071
  latexSubfigurePdfTransform.markdown,
7033
7072
  sourcePath,
7034
7073
  resourcePath,
7035
7074
  )
7036
- : markdown;
7075
+ : pandocInputSource;
7037
7076
  const sourceWithResolvedRefs = isLatex
7038
7077
  ? injectStudioLatexEquationTags(preprocessStudioLatexReferences(latexPdfSource, sourcePath, resourcePath), sourcePath, resourcePath)
7039
- : markdown;
7078
+ : pandocInputSource;
7040
7079
  const effectiveEditorLanguage = inferStudioPdfLanguage(sourceWithResolvedRefs, editorPdfLanguage);
7041
7080
  const pdfCalloutTransform = !isLatex && (!effectiveEditorLanguage || effectiveEditorLanguage === "markdown")
7042
7081
  ? preprocessStudioMarkdownCalloutsForPdf(sourceWithResolvedRefs)
@@ -7044,8 +7083,7 @@ async function renderStudioPdfWithPandoc(
7044
7083
  const pdfAlignedImageTransform = !isLatex && (!effectiveEditorLanguage || effectiveEditorLanguage === "markdown")
7045
7084
  ? preprocessStudioMarkdownImageAlignmentForPdf(pdfCalloutTransform.markdown)
7046
7085
  : { markdown: pdfCalloutTransform.markdown, blocks: [] as StudioPdfAlignedImageBlock[] };
7047
- const pandocWorkingDir = resolveStudioPandocWorkingDir(resourcePath);
7048
- const bibliographyArgs = buildStudioPandocBibliographyArgs(markdown, isLatex, resourcePath);
7086
+ const bibliographyArgs = buildStudioPandocBibliographyArgs(pandocInputSource, isLatex, resourcePath);
7049
7087
 
7050
7088
  const runPandocPdfExport = async (
7051
7089
  inputFormat: string,
@@ -7098,7 +7136,7 @@ async function renderStudioPdfWithPandoc(
7098
7136
  };
7099
7137
 
7100
7138
  if (isLatex && (latexSubfigurePdfTransform.groups.length > 0 || collectStudioInlineAnnotationMarkers(sourceWithResolvedRefs).length > 0)) {
7101
- return await renderStudioPdfFromGeneratedLatex(
7139
+ const rendered = await renderStudioPdfFromGeneratedLatex(
7102
7140
  sourceWithResolvedRefs,
7103
7141
  pandocCommand,
7104
7142
  pdfEngine,
@@ -7114,6 +7152,10 @@ async function renderStudioPdfWithPandoc(
7114
7152
  "",
7115
7153
  themeStyle,
7116
7154
  );
7155
+ return {
7156
+ pdf: rendered.pdf,
7157
+ warning: combineStudioWarnings(latexPandocCompatibilityWarning, rendered.warning),
7158
+ };
7117
7159
  }
7118
7160
 
7119
7161
  if (!isLatex && effectiveEditorLanguage === "diff") {
@@ -7187,7 +7229,10 @@ async function renderStudioPdfWithPandoc(
7187
7229
  themeStyle,
7188
7230
  );
7189
7231
  await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
7190
- return { pdf: rendered.pdf, warning: mermaidPrepared.warning ?? rendered.warning };
7232
+ return {
7233
+ pdf: rendered.pdf,
7234
+ warning: combineStudioWarnings(latexPandocCompatibilityWarning, mermaidPrepared.warning, rendered.warning),
7235
+ };
7191
7236
  }
7192
7237
 
7193
7238
  const hasYamlHeaderIncludesForPdf = !isLatex && hasStudioYamlHeaderIncludes(markdownForPdf);
@@ -7222,7 +7267,10 @@ async function renderStudioPdfWithPandoc(
7222
7267
  throw new Error(`pandoc PDF export failed with exit code ${pandocResult.code}${stderr ? `: ${stderr}` : ""}${hint}`);
7223
7268
  }
7224
7269
 
7225
- return { pdf: await readFile(outputPath), warning: mermaidPrepared.warning };
7270
+ return {
7271
+ pdf: await readFile(outputPath),
7272
+ warning: combineStudioWarnings(latexPandocCompatibilityWarning, mermaidPrepared.warning),
7273
+ };
7226
7274
  } finally {
7227
7275
  await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
7228
7276
  }
@@ -7308,16 +7356,31 @@ function respondPdfFile(req: IncomingMessage, res: ServerResponse, filePath: str
7308
7356
  return;
7309
7357
  }
7310
7358
 
7311
- const pdf = readFileSync(filePath);
7312
- res.writeHead(200, {
7359
+ const stats = statSync(filePath);
7360
+ const etag = `W/"${[
7361
+ stats.size,
7362
+ Math.trunc(stats.mtimeMs),
7363
+ Math.trunc(stats.ctimeMs),
7364
+ stats.ino,
7365
+ ].map((value) => Number(value).toString(16)).join("-")}"`;
7366
+ const commonHeaders = {
7313
7367
  "Content-Type": "application/pdf",
7314
- "Content-Length": String(pdf.length),
7315
7368
  "Content-Disposition": `inline; filename="${basename(filePath).replace(/["\\]/g, "") || "document.pdf"}"`,
7316
7369
  "Cache-Control": "no-store",
7317
7370
  "X-Content-Type-Options": "nosniff",
7318
7371
  "Cross-Origin-Resource-Policy": "same-origin",
7319
- });
7320
- res.end(method === "HEAD" ? undefined : pdf);
7372
+ "ETag": etag,
7373
+ "Last-Modified": stats.mtime.toUTCString(),
7374
+ };
7375
+ if (method === "HEAD") {
7376
+ res.writeHead(200, { ...commonHeaders, "Content-Length": String(stats.size) });
7377
+ res.end();
7378
+ return;
7379
+ }
7380
+
7381
+ const pdf = readFileSync(filePath);
7382
+ res.writeHead(200, { ...commonHeaders, "Content-Length": String(pdf.length) });
7383
+ res.end(pdf);
7321
7384
  }
7322
7385
 
7323
7386
  function respondHtmlPreviewResourceJson(req: IncomingMessage, res: ServerResponse, filePath: string, mimeType: string): void {
@@ -7345,7 +7408,7 @@ function sanitizeStudioPreviewBlockLine(value: string): string {
7345
7408
  return String(value || "").replace(/[\r\n]+/g, " ").trim();
7346
7409
  }
7347
7410
 
7348
- function buildStudioLocalResourcePreviewDocument(resource: StudioLocalPreviewResource): InitialStudioDocument {
7411
+ function buildStudioLocalResourcePreviewDocument(resource: StudioLocalPreviewResource, options?: { watchPdf?: boolean }): InitialStudioDocument {
7349
7412
  const label = basename(resource.filePath) || resource.label || "local preview";
7350
7413
  const resourcePath = resource.label || basename(resource.filePath) || resource.filePath;
7351
7414
  const title = sanitizeStudioPreviewBlockLine(label);
@@ -7354,6 +7417,8 @@ function buildStudioLocalResourcePreviewDocument(resource: StudioLocalPreviewRes
7354
7417
  text = "```studio-pdf\n"
7355
7418
  + `path: ${sanitizeStudioPreviewBlockLine(resourcePath)}\n`
7356
7419
  + `title: ${title || "PDF preview"}\n`
7420
+ + (resource.page ? `page: ${resource.page}\n` : "")
7421
+ + (options?.watchPdf ? "watch: true\n" : "")
7357
7422
  + "height: 820\n"
7358
7423
  + "```\n";
7359
7424
  } else if (resource.kind === "image") {
@@ -7532,6 +7597,64 @@ function revealStudioLocalFile(filePath: string): { ok: true; message: string }
7532
7597
  return { ok: true, message: process.platform === "linux" ? "Opened containing folder." : "Revealed resource in file manager." };
7533
7598
  }
7534
7599
 
7600
+ async function handleImportStudioFileCopyRequest(req: IncomingMessage, res: ServerResponse, studioCwd: string): Promise<void> {
7601
+ const method = (req.method ?? "GET").toUpperCase();
7602
+ if (method !== "POST") {
7603
+ res.setHeader("Allow", "POST");
7604
+ respondJson(res, 405, { ok: false, error: "Method not allowed. Use POST." });
7605
+ return;
7606
+ }
7607
+
7608
+ const rawBody = await readRequestBody(req, REQUEST_BODY_MAX_BYTES);
7609
+ let payload: Record<string, unknown> = {};
7610
+ try {
7611
+ payload = rawBody ? JSON.parse(rawBody) : {};
7612
+ } catch {
7613
+ respondJson(res, 400, { ok: false, error: "Invalid JSON body." });
7614
+ return;
7615
+ }
7616
+
7617
+ const requestedPath = typeof payload.path === "string" ? payload.path : "";
7618
+ const resolved = resolveStudioPath(requestedPath, studioCwd);
7619
+ if (resolved.ok === false) {
7620
+ respondJson(res, 400, { ok: false, error: resolved.message });
7621
+ return;
7622
+ }
7623
+
7624
+ try {
7625
+ const stats = statSync(resolved.resolved);
7626
+ if (!stats.isFile()) {
7627
+ respondJson(res, 400, { ok: false, error: `Path is not a file: ${resolved.label}` });
7628
+ return;
7629
+ }
7630
+ if (stats.size > STUDIO_IMPORT_FILE_MAX_BYTES) {
7631
+ respondJson(res, 413, {
7632
+ ok: false,
7633
+ error: `File is too large to import into Studio (${stats.size} bytes; limit ${STUDIO_IMPORT_FILE_MAX_BYTES} bytes).`,
7634
+ });
7635
+ return;
7636
+ }
7637
+ } catch (error) {
7638
+ respondJson(res, 404, {
7639
+ ok: false,
7640
+ error: `Could not access file: ${resolved.label} (${error instanceof Error ? error.message : String(error)})`,
7641
+ });
7642
+ return;
7643
+ }
7644
+
7645
+ const file = readStudioFile(resolved.resolved, studioCwd);
7646
+ if (file.ok === false) {
7647
+ respondJson(res, 400, { ok: false, error: file.message });
7648
+ return;
7649
+ }
7650
+ respondJson(res, 200, {
7651
+ ok: true,
7652
+ text: file.text,
7653
+ filename: basename(file.resolvedPath),
7654
+ resolvedPath: file.resolvedPath,
7655
+ });
7656
+ }
7657
+
7535
7658
  async function handleRevealLocalPreviewResourceRequest(req: IncomingMessage, res: ServerResponse, studioCwd: string): Promise<void> {
7536
7659
  const method = (req.method ?? "GET").toUpperCase();
7537
7660
  if (method !== "POST") {
@@ -7567,6 +7690,50 @@ async function handleRevealLocalPreviewResourceRequest(req: IncomingMessage, res
7567
7690
  }
7568
7691
  }
7569
7692
 
7693
+ async function handleOpenLocalPreviewResourceRequest(req: IncomingMessage, res: ServerResponse, studioCwd: string): Promise<void> {
7694
+ const method = (req.method ?? "GET").toUpperCase();
7695
+ if (method !== "POST") {
7696
+ res.setHeader("Allow", "POST");
7697
+ respondJson(res, 405, { ok: false, error: "Method not allowed. Use POST." });
7698
+ return;
7699
+ }
7700
+ if (isSshSession()) {
7701
+ respondJson(res, 409, { ok: false, error: "Cannot open a system PDF viewer from an SSH/headless Studio session. Copy the path instead." });
7702
+ return;
7703
+ }
7704
+
7705
+ const rawBody = await readRequestBody(req, REQUEST_BODY_MAX_BYTES);
7706
+ let payload: Record<string, unknown> = {};
7707
+ try {
7708
+ payload = rawBody ? JSON.parse(rawBody) : {};
7709
+ } catch {
7710
+ respondJson(res, 400, { ok: false, error: "Invalid JSON body." });
7711
+ return;
7712
+ }
7713
+
7714
+ try {
7715
+ const resource = resolveStudioLocalPreviewResourcePath(
7716
+ typeof payload.path === "string" ? payload.path : "",
7717
+ typeof payload.sourcePath === "string" ? payload.sourcePath : undefined,
7718
+ typeof payload.resourceDir === "string" ? payload.resourceDir : undefined,
7719
+ studioCwd,
7720
+ );
7721
+ if (resource.kind !== "pdf") {
7722
+ respondJson(res, 400, { ok: false, error: "Only local PDF previews can be opened in the system PDF viewer." });
7723
+ return;
7724
+ }
7725
+ await openPathInDefaultViewer(resource.filePath);
7726
+ respondJson(res, 200, {
7727
+ ok: true,
7728
+ message: "Opened PDF in the system viewer.",
7729
+ path: resource.filePath,
7730
+ label: resource.label,
7731
+ });
7732
+ } catch (error) {
7733
+ respondJson(res, 404, { ok: false, error: `Local PDF unavailable: ${error instanceof Error ? error.message : String(error)}` });
7734
+ }
7735
+ }
7736
+
7570
7737
  function openPathInDefaultViewer(path: string): Promise<void> {
7571
7738
  const openCommand =
7572
7739
  process.platform === "darwin"
@@ -10120,7 +10287,7 @@ function buildStudioRelativeUrl(
10120
10287
  mode: StudioUiMode = "full",
10121
10288
  doc?: InitialStudioDocument | null,
10122
10289
  docId?: string,
10123
- options?: { skipWorkspaceRestore?: boolean },
10290
+ options?: StudioUrlOptions,
10124
10291
  ): string {
10125
10292
  const params = new URLSearchParams({ token });
10126
10293
  if (mode !== "full") params.set("mode", mode);
@@ -10131,6 +10298,7 @@ function buildStudioRelativeUrl(
10131
10298
  if (doc?.draftId) params.set("draftId", doc.draftId);
10132
10299
  if (doc?.resourceDir) params.set("resourceDir", doc.resourceDir);
10133
10300
  if (options?.skipWorkspaceRestore) params.set("skipWorkspaceRestore", "1");
10301
+ if (options?.paneFocus) params.set("paneFocus", options.paneFocus);
10134
10302
  return `/?${params.toString()}`;
10135
10303
  }
10136
10304
 
@@ -10140,7 +10308,7 @@ function buildStudioUrl(
10140
10308
  mode: StudioUiMode = "full",
10141
10309
  doc?: InitialStudioDocument | null,
10142
10310
  docId?: string,
10143
- options?: { skipWorkspaceRestore?: boolean },
10311
+ options?: StudioUrlOptions,
10144
10312
  ): string {
10145
10313
  return `http://127.0.0.1:${port}${buildStudioRelativeUrl(token, mode, doc, docId, options)}`;
10146
10314
  }
@@ -10149,16 +10317,18 @@ interface StudioLaunchFlags {
10149
10317
  args: string;
10150
10318
  openRemoteBrowser: boolean;
10151
10319
  noBrowser: boolean;
10320
+ watchPdf: boolean;
10152
10321
  port?: number;
10153
10322
  error?: string;
10154
10323
  }
10155
10324
 
10156
10325
  function parseStudioLaunchOpenFlags(rawArgs: string): StudioLaunchFlags {
10157
10326
  const parsed = tokenizeStudioCommandArgs(rawArgs);
10158
- if (parsed.error) return { args: rawArgs, openRemoteBrowser: false, noBrowser: false, error: parsed.error };
10327
+ if (parsed.error) return { args: rawArgs, openRemoteBrowser: false, noBrowser: false, watchPdf: false, error: parsed.error };
10159
10328
  const remaining: string[] = [];
10160
10329
  let openRemoteBrowser = false;
10161
10330
  let noBrowser = false;
10331
+ let watchPdf = false;
10162
10332
  let port: number | undefined;
10163
10333
  for (let i = 0; i < parsed.tokens.length; i += 1) {
10164
10334
  const token = parsed.tokens[i]!;
@@ -10170,14 +10340,18 @@ function parseStudioLaunchOpenFlags(rawArgs: string): StudioLaunchFlags {
10170
10340
  noBrowser = true;
10171
10341
  continue;
10172
10342
  }
10343
+ if (token === "--watch" || token === "--auto-refresh") {
10344
+ watchPdf = true;
10345
+ continue;
10346
+ }
10173
10347
  if (token === "--port" || token.startsWith("--port=")) {
10174
10348
  const rawPort = token.startsWith("--port=") ? token.slice("--port=".length) : parsed.tokens[++i];
10175
10349
  if (!rawPort) {
10176
- return { args: rawArgs, openRemoteBrowser, noBrowser, error: "Missing value for --port." };
10350
+ return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, error: "Missing value for --port." };
10177
10351
  }
10178
10352
  const requestedPort = Number(rawPort);
10179
10353
  if (!Number.isInteger(requestedPort) || requestedPort < 1 || requestedPort > 65535) {
10180
- return { args: rawArgs, openRemoteBrowser, noBrowser, error: `Invalid --port value: ${rawPort}. Use an integer from 1 to 65535.` };
10354
+ return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, error: `Invalid --port value: ${rawPort}. Use an integer from 1 to 65535.` };
10181
10355
  }
10182
10356
  port = requestedPort;
10183
10357
  continue;
@@ -10185,9 +10359,9 @@ function parseStudioLaunchOpenFlags(rawArgs: string): StudioLaunchFlags {
10185
10359
  remaining.push(token);
10186
10360
  }
10187
10361
  if (openRemoteBrowser && noBrowser) {
10188
- return { args: rawArgs, openRemoteBrowser, noBrowser, port, error: "Use either --no-browser or --open-browser, not both." };
10362
+ return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, port, error: "Use either --no-browser or --open-browser, not both." };
10189
10363
  }
10190
- return { args: remaining.join(" "), openRemoteBrowser, noBrowser, port };
10364
+ return { args: remaining.join(" "), openRemoteBrowser, noBrowser, watchPdf, port };
10191
10365
  }
10192
10366
 
10193
10367
  function shouldAutoOpenStudioBrowser(options?: { openRemoteBrowser?: boolean; noBrowser?: boolean }): boolean {
@@ -10589,7 +10763,8 @@ ${cssVarsBlock}
10589
10763
  <button id="saveOverBtn" type="button" title="Overwrite current file with editor content. Shortcut: Cmd/Ctrl+S.">Save editor</button>
10590
10764
  <button id="refreshFromDiskBtn" type="button" title="Reload the current file-backed document from disk.">Refresh from disk</button>
10591
10765
  <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>
10592
- <label class="file-label" title="Browser import: load a selected text file as a detached copy. Use Save editor as… to attach this copy to a file path and make it file-backed, or use the Files view to open a refreshable file-backed document directly.">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>
10766
+ <button id="importFileBtn" type="button" title="Import a file as an editable copy.">Import file copy…</button>
10767
+ <input id="fileInput" class="file-input-hidden" type="file" tabindex="-1" aria-hidden="true" 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" />
10593
10768
  <button id="getEditorBtn" type="button" title="Load the current terminal editor draft into Studio.">Load from pi editor</button>
10594
10769
  <button id="zenModeBtn" class="zen-mode-btn" type="button" title="Hide secondary Studio controls. Shortcut: F9.">Zen</button>
10595
10770
  </div>
@@ -10918,6 +11093,7 @@ ${cssVarsBlock}
10918
11093
  <div><dt>Alt/Option+=</dt><dd>Increase the active pane's text size when not editing text</dd></div>
10919
11094
  <div><dt>Alt/Option+-</dt><dd>Decrease the active pane's text size when not editing text</dd></div>
10920
11095
  <div><dt>Alt/Option+0</dt><dd>Reset the active pane's text size when not editing text</dd></div>
11096
+ <div><dt>Cmd/Ctrl+Alt+R</dt><dd>Refresh the focused or visible PDF preview from disk</dd></div>
10921
11097
  </dl>
10922
11098
  </section>
10923
11099
  <section class="shortcuts-group">
@@ -14909,6 +15085,19 @@ export default function (pi: ExtensionAPI) {
14909
15085
  return;
14910
15086
  }
14911
15087
 
15088
+ if (requestUrl.pathname === "/import-file-copy") {
15089
+ const token = requestUrl.searchParams.get("token") ?? "";
15090
+ if (token !== serverState.token) {
15091
+ respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
15092
+ return;
15093
+ }
15094
+
15095
+ void handleImportStudioFileCopyRequest(req, res, studioCwd).catch((error) => {
15096
+ respondJson(res, 500, { ok: false, error: `File import failed: ${error instanceof Error ? error.message : String(error)}` });
15097
+ });
15098
+ return;
15099
+ }
15100
+
14912
15101
  if (requestUrl.pathname === "/local-preview-link") {
14913
15102
  const token = requestUrl.searchParams.get("token") ?? "";
14914
15103
  if (token !== serverState.token) {
@@ -14945,6 +15134,19 @@ export default function (pi: ExtensionAPI) {
14945
15134
  return;
14946
15135
  }
14947
15136
 
15137
+ if (requestUrl.pathname === "/open-local-resource") {
15138
+ const token = requestUrl.searchParams.get("token") ?? "";
15139
+ if (token !== serverState.token) {
15140
+ respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
15141
+ return;
15142
+ }
15143
+
15144
+ void handleOpenLocalPreviewResourceRequest(req, res, studioCwd).catch((error) => {
15145
+ respondJson(res, 500, { ok: false, error: `Open in system viewer failed: ${error instanceof Error ? error.message : String(error)}` });
15146
+ });
15147
+ return;
15148
+ }
15149
+
14948
15150
  if (requestUrl.pathname === "/pdf-resource") {
14949
15151
  const token = requestUrl.searchParams.get("token") ?? "";
14950
15152
  if (token !== serverState.token) {
@@ -15554,10 +15756,11 @@ export default function (pi: ExtensionAPI) {
15554
15756
  const resolveStudioLaunchDocument = (
15555
15757
  trimmed: string,
15556
15758
  ctx: ExtensionCommandContext,
15557
- options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string },
15558
- ): InitialStudioDocument | null => {
15759
+ options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string; allowPdfPreview?: boolean; watchPdf?: boolean },
15760
+ ): StudioLaunchSelection | null => {
15559
15761
  const defaultSource = options?.defaultSource === "blank" ? "blank" : "last-response";
15560
15762
  const commandLabel = options?.commandLabel ?? "/studio";
15763
+ const selectDocument = (document: InitialStudioDocument): StudioLaunchSelection => ({ document, kind: "document" });
15561
15764
  const latestAssistant =
15562
15765
  extractLatestAssistantFromEntries(ctx.sessionManager.getBranch())
15563
15766
  ?? extractLatestAssistantFromEntries(ctx.sessionManager.getEntries())
@@ -15566,51 +15769,51 @@ export default function (pi: ExtensionAPI) {
15566
15769
 
15567
15770
  if (!trimmed) {
15568
15771
  if (defaultSource === "last-response" && latestAssistant) {
15569
- return {
15772
+ return selectDocument({
15570
15773
  text: latestAssistant,
15571
15774
  label: "last model response",
15572
15775
  source: "last-response",
15573
15776
  draftId: createStudioDraftId(),
15574
15777
  resourceDir: ctx.cwd,
15575
- };
15778
+ });
15576
15779
  }
15577
- return {
15780
+ return selectDocument({
15578
15781
  text: "",
15579
15782
  label: "blank",
15580
15783
  source: "blank",
15581
15784
  draftId: createStudioDraftId(),
15582
15785
  resourceDir: ctx.cwd,
15583
- };
15786
+ });
15584
15787
  }
15585
15788
 
15586
15789
  if (trimmed === "--blank" || trimmed === "blank") {
15587
- return {
15790
+ return selectDocument({
15588
15791
  text: "",
15589
15792
  label: "blank",
15590
15793
  source: "blank",
15591
15794
  draftId: createStudioDraftId(),
15592
15795
  resourceDir: ctx.cwd,
15593
- };
15796
+ });
15594
15797
  }
15595
15798
 
15596
15799
  if (trimmed === "--last" || trimmed === "last") {
15597
15800
  if (!latestAssistant) {
15598
15801
  ctx.ui.notify("No assistant response found; opening blank studio.", "warning");
15599
- return {
15802
+ return selectDocument({
15600
15803
  text: "",
15601
15804
  label: "blank",
15602
15805
  source: "blank",
15603
15806
  draftId: createStudioDraftId(),
15604
15807
  resourceDir: ctx.cwd,
15605
- };
15808
+ });
15606
15809
  }
15607
- return {
15810
+ return selectDocument({
15608
15811
  text: latestAssistant,
15609
15812
  label: "last model response",
15610
15813
  source: "last-response",
15611
15814
  draftId: createStudioDraftId(),
15612
15815
  resourceDir: ctx.cwd,
15613
- };
15816
+ });
15614
15817
  }
15615
15818
 
15616
15819
  if (trimmed.startsWith("-")) {
@@ -15624,6 +15827,36 @@ export default function (pi: ExtensionAPI) {
15624
15827
  return null;
15625
15828
  }
15626
15829
 
15830
+ const pdfTarget = options?.allowPdfPreview ? parseStudioPdfLaunchTarget(normalizePathInput(pathArg)) : null;
15831
+ if (pdfTarget) {
15832
+ const resolved = resolveStudioPath(pdfTarget.path, ctx.cwd);
15833
+ if (resolved.ok === false) {
15834
+ ctx.ui.notify(resolved.message, "error");
15835
+ return null;
15836
+ }
15837
+ try {
15838
+ const resource = resolveStudioLocalPreviewResourcePath(
15839
+ pdfTarget.page ? `${resolved.resolved}#page=${pdfTarget.page}` : resolved.resolved,
15840
+ resolved.resolved,
15841
+ dirname(resolved.resolved),
15842
+ ctx.cwd,
15843
+ );
15844
+ if (resource.kind !== "pdf") throw new Error("Only local .pdf files can open in the Studio PDF viewer.");
15845
+ return {
15846
+ document: buildStudioLocalResourcePreviewDocument(resource, { watchPdf: options?.watchPdf }),
15847
+ kind: "pdf-preview",
15848
+ mode: "editor-only",
15849
+ transient: true,
15850
+ skipWorkspaceRestore: true,
15851
+ paneFocus: "right",
15852
+ resourcePath: resource.filePath,
15853
+ };
15854
+ } catch (error) {
15855
+ ctx.ui.notify(`Could not open PDF preview: ${error instanceof Error ? error.message : String(error)}`, "error");
15856
+ return null;
15857
+ }
15858
+ }
15859
+
15627
15860
  const file = readStudioFile(pathArg, ctx.cwd);
15628
15861
  if (file.ok === false) {
15629
15862
  ctx.ui.notify(file.message, "error");
@@ -15637,13 +15870,13 @@ export default function (pi: ExtensionAPI) {
15637
15870
  );
15638
15871
  }
15639
15872
 
15640
- return {
15873
+ return selectDocument({
15641
15874
  text: file.text,
15642
15875
  label: file.label,
15643
15876
  source: "file",
15644
15877
  path: file.resolvedPath,
15645
15878
  resourceDir: ctx.cwd,
15646
- };
15879
+ });
15647
15880
  };
15648
15881
 
15649
15882
  const resolveLastModelResponseForExport = (ctx: ExtensionContext): { markdown: string } | null => {
@@ -15900,7 +16133,7 @@ export default function (pi: ExtensionAPI) {
15900
16133
  trimmed: string,
15901
16134
  ctx: ExtensionCommandContext,
15902
16135
  mode: StudioUiMode,
15903
- options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string; replaceExistingFull?: boolean },
16136
+ options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string; replaceExistingFull?: boolean; allowPdfPreview?: boolean; watchPdf?: boolean },
15904
16137
  ) => {
15905
16138
  const launchOpenFlags = parseStudioLaunchOpenFlags(trimmed);
15906
16139
  if (launchOpenFlags.error) {
@@ -15911,7 +16144,17 @@ export default function (pi: ExtensionAPI) {
15911
16144
  if (serverState && launchOpenFlags.port && serverState.port !== launchOpenFlags.port) {
15912
16145
  ctx.ui.notify(`Studio server is already running on port ${serverState.port}; requested port ${launchOpenFlags.port}. Use /studio --stop, then restart Studio with --port ${launchOpenFlags.port} to change it.`, "warning");
15913
16146
  }
15914
- if (mode === "full" && hasConnectedFullStudioView()) {
16147
+
16148
+ const parsedLaunchPath = options?.allowPdfPreview ? parsePathArgument(launchArgs) : null;
16149
+ const launchesPdfPreview = parsedLaunchPath
16150
+ ? Boolean(parseStudioPdfLaunchTarget(normalizePathInput(parsedLaunchPath)))
16151
+ : false;
16152
+ if (launchOpenFlags.watchPdf && !launchesPdfPreview) {
16153
+ ctx.ui.notify("--watch requires a local PDF path, for example: /studio --watch main.pdf", "error");
16154
+ return;
16155
+ }
16156
+ const requestedLaunchMode: StudioUiMode = launchesPdfPreview ? "editor-only" : mode;
16157
+ if (requestedLaunchMode === "full" && hasConnectedFullStudioView()) {
15915
16158
  if (options?.replaceExistingFull) {
15916
16159
  closeStudioClientsByMode("full", 4001, "Full Studio replaced");
15917
16160
  } else {
@@ -15942,9 +16185,14 @@ export default function (pi: ExtensionAPI) {
15942
16185
  // ignore theme read errors
15943
16186
  }
15944
16187
 
15945
- const selected = resolveStudioLaunchDocument(launchArgs, ctx, options);
15946
- if (!selected) return;
15947
- initialStudioDocument = selected;
16188
+ const selection = resolveStudioLaunchDocument(launchArgs, ctx, {
16189
+ ...options,
16190
+ watchPdf: launchOpenFlags.watchPdf,
16191
+ });
16192
+ if (!selection) return;
16193
+ const selected = selection.document;
16194
+ const launchMode = selection.mode ?? requestedLaunchMode;
16195
+ if (!selection.transient) initialStudioDocument = selected;
15948
16196
 
15949
16197
  let state: StudioServerState;
15950
16198
  try {
@@ -15955,10 +16203,16 @@ export default function (pi: ExtensionAPI) {
15955
16203
  ctx.ui.notify(`Failed to start Studio server${portText}: ${message}`, "error");
15956
16204
  return;
15957
16205
  }
15958
- const url = buildStudioUrl(state.port, state.token, mode, selected);
16206
+ const docId = selection.transient ? storeTransientStudioDocument(selected) : undefined;
16207
+ const url = buildStudioUrl(state.port, state.token, launchMode, selected, docId, {
16208
+ skipWorkspaceRestore: selection.skipWorkspaceRestore,
16209
+ paneFocus: selection.paneFocus,
16210
+ });
15959
16211
  const tunnelHint = buildStudioSshTunnelHint(state.port, url)
15960
16212
  ?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(state.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
15961
- const openedLabel = mode === "editor-only" ? "pi Studio editor-only view" : "pi Studio";
16213
+ const openedLabel = selection.kind === "pdf-preview"
16214
+ ? "pi Studio PDF preview"
16215
+ : (launchMode === "editor-only" ? "pi Studio editor-only view" : "pi Studio");
15962
16216
 
15963
16217
  const shouldOpenBrowser = shouldAutoOpenStudioBrowser({
15964
16218
  openRemoteBrowser: launchOpenFlags.openRemoteBrowser,
@@ -15970,7 +16224,10 @@ export default function (pi: ExtensionAPI) {
15970
16224
  ctx.ui.notify(`${openedLabel} is ready. Browser auto-open was skipped because ${skipReason}.`, "info");
15971
16225
  } else {
15972
16226
  await openStudioUrlInBrowser(url);
15973
- if (selected.source === "file") {
16227
+ if (selection.kind === "pdf-preview") {
16228
+ const watchLabel = launchOpenFlags.watchPdf ? " (auto-refresh on)" : "";
16229
+ ctx.ui.notify(`Opened ${openedLabel}${watchLabel}: ${selection.resourcePath ?? selected.label}`, "info");
16230
+ } else if (selected.source === "file") {
15974
16231
  ctx.ui.notify(`Opened ${openedLabel} with file loaded: ${selected.label}`, "info");
15975
16232
  } else if (selected.source === "last-response") {
15976
16233
  ctx.ui.notify(`Opened ${openedLabel} with last model response (${selected.text.length} chars).`, "info");
@@ -15992,7 +16249,7 @@ export default function (pi: ExtensionAPI) {
15992
16249
  };
15993
16250
 
15994
16251
  pi.registerCommand("studio", {
15995
- description: "Open pi Studio browser UI (/studio, /studio <file>, /studio --blank, /studio --last, /studio --no-browser, /studio --port <port>)",
16252
+ description: "Open pi Studio browser UI or a PDF preview (/studio, /studio <file>, /studio --watch <pdf>, /studio --blank, /studio --last, /studio --no-browser)",
15996
16253
  handler: async (args: string, ctx: ExtensionCommandContext) => {
15997
16254
  const trimmed = args.trim();
15998
16255
 
@@ -16022,7 +16279,8 @@ export default function (pi: ExtensionAPI) {
16022
16279
  ctx.ui.notify(
16023
16280
  "Usage: /studio [path|--blank|--last]\n"
16024
16281
  + " /studio Open studio with last model response (fallback: blank)\n"
16025
- + " /studio <path> Open studio with file preloaded\n"
16282
+ + " /studio <path> Open a text file in Studio, or a PDF in a read-only companion preview\n"
16283
+ + " /studio --watch <pdf> Open a PDF with auto-refresh enabled\n"
16026
16284
  + " /studio --blank Open with blank editor\n"
16027
16285
  + " /studio --last Open with last model response\n"
16028
16286
  + " /studio --no-browser Print the Studio URL without opening a browser\n"
@@ -16030,7 +16288,7 @@ export default function (pi: ExtensionAPI) {
16030
16288
  + " /studio --open-remote Over SSH, open the remote browser anyway\n"
16031
16289
  + " /studio --status Show studio status\n"
16032
16290
  + " /studio --stop Stop studio server\n"
16033
- + " Note: only one full /studio view is allowed per Pi session.\n"
16291
+ + " Note: only one full /studio view is allowed per Pi session; PDF previews open as companions.\n"
16034
16292
  + " /studio-replace [path] Replace the current full Studio view with a new one\n"
16035
16293
  + " /studio-editor-only [path] Open another Studio tab in editor-only mode\n"
16036
16294
  + " /studio-current <path> Load a file into currently open Studio tab(s)\n"
@@ -16041,7 +16299,7 @@ export default function (pi: ExtensionAPI) {
16041
16299
  return;
16042
16300
  }
16043
16301
 
16044
- await openStudioView(trimmed, ctx, "full", { defaultSource: "last-response", commandLabel: "/studio" });
16302
+ await openStudioView(trimmed, ctx, "full", { defaultSource: "last-response", commandLabel: "/studio", allowPdfPreview: true });
16045
16303
  },
16046
16304
  });
16047
16305
 
@@ -16073,14 +16331,15 @@ export default function (pi: ExtensionAPI) {
16073
16331
  });
16074
16332
 
16075
16333
  pi.registerCommand("studio-editor-only", {
16076
- description: "Open pi Studio in editor-only mode (/studio-editor-only, /studio-editor-only <file>, /studio-editor-only --no-browser)",
16334
+ description: "Open pi Studio in editor-only mode or preview a PDF (/studio-editor-only, /studio-editor-only <file>, /studio-editor-only --watch <pdf>)",
16077
16335
  handler: async (args: string, ctx: ExtensionCommandContext) => {
16078
16336
  const trimmed = args.trim();
16079
16337
  if (trimmed === "help" || trimmed === "--help" || trimmed === "-h") {
16080
16338
  ctx.ui.notify(
16081
16339
  "Usage: /studio-editor-only [path|--blank|--last]\n"
16082
16340
  + " /studio-editor-only Open an editor-only Studio view (default: blank editor)\n"
16083
- + " /studio-editor-only <path> Open an editor-only Studio view with file preloaded\n"
16341
+ + " /studio-editor-only <path> Open a text file for editing, or a PDF in a read-only preview\n"
16342
+ + " /studio-editor-only --watch <pdf> Open a PDF with auto-refresh enabled\n"
16084
16343
  + " /studio-editor-only --blank Open with blank editor\n"
16085
16344
  + " /studio-editor-only --last Open with last model response loaded into the editor\n"
16086
16345
  + " /studio-editor-only --no-browser Print URL without opening a browser\n"
@@ -16091,7 +16350,7 @@ export default function (pi: ExtensionAPI) {
16091
16350
  return;
16092
16351
  }
16093
16352
 
16094
- await openStudioView(trimmed, ctx, "editor-only", { defaultSource: "blank", commandLabel: "/studio-editor-only" });
16353
+ await openStudioView(trimmed, ctx, "editor-only", { defaultSource: "blank", commandLabel: "/studio-editor-only", allowPdfPreview: true });
16095
16354
  },
16096
16355
  });
16097
16356