pi-studio 0.9.6 → 0.9.7

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,15 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.7] — 2026-05-19
8
+
9
+ ### Added
10
+ - Added Focus support for interactive HTML previews, using the existing sandboxed preview iframe in-place so form input and script state survive focus/unfocus.
11
+
12
+ ### Changed
13
+ - `/studio` launched over SSH now skips opening the remote host browser by default, prints a local tunnel hint instead, and keeps local/non-SSH browser auto-open behavior unchanged. Use `/studio --open-remote` to request the previous remote-browser behavior.
14
+ - Moved the HTML preview Focus control next to the preview title to match PDF Focus placement.
15
+
7
16
  ## [0.9.6] — 2026-05-19
8
17
 
9
18
  ### Changed
@@ -192,6 +192,11 @@
192
192
  let studioPdfFocusCloseBtn = null;
193
193
  let studioPdfFocusLastFocusedEl = null;
194
194
  let studioPdfFocusMovedFrameState = null;
195
+ let studioHtmlFocusOverlayEl = null;
196
+ let studioHtmlFocusShellEl = null;
197
+ let studioHtmlFocusFullscreenBtn = null;
198
+ let studioHtmlFocusLastFocusedEl = null;
199
+ let studioHtmlFocusRestoreState = null;
195
200
  let pendingRequestId = null;
196
201
  let pendingKind = null;
197
202
  let stickyStudioKind = null;
@@ -3156,6 +3161,12 @@
3156
3161
  && typeof studioPdfFocusDialogEl.contains === "function"
3157
3162
  && studioPdfFocusDialogEl.contains(event.target)
3158
3163
  );
3164
+ const htmlFocusOwnsEvent = Boolean(
3165
+ studioHtmlFocusShellEl
3166
+ && event.target
3167
+ && typeof studioHtmlFocusShellEl.contains === "function"
3168
+ && studioHtmlFocusShellEl.contains(event.target)
3169
+ );
3159
3170
  const quizOwnsEvent = Boolean(
3160
3171
  quizDialogEl
3161
3172
  && event.target
@@ -3175,6 +3186,12 @@
3175
3186
  return;
3176
3187
  }
3177
3188
 
3189
+ if (isStudioHtmlFocusOpen() && plainEscape) {
3190
+ event.preventDefault();
3191
+ closeStudioHtmlFocusViewer();
3192
+ return;
3193
+ }
3194
+
3178
3195
  if (isScratchpadOpen() && plainEscape) {
3179
3196
  event.preventDefault();
3180
3197
  closeScratchpad();
@@ -3193,7 +3210,7 @@
3193
3210
  return;
3194
3211
  }
3195
3212
 
3196
- if (scratchpadOwnsEvent || reviewNotesOwnsEvent || outlineOwnsEvent || pdfFocusOwnsEvent || quizOwnsEvent) {
3213
+ if (scratchpadOwnsEvent || reviewNotesOwnsEvent || outlineOwnsEvent || pdfFocusOwnsEvent || htmlFocusOwnsEvent || quizOwnsEvent) {
3197
3214
  return;
3198
3215
  }
3199
3216
 
@@ -3854,6 +3871,10 @@
3854
3871
  return;
3855
3872
  }
3856
3873
  if (event.source && record.iframe.contentWindow && event.source !== record.iframe.contentWindow) return;
3874
+ if (record.shell && record.shell.classList && record.shell.classList.contains("is-focused")) {
3875
+ if (record.detail) record.detail.textContent = "HTML preview";
3876
+ return;
3877
+ }
3857
3878
  const rawHeight = Number(data.height);
3858
3879
  if (!Number.isFinite(rawHeight) || rawHeight <= 0) return;
3859
3880
  const measuredHeight = Math.ceil(rawHeight + 2);
@@ -3875,30 +3896,232 @@
3875
3896
 
3876
3897
  window.addEventListener("message", handleHtmlArtifactFrameSizeMessage);
3877
3898
 
