pi-studio 0.9.27 → 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,20 @@ 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
+
12
+ ## [0.9.28] — 2026-06-08
13
+
14
+ ### Added
15
+ - Added a footer **Pi model & thinking** menu for switching the active Pi model and thinking level from Studio while keeping Studio Suggest model choice separate.
16
+
17
+ ### Changed
18
+ - Clarified the browser-import tooltip to explain that **Save editor as…** can make an imported copy file-backed.
19
+ - Regularized Source & context menu notes so explanatory text uses a quieter, consistent menu-note style.
20
+
7
21
  ## [0.9.27] — 2026-06-08
8
22
 
9
23
  ### 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.
@@ -7,6 +7,7 @@
7
7
  const footerMetaModelEl = document.getElementById("footerMetaModel");
8
8
  const footerMetaTerminalEl = document.getElementById("footerMetaTerminal");
9
9
  const footerMetaContextEl = document.getElementById("footerMetaContext");
10
+ const footerModelMenuEl = document.getElementById("footerModelMenu");
10
11
  let faviconLinkEl = document.querySelector('link[rel="icon"], link[rel="shortcut icon"]');
11
12
  if (!faviconLinkEl) {
12
13
  faviconLinkEl = document.createElement("link");
@@ -522,6 +523,10 @@
522
523
  let previewExportInProgress = false;
523
524
  let compactInProgress = false;
524
525
  let modelLabel = (document.body && document.body.dataset && document.body.dataset.modelLabel) || "none";
526
+ let piModelOptions = [];
527
+ let piCurrentModel = null;
528
+ let piThinkingLevel = "";
529
+ let footerModelMenuOpen = false;
525
530
  let terminalSessionLabel = (document.body && document.body.dataset && document.body.dataset.terminalLabel) || "unknown";
526
531
  let terminalSessionDetail = (document.body && document.body.dataset && document.body.dataset.terminalDetail) || terminalSessionLabel;
527
532
  let contextTokens = null;
@@ -2510,13 +2515,13 @@
2510
2515
  completionModelSelect.hidden = false;
2511
2516
  suggestionItems.push(completionModelSelect);
2512
2517
  }
2513
- const completionThinkingNoteEl = makeStudioUiRefreshElement("div", "source-badge completion-thinking-note", "Suggest uses thinking off and does not change the main Pi model.");
2518
+ const completionThinkingNoteEl = makeStudioUiRefreshElement("div", "studio-refresh-menu-note completion-thinking-note", "Suggest: thinking off; model choice only affects suggestions.");
2514
2519
  completionThinkingNoteEl.setAttribute("aria-label", "Suggestion model note");
2515
2520
  suggestionItems.push(completionThinkingNoteEl);
2516
2521
  appendStudioUiRefreshMenuSection(contextMenu.menu, "Suggestions", suggestionItems);
2517
2522
  const statusItems = [];
2518
2523
  if (!isEditorOnlyMode) {
2519
- sourceSessionSummaryEl = makeStudioUiRefreshElement("div", "source-badge source-session-summary", "Session tree: branch history follows the current Pi branch. Editor text is independent.");
2524
+ sourceSessionSummaryEl = makeStudioUiRefreshElement("div", "studio-refresh-menu-note source-session-summary", "Session tree: branch history follows the current Pi branch; editor text stays independent.");
2520
2525
  sourceSessionSummaryEl.setAttribute("aria-label", "Pi session tree and editor sync behaviour");
2521
2526
  sourceSessionSummaryEl.title = "Use /tree in the Pi terminal to navigate branches. Studio updates branch history to match the active branch and leaves editor text unchanged.";
2522
2527
  statusItems.push(sourceSessionSummaryEl);
@@ -3179,6 +3184,118 @@
3179
3184
  }
3180
3185
  }
3181
3186
 
