pi-studio 0.9.19 → 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/client/studio.css CHANGED
@@ -392,7 +392,6 @@
392
392
  }
393
393
 
394
394
  body[data-studio-mode="editor-only"] #editorViewSelect,
395
- body[data-studio-mode="editor-only"] #rightViewSelect,
396
395
  body[data-studio-mode="editor-only"] #sendRunBtn,
397
396
  body[data-studio-mode="editor-only"] #queueSteerBtn,
398
397
  body[data-studio-mode="editor-only"] #sendEditorBtn,
@@ -424,7 +423,7 @@
424
423
  }
425
424
 
426
425
  body[data-studio-mode="editor-only"] #rightSectionHeader .section-header-main::before {
427
- content: "Preview";
426
+ content: "View";
428
427
  font-weight: 600;
429
428
  font-size: 14px;
430
429
  }
@@ -2506,6 +2505,35 @@
2506
2505
  white-space: nowrap;
2507
2506
  }
2508
2507
 
2508
+ .rendered-markdown .studio-html-artifact-comment-btn {
2509
+ flex: 0 0 auto;
2510
+ min-height: 24px;
2511
+ padding: 0 9px;
2512
+ border: 1px solid var(--control-border);
2513
+ border-radius: 999px;
2514
+ background: var(--panel);
2515
+ color: var(--text);
2516
+ font: inherit;
2517
+ font-size: 11px;
2518
+ line-height: 1;
2519
+ cursor: pointer;
2520
+ white-space: nowrap;
2521
+ }
2522
+
2523
+ .rendered-markdown .studio-html-artifact-comment-btn:not(:disabled):hover,
2524
+ .rendered-markdown .studio-html-artifact-comment-btn:focus-visible {
2525
+ background: var(--control-hover-bg, var(--inline-code-bg));
2526
+ border-color: var(--control-border-hover, var(--accent));
2527
+ outline: none;
2528
+ }
2529
+
2530
+ .rendered-markdown .studio-html-artifact-comment-btn.is-active,
2531
+ .rendered-markdown .studio-html-artifact-shell.is-comment-mode .studio-html-artifact-comment-btn.is-active {
2532
+ background: var(--accent-soft);
2533
+ border-color: var(--accent);
2534
+ color: var(--accent);
2535
+ }
2536
+
2509
2537
  .rendered-markdown .studio-html-artifact-zoom-controls {
2510
2538
  flex: 0 0 auto;
2511
2539
  display: inline-flex;
@@ -4036,6 +4064,83 @@
4036
4064
  line-height: 1.35;
4037
4065
  }
4038
4066
 