3899
+ function isStudioHtmlFocusOpen() {
3900
+ return Boolean(studioHtmlFocusOverlayEl && studioHtmlFocusOverlayEl.hidden === false && studioHtmlFocusShellEl);
3901
+ }
3902
+
3903
+ function ensureStudioHtmlFocusViewer() {
3904
+ if (studioHtmlFocusOverlayEl) return studioHtmlFocusOverlayEl;
3905
+
3906
+ const overlay = document.createElement("div");
3907
+ overlay.className = "studio-pdf-focus-overlay studio-html-focus-overlay";
3908
+ overlay.hidden = true;
3909
+ overlay.setAttribute("aria-hidden", "true");
3910
+ overlay.addEventListener("click", (event) => {
3911
+ if (event.target === overlay) closeStudioHtmlFocusViewer();
3912
+ });
3913
+ document.addEventListener("fullscreenchange", syncStudioHtmlFocusFullscreenButton);
3914
+ document.body.appendChild(overlay);
3915
+ studioHtmlFocusOverlayEl = overlay;
3916
+ syncStudioHtmlFocusFullscreenButton();
3917
+ return overlay;
3918
+ }
3919
+
3920
+ function getStudioHtmlFocusButton(shell) {
3921
+ return shell && typeof shell.querySelector === "function"
3922
+ ? shell.querySelector(".studio-html-artifact-focus-btn")
3923
+ : null;
3924
+ }
3925
+
3926
+ function getStudioHtmlFullscreenButton(shell) {
3927
+ return shell && typeof shell.querySelector === "function"
3928
+ ? shell.querySelector(".studio-html-artifact-fullscreen-btn")
3929
+ : null;
3930
+ }
3931
+
3932
+ function setStudioHtmlFocusButtonMode(button, focused) {
3933
+ if (!button) return;
3934
+ button.replaceChildren(makeStudioUiRefreshIcon(focused ? "focus-exit" : "focus"));
3935
+ button.title = focused
3936
+ ? "Exit HTML preview focus view."
3937
+ : "Open this HTML preview in a larger Studio overlay.";
3938
+ button.setAttribute("aria-label", focused ? "Exit HTML preview focus view" : "Focus HTML preview");
3939
+ button.setAttribute("aria-pressed", focused ? "true" : "false");
3940
+ }
3941
+
3942
+ function syncStudioHtmlFocusFullscreenButton() {
3943
+ const button = studioHtmlFocusFullscreenBtn;
3944
+ if (!button) return;
3945
+ const shell = studioHtmlFocusShellEl;
3946
+ const isFullscreen = Boolean(shell && document.fullscreenElement && document.fullscreenElement === shell);
3947
+ button.replaceChildren(makeStudioUiRefreshIcon(isFullscreen ? "fullscreen-exit" : "fullscreen"));
3948
+ const label = isFullscreen ? "Exit fullscreen" : "Fullscreen";
3949
+ button.title = isFullscreen
3950
+ ? "Exit browser fullscreen and keep the HTML preview focus view open."
3951
+ : "Ask the browser to make this HTML preview fullscreen.";
3952
+ button.setAttribute("aria-label", label);
3953
+ button.setAttribute("aria-pressed", isFullscreen ? "true" : "false");
3954
+ }
3955
+
3956
+ async function toggleStudioHtmlFocusFullscreen() {
3957
+ const shell = studioHtmlFocusShellEl;
3958
+ if (!shell) return;
3959
+ const isFullscreen = Boolean(document.fullscreenElement && document.fullscreenElement === shell);
3960
+ if (isFullscreen) {
3961
+ try {
3962
+ if (typeof document.exitFullscreen === "function") await document.exitFullscreen();
3963
+ } catch (error) {
3964
+ setStatus("Could not exit HTML preview fullscreen: " + (error && error.message ? error.message : String(error || "unknown error")), "warning");
3965
+ } finally {
3966
+ syncStudioHtmlFocusFullscreenButton();
3967
+ }
3968
+ return;
3969
+ }
3970
+ if (typeof shell.requestFullscreen !== "function") {
3971
+ setStatus("Browser fullscreen is not available for this HTML preview.", "warning");
3972
+ return;
3973
+ }
3974
+ try {
3975
+ await shell.requestFullscreen();
3976
+ } catch (error) {
3977
+ setStatus("Could not enter HTML preview fullscreen: " + (error && error.message ? error.message : String(error || "unknown error")), "warning");
3978
+ } finally {
3979
+ syncStudioHtmlFocusFullscreenButton();
3980
+ }
3981
+ }
3982
+
3983
+ function openStudioHtmlFocusViewer(title, shell) {
3984
+ // Keep the existing sandboxed iframe in place. Reparenting srcdoc iframes can
3985
+ // recreate their browsing context and lose form/script state.
3986
+ const focusShell = shell instanceof HTMLElement ? shell : null;
3987
+ if (!focusShell || !focusShell.isConnected) return false;
3988
+ if (isStudioHtmlFocusOpen() && studioHtmlFocusShellEl === focusShell) return true;
3989
+ if (isStudioHtmlFocusOpen()) closeStudioHtmlFocusViewer();
3990
+ ensureStudioHtmlFocusViewer();
3991
+
3992
+ studioHtmlFocusLastFocusedEl = document.activeElement instanceof HTMLElement ? document.activeElement : null;
3993
+ studioHtmlFocusShellEl = focusShell;
3994
+ studioHtmlFocusRestoreState = {
3995
+ role: focusShell.getAttribute("role"),
3996
+ ariaModal: focusShell.getAttribute("aria-modal"),
3997
+ ariaLabel: focusShell.getAttribute("aria-label"),
3998
+ };
3999
+
4000
+ focusShell.classList.add("is-focused");
4001
+ focusShell.setAttribute("role", "dialog");
4002
+ focusShell.setAttribute("aria-modal", "true");
4003
+ focusShell.setAttribute("aria-label", String(title || "HTML preview").trim() || "HTML preview");
4004
+
4005
+ const focusButton = getStudioHtmlFocusButton(focusShell);
4006
+ setStudioHtmlFocusButtonMode(focusButton, true);
4007
+ studioHtmlFocusFullscreenBtn = getStudioHtmlFullscreenButton(focusShell);
4008
+ if (studioHtmlFocusFullscreenBtn) studioHtmlFocusFullscreenBtn.hidden = false;
4009
+
4010
+ if (document.body) document.body.classList.add("studio-html-focus-open", "studio-pdf-focus-open");
4011
+ if (studioHtmlFocusOverlayEl) {
4012
+ studioHtmlFocusOverlayEl.hidden = false;
4013
+ studioHtmlFocusOverlayEl.setAttribute("aria-hidden", "false");
4014
+ }
4015
+ syncStudioHtmlFocusFullscreenButton();
4016
+ closeStudioUiRefreshMenus();
4017
+ closeExportPreviewMenu();
4018
+ window.setTimeout(() => {
4019
+ if (focusButton && typeof focusButton.focus === "function") focusButton.focus();
4020
+ }, 0);
4021
+ return true;
4022
+ }
4023
+
4024
+ function closeStudioHtmlFocusViewer() {
4025
+ if (!isStudioHtmlFocusOpen()) return false;
4026
+ const shell = studioHtmlFocusShellEl;
4027
+ if (document.fullscreenElement && shell && document.fullscreenElement === shell) {
4028
+ try {
4029
+ const exitResult = document.exitFullscreen && document.exitFullscreen();
4030
+ if (exitResult && typeof exitResult.catch === "function") exitResult.catch(() => {});
4031
+ } catch {}
4032
+ }
4033
+ if (studioHtmlFocusOverlayEl) {
4034
+ studioHtmlFocusOverlayEl.hidden = true;
4035
+ studioHtmlFocusOverlayEl.setAttribute("aria-hidden", "true");
4036
+ }
4037
+ if (shell) {
4038
+ shell.classList.remove("is-focused");
4039
+ const restore = studioHtmlFocusRestoreState || {};
4040
+ if (restore.role === null || typeof restore.role === "undefined") shell.removeAttribute("role");
4041
+ else shell.setAttribute("role", restore.role);
4042
+ if (restore.ariaModal === null || typeof restore.ariaModal === "undefined") shell.removeAttribute("aria-modal");
4043
+ else shell.setAttribute("aria-modal", restore.ariaModal);
4044
+ if (restore.ariaLabel === null || typeof restore.ariaLabel === "undefined") shell.removeAttribute("aria-label");
4045
+ else shell.setAttribute("aria-label", restore.ariaLabel);
4046
+ setStudioHtmlFocusButtonMode(getStudioHtmlFocusButton(shell), false);
4047
+ }
4048
+ if (studioHtmlFocusFullscreenBtn) studioHtmlFocusFullscreenBtn.hidden = true;
4049
+ studioHtmlFocusShellEl = null;
4050
+ studioHtmlFocusFullscreenBtn = null;
4051
+ studioHtmlFocusRestoreState = null;
4052
+ if (document.body) document.body.classList.remove("studio-html-focus-open", "studio-pdf-focus-open");
4053
+ const focusTarget = studioHtmlFocusLastFocusedEl;
4054
+ studioHtmlFocusLastFocusedEl = null;
4055
+ if (focusTarget && typeof focusTarget.focus === "function" && document.contains(focusTarget)) {
4056
+ window.setTimeout(() => focusTarget.focus(), 0);
4057
+ }
4058
+ return true;
4059
+ }
4060
+
4061
+ function openStudioHtmlFocusFromButton(buttonEl) {
4062
+ if (!buttonEl) return false;
4063
+ const shell = buttonEl.closest && buttonEl.closest(".studio-html-artifact-shell");
4064
+ if (!shell) return false;
4065
+ if (isStudioHtmlFocusOpen() && studioHtmlFocusShellEl === shell) {
4066
+ return closeStudioHtmlFocusViewer();
4067
+ }
4068
+ const title = String(shell.dataset && shell.dataset.studioHtmlTitle ? shell.dataset.studioHtmlTitle : "").trim()
4069
+ || String(buttonEl.dataset && buttonEl.dataset.studioHtmlTitle ? buttonEl.dataset.studioHtmlTitle : "").trim()
4070
+ || "HTML preview";
4071
+ return openStudioHtmlFocusViewer(title, shell);
4072
+ }
4073
+
4074
+ function handleStudioHtmlFocusButtonClick(event) {
4075
+ const target = event && event.target;
4076
+ const buttonEl = target instanceof Element ? target.closest(".studio-html-artifact-focus-btn") : null;
4077
+ if (!buttonEl) return;
4078
+ event.preventDefault();
4079
+ event.stopPropagation();
4080
+ if (typeof event.stopImmediatePropagation === "function") {
4081
+ event.stopImmediatePropagation();
4082
+ }
4083
+ if (!openStudioHtmlFocusFromButton(buttonEl)) {
4084
+ setStatus("Could not open HTML preview focus view.", "warning");
4085
+ }
4086
+ }
4087
+
3878
4088
  function renderHtmlArtifactPreview(targetEl, html, pane, options) {
3879
4089
  if (!targetEl) return;
3880
4090
  const title = options && options.title ? String(options.title) : "HTML preview";
3881
4091
  const previewId = "html_artifact_" + Date.now().toString(36) + "_" + Math.random().toString(36).slice(2, 10);
3882
4092
  pruneDisconnectedHtmlArtifactFrames();
4093
+ if (isStudioHtmlFocusOpen()) closeStudioHtmlFocusViewer();
3883
4094
  clearPreviewJumpHighlight(targetEl);
3884
4095
  finishPreviewRender(targetEl);
3885
4096
  targetEl.innerHTML = "";
3886
4097
 
3887
4098
  const shell = document.createElement("div");
3888
4099
  shell.className = "studio-html-artifact-shell";
4100
+ if (shell.dataset) shell.dataset.studioHtmlTitle = title;
3889
4101
 
3890
4102
  const toolbar = document.createElement("div");
3891
4103
  toolbar.className = "studio-html-artifact-toolbar";
4104
+ const titleGroup = document.createElement("div");
4105
+ titleGroup.className = "studio-html-artifact-title-group";
4106
+ const focusBtn = document.createElement("button");
4107
+ focusBtn.type = "button";
4108
+ focusBtn.className = "studio-html-artifact-focus-btn";
4109
+ focusBtn.title = "Open this HTML preview in a larger Studio overlay.";
4110
+ focusBtn.setAttribute("aria-label", "Focus HTML preview");
4111
+ if (focusBtn.dataset) focusBtn.dataset.studioHtmlTitle = title;
4112
+ focusBtn.appendChild(makeStudioUiRefreshIcon("focus"));
4113
+ focusBtn.addEventListener("click", handleStudioHtmlFocusButtonClick);
4114
+ titleGroup.appendChild(focusBtn);
3892
4115
  const label = document.createElement("span");
3893
4116
  label.className = "studio-html-artifact-label";
3894
4117
  label.textContent = title;
4118
+ titleGroup.appendChild(label);
3895
4119
  const detail = document.createElement("span");
3896
4120
  detail.className = "studio-html-artifact-detail";
3897
4121
  detail.textContent = "HTML preview";
3898
4122
 
3899
4123
  const tools = document.createElement("span");
3900
4124
  tools.className = "studio-html-artifact-tools";
3901
- tools.appendChild(detail);
3902
4125
 
3903
4126
  const zoomControls = document.createElement("span");
3904
4127
  zoomControls.className = "studio-html-artifact-zoom-controls";
@@ -3948,10 +4171,24 @@
3948
4171
  zoomControls.appendChild(zoomOutBtn);
3949
4172
  zoomControls.appendChild(zoomResetBtn);
3950
4173
  zoomControls.appendChild(zoomInBtn);
4174
+ const fullscreenBtn = document.createElement("button");
4175
+ fullscreenBtn.type = "button";
4176
+ fullscreenBtn.className = "studio-html-artifact-fullscreen-btn";
4177
+ fullscreenBtn.hidden = true;
4178
+ fullscreenBtn.addEventListener("pointerdown", (event) => { event.stopPropagation(); });
4179
+ fullscreenBtn.addEventListener("mousedown", (event) => { event.stopPropagation(); });
4180
+ fullscreenBtn.addEventListener("click", (event) => {
4181
+ event.preventDefault();
4182
+ event.stopPropagation();
4183
+ toggleStudioHtmlFocusFullscreen();
4184
+ });
4185
+ fullscreenBtn.appendChild(makeStudioUiRefreshIcon("fullscreen"));
3951
4186
  updateZoomUi();
4187
+ tools.appendChild(detail);
3952
4188
  tools.appendChild(zoomControls);
4189
+ tools.appendChild(fullscreenBtn);
3953
4190
 
3954
- toolbar.appendChild(label);
4191
+ toolbar.appendChild(titleGroup);
3955
4192
  toolbar.appendChild(tools);
3956
4193
  shell.appendChild(toolbar);
3957
4194
 
@@ -15398,6 +15635,13 @@
15398
15635
  handleStudioPdfFocusButtonClick(event);
15399
15636
  }, true);
