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 +10 -0
- package/README.md +3 -1
- package/client/studio-annotation-helpers.js +7 -0
- package/index.ts +89 -25
- package/package.json +1 -1
- package/shared/studio-annotation-render.js +148 -0
- package/shared/studio-ssh-hint.js +15 -6
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
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
|
|
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)}">${
|
|
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
|
-
|
|
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
|
-
|
|
9852
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
14993
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
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, "&")
|
|
11
|
+
.replace(/</g, "<")
|
|
12
|
+
.replace(/>/g, ">")
|
|
13
|
+
.replace(/\"/g, """)
|
|
14
|
+
.replace(/'/g, "'");
|
|
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
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
}
|