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 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
- function parseStudioLaunchOpenFlags(rawArgs: string): { args: string; openRemoteBrowser: boolean; error?: string } {
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
- for (const token of parsed.tokens) {
9852
- if (token === "--open-remote" || token === "--open-remote-browser") {
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
- return { args: remaining.join(" "), openRemoteBrowser };
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(0, "127.0.0.1");
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 sshTunnelHint = buildStudioSshTunnelHint(serverState.port, url);
14993
- if (sshTunnelHint) ctx.ui.notify(sshTunnelHint, "info");
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
- const state = await ensureServer();
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 sshTunnelHint = buildStudioSshTunnelHint(state.port, url);
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
- ctx.ui.notify(`${openedLabel} is ready. Browser auto-open was skipped because SSH was detected.`, "info");
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 (sshTunnelHint) ctx.ui.notify(sshTunnelHint, "info");
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.28",
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 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
  }