pi-studio 0.5.55 → 0.5.57

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,25 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.57] — 2026-04-20
8
+
9
+ ### Changed
10
+ - The inserted annotated-reply scaffold now renders more cleanly in Markdown preview, using `annotated reply: below` plus a short metadata list instead of relying on hard line-break spacing.
11
+ - Local review-note comment boxes now use normal multiline textarea behavior, so `Enter` inserts a newline while edits continue saving automatically as you type.
12
+
13
+ ### Fixed
14
+ - Markdown preview now preserves standalone LaTeX math definition lines such as `\newcommand`, `\def`, and `\DeclareMathOperator` so custom math macros/operators can work in Markdown documents instead of showing up as literal preview text.
15
+ - Markdown PDF export now moves standalone LaTeX math definition lines into the generated PDF preamble when needed, avoiding LaTeX errors like `Can be used only in preamble` for commands such as `\DeclareMathOperator`.
16
+ - Annotated-reply header detection now accepts both the older `annotated reply below:` form and the newer `annotated reply: below` form when removing/reapplying the scaffold.
17
+
18
+ ## [0.5.56] — 2026-04-15
19
+
20
+ ### Removed
21
+ - Removed Studio's standalone npm update checker and footer update badge now that package update tracking is handled in pi.
22
+
23
+ ### Fixed
24
+ - Studio now always prints its localhost URL even when automatic browser open fails, and SSH sessions get a localhost-only port-forwarding hint instead of needing non-local binding.
25
+
7
26
  ## [0.5.54] — 2026-04-13
8
27
 
9
28
  ### Fixed
package/README.md CHANGED
@@ -74,6 +74,7 @@ pi -e https://github.com/omaclaren/pi-studio
74
74
  ## Notes
75
75
 
76
76
  - Local-only server (`127.0.0.1`) with tokenized Studio URLs.
77
+ - For remote SSH sessions, keep Studio bound to localhost and use SSH local port forwarding; `/studio` prints the localhost URL and an SSH tunnel hint when SSH is detected.
77
78
  - Full Studio is a singleton per Pi session: use `/studio` to open it, `/studio-replace` to explicitly replace it, and `/studio-editor-only` for extra editing/preview tabs that do not take over the full Studio session view.
78
79
  - Studio is designed as a complement to terminal pi, not a replacement.
79
80
  - Editor/code font uses a best-effort terminal-monospace match when the current terminal config exposes it; set `PI_STUDIO_FONT_MONO` to force a specific CSS `font-family` stack.
package/WORKFLOW.md CHANGED
@@ -20,9 +20,11 @@ Studio uses a **single workspace**:
20
20
  Adds/updates an `annotated-reply` compatible scaffold in the editor:
21
21
 
