pi-studio 0.9.48 → 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/CHANGELOG.md CHANGED
@@ -4,6 +4,15 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.49] — 2026-08-24
8
+
9
+ ### Added
10
+ - Open local PDFs directly with `/studio <path.pdf>` or `/studio-editor-only <path.pdf>` in a read-only, right-focused companion preview, including optional `#page=N` hints and the existing PDF viewer actions without replacing an open full Studio workspace.
11
+ - Add opt-in PDF auto-refresh through `/studio --watch <path.pdf>`, `watch: true` preview blocks, and synchronized card/focus-viewer toggles; Studio checks only visible tabs, waits for a changed file to stabilize, and retains **Cmd/Ctrl+Alt+R** as a manual refresh shortcut.
12
+
13
+ ### Changed
14
+ - Show active PDF auto-refresh controls as plain accent text, matching Studio's current active-tool treatment instead of using a filled toggle pill.
15
+
7
16
  ## [0.9.48] — 2026-08-21
8
17
 
9
18
  ### Added
package/README.md CHANGED
@@ -54,7 +54,7 @@ _The video shows an earlier version of the Studio interface. The basic workflow
54
54
  - Renders Markdown/LaTeX/code previews (math + Mermaid) plus lightweight CSV/TSV table previews, theme-synced with pi, with copy buttons for code blocks and blockquotes; Mermaid previews include Lucide/Logos icon nodes and accessible label contrast over custom fills
55
55
  - Adds a contextual **Editor (Quarto Preview)** right-pane view for file-backed `.qmd`, `.md`, and `.markdown` documents. Studio checks Quarto and the document/project configuration before showing an explicit start action, launches a single loopback `quarto preview` process with `--no-execute`, embeds Quarto's authoritative saved-file output without restyling it, and provides open-in-browser, restart, stop, logs, and unsaved-editor warnings. If Quarto is missing, the view remains available with an actionable dependency message.
56
56
  - Renders straight, unfenced interactive HTML in preview via a sandboxed browser iframe with zoom controls, while fenced `html` blocks remain source code
57
- - Embeds local PDFs in Studio Markdown previews via explicit `studio-pdf` fenced blocks, with Focus, browser-tab, system-viewer, and show-in-folder actions
57
+ - Embeds local PDFs in Studio Markdown previews via explicit `studio-pdf` fenced blocks and opens existing PDFs directly with `/studio <path.pdf>`, with Focus, browser-tab, system-viewer, show-in-folder, manual refresh, and opt-in stable-file auto-refresh actions
58
58
  - Ships optional `pi-studio-dark` and `pi-studio-light` themes tuned for Studio's browser workspace
59
59
  - Exports right-pane preview as PDF (pandoc + LaTeX) or standalone HTML into the source file directory, Studio working directory, or Pi session directory; PDF export can open in a Studio preview tab or the default PDF viewer, and HTML export can open in the default browser or in a new Studio editor tab for inspection/commenting, while preserving authored HTML previews as HTML and rendering CSV/TSV editor previews as tables
60
60
  - Exports local files headlessly via `/studio-pdf <path>` to `<name>.studio.pdf` or `/studio-html <path>` to `<name>.studio.html`; without a path, those commands export the last model response to a timestamped file. Agent tools `studio_export_pdf` and `studio_export_html` expose the same export pipeline for remote/Telegram-style sessions.
@@ -65,7 +65,8 @@ _The video shows an earlier version of the Studio interface. The basic workflow
65
65
  | Command | Description |
66
66
  |---|---|
67
67
  | `/studio` | Open in Muxy or cmux when available, otherwise the system browser, with the last assistant response (fallback: blank) |
68
- | `/studio <path>` | Open with file preloaded |
68
+ | `/studio <path>` | Open a text file in the editor, or a PDF in a read-only companion preview tab |
69
+ | `/studio --watch <pdf>` | Open a local PDF with stable-file auto-refresh enabled |
69
70
  | `/studio --last` | Force last response |
70
71
  | `/studio --blank` | Force blank editor |
71
72
  | `/studio --no-browser` | Start/print the Studio URL without opening a browser, useful for forwarded or phone/browser sessions |
@@ -74,7 +75,7 @@ _The video shows an earlier version of the Studio interface. The basic workflow
74
75
  | `/studio --stop` | Stop studio server |
75
76
  | `/studio --help` | Show help |
76
77
  | `/studio-replace [path\|--blank\|--last]` | Replace the current full Studio view with a new full Studio view |
77
- | `/studio-editor-only [path\|--blank\|--last]` | Open an editor-only Studio view; multiple editor-only views may be open at once |
78
+ | `/studio-editor-only [path\|--blank\|--last]` | Open an editor-only view, or a read-only PDF preview; multiple companion views may be open at once |
78
79
  | `/studio-current <path>` | Load a file into currently open Studio tab(s) without opening a new browser window |
79
80
  | `/studio-pdf [path] [options]` | Export a local file, or the last model response when no path is given, via the Studio PDF pipeline |
80
81
  | `/studio-html [path]` | Export a local file, or the last model response when no path is given, to standalone HTML via the Studio preview pipeline |
@@ -118,11 +119,14 @@ path: attachments/paper.pdf
118
119
  title: Optional title
119
120
  page: 3
120
121
  height: 760
122
+ watch: true
121
123
  caption: Optional caption
122
124
  ```
123
125
  ````
124
126
 
125
- `path` must point to a local `.pdf` within the current Studio resource directory. Relative paths resolve from the opened document's directory, or from Studio's working dir for non-file-backed content. `page` is an initial page hint for the browser PDF viewer, and `height` controls the embedded frame height in pixels. Use normal Markdown links for PDFs when embedding is not useful.
127
+ `path` must point to a local `.pdf` within the current Studio resource directory. Relative paths resolve from the opened document's directory, or from Studio's working dir for non-file-backed content. `page` is an initial page hint for the browser PDF viewer, `height` controls the embedded frame height in pixels, and `watch: true` enables auto-refresh after Studio observes the changed file in a stable state. Use normal Markdown links for PDFs when embedding is not useful.
128
+
129
+ To view an existing PDF directly, run `/studio report.pdf` (or `/studio "path with spaces/report.pdf"`). The PDF opens read-only in a focused companion preview, even when the full Studio workspace is already open. A `#page=N` suffix selects the initial page. Auto-refresh is off by default; use `/studio --watch report.pdf` to start with it on, or toggle **Auto-refresh** in the PDF card or focused viewer. Studio checks only while the tab is visible and waits for two matching file-version observations before reloading, which avoids reading a PDF while LaTeX is still writing it. **Cmd/Ctrl+Alt+R** manually refreshes the focused or visible PDF. This is distinct from `/studio-pdf`, which exports Markdown, LaTeX, code, or the last response to a new PDF.
126
130
 
