pi-studio 0.9.28 → 0.9.30

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,16 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.30] — 2026-06-09
8
+
9
+ ### Fixed
10
+ - Rendered inline strikethrough, emphasis, and code inside `[an: ...]` annotation badges consistently across Studio preview and HTML/PDF export paths.
11
+
12
+ ## [0.9.29] — 2026-06-09
13
+
14
+ ### Added
15
+ - Added `/studio --no-browser` and `/studio --port <port>` launch flags for explicit remote/forwarded Studio sessions when SSH auto-detection is not enough.
16
+
7
17
  ## [0.9.28] — 2026-06-08
8
18
 
9
19
  ### Added
package/README.md CHANGED
@@ -56,6 +56,8 @@ Extension for [pi](https://pi.dev) that opens a local two-pane browser workspace
56
56
  | `/studio <path>` | Open with file preloaded |
57
57
  | `/studio --last` | Force last response |
58
58
  | `/studio --blank` | Force blank editor |
59
+ | `/studio --no-browser` | Start/print the Studio URL without opening a browser, useful for forwarded or phone/browser sessions |
60
+ | `/studio --port <port>` | Bind Studio to a fixed localhost port instead of a random free port |
59
61
  | `/studio --status` | Show studio server status |
60
62
  | `/studio --stop` | Stop studio server |
61
63
  | `/studio --help` | Show help |
@@ -113,7 +115,7 @@ caption: Optional caption
113
115
  ## Notes
114
116
 
115
117
  - Local-only server (`127.0.0.1`) with tokenized Studio URLs.
116
- - For remote SSH sessions, keep Studio bound to localhost and use SSH local port forwarding; `/studio` and `/studio --status` print the full tokenized localhost URL. The SSH hint repeats the full URL so it is visible even if your terminal only shows the latest notification. Open that URL through the tunnel, preserving the `?token=...` parameter.
118
+ - For remote SSH sessions, keep Studio bound to localhost and use SSH local port forwarding; `/studio` and `/studio --status` print the full tokenized localhost URL. The SSH hint repeats the full URL so it is visible even if your terminal only shows the latest notification. Open that URL through the tunnel, preserving the `?token=...` parameter. If SSH is not auto-detected, use `/studio --no-browser`; for stable forwarding, use `/studio --port <port>` or combine them, e.g. `/studio --no-browser --port 3417`.
117
119
  - 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.
118
120
  - Studio is designed as a complement to terminal pi, not a replacement.
119
121
  - Installing pi-studio makes the optional `pi-studio-dark` and `pi-studio-light` themes available in pi's theme selector; it does not change your active theme.
@@ -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
@@ -29,7 +29,8 @@ import {
29
29
  } from "./shared/studio-markdown-latex-literals.js";
30
30
  import { escapeStudioPdfLatexTextFragment } from "./shared/studio-pdf-escape.js";
31
31
  import { resolveStudioPdfResourceFile } from "./shared/studio-pdf-resource.js";
32
- import { buildStudioSshTunnelHint, isStudioSshSession as isSshSession } from "./shared/studio-ssh-hint.js";
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}
@@ -5031,6 +5033,13 @@ function renderStudioAnnotationPlainTextPdfLatex(text: string): string {
5031
5033
  let index = 0;
5032
5034
 
5033
5035
  while (index < source.length) {
5036
+ const strikeMatch = readStudioAnnotationPdfEmphasisSpanAt(source, index, "~~", "sout");
5037
+ if (strikeMatch) {
5038
+ out += strikeMatch.latex;
5039
+ index = strikeMatch.end;
5040
+ continue;
5041
+ }
5042
+
5034
5043
  const strongMatch = readStudioAnnotationPdfEmphasisSpanAt(source, index, "**", "textbf")
5035
5044
  ?? readStudioAnnotationPdfEmphasisSpanAt(source, index, "__", "textbf");
5036
5045
  if (strongMatch) {
@@ -5648,13 +5657,14 @@ function prepareStudioPdfMarkdown(markdown: string, isLatex?: boolean, editorLan
5648
5657
  ? wrapStudioCodeAsMarkdown(input, effectiveEditorLanguage)
5649
5658
  : input;
5650
5659
  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),
5660
+ const annotationReadyLanguage = !effectiveEditorLanguage || effectiveEditorLanguage === "markdown" || effectiveEditorLanguage === "latex";
5661
+ const commentStrippedSource = stripStudioMarkdownHtmlCommentsPreservingYamlFrontMatter(fenceNormalizedSource);
5662
+ const pandocReadySource = prepareStudioMarkdownForPandoc(commentStrippedSource, {
5663
+ preserveLiteralLatexCommands: !hasStudioYamlHeaderIncludes(fenceNormalizedSource),
5657
5664
  });
5665
+ return annotationReadyLanguage
5666
+ ? replaceStudioAnnotationMarkersForPdf(pandocReadySource)
5667
+ : pandocReadySource;
5658
5668
  }
5659
5669
 
5660
5670
  function stripMathMlAnnotationTags(html: string): string {
@@ -6118,7 +6128,7 @@ function applyStudioAnnotationPlaceholdersToHtml(html: string, placeholders: Stu
6118
6128
  let transformed = String(html ?? "");
6119
6129
  for (const placeholder of placeholders) {
6120
6130
  const tokenPattern = new RegExp(escapeStudioRegExpLiteral(placeholder.token), "g");
6121
- const markerHtml = `<span class="annotation-preview-marker" title="${escapeStudioHtmlText(placeholder.title)}">${escapeStudioHtmlText(placeholder.text)}</span>`;
6131
+ const markerHtml = `<span class="annotation-preview-marker" title="${escapeStudioHtmlText(placeholder.title)}">${renderStudioAnnotationInlineHtml(placeholder.text)}</span>`;
6122
6132
  transformed = transformed.replace(tokenPattern, markerHtml);
6123
6133
  }
6124
6134
  return transformed;
@@ -9843,22 +9853,53 @@ function buildStudioUrl(
9843
9853
  return `http://127.0.0.1:${port}/?${params.toString()}`;
9844
9854
  }
9845
9855
 
9846
- function parseStudioLaunchOpenFlags(rawArgs: string): { args: string; openRemoteBrowser: boolean; error?: string } {
9856
+ interface StudioLaunchFlags {
9857
+ args: string;
9858
+ openRemoteBrowser: boolean;
9859
+ noBrowser: boolean;
9860
+ port?: number;
9861
+ error?: string;
9862
+ }
9863
+
9864
+ function parseStudioLaunchOpenFlags(rawArgs: string): StudioLaunchFlags {
9847
9865
  const parsed = tokenizeStudioCommandArgs(rawArgs);
9848
- if (parsed.error) return { args: rawArgs, openRemoteBrowser: false, error: parsed.error };
9866
+ if (parsed.error) return { args: rawArgs, openRemoteBrowser: false, noBrowser: false, error: parsed.error };
9849
9867
  const remaining: string[] = [];
9850
9868
  let openRemoteBrowser = false;
9851
- for (const token of parsed.tokens) {
9852
- if (token === "--open-remote" || token === "--open-remote-browser") {
9869
+ let noBrowser = false;
9870
+ let port: number | undefined;
9871
+ for (let i = 0; i < parsed.tokens.length; i += 1) {
9872
+ const token = parsed.tokens[i]!;
9873
+ if (token === "--open-remote" || token === "--open-remote-browser" || token === "--open-browser") {
9853
9874
  openRemoteBrowser = true;
9854
9875
  continue;
9855
9876
  }
9877
+ if (token === "--no-browser" || token === "--no-open" || token === "--no-open-browser") {
9878
+ noBrowser = true;
9879
+ continue;
9880
+ }
9881
+ if (token === "--port" || token.startsWith("--port=")) {
9882
+ const rawPort = token.startsWith("--port=") ? token.slice("--port=".length) : parsed.tokens[++i];
9883
+ if (!rawPort) {
9884
+ return { args: rawArgs, openRemoteBrowser, noBrowser, error: "Missing value for --port." };
9885
+ }
9886
+ const requestedPort = Number(rawPort);
9887
+ if (!Number.isInteger(requestedPort) || requestedPort < 1 || requestedPort > 65535) {
9888
+ return { args: rawArgs, openRemoteBrowser, noBrowser, error: `Invalid --port value: ${rawPort}. Use an integer from 1 to 65535.` };
9889
+ }
9890
+ port = requestedPort;
9891
+ continue;
9892
+ }
9856
9893
  remaining.push(token);
9857
9894
  }
9858
- return { args: remaining.join(" "), openRemoteBrowser };
9895
+ if (openRemoteBrowser && noBrowser) {
9896
+ return { args: rawArgs, openRemoteBrowser, noBrowser, port, error: "Use either --no-browser or --open-browser, not both." };
9897
+ }
9898
+ return { args: remaining.join(" "), openRemoteBrowser, noBrowser, port };
9859
9899
  }
9860
9900
 
9861
- function shouldAutoOpenStudioBrowser(options?: { openRemoteBrowser?: boolean }): boolean {
9901
+ function shouldAutoOpenStudioBrowser(options?: { openRemoteBrowser?: boolean; noBrowser?: boolean }): boolean {
9902
+ if (options?.noBrowser) return false;
9862
9903
  return !isSshSession() || Boolean(options?.openRemoteBrowser);
9863
9904
  }
9864
9905
 
@@ -14096,7 +14137,7 @@ export default function (pi: ExtensionAPI) {
14096
14137
  res.end(buildStudioHtml(requestInitialDocument, serverState.token, lastCommandCtx?.ui.theme, currentModelLabel, terminalSessionLabel, terminalSessionDetail, contextUsageSnapshot, studioMode));
14097
14138
  };
14098
14139
 
14099
- const ensureServer = async (): Promise<StudioServerState> => {
14140
+ const ensureServer = async (requestedPort?: number): Promise<StudioServerState> => {
14100
14141
  if (serverState) return serverState;
14101
14142
 
14102
14143
  const server = createServer(handleHttpRequest);
@@ -14184,6 +14225,8 @@ export default function (pi: ExtensionAPI) {
14184
14225
  });
14185
14226
  });
14186
14227
 
14228
+ const listenPort = typeof requestedPort === "number" && Number.isInteger(requestedPort) && requestedPort > 0 ? requestedPort : 0;
14229
+
14187
14230
  await new Promise<void>((resolve, reject) => {
14188
14231
  const onError = (error: Error) => {
14189
14232
  server.off("listening", onListening);
@@ -14195,7 +14238,7 @@ export default function (pi: ExtensionAPI) {
14195
14238
  };
14196
14239
  server.once("error", onError);
14197
14240
  server.once("listening", onListening);
14198
- server.listen(0, "127.0.0.1");
14241
+ server.listen(listenPort, "127.0.0.1");
14199
14242
  });
14200
14243
 
14201
14244
  const address = server.address();
@@ -14981,6 +15024,9 @@ export default function (pi: ExtensionAPI) {
14981
15024
  return;
14982
15025
  }
14983
15026
  const launchArgs = launchOpenFlags.args;
15027
+ if (serverState && launchOpenFlags.port && serverState.port !== launchOpenFlags.port) {
15028
+ ctx.ui.notify(`Studio server is already running on port ${serverState.port}; requested port ${launchOpenFlags.port}. Use /studio --stop, then restart Studio with --port ${launchOpenFlags.port} to change it.`, "warning");
15029
+ }
14984
15030
  if (mode === "full" && hasConnectedFullStudioView()) {
14985
15031
  if (options?.replaceExistingFull) {
14986
15032
  closeStudioClientsByMode("full", 4001, "Full Studio replaced");
@@ -14989,8 +15035,9 @@ export default function (pi: ExtensionAPI) {
14989
15035
  if (serverState) {
14990
15036
  const url = buildStudioUrl(serverState.port, serverState.token, "full");
14991
15037
  ctx.ui.notify(`Studio URL: ${url}`, "info");
14992
- const sshTunnelHint = buildStudioSshTunnelHint(serverState.port, url);
14993
- if (sshTunnelHint) ctx.ui.notify(sshTunnelHint, "info");
15038
+ const tunnelHint = buildStudioSshTunnelHint(serverState.port, url)
15039
+ ?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(serverState.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
15040
+ if (tunnelHint) ctx.ui.notify(tunnelHint, "info");
14994
15041
  }
14995
15042
  return;
14996
15043
  }
@@ -15015,17 +15062,28 @@ export default function (pi: ExtensionAPI) {
15015
15062
  if (!selected) return;
15016
15063
  initialStudioDocument = selected;
15017
15064
 
15018
- const state = await ensureServer();
15065
+ let state: StudioServerState;
15066
+ try {
15067
+ state = await ensureServer(launchOpenFlags.port);
15068
+ } catch (error) {
15069
+ const message = error instanceof Error ? error.message : String(error);
15070
+ const portText = launchOpenFlags.port ? ` on port ${launchOpenFlags.port}` : "";
15071
+ ctx.ui.notify(`Failed to start Studio server${portText}: ${message}`, "error");
15072
+ return;
15073
+ }
15019
15074
  const url = buildStudioUrl(state.port, state.token, mode, selected);
15020
- const sshTunnelHint = buildStudioSshTunnelHint(state.port, url);
15075
+ const tunnelHint = buildStudioSshTunnelHint(state.port, url)
15076
+ ?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(state.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
15021
15077
  const openedLabel = mode === "editor-only" ? "pi Studio editor-only view" : "pi Studio";
15022
15078
 
15023
15079
  const shouldOpenBrowser = shouldAutoOpenStudioBrowser({
15024
15080
  openRemoteBrowser: launchOpenFlags.openRemoteBrowser,
15081
+ noBrowser: launchOpenFlags.noBrowser,
15025
15082
  });
15026
15083
  try {
15027
15084
  if (!shouldOpenBrowser) {
15028
- ctx.ui.notify(`${openedLabel} is ready. Browser auto-open was skipped because SSH was detected.`, "info");
15085
+ const skipReason = launchOpenFlags.noBrowser ? "--no-browser was used" : "SSH was detected";
15086
+ ctx.ui.notify(`${openedLabel} is ready. Browser auto-open was skipped because ${skipReason}.`, "info");
15029
15087
  } else {
15030
15088
  await openUrlInDefaultBrowser(url);
15031
15089
  if (selected.source === "file") {
@@ -15045,12 +15103,12 @@ export default function (pi: ExtensionAPI) {
15045
15103
  }
15046
15104
  } finally {
15047
15105
  ctx.ui.notify(`Studio URL: ${url}`, "info");
15048
- if (sshTunnelHint) ctx.ui.notify(sshTunnelHint, "info");
15106
+ if (tunnelHint) ctx.ui.notify(tunnelHint, "info");
15049
15107
  }
15050
15108
  };
15051
15109
 
15052
15110
  pi.registerCommand("studio", {
15053
- description: "Open pi Studio browser UI (/studio, /studio <file>, /studio --blank, /studio --last)",
15111
+ description: "Open pi Studio browser UI (/studio, /studio <file>, /studio --blank, /studio --last, /studio --no-browser, /studio --port <port>)",
15054
15112
  handler: async (args: string, ctx: ExtensionCommandContext) => {
15055
15113
  const trimmed = args.trim();
15056
15114
 
@@ -15083,6 +15141,8 @@ export default function (pi: ExtensionAPI) {
15083
15141
  + " /studio <path> Open studio with file preloaded\n"
15084
15142
  + " /studio --blank Open with blank editor\n"
15085
15143
  + " /studio --last Open with last model response\n"
15144
+ + " /studio --no-browser Print the Studio URL without opening a browser\n"
15145
+ + " /studio --port <port> Bind Studio to a fixed localhost port when starting\n"
15086
15146
  + " /studio --open-remote Over SSH, open the remote browser anyway\n"
15087
15147
  + " /studio --status Show studio status\n"
15088
15148
  + " /studio --stop Stop studio server\n"
@@ -15102,7 +15162,7 @@ export default function (pi: ExtensionAPI) {
15102
15162
  });
15103
15163
 
15104
15164
  pi.registerCommand("studio-replace", {
15105
- description: "Replace the current full pi Studio view (/studio-replace, /studio-replace <file>)",
15165
+ description: "Replace the current full pi Studio view (/studio-replace, /studio-replace <file>, /studio-replace --no-browser)",
15106
15166
  handler: async (args: string, ctx: ExtensionCommandContext) => {
15107
15167
  const trimmed = args.trim();
15108
15168
  if (trimmed === "help" || trimmed === "--help" || trimmed === "-h") {
@@ -15112,6 +15172,8 @@ export default function (pi: ExtensionAPI) {
15112
15172
  + " /studio-replace <path> Replace the current full Studio view with file preloaded\n"
15113
15173
  + " /studio-replace --blank Replace with blank editor\n"
15114
15174
  + " /studio-replace --last Replace with last model response\n"
15175
+ + " /studio-replace --no-browser Print URL without opening a browser\n"
15176
+ + " /studio-replace --port <port> Bind Studio to a fixed localhost port when starting\n"
15115
15177
  + "Editor-only Studio views stay open.",
15116
15178
  "info",
15117
15179
  );
@@ -15127,7 +15189,7 @@ export default function (pi: ExtensionAPI) {
15127
15189
  });
15128
15190
 
15129
15191
  pi.registerCommand("studio-editor-only", {
15130
- description: "Open pi Studio in editor-only mode (/studio-editor-only, /studio-editor-only <file>)",
15192
+ description: "Open pi Studio in editor-only mode (/studio-editor-only, /studio-editor-only <file>, /studio-editor-only --no-browser)",
15131
15193
  handler: async (args: string, ctx: ExtensionCommandContext) => {
15132
15194
  const trimmed = args.trim();
15133
15195
  if (trimmed === "help" || trimmed === "--help" || trimmed === "-h") {
@@ -15137,6 +15199,8 @@ export default function (pi: ExtensionAPI) {
15137
15199
  + " /studio-editor-only <path> Open an editor-only Studio view with file preloaded\n"
15138
15200
  + " /studio-editor-only --blank Open with blank editor\n"
15139
15201
  + " /studio-editor-only --last Open with last model response loaded into the editor\n"
15202
+ + " /studio-editor-only --no-browser Print URL without opening a browser\n"
15203
+ + " /studio-editor-only --port <port> Bind Studio to a fixed localhost port when starting\n"
15140
15204
  + "Multiple editor-only views are allowed in the same Pi session.",
15141
15205
  "info",
15142
15206
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.28",
3
+ "version": "0.9.30",
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
+ }
@@ -4,16 +4,25 @@ export function isStudioSshSession(env = process.env) {
4
4
  );
5
5
  }
6
6
 
7
- export function buildStudioSshTunnelHint(port, studioUrl, env = process.env) {
8
- if (!isStudioSshSession(env)) return null;
7
+ export function buildStudioForwardingHint(port, studioUrl, options = {}) {
9
8
  const normalizedPort = Number(port);
10
9
  const remotePort = Number.isInteger(normalizedPort) && normalizedPort > 0 ? normalizedPort : port;
11
10
  const url = String(studioUrl || "").trim();
12
- return [
13
- "SSH detected. Studio was not opened in the remote browser.",
14
- "To open it locally, run this on your local machine:",
11
+ const prefix = String(options.prefix || "").trim();
12
+ const lines = [];
13
+ if (prefix) lines.push(prefix);
14
+ lines.push(
15
+ "To open Studio locally through SSH, run this on your local machine:",
15
16
  ` ssh -L ${remotePort}:127.0.0.1:${remotePort} <remote-host>`,
16
17
  "Then open this Studio URL in your local browser:",
17
18
  ` ${url}`,
18
- ].join("\n");
19
+ );
20
+ return lines.join("\n");
21
+ }
22
+
23
+ export function buildStudioSshTunnelHint(port, studioUrl, env = process.env) {
24
+ if (!isStudioSshSession(env)) return null;
25
+ return buildStudioForwardingHint(port, studioUrl, {
26
+ prefix: "SSH detected. Studio was not opened in the remote browser.",
27
+ });
19
28
  }