15400
15637
 
15638
+ document.addEventListener("click", (event) => {
15639
+ const target = event.target;
15640
+ const focusBtn = target instanceof Element ? target.closest(".studio-html-artifact-focus-btn") : null;
15641
+ if (!focusBtn) return;
15642
+ handleStudioHtmlFocusButtonClick(event);
15643
+ }, true);
15644
+
15401
15645
  document.addEventListener("click", (event) => {
15402
15646
  const target = event.target;
15403
15647
  const copyBtn = target instanceof Element ? target.closest(".studio-copy-block-btn") : null;
package/client/studio.css CHANGED
@@ -1635,7 +1635,8 @@
1635
1635
  background: #fff;
1636
1636
  }
1637
1637
 
1638
- body.studio-pdf-focus-open {
1638
+ body.studio-pdf-focus-open,
1639
+ body.studio-html-focus-open {
1639
1640
  overflow: hidden;
1640
1641
  }
1641
1642
 
@@ -2115,6 +2116,31 @@
2115
2116
  box-shadow: 0 1px 2px var(--shadow-color);
2116
2117
  }
2117
2118
 
2119
+ .rendered-markdown .studio-html-artifact-shell.is-focused {
2120
+ position: fixed;
2121
+ top: 50%;
2122
+ left: 50%;
2123
+ z-index: 71;
2124
+ width: min(96vw, 1440px);
2125
+ height: min(92vh, 1080px);
2126
+ min-height: 420px;
2127
+ margin: 0;
2128
+ transform: translate(-50%, -50%);
2129
+ border-radius: 14px;
2130
+ box-shadow: 0 24px 70px var(--shadow-color);
2131
+ }
2132
+
2133
+ .rendered-markdown .studio-html-artifact-shell.is-focused:fullscreen {
2134
+ top: 0;
2135
+ left: 0;
2136
+ width: 100vw;
2137
+ height: 100vh;
2138
+ min-height: 100vh;
2139
+ transform: none;
2140
+ border: 0;
2141
+ border-radius: 0;
2142
+ }
2143
+
2118
2144
  .rendered-markdown .studio-html-artifact-toolbar {
2119
2145
  display: flex;
2120
2146
  align-items: center;
@@ -2129,6 +2155,20 @@
2129
2155
  line-height: 1.25;
2130
2156
  }
2131
2157
 
2158
+ .rendered-markdown .studio-html-artifact-shell.is-focused .studio-html-artifact-toolbar {
2159
+ gap: 12px;
2160
+ padding: 9px 12px;
2161
+ border-bottom-color: var(--panel-border);
2162
+ }
2163
+
2164
+ .rendered-markdown .studio-html-artifact-title-group {
2165
+ min-width: 0;
2166
+ flex: 1 1 auto;
2167
+ display: inline-flex;
2168
+ align-items: center;
2169
+ gap: 8px;
2170
+ }
2171
+
2132
2172
  .rendered-markdown .studio-html-artifact-label {
2133
2173
  min-width: 0;
2134
2174
  overflow: hidden;
@@ -2138,6 +2178,48 @@
2138
2178
  font-weight: 600;
2139
2179
  }
2140
2180
 
2181
+ .rendered-markdown .studio-html-artifact-focus-btn,
2182
+ .rendered-markdown .studio-html-artifact-fullscreen-btn {
2183
+ flex: 0 0 auto;
2184
+ display: inline-flex;
2185
+ align-items: center;
2186
+ justify-content: center;
2187
+ width: 26px;
2188
+ min-width: 26px;
2189
+ height: 26px;
2190
+ padding: 0;
2191
+ border: 1px solid transparent;
2192
+ border-radius: 8px;
2193
+ background: transparent;
2194
+ color: var(--studio-info-text, var(--muted));
2195
+ }
2196
+
2197
+ .rendered-markdown .studio-html-artifact-shell.is-focused .studio-html-artifact-focus-btn,
2198
+ .rendered-markdown .studio-html-artifact-shell.is-focused .studio-html-artifact-fullscreen-btn {
2199
+ width: 29px;
2200
+ min-width: 29px;
2201
+ height: 29px;
2202
+ }
2203
+
2204
+ .rendered-markdown .studio-html-artifact-fullscreen-btn[hidden] {
2205
+ display: none !important;
2206
+ }
2207
+
2208
+ .rendered-markdown .studio-html-artifact-focus-btn:not(:disabled):hover,
2209
+ .rendered-markdown .studio-html-artifact-focus-btn:focus-visible,
2210
+ .rendered-markdown .studio-html-artifact-fullscreen-btn:not(:disabled):hover,
2211
+ .rendered-markdown .studio-html-artifact-fullscreen-btn:focus-visible {
2212
+ background: var(--panel);
2213
+ border-color: var(--control-border);
2214
+ color: var(--text);
2215
+ }
2216
+
2217
+ .rendered-markdown .studio-html-artifact-focus-btn .studio-refresh-icon,
2218
+ .rendered-markdown .studio-html-artifact-fullscreen-btn .studio-refresh-icon {
2219
+ width: 14px;
2220
+ height: 14px;
2221
+ }
2222
+
2141
2223
  .rendered-markdown .studio-html-artifact-tools {
2142
2224
  flex: 0 0 auto;
2143
2225
  display: inline-flex;
@@ -2204,6 +2286,11 @@
2204
2286
  background: #fff;
2205
2287
  }
2206
2288
 
2289
+ .rendered-markdown .studio-html-artifact-shell.is-focused .studio-html-artifact-frame {
2290
+ min-height: 0;
2291
+ height: auto !important;
2292
+ }
2293
+
2207
2294
  .rendered-markdown .studio-html-artifact-shell.is-height-capped .studio-html-artifact-toolbar {
2208
2295
  border-bottom-color: var(--control-border);
2209
2296
  }
package/index.ts CHANGED
@@ -8276,9 +8276,33 @@ function isSshSession(): boolean {
8276
8276
  );
8277
8277
  }
