pi-studio 0.9.20 → 0.9.22

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
@@ -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;
@@ -5103,6 +5176,15 @@
5103
5176
  font-weight: 450;
5104
5177
  }
5105
5178
 
5179
+ body.studio-ui-refresh .studio-refresh-menu-item > .source-origin-summary {
5180
+ width: 100%;
5181
+ border-color: var(--border-subtle);
5182
+ background: var(--panel-2);
5183
+ color: var(--studio-info-text, var(--muted));
5184
+ white-space: normal;
5185
+ line-height: 1.35;
5186
+ }
5187
+
5106
5188
  body.studio-ui-refresh .studio-refresh-menu #critiqueBtn {
5107
5189
  justify-content: flex-start;
5108
5190
  text-align: left;
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)
@@ -6933,7 +6991,7 @@ async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResp
6933
6991
  }
6934
6992
  const document = buildStudioLocalResourcePreviewDocument(resource);
6935
6993
  const docId = storeTransientStudioDocument(document);
6936
- const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId);
6994
+ const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true });
6937
6995
  const parsedUrl = new URL(url);
6938
6996
  respondJson(res, 200, {
6939
6997
  ...basePayload,
@@ -6998,7 +7056,7 @@ async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResp
6998
7056
  }
6999
7057
 
7000
7058
  const docId = storeTransientStudioDocument(document);
7001
- const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId);
7059
+ const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true });
7002
7060
  const parsedUrl = new URL(url);