127
131
  ### Mermaid icons
128
132
 
@@ -227,7 +227,10 @@
227
227
  if (
228
228
  !previewResourceHelpers
229
229
  || typeof previewResourceHelpers.areStudioPreviewResourceContextsEqual !== "function"
230
+ || typeof previewResourceHelpers.buildStudioPdfVersionSignature !== "function"
231
+ || typeof previewResourceHelpers.createStudioPdfVersionObservationState !== "function"
230
232
  || typeof previewResourceHelpers.hydrateStudioPreviewLocalImages !== "function"
233
+ || typeof previewResourceHelpers.observeStudioPdfVersion !== "function"
231
234
  ) {
232
235
  throw new Error("Studio preview resource helpers failed to load.");
233
236
  }
@@ -271,11 +274,17 @@
271
274
  let studioPdfFocusOpenLinkEl = null;
272
275
  let studioPdfFocusSystemViewerBtn = null;
273
276
  let studioPdfFocusRevealBtn = null;
277
+ let studioPdfFocusAutoRefreshBtn = null;
274
278
  let studioPdfFocusFullscreenBtn = null;
275
279
  let studioPdfFocusCloseBtn = null;
276
280
  let studioPdfFocusLastFocusedEl = null;
277
281
  let studioPdfFocusMovedFrameState = null;
278
282
  let studioPdfFocusResourceQuery = null;
283
+ let studioPdfFocusSourceCard = null;
284
+ let studioPdfFocusStandaloneAutoRefreshState = null;
285
+ const studioPdfCardAutoRefreshStates = new WeakMap();
286
+ const STUDIO_PDF_AUTO_REFRESH_INTERVAL_MS = 1_000;
287
+ const STUDIO_PDF_AUTO_REFRESH_STABLE_OBSERVATIONS = 2;
279
288
  let studioHtmlFocusOverlayEl = null;
280
289
  let studioHtmlFocusShellEl = null;
281
290
  let studioHtmlFocusFullscreenBtn = null;
@@ -4489,6 +4498,15 @@
4489
4498
 
4490
4499
  if (handleStudioImageFocusShortcut(event)) return;
4491
4500
 
4501
+ const otherModalOwnsEvent = scratchpadOwnsEvent
4502
+ || reviewNotesOwnsEvent
4503
+ || outlineOwnsEvent
4504
+ || shortcutsOwnsEvent
4505
+ || htmlFocusOwnsEvent
4506
+ || imageFocusOwnsEvent
4507
+ || quizOwnsEvent;
4508
+ if (!otherModalOwnsEvent && handleStudioPdfRefreshShortcut(event)) return;
4509
+
4492
4510
  if (isScratchpadOpen() && plainEscape) {
4493
4511
  event.preventDefault();
4494
4512
  closeScratchpad();
@@ -6851,7 +6869,7 @@
6851
6869
  }
6852
6870
 
6853
6871
  function parseStudioPdfBlockOptions(body) {
6854
- const options = { path: "", title: "", caption: "", page: "", height: "" };
6872
+ const options = { path: "", title: "", caption: "", page: "", height: "", watch: "" };
6855
6873
  String(body || "").split(/\r?\n/).forEach((line) => {
6856
6874
  const raw = String(line || "").trim();
6857
6875
  if (!raw || raw.startsWith("#")) return;
@@ -6864,6 +6882,7 @@
6864
6882
  else if (key === "caption") options.caption = value;
6865
6883
  else if (key === "page") options.page = value;
6866
6884
  else if (key === "height") options.height = value;
6885
+ else if (key === "watch" || key === "auto-refresh" || key === "autorefresh") options.watch = value;
6867
6886
  return;
6868
6887
  }
6869
6888
  if (!options.path) options.path = stripMatchingQuotes(raw);
@@ -6895,6 +6914,10 @@
6895
6914
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
6896
6915
  }
6897
6916
 
6917
+ function normalizeStudioPdfAutoRefresh(value) {
6918
+ return /^(?:1|true|yes|on|watch)$/i.test(String(value || "").trim());
6919
+ }
6920
+
6898
6921
  function isStudioPdfFocusOpen() {
6899
6922
  return Boolean(studioPdfFocusOverlayEl && studioPdfFocusOverlayEl.hidden === false);
6900
6923
  }
@@ -6969,11 +6992,21 @@
6969
6992
  refreshBtn.type = "button";
6970
6993
  refreshBtn.className = "studio-pdf-focus-btn studio-pdf-focus-refresh";
6971
6994
  refreshBtn.textContent = "Refresh";
6972
- refreshBtn.title = "Reload this PDF preview from disk.";
6995
+ refreshBtn.title = "Reload this PDF preview from disk. Shortcut: Cmd/Ctrl+Alt+R.";
6973
6996
  refreshBtn.setAttribute("aria-label", "Refresh PDF preview from disk");
6974
6997
  refreshBtn.addEventListener("click", () => refreshStudioPdfFocusViewer());
6975
6998
  actions.appendChild(refreshBtn);
6976
6999
 
7000
+ const autoRefreshBtn = document.createElement("button");
7001
+ autoRefreshBtn.type = "button";
7002
+ autoRefreshBtn.className = "studio-pdf-focus-btn studio-pdf-focus-auto-refresh";
7003
+ autoRefreshBtn.textContent = "Auto-refresh";
7004
+ autoRefreshBtn.title = "Watch this local PDF and reload it after a changed file is stable on disk.";
7005
+ autoRefreshBtn.setAttribute("aria-label", "Enable PDF auto-refresh");
7006
+ autoRefreshBtn.setAttribute("aria-pressed", "false");
7007
+ autoRefreshBtn.addEventListener("click", () => toggleStudioPdfFocusAutoRefresh());
7008
+ actions.appendChild(autoRefreshBtn);
7009
+
6977
7010
  const fullscreenBtn = document.createElement("button");
6978
7011
  fullscreenBtn.type = "button";
6979
7012
  fullscreenBtn.className = "studio-pdf-focus-btn studio-pdf-focus-fullscreen";
@@ -7030,6 +7063,7 @@
7030
7063
  studioPdfFocusOpenLinkEl = openLink;
7031
7064
  studioPdfFocusSystemViewerBtn = systemViewerBtn;
7032
7065
  studioPdfFocusRevealBtn = revealBtn;
7066
+ studioPdfFocusAutoRefreshBtn = autoRefreshBtn;
7033
7067
  studioPdfFocusFullscreenBtn = fullscreenBtn;
7034
7068
  studioPdfFocusCloseBtn = closeBtn;
7035
7069
  syncStudioPdfFocusFullscreenButton();
@@ -7037,11 +7071,12 @@
7037
7071
  return overlay;
7038
7072
  }
