pi-studio 0.9.48 → 0.9.50

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,6 +28,7 @@ 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";
33
34
  import { prepareStudioLatexForPandoc } from "./shared/studio-latex-pandoc-compat.js";
@@ -276,6 +277,21 @@ interface InitialStudioDocument {
276
277
  resourceDir?: string;
277
278
  }
278
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
+
279
295
  type PersistedStudioReviewNoteAnchorKind = "source" | "html-selection" | "html-element" | "html-page";
280
296
 
281
297
  interface PersistedStudioReviewNote {
@@ -2949,35 +2965,6 @@ function decodeStudioHtmlPreviewResourcePath(resourcePath: string): string {
2949
2965
  }
2950
2966
  }
2951
2967
 
2952
- function parseStudioLocalPreviewResourcePage(resourcePath: string): number | null {
2953
- const raw = String(resourcePath || "");
2954
- const parts: string[] = [];
2955
- const queryIndex = raw.indexOf("?");
2956
- if (queryIndex >= 0) {
2957
- const queryEnd = raw.indexOf("#", queryIndex);
2958
- parts.push(raw.slice(queryIndex + 1, queryEnd >= 0 ? queryEnd : raw.length));
2959
- }
2960
- const hashIndex = raw.indexOf("#");
2961
- if (hashIndex >= 0) parts.push(raw.slice(hashIndex + 1));
2962
- for (const part of parts) {
2963
- try {
2964
- const params = new URLSearchParams(part);
2965
- const rawPage = params.get("page") || params.get("p");
2966
- if (rawPage) {
2967
- const page = Number.parseInt(rawPage, 10);
2968
- if (Number.isFinite(page) && page > 0) return page;
2969
- }
2970
- } catch {
2971
- const match = part.match(/(?:^|[&;])page=(\d+)/i) || part.match(/^page=(\d+)$/i);
2972
- if (match && match[1]) {
2973
- const page = Number.parseInt(match[1], 10);
2974
- if (Number.isFinite(page) && page > 0) return page;
2975
- }
2976
- }
2977
- }
2978
- return null;
2979
- }
2980
-
2981
2968
  function getStudioLocalPreviewResourceKind(extension: string, filePathOrName?: string): StudioLocalPreviewResourceKind {
2982
2969
  const ext = extension.toLowerCase();
2983
2970
  const name = basename(String(filePathOrName || "")).toLowerCase();
@@ -3022,7 +3009,7 @@ function resolveStudioLocalPreviewResourcePath(
3022
3009
  label: rel && rel !== "" ? rel : basename(candidateReal),
3023
3010
  extension,
3024
3011
  kind: getStudioLocalPreviewResourceKind(extension, candidateReal),
3025
- page: parseStudioLocalPreviewResourcePage(rawPath),
3012
+ page: parseStudioLocalPreviewPage(rawPath),
3026
3013
  resourceDir: boundaryReal,
3027
3014
  };
3028
3015
  }
@@ -7369,16 +7356,31 @@ function respondPdfFile(req: IncomingMessage, res: ServerResponse, filePath: str
7369
7356
  return;
7370
7357
  }
7371
7358
 
7372
- const pdf = readFileSync(filePath);
7373
- 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 = {
7374
7367
  "Content-Type": "application/pdf",
7375
- "Content-Length": String(pdf.length),
7376
7368
  "Content-Disposition": `inline; filename="${basename(filePath).replace(/["\\]/g, "") || "document.pdf"}"`,
7377
7369
  "Cache-Control": "no-store",
7378
7370
  "X-Content-Type-Options": "nosniff",
7379
7371
  "Cross-Origin-Resource-Policy": "same-origin",
7380
- });
7381
- 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);
7382
7384
  }
7383
7385
 