8278
8278
 
8279
- function buildStudioSshTunnelHint(port: number, studioUrl: string): string | null {
8279
+ function parseStudioLaunchOpenFlags(rawArgs: string): { args: string; openRemoteBrowser: boolean; error?: string } {
8280
+ const parsed = tokenizeStudioCommandArgs(rawArgs);
8281
+ if (parsed.error) return { args: rawArgs, openRemoteBrowser: false, error: parsed.error };
8282
+ const remaining: string[] = [];
8283
+ let openRemoteBrowser = false;
8284
+ for (const token of parsed.tokens) {
8285
+ if (token === "--open-remote" || token === "--open-remote-browser") {
8286
+ openRemoteBrowser = true;
8287
+ continue;
8288
+ }
8289
+ remaining.push(token);
8290
+ }
8291
+ return { args: remaining.join(" "), openRemoteBrowser };
8292
+ }
8293
+
8294
+ function shouldAutoOpenStudioBrowser(options?: { openRemoteBrowser?: boolean }): boolean {
8295
+ return !isSshSession() || Boolean(options?.openRemoteBrowser);
8296
+ }
8297
+
8298
+ function buildStudioSshTunnelHint(port: number): string | null {
8280
8299
  if (!isSshSession()) return null;
8281
- return `SSH detected. Full Studio URL: ${studioUrl}. Forward the remote Studio port with: ssh -L ${port}:127.0.0.1:${port} <remote-host>. Open the full URL locally through the tunnel, preserving its ?token=... parameter. If you choose a different local port, change only the port in the URL; keep the token.`;
8300
+ return [
8301
+ "SSH detected. Studio was not opened in the remote browser.",
8302
+ "To open it locally, run this on your local machine:",
8303
+ ` ssh -L ${port}:127.0.0.1:${port} <remote-host>`,
8304
+ "Then open the Studio URL above in your local browser.",
8305
+ ].join("\n");
8282
8306
  }
8283
8307
 
8284
8308
  function resolveRequestedStudioDocumentFromUrl(
@@ -12512,6 +12536,12 @@ export default function (pi: ExtensionAPI) {
12512
12536
  mode: StudioUiMode,
12513
12537
  options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string; replaceExistingFull?: boolean },
12514
12538
  ) => {
12539
+ const launchOpenFlags = parseStudioLaunchOpenFlags(trimmed);
12540
+ if (launchOpenFlags.error) {
12541
+ ctx.ui.notify(`${launchOpenFlags.error} Use ${options?.commandLabel ?? "/studio"} --help`, "error");
12542
+ return;
12543
+ }
12544
+ const launchArgs = launchOpenFlags.args;
12515
12545
  if (mode === "full" && hasConnectedFullStudioView()) {
12516
12546
  if (options?.replaceExistingFull) {
12517
12547
  closeStudioClientsByMode("full", 4001, "Full Studio replaced");
@@ -12520,7 +12550,7 @@ export default function (pi: ExtensionAPI) {
12520
12550
  if (serverState) {
12521
12551
  const url = buildStudioUrl(serverState.port, serverState.token, "full");
12522
12552
  ctx.ui.notify(`Studio URL: ${url}`, "info");
12523
- const sshTunnelHint = buildStudioSshTunnelHint(serverState.port, url);
12553
+ const sshTunnelHint = buildStudioSshTunnelHint(serverState.port);
12524
12554
  if (sshTunnelHint) ctx.ui.notify(sshTunnelHint, "info");
12525
12555
  }
12526
12556
  return;
@@ -12542,23 +12572,30 @@ export default function (pi: ExtensionAPI) {
12542
12572
  // ignore theme read errors
12543
12573
  }
12544
12574
 
12545
- const selected = resolveStudioLaunchDocument(trimmed, ctx, options);
12575
+ const selected = resolveStudioLaunchDocument(launchArgs, ctx, options);
12546
12576
  if (!selected) return;
12547
12577
  initialStudioDocument = selected;
12548
12578
 
12549
12579
  const state = await ensureServer();
12550
12580
  const url = buildStudioUrl(state.port, state.token, mode, selected);
12551
- const sshTunnelHint = buildStudioSshTunnelHint(state.port, url);
12581
+ const sshTunnelHint = buildStudioSshTunnelHint(state.port);
12552
12582
  const openedLabel = mode === "editor-only" ? "pi Studio editor-only view" : "pi Studio";
12553
12583
 
12584
+ const shouldOpenBrowser = shouldAutoOpenStudioBrowser({
12585
+ openRemoteBrowser: launchOpenFlags.openRemoteBrowser,
12586
+ });
12554
12587
  try {
12555
- await openUrlInDefaultBrowser(url);
12556
- if (selected.source === "file") {
12557
- ctx.ui.notify(`Opened ${openedLabel} with file loaded: ${selected.label}`, "info");
12558
- } else if (selected.source === "last-response") {
12559
- ctx.ui.notify(`Opened ${openedLabel} with last model response (${selected.text.length} chars).`, "info");
12588
+ if (!shouldOpenBrowser) {
12589
+ ctx.ui.notify(`${openedLabel} is ready. Browser auto-open was skipped because SSH was detected.`, "info");
12560
12590
  } else {
12561
- ctx.ui.notify(`Opened ${openedLabel} with blank editor.`, "info");
12591
+ await openUrlInDefaultBrowser(url);
12592
+ if (selected.source === "file") {
12593
+ ctx.ui.notify(`Opened ${openedLabel} with file loaded: ${selected.label}`, "info");
12594
+ } else if (selected.source === "last-response") {
12595
+ ctx.ui.notify(`Opened ${openedLabel} with last model response (${selected.text.length} chars).`, "info");
12596
+ } else {
12597
+ ctx.ui.notify(`Opened ${openedLabel} with blank editor.`, "info");
12598
+ }
12562
12599
  }
12563
12600
  } catch (error) {
12564
12601
  const message = error instanceof Error ? error.message : String(error);
@@ -12595,7 +12632,7 @@ export default function (pi: ExtensionAPI) {
12595
12632
  `Studio running at ${url} (busy: ${isStudioBusy() ? "yes" : "no"}; full views: ${counts.full}; editor-only views: ${counts.editorOnly})`,
12596
12633
  "info",
12597
12634
  );
12598
- const sshTunnelHint = buildStudioSshTunnelHint(serverState.port, url);
12635
+ const sshTunnelHint = buildStudioSshTunnelHint(serverState.port);
12599
12636
  if (sshTunnelHint) ctx.ui.notify(sshTunnelHint, "info");
12600
12637
  return;
12601
12638
  }
@@ -12607,6 +12644,7 @@ export default function (pi: ExtensionAPI) {
12607
12644
  + " /studio <path> Open studio with file preloaded\n"
12608
12645
  + " /studio --blank Open with blank editor\n"
12609
12646
  + " /studio --last Open with last model response\n"
12647
+ + " /studio --open-remote Over SSH, open the remote browser anyway\n"
12610
12648
  + " /studio --status Show studio status\n"
12611
12649
  + " /studio --stop Stop studio server\n"
12612
12650
  + " Note: only one full /studio view is allowed per Pi session.\n"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.6",
3
+ "version": "0.9.7",
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",