pi-studio 0.9.58 → 0.9.60-rc.0
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 +17 -0
- package/README.md +23 -3
- package/ROADMAP.md +21 -0
- package/client/studio-client.js +171 -12
- package/client/studio.css +1 -0
- package/index.ts +165 -41
- package/package.json +1 -1
- package/shared/studio-network-binding.js +103 -0
- package/shared/studio-pi-editor-draft.js +49 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@ All notable changes to `pi-studio` are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.9.60-rc.0] — 2026-09-03
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Add explicit `/studio --listen-all` support for container and trusted private-network workflows. Studio binds to `0.0.0.0` only when requested, retains token authentication, keeps generated browser URLs on `127.0.0.1` for same-port host publishing, reports the active listening address in `/studio --status`, and requires a stop/restart before changing a running localhost server to wildcard binding.
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
- Wildcard network binding remains opt-in and emits a prominent warning that the tokenized URL grants control of Studio for the Pi process, including prompt submission and broad file access through Studio workflows. HTTP routing no longer derives its base authority from the inbound `Host` header. In wildcard mode, a browser-supplied HTTP(S) `Origin` must match that request's `Host`; originless non-browser or embedded clients continue to rely on the URL token. Documentation recommends loopback-only host port publishing, forbids direct public-internet exposure, and retains localhost plus SSH forwarding as the safe default.
|
|
14
|
+
|
|
15
|
+
## [0.9.59] — 2026-09-03
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- Add `Cmd/Ctrl+Shift+L` for loading the current Pi terminal input draft into Studio without clearing it.
|
|
19
|
+
- Add a confirmed **Clear Pi editor text…** action for removing text waiting in Pi's terminal input editor without changing Studio text or conversation history.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- Treat Pi terminal drafts loaded into Studio or staged from Studio as transient linked handoffs. An accepted **Run editor text** now clears the terminal draft only when its SHA-256 fingerprint and byte length still match; later terminal typing, added `pi-nvim-context` context, another client change, and failed submissions all preserve it.
|
|
23
|
+
|
|
7
24
|
## [0.9.58] — 2026-09-03
|
|
8
25
|
|
|
9
26
|
### Added
|
package/README.md
CHANGED
|
@@ -35,6 +35,7 @@ _The video shows an earlier version of the Studio interface. The basic workflow
|
|
|
35
35
|
- Supports one canonical full Studio view per Pi session, plus additional editor-only companion views when you want extra editing/preview surfaces; editor-only views can also browse files and use the Studio REPL send controls without taking over the full Studio session view
|
|
36
36
|
- Includes a global **Zen** mode that hides the Studio header and secondary chrome without changing the current pane layout; the header can also be hidden independently and restored from the top-right edge
|
|
37
37
|
- Runs editor text directly, asks for structured critique (auto/writing/code focus), offers explicit **Show me** actions for a one-turn compact visual or structural explanation of the editor selection/document, displayed response, or current conversation topic, provides a manual **Suggest completion** action for short cursor-aware continuations (`Option/Alt+Tab` where available or `Cmd/Ctrl+Shift+Space` from the editor, `Tab` to insert a visible suggestion) with an optional editor-plus-latest-response context mode, or opens **Quiz me** for a Studio-native active-recall loop over the current editor text, selection, current file, folder, or repo, with optional focus guidance for shaping question selection
|
|
38
|
+
- Treats text copied from or staged in Pi's terminal input editor as a linked draft: ordinary Studio edits retain the link, and an accepted **Run editor text** clears the terminal draft only when it has not changed in the meantime; a confirmed **Clear Pi editor text…** action remains available explicitly
|
|
38
39
|
- Adds a right-pane **Side questions** thread for contextual questions that stay outside the main Pi conversation unless explicitly promoted. It can start with the editor selection, the Markdown/LaTeX heading block at the cursor, nearby unstructured text, the whole editor, the displayed response, or no starting text; independently, it can use read-only tools to map, search, and read the document folder, repository, or another chosen folder—including extracted PDF, DOCX, ODT, and EPUB text—and can optionally search the web through Brave Search.
|
|
39
40
|
- Includes a live **Working** view for following current model/tool activity, with `All` / `Thinking` / `Tools` filters, image previews for image-producing tool outputs, plus **Load visible into editor** and **Copy visible** actions; optional browser-stored **Follow activity** (off by default under **View → Workspace**) switches to Working when the main Pi agent starts generating and to Response Preview when it completes, unless you manually choose another view during that request. When cycling response history, Working follows saved working details for the selected response when available, and `Cmd/Ctrl+Alt+1–8` switches directly between right-pane views while `Cmd/Ctrl+Alt+P` / `Cmd/Ctrl+Alt+E` / `Cmd/Ctrl+Alt+W` / `Cmd/Ctrl+Alt+F` / `Cmd/Ctrl+Alt+R` / `Cmd/Ctrl+Alt+Q` keep quick mnemonic shortcuts for Response Preview, Editor Preview, Working, Files, REPL, and Side questions
|
|
40
41
|
- Includes a right-pane **Changes** view for browsing the current git diff by file, previewing per-file diffs, opening changed files, loading the full diff into the editor, and copying the diff
|
|
@@ -66,6 +67,12 @@ _The video shows an earlier version of the Studio interface. The basic workflow
|
|
|
66
67
|
- Exports local files headlessly via `/studio-pdf <path>` to `<name>.studio.pdf` or `/studio-html <path>` to `<name>.studio.html`; without a path, those commands export the last model response to a timestamped file. Agent tools `studio_export_pdf` and `studio_export_html` expose the same export pipeline for remote/Telegram-style sessions.
|
|
67
68
|
- Shows model/session/context usage in the footer, plus compact-context and active-theme controls
|
|
68
69
|
|
|
70
|
+
## Pi input-draft handoff
|
|
71
|
+
|
|
72
|
+
Tools such as [`pi-nvim-context`](https://github.com/omaclaren/pi-nvim-context) deliberately accumulate visible context in Pi's terminal input draft without submitting it. **Load from pi editor** (`Cmd/Ctrl+Shift+L`) copies that draft into Studio without clearing it and records only a transient SHA-256 fingerprint and byte length for the handoff—no additional snapshot copy beyond the text already in Studio. You can then edit the text freely in Studio.
|
|
73
|
+
|
|
74
|
+
When Pi accepts **Run editor text**, Studio clears the linked terminal draft only if its current fingerprint and byte length still match those captured at load time. Terminal typing, additional Neovim context, or another client changing the draft causes Studio to preserve it silently. A failed or rejected run also leaves it intact. **Send current text to Pi editor** establishes the same safe link, while **Clear Pi editor text…** provides a separate confirmed, unconditional cleanup action. Studio never clears terminal history or its own editor through this handoff.
|
|
75
|
+
|
|
69
76
|
## Commands
|
|
70
77
|
|
|
71
78
|
| Command | Description |
|
|
@@ -76,8 +83,9 @@ _The video shows an earlier version of the Studio interface. The basic workflow
|
|
|
76
83
|
| `/studio --last` | Force last response |
|
|
77
84
|
| `/studio --blank` | Force blank editor |
|
|
78
85
|
| `/studio --no-browser` | Start/print the Studio URL without opening a browser, useful for forwarded or phone/browser sessions |
|
|
79
|
-
| `/studio --port <port>` | Bind Studio to a fixed
|
|
80
|
-
| `/studio --
|
|
86
|
+
| `/studio --port <port>` | Bind Studio to a fixed port instead of a random free port; the default host remains localhost |
|
|
87
|
+
| `/studio --listen-all` | Explicitly bind Studio to all IPv4 interfaces (`0.0.0.0`) for a container or trusted private-network workflow |
|
|
88
|
+
| `/studio --status` | Show studio server status, including its listening address |
|
|
81
89
|
| `/studio --stop` | Stop studio server |
|
|
82
90
|
| `/studio --help` | Show help |
|
|
83
91
|
| `/studio-replace [path\|--blank\|--last]` | Replace the current full Studio view with a new full Studio view |
|
|
@@ -111,6 +119,18 @@ Run once without installing:
|
|
|
111
119
|
pi -e https://github.com/omaclaren/pi-studio
|
|
112
120
|
```
|
|
113
121
|
|
|
122
|
+
## Container and network access
|
|
123
|
+
|
|
124
|
+
Studio binds to `127.0.0.1` by default. For a container or sandbox whose port must be published to the host, opt in explicitly and choose a stable port:
|
|
125
|
+
|
|
126
|
+
```text
|
|
127
|
+
/studio --no-browser --listen-all --port 4321
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
This listens on `0.0.0.0:4321` while still printing a tokenized `http://127.0.0.1:4321/...` browser URL. With a same-port mapping such as `sbx run --publish 4321:4321`, open that URL unchanged on the host. `--listen-all` is a server-lifetime setting: if Studio is already running on localhost, use `/studio --stop` before restarting it with the flag.
|
|
131
|
+
|
|
132
|
+
Treat wildcard binding as a security-sensitive mode. The URL token remains required, but anyone who obtains the full URL can control Studio for that Pi process: they can submit prompts, read or write files through Studio, and expand local-resource access. Studio does not add TLS. Treat the URL like a password, never expose the port directly to the public internet, and bind the host side of a container mapping to loopback when possible—for example, `-p 127.0.0.1:4321:4321`. For access across an untrusted network, keep Studio on its localhost default and use SSH local port forwarding instead.
|
|
133
|
+
|
|
114
134
|
## Shared REPL record
|
|
115
135
|
|
|
116
136
|
Studio remains a standalone REPL client: it can create and use its own tmux sessions without `pi-repl`. When a compatible `pi-repl` installation uses the same tmux session, both clients automatically discover one session-owned clean record and see each other's code and captured output. No package imports or depends on the other.
|
|
@@ -213,7 +233,7 @@ Thanks to [Hal Gumbert / CampSoftware](https://github.com/campsoftware) for publ
|
|
|
213
233
|
|
|
214
234
|
## Notes
|
|
215
235
|
|
|
216
|
-
-
|
|
236
|
+
- The Studio server is localhost-only (`127.0.0.1`) by default and every Studio URL carries a server-lifetime token. The explicit `--listen-all` option changes the listening address to `0.0.0.0` without changing the advertised loopback URL or removing token authentication; `/studio --status` reports the active binding.
|
|
217
237
|
- When Pi runs inside Muxy or cmux, Studio opens in that terminal app’s built-in browser. Muxy is detected from its pane/socket environment without installing global hooks; cmux targets and focuses the caller’s workspace. If the detected terminal browser is unavailable, disabled, or declines the request, Studio falls back once to the system browser.
|
|
218
238
|
- For remote SSH sessions, keep Studio bound to localhost and use SSH local port forwarding; `/studio` and `/studio --status` print the full tokenized localhost URL. The SSH hint repeats the full URL so it is visible even if your terminal only shows the latest notification. Open that URL through the tunnel, preserving the `?token=...` parameter. If SSH is not auto-detected, use `/studio --no-browser`; for stable forwarding, use `/studio --port <port>` or combine them, e.g. `/studio --no-browser --port 3417`.
|
|
219
239
|
- 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.
|
package/ROADMAP.md
CHANGED
|
@@ -106,6 +106,27 @@ A small interaction increment before the buffer-first architecture:
|
|
|
106
106
|
- add opt-in activity following from Working during main-agent generation to Response Preview on completion, disabled by default and cancelled when the user manually changes views;
|
|
107
107
|
- keep Quick send separate from the durable editor, the raw tmux mirror read-only, and protocol-v1 authority unchanged.
|
|
108
108
|
|
|
109
|
+
## 0.9.59 — Safe Pi input-draft handoff (shipped 2026-09-03)
|
|
110
|
+
|
|
111
|
+
A small workflow refinement for moving Neovim-provided context through Pi and Studio:
|
|
112
|
+
|
|
113
|
+
- load or stage Pi's terminal input draft without clearing it, retaining only a transient SHA-256 fingerprint and byte length for provenance;
|
|
114
|
+
- keep that link through unrestricted Studio edits, then clear the original Pi draft only after Pi accepts the Studio run and the current terminal text still matches exactly;
|
|
115
|
+
- silently preserve later terminal typing, added `pi-nvim-context` context, or another client's changes, and never clear after a rejected submission;
|
|
116
|
+
- provide `Cmd/Ctrl+Shift+L` for loading and a confirmed **Clear Pi editor text…** fallback without changing Studio text or conversation history;
|
|
117
|
+
- keep `pi-nvim-context`, protocol v1, networking, and the buffer-first architecture unchanged.
|
|
118
|
+
|
|
119
|
+
## 0.9.60 — Opt-in container/network binding (`0.9.60-rc.0` prerelease, 2026-09-03)
|
|
120
|
+
|
|
121
|
+
A bounded implementation of GitHub issue #4, separate from REPL transcript and buffer work:
|
|
122
|
+
|
|
123
|
+
- retain `127.0.0.1` as the default and require explicit `--listen-all` intent before binding to `0.0.0.0`;
|
|
124
|
+
- keep the server-lifetime bearer token mandatory and the generated URL on loopback for same-port container publishing;
|
|
125
|
+
- keep inbound `Host` values out of route parsing and require wildcard-mode browser origins to match the request authority;
|
|
126
|
+
- report the actual listening address, emit a prominent exposure warning, and require `/studio --stop` before changing a running server's binding;
|
|
127
|
+
- document loopback-only host publishing, private-network limits, and SSH forwarding as the safer option on untrusted networks;
|
|
128
|
+
- validate both default loopback and opt-in wildcard sockets without changing the buffer model, protocol v1, or `pi-repl`.
|
|
129
|
+
|
|
109
130
|
## 0.10.0 — Buffer-first editing
|
|
110
131
|
|
|
111
132
|
The first architectural `0.10` release should add:
|
package/client/studio-client.js
CHANGED
|
@@ -121,6 +121,7 @@
|
|
|
121
121
|
const refreshFromDiskBtn = document.getElementById("refreshFromDiskBtn");
|
|
122
122
|
const clearWorkspaceBtn = document.getElementById("clearWorkspaceBtn");
|
|
123
123
|
const sendEditorBtn = document.getElementById("sendEditorBtn");
|
|
124
|
+
const clearPiEditorBtn = document.getElementById("clearPiEditorBtn");
|
|
124
125
|
const openCompanionBtn = document.getElementById("openCompanionBtn");
|
|
125
126
|
const getEditorBtn = document.getElementById("getEditorBtn");
|
|
126
127
|
const studioHeaderEl = document.getElementById("studioHeader");
|
|
@@ -361,6 +362,9 @@
|
|
|
361
362
|
const pendingSaveOperations = new Map();
|
|
362
363
|
const pendingCompanionLaunches = new Map();
|
|
363
364
|
const activeStudioTabLaunches = new Set();
|
|
365
|
+
// Deliberately page-memory-only: never persist Pi input-draft handoff fingerprints.
|
|
366
|
+
const pendingPiEditorDraftSnapshots = new Map();
|
|
367
|
+
let linkedPiEditorDraftSnapshot = null;
|
|
364
368
|
let sourceOriginSummaryEl = null;
|
|
365
369
|
let sourceResetOriginBtn = null;
|
|
366
370
|
let sourceOpenCurrentFileTabBtn = null;
|
|
@@ -3186,7 +3190,9 @@
|
|
|
3186
3190
|
if (sendEditorBtn) sendEditorBtn.textContent = "Send current text to Pi editor";
|
|
3187
3191
|
appendStudioUiRefreshMenuSection(contextMenu.menu, "Document", [sourceOriginSummaryEl, sourceResetOriginBtn, sourceOpenCurrentFileTabBtn, sourceOpenCurrentTextCopyTabBtn]);
|
|
3188
3192
|
appendStudioUiRefreshMenuSection(contextMenu.menu, "Working directory", [resourceDirBtn, resourceDirLabel, resourceDirInputWrap]);
|
|
3189
|
-
if (!isEditorOnlyMode
|
|
3193
|
+
if (!isEditorOnlyMode) {
|
|
3194
|
+
appendStudioUiRefreshMenuSection(contextMenu.menu, "Pi editor", [sendEditorBtn, clearPiEditorBtn]);
|
|
3195
|
+
}
|
|
3190
3196
|
const cursorContextBtn = makeStudioUiRefreshElement("button", "completion-context-option", "Editor only");
|
|
3191
3197
|
cursorContextBtn.type = "button";
|
|
3192
3198
|
cursorContextBtn.setAttribute("data-completion-context-mode", "cursor");
|
|
@@ -3545,6 +3551,7 @@
|
|
|
3545
3551
|
if (kind === "compact") return "compacting context";
|
|
3546
3552
|
if (kind === "send_to_editor") return "sending to pi editor";
|
|
3547
3553
|
if (kind === "get_from_editor") return "loading from pi editor";
|
|
3554
|
+
if (kind === "clear_pi_editor") return "clearing Pi editor text";
|
|
3548
3555
|
if (kind === "load_git_diff") return "loading git diff";
|
|
3549
3556
|
if (kind === "open_editor_only") return "opening companion editor";
|
|
3550
3557
|
if (kind === "refresh_from_disk") return "refreshing from disk";
|
|
@@ -3803,6 +3810,7 @@
|
|
|
3803
3810
|
if (kind === "compact") return "Compacting…";
|
|
3804
3811
|
if (kind === "send_to_editor") return "Sending to editor…";
|
|
3805
3812
|
if (kind === "get_from_editor") return "Loading from editor…";
|
|
3813
|
+
if (kind === "clear_pi_editor") return "Clearing Pi editor text…";
|
|
3806
3814
|
if (kind === "load_git_diff") return "Loading git diff…";
|
|
3807
3815
|
if (kind === "refresh_from_disk") return "Refreshing from disk…";
|
|
3808
3816
|
if (kind === "save_as" || kind === "save_over") return "Saving…";
|
|
@@ -4464,6 +4472,58 @@
|
|
|
4464
4472
|
return /^sha256:[a-f0-9]{64}$/.test(revision) ? revision : null;
|
|
4465
4473
|
}
|
|
4466
4474
|
|
|
4475
|
+
function normalizePiEditorDraftSnapshot(value) {
|
|
4476
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
4477
|
+
const fingerprint = normalizeStudioDiskRevision(value.fingerprint);
|
|
4478
|
+
const byteLength = value.byteLength;
|
|
4479
|
+
if (!fingerprint || typeof byteLength !== "number" || !Number.isSafeInteger(byteLength) || byteLength < 0) return null;
|
|
4480
|
+
return { fingerprint, byteLength };
|
|
4481
|
+
}
|
|
4482
|
+
|
|
4483
|
+
function piEditorDraftSnapshotsMatch(left, right) {
|
|
4484
|
+
const normalizedLeft = normalizePiEditorDraftSnapshot(left);
|
|
4485
|
+
const normalizedRight = normalizePiEditorDraftSnapshot(right);
|
|
4486
|
+
return Boolean(
|
|
4487
|
+
normalizedLeft
|
|
4488
|
+
&& normalizedRight
|
|
4489
|
+
&& normalizedLeft.fingerprint === normalizedRight.fingerprint
|
|
4490
|
+
&& normalizedLeft.byteLength === normalizedRight.byteLength
|
|
4491
|
+
);
|
|
4492
|
+
}
|
|
4493
|
+
|
|
4494
|
+
function setLinkedPiEditorDraftSnapshot(value, hasContent) {
|
|
4495
|
+
linkedPiEditorDraftSnapshot = hasContent ? normalizePiEditorDraftSnapshot(value) : null;
|
|
4496
|
+
}
|
|
4497
|
+
|
|
4498
|
+
function clearLinkedPiEditorDraftSnapshot() {
|
|
4499
|
+
linkedPiEditorDraftSnapshot = null;
|
|
4500
|
+
}
|
|
4501
|
+
|
|
4502
|
+
function reserveLinkedPiEditorDraftSnapshot(requestId) {
|
|
4503
|
+
const snapshot = normalizePiEditorDraftSnapshot(linkedPiEditorDraftSnapshot);
|
|
4504
|
+
if (!snapshot || typeof requestId !== "string" || !requestId) return null;
|
|
4505
|
+
linkedPiEditorDraftSnapshot = null;
|
|
4506
|
+
pendingPiEditorDraftSnapshots.set(requestId, snapshot);
|
|
4507
|
+
return snapshot;
|
|
4508
|
+
}
|
|
4509
|
+
|
|
4510
|
+
function restoreReservedPiEditorDraftSnapshot(requestId) {
|
|
4511
|
+
if (typeof requestId !== "string" || !requestId) return;
|
|
4512
|
+
const snapshot = pendingPiEditorDraftSnapshots.get(requestId);
|
|
4513
|
+
if (!snapshot) return;
|
|
4514
|
+
pendingPiEditorDraftSnapshots.delete(requestId);
|
|
4515
|
+
if (!linkedPiEditorDraftSnapshot) linkedPiEditorDraftSnapshot = snapshot;
|
|
4516
|
+
}
|
|
4517
|
+
|
|
4518
|
+
function settleReservedPiEditorDraftSnapshot(requestId, reportedSnapshot) {
|
|
4519
|
+
if (typeof requestId !== "string" || !requestId) return false;
|
|
4520
|
+
const snapshot = pendingPiEditorDraftSnapshots.get(requestId);
|
|
4521
|
+
if (!snapshot) return false;
|
|
4522
|
+
if (reportedSnapshot && !piEditorDraftSnapshotsMatch(snapshot, reportedSnapshot)) return false;
|
|
4523
|
+
pendingPiEditorDraftSnapshots.delete(requestId);
|
|
4524
|
+
return true;
|
|
4525
|
+
}
|
|
4526
|
+
|
|
4467
4527
|
function markFileBackedBaseline(text, diskRevision) {
|
|
4468
4528
|
fileBackedBaselineText = String(text || "");
|
|
4469
4529
|
fileBackedDiskRevision = normalizeStudioDiskRevision(diskRevision);
|
|
@@ -5098,6 +5158,20 @@
|
|
|
5098
5158
|
return true;
|
|
5099
5159
|
}
|
|
5100
5160
|
|
|
5161
|
+
function triggerLoadFromPiEditorShortcut() {
|
|
5162
|
+
if (isWatchedFilePreview) {
|
|
5163
|
+
setStatus("This preview is read-only. Open full Studio to load the Pi draft.", "warning");
|
|
5164
|
+
return false;
|
|
5165
|
+
}
|
|
5166
|
+
if (!getEditorBtn || getEditorBtn.hidden) return false;
|
|
5167
|
+
if (getEditorBtn.disabled) {
|
|
5168
|
+
setStatus("Load from Pi editor is unavailable while Studio is busy.", "warning");
|
|
5169
|
+
return false;
|
|
5170
|
+
}
|
|
5171
|
+
getEditorBtn.click();
|
|
5172
|
+
return true;
|
|
5173
|
+
}
|
|
5174
|
+
|
|
5101
5175
|
function triggerEditorSaveShortcut() {
|
|
5102
5176
|
if (hasRefreshableFilePath() && saveOverBtn && !saveOverBtn.disabled && !saveOverBtn.hidden) {
|
|
5103
5177
|
saveOverBtn.click();
|
|
@@ -5459,6 +5533,18 @@
|
|
|
5459
5533
|
return;
|
|
5460
5534
|
}
|
|
5461
5535
|
|
|
5536
|
+
const isLoadFromPiEditorShortcut =
|
|
5537
|
+
(key.toLowerCase() === "l" || code === "KeyL")
|
|
5538
|
+
&& (event.metaKey || event.ctrlKey)
|
|
5539
|
+
&& !event.altKey
|
|
5540
|
+
&& event.shiftKey;
|
|
5541
|
+
|
|
5542
|
+
if (isLoadFromPiEditorShortcut) {
|
|
5543
|
+
event.preventDefault();
|
|
5544
|
+
triggerLoadFromPiEditorShortcut();
|
|
5545
|
+
return;
|
|
5546
|
+
}
|
|
5547
|
+
|
|
5462
5548
|
const isSaveAsShortcut =
|
|
5463
5549
|
key.toLowerCase() === "s"
|
|
5464
5550
|
&& (event.metaKey || event.ctrlKey)
|
|
@@ -13888,7 +13974,7 @@
|
|
|
13888
13974
|
const safeStart = Math.max(0, Math.min(start, current.length));
|
|
13889
13975
|
const safeEnd = Math.max(safeStart, Math.min(end, current.length));
|
|
13890
13976
|
const next = current.slice(0, safeStart) + answer.text + current.slice(safeEnd);
|
|
13891
|
-
setEditorText(next, { preserveScroll: false, preserveSelection: false });
|
|
13977
|
+
setEditorText(next, { preserveScroll: false, preserveSelection: false, preservePiEditorDraftLink: true });
|
|
13892
13978
|
const caret = safeStart + answer.text.length;
|
|
13893
13979
|
sourceTextEl.setSelectionRange(caret, caret);
|
|
13894
13980
|
setActivePane("left");
|
|
@@ -14504,6 +14590,7 @@
|
|
|
14504
14590
|
if (refreshFromDiskBtn) refreshFromDiskBtn.disabled = uiBusy || isWatchedFilePreview || !canRefreshFromDisk;
|
|
14505
14591
|
if (clearWorkspaceBtn) clearWorkspaceBtn.disabled = uiBusy || isWatchedFilePreview;
|
|
14506
14592
|
sendEditorBtn.disabled = uiBusy || isEditorOnlyMode;
|
|
14593
|
+
if (clearPiEditorBtn) clearPiEditorBtn.disabled = uiBusy || isEditorOnlyMode;
|
|
14507
14594
|
if (getEditorBtn) getEditorBtn.disabled = uiBusy || isWatchedFilePreview;
|
|
14508
14595
|
if (watchedOpenEditableBtn) {
|
|
14509
14596
|
watchedOpenEditableBtn.hidden = !isWatchedFilePreview;
|
|
@@ -14885,6 +14972,9 @@
|
|
|
14885
14972
|
const value = String(nextText || "");
|
|
14886
14973
|
const preserveScroll = Boolean(options && options.preserveScroll);
|
|
14887
14974
|
const preserveSelection = Boolean(options && options.preserveSelection);
|
|
14975
|
+
if (!(options && options.preservePiEditorDraftLink === true)) {
|
|
14976
|
+
clearLinkedPiEditorDraftSnapshot();
|
|
14977
|
+
}
|
|
14888
14978
|
if (activePreviewCommentSelection) {
|
|
14889
14979
|
clearPreviewCommentSelection();
|
|
14890
14980
|
}
|
|
@@ -22387,7 +22477,7 @@
|
|
|
22387
22477
|
? current.slice(0, inlineState.range.end) + current.slice(inlineState.range.end + inlineState.markerText.length)
|
|
22388
22478
|
: current.slice(0, inlineState.range.end) + inlineState.markerText + current.slice(inlineState.range.end);
|
|
22389
22479
|
setEditorView("markdown");
|
|
22390
|
-
setEditorText(next, { preserveScroll: true, preserveSelection: true });
|
|
22480
|
+
setEditorText(next, { preserveScroll: true, preserveSelection: true, preservePiEditorDraftLink: true });
|
|
22391
22481
|
pendingReviewNoteInlineFocusId = note.id;
|
|
22392
22482
|
renderReviewNotesList();
|
|
22393
22483
|
updateReviewNotesUi();
|
|
@@ -22432,7 +22522,7 @@
|
|
|
22432
22522
|
}
|
|
22433
22523
|
|
|
22434
22524
|
setEditorView("markdown");
|
|
22435
|
-
setEditorText(currentText, { preserveScroll: true, preserveSelection: true });
|
|
22525
|
+
setEditorText(currentText, { preserveScroll: true, preserveSelection: true, preservePiEditorDraftLink: true });
|
|
22436
22526
|
renderReviewNotesList();
|
|
22437
22527
|
updateReviewNotesUi();
|
|
22438
22528
|
if (reviewNotesInlineAllBtn && typeof reviewNotesInlineAllBtn.focus === "function") {
|
|
@@ -22618,7 +22708,7 @@
|
|
|
22618
22708
|
const safeStart = Math.max(0, Math.min(start, current.length));
|
|
22619
22709
|
const safeEnd = Math.max(safeStart, Math.min(end, current.length));
|
|
22620
22710
|
const next = current.slice(0, safeStart) + content + current.slice(safeEnd);
|
|
22621
|
-
setEditorText(next, { preserveScroll: false, preserveSelection: false });
|
|
22711
|
+
setEditorText(next, { preserveScroll: false, preserveSelection: false, preservePiEditorDraftLink: true });
|
|
22622
22712
|
const caret = safeStart + content.length;
|
|
22623
22713
|
sourceTextEl.setSelectionRange(caret, caret);
|
|
22624
22714
|
setActivePane("left");
|
|
@@ -22821,11 +22911,14 @@
|
|
|
22821
22911
|
const replHint = rightView === "repl" && getActiveReplSessionForCurrentRuntime()
|
|
22822
22912
|
? " Sends text to Pi, not the REPL; use Send chunk/selection or Send to REPL to execute code in the active REPL."
|
|
22823
22913
|
: "";
|
|
22914
|
+
const piEditorDraftHint = linkedPiEditorDraftSnapshot
|
|
22915
|
+
? " The linked Pi terminal draft will be cleared after Pi accepts this run only if that draft remains unchanged."
|
|
22916
|
+
: "";
|
|
22824
22917
|
sendRunBtn.title = directIsStop
|
|
22825
22918
|
? "Stop the active run. Shortcut: Esc."
|
|
22826
22919
|
: (annotationsEnabled
|
|
22827
|
-
? "Run editor text as-is (includes [an: ...] markers). Shortcut: Cmd/Ctrl+Enter. Stop the active request with Esc." + replHint
|
|
22828
|
-
: "Run editor text with [an: ...] markers stripped. Shortcut: Cmd/Ctrl+Enter. Stop the active request with Esc." + replHint);
|
|
22920
|
+
? "Run editor text as-is (includes [an: ...] markers). Shortcut: Cmd/Ctrl+Enter. Stop the active request with Esc." + piEditorDraftHint + replHint
|
|
22921
|
+
: "Run editor text with [an: ...] markers stripped. Shortcut: Cmd/Ctrl+Enter. Stop the active request with Esc." + piEditorDraftHint + replHint);
|
|
22829
22922
|
}
|
|
22830
22923
|
|
|
22831
22924
|
if (queueSteerBtn) {
|
|
@@ -23670,6 +23763,7 @@
|
|
|
23670
23763
|
}
|
|
23671
23764
|
|
|
23672
23765
|
const completedRequestId = typeof message.requestId === "string" ? message.requestId : pendingRequestId;
|
|
23766
|
+
if (completedRequestId) pendingPiEditorDraftSnapshots.delete(completedRequestId);
|
|
23673
23767
|
const responseKind =
|
|
23674
23768
|
typeof message.kind === "string"
|
|
23675
23769
|
? message.kind
|
|
@@ -23804,17 +23898,46 @@
|
|
|
23804
23898
|
return;
|
|
23805
23899
|
}
|
|
23806
23900
|
|
|
23901
|
+
if (message.type === "pi_editor_draft_result") {
|
|
23902
|
+
const requestId = typeof message.requestId === "string" ? message.requestId : "";
|
|
23903
|
+
if (!settleReservedPiEditorDraftSnapshot(requestId, message.piEditorDraftSnapshot)) return;
|
|
23904
|
+
const outcome = String(message.outcome || "");
|
|
23905
|
+
syncActionButtons();
|
|
23906
|
+
if (outcome === "cleared") {
|
|
23907
|
+
setStatus("Submission accepted; cleared the unchanged Pi draft.", "success");
|
|
23908
|
+
} else if (outcome === "already-empty") {
|
|
23909
|
+
setStatus("Submission accepted; the Pi draft was already empty.", "success");
|
|
23910
|
+
} else if (outcome !== "changed") {
|
|
23911
|
+
setStatus("Submission accepted, but Studio could not confirm Pi-draft cleanup; check the terminal draft.", "warning");
|
|
23912
|
+
}
|
|
23913
|
+
return;
|
|
23914
|
+
}
|
|
23915
|
+
|
|
23807
23916
|
if (message.type === "editor_loaded") {
|
|
23808
23917
|
if (typeof message.requestId === "string" && pendingRequestId === message.requestId) {
|
|
23809
23918
|
pendingRequestId = null;
|
|
23810
23919
|
pendingKind = null;
|
|
23811
23920
|
}
|
|
23921
|
+
const snapshot = normalizePiEditorDraftSnapshot(message.piEditorDraftSnapshot);
|
|
23922
|
+
setLinkedPiEditorDraftSnapshot(snapshot, Boolean(snapshot && snapshot.byteLength > 0));
|
|
23812
23923
|
setBusy(false);
|
|
23813
23924
|
setWsState("Ready");
|
|
23814
23925
|
setStatus(typeof message.message === "string" ? message.message : "Loaded into pi editor.", "success");
|
|
23815
23926
|
return;
|
|
23816
23927
|
}
|
|
23817
23928
|
|
|
23929
|
+
if (message.type === "pi_editor_cleared") {
|
|
23930
|
+
if (typeof message.requestId === "string" && pendingRequestId === message.requestId) {
|
|
23931
|
+
pendingRequestId = null;
|
|
23932
|
+
pendingKind = null;
|
|
23933
|
+
}
|
|
23934
|
+
clearLinkedPiEditorDraftSnapshot();
|
|
23935
|
+
setBusy(false);
|
|
23936
|
+
setWsState("Ready");
|
|
23937
|
+
setStatus(message.cleared ? "Cleared Pi editor text." : "Pi editor text was already empty.", "success");
|
|
23938
|
+
return;
|
|
23939
|
+
}
|
|
23940
|
+
|
|
23818
23941
|
if (message.type === "editor_snapshot") {
|
|
23819
23942
|
if (isWatchedFilePreview) {
|
|
23820
23943
|
setStatus("Ignored editor snapshot because this preview follows its watched file on disk.", "warning");
|
|
@@ -23830,12 +23953,14 @@
|
|
|
23830
23953
|
|
|
23831
23954
|
const content = typeof message.content === "string" ? message.content : "";
|
|
23832
23955
|
setEditorText(content, { preserveScroll: false, preserveSelection: false });
|
|
23956
|
+
const snapshot = normalizePiEditorDraftSnapshot(message.piEditorDraftSnapshot);
|
|
23957
|
+
setLinkedPiEditorDraftSnapshot(snapshot, Boolean(content.length > 0 && snapshot));
|
|
23833
23958
|
setSourceState({ source: "pi-editor", label: "pi editor draft", path: null });
|
|
23834
23959
|
setBusy(false);
|
|
23835
23960
|
setWsState("Ready");
|
|
23836
23961
|
setStatus(
|
|
23837
23962
|
content.trim()
|
|
23838
|
-
? "Loaded draft from pi editor."
|
|
23963
|
+
? "Loaded draft from pi editor. A Studio Run will clear it if the Pi draft remains unchanged."
|
|
23839
23964
|
: "pi editor is empty. Loaded blank text.",
|
|
23840
23965
|
content.trim() ? "success" : "warning",
|
|
23841
23966
|
);
|
|
@@ -24032,6 +24157,7 @@
|
|
|
24032
24157
|
|
|
24033
24158
|
if (message.type === "busy") {
|
|
24034
24159
|
if (typeof message.requestId === "string") {
|
|
24160
|
+
restoreReservedPiEditorDraftSnapshot(message.requestId);
|
|
24035
24161
|
pendingSaveOperations.delete(message.requestId);
|
|
24036
24162
|
failPendingCompanionLaunch(message.requestId, "Studio could not start the companion editor because another request was busy.");
|
|
24037
24163
|
}
|
|
@@ -24055,6 +24181,7 @@
|
|
|
24055
24181
|
|
|
24056
24182
|
if (message.type === "error") {
|
|
24057
24183
|
if (typeof message.requestId === "string") {
|
|
24184
|
+
restoreReservedPiEditorDraftSnapshot(message.requestId);
|
|
24058
24185
|
pendingSaveOperations.delete(message.requestId);
|
|
24059
24186
|
failPendingCompanionLaunch(message.requestId, "Studio could not prepare the companion editor. Return to the originating Studio page for details.");
|
|
24060
24187
|
}
|
|
@@ -24203,6 +24330,7 @@
|
|
|
24203
24330
|
quartoPreviewCheckSourcePath = "";
|
|
24204
24331
|
quartoPreviewActionRequestId = null;
|
|
24205
24332
|
sideQuestionMarkdownExportRequest = null;
|
|
24333
|
+
pendingPiEditorDraftSnapshots.clear();
|
|
24206
24334
|
failAllPendingCompanionLaunches("The originating Studio connection was lost before the companion editor was ready.");
|
|
24207
24335
|
if (rightView === "editor-quarto-preview") renderQuartoPreviewView();
|
|
24208
24336
|
setBusy(true);
|
|
@@ -24473,7 +24601,7 @@
|
|
|
24473
24601
|
|
|
24474
24602
|
if (stripped.hadHeader) {
|
|
24475
24603
|
const updated = stripped.body;
|
|
24476
|
-
setEditorText(updated, { preserveScroll: true, preserveSelection: true });
|
|
24604
|
+
setEditorText(updated, { preserveScroll: true, preserveSelection: true, preservePiEditorDraftLink: true });
|
|
24477
24605
|
updateResultActionButtons();
|
|
24478
24606
|
setStatus("Removed annotated reply header.", "success");
|
|
24479
24607
|
return;
|
|
@@ -24486,7 +24614,7 @@
|
|
|
24486
24614
|
return;
|
|
24487
24615
|
}
|
|
24488
24616
|
|
|
24489
|
-
setEditorText(updated, { preserveScroll: true, preserveSelection: true });
|
|
24617
|
+
setEditorText(updated, { preserveScroll: true, preserveSelection: true, preservePiEditorDraftLink: true });
|
|
24490
24618
|
updateResultActionButtons();
|
|
24491
24619
|
setStatus("Inserted annotated reply header.", "success");
|
|
24492
24620
|
}
|
|
@@ -25356,6 +25484,29 @@
|
|
|
25356
25484
|
}
|
|
25357
25485
|
});
|
|
25358
25486
|
|
|
25487
|
+
if (clearPiEditorBtn) {
|
|
25488
|
+
clearPiEditorBtn.addEventListener("click", async () => {
|
|
25489
|
+
closeStudioUiRefreshMenus();
|
|
25490
|
+
const confirmed = await requestStudioConfirmation(
|
|
25491
|
+
"Clear the current Pi input draft? This removes text waiting in the terminal editor, including context added from Neovim. Studio text and Pi conversation history will not change.",
|
|
25492
|
+
{ title: "Clear Pi editor text?", confirmLabel: "Clear text", destructive: true },
|
|
25493
|
+
);
|
|
25494
|
+
if (!confirmed) {
|
|
25495
|
+
setStatus("Pi editor text was not cleared.");
|
|
25496
|
+
return;
|
|
25497
|
+
}
|
|
25498
|
+
|
|
25499
|
+
const requestId = beginUiAction("clear_pi_editor");
|
|
25500
|
+
if (!requestId) return;
|
|
25501
|
+
const sent = sendMessage({ type: "clear_pi_editor_request", requestId });
|
|
25502
|
+
if (!sent) {
|
|
25503
|
+
pendingRequestId = null;
|
|
25504
|
+
pendingKind = null;
|
|
25505
|
+
setBusy(false);
|
|
25506
|
+
}
|
|
25507
|
+
});
|
|
25508
|
+
}
|
|
25509
|
+
|
|
25359
25510
|
if (openCompanionBtn) {
|
|
25360
25511
|
openCompanionBtn.addEventListener("click", () => {
|
|
25361
25512
|
requestOpenEditorOnlyDocument("", {
|
|
@@ -25423,14 +25574,17 @@
|
|
|
25423
25574
|
|
|
25424
25575
|
const requestId = beginUiAction("direct");
|
|
25425
25576
|
if (!requestId) return;
|
|
25577
|
+
const piEditorDraftSnapshot = reserveLinkedPiEditorDraftSnapshot(requestId);
|
|
25426
25578
|
|
|
25427
25579
|
const sent = sendMessage({
|
|
25428
25580
|
type: "send_run_request",
|
|
25429
25581
|
requestId,
|
|
25430
25582
|
text: prepared,
|
|
25583
|
+
piEditorDraftSnapshot: piEditorDraftSnapshot || undefined,
|
|
25431
25584
|
});
|
|
25432
25585
|
|
|
25433
25586
|
if (!sent) {
|
|
25587
|
+
restoreReservedPiEditorDraftSnapshot(requestId);
|
|
25434
25588
|
pendingRequestId = null;
|
|
25435
25589
|
pendingKind = null;
|
|
25436
25590
|
setBusy(false);
|
|
@@ -25451,12 +25605,17 @@
|
|
|
25451
25605
|
|
|
25452
25606
|
const requestId = makeRequestId();
|
|
25453
25607
|
clearTitleAttention();
|
|
25608
|
+
const piEditorDraftSnapshot = reserveLinkedPiEditorDraftSnapshot(requestId);
|
|
25454
25609
|
const sent = sendMessage({
|
|
25455
25610
|
type: "send_run_request",
|
|
25456
25611
|
requestId,
|
|
25457
25612
|
text: prepared,
|
|
25613
|
+
piEditorDraftSnapshot: piEditorDraftSnapshot || undefined,
|
|
25458
25614
|
});
|
|
25459
|
-
if (!sent)
|
|
25615
|
+
if (!sent) {
|
|
25616
|
+
restoreReservedPiEditorDraftSnapshot(requestId);
|
|
25617
|
+
return;
|
|
25618
|
+
}
|
|
25460
25619
|
setStatus("Queueing steering…", "warning");
|
|
25461
25620
|
});
|
|
25462
25621
|
}
|
|
@@ -25892,7 +26051,7 @@
|
|
|
25892
26051
|
if (!confirmed) return;
|
|
25893
26052
|
|
|
25894
26053
|
const strippedContent = stripAnnotationMarkers(content);
|
|
25895
|
-
setEditorText(strippedContent, { preserveScroll: true, preserveSelection: false });
|
|
26054
|
+
setEditorText(strippedContent, { preserveScroll: true, preserveSelection: false, preservePiEditorDraftLink: true });
|
|
25896
26055
|
setStatus("Removed annotation markers from editor text.", "success");
|
|
25897
26056
|
});
|
|
25898
26057
|
}
|
package/client/studio.css
CHANGED
|
@@ -569,6 +569,7 @@
|
|
|
569
569
|
body[data-studio-mode="editor-only"] #sendRunBtn,
|
|
570
570
|
body[data-studio-mode="editor-only"] #queueSteerBtn,
|
|
571
571
|
body[data-studio-mode="editor-only"] #sendEditorBtn,
|
|
572
|
+
body[data-studio-mode="editor-only"] #clearPiEditorBtn,
|
|
572
573
|
body[data-studio-mode="editor-only"] #lensSelect,
|
|
573
574
|
body[data-studio-mode="editor-only"] #critiqueBtn,
|
|
574
575
|
body[data-studio-mode="editor-only"] #showMeBtn,
|
package/index.ts
CHANGED
|
@@ -29,6 +29,11 @@ import {
|
|
|
29
29
|
saveStudioDiskFileAs,
|
|
30
30
|
saveStudioDiskFileIfRevision,
|
|
31
31
|
} from "./shared/studio-disk-revisions.js";
|
|
32
|
+
import {
|
|
33
|
+
consumeStudioPiEditorDraftSnapshot,
|
|
34
|
+
createStudioPiEditorDraftSnapshot,
|
|
35
|
+
normalizeStudioPiEditorDraftSnapshot,
|
|
36
|
+
} from "./shared/studio-pi-editor-draft.js";
|
|
32
37
|
import { normalizeStudioMarkdownSmartFences } from "./shared/studio-markdown-fences.js";
|
|
33
38
|
import {
|
|
34
39
|
extractStandaloneLatexDefinitionsFromMarkdown,
|
|
@@ -41,6 +46,13 @@ import { createStudioPandocHtmlResourceFlagResolver } from "./shared/studio-pand
|
|
|
41
46
|
import { createStudioResourceGrantRegistry } from "./shared/studio-resource-grants.js";
|
|
42
47
|
import { prepareStudioLatexForPandoc } from "./shared/studio-latex-pandoc-compat.js";
|
|
43
48
|
import { isStudioCmuxSession, openStudioUrlInBrowser } from "./shared/studio-browser-launcher.js";
|
|
49
|
+
import {
|
|
50
|
+
buildStudioListenAllWarning,
|
|
51
|
+
isStudioWebSocketOriginAllowed,
|
|
52
|
+
parseStudioRequestTarget,
|
|
53
|
+
resolveStudioNetworkBinding,
|
|
54
|
+
STUDIO_ADVERTISED_HOST,
|
|
55
|
+
} from "./shared/studio-network-binding.js";
|
|
44
56
|
import { buildStudioReplTmuxStartArgs } from "./shared/studio-repl-tmux.js";
|
|
45
57
|
import {
|
|
46
58
|
REPL_SESSION_RECORD_ID_OPTION,
|
|
@@ -192,6 +204,8 @@ interface StudioServerState {
|
|
|
192
204
|
clients: Set<WebSocket>;
|
|
193
205
|
clientModes: Map<WebSocket, StudioUiMode>;
|
|
194
206
|
port: number;
|
|
207
|
+
bindHost: string;
|
|
208
|
+
listenAll: boolean;
|
|
195
209
|
token: string;
|
|
196
210
|
}
|
|
197
211
|
|
|
@@ -646,10 +660,16 @@ interface AnnotationRequestMessage {
|
|
|
646
660
|
text: string;
|
|
647
661
|
}
|
|
648
662
|
|
|
663
|
+
interface StudioPiEditorDraftSnapshot {
|
|
664
|
+
fingerprint: string;
|
|
665
|
+
byteLength: number;
|
|
666
|
+
}
|
|
667
|
+
|
|
649
668
|
interface SendRunRequestMessage {
|
|
650
669
|
type: "send_run_request";
|
|
651
670
|
requestId: string;
|
|
652
671
|
text: string;
|
|
672
|
+
piEditorDraftSnapshot?: StudioPiEditorDraftSnapshot;
|
|
653
673
|
}
|
|
654
674
|
|
|
655
675
|
interface CompletionSuggestionRequestMessage {
|
|
@@ -839,6 +859,11 @@ interface GetFromEditorRequestMessage {
|
|
|
839
859
|
requestId: string;
|
|
840
860
|
}
|
|
841
861
|
|
|
862
|
+
interface ClearPiEditorRequestMessage {
|
|
863
|
+
type: "clear_pi_editor_request";
|
|
864
|
+
requestId: string;
|
|
865
|
+
}
|
|
866
|
+
|
|
842
867
|
interface QuartoPreviewCheckRequestMessage {
|
|
843
868
|
type: "quarto_preview_check_request";
|
|
844
869
|
requestId: string;
|
|
@@ -922,6 +947,7 @@ type IncomingStudioMessage =
|
|
|
922
947
|
| WatchFileSubscribeMessage
|
|
923
948
|
| SendToEditorRequestMessage
|
|
924
949
|
| GetFromEditorRequestMessage
|
|
950
|
+
| ClearPiEditorRequestMessage
|
|
925
951
|
| QuartoPreviewCheckRequestMessage
|
|
926
952
|
| QuartoPreviewStartRequestMessage
|
|
927
953
|
| QuartoPreviewStopRequestMessage
|
|
@@ -9917,10 +9943,15 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
|
|
|
9917
9943
|
}
|
|
9918
9944
|
|
|
9919
9945
|
if (msg.type === "send_run_request" && typeof msg.requestId === "string" && typeof msg.text === "string") {
|
|
9946
|
+
const piEditorDraftSnapshot = msg.piEditorDraftSnapshot === undefined
|
|
9947
|
+
? undefined
|
|
9948
|
+
: normalizeStudioPiEditorDraftSnapshot(msg.piEditorDraftSnapshot);
|
|
9949
|
+
if (msg.piEditorDraftSnapshot !== undefined && !piEditorDraftSnapshot) return null;
|
|
9920
9950
|
return {
|
|
9921
9951
|
type: "send_run_request",
|
|
9922
9952
|
requestId: msg.requestId,
|
|
9923
9953
|
text: msg.text,
|
|
9954
|
+
piEditorDraftSnapshot: piEditorDraftSnapshot ?? undefined,
|
|
9924
9955
|
};
|
|
9925
9956
|
}
|
|
9926
9957
|
|
|
@@ -10200,6 +10231,13 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
|
|
|
10200
10231
|
};
|
|
10201
10232
|
}
|
|
10202
10233
|
|
|
10234
|
+
if (msg.type === "clear_pi_editor_request" && typeof msg.requestId === "string") {
|
|
10235
|
+
return {
|
|
10236
|
+
type: "clear_pi_editor_request",
|
|
10237
|
+
requestId: msg.requestId,
|
|
10238
|
+
};
|
|
10239
|
+
}
|
|
10240
|
+
|
|
10203
10241
|
if (
|
|
10204
10242
|
msg.type === "quarto_preview_check_request"
|
|
10205
10243
|
&& typeof msg.requestId === "string"
|
|
@@ -11836,13 +11874,6 @@ function interruptStudioReplSession(sessionName: string): { ok: true; message: s
|
|
|
11836
11874
|
return { ok: true, message: `Interrupted ${sessionName}.` };
|
|
11837
11875
|
}
|
|
11838
11876
|
|
|
11839
|
-
function isAllowedOrigin(_origin: string | undefined, _port: number): boolean {
|
|
11840
|
-
// For local-only studio, token auth is the primary guard. In practice,
|
|
11841
|
-
// browser origin headers can vary (or be omitted) across wrappers/browsers,
|
|
11842
|
-
// so we avoid brittle origin-based rejection here.
|
|
11843
|
-
return true;
|
|
11844
|
-
}
|
|
11845
|
-
|
|
11846
11877
|
function normalizeStudioUiMode(raw: string | null | undefined): StudioUiMode {
|
|
11847
11878
|
return raw === "editor-only" ? "editor-only" : "full";
|
|
11848
11879
|
}
|
|
@@ -11907,7 +11938,7 @@ function buildStudioUrl(
|
|
|
11907
11938
|
docId?: string,
|
|
11908
11939
|
options?: StudioUrlOptions,
|
|
11909
11940
|
): string {
|
|
11910
|
-
return `http
|
|
11941
|
+
return `http://${STUDIO_ADVERTISED_HOST}:${port}${buildStudioRelativeUrl(token, mode, doc, docId, options)}`;
|
|
11911
11942
|
}
|
|
11912
11943
|
|
|
11913
11944
|
interface StudioLaunchFlags {
|
|
@@ -11915,17 +11946,19 @@ interface StudioLaunchFlags {
|
|
|
11915
11946
|
openRemoteBrowser: boolean;
|
|
11916
11947
|
noBrowser: boolean;
|
|
11917
11948
|
watchPdf: boolean;
|
|
11949
|
+
listenAll: boolean;
|
|
11918
11950
|
port?: number;
|
|
11919
11951
|
error?: string;
|
|
11920
11952
|
}
|
|
11921
11953
|
|
|
11922
11954
|
function parseStudioLaunchOpenFlags(rawArgs: string): StudioLaunchFlags {
|
|
11923
11955
|
const parsed = tokenizeStudioCommandArgs(rawArgs);
|
|
11924
|
-
if (parsed.error) return { args: rawArgs, openRemoteBrowser: false, noBrowser: false, watchPdf: false, error: parsed.error };
|
|
11956
|
+
if (parsed.error) return { args: rawArgs, openRemoteBrowser: false, noBrowser: false, watchPdf: false, listenAll: false, error: parsed.error };
|
|
11925
11957
|
const remaining: string[] = [];
|
|
11926
11958
|
let openRemoteBrowser = false;
|
|
11927
11959
|
let noBrowser = false;
|
|
11928
11960
|
let watchPdf = false;
|
|
11961
|
+
let listenAll = false;
|
|
11929
11962
|
let port: number | undefined;
|
|
11930
11963
|
for (let i = 0; i < parsed.tokens.length; i += 1) {
|
|
11931
11964
|
const token = parsed.tokens[i]!;
|
|
@@ -11941,14 +11974,18 @@ function parseStudioLaunchOpenFlags(rawArgs: string): StudioLaunchFlags {
|
|
|
11941
11974
|
watchPdf = true;
|
|
11942
11975
|
continue;
|
|
11943
11976
|
}
|
|
11977
|
+
if (token === "--listen-all") {
|
|
11978
|
+
listenAll = true;
|
|
11979
|
+
continue;
|
|
11980
|
+
}
|
|
11944
11981
|
if (token === "--port" || token.startsWith("--port=")) {
|
|
11945
11982
|
const rawPort = token.startsWith("--port=") ? token.slice("--port=".length) : parsed.tokens[++i];
|
|
11946
11983
|
if (!rawPort) {
|
|
11947
|
-
return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, error: "Missing value for --port." };
|
|
11984
|
+
return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, listenAll, error: "Missing value for --port." };
|
|
11948
11985
|
}
|
|
11949
11986
|
const requestedPort = Number(rawPort);
|
|
11950
11987
|
if (!Number.isInteger(requestedPort) || requestedPort < 1 || requestedPort > 65535) {
|
|
11951
|
-
return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, error: `Invalid --port value: ${rawPort}. Use an integer from 1 to 65535.` };
|
|
11988
|
+
return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, listenAll, error: `Invalid --port value: ${rawPort}. Use an integer from 1 to 65535.` };
|
|
11952
11989
|
}
|
|
11953
11990
|
port = requestedPort;
|
|
11954
11991
|
continue;
|
|
@@ -11956,9 +11993,9 @@ function parseStudioLaunchOpenFlags(rawArgs: string): StudioLaunchFlags {
|
|
|
11956
11993
|
remaining.push(token);
|
|
11957
11994
|
}
|
|
11958
11995
|
if (openRemoteBrowser && noBrowser) {
|
|
11959
|
-
return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, port, error: "Use either --no-browser or --open-browser, not both." };
|
|
11996
|
+
return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, listenAll, port, error: "Use either --no-browser or --open-browser, not both." };
|
|
11960
11997
|
}
|
|
11961
|
-
return { args: remaining.join(" "), openRemoteBrowser, noBrowser, watchPdf, port };
|
|
11998
|
+
return { args: remaining.join(" "), openRemoteBrowser, noBrowser, watchPdf, listenAll, port };
|
|
11962
11999
|
}
|
|
11963
12000
|
|
|
11964
12001
|
function shouldAutoOpenStudioBrowser(options?: { openRemoteBrowser?: boolean; noBrowser?: boolean }): boolean {
|
|
@@ -12385,7 +12422,7 @@ ${cssVarsBlock}
|
|
|
12385
12422
|
<button id="clearWorkspaceBtn" type="button" title="Clear editor text and reset this tab to a fresh blank draft. Saved files and responses are not changed.">Reset editor</button>
|
|
12386
12423
|
<button id="importFileBtn" type="button" title="Import a file as an editable copy.">Import file copy…</button>
|
|
12387
12424
|
<input id="fileInput" class="file-input-hidden" type="file" tabindex="-1" aria-hidden="true" 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" />
|
|
12388
|
-
<button id="getEditorBtn" type="button" title="
|
|
12425
|
+
<button id="getEditorBtn" type="button" aria-keyshortcuts="Meta+Shift+L Control+Shift+L" title="Copy the current Pi terminal input draft into Studio without clearing it. A later Studio Run clears that draft only if it remains unchanged. Shortcut: Cmd/Ctrl+Shift+L.">Load from pi editor</button>
|
|
12389
12426
|
<button id="hideStudioHeaderBtn" class="header-visibility-btn" type="button" aria-controls="studioHeader" title="Hide the global Studio header. Restore it from the top-right edge.">Hide header</button>
|
|
12390
12427
|
<button id="zenModeBtn" class="zen-mode-btn" type="button" title="Hide the Studio header and secondary controls. Shortcut: F9.">Zen</button>
|
|
12391
12428
|
</div>
|
|
@@ -12426,7 +12463,7 @@ ${cssVarsBlock}
|
|
|
12426
12463
|
</div>
|
|
12427
12464
|
<div class="source-actions">
|
|
12428
12465
|
<div class="source-actions-row">
|
|
12429
|
-
<button id="sendRunBtn" type="button" title="Run editor text. While a direct run is active, this button becomes Stop. Cmd/Ctrl+Enter queues steering from the current editor text. Stop the active request with Esc.">Run editor text</button>
|
|
12466
|
+
<button id="sendRunBtn" type="button" title="Run editor text. If Studio loaded or staged an unchanged Pi terminal draft, clear that draft after Pi accepts the run. While a direct run is active, this button becomes Stop. Cmd/Ctrl+Enter queues steering from the current editor text. Stop the active request with Esc.">Run editor text</button>
|
|
12430
12467
|
<button id="queueSteerBtn" type="button" title="Queue steering is available while Run editor text is active." disabled>Queue steering</button>
|
|
12431
12468
|
</div>
|
|
12432
12469
|
<div class="source-actions-row repl-action-line" hidden>
|
|
@@ -12453,7 +12490,8 @@ ${cssVarsBlock}
|
|
|
12453
12490
|
<option value="current" selected>Suggestion model: current Pi model</option>
|
|
12454
12491
|
</select>
|
|
12455
12492
|
<button id="openCompanionBtn" type="button" title="Open a blank editor-only Studio tab.">New editor tab</button>
|
|
12456
|
-
<button id="sendEditorBtn" type="button">Send current text to Pi editor</button>
|
|
12493
|
+
<button id="sendEditorBtn" type="button" title="Replace the Pi terminal input draft with the current Studio text. A later Studio Run clears that staged draft only if it remains unchanged.">Send current text to Pi editor</button>
|
|
12494
|
+
<button id="clearPiEditorBtn" type="button" title="Clear text currently waiting in the Pi terminal input editor without changing Studio text or conversation history.">Clear Pi editor text…</button>
|
|
12457
12495
|
</div>
|
|
12458
12496
|
<div class="source-actions-row">
|
|
12459
12497
|
<button id="insertHeaderBtn" type="button" title="Insert annotated-reply protocol header (source metadata, [an: ...] syntax hint, precedence note, and end marker).">Annotation header</button>
|
|
@@ -12759,6 +12797,7 @@ ${cssVarsBlock}
|
|
|
12759
12797
|
<dl>
|
|
12760
12798
|
<div><dt>Cmd/Ctrl+S</dt><dd>Save editor when the disk revision still matches</dd></div>
|
|
12761
12799
|
<div><dt>Cmd/Ctrl+Shift+S</dt><dd>Save editor as a new file</dd></div>
|
|
12800
|
+
<div><dt>Cmd/Ctrl+Shift+L</dt><dd>Load the current Pi terminal input draft without clearing it</dd></div>
|
|
12762
12801
|
<div class="shortcuts-full-only"><dt>Cmd/Ctrl+Enter</dt><dd>Run editor text, or queue steering during an active run</dd></div>
|
|
12763
12802
|
<div><dt>Option/Alt+Tab or Cmd/Ctrl+Shift+Space</dt><dd>Suggest a completion at the editor cursor</dd></div>
|
|
12764
12803
|
<div><dt>Tab</dt><dd>Insert a visible completion suggestion; otherwise indent selected editor text</dd></div>
|
|
@@ -13649,6 +13688,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
13649
13688
|
}
|
|
13650
13689
|
};
|
|
13651
13690
|
|
|
13691
|
+
const reportPiEditorDraftDisposition = (
|
|
13692
|
+
client: WebSocket,
|
|
13693
|
+
requestId: string,
|
|
13694
|
+
snapshot: StudioPiEditorDraftSnapshot | undefined,
|
|
13695
|
+
) => {
|
|
13696
|
+
if (!snapshot) return;
|
|
13697
|
+
const result = consumeStudioPiEditorDraftSnapshot(lastCommandCtx?.ui, snapshot);
|
|
13698
|
+
if (result.status === "not-requested") return;
|
|
13699
|
+
emitDebugEvent("pi_editor_draft_disposition", { requestId, outcome: result.status });
|
|
13700
|
+
sendToClient(client, {
|
|
13701
|
+
type: "pi_editor_draft_result",
|
|
13702
|
+
requestId,
|
|
13703
|
+
outcome: result.status,
|
|
13704
|
+
piEditorDraftSnapshot: snapshot,
|
|
13705
|
+
});
|
|
13706
|
+
};
|
|
13707
|
+
|
|
13652
13708
|
const broadcast = (payload: unknown) => {
|
|
13653
13709
|
if (!serverState) return;
|
|
13654
13710
|
const serialized = JSON.stringify(payload);
|
|
@@ -15743,6 +15799,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
15743
15799
|
queuedSteeringCount: getQueuedStudioSteeringCount(),
|
|
15744
15800
|
});
|
|
15745
15801
|
broadcastState();
|
|
15802
|
+
reportPiEditorDraftDisposition(client, msg.requestId, msg.piEditorDraftSnapshot);
|
|
15746
15803
|
} catch (error) {
|
|
15747
15804
|
queuedStudioDirectRequests = queuedStudioDirectRequests.filter((request) => request.requestId !== msg.requestId);
|
|
15748
15805
|
if (studioDirectRunChain?.steeringPrompts.length) {
|
|
@@ -15766,6 +15823,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
15766
15823
|
|
|
15767
15824
|
try {
|
|
15768
15825
|
pi.sendUserMessage(msg.text);
|
|
15826
|
+
reportPiEditorDraftDisposition(client, msg.requestId, msg.piEditorDraftSnapshot);
|
|
15769
15827
|
} catch (error) {
|
|
15770
15828
|
clearStudioDirectRunState();
|
|
15771
15829
|
clearActiveRequest();
|
|
@@ -16510,7 +16568,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
16510
16568
|
sendToClient(client, {
|
|
16511
16569
|
type: "editor_loaded",
|
|
16512
16570
|
requestId: msg.requestId,
|
|
16513
|
-
message: "Draft loaded into pi editor.",
|
|
16571
|
+
message: "Draft loaded into pi editor. A Studio Run will clear it if it remains unchanged.",
|
|
16572
|
+
piEditorDraftSnapshot: createStudioPiEditorDraftSnapshot(msg.content),
|
|
16514
16573
|
});
|
|
16515
16574
|
} catch (error) {
|
|
16516
16575
|
sendToClient(client, {
|
|
@@ -16546,6 +16605,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
16546
16605
|
type: "editor_snapshot",
|
|
16547
16606
|
requestId: msg.requestId,
|
|
16548
16607
|
content,
|
|
16608
|
+
piEditorDraftSnapshot: createStudioPiEditorDraftSnapshot(content),
|
|
16549
16609
|
});
|
|
16550
16610
|
} catch (error) {
|
|
16551
16611
|
sendToClient(client, {
|
|
@@ -16556,6 +16616,43 @@ export default function (pi: ExtensionAPI) {
|
|
|
16556
16616
|
}
|
|
16557
16617
|
return;
|
|
16558
16618
|
}
|
|
16619
|
+
|
|
16620
|
+
if (msg.type === "clear_pi_editor_request") {
|
|
16621
|
+
if (!isValidRequestId(msg.requestId)) {
|
|
16622
|
+
sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
|
|
16623
|
+
return;
|
|
16624
|
+
}
|
|
16625
|
+
if (isStudioBusy()) {
|
|
16626
|
+
sendToClient(client, { type: "busy", requestId: msg.requestId, message: "Studio is busy." });
|
|
16627
|
+
return;
|
|
16628
|
+
}
|
|
16629
|
+
if (!lastCommandCtx || !lastCommandCtx.hasUI) {
|
|
16630
|
+
sendToClient(client, {
|
|
16631
|
+
type: "error",
|
|
16632
|
+
requestId: msg.requestId,
|
|
16633
|
+
message: "No interactive pi editor context is available.",
|
|
16634
|
+
});
|
|
16635
|
+
return;
|
|
16636
|
+
}
|
|
16637
|
+
|
|
16638
|
+
try {
|
|
16639
|
+
const hadContent = lastCommandCtx.ui.getEditorText().length > 0;
|
|
16640
|
+
if (hadContent) lastCommandCtx.ui.setEditorText("");
|
|
16641
|
+
lastCommandCtx.ui.notify(hadContent ? "Pi editor text cleared from Studio." : "Pi editor text is already empty.", "info");
|
|
16642
|
+
sendToClient(client, {
|
|
16643
|
+
type: "pi_editor_cleared",
|
|
16644
|
+
requestId: msg.requestId,
|
|
16645
|
+
cleared: hadContent,
|
|
16646
|
+
});
|
|
16647
|
+
} catch (error) {
|
|
16648
|
+
sendToClient(client, {
|
|
16649
|
+
type: "error",
|
|
16650
|
+
requestId: msg.requestId,
|
|
16651
|
+
message: `Failed to clear pi editor text: ${error instanceof Error ? error.message : String(error)}`,
|
|
16652
|
+
});
|
|
16653
|
+
}
|
|
16654
|
+
return;
|
|
16655
|
+
}
|
|
16559
16656
|
};
|
|
16560
16657
|
|
|
16561
16658
|
const disposePreparedPdfExport = (entry: PreparedStudioPdfExport | null | undefined) => {
|
|
@@ -17357,12 +17454,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
17357
17454
|
return;
|
|
17358
17455
|
}
|
|
17359
17456
|
|
|
17360
|
-
|
|
17361
|
-
|
|
17362
|
-
|
|
17363
|
-
requestUrl = new URL(req.url ?? "/", `http://${host}`);
|
|
17364
|
-
} catch (error) {
|
|
17365
|
-
respondText(res, 400, `Invalid request URL: ${error instanceof Error ? error.message : String(error)}`);
|
|
17457
|
+
const requestUrl = parseStudioRequestTarget(req.url);
|
|
17458
|
+
if (!requestUrl) {
|
|
17459
|
+
respondText(res, 400, "Invalid request URL");
|
|
17366
17460
|
return;
|
|
17367
17461
|
}
|
|
17368
17462
|
|
|
@@ -17955,9 +18049,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
17955
18049
|
res.end(buildStudioHtml(requestInitialDocument, serverState.token, lastCommandCtx?.ui.theme, currentModelLabel, terminalSessionLabel, terminalSessionDetail, contextUsageSnapshot, studioMode));
|
|
17956
18050
|
};
|
|
17957
18051
|
|
|
17958
|
-
const ensureServer = async (requestedPort?: number): Promise<StudioServerState> => {
|
|
18052
|
+
const ensureServer = async (requestedPort?: number, listenAll = false): Promise<StudioServerState> => {
|
|
17959
18053
|
if (serverState) return serverState;
|
|
17960
18054
|
|
|
18055
|
+
const binding = resolveStudioNetworkBinding(listenAll);
|
|
17961
18056
|
const server = createServer(handleHttpRequest);
|
|
17962
18057
|
const wsServer = new WebSocketServer({ noServer: true });
|
|
17963
18058
|
const clients = new Set<WebSocket>();
|
|
@@ -17969,12 +18064,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
17969
18064
|
clients,
|
|
17970
18065
|
clientModes,
|
|
17971
18066
|
port: 0,
|
|
18067
|
+
bindHost: binding.bindHost,
|
|
18068
|
+
listenAll: binding.listenAll,
|
|
17972
18069
|
token: createSessionToken(),
|
|
17973
18070
|
};
|
|
17974
18071
|
|
|
17975
18072
|
server.on("upgrade", (req, socket, head) => {
|
|
17976
|
-
const
|
|
17977
|
-
|
|
18073
|
+
const requestUrl = parseStudioRequestTarget(req.url);
|
|
18074
|
+
if (!requestUrl) {
|
|
18075
|
+
socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
18076
|
+
socket.destroy();
|
|
18077
|
+
return;
|
|
18078
|
+
}
|
|
17978
18079
|
|
|
17979
18080
|
if (requestUrl.pathname !== "/ws") {
|
|
17980
18081
|
socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
|
|
@@ -17989,7 +18090,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
17989
18090
|
return;
|
|
17990
18091
|
}
|
|
17991
18092
|
|
|
17992
|
-
|
|
18093
|
+
const origin = typeof req.headers.origin === "string" ? req.headers.origin : undefined;
|
|
18094
|
+
const requestHost = typeof req.headers.host === "string" ? req.headers.host : undefined;
|
|
18095
|
+
if (!isStudioWebSocketOriginAllowed(origin, requestHost, state.listenAll)) {
|
|
17993
18096
|
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
17994
18097
|
socket.destroy();
|
|
17995
18098
|
return;
|
|
@@ -18001,8 +18104,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
18001
18104
|
});
|
|
18002
18105
|
|
|
18003
18106
|
wsServer.on("connection", (ws, req) => {
|
|
18004
|
-
const
|
|
18005
|
-
|
|
18107
|
+
const requestUrl = parseStudioRequestTarget(req.url);
|
|
18108
|
+
if (!requestUrl) {
|
|
18109
|
+
try {
|
|
18110
|
+
ws.close(1008, "Invalid request URL");
|
|
18111
|
+
} catch {
|
|
18112
|
+
// Ignore close errors.
|
|
18113
|
+
}
|
|
18114
|
+
return;
|
|
18115
|
+
}
|
|
18006
18116
|
const clientMode = normalizeStudioUiMode(requestUrl.searchParams.get("mode"));
|
|
18007
18117
|
const requestedWatchedPath = (requestUrl.searchParams.get("watchPath") ?? "").trim();
|
|
18008
18118
|
const canonicalWatchedPath = requestedWatchedPath ? resolve(requestedWatchedPath) : "";
|
|
@@ -18101,7 +18211,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
18101
18211
|
};
|
|
18102
18212
|
server.once("error", onError);
|
|
18103
18213
|
server.once("listening", onListening);
|
|
18104
|
-
server.listen(listenPort,
|
|
18214
|
+
server.listen(listenPort, state.bindHost);
|
|
18105
18215
|
});
|
|
18106
18216
|
|
|
18107
18217
|
const address = server.address();
|
|
@@ -18974,6 +19084,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
18974
19084
|
if (serverState && launchOpenFlags.port && serverState.port !== launchOpenFlags.port) {
|
|
18975
19085
|
ctx.ui.notify(`Studio server is already running on port ${serverState.port}; requested port ${launchOpenFlags.port}. Use /studio --stop, then restart Studio with --port ${launchOpenFlags.port} to change it.`, "warning");
|
|
18976
19086
|
}
|
|
19087
|
+
if (serverState && launchOpenFlags.listenAll && !serverState.listenAll) {
|
|
19088
|
+
ctx.ui.notify(`Studio server is already bound to localhost at 127.0.0.1:${serverState.port}. Use /studio --stop, then restart it with --listen-all.`, "warning");
|
|
19089
|
+
return;
|
|
19090
|
+
}
|
|
18977
19091
|
|
|
18978
19092
|
const parsedLaunchPath = options?.allowPdfPreview ? parsePathArgument(launchArgs) : null;
|
|
18979
19093
|
const launchesPdfPreview = parsedLaunchPath
|
|
@@ -18993,9 +19107,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
18993
19107
|
if (serverState) {
|
|
18994
19108
|
const url = buildStudioUrl(serverState.port, serverState.token, "full");
|
|
18995
19109
|
ctx.ui.notify(`Studio URL: ${url}`, "info");
|
|
18996
|
-
const tunnelHint =
|
|
18997
|
-
|
|
19110
|
+
const tunnelHint = serverState.listenAll
|
|
19111
|
+
? null
|
|
19112
|
+
: buildStudioSshTunnelHint(serverState.port, url)
|
|
19113
|
+
?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(serverState.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
|
|
18998
19114
|
if (tunnelHint) ctx.ui.notify(tunnelHint, "info");
|
|
19115
|
+
if (serverState.listenAll) ctx.ui.notify(buildStudioListenAllWarning(serverState.port, url), "warning");
|
|
18999
19116
|
}
|
|
19000
19117
|
return;
|
|
19001
19118
|
}
|
|
@@ -19028,7 +19145,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
19028
19145
|
|
|
19029
19146
|
let state: StudioServerState;
|
|
19030
19147
|
try {
|
|
19031
|
-
state = await ensureServer(launchOpenFlags.port);
|
|
19148
|
+
state = await ensureServer(launchOpenFlags.port, launchOpenFlags.listenAll);
|
|
19032
19149
|
} catch (error) {
|
|
19033
19150
|
const message = error instanceof Error ? error.message : String(error);
|
|
19034
19151
|
const portText = launchOpenFlags.port ? ` on port ${launchOpenFlags.port}` : "";
|
|
@@ -19041,8 +19158,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
19041
19158
|
skipWorkspaceRestore: selection.skipWorkspaceRestore,
|
|
19042
19159
|
paneFocus: selection.paneFocus,
|
|
19043
19160
|
});
|
|
19044
|
-
const tunnelHint =
|
|
19045
|
-
|
|
19161
|
+
const tunnelHint = state.listenAll
|
|
19162
|
+
? null
|
|
19163
|
+
: buildStudioSshTunnelHint(state.port, url)
|
|
19164
|
+
?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(state.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
|
|
19046
19165
|
const openedLabel = selection.kind === "pdf-preview"
|
|
19047
19166
|
? "pi Studio PDF preview"
|
|
19048
19167
|
: (selection.kind === "watched-preview"
|
|
@@ -19082,11 +19201,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
19082
19201
|
} finally {
|
|
19083
19202
|
ctx.ui.notify(`Studio URL: ${url}`, "info");
|
|
19084
19203
|
if (tunnelHint) ctx.ui.notify(tunnelHint, "info");
|
|
19204
|
+
if (state.listenAll) ctx.ui.notify(buildStudioListenAllWarning(state.port, url), "warning");
|
|
19085
19205
|
}
|
|
19086
19206
|
};
|
|
19087
19207
|
|
|
19088
19208
|
pi.registerCommand("studio", {
|
|
19089
|
-
description: "Open pi Studio browser UI or a read-only watched preview (/studio, /studio <file>, /studio --watch <file>, /studio --blank, /studio --last, /studio --no-browser)",
|
|
19209
|
+
description: "Open pi Studio browser UI or a read-only watched preview (/studio, /studio <file>, /studio --watch <file>, /studio --blank, /studio --last, /studio --no-browser, /studio --listen-all)",
|
|
19090
19210
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
19091
19211
|
const trimmed = args.trim();
|
|
19092
19212
|
|
|
@@ -19104,11 +19224,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
19104
19224
|
const counts = getStudioClientCounts();
|
|
19105
19225
|
const url = buildStudioUrl(serverState.port, serverState.token, "full");
|
|
19106
19226
|
ctx.ui.notify(
|
|
19107
|
-
`Studio running at ${url} (busy: ${isStudioBusy() ? "yes" : "no"}; full views: ${counts.full}; editor-only views: ${counts.editorOnly})`,
|
|
19227
|
+
`Studio running at ${url} (listening on ${serverState.bindHost}:${serverState.port}; busy: ${isStudioBusy() ? "yes" : "no"}; full views: ${counts.full}; editor-only views: ${counts.editorOnly})`,
|
|
19108
19228
|
"info",
|
|
19109
19229
|
);
|
|
19110
|
-
const sshTunnelHint = buildStudioSshTunnelHint(serverState.port, url);
|
|
19230
|
+
const sshTunnelHint = serverState.listenAll ? null : buildStudioSshTunnelHint(serverState.port, url);
|
|
19111
19231
|
if (sshTunnelHint) ctx.ui.notify(sshTunnelHint, "info");
|
|
19232
|
+
if (serverState.listenAll) ctx.ui.notify(buildStudioListenAllWarning(serverState.port, url), "warning");
|
|
19112
19233
|
return;
|
|
19113
19234
|
}
|
|
19114
19235
|
|
|
@@ -19121,7 +19242,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
19121
19242
|
+ " /studio --blank Open with blank editor\n"
|
|
19122
19243
|
+ " /studio --last Open with last model response\n"
|
|
19123
19244
|
+ " /studio --no-browser Print the Studio URL without opening a browser\n"
|
|
19124
|
-
+ " /studio --port <port> Bind Studio to a fixed
|
|
19245
|
+
+ " /studio --port <port> Bind Studio to a fixed port when starting\n"
|
|
19246
|
+
+ " /studio --listen-all Explicitly bind to 0.0.0.0 instead of localhost (security-sensitive)\n"
|
|
19125
19247
|
+ " /studio --open-remote Over SSH, open the remote browser anyway\n"
|
|
19126
19248
|
+ " /studio --status Show studio status\n"
|
|
19127
19249
|
+ " /studio --stop Stop studio server\n"
|
|
@@ -19152,7 +19274,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
19152
19274
|
+ " /studio-replace --blank Replace with blank editor\n"
|
|
19153
19275
|
+ " /studio-replace --last Replace with last model response\n"
|
|
19154
19276
|
+ " /studio-replace --no-browser Print URL without opening a browser\n"
|
|
19155
|
-
+ " /studio-replace --port <port> Bind Studio to a fixed
|
|
19277
|
+
+ " /studio-replace --port <port> Bind Studio to a fixed port when starting\n"
|
|
19278
|
+
+ " /studio-replace --listen-all Explicitly bind to 0.0.0.0 instead of localhost\n"
|
|
19156
19279
|
+ "Editor-only Studio views stay open.",
|
|
19157
19280
|
"info",
|
|
19158
19281
|
);
|
|
@@ -19180,7 +19303,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
19180
19303
|
+ " /studio-editor-only --blank Open with blank editor\n"
|
|
19181
19304
|
+ " /studio-editor-only --last Open with last model response loaded into the editor\n"
|
|
19182
19305
|
+ " /studio-editor-only --no-browser Print URL without opening a browser\n"
|
|
19183
|
-
+ " /studio-editor-only --port <port> Bind Studio to a fixed
|
|
19306
|
+
+ " /studio-editor-only --port <port> Bind Studio to a fixed port when starting\n"
|
|
19307
|
+
+ " /studio-editor-only --listen-all Explicitly bind to 0.0.0.0 instead of localhost\n"
|
|
19184
19308
|
+ "Multiple editor-only views are allowed in the same Pi session.",
|
|
19185
19309
|
"info",
|
|
19186
19310
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-studio",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.60-rc.0",
|
|
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",
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export const STUDIO_LOOPBACK_BIND_HOST = "127.0.0.1";
|
|
2
|
+
export const STUDIO_ALL_INTERFACES_BIND_HOST = "0.0.0.0";
|
|
3
|
+
export const STUDIO_ADVERTISED_HOST = STUDIO_LOOPBACK_BIND_HOST;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolve Studio's server-lifetime network binding. Listening on every IPv4
|
|
7
|
+
* interface is deliberately opt-in; generated browser URLs remain loopback URLs
|
|
8
|
+
* so same-port container publishing does not require URL editing.
|
|
9
|
+
*
|
|
10
|
+
* @param {boolean} [listenAll]
|
|
11
|
+
* @returns {{ bindHost: string, advertisedHost: string, listenAll: boolean }}
|
|
12
|
+
*/
|
|
13
|
+
export function resolveStudioNetworkBinding(listenAll = false) {
|
|
14
|
+
const enabled = listenAll === true;
|
|
15
|
+
return {
|
|
16
|
+
bindHost: enabled ? STUDIO_ALL_INTERFACES_BIND_HOST : STUDIO_LOOPBACK_BIND_HOST,
|
|
17
|
+
advertisedHost: STUDIO_ADVERTISED_HOST,
|
|
18
|
+
listenAll: enabled,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse only an HTTP origin-form request target. The inbound Host header is not
|
|
24
|
+
* needed for Studio routing and must not become the base URL authority.
|
|
25
|
+
*
|
|
26
|
+
* @param {string | undefined} requestTarget
|
|
27
|
+
* @returns {URL | null}
|
|
28
|
+
*/
|
|
29
|
+
export function parseStudioRequestTarget(requestTarget) {
|
|
30
|
+
const value = typeof requestTarget === "string" ? requestTarget : "/";
|
|
31
|
+
if (!value.startsWith("/") || value.startsWith("//")) return null;
|
|
32
|
+
try {
|
|
33
|
+
const expectedOrigin = `http://${STUDIO_ADVERTISED_HOST}`;
|
|
34
|
+
const parsed = new URL(value, expectedOrigin);
|
|
35
|
+
return parsed.origin === expectedOrigin ? parsed : null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Preserve the existing permissive Origin behavior for the localhost server.
|
|
43
|
+
* In wildcard mode, an omitted Origin remains compatible with embedded and
|
|
44
|
+
* non-browser clients; a supplied browser Origin must be HTTP(S) and match the
|
|
45
|
+
* request's Host authority so a cross-origin page cannot drive the WebSocket.
|
|
46
|
+
*
|
|
47
|
+
* @param {string | undefined} originHeader
|
|
48
|
+
* @param {string | undefined} requestHost
|
|
49
|
+
* @param {boolean} [requireSameHost]
|
|
50
|
+
* @returns {boolean}
|
|
51
|
+
*/
|
|
52
|
+
export function isStudioWebSocketOriginAllowed(originHeader, requestHost, requireSameHost = false) {
|
|
53
|
+
if (!originHeader || !requireSameHost) return true;
|
|
54
|
+
let originUrl;
|
|
55
|
+
try {
|
|
56
|
+
originUrl = new URL(originHeader);
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
if (originUrl.protocol !== "http:" && originUrl.protocol !== "https:") return false;
|
|
61
|
+
if (
|
|
62
|
+
originUrl.username
|
|
63
|
+
|| originUrl.password
|
|
64
|
+
|| originUrl.pathname !== "/"
|
|
65
|
+
|| originUrl.search
|
|
66
|
+
|| originUrl.hash
|
|
67
|
+
) return false;
|
|
68
|
+
if (typeof requestHost !== "string" || requestHost.length === 0) return false;
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const requestOrigin = new URL(`http://${requestHost}`);
|
|
72
|
+
if (
|
|
73
|
+
requestOrigin.username
|
|
74
|
+
|| requestOrigin.password
|
|
75
|
+
|| requestOrigin.pathname !== "/"
|
|
76
|
+
|| requestOrigin.search
|
|
77
|
+
|| requestOrigin.hash
|
|
78
|
+
) return false;
|
|
79
|
+
return originUrl.host.toLowerCase() === requestOrigin.host.toLowerCase();
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {number} port
|
|
87
|
+
* @param {string} [studioUrl]
|
|
88
|
+
* @returns {string}
|
|
89
|
+
*/
|
|
90
|
+
export function buildStudioListenAllWarning(port, studioUrl) {
|
|
91
|
+
const normalizedPort = Number.isInteger(port) && port > 0 && port <= 65535
|
|
92
|
+
? port
|
|
93
|
+
: "<port>";
|
|
94
|
+
const urlLine = typeof studioUrl === "string" && studioUrl.length > 0
|
|
95
|
+
? `\nStudio URL: ${studioUrl}`
|
|
96
|
+
: "";
|
|
97
|
+
return "Security warning: pi Studio is listening on all IPv4 interfaces at "
|
|
98
|
+
+ `${STUDIO_ALL_INTERFACES_BIND_HOST}:${normalizedPort}. `
|
|
99
|
+
+ "Anyone who obtains the tokenized Studio URL can control Studio for this Pi process, including submitting prompts, reading or writing files through Studio, and expanding local-resource access. "
|
|
100
|
+
+ "Treat the URL like a password. Expose this port only through a trusted local mapping or private network, and prefer SSH tunnelling on untrusted networks. "
|
|
101
|
+
+ "Use /studio --stop before restarting without --listen-all to return to localhost-only mode."
|
|
102
|
+
+ urlLine;
|
|
103
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const STUDIO_PI_EDITOR_DRAFT_FINGERPRINT_PATTERN = /^sha256:[a-f0-9]{64}$/;
|
|
4
|
+
|
|
5
|
+
export function createStudioPiEditorDraftSnapshot(content) {
|
|
6
|
+
const buffer = Buffer.from(String(content ?? ""), "utf8");
|
|
7
|
+
return Object.freeze({
|
|
8
|
+
fingerprint: `sha256:${createHash("sha256").update(buffer).digest("hex")}`,
|
|
9
|
+
byteLength: buffer.length,
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function normalizeStudioPiEditorDraftSnapshot(value) {
|
|
14
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
15
|
+
const fingerprint = typeof value.fingerprint === "string"
|
|
16
|
+
? value.fingerprint.trim().toLowerCase()
|
|
17
|
+
: "";
|
|
18
|
+
const byteLength = value.byteLength;
|
|
19
|
+
if (!STUDIO_PI_EDITOR_DRAFT_FINGERPRINT_PATTERN.test(fingerprint)) return null;
|
|
20
|
+
if (typeof byteLength !== "number" || !Number.isSafeInteger(byteLength) || byteLength < 0) return null;
|
|
21
|
+
return Object.freeze({ fingerprint, byteLength });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function studioPiEditorDraftMatchesSnapshot(content, snapshot) {
|
|
25
|
+
const expected = normalizeStudioPiEditorDraftSnapshot(snapshot);
|
|
26
|
+
if (!expected) return false;
|
|
27
|
+
const current = createStudioPiEditorDraftSnapshot(content);
|
|
28
|
+
return current.byteLength === expected.byteLength && current.fingerprint === expected.fingerprint;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function consumeStudioPiEditorDraftSnapshot(ui, snapshot) {
|
|
32
|
+
const expected = normalizeStudioPiEditorDraftSnapshot(snapshot);
|
|
33
|
+
if (!expected) return Object.freeze({ status: "not-requested" });
|
|
34
|
+
if (!ui || typeof ui.getEditorText !== "function" || typeof ui.setEditorText !== "function") {
|
|
35
|
+
return Object.freeze({ status: "unavailable" });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const current = String(ui.getEditorText() ?? "");
|
|
40
|
+
if (current.length === 0) return Object.freeze({ status: "already-empty" });
|
|
41
|
+
if (!studioPiEditorDraftMatchesSnapshot(current, expected)) {
|
|
42
|
+
return Object.freeze({ status: "changed" });
|
|
43
|
+
}
|
|
44
|
+
ui.setEditorText("");
|
|
45
|
+
return Object.freeze({ status: "cleared" });
|
|
46
|
+
} catch {
|
|
47
|
+
return Object.freeze({ status: "unavailable" });
|
|
48
|
+
}
|
|
49
|
+
}
|