7003
7061
  respondJson(res, 200, {
7004
7062
  ...basePayload,
@@ -9467,6 +9525,7 @@ function buildStudioUrl(
9467
9525
  mode: StudioUiMode = "full",
9468
9526
  doc?: InitialStudioDocument | null,
9469
9527
  docId?: string,
9528
+ options?: { skipWorkspaceRestore?: boolean },
9470
9529
  ): string {
9471
9530
  const params = new URLSearchParams({ token });
9472
9531
  if (mode !== "full") params.set("mode", mode);
@@ -9476,6 +9535,7 @@ function buildStudioUrl(
9476
9535
  if (doc?.path) params.set("docPath", doc.path);
9477
9536
  if (doc?.draftId) params.set("draftId", doc.draftId);
9478
9537
  if (doc?.resourceDir) params.set("resourceDir", doc.resourceDir);
9538
+ if (options?.skipWorkspaceRestore) params.set("skipWorkspaceRestore", "1");
9479
9539
  return `http://127.0.0.1:${port}/?${params.toString()}`;
9480
9540
  }
9481
9541
 
@@ -9936,8 +9996,8 @@ ${cssVarsBlock}
9936
9996
  <option value="cursor" selected>Context: editor only</option>
9937
9997
  <option value="session">Context: editor + latest response</option>
9938
9998
  </select>
9939
- <button id="openCompanionBtn" type="button" title="Open a detached copy of the current editor text in a new editor-only Studio tab.">New editor</button>
9940
- <button id="sendEditorBtn" type="button">Send to pi editor</button>
9999
+ <button id="openCompanionBtn" type="button" title="Open a blank editor-only Studio tab.">New editor tab</button>
10000
+ <button id="sendEditorBtn" type="button">Send current text to Pi editor</button>
9941
10001
  </div>
9942
10002
  <div class="source-actions-row">
9943
10003
  <button id="insertHeaderBtn" type="button" title="Insert annotated-reply protocol header (source metadata, [an: ...] syntax hint, precedence note, and end marker).">Annotation header</button>
@@ -10058,7 +10118,7 @@ ${cssVarsBlock}
10058
10118
  <div class="scratchpad-header">
10059
10119
  <div>
10060
10120
  <h2 id="reviewNotesTitle">Comments</h2>
10061
- <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>
10121
+ <p class="scratchpad-description">Local comments for editor text and editor previews. They stay out of the text; can be converted into inline <span class="review-notes-inline-token">[an: ...]</span> annotations.</p>
10062
10122
  </div>
10063
10123
  <button id="reviewNotesCloseBtn" type="button" class="scratchpad-close-btn" aria-label="Hide comments" title="Hide comments">✕</button>
10064
10124
  </div>
@@ -10087,7 +10147,7 @@ ${cssVarsBlock}
10087
10147
  <section id="rightPane">
10088
10148
  <div id="rightSectionHeader" class="section-header">
10089
10149
  <div class="section-header-main">
10090
- <select id="rightViewSelect" aria-label="Response view mode" title="Right pane view mode. Shortcut: F7 when the right pane is active; F6 switches panes.">
10150
+ <select id="rightViewSelect" aria-label="Response view mode" title="Right pane view mode. F7 cycles when the right pane is active; Cmd/Ctrl+Alt+P switches directly to Preview.">
10091
10151
  <option value="markdown">Response (Raw)</option>
10092
10152
  <option value="preview" selected>Response (Preview)</option>
10093
10153
  <option value="editor-preview">Editor (Preview)</option>
@@ -10101,8 +10161,10 @@ ${cssVarsBlock}
10101
10161
  <span id="exportPreviewControls" class="export-preview-controls">
10102
10162
  <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
10163
  <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>
10164
+ <button id="exportPreviewPdfStudioBtn" type="button" role="menuitem" data-export-preview-format="pdf-studio">Export PDF and Open in Studio preview tab</button>
10165
+ <button id="exportPreviewPdfBtn" type="button" role="menuitem" data-export-preview-format="pdf-default">Export PDF and Open in default PDF viewer</button>
10166
+ <button id="exportPreviewHtmlStudioBtn" type="button" role="menuitem" data-export-preview-format="html-studio">Export HTML and Open in Studio editor</button>
10167
+ <button id="exportPreviewHtmlBtn" type="button" role="menuitem" data-export-preview-format="html-browser">Export HTML and Open in browser</button>
10106
10168
  </div>
10107
10169
  </span>
10108
10170
  </div>
@@ -10177,6 +10239,7 @@ ${cssVarsBlock}
10177
10239
  <dl>
10178
10240
  <div><dt>F6</dt><dd>Switch between editor and right pane</dd></div>
10179
10241
  <div><dt>F7 / Shift+F7</dt><dd>Cycle the active pane's view</dd></div>
10242
+ <div><dt>Cmd/Ctrl+Alt+P</dt><dd>Switch the right pane directly to Preview</dd></div>
10180
10243
  <div><dt>F8</dt><dd>Focus editor text</dd></div>
10181
10244
  <div><dt>Shift+F8</dt><dd>Focus right-pane content</dd></div>
10182
10245
  <div><dt>F9</dt><dd>Toggle Zen mode</dd></div>
@@ -10227,14 +10290,16 @@ ${cssVarsBlock}
10227
10290
  <div class="scratchpad-header">
10228
10291
  <div>
10229
10292
  <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>
10293
+ <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
10294
  </div>
10232
10295
  <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
10296
  </div>
10297
+ <div id="scratchpadRecentPanel" class="scratchpad-recent-panel" hidden></div>
10234
10298
  <textarea id="scratchpadText" class="scratchpad-textarea" placeholder="Jot quick thoughts, TODOs, or prompt ideas here..."></textarea>
10235
10299
  <div class="scratchpad-footer">
10236
10300
  <span id="scratchpadMeta" class="scratchpad-meta">Empty · local only</span>
10237
10301
  <div class="scratchpad-actions">
10302
+ <button id="scratchpadRecentBtn" type="button" title="Show recent non-empty scratchpads saved for other files and drafts.">Recent…</button>
10238
10303
  <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
10304
  <button id="scratchpadCopyBtn" type="button" title="Copy scratchpad text to the clipboard.">Copy</button>
10240
10305
  <button id="scratchpadClearBtn" type="button" title="Clear scratchpad text.">Clear</button>
@@ -11634,7 +11699,7 @@ export default function (pi: ExtensionAPI) {
11634
11699
  resourceDir,
11635
11700
  };
11636
11701
  const docId = storeTransientStudioDocument(document);
11637
- const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId);
11702
+ const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true });
11638
11703
  const parsedUrl = new URL(url);