7039
7073
 
7040
- function openStudioPdfFocusViewer(viewerUrl, title, sourceFrame, resourceQuery) {
7074
+ function openStudioPdfFocusViewer(viewerUrl, title, sourceFrame, resourceQuery, sourceCard) {
7041
7075
  const src = String(viewerUrl || "").trim();
7042
7076
  if (!src) return;
7043
7077
  ensureStudioPdfFocusViewer();
7044
7078
  studioPdfFocusResourceQuery = normalizeStudioPdfResourceQuery(resourceQuery);
7079
+ setStudioPdfFocusAutoRefreshSource(sourceCard, studioPdfFocusResourceQuery);
7045
7080
  syncStudioPdfFocusResourceActions();
7046
7081
  studioPdfFocusLastFocusedEl = document.activeElement instanceof HTMLElement ? document.activeElement : null;
7047
7082
  if (studioPdfFocusTitleEl) studioPdfFocusTitleEl.textContent = String(title || "PDF preview").trim() || "PDF preview";
@@ -7071,6 +7106,7 @@
7071
7106
  restoreStudioPdfFocusMovedFrame();
7072
7107
  if (studioPdfFocusFrameEl) studioPdfFocusFrameEl.src = "about:blank";
7073
7108
  if (document.body) document.body.classList.remove("studio-pdf-focus-open");
7109
+ clearStudioPdfFocusAutoRefreshSource();
7074
7110
  studioPdfFocusResourceQuery = null;
7075
7111
  syncStudioPdfFocusResourceActions();
7076
7112
  syncStudioPdfFocusFullscreenButton();
@@ -7113,6 +7149,214 @@
7113
7149
  return query;
7114
7150
  }
7115
7151
 
7152
+ function syncStudioPdfAutoRefreshButton(button, enabled, available) {
7153
+ if (!button) return;
7154
+ const active = Boolean(enabled && available);
7155
+ button.disabled = !available;
7156
+ button.setAttribute("aria-pressed", active ? "true" : "false");
7157
+ button.setAttribute("aria-label", active ? "Disable PDF auto-refresh" : "Enable PDF auto-refresh");
7158
+ button.textContent = "Auto-refresh";
7159
+ button.title = active
7160
+ ? "Auto-refresh is on. Studio checks this PDF while the tab is visible and reloads it after a changed file is stable on disk."
7161
+ : "Watch this local PDF and reload it after a changed file is stable on disk.";
7162
+ }
7163
+
7164
+ function createStudioPdfAutoRefreshState(resourceQuery, hooks) {
7165
+ return {
7166
+ resourceQuery: normalizeStudioPdfResourceQuery(resourceQuery),
7167
+ enabled: false,
7168
+ timer: null,
7169
+ inFlight: false,
7170
+ generation: 0,
7171
+ observation: previewResourceHelpers.createStudioPdfVersionObservationState(),
7172
+ isAlive: hooks && typeof hooks.isAlive === "function" ? hooks.isAlive : () => false,
7173
+ refresh: hooks && typeof hooks.refresh === "function" ? hooks.refresh : () => false,
7174
+ sync: hooks && typeof hooks.sync === "function" ? hooks.sync : () => {},
7175
+ };
7176
+ }
7177
+
7178
+ function resetStudioPdfAutoRefreshObservation(state) {
7179
+ if (!state) return;
7180
+ state.observation = previewResourceHelpers.createStudioPdfVersionObservationState();
7181
+ }
7182
+
7183
+ function scheduleStudioPdfAutoRefreshPoll(state, delayMs) {
7184
+ if (!state || !state.enabled) return;
7185
+ if (state.timer) window.clearTimeout(state.timer);
7186
+ state.timer = window.setTimeout(() => {
7187
+ state.timer = null;
7188
+ void pollStudioPdfAutoRefreshState(state);
7189
+ }, Math.max(0, Number(delayMs) || 0));
7190
+ }
7191
+
7192
+ function stopStudioPdfAutoRefreshState(state) {
7193
+ if (!state) return;
7194
+ state.enabled = false;
7195
+ state.inFlight = false;
7196
+ state.generation += 1;
7197
+ if (state.timer) window.clearTimeout(state.timer);
7198
+ state.timer = null;
7199
+ resetStudioPdfAutoRefreshObservation(state);
7200
+ state.sync();
7201
+ }
7202
+
7203
+ function setStudioPdfAutoRefreshEnabled(state, enabled, options) {
7204
+ if (!state) return false;
7205
+ const nextEnabled = Boolean(enabled && state.resourceQuery);
7206
+ if (state.enabled === nextEnabled) {
7207
+ state.sync();
7208
+ return nextEnabled;
7209
+ }
7210
+ if (state.timer) window.clearTimeout(state.timer);
7211
+ state.timer = null;
7212
+ state.enabled = nextEnabled;
7213
+ state.inFlight = false;
7214
+ state.generation += 1;
7215
+ resetStudioPdfAutoRefreshObservation(state);
7216
+ state.sync();
7217
+ if (nextEnabled) {
7218
+ if (!options || !options.silent) state.refresh();
7219
+ scheduleStudioPdfAutoRefreshPoll(state, 0);
7220
+ }
7221
+ if (!options || !options.silent) {
7222
+ setStatus(nextEnabled ? "PDF auto-refresh on." : "PDF auto-refresh off.", "success");
7223
+ }
7224
+ return nextEnabled;
7225
+ }
7226
+
7227
+ async function fetchStudioPdfVersionSignature(resourceQuery) {
7228
+ const url = buildStudioPdfResourceUrl(resourceQuery, false);
7229
+ if (!url) throw new Error("Could not resolve this PDF for auto-refresh.");
7230
+ const response = await fetchWithTimeout(url, {
7231
+ method: "HEAD",
7232
+ cache: "no-store",
7233
+ }, 4_000, "PDF auto-refresh check");
7234
+ if (!response.ok) throw new Error("PDF auto-refresh check failed with HTTP " + response.status + ".");
7235
+ return previewResourceHelpers.buildStudioPdfVersionSignature(response.headers);
7236
+ }
7237
+
7238
+ async function pollStudioPdfAutoRefreshState(state) {
7239
+ if (!state || !state.enabled || state.inFlight) return;
7240
+ if (!state.isAlive()) {
7241
+ stopStudioPdfAutoRefreshState(state);
7242
+ return;
7243
+ }
7244
+ if (document.hidden || document.visibilityState === "hidden") {
7245
+ scheduleStudioPdfAutoRefreshPoll(state, STUDIO_PDF_AUTO_REFRESH_INTERVAL_MS);
7246
+ return;
7247
+ }
7248
+ const generation = state.generation;
7249
+ state.inFlight = true;
7250
+ try {
7251
+ const signature = await fetchStudioPdfVersionSignature(state.resourceQuery);
7252
+ if (!state.enabled || state.generation !== generation) return;
7253
+ if (document.hidden || document.visibilityState === "hidden") return;
7254
+ if (!state.isAlive()) {
7255
+ stopStudioPdfAutoRefreshState(state);
7256
+ return;
7257
+ }
7258
+ const observed = previewResourceHelpers.observeStudioPdfVersion(
7259
+ state.observation,
7260
+ signature,
7261
+ STUDIO_PDF_AUTO_REFRESH_STABLE_OBSERVATIONS,
7262
+ );
7263
+ state.observation = observed.state;
7264
+ if (observed.changed) state.refresh();
7265
+ } catch {
7266
+ // LaTeX tools may briefly replace or lock the output PDF. Retry quietly.
7267
+ } finally {
7268
+ if (state.generation !== generation) return;
7269
+ state.inFlight = false;
7270
+ if (state.enabled) scheduleStudioPdfAutoRefreshPoll(state, STUDIO_PDF_AUTO_REFRESH_INTERVAL_MS);
7271
+ }
7272
+ }
7273
+
7274
+ function syncStudioPdfCardAutoRefreshButton(card) {
7275
+ if (!card) return;
7276
+ const state = studioPdfCardAutoRefreshStates.get(card) || null;
7277
+ const button = typeof card.querySelector === "function" ? card.querySelector(".studio-pdf-card-auto-refresh") : null;
7278
+ syncStudioPdfAutoRefreshButton(button, state && state.enabled, Boolean(state && state.resourceQuery));
7279
+ }
7280
+
7281
+ function ensureStudioPdfCardAutoRefreshState(card, resourceQuery) {
7282
+ if (!card) return null;
7283
+ let state = studioPdfCardAutoRefreshStates.get(card) || null;
7284
+ if (state) return state;
7285
+ state = createStudioPdfAutoRefreshState(resourceQuery, {
7286
+ isAlive: () => Boolean(card.isConnected || (isStudioPdfFocusOpen() && studioPdfFocusSourceCard === card)),
7287
+ refresh: () => {
7288
+ if (isStudioPdfFocusOpen() && studioPdfFocusSourceCard === card) {
7289
+ return refreshStudioPdfFocusViewer({ automatic: true });
7290
+ }
7291
+ return refreshStudioPdfCard(card, { automatic: true });
7292
+ },
7293
+ sync: () => {
7294
+ syncStudioPdfCardAutoRefreshButton(card);
7295
+ if (studioPdfFocusSourceCard === card) syncStudioPdfFocusAutoRefreshButton();
7296
+ },
7297
+ });
7298
+ studioPdfCardAutoRefreshStates.set(card, state);
7299
+ syncStudioPdfCardAutoRefreshButton(card);
7300
+ return state;
7301
+ }
7302
+
7303
+ function setStudioPdfCardAutoRefresh(card, enabled, options) {
7304
+ const state = studioPdfCardAutoRefreshStates.get(card) || null;
7305
+ return setStudioPdfAutoRefreshEnabled(state, enabled, options);
7306
+ }
7307
+
7308
+ function getStudioPdfFocusAutoRefreshState() {
7309
+ if (studioPdfFocusSourceCard) return studioPdfCardAutoRefreshStates.get(studioPdfFocusSourceCard) || null;
7310
+ return studioPdfFocusStandaloneAutoRefreshState;
7311
+ }
7312
+
7313
+ function syncStudioPdfFocusAutoRefreshButton() {
7314
+ const state = getStudioPdfFocusAutoRefreshState();
7315
+ syncStudioPdfAutoRefreshButton(
7316
+ studioPdfFocusAutoRefreshBtn,
7317
+ state && state.enabled,
7318
+ Boolean(state && state.resourceQuery),
7319
+ );
7320
+ }
7321
+
7322
+ function clearStudioPdfFocusAutoRefreshSource() {
7323
+ if (studioPdfFocusStandaloneAutoRefreshState) stopStudioPdfAutoRefreshState(studioPdfFocusStandaloneAutoRefreshState);
7324
+ studioPdfFocusStandaloneAutoRefreshState = null;
7325
+ studioPdfFocusSourceCard = null;
7326
+ syncStudioPdfFocusAutoRefreshButton();
7327
+ }
7328
+
7329
+ function setStudioPdfFocusAutoRefreshSource(sourceCard, resourceQuery) {
7330
+ clearStudioPdfFocusAutoRefreshSource();
7331
+ const card = sourceCard instanceof Element ? sourceCard : null;
7332
+ if (card) {
7333
+ studioPdfFocusSourceCard = card;
7334
+ ensureStudioPdfCardAutoRefreshState(card, resourceQuery);
7335
+ syncStudioPdfFocusAutoRefreshButton();
7336
+ return;
7337
+ }
7338
+ const query = normalizeStudioPdfResourceQuery(resourceQuery);
7339
+ if (!query) {
7340
+ syncStudioPdfFocusAutoRefreshButton();
7341
+ return;
7342
+ }
7343
+ studioPdfFocusStandaloneAutoRefreshState = createStudioPdfAutoRefreshState(query, {
7344
+ isAlive: () => Boolean(isStudioPdfFocusOpen() && !studioPdfFocusSourceCard),
7345
+ refresh: () => refreshStudioPdfFocusViewer({ automatic: true }),
7346
+ sync: () => syncStudioPdfFocusAutoRefreshButton(),
7347
+ });
7348
+ syncStudioPdfFocusAutoRefreshButton();
7349
+ }
7350
+
7351
+ function toggleStudioPdfFocusAutoRefresh() {
7352
+ const state = getStudioPdfFocusAutoRefreshState();
7353
+ if (!state) {
7354
+ setStatus("Could not resolve this PDF for auto-refresh.", "warning");
7355
+ return false;
7356
+ }
7357
+ return setStudioPdfAutoRefreshEnabled(state, !state.enabled);
7358
+ }
7359
+
7116
7360
  async function runStudioPdfLocalAction(action, resourceQuery) {
7117
7361
  const query = normalizeStudioPdfResourceQuery(resourceQuery);
7118
7362
  if (!query) {
@@ -7174,7 +7418,7 @@
7174
7418
  if (focusBtn && focusBtn.dataset) focusBtn.dataset.studioPdfViewerUrl = nextUrl;
7175
7419
  }
7176
7420
 
7177
- function refreshStudioPdfCard(card) {
7421
+ function refreshStudioPdfCard(card, options) {
7178
7422
  if (!card) return false;
7179
7423
  const frame = typeof card.querySelector === "function" ? card.querySelector("iframe.studio-pdf-frame") : null;
7180
7424
  const currentUrl = String(card.dataset && card.dataset.studioPdfViewerUrl ? card.dataset.studioPdfViewerUrl : "").trim()
@@ -7182,7 +7426,14 @@
7182
7426
  const nextUrl = buildRefreshedStudioPdfViewerUrl(currentUrl);
7183
7427
  if (!nextUrl) return false;
7184
7428
  syncStudioPdfCardViewerUrl(card, nextUrl);
7185
- setStatus("Refreshed PDF preview from disk.", "success");
7429
+ if (!options || !options.automatic) {
7430
+ resetStudioPdfAutoRefreshObservation(studioPdfCardAutoRefreshStates.get(card) || null);
7431
+ }
7432
+ if (!options || !options.silent) {
7433
+ setStatus(options && options.automatic
7434
+ ? "PDF changed on disk; refreshed preview."
7435
+ : "Refreshed PDF preview from disk.", "success");
7436
+ }
7186
7437
  return true;
7187
7438
  }
7188
7439
 
@@ -7193,7 +7444,7 @@
7193
7444
  return studioPdfFocusFrameEl;
7194
7445
  }
7195
7446
 
7196
- function refreshStudioPdfFocusViewer() {
7447
+ function refreshStudioPdfFocusViewer(options) {
7197
7448
  const frame = getStudioPdfFocusActiveFrame();
7198
7449
  const currentUrl = String(frame && frame.src ? frame.src : "").trim()
7199
7450
  || String(studioPdfFocusOpenLinkEl && studioPdfFocusOpenLinkEl.href ? studioPdfFocusOpenLinkEl.href : "").trim();
@@ -7204,7 +7455,58 @@
7204
7455
  }
7205
7456
  if (frame) frame.src = nextUrl;
7206
7457
  if (studioPdfFocusOpenLinkEl) studioPdfFocusOpenLinkEl.href = nextUrl;
7207
- setStatus("Refreshed PDF preview from disk.", "success");
7458
+ if (studioPdfFocusSourceCard) syncStudioPdfCardViewerUrl(studioPdfFocusSourceCard, nextUrl);
7459
+ if (!options || !options.automatic) {
7460
+ resetStudioPdfAutoRefreshObservation(getStudioPdfFocusAutoRefreshState());
7461
+ }
7462
+ if (!options || !options.silent) {
7463
+ setStatus(options && options.automatic
7464
+ ? "PDF changed on disk; refreshed preview."
7465
+ : "Refreshed PDF preview from disk.", "success");
7466
+ }
7467
+ return true;
7468
+ }
7469
+
7470
+ function getVisibleStudioPdfCards() {
7471
+ return Array.from(document.querySelectorAll(".studio-pdf-card")).filter((card) => {
7472
+ if (!card || !card.isConnected || card.hidden) return false;
7473
+ if (typeof card.getClientRects === "function" && card.getClientRects().length === 0) return false;
7474
+ try {
7475
+ const style = window.getComputedStyle(card);
7476
+ return style.display !== "none" && style.visibility !== "hidden";
7477
+ } catch {
7478
+ return true;
7479
+ }
7480
+ });
7481
+ }
7482
+
7483
+ function refreshVisibleStudioPdfPreviews() {
7484
+ if (isStudioPdfFocusOpen()) return refreshStudioPdfFocusViewer();
7485
+ const cards = getVisibleStudioPdfCards();
7486
+ let refreshed = 0;
7487
+ cards.forEach((card) => {
7488
+ if (refreshStudioPdfCard(card, { silent: true })) refreshed += 1;
7489
+ });
7490
+ if (refreshed > 0) {
7491
+ setStatus(refreshed === 1
7492
+ ? "Refreshed PDF preview from disk."
7493
+ : ("Refreshed " + refreshed + " PDF previews from disk."), "success");
7494
+ return true;
7495
+ }
7496
+ return false;
7497
+ }
7498
+
7499
+ function handleStudioPdfRefreshShortcut(event) {
7500
+ if (!event) return false;
7501
+ const key = typeof event.key === "string" ? event.key.toLowerCase() : "";
7502
+ const code = typeof event.code === "string" ? event.code : "";
7503
+ const matches = (key === "r" || code === "KeyR")
7504
+ && (event.metaKey || event.ctrlKey)
7505
+ && event.altKey
7506
+ && !event.shiftKey;
7507
+ if (!matches) return false;
7508
+ if (!refreshVisibleStudioPdfPreviews()) return false;
7509
+ event.preventDefault();
7208
7510
  return true;
7209
7511
  }
7210
7512
 
@@ -7285,7 +7587,7 @@
7285
7587
  })