4067
+ body[data-studio-mode="editor-only"] .shortcuts-full-only {
4068
+ display: none !important;
4069
+ }
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
+
4039
4144
  .scratchpad-textarea {
4040
4145
  width: 100%;
4041
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,
@@ -209,6 +210,8 @@ interface InitialStudioDocument {
209
210
  resourceDir?: string;
210
211
  }
211
212
 
213
+ type PersistedStudioReviewNoteAnchorKind = "source" | "html-selection" | "html-element" | "html-page";
214
+
212
215
  interface PersistedStudioReviewNote {
213
216
  id: string;
214
217
  text: string;
@@ -220,11 +223,22 @@ interface PersistedStudioReviewNote {
220
223
  lineEnd: number;
221
224
  selectedText: string;
222
225
  selectedDisplayText?: string;
226
+ anchorKind?: PersistedStudioReviewNoteAnchorKind;
227
+ htmlSelector?: string;
228
+ htmlTag?: string;
229
+ htmlLabel?: string;
230
+ htmlPreviewTitle?: string;
231
+ }
232
+
233
+ interface PersistedStudioScratchpadMetadata {
234
+ label?: string;
235
+ updatedAt?: number;
223
236
  }
224
237
 
225
238
  interface StudioPersistentState {
226
239
  version: 2;
227
240
  scratchpadsByDocument: Record<string, string>;
241
+ scratchpadMetadataByDocument: Record<string, PersistedStudioScratchpadMetadata>;
228
242
  reviewNotesByDocument: Record<string, PersistedStudioReviewNote[]>;
229
243
  }
230
244
 
@@ -721,10 +735,15 @@ function createEmptyStudioPersistentState(): StudioPersistentState {
721
735
  return {
722
736
  version: 2,
723
737
  scratchpadsByDocument: {},
738
+ scratchpadMetadataByDocument: {},
724
739
  reviewNotesByDocument: {},
725
740
  };
726
741
  }
727
742
 
743
+ function normalizePersistedStudioReviewNoteAnchorKind(value: unknown): PersistedStudioReviewNoteAnchorKind {
744
+ return value === "html-selection" || value === "html-element" || value === "html-page" ? value : "source";
745
+ }
746
+
728
747
  function normalizePersistedStudioReviewNote(value: unknown): PersistedStudioReviewNote | null {
729
748
  if (!value || typeof value !== "object") return null;
730
749
  const candidate = value as Partial<PersistedStudioReviewNote>;
@@ -759,6 +778,11 @@ function normalizePersistedStudioReviewNote(value: unknown): PersistedStudioRevi
759
778
  lineEnd,
760
779
  selectedText: typeof candidate.selectedText === "string" ? candidate.selectedText : "",
761
780
  selectedDisplayText: typeof candidate.selectedDisplayText === "string" ? candidate.selectedDisplayText : "",
781
+ anchorKind: normalizePersistedStudioReviewNoteAnchorKind(candidate.anchorKind),
782
+ htmlSelector: typeof candidate.htmlSelector === "string" ? candidate.htmlSelector : "",
783
+ htmlTag: typeof candidate.htmlTag === "string" ? candidate.htmlTag : "",
784
+ htmlLabel: typeof candidate.htmlLabel === "string" ? candidate.htmlLabel : "",
785
+ htmlPreviewTitle: typeof candidate.htmlPreviewTitle === "string" ? candidate.htmlPreviewTitle : "",
762
786
  };
763
787
  }
764
788
 
@@ -768,6 +792,7 @@ function normalizeStudioPersistentState(value: unknown): StudioPersistentState {
768
792
  const candidate = value as Partial<StudioPersistentState> & {
769
793
  reviewNotesByDocument?: unknown;
770
794
  scratchpadsByDocument?: unknown;
795
+ scratchpadMetadataByDocument?: unknown;
771
796
  scratchpadText?: unknown;
772
797
  };
773
798
  const reviewNotesByDocument: Record<string, PersistedStudioReviewNote[]> = {};
@@ -791,9 +816,21 @@ function normalizeStudioPersistentState(value: unknown): StudioPersistentState {
791
816
  } else if (typeof candidate.scratchpadText === "string" && candidate.scratchpadText.length > 0) {
792
817
  scratchpadsByDocument[STUDIO_DEFAULT_SCRATCHPAD_DOCUMENT_KEY] = candidate.scratchpadText;
793
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
+ }
794
830
  return {
795
831
  version: 2,
796
832
  scratchpadsByDocument,
833
+ scratchpadMetadataByDocument,
797
834
  reviewNotesByDocument,
798
835
  };
799
836
  }
@@ -836,16 +873,50 @@ async function readPersistedStudioScratchpadText(documentKey: string): Promise<s
836
873
  return typeof value === "string" ? value : "";
837
874
  }
838
875
 
839
- 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> {
840
905
  const key = String(documentKey ?? "").trim();
841
906
  if (!key) return;
842
907
  await mutateStudioPersistentState((state) => {
843
908
  const normalized = String(text ?? "");
844
909
  if (normalized.length === 0) {
845
910
  delete state.scratchpadsByDocument[key];
911
+ delete state.scratchpadMetadataByDocument[key];
846
912
  return;
847
913
  }
848
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
+ };
849
920
  });
850
921
  }
851
922
 
