pi-studio 0.5.37 → 0.5.38
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 +13 -0
- package/README.md +5 -1
- package/client/studio-client.js +60 -13
- package/client/studio.css +38 -0
- package/index.ts +265 -121
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,19 @@ All notable changes to `pi-studio` are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.5.38] — 2026-03-29
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- 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.
|
|
11
|
+
- Studio now supports `/studio-replace` for explicitly replacing the current full Studio view while leaving editor-only views open.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- 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.
|
|
15
|
+
- README/help text and Studio status/warning messages now describe the updated full-vs-editor-only session model more clearly.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- 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.
|
|
19
|
+
|
|
7
20
|
## [0.5.37] — 2026-03-29
|
|
8
21
|
|
|
9
22
|
### 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
|
|
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):
|
package/client/studio-client.js
CHANGED
|
@@ -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 =
|
|
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 = "";
|
|
@@ -928,6 +933,7 @@
|
|
|
928
933
|
&& !event.altKey
|
|
929
934
|
&& !event.shiftKey
|
|
930
935
|
&& activePane === "left"
|
|
936
|
+
&& !isEditorOnlyMode
|
|
931
937
|
) {
|
|
932
938
|
if (queueSteerBtn && !queueSteerBtn.disabled) {
|
|
933
939
|
event.preventDefault();
|
|
@@ -2532,7 +2538,7 @@
|
|
|
2532
2538
|
fileInput.disabled = uiBusy;
|
|
2533
2539
|
saveAsBtn.disabled = uiBusy;
|
|
2534
2540
|
saveOverBtn.disabled = uiBusy || !canSaveOver;
|
|
2535
|
-
sendEditorBtn.disabled = uiBusy;
|
|
2541
|
+
sendEditorBtn.disabled = uiBusy || isEditorOnlyMode;
|
|
2536
2542
|
if (getEditorBtn) getEditorBtn.disabled = uiBusy;
|
|
2537
2543
|
if (loadGitDiffBtn) loadGitDiffBtn.disabled = uiBusy;
|
|
2538
2544
|
syncRunAndCritiqueButtons();
|
|
@@ -2542,13 +2548,13 @@
|
|
|
2542
2548
|
if (annotationModeSelect) annotationModeSelect.disabled = uiBusy;
|
|
2543
2549
|
if (saveAnnotatedBtn) saveAnnotatedBtn.disabled = uiBusy;
|
|
2544
2550
|
if (stripAnnotationsBtn) stripAnnotationsBtn.disabled = uiBusy || !hasAnnotationMarkers(sourceTextEl.value);
|
|
2545
|
-
if (compactBtn) compactBtn.disabled = uiBusy || compactInProgress || wsState === "Disconnected";
|
|
2546
|
-
editorViewSelect.disabled =
|
|
2547
|
-
rightViewSelect.disabled =
|
|
2548
|
-
followSelect.disabled = uiBusy;
|
|
2549
|
-
if (responseHighlightSelect) responseHighlightSelect.disabled = rightView !== "markdown";
|
|
2550
|
-
insertHeaderBtn.disabled = uiBusy;
|
|
2551
|
-
lensSelect.disabled = uiBusy;
|
|
2551
|
+
if (compactBtn) compactBtn.disabled = isEditorOnlyMode || uiBusy || compactInProgress || wsState === "Disconnected";
|
|
2552
|
+
editorViewSelect.disabled = isEditorOnlyMode;
|
|
2553
|
+
rightViewSelect.disabled = isEditorOnlyMode;
|
|
2554
|
+
followSelect.disabled = isEditorOnlyMode || uiBusy;
|
|
2555
|
+
if (responseHighlightSelect) responseHighlightSelect.disabled = isEditorOnlyMode || rightView !== "markdown";
|
|
2556
|
+
insertHeaderBtn.disabled = uiBusy || isEditorOnlyMode;
|
|
2557
|
+
lensSelect.disabled = uiBusy || isEditorOnlyMode;
|
|
2552
2558
|
updateSaveFileTooltip();
|
|
2553
2559
|
updateHistoryControls();
|
|
2554
2560
|
updateResultActionButtons();
|
|
@@ -3472,6 +3478,28 @@
|
|
|
3472
3478
|
const critiqueIsStop = activeKind === "critique";
|
|
3473
3479
|
const canQueueSteering = studioRunChainActive && !critiqueIsStop;
|
|
3474
3480
|
|
|
3481
|
+
if (isEditorOnlyMode) {
|
|
3482
|
+
if (sendRunBtn) {
|
|
3483
|
+
sendRunBtn.textContent = "Run editor text";
|
|
3484
|
+
sendRunBtn.classList.remove("request-stop-active");
|
|
3485
|
+
sendRunBtn.disabled = true;
|
|
3486
|
+
sendRunBtn.title = "Run is unavailable in editor-only mode.";
|
|
3487
|
+
}
|
|
3488
|
+
if (queueSteerBtn) {
|
|
3489
|
+
queueSteerBtn.hidden = false;
|
|
3490
|
+
queueSteerBtn.disabled = true;
|
|
3491
|
+
queueSteerBtn.classList.remove("request-stop-active");
|
|
3492
|
+
queueSteerBtn.title = "Queue steering is unavailable in editor-only mode.";
|
|
3493
|
+
}
|
|
3494
|
+
if (critiqueBtn) {
|
|
3495
|
+
critiqueBtn.textContent = "Critique editor text";
|
|
3496
|
+
critiqueBtn.classList.remove("request-stop-active");
|
|
3497
|
+
critiqueBtn.disabled = true;
|
|
3498
|
+
critiqueBtn.title = "Critique is unavailable in editor-only mode.";
|
|
3499
|
+
}
|
|
3500
|
+
return;
|
|
3501
|
+
}
|
|
3502
|
+
|
|
3475
3503
|
if (sendRunBtn) {
|
|
3476
3504
|
sendRunBtn.textContent = directIsStop ? "Stop" : "Run editor text";
|
|
3477
3505
|
sendRunBtn.classList.toggle("request-stop-active", directIsStop);
|
|
@@ -4174,7 +4202,14 @@
|
|
|
4174
4202
|
}
|
|
4175
4203
|
|
|
4176
4204
|
const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
|
|
4177
|
-
const
|
|
4205
|
+
const wsParams = new URLSearchParams({ token: token });
|
|
4206
|
+
if (studioMode !== "full") {
|
|
4207
|
+
wsParams.set("mode", studioMode);
|
|
4208
|
+
}
|
|
4209
|
+
if (DEBUG_ENABLED) {
|
|
4210
|
+
wsParams.set("debug", "1");
|
|
4211
|
+
}
|
|
4212
|
+
const wsUrl = wsProtocol + "://" + window.location.host + "/ws?" + wsParams.toString();
|
|
4178
4213
|
const wasReconnect = reconnectAttempt > 0;
|
|
4179
4214
|
let disconnectHandled = false;
|
|
4180
4215
|
|
|
@@ -4203,7 +4238,15 @@
|
|
|
4203
4238
|
clearScheduledReconnect();
|
|
4204
4239
|
reconnectAttempt = 0;
|
|
4205
4240
|
setWsState("Disconnected");
|
|
4206
|
-
setStatus("This tab was
|
|
4241
|
+
setStatus("This full Studio tab was replaced by a newer Studio session.", "warning");
|
|
4242
|
+
return;
|
|
4243
|
+
}
|
|
4244
|
+
|
|
4245
|
+
if (kind === "full_conflict") {
|
|
4246
|
+
clearScheduledReconnect();
|
|
4247
|
+
reconnectAttempt = 0;
|
|
4248
|
+
setWsState("Disconnected");
|
|
4249
|
+
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
4250
|
return;
|
|
4208
4251
|
}
|
|
4209
4252
|
|
|
@@ -4244,6 +4287,10 @@
|
|
|
4244
4287
|
handleDisconnect("invalidated", 4001);
|
|
4245
4288
|
return;
|
|
4246
4289
|
}
|
|
4290
|
+
if (event && event.code === 4004) {
|
|
4291
|
+
handleDisconnect("full_conflict", 4004);
|
|
4292
|
+
return;
|
|
4293
|
+
}
|
|
4247
4294
|
if (event && event.code === 1001) {
|
|
4248
4295
|
handleDisconnect("shutdown", 1001);
|
|
4249
4296
|
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
|
|
5515
|
-
|
|
5516
|
-
|
|
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
|
|
5722
|
+
<title>${appTitle}</title>
|
|
5712
5723
|
<link rel="icon" href="${faviconHref}" type="image/svg+xml" />
|
|
5713
5724
|
<style>
|
|
5714
5725
|
:root {
|
|
@@ -5717,9 +5728,9 @@ ${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"
|
|
5733
|
+
<h1><span class="app-logo" aria-hidden="true">π</span> Studio <span class="app-subtitle">${appSubtitle}</span></h1>
|
|
5723
5734
|
<div class="controls">
|
|
5724
5735
|
<button id="saveAsBtn" type="button" title="Save editor content to a new file path.">Save editor as…</button>
|
|
5725
5736
|
<button id="saveOverBtn" type="button" title="Overwrite current file with editor content." disabled>Save editor</button>
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
8051
|
-
|
|
8052
|
-
|
|
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
|
-
|
|
8064
|
-
|
|
8065
|
-
|
|
8066
|
-
|
|
8067
|
-
|
|
8068
|
-
|
|
8069
|
-
|
|
8070
|
-
|
|
8071
|
-
|
|
8072
|
-
|
|
8073
|
-
|
|
8074
|
-
|
|
8075
|
-
|
|
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
|
-
|
|
8136
|
-
|
|
8137
|
-
|
|
8138
|
-
|
|
8139
|
-
|
|
8277
|
+
await openStudioView(trimmed, ctx, "full", {
|
|
8278
|
+
defaultSource: "last-response",
|
|
8279
|
+
commandLabel: "/studio-replace",
|
|
8280
|
+
replaceExistingFull: true,
|
|
8281
|
+
});
|
|
8282
|
+
},
|
|
8283
|
+
});
|
|
8140
8284
|
|
|
8141
|
-
|
|
8142
|
-
|
|
8143
|
-
|
|
8144
|
-
|
|
8145
|
-
|
|
8146
|
-
|
|
8147
|
-
|
|
8148
|
-
"
|
|
8149
|
-
|
|
8150
|
-
|
|
8151
|
-
|
|
8152
|
-
|
|
8153
|
-
|
|
8154
|
-
|
|
8155
|
-
|
|
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.
|
|
3
|
+
"version": "0.5.38",
|
|
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",
|