7286
7588
  : null;
7287
7589
  if (!viewerUrl) return false;
7288
- openStudioPdfFocusViewer(viewerUrl, title, sourceFrame, resourceQuery);
7590
+ openStudioPdfFocusViewer(viewerUrl, title, sourceFrame, resourceQuery, card);
7289
7591
  return true;
7290
7592
  }
7291
7593
 
@@ -7698,6 +8000,7 @@
7698
8000
  const caption = String(options.caption || "").trim();
7699
8001
  const height = normalizeStudioPdfHeight(options.height);
7700
8002
  const page = normalizeStudioPdfPage(options.page);
8003
+ const autoRefreshRequested = normalizeStudioPdfAutoRefresh(options.watch);
7701
8004
  const resourceQuery = buildStudioPdfResourceQuery(options, useEditorResourceContext);
7702
8005
  const resourceUrl = buildStudioPdfResourceUrl(options, useEditorResourceContext);
7703
8006
  const viewerUrl = resourceUrl && page ? resourceUrl + "#page=" + encodeURIComponent(String(page)) : resourceUrl;
@@ -7778,7 +8081,7 @@
7778
8081
  refreshBtn.type = "button";
7779
8082
  refreshBtn.className = "studio-pdf-card-action studio-pdf-card-refresh";