3187
+ function encodePiModelValue(provider, id) {
3188
+ return JSON.stringify([String(provider || ""), String(id || "")]);
3189
+ }
3190
+
3191
+ function decodePiModelValue(value) {
3192
+ try {
3193
+ const parsed = JSON.parse(String(value || ""));
3194
+ if (!Array.isArray(parsed) || parsed.length < 2) return null;
3195
+ const provider = String(parsed[0] || "").trim();
3196
+ const id = String(parsed[1] || "").trim();
3197
+ return provider && id ? { provider, id } : null;
3198
+ } catch {
3199
+ return null;
3200
+ }
3201
+ }
3202
+
3203
+ function normalizePiModelOptions(options) {
3204
+ return Array.isArray(options)
3205
+ ? options.map((option) => ({
3206
+ provider: String(option && option.provider || "").trim(),
3207
+ id: String(option && option.id || "").trim(),
3208
+ label: String(option && option.label || "").trim(),
3209
+ reasoning: Boolean(option && option.reasoning),
3210
+ })).filter((option) => option.provider && option.id)
3211
+ : [];
3212
+ }
3213
+
3214
+ function updatePiSessionModelState(message) {
3215
+ if (!message || typeof message !== "object") return;
3216
+ if (Array.isArray(message.piModels)) {
3217
+ piModelOptions = normalizePiModelOptions(message.piModels);
3218
+ } else if (Array.isArray(message.suggestionModels) && !piModelOptions.length) {
3219
+ piModelOptions = normalizePiModelOptions(message.suggestionModels);
3220
+ }
3221
+ if (message.currentModel && typeof message.currentModel === "object") {
3222
+ const model = message.currentModel;
3223
+ const provider = String(model.provider || "").trim();
3224
+ const id = String(model.id || "").trim();
3225
+ piCurrentModel = provider && id ? {
3226
+ provider,
3227
+ id,
3228
+ label: String(model.label || "").trim(),
3229
+ reasoning: Boolean(model.reasoning),
3230
+ } : null;
3231
+ }
3232
+ if (typeof message.thinkingLevel === "string") {
3233
+ piThinkingLevel = message.thinkingLevel.trim();
3234
+ }
3235
+ renderFooterModelMenu();
3236
+ }
3237
+
3238
+ function getPiCurrentModelValue() {
3239
+ return piCurrentModel && piCurrentModel.provider && piCurrentModel.id
3240
+ ? encodePiModelValue(piCurrentModel.provider, piCurrentModel.id)
3241
+ : "";
3242
+ }
3243
+
3244
+ function getPiThinkingLevels() {
3245
+ return ["off", "minimal", "low", "medium", "high", "xhigh"];
3246
+ }
3247
+
3248
+ function renderFooterModelMenu() {
3249
+ if (!footerModelMenuEl) return;
3250
+ const currentValue = getPiCurrentModelValue();
3251
+ const optionValues = new Set(piModelOptions.map((option) => encodePiModelValue(option.provider, option.id)));
3252
+ const modelOptionsHtml = piModelOptions.map((option) => {
3253
+ const value = encodePiModelValue(option.provider, option.id);
3254
+ const label = option.label || (option.provider + "/" + option.id);
3255
+ return "<option value='" + escapeHtml(value) + "'" + (value === currentValue ? " selected" : "") + ">" + escapeHtml(label) + "</option>";
3256
+ });
3257
+ if (currentValue && !optionValues.has(currentValue)) {
3258
+ const label = piCurrentModel && piCurrentModel.label ? piCurrentModel.label : modelLabel;
3259
+ modelOptionsHtml.unshift("<option value='" + escapeHtml(currentValue) + "' selected>" + escapeHtml(label || "current model") + "</option>");
3260
+ }
3261
+ const thinking = piThinkingLevel || "off";
3262
+ const thinkingOptionsHtml = getPiThinkingLevels().map((level) => {
3263
+ return "<option value='" + escapeHtml(level) + "'" + (level === thinking ? " selected" : "") + ">Thinking: " + escapeHtml(level) + "</option>";
3264
+ });
3265
+ footerModelMenuEl.innerHTML = ""
3266
+ + "<div class='footer-model-menu-heading'>Pi model & thinking</div>"
3267
+ + "<label class='footer-model-menu-field'><span>Pi model</span><select id='footerPiModelSelect'>" + modelOptionsHtml.join("") + "</select></label>"
3268
+ + "<label class='footer-model-menu-field'><span>Thinking</span><select id='footerPiThinkingSelect'>" + thinkingOptionsHtml.join("") + "</select></label>"
3269
+ + "<div class='footer-model-menu-note'>Affects future Pi turns. Studio Suggest has its own model setting.</div>";
3270
+ }
3271
+
3272
+ function setFooterModelMenuOpen(open) {
3273
+ footerModelMenuOpen = Boolean(open);
3274
+ if (footerModelMenuEl) footerModelMenuEl.hidden = !footerModelMenuOpen;
3275
+ if (footerMetaModelEl) {
3276
+ footerMetaModelEl.classList.toggle("is-open", footerModelMenuOpen);
3277
+ footerMetaModelEl.setAttribute("aria-expanded", footerModelMenuOpen ? "true" : "false");
3278
+ }
3279
+ if (footerModelMenuOpen) renderFooterModelMenu();
3280
+ }
3281
+
3282
+ function requestPiModelSelection(value) {
3283
+ const model = decodePiModelValue(value);
3284
+ if (!model) {
3285
+ setStatus("No Pi model selected.", "warning");
3286
+ return;
3287
+ }
3288
+ const sent = sendMessage({ type: "pi_model_select_request", provider: model.provider, id: model.id });
3289
+ if (sent) setStatus("Switching Pi model…", "warning");
3290
+ }
3291
+
3292
+ function requestPiThinkingLevel(level) {
3293
+ const normalized = String(level || "").trim();
3294
+ if (!normalized) return;
3295
+ const sent = sendMessage({ type: "pi_thinking_level_request", level: normalized });
3296
+ if (sent) setStatus("Setting Pi thinking level…", "warning");
3297
+ }
3298
+
3182
3299
  function updateFooterMeta() {
3183
3300
  const modelText = modelLabel && modelLabel.trim() ? modelLabel.trim() : "none";
3184
3301
  const terminalText = terminalSessionLabel && terminalSessionLabel.trim() ? terminalSessionLabel.trim() : "unknown";
@@ -3192,7 +3309,9 @@
3192
3309
  footerMetaModelEl.textContent = modelText;
3193
3310
  footerMetaTerminalEl.textContent = terminalText;
3194
3311
  footerMetaContextEl.textContent = contextDisplayText;
3195
- footerMetaModelEl.title = "Model: " + modelText;
3312
+ footerMetaModelEl.title = "Pi model and thinking: " + modelText;
3313
+ footerMetaModelEl.setAttribute("aria-haspopup", "menu");
3314
+ footerMetaModelEl.setAttribute("aria-expanded", footerModelMenuOpen ? "true" : "false");
3196
3315
  footerMetaTerminalEl.title = terminalDetailText;
3197
3316
  footerMetaContextEl.title = contextTitleText;
3198
3317
  if (footerMetaTextEl) footerMetaTextEl.title = titleText;
@@ -18649,6 +18768,7 @@
18649
18768
  if (Array.isArray(message.suggestionModels)) {
18650
18769
  updateCompletionSuggestionModelOptions(message.suggestionModels);
18651
18770
  }
18771
+ updatePiSessionModelState(message);
18652
18772
  if (typeof message.terminalSessionLabel === "string") {
18653
18773
  terminalSessionLabel = message.terminalSessionLabel;
18654
18774
  }
@@ -19224,6 +19344,7 @@
19224
19344
  if (Array.isArray(message.suggestionModels)) {
19225
19345
  updateCompletionSuggestionModelOptions(message.suggestionModels);
19226
19346
  }
19347
+ updatePiSessionModelState(message);
19227
19348
  if (typeof message.terminalSessionLabel === "string") {
19228
19349
  terminalSessionLabel = message.terminalSessionLabel;
19229
19350
  }
@@ -20103,9 +20224,39 @@
20103
20224
  if (event.key === "Escape") {
20104
20225
  closeExportPreviewMenu();
20105
20226
  closePreviewLinkMenu();
20227
+ setFooterModelMenuOpen(false);
20106
20228
  }
20107
20229
  });
