pi-studio 0.9.20 → 0.9.21

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
@@ -2,7 +2,12 @@
2
2
 
3
3
  All notable changes to `pi-studio` are documented here.
4
4
 
5
- ## [Unreleased]
5
+ ## [0.9.21] — 2026-05-28
6
+
7
+ ### Added
8
+ - Added **Recent…** to the Scratchpad so scratchpads saved under previous file/draft identities can be loaded, appended, or copied after reopening Studio or continuing a Pi session.
9
+ - Preview export now distinguishes **Export PDF and Open in Studio preview tab** from **Export PDF and Open in default PDF viewer**; the Studio path opens a Files-view-style editor-only preview tab with a `studio-pdf` block.
10
+ - HTML export now distinguishes **Export HTML and Open in Studio editor** from **Export HTML and Open in browser**; the Studio path opens the exported HTML in an editor-only Studio tab for inspection, editing, previewing, and comment mode.
6
11
 
7
12
  ## [0.9.20] — 2026-05-27
8
13
 
package/README.md CHANGED
@@ -25,7 +25,7 @@ Extension for [pi](https://pi.dev) that opens a local two-pane browser workspace
25
25
  - Includes a live **Working** view for following current model/tool activity, with `All` / `Thinking` / `Tools` filters, image previews for image-producing tool outputs, plus **Load visible into editor** and **Copy visible** actions; when cycling response history, Working follows saved working details for the selected response when available
26
26
  - Includes a right-pane **Files** view for browsing the current Pi session/resource directory, opening folders, loading text/code/CSV/TSV documents into the editor, previewing PDFs/images, opening PDF/image previews in a new Studio tab, converting DOCX/ODT documents to editable Markdown when Pandoc is available after confirmation, copying paths, setting the current folder as the Studio working directory, and revealing files in the file manager
27
27
  - Includes an optional tmux-backed **REPL** view for Shell, Python, IPython, Julia, R, GHCi, and Clojure sessions, with Raw/Literate send modes, `Cmd/Ctrl+Shift+Enter` **Send to REPL**, session start/stop/interrupt controls, a compact refresh-persistent **Studio REPL Record** of user and Pi-sent code, a secondary raw tmux mirror, agent-facing `studio_repl_status` / `studio_repl_send` tools, and Markdown/PDF/HTML export
28
- - Includes a local persistent scratchpad for quick notes you want to keep out of the main editor until you're ready to copy or insert them
28
+ - Includes a local persistent scratchpad for quick notes you want to keep out of the main editor until you're ready to copy or insert them, with a **Recent…** picker for recovering scratchpads saved under earlier file/draft identities
29
29
  - Includes a docked **Outline** rail for navigating document structure in the current editor text, with clickable entries that jump in the raw editor and reveal matching preview locations when available
30
30
  - Restores the current browser-tab editor workspace after refresh and provides an explicit **Reset editor** action when you want to discard the restored draft and return the tab to a fresh blank draft without changing responses or saved files
31
31
  - Turns local preview links, including links inside sandboxed HTML previews, into Studio actions: PDFs open in the embedded viewer, images open in a zoomable focus viewer, PDF/image links can open in a new Studio preview tab, text/code/CSV/TSV document links can open in a new editor tab, DOCX/ODT links can be converted to editable Markdown, and right-click menus provide **Open here**, **Reveal in file manager**, and **Copy path** for local resources
@@ -43,7 +43,7 @@ Extension for [pi](https://pi.dev) that opens a local two-pane browser workspace
43
43
  - Renders straight, unfenced interactive HTML in preview via a sandboxed browser iframe with zoom controls, while fenced `html` blocks remain source code
44
44
  - Embeds local PDFs in Studio Markdown previews via explicit `studio-pdf` fenced blocks, with a Focus action for temporarily enlarging the embedded viewer
45
45
  - Ships optional `pi-studio-dark` and `pi-studio-light` themes tuned for Studio's browser workspace
46
- - Exports right-pane preview as PDF (pandoc + LaTeX) or standalone HTML into the source file directory, Studio working directory, or Pi session directory, preserving authored HTML previews as HTML and rendering CSV/TSV editor previews as tables
46
+ - 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
47
47
  - 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
48
48
  - Shows model/session/context usage in the footer, plus a compact-context action
49
49
 
@@ -500,6 +500,23 @@
500
500
  return out;
501
501
  }
502
502
 
503
+ const SMART_MARKDOWN_FENCE_CHARS = "`´‘’‚‛“”„‟′‵";
504
+ const SMART_MARKDOWN_FENCE_RE = new RegExp(`^([ \\t]{0,3})([${SMART_MARKDOWN_FENCE_CHARS.replace(/[\\\\\]^-]/g, "\\\\$&")}]{3,})([^${SMART_MARKDOWN_FENCE_CHARS.replace(/[\\\\\]^-]/g, "\\\\$&")}\\r\\n]*)$`);
505
+
506
+ function normalizeMarkdownSmartFences(markdown) {
507
+ return String(markdown || "")
508
+ .replace(/\r\n/g, "\n")
509
+ .split("\n")
510
+ .map(function(line) {
511
+ const match = line.match(SMART_MARKDOWN_FENCE_RE);
512
+ if (!match) return line;
513
+ const run = match[2] || "";
514
+ if (!/[´‘’‚‛“”„‟′‵]/u.test(run)) return line;
515
+ return (match[1] || "") + "`".repeat(Math.max(3, Array.from(run).length)) + (match[3] || "");
516
+ })
517
+ .join("\n");
518
+ }
519
+
503
520
  function transformMarkdownOutsideFences(text, plainTransformer) {
504
521
  const source = String(text || "").replace(/\r\n/g, "\n");
505
522
  if (!source) return source;
@@ -642,11 +659,12 @@
642
659
  }
643
660
 
644
661
  function prepareMarkdownForPandocPreview(markdown, placeholderPrefix) {
662
+ const normalizedMarkdown = normalizeMarkdownSmartFences(markdown);
645
663
  const placeholders = [];
646
664
  const prefix = typeof placeholderPrefix === "string" && placeholderPrefix
647
665
  ? placeholderPrefix
648
666
  : "PISTUDIOANNOT";
649
- const prepared = transformMarkdownOutsideFences(markdown, function(segment) {
667
+ const prepared = transformMarkdownOutsideFences(normalizedMarkdown, function(segment) {
650
668
  return replaceInlineAnnotationMarkers(segment, function(marker) {
651
669
  const label = normalizePreviewAnnotationLabel(marker.body);
652
670
  if (!label) return "";
@@ -663,6 +681,7 @@
663
681
  hasAnnotationMarkers: hasAnnotationMarkers,
664
682
  normalizePreviewAnnotationLabel: normalizePreviewAnnotationLabel,
665
683
  extractStandaloneMarkdownImageCaptionText: extractStandaloneMarkdownImageCaptionText,
684
+ normalizeMarkdownSmartFences: normalizeMarkdownSmartFences,
666
685
  prepareMarkdownForPandocPreview: prepareMarkdownForPandocPreview,
667
686
  readInlineAnnotationMarkerAt: readInlineAnnotationMarkerAt,
668
687
  renderPreviewAnnotationHtml: renderPreviewAnnotationHtml,
@@ -92,7 +92,9 @@
92
92
  const copyResponseBtn = document.getElementById("copyResponseBtn");
93
93
  const exportPreviewControlsEl = document.getElementById("exportPreviewControls");
94
94
  const exportPreviewMenuEl = document.getElementById("exportPreviewMenu");
95
+ const exportPreviewPdfStudioBtn = document.getElementById("exportPreviewPdfStudioBtn");
95
96
  const exportPreviewPdfBtn = document.getElementById("exportPreviewPdfBtn");
97
+ const exportPreviewHtmlStudioBtn = document.getElementById("exportPreviewHtmlStudioBtn");
96
98
  const exportPreviewHtmlBtn = document.getElementById("exportPreviewHtmlBtn");
97
99
  const exportPdfBtn = document.getElementById("exportPdfBtn");
98
100
  const historyPrevBtn = document.getElementById("historyPrevBtn");
@@ -142,6 +144,8 @@
142
144
  const scratchpadDialogEl = document.getElementById("scratchpadDialog");
143
145
  const scratchpadTextEl = document.getElementById("scratchpadText");
144
146
  const scratchpadMetaEl = document.getElementById("scratchpadMeta");
147
+ const scratchpadRecentBtn = document.getElementById("scratchpadRecentBtn");
148
+ const scratchpadRecentPanelEl = document.getElementById("scratchpadRecentPanel");
145
149
  const scratchpadInsertBtn = document.getElementById("scratchpadInsertBtn");
146
150
  const scratchpadCopyBtn = document.getElementById("scratchpadCopyBtn");
147
151
  const scratchpadClearBtn = document.getElementById("scratchpadClearBtn");
@@ -2044,6 +2048,9 @@
2044
2048
  let scratchpadReturnFocusEl = null;
2045
2049
  let scratchpadPersistTimer = null;
2046
2050
  let scratchpadLoadNonce = 0;
2051
+ let scratchpadRecentEntries = [];
2052
+ let scratchpadRecentVisible = false;
2053
+ let scratchpadRecentLoading = false;
2047
2054
  let reviewNotes = [];
2048
2055
  let reviewNotesReturnFocusEl = null;
2049
2056
  let reviewNotesPersistTimer = null;
@@ -7940,14 +7947,32 @@
7940
7947
  }
7941
7948
  }
7942
7949
 
7943
- async function exportRightPanePdf() {
7950
+ function getStudioPdfViewerUrlForExportPayload(payload) {
7951
+ if (!payload || typeof payload !== "object") return "";
7952
+ const exportPath = typeof payload.path === "string" ? payload.path.trim() : "";
7953
+ if (exportPath) {
7954
+ const resourceUrl = buildStudioPdfResourceUrl({ path: exportPath, resourceDir: exportPath.split(/[\\/]/).slice(0, -1).join("/") }, false);
7955
+ if (resourceUrl) return resourceUrl;
7956
+ }
7957
+ return typeof payload.downloadUrl === "string" ? payload.downloadUrl : "";
7958
+ }
7959
+
7960
+ async function exportRightPanePdf(options) {
7961
+ const exportOptions = options && typeof options === "object" ? options : {};
7962
+ const openTarget = exportOptions.openTarget === "studio" ? "studio" : "default";
7963
+ let studioPopup = null;
7964
+ if (openTarget === "studio") {
7965
+ studioPopup = openExportStudioPlaceholderWindow("PDF");
7966
+ }
7944
7967
  if (uiBusy || previewExportInProgress) {
7968
+ closeExportStudioWindow(studioPopup);
7945
7969
  setStatus("Studio is busy.", "warning");
7946
7970
  return;
7947
7971
  }
7948
7972
 
7949
7973
  const token = getToken();
7950
7974
  if (!token) {
7975
+ closeExportStudioWindow(studioPopup);
7951
7976
  setStatus("Missing Studio token in URL. Re-run /studio.", "error");
7952
7977
  return;
7953
7978
  }
@@ -7955,17 +7980,20 @@
7955
7980
  const exportingReplJournal = rightView === "repl";
7956
7981
  const rightPaneShowsPreview = rightView === "preview" || rightView === "editor-preview";
7957
7982
  if (!rightPaneShowsPreview && !exportingReplJournal) {
7983
+ closeExportStudioWindow(studioPopup);
7958
7984
  setStatus("Switch right pane to Response (Preview), Editor (Preview), or REPL to export PDF.", "warning");
7959
7985
  return;
7960
7986
  }
7961
7987
  const replJournalExportEntries = exportingReplJournal ? getVisibleReplJournalEntries() : [];
7962
7988
  if (exportingReplJournal && !replJournalExportEntries.length) {
7989
+ closeExportStudioWindow(studioPopup);
7963
7990
  setStatus("No Studio REPL record entries to export for this session yet.", "warning");
7964
7991
  return;
7965
7992
  }
7966
7993
 
7967
7994
  const htmlArtifactSource = exportingReplJournal ? "" : getRightPaneHtmlArtifactSource();
7968
7995
  if (htmlArtifactSource) {
7996
+ closeExportStudioWindow(studioPopup);
7969
7997
  setStatus("PDF export does not support interactive HTML previews yet. Export as HTML or use the browser print dialog inside the preview.", "warning");
7970
7998
  return;
7971
7999
  }
@@ -7976,6 +8004,7 @@
7976
8004
  ? prepareEditorTextForPdfExport(sourceTextEl.value)
7977
8005
  : prepareEditorTextForPreview(latestResponseMarkdown));
7978
8006
  if (!markdown || !markdown.trim()) {
8007
+ closeExportStudioWindow(studioPopup);
7979
8008
  setStatus("Nothing to export yet.", "warning");
7980
8009
  return;
7981
8010
  }
@@ -7998,7 +8027,7 @@
7998
8027
 
7999
8028
  previewExportInProgress = true;
8000
8029
  updateResultActionButtons();
8001
- setStatus("Exporting PDF…", "warning");
8030
+ setStatus(openTarget === "studio" ? "Exporting PDF for Studio…" : "Exporting PDF…", "warning");
8002
8031
 
8003
8032
  try {
8004
8033
  const response = await fetchWithTimeout("/export-pdf?token=" + encodeURIComponent(token), {
@@ -8013,6 +8042,7 @@
8013
8042
  isLatex: isLatex,
8014
8043
  editorPdfLanguage: editorPdfLanguage,
8015
8044
  filenameHint: filenameHint,
8045
+ openTarget: openTarget,
8016
8046
  }),
8017
8047
  }, PDF_EXPORT_FETCH_TIMEOUT_MS, "PDF export");
8018
8048
 
@@ -8051,6 +8081,35 @@
8051
8081
  downloadName += ".pdf";
8052
8082
  }
8053
8083
 
8084
+ if (openTarget === "studio") {
8085
+ const targetUrl = typeof payload.relativeUrl === "string" && payload.relativeUrl
8086
+ ? new URL(payload.relativeUrl, window.location.href).href
8087
+ : (typeof payload.url === "string" ? payload.url : "");
8088
+ const openedStudio = navigateExportStudioWindow(studioPopup, targetUrl);
8089
+ if (!openedStudio) {
8090
+ closeExportStudioWindow(studioPopup);
8091
+ const viewerUrl = getStudioPdfViewerUrlForExportPayload(payload);
8092
+ if (viewerUrl) openStudioPdfFocusViewer(viewerUrl, downloadName);
8093
+ }
8094
+ if (writeError) {
8095
+ setStatus(openedStudio
8096
+ ? "Opened exported PDF in a Studio preview tab, but could not write project file: " + writeError
8097
+ : "Exported PDF, but could not open a Studio preview tab and could not write project file: " + writeError,
8098
+ "warning");
8099
+ } else if (exportWarning) {
8100
+ setStatus(openedStudio
8101
+ ? "Opened exported PDF in a Studio preview tab with warning: " + exportWarning
8102
+ : "Exported PDF, but could not open a Studio preview tab. Warning: " + exportWarning,
8103
+ "warning");
8104
+ } else {
8105
+ setStatus(openedStudio
8106
+ ? "Opened exported PDF in a Studio preview tab: " + (exportPath || downloadName)
8107
+ : "Exported PDF, but could not open a Studio preview tab" + (targetUrl ? ": " + targetUrl : "."),
8108
+ openedStudio ? "success" : "warning");
8109
+ }
8110
+ return;
8111
+ }
8112
+
8054
8113
  if (openedExternal) {
8055
8114
  if (writeError) {
8056
8115
  setStatus("Opened PDF in default viewer, but could not write project file: " + writeError, "warning");
@@ -8112,6 +8171,7 @@
8112
8171
  setStatus("Exported PDF: " + downloadName, "success");
8113
8172
  }
8114
8173
  } catch (error) {
8174
+ closeExportStudioWindow(studioPopup);
8115
8175
  const detail = error && error.message ? error.message : String(error || "unknown error");
8116
8176
  setStatus("PDF export failed: " + detail, "error");
8117
8177
  } finally {
@@ -8120,14 +8180,58 @@
8120
8180
  }
8121
8181
  }
8122
8182
 
8123
- async function exportRightPaneHtml() {
8183
+ function openExportStudioPlaceholderWindow(formatLabel) {
8184
+ const label = String(formatLabel || "preview").trim() || "preview";
8185
+ let popup = null;
8186
+ try {
8187
+ popup = window.open("", "_blank");
8188
+ if (popup && popup.document && popup.document.body) {
8189
+ popup.document.title = "Opening " + label + " in Studio…";
8190
+ popup.document.body.innerHTML = "<p style=\"font: 13px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 16px;\">Exporting " + escapeHtml(label) + " and opening it in Studio…</p>";
8191
+ }
8192
+ } catch {
8193
+ popup = null;
8194
+ }
8195
+ return popup;
8196
+ }
8197
+
8198
+ function navigateExportStudioWindow(popup, targetUrl) {
8199
+ if (!targetUrl) return false;
8200
+ if (popup && !popup.closed) {
8201
+ try {
8202
+ popup.opener = null;
8203
+ popup.location.href = targetUrl;
8204
+ return true;
8205
+ } catch {}
8206
+ }
8207
+ try {
8208
+ return Boolean(window.open(targetUrl, "_blank", "noopener"));
8209
+ } catch {
8210
+ return false;
8211
+ }
8212
+ }
8213
+
8214
+ function closeExportStudioWindow(popup) {
8215
+ if (!popup || popup.closed) return;
8216
+ try { popup.close(); } catch {}
8217
+ }
8218
+
8219
+ async function exportRightPaneHtml(options) {
8220
+ const exportOptions = options && typeof options === "object" ? options : {};
8221
+ const openTarget = exportOptions.openTarget === "studio" ? "studio" : "browser";
8222
+ let studioPopup = null;
8223
+ if (openTarget === "studio") {
8224
+ studioPopup = openExportStudioPlaceholderWindow("HTML");
8225
+ }
8124
8226
  if (uiBusy || previewExportInProgress) {
8227
+ closeExportStudioWindow(studioPopup);
8125
8228
  setStatus("Studio is busy.", "warning");
8126
8229
  return;
8127
8230
  }
8128
8231
 
8129
8232
  const token = getToken();
8130
8233
  if (!token) {
8234
+ closeExportStudioWindow(studioPopup);
8131
8235
  setStatus("Missing Studio token in URL. Re-run /studio.", "error");
8132
8236
  return;
8133
8237
  }
@@ -8135,11 +8239,13 @@
8135
8239
  const exportingReplJournal = rightView === "repl";
8136
8240
  const rightPaneShowsPreview = rightView === "preview" || rightView === "editor-preview";
8137
8241
  if (!rightPaneShowsPreview && !exportingReplJournal) {
8242
+ closeExportStudioWindow(studioPopup);
8138
8243
  setStatus("Switch right pane to Response (Preview), Editor (Preview), or REPL to export HTML.", "warning");
8139
8244
  return;
8140
8245
  }
8141
8246
  const replJournalExportEntries = exportingReplJournal ? getVisibleReplJournalEntries() : [];
8142
8247
  if (exportingReplJournal && !replJournalExportEntries.length) {
8248
+ closeExportStudioWindow(studioPopup);
8143
8249
  setStatus("No Studio REPL record entries to export for this session yet.", "warning");
8144
8250
  return;
8145
8251
  }
@@ -8149,6 +8255,7 @@
8149
8255
  ? prepareEditorTextForHtmlExport(sourceTextEl.value)
8150
8256
  : prepareEditorTextForPreview(latestResponseMarkdown)));
8151
8257
  if (!markdown || !markdown.trim()) {
8258
+ closeExportStudioWindow(studioPopup);
8152
8259
  setStatus("Nothing to export yet.", "warning");
8153
8260
  return;
8154
8261
  }
@@ -8173,7 +8280,7 @@
8173
8280
 
8174
8281
  previewExportInProgress = true;
8175
8282
  updateResultActionButtons();
8176
- setStatus("Exporting HTML…", "warning");
8283
+ setStatus(openTarget === "studio" ? "Exporting HTML for Studio…" : "Exporting HTML…", "warning");
8177
8284
 
8178
8285
  try {
8179
8286
  const response = await fetchWithTimeout("/export-html?token=" + encodeURIComponent(token), {
@@ -8189,6 +8296,7 @@
8189
8296
  editorHtmlLanguage: editorHtmlLanguage,
8190
8297
  filenameHint: filenameHint,
8191
8298
  title: titleHint,
8299
+ openTarget: openTarget,
8192
8300
  }),
8193
8301
  }, HTML_EXPORT_FETCH_TIMEOUT_MS, "HTML export");
8194
8302
 
@@ -8227,6 +8335,31 @@
8227
8335
  downloadName += ".html";
8228
8336
  }
8229
8337
 
8338
+ if (openTarget === "studio") {
8339
+ const targetUrl = typeof payload.relativeUrl === "string" && payload.relativeUrl
8340
+ ? new URL(payload.relativeUrl, window.location.href).href
8341
+ : (typeof payload.url === "string" ? payload.url : "");
8342
+ const openedStudio = navigateExportStudioWindow(studioPopup, targetUrl);
8343
+ if (!openedStudio) closeExportStudioWindow(studioPopup);
8344
+ if (writeError) {
8345
+ setStatus(openedStudio
8346
+ ? "Opened exported HTML in Studio as an unsaved copy; could not write project file: " + writeError
8347
+ : "Exported HTML for Studio, but the popup was blocked and the project file could not be written: " + writeError,
8348
+ "warning");
8349
+ } else if (exportWarning) {
8350
+ setStatus(openedStudio
8351
+ ? "Opened exported HTML in Studio with warning: " + exportWarning
8352
+ : "Exported HTML for Studio, but the popup was blocked. Warning: " + exportWarning,
8353
+ "warning");
8354
+ } else {
8355
+ setStatus(openedStudio
8356
+ ? "Opened exported HTML in Studio: " + (exportPath || downloadName)
8357
+ : (targetUrl ? "Exported HTML for Studio: " + targetUrl : "Exported HTML, but Studio did not receive an editor URL."),
8358
+ openedStudio ? "success" : "warning");
8359
+ }
8360
+ return;
8361
+ }
8362
+
8230
8363
  if (openedExternal) {
8231
8364
  if (writeError) {
8232
8365
  setStatus("Opened HTML in default browser, but could not write project file: " + writeError, "warning");
@@ -8262,6 +8395,7 @@
8262
8395
  return;
8263
8396
  }
8264
8397
 
8398
+ closeExportStudioWindow(studioPopup);
8265
8399
  const exportWarning = String(response.headers.get("x-pi-studio-export-warning") || "").trim();
8266
8400
  const blob = await response.blob();
8267
8401
  const headerFilename = parseContentDispositionFilename(response.headers.get("content-disposition"));
@@ -8288,6 +8422,7 @@
8288
8422
  setStatus("Exported HTML: " + downloadName, "success");
8289
8423
  }
8290
8424
  } catch (error) {
8425
+ closeExportStudioWindow(studioPopup);
8291
8426
  const detail = error && error.message ? error.message : String(error || "unknown error");
8292
8427
  setStatus("HTML export failed: " + detail, "error");
8293
8428
  } finally {
@@ -8318,10 +8453,16 @@
8318
8453
 
8319
8454
  function exportRightPaneFormat(format) {
8320
8455
  closeExportPreviewMenu();
8321
- if (format === "html") {
8322
- return exportRightPaneHtml();
8456
+ if (format === "html-studio") {
8457
+ return exportRightPaneHtml({ openTarget: "studio" });
8458
+ }
8459
+ if (format === "html" || format === "html-browser") {
8460
+ return exportRightPaneHtml({ openTarget: "browser" });
8461
+ }
8462
+ if (format === "pdf-studio") {
8463
+ return exportRightPanePdf({ openTarget: "studio" });
8323
8464
  }
8324
- return exportRightPanePdf();
8465
+ return exportRightPanePdf({ openTarget: "default" });
8325
8466
  }
8326
8467
 
8327
8468
  function normalizeCopyableBlockText(text) {
@@ -9611,28 +9752,40 @@
9611
9752
  } else if (isHtmlArtifactPreview) {
9612
9753
  exportPdfBtn.title = "This is an interactive HTML preview. Export as HTML; PDF export is not available yet.";
9613
9754
  } else if (exportingReplJournal) {
9614
- exportPdfBtn.title = "Choose PDF or HTML and export the Studio REPL record.";
9755
+ exportPdfBtn.title = "Choose PDF export or an HTML export destination for the Studio REPL record.";
9615
9756
  } else {
9616
- exportPdfBtn.title = "Choose PDF or HTML and export the current right-pane preview.";
9757
+ exportPdfBtn.title = "Choose PDF export or an HTML export destination for the current right-pane preview.";
9617
9758
  }
9618
9759
  }
9760
+ if (exportPreviewPdfStudioBtn) {
9761
+ exportPreviewPdfStudioBtn.disabled = uiBusy || previewExportInProgress || !canExportPreview || isHtmlArtifactPreview;
9762
+ exportPreviewPdfStudioBtn.title = isHtmlArtifactPreview
9763
+ ? "Interactive HTML preview PDF export is not available yet."
9764
+ : (exportingReplJournal ? "Export the Studio REPL record as PDF and open it in Studio." : "Export the current right-pane preview as PDF and open it in Studio.");
9765
+ }
9619
9766
  if (exportPreviewPdfBtn) {
9620
9767
  exportPreviewPdfBtn.disabled = uiBusy || previewExportInProgress || !canExportPreview || isHtmlArtifactPreview;
9621
9768
  exportPreviewPdfBtn.title = isHtmlArtifactPreview
9622
9769
  ? "Interactive HTML preview PDF export is not available yet."
9623
- : (exportingReplJournal ? "Export the Studio REPL record as PDF." : "Export the current right-pane preview as PDF.");
9770
+ : (exportingReplJournal ? "Export the Studio REPL record as PDF and open it in the default PDF viewer." : "Export the current right-pane preview as PDF and open it in the default PDF viewer.");
9771
+ }
9772
+ if (exportPreviewHtmlStudioBtn) {
9773
+ exportPreviewHtmlStudioBtn.disabled = uiBusy || previewExportInProgress || !canExportPreview;
9774
+ exportPreviewHtmlStudioBtn.title = isHtmlArtifactPreview
9775
+ ? "Export the authored HTML preview and open it in a new Studio editor tab."
9776
+ : (exportingReplJournal ? "Export the Studio REPL record as standalone HTML and open it in a new Studio editor tab." : "Export the current right-pane preview as standalone HTML and open it in a new Studio editor tab.");
9624
9777
  }
9625
9778
  if (exportPreviewHtmlBtn) {
9626
9779
  exportPreviewHtmlBtn.disabled = uiBusy || previewExportInProgress || !canExportPreview;
9627
9780
  exportPreviewHtmlBtn.title = isHtmlArtifactPreview
9628
- ? "Export the authored HTML preview."
9629
- : (exportingReplJournal ? "Export the Studio REPL record as standalone HTML." : "Export the current right-pane preview as standalone HTML.");
9781
+ ? "Export the authored HTML preview and open it in the default browser."
9782
+ : (exportingReplJournal ? "Export the Studio REPL record as standalone HTML and open it in the default browser." : "Export the current right-pane preview as standalone HTML and open it in the default browser.");
9630
9783
  }
9631
9784
  if (exportPreviewControlsEl) {
9632
9785
  exportPreviewControlsEl.title = canExportPreview
9633
9786
  ? (exportingReplJournal
9634
- ? "Choose a format and export the Studio REPL record."
9635
- : (isHtmlArtifactPreview ? "Export this HTML preview." : "Choose a format and export the current right-pane preview."))
9787
+ ? "Choose a format and export destination for the Studio REPL record."
9788
+ : (isHtmlArtifactPreview ? "Export this HTML preview to Studio or browser." : "Choose a format and export destination for the current right-pane preview."))
9636
9789
  : (exportingReplJournal ? "No Studio REPL record entries to export for this session yet." : "Switch right pane to a non-empty preview before exporting.");
9637
9790
  }
9638
9791
  if (!canExportPreview || previewExportInProgress) {
@@ -12823,6 +12976,110 @@
12823
12976
  return describeStudioDocument(sourceState);
12824
12977
  }
12825
12978
 
12979
+ function formatScratchpadRecentTime(timestamp) {
12980
+ const value = Number(timestamp) || 0;
12981
+ if (!value) return "unknown time";
12982
+ try {
12983
+ return new Date(value).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
12984
+ } catch {
12985
+ return "unknown time";
12986
+ }
12987
+ }
12988
+
12989
+ function renderScratchpadRecentPanel() {
12990
+ if (!scratchpadRecentPanelEl) return;
12991
+ scratchpadRecentPanelEl.hidden = !scratchpadRecentVisible;
12992
+ if (!scratchpadRecentVisible) return;
12993
+ if (scratchpadRecentLoading) {
12994
+ scratchpadRecentPanelEl.innerHTML = "<div class='scratchpad-recent-loading'>Loading recent scratchpads…</div>";
12995
+ return;
12996
+ }
12997
+ const currentKey = getCurrentStudioDocumentDescriptor().key;
12998
+ const entries = Array.isArray(scratchpadRecentEntries) ? scratchpadRecentEntries : [];
12999
+ if (!entries.length) {
13000
+ scratchpadRecentPanelEl.innerHTML = "<div class='scratchpad-recent-empty'>No other saved scratchpads yet.</div>";
13001
+ return;
13002
+ }
13003
+ scratchpadRecentPanelEl.innerHTML = "<div class='scratchpad-recent-list'>" + entries.map((entry) => {
13004
+ const key = String(entry && entry.documentKey ? entry.documentKey : "");
13005
+ const isCurrent = key === currentKey;
13006
+ const label = String(entry && entry.label ? entry.label : key || "scratchpad");
13007
+ const kind = String(entry && entry.kind ? entry.kind : "Scratchpad");
13008
+ const textLength = Math.max(0, Number(entry && entry.textLength) || 0);
13009
+ const preview = String(entry && entry.textPreview ? entry.textPreview : "");
13010
+ const meta = (isCurrent ? "Current · " : "") + kind + " · " + String(textLength) + " chars · " + formatScratchpadRecentTime(entry && entry.updatedAt);
13011
+ return "<div class='scratchpad-recent-item' data-scratchpad-key='" + escapeHtml(key) + "'>"
13012
+ + "<div class='scratchpad-recent-main'>"
13013
+ + "<div class='scratchpad-recent-title' title='" + escapeHtml(label) + "'>" + escapeHtml(label) + "</div>"
13014
+ + "<div class='scratchpad-recent-meta'>" + escapeHtml(meta) + "</div>"
13015
+ + (preview ? "<div class='scratchpad-recent-preview'>" + escapeHtml(preview) + "</div>" : "")
13016
+ + "</div>"
13017
+ + "<div class='scratchpad-recent-actions'>"
13018
+ + "<button type='button' data-scratchpad-recent-action='load' data-scratchpad-key='" + escapeHtml(key) + "'" + (isCurrent ? " disabled" : "") + ">Load</button>"
13019
+ + "<button type='button' data-scratchpad-recent-action='append' data-scratchpad-key='" + escapeHtml(key) + "'" + (isCurrent ? " disabled" : "") + ">Append</button>"
13020
+ + "<button type='button' data-scratchpad-recent-action='copy' data-scratchpad-key='" + escapeHtml(key) + "'>Copy</button>"
13021
+ + "</div>"
13022
+ + "</div>";
13023
+ }).join("") + "</div>";
13024
+ }
13025
+
13026
+ async function loadScratchpadRecentEntries() {
13027
+ scratchpadRecentLoading = true;
13028
+ renderScratchpadRecentPanel();
13029
+ try {
13030
+ const payload = await fetchStudioJson("/scratchpad-state", { query: { action: "recent", limit: "20" } });
13031
+ scratchpadRecentEntries = Array.isArray(payload && payload.scratchpads) ? payload.scratchpads : [];
13032
+ } catch (error) {
13033
+ scratchpadRecentEntries = [];
13034
+ setStatus("Could not load recent scratchpads: " + (error && error.message ? error.message : String(error || "unknown error")), "warning");
13035
+ } finally {
13036
+ scratchpadRecentLoading = false;
13037
+ renderScratchpadRecentPanel();
13038
+ }
13039
+ }
13040
+
13041
+ function toggleScratchpadRecentPanel() {
13042
+ scratchpadRecentVisible = !scratchpadRecentVisible;
13043
+ if (scratchpadRecentVisible) {
13044
+ void loadScratchpadRecentEntries();
13045
+ } else {
13046
+ renderScratchpadRecentPanel();
13047
+ }
13048
+ updateScratchpadUi();
13049
+ }
13050
+
13051
+ async function applyScratchpadRecentAction(action, documentKey) {
13052
+ const key = String(documentKey || "").trim();
13053
+ if (!key) return;
13054
+ const mode = action === "append" ? "append" : (action === "copy" ? "copy" : "load");
13055
+ try {
13056
+ const text = await fetchScratchpadTextForDocumentKey(key);
13057
+ if (!String(text || "").trim()) {
13058
+ setStatus("That scratchpad is empty.", "warning");
13059
+ return;
13060
+ }
13061
+ if (mode === "copy") {
13062
+ const ok = await writeTextToClipboard(text);
13063
+ setStatus(ok ? "Copied recent scratchpad." : "Could not copy recent scratchpad.", ok ? "success" : "warning");
13064
+ return;
13065
+ }
13066
+ if (mode === "append") {
13067
+ const separator = scratchpadText && !scratchpadText.endsWith("\n") ? "\n\n" : (scratchpadText ? "\n" : "");
13068
+ setScratchpadText(String(scratchpadText || "") + separator + String(text || ""));
13069
+ setStatus("Appended recent scratchpad.", "success");
13070
+ return;
13071
+ }
13072
+ if (String(scratchpadText || "").trim() && String(scratchpadText || "") !== String(text || "")) {
13073
+ const confirmed = window.confirm("Replace the current scratchpad with this recent scratchpad? Current scratchpad text will remain saved under its current document/draft identity, but this panel will show the loaded text for the current document.");
13074
+ if (!confirmed) return;
13075
+ }
13076
+ setScratchpadText(text);
13077
+ setStatus("Loaded recent scratchpad into current scratchpad.", "success");
13078
+ } catch (error) {
13079
+ setStatus("Could not use recent scratchpad: " + (error && error.message ? error.message : String(error || "unknown error")), "warning");
13080
+ }
13081
+ }
13082
+
12826
13083
  async function fetchScratchpadTextForDocumentKey(documentKey) {
12827
13084
  const payload = await fetchStudioJson("/scratchpad-state", {
12828
13085
  query: { documentKey: documentKey },
@@ -12830,9 +13087,9 @@
12830
13087
  return payload && typeof payload.text === "string" ? payload.text : "";
12831
13088
  }
12832
13089
 
12833
- function flushScratchpadPersistence(documentKeyOverride, textOverride) {
13090
+ function flushScratchpadPersistence(documentKeyOverride, textOverride, labelOverride) {
12834
13091
  const descriptor = documentKeyOverride
12835
- ? { key: String(documentKeyOverride || "").trim() }
13092
+ ? { key: String(documentKeyOverride || "").trim(), label: String(labelOverride || "").trim() }
12836
13093
  : getCurrentStudioDocumentDescriptor();
12837
13094
  const key = String(descriptor && descriptor.key ? descriptor.key : "").trim();
12838
13095
  if (!key) return;
@@ -12841,27 +13098,29 @@
12841
13098
  scratchpadPersistTimer = null;
12842
13099
  }
12843
13100
  const snapshot = String(arguments.length >= 2 ? textOverride : scratchpadText || "");
12844
- if (trySendStudioJsonBeacon("/scratchpad-state", { documentKey: key, text: snapshot })) {
13101
+ const label = String(descriptor && descriptor.label ? descriptor.label : "").trim();
13102
+ if (trySendStudioJsonBeacon("/scratchpad-state", { documentKey: key, text: snapshot, label })) {
12845
13103
  return;
12846
13104
  }
12847
13105
  void fetchStudioJson("/scratchpad-state", {
12848
13106
  method: "POST",
12849
- body: JSON.stringify({ documentKey: key, text: snapshot }),
13107
+ body: JSON.stringify({ documentKey: key, text: snapshot, label }),
12850
13108
  }).catch(() => {
12851
13109
  // Ignore scratchpad persistence failures for now.
12852
13110
  });
12853
13111
  }
12854
13112
 
12855
- function scheduleScratchpadPersistence(text, documentKey) {
13113
+ function scheduleScratchpadPersistence(text, documentKey, label) {
12856
13114
  if (scratchpadPersistTimer !== null) {
12857
13115
  window.clearTimeout(scratchpadPersistTimer);
12858
13116
  }
12859
13117
  const snapshot = String(text || "");
12860
13118
  const key = String(documentKey || "").trim();
13119
+ const labelSnapshot = String(label || "").trim();
12861
13120
  if (!key) return;
12862
13121
  scratchpadPersistTimer = window.setTimeout(() => {
12863
13122
  scratchpadPersistTimer = null;
12864
- flushScratchpadPersistence(key, snapshot);
13123
+ flushScratchpadPersistence(key, snapshot, labelSnapshot);
12865
13124
  }, 180);
12866
13125
  }
12867
13126
 
@@ -12893,7 +13152,7 @@
12893
13152
  if (String(existing || "").trim()) return;
12894
13153
  await fetchStudioJson("/scratchpad-state", {
12895
13154
  method: "POST",
12896
- body: JSON.stringify({ documentKey: nextDescriptor.key, text: snapshot }),
13155
+ body: JSON.stringify({ documentKey: nextDescriptor.key, text: snapshot, label: nextDescriptor.label }),
12897
13156
  });
12898
13157
  } catch {
12899
13158
  // Ignore carry-over failures and just fall back to normal scope loading.
@@ -12914,7 +13173,7 @@
12914
13173
 
12915
13174
  function persistScratchpadText(value) {
12916
13175
  const descriptor = getCurrentStudioDocumentDescriptor();
12917
- scheduleScratchpadPersistence(value, descriptor.key);
13176
+ scheduleScratchpadPersistence(value, descriptor.key, descriptor.label);
12918
13177
  }
12919
13178
 
12920
13179
  function normalizeReviewNoteAnchorKind(value) {
@@ -17111,6 +17370,10 @@
17111
17370
  ? ("Saved locally for this document/draft · " + normalized.length + " chars")
17112
17371
  : "Empty · local to this document/draft";
17113
17372
  }
17373
+ if (scratchpadRecentBtn) {
17374
+ scratchpadRecentBtn.textContent = scratchpadRecentVisible ? "Hide recent" : "Recent…";
17375
+ scratchpadRecentBtn.setAttribute("aria-expanded", scratchpadRecentVisible ? "true" : "false");
17376
+ }
17114
17377
  if (scratchpadInsertBtn) scratchpadInsertBtn.disabled = !hasContent;
17115
17378
  if (scratchpadCopyBtn) scratchpadCopyBtn.disabled = !hasContent;
17116
17379
  if (scratchpadClearBtn) scratchpadClearBtn.disabled = !normalized.length;
@@ -19100,7 +19363,7 @@
19100
19363
  event.stopPropagation();
19101
19364
  if (actionBtn.disabled) return;
19102
19365
  const format = String(actionBtn.getAttribute("data-export-preview-format") || "pdf").toLowerCase();
19103
- void exportRightPaneFormat(format === "html" ? "html" : "pdf");
19366
+ void exportRightPaneFormat(format);
19104
19367
  });
19105
19368
  }
19106
19369
 
@@ -19651,6 +19914,24 @@
19651
19914
  });
