pi-studio 0.5.37 → 0.5.39

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,28 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.39] — 2026-03-30
8
+
9
+ ### Added
10
+ - Studio now supports the familiar `Cmd/Ctrl+S` shortcut for saving editor content.
11
+
12
+ ### Changed
13
+ - `Cmd/Ctrl+S` now triggers **Save editor** when a direct save path is available, and falls back to **Save editor as…** otherwise.
14
+ - Save button tooltips and the footer shortcut hint now advertise the save shortcut explicitly.
15
+
16
+ ## [0.5.38] — 2026-03-29
17
+
18
+ ### Added
19
+ - Studio now supports `/studio-editor-only` for opening additional editor/preview companion views from the same Pi session, without taking over the main full Studio workspace.
20
+ - Studio now supports `/studio-replace` for explicitly replacing the current full Studio view while leaving editor-only views open.
21
+
22
+ ### Changed
23
+ - Full Studio is now treated as a singleton per Pi session: `/studio` opens the canonical full workspace, while attempts to open another full Studio now guide you toward `/studio-replace` or `/studio-editor-only` instead of silently invalidating the existing full Studio tab.
24
+ - README/help text and Studio status/warning messages now describe the updated full-vs-editor-only session model more clearly.
25
+
26
+ ### Fixed
27
+ - Editor-only Studio views now hide remaining critique/history controls, including the critique-focus dropdown, and keep their WebSocket mode metadata aligned with the server-side full/editor-only view tracking.
28
+
7
29
  ## [0.5.37] — 2026-03-29
8
30
 
9
31
  ### Added