7384
7386
  function respondHtmlPreviewResourceJson(req: IncomingMessage, res: ServerResponse, filePath: string, mimeType: string): void {
@@ -7406,7 +7408,7 @@ function sanitizeStudioPreviewBlockLine(value: string): string {
7406
7408
  return String(value || "").replace(/[\r\n]+/g, " ").trim();
7407
7409
  }
7408
7410
 
7409
- function buildStudioLocalResourcePreviewDocument(resource: StudioLocalPreviewResource): InitialStudioDocument {
7411
+ function buildStudioLocalResourcePreviewDocument(resource: StudioLocalPreviewResource, options?: { watchPdf?: boolean }): InitialStudioDocument {
7410
7412
  const label = basename(resource.filePath) || resource.label || "local preview";
7411
7413
  const resourcePath = resource.label || basename(resource.filePath) || resource.filePath;
7412
7414
  const title = sanitizeStudioPreviewBlockLine(label);
@@ -7415,6 +7417,8 @@ function buildStudioLocalResourcePreviewDocument(resource: StudioLocalPreviewRes
7415
7417
  text = "```studio-pdf\n"
7416
7418
  + `path: ${sanitizeStudioPreviewBlockLine(resourcePath)}\n`
7417
7419
  + `title: ${title || "PDF preview"}\n`
7420
+ + (resource.page ? `page: ${resource.page}\n` : "")
7421
+ + (options?.watchPdf ? "watch: true\n" : "")
7418
7422
  + "height: 820\n"
7419
7423
  + "```\n";
7420
7424
  } else if (resource.kind === "image") {
@@ -10283,7 +10287,7 @@ function buildStudioRelativeUrl(
10283
10287
  mode: StudioUiMode = "full",
10284
10288
  doc?: InitialStudioDocument | null,
10285
10289
  docId?: string,
10286
- options?: { skipWorkspaceRestore?: boolean },
10290
+ options?: StudioUrlOptions,
10287
10291
  ): string {
10288
10292
  const params = new URLSearchParams({ token });
10289
10293
  if (mode !== "full") params.set("mode", mode);
@@ -10294,6 +10298,7 @@ function buildStudioRelativeUrl(
10294
10298
  if (doc?.draftId) params.set("draftId", doc.draftId);
10295
10299
  if (doc?.resourceDir) params.set("resourceDir", doc.resourceDir);
10296
10300
  if (options?.skipWorkspaceRestore) params.set("skipWorkspaceRestore", "1");
10301
+ if (options?.paneFocus) params.set("paneFocus", options.paneFocus);
10297
10302
  return `/?${params.toString()}`;
10298
10303
  }
10299
10304
 
@@ -10303,7 +10308,7 @@ function buildStudioUrl(
10303
10308
  mode: StudioUiMode = "full",
10304
10309
  doc?: InitialStudioDocument | null,
10305
10310
  docId?: string,
10306
- options?: { skipWorkspaceRestore?: boolean },
10311
+ options?: StudioUrlOptions,
10307
10312
  ): string {
10308
10313
  return `http://127.0.0.1:${port}${buildStudioRelativeUrl(token, mode, doc, docId, options)}`;
10309
10314
  }
@@ -10312,16 +10317,18 @@ interface StudioLaunchFlags {
10312
10317
  args: string;
10313
10318
  openRemoteBrowser: boolean;
10314
10319
  noBrowser: boolean;
10320
+ watchPdf: boolean;
10315
10321
  port?: number;
10316
10322
  error?: string;
10317
10323
  }
10318
10324
 
10319
10325
  function parseStudioLaunchOpenFlags(rawArgs: string): StudioLaunchFlags {
10320
10326
  const parsed = tokenizeStudioCommandArgs(rawArgs);
10321
- 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 };
10322
10328
  const remaining: string[] = [];
10323
10329
  let openRemoteBrowser = false;
10324
10330
  let noBrowser = false;
10331
+ let watchPdf = false;
10325
10332
  let port: number | undefined;
10326
10333
  for (let i = 0; i < parsed.tokens.length; i += 1) {
10327
10334
  const token = parsed.tokens[i]!;
@@ -10333,14 +10340,18 @@ function parseStudioLaunchOpenFlags(rawArgs: string): StudioLaunchFlags {
10333
10340
  noBrowser = true;
10334
10341
  continue;
10335
10342
  }
10343
+ if (token === "--watch" || token === "--auto-refresh") {
10344
+ watchPdf = true;
10345
+ continue;
10346
+ }
10336
10347
  if (token === "--port" || token.startsWith("--port=")) {
10337
10348
  const rawPort = token.startsWith("--port=") ? token.slice("--port=".length) : parsed.tokens[++i];
10338
10349
  if (!rawPort) {
10339
- return { args: rawArgs, openRemoteBrowser, noBrowser, error: "Missing value for --port." };
10350
+ return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, error: "Missing value for --port." };
10340
10351
  }
10341
10352
  const requestedPort = Number(rawPort);
10342
10353
  if (!Number.isInteger(requestedPort) || requestedPort < 1 || requestedPort > 65535) {
10343
- 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.` };
10344
10355
  }
10345
10356
  port = requestedPort;
10346
10357
  continue;
@@ -10348,9 +10359,9 @@ function parseStudioLaunchOpenFlags(rawArgs: string): StudioLaunchFlags {
10348
10359
  remaining.push(token);
10349
10360
  }
10350
10361
  if (openRemoteBrowser && noBrowser) {
10351
- 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." };
10352
10363
  }
10353
- return { args: remaining.join(" "), openRemoteBrowser, noBrowser, port };
10364
+ return { args: remaining.join(" "), openRemoteBrowser, noBrowser, watchPdf, port };
10354
10365
  }
10355
10366
 
10356
10367
  function shouldAutoOpenStudioBrowser(options?: { openRemoteBrowser?: boolean; noBrowser?: boolean }): boolean {
@@ -10745,7 +10756,7 @@ ${cssVarsBlock}
10745
10756
  <link rel="stylesheet" href="${stylesheetHref}" />
10746
10757
  </head>
10747
10758
  <body data-initial-source="${initialSource}" data-initial-label="${initialLabel}" data-initial-path="${initialPath}" data-initial-draft-id="${initialDraftId}" data-initial-resource-dir="${initialResourceDir}" data-model-label="${initialModel}" data-terminal-label="${initialTerminal}" data-terminal-detail="${initialTerminalDetailAttr}" data-theme-name="${initialTheme}" data-context-tokens="${initialContextTokens}" data-context-window="${initialContextWindow}" data-context-percent="${initialContextPercent}" data-studio-mode="${studioMode}" data-ssh-session="${initialSshSession}">
10748
- <header>
10759
+ <header id="studioHeader">
10749
10760
  <h1><span class="app-logo" aria-hidden="true">π</span> Studio <span class="app-subtitle">${appSubtitle}</span></h1>
10750
10761
  <div class="controls">
10751
10762
  <button id="saveAsBtn" type="button" title="Save editor content to a new file path. Cmd/Ctrl+S falls back here when no direct save path is available.">Save editor as…</button>
@@ -10755,9 +10766,13 @@ ${cssVarsBlock}
10755
10766
  <button id="importFileBtn" type="button" title="Import a file as an editable copy.">Import file copy…</button>
10756
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" />
10757
10768
  <button id="getEditorBtn" type="button" title="Load the current terminal editor draft into Studio.">Load from pi editor</button>
10758
- <button id="zenModeBtn" class="zen-mode-btn" type="button" title="Hide secondary Studio controls. Shortcut: F9.">Zen</button>
10769
+ <button id="hideStudioHeaderBtn" class="header-visibility-btn" type="button" aria-controls="studioHeader" title="Hide the global Studio header. Restore it from the top-right edge.">Hide header</button>
10770
+ <button id="zenModeBtn" class="zen-mode-btn" type="button" title="Hide the Studio header and secondary controls. Shortcut: F9.">Zen</button>
10759
10771
  </div>
10760
10772
  </header>
10773
+ <div id="studioHeaderReveal" class="studio-header-reveal" hidden>
10774
+ <button id="studioHeaderRevealBtn" type="button" aria-controls="studioHeader" title="Show the Studio header.">Show header</button>
10775
+ </div>
10761
10776
 
10762
10777
  <main>
10763
10778
  <section id="leftPane">
@@ -11070,7 +11085,7 @@ ${cssVarsBlock}
11070
11085
  <div><dt>Cmd/Ctrl+Alt+W</dt><dd>Switch the right pane directly to Working</dd></div>
11071
11086
  <div><dt>F8</dt><dd>Focus editor text</dd></div>
11072
11087
  <div><dt>Shift+F8</dt><dd>Focus right-pane content</dd></div>
11073
- <div><dt>F9</dt><dd>Toggle Zen mode</dd></div>
11088
+ <div><dt>F9</dt><dd>Toggle Zen mode and hide or restore the Studio header</dd></div>
11074
11089
  <div><dt>F10</dt><dd>Focus or unfocus the active pane</dd></div>
11075
11090
  <div><dt>Esc</dt><dd>Close overlays, exit pane focus, or stop an active request</dd></div>
11076
11091
  <div><dt>?</dt><dd>Show keyboard shortcuts when not editing text</dd></div>
@@ -11082,6 +11097,7 @@ ${cssVarsBlock}
11082
11097
  <div><dt>Alt/Option+=</dt><dd>Increase the active pane's text size when not editing text</dd></div>
11083
11098
  <div><dt>Alt/Option+-</dt><dd>Decrease the active pane's text size when not editing text</dd></div>
11084
11099
  <div><dt>Alt/Option+0</dt><dd>Reset the active pane's text size when not editing text</dd></div>
11100
+ <div><dt>Cmd/Ctrl+Alt+R</dt><dd>Refresh the focused or visible PDF preview from disk</dd></div>
11085
11101
  </dl>
11086
11102
  </section>
11087
11103
  <section class="shortcuts-group">
@@ -15744,10 +15760,11 @@ export default function (pi: ExtensionAPI) {
15744
15760
  const resolveStudioLaunchDocument = (
15745
15761
  trimmed: string,
15746
15762
  ctx: ExtensionCommandContext,
15747
- options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string },
15748
- ): InitialStudioDocument | null => {
15763
+ options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string; allowPdfPreview?: boolean; watchPdf?: boolean },
15764
+ ): StudioLaunchSelection | null => {
15749
15765
  const defaultSource = options?.defaultSource === "blank" ? "blank" : "last-response";
15750
15766
  const commandLabel = options?.commandLabel ?? "/studio";
15767
+ const selectDocument = (document: InitialStudioDocument): StudioLaunchSelection => ({ document, kind: "document" });
15751
15768
  const latestAssistant =
15752
15769
  extractLatestAssistantFromEntries(ctx.sessionManager.getBranch())
15753
15770
  ?? extractLatestAssistantFromEntries(ctx.sessionManager.getEntries())
@@ -15756,51 +15773,51 @@ export default function (pi: ExtensionAPI) {
15756
15773
 
15757
15774
  if (!trimmed) {
15758
15775
  if (defaultSource === "last-response" && latestAssistant) {
15759
- return {
15776
+ return selectDocument({
15760
15777
  text: latestAssistant,
15761
15778
  label: "last model response",
15762
15779
  source: "last-response",
15763
15780
  draftId: createStudioDraftId(),
15764
15781
  resourceDir: ctx.cwd,
15765
- };
15782
+ });
15766
15783
  }
15767
- return {
15784
+ return selectDocument({
15768
15785
  text: "",
15769
15786
  label: "blank",
15770
15787
  source: "blank",
15771
15788
  draftId: createStudioDraftId(),
15772
15789
  resourceDir: ctx.cwd,
15773
- };
15790
+ });
15774
15791
  }
15775
15792
 
15776
15793
  if (trimmed === "--blank" || trimmed === "blank") {
15777
- return {
15794
+ return selectDocument({
15778
15795
  text: "",
15779
15796
  label: "blank",
15780
15797
  source: "blank",
15781
15798
  draftId: createStudioDraftId(),
15782
15799
  resourceDir: ctx.cwd,
15783
- };
15800
+ });
15784
15801
  }
15785
15802
 
15786
15803
  if (trimmed === "--last" || trimmed === "last") {
15787
15804
  if (!latestAssistant) {
15788
15805
  ctx.ui.notify("No assistant response found; opening blank studio.", "warning");
15789
- return {
15806
+ return selectDocument({
15790
15807
  text: "",
15791
15808
  label: "blank",
15792
15809
  source: "blank",
15793
15810
  draftId: createStudioDraftId(),
15794
15811
  resourceDir: ctx.cwd,
15795
- };
15812
+ });
15796
15813
  }
15797
- return {
15814
+ return selectDocument({
15798
15815
  text: latestAssistant,
15799
15816
  label: "last model response",
15800
15817
  source: "last-response",
15801
15818
  draftId: createStudioDraftId(),
15802
15819
  resourceDir: ctx.cwd,
15803
- };
15820
+ });
15804
15821
  }
15805
15822
 
15806
15823
  if (trimmed.startsWith("-")) {
@@ -15814,6 +15831,36 @@ export default function (pi: ExtensionAPI) {
15814
15831
  return null;
15815
15832
  }
15816
15833
 
15834
+ const pdfTarget = options?.allowPdfPreview ? parseStudioPdfLaunchTarget(normalizePathInput(pathArg)) : null;
15835
+ if (pdfTarget) {
15836
+ const resolved = resolveStudioPath(pdfTarget.path, ctx.cwd);
15837
+ if (resolved.ok === false) {
15838
+ ctx.ui.notify(resolved.message, "error");
15839
+ return null;
15840
+ }
15841
+ try {
15842
+ const resource = resolveStudioLocalPreviewResourcePath(
15843
+ pdfTarget.page ? `${resolved.resolved}#page=${pdfTarget.page}` : resolved.resolved,
15844
+ resolved.resolved,
15845
+ dirname(resolved.resolved),
15846
+ ctx.cwd,
15847
+ );
15848
+ if (resource.kind !== "pdf") throw new Error("Only local .pdf files can open in the Studio PDF viewer.");
15849
+ return {
15850
+ document: buildStudioLocalResourcePreviewDocument(resource, { watchPdf: options?.watchPdf }),
15851
+ kind: "pdf-preview",
15852
+ mode: "editor-only",
15853
+ transient: true,
15854
+ skipWorkspaceRestore: true,
15855
+ paneFocus: "right",
15856
+ resourcePath: resource.filePath,
15857
+ };
15858
+ } catch (error) {
15859
+ ctx.ui.notify(`Could not open PDF preview: ${error instanceof Error ? error.message : String(error)}`, "error");
15860
+ return null;
15861
+ }
15862
+ }
15863
+
15817
15864
  const file = readStudioFile(pathArg, ctx.cwd);
15818
15865
  if (file.ok === false) {
15819
15866
  ctx.ui.notify(file.message, "error");
@@ -15827,13 +15874,13 @@ export default function (pi: ExtensionAPI) {
15827
15874
  );
15828
15875
  }
15829
15876
 
15830
- return {
15877
+ return selectDocument({
15831
15878
  text: file.text,
15832
15879
  label: file.label,
15833
15880
  source: "file",
15834
15881
  path: file.resolvedPath,
15835
15882
  resourceDir: ctx.cwd,
15836
- };
15883
+ });
15837
15884
  };
15838
15885
 
15839
15886
  const resolveLastModelResponseForExport = (ctx: ExtensionContext): { markdown: string } | null => {
@@ -16090,7 +16137,7 @@ export default function (pi: ExtensionAPI) {
16090
16137
  trimmed: string,
16091
16138
  ctx: ExtensionCommandContext,
16092
16139
  mode: StudioUiMode,
16093
- options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string; replaceExistingFull?: boolean },
16140
+ options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string; replaceExistingFull?: boolean; allowPdfPreview?: boolean; watchPdf?: boolean },
16094
16141
  ) => {
16095
16142
  const launchOpenFlags = parseStudioLaunchOpenFlags(trimmed);
16096
16143
  if (launchOpenFlags.error) {
@@ -16101,7 +16148,17 @@ export default function (pi: ExtensionAPI) {
16101
16148
  if (serverState && launchOpenFlags.port && serverState.port !== launchOpenFlags.port) {
16102
16149
  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");
16103
16150
  }
16104
- if (mode === "full" && hasConnectedFullStudioView()) {
16151
+
16152
+ const parsedLaunchPath = options?.allowPdfPreview ? parsePathArgument(launchArgs) : null;
16153
+ const launchesPdfPreview = parsedLaunchPath
16154
+ ? Boolean(parseStudioPdfLaunchTarget(normalizePathInput(parsedLaunchPath)))
16155
+ : false;
16156
+ if (launchOpenFlags.watchPdf && !launchesPdfPreview) {
16157
+ ctx.ui.notify("--watch requires a local PDF path, for example: /studio --watch main.pdf", "error");
16158
+ return;
16159
+ }
16160
+ const requestedLaunchMode: StudioUiMode = launchesPdfPreview ? "editor-only" : mode;
16161
+ if (requestedLaunchMode === "full" && hasConnectedFullStudioView()) {
16105
16162
  if (options?.replaceExistingFull) {
16106
16163
  closeStudioClientsByMode("full", 4001, "Full Studio replaced");
16107
16164
  } else {
@@ -16132,9 +16189,14 @@ export default function (pi: ExtensionAPI) {
16132
16189
  // ignore theme read errors
16133
16190
  }
16134
16191
 
16135
- const selected = resolveStudioLaunchDocument(launchArgs, ctx, options);
16136
- if (!selected) return;
16137
- initialStudioDocument = selected;
16192
+ const selection = resolveStudioLaunchDocument(launchArgs, ctx, {
16193
+ ...options,
16194
+ watchPdf: launchOpenFlags.watchPdf,
16195
+ });
16196
+ if (!selection) return;
16197
+ const selected = selection.document;
16198
+ const launchMode = selection.mode ?? requestedLaunchMode;
16199
+ if (!selection.transient) initialStudioDocument = selected;
16138
16200
 
16139
16201
  let state: StudioServerState;
16140
16202
  try {
@@ -16145,10 +16207,16 @@ export default function (pi: ExtensionAPI) {
16145
16207
  ctx.ui.notify(`Failed to start Studio server${portText}: ${message}`, "error");
16146
16208
  return;
16147
16209
  }
16148
- const url = buildStudioUrl(state.port, state.token, mode, selected);
16210
+ const docId = selection.transient ? storeTransientStudioDocument(selected) : undefined;
16211
+ const url = buildStudioUrl(state.port, state.token, launchMode, selected, docId, {
16212
+ skipWorkspaceRestore: selection.skipWorkspaceRestore,
16213
+ paneFocus: selection.paneFocus,
16214
+ });
16149
16215
  const tunnelHint = buildStudioSshTunnelHint(state.port, url)
16150
16216
  ?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(state.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
16151
- const openedLabel = mode === "editor-only" ? "pi Studio editor-only view" : "pi Studio";
16217
+ const openedLabel = selection.kind === "pdf-preview"
16218
+ ? "pi Studio PDF preview"
16219
+ : (launchMode === "editor-only" ? "pi Studio editor-only view" : "pi Studio");
16152
16220
 
16153
16221
  const shouldOpenBrowser = shouldAutoOpenStudioBrowser({
16154
16222
  openRemoteBrowser: launchOpenFlags.openRemoteBrowser,
@@ -16160,7 +16228,10 @@ export default function (pi: ExtensionAPI) {
16160
16228
  ctx.ui.notify(`${openedLabel} is ready. Browser auto-open was skipped because ${skipReason}.`, "info");
16161
16229
  } else {
16162
16230
  await openStudioUrlInBrowser(url);
16163
- if (selected.source === "file") {
16231
+ if (selection.kind === "pdf-preview") {
16232
+ const watchLabel = launchOpenFlags.watchPdf ? " (auto-refresh on)" : "";
16233
+ ctx.ui.notify(`Opened ${openedLabel}${watchLabel}: ${selection.resourcePath ?? selected.label}`, "info");
16234
+ } else if (selected.source === "file") {
16164
16235
  ctx.ui.notify(`Opened ${openedLabel} with file loaded: ${selected.label}`, "info");
16165
16236
  } else if (selected.source === "last-response") {
16166
16237
  ctx.ui.notify(`Opened ${openedLabel} with last model response (${selected.text.length} chars).`, "info");
@@ -16182,7 +16253,7 @@ export default function (pi: ExtensionAPI) {
16182
16253
  };
16183
16254
 
16184
16255
  pi.registerCommand("studio", {
16185
- description: "Open pi Studio browser UI (/studio, /studio <file>, /studio --blank, /studio --last, /studio --no-browser, /studio --port <port>)",
16256
+ description: "Open pi Studio browser UI or a PDF preview (/studio, /studio <file>, /studio --watch <pdf>, /studio --blank, /studio --last, /studio --no-browser)",
16186
16257
  handler: async (args: string, ctx: ExtensionCommandContext) => {
16187
16258
  const trimmed = args.trim();
16188
16259
 
@@ -16212,7 +16283,8 @@ export default function (pi: ExtensionAPI) {
16212
16283
  ctx.ui.notify(
16213
16284
  "Usage: /studio [path|--blank|--last]\n"
16214
16285
  + " /studio Open studio with last model response (fallback: blank)\n"
16215
- + " /studio <path> Open studio with file preloaded\n"
16286
+ + " /studio <path> Open a text file in Studio, or a PDF in a read-only companion preview\n"
16287
+ + " /studio --watch <pdf> Open a PDF with auto-refresh enabled\n"
16216
16288
  + " /studio --blank Open with blank editor\n"
16217
16289
  + " /studio --last Open with last model response\n"
16218
16290
  + " /studio --no-browser Print the Studio URL without opening a browser\n"
@@ -16220,7 +16292,7 @@ export default function (pi: ExtensionAPI) {
16220
16292
  + " /studio --open-remote Over SSH, open the remote browser anyway\n"
16221
16293
  + " /studio --status Show studio status\n"
16222
16294
  + " /studio --stop Stop studio server\n"
16223
- + " Note: only one full /studio view is allowed per Pi session.\n"
16295
+ + " Note: only one full /studio view is allowed per Pi session; PDF previews open as companions.\n"
16224
16296
  + " /studio-replace [path] Replace the current full Studio view with a new one\n"
16225
16297
  + " /studio-editor-only [path] Open another Studio tab in editor-only mode\n"
16226
16298
  + " /studio-current <path> Load a file into currently open Studio tab(s)\n"
@@ -16231,7 +16303,7 @@ export default function (pi: ExtensionAPI) {
16231
16303
  return;
16232
16304
  }
16233
16305
 
16234
- await openStudioView(trimmed, ctx, "full", { defaultSource: "last-response", commandLabel: "/studio" });
16306
+ await openStudioView(trimmed, ctx, "full", { defaultSource: "last-response", commandLabel: "/studio", allowPdfPreview: true });
16235
16307
  },
16236
16308
  });
16237
16309
 
@@ -16263,14 +16335,15 @@ export default function (pi: ExtensionAPI) {
16263
16335
  });
16264
16336
 
16265
16337
  pi.registerCommand("studio-editor-only", {
16266
- description: "Open pi Studio in editor-only mode (/studio-editor-only, /studio-editor-only <file>, /studio-editor-only --no-browser)",
16338
+ description: "Open pi Studio in editor-only mode or preview a PDF (/studio-editor-only, /studio-editor-only <file>, /studio-editor-only --watch <pdf>)",
16267
16339
  handler: async (args: string, ctx: ExtensionCommandContext) => {
16268
16340
  const trimmed = args.trim();
16269
16341
  if (trimmed === "help" || trimmed === "--help" || trimmed === "-h") {
16270
16342
  ctx.ui.notify(
16271
16343
  "Usage: /studio-editor-only [path|--blank|--last]\n"
16272
16344
  + " /studio-editor-only Open an editor-only Studio view (default: blank editor)\n"
16273
- + " /studio-editor-only <path> Open an editor-only Studio view with file preloaded\n"
16345
+ + " /studio-editor-only <path> Open a text file for editing, or a PDF in a read-only preview\n"
16346
+ + " /studio-editor-only --watch <pdf> Open a PDF with auto-refresh enabled\n"
16274
16347
  + " /studio-editor-only --blank Open with blank editor\n"
16275
16348
  + " /studio-editor-only --last Open with last model response loaded into the editor\n"
16276
16349
  + " /studio-editor-only --no-browser Print URL without opening a browser\n"
@@ -16281,7 +16354,7 @@ export default function (pi: ExtensionAPI) {
16281
16354
  return;
16282
16355
  }
16283
16356
 
16284
- await openStudioView(trimmed, ctx, "editor-only", { defaultSource: "blank", commandLabel: "/studio-editor-only" });
16357
+ await openStudioView(trimmed, ctx, "editor-only", { defaultSource: "blank", commandLabel: "/studio-editor-only", allowPdfPreview: true });
16285
16358
  },
16286
16359
  });
16287
16360
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.48",
3
+ "version": "0.9.50",
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",
@@ -0,0 +1,46 @@
1
+ function parseStudioLocalPreviewPage(resourcePath) {
2
+ const raw = String(resourcePath || "");
3
+ const parts = [];
4
+ const queryIndex = raw.indexOf("?");
5
+ if (queryIndex >= 0) {
6
+ const queryEnd = raw.indexOf("#", queryIndex);
7
+ parts.push(raw.slice(queryIndex + 1, queryEnd >= 0 ? queryEnd : raw.length));
8
+ }
9
+ const hashIndex = raw.indexOf("#");
10
+ if (hashIndex >= 0) parts.push(raw.slice(hashIndex + 1));
11
+ for (const part of parts) {
12
+ try {
13
+ const params = new URLSearchParams(part);
14
+ const rawPage = params.get("page") || params.get("p");
15
+ if (rawPage) {
16
+ const page = Number.parseInt(rawPage, 10);
17
+ if (Number.isFinite(page) && page > 0) return page;
18
+ }
19
+ } catch {
20
+ const match = part.match(/(?:^|[&;])page=(\d+)/i) || part.match(/^page=(\d+)$/i);
21
+ if (match && match[1]) {
22
+ const page = Number.parseInt(match[1], 10);
23
+ if (Number.isFinite(page) && page > 0) return page;
24
+ }
25
+ }
26
+ }
27
+ return null;
28
+ }
29
+
30
+ function parseStudioPdfLaunchTarget(pathInput) {
31
+ const raw = String(pathInput || "").trim();
32
+ if (!raw || /\0/.test(raw) || /^\/\//.test(raw)) return null;
33
+ if (/^[a-z][a-z0-9+.-]*:/i.test(raw) && !/^[a-z]:[\\/]/i.test(raw)) return null;
34
+
35
+ const match = raw.match(/^(.*?\.pdf)(?:(?:\?[^#]*)?(?:#.*)?)?$/i);
36
+ if (!match || !match[1]) return null;
37
+ return {
38
+ path: match[1],
39
+ page: parseStudioLocalPreviewPage(raw),
40
+ };
41
+ }
42
+
43
+ export {
44
+ parseStudioLocalPreviewPage,
45
+ parseStudioPdfLaunchTarget,
46
+ };