19652
19915
  }
19653
19916
 
19917
+ if (scratchpadRecentBtn) {
19918
+ scratchpadRecentBtn.addEventListener("click", () => {
19919
+ toggleScratchpadRecentPanel();
19920
+ });
19921
+ }
19922
+
19923
+ if (scratchpadRecentPanelEl) {
19924
+ scratchpadRecentPanelEl.addEventListener("click", (event) => {
19925
+ const target = event.target;
19926
+ const actionEl = target instanceof Element ? target.closest("[data-scratchpad-recent-action]") : null;
19927
+ if (!actionEl) return;
19928
+ event.preventDefault();
19929
+ const action = String(actionEl.getAttribute("data-scratchpad-recent-action") || "load");
19930
+ const key = String(actionEl.getAttribute("data-scratchpad-key") || "");
19931
+ void applyScratchpadRecentAction(action, key);
19932
+ });
19933
+ }
19934
+
19654
19935
  if (scratchpadInsertBtn) {
19655
19936
  scratchpadInsertBtn.addEventListener("click", () => {
19656
19937
  insertScratchpadIntoEditor();
package/client/studio.css CHANGED
@@ -4068,6 +4068,79 @@
4068
4068
  display: none !important;
4069
4069
  }
4070
4070
 
4071
+ .scratchpad-recent-panel {
4072
+ flex: 0 0 auto;
4073
+ max-height: 260px;
4074
+ overflow: auto;
4075
+ border-bottom: 1px solid var(--panel-border);
4076
+ background: var(--scratchpad-body-bg, var(--panel));
4077
+ padding: 10px 12px;
4078
+ }
4079
+
4080
+ .scratchpad-recent-panel[hidden] {
4081
+ display: none !important;
4082
+ }
4083
+
4084
+ .scratchpad-recent-empty,
4085
+ .scratchpad-recent-loading {
4086
+ color: var(--studio-info-text, var(--muted));
4087
+ font-size: 12px;
4088
+ padding: 8px 6px;
4089
+ }
4090
+
4091
+ .scratchpad-recent-list {
4092
+ display: grid;
4093
+ gap: 8px;
4094
+ }
4095
+
4096
+ .scratchpad-recent-item {
4097
+ display: grid;
4098
+ grid-template-columns: minmax(0, 1fr) auto;
4099
+ gap: 8px 12px;
4100
+ align-items: start;
4101
+ padding: 9px 10px;
4102
+ border: 1px solid var(--border-subtle);
4103
+ border-radius: 10px;
4104
+ background: var(--panel-2);
4105
+ }
4106
+
4107
+ .scratchpad-recent-main {
4108
+ min-width: 0;
4109
+ }
4110
+
4111
+ .scratchpad-recent-title {
4112
+ font-size: 12px;
4113
+ font-weight: 650;
4114
+ color: var(--text);
4115
+ overflow: hidden;
4116
+ text-overflow: ellipsis;
4117
+ white-space: nowrap;
4118
+ }
4119
+
4120
+ .scratchpad-recent-meta,
4121
+ .scratchpad-recent-preview {
4122
+ font-size: 11px;
4123
+ color: var(--studio-info-text, var(--muted));
4124
+ line-height: 1.35;
4125
+ }
4126
+
4127
+ .scratchpad-recent-preview {
4128
+ margin-top: 3px;
4129
+ }
4130
+
4131
+ .scratchpad-recent-actions {
4132
+ display: inline-flex;
4133
+ gap: 6px;
4134
+ flex-wrap: wrap;
4135
+ justify-content: flex-end;
4136
+ }
4137
+
4138
+ .scratchpad-recent-actions button {
4139
+ padding: 4px 7px;
4140
+ font-size: 11px;
4141
+ line-height: 1.1;
4142
+ }
4143
+
4071
4144
  .scratchpad-textarea {
4072
4145
  width: 100%;
4073
4146
  min-height: 280px;
package/index.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  transformStudioMarkdownOutsideFences,
23
23
  } from "./shared/studio-annotation-scanner.js";
24
24
  import { stripStudioMarkdownHtmlComments } from "./shared/studio-markdown-html-comments.js";
25
+ import { normalizeStudioMarkdownSmartFences } from "./shared/studio-markdown-fences.js";
25
26
  import {
26
27
  extractStandaloneLatexDefinitionsFromMarkdown,
27
28
  preserveLiteralLatexCommandsInMarkdown,
@@ -229,9 +230,15 @@ interface PersistedStudioReviewNote {
229
230
  htmlPreviewTitle?: string;
230
231
  }
231
232
 
233
+ interface PersistedStudioScratchpadMetadata {
234
+ label?: string;
235
+ updatedAt?: number;
236
+ }
237
+
232
238
  interface StudioPersistentState {
233
239
  version: 2;
234
240
  scratchpadsByDocument: Record<string, string>;
241
+ scratchpadMetadataByDocument: Record<string, PersistedStudioScratchpadMetadata>;
235
242
  reviewNotesByDocument: Record<string, PersistedStudioReviewNote[]>;
236
243
  }
237
244
 
@@ -728,6 +735,7 @@ function createEmptyStudioPersistentState(): StudioPersistentState {
728
735
  return {
729
736
  version: 2,
730
737
  scratchpadsByDocument: {},
738
+ scratchpadMetadataByDocument: {},
731
739
  reviewNotesByDocument: {},
732
740
  };
733
741
  }
@@ -784,6 +792,7 @@ function normalizeStudioPersistentState(value: unknown): StudioPersistentState {
784
792
  const candidate = value as Partial<StudioPersistentState> & {
785
793
  reviewNotesByDocument?: unknown;
786
794
  scratchpadsByDocument?: unknown;
795
+ scratchpadMetadataByDocument?: unknown;
787
796
  scratchpadText?: unknown;
788
797
  };
789
798
  const reviewNotesByDocument: Record<string, PersistedStudioReviewNote[]> = {};
@@ -807,9 +816,21 @@ function normalizeStudioPersistentState(value: unknown): StudioPersistentState {
807
816
  } else if (typeof candidate.scratchpadText === "string" && candidate.scratchpadText.length > 0) {
808
817
  scratchpadsByDocument[STUDIO_DEFAULT_SCRATCHPAD_DOCUMENT_KEY] = candidate.scratchpadText;
809
818
  }
819
+ const scratchpadMetadataByDocument: Record<string, PersistedStudioScratchpadMetadata> = {};
820
+ if (candidate.scratchpadMetadataByDocument && typeof candidate.scratchpadMetadataByDocument === "object") {
821
+ for (const [documentKey, rawMeta] of Object.entries(candidate.scratchpadMetadataByDocument as Record<string, unknown>)) {
822
+ if (typeof documentKey !== "string" || !documentKey.trim() || !rawMeta || typeof rawMeta !== "object") continue;
823
+ const meta = rawMeta as { label?: unknown; updatedAt?: unknown };
824
+ scratchpadMetadataByDocument[documentKey] = {
825
+ label: typeof meta.label === "string" ? meta.label : undefined,
826
+ updatedAt: typeof meta.updatedAt === "number" && Number.isFinite(meta.updatedAt) ? meta.updatedAt : undefined,
827
+ };
828
+ }
829
+ }
810
830
  return {
811
831
  version: 2,
812
832
  scratchpadsByDocument,
833
+ scratchpadMetadataByDocument,
813
834
  reviewNotesByDocument,
814
835
  };
815
836
  }
@@ -852,16 +873,50 @@ async function readPersistedStudioScratchpadText(documentKey: string): Promise<s
852
873
  return typeof value === "string" ? value : "";
853
874
  }
854
875
 
855
- async function writePersistedStudioScratchpadText(documentKey: string, text: string): Promise<void> {
876
+ function describePersistedScratchpadKey(documentKey: string): { label: string; kind: string } {
877
+ const key = String(documentKey || "").trim();
878
+ if (key.startsWith("file:")) return { label: key.slice(5) || "file", kind: "File" };
879
+ if (key.startsWith("draft:")) return { label: key.slice(6) || "draft", kind: "Draft" };
880
+ if (key.startsWith("doc:")) return { label: key.slice(4).replace(/^blank:/, "") || "document", kind: "Document" };
881
+ return { label: key || "scratchpad", kind: "Scratchpad" };
882
+ }
883
+
884
+ function summarizeScratchpadText(text: string): string {
885
+ const normalized = String(text || "").replace(/\s+/g, " ").trim();
886
+ return normalized.length > 160 ? `${normalized.slice(0, 157)}…` : normalized;
887
+ }
888
+
889
+ async function listRecentPersistedStudioScratchpads(limit = 20): Promise<Array<{ documentKey: string; label: string; kind: string; updatedAt: number; textPreview: string; textLength: number }>> {
890
+ const state = await loadStudioPersistentState();
891
+ return Object.entries(state.scratchpadsByDocument)
892
+ .filter((entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string" && entry[1].trim().length > 0)
893
+ .map(([documentKey, text]) => {
894
+ const fallback = describePersistedScratchpadKey(documentKey);
895
+ const meta = state.scratchpadMetadataByDocument[documentKey] ?? {};
896
+ const label = typeof meta.label === "string" && meta.label.trim() ? meta.label.trim() : fallback.label;
897
+ const updatedAt = typeof meta.updatedAt === "number" && Number.isFinite(meta.updatedAt) ? meta.updatedAt : 0;
898
+ return { documentKey, label, kind: fallback.kind, updatedAt, textPreview: summarizeScratchpadText(text), textLength: text.length };
899
+ })
900
+ .sort((left, right) => (right.updatedAt - left.updatedAt) || left.label.localeCompare(right.label) || left.documentKey.localeCompare(right.documentKey))
901
+ .slice(0, Math.max(1, Math.min(100, Math.floor(limit) || 20)));
902
+ }
903
+
904
+ async function writePersistedStudioScratchpadText(documentKey: string, text: string, label?: string): Promise<void> {
856
905
  const key = String(documentKey ?? "").trim();
857
906
  if (!key) return;
858
907
  await mutateStudioPersistentState((state) => {
859
908
  const normalized = String(text ?? "");
860
909
  if (normalized.length === 0) {
861
910
  delete state.scratchpadsByDocument[key];
911
+ delete state.scratchpadMetadataByDocument[key];
862
912
  return;
863
913
  }
864
914
  state.scratchpadsByDocument[key] = normalized;
915
+ state.scratchpadMetadataByDocument[key] = {
916
+ ...(state.scratchpadMetadataByDocument[key] ?? {}),
917
+ label: typeof label === "string" && label.trim() ? label.trim() : state.scratchpadMetadataByDocument[key]?.label,
918
+ updatedAt: Date.now(),
919
+ };
865
920
  });
866
921
  }
867
922
 
@@ -4582,7 +4637,8 @@ function hasStudioYamlHeaderIncludes(markdown: string): boolean {
4582
4637
  function prepareStudioMarkdownForPandoc(markdown: string, options?: { preserveLiteralLatexCommands?: boolean }): string {
4583
4638
  const shouldPreserveLiteralLatexCommands = options?.preserveLiteralLatexCommands !== false;
4584
4639
  return mapStudioMarkdownBodyPreservingYamlFrontMatter(markdown, (body) => {
4585
- const normalizedMath = normalizeMathDelimiters(body);
4640
+ const normalizedFences = normalizeStudioMarkdownSmartFences(body);
4641
+ const normalizedMath = normalizeMathDelimiters(normalizedFences);
4586
4642
  const latexReady = shouldPreserveLiteralLatexCommands
4587
4643
  ? preserveLiteralLatexCommandsInMarkdown(normalizedMath)
4588
4644
  : normalizedMath;
@@ -5432,9 +5488,10 @@ function prepareStudioPdfMarkdown(markdown: string, isLatex?: boolean, editorLan
5432
5488
  && !isStudioSingleFencedCodeBlock(input)
5433
5489
  ? wrapStudioCodeAsMarkdown(input, effectiveEditorLanguage)
5434
5490
  : input;
5491
+ const fenceNormalizedSource = effectiveEditorLanguage === "latex" ? source : normalizeStudioMarkdownSmartFences(source);
5435
5492
  const annotationReadySource = !effectiveEditorLanguage || effectiveEditorLanguage === "markdown" || effectiveEditorLanguage === "latex"
5436
- ? replaceStudioAnnotationMarkersForPdf(source)
5437
- : source;
5493
+ ? replaceStudioAnnotationMarkersForPdf(fenceNormalizedSource)
5494
+ : fenceNormalizedSource;
5438
5495
  const commentStrippedSource = stripStudioMarkdownHtmlCommentsPreservingYamlFrontMatter(annotationReadySource);
5439
5496
  return prepareStudioMarkdownForPandoc(commentStrippedSource, {
5440
5497
  preserveLiteralLatexCommands: !hasStudioYamlHeaderIncludes(annotationReadySource),
@@ -5733,7 +5790,8 @@ function decorateStudioPandocSyntaxHtml(html: string): string {
5733
5790
 
5734
5791
  async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolean, resourcePath?: string, sourcePath?: string): Promise<string> {
5735
5792
  const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
5736
- const markdownWithoutHtmlComments = isLatex ? markdown : stripStudioMarkdownHtmlCommentsPreservingYamlFrontMatter(markdown);
5793
+ const markdownWithNormalizedFences = isLatex ? markdown : normalizeStudioMarkdownSmartFences(markdown);
5794
+ const markdownWithoutHtmlComments = isLatex ? markdownWithNormalizedFences : stripStudioMarkdownHtmlCommentsPreservingYamlFrontMatter(markdownWithNormalizedFences);
5737
5795
  const markdownWithPreviewPageBreaks = isLatex ? markdownWithoutHtmlComments : replaceStudioPreviewPageBreakCommands(markdownWithoutHtmlComments);
5738
5796
  const latexSubfigurePreviewTransform = isLatex
5739
5797
  ? preprocessStudioLatexSubfiguresForPreview(markdownWithPreviewPageBreaks)
@@ -10101,8 +10159,10 @@ ${cssVarsBlock}
10101
10159
  <span id="exportPreviewControls" class="export-preview-controls">
10102
10160
  <button id="exportPdfBtn" class="export-preview-trigger" type="button" aria-haspopup="menu" aria-expanded="false" title="Choose a format and export the current right-pane preview.">Export right preview</button>
10103
10161
  <div id="exportPreviewMenu" class="export-preview-menu" role="menu" hidden>
10104
- <button id="exportPreviewPdfBtn" type="button" role="menuitem" data-export-preview-format="pdf">Export as PDF</button>
10105
- <button id="exportPreviewHtmlBtn" type="button" role="menuitem" data-export-preview-format="html">Export as HTML</button>
10162
+ <button id="exportPreviewPdfStudioBtn" type="button" role="menuitem" data-export-preview-format="pdf-studio">Export PDF and Open in Studio preview tab</button>
10163
+ <button id="exportPreviewPdfBtn" type="button" role="menuitem" data-export-preview-format="pdf-default">Export PDF and Open in default PDF viewer</button>
10164
+ <button id="exportPreviewHtmlStudioBtn" type="button" role="menuitem" data-export-preview-format="html-studio">Export HTML and Open in Studio editor</button>
10165
+ <button id="exportPreviewHtmlBtn" type="button" role="menuitem" data-export-preview-format="html-browser">Export HTML and Open in browser</button>
10106
10166
  </div>
10107
10167
  </span>
10108
10168
  </div>
@@ -10227,14 +10287,16 @@ ${cssVarsBlock}
10227
10287
  <div class="scratchpad-header">
10228
10288
  <div>
10229
10289
  <h2 id="scratchpadTitle">Scratchpad</h2>
10230
- <p class="scratchpad-description">Local persistent notes for thoughts you want to park while working on the current Studio document or draft. Closing the scratchpad does not clear it: notes persist locally for this document identity until you edit or clear them. File-backed documents reliably come back across Pi restarts; unsaved drafts stay with their own draft instance until you save them or discard them. Scratchpad text is not run, critiqued, sent, or exported unless you explicitly insert it into the editor.</p>
10290
+ <p class="scratchpad-description">Local persistent notes for thoughts you want to park while working on the current Studio document or draft. Closing the scratchpad does not clear it: notes persist locally for this document identity until you edit or clear them. File-backed documents reliably come back across Pi restarts; unsaved drafts stay with their own draft instance until you save them or discard them. Use Recent… to recover scratchpads from other draft identities after a Studio/Pi restart. Scratchpad text is not run, critiqued, sent, or exported unless you explicitly insert it into the editor.</p>
10231
10291
  </div>
10232
10292
  <button id="scratchpadCloseBtn" type="button" class="scratchpad-close-btn" aria-label="Keep current scratchpad text and close scratchpad" title="Keep current scratchpad text and close scratchpad">✕</button>
10233
10293
  </div>
10294
+ <div id="scratchpadRecentPanel" class="scratchpad-recent-panel" hidden></div>
10234
10295
  <textarea id="scratchpadText" class="scratchpad-textarea" placeholder="Jot quick thoughts, TODOs, or prompt ideas here..."></textarea>
10235
10296
  <div class="scratchpad-footer">
10236
10297
  <span id="scratchpadMeta" class="scratchpad-meta">Empty · local only</span>
10237
10298
  <div class="scratchpad-actions">
10299
+ <button id="scratchpadRecentBtn" type="button" title="Show recent non-empty scratchpads saved for other files and drafts.">Recent…</button>
10238
10300
  <button id="scratchpadInsertBtn" type="button" title="Insert the scratchpad text into the editor at the current selection, or append it if no editor selection is available.">Insert into editor</button>
10239
10301
  <button id="scratchpadCopyBtn" type="button" title="Copy scratchpad text to the clipboard.">Copy</button>
10240
10302
  <button id="scratchpadClearBtn" type="button" title="Clear scratchpad text.">Clear</button>
@@ -12607,6 +12669,12 @@ export default function (pi: ExtensionAPI) {
12607
12669
  const handleScratchpadStateRequest = async (req: IncomingMessage, res: ServerResponse, requestUrl: URL) => {
12608
12670
  const method = (req.method ?? "GET").toUpperCase();
12609
12671
  if (method === "GET") {
12672
+ const action = (requestUrl.searchParams.get("action") ?? "").trim().toLowerCase();
12673
+ if (action === "recent") {
12674
+ const limit = Number.parseInt(requestUrl.searchParams.get("limit") ?? "20", 10);
12675
+ respondJson(res, 200, { ok: true, scratchpads: await listRecentPersistedStudioScratchpads(limit) });
12676
+ return;
12677
+ }
12610
12678
  const documentKey = (requestUrl.searchParams.get("documentKey") ?? "").trim();
12611
12679
  if (!documentKey) {
12612
12680
  respondJson(res, 400, { ok: false, error: "Missing documentKey query parameter." });
@@ -12656,8 +12724,12 @@ export default function (pi: ExtensionAPI) {
12656
12724
  respondJson(res, 400, { ok: false, error: "Missing scratchpad text in request body." });
12657
12725
  return;
12658
12726
  }
12727
+ const label =
12728
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { label?: unknown }).label === "string"
12729
+ ? (parsedBody as { label: string }).label
12730
+ : undefined;
12659
12731
 
12660
- await writePersistedStudioScratchpadText(documentKey, text);
12732
+ await writePersistedStudioScratchpadText(documentKey, text, label);
12661
12733
  respondJson(res, 200, { ok: true });
12662
12734
  };
12663
12735
 
@@ -12966,6 +13038,11 @@ export default function (pi: ExtensionAPI) {
12966
13038
  parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { editorPdfLanguage?: unknown }).editorPdfLanguage === "string"
12967
13039
  ? (parsedBody as { editorPdfLanguage: string }).editorPdfLanguage
12968
13040
  : "";
13041
+ const requestedOpenTarget =
13042
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { openTarget?: unknown }).openTarget === "string"
13043
+ ? (parsedBody as { openTarget: string }).openTarget.trim().toLowerCase()
13044
+ : "default";
13045
+ const openTarget = requestedOpenTarget === "studio" ? "studio" : "default";
12969
13046
  const editorPdfLanguage = inferStudioPdfLanguage(markdown, requestedEditorPdfLanguage);
12970
13047
  const isLatex = editorPdfLanguage === "latex"
12971
13048
  || (
@@ -12979,17 +13056,48 @@ export default function (pi: ExtensionAPI) {
12979
13056
  const writeResult = writeStudioPreviewExportFile(buildStudioPreviewExportPath(sourcePath || undefined, userResourceDir || undefined, studioCwd, filename), pdf);
12980
13057
  const exportId = storePreparedPdfExport(pdf, filename, warning, writeResult.filePath ?? undefined);
12981
13058
  const token = serverState?.token ?? "";
13059
+ if (openTarget === "studio" && serverState && writeResult.filePath) {
13060
+ const exportedPath = writeResult.filePath;
13061
+ const title = sanitizeStudioPreviewBlockLine(filename || basename(exportedPath) || "PDF preview");
13062
+ const document: InitialStudioDocument = {
13063
+ text: "```studio-pdf\n"
13064
+ + `path: ${sanitizeStudioPreviewBlockLine(basename(exportedPath))}\n`
13065
+ + `title: ${title || "PDF preview"}\n`
13066
+ + "height: 820\n"
13067
+ + "```\n",
13068
+ label: `${filename || basename(exportedPath) || "PDF"} preview`,
13069
+ source: "blank",
13070
+ resourceDir: dirname(exportedPath),
13071
+ };
13072
+ const docId = storeTransientStudioDocument(document);
13073
+ const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId);
13074
+ const parsedUrl = new URL(url);
13075
+ respondJson(res, 200, {
13076
+ ok: true,
13077
+ filename,
13078
+ path: writeResult.filePath,
13079
+ writeError: writeResult.error,
13080
+ warning: warning ?? null,
13081
+ openedStudio: true,
13082
+ url,
13083
+ relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
13084
+ downloadUrl: `/export-pdf?token=${encodeURIComponent(token)}&id=${encodeURIComponent(exportId)}`,
13085
+ });
13086
+ return;
13087
+ }
12982
13088
  let openedExternal = false;
12983
13089
  let openError: string | null = null;
12984
- try {
12985
- const prepared = await ensurePreparedPdfExportFile(exportId);
12986
- if (!prepared?.filePath) {
12987
- throw new Error("Prepared PDF file was not available for external open.");
13090
+ if (openTarget !== "studio") {
13091
+ try {
13092
+ const prepared = await ensurePreparedPdfExportFile(exportId);
13093
+ if (!prepared?.filePath) {
13094
+ throw new Error("Prepared PDF file was not available for external open.");
13095
+ }
13096
+ await openPathInDefaultViewer(prepared.filePath);
13097
+ openedExternal = true;
13098
+ } catch (viewerError) {
13099
+ openError = viewerError instanceof Error ? viewerError.message : String(viewerError);
12988
13100
  }
12989
- await openPathInDefaultViewer(prepared.filePath);
12990
- openedExternal = true;
12991
- } catch (viewerError) {
12992
- openError = viewerError instanceof Error ? viewerError.message : String(viewerError);
12993
13101
  }
12994
13102
  respondJson(res, 200, {
12995
13103
  ok: true,
@@ -13068,6 +13176,11 @@ export default function (pi: ExtensionAPI) {
13068
13176
  parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { editorHtmlLanguage?: unknown }).editorHtmlLanguage === "string"
13069
13177
  ? (parsedBody as { editorHtmlLanguage: string }).editorHtmlLanguage
13070
13178
  : "";
13179
+ const requestedOpenTarget =
13180
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { openTarget?: unknown }).openTarget === "string"
13181
+ ? (parsedBody as { openTarget: string }).openTarget.trim().toLowerCase()
13182
+ : "browser";
13183
+ const openTarget = requestedOpenTarget === "studio" ? "studio" : "browser";
13071
13184
  const editorHtmlLanguage = inferStudioPdfLanguage(markdown, requestedEditorHtmlLanguage);
13072
13185
  const isLatex = editorHtmlLanguage === "latex"
13073
13186
  || (
@@ -13093,6 +13206,32 @@ export default function (pi: ExtensionAPI) {
13093
13206
  const writeResult = writeStudioPreviewExportFile(buildStudioPreviewExportPath(sourcePath || undefined, userResourceDir || undefined, studioCwd, filename), html);
13094
13207
  const exportId = storePreparedHtmlExport(html, filename, warning, writeResult.filePath ?? undefined);
13095
13208
  const token = serverState?.token ?? "";
13209
+ if (openTarget === "studio" && serverState) {
13210
+ const exportedPath = writeResult.filePath ?? "";
13211
+ const document: InitialStudioDocument = {
13212
+ text: html.toString("utf-8"),
13213
+ label: filename,
13214
+ source: exportedPath ? "file" : "blank",
13215
+ path: exportedPath || undefined,
13216
+ resourceDir: exportedPath ? dirname(exportedPath) : (userResourceDir || resourcePath || studioCwd),
13217
+ draftId: exportedPath ? undefined : createStudioDraftId(),
13218
+ };
13219
+ const docId = storeTransientStudioDocument(document);
13220
+ const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId);
13221
+ const parsedUrl = new URL(url);
13222
+ respondJson(res, 200, {
13223
+ ok: true,
13224
+ filename,
13225
+ path: writeResult.filePath,
13226
+ writeError: writeResult.error,
13227
+ warning: warning ?? null,
13228
+ openedStudio: true,
13229
+ url,
13230
+ relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
13231
+ downloadUrl: `/export-html?token=${encodeURIComponent(token)}&id=${encodeURIComponent(exportId)}`,
13232
+ });
13233
+ return;
13234
+ }
13096
13235
  let openedExternal = false;
13097
13236
  let openError: string | null = null;
13098
13237
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.20",
3
+ "version": "0.9.21",
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,22 @@
1
+ const SMART_MARKDOWN_FENCE_CHARS = "`´‘’‚‛“”„‟′‵";
2
+ const SMART_MARKDOWN_FENCE_RE = new RegExp(`^([ \\t]{0,3})([${SMART_MARKDOWN_FENCE_CHARS.replace(/[\\\]^-]/g, "\\$&")}]{3,})([^${SMART_MARKDOWN_FENCE_CHARS.replace(/[\\\]^-]/g, "\\$&")}\\r\\n]*)$`);
3
+
4
+ function normalizeSmartFenceRun(run) {
5
+ return "`".repeat(Math.max(3, Array.from(String(run || "")).length));
6
+ }
7
+
8
+ export function normalizeStudioMarkdownSmartFences(markdown) {
9
+ return String(markdown ?? "")
10
+ .replace(/\r\n/g, "\n")
11
+ .split("\n")
12
+ .map((line) => {
13
+ const match = line.match(SMART_MARKDOWN_FENCE_RE);
14
+ if (!match) return line;
15
+ const indent = match[1] ?? "";
16
+ const run = match[2] ?? "";
17
+ const suffix = match[3] ?? "";
18
+ if (!/[´‘’‚‛“”„‟′‵]/u.test(run)) return line;
19
+ return `${indent}${normalizeSmartFenceRun(run)}${suffix}`;
20
+ })
21
+ .join("\n");
22
+ }
@@ -0,0 +1,22 @@
1
+ const SMART_MARKDOWN_FENCE_CHARS = "`´‘’‚‛“”„‟′‵";
2
+ const SMART_MARKDOWN_FENCE_RE = new RegExp(`^([ \\t]{0,3})([${SMART_MARKDOWN_FENCE_CHARS.replace(/[\\\]^-]/g, "\\$&")}]{3,})([^${SMART_MARKDOWN_FENCE_CHARS.replace(/[\\\]^-]/g, "\\$&")}\\r\\n]*)$`);
3
+
4
+ function normalizeSmartFenceRun(run: string): string {
5
+ return "`".repeat(Math.max(3, Array.from(String(run || "")).length));
6
+ }
7
+
8
+ export function normalizeStudioMarkdownSmartFences(markdown: string): string {
9
+ return String(markdown ?? "")
10
+ .replace(/\r\n/g, "\n")
11
+ .split("\n")
12
+ .map((line) => {
13
+ const match = line.match(SMART_MARKDOWN_FENCE_RE);
14
+ if (!match) return line;
15
+ const indent = match[1] ?? "";
16
+ const run = match[2] ?? "";
17
+ const suffix = match[3] ?? "";
18
+ if (!/[´‘’‚‛“”„‟′‵]/u.test(run)) return line;
19
+ return `${indent}${normalizeSmartFenceRun(run)}${suffix}`;
20
+ })
21
+ .join("\n");
22
+ }