20108
20230
 
20231
+ if (footerMetaModelEl) {
20232
+ footerMetaModelEl.addEventListener("click", (event) => {
20233
+ event.preventDefault();
20234
+ event.stopPropagation();
20235
+ setFooterModelMenuOpen(!footerModelMenuOpen);
20236
+ });
20237
+ }
20238
+ if (footerModelMenuEl) {
20239
+ footerModelMenuEl.addEventListener("click", (event) => {
20240
+ event.stopPropagation();
20241
+ });
20242
+ footerModelMenuEl.addEventListener("change", (event) => {
20243
+ const target = event.target;
20244
+ if (!(target instanceof HTMLSelectElement)) return;
20245
+ if (target.id === "footerPiModelSelect") {
20246
+ requestPiModelSelection(target.value);
20247
+ setFooterModelMenuOpen(false);
20248
+ } else if (target.id === "footerPiThinkingSelect") {
20249
+ requestPiThinkingLevel(target.value);
20250
+ setFooterModelMenuOpen(false);
20251
+ }
20252
+ });
20253
+ }
20254
+ document.addEventListener("click", (event) => {
20255
+ const target = event.target;
20256
+ if (target instanceof Element && (target.closest("#footerModelMenu") || target.closest("#footerMetaModel"))) return;
20257
+ setFooterModelMenuOpen(false);
20258
+ });
20259
+
20109
20260
  saveAsBtn.addEventListener("click", () => {
20110
20261
  const content = sourceTextEl.value;
20111
20262
  if (!content.trim()) {
package/client/studio.css CHANGED
@@ -4241,6 +4241,82 @@
4241
4241
  max-width: 34ch;
4242
4242
  }
4243
4243
 
4244
+ .footer-model-btn {
4245
+ border: 1px solid transparent;
4246
+ border-radius: 7px;
4247
+ background: transparent;
4248
+ color: inherit;
4249
+ font: inherit;
4250
+ text-align: left;
4251
+ padding: 2px 4px;
4252
+ cursor: pointer;
4253
+ }
4254
+
4255
+ .footer-model-btn:hover,
4256
+ .footer-model-btn:focus-visible,
4257
+ .footer-model-btn.is-open {
4258
+ background: var(--panel-2);
4259
+ border-color: var(--control-border);
4260
+ color: var(--text);
4261
+ outline: none;
4262
+ }
4263
+
4264
+ .footer-model-menu {
4265
+ position: fixed;
4266
+ left: 12px;
4267
+ bottom: 38px;
4268
+ width: min(440px, calc(100vw - 24px));
4269
+ padding: 10px;
4270
+ border: 1px solid var(--panel-border);
4271
+ border-radius: 12px;
4272
+ background: var(--panel);
4273
+ color: var(--text);
4274
+ box-shadow: 0 18px 46px var(--shadow-color);
4275
+ z-index: 110;
4276
+ }
4277
+
4278
+ .footer-model-menu[hidden] {
4279
+ display: none !important;
4280
+ }
4281
+
4282
+ .footer-model-menu-heading {
4283
+ margin: 0 2px 8px;
4284
+ color: var(--muted);
4285
+ font-size: 11px;
4286
+ font-weight: 650;
4287
+ text-transform: uppercase;
4288
+ letter-spacing: 0.06em;
4289
+ }
4290
+
4291
+ .footer-model-menu-field {
4292
+ display: grid;
4293
+ gap: 4px;
4294
+ margin: 8px 0;
4295
+ min-width: 0;
4296
+ }
4297
+
4298
+ .footer-model-menu-field span,
4299
+ .footer-model-menu-note {
4300
+ color: var(--muted);
4301
+ font-size: 12px;
4302
+ line-height: 1.35;
4303
+ }
4304
+
4305
+ .footer-model-menu-field select {
4306
+ width: 100%;
4307
+ min-width: 0;
4308
+ background: var(--panel-2);
4309
+ color: var(--text);
4310
+ border: 1px solid var(--control-border);
4311
+ border-radius: 8px;
4312
+ padding: 5px 7px;
4313
+ font: inherit;
4314
+ }
4315
+
4316
+ .footer-model-menu-note {
4317
+ margin-top: 8px;
4318
+ }
4319
+
4244
4320
  .footer-meta-terminal {
4245
4321
  flex: 0 4 auto;
4246
4322
  max-width: 34ch;
@@ -5629,9 +5705,7 @@
5629
5705
  font-weight: 450;
5630
5706
  }
5631
5707
 
5632
- body.studio-ui-refresh .studio-refresh-menu-item > .source-origin-summary,
5633
- body.studio-ui-refresh .studio-refresh-menu-item > .source-session-summary,
5634
- body.studio-ui-refresh .studio-refresh-menu-item > .completion-thinking-note {
5708
+ body.studio-ui-refresh .studio-refresh-menu-item > .source-origin-summary {
5635
5709
  width: 100%;
5636
5710
  min-width: 0;
5637
5711
  border-color: var(--border-subtle);
@@ -5640,6 +5714,18 @@
5640
5714
  white-space: normal;
5641
5715
  overflow-wrap: anywhere;
5642
5716
  line-height: 1.35;
5717
+ font-weight: 500;
5718
+ }
5719
+
5720
+ body.studio-ui-refresh .studio-refresh-menu-note {
5721
+ width: 100%;
5722
+ min-width: 0;
5723
+ padding: 4px 6px;
5724
+ color: var(--muted);
5725
+ font-size: 12px;
5726
+ line-height: 1.35;
5727
+ white-space: normal;
5728
+ overflow-wrap: anywhere;
5643
5729
  }
5644
5730
 
5645
5731
  body.studio-ui-refresh .studio-refresh-menu #critiqueBtn {
package/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, SessionEntry, Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
3
- import { completeSimple, type ThinkingLevel } from "@earendil-works/pi-ai";
3
+ import { completeSimple, type ModelThinkingLevel, type ThinkingLevel } from "@earendil-works/pi-ai";
4
4
  import { Type } from "@sinclair/typebox";
5
5
  import { spawn, spawnSync } from "node:child_process";
6
6
  import { createHash, randomUUID } from "node:crypto";
@@ -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";
@@ -349,6 +349,17 @@ interface CompletionSuggestionCancelRequestMessage {
349
349
  requestId: string;
350
350
  }
351
351
 
352
+ interface PiModelSelectRequestMessage {
353
+ type: "pi_model_select_request";
354
+ provider: string;
355
+ id: string;
356
+ }
357
+
358
+ interface PiThinkingLevelRequestMessage {
359
+ type: "pi_thinking_level_request";
360
+ level: ModelThinkingLevel;
361
+ }
362
+
352
363
  interface QuizGenerateRequestMessage {
353
364
  type: "quiz_generate_request";
354
365
  requestId: string;
@@ -492,6 +503,8 @@ type IncomingStudioMessage =
492
503
  | SendRunRequestMessage
493
504
  | CompletionSuggestionRequestMessage
494
505
  | CompletionSuggestionCancelRequestMessage
506
+ | PiModelSelectRequestMessage
507
+ | PiThinkingLevelRequestMessage
495
508
  | QuizGenerateRequestMessage
496
509
  | QuizAnswerRequestMessage
497
510
  | QuizDiscussRequestMessage
@@ -8324,6 +8337,24 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
8324
8337
  };
8325
8338
  }
8326
8339
 
8340
+ if (msg.type === "pi_model_select_request" && typeof msg.provider === "string" && typeof msg.id === "string") {
8341
+ return {
8342
+ type: "pi_model_select_request",
8343
+ provider: msg.provider,
8344
+ id: msg.id,
8345
+ };
8346
+ }
8347
+
8348
+ if (msg.type === "pi_thinking_level_request" && typeof msg.level === "string") {
8349
+ const level = msg.level.trim().toLowerCase();
8350
+ if (level === "off" || level === "minimal" || level === "low" || level === "medium" || level === "high" || level === "xhigh") {
8351
+ return {
8352
+ type: "pi_thinking_level_request",
8353
+ level,
8354
+ };
8355
+ }
8356
+ }
8357
+
8327
8358
  if (msg.type === "completion_suggestion_request" && typeof msg.requestId === "string" && typeof msg.text === "string") {
8328
8359
  const textLength = msg.text.length;
8329
8360
  const rawStart = typeof msg.selectionStart === "number" && Number.isFinite(msg.selectionStart) ? msg.selectionStart : textLength;
@@ -9812,22 +9843,53 @@ function buildStudioUrl(
9812
9843
  return `http://127.0.0.1:${port}/?${params.toString()}`;
9813
9844
  }
9814
9845
 
9815
- 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 {
9816
9855
  const parsed = tokenizeStudioCommandArgs(rawArgs);
9817
- 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 };
9818
9857
  const remaining: string[] = [];
9819
9858
  let openRemoteBrowser = false;
9820
- for (const token of parsed.tokens) {
9821
- 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") {
9822
9864
  openRemoteBrowser = true;
9823
9865
  continue;
9824
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
+ }
9825
9883
  remaining.push(token);
9826
9884
  }
9827
- 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 };
9828
9889
  }