7780
8083
  refreshBtn.textContent = "Refresh";
7781
- refreshBtn.title = "Reload this PDF preview from disk.";
8084
+ refreshBtn.title = "Reload this PDF preview from disk. Shortcut: Cmd/Ctrl+Alt+R.";
7782
8085
  refreshBtn.addEventListener("click", (event) => {
7783
8086
  event.preventDefault();
7784
8087
  event.stopPropagation();
@@ -7786,6 +8089,25 @@
7786
8089
  });
7787
8090
  actions.appendChild(refreshBtn);
7788
8091
 
8092
+ const autoRefreshBtn = document.createElement("button");
8093
+ autoRefreshBtn.type = "button";
8094
+ autoRefreshBtn.className = "studio-pdf-card-action studio-pdf-card-auto-refresh";
8095
+ autoRefreshBtn.textContent = "Auto-refresh";
8096
+ autoRefreshBtn.title = "Watch this local PDF and reload it after a changed file is stable on disk.";
8097
+ autoRefreshBtn.setAttribute("aria-label", "Enable PDF auto-refresh");
8098
+ autoRefreshBtn.setAttribute("aria-pressed", "false");
8099
+ autoRefreshBtn.addEventListener("click", (event) => {
8100
+ event.preventDefault();
8101
+ event.stopPropagation();
8102
+ const state = ensureStudioPdfCardAutoRefreshState(card, resourceQuery);
8103
+ if (!state) {
8104
+ setStatus("Could not resolve this PDF for auto-refresh.", "warning");
8105
+ return;
8106
+ }
8107
+ setStudioPdfCardAutoRefresh(card, !state.enabled);
8108
+ });
8109
+ actions.appendChild(autoRefreshBtn);
8110
+
7789
8111
  header.appendChild(actions);
