pi-studio 0.5.9 → 0.5.11

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.
Files changed (3) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/index.ts +136 -6
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.11] — 2026-03-15
8
+
9
+ ### Added
10
+ - Studio tabs now show a title attention marker like `● Response ready` or `● Critique ready` when a Studio-started model request finishes while the tab is unfocused, and clear that marker when the tab regains focus or the next Studio request starts.
11
+
12
+ ## [0.5.10] — 2026-03-14
13
+
14
+ ### Fixed
15
+ - Studio preview/PDF math normalization is now more robust for model-emitted `\(...\)` / `\[...\]` math, including malformed mixed delimiters like `$\(...\)$`, optional spacing around those mixed delimiters, and multiline display-math line-break formatting that previously leaked raw/broken `$$` output into preview.
16
+
7
17
  ## [0.5.9] — 2026-03-13
8
18
 
9
19
  ### Fixed
package/index.ts CHANGED
@@ -991,13 +991,58 @@ async function fetchLatestNpmVersion(packageName: string, timeoutMs = UPDATE_CHE
991
991
  }
992
992
  }
993
993
 
994
+ function isLikelyMathExpression(expr: string): boolean {
995
+ const content = expr.trim();
996
+ if (content.length === 0) return false;
997
+
998
+ if (/\\[a-zA-Z]+/.test(content)) return true; // LaTeX commands like \frac, \alpha
999
+ if (/[0-9]/.test(content)) return true;
1000
+ if (/[=+\-*/^_<>≤≥±×÷]/u.test(content)) return true;
1001
+ if (/[{}]/.test(content)) return true;
1002
+ if (/[α-ωΑ-Ω]/u.test(content)) return true;
1003
+ if (/^[A-Za-z]$/.test(content)) return true; // single-variable forms like \(x\)
1004
+
1005
+ // Plain words (e.g. escaped markdown like \[not a link\]) are not math.
1006
+ if (/^[A-Za-z][A-Za-z\s'".,:;!?-]*[A-Za-z]$/.test(content)) return false;
1007
+
1008
+ return false;
1009
+ }
1010
+
1011
+ function collapseDisplayMathContent(expr: string): string {
1012
+ let content = expr.trim();
1013
+ if (content.includes("\\\\") || content.includes("\n")) {
1014
+ content = content.replace(/\\\\\s*/g, " ");
1015
+ content = content.replace(/\s*\n\s*/g, " ");
1016
+ content = content.replace(/\s{2,}/g, " ").trim();
1017
+ }
1018
+ return content;
1019
+ }
1020
+
994
1021
  function normalizeMathDelimitersInSegment(markdown: string): string {
995
- let normalized = markdown.replace(/\\\[\s*([\s\S]*?)\s*\\\]/g, (_match, expr: string) => {
1022
+ let normalized = markdown.replace(/\$\s*\\\(([\s\S]*?)\\\)\s*\$/g, (match, expr: string) => {
1023
+ if (!isLikelyMathExpression(expr)) return match;
1024
+ const content = expr.trim();
1025
+ return content.length > 0 ? `\\(${content}\\)` : "\\(\\)";
1026
+ });
1027
+
1028
+ normalized = normalized.replace(/\$\s*\\\[\s*([\s\S]*?)\s*\\\]\s*\$/g, (match, expr: string) => {
1029
+ if (!isLikelyMathExpression(expr)) return match;
1030
+ const content = collapseDisplayMathContent(expr);
1031
+ return content.length > 0 ? `\\[${content}\\]` : "\\[\\]";
1032
+ });
1033
+
1034
+ normalized = normalized.replace(/\\\[\s*([\s\S]*?)\s*\\\]/g, (match, expr: string) => {
1035
+ if (!isLikelyMathExpression(expr)) return `[${expr.trim()}]`;
1036
+ const content = collapseDisplayMathContent(expr);
1037
+ return content.length > 0 ? `\\[${content}\\]` : "\\[\\]";
1038
+ });
1039
+
1040
+ normalized = normalized.replace(/\\\(([\s\S]*?)\\\)/g, (match, expr: string) => {
1041
+ if (!isLikelyMathExpression(expr)) return `(${expr})`;
996
1042
  const content = expr.trim();
997
- return content.length > 0 ? `$$\n${content}\n$$` : "$$\n$$";
1043
+ return content.length > 0 ? `\\(${content}\\)` : "\\(\\)";
998
1044
  });
999
1045
 
1000
- normalized = normalized.replace(/\\\(([\s\S]*?)\\\)/g, (_match, expr: string) => `$${expr}$`);
1001
1046
  return normalized;
1002
1047
  }
1003
1048
 
@@ -1216,7 +1261,7 @@ async function preprocessStudioMermaidForPdf(markdown: string, workDir: string):
1216
1261
 
1217
1262
  async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolean, resourcePath?: string): Promise<string> {
1218
1263
  const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
1219
- const inputFormat = isLatex ? "latex" : "markdown+tex_math_dollars+autolink_bare_uris-raw_html";
1264
+ const inputFormat = isLatex ? "latex" : "markdown+tex_math_dollars+tex_math_single_backslash+tex_math_double_backslash+autolink_bare_uris-raw_html";
1220
1265
  const args = ["-f", inputFormat, "-t", "html5", "--mathml", "--wrap=none"];
1221
1266
  if (resourcePath) {
1222
1267
  args.push(`--resource-path=${resourcePath}`);
@@ -1288,7 +1333,7 @@ async function renderStudioPdfWithPandoc(
1288
1333
  const pdfEngine = process.env.PANDOC_PDF_ENGINE?.trim() || "xelatex";
1289
1334
  const inputFormat = isLatex
1290
1335
  ? "latex"
1291
- : "markdown+tex_math_dollars+autolink_bare_uris+superscript+subscript-raw_html";
1336
+ : "markdown+tex_math_dollars+tex_math_single_backslash+tex_math_double_backslash+autolink_bare_uris+superscript+subscript-raw_html";
1292
1337
  const normalizedMarkdown = isLatex ? markdown : normalizeObsidianImages(normalizeMathDelimiters(markdown));
1293
1338
 
1294
1339
  const tempDir = join(tmpdir(), `pi-studio-pdf-${Date.now()}-${randomUUID()}`);
@@ -3494,6 +3539,10 @@ ${cssVarsBlock}
3494
3539
  let contextPercent = null;
3495
3540
  let updateInstalledVersion = null;
3496
3541
  let updateLatestVersion = null;
3542
+ let windowHasFocus = typeof document.hasFocus === "function" ? document.hasFocus() : true;
3543
+ let titleAttentionMessage = "";
3544
+ let titleAttentionRequestId = null;
3545
+ let titleAttentionRequestKind = null;
3497
3546
 
3498
3547
  function parseFiniteNumber(value) {
3499
3548
  if (value == null || value === "") return null;
@@ -3854,12 +3903,63 @@ ${cssVarsBlock}
3854
3903
  return changed;
3855
3904
  }
3856
3905
 
3906
+ function isTitleAttentionRequestKind(kind) {
3907
+ return kind === "annotation" || kind === "critique" || kind === "direct";
3908
+ }
3909
+
3910
+ function armTitleAttentionForRequest(requestId, kind) {
3911
+ if (typeof requestId !== "string" || !isTitleAttentionRequestKind(kind)) {
3912
+ titleAttentionRequestId = null;
3913
+ titleAttentionRequestKind = null;
3914
+ return;
3915
+ }
3916
+ titleAttentionRequestId = requestId;
3917
+ titleAttentionRequestKind = kind;
3918
+ }
3919
+
3920
+ function clearArmedTitleAttention(requestId) {
3921
+ if (typeof requestId === "string" && titleAttentionRequestId && requestId !== titleAttentionRequestId) {
3922
+ return;
3923
+ }
3924
+ titleAttentionRequestId = null;
3925
+ titleAttentionRequestKind = null;
3926
+ }
3927
+
3928
+ function clearTitleAttention() {
3929
+ if (!titleAttentionMessage) return;
3930
+ titleAttentionMessage = "";
3931
+ updateDocumentTitle();
3932
+ }
3933
+
3934
+ function shouldShowTitleAttention() {
3935
+ const focused = typeof document.hasFocus === "function" ? document.hasFocus() : windowHasFocus;
3936
+ return Boolean(document.hidden) || !focused;
3937
+ }
3938
+
3939
+ function getTitleAttentionMessage(kind) {
3940
+ if (kind === "critique") return "● Critique ready";
3941
+ if (kind === "direct") return "● Response ready";
3942
+ return "● Reply ready";
3943
+ }
3944
+
3945
+ function maybeShowTitleAttentionForCompletedRequest(requestId, kind) {
3946
+ const matchedRequest = typeof requestId === "string" && titleAttentionRequestId && requestId === titleAttentionRequestId;
3947
+ const completedKind = isTitleAttentionRequestKind(kind) ? kind : titleAttentionRequestKind;
3948
+ clearArmedTitleAttention(requestId);
3949
+ if (!matchedRequest || !completedKind || !shouldShowTitleAttention()) {
3950
+ return;
3951
+ }
3952
+ titleAttentionMessage = getTitleAttentionMessage(completedKind);
3953
+ updateDocumentTitle();
3954
+ }
3955
+
3857
3956
  function updateDocumentTitle() {
3858
3957
  const modelText = modelLabel && modelLabel.trim() ? modelLabel.trim() : "none";
3859
3958
  const terminalText = terminalSessionLabel && terminalSessionLabel.trim() ? terminalSessionLabel.trim() : "unknown";
3860
3959
  const titleParts = ["pi Studio"];
3861
3960
  if (terminalText && terminalText !== "unknown") titleParts.push(terminalText);
3862
3961
  if (modelText && modelText !== "none") titleParts.push(modelText);
3962
+ if (titleAttentionMessage) titleParts.unshift(titleAttentionMessage);
3863
3963
  document.title = titleParts.join(" · ");
3864
3964
  }
3865
3965
 
@@ -3953,6 +4053,24 @@ ${cssVarsBlock}
3953
4053
 
3954
4054
  renderStatus();
3955
4055
 
4056
+ window.addEventListener("focus", () => {
4057
+ windowHasFocus = true;
4058
+ clearTitleAttention();
4059
+ });
4060
+
4061
+ window.addEventListener("blur", () => {
4062
+ windowHasFocus = false;
4063
+ });
4064
+
4065
+ document.addEventListener("visibilitychange", () => {
4066
+ if (!document.hidden) {
4067
+ windowHasFocus = typeof document.hasFocus === "function" ? document.hasFocus() : windowHasFocus;
4068
+ if (windowHasFocus) {
4069
+ clearTitleAttention();
4070
+ }
4071
+ }
4072
+ });
4073
+
3956
4074
  function updateSourceBadge() {
3957
4075
  const label = sourceState && sourceState.label ? sourceState.label : "blank";
3958
4076
  sourceBadgeEl.textContent = "Editor origin: " + label;
@@ -5765,8 +5883,10 @@ ${cssVarsBlock}
5765
5883
  setStatus("No matching Studio request is running.", "warning");
5766
5884
  return false;
5767
5885
  }
5768
- const sent = sendMessage({ type: "cancel_request", requestId: pendingRequestId });
5886
+ const requestId = pendingRequestId;
5887
+ const sent = sendMessage({ type: "cancel_request", requestId });
5769
5888
  if (!sent) return false;
5889
+ clearArmedTitleAttention(requestId);
5770
5890
  setStatus("Stopping request…", "warning");
5771
5891
  return true;
5772
5892
  }
@@ -6074,6 +6194,7 @@ ${cssVarsBlock}
6074
6194
  return;
6075
6195
  }
6076
6196
 
6197
+ const completedRequestId = typeof message.requestId === "string" ? message.requestId : pendingRequestId;
6077
6198
  const responseKind =
6078
6199
  typeof message.kind === "string"
6079
6200
  ? message.kind
@@ -6106,6 +6227,7 @@ ${cssVarsBlock}
6106
6227
  } else {
6107
6228
  setStatus("Response ready.", "success");
6108
6229
  }
6230
+ maybeShowTitleAttentionForCompletedRequest(completedRequestId, responseKind);
6109
6231
  return;
6110
6232
  }
6111
6233
 
@@ -6307,6 +6429,9 @@ ${cssVarsBlock}
6307
6429
  pendingRequestId = null;
6308
6430
  pendingKind = null;
6309
6431
  }
6432
+ if (typeof message.requestId === "string") {
6433
+ clearArmedTitleAttention(message.requestId);
6434
+ }
6310
6435
  stickyStudioKind = null;
6311
6436
  setBusy(false);
6312
6437
  setWsState("Ready");
@@ -6322,6 +6447,9 @@ ${cssVarsBlock}
6322
6447
  pendingRequestId = null;
6323
6448
  pendingKind = null;
6324
6449
  }
6450
+ if (typeof message.requestId === "string") {
6451
+ clearArmedTitleAttention(message.requestId);
6452
+ }
6325
6453
  stickyStudioKind = null;
6326
6454
  setBusy(false);
6327
6455
  setWsState("Ready");
@@ -6480,10 +6608,12 @@ ${cssVarsBlock}
6480
6608
  setStatus("Studio is busy.", "warning");
6481
6609
  return null;
6482
6610
  }
6611
+ clearTitleAttention();
6483
6612
  const requestId = makeRequestId();
6484
6613
  pendingRequestId = requestId;
6485
6614
  pendingKind = kind;
6486
6615
  stickyStudioKind = kind;
6616
+ armTitleAttentionForRequest(requestId, kind);
6487
6617
  setBusy(true);
6488
6618
  setWsState("Submitting");
6489
6619
  setStatus(getStudioBusyStatus(kind), "warning");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.5.9",
3
+ "version": "0.5.11",
4
4
  "description": "Browser GUI for structured critique workflows in pi",
5
5
  "type": "module",
6
6
  "license": "MIT",