pi-studio 0.5.47 → 0.5.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.48] — 2026-04-07
8
+
9
+ ### Added
10
+ - Editor-preview comments now also work for code, plain-text, and diff files using line-based preview selection anchors, so code/text review can use the same local comments workflow as Markdown preview.
11
+
12
+ ### Fixed
13
+ - LaTeX editor preview now correctly treats `.tex`/LaTeX editor content as LaTeX in preview even when the document is only a fragment rather than a full `\documentclass` wrapper.
14
+ - Ordinary response preview no longer flips into LaTeX mode just because quoted LaTeX commands like `` `\\documentclass` `` or `` `\\begin{document}` `` appear in a normal response.
15
+
16
+ ### Changed
17
+ - LaTeX preview comments remain disabled for now; the earlier heuristic preview-side mapping attempts were removed in favor of keeping the rendering fixes and waiting for a more pipeline-grounded LaTeX comment design.
18
+
7
19
  ## [0.5.47] — 2026-04-07
8
20
 
9
21
  ### Changed
package/README.md CHANGED
@@ -18,7 +18,7 @@ Extension for [pi](https://pi.dev) that opens a local two-pane browser workspace
18
18
  - Supports one canonical full Studio view per Pi session, plus additional editor-only companion views when you just want extra editing/preview surfaces
19
19
  - Runs editor text directly, or asks for structured critique (auto/writing/code focus)
20
20
  - 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
21
- - Includes local comments anchored to selections/lines, shown in a docked **Comments** rail, with **Comment** actions from raw-editor and editor-preview selections plus optional inline `[an: ...]` toggles when you want them in the document text
21
+ - Includes local comments anchored to selections/lines, shown in a docked **Comments** rail, with **Comment** actions from raw-editor selections plus editor-preview selections for Markdown and code/text/diff previews, alongside optional inline `[an: ...]` toggles when you want comments reflected in the document text
22
22
  - Browses response history (`Prev/Next/Last`) and loads either:
23
23
  - response text
24
24
  - critique notes/full critique
@@ -1534,19 +1534,25 @@
1534
1534
 
1535
1535
  function sanitizeRenderedHtml(html, markdown) {
1536
1536
  const rawHtml = typeof html === "string" ? html : "";
1537
- const mathAnnotationStripped = rawHtml
1538
- .replace(/<annotation-xml\b[\s\S]*?<\/annotation-xml>/gi, "")
1539
- .replace(/<annotation\b[\s\S]*?<\/annotation>/gi, "");
1537
+ const mathAnnotationPreserved = rawHtml.replace(/<math\b([^>]*)>([\s\S]*?)<\/math>/gi, (match, attrs, inner) => {
1538
+ const texAnnotationMatch = String(inner || "").match(/<annotation\b[^>]*encoding="application\/x-tex"[^>]*>([\s\S]*?)<\/annotation>/i);
1539
+ const texSource = texAnnotationMatch ? String(texAnnotationMatch[1] || "").trim() : "";
1540
+ const cleanedInner = String(inner || "")
1541
+ .replace(/<annotation-xml\b[\s\S]*?<\/annotation-xml>/gi, "")
1542
+ .replace(/<annotation\b[\s\S]*?<\/annotation>/gi, "");
1543
+ const texAttr = texSource ? (" data-tex-source=\"" + escapeHtml(texSource) + "\"") : "";
1544
+ return "<math" + attrs + texAttr + ">" + cleanedInner + "</math>";
1545
+ });
1540
1546
 
1541
1547
  if (window.DOMPurify && typeof window.DOMPurify.sanitize === "function") {
1542
- return window.DOMPurify.sanitize(mathAnnotationStripped, {
1548
+ return window.DOMPurify.sanitize(mathAnnotationPreserved, {
1543
1549
  USE_PROFILES: {
1544
1550
  html: true,
1545
1551
  mathMl: true,
1546
1552
  svg: true,
1547
1553
  },
1548
1554
  ADD_TAGS: ["embed"],
1549
- ADD_ATTR: ["src", "type", "title", "width", "height", "style", "data-fig-align"],
1555
+ ADD_ATTR: ["src", "type", "title", "width", "height", "style", "data-fig-align", "data-tex-source"],
1550
1556
  ADD_DATA_URI_TAGS: ["embed"],
1551
1557
  });
1552
1558
  }
@@ -2275,7 +2281,7 @@
2275
2281
  }
2276
2282
  }
2277
2283
 
2278
- async function renderMarkdownWithPandoc(markdown) {
2284
+ async function renderMarkdownWithPandoc(markdown, options) {
2279
2285
  const token = getToken();
2280
2286
  if (!token) {
2281
2287
  throw new Error("Missing Studio token in URL.");
@@ -2288,20 +2294,26 @@
2288
2294
  const controller = typeof AbortController === "function" ? new AbortController() : null;
2289
2295
  const timeoutId = controller ? window.setTimeout(() => controller.abort(), 8000) : null;
2290
2296
 
2297
+ const previewOptions = options && typeof options === "object" ? options : {};
2298
+
2291
2299
  let response;
2292
2300
  try {
2293
2301
  const effectivePath = getEffectiveSavePath();
2294
2302
  const sourcePath = effectivePath || sourceState.path || "";
2303
+ const payload = {
2304
+ markdown: String(markdown || ""),
2305
+ sourcePath: sourcePath,
2306
+ resourceDir: (!sourcePath && resourceDirInput) ? resourceDirInput.value.trim() : "",
2307
+ };
2308
+ if (previewOptions.includeEditorLanguage) {
2309
+ payload.editorLanguage = String(editorLanguage || "");
2310
+ }
2295
2311
  response = await fetch("/render-preview?token=" + encodeURIComponent(token), {
2296
2312
  method: "POST",
2297
2313
  headers: {
2298
2314
  "Content-Type": "application/json",
2299
2315
  },
2300
- body: JSON.stringify({
2301
- markdown: String(markdown || ""),
2302
- sourcePath: sourcePath,
2303
- resourceDir: (!sourcePath && resourceDirInput) ? resourceDirInput.value.trim() : "",
2304
- }),
2316
+ body: JSON.stringify(payload),
2305
2317
  signal: controller ? controller.signal : undefined,
2306
2318
  });
2307
2319
  } catch (error) {
@@ -2526,7 +2538,9 @@
2526
2538
  : { markdown: stripAnnotationMarkers(String(markdown || "")), placeholders: [] };
2527
2539
 
2528
2540
  try {
2529
- const renderedHtml = await renderMarkdownWithPandoc(previewPrepared.markdown);
2541
+ const renderedHtml = await renderMarkdownWithPandoc(previewPrepared.markdown, {
2542
+ includeEditorLanguage: pane === "source" || rightView === "editor-preview",
2543
+ });
2530
2544
 
2531
2545
  if (pane === "source") {
2532
2546
  if (nonce !== sourcePreviewRenderNonce || editorView !== "preview") return;
@@ -2591,9 +2605,8 @@
2591
2605
  function renderSourcePreviewNow() {
2592
2606
  if (editorView !== "preview") return;
2593
2607
  const text = prepareEditorTextForPreview(sourceTextEl.value || "");
2594
- if (editorLanguage && editorLanguage !== "markdown" && editorLanguage !== "latex") {
2595
- finishPreviewRender(sourcePreviewEl);
2596
- sourcePreviewEl.innerHTML = "<div class='response-markdown-highlight'>" + highlightCode(text, editorLanguage, "preview") + "</div>";
2608
+ if (supportsCodePreviewCommentsForCurrentEditor()) {
2609
+ renderCodePreviewWithCommentBlocks(sourcePreviewEl, text, "source");
2597
2610
  return;
2598
2611
  }
2599
2612
  const nonce = ++sourcePreviewRenderNonce;
@@ -2660,10 +2673,8 @@
2660
2673
  scheduleResponsePaneRepaintNudge();
2661
2674
  return;
2662
2675
  }
2663
- if (editorLanguage && editorLanguage !== "markdown" && editorLanguage !== "latex") {
2664
- finishPreviewRender(critiqueViewEl);
2665
- critiqueViewEl.innerHTML = "<div class='response-markdown-highlight'>" + highlightCode(editorText, editorLanguage, "preview") + "</div>";
2666
- scheduleResponsePaneRepaintNudge();
2676
+ if (supportsCodePreviewCommentsForCurrentEditor()) {
2677
+ renderCodePreviewWithCommentBlocks(critiqueViewEl, editorText, "response");
2667
2678
  return;
2668
2679
  }
2669
2680
  const nonce = ++responsePreviewRenderNonce;
@@ -3798,6 +3809,68 @@
3798
3809
  return out.join("<br>");
3799
3810
  }
3800
3811
 
3812
+ function supportsCodePreviewCommentsForCurrentEditor() {
3813
+ return Boolean(editorLanguage) && editorLanguage !== "markdown" && editorLanguage !== "latex";
3814
+ }
3815
+
3816
+ function getCodePreviewCommentKind(language) {
3817
+ const lang = normalizeFenceLanguage(language || "");
3818
+ if (lang === "diff") return "diff-line";
3819
+ if (lang === "text") return "text-line";
3820
+ return "code-line";
3821
+ }
3822
+
3823
+ function buildCodePreviewHtmlWithCommentBlocks(text, language) {
3824
+ const source = String(text || "").replace(/\r\n/g, "\n");
3825
+ const lines = source.split("\n");
3826
+ const lang = normalizeFenceLanguage(language || "");
3827
+ const kind = getCodePreviewCommentKind(lang);
3828
+ const html = [];
3829
+ let offset = 0;
3830
+
3831
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
3832
+ const line = String(lines[lineIndex] || "");
3833
+ const start = offset;
3834
+ const end = start + line.length;
3835
+ const lineNumber = lineIndex + 1;
3836
+ const lineHtml = line.length === 0
3837
+ ? "<span class='hl-code'>" + EMPTY_OVERLAY_LINE + "</span>"
3838
+ : (lang ? highlightCodeLine(line, lang, "preview") : escapeHtml(line));
3839
+
3840
+ html.push(
3841
+ "<div class='preview-comment-block preview-comment-line-block'"
3842
+ + " data-review-note-start='" + String(start) + "'"
3843
+ + " data-review-note-end='" + String(end) + "'"
3844
+ + " data-review-note-line-start='" + String(lineNumber) + "'"
3845
+ + " data-review-note-line-end='" + String(lineNumber) + "'"
3846
+ + " data-preview-comment-kind='" + escapeHtml(kind) + "'"
3847
+ + ">"
3848
+ + "<div class='preview-comment-controls'>"
3849
+ + "<button type='button' class='preview-comment-summary' hidden></button>"
3850
+ + "<button type='button' class='preview-comment-add'>Comment</button>"
3851
+ + "</div>"
3852
+ + "<div class='preview-comment-block-content preview-code-line-content'>" + lineHtml + "</div>"
3853
+ + "</div>",
3854
+ );
3855
+
3856
+ offset = end + 1;
3857
+ }
3858
+
3859
+ return "<div class='response-markdown-highlight preview-code-lines'>" + html.join("") + "</div>";
3860
+ }
3861
+
3862
+ function renderCodePreviewWithCommentBlocks(targetEl, text, pane) {
3863
+ if (!targetEl) return;
3864
+ clearPreviewJumpHighlight(targetEl);
3865
+ finishPreviewRender(targetEl);
3866
+ targetEl.innerHTML = buildCodePreviewHtmlWithCommentBlocks(text, editorLanguage || "");
3867
+ updatePreviewCommentBlocksForElement(targetEl);
3868
+ if (pane === "response") {
3869
+ applyPendingResponseScrollReset();
3870
+ scheduleResponsePaneRepaintNudge();
3871
+ }
3872
+ }
3873
+
3801
3874
  function detectLanguageFromName(name) {
3802
3875
  if (!name) return "";
3803
3876
  var dot = name.lastIndexOf(".");
@@ -4361,7 +4434,10 @@
4361
4434
  }
4362
4435
 
4363
4436
  function supportsPreviewCommentsForCurrentEditor() {
4364
- return editorLanguage === "markdown";
4437
+ // LaTeX preview comments are intentionally disabled for now.
4438
+ // The initial client-side source/block heuristics were too unreliable on complex real documents.
4439
+ // Keep the independent LaTeX rendering fixes, but only enable preview comments where mapping is robust.
4440
+ return editorLanguage === "markdown" || supportsCodePreviewCommentsForCurrentEditor();
4365
4441
  }
4366
4442
 
4367
4443
  function getPreviewCommentBlockKindLabel(kind) {
@@ -4370,11 +4446,20 @@
4370
4446
  if (kind === "list") return "list";
4371
4447
  if (kind === "code") return "code block";
4372
4448
  if (kind === "table") return "table";
4449
+ if (kind === "code-line") return "code line";
4450
+ if (kind === "diff-line") return "diff line";
4451
+ if (kind === "text-line") return "text line";
4373
4452
  return "paragraph";
4374
4453
  }
4375
4454
 
4376
4455
  function supportsPreviewSelectionCommentsForBlockKind(kind) {
4377
- return kind === "paragraph" || kind === "heading" || kind === "blockquote" || kind === "list";
4456
+ return kind === "paragraph"
4457
+ || kind === "heading"
4458
+ || kind === "blockquote"
4459
+ || kind === "list"
4460
+ || kind === "code-line"
4461
+ || kind === "diff-line"
4462
+ || kind === "text-line";
4378
4463
  }
4379
4464
 
4380
4465
  function normalizeVisiblePreviewText(text) {
@@ -4711,6 +4796,11 @@
4711
4796
  return safeStartA < safeEndB && safeStartB < safeEndA;
4712
4797
  }
4713
4798
 
4799
+ function scanSourcePreviewCommentBlocks(markdown) {
4800
+ if (editorLanguage !== "markdown") return [];
4801
+ return scanMarkdownPreviewCommentBlocks(markdown);
4802
+ }
4803
+
4714
4804
  function scanMarkdownPreviewCommentBlocks(markdown) {
4715
4805
  const source = String(markdown || "").replace(/\r\n/g, "\n");
4716
4806
  const lines = source.split("\n");
@@ -5077,7 +5167,7 @@
5077
5167
 
5078
5168
  function decorateRenderedEditorPreviewComments(targetEl, sourceText) {
5079
5169
  if (!targetEl || typeof targetEl.querySelectorAll !== "function") return;
5080
- const sourceBlocks = scanMarkdownPreviewCommentBlocks(sourceText);
5170
+ const sourceBlocks = scanSourcePreviewCommentBlocks(sourceText);
5081
5171
  const targetBlocks = collectPreviewCommentTargetElements(targetEl);
5082
5172
  if (sourceBlocks.length === 0 || targetBlocks.length === 0) return;
5083
5173
 
@@ -5271,12 +5361,43 @@
5271
5361
  return bestBlock;
5272
5362
  }
5273
5363
 
5364
+ function getPreviewNoteNormalizedSelectionText(note) {
5365
+ const direct = normalizeVisiblePreviewText(note && (note.selectedDisplayText || note.selectedText) ? (note.selectedDisplayText || note.selectedText) : "");
5366
+ if (direct) return direct;
5367
+ return "";
5368
+ }
5369
+
5370
+ function findPreviewCommentBlockForNoteText(targetEl, note) {
5371
+ if (!targetEl || !note || typeof targetEl.querySelectorAll !== "function") return null;
5372
+ const selectionText = getPreviewNoteNormalizedSelectionText(note);
5373
+ if (!selectionText) return null;
5374
+
5375
+ let bestBlock = null;
5376
+ let bestScore = Number.NEGATIVE_INFINITY;
5377
+ Array.from(targetEl.querySelectorAll(".preview-comment-block")).forEach((blockEl) => {
5378
+ const contentEl = blockEl.querySelector(".preview-comment-block-content") || blockEl;
5379
+ const blockText = normalizeVisiblePreviewText(buildNormalizedDomTextMap(contentEl).text);
5380
+ if (!blockText) return;
5381
+ const matchIndex = blockText.indexOf(selectionText);
5382
+ if (matchIndex < 0) return;
5383
+ const lineStart = Math.max(1, Number(blockEl.dataset && blockEl.dataset.reviewNoteLineStart) || 1);
5384
+ const desiredLine = Math.max(1, Number(note && note.lineStart) || 1);
5385
+ const proximityPenalty = Math.abs(lineStart - desiredLine);
5386
+ const score = 1000000 - (matchIndex * 4) - proximityPenalty - Math.max(0, blockText.length - selectionText.length);
5387
+ if (score > bestScore) {
5388
+ bestScore = score;
5389
+ bestBlock = blockEl;
5390
+ }
5391
+ });
5392
+ return bestBlock;
5393
+ }
5394
+
5274
5395
  function revealReviewNoteInPreviewElement(targetEl, note) {
5275
5396
  if (!targetEl || !note) return false;
5276
5397
  const source = String(sourceTextEl && sourceTextEl.value ? sourceTextEl.value : "");
5277
5398
  const range = resolveReviewNoteRange(note, source);
5278
5399
  if (!range) return false;
5279
- const blockEl = findPreviewCommentBlockForRange(targetEl, range);
5400
+ const blockEl = findPreviewCommentBlockForRange(targetEl, range) || findPreviewCommentBlockForNoteText(targetEl, note);
5280
5401
  if (!blockEl) return false;
5281
5402
  const contentEl = blockEl.querySelector(".preview-comment-block-content") || blockEl;
5282
5403
  const inlineHighlightEl = createPreviewJumpInlineHighlight(contentEl, blockEl, note, range);
@@ -5288,6 +5409,7 @@
5288
5409
  }
5289
5410
 
5290
5411
  function revealReviewNoteInPreview(note) {
5412
+ if (!supportsPreviewCommentsForCurrentEditor()) return;
5291
5413
  if (rightView === "editor-preview" && critiqueViewEl && critiqueViewEl.isConnected) {
5292
5414
  revealReviewNoteInPreviewElement(critiqueViewEl, note);
5293
5415
  }
@@ -7689,7 +7811,7 @@
7689
7811
  event.preventDefault();
7690
7812
  event.stopPropagation();
7691
7813
  const mode = String(actionBtn.dataset && actionBtn.dataset.previewCommentMode ? actionBtn.dataset.previewCommentMode : "");
7692
- if (mode !== "selection") return;
7814
+ if (!mode || !mode.startsWith("selection")) return;
7693
7815
  addReviewNoteFromPreviewSelection(blockEl);
7694
7816
  }
7695
7817
 
package/client/studio.css CHANGED
@@ -898,6 +898,24 @@
898
898
  scroll-margin-top: 24px;
899
899
  }
900
900
 
901
+ .preview-comment-line-block {
902
+ min-height: 1.5em;
903
+ }
904
+
905
+ .preview-code-lines {
906
+ white-space: pre-wrap;
907
+ word-break: break-word;
908
+ overflow-wrap: anywhere;
909
+ }
910
+
911
+ .preview-code-line-content {
912
+ display: block;
913
+ min-height: 1.5em;
914
+ white-space: inherit;
915
+ word-break: inherit;
916
+ overflow-wrap: inherit;
917
+ }
918
+
901
919
  .preview-comment-controls {
902
920
  position: absolute;
903
921
  top: 0;
package/index.ts CHANGED
@@ -10,6 +10,7 @@ import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path
10
10
  import { URL, pathToFileURL } from "node:url";
11
11
  import { WebSocketServer, WebSocket, type RawData } from "ws";
12
12
  import {
13
+ advancePastStudioInlineBacktickSpan,
13
14
  collectStudioInlineAnnotationMarkers,
14
15
  hasStudioMarkdownAnnotationMarkers,
15
16
  isStudioAnnotationWordChar,
@@ -3263,6 +3264,26 @@ function inferStudioPdfLanguage(markdown: string, editorLanguage?: string): stri
3263
3264
  return undefined;
3264
3265
  }
3265
3266
 
3267
+ function stripStudioMarkdownInlineCodeSpans(markdown: string): string {
3268
+ const source = String(markdown ?? "");
3269
+ let out = "";
3270
+ let index = 0;
3271
+ while (index < source.length) {
3272
+ if (source[index] === "`") {
3273
+ index = advancePastStudioInlineBacktickSpan(source, index);
3274
+ continue;
3275
+ }
3276
+ out += source[index];
3277
+ index += 1;
3278
+ }
3279
+ return out;
3280
+ }
3281
+
3282
+ function isLikelyStandaloneLatexPreview(markdown: string): boolean {
3283
+ const outsideFences = transformStudioMarkdownOutsideFences(markdown, (segment: string) => stripStudioMarkdownInlineCodeSpans(segment));
3284
+ return /\\documentclass\b|\\begin\{document\}/.test(outsideFences);
3285
+ }
3286
+
3266
3287
  function escapeStudioPdfLatexText(text: string): string {
3267
3288
  const normalized = String(text ?? "")
3268
3289
  .replace(/\r\n/g, "\n")
@@ -4043,9 +4064,15 @@ function prepareStudioPdfMarkdown(markdown: string, isLatex?: boolean, editorLan
4043
4064
  }
4044
4065
 
4045
4066
  function stripMathMlAnnotationTags(html: string): string {
4046
- return html
4047
- .replace(/<annotation-xml\b[\s\S]*?<\/annotation-xml>/gi, "")
4048
- .replace(/<annotation\b[\s\S]*?<\/annotation>/gi, "");
4067
+ return String(html ?? "").replace(/<math\b([^>]*)>([\s\S]*?)<\/math>/gi, (_match, attrs, inner) => {
4068
+ const texAnnotationMatch = String(inner ?? "").match(/<annotation\b[^>]*encoding="application\/x-tex"[^>]*>([\s\S]*?)<\/annotation>/i);
4069
+ const texSource = texAnnotationMatch ? String(texAnnotationMatch[1] ?? "").trim() : "";
4070
+ const cleanedInner = String(inner ?? "")
4071
+ .replace(/<annotation-xml\b[\s\S]*?<\/annotation-xml>/gi, "")
4072
+ .replace(/<annotation\b[\s\S]*?<\/annotation>/gi, "");
4073
+ const texAttr = texSource ? ` data-tex-source="${escapeStudioHtmlText(texSource)}"` : "";
4074
+ return `<math${attrs}${texAttr}>${cleanedInner}</math>`;
4075
+ });
4049
4076
  }
4050
4077
 
4051
4078
  function normalizeObsidianImages(markdown: string): string {
@@ -7869,8 +7896,17 @@ export default function (pi: ExtensionAPI) {
7869
7896
  parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { resourceDir?: unknown }).resourceDir === "string"
7870
7897
  ? (parsedBody as { resourceDir: string }).resourceDir
7871
7898
  : "";
7899
+ const requestedEditorLanguage =
7900
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { editorLanguage?: unknown }).editorLanguage === "string"
7901
+ ? (parsedBody as { editorLanguage: string }).editorLanguage
7902
+ : "";
7872
7903
  const resourcePath = resolveStudioBaseDir(sourcePath || undefined, userResourceDir || undefined, studioCwd);
7873
- const isLatex = /\\documentclass\b|\\begin\{document\}/.test(markdown);
7904
+ const editorPreviewLanguage = normalizeStudioEditorLanguage(requestedEditorLanguage);
7905
+ const isLatex = editorPreviewLanguage === "latex"
7906
+ || (
7907
+ (editorPreviewLanguage === undefined || editorPreviewLanguage === "markdown")
7908
+ && isLikelyStandaloneLatexPreview(markdown)
7909
+ );
7874
7910
  const html = await renderStudioMarkdownWithPandoc(markdown, isLatex, resourcePath, sourcePath || undefined);
7875
7911
  respondJson(res, 200, { ok: true, html, renderer: "pandoc" });
7876
7912
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.5.47",
3
+ "version": "0.5.48",
4
4
  "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, prompt/response history, and live Markdown/LaTeX/code preview",
5
5
  "type": "module",
6
6
  "license": "MIT",