pi-studio 0.9.28 → 0.9.29
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 +5 -0
- package/README.md +3 -1
- package/index.ts +72 -18
- package/package.json +1 -1
- package/shared/studio-ssh-hint.js +15 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,11 @@ All notable changes to `pi-studio` are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.9.29] — 2026-06-09
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Added `/studio --no-browser` and `/studio --port <port>` launch flags for explicit remote/forwarded Studio sessions when SSH auto-detection is not enough.
|
|
11
|
+
|
|
7
12
|
## [0.9.28] — 2026-06-08
|
|
8
13
|
|
|
9
14
|
### 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.
|
package/index.ts
CHANGED
|
@@ -29,7 +29,7 @@ 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
33
|
|
|
34
34
|
type Lens = "writing" | "code";
|
|
35
35
|
type RequestedLens = Lens | "auto";
|
|
@@ -9843,22 +9843,53 @@ function buildStudioUrl(
|
|
|
9843
9843
|
return `http://127.0.0.1:${port}/?${params.toString()}`;
|
|
9844
9844
|
}
|
|
9845
9845
|
|
|
9846
|
-
|
|
9846
|
+
interface StudioLaunchFlags {
|
|
9847
|
+
args: string;
|
|
9848
|
+
openRemoteBrowser: boolean;
|
|
9849
|
+
noBrowser: boolean;
|
|
9850
|
+
port?: number;
|
|
9851
|
+
error?: string;
|
|
9852
|
+
}
|
|
9853
|
+
|
|
9854
|
+
function parseStudioLaunchOpenFlags(rawArgs: string): StudioLaunchFlags {
|
|
9847
9855
|
const parsed = tokenizeStudioCommandArgs(rawArgs);
|
|
9848
|
-
if (parsed.error) return { args: rawArgs, openRemoteBrowser: false, error: parsed.error };
|
|
9856
|
+
if (parsed.error) return { args: rawArgs, openRemoteBrowser: false, noBrowser: false, error: parsed.error };
|
|
9849
9857
|
const remaining: string[] = [];
|
|
9850
9858
|
let openRemoteBrowser = false;
|
|
9851
|
-
|
|
9852
|
-
|
|
9859
|
+
let noBrowser = false;
|
|
9860
|
+
let port: number | undefined;
|
|
9861
|
+
for (let i = 0; i < parsed.tokens.length; i += 1) {
|
|
9862
|
+
const token = parsed.tokens[i]!;
|
|
9863
|
+
if (token === "--open-remote" || token === "--open-remote-browser" || token === "--open-browser") {
|
|
9853
9864
|
openRemoteBrowser = true;
|
|
9854
9865
|
continue;
|
|
9855
9866
|
}
|
|
9867
|
+
if (token === "--no-browser" || token === "--no-open" || token === "--no-open-browser") {
|
|
9868
|
+
noBrowser = true;
|
|
9869
|
+
continue;
|
|
9870
|
+
}
|
|
9871
|
+
if (token === "--port" || token.startsWith("--port=")) {
|
|
9872
|
+
const rawPort = token.startsWith("--port=") ? token.slice("--port=".length) : parsed.tokens[++i];
|
|
9873
|
+
if (!rawPort) {
|
|
9874
|
+
return { args: rawArgs, openRemoteBrowser, noBrowser, error: "Missing value for --port." };
|
|
9875
|
+
}
|
|
9876
|
+
const requestedPort = Number(rawPort);
|
|
9877
|
+
if (!Number.isInteger(requestedPort) || requestedPort < 1 || requestedPort > 65535) {
|
|
9878
|
+
return { args: rawArgs, openRemoteBrowser, noBrowser, error: `Invalid --port value: ${rawPort}. Use an integer from 1 to 65535.` };
|
|
9879
|
+
}
|
|
9880
|
+
port = requestedPort;
|
|
9881
|
+
continue;
|
|
9882
|
+
}
|
|
9856
9883
|
remaining.push(token);
|
|
9857
9884
|
}
|
|
9858
|
-
|
|
9885
|
+
if (openRemoteBrowser && noBrowser) {
|
|
9886
|
+
return { args: rawArgs, openRemoteBrowser, noBrowser, port, error: "Use either --no-browser or --open-browser, not both." };
|
|
9887
|
+
}
|
|
9888
|
+
return { args: remaining.join(" "), openRemoteBrowser, noBrowser, port };
|
|
9859
9889
|
}
|
|
9860
9890
|
|
|
9861
|
-
function shouldAutoOpenStudioBrowser(options?: { openRemoteBrowser?: boolean }): boolean {
|
|
9891
|
+
function shouldAutoOpenStudioBrowser(options?: { openRemoteBrowser?: boolean; noBrowser?: boolean }): boolean {
|
|
9892
|
+
if (options?.noBrowser) return false;
|
|
9862
9893
|
return !isSshSession() || Boolean(options?.openRemoteBrowser);
|
|
9863
9894
|
}
|
|
9864
9895
|
|
|
@@ -14096,7 +14127,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
14096
14127
|
res.end(buildStudioHtml(requestInitialDocument, serverState.token, lastCommandCtx?.ui.theme, currentModelLabel, terminalSessionLabel, terminalSessionDetail, contextUsageSnapshot, studioMode));
|
|
14097
14128
|
};
|
|
14098
14129
|
|
|
14099
|
-
const ensureServer = async (): Promise<StudioServerState> => {
|
|
14130
|
+
const ensureServer = async (requestedPort?: number): Promise<StudioServerState> => {
|
|
14100
14131
|
if (serverState) return serverState;
|
|
14101
14132
|
|
|
14102
14133
|
const server = createServer(handleHttpRequest);
|
|
@@ -14184,6 +14215,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
14184
14215
|
});
|
|
14185
14216
|
});
|
|
14186
14217
|
|
|
14218
|
+
const listenPort = typeof requestedPort === "number" && Number.isInteger(requestedPort) && requestedPort > 0 ? requestedPort : 0;
|
|
14219
|
+
|
|
14187
14220
|
await new Promise<void>((resolve, reject) => {
|
|
14188
14221
|
const onError = (error: Error) => {
|
|
14189
14222
|
server.off("listening", onListening);
|
|
@@ -14195,7 +14228,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
14195
14228
|
};
|
|
14196
14229
|
server.once("error", onError);
|
|
14197
14230
|
server.once("listening", onListening);
|
|
14198
|
-
server.listen(
|
|
14231
|
+
server.listen(listenPort, "127.0.0.1");
|
|
14199
14232
|
});
|
|
14200
14233
|
|
|
14201
14234
|
const address = server.address();
|
|
@@ -14981,6 +15014,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
14981
15014
|
return;
|
|
14982
15015
|
}
|
|
14983
15016
|
const launchArgs = launchOpenFlags.args;
|
|
15017
|
+
if (serverState && launchOpenFlags.port && serverState.port !== launchOpenFlags.port) {
|
|
15018
|
+
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");
|
|
15019
|
+
}
|
|
14984
15020
|
if (mode === "full" && hasConnectedFullStudioView()) {
|
|
14985
15021
|
if (options?.replaceExistingFull) {
|
|
14986
15022
|
closeStudioClientsByMode("full", 4001, "Full Studio replaced");
|
|
@@ -14989,8 +15025,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
14989
15025
|
if (serverState) {
|
|
14990
15026
|
const url = buildStudioUrl(serverState.port, serverState.token, "full");
|
|
14991
15027
|
ctx.ui.notify(`Studio URL: ${url}`, "info");
|
|
14992
|
-
const
|
|
14993
|
-
|
|
15028
|
+
const tunnelHint = buildStudioSshTunnelHint(serverState.port, url)
|
|
15029
|
+
?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(serverState.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
|
|
15030
|
+
if (tunnelHint) ctx.ui.notify(tunnelHint, "info");
|
|
14994
15031
|
}
|
|
14995
15032
|
return;
|
|
14996
15033
|
}
|
|
@@ -15015,17 +15052,28 @@ export default function (pi: ExtensionAPI) {
|
|
|
15015
15052
|
if (!selected) return;
|
|
15016
15053
|
initialStudioDocument = selected;
|
|
15017
15054
|
|
|
15018
|
-
|
|
15055
|
+
let state: StudioServerState;
|
|
15056
|
+
try {
|
|
15057
|
+
state = await ensureServer(launchOpenFlags.port);
|
|
15058
|
+
} catch (error) {
|
|
15059
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
15060
|
+
const portText = launchOpenFlags.port ? ` on port ${launchOpenFlags.port}` : "";
|
|
15061
|
+
ctx.ui.notify(`Failed to start Studio server${portText}: ${message}`, "error");
|
|
15062
|
+
return;
|
|
15063
|
+
}
|
|
15019
15064
|
const url = buildStudioUrl(state.port, state.token, mode, selected);
|
|
15020
|
-
const
|
|
15065
|
+
const tunnelHint = buildStudioSshTunnelHint(state.port, url)
|
|
15066
|
+
?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(state.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
|
|
15021
15067
|
const openedLabel = mode === "editor-only" ? "pi Studio editor-only view" : "pi Studio";
|
|
15022
15068
|
|
|
15023
15069
|
const shouldOpenBrowser = shouldAutoOpenStudioBrowser({
|
|
15024
15070
|
openRemoteBrowser: launchOpenFlags.openRemoteBrowser,
|
|
15071
|
+
noBrowser: launchOpenFlags.noBrowser,
|
|
15025
15072
|
});
|
|
15026
15073
|
try {
|
|
15027
15074
|
if (!shouldOpenBrowser) {
|
|
15028
|
-
|
|
15075
|
+
const skipReason = launchOpenFlags.noBrowser ? "--no-browser was used" : "SSH was detected";
|
|
15076
|
+
ctx.ui.notify(`${openedLabel} is ready. Browser auto-open was skipped because ${skipReason}.`, "info");
|
|
15029
15077
|
} else {
|
|
15030
15078
|
await openUrlInDefaultBrowser(url);
|
|
15031
15079
|
if (selected.source === "file") {
|
|
@@ -15045,12 +15093,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
15045
15093
|
}
|
|
15046
15094
|
} finally {
|
|
15047
15095
|
ctx.ui.notify(`Studio URL: ${url}`, "info");
|
|
15048
|
-
if (
|
|
15096
|
+
if (tunnelHint) ctx.ui.notify(tunnelHint, "info");
|
|
15049
15097
|
}
|
|
15050
15098
|
};
|
|
15051
15099
|
|
|
15052
15100
|
pi.registerCommand("studio", {
|
|
15053
|
-
description: "Open pi Studio browser UI (/studio, /studio <file>, /studio --blank, /studio --last)",
|
|
15101
|
+
description: "Open pi Studio browser UI (/studio, /studio <file>, /studio --blank, /studio --last, /studio --no-browser, /studio --port <port>)",
|
|
15054
15102
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
15055
15103
|
const trimmed = args.trim();
|
|
15056
15104
|
|
|
@@ -15083,6 +15131,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
15083
15131
|
+ " /studio <path> Open studio with file preloaded\n"
|
|
15084
15132
|
+ " /studio --blank Open with blank editor\n"
|
|
15085
15133
|
+ " /studio --last Open with last model response\n"
|
|
15134
|
+
+ " /studio --no-browser Print the Studio URL without opening a browser\n"
|
|
15135
|
+
+ " /studio --port <port> Bind Studio to a fixed localhost port when starting\n"
|
|
15086
15136
|
+ " /studio --open-remote Over SSH, open the remote browser anyway\n"
|
|
15087
15137
|
+ " /studio --status Show studio status\n"
|
|
15088
15138
|
+ " /studio --stop Stop studio server\n"
|
|
@@ -15102,7 +15152,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
15102
15152
|
});
|
|
15103
15153
|
|
|
15104
15154
|
pi.registerCommand("studio-replace", {
|
|
15105
|
-
description: "Replace the current full pi Studio view (/studio-replace, /studio-replace <file
|
|
15155
|
+
description: "Replace the current full pi Studio view (/studio-replace, /studio-replace <file>, /studio-replace --no-browser)",
|
|
15106
15156
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
15107
15157
|
const trimmed = args.trim();
|
|
15108
15158
|
if (trimmed === "help" || trimmed === "--help" || trimmed === "-h") {
|
|
@@ -15112,6 +15162,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
15112
15162
|
+ " /studio-replace <path> Replace the current full Studio view with file preloaded\n"
|
|
15113
15163
|
+ " /studio-replace --blank Replace with blank editor\n"
|
|
15114
15164
|
+ " /studio-replace --last Replace with last model response\n"
|
|
15165
|
+
+ " /studio-replace --no-browser Print URL without opening a browser\n"
|
|
15166
|
+
+ " /studio-replace --port <port> Bind Studio to a fixed localhost port when starting\n"
|
|
15115
15167
|
+ "Editor-only Studio views stay open.",
|
|
15116
15168
|
"info",
|
|
15117
15169
|
);
|
|
@@ -15127,7 +15179,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
15127
15179
|
});
|
|
15128
15180
|
|
|
15129
15181
|
pi.registerCommand("studio-editor-only", {
|
|
15130
|
-
description: "Open pi Studio in editor-only mode (/studio-editor-only, /studio-editor-only <file
|
|
15182
|
+
description: "Open pi Studio in editor-only mode (/studio-editor-only, /studio-editor-only <file>, /studio-editor-only --no-browser)",
|
|
15131
15183
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
15132
15184
|
const trimmed = args.trim();
|
|
15133
15185
|
if (trimmed === "help" || trimmed === "--help" || trimmed === "-h") {
|
|
@@ -15137,6 +15189,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
15137
15189
|
+ " /studio-editor-only <path> Open an editor-only Studio view with file preloaded\n"
|
|
15138
15190
|
+ " /studio-editor-only --blank Open with blank editor\n"
|
|
15139
15191
|
+ " /studio-editor-only --last Open with last model response loaded into the editor\n"
|
|
15192
|
+
+ " /studio-editor-only --no-browser Print URL without opening a browser\n"
|
|
15193
|
+
+ " /studio-editor-only --port <port> Bind Studio to a fixed localhost port when starting\n"
|
|
15140
15194
|
+ "Multiple editor-only views are allowed in the same Pi session.",
|
|
15141
15195
|
"info",
|
|
15142
15196
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-studio",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.29",
|
|
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",
|
|
@@ -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
|
}
|