7790
8112
  }
7791
8113
  card.appendChild(header);
@@ -7812,6 +8134,12 @@
7812
8134
  iframe.loading = "lazy";
7813
8135
  iframe.style.height = height + "px";
7814
8136
  card.appendChild(iframe);
8137
+ const autoRefreshState = ensureStudioPdfCardAutoRefreshState(card, resourceQuery);
8138
+ if (autoRefreshRequested) {
8139
+ setStudioPdfAutoRefreshEnabled(autoRefreshState, true, { silent: true });
8140
+ } else {
8141
+ syncStudioPdfCardAutoRefreshButton(card);
8142
+ }
7815
8143
  return card;
7816
8144
  }
7817
8145
 
@@ -43,10 +43,62 @@
43
43
  return { attempted: images.length, resolved };
44
44
  }
45
45
 
46
+ function buildStudioPdfVersionSignature(headers) {
47
+ if (!headers || typeof headers.get !== "function") return "";
48
+ const etag = String(headers.get("etag") || "").trim();
49
+ const modified = String(headers.get("last-modified") || "").trim();
50
+ const length = String(headers.get("content-length") || "").trim();
51
+ if (!etag && !modified && !length) return "";
52
+ return [etag, modified, length].join("\n");
53
+ }
54
+
55
+ function createStudioPdfVersionObservationState() {
56
+ return {
57
+ baseline: "",
58
+ candidate: "",
59
+ candidateCount: 0,
60
+ };
61
+ }
62
+
63
+ function observeStudioPdfVersion(previousState, signature, stableObservations) {
64
+ const state = previousState && typeof previousState === "object"
65
+ ? previousState
66
+ : createStudioPdfVersionObservationState();
67
+ const nextSignature = String(signature || "").trim();
68
+ const required = Math.max(2, Number.parseInt(String(stableObservations || "2"), 10) || 2);
69
+ if (!nextSignature) return { state, changed: false };
70
+ if (!state.baseline) {
71
+ return {
72
+ state: { baseline: nextSignature, candidate: "", candidateCount: 0 },
73
+ changed: false,
74
+ };
75
+ }
76
+ if (nextSignature === state.baseline) {
77
+ return {
78
+ state: { baseline: state.baseline, candidate: "", candidateCount: 0 },
79
+ changed: false,
80
+ };
81
+ }
82
+ const candidateCount = nextSignature === state.candidate ? state.candidateCount + 1 : 1;
83
+ if (candidateCount < required) {
84
+ return {
85
+ state: { baseline: state.baseline, candidate: nextSignature, candidateCount },
86
+ changed: false,
87
+ };
88
+ }
89
+ return {
90
+ state: { baseline: nextSignature, candidate: "", candidateCount: 0 },
91
+ changed: true,
92
+ };
93
+ }
94
+
46
95
  globalThis.PiStudioPreviewResourceHelpers = Object.freeze({
47
96
  STUDIO_PREVIEW_LOCAL_IMAGE_LIMIT,
48
97
  areStudioPreviewResourceContextsEqual,
98
+ buildStudioPdfVersionSignature,
99
+ createStudioPdfVersionObservationState,
49
100
  hydrateStudioPreviewLocalImages,
50
101
  isResolvableStudioPreviewImageSource,
102
+ observeStudioPdfVersion,
51
103
  });
