pi-studio 0.9.59 → 0.9.60
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 +24 -0
- package/README.md +21 -7
- package/ROADMAP.md +67 -11
- package/WORKFLOW.md +4 -2
- package/client/studio-client.js +131 -17
- package/client/studio-editor-draft-helpers.js +64 -0
- package/index.ts +105 -57
- package/package.json +1 -1
- package/shared/studio-network-binding.js +103 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,30 @@ All notable changes to `pi-studio` are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.9.60] — 2026-09-07
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Promote the opt-in `--listen-all` networking support from `0.9.60-rc.0` to stable: default binding remains localhost, wildcard binding requires explicit intent and token authentication, and status reports the actual bind address. See the RC security notes below for the exposure and Origin-check boundaries.
|
|
11
|
+
- Add `Cmd/Ctrl+Option/Alt+A` to toggle the browser-stored **Follow activity** preference from the main editable Studio workspace.
|
|
12
|
+
- Add `Cmd/Ctrl+Option/Alt+Enter` for **Annotate response** from either main pane, retaining the existing action's availability and modal guards without changing Run, REPL, or Side question shortcuts. Add Shift (`Cmd/Ctrl+Option/Alt+Shift+Enter`) for **Load response into editor** without switching views, through the existing button and the same replacement safeguards. All three shortcuts ignore held-key repeats and text composition. View-preserving Load refuses to detach the file required by Quarto Preview; choose another view first or use Annotate response.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- Use **Option/Alt** consistently in current shortcut labels, tooltips, and documentation. Keyboard behavior and standard accessibility key names are unchanged.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- Protect unsubmitted editor changes and unsaved file edits before **Annotate response** or **Load response into editor** replaces them. Keep unchanged accepted prompts confirmation-free using bounded, page-memory-only request-correlated snapshots; failed submissions and late acknowledgements cannot mark newer edits as submitted, and submission never marks file text saved. Cancel preserves text, source identity, and view; confirmation is revalidated against the editor and selected response before replacement. Pi-terminal draft-cleanup provenance remains separate.
|
|
19
|
+
|
|
20
|
+
### Validation note
|
|
21
|
+
- Production network lifecycle and TCP-forwarding checks pass, but exact `sbx` confirmation from the issue #4 reporter is still outstanding. The issue remains open for confirmation or a follow-up fix; this release does not claim that environment has been tested directly.
|
|
22
|
+
|
|
23
|
+
## [0.9.60-rc.0] — 2026-09-03
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- 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.
|
|
27
|
+
|
|
28
|
+
### Security
|
|
29
|
+
- 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.
|
|
30
|
+
|
|
7
31
|
## [0.9.59] — 2026-09-03
|
|
8
32
|
|
|
9
33
|
### Added
|
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ _The video shows an earlier version of the Studio interface. The basic workflow
|
|
|
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
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
|
|
39
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.
|
|
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
|
+
- 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** (`Cmd/Ctrl+Option/Alt+A`; 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+Option/Alt+1–8` switches directly between right-pane views while `Cmd/Ctrl+Option/Alt+P` / `Cmd/Ctrl+Option/Alt+E` / `Cmd/Ctrl+Option/Alt+W` / `Cmd/Ctrl+Option/Alt+F` / `Cmd/Ctrl+Option/Alt+R` / `Cmd/Ctrl+Option/Alt+Q` keep quick mnemonic shortcuts for Response Preview, Editor Preview, Working, Files, REPL, and Side questions
|
|
41
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
|
|
42
42
|
- Includes a right-pane **Files** view rooted in folders allowed for the current Studio session, with an **Allow folder…** action and location selector; exact-file grants can still be opened independently without exposing their parent folders. Files supports sorting by name/modified time/size, opening folders or the current root in Finder/the system file manager, loading text/code/CSV/TSV documents into the editor, opening a read-only **Preview (follow)** tab whose disk content is authoritative, previewing PDFs/images, opening previews in new Studio tabs, converting DOCX/ODT documents to editable Markdown when Pandoc is available after confirmation, copying paths, setting the current folder as the Studio working directory, and revealing files in the file manager
|
|
43
43
|
- Imports detached text-file copies either through the browser's file picker or from a path on the computer running Pi, keeping the path option available in embedded and remote browser views; imported copies remain detached until saved or opened as file-backed documents
|
|
@@ -50,9 +50,10 @@ _The video shows an earlier version of the Studio interface. The basic workflow
|
|
|
50
50
|
- Turns local preview links, including links inside sandboxed HTML previews, into Studio actions: PDFs open in the embedded viewer, images open in a zoomable focus viewer, PDF/image links can open in a new Studio preview tab, text/code/CSV/TSV document links can open in a new editor tab, DOCX/ODT links can be converted to editable Markdown, and right-click menus provide **Open here**, **Reveal in file manager**, and **Copy path** for local resources
|
|
51
51
|
- Includes local comments anchored to selections/lines, shown in a docked **Comments** rail, with transient **Comment** / **Jump** actions from raw-editor selections plus editor-preview selections for Markdown, LaTeX, code/text/diff previews, and an opt-in comment mode for editor HTML previews; source-anchored comments can be toggled into inline `[an: ...]` annotations when you want comments reflected in the document text
|
|
52
52
|
- Browses response history (`Prev/Next/Last`) and loads either:
|
|
53
|
-
- response text, with
|
|
53
|
+
- response text, with **Annotate response** (`Cmd/Ctrl+Option/Alt+Enter` from either main pane) also opening **Editor (Preview)**; add Shift (`Cmd/Ctrl+Option/Alt+Shift+Enter`) for **Load response into editor** without switching views (choose another view first if file-backed Quarto Preview is active)
|
|
54
54
|
- critique notes/full critique
|
|
55
55
|
- the prompt that generated a selected response
|
|
56
|
+
- Keeps the prompt → accepted Run → **Annotate response** loop confirmation-free when the submitted editor text is unchanged. **Annotate response** and **Load response into editor** ask before replacing unsubmitted edits or unsaved file changes; Cancel preserves the editor and its view so you can save or copy first. A failed submission or late acknowledgement cannot mark later typing as submitted, and sending file text does not save it. This bounded accepted-prompt baseline stays in page memory, is not restored after a page reload, and is separate from Pi-terminal draft cleanup.
|
|
56
57
|
- Supports an annotation workflow for `[an: ...]` markers:
|
|
57
58
|
- inserts/removes the annotated-reply header
|
|
58
59
|
- shows/hides annotation markers in preview
|
|
@@ -83,8 +84,9 @@ When Pi accepts **Run editor text**, Studio clears the linked terminal draft onl
|
|
|
83
84
|
| `/studio --last` | Force last response |
|
|
84
85
|
| `/studio --blank` | Force blank editor |
|
|
85
86
|
| `/studio --no-browser` | Start/print the Studio URL without opening a browser, useful for forwarded or phone/browser sessions |
|
|
86
|
-
| `/studio --port <port>` | Bind Studio to a fixed
|
|
87
|
-
| `/studio --
|
|
87
|
+
| `/studio --port <port>` | Bind Studio to a fixed port instead of a random free port; the default host remains localhost |
|
|
88
|
+
| `/studio --listen-all` | Explicitly bind Studio to all IPv4 interfaces (`0.0.0.0`) for a container or trusted private-network workflow |
|
|
89
|
+
| `/studio --status` | Show studio server status, including its listening address |
|
|
88
90
|
| `/studio --stop` | Stop studio server |
|
|
89
91
|
| `/studio --help` | Show help |
|
|
90
92
|
| `/studio-replace [path\|--blank\|--last]` | Replace the current full Studio view with a new full Studio view |
|
|
@@ -118,6 +120,18 @@ Run once without installing:
|
|
|
118
120
|
pi -e https://github.com/omaclaren/pi-studio
|
|
119
121
|
```
|
|
120
122
|
|
|
123
|
+
## Container and network access
|
|
124
|
+
|
|
125
|
+
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:
|
|
126
|
+
|
|
127
|
+
```text
|
|
128
|
+
/studio --no-browser --listen-all --port 4321
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
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.
|
|
132
|
+
|
|
133
|
+
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.
|
|
134
|
+
|
|
121
135
|
## Shared REPL record
|
|
122
136
|
|
|
123
137
|
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.
|
|
@@ -146,7 +160,7 @@ See [`shared/REPL_SESSION_RECORD_PROTOCOL.md`](./shared/REPL_SESSION_RECORD_PROT
|
|
|
146
160
|
|
|
147
161
|
## Side questions and research context
|
|
148
162
|
|
|
149
|
-
Open **Review → Side question**, select **Side questions** in the right pane, or press **Cmd/Ctrl+Alt+Q**. A new side thread captures its starting text while keeping related-file access separate:
|
|
163
|
+
Open **Review → Side question**, select **Side questions** in the right pane, or press **Cmd/Ctrl+Option/Alt+Q**. A new side thread captures its starting text while keeping related-file access separate:
|
|
150
164
|
|
|
151
165
|
- **Starting text** controls what unsaved text begins the thread. The compact **Automatic: selection → heading block at cursor → nearby text** disclosure explains the fallback order; when expanded, it defines the heading boundary. The summary below reports the resolved starting text, editor line range when applicable, and character count separately from the related-file scope.
|
|
152
166
|
- Explicit **Editor selection only** no longer falls back to the whole document when no text is selected; the summary instead reports that no editor text is selected.
|
|
@@ -186,7 +200,7 @@ caption: Optional caption
|
|
|
186
200
|
|
|
187
201
|
`path` must point to a local `.pdf`. Relative paths resolve from the opened document's directory, or from Studio's working directory for non-file-backed content; the resolved file must be inside a location allowed for the current Studio session before it loads. `page` is an initial page hint for the browser PDF viewer, `height` controls the embedded frame height in pixels, and `watch: true` enables auto-refresh after Studio observes the changed file in a stable state. Use normal Markdown links for PDFs when embedding is not useful.
|
|
188
202
|
|
|
189
|
-
To view an existing PDF directly, run `/studio report.pdf` (or `/studio "path with spaces/report.pdf"`). The PDF opens read-only in a focused companion preview, even when the full Studio workspace is already open. A `#page=N` suffix selects the initial page. Auto-refresh is off by default; use `/studio --watch report.pdf` to start with it on, or toggle **Auto-refresh** in the PDF card or focused viewer. Studio checks only while the tab is visible and waits for two matching file-version observations before reloading, which avoids reading a PDF while LaTeX is still writing it. **Cmd/Ctrl+Alt+Shift+R** manually refreshes the focused or visible PDF. This is distinct from `/studio-pdf`, which exports Markdown, LaTeX, code, or the last response to a new PDF.
|
|
203
|
+
To view an existing PDF directly, run `/studio report.pdf` (or `/studio "path with spaces/report.pdf"`). The PDF opens read-only in a focused companion preview, even when the full Studio workspace is already open. A `#page=N` suffix selects the initial page. Auto-refresh is off by default; use `/studio --watch report.pdf` to start with it on, or toggle **Auto-refresh** in the PDF card or focused viewer. Studio checks only while the tab is visible and waits for two matching file-version observations before reloading, which avoids reading a PDF while LaTeX is still writing it. **Cmd/Ctrl+Option/Alt+Shift+R** manually refreshes the focused or visible PDF. This is distinct from `/studio-pdf`, which exports Markdown, LaTeX, code, or the last response to a new PDF.
|
|
190
204
|
|
|
191
205
|
For a disk-authoritative text or code preview, run `/studio --watch notes.md` or choose **Preview file (follow changes)** from a local-link menu / **Preview (follow)** in Files. This opens a transient read-only companion tab with the preview pane focused; leaving pane focus reveals the same disk-following text as **Source** with a visible **Read-only · follows disk** badge rather than as an editable buffer. Studio polls the canonical file, debounces change bursts, compares SHA-256 content revisions, accepts ordinary and atomic-replacement saves, and never writes back. Each watcher socket is bound to the exact path in a bounded, expiring server-created tab capability; if that identity expires before a reload or reconnect, the tab stays read-only and asks you to open a fresh watched preview. A missing, unreadable, oversized, binary, or path-replaced source leaves the last good preview visible and automatically recovers when the same canonical file becomes readable again. Render failures likewise retain the last successfully staged document rather than replacing it with partial output. On successful updates, Studio restores a nearby stable content anchor where possible and falls back to the previous scroll ratio.
|
|
192
206
|
|
|
@@ -220,7 +234,7 @@ Thanks to [Hal Gumbert / CampSoftware](https://github.com/campsoftware) for publ
|
|
|
220
234
|
|
|
221
235
|
## Notes
|
|
222
236
|
|
|
223
|
-
-
|
|
237
|
+
- 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.
|
|
224
238
|
- 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.
|
|
225
239
|
- 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`.
|
|
226
240
|
- 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
|
@@ -6,7 +6,7 @@ This is the current planning source of truth for Studio work. `NEXT-STAGE.md` is
|
|
|
6
6
|
|
|
7
7
|
Studio development follows two tracks:
|
|
8
8
|
|
|
9
|
-
- **Stable `0.9.x` releases** contain
|
|
9
|
+
- **Stable `0.9.x` releases** contain a bounded, explicitly scoped batch of fixes or incremental features and keep `main` releasable.
|
|
10
10
|
- **`0.10.0`** is reserved for the buffer-first document architecture. Risky prereleases should use the npm `next` tag before replacing `latest`.
|
|
11
11
|
|
|
12
12
|
Changes should be based on capabilities rather than browser, terminal host, or user-agent detection. Muxy, ordinary Chromium browsers, embedded WebKit views, and SSH/headless use are validation environments, not separate product variants.
|
|
@@ -19,7 +19,19 @@ Each release should finish with:
|
|
|
19
19
|
4. exact package-content and byte-level artifact audit;
|
|
20
20
|
5. a fresh npm installation and naturally rendered Studio smoke test.
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
Committing, publishing, pushing, and tagging remain explicit actions rather than automatic consequences of implementation or planning approval.
|
|
23
|
+
|
|
24
|
+
## Agreed next sequence (2026-09-07)
|
|
25
|
+
|
|
26
|
+
1. Ship **`0.9.60`** as the bounded networking/shortcut release. Do not start buffer architecture in this batch.
|
|
27
|
+
2. After that release, implement **`0.10.0`** around an explicit active Prompt, independently editable documents, context transfer, and parked prompt drafts. The design is in [`BUFFER-DESIGN.md`](https://github.com/omaclaren/pi-studio/blob/main/BUFFER-DESIGN.md).
|
|
28
|
+
3. Keep Derived REPL Transcript, editor-engine migration, and arbitrary pane arrangements outside both batches.
|
|
29
|
+
|
|
30
|
+
Current status:
|
|
31
|
+
|
|
32
|
+
- `0.9.60` combines the networking fix with Follow activity, Annotate response, and view-preserving Load response shortcuts, plus accurate draft-replacement protection. The published networking RC remains unchanged.
|
|
33
|
+
- The reporter has not replied to the RC invitation on [issue #4](https://github.com/omaclaren/pi-studio/issues/4) as of 2026-09-07. Exact `sbx` validation remains outstanding; the release proceeds with that limitation documented and the issue open for confirmation or a follow-up fix.
|
|
34
|
+
- `0.10.0` remains design-only. Approval to release `0.9.60` does not authorize buffer implementation.
|
|
23
35
|
|
|
24
36
|
## 0.9.52 — Rendering and media consistency (shipped 2026-08-28)
|
|
25
37
|
|
|
@@ -116,18 +128,62 @@ A small workflow refinement for moving Neovim-provided context through Pi and St
|
|
|
116
128
|
- provide `Cmd/Ctrl+Shift+L` for loading and a confirmed **Clear Pi editor text…** fallback without changing Studio text or conversation history;
|
|
117
129
|
- keep `pi-nvim-context`, protocol v1, networking, and the buffer-first architecture unchanged.
|
|
118
130
|
|
|
119
|
-
## 0.
|
|
131
|
+
## 0.9.60 — Networking and prompt-workflow shortcuts (2026-09-07)
|
|
132
|
+
|
|
133
|
+
This release combines the networking portion shipped as `0.9.60-rc.0` on 2026-09-03 with the small shortcut batch below. The published RC remains a reproducible networking test target.
|
|
134
|
+
|
|
135
|
+
### Networking
|
|
136
|
+
|
|
137
|
+
A bounded implementation of GitHub issue #4:
|
|
138
|
+
|
|
139
|
+
- retain `127.0.0.1` as the default and require explicit `--listen-all` intent before binding to `0.0.0.0`;
|
|
140
|
+
- keep the server-lifetime bearer token mandatory and the generated URL on loopback for same-port container publishing;
|
|
141
|
+
- keep inbound `Host` values out of route parsing and require wildcard-mode browser origins to match the request authority;
|
|
142
|
+
- report the actual listening address, emit a prominent exposure warning, and require `/studio --stop` before changing a running server's binding;
|
|
143
|
+
- document loopback-only host publishing, private-network limits, and SSH forwarding as the safer option on untrusted networks;
|
|
144
|
+
- validate both default loopback and opt-in wildcard sockets without changing the buffer model, protocol v1, or `pi-repl`.
|
|
145
|
+
|
|
146
|
+
### Shortcut and draft-replacement batch
|
|
147
|
+
|
|
148
|
+
The shortcuts and replacement safeguards below are implemented, with focused regression tests and browser workflow checks using controlled model replies. The buffer architecture remains deferred.
|
|
149
|
+
|
|
150
|
+
- Include `Cmd/Ctrl+Option/Alt+A` **Follow activity** toggle: retain opt-in persistence, full-workspace scope, modal protections, and key-repeat suppression.
|
|
151
|
+
- Add `Cmd/Ctrl+Option/Alt+Enter` for **Annotate response** from either main pane when the existing action is available. Invoke the same action as the button and retain its busy, response-type, and mode guards. Add Shift (`Cmd/Ctrl+Option/Alt+Shift+Enter`) to invoke **Load response into editor** without switching views, with the same replacement safeguards. Do not reinterpret `Cmd/Ctrl+Enter` as the right pane's primary action.
|
|
152
|
+
- Keep Run's existing active-pane behaviour and REPL/Side question submission shortcuts unchanged in this release.
|
|
153
|
+
- Protect **unsubmitted or unsaved work**, not every non-empty editor. The normal write prompt → accepted Run → response → Annotate response loop must remain confirmation-free when the submitted prompt is still unchanged. Ask before replacing post-submission edits, a new unsent draft, edited response annotations, or unsaved file changes; Cancel preserves text, source identity, and view state. Keep the decision/save-or-copy workflow bounded rather than adding a hidden second editor or a new recovery schema to `0.9.60`.
|
|
154
|
+
- Correlate an editor-text/source snapshot with the accepted submission and compare the current editor against that baseline; do not mark later typing as submitted when an acknowledgement or response arrives. Failed submissions do not establish the baseline, and submitting file text does not mark it saved.
|
|
155
|
+
- Capture the intended response and revalidate the editor after an asynchronous replacement decision; later typing or a changed target must not be overwritten by stale consent.
|
|
156
|
+
- Test both modifier variants, option-modified key values, key repeat, modal ownership, unavailable actions, and unchanged Run/REPL/Side question behaviour. Update the shortcut overlay, tooltips, accessibility metadata, and changelog only as behaviour is implemented.
|
|
157
|
+
|
|
158
|
+
### Release gate
|
|
159
|
+
|
|
160
|
+
Run the normal full validation and fresh-package browser smoke over the combined batch, including a confirmation-free submitted-prompt → Annotate response loop and protected replacement/cancellation for genuinely new work. The previous RC verification does not substitute for checking the final artifact.
|
|
161
|
+
|
|
162
|
+
The networking review and production lifecycle tests support the reported fix; a separate TCP-forward smoke also verified HTML, client JavaScript, and WebSocket state delivery through a non-loopback destination. Neither is an exact `sbx` test. On 2026-09-07, Oliver approved shipping after final validation with this limitation documented and issue #4 left open. Reporter feedback can confirm the fix or lead to a follow-up patch. Any updated prerelease must use a new version, never replace `rc.0`.
|
|
163
|
+
|
|
164
|
+
Do not include buffer tabs, context-transfer messages, recovery schema v2, or REPL transcript work in this release.
|
|
165
|
+
|
|
166
|
+
## 0.10.0 — Active Prompt and editable document buffers (planned)
|
|
167
|
+
|
|
168
|
+
Studio's primary loop is prompt construction and response reading. Documents remain useful editable sources, not implicit submission targets or a replacement for Neovim. The design is **one clearly active Prompt, other prompt drafts that can be parked, and independently editable documents**.
|
|
169
|
+
|
|
170
|
+
The first architectural release should add:
|
|
171
|
+
|
|
172
|
+
- a `StudioBufferStore` with stable buffer identity, explicit prompt/document roles, and separate selected-buffer and active-prompt identities;
|
|
173
|
+
- a prominent **Prompt** view with response/Working alongside it, plus editable document tabs with their own previews;
|
|
174
|
+
- recovery schema v2 with non-destructive migration from the single-document schema;
|
|
175
|
+
- buffer-specific text/baseline, dirty state, cursor, selection, scroll, preview context, annotations, and resource directory, preserving existing disk-revision checks;
|
|
176
|
+
- **Add selection to prompt** and **Copy document to prompt**, including an acknowledged handoff from editable companion views to the full workspace's explicit prompt destination;
|
|
177
|
+
- editable context snapshots of the visible text, including unsaved edits and source labels/ranges where known; no automatic submission, replacement, file write, or later live update of the copied text;
|
|
178
|
+
- a lightweight parked-prompt picker, with one active submission draft at a time and no per-draft conversation forks;
|
|
179
|
+
- tabs at ordinary widths, a compact searchable picker at narrow widths, and MRU/next/previous/close/reopen navigation;
|
|
180
|
+
- originating buffer ID, revision, and request ID targeting for asynchronous work, so switching tabs cannot redirect results or destructive actions.
|
|
120
181
|
|
|
121
|
-
|
|
182
|
+
Opening a document never changes the active prompt. Initially, document views should offer a clear return to Prompt rather than implicitly submit the document or send a hidden prompt. Keep Run's precise shortcut behaviour separate from the buffer model; settle it in the interaction tests before shipping.
|
|
122
183
|
|
|
123
|
-
|
|
124
|
-
- recovery schema v2 with migration from the single-document schema;
|
|
125
|
-
- buffer-specific text/baseline, dirty state, cursor, selection, scroll, preview context, annotations, and resource directory;
|
|
126
|
-
- tabs at ordinary widths and a compact searchable buffer picker at narrow widths;
|
|
127
|
-
- MRU cycling, next/previous, close, and reopen commands;
|
|
128
|
-
- originating buffer ID and revision targeting for asynchronous Pi work.
|
|
184
|
+
**Acceptance workflow:** start a prompt → inspect and edit a document with preview → add context to the prompt → annotate that detached copy → inspect another document → return and submit, without losing edits, changing the original through prompt annotations, or changing the submission destination accidentally.
|
|
129
185
|
|
|
130
|
-
PDFs remain preview documents
|
|
186
|
+
PDFs remain preview documents; watched previews remain disk-authoritative and read-only. Conversation history, resource grants, and the exact-session Shared REPL Record remain outside individual buffers. Arbitrary multi-pane layouts, project trees, simultaneous per-buffer Pi conversations, editor-engine migration, and Derived REPL Transcript are outside the initial scope. See [`BUFFER-DESIGN.md`](https://github.com/omaclaren/pi-studio/blob/main/BUFFER-DESIGN.md) for the state, recovery, handoff, and implementation sequence.
|
|
131
187
|
|
|
132
188
|
## Validation matrix
|
|
133
189
|
|
package/WORKFLOW.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# pi-studio workflow/spec note
|
|
2
2
|
|
|
3
|
+
This note describes the original single-editor workflow; it is not the current release backlog. See [ROADMAP.md](ROADMAP.md) for the active `0.9.60` networking/shortcut batch and planned `0.10.0` work. The future active-Prompt/editable-document model is recorded in [BUFFER-DESIGN.md](https://github.com/omaclaren/pi-studio/blob/main/BUFFER-DESIGN.md); buffer tabs and context transfer are not implemented yet.
|
|
4
|
+
|
|
3
5
|
## Goal
|
|
4
6
|
|
|
5
7
|
Keep Studio simple while supporting both loops:
|
|
@@ -117,8 +119,8 @@ Rules of thumb:
|
|
|
117
119
|
|
|
118
120
|
---
|
|
119
121
|
|
|
120
|
-
## Non-goals
|
|
122
|
+
## Non-goals for the single-editor `0.9.x` workflow
|
|
121
123
|
|
|
122
|
-
- Multi-document tabs
|
|
124
|
+
- Multi-document tabs (planned separately for `0.10.0`)
|
|
123
125
|
- Multi-user collaboration
|
|
124
126
|
- Heavy schema validation
|
package/client/studio-client.js
CHANGED
|
@@ -255,6 +255,14 @@
|
|
|
255
255
|
) {
|
|
256
256
|
throw new Error("Studio preview resource helpers failed to load.");
|
|
257
257
|
}
|
|
258
|
+
const editorDraftHelpers = globalThis.PiStudioEditorDraftHelpers;
|
|
259
|
+
if (!editorDraftHelpers || typeof editorDraftHelpers.createSubmittedEditorDraftTracker !== "function"
|
|
260
|
+
|| typeof editorDraftHelpers.needsDraftReplacementConfirmation !== "function") {
|
|
261
|
+
throw new Error("Studio editor draft helpers failed to load.");
|
|
262
|
+
}
|
|
263
|
+
const submittedEditorDrafts = editorDraftHelpers.createSubmittedEditorDraftTracker();
|
|
264
|
+
let editorSourceGeneration = 0;
|
|
265
|
+
let responseReplacementPending = false;
|
|
258
266
|
const showMeHelpers = globalThis.PiStudioShowMeHelpers;
|
|
259
267
|
if (!showMeHelpers || typeof showMeHelpers.chooseStudioShowMeFocus !== "function") {
|
|
260
268
|
throw new Error("Studio Show me helpers failed to load.");
|
|
@@ -433,8 +441,8 @@
|
|
|
433
441
|
rightViewSelect.title = isWatchedFilePreview
|
|
434
442
|
? "Read-only watched preview follows this file on disk."
|
|
435
443
|
: (isEditorOnlyMode
|
|
436
|
-
? "Editor-only views: Editor Preview, contextual Quarto Preview for .qmd/.md/.markdown files, Changes, Files, REPL, or Side questions. F7 cycles; Cmd/Ctrl+Alt+3/5/6/7/8 switch directly to numbered right-pane views, and Cmd/Ctrl+Alt+F/R/Q open Files/REPL/Side questions."
|
|
437
|
-
: "Right pane view mode. F7 cycles, including contextual Quarto Preview for file-backed .qmd, .md, and .markdown documents; Cmd/Ctrl+Alt+1–8 switches directly between the numbered views. Cmd/Ctrl+Alt+P/E/W/F/R/Q keep mnemonic shortcuts for Preview, Editor Preview, Working, Files, REPL, and Side questions.");
|
|
444
|
+
? "Editor-only views: Editor Preview, contextual Quarto Preview for .qmd/.md/.markdown files, Changes, Files, REPL, or Side questions. F7 cycles; Cmd/Ctrl+Option/Alt+3/5/6/7/8 switch directly to numbered right-pane views, and Cmd/Ctrl+Option/Alt+F/R/Q open Files/REPL/Side questions."
|
|
445
|
+
: "Right pane view mode. F7 cycles, including contextual Quarto Preview for file-backed .qmd, .md, and .markdown documents; Cmd/Ctrl+Option/Alt+1–8 switches directly between the numbered views. Cmd/Ctrl+Option/Alt+P/E/W/F/R/Q keep mnemonic shortcuts for Preview, Editor Preview, Working, Files, REPL, and Side questions.");
|
|
438
446
|
}
|
|
439
447
|
|
|
440
448
|
function getInitialRightView(source) {
|
|
@@ -4524,6 +4532,11 @@
|
|
|
4524
4532
|
return true;
|
|
4525
4533
|
}
|
|
4526
4534
|
|
|
4535
|
+
function getEditorDraftSourceKey() {
|
|
4536
|
+
return JSON.stringify([editorSourceGeneration, sourceState.source, sourceState.path,
|
|
4537
|
+
sourceState.draftId, getCurrentResourceDirValue()]);
|
|
4538
|
+
}
|
|
4539
|
+
|
|
4527
4540
|
function markFileBackedBaseline(text, diskRevision) {
|
|
4528
4541
|
fileBackedBaselineText = String(text || "");
|
|
4529
4542
|
fileBackedDiskRevision = normalizeStudioDiskRevision(diskRevision);
|
|
@@ -5096,6 +5109,35 @@
|
|
|
5096
5109
|
switchRightPaneToView("editor-preview");
|
|
5097
5110
|
}
|
|
5098
5111
|
|
|
5112
|
+
function studioModalBlocksDraftAction() {
|
|
5113
|
+
return Boolean(studioDecisionState || isScratchpadOpen() || isShortcutsOpen() || isQuizOpen()
|
|
5114
|
+
|| isStudioPdfFocusOpen() || isStudioHtmlFocusOpen() || isStudioImageFocusOpen());
|
|
5115
|
+
}
|
|
5116
|
+
|
|
5117
|
+
function triggerLoadResponseShortcut() {
|
|
5118
|
+
if (isEditorOnlyMode || isWatchedFilePreview || studioModalBlocksDraftAction()) return false;
|
|
5119
|
+
if (!loadResponseBtn || loadResponseBtn.hidden || loadResponseBtn.disabled) return false;
|
|
5120
|
+
loadResponseBtn.click();
|
|
5121
|
+
return true;
|
|
5122
|
+
}
|
|
5123
|
+
|
|
5124
|
+
function triggerAnnotateResponseShortcut() {
|
|
5125
|
+
if (isEditorOnlyMode || isWatchedFilePreview || studioModalBlocksDraftAction()) return false;
|
|
5126
|
+
if (!annotateResponseBtn || annotateResponseBtn.hidden || annotateResponseBtn.disabled) return false;
|
|
5127
|
+
annotateResponseBtn.click();
|
|
5128
|
+
return true;
|
|
5129
|
+
}
|
|
5130
|
+
|
|
5131
|
+
function triggerActivityTrackingShortcut() {
|
|
5132
|
+
if (studioModalBlocksDraftAction()) return false;
|
|
5133
|
+
if (isEditorOnlyMode || isWatchedFilePreview) {
|
|
5134
|
+
setStatus("Follow activity is available only in the main editable Studio workspace.", "warning");
|
|
5135
|
+
return false;
|
|
5136
|
+
}
|
|
5137
|
+
setActivityTrackingEnabled(!activityTrackingEnabled);
|
|
5138
|
+
return true;
|
|
5139
|
+
}
|
|
5140
|
+
|
|
5099
5141
|
function cycleActivePaneView(direction) {
|
|
5100
5142
|
if (activePane === "right") {
|
|
5101
5143
|
if (!rightViewSelect || rightViewSelect.disabled) {
|
|
@@ -5495,6 +5537,36 @@
|
|
|
5495
5537
|
return;
|
|
5496
5538
|
}
|
|
5497
5539
|
|
|
5540
|
+
const isLoadResponseShortcut = key === "Enter"
|
|
5541
|
+
&& (event.metaKey || event.ctrlKey)
|
|
5542
|
+
&& event.altKey
|
|
5543
|
+
&& event.shiftKey;
|
|
5544
|
+
if (isLoadResponseShortcut) {
|
|
5545
|
+
event.preventDefault();
|
|
5546
|
+
if (!event.repeat && !event.isComposing) triggerLoadResponseShortcut();
|
|
5547
|
+
return;
|
|
5548
|
+
}
|
|
5549
|
+
|
|
5550
|
+
const isAnnotateResponseShortcut = key === "Enter"
|
|
5551
|
+
&& (event.metaKey || event.ctrlKey)
|
|
5552
|
+
&& event.altKey
|
|
5553
|
+
&& !event.shiftKey;
|
|
5554
|
+
if (isAnnotateResponseShortcut) {
|
|
5555
|
+
event.preventDefault();
|
|
5556
|
+
if (!event.repeat && !event.isComposing) triggerAnnotateResponseShortcut();
|
|
5557
|
+
return;
|
|
5558
|
+
}
|
|
5559
|
+
|
|
5560
|
+
const isActivityTrackingShortcut = (key.toLowerCase() === "a" || code === "KeyA")
|
|
5561
|
+
&& (event.metaKey || event.ctrlKey)
|
|
5562
|
+
&& event.altKey
|
|
5563
|
+
&& !event.shiftKey;
|
|
5564
|
+
if (isActivityTrackingShortcut) {
|
|
5565
|
+
event.preventDefault();
|
|
5566
|
+
if (!event.repeat && !event.isComposing) triggerActivityTrackingShortcut();
|
|
5567
|
+
return;
|
|
5568
|
+
}
|
|
5569
|
+
|
|
5498
5570
|
const isContentFocusShortcut = key === "F8" && !event.metaKey && !event.ctrlKey && !event.altKey;
|
|
5499
5571
|
if (isContentFocusShortcut) {
|
|
5500
5572
|
event.preventDefault();
|
|
@@ -7993,7 +8065,7 @@
|
|
|
7993
8065
|
refreshBtn.type = "button";
|
|
7994
8066
|
refreshBtn.className = "studio-pdf-focus-btn studio-pdf-focus-refresh";
|
|
7995
8067
|
refreshBtn.textContent = "Refresh";
|
|
7996
|
-
refreshBtn.title = "Reload this PDF preview from disk. Shortcut: Cmd/Ctrl+Alt+Shift+R.";
|
|
8068
|
+
refreshBtn.title = "Reload this PDF preview from disk. Shortcut: Cmd/Ctrl+Option/Alt+Shift+R.";
|
|
7997
8069
|
refreshBtn.setAttribute("aria-label", "Refresh PDF preview from disk");
|
|
7998
8070
|
refreshBtn.addEventListener("click", () => refreshStudioPdfFocusViewer());
|
|
7999
8071
|
actions.appendChild(refreshBtn);
|
|
@@ -9312,7 +9384,7 @@
|
|
|
9312
9384
|
refreshBtn.type = "button";
|
|
9313
9385
|
refreshBtn.className = "studio-pdf-card-action studio-pdf-card-refresh";
|
|
9314
9386
|
refreshBtn.textContent = "Refresh";
|
|
9315
|
-
refreshBtn.title = "Reload this PDF preview from disk. Shortcut: Cmd/Ctrl+Alt+Shift+R.";
|
|
9387
|
+
refreshBtn.title = "Reload this PDF preview from disk. Shortcut: Cmd/Ctrl+Option/Alt+Shift+R.";
|
|
9316
9388
|
refreshBtn.addEventListener("click", (event) => {
|
|
9317
9389
|
event.preventDefault();
|
|
9318
9390
|
event.stopPropagation();
|
|
@@ -14250,7 +14322,7 @@
|
|
|
14250
14322
|
annotateResponseBtn.textContent = annotationWorkspaceReady ? "Response ready to annotate" : "Annotate response";
|
|
14251
14323
|
annotateResponseBtn.title = annotationWorkspaceReady
|
|
14252
14324
|
? "The selected response is exactly in the raw editor with Editor Preview open."
|
|
14253
|
-
: "Load the selected response into the raw editor and show Editor Preview.
|
|
14325
|
+
: "Load the selected response into the raw editor and show Editor Preview. Ask before replacing unsubmitted or unsaved work. Shortcut: Cmd/Ctrl+Option/Alt+Enter.";
|
|
14254
14326
|
|
|
14255
14327
|
loadCritiqueNotesBtn.disabled = uiBusy || !isCritiqueResponse || !critiqueNotes || critiqueNotesLoaded;
|
|
14256
14328
|
loadCritiqueNotesBtn.textContent = critiqueNotesLoaded ? "Critique notes already in editor" : "Load critique notes into editor";
|
|
@@ -14649,6 +14721,7 @@
|
|
|
14649
14721
|
setStatus("This read-only preview remains bound to its watched file.", "warning");
|
|
14650
14722
|
return false;
|
|
14651
14723
|
}
|
|
14724
|
+
editorSourceGeneration += 1;
|
|
14652
14725
|
sourceState = {
|
|
14653
14726
|
source: next && next.source ? next.source : "blank",
|
|
14654
14727
|
label: next && next.label ? next.label : "blank",
|
|
@@ -23701,6 +23774,11 @@
|
|
|
23701
23774
|
return;
|
|
23702
23775
|
}
|
|
23703
23776
|
|
|
23777
|
+
if (message.type === "run_accepted") {
|
|
23778
|
+
submittedEditorDrafts.accept(message.requestId);
|
|
23779
|
+
return;
|
|
23780
|
+
}
|
|
23781
|
+
|
|
23704
23782
|
if (message.type === "request_started") {
|
|
23705
23783
|
pendingRequestId = typeof message.requestId === "string" ? message.requestId : pendingRequestId;
|
|
23706
23784
|
pendingKind = typeof message.kind === "string" ? message.kind : "unknown";
|
|
@@ -24157,6 +24235,7 @@
|
|
|
24157
24235
|
|
|
24158
24236
|
if (message.type === "busy") {
|
|
24159
24237
|
if (typeof message.requestId === "string") {
|
|
24238
|
+
submittedEditorDrafts.discard(message.requestId);
|
|
24160
24239
|
restoreReservedPiEditorDraftSnapshot(message.requestId);
|
|
24161
24240
|
pendingSaveOperations.delete(message.requestId);
|
|
24162
24241
|
failPendingCompanionLaunch(message.requestId, "Studio could not start the companion editor because another request was busy.");
|
|
@@ -24181,6 +24260,7 @@
|
|
|
24181
24260
|
|
|
24182
24261
|
if (message.type === "error") {
|
|
24183
24262
|
if (typeof message.requestId === "string") {
|
|
24263
|
+
submittedEditorDrafts.discard(message.requestId);
|
|
24184
24264
|
restoreReservedPiEditorDraftSnapshot(message.requestId);
|
|
24185
24265
|
pendingSaveOperations.delete(message.requestId);
|
|
24186
24266
|
failPendingCompanionLaunch(message.requestId, "Studio could not prepare the companion editor. Return to the originating Studio page for details.");
|
|
@@ -24331,6 +24411,7 @@
|
|
|
24331
24411
|
quartoPreviewActionRequestId = null;
|
|
24332
24412
|
sideQuestionMarkdownExportRequest = null;
|
|
24333
24413
|
pendingPiEditorDraftSnapshots.clear();
|
|
24414
|
+
submittedEditorDrafts.clearPending();
|
|
24334
24415
|
failAllPendingCompanionLaunches("The originating Studio connection was lost before the companion editor was ready.");
|
|
24335
24416
|
if (rightView === "editor-quarto-preview") renderQuartoPreviewView();
|
|
24336
24417
|
setBusy(true);
|
|
@@ -25034,27 +25115,56 @@
|
|
|
25034
25115
|
}
|
|
25035
25116
|
|
|
25036
25117
|
async function loadSelectedResponseIntoEditor(options) {
|
|
25037
|
-
if (
|
|
25118
|
+
if (isEditorOnlyMode || isWatchedFilePreview || uiBusy || responseReplacementPending
|
|
25119
|
+
|| studioModalBlocksDraftAction()) return false;
|
|
25120
|
+
const prepareForAnnotation = Boolean(options && options.annotate);
|
|
25121
|
+
if (prepareForAnnotation && latestResponseIsStructuredCritique) return false;
|
|
25122
|
+
if (!prepareForAnnotation && rightView === "editor-quarto-preview") {
|
|
25123
|
+
setStatus("Choose another right-pane view before loading a response without switching views, or use Annotate response.", "warning");
|
|
25124
|
+
return false;
|
|
25125
|
+
}
|
|
25126
|
+
const responseText = latestResponseMarkdown;
|
|
25127
|
+
if (!responseText.trim()) {
|
|
25038
25128
|
setStatus("No response available yet.", "warning");
|
|
25039
25129
|
return false;
|
|
25040
25130
|
}
|
|
25041
|
-
const prepareForAnnotation = Boolean(options && options.annotate);
|
|
25042
25131
|
const currentEditorText = String(sourceTextEl.value || "");
|
|
25043
|
-
const
|
|
25044
|
-
|
|
25045
|
-
|
|
25046
|
-
|
|
25047
|
-
|
|
25048
|
-
|
|
25049
|
-
|
|
25050
|
-
|
|
25051
|
-
)
|
|
25132
|
+
const sourceKey = getEditorDraftSourceKey();
|
|
25133
|
+
const responseIndex = responseHistoryIndex;
|
|
25134
|
+
const responseTimestamp = latestResponseTimestamp;
|
|
25135
|
+
const diskRevision = fileBackedDiskRevision;
|
|
25136
|
+
const needsConfirmation = editorDraftHelpers.needsDraftReplacementConfirmation({
|
|
25137
|
+
text: currentEditorText,
|
|
25138
|
+
responseText,
|
|
25139
|
+
fileBacked: hasRefreshableFilePath(),
|
|
25140
|
+
dirty: editorDiffersFromFileBackedBaseline(),
|
|
25141
|
+
submitted: submittedEditorDrafts.matches(currentEditorText, sourceKey),
|
|
25142
|
+
});
|
|
25143
|
+
if (needsConfirmation) {
|
|
25144
|
+
responseReplacementPending = true;
|
|
25145
|
+
let confirmed;
|
|
25146
|
+
try {
|
|
25147
|
+
confirmed = await requestStudioConfirmation(
|
|
25148
|
+
"Replace the current editor text with the selected response? Unsubmitted edits or unsaved file changes will be lost. Cancel to save or copy them first.",
|
|
25149
|
+
{ title: "Replace editor text?", confirmLabel: "Replace", destructive: true },
|
|
25150
|
+
);
|
|
25151
|
+
} finally {
|
|
25152
|
+
responseReplacementPending = false;
|
|
25153
|
+
}
|
|
25052
25154
|
if (!confirmed) {
|
|
25053
25155
|
setStatus("Kept the current editor text.");
|
|
25054
25156
|
return false;
|
|
25055
25157
|
}
|
|
25158
|
+
if (uiBusy || studioModalBlocksDraftAction() || currentEditorText !== sourceTextEl.value
|
|
25159
|
+
|| sourceKey !== getEditorDraftSourceKey() || diskRevision !== fileBackedDiskRevision
|
|
25160
|
+
|| responseText !== latestResponseMarkdown || responseIndex !== responseHistoryIndex
|
|
25161
|
+
|| responseTimestamp !== latestResponseTimestamp
|
|
25162
|
+
|| (!prepareForAnnotation && rightView === "editor-quarto-preview")) {
|
|
25163
|
+
setStatus("Editor, response, or view changed while confirmation was open. Kept the editor text; try again.", "warning");
|
|
25164
|
+
return false;
|
|
25165
|
+
}
|
|
25056
25166
|
}
|
|
25057
|
-
setEditorText(
|
|
25167
|
+
setEditorText(responseText, { preserveScroll: false, preserveSelection: false });
|
|
25058
25168
|
setSourceState({ source: "last-response", label: "last model response", path: null });
|
|
25059
25169
|
if (prepareForAnnotation) {
|
|
25060
25170
|
if (editorView !== "markdown") setEditorView("markdown");
|
|
@@ -25574,6 +25684,7 @@
|
|
|
25574
25684
|
|
|
25575
25685
|
const requestId = beginUiAction("direct");
|
|
25576
25686
|
if (!requestId) return;
|
|
25687
|
+
submittedEditorDrafts.remember(requestId, sourceTextEl.value, getEditorDraftSourceKey());
|
|
25577
25688
|
const piEditorDraftSnapshot = reserveLinkedPiEditorDraftSnapshot(requestId);
|
|
25578
25689
|
|
|
25579
25690
|
const sent = sendMessage({
|
|
@@ -25584,6 +25695,7 @@
|
|
|
25584
25695
|
});
|
|
25585
25696
|
|
|
25586
25697
|
if (!sent) {
|
|
25698
|
+
submittedEditorDrafts.discard(requestId);
|
|
25587
25699
|
restoreReservedPiEditorDraftSnapshot(requestId);
|
|
25588
25700
|
pendingRequestId = null;
|
|
25589
25701
|
pendingKind = null;
|
|
@@ -25605,6 +25717,7 @@
|
|
|
25605
25717
|
|
|
25606
25718
|
const requestId = makeRequestId();
|
|
25607
25719
|
clearTitleAttention();
|
|
25720
|
+
submittedEditorDrafts.remember(requestId, sourceTextEl.value, getEditorDraftSourceKey());
|
|
25608
25721
|
const piEditorDraftSnapshot = reserveLinkedPiEditorDraftSnapshot(requestId);
|
|
25609
25722
|
const sent = sendMessage({
|
|
25610
25723
|
type: "send_run_request",
|
|
@@ -25613,6 +25726,7 @@
|
|
|
25613
25726
|
piEditorDraftSnapshot: piEditorDraftSnapshot || undefined,
|
|
25614
25727
|
});
|
|
25615
25728
|
if (!sent) {
|
|
25729
|
+
submittedEditorDrafts.discard(requestId);
|
|
25616
25730
|
restoreReservedPiEditorDraftSnapshot(requestId);
|
|
25617
25731
|
return;
|
|
25618
25732
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
// Page-memory-only. No persistence or authority over Pi's terminal composer.
|
|
3
|
+
const MAX_TEXT_CHARS = 900_000;
|
|
4
|
+
const MAX_PENDING = 8;
|
|
5
|
+
const MAX_PENDING_TEXT_CHARS = 2_700_000;
|
|
6
|
+
|
|
7
|
+
function createSubmittedEditorDraftTracker() {
|
|
8
|
+
const pending = new Map();
|
|
9
|
+
let pendingChars = 0;
|
|
10
|
+
let sequence = 0;
|
|
11
|
+
let accepted = null;
|
|
12
|
+
|
|
13
|
+
function discard(requestId) {
|
|
14
|
+
const snapshot = pending.get(requestId);
|
|
15
|
+
if (!snapshot) return false;
|
|
16
|
+
pendingChars -= snapshot.text.length;
|
|
17
|
+
pending.delete(requestId);
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return Object.freeze({
|
|
22
|
+
remember(requestId, text, sourceKey) {
|
|
23
|
+
if (typeof requestId !== "string" || !requestId || requestId.length > 256
|
|
24
|
+
|| pending.has(requestId) || (accepted && accepted.requestId === requestId)) return false;
|
|
25
|
+
if (typeof text !== "string" || text.length > MAX_TEXT_CHARS
|
|
26
|
+
|| typeof sourceKey !== "string" || !sourceKey || sourceKey.length > 24_000) return false;
|
|
27
|
+
while (pending.size >= MAX_PENDING || pendingChars + text.length > MAX_PENDING_TEXT_CHARS) {
|
|
28
|
+
discard(pending.keys().next().value);
|
|
29
|
+
}
|
|
30
|
+
pending.set(requestId, { requestId, text, sourceKey, sequence: ++sequence });
|
|
31
|
+
pendingChars += text.length;
|
|
32
|
+
return true;
|
|
33
|
+
},
|
|
34
|
+
accept(requestId) {
|
|
35
|
+
const snapshot = pending.get(requestId);
|
|
36
|
+
if (!snapshot) return false;
|
|
37
|
+
discard(requestId);
|
|
38
|
+
// Late/duplicate acknowledgements cannot replace a newer accepted baseline.
|
|
39
|
+
if (accepted && snapshot.sequence <= accepted.sequence) return false;
|
|
40
|
+
accepted = snapshot;
|
|
41
|
+
return true;
|
|
42
|
+
},
|
|
43
|
+
matches(text, sourceKey) {
|
|
44
|
+
return Boolean(accepted && accepted.text === text && accepted.sourceKey === sourceKey);
|
|
45
|
+
},
|
|
46
|
+
discard,
|
|
47
|
+
clearPending() {
|
|
48
|
+
pending.clear();
|
|
49
|
+
pendingChars = 0;
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function needsDraftReplacementConfirmation({ text, responseText, fileBacked, dirty, submitted }) {
|
|
55
|
+
// Even an empty file buffer can contain an unsaved deletion. Sending it is not saving it.
|
|
56
|
+
if (fileBacked) return Boolean(dirty);
|
|
57
|
+
return Boolean(text.trim() && text !== responseText && !submitted);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
globalThis.PiStudioEditorDraftHelpers = Object.freeze({
|
|
61
|
+
createSubmittedEditorDraftTracker,
|
|
62
|
+
needsDraftReplacementConfirmation,
|
|
63
|
+
});
|
|
64
|
+
})();
|
package/index.ts
CHANGED
|
@@ -46,6 +46,13 @@ import { createStudioPandocHtmlResourceFlagResolver } from "./shared/studio-pand
|
|
|
46
46
|
import { createStudioResourceGrantRegistry } from "./shared/studio-resource-grants.js";
|
|
47
47
|
import { prepareStudioLatexForPandoc } from "./shared/studio-latex-pandoc-compat.js";
|
|
48
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";
|
|
49
56
|
import { buildStudioReplTmuxStartArgs } from "./shared/studio-repl-tmux.js";
|
|
50
57
|
import {
|
|
51
58
|
REPL_SESSION_RECORD_ID_OPTION,
|
|
@@ -189,6 +196,7 @@ const STUDIO_NAVIGATION_HELPERS_URL = new URL("./client/studio-navigation-helper
|
|
|
189
196
|
const STUDIO_PREVIEW_RESOURCE_HELPERS_URL = new URL("./client/studio-preview-resource-helpers.js", import.meta.url);
|
|
190
197
|
const STUDIO_SHOW_ME_HELPERS_URL = new URL("./client/studio-show-me-helpers.js", import.meta.url);
|
|
191
198
|
const STUDIO_SIDE_QUESTION_HELPERS_URL = new URL("./client/studio-side-question-helpers.js", import.meta.url);
|
|
199
|
+
const STUDIO_EDITOR_DRAFT_HELPERS_URL = new URL("./client/studio-editor-draft-helpers.js", import.meta.url);
|
|
192
200
|
const STUDIO_CLIENT_URL = new URL("./client/studio-client.js", import.meta.url);
|
|
193
201
|
|
|
194
202
|
interface StudioServerState {
|
|
@@ -197,6 +205,8 @@ interface StudioServerState {
|
|
|
197
205
|
clients: Set<WebSocket>;
|
|
198
206
|
clientModes: Map<WebSocket, StudioUiMode>;
|
|
199
207
|
port: number;
|
|
208
|
+
bindHost: string;
|
|
209
|
+
listenAll: boolean;
|
|
200
210
|
token: string;
|
|
201
211
|
}
|
|
202
212
|
|
|
@@ -11865,13 +11875,6 @@ function interruptStudioReplSession(sessionName: string): { ok: true; message: s
|
|
|
11865
11875
|
return { ok: true, message: `Interrupted ${sessionName}.` };
|
|
11866
11876
|
}
|
|
11867
11877
|
|
|
11868
|
-
function isAllowedOrigin(_origin: string | undefined, _port: number): boolean {
|
|
11869
|
-
// For local-only studio, token auth is the primary guard. In practice,
|
|
11870
|
-
// browser origin headers can vary (or be omitted) across wrappers/browsers,
|
|
11871
|
-
// so we avoid brittle origin-based rejection here.
|
|
11872
|
-
return true;
|
|
11873
|
-
}
|
|
11874
|
-
|
|
11875
11878
|
function normalizeStudioUiMode(raw: string | null | undefined): StudioUiMode {
|
|
11876
11879
|
return raw === "editor-only" ? "editor-only" : "full";
|
|
11877
11880
|
}
|
|
@@ -11936,7 +11939,7 @@ function buildStudioUrl(
|
|
|
11936
11939
|
docId?: string,
|
|
11937
11940
|
options?: StudioUrlOptions,
|
|
11938
11941
|
): string {
|
|
11939
|
-
return `http
|
|
11942
|
+
return `http://${STUDIO_ADVERTISED_HOST}:${port}${buildStudioRelativeUrl(token, mode, doc, docId, options)}`;
|
|
11940
11943
|
}
|
|
11941
11944
|
|
|
11942
11945
|
interface StudioLaunchFlags {
|
|
@@ -11944,17 +11947,19 @@ interface StudioLaunchFlags {
|
|
|
11944
11947
|
openRemoteBrowser: boolean;
|
|
11945
11948
|
noBrowser: boolean;
|
|
11946
11949
|
watchPdf: boolean;
|
|
11950
|
+
listenAll: boolean;
|
|
11947
11951
|
port?: number;
|
|
11948
11952
|
error?: string;
|
|
11949
11953
|
}
|
|
11950
11954
|
|
|
11951
11955
|
function parseStudioLaunchOpenFlags(rawArgs: string): StudioLaunchFlags {
|
|
11952
11956
|
const parsed = tokenizeStudioCommandArgs(rawArgs);
|
|
11953
|
-
if (parsed.error) return { args: rawArgs, openRemoteBrowser: false, noBrowser: false, watchPdf: false, error: parsed.error };
|
|
11957
|
+
if (parsed.error) return { args: rawArgs, openRemoteBrowser: false, noBrowser: false, watchPdf: false, listenAll: false, error: parsed.error };
|
|
11954
11958
|
const remaining: string[] = [];
|
|
11955
11959
|
let openRemoteBrowser = false;
|
|
11956
11960
|
let noBrowser = false;
|
|
11957
11961
|
let watchPdf = false;
|
|
11962
|
+
let listenAll = false;
|
|
11958
11963
|
let port: number | undefined;
|
|
11959
11964
|
for (let i = 0; i < parsed.tokens.length; i += 1) {
|
|
11960
11965
|
const token = parsed.tokens[i]!;
|
|
@@ -11970,14 +11975,18 @@ function parseStudioLaunchOpenFlags(rawArgs: string): StudioLaunchFlags {
|
|
|
11970
11975
|
watchPdf = true;
|
|
11971
11976
|
continue;
|
|
11972
11977
|
}
|
|
11978
|
+
if (token === "--listen-all") {
|
|
11979
|
+
listenAll = true;
|
|
11980
|
+
continue;
|
|
11981
|
+
}
|
|
11973
11982
|
if (token === "--port" || token.startsWith("--port=")) {
|
|
11974
11983
|
const rawPort = token.startsWith("--port=") ? token.slice("--port=".length) : parsed.tokens[++i];
|
|
11975
11984
|
if (!rawPort) {
|
|
11976
|
-
return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, error: "Missing value for --port." };
|
|
11985
|
+
return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, listenAll, error: "Missing value for --port." };
|
|
11977
11986
|
}
|
|
11978
11987
|
const requestedPort = Number(rawPort);
|
|
11979
11988
|
if (!Number.isInteger(requestedPort) || requestedPort < 1 || requestedPort > 65535) {
|
|
11980
|
-
return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, error: `Invalid --port value: ${rawPort}. Use an integer from 1 to 65535.` };
|
|
11989
|
+
return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, listenAll, error: `Invalid --port value: ${rawPort}. Use an integer from 1 to 65535.` };
|
|
11981
11990
|
}
|
|
11982
11991
|
port = requestedPort;
|
|
11983
11992
|
continue;
|
|
@@ -11985,9 +11994,9 @@ function parseStudioLaunchOpenFlags(rawArgs: string): StudioLaunchFlags {
|
|
|
11985
11994
|
remaining.push(token);
|
|
11986
11995
|
}
|
|
11987
11996
|
if (openRemoteBrowser && noBrowser) {
|
|
11988
|
-
return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, port, error: "Use either --no-browser or --open-browser, not both." };
|
|
11997
|
+
return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, listenAll, port, error: "Use either --no-browser or --open-browser, not both." };
|
|
11989
11998
|
}
|
|
11990
|
-
return { args: remaining.join(" "), openRemoteBrowser, noBrowser, watchPdf, port };
|
|
11999
|
+
return { args: remaining.join(" "), openRemoteBrowser, noBrowser, watchPdf, listenAll, port };
|
|
11991
12000
|
}
|
|
11992
12001
|
|
|
11993
12002
|
function shouldAutoOpenStudioBrowser(options?: { openRemoteBrowser?: boolean; noBrowser?: boolean }): boolean {
|
|
@@ -12380,6 +12389,7 @@ function buildStudioHtml(
|
|
|
12380
12389
|
const mermaidHelpersScriptHref = `/studio-mermaid-helpers.js?token=${encodeURIComponent(studioToken ?? "")}`;
|
|
12381
12390
|
const navigationHelpersScriptHref = `/studio-navigation-helpers.js?token=${encodeURIComponent(studioToken ?? "")}`;
|
|
12382
12391
|
const previewResourceHelpersScriptHref = `/studio-preview-resource-helpers.js?token=${encodeURIComponent(studioToken ?? "")}`;
|
|
12392
|
+
const editorDraftHelpersScriptHref = `/studio-editor-draft-helpers.js?token=${encodeURIComponent(studioToken ?? "")}`;
|
|
12383
12393
|
const showMeHelpersScriptHref = `/studio-show-me-helpers.js?token=${encodeURIComponent(studioToken ?? "")}`;
|
|
12384
12394
|
const sideQuestionHelpersScriptHref = `/studio-side-question-helpers.js?token=${encodeURIComponent(studioToken ?? "")}`;
|
|
12385
12395
|
const clientScriptHref = `/studio-client.js?token=${encodeURIComponent(studioToken ?? "")}`;
|
|
@@ -12544,7 +12554,7 @@ ${cssVarsBlock}
|
|
|
12544
12554
|
<option value="editor-top">Layout: Editor above</option>
|
|
12545
12555
|
<option value="response-top">Layout: Response above</option>
|
|
12546
12556
|
</select>
|
|
12547
|
-
<select id="activityTrackingSelect" class="studio-flat-select" aria-label="Follow main Pi activity" title="Optionally follow main Pi activity from Working to the response view; off by default.">
|
|
12557
|
+
<select id="activityTrackingSelect" class="studio-flat-select" aria-label="Follow main Pi activity" aria-keyshortcuts="Meta+Alt+A Control+Alt+A" title="Optionally follow main Pi activity from Working to the response view; off by default. Shortcut: Cmd/Ctrl+Option/Alt+A.">
|
|
12548
12558
|
<option value="off">Follow activity: Off</option>
|
|
12549
12559
|
<option value="on">Follow activity: On</option>
|
|
12550
12560
|
</select>
|
|
@@ -12647,7 +12657,7 @@ ${cssVarsBlock}
|
|
|
12647
12657
|
<div id="rightSectionHeader" class="section-header">
|
|
12648
12658
|
<div class="section-header-main">
|
|
12649
12659
|
<span id="rightViewSelectWrap" class="studio-header-select-wrap">
|
|
12650
|
-
<select id="rightViewSelect" aria-label="Response view mode" title="Right pane view mode. F7 cycles when the right pane is active; Cmd/Ctrl+Alt+1–8 switches directly between all right-pane views. Cmd/Ctrl+Alt+P/E/W/F/R/Q keep mnemonic shortcuts for Preview, Editor Preview, Working, Files, REPL, and Side questions.">
|
|
12660
|
+
<select id="rightViewSelect" aria-label="Response view mode" title="Right pane view mode. F7 cycles when the right pane is active; Cmd/Ctrl+Option/Alt+1–8 switches directly between all right-pane views. Cmd/Ctrl+Option/Alt+P/E/W/F/R/Q keep mnemonic shortcuts for Preview, Editor Preview, Working, Files, REPL, and Side questions.">
|
|
12651
12661
|
<option value="markdown">Response (Raw)</option>
|
|
12652
12662
|
<option value="preview" selected>Response (Preview)</option>
|
|
12653
12663
|
<option value="editor-preview">Editor (Preview)</option>
|
|
@@ -12722,8 +12732,8 @@ ${cssVarsBlock}
|
|
|
12722
12732
|
<button id="historyLastBtn" type="button" title="Jump to the latest loaded response in the current branch history.">Last response ▶|</button>
|
|
12723
12733
|
</div>
|
|
12724
12734
|
<div class="response-actions-row response-result-row">
|
|
12725
|
-
<button id="annotateResponseBtn" type="button" title="Load the selected response into the raw editor and show Editor Preview.
|
|
12726
|
-
<button id="loadResponseBtn" type="button">Load response into editor</button>
|
|
12735
|
+
<button id="annotateResponseBtn" type="button" aria-keyshortcuts="Meta+Alt+Enter Control+Alt+Enter" title="Load the selected response into the raw editor and show Editor Preview. Ask before replacing unsubmitted or unsaved work. Shortcut: Cmd/Ctrl+Option/Alt+Enter.">Annotate response</button>
|
|
12736
|
+
<button id="loadResponseBtn" type="button" aria-keyshortcuts="Meta+Alt+Shift+Enter Control+Alt+Shift+Enter" title="Load the selected response into the editor without switching views. Choose another view first if Quarto Preview is active. Ask before replacing unsubmitted or unsaved work. Shortcut: Cmd/Ctrl+Option/Alt+Shift+Enter.">Load response into editor</button>
|
|
12727
12737
|
<button id="loadCritiqueNotesBtn" type="button" hidden>Load critique notes into editor</button>
|
|
12728
12738
|
<button id="loadCritiqueFullBtn" type="button" hidden>Load full critique into editor</button>
|
|
12729
12739
|
<button id="loadHistoryPromptBtn" type="button" title="Load the prompt that generated the selected response into the editor.">Load response prompt into editor</button>
|
|
@@ -12760,13 +12770,13 @@ ${cssVarsBlock}
|
|
|
12760
12770
|
<dl>
|
|
12761
12771
|
<div><dt>F6</dt><dd>Switch between editor and right pane</dd></div>
|
|
12762
12772
|
<div><dt>F7 / Shift+F7</dt><dd>Cycle the active pane's view</dd></div>
|
|
12763
|
-
<div><dt>Cmd/Ctrl+Alt+1–8</dt><dd>Switch the right pane directly: Response Raw, Response Preview, Editor Preview, Working, Changes, Files, REPL, Side questions</dd></div>
|
|
12764
|
-
<div><dt>Cmd/Ctrl+Alt+P</dt><dd>Switch the right pane directly to Response Preview; in editor-only views, Editor Preview</dd></div>
|
|
12765
|
-
<div><dt>Cmd/Ctrl+Alt+E</dt><dd>Switch the right pane directly to Editor Preview</dd></div>
|
|
12766
|
-
<div><dt>Cmd/Ctrl+Alt+W</dt><dd>Switch the right pane directly to Working</dd></div>
|
|
12767
|
-
<div><dt>Cmd/Ctrl+Alt+F</dt><dd>Switch the right pane directly to Files</dd></div>
|
|
12768
|
-
<div><dt>Cmd/Ctrl+Alt+R</dt><dd>Switch the right pane directly to REPL and focus Quick send when a session is selected</dd></div>
|
|
12769
|
-
<div><dt>Cmd/Ctrl+Alt+Q</dt><dd>Switch the right pane directly to Side questions</dd></div>
|
|
12773
|
+
<div><dt>Cmd/Ctrl+Option/Alt+1–8</dt><dd>Switch the right pane directly: Response Raw, Response Preview, Editor Preview, Working, Changes, Files, REPL, Side questions</dd></div>
|
|
12774
|
+
<div><dt>Cmd/Ctrl+Option/Alt+P</dt><dd>Switch the right pane directly to Response Preview; in editor-only views, Editor Preview</dd></div>
|
|
12775
|
+
<div><dt>Cmd/Ctrl+Option/Alt+E</dt><dd>Switch the right pane directly to Editor Preview</dd></div>
|
|
12776
|
+
<div><dt>Cmd/Ctrl+Option/Alt+W</dt><dd>Switch the right pane directly to Working</dd></div>
|
|
12777
|
+
<div><dt>Cmd/Ctrl+Option/Alt+F</dt><dd>Switch the right pane directly to Files</dd></div>
|
|
12778
|
+
<div><dt>Cmd/Ctrl+Option/Alt+R</dt><dd>Switch the right pane directly to REPL and focus Quick send when a session is selected</dd></div>
|
|
12779
|
+
<div><dt>Cmd/Ctrl+Option/Alt+Q</dt><dd>Switch the right pane directly to Side questions</dd></div>
|
|
12770
12780
|
<div><dt>F8</dt><dd>Focus editor text</dd></div>
|
|
12771
12781
|
<div><dt>Shift+F8</dt><dd>Focus right-pane content</dd></div>
|
|
12772
12782
|
<div><dt>F9</dt><dd>Toggle Zen mode and hide or restore the Studio header</dd></div>
|
|
@@ -12778,10 +12788,11 @@ ${cssVarsBlock}
|
|
|
12778
12788
|
<section class="shortcuts-group">
|
|
12779
12789
|
<h3>View</h3>
|
|
12780
12790
|
<dl>
|
|
12781
|
-
<div><dt>Alt
|
|
12782
|
-
<div><dt>Alt
|
|
12783
|
-
<div><dt>Alt
|
|
12784
|
-
<div><dt>Cmd/Ctrl+Alt+
|
|
12791
|
+
<div><dt>Option/Alt+=</dt><dd>Increase the active pane's text size when not editing text</dd></div>
|
|
12792
|
+
<div><dt>Option/Alt+-</dt><dd>Decrease the active pane's text size when not editing text</dd></div>
|
|
12793
|
+
<div><dt>Option/Alt+0</dt><dd>Reset the active pane's text size when not editing text</dd></div>
|
|
12794
|
+
<div class="shortcuts-full-only"><dt>Cmd/Ctrl+Option/Alt+A</dt><dd>Toggle Follow activity in the main editable Studio workspace</dd></div>
|
|
12795
|
+
<div><dt>Cmd/Ctrl+Option/Alt+Shift+R</dt><dd>Refresh the focused or visible PDF preview from disk</dd></div>
|
|
12785
12796
|
</dl>
|
|
12786
12797
|
</section>
|
|
12787
12798
|
<section class="shortcuts-group">
|
|
@@ -12800,9 +12811,11 @@ ${cssVarsBlock}
|
|
|
12800
12811
|
<section class="shortcuts-group shortcuts-full-only">
|
|
12801
12812
|
<h3>Response</h3>
|
|
12802
12813
|
<dl>
|
|
12803
|
-
<div><dt>
|
|
12804
|
-
<div><dt>
|
|
12805
|
-
<div><dt>Alt
|
|
12814
|
+
<div><dt>Cmd/Ctrl+Option/Alt+Enter</dt><dd>Annotate the selected response from either main pane; protect unsubmitted or unsaved work</dd></div>
|
|
12815
|
+
<div><dt>Cmd/Ctrl+Option/Alt+Shift+Enter</dt><dd>Load the selected response into the editor without switching views; same replacement safeguards</dd></div>
|
|
12816
|
+
<div><dt>Option/Alt+←</dt><dd>Previous response when not editing text</dd></div>
|
|
12817
|
+
<div><dt>Option/Alt+→</dt><dd>Next response when not editing text</dd></div>
|
|
12818
|
+
<div><dt>Option/Alt+l</dt><dd>Latest response when not editing text</dd></div>
|
|
12806
12819
|
</dl>
|
|
12807
12820
|
</section>
|
|
12808
12821
|
<section class="shortcuts-group">
|
|
@@ -12855,6 +12868,7 @@ ${cssVarsBlock}
|
|
|
12855
12868
|
<script src="${mermaidHelpersScriptHref}"></script>
|
|
12856
12869
|
<script src="${navigationHelpersScriptHref}"></script>
|
|
12857
12870
|
<script src="${previewResourceHelpersScriptHref}"></script>
|
|
12871
|
+
<script src="${editorDraftHelpersScriptHref}"></script>
|
|
12858
12872
|
<script src="${showMeHelpersScriptHref}"></script>
|
|
12859
12873
|
<script src="${sideQuestionHelpersScriptHref}"></script>
|
|
12860
12874
|
<script src="${clientScriptHref}"></script>
|
|
@@ -15782,6 +15796,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
15782
15796
|
|
|
15783
15797
|
try {
|
|
15784
15798
|
pi.sendUserMessage(msg.text, { deliverAs: "steer" });
|
|
15799
|
+
sendToClient(client, { type: "run_accepted", requestId: msg.requestId });
|
|
15785
15800
|
broadcast({
|
|
15786
15801
|
type: "request_queued",
|
|
15787
15802
|
requestId: msg.requestId,
|
|
@@ -15815,6 +15830,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
15815
15830
|
|
|
15816
15831
|
try {
|
|
15817
15832
|
pi.sendUserMessage(msg.text);
|
|
15833
|
+
sendToClient(client, { type: "run_accepted", requestId: msg.requestId });
|
|
15818
15834
|
reportPiEditorDraftDisposition(client, msg.requestId, msg.piEditorDraftSnapshot);
|
|
15819
15835
|
} catch (error) {
|
|
15820
15836
|
clearStudioDirectRunState();
|
|
@@ -17446,12 +17462,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
17446
17462
|
return;
|
|
17447
17463
|
}
|
|
17448
17464
|
|
|
17449
|
-
|
|
17450
|
-
|
|
17451
|
-
|
|
17452
|
-
requestUrl = new URL(req.url ?? "/", `http://${host}`);
|
|
17453
|
-
} catch (error) {
|
|
17454
|
-
respondText(res, 400, `Invalid request URL: ${error instanceof Error ? error.message : String(error)}`);
|
|
17465
|
+
const requestUrl = parseStudioRequestTarget(req.url);
|
|
17466
|
+
if (!requestUrl) {
|
|
17467
|
+
respondText(res, 400, "Invalid request URL");
|
|
17455
17468
|
return;
|
|
17456
17469
|
}
|
|
17457
17470
|
|
|
@@ -17535,6 +17548,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
17535
17548
|
|| requestUrl.pathname === "/studio-preview-resource-helpers.js"
|
|
17536
17549
|
|| requestUrl.pathname === "/studio-show-me-helpers.js"
|
|
17537
17550
|
|| requestUrl.pathname === "/studio-side-question-helpers.js"
|
|
17551
|
+
|| requestUrl.pathname === "/studio-editor-draft-helpers.js"
|
|
17538
17552
|
|| requestUrl.pathname === "/studio-client.js"
|
|
17539
17553
|
) {
|
|
17540
17554
|
const token = requestUrl.searchParams.get("token") ?? "";
|
|
@@ -17562,7 +17576,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
17562
17576
|
? STUDIO_SHOW_ME_HELPERS_URL
|
|
17563
17577
|
: requestUrl.pathname === "/studio-side-question-helpers.js"
|
|
17564
17578
|
? STUDIO_SIDE_QUESTION_HELPERS_URL
|
|
17565
|
-
:
|
|
17579
|
+
: requestUrl.pathname === "/studio-editor-draft-helpers.js"
|
|
17580
|
+
? STUDIO_EDITOR_DRAFT_HELPERS_URL
|
|
17581
|
+
: STUDIO_CLIENT_URL;
|
|
17566
17582
|
const targetLabel = requestUrl.pathname === "/studio-annotation-helpers.js"
|
|
17567
17583
|
? "studio annotation helper script"
|
|
17568
17584
|
: requestUrl.pathname === "/studio-mermaid-helpers.js"
|
|
@@ -17575,7 +17591,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
17575
17591
|
? "studio Show me helper script"
|
|
17576
17592
|
: requestUrl.pathname === "/studio-side-question-helpers.js"
|
|
17577
17593
|
? "studio side-question helper script"
|
|
17578
|
-
:
|
|
17594
|
+
: requestUrl.pathname === "/studio-editor-draft-helpers.js"
|
|
17595
|
+
? "studio editor draft helper script"
|
|
17596
|
+
: "studio client script";
|
|
17579
17597
|
|
|
17580
17598
|
try {
|
|
17581
17599
|
const clientScript = readFileSync(targetUrl, "utf-8");
|
|
@@ -18044,9 +18062,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
18044
18062
|
res.end(buildStudioHtml(requestInitialDocument, serverState.token, lastCommandCtx?.ui.theme, currentModelLabel, terminalSessionLabel, terminalSessionDetail, contextUsageSnapshot, studioMode));
|
|
18045
18063
|
};
|
|
18046
18064
|
|
|
18047
|
-
const ensureServer = async (requestedPort?: number): Promise<StudioServerState> => {
|
|
18065
|
+
const ensureServer = async (requestedPort?: number, listenAll = false): Promise<StudioServerState> => {
|
|
18048
18066
|
if (serverState) return serverState;
|
|
18049
18067
|
|
|
18068
|
+
const binding = resolveStudioNetworkBinding(listenAll);
|
|
18050
18069
|
const server = createServer(handleHttpRequest);
|
|
18051
18070
|
const wsServer = new WebSocketServer({ noServer: true });
|
|
18052
18071
|
const clients = new Set<WebSocket>();
|
|
@@ -18058,12 +18077,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
18058
18077
|
clients,
|
|
18059
18078
|
clientModes,
|
|
18060
18079
|
port: 0,
|
|
18080
|
+
bindHost: binding.bindHost,
|
|
18081
|
+
listenAll: binding.listenAll,
|
|
18061
18082
|
token: createSessionToken(),
|
|
18062
18083
|
};
|
|
18063
18084
|
|
|
18064
18085
|
server.on("upgrade", (req, socket, head) => {
|
|
18065
|
-
const
|
|
18066
|
-
|
|
18086
|
+
const requestUrl = parseStudioRequestTarget(req.url);
|
|
18087
|
+
if (!requestUrl) {
|
|
18088
|
+
socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
18089
|
+
socket.destroy();
|
|
18090
|
+
return;
|
|
18091
|
+
}
|
|
18067
18092
|
|
|
18068
18093
|
if (requestUrl.pathname !== "/ws") {
|
|
18069
18094
|
socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
|
|
@@ -18078,7 +18103,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
18078
18103
|
return;
|
|
18079
18104
|
}
|
|
18080
18105
|
|
|
18081
|
-
|
|
18106
|
+
const origin = typeof req.headers.origin === "string" ? req.headers.origin : undefined;
|
|
18107
|
+
const requestHost = typeof req.headers.host === "string" ? req.headers.host : undefined;
|
|
18108
|
+
if (!isStudioWebSocketOriginAllowed(origin, requestHost, state.listenAll)) {
|
|
18082
18109
|
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
18083
18110
|
socket.destroy();
|
|
18084
18111
|
return;
|
|
@@ -18090,8 +18117,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
18090
18117
|
});
|
|
18091
18118
|
|
|
18092
18119
|
wsServer.on("connection", (ws, req) => {
|
|
18093
|
-
const
|
|
18094
|
-
|
|
18120
|
+
const requestUrl = parseStudioRequestTarget(req.url);
|
|
18121
|
+
if (!requestUrl) {
|
|
18122
|
+
try {
|
|
18123
|
+
ws.close(1008, "Invalid request URL");
|
|
18124
|
+
} catch {
|
|
18125
|
+
// Ignore close errors.
|
|
18126
|
+
}
|
|
18127
|
+
return;
|
|
18128
|
+
}
|
|
18095
18129
|
const clientMode = normalizeStudioUiMode(requestUrl.searchParams.get("mode"));
|
|
18096
18130
|
const requestedWatchedPath = (requestUrl.searchParams.get("watchPath") ?? "").trim();
|
|
18097
18131
|
const canonicalWatchedPath = requestedWatchedPath ? resolve(requestedWatchedPath) : "";
|
|
@@ -18190,7 +18224,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
18190
18224
|
};
|
|
18191
18225
|
server.once("error", onError);
|
|
18192
18226
|
server.once("listening", onListening);
|
|
18193
|
-
server.listen(listenPort,
|
|
18227
|
+
server.listen(listenPort, state.bindHost);
|
|
18194
18228
|
});
|
|
18195
18229
|
|
|
18196
18230
|
const address = server.address();
|
|
@@ -19063,6 +19097,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
19063
19097
|
if (serverState && launchOpenFlags.port && serverState.port !== launchOpenFlags.port) {
|
|
19064
19098
|
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");
|
|
19065
19099
|
}
|
|
19100
|
+
if (serverState && launchOpenFlags.listenAll && !serverState.listenAll) {
|
|
19101
|
+
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");
|
|
19102
|
+
return;
|
|
19103
|
+
}
|
|
19066
19104
|
|
|
19067
19105
|
const parsedLaunchPath = options?.allowPdfPreview ? parsePathArgument(launchArgs) : null;
|
|
19068
19106
|
const launchesPdfPreview = parsedLaunchPath
|
|
@@ -19082,9 +19120,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
19082
19120
|
if (serverState) {
|
|
19083
19121
|
const url = buildStudioUrl(serverState.port, serverState.token, "full");
|
|
19084
19122
|
ctx.ui.notify(`Studio URL: ${url}`, "info");
|
|
19085
|
-
const tunnelHint =
|
|
19086
|
-
|
|
19123
|
+
const tunnelHint = serverState.listenAll
|
|
19124
|
+
? null
|
|
19125
|
+
: buildStudioSshTunnelHint(serverState.port, url)
|
|
19126
|
+
?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(serverState.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
|
|
19087
19127
|
if (tunnelHint) ctx.ui.notify(tunnelHint, "info");
|
|
19128
|
+
if (serverState.listenAll) ctx.ui.notify(buildStudioListenAllWarning(serverState.port, url), "warning");
|
|
19088
19129
|
}
|
|
19089
19130
|
return;
|
|
19090
19131
|
}
|
|
@@ -19117,7 +19158,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
19117
19158
|
|
|
19118
19159
|
let state: StudioServerState;
|
|
19119
19160
|
try {
|
|
19120
|
-
state = await ensureServer(launchOpenFlags.port);
|
|
19161
|
+
state = await ensureServer(launchOpenFlags.port, launchOpenFlags.listenAll);
|
|
19121
19162
|
} catch (error) {
|
|
19122
19163
|
const message = error instanceof Error ? error.message : String(error);
|
|
19123
19164
|
const portText = launchOpenFlags.port ? ` on port ${launchOpenFlags.port}` : "";
|
|
@@ -19130,8 +19171,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
19130
19171
|
skipWorkspaceRestore: selection.skipWorkspaceRestore,
|
|
19131
19172
|
paneFocus: selection.paneFocus,
|
|
19132
19173
|
});
|
|
19133
|
-
const tunnelHint =
|
|
19134
|
-
|
|
19174
|
+
const tunnelHint = state.listenAll
|
|
19175
|
+
? null
|
|
19176
|
+
: buildStudioSshTunnelHint(state.port, url)
|
|
19177
|
+
?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(state.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
|
|
19135
19178
|
const openedLabel = selection.kind === "pdf-preview"
|
|
19136
19179
|
? "pi Studio PDF preview"
|
|
19137
19180
|
: (selection.kind === "watched-preview"
|
|
@@ -19171,11 +19214,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
19171
19214
|
} finally {
|
|
19172
19215
|
ctx.ui.notify(`Studio URL: ${url}`, "info");
|
|
19173
19216
|
if (tunnelHint) ctx.ui.notify(tunnelHint, "info");
|
|
19217
|
+
if (state.listenAll) ctx.ui.notify(buildStudioListenAllWarning(state.port, url), "warning");
|
|
19174
19218
|
}
|
|
19175
19219
|
};
|
|
19176
19220
|
|
|
19177
19221
|
pi.registerCommand("studio", {
|
|
19178
|
-
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)",
|
|
19222
|
+
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)",
|
|
19179
19223
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
19180
19224
|
const trimmed = args.trim();
|
|
19181
19225
|
|
|
@@ -19193,11 +19237,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
19193
19237
|
const counts = getStudioClientCounts();
|
|
19194
19238
|
const url = buildStudioUrl(serverState.port, serverState.token, "full");
|
|
19195
19239
|
ctx.ui.notify(
|
|
19196
|
-
`Studio running at ${url} (busy: ${isStudioBusy() ? "yes" : "no"}; full views: ${counts.full}; editor-only views: ${counts.editorOnly})`,
|
|
19240
|
+
`Studio running at ${url} (listening on ${serverState.bindHost}:${serverState.port}; busy: ${isStudioBusy() ? "yes" : "no"}; full views: ${counts.full}; editor-only views: ${counts.editorOnly})`,
|
|
19197
19241
|
"info",
|
|
19198
19242
|
);
|
|
19199
|
-
const sshTunnelHint = buildStudioSshTunnelHint(serverState.port, url);
|
|
19243
|
+
const sshTunnelHint = serverState.listenAll ? null : buildStudioSshTunnelHint(serverState.port, url);
|
|
19200
19244
|
if (sshTunnelHint) ctx.ui.notify(sshTunnelHint, "info");
|
|
19245
|
+
if (serverState.listenAll) ctx.ui.notify(buildStudioListenAllWarning(serverState.port, url), "warning");
|
|
19201
19246
|
return;
|
|
19202
19247
|
}
|
|
19203
19248
|
|
|
@@ -19210,7 +19255,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
19210
19255
|
+ " /studio --blank Open with blank editor\n"
|
|
19211
19256
|
+ " /studio --last Open with last model response\n"
|
|
19212
19257
|
+ " /studio --no-browser Print the Studio URL without opening a browser\n"
|
|
19213
|
-
+ " /studio --port <port> Bind Studio to a fixed
|
|
19258
|
+
+ " /studio --port <port> Bind Studio to a fixed port when starting\n"
|
|
19259
|
+
+ " /studio --listen-all Explicitly bind to 0.0.0.0 instead of localhost (security-sensitive)\n"
|
|
19214
19260
|
+ " /studio --open-remote Over SSH, open the remote browser anyway\n"
|
|
19215
19261
|
+ " /studio --status Show studio status\n"
|
|
19216
19262
|
+ " /studio --stop Stop studio server\n"
|
|
@@ -19241,7 +19287,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
19241
19287
|
+ " /studio-replace --blank Replace with blank editor\n"
|
|
19242
19288
|
+ " /studio-replace --last Replace with last model response\n"
|
|
19243
19289
|
+ " /studio-replace --no-browser Print URL without opening a browser\n"
|
|
19244
|
-
+ " /studio-replace --port <port> Bind Studio to a fixed
|
|
19290
|
+
+ " /studio-replace --port <port> Bind Studio to a fixed port when starting\n"
|
|
19291
|
+
+ " /studio-replace --listen-all Explicitly bind to 0.0.0.0 instead of localhost\n"
|
|
19245
19292
|
+ "Editor-only Studio views stay open.",
|
|
19246
19293
|
"info",
|
|
19247
19294
|
);
|
|
@@ -19269,7 +19316,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
19269
19316
|
+ " /studio-editor-only --blank Open with blank editor\n"
|
|
19270
19317
|
+ " /studio-editor-only --last Open with last model response loaded into the editor\n"
|
|
19271
19318
|
+ " /studio-editor-only --no-browser Print URL without opening a browser\n"
|
|
19272
|
-
+ " /studio-editor-only --port <port> Bind Studio to a fixed
|
|
19319
|
+
+ " /studio-editor-only --port <port> Bind Studio to a fixed port when starting\n"
|
|
19320
|
+
+ " /studio-editor-only --listen-all Explicitly bind to 0.0.0.0 instead of localhost\n"
|
|
19273
19321
|
+ "Multiple editor-only views are allowed in the same Pi session.",
|
|
19274
19322
|
"info",
|
|
19275
19323
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-studio",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.60",
|
|
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
|
+
}
|