pi-studio 0.9.29 → 0.9.31

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,17 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.31] — 2026-06-09
8
+
9
+ ### Fixed
10
+ - Render long PDF annotations as full-width display-style boxes at their marker position so exported feedback avoids clipping while preserving the surrounding annotation order.
11
+ - Avoided annotation placeholder collisions in HTML exports with ten or more annotations.
12
+
13
+ ## [0.9.30] — 2026-06-09
14
+
15
+ ### Fixed
16
+ - Rendered inline strikethrough, emphasis, and code inside `[an: ...]` annotation badges consistently across Studio preview and HTML/PDF export paths.
17
+
7
18
  ## [0.9.29] — 2026-06-09
8
19
 
9
20
  ### Added
@@ -443,6 +443,13 @@
443
443
  let index = 0;
444
444
 
445
445
  while (index < source.length) {
446
+ const strikeMatch = readAnnotationEmphasisSpanAt(source, index, "~~", "s");
447
+ if (strikeMatch) {
448
+ out += strikeMatch.html;
449
+ index = strikeMatch.end;
450
+ continue;
451
+ }
452
+
446
453
  const strongMatch = readAnnotationEmphasisSpanAt(source, index, "**", "strong")
447
454
  || readAnnotationEmphasisSpanAt(source, index, "__", "strong");
448
455
  if (strongMatch) {
package/index.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  import { escapeStudioPdfLatexTextFragment } from "./shared/studio-pdf-escape.js";
31
31
  import { resolveStudioPdfResourceFile } from "./shared/studio-pdf-resource.js";
32
32
  import { buildStudioForwardingHint, buildStudioSshTunnelHint, isStudioSshSession as isSshSession } from "./shared/studio-ssh-hint.js";
33
+ import { renderStudioAnnotationInlineHtml } from "./shared/studio-annotation-render.js";
33
34
 
34
35
  type Lens = "writing" | "code";
35
36
  type RequestedLens = Lens | "auto";
@@ -1086,6 +1087,7 @@ function buildStudioPdfPreamble(options?: StudioPdfRenderOptions, extraPreamble
1086
1087
  \\titlespacing*{\\subparagraph}{0pt}{0.7ex plus 0.2ex minus 0.1ex}{0.7em}
1087
1088
  \\usepackage{xcolor}
1088
1089
  \\usepackage{varwidth}
1090
+ \\usepackage[normalem]{ulem}
1089
1091
  \\definecolor{StudioAnnotationBg}{HTML}{EAF3FF}
1090
1092
  \\definecolor{StudioAnnotationBorder}{HTML}{8CB8FF}
1091
1093
  \\definecolor{StudioAnnotationText}{HTML}{1F5FBF}
@@ -1110,6 +1112,7 @@ function buildStudioPdfPreamble(options?: StudioPdfRenderOptions, extraPreamble
1110
1112
  \\definecolor{StudioCalloutCautionText}{HTML}{A40E26}
1111
1113
  \\definecolor{StudioCalloutCautionLabelBg}{HTML}{FDEBEC}
1112
1114
  \\newcommand{\\studioannotation}[1]{\\begingroup\\setlength{\\fboxsep}{1.5pt}\\fcolorbox{StudioAnnotationBorder}{StudioAnnotationBg}{\\begin{varwidth}{\\dimexpr\\linewidth-2\\fboxsep-2\\fboxrule\\relax}\\raggedright\\textcolor{StudioAnnotationText}{\\sffamily\\footnotesize\\strut #1}\\end{varwidth}}\\endgroup}
1115
+ \\newcommand{\\studioblockannotation}[1]{\\par\\smallskip\\noindent\\begingroup\\setlength{\\fboxsep}{1.5pt}\\fcolorbox{StudioAnnotationBorder}{StudioAnnotationBg}{\\begin{minipage}{\\dimexpr\\linewidth-2\\fboxsep-2\\fboxrule\\relax}\\raggedright\\textcolor{StudioAnnotationText}{\\sffamily\\footnotesize\\strut #1}\\end{minipage}}\\endgroup\\par\\smallskip\\noindent\\ignorespaces}
1113
1116
  \\newcommand{\\StudioDiffAddTok}[1]{\\textcolor{StudioDiffAddText}{#1}}
1114
1117
  \\newcommand{\\StudioDiffDelTok}[1]{\\textcolor{StudioDiffDelText}{#1}}
1115
1118
  \\newcommand{\\StudioDiffMetaTok}[1]{\\textcolor{StudioDiffMetaText}{#1}}
@@ -5031,6 +5034,13 @@ function renderStudioAnnotationPlainTextPdfLatex(text: string): string {
5031
5034
  let index = 0;
5032
5035
 
5033
5036
  while (index < source.length) {
5037
+ const strikeMatch = readStudioAnnotationPdfEmphasisSpanAt(source, index, "~~", "sout");
5038
+ if (strikeMatch) {
5039
+ out += strikeMatch.latex;
5040
+ index = strikeMatch.end;
5041
+ continue;
5042
+ }
5043
+
5034
5044
  const strongMatch = readStudioAnnotationPdfEmphasisSpanAt(source, index, "**", "textbf")
5035
5045
  ?? readStudioAnnotationPdfEmphasisSpanAt(source, index, "__", "textbf");
5036
5046
  if (strongMatch) {
@@ -5060,22 +5070,25 @@ function renderStudioAnnotationPdfLatex(text: string): string {
5060
5070
  return renderStudioAnnotationPdfLatexContent(normalized).trim();
5061
5071
  }
5062
5072
 
5073
+ function renderStudioAnnotationPdfBox(markerText: string, block = false): string {
5074
+ const cleaned = renderStudioAnnotationPdfLatex(markerText);
5075
+ if (!cleaned) return "";
5076
+ return block ? `\\studioblockannotation{${cleaned}}` : `\\studioannotation{${cleaned}}`;
5077
+ }
5078
+
5063
5079
  function replaceStudioAnnotationMarkersForPdfInSegment(text: string): string {
5080
+ const renderMarker = (markerText: string): string => {
5081
+ const label = normalizeStudioAnnotationText(markerText);
5082
+ if (!label) return "";
5083
+ return renderStudioAnnotationPdfBox(label, shouldRenderStudioAnnotationAsPdfBlock(label));
5084
+ };
5064
5085
  const replaced = replaceStudioInlineAnnotationMarkers(
5065
5086
  String(text ?? ""),
5066
- (marker: { body: string }) => {
5067
- const cleaned = renderStudioAnnotationPdfLatex(marker.body);
5068
- if (!cleaned) return "";
5069
- return `\\studioannotation{${cleaned}}`;
5070
- },
5087
+ (marker: { body: string }) => renderMarker(marker.body),
5071
5088
  );
5072
5089
 
5073
5090
  return String(replaced ?? "")
5074
- .replace(/\{\[\}\s*an:\s*([\s\S]*?)\s*\{\]\}/gi, (_match, markerText: string) => {
5075
- const cleaned = renderStudioAnnotationPdfLatex(markerText);
5076
- if (!cleaned) return "";
5077
- return `\\studioannotation{${cleaned}}`;
5078
- });
5091
+ .replace(/\{\[\}\s*an:\s*([\s\S]*?)\s*\{\]\}/gi, (_match, markerText: string) => renderMarker(markerText));
5079
5092
  }
5080
5093
 
5081
5094
  function replaceStudioAnnotationMarkersForPdf(markdown: string): string {
@@ -5648,13 +5661,14 @@ function prepareStudioPdfMarkdown(markdown: string, isLatex?: boolean, editorLan
5648
5661
  ? wrapStudioCodeAsMarkdown(input, effectiveEditorLanguage)
5649
5662
  : input;
5650
5663
  const fenceNormalizedSource = effectiveEditorLanguage === "latex" ? source : normalizeStudioMarkdownSmartFences(source);
5651
- const annotationReadySource = !effectiveEditorLanguage || effectiveEditorLanguage === "markdown" || effectiveEditorLanguage === "latex"
5652
- ? replaceStudioAnnotationMarkersForPdf(fenceNormalizedSource)
5653
- : fenceNormalizedSource;
5654
- const commentStrippedSource = stripStudioMarkdownHtmlCommentsPreservingYamlFrontMatter(annotationReadySource);
5655
- return prepareStudioMarkdownForPandoc(commentStrippedSource, {
5656
- preserveLiteralLatexCommands: !hasStudioYamlHeaderIncludes(annotationReadySource),
5664
+ const annotationReadyLanguage = !effectiveEditorLanguage || effectiveEditorLanguage === "markdown" || effectiveEditorLanguage === "latex";
5665
+ const commentStrippedSource = stripStudioMarkdownHtmlCommentsPreservingYamlFrontMatter(fenceNormalizedSource);
5666
+ const pandocReadySource = prepareStudioMarkdownForPandoc(commentStrippedSource, {
5667
+ preserveLiteralLatexCommands: !hasStudioYamlHeaderIncludes(fenceNormalizedSource),
5657
5668
  });
5669
+ return annotationReadyLanguage
5670
+ ? replaceStudioAnnotationMarkersForPdf(pandocReadySource)
5671
+ : pandocReadySource;
5658
5672
  }
5659
5673
 
5660
5674
  function stripMathMlAnnotationTags(html: string): string {
@@ -6086,6 +6100,15 @@ function parseStudioHtmlPdfBlockOptions(body: string): StudioHtmlPdfBlockOptions
6086
6100
  return options;
6087
6101
  }
6088
6102
 
6103
+ // PDF/LaTeX cannot fully mimic the browser's inline-block wrapping for long
6104
+ // annotation chips, so long annotations switch to a display box at the marker.
6105
+ const STUDIO_PDF_ANNOTATION_DISPLAY_THRESHOLD_CHARS = 115;
6106
+
6107
+ function shouldRenderStudioAnnotationAsPdfBlock(text: string): boolean {
6108
+ const normalized = normalizeStudioAnnotationText(text);
6109
+ return normalized.length > STUDIO_PDF_ANNOTATION_DISPLAY_THRESHOLD_CHARS;
6110
+ }
6111
+
6089
6112
  function prepareStudioPdfBlocksForHtml(markdown: string): { markdown: string; blocks: StudioHtmlPdfBlock[] } {
6090
6113
  const blocks: StudioHtmlPdfBlock[] = [];
6091
6114
  const prefix = `PISTUDIOHTMLPDF${Date.now().toString(36)}${randomUUID().replace(/-/g, "")}TOKEN`;
@@ -6116,9 +6139,9 @@ function prepareStudioAnnotationMarkersForHtml(markdown: string): { markdown: st
6116
6139
 
6117
6140
  function applyStudioAnnotationPlaceholdersToHtml(html: string, placeholders: StudioHtmlAnnotationPlaceholder[]): string {
6118
6141
  let transformed = String(html ?? "");
6119
- for (const placeholder of placeholders) {
6142
+ for (const placeholder of [...placeholders].sort((a, b) => b.token.length - a.token.length)) {
6120
6143
  const tokenPattern = new RegExp(escapeStudioRegExpLiteral(placeholder.token), "g");
6121
- const markerHtml = `<span class="annotation-preview-marker" title="${escapeStudioHtmlText(placeholder.title)}">${escapeStudioHtmlText(placeholder.text)}</span>`;
6144
+ const markerHtml = `<span class="annotation-preview-marker" title="${escapeStudioHtmlText(placeholder.title)}">${renderStudioAnnotationInlineHtml(placeholder.text)}</span>`;
6122
6145
  transformed = transformed.replace(tokenPattern, markerHtml);
6123
6146
  }
6124
6147
  return transformed;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.29",
3
+ "version": "0.9.31",
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,148 @@
1
+ import {
2
+ advancePastStudioInlineBacktickSpan,
3
+ isStudioAnnotationWordChar,
4
+ normalizeStudioAnnotationText,
5
+ readStudioAnnotationProtectedTokenAt,
6
+ } from "./studio-annotation-scanner.js";
7
+
8
+ function escapeHtml(text) {
9
+ return String(text ?? "")
10
+ .replace(/&/g, "&amp;")
11
+ .replace(/</g, "&lt;")
12
+ .replace(/>/g, "&gt;")
13
+ .replace(/\"/g, "&quot;")
14
+ .replace(/'/g, "&#39;");
15
+ }
16
+
17
+ function canOpenAnnotationInlineDelimiter(source, startIndex, delimiter) {
18
+ const text = String(source || "");
19
+ if (text.slice(startIndex, startIndex + delimiter.length) !== delimiter) return false;
20
+ const prev = startIndex > 0 ? text[startIndex - 1] : "";
21
+ const next = text[startIndex + delimiter.length] || "";
22
+ if (!next || /\s/.test(next)) return false;
23
+ return !isStudioAnnotationWordChar(prev);
24
+ }
25
+
26
+ function canCloseAnnotationInlineDelimiter(source, startIndex, delimiter) {
27
+ const text = String(source || "");
28
+ if (text.slice(startIndex, startIndex + delimiter.length) !== delimiter) return false;
29
+ const prev = startIndex > 0 ? text[startIndex - 1] : "";
30
+ const next = text[startIndex + delimiter.length] || "";
31
+ if (!prev || /\s/.test(prev)) return false;
32
+ return !isStudioAnnotationWordChar(next);
33
+ }
34
+
35
+ function readAnnotationInlineSpanAt(source, startIndex, delimiter, tagName) {
36
+ const text = String(source || "");
37
+ if (!canOpenAnnotationInlineDelimiter(text, startIndex, delimiter)) return null;
38
+
39
+ let index = startIndex + delimiter.length;
40
+ while (index < text.length) {
41
+ if (text[index] === "\\") {
42
+ index = Math.min(text.length, index + 2);
43
+ continue;
44
+ }
45
+
46
+ const protectedToken = readStudioAnnotationProtectedTokenAt(text, index);
47
+ if (protectedToken) {
48
+ index = protectedToken.end;
49
+ continue;
50
+ }
51
+
52
+ if (canCloseAnnotationInlineDelimiter(text, index, delimiter)) {
53
+ const inner = text.slice(startIndex + delimiter.length, index);
54
+ return {
55
+ end: index + delimiter.length,
56
+ html: `<${tagName}>${renderAnnotationPlainInlineHtml(inner)}</${tagName}>`,
57
+ };
58
+ }
59
+
60
+ index += 1;
61
+ }
62
+
63
+ return null;
64
+ }
65
+
66
+ function renderAnnotationCodeSpanHtml(rawToken) {
67
+ const raw = String(rawToken || "");
68
+ if (!raw || raw[0] !== "`") return escapeHtml(raw);
69
+
70
+ let fenceLength = 1;
71
+ while (raw[fenceLength] === "`") fenceLength += 1;
72
+ const fence = "`".repeat(fenceLength);
73
+ if (raw.length < fenceLength * 2 || raw.slice(raw.length - fenceLength) !== fence) {
74
+ return escapeHtml(raw);
75
+ }
76
+
77
+ return `<code>${escapeHtml(raw.slice(fenceLength, raw.length - fenceLength))}</code>`;
78
+ }
79
+
80
+ function renderAnnotationPlainInlineHtml(text) {
81
+ const source = String(text || "");
82
+ let out = "";
83
+ let index = 0;
84
+
85
+ while (index < source.length) {
86
+ const strikeMatch = readAnnotationInlineSpanAt(source, index, "~~", "s");
87
+ if (strikeMatch) {
88
+ out += strikeMatch.html;
89
+ index = strikeMatch.end;
90
+ continue;
91
+ }
92
+
93
+ const strongMatch = readAnnotationInlineSpanAt(source, index, "**", "strong")
94
+ || readAnnotationInlineSpanAt(source, index, "__", "strong");
95
+ if (strongMatch) {
96
+ out += strongMatch.html;
97
+ index = strongMatch.end;
98
+ continue;
99
+ }
100
+
101
+ const emphasisMatch = readAnnotationInlineSpanAt(source, index, "*", "em")
102
+ || readAnnotationInlineSpanAt(source, index, "_", "em");
103
+ if (emphasisMatch) {
104
+ out += emphasisMatch.html;
105
+ index = emphasisMatch.end;
106
+ continue;
107
+ }
108
+
109
+ out += escapeHtml(source[index]);
110
+ index += 1;
111
+ }
112
+
113
+ return out;
114
+ }
115
+
116
+ export function renderStudioAnnotationInlineHtml(text) {
117
+ const source = normalizeStudioAnnotationText(text);
118
+ let out = "";
119
+ let plainStart = 0;
120
+ let index = 0;
121
+
122
+ while (index < source.length) {
123
+ const token = readStudioAnnotationProtectedTokenAt(source, index);
124
+ if (!token) {
125
+ index += 1;
126
+ continue;
127
+ }
128
+
129
+ if (index > plainStart) {
130
+ out += renderAnnotationPlainInlineHtml(source.slice(plainStart, index));
131
+ }
132
+
133
+ if (token.type === "code") {
134
+ out += renderAnnotationCodeSpanHtml(token.raw);
135
+ } else {
136
+ out += escapeHtml(token.raw);
137
+ }
138
+
139
+ index = token.end;
140
+ plainStart = index;
141
+ }
142
+
143
+ if (plainStart < source.length) {
144
+ out += renderAnnotationPlainInlineHtml(source.slice(plainStart));
145
+ }
146
+
147
+ return out;
148
+ }