9829
9890
 
9830
- function shouldAutoOpenStudioBrowser(options?: { openRemoteBrowser?: boolean }): boolean {
9891
+ function shouldAutoOpenStudioBrowser(options?: { openRemoteBrowser?: boolean; noBrowser?: boolean }): boolean {
9892
+ if (options?.noBrowser) return false;
9831
9893
  return !isSshSession() || Boolean(options?.openRemoteBrowser);
9832
9894
  }
9833
9895
 
@@ -10220,7 +10282,7 @@ ${cssVarsBlock}
10220
10282
  <button id="saveOverBtn" type="button" title="Overwrite current file with editor content. Shortcut: Cmd/Ctrl+S.">Save editor</button>
10221
10283
  <button id="refreshFromDiskBtn" type="button" title="Reload the current file-backed document from disk.">Refresh from disk</button>
10222
10284
  <button id="clearWorkspaceBtn" type="button" title="Clear editor text and reset this tab to a fresh blank draft. Saved files and responses are not changed.">Reset editor</button>
10223
- <label class="file-label" title="Browser import: load a selected text file as a detached unsaved copy. It will not be refreshable from disk. Use the Files view to open a file-backed document.">Import file copy…<input id="fileInput" type="file" accept=".md,.markdown,.mdx,.qmd,.js,.mjs,.cjs,.jsx,.ts,.mts,.cts,.tsx,.py,.pyw,.sh,.bash,.zsh,.json,.jsonc,.json5,.rs,.c,.h,.cpp,.cxx,.cc,.hpp,.hxx,.jl,.f90,.f95,.f03,.f,.for,.r,.R,.m,.tex,.latex,.diff,.patch,.java,.go,.rb,.swift,.html,.htm,.css,.xml,.yaml,.yml,.toml,.lua,.txt,.rst,.adoc" /></label>
10285
+ <label class="file-label" title="Browser import: load a selected text file as a detached copy. Use Save editor as… to attach this copy to a file path and make it file-backed, or use the Files view to open a refreshable file-backed document directly.">Import file copy…<input id="fileInput" type="file" accept=".md,.markdown,.mdx,.qmd,.js,.mjs,.cjs,.jsx,.ts,.mts,.cts,.tsx,.py,.pyw,.sh,.bash,.zsh,.json,.jsonc,.json5,.rs,.c,.h,.cpp,.cxx,.cc,.hpp,.hxx,.jl,.f90,.f95,.f03,.f,.for,.r,.R,.m,.tex,.latex,.diff,.patch,.java,.go,.rb,.swift,.html,.htm,.css,.xml,.yaml,.yml,.toml,.lua,.txt,.rst,.adoc" /></label>
10224
10286
  <button id="getEditorBtn" type="button" title="Load the current terminal editor draft into Studio.">Load from pi editor</button>
10225
10287
  <button id="zenModeBtn" class="zen-mode-btn" type="button" title="Hide secondary Studio controls. Shortcut: F9.">Zen</button>
10226
10288
  </div>
@@ -10503,7 +10565,8 @@ ${cssVarsBlock}
10503
10565
 
10504
10566
  <footer>
10505
10567
  <span id="statusLine"><span id="statusSpinner" aria-hidden="true"> </span><span id="status">Booting studio…</span></span>
10506
- <span id="footerMeta" class="footer-meta"><span id="footerMetaText" class="footer-meta-text"><span id="footerMetaModel" class="footer-meta-part footer-meta-model">${initialModel}</span><span class="footer-meta-sep">·</span><span id="footerMetaTerminal" class="footer-meta-part footer-meta-terminal">${initialTerminal}</span><span class="footer-meta-sep">·</span><span id="footerMetaContext" class="footer-meta-part footer-meta-context">unknown</span></span><button id="compactBtn" class="footer-compact-btn" type="button" title="Trigger pi context compaction now.">Compact</button></span>
10568
+ <span id="footerMeta" class="footer-meta"><span id="footerMetaText" class="footer-meta-text"><button id="footerMetaModel" class="footer-meta-part footer-meta-model footer-model-btn" type="button" aria-haspopup="menu" aria-expanded="false">${initialModel}</button><span class="footer-meta-sep">·</span><span id="footerMetaTerminal" class="footer-meta-part footer-meta-terminal">${initialTerminal}</span><span class="footer-meta-sep">·</span><span id="footerMetaContext" class="footer-meta-part footer-meta-context">unknown</span></span><button id="compactBtn" class="footer-compact-btn" type="button" title="Trigger pi context compaction now.">Compact</button></span>
10569
+ <div id="footerModelMenu" class="footer-model-menu" hidden></div>
10507
10570
  <button id="shortcutsBtn" class="shortcut-hint" type="button" title="Show Studio keyboard shortcuts. Press ? when not editing text.">Shortcuts (?)</button>
10508
10571
  </footer>
10509
10572
 
@@ -10624,7 +10687,7 @@ export default function (pi: ExtensionAPI) {
10624
10687
  let terminalActivityToolName: string | null = null;
10625
10688
  let terminalActivityLabel: string | null = null;
10626
10689
  let lastSpecificToolActivityLabel: string | null = null;
10627
- let currentModel: { provider?: string; id?: string } | undefined;
10690
+ let currentModel: { provider?: string; id?: string; name?: string; reasoning?: boolean } | undefined;
10628
10691
  let currentModelLabel = "none";
10629
10692
  let terminalSessionLabel = buildTerminalSessionLabel(studioCwd);
10630
10693
  let terminalSessionDetail = buildTerminalSessionDetail(studioCwd);
@@ -10897,15 +10960,20 @@ export default function (pi: ExtensionAPI) {
10897
10960
  }
10898
10961
  };
10899
10962
 
10900
- const getThinkingLevelSafe = (): string | undefined => {
10963
+ const getThinkingLevelSafe = (): ModelThinkingLevel | undefined => {
10901
10964
  try {
10902
- return pi.getThinkingLevel();
10965
+ return pi.getThinkingLevel() as ModelThinkingLevel;
10903
10966
  } catch {
10904
10967
  return undefined;
10905
10968
  }
10906
10969
  };
10907
10970
 
10908
- const refreshRuntimeMetadata = (ctx?: { cwd?: string; model?: { provider?: string; id?: string } | undefined }) => {
10971
+ const setThinkingLevelSafe = (level: ModelThinkingLevel) => {
10972
+ // Pi's CLI/model config support "off" as a thinking level; some extension API typings still expose the narrower reasoning-only type.
10973
+ (pi.setThinkingLevel as (nextLevel: ModelThinkingLevel) => void)(level);
10974
+ };
10975
+
10976
+ const refreshRuntimeMetadata = (ctx?: { cwd?: string; model?: { provider?: string; id?: string; name?: string; reasoning?: boolean } | undefined }) => {
10909
10977
  if (ctx?.cwd) {
10910
10978
  studioCwd = ctx.cwd;
10911
10979
  }
@@ -10914,6 +10982,8 @@ export default function (pi: ExtensionAPI) {
10914
10982
  currentModel = {
10915
10983
  provider: ctx.model.provider,
10916
10984
  id: ctx.model.id,
10985
+ name: ctx.model.name,
10986
+ reasoning: Boolean(ctx.model.reasoning),
10917
10987
  };
10918
10988
  } else {
10919
10989
  currentModel = undefined;
@@ -10922,6 +10992,8 @@ export default function (pi: ExtensionAPI) {
10922
10992
  currentModel = {
10923
10993
  provider: lastCommandCtx.model.provider,
10924
10994
  id: lastCommandCtx.model.id,
10995
+ name: lastCommandCtx.model.name,
10996
+ reasoning: Boolean(lastCommandCtx.model.reasoning),
10925
10997
  };
10926
10998
  }
10927
10999
  const baseModelLabel = formatModelLabel(currentModel);
@@ -11606,7 +11678,7 @@ export default function (pi: ExtensionAPI) {
11606
11678
  broadcastState();
11607
11679
  };
11608
11680
 
11609
- const getSuggestionModelOptions = () => {
11681
+ const getStudioModelOptions = () => {
11610
11682
  const registry = lastCommandCtx?.modelRegistry ?? latestModelRequestCtx?.modelRegistry;
11611
11683
  if (!registry || typeof registry.getAvailable !== "function") return [];
11612
11684
  return registry.getAvailable().map((model) => ({
@@ -11617,11 +11689,21 @@ export default function (pi: ExtensionAPI) {
11617
11689
  }));
11618
11690
  };
11619
11691
 
11692
+ const getCurrentStudioModelDescriptor = () => currentModel
11693
+ ? {
11694
+ provider: currentModel.provider,
11695
+ id: currentModel.id,
11696
+ label: formatStudioModelOptionLabel(currentModel),
11697
+ reasoning: Boolean(currentModel.reasoning),
11698
+ }
11699
+ : null;
11700
+
11620
11701
  const broadcastState = () => {
11621
11702
  terminalSessionLabel = buildTerminalSessionLabel(studioCwd, getSessionNameSafe());
11622
11703
  terminalSessionDetail = buildTerminalSessionDetail(studioCwd, getSessionNameSafe());
11623
11704
  currentModelLabel = formatModelLabelWithThinking(formatModelLabel(currentModel), getThinkingLevelSafe());
11624
11705
  refreshContextUsage();
11706
+ const modelOptions = getStudioModelOptions();
11625
11707
  broadcast({
11626
11708
  type: "studio_state",
11627
11709
  busy: isStudioBusy(),
@@ -11630,7 +11712,10 @@ export default function (pi: ExtensionAPI) {
11630
11712
  terminalToolName: terminalActivityToolName,
11631
11713
  terminalActivityLabel,
11632
11714
  modelLabel: currentModelLabel,
11633
- suggestionModels: getSuggestionModelOptions(),
11715
+ currentModel: getCurrentStudioModelDescriptor(),
11716
+ thinkingLevel: getThinkingLevelSafe() ?? "off",
11717
+ piModels: modelOptions,
11718
+ suggestionModels: modelOptions,
11634
11719
  terminalSessionLabel,
11635
11720
  terminalSessionDetail,
11636
11721
  contextTokens: contextUsageSnapshot.tokens,
@@ -11926,7 +12011,10 @@ export default function (pi: ExtensionAPI) {
11926
12011
  terminalToolName: terminalActivityToolName,
11927
12012
  terminalActivityLabel,
11928
12013
  modelLabel: currentModelLabel,
11929
- suggestionModels: getSuggestionModelOptions(),
12014
+ currentModel: getCurrentStudioModelDescriptor(),
12015
+ thinkingLevel: getThinkingLevelSafe() ?? "off",
12016
+ piModels: getStudioModelOptions(),
12017
+ suggestionModels: getStudioModelOptions(),
11930
12018
  terminalSessionLabel,
11931
12019
  terminalSessionDetail,
11932
12020
  contextTokens: contextUsageSnapshot.tokens,
@@ -11945,6 +12033,47 @@ export default function (pi: ExtensionAPI) {
11945
12033
  return;
11946
12034
  }
11947
12035
 
12036
+ if (msg.type === "pi_model_select_request") {
12037
+ void (async () => {
12038
+ const registry = lastCommandCtx?.modelRegistry ?? latestModelRequestCtx?.modelRegistry;
12039
+ if (!registry || typeof registry.find !== "function") {
12040
+ sendToClient(client, { type: "info", level: "warning", message: "Pi model registry is not available yet." });
12041
+ return;
12042
+ }
12043
+ const model = registry.find(msg.provider, msg.id);
12044
+ if (!model) {
12045
+ sendToClient(client, { type: "info", level: "warning", message: `Pi model not found: ${msg.provider}/${msg.id}` });
12046
+ return;
12047
+ }
12048
+ try {
12049
+ const ok = await pi.setModel(model);
12050
+ if (!ok) {
12051
+ sendToClient(client, { type: "info", level: "warning", message: `Could not switch to ${formatStudioModelOptionLabel(model)}; credentials may be unavailable.` });
12052
+ return;
12053
+ }
12054
+ latestModelRequestCtx = { model, modelRegistry: registry };
12055
+ refreshRuntimeMetadata({ model });
12056
+ broadcastState();
12057
+ sendToClient(client, { type: "info", level: "info", message: `Pi model switched to ${formatStudioModelOptionLabel(model)}.` });
12058
+ } catch (error) {
12059
+ sendToClient(client, { type: "info", level: "error", message: `Model switch failed: ${error instanceof Error ? error.message : String(error)}` });
12060
+ }
12061
+ })();
12062
+ return;
12063
+ }
12064
+
12065
+ if (msg.type === "pi_thinking_level_request") {
12066
+ try {
12067
+ setThinkingLevelSafe(msg.level);
12068
+ refreshRuntimeMetadata({ model: lastCommandCtx?.model ?? latestModelRequestCtx?.model });
12069
+ broadcastState();
12070
+ sendToClient(client, { type: "info", level: "info", message: `Pi thinking level set to ${getThinkingLevelSafe() ?? msg.level}.` });
12071
+ } catch (error) {
12072
+ sendToClient(client, { type: "info", level: "error", message: `Thinking level change failed: ${error instanceof Error ? error.message : String(error)}` });
12073
+ }
12074
+ return;
12075
+ }
12076
+
11948
12077
  if (msg.type === "get_latest_response") {
11949
12078
  if (!lastStudioResponse) {
11950
12079
  sendToClient(client, { type: "info", message: "No latest assistant response is available yet." });
@@ -13998,7 +14127,7 @@ export default function (pi: ExtensionAPI) {
13998
14127
  res.end(buildStudioHtml(requestInitialDocument, serverState.token, lastCommandCtx?.ui.theme, currentModelLabel, terminalSessionLabel, terminalSessionDetail, contextUsageSnapshot, studioMode));
13999
14128
  };
14000
14129
 
14001
- const ensureServer = async (): Promise<StudioServerState> => {
14130
+ const ensureServer = async (requestedPort?: number): Promise<StudioServerState> => {
14002
14131
  if (serverState) return serverState;
14003
14132
 
14004
14133
  const server = createServer(handleHttpRequest);
@@ -14086,6 +14215,8 @@ export default function (pi: ExtensionAPI) {
14086
14215
  });
14087
14216
  });
14088
14217
 
14218
+ const listenPort = typeof requestedPort === "number" && Number.isInteger(requestedPort) && requestedPort > 0 ? requestedPort : 0;
14219
+
14089
14220
  await new Promise<void>((resolve, reject) => {
14090
14221
  const onError = (error: Error) => {
14091
14222
  server.off("listening", onListening);
@@ -14097,7 +14228,7 @@ export default function (pi: ExtensionAPI) {
14097
14228
  };
14098
14229
  server.once("error", onError);
14099
14230
  server.once("listening", onListening);
14100
- server.listen(0, "127.0.0.1");
14231
+ server.listen(listenPort, "127.0.0.1");
14101
14232
  });
14102
14233
 
14103
14234
  const address = server.address();
@@ -14883,6 +15014,9 @@ export default function (pi: ExtensionAPI) {
14883
15014
  return;
14884
15015
  }
14885
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
+ }
14886
15020
  if (mode === "full" && hasConnectedFullStudioView()) {
14887
15021
  if (options?.replaceExistingFull) {
14888
15022
  closeStudioClientsByMode("full", 4001, "Full Studio replaced");
@@ -14891,8 +15025,9 @@ export default function (pi: ExtensionAPI) {
14891
15025
  if (serverState) {
14892
15026
  const url = buildStudioUrl(serverState.port, serverState.token, "full");
14893
15027
  ctx.ui.notify(`Studio URL: ${url}`, "info");
14894
- const sshTunnelHint = buildStudioSshTunnelHint(serverState.port, url);
14895
- 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");
14896
15031
  }
14897
15032
  return;
14898
15033
  }
@@ -14917,17 +15052,28 @@ export default function (pi: ExtensionAPI) {
14917
15052
  if (!selected) return;
14918
15053
  initialStudioDocument = selected;
14919
15054
 
14920
- 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
+ }
14921
15064
  const url = buildStudioUrl(state.port, state.token, mode, selected);
14922
- 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);
14923
15067
  const openedLabel = mode === "editor-only" ? "pi Studio editor-only view" : "pi Studio";
14924
15068
 
14925
15069
  const shouldOpenBrowser = shouldAutoOpenStudioBrowser({
14926
15070
  openRemoteBrowser: launchOpenFlags.openRemoteBrowser,
15071
+ noBrowser: launchOpenFlags.noBrowser,
14927
15072
  });
14928
15073
  try {
14929
15074
  if (!shouldOpenBrowser) {
14930
- 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");
14931
15077
  } else {
14932
15078
  await openUrlInDefaultBrowser(url);
14933
15079
  if (selected.source === "file") {
@@ -14947,12 +15093,12 @@ export default function (pi: ExtensionAPI) {
14947
15093
  }
14948
15094
  } finally {
14949
15095
  ctx.ui.notify(`Studio URL: ${url}`, "info");
14950
- if (sshTunnelHint) ctx.ui.notify(sshTunnelHint, "info");
15096
+ if (tunnelHint) ctx.ui.notify(tunnelHint, "info");
14951
15097
  }
14952
15098
  };
14953
15099
 
14954
15100
  pi.registerCommand("studio", {
14955
- 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>)",
14956
15102
  handler: async (args: string, ctx: ExtensionCommandContext) => {
14957
15103
  const trimmed = args.trim();
14958
15104
 
@@ -14985,6 +15131,8 @@ export default function (pi: ExtensionAPI) {
14985
15131
  + " /studio <path> Open studio with file preloaded\n"
14986
15132
  + " /studio --blank Open with blank editor\n"
14987
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"
14988
15136
  + " /studio --open-remote Over SSH, open the remote browser anyway\n"
14989
15137
  + " /studio --status Show studio status\n"
14990
15138
  + " /studio --stop Stop studio server\n"
@@ -15004,7 +15152,7 @@ export default function (pi: ExtensionAPI) {
15004
15152
  });
15005
15153
 
15006
15154
  pi.registerCommand("studio-replace", {
15007
- 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)",
15008
15156
  handler: async (args: string, ctx: ExtensionCommandContext) => {
15009
15157
  const trimmed = args.trim();
15010
15158
  if (trimmed === "help" || trimmed === "--help" || trimmed === "-h") {
@@ -15014,6 +15162,8 @@ export default function (pi: ExtensionAPI) {
15014
15162
  + " /studio-replace <path> Replace the current full Studio view with file preloaded\n"
15015
15163
  + " /studio-replace --blank Replace with blank editor\n"
15016
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"
15017
15167
  + "Editor-only Studio views stay open.",
15018
15168
  "info",
15019
15169
  );
@@ -15029,7 +15179,7 @@ export default function (pi: ExtensionAPI) {
15029
15179
  });
15030
15180
 
15031
15181
  pi.registerCommand("studio-editor-only", {
15032
- 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)",
15033
15183
  handler: async (args: string, ctx: ExtensionCommandContext) => {
15034
15184
  const trimmed = args.trim();
15035
15185
  if (trimmed === "help" || trimmed === "--help" || trimmed === "-h") {
@@ -15039,6 +15189,8 @@ export default function (pi: ExtensionAPI) {
15039
15189
  + " /studio-editor-only <path> Open an editor-only Studio view with file preloaded\n"
15040
15190
  + " /studio-editor-only --blank Open with blank editor\n"
15041
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"
15042
15194
  + "Multiple editor-only views are allowed in the same Pi session.",
15043
15195
  "info",
15044
15196
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.27",
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
  }