11639
11704
  sendToClient(client, {
11640
11705
  type: "editor_only_ready",
@@ -11642,8 +11707,8 @@ export default function (pi: ExtensionAPI) {
11642
11707
  url,
11643
11708
  relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
11644
11709
  message: hasContent
11645
- ? "Companion editor is ready with a detached copy of the current editor text."
11646
- : "Blank companion editor is ready.",
11710
+ ? "Editor tab is ready with a detached copy of the current editor text."
11711
+ : "Blank editor tab is ready.",
11647
11712
  });
11648
11713
  return;
11649
11714
  }
@@ -12607,6 +12672,12 @@ export default function (pi: ExtensionAPI) {
12607
12672
  const handleScratchpadStateRequest = async (req: IncomingMessage, res: ServerResponse, requestUrl: URL) => {
12608
12673
  const method = (req.method ?? "GET").toUpperCase();
12609
12674
  if (method === "GET") {
12675
+ const action = (requestUrl.searchParams.get("action") ?? "").trim().toLowerCase();
12676
+ if (action === "recent") {
12677
+ const limit = Number.parseInt(requestUrl.searchParams.get("limit") ?? "20", 10);
12678
+ respondJson(res, 200, { ok: true, scratchpads: await listRecentPersistedStudioScratchpads(limit) });
12679
+ return;
12680
+ }
12610
12681
  const documentKey = (requestUrl.searchParams.get("documentKey") ?? "").trim();
12611
12682
  if (!documentKey) {
12612
12683
  respondJson(res, 400, { ok: false, error: "Missing documentKey query parameter." });
@@ -12656,8 +12727,12 @@ export default function (pi: ExtensionAPI) {
12656
12727
  respondJson(res, 400, { ok: false, error: "Missing scratchpad text in request body." });
12657
12728
  return;
12658
12729
  }
12730
+ const label =
12731
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { label?: unknown }).label === "string"
12732
+ ? (parsedBody as { label: string }).label
12733
+ : undefined;
12659
12734
 
12660
- await writePersistedStudioScratchpadText(documentKey, text);
12735
+ await writePersistedStudioScratchpadText(documentKey, text, label);
12661
12736
  respondJson(res, 200, { ok: true });
12662
12737
  };
12663
12738
 
@@ -12966,6 +13041,11 @@ export default function (pi: ExtensionAPI) {
12966
13041
  parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { editorPdfLanguage?: unknown }).editorPdfLanguage === "string"
12967
13042
  ? (parsedBody as { editorPdfLanguage: string }).editorPdfLanguage
12968
13043
  : "";
13044
+ const requestedOpenTarget =
13045
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { openTarget?: unknown }).openTarget === "string"
13046
+ ? (parsedBody as { openTarget: string }).openTarget.trim().toLowerCase()
13047
+ : "default";
13048
+ const openTarget = requestedOpenTarget === "studio" ? "studio" : "default";
12969
13049
  const editorPdfLanguage = inferStudioPdfLanguage(markdown, requestedEditorPdfLanguage);
12970
13050
  const isLatex = editorPdfLanguage === "latex"
12971
13051
  || (
@@ -12979,17 +13059,48 @@ export default function (pi: ExtensionAPI) {
12979
13059
  const writeResult = writeStudioPreviewExportFile(buildStudioPreviewExportPath(sourcePath || undefined, userResourceDir || undefined, studioCwd, filename), pdf);
12980
13060
  const exportId = storePreparedPdfExport(pdf, filename, warning, writeResult.filePath ?? undefined);
12981
13061
  const token = serverState?.token ?? "";
13062
+ if (openTarget === "studio" && serverState && writeResult.filePath) {
13063
+ const exportedPath = writeResult.filePath;
13064
+ const title = sanitizeStudioPreviewBlockLine(filename || basename(exportedPath) || "PDF preview");
13065
+ const document: InitialStudioDocument = {
13066
+ text: "```studio-pdf\n"
13067
+ + `path: ${sanitizeStudioPreviewBlockLine(basename(exportedPath))}\n`
13068
+ + `title: ${title || "PDF preview"}\n`
13069
+ + "height: 820\n"
13070
+ + "```\n",
13071
+ label: `${filename || basename(exportedPath) || "PDF"} preview`,
13072
+ source: "blank",
13073
+ resourceDir: dirname(exportedPath),
13074
+ };
13075
+ const docId = storeTransientStudioDocument(document);
13076
+ const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true });
13077
+ const parsedUrl = new URL(url);
13078
+ respondJson(res, 200, {
13079
+ ok: true,
13080
+ filename,
13081
+ path: writeResult.filePath,
13082
+ writeError: writeResult.error,
13083
+ warning: warning ?? null,
13084
+ openedStudio: true,
13085
+ url,
13086
+ relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
13087
+ downloadUrl: `/export-pdf?token=${encodeURIComponent(token)}&id=${encodeURIComponent(exportId)}`,
13088
+ });
13089
+ return;
13090
+ }
12982
13091
  let openedExternal = false;
12983
13092
  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.");
13093
+ if (openTarget !== "studio") {
13094
+ try {
13095
+ const prepared = await ensurePreparedPdfExportFile(exportId);
13096
+ if (!prepared?.filePath) {
13097
+ throw new Error("Prepared PDF file was not available for external open.");
13098
+ }
13099
+ await openPathInDefaultViewer(prepared.filePath);
13100
+ openedExternal = true;
13101
+ } catch (viewerError) {
13102
+ openError = viewerError instanceof Error ? viewerError.message : String(viewerError);
12988
13103
  }
12989
- await openPathInDefaultViewer(prepared.filePath);
12990
- openedExternal = true;
12991
- } catch (viewerError) {
12992
- openError = viewerError instanceof Error ? viewerError.message : String(viewerError);
12993
13104
  }
12994
13105
  respondJson(res, 200, {
12995
13106
  ok: true,
@@ -13068,6 +13179,11 @@ export default function (pi: ExtensionAPI) {
13068
13179
  parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { editorHtmlLanguage?: unknown }).editorHtmlLanguage === "string"
13069
13180
  ? (parsedBody as { editorHtmlLanguage: string }).editorHtmlLanguage
13070
13181
  : "";
13182
+ const requestedOpenTarget =
13183
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { openTarget?: unknown }).openTarget === "string"
13184
+ ? (parsedBody as { openTarget: string }).openTarget.trim().toLowerCase()
13185
+ : "browser";
13186
+ const openTarget = requestedOpenTarget === "studio" ? "studio" : "browser";
13071
13187
  const editorHtmlLanguage = inferStudioPdfLanguage(markdown, requestedEditorHtmlLanguage);
13072
13188
  const isLatex = editorHtmlLanguage === "latex"
13073
13189
  || (
@@ -13093,6 +13209,32 @@ export default function (pi: ExtensionAPI) {
13093
13209
  const writeResult = writeStudioPreviewExportFile(buildStudioPreviewExportPath(sourcePath || undefined, userResourceDir || undefined, studioCwd, filename), html);
13094
13210
  const exportId = storePreparedHtmlExport(html, filename, warning, writeResult.filePath ?? undefined);
13095
13211
  const token = serverState?.token ?? "";
13212
+ if (openTarget === "studio" && serverState) {
13213
+ const exportedPath = writeResult.filePath ?? "";
13214
+ const document: InitialStudioDocument = {
13215
+ text: html.toString("utf-8"),
13216
+ label: filename,
13217
+ source: exportedPath ? "file" : "blank",
13218
+ path: exportedPath || undefined,
13219
+ resourceDir: exportedPath ? dirname(exportedPath) : (userResourceDir || resourcePath || studioCwd),
13220
+ draftId: exportedPath ? undefined : createStudioDraftId(),
13221
+ };
13222
+ const docId = storeTransientStudioDocument(document);
13223
+ const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true });
13224
+ const parsedUrl = new URL(url);
13225
+ respondJson(res, 200, {
13226
+ ok: true,
13227
+ filename,
13228
+ path: writeResult.filePath,
13229
+ writeError: writeResult.error,
13230
+ warning: warning ?? null,
13231
+ openedStudio: true,
13232
+ url,
13233
+ relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
13234
+ downloadUrl: `/export-html?token=${encodeURIComponent(token)}&id=${encodeURIComponent(exportId)}`,
13235
+ });
13236
+ return;
13237
+ }
13096
13238
  let openedExternal = false;
13097
13239
  let openError: string | null = null;
13098
13240
  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.22",
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
+ }