22
22
  ```md
23
- annotated reply below:
24
- original source: <last model response | file <path> | studio editor>
25
- annotation syntax: [an: your note]
23
+ annotated reply: below
24
+
25
+ - original source: <last model response | file <path> | studio editor>
26
+ - user annotation syntax: [an: your note]
27
+ - precedence: later messages supersede these annotations unless user explicitly references them
26
28
 
27
29
  ---
28
30
 
@@ -202,8 +202,6 @@
202
202
  let contextTokens = null;
203
203
  let contextWindow = null;
204
204
  let contextPercent = null;
205
- let updateInstalledVersion = null;
206
- let updateLatestVersion = null;
207
205
  let windowHasFocus = typeof document.hasFocus === "function" ? document.hasFocus() : true;
208
206
  let titleAttentionMessage = "";
209
207
  let titleAttentionRequestId = null;
@@ -664,8 +662,6 @@
664
662
  if (typeof message.contextTokens === "number") summary.contextTokens = message.contextTokens;
665
663
  if (typeof message.contextWindow === "number") summary.contextWindow = message.contextWindow;
666
664
  if (typeof message.contextPercent === "number") summary.contextPercent = message.contextPercent;
667
- if (typeof message.updateInstalledVersion === "string") summary.updateInstalledVersion = message.updateInstalledVersion;
668
- if (typeof message.updateLatestVersion === "string") summary.updateLatestVersion = message.updateLatestVersion;
669
665
  if (message.document && typeof message.document === "object" && typeof message.document.text === "string") {
670
666
  summary.documentLength = message.document.text.length;
671
667
  if (typeof message.document.label === "string") summary.documentLabel = message.document.label;
@@ -926,30 +922,6 @@
926
922
  return changed;
927
923
  }
928
924
 
929
- function applyUpdateInfoFromMessage(message) {
930
- if (!message || typeof message !== "object") return false;
931
-
932
- let changed = false;
933
-
934
- if (Object.prototype.hasOwnProperty.call(message, "updateInstalledVersion")) {
935
- const nextInstalled = parseNonEmptyString(message.updateInstalledVersion);
936
- if (nextInstalled !== updateInstalledVersion) {
937
- updateInstalledVersion = nextInstalled;
938
- changed = true;
939
- }
940
- }
941
-
942
- if (Object.prototype.hasOwnProperty.call(message, "updateLatestVersion")) {
943
- const nextLatest = parseNonEmptyString(message.updateLatestVersion);
944
- if (nextLatest !== updateLatestVersion) {
945
- updateLatestVersion = nextLatest;
946
- changed = true;
947
- }
948
- }
949
-
950
- return changed;
951
- }
952
-
953
925
  function isTitleAttentionRequestKind(kind) {
954
926
  return kind === "annotation" || kind === "critique" || kind === "direct";
955
927
  }
@@ -1134,13 +1106,7 @@
1134
1106
  const modelText = modelLabel && modelLabel.trim() ? modelLabel.trim() : "none";
1135
1107
  const terminalText = terminalSessionLabel && terminalSessionLabel.trim() ? terminalSessionLabel.trim() : "unknown";
1136
1108
  const contextText = formatContextUsageText();
1137
- let updateText = "";
1138
- if (updateLatestVersion) {
1139
- updateText = updateInstalledVersion
1140
- ? "Update: " + updateInstalledVersion + " → " + updateLatestVersion
1141
- : "Update: " + updateLatestVersion + " available";
1142
- }
1143
- const text = "Model: " + modelText + " · Terminal: " + terminalText + " · " + contextText + (updateText ? " · " + updateText : "");
1109
+ const text = "Model: " + modelText + " · Terminal: " + terminalText + " · " + contextText;
1144
1110
  if (footerMetaTextEl) {
1145
1111
  footerMetaTextEl.textContent = text;
1146
1112
  footerMetaTextEl.title = text;
@@ -8137,7 +8103,7 @@
8137
8103
  const textarea = document.createElement("textarea");
8138
8104
  textarea.value = String(note.text || "");
8139
8105
  textarea.placeholder = "Write a local comment here…";
8140
- textarea.title = "Write a local comment. Press Enter to finish editing, or Shift+Enter for a new line.";
8106
+ textarea.title = "Write a local comment. Enter inserts a new line; changes save automatically as you type.";
8141
8107
  card.appendChild(textarea);
8142
8108
 
8143
8109
  const footer = document.createElement("div");
@@ -8203,22 +8169,6 @@
8203
8169
  updateReviewNotesUi();
8204
8170
  });
8205
8171
 
8206
- textarea.addEventListener("keydown", (event) => {
8207
- if (
8208
- event.key === "Enter"
8209
- && !event.shiftKey
8210
- && !event.altKey
8211
- && !event.ctrlKey
8212
- && !event.metaKey
8213
- ) {
8214
- event.preventDefault();
8215
- textarea.blur();
8216
- if (!convertBtn.disabled) {
8217
- convertBtn.focus();
8218
- }
8219
- }
8220
- });
8221
-
8222
8172
  reviewNotesListEl.appendChild(card);
8223
8173
 
8224
8174
  if (pendingReviewNoteInlineFocusId && pendingReviewNoteInlineFocusId === note.id && isReviewNotesOpen()) {
@@ -9000,8 +8950,7 @@
9000
8950
  debugTrace("server_message", summarizeServerMessage(message));
9001
8951
 
9002
8952
  const contextChanged = applyContextUsageFromMessage(message);
9003
- const updateInfoChanged = applyUpdateInfoFromMessage(message);
9004
- if (contextChanged || updateInfoChanged) {
8953
+ if (contextChanged) {
9005
8954
  updateFooterMeta();
9006
8955
  }
9007
8956
 
@@ -9719,10 +9668,10 @@
9719
9668
 
9720
9669
  function buildAnnotationHeader() {
9721
9670
  const sourceDescriptor = describeSourceForAnnotation();
9722
- let header = "annotated reply below:\n";
9723
- header += "original source: " + sourceDescriptor + "\n";
9724
- header += "user annotation syntax: [an: note]\n";
9725
- header += "precedence: later messages supersede these annotations unless user explicitly references them\n\n---\n\n";
9671
+ let header = "annotated reply: below\n\n";
9672
+ header += "- original source: " + sourceDescriptor + "\n";
9673
+ header += "- user annotation syntax: [an: note]\n";
9674
+ header += "- precedence: later messages supersede these annotations unless user explicitly references them\n\n---\n\n";
9726
9675
  return header;
9727
9676
  }
9728
9677
 
@@ -9732,7 +9681,8 @@
9732
9681
 
9733
9682
  function stripAnnotationHeader(text) {
9734
9683
  const normalized = String(text || "").replace(/\r\n/g, "\n");
9735
- if (!normalized.toLowerCase().startsWith("annotated reply below:")) {
9684
+ const lower = normalized.toLowerCase();
9685
+ if (!lower.startsWith("annotated reply: below") && !lower.startsWith("annotated reply below:")) {
9736
9686
  return { hadHeader: false, body: normalized };
9737
9687
  }
9738
9688
 
package/index.ts CHANGED
@@ -20,7 +20,10 @@ import {
20
20
  transformStudioMarkdownOutsideFences,
21
21
  } from "./shared/studio-annotation-scanner.js";
22
22
  import { stripStudioMarkdownHtmlComments } from "./shared/studio-markdown-html-comments.js";
23
- import { preserveLiteralLatexCommandsInMarkdown } from "./shared/studio-markdown-latex-literals.js";
23
+ import {
24
+ extractStandaloneLatexDefinitionsFromMarkdown,
25
+ preserveLiteralLatexCommandsInMarkdown,
26
+ } from "./shared/studio-markdown-latex-literals.js";
24
27
  import { escapeStudioPdfLatexTextFragment } from "./shared/studio-pdf-escape.js";
25
28
 
26
29
  type Lens = "writing" | "code";
@@ -272,7 +275,6 @@ const PREVIEW_RENDER_MAX_CHARS = 400_000;
272
275
  const PDF_EXPORT_MAX_CHARS = 400_000;
273
276
  const REQUEST_BODY_MAX_BYTES = 1_000_000;
274
277
  const RESPONSE_HISTORY_LIMIT = 30;
275
- const UPDATE_CHECK_TIMEOUT_MS = 1800;
276
278
  const CMUX_NOTIFY_TIMEOUT_MS = 1200;
277
279
  const PREPARED_PDF_EXPORT_TTL_MS = 5 * 60 * 1000;
278
280
  const MAX_PREPARED_PDF_EXPORTS = 8;
@@ -478,7 +480,7 @@ function buildStudioPdfCalloutTitleSizeCommand(options?: StudioPdfRenderOptions)
478
480
  return "\\footnotesize";
479
481
  }
480
482
 
481
- function buildStudioPdfPreamble(options?: StudioPdfRenderOptions): string {
483
+ function buildStudioPdfPreamble(options?: StudioPdfRenderOptions, extraPreamble = ""): string {
482
484
  const sectionHeadingSize = buildStudioPdfHeadingSizeCommand(options?.sectionSize, "\\Large");
483
485
  const subsectionHeadingSize = buildStudioPdfHeadingSizeCommand(options?.subsectionSize, "\\large");
484
486
  const subsubsectionHeadingSize = buildStudioPdfHeadingSizeCommand(options?.subsubsectionSize, "\\normalsize");
@@ -544,7 +546,7 @@ function buildStudioPdfPreamble(options?: StudioPdfRenderOptions): string {
544
546
  \\RecustomVerbatimEnvironment{Highlighting}{Verbatim}{commandchars=\\\\\\{\\},breaklines,breakanywhere,bgcolor=StudioCodeBlockBg,framesep=2mm}%
545
547
  }
546
548
  \\makeatother
547
- `;
549
+ ${extraPreamble ? `${extraPreamble.trim()}\n` : ""}`;
548
550
  }
549
551
 
550
552
  type StudioThemeMode = "dark" | "light";
@@ -2737,93 +2739,6 @@ function readStudioGitDiff(baseDir: string):
2737
2739
  return { ok: true, text: fullDiff, label };
2738
2740
  }
2739
2741
 
2740
- function readLocalPackageMetadata(): { name: string; version: string } | null {
2741
- try {
2742
- const raw = readFileSync(new URL("./package.json", import.meta.url), "utf-8");
2743
- const parsed = JSON.parse(raw) as { name?: unknown; version?: unknown };
2744
- const name = typeof parsed.name === "string" ? parsed.name.trim() : "";
2745
- const version = typeof parsed.version === "string" ? parsed.version.trim() : "";
2746
- if (!name || !version) return null;
2747
- return { name, version };
2748
- } catch {
2749
- return null;
2750
- }
2751
- }
2752
-
2753
- interface ParsedSemver {
2754
- major: number;
2755
- minor: number;
2756
- patch: number;
2757
- prerelease: string | null;
2758
- }
2759
-
2760
- function parseSemverLoose(version: string): ParsedSemver | null {
2761
- const normalized = String(version || "").trim().replace(/^v/i, "");
2762
- const match = normalized.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?/);
2763
- if (!match) return null;
2764
- const major = Number.parseInt(match[1] ?? "", 10);
2765
- const minor = Number.parseInt(match[2] ?? "0", 10);
2766
- const patch = Number.parseInt(match[3] ?? "0", 10);
2767
- if (!Number.isFinite(major) || !Number.isFinite(minor) || !Number.isFinite(patch)) return null;
2768
- const prerelease = typeof match[4] === "string" && match[4].trim() ? match[4].trim() : null;
2769
- return { major, minor, patch, prerelease };
2770
- }
2771
-
2772
- function compareSemverLoose(a: string, b: string): number {
2773
- const pa = parseSemverLoose(a);
2774
- const pb = parseSemverLoose(b);
2775
- if (!pa || !pb) {
2776
- return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
2777
- }
2778
- if (pa.major !== pb.major) return pa.major - pb.major;
2779
- if (pa.minor !== pb.minor) return pa.minor - pb.minor;
2780
- if (pa.patch !== pb.patch) return pa.patch - pb.patch;
2781
- if (pa.prerelease && !pb.prerelease) return -1;
2782
- if (!pa.prerelease && pb.prerelease) return 1;
2783
- if (!pa.prerelease && !pb.prerelease) return 0;
2784
- return (pa.prerelease ?? "").localeCompare(pb.prerelease ?? "", undefined, {
2785
- numeric: true,
2786
- sensitivity: "base",
2787
- });
2788
- }
2789
-
2790
- function isVersionBehind(installedVersion: string, latestVersion: string): boolean {
2791
- return compareSemverLoose(installedVersion, latestVersion) < 0;
2792
- }
2793
-
2794
- async function fetchLatestNpmVersion(packageName: string, timeoutMs = UPDATE_CHECK_TIMEOUT_MS): Promise<string | null> {
2795
- const pkg = String(packageName || "").trim();
2796
- if (!pkg) return null;
2797
- const encodedPackage = encodeURIComponent(pkg).replace(/^%40/, "@");
2798
- const endpoint = `https://registry.npmjs.org/${encodedPackage}/latest`;
2799
- const controller = typeof AbortController === "function" ? new AbortController() : null;
2800
- const timer = controller
2801
- ? setTimeout(() => {
2802
- try {
2803
- controller.abort();
2804
- } catch {
2805
- // ignore abort race
2806
- }
2807
- }, timeoutMs)
2808
- : null;
2809
-
2810
- try {
2811
- const response = await fetch(endpoint, {
2812
- method: "GET",
2813
- headers: { Accept: "application/json" },
2814
- signal: controller?.signal,
2815
- });
2816
- if (!response.ok) return null;
2817
- const payload = await response.json() as { version?: unknown };
2818
- const version = typeof payload.version === "string" ? payload.version.trim() : "";
2819
- return version || null;
2820
- } catch {
2821
- return null;
2822
- } finally {
2823
- if (timer) clearTimeout(timer);
2824
- }
2825
- }
2826
-
2827
2742
  function isLikelyMathExpression(expr: string): boolean {
2828
2743
  const content = expr.trim();
2829
2744
  if (content.length === 0) return false;
@@ -4566,6 +4481,7 @@ async function renderStudioPdfFromGeneratedLatex(
4566
4481
  calloutBlocks: StudioPdfMarkdownCalloutBlock[] = [],
4567
4482
  alignedImageBlocks: StudioPdfAlignedImageBlock[] = [],
4568
4483
  pdfOptions?: StudioPdfRenderOptions,
4484
+ extraPreamble = "",
4569
4485
  ): Promise<{ pdf: Buffer; warning?: string }> {
4570
4486
  const tempDir = join(tmpdir(), `pi-studio-pdf-${Date.now()}-${randomUUID()}`);
4571
4487
  const preamblePath = join(tempDir, "_pdf_preamble.tex");
@@ -4573,7 +4489,7 @@ async function renderStudioPdfFromGeneratedLatex(
4573
4489
  const outputPath = join(tempDir, "studio-export.pdf");
4574
4490
 
4575
4491
  await mkdir(tempDir, { recursive: true });
4576
- await writeFile(preamblePath, buildStudioPdfPreamble(pdfOptions), "utf-8");
4492
+ await writeFile(preamblePath, buildStudioPdfPreamble(pdfOptions, extraPreamble), "utf-8");
4577
4493
 
4578
4494
  const pandocArgs = [
4579
4495
  "-f", inputFormat,
@@ -4855,17 +4771,22 @@ async function renderStudioPdfWithPandoc(
4855
4771
  ? "latex"
4856
4772
  : "markdown+lists_without_preceding_blankline-blank_before_blockquote-blank_before_header+tex_math_dollars+tex_math_single_backslash+tex_math_double_backslash+autolink_bare_uris+superscript+subscript-raw_html";
4857
4773
  const normalizedMarkdown = prepareStudioPdfMarkdown(pdfAlignedImageTransform.markdown, isLatex, effectiveEditorLanguage);
4774
+ const markdownPreambleSplit = !isLatex && (!effectiveEditorLanguage || effectiveEditorLanguage === "markdown")
4775
+ ? extractStandaloneLatexDefinitionsFromMarkdown(normalizedMarkdown)
4776
+ : { body: normalizedMarkdown, definitions: [], preamble: "" };
4777
+ const normalizedMarkdownBody = markdownPreambleSplit.body;
4778
+ const extraPdfPreamble = markdownPreambleSplit.preamble;
4858
4779
 
4859
4780
  const tempDir = join(tmpdir(), `pi-studio-pdf-${Date.now()}-${randomUUID()}`);
4860
4781
  const preamblePath = join(tempDir, "_pdf_preamble.tex");
4861
4782
  const outputPath = join(tempDir, "studio-export.pdf");
4862
4783
 
4863
4784
  await mkdir(tempDir, { recursive: true });
4864
- await writeFile(preamblePath, buildStudioPdfPreamble(pdfOptions), "utf-8");
4785
+ await writeFile(preamblePath, buildStudioPdfPreamble(pdfOptions, extraPdfPreamble), "utf-8");
4865
4786
 
4866
4787
  const mermaidPrepared: StudioMermaidPdfPreprocessResult = isLatex
4867
- ? { markdown: normalizedMarkdown, found: 0, replaced: 0, failed: 0, missingCli: false }
4868
- : await preprocessStudioMermaidForPdf(normalizedMarkdown, tempDir);
4788
+ ? { markdown: normalizedMarkdownBody, found: 0, replaced: 0, failed: 0, missingCli: false }
4789
+ : await preprocessStudioMermaidForPdf(normalizedMarkdownBody, tempDir);
4869
4790
  const markdownForPdf = mermaidPrepared.markdown;
4870
4791
  const hasDiffBlocks = !isLatex && hasStudioMarkdownDiffFence(markdownForPdf);
4871
4792
 
@@ -4883,6 +4804,7 @@ async function renderStudioPdfWithPandoc(
4883
4804
  pdfCalloutTransform.blocks,
4884
4805
  pdfAlignedImageTransform.blocks,
4885
4806
  pdfOptions,
4807
+ extraPdfPreamble,
4886
4808
  );
4887
4809
  await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
4888
4810
  return { pdf: rendered.pdf, warning: mermaidPrepared.warning ?? rendered.warning };
@@ -5842,6 +5764,17 @@ function buildStudioUrl(
5842
5764
  return `http://127.0.0.1:${port}/?${params.toString()}`;
5843
5765
  }
5844
5766
 
5767
+ function isSshSession(): boolean {
5768
+ return Boolean(
5769
+ String(process.env.SSH_CONNECTION ?? process.env.SSH_CLIENT ?? process.env.SSH_TTY ?? "").trim(),
5770
+ );
5771
+ }
5772
+
5773
+ function buildStudioSshTunnelHint(port: number): string | null {
5774
+ if (!isSshSession()) return null;
5775
+ return `SSH detected. If Studio is running on a remote machine, forward the port from your local machine, then open the Studio URL locally: ssh -L ${port}:127.0.0.1:${port} <remote-host>`;
5776
+ }
5777
+
5845
5778
  function resolveRequestedStudioDocumentFromUrl(
5846
5779
  requestUrl: URL,
5847
5780
  fallback: InitialStudioDocument | null,
@@ -6382,11 +6315,6 @@ export default function (pi: ExtensionAPI) {
6382
6315
  };
6383
6316
  let compactInProgress = false;
6384
6317
  let compactRequestId: string | null = null;
6385
- let updateCheckStarted = false;
6386
- let updateCheckCompleted = false;
6387
- const packageMetadata = readLocalPackageMetadata();
6388
- const installedPackageVersion = packageMetadata?.version ?? null;
6389
- let updateAvailableLatestVersion: string | null = null;
6390
6318
 
6391
6319
  const isStudioDirectRunChainActive = () => Boolean(studioDirectRunChain);
6392
6320
  const getQueuedStudioSteeringCount = () => queuedStudioDirectRequests.length;
@@ -6766,28 +6694,6 @@ export default function (pi: ExtensionAPI) {
6766
6694
  });
6767
6695
  };
6768
6696
 
6769
- const maybeNotifyUpdateAvailable = async (ctx: ExtensionCommandContext) => {
6770
- if (updateCheckStarted || updateCheckCompleted) return;
6771
- updateCheckStarted = true;
6772
- try {
6773
- const metadata = packageMetadata;
6774
- if (!metadata) return;
6775
- const latest = await fetchLatestNpmVersion(metadata.name, UPDATE_CHECK_TIMEOUT_MS);
6776
- if (!latest) return;
6777
- if (!isVersionBehind(metadata.version, latest)) return;
6778
-
6779
- updateAvailableLatestVersion = latest;
6780
- broadcastState();
6781
-
6782
- const notification =
6783
- `Update available for ${metadata.name}: ${metadata.version} → ${latest}. Run: pi install npm:${metadata.name}`;
6784
- ctx.ui.notify(notification, "info");
6785
- broadcast({ type: "info", message: notification, level: "info" });
6786
- } finally {
6787
- updateCheckCompleted = true;
6788
- }
6789
- };
6790
-
6791
6697
  const sendToClient = (client: WebSocket, payload: unknown) => {
6792
6698
  if (client.readyState !== WebSocket.OPEN) return;
6793
6699
  try {
@@ -7067,8 +6973,6 @@ export default function (pi: ExtensionAPI) {
7067
6973
  contextTokens: contextUsageSnapshot.tokens,
7068
6974
  contextWindow: contextUsageSnapshot.contextWindow,
7069
6975
  contextPercent: contextUsageSnapshot.percent,
7070
- updateInstalledVersion: installedPackageVersion,
7071
- updateLatestVersion: updateAvailableLatestVersion,
7072
6976
  compactInProgress,
7073
6977
  activeRequestId: activeRequest?.id ?? compactRequestId ?? null,
7074
6978
  activeRequestKind: activeRequest?.kind ?? (compactInProgress ? "compact" : null),
@@ -7349,8 +7253,6 @@ export default function (pi: ExtensionAPI) {
7349
7253
  contextTokens: contextUsageSnapshot.tokens,
7350
7254
  contextWindow: contextUsageSnapshot.contextWindow,
7351
7255
  contextPercent: contextUsageSnapshot.percent,
7352
- updateInstalledVersion: installedPackageVersion,
7353
- updateLatestVersion: updateAvailableLatestVersion,
7354
7256
  compactInProgress,
7355
7257
  activeRequestId: activeRequest?.id ?? compactRequestId ?? null,
7356
7258
  activeRequestKind: activeRequest?.kind ?? (compactInProgress ? "compact" : null),
@@ -9018,6 +8920,8 @@ export default function (pi: ExtensionAPI) {
9018
8920
  ctx.ui.notify("A full pi Studio view is already open for this session. Close it first, use /studio-replace for a fresh full Studio view, or use /studio-editor-only for a concurrent editor-only Studio view.", "warning");
9019
8921
  if (serverState) {
9020
8922
  ctx.ui.notify(`Studio URL: ${buildStudioUrl(serverState.port, serverState.token, "full")}`, "info");
8923
+ const sshTunnelHint = buildStudioSshTunnelHint(serverState.port);
8924
+ if (sshTunnelHint) ctx.ui.notify(sshTunnelHint, "info");
9021
8925
  }
9022
8926
  return;
9023
8927
  }
@@ -9043,6 +8947,7 @@ export default function (pi: ExtensionAPI) {
9043
8947
 
9044
8948
  const state = await ensureServer();
9045
8949
  const url = buildStudioUrl(state.port, state.token, mode, selected);
8950
+ const sshTunnelHint = buildStudioSshTunnelHint(state.port);
9046
8951
  const openedLabel = mode === "editor-only" ? "pi Studio editor-only view" : "pi Studio";
9047
8952
 
9048
8953
  try {
@@ -9054,11 +8959,16 @@ export default function (pi: ExtensionAPI) {
9054
8959
  } else {
9055
8960
  ctx.ui.notify(`Opened ${openedLabel} with blank editor.`, "info");
9056
8961
  }
9057
- ctx.ui.notify(`Studio URL: ${url}`, "info");
9058
8962
  } catch (error) {
9059
- ctx.ui.notify(`Failed to open browser: ${error instanceof Error ? error.message : String(error)}`, "error");
8963
+ const message = error instanceof Error ? error.message : String(error);
8964
+ if (isSshSession()) {
8965
+ ctx.ui.notify(`Failed to open browser automatically over SSH: ${message}`, "warning");
8966
+ } else {
8967
+ ctx.ui.notify(`Failed to open browser: ${message}`, "error");
8968
+ }
9060
8969
  } finally {
9061
- void maybeNotifyUpdateAvailable(ctx);
8970
+ ctx.ui.notify(`Studio URL: ${url}`, "info");
8971
+ if (sshTunnelHint) ctx.ui.notify(sshTunnelHint, "info");
9062
8972
  }
9063
8973
  };
9064
8974
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.5.55",
3
+ "version": "0.5.57",
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",
@@ -51,6 +51,18 @@ const STUDIO_LITERAL_MARKDOWN_LATEX_COMMANDS = new Set([
51
51
  "tex",
52
52
  ]);
53
53
 
54
+ const STUDIO_STANDALONE_MARKDOWN_LATEX_DEFINITION_COMMANDS = new Set([
55
+ "newcommand",
56
+ "renewcommand",
57
+ "providecommand",
58
+ "declaremathoperator",
59
+ "def",
60
+ "gdef",
61
+ "edef",
62
+ "xdef",
63
+ "let",
64
+ ]);
65
+
54
66
  function isEscapedAt(text, index) {
55
67
  let slashCount = 0;
56
68
  for (let i = index - 1; i >= 0 && text[i] === "\\"; i -= 1) {
@@ -70,12 +82,40 @@ function findClosingUnescapedDelimiter(text, startIndex, delimiter) {
70
82
  return -1;
71
83
  }
72
84
 
85
+ function isStandaloneLatexDefinitionLine(line) {
86
+ const commandMatch = String(line || "").match(/^[ \t]{0,3}\\([A-Za-z@]+)\*?(?=\s|\\|\{|\[|$)/);
87
+ if (!commandMatch) return false;
88
+
89
+ const commandName = String(commandMatch[1] || "").toLowerCase();
90
+ return STUDIO_STANDALONE_MARKDOWN_LATEX_DEFINITION_COMMANDS.has(commandName);
91
+ }
92
+
93
+ function readStandaloneLatexDefinitionLine(text, startIndex) {
94
+ if (startIndex > 0 && text[startIndex - 1] !== "\n") return null;
95
+
96
+ const lineEndIndex = text.indexOf("\n", startIndex);
97
+ const sliceEnd = lineEndIndex >= 0 ? lineEndIndex : text.length;
98
+ const line = text.slice(startIndex, sliceEnd);
99
+ if (!isStandaloneLatexDefinitionLine(line)) return null;
100
+
101
+ return {
102
+ text: lineEndIndex >= 0 ? text.slice(startIndex, lineEndIndex + 1) : line,
103
+ nextIndex: lineEndIndex >= 0 ? lineEndIndex + 1 : text.length,
104
+ };
105
+ }
106
+
73
107
  function preserveLiteralLatexCommandsInMarkdownSegment(markdown) {
74
108
  const source = String(markdown || "");
75
109
  let out = "";
76
110
  let index = 0;
77
111
 
78
112
  while (index < source.length) {
113
+ const standaloneDefinitionLine = readStandaloneLatexDefinitionLine(source, index);
114
+ if (standaloneDefinitionLine) {
115
+ out += standaloneDefinitionLine.text;
116
+ index = standaloneDefinitionLine.nextIndex;
117
+ continue;
118
+ }
79
119
  if (source[index] === "`") {
80
120
  let tickCount = 1;
81
121
  while (source[index + tickCount] === "`") tickCount += 1;
@@ -201,3 +241,49 @@ export function preserveLiteralLatexCommandsInMarkdown(markdown) {
201
241
  flushPlain();
202
242
  return out.join("\n");
203
243
  }
244
+
245
+ export function extractStandaloneLatexDefinitionsFromMarkdown(markdown) {
246
+ const lines = String(markdown || "").split("\n");
247
+ const bodyLines = [];
248
+ const definitions = [];
249
+ let inFence = false;
250
+ let fenceChar;
251
+ let fenceLength = 0;
252
+
253
+ for (const line of lines) {
254
+ const trimmed = line.trimStart();
255
+ const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
256
+
257
+ if (fenceMatch) {
258
+ const marker = fenceMatch[1];
259
+ const markerChar = marker[0];
260
+ const markerLength = marker.length;
261
+
262
+ if (!inFence) {
263
+ inFence = true;
264
+ fenceChar = markerChar;
265
+ fenceLength = markerLength;
266
+ } else if (fenceChar === markerChar && markerLength >= fenceLength) {
267
+ inFence = false;
268
+ fenceChar = undefined;
269
+ fenceLength = 0;
270
+ }
271
+
272
+ bodyLines.push(line);
273
+ continue;
274
+ }
275
+
276
+ if (!inFence && isStandaloneLatexDefinitionLine(line)) {
277
+ definitions.push(line);
278
+ continue;
279
+ }
280
+
281
+ bodyLines.push(line);
282
+ }
283
+
284
+ return {
285
+ body: bodyLines.join("\n"),
286
+ definitions,
287
+ preamble: definitions.join("\n"),
288
+ };
289
+ }