52
104
  })();
package/client/studio.css CHANGED
@@ -1822,6 +1822,20 @@
1822
1822
  border-color: var(--control-border);
1823
1823
  }
1824
1824
 
1825
+ .rendered-markdown button.studio-pdf-card-auto-refresh[aria-pressed="true"] {
1826
+ background: transparent;
1827
+ color: var(--accent);
1828
+ border-color: transparent;
1829
+ font-weight: 600;
1830
+ }
1831
+
1832
+ .rendered-markdown button.studio-pdf-card-auto-refresh[aria-pressed="true"]:not(:disabled):hover,
1833
+ .rendered-markdown button.studio-pdf-card-auto-refresh[aria-pressed="true"]:focus-visible {
1834
+ background: var(--panel);
1835
+ color: var(--accent);
1836
+ border-color: var(--control-border);
1837
+ }
1838
+
1825
1839
  .rendered-markdown button.studio-pdf-card-focus {
1826
1840
  width: 29px;
1827
1841
  min-width: 29px;
@@ -1983,6 +1997,20 @@
1983
1997
  color: var(--text);
1984
1998
  }
1985
1999
 
2000
+ .studio-pdf-focus-auto-refresh[aria-pressed="true"] {
2001
+ background: transparent;
2002
+ border-color: transparent;
2003
+ color: var(--accent);
2004
+ font-weight: 600;
2005
+ }
2006
+
2007
+ .studio-pdf-focus-auto-refresh[aria-pressed="true"]:not(:disabled):hover,
2008
+ .studio-pdf-focus-auto-refresh[aria-pressed="true"]:focus-visible {
2009
+ background: var(--panel);
2010
+ border-color: var(--control-border);
2011
+ color: var(--accent);
2012
+ }
2013
+
1986
2014
  .studio-pdf-focus-btn .studio-refresh-icon {
1987
2015
  width: 14px;
1988
2016
  height: 14px;
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 {
@@ -11082,6 +11093,7 @@ ${cssVarsBlock}
11082
11093
  <div><dt>Alt/Option+=</dt><dd>Increase the active pane's text size when not editing text</dd></div>
11083
11094
  <div><dt>Alt/Option+-</dt><dd>Decrease the active pane's text size when not editing text</dd></div>
11084
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>
11085
11097
  </dl>
11086
11098
  </section>
11087
11099
  <section class="shortcuts-group">
@@ -15744,10 +15756,11 @@ export default function (pi: ExtensionAPI) {
15744
15756
  const resolveStudioLaunchDocument = (
15745
15757
  trimmed: string,
15746
15758
  ctx: ExtensionCommandContext,
15747
- options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string },
15748
- ): InitialStudioDocument | null => {
15759
+ options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string; allowPdfPreview?: boolean; watchPdf?: boolean },
15760
+ ): StudioLaunchSelection | null => {
15749
15761
  const defaultSource = options?.defaultSource === "blank" ? "blank" : "last-response";
15750
15762
  const commandLabel = options?.commandLabel ?? "/studio";
15763
+ const selectDocument = (document: InitialStudioDocument): StudioLaunchSelection => ({ document, kind: "document" });
15751
15764
  const latestAssistant =
15752
15765
  extractLatestAssistantFromEntries(ctx.sessionManager.getBranch())
15753
15766
  ?? extractLatestAssistantFromEntries(ctx.sessionManager.getEntries())
@@ -15756,51 +15769,51 @@ export default function (pi: ExtensionAPI) {
15756
15769
 
15757
15770
  if (!trimmed) {
15758
15771
  if (defaultSource === "last-response" && latestAssistant) {
15759
- return {
15772
+ return selectDocument({
15760
15773
  text: latestAssistant,
15761
15774
  label: "last model response",
15762
15775
  source: "last-response",
15763
15776
  draftId: createStudioDraftId(),
15764
15777
  resourceDir: ctx.cwd,
15765
- };
15778
+ });
15766
15779
  }
15767
- return {
15780
+ return selectDocument({
15768
15781
  text: "",
15769
15782
  label: "blank",
15770
15783
  source: "blank",
15771
15784
  draftId: createStudioDraftId(),
15772
15785
  resourceDir: ctx.cwd,
15773
- };
15786
+ });
15774
15787
  }
15775
15788
 
15776
15789
  if (trimmed === "--blank" || trimmed === "blank") {
15777
- return {
15790
+ return selectDocument({
15778
15791
  text: "",
15779
15792
  label: "blank",
15780
15793
  source: "blank",
15781
15794
  draftId: createStudioDraftId(),
15782
15795
  resourceDir: ctx.cwd,
15783
- };
15796
+ });
15784
15797
  }
15785
15798
 
15786
15799
  if (trimmed === "--last" || trimmed === "last") {
15787
15800
  if (!latestAssistant) {
15788
15801
  ctx.ui.notify("No assistant response found; opening blank studio.", "warning");
15789
- return {
15802
+ return selectDocument({
15790
15803
  text: "",
15791
15804
  label: "blank",
15792
15805
  source: "blank",
15793
15806
  draftId: createStudioDraftId(),
15794
15807
  resourceDir: ctx.cwd,
15795
- };
15808
+ });
15796
15809
  }
15797
- return {
15810
+ return selectDocument({
15798
15811
  text: latestAssistant,
15799
15812
  label: "last model response",
15800
15813
  source: "last-response",
15801
15814
  draftId: createStudioDraftId(),
15802
15815
  resourceDir: ctx.cwd,
15803
- };
15816
+ });
15804
15817
  }
15805
15818
 
15806
15819
  if (trimmed.startsWith("-")) {
@@ -15814,6 +15827,36 @@ export default function (pi: ExtensionAPI) {
15814
15827
  return null;
15815
15828
  }
15816
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
+
15817
15860
  const file = readStudioFile(pathArg, ctx.cwd);
15818
15861
  if (file.ok === false) {
15819
15862
  ctx.ui.notify(file.message, "error");
@@ -15827,13 +15870,13 @@ export default function (pi: ExtensionAPI) {
15827
15870
  );
15828
15871
  }
15829
15872
 
15830
- return {
15873
+ return selectDocument({
15831
15874
  text: file.text,
15832
15875
  label: file.label,
15833
15876
  source: "file",
15834
15877
  path: file.resolvedPath,
15835
15878
  resourceDir: ctx.cwd,
15836
- };
15879
+ });
15837
15880
  };
15838
15881
 
15839
15882
  const resolveLastModelResponseForExport = (ctx: ExtensionContext): { markdown: string } | null => {
@@ -16090,7 +16133,7 @@ export default function (pi: ExtensionAPI) {
16090
16133
  trimmed: string,
16091
16134
  ctx: ExtensionCommandContext,
16092
16135
  mode: StudioUiMode,
16093
- options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string; replaceExistingFull?: boolean },
16136
+ options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string; replaceExistingFull?: boolean; allowPdfPreview?: boolean; watchPdf?: boolean },
16094
16137
  ) => {
16095
16138
  const launchOpenFlags = parseStudioLaunchOpenFlags(trimmed);
16096
16139
  if (launchOpenFlags.error) {
@@ -16101,7 +16144,17 @@ export default function (pi: ExtensionAPI) {
16101
16144
  if (serverState && launchOpenFlags.port && serverState.port !== launchOpenFlags.port) {
16102
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");
16103
16146
  }
16104
- 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()) {
16105
16158
  if (options?.replaceExistingFull) {
16106
16159
  closeStudioClientsByMode("full", 4001, "Full Studio replaced");
16107
16160
  } else {
@@ -16132,9 +16185,14 @@ export default function (pi: ExtensionAPI) {
16132
16185
  // ignore theme read errors
16133
16186
  }
16134
16187
 
16135
- const selected = resolveStudioLaunchDocument(launchArgs, ctx, options);
16136
- if (!selected) return;
16137
- 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;
16138
16196
 
16139
16197
  let state: StudioServerState;
16140
16198
  try {
@@ -16145,10 +16203,16 @@ export default function (pi: ExtensionAPI) {
16145
16203
  ctx.ui.notify(`Failed to start Studio server${portText}: ${message}`, "error");
16146
16204
  return;
16147
16205
  }
16148
- 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
+ });
16149
16211
  const tunnelHint = buildStudioSshTunnelHint(state.port, url)
16150
16212
  ?? (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";
16213
+ const openedLabel = selection.kind === "pdf-preview"
16214
+ ? "pi Studio PDF preview"
16215
+ : (launchMode === "editor-only" ? "pi Studio editor-only view" : "pi Studio");
16152
16216
 
16153
16217
  const shouldOpenBrowser = shouldAutoOpenStudioBrowser({
16154
16218
  openRemoteBrowser: launchOpenFlags.openRemoteBrowser,
@@ -16160,7 +16224,10 @@ export default function (pi: ExtensionAPI) {
16160
16224
  ctx.ui.notify(`${openedLabel} is ready. Browser auto-open was skipped because ${skipReason}.`, "info");
16161
16225
  } else {
16162
16226
  await openStudioUrlInBrowser(url);
16163
- 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") {
16164
16231
  ctx.ui.notify(`Opened ${openedLabel} with file loaded: ${selected.label}`, "info");
16165
16232
  } else if (selected.source === "last-response") {
16166
16233
  ctx.ui.notify(`Opened ${openedLabel} with last model response (${selected.text.length} chars).`, "info");
@@ -16182,7 +16249,7 @@ export default function (pi: ExtensionAPI) {
16182
16249
  };
16183
16250
 
16184
16251
  pi.registerCommand("studio", {
16185
- 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)",
16186
16253
  handler: async (args: string, ctx: ExtensionCommandContext) => {
16187
16254
  const trimmed = args.trim();
16188
16255
 
@@ -16212,7 +16279,8 @@ export default function (pi: ExtensionAPI) {
16212
16279
  ctx.ui.notify(
16213
16280
  "Usage: /studio [path|--blank|--last]\n"
16214
16281
  + " /studio Open studio with last model response (fallback: blank)\n"
16215
- + " /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"
16216
16284
  + " /studio --blank Open with blank editor\n"
16217
16285
  + " /studio --last Open with last model response\n"
16218
16286
  + " /studio --no-browser Print the Studio URL without opening a browser\n"
@@ -16220,7 +16288,7 @@ export default function (pi: ExtensionAPI) {
16220
16288
  + " /studio --open-remote Over SSH, open the remote browser anyway\n"
16221
16289
  + " /studio --status Show studio status\n"
16222
16290
  + " /studio --stop Stop studio server\n"
16223
- + " 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"
16224
16292
  + " /studio-replace [path] Replace the current full Studio view with a new one\n"
16225
16293
  + " /studio-editor-only [path] Open another Studio tab in editor-only mode\n"
16226
16294
  + " /studio-current <path> Load a file into currently open Studio tab(s)\n"
@@ -16231,7 +16299,7 @@ export default function (pi: ExtensionAPI) {
16231
16299
  return;
16232
16300
  }
16233
16301
 
16234
- await openStudioView(trimmed, ctx, "full", { defaultSource: "last-response", commandLabel: "/studio" });
16302
+ await openStudioView(trimmed, ctx, "full", { defaultSource: "last-response", commandLabel: "/studio", allowPdfPreview: true });
16235
16303
  },
16236
16304
  });
16237
16305
 
@@ -16263,14 +16331,15 @@ export default function (pi: ExtensionAPI) {
16263
16331
  });
16264
16332
 
16265
16333
  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)",
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>)",
16267
16335
  handler: async (args: string, ctx: ExtensionCommandContext) => {
16268
16336
  const trimmed = args.trim();
16269
16337
  if (trimmed === "help" || trimmed === "--help" || trimmed === "-h") {
16270
16338
  ctx.ui.notify(
16271
16339
  "Usage: /studio-editor-only [path|--blank|--last]\n"
16272
16340
  + " /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"
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"
16274
16343
  + " /studio-editor-only --blank Open with blank editor\n"
16275
16344
  + " /studio-editor-only --last Open with last model response loaded into the editor\n"
16276
16345
  + " /studio-editor-only --no-browser Print URL without opening a browser\n"
@@ -16281,7 +16350,7 @@ export default function (pi: ExtensionAPI) {
16281
16350
  return;
16282
16351
  }
16283
16352
 
16284
- 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 });
16285
16354
  },
16286
16355
  });
16287
16356
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.48",
3
+ "version": "0.9.49",
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
+ };