@@ -4566,7 +4637,8 @@ function hasStudioYamlHeaderIncludes(markdown: string): boolean {
4566
4637
  function prepareStudioMarkdownForPandoc(markdown: string, options?: { preserveLiteralLatexCommands?: boolean }): string {
4567
4638
  const shouldPreserveLiteralLatexCommands = options?.preserveLiteralLatexCommands !== false;
4568
4639
  return mapStudioMarkdownBodyPreservingYamlFrontMatter(markdown, (body) => {
4569
- const normalizedMath = normalizeMathDelimiters(body);
4640
+ const normalizedFences = normalizeStudioMarkdownSmartFences(body);
4641
+ const normalizedMath = normalizeMathDelimiters(normalizedFences);
4570
4642
  const latexReady = shouldPreserveLiteralLatexCommands
4571
4643
  ? preserveLiteralLatexCommandsInMarkdown(normalizedMath)
4572
4644
  : normalizedMath;
@@ -5416,9 +5488,10 @@ function prepareStudioPdfMarkdown(markdown: string, isLatex?: boolean, editorLan
5416
5488
  && !isStudioSingleFencedCodeBlock(input)
5417
5489
  ? wrapStudioCodeAsMarkdown(input, effectiveEditorLanguage)
5418
5490
  : input;
5491
+ const fenceNormalizedSource = effectiveEditorLanguage === "latex" ? source : normalizeStudioMarkdownSmartFences(source);
5419
5492
  const annotationReadySource = !effectiveEditorLanguage || effectiveEditorLanguage === "markdown" || effectiveEditorLanguage === "latex"
5420
- ? replaceStudioAnnotationMarkersForPdf(source)
5421
- : source;
5493
+ ? replaceStudioAnnotationMarkersForPdf(fenceNormalizedSource)
5494
+ : fenceNormalizedSource;
5422
5495
  const commentStrippedSource = stripStudioMarkdownHtmlCommentsPreservingYamlFrontMatter(annotationReadySource);
5423
5496
  return prepareStudioMarkdownForPandoc(commentStrippedSource, {
5424
5497
  preserveLiteralLatexCommands: !hasStudioYamlHeaderIncludes(annotationReadySource),
@@ -5717,7 +5790,8 @@ function decorateStudioPandocSyntaxHtml(html: string): string {
5717
5790
 
5718
5791
  async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolean, resourcePath?: string, sourcePath?: string): Promise<string> {
5719
5792
  const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
5720
- const markdownWithoutHtmlComments = isLatex ? markdown : stripStudioMarkdownHtmlCommentsPreservingYamlFrontMatter(markdown);
5793
+ const markdownWithNormalizedFences = isLatex ? markdown : normalizeStudioMarkdownSmartFences(markdown);
5794
+ const markdownWithoutHtmlComments = isLatex ? markdownWithNormalizedFences : stripStudioMarkdownHtmlCommentsPreservingYamlFrontMatter(markdownWithNormalizedFences);
5721
5795
  const markdownWithPreviewPageBreaks = isLatex ? markdownWithoutHtmlComments : replaceStudioPreviewPageBreakCommands(markdownWithoutHtmlComments);
5722
5796
  const latexSubfigurePreviewTransform = isLatex
5723
5797
  ? preprocessStudioLatexSubfiguresForPreview(markdownWithPreviewPageBreaks)
@@ -10042,14 +10116,14 @@ ${cssVarsBlock}
10042
10116
  <div class="scratchpad-header">
10043
10117
  <div>
10044
10118
  <h2 id="reviewNotesTitle">Comments</h2>
10045
- <p class="scratchpad-description">Local comments for editor text. Stay out of the text, anchored to selections or lines, and can be converted into inline <span class="review-notes-inline-token">[an: ...]</span> annotations.</p>
10119
+ <p class="scratchpad-description">Local comments for editor text and editor previews. They stay out of the text; source-anchored comments can be converted into inline <span class="review-notes-inline-token">[an: ...]</span> annotations.</p>
10046
10120
  </div>
10047
10121
  <button id="reviewNotesCloseBtn" type="button" class="scratchpad-close-btn" aria-label="Hide comments" title="Hide comments">✕</button>
10048
10122
  </div>
10049
10123
  <div class="review-notes-toolbar">
10050
10124
  <span id="reviewNotesMeta" class="scratchpad-meta">No comments</span>
10051
10125
  </div>
10052
- <div id="reviewNotesEmptyState" class="review-notes-empty">No comments yet for this document. Select text in <strong>Editor (Raw)</strong> or <strong>Editor (Preview)</strong> and use <em>Comment</em>, or use <em>Line comment</em> in <strong>Editor (Raw)</strong>.</div>
10126
+ <div id="reviewNotesEmptyState" class="review-notes-empty">No comments yet for this document. Select text in <strong>Editor (Raw)</strong> or <strong>Editor (Preview)</strong> and use <em>Comment</em>, use <em>Line comment</em> in <strong>Editor (Raw)</strong>, or use <em>Comment mode</em> in an editor HTML preview.</div>
10053
10127
  <div id="reviewNotesList" class="review-notes-list" aria-live="polite"></div>
10054
10128
  <div class="review-notes-dock-footer">
10055
10129
  <div class="scratchpad-actions">
@@ -10085,8 +10159,10 @@ ${cssVarsBlock}
10085
10159
  <span id="exportPreviewControls" class="export-preview-controls">
10086
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>
10087
10161
  <div id="exportPreviewMenu" class="export-preview-menu" role="menu" hidden>
10088
- <button id="exportPreviewPdfBtn" type="button" role="menuitem" data-export-preview-format="pdf">Export as PDF</button>
10089
- <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>
10090
10166
  </div>
10091
10167
  </span>
10092
10168
  </div>
@@ -10181,14 +10257,14 @@ ${cssVarsBlock}
10181
10257
  <h3>Editor</h3>
10182
10258
  <dl>
10183
10259
  <div><dt>Cmd/Ctrl+S</dt><dd>Save editor</dd></div>
10184
- <div><dt>Cmd/Ctrl+Enter</dt><dd>Run editor text, or queue steering during an active run</dd></div>
10260
+ <div class="shortcuts-full-only"><dt>Cmd/Ctrl+Enter</dt><dd>Run editor text, or queue steering during an active run</dd></div>
10185
10261
  <div><dt>Option/Alt+Tab or Cmd/Ctrl+Shift+Space</dt><dd>Suggest a completion at the editor cursor</dd></div>
10186
10262
  <div><dt>Tab</dt><dd>Insert a visible completion suggestion; otherwise indent selected editor text</dd></div>
10187
10263
  <div><dt>Esc</dt><dd>Dismiss a visible completion suggestion, close overlays, exit pane focus, or stop an active request</dd></div>
10188
10264
  <div><dt>Shift+Tab</dt><dd>Unindent selected editor text</dd></div>
10189
10265
  </dl>
10190
10266
  </section>
10191
- <section class="shortcuts-group">
10267
+ <section class="shortcuts-group shortcuts-full-only">
10192
10268
  <h3>Response</h3>
10193
10269
  <dl>
10194
10270
  <div><dt>Alt/Option+←</dt><dd>Previous response when not editing text</dd></div>
@@ -10211,14 +10287,16 @@ ${cssVarsBlock}
10211
10287
  <div class="scratchpad-header">
10212
10288
  <div>
10213
10289
  <h2 id="scratchpadTitle">Scratchpad</h2>
10214
- <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>
10215
10291
  </div>
10216
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>
10217
10293
  </div>
10294
+ <div id="scratchpadRecentPanel" class="scratchpad-recent-panel" hidden></div>
10218
10295
  <textarea id="scratchpadText" class="scratchpad-textarea" placeholder="Jot quick thoughts, TODOs, or prompt ideas here..."></textarea>
10219
10296
  <div class="scratchpad-footer">
10220
10297
  <span id="scratchpadMeta" class="scratchpad-meta">Empty · local only</span>
10221
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>
10222
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>
10223
10301
  <button id="scratchpadCopyBtn" type="button" title="Copy scratchpad text to the clipboard.">Copy</button>
10224
10302
  <button id="scratchpadClearBtn" type="button" title="Clear scratchpad text.">Clear</button>
@@ -12591,6 +12669,12 @@ export default function (pi: ExtensionAPI) {
12591
12669
  const handleScratchpadStateRequest = async (req: IncomingMessage, res: ServerResponse, requestUrl: URL) => {
12592
12670
  const method = (req.method ?? "GET").toUpperCase();
12593
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
+ }
12594
12678
  const documentKey = (requestUrl.searchParams.get("documentKey") ?? "").trim();
12595
12679
  if (!documentKey) {
12596
12680
  respondJson(res, 400, { ok: false, error: "Missing documentKey query parameter." });
@@ -12640,8 +12724,12 @@ export default function (pi: ExtensionAPI) {
12640
12724
  respondJson(res, 400, { ok: false, error: "Missing scratchpad text in request body." });
12641
12725
  return;
12642
12726
  }
12727
+ const label =
12728
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { label?: unknown }).label === "string"
12729
+ ? (parsedBody as { label: string }).label
12730
+ : undefined;
12643
12731
 
12644
- await writePersistedStudioScratchpadText(documentKey, text);
12732
+ await writePersistedStudioScratchpadText(documentKey, text, label);
12645
12733
  respondJson(res, 200, { ok: true });
12646
12734
  };
12647
12735
 
@@ -12950,6 +13038,11 @@ export default function (pi: ExtensionAPI) {
12950
13038
  parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { editorPdfLanguage?: unknown }).editorPdfLanguage === "string"
12951
13039
  ? (parsedBody as { editorPdfLanguage: string }).editorPdfLanguage
12952
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";
12953
13046
  const editorPdfLanguage = inferStudioPdfLanguage(markdown, requestedEditorPdfLanguage);
12954
13047
  const isLatex = editorPdfLanguage === "latex"
12955
13048
  || (
@@ -12963,17 +13056,48 @@ export default function (pi: ExtensionAPI) {
12963
13056
  const writeResult = writeStudioPreviewExportFile(buildStudioPreviewExportPath(sourcePath || undefined, userResourceDir || undefined, studioCwd, filename), pdf);
12964
13057
  const exportId = storePreparedPdfExport(pdf, filename, warning, writeResult.filePath ?? undefined);
12965
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
+ }
12966
13088
  let openedExternal = false;
12967
13089
  let openError: string | null = null;
12968
- try {
12969
- const prepared = await ensurePreparedPdfExportFile(exportId);
12970
- if (!prepared?.filePath) {
12971
- 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);
12972
13100
  }
12973
- await openPathInDefaultViewer(prepared.filePath);
12974
- openedExternal = true;
12975
- } catch (viewerError) {
12976
- openError = viewerError instanceof Error ? viewerError.message : String(viewerError);
12977
13101
  }
12978
13102
  respondJson(res, 200, {
12979
13103
  ok: true,
@@ -13052,6 +13176,11 @@ export default function (pi: ExtensionAPI) {
13052
13176
  parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { editorHtmlLanguage?: unknown }).editorHtmlLanguage === "string"
13053
13177
  ? (parsedBody as { editorHtmlLanguage: string }).editorHtmlLanguage
13054
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";
13055
13184
  const editorHtmlLanguage = inferStudioPdfLanguage(markdown, requestedEditorHtmlLanguage);
13056
13185
  const isLatex = editorHtmlLanguage === "latex"
13057
13186
  || (
@@ -13077,6 +13206,32 @@ export default function (pi: ExtensionAPI) {
13077
13206
  const writeResult = writeStudioPreviewExportFile(buildStudioPreviewExportPath(sourcePath || undefined, userResourceDir || undefined, studioCwd, filename), html);
13078
13207
  const exportId = storePreparedHtmlExport(html, filename, warning, writeResult.filePath ?? undefined);
13079
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
+ }
13080
13235
  let openedExternal = false;
13081
13236
  let openError: string | null = null;
13082
13237
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.19",
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
+ }