package/README.md CHANGED
@@ -15,6 +15,7 @@ Extension for [pi](https://pi.dev) that opens a local two-pane browser workspace
15
15
  ## What it does
16
16
 
17
17
  - Opens a two-pane browser workspace: **Editor** (left) + **Response/Thinking/Editor Preview** (right)
18
+ - Supports one canonical full Studio view per Pi session, plus additional editor-only companion views when you just want extra editing/preview surfaces
18
19
  - Runs editor text directly, or asks for structured critique (auto/writing/code focus)
19
20
  - Includes a local persistent scratchpad for quick notes you want to keep out of the main editor until you're ready to copy or insert them
20
21
  - Browses response history (`Prev/Next/Last`) and loads either:
@@ -43,6 +44,8 @@ Extension for [pi](https://pi.dev) that opens a local two-pane browser workspace
43
44
  | `/studio --status` | Show studio server status |
44
45
  | `/studio --stop` | Stop studio server |
45
46
  | `/studio --help` | Show help |
47
+ | `/studio-replace [path\|--blank\|--last]` | Replace the current full Studio view with a new full Studio view |
48
+ | `/studio-editor-only [path\|--blank\|--last]` | Open an editor-only Studio view; multiple editor-only views may be open at once |
46
49
  | `/studio-current <path>` | Load a file into currently open Studio tab(s) without opening a new browser window |
47
50
  | `/studio-pdf <path> [options]` | Export a local file to `<name>.studio.pdf` via the Studio PDF pipeline, with optional layout controls |
48
51
 
@@ -64,7 +67,8 @@ pi -e https://github.com/omaclaren/pi-studio
64
67
 
65
68
  ## Notes
66
69
 
67
- - Local-only server (`127.0.0.1`) with rotating tokenized URLs.
70
+ - Local-only server (`127.0.0.1`) with tokenized Studio URLs.
71
+ - 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.
68
72
  - Studio is designed as a complement to terminal pi, not a replacement.
69
73
  - Editor/code font uses a best-effort terminal-monospace match when the current terminal config exposes it; set `PI_STUDIO_FONT_MONO` to force a specific CSS `font-family` stack.
70
74
  - Full preview/PDF quality depends on `pandoc` (and `xelatex` for PDF):
@@ -99,6 +99,11 @@
99
99
  const scratchpadCloseBtn = document.getElementById("scratchpadCloseBtn");
100
100
  const scratchpadDoneBtn = document.getElementById("scratchpadDoneBtn");
101
101
 
102
+ const studioMode = (document.body && document.body.dataset && document.body.dataset.studioMode) === "editor-only"
103
+ ? "editor-only"
104
+ : "full";
105
+ const isEditorOnlyMode = studioMode === "editor-only";
106
+
102
107
  const initialSourceState = {
103
108
  source: (document.body && document.body.dataset && document.body.dataset.initialSource) || "blank",
104
109
  label: (document.body && document.body.dataset && document.body.dataset.initialLabel) || "blank",
@@ -115,9 +120,9 @@
115
120
  let pendingKind = null;
116
121
  let stickyStudioKind = null;
117
122
  let initialDocumentApplied = false;
118
- let editorView = "markdown";
119
- let rightView = "preview";
120
- let followLatest = true;
123
+ let editorView = isEditorOnlyMode ? "markdown" : "markdown";
124
+ let rightView = isEditorOnlyMode ? "editor-preview" : "preview";
125
+ let followLatest = !isEditorOnlyMode;
121
126
  let queuedLatestResponse = null;
122
127
  let latestResponseMarkdown = "";
123
128
  let latestResponseThinking = "";
@@ -873,6 +878,19 @@
873
878
  return true;
874
879
  }
875
880
 
881
+ function triggerEditorSaveShortcut() {
882
+ if (saveOverBtn && !saveOverBtn.disabled && !saveOverBtn.hidden) {
883
+ saveOverBtn.click();
884
+ return true;
885
+ }
886
+ if (saveAsBtn && !saveAsBtn.disabled && !saveAsBtn.hidden) {
887
+ saveAsBtn.click();
888
+ return true;
889
+ }
890
+ setStatus("Save is unavailable right now.", "warning");
891
+ return false;
892
+ }
893
+
876
894
  function handlePaneShortcut(event) {
877
895
  if (!event || event.defaultPrevented) return;
878
896
 
@@ -909,6 +927,18 @@
909
927
  return;
910
928
  }
911
929
 
930
+ const isSaveShortcut =
931
+ key.toLowerCase() === "s"
932
+ && (event.metaKey || event.ctrlKey)
933
+ && !event.altKey
934
+ && !event.shiftKey;
935
+
936
+ if (isSaveShortcut) {
937
+ event.preventDefault();
938
+ triggerEditorSaveShortcut();
939
+ return;
940
+ }
941
+
912
942
  if (plainEscape) {
913
943
  const activeKind = getAbortablePendingKind();
914
944
  if (activeKind === "direct" || activeKind === "critique") {
@@ -928,6 +958,7 @@
928
958
  && !event.altKey
929
959
  && !event.shiftKey
930
960
  && activePane === "left"
961
+ && !isEditorOnlyMode
931
962
  ) {
932
963
  if (queueSteerBtn && !queueSteerBtn.disabled) {
933
964
  event.preventDefault();
@@ -2519,11 +2550,11 @@
2519
2550
 
2520
2551
  var effectivePath = getEffectiveSavePath();
2521
2552
  if (effectivePath) {
2522
- saveOverBtn.title = "Overwrite file: " + effectivePath;
2553
+ saveOverBtn.title = "Overwrite file: " + effectivePath + " · Shortcut: Cmd/Ctrl+S.";
2523
2554
  return;
2524
2555
  }
2525
2556
 
2526
- saveOverBtn.title = "Save editor is available after opening a file, setting a working dir, or using Save editor as…";
2557
+ saveOverBtn.title = "Save editor is available after opening a file, setting a working dir, or using Save editor as…. Shortcut: Cmd/Ctrl+S falls back to Save editor as when needed.";
2527
2558
  }
2528
2559
 
2529
2560
  function syncActionButtons() {
@@ -2532,7 +2563,7 @@
2532
2563
  fileInput.disabled = uiBusy;
2533
2564
  saveAsBtn.disabled = uiBusy;
2534
2565
  saveOverBtn.disabled = uiBusy || !canSaveOver;
2535
- sendEditorBtn.disabled = uiBusy;
2566
+ sendEditorBtn.disabled = uiBusy || isEditorOnlyMode;
2536
2567
  if (getEditorBtn) getEditorBtn.disabled = uiBusy;
2537
2568
  if (loadGitDiffBtn) loadGitDiffBtn.disabled = uiBusy;
2538
2569
  syncRunAndCritiqueButtons();
@@ -2542,13 +2573,13 @@
2542
2573
  if (annotationModeSelect) annotationModeSelect.disabled = uiBusy;
2543
2574
  if (saveAnnotatedBtn) saveAnnotatedBtn.disabled = uiBusy;
2544
2575
  if (stripAnnotationsBtn) stripAnnotationsBtn.disabled = uiBusy || !hasAnnotationMarkers(sourceTextEl.value);
2545
- if (compactBtn) compactBtn.disabled = uiBusy || compactInProgress || wsState === "Disconnected";
2546
- editorViewSelect.disabled = false;
2547
- rightViewSelect.disabled = false;
2548
- followSelect.disabled = uiBusy;
2549
- if (responseHighlightSelect) responseHighlightSelect.disabled = rightView !== "markdown";
2550
- insertHeaderBtn.disabled = uiBusy;
2551
- lensSelect.disabled = uiBusy;
2576
+ if (compactBtn) compactBtn.disabled = isEditorOnlyMode || uiBusy || compactInProgress || wsState === "Disconnected";
2577
+ editorViewSelect.disabled = isEditorOnlyMode;
2578
+ rightViewSelect.disabled = isEditorOnlyMode;
2579
+ followSelect.disabled = isEditorOnlyMode || uiBusy;
2580
+ if (responseHighlightSelect) responseHighlightSelect.disabled = isEditorOnlyMode || rightView !== "markdown";
2581
+ insertHeaderBtn.disabled = uiBusy || isEditorOnlyMode;
2582
+ lensSelect.disabled = uiBusy || isEditorOnlyMode;
2552
2583
  updateSaveFileTooltip();
2553
2584
  updateHistoryControls();
2554
2585
  updateResultActionButtons();
@@ -3472,6 +3503,28 @@
3472
3503
  const critiqueIsStop = activeKind === "critique";
3473
3504
  const canQueueSteering = studioRunChainActive && !critiqueIsStop;
3474
3505
 
3506
+ if (isEditorOnlyMode) {
3507
+ if (sendRunBtn) {
3508
+ sendRunBtn.textContent = "Run editor text";
3509
+ sendRunBtn.classList.remove("request-stop-active");
3510
+ sendRunBtn.disabled = true;
3511
+ sendRunBtn.title = "Run is unavailable in editor-only mode.";
3512
+ }
3513
+ if (queueSteerBtn) {
3514
+ queueSteerBtn.hidden = false;
3515
+ queueSteerBtn.disabled = true;
3516
+ queueSteerBtn.classList.remove("request-stop-active");
3517
+ queueSteerBtn.title = "Queue steering is unavailable in editor-only mode.";
3518
+ }
3519
+ if (critiqueBtn) {
3520
+ critiqueBtn.textContent = "Critique editor text";
3521
+ critiqueBtn.classList.remove("request-stop-active");
3522
+ critiqueBtn.disabled = true;
3523
+ critiqueBtn.title = "Critique is unavailable in editor-only mode.";
3524
+ }
3525
+ return;
3526
+ }
3527
+
3475
3528
  if (sendRunBtn) {
3476
3529
  sendRunBtn.textContent = directIsStop ? "Stop" : "Run editor text";
3477
3530
  sendRunBtn.classList.toggle("request-stop-active", directIsStop);
@@ -4174,7 +4227,14 @@
4174
4227
  }
4175
4228
 
4176
4229
  const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
4177
- const wsUrl = wsProtocol + "://" + window.location.host + "/ws?token=" + encodeURIComponent(token) + (DEBUG_ENABLED ? "&debug=1" : "");
4230
+ const wsParams = new URLSearchParams({ token: token });
4231
+ if (studioMode !== "full") {
4232
+ wsParams.set("mode", studioMode);
4233
+ }
4234
+ if (DEBUG_ENABLED) {
4235
+ wsParams.set("debug", "1");
4236
+ }
4237
+ const wsUrl = wsProtocol + "://" + window.location.host + "/ws?" + wsParams.toString();
4178
4238
  const wasReconnect = reconnectAttempt > 0;
4179
4239
  let disconnectHandled = false;
4180
4240
 
@@ -4203,7 +4263,15 @@
4203
4263
  clearScheduledReconnect();
4204
4264
  reconnectAttempt = 0;
4205
4265
  setWsState("Disconnected");
4206
- setStatus("This tab was invalidated by a newer /studio session.", "warning");
4266
+ setStatus("This full Studio tab was replaced by a newer Studio session.", "warning");
4267
+ return;
4268
+ }
4269
+
4270
+ if (kind === "full_conflict") {
4271
+ clearScheduledReconnect();
4272
+ reconnectAttempt = 0;
4273
+ setWsState("Disconnected");
4274
+ setStatus("Another full Studio view is already active for this session. Use /studio-replace for a fresh full Studio view, or /studio-editor-only for a concurrent editor-only Studio view.", "warning");
4207
4275
  return;
4208
4276
  }
4209
4277
 
@@ -4244,6 +4312,10 @@
4244
4312
  handleDisconnect("invalidated", 4001);
4245
4313
  return;
4246
4314
  }
4315
+ if (event && event.code === 4004) {
4316
+ handleDisconnect("full_conflict", 4004);
4317
+ return;
4318
+ }
4247
4319
  if (event && event.code === 1001) {
4248
4320
  handleDisconnect("shutdown", 1001);
4249
4321
  return;
package/client/studio.css CHANGED
@@ -240,6 +240,44 @@
240
240
  appearance: menulist;
241
241
  }
242
242
 
243
+ body[data-studio-mode="editor-only"] #editorViewSelect,
244
+ body[data-studio-mode="editor-only"] #rightViewSelect,
245
+ body[data-studio-mode="editor-only"] #sendRunBtn,
246
+ body[data-studio-mode="editor-only"] #queueSteerBtn,
247
+ body[data-studio-mode="editor-only"] #sendEditorBtn,
248
+ body[data-studio-mode="editor-only"] #insertHeaderBtn,
249
+ body[data-studio-mode="editor-only"] #lensSelect,
250
+ body[data-studio-mode="editor-only"] #critiqueBtn,
251
+ body[data-studio-mode="editor-only"] #followSelect,
252
+ body[data-studio-mode="editor-only"] #responseHighlightSelect,
253
+ body[data-studio-mode="editor-only"] #pullLatestBtn,
254
+ body[data-studio-mode="editor-only"] #historyPrevBtn,
255
+ body[data-studio-mode="editor-only"] #historyIndexBadge,
256
+ body[data-studio-mode="editor-only"] #historyNextBtn,
257
+ body[data-studio-mode="editor-only"] #historyLastBtn,
258
+ body[data-studio-mode="editor-only"] #loadResponseBtn,
259
+ body[data-studio-mode="editor-only"] #loadCritiqueNotesBtn,
260
+ body[data-studio-mode="editor-only"] #loadCritiqueFullBtn,
261
+ body[data-studio-mode="editor-only"] #loadHistoryPromptBtn,
262
+ body[data-studio-mode="editor-only"] #copyResponseBtn,
263
+ body[data-studio-mode="editor-only"] #compactBtn,
264
+ body[data-studio-mode="editor-only"] .reference-meta,
265
+ body[data-studio-mode="editor-only"] #responseActions {
266
+ display: none !important;
267
+ }
268
+
269
+ body[data-studio-mode="editor-only"] #leftSectionHeader .section-header-main::before {
270
+ content: "Editor";
271
+ font-weight: 600;
272
+ font-size: 14px;
273
+ }
274
+
275
+ body[data-studio-mode="editor-only"] #rightSectionHeader .section-header-main::before {
276
+ content: "Preview";
277
+ font-weight: 600;
278
+ font-size: 14px;
279
+ }
280
+
243
281
  .reference-meta {
244
282
  padding: 8px 10px;
245
283
  border-bottom: 1px solid var(--border-muted);
package/index.ts CHANGED
@@ -22,6 +22,7 @@ import { escapeStudioPdfLatexTextFragment } from "./shared/studio-pdf-escape.js"
22
22
  type Lens = "writing" | "code";
23
23
  type RequestedLens = Lens | "auto";
24
24
  type StudioRequestKind = "critique" | "annotation" | "direct" | "compact";
25
+ type StudioUiMode = "full" | "editor-only";
25
26
  type StudioSourceKind = "file" | "last-response" | "blank";
26
27
  type TerminalActivityPhase = "idle" | "running" | "tool" | "responding";
27
28
  type StudioPromptMode = "response" | "run" | "effective";
@@ -35,6 +36,7 @@ interface StudioServerState {
35
36
  server: Server;
36
37
  wsServer: WebSocketServer;
37
38
  clients: Set<WebSocket>;
39
+ clientModes: Map<WebSocket, StudioUiMode>;
38
40
  port: number;
39
41
  token: string;
40
42
  }
@@ -5511,9 +5513,14 @@ function isAllowedOrigin(_origin: string | undefined, _port: number): boolean {
5511
5513
  return true;
5512
5514
  }
5513
5515
 
5514
- function buildStudioUrl(port: number, token: string): string {
5515
- const encoded = encodeURIComponent(token);
5516
- return `http://127.0.0.1:${port}/?token=${encoded}`;
5516
+ function normalizeStudioUiMode(raw: string | null | undefined): StudioUiMode {
5517
+ return raw === "editor-only" ? "editor-only" : "full";
5518
+ }
5519
+
5520
+ function buildStudioUrl(port: number, token: string, mode: StudioUiMode = "full"): string {
5521
+ const params = new URLSearchParams({ token });
5522
+ if (mode !== "full") params.set("mode", mode);
5523
+ return `http://127.0.0.1:${port}/?${params.toString()}`;
5517
5524
  }
5518
5525
 
5519
5526
  function formatModelLabel(model: { provider?: string; id?: string } | undefined): string {
@@ -5647,6 +5654,7 @@ function buildStudioHtml(
5647
5654
  initialModelLabel?: string,
5648
5655
  initialTerminalLabel?: string,
5649
5656
  initialContextUsage?: StudioContextUsageSnapshot,
5657
+ studioMode: StudioUiMode = "full",
5650
5658
  ): string {
5651
5659
  const initialText = escapeHtmlForInline(initialDocument?.text ?? "");
5652
5660
  const initialSource = initialDocument?.source ?? "blank";
@@ -5702,13 +5710,16 @@ function buildStudioHtml(
5702
5710
  const clientScriptHref = `/studio-client.js?token=${encodeURIComponent(studioToken ?? "")}`;
5703
5711
  const faviconHref = buildStudioFaviconDataUri(style);
5704
5712
  const bootConfigJson = JSON.stringify({ mermaidConfig }).replace(/</g, "\\u003c");
5713
+ const isEditorOnlyMode = studioMode === "editor-only";
5714
+ const appTitle = isEditorOnlyMode ? "π Studio — Editor" : "π Studio";
5715
+ const appSubtitle = isEditorOnlyMode ? "Editor Workspace" : "Editor & Response Workspace";
5705
5716
 
5706
5717
  return `<!doctype html>
5707
5718
  <html>
5708
5719
  <head>
5709
5720
  <meta charset="utf-8" />
5710
5721
  <meta name="viewport" content="width=device-width,initial-scale=1" />
5711
- <title>π Studio</title>
5722
+ <title>${appTitle}</title>
5712
5723
  <link rel="icon" href="${faviconHref}" type="image/svg+xml" />
5713
5724
  <style>
5714
5725
  :root {
@@ -5717,12 +5728,12 @@ ${cssVarsBlock}
5717
5728
  </style>
5718
5729
  <link rel="stylesheet" href="${stylesheetHref}" />
5719
5730
  </head>
5720
- <body data-initial-source="${initialSource}" data-initial-label="${initialLabel}" data-initial-path="${initialPath}" data-model-label="${initialModel}" data-terminal-label="${initialTerminal}" data-context-tokens="${initialContextTokens}" data-context-window="${initialContextWindow}" data-context-percent="${initialContextPercent}">
5731
+ <body data-initial-source="${initialSource}" data-initial-label="${initialLabel}" data-initial-path="${initialPath}" data-model-label="${initialModel}" data-terminal-label="${initialTerminal}" data-context-tokens="${initialContextTokens}" data-context-window="${initialContextWindow}" data-context-percent="${initialContextPercent}" data-studio-mode="${studioMode}">
5721
5732
  <header>
5722
- <h1><span class="app-logo" aria-hidden="true">π</span> Studio <span class="app-subtitle">Editor & Response Workspace</span></h1>
5733
+ <h1><span class="app-logo" aria-hidden="true">π</span> Studio <span class="app-subtitle">${appSubtitle}</span></h1>
5723
5734
  <div class="controls">
5724
- <button id="saveAsBtn" type="button" title="Save editor content to a new file path.">Save editor as…</button>
5725
- <button id="saveOverBtn" type="button" title="Overwrite current file with editor content." disabled>Save editor</button>
5735
+ <button id="saveAsBtn" type="button" title="Save editor content to a new file path. Cmd/Ctrl+S falls back here when no direct save path is available.">Save editor as…</button>
5736
+ <button id="saveOverBtn" type="button" title="Overwrite current file with editor content. Shortcut: Cmd/Ctrl+S.">Save editor</button>
5726
5737
  <label class="file-label" title="Load a local file into editor text.">Load file content<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>
5727
5738
  <button id="loadGitDiffBtn" type="button" title="Load the current git diff from the Studio context into the editor.">Load git diff</button>
5728
5739
  <button id="getEditorBtn" type="button" title="Load the current terminal editor draft into Studio.">Load from pi editor</button>
@@ -5873,7 +5884,7 @@ ${cssVarsBlock}
5873
5884
  <footer>
5874
5885
  <span id="statusLine"><span id="statusSpinner" aria-hidden="true"> </span><span id="status">Booting studio…</span></span>
5875
5886
  <span id="footerMeta" class="footer-meta"><span id="footerMetaText" class="footer-meta-text">Model: ${initialModel} · Terminal: ${initialTerminal} · Context: unknown</span><button id="compactBtn" class="footer-compact-btn" type="button" title="Trigger pi context compaction now.">Compact</button></span>
5876
- <span class="shortcut-hint">Focus pane: F10 (or Cmd/Ctrl+Esc) to toggle · Run / queue steering: Cmd/Ctrl+Enter · Stop request: Esc</span>
5887
+ <span class="shortcut-hint">Focus pane: F10 (or Cmd/Ctrl+Esc) to toggle · Save editor: Cmd/Ctrl+S · Run / queue steering: Cmd/Ctrl+Enter · Stop request: Esc</span>
5877
5888
  </footer>
5878
5889
 
5879
5890
  <div id="scratchpadOverlay" class="scratchpad-overlay" hidden>
@@ -5949,6 +5960,22 @@ export default function (pi: ExtensionAPI) {
5949
5960
 
5950
5961
  const isStudioDirectRunChainActive = () => Boolean(studioDirectRunChain);
5951
5962
  const getQueuedStudioSteeringCount = () => queuedStudioDirectRequests.length;
5963
+ const getStudioClientCounts = (): { full: number; editorOnly: number } => {
5964
+ if (!serverState) return { full: 0, editorOnly: 0 };
5965
+ let full = 0;
5966
+ let editorOnly = 0;
5967
+ for (const client of serverState.clients) {
5968
+ if (client.readyState !== WebSocket.OPEN) continue;
5969
+ const mode = serverState.clientModes.get(client) ?? "full";
5970
+ if (mode === "editor-only") {
5971
+ editorOnly += 1;
5972
+ } else {
5973
+ full += 1;
5974
+ }
5975
+ }
5976
+ return { full, editorOnly };
5977
+ };
5978
+ const hasConnectedFullStudioView = () => getStudioClientCounts().full > 0;
5952
5979
  const canQueueStudioSteeringRequest = () => {
5953
5980
  if (compactInProgress) return false;
5954
5981
  if (!agentBusy) return false;
@@ -6664,6 +6691,26 @@ export default function (pi: ExtensionAPI) {
6664
6691
  }
6665
6692
  }
6666
6693
  serverState.clients.clear();
6694
+ serverState.clientModes.clear();
6695
+ };
6696
+
6697
+ const closeStudioClientsByMode = (mode: StudioUiMode, code = 4001, reason = "Session invalidated"): number => {
6698
+ if (!serverState) return 0;
6699
+ let closed = 0;
6700
+ for (const client of Array.from(serverState.clients)) {
6701
+ if (client.readyState !== WebSocket.OPEN) continue;
6702
+ const clientMode = serverState.clientModes.get(client) ?? "full";
6703
+ if (clientMode !== mode) continue;
6704
+ serverState.clients.delete(client);
6705
+ serverState.clientModes.delete(client);
6706
+ closed += 1;
6707
+ try {
6708
+ client.close(code, reason);
6709
+ } catch {
6710
+ // Ignore close errors
6711
+ }
6712
+ }
6713
+ return closed;
6667
6714
  };
6668
6715
 
6669
6716
  const handleStudioMessage = (client: WebSocket, msg: IncomingStudioMessage) => {
@@ -7560,7 +7607,8 @@ export default function (pi: ExtensionAPI) {
7560
7607
  "Cross-Origin-Resource-Policy": "same-origin",
7561
7608
  });
7562
7609
  refreshContextUsage();
7563
- res.end(buildStudioHtml(initialStudioDocument, serverState.token, lastCommandCtx?.ui.theme, currentModelLabel, terminalSessionLabel, contextUsageSnapshot));
7610
+ const studioMode = normalizeStudioUiMode(requestUrl.searchParams.get("mode"));
7611
+ res.end(buildStudioHtml(initialStudioDocument, serverState.token, lastCommandCtx?.ui.theme, currentModelLabel, terminalSessionLabel, contextUsageSnapshot, studioMode));
7564
7612
  };
7565
7613
 
7566
7614
  const ensureServer = async (): Promise<StudioServerState> => {
@@ -7569,11 +7617,13 @@ export default function (pi: ExtensionAPI) {
7569
7617
  const server = createServer(handleHttpRequest);
7570
7618
  const wsServer = new WebSocketServer({ noServer: true });
7571
7619
  const clients = new Set<WebSocket>();
7620
+ const clientModes = new Map<WebSocket, StudioUiMode>();
7572
7621
 
7573
7622
  const state: StudioServerState = {
7574
7623
  server,
7575
7624
  wsServer,
7576
7625
  clients,
7626
+ clientModes,
7577
7627
  port: 0,
7578
7628
  token: createSessionToken(),
7579
7629
  };
@@ -7606,9 +7656,26 @@ export default function (pi: ExtensionAPI) {
7606
7656
  });
7607
7657
  });
7608
7658
 
7609
- wsServer.on("connection", (ws) => {
7659
+ wsServer.on("connection", (ws, req) => {
7660
+ const host = req.headers.host ?? `127.0.0.1:${state.port}`;
7661
+ const requestUrl = new URL(req.url ?? "/ws", `http://${host}`);
7662
+ const clientMode = normalizeStudioUiMode(requestUrl.searchParams.get("mode"));
7663
+ if (clientMode === "full") {
7664
+ for (const client of clients) {
7665
+ if (client.readyState !== WebSocket.OPEN) continue;
7666
+ const existingMode = clientModes.get(client) ?? "full";
7667
+ if (existingMode !== "full") continue;
7668
+ try {
7669
+ ws.close(4004, "Full Studio already active");
7670
+ } catch {
7671
+ // Ignore close errors
7672
+ }
7673
+ return;
7674
+ }
7675
+ }
7610
7676
  clients.add(ws);
7611
- emitDebugEvent("studio_ws_connected", { clients: clients.size });
7677
+ clientModes.set(ws, clientMode);
7678
+ emitDebugEvent("studio_ws_connected", { clients: clients.size, mode: clientMode });
7612
7679
  broadcastState();
7613
7680
 
7614
7681
  ws.on("message", (data) => {
@@ -7622,11 +7689,13 @@ export default function (pi: ExtensionAPI) {
7622
7689
 
7623
7690
  ws.on("close", () => {
7624
7691
  clients.delete(ws);
7692
+ clientModes.delete(ws);
7625
7693
  emitDebugEvent("studio_ws_disconnected", { clients: clients.size });
7626
7694
  });
7627
7695
 
7628
7696
  ws.on("error", () => {
7629
7697
  clients.delete(ws);
7698
+ clientModes.delete(ws);
7630
7699
  });
7631
7700
  });
7632
7701
 
@@ -7710,14 +7779,6 @@ export default function (pi: ExtensionAPI) {
7710
7779
  });
7711
7780
  };
7712
7781
 
7713
- const rotateToken = () => {
7714
- if (!serverState) return;
7715
- serverState.token = createSessionToken();
7716
- clearPreparedPdfExports();
7717
- closeAllClients(4001, "Session invalidated");
7718
- broadcastState();
7719
- };
7720
-
7721
7782
  const hydrateLatestAssistant = (entries: SessionEntry[]) => {
7722
7783
  syncStudioResponseHistory(entries);
7723
7784
  };
@@ -8008,6 +8069,147 @@ export default function (pi: ExtensionAPI) {
8008
8069
  await stopServer();
8009
8070
  });
8010
8071
 
8072
+ const resolveStudioLaunchDocument = (
8073
+ trimmed: string,
8074
+ ctx: ExtensionCommandContext,
8075
+ options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string },
8076
+ ): InitialStudioDocument | null => {
8077
+ const defaultSource = options?.defaultSource === "blank" ? "blank" : "last-response";
8078
+ const commandLabel = options?.commandLabel ?? "/studio";
8079
+ const latestAssistant =
8080
+ extractLatestAssistantFromEntries(ctx.sessionManager.getBranch())
8081
+ ?? extractLatestAssistantFromEntries(ctx.sessionManager.getEntries())
8082
+ ?? lastStudioResponse?.markdown
8083
+ ?? null;
8084
+
8085
+ if (!trimmed) {
8086
+ if (defaultSource === "last-response" && latestAssistant) {
8087
+ return {
8088
+ text: latestAssistant,
8089
+ label: "last model response",
8090
+ source: "last-response",
8091
+ };
8092
+ }
8093
+ return {
8094
+ text: "",
8095
+ label: "blank",
8096
+ source: "blank",
8097
+ };
8098
+ }
8099
+
8100
+ if (trimmed === "--blank" || trimmed === "blank") {
8101
+ return {
8102
+ text: "",
8103
+ label: "blank",
8104
+ source: "blank",
8105
+ };
8106
+ }
8107
+
8108
+ if (trimmed === "--last" || trimmed === "last") {
8109
+ if (!latestAssistant) {
8110
+ ctx.ui.notify("No assistant response found; opening blank studio.", "warning");
8111
+ return {
8112
+ text: "",
8113
+ label: "blank",
8114
+ source: "blank",
8115
+ };
8116
+ }
8117
+ return {
8118
+ text: latestAssistant,
8119
+ label: "last model response",
8120
+ source: "last-response",
8121
+ };
8122
+ }
8123
+
8124
+ if (trimmed.startsWith("-")) {
8125
+ ctx.ui.notify(`Unknown flag: ${trimmed}. Use ${commandLabel} --help`, "error");
8126
+ return null;
8127
+ }
8128
+
8129
+ const pathArg = parsePathArgument(trimmed);
8130
+ if (!pathArg) {
8131
+ ctx.ui.notify("Invalid file path argument.", "error");
8132
+ return null;
8133
+ }
8134
+
8135
+ const file = readStudioFile(pathArg, ctx.cwd);
8136
+ if (file.ok === false) {
8137
+ ctx.ui.notify(file.message, "error");
8138
+ return null;
8139
+ }
8140
+
8141
+ if (file.text.length > 200_000) {
8142
+ ctx.ui.notify(
8143
+ "Loaded a large file. Studio critique requests currently reject documents over 200k characters.",
8144
+ "warning",
8145
+ );
8146
+ }
8147
+
8148
+ return {
8149
+ text: file.text,
8150
+ label: file.label,
8151
+ source: "file",
8152
+ path: file.resolvedPath,
8153
+ };
8154
+ };
8155
+
8156
+ const openStudioView = async (
8157
+ trimmed: string,
8158
+ ctx: ExtensionCommandContext,
8159
+ mode: StudioUiMode,
8160
+ options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string; replaceExistingFull?: boolean },
8161
+ ) => {
8162
+ if (mode === "full" && hasConnectedFullStudioView()) {
8163
+ if (options?.replaceExistingFull) {
8164
+ closeStudioClientsByMode("full", 4001, "Full Studio replaced");
8165
+ } else {
8166
+ ctx.ui.notify("A full pi Studio view is already open for this session. Close it first, use /studio-replace for a fresh full Studio view, or use /studio-editor-only for a concurrent editor-only Studio view.", "warning");
8167
+ if (serverState) {
8168
+ ctx.ui.notify(`Studio URL: ${buildStudioUrl(serverState.port, serverState.token, "full")}`, "info");
8169
+ }
8170
+ return;
8171
+ }
8172
+ }
8173
+
8174
+ await ctx.waitForIdle();
8175
+ lastCommandCtx = ctx;
8176
+ refreshRuntimeMetadata({ cwd: ctx.cwd, model: ctx.model });
8177
+ refreshContextUsage(ctx);
8178
+ syncStudioResponseHistory(ctx.sessionManager.getBranch());
8179
+ broadcastState();
8180
+ broadcastResponseHistory();
8181
+ try {
8182
+ const currentStyle = getStudioThemeStyle(ctx.ui.theme);
8183
+ lastThemeVarsJson = JSON.stringify(buildThemeCssVars(currentStyle));
8184
+ } catch {
8185
+ // ignore theme read errors
8186
+ }
8187
+
8188
+ const selected = resolveStudioLaunchDocument(trimmed, ctx, options);
8189
+ if (!selected) return;
8190
+ initialStudioDocument = selected;
8191
+
8192
+ const state = await ensureServer();
8193
+ const url = buildStudioUrl(state.port, state.token, mode);
8194
+ const openedLabel = mode === "editor-only" ? "pi Studio editor-only view" : "pi Studio";
8195
+
8196
+ try {
8197
+ await openUrlInDefaultBrowser(url);
8198
+ if (selected.source === "file") {
8199
+ ctx.ui.notify(`Opened ${openedLabel} with file loaded: ${selected.label}`, "info");
8200
+ } else if (selected.source === "last-response") {
8201
+ ctx.ui.notify(`Opened ${openedLabel} with last model response (${selected.text.length} chars).`, "info");
8202
+ } else {
8203
+ ctx.ui.notify(`Opened ${openedLabel} with blank editor.`, "info");
8204
+ }
8205
+ ctx.ui.notify(`Studio URL: ${url}`, "info");
8206
+ } catch (error) {
8207
+ ctx.ui.notify(`Failed to open browser: ${error instanceof Error ? error.message : String(error)}`, "error");
8208
+ } finally {
8209
+ void maybeNotifyUpdateAvailable(ctx);
8210
+ }
8211
+ };
8212
+
8011
8213
  pi.registerCommand("studio", {
8012
8214
  description: "Open pi Studio browser UI (/studio, /studio <file>, /studio --blank, /studio --last)",
8013
8215
  handler: async (args: string, ctx: ExtensionCommandContext) => {
@@ -8024,8 +8226,9 @@ export default function (pi: ExtensionAPI) {
8024
8226
  ctx.ui.notify("Studio server is not running.", "info");
8025
8227
  return;
8026
8228
  }
8229
+ const counts = getStudioClientCounts();
8027
8230
  ctx.ui.notify(
8028
- `Studio running at http://127.0.0.1:${serverState.port}/ (busy: ${isStudioBusy() ? "yes" : "no"})`,
8231
+ `Studio running at http://127.0.0.1:${serverState.port}/ (busy: ${isStudioBusy() ? "yes" : "no"}; full views: ${counts.full}; editor-only views: ${counts.editorOnly})`,
8029
8232
  "info",
8030
8233
  );
8031
8234
  return;
@@ -8040,6 +8243,9 @@ export default function (pi: ExtensionAPI) {
8040
8243
  + " /studio --last Open with last model response\n"
8041
8244
  + " /studio --status Show studio status\n"
8042
8245
  + " /studio --stop Stop studio server\n"
8246
+ + " Note: only one full /studio view is allowed per Pi session.\n"
8247
+ + " /studio-replace [path] Replace the current full Studio view with a new one\n"
8248
+ + " /studio-editor-only [path] Open another Studio tab in editor-only mode\n"
8043
8249
  + " /studio-current <path> Load a file into currently open Studio tab(s)\n"
8044
8250
  + " /studio-pdf <path> Export a file to <name>.studio.pdf via Studio PDF",
8045
8251
  "info",
@@ -8047,115 +8253,53 @@ export default function (pi: ExtensionAPI) {
8047
8253
  return;
8048
8254
  }
8049
8255
 
8050
- await ctx.waitForIdle();
8051
- lastCommandCtx = ctx;
8052
- refreshRuntimeMetadata({ cwd: ctx.cwd, model: ctx.model });
8053
- refreshContextUsage(ctx);
8054
- syncStudioResponseHistory(ctx.sessionManager.getBranch());
8055
- broadcastState();
8056
- broadcastResponseHistory();
8057
- // Seed theme vars so first ping doesn't trigger a false update
8058
- try {
8059
- const currentStyle = getStudioThemeStyle(ctx.ui.theme);
8060
- lastThemeVarsJson = JSON.stringify(buildThemeCssVars(currentStyle));
8061
- } catch { /* ignore */ }
8256
+ await openStudioView(trimmed, ctx, "full", { defaultSource: "last-response", commandLabel: "/studio" });
8257
+ },
8258
+ });
8062
8259
 
8063
- const latestAssistant =
8064
- extractLatestAssistantFromEntries(ctx.sessionManager.getBranch())
8065
- ?? extractLatestAssistantFromEntries(ctx.sessionManager.getEntries())
8066
- ?? lastStudioResponse?.markdown
8067
- ?? null;
8068
- let selected: InitialStudioDocument | null = null;
8069
-
8070
- if (!trimmed) {
8071
- if (latestAssistant) {
8072
- selected = {
8073
- text: latestAssistant,
8074
- label: "last model response",
8075
- source: "last-response",
8076
- };
8077
- } else {
8078
- selected = {
8079
- text: "",
8080
- label: "blank",
8081
- source: "blank",
8082
- };
8083
- }
8084
- } else if (trimmed === "--blank" || trimmed === "blank") {
8085
- selected = {
8086
- text: "",
8087
- label: "blank",
8088
- source: "blank",
8089
- };
8090
- } else if (trimmed === "--last" || trimmed === "last") {
8091
- if (!latestAssistant) {
8092
- ctx.ui.notify("No assistant response found; opening blank studio.", "warning");
8093
- selected = {
8094
- text: "",
8095
- label: "blank",
8096
- source: "blank",
8097
- };
8098
- } else {
8099
- selected = {
8100
- text: latestAssistant,
8101
- label: "last model response",
8102
- source: "last-response",
8103
- };
8104
- }
8105
- } else if (trimmed.startsWith("-")) {
8106
- ctx.ui.notify(`Unknown flag: ${trimmed}. Use /studio --help`, "error");
8260
+ pi.registerCommand("studio-replace", {
8261
+ description: "Replace the current full pi Studio view (/studio-replace, /studio-replace <file>)",
8262
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
8263
+ const trimmed = args.trim();
8264
+ if (trimmed === "help" || trimmed === "--help" || trimmed === "-h") {
8265
+ ctx.ui.notify(
8266
+ "Usage: /studio-replace [path|--blank|--last]\n"
8267
+ + " /studio-replace Replace the current full Studio view (default: last response, fallback: blank)\n"
8268
+ + " /studio-replace <path> Replace the current full Studio view with file preloaded\n"
8269
+ + " /studio-replace --blank Replace with blank editor\n"
8270
+ + " /studio-replace --last Replace with last model response\n"
8271
+ + "Editor-only Studio views stay open.",
8272
+ "info",
8273
+ );
8107
8274
  return;
8108
- } else {
8109
- const pathArg = parsePathArgument(trimmed);
8110
- if (!pathArg) {
8111
- ctx.ui.notify("Invalid file path argument.", "error");
8112
- return;
8113
- }
8114
-
8115
- const file = readStudioFile(pathArg, ctx.cwd);
8116
- if (file.ok === false) {
8117
- ctx.ui.notify(file.message, "error");
8118
- return;
8119
- }
8120
-
8121
- selected = {
8122
- text: file.text,
8123
- label: file.label,
8124
- source: "file",
8125
- path: file.resolvedPath,
8126
- };
8127
- if (file.text.length > 200_000) {
8128
- ctx.ui.notify(
8129
- "Loaded a large file. Studio critique requests currently reject documents over 200k characters.",
8130
- "warning",
8131
- );
8132
- }
8133
8275
  }
8134
8276
 
8135
- initialStudioDocument = selected;
8136
-
8137
- const state = await ensureServer();
8138
- rotateToken();
8139
- const url = buildStudioUrl(state.port, state.token);
8277
+ await openStudioView(trimmed, ctx, "full", {
8278
+ defaultSource: "last-response",
8279
+ commandLabel: "/studio-replace",
8280
+ replaceExistingFull: true,
8281
+ });
8282
+ },
8283
+ });
8140
8284
 
8141
- try {
8142
- await openUrlInDefaultBrowser(url);
8143
- if (initialStudioDocument?.source === "file") {
8144
- ctx.ui.notify(`Opened pi Studio with file loaded: ${initialStudioDocument.label}`, "info");
8145
- } else if (initialStudioDocument?.source === "last-response") {
8146
- ctx.ui.notify(
8147
- `Opened pi Studio with last model response (${initialStudioDocument.text.length} chars).`,
8148
- "info",
8149
- );
8150
- } else {
8151
- ctx.ui.notify("Opened pi Studio with blank editor.", "info");
8152
- }
8153
- ctx.ui.notify(`Studio URL: ${url}`, "info");
8154
- } catch (error) {
8155
- ctx.ui.notify(`Failed to open browser: ${error instanceof Error ? error.message : String(error)}`, "error");
8156
- } finally {
8157
- void maybeNotifyUpdateAvailable(ctx);
8285
+ pi.registerCommand("studio-editor-only", {
8286
+ description: "Open pi Studio in editor-only mode (/studio-editor-only, /studio-editor-only <file>)",
8287
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
8288
+ const trimmed = args.trim();
8289
+ if (trimmed === "help" || trimmed === "--help" || trimmed === "-h") {
8290
+ ctx.ui.notify(
8291
+ "Usage: /studio-editor-only [path|--blank|--last]\n"
8292
+ + " /studio-editor-only Open an editor-only Studio view (default: blank editor)\n"
8293
+ + " /studio-editor-only <path> Open an editor-only Studio view with file preloaded\n"
8294
+ + " /studio-editor-only --blank Open with blank editor\n"
8295
+ + " /studio-editor-only --last Open with last model response loaded into the editor\n"
8296
+ + "Multiple editor-only views are allowed in the same Pi session.",
8297
+ "info",
8298
+ );
8299
+ return;
8158
8300
  }
8301
+
8302
+ await openStudioView(trimmed, ctx, "editor-only", { defaultSource: "blank", commandLabel: "/studio-editor-only" });
8159
8303
  },
8160
8304
  });
8161
8305
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.5.37",
3
+ "version": "0.5.39",
4
4
  "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, prompt/response history, and live Markdown/LaTeX/code preview",
5
5
  "type": "module",
6
6
  "license": "MIT",