pi-studio 0.10.1 → 0.10.2
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 +22 -0
- package/README.md +13 -18
- package/client/studio-client.js +237 -67
- package/index.ts +484 -270
- package/package.json +1 -1
- package/shared/repl-gnuplot-processes.js +180 -0
- package/shared/repl-session-stop.js +285 -0
- package/shared/studio-browser-launcher.js +43 -4
- package/shared/studio-buffer-client.js +3 -1
- package/shared/studio-document-open-client.js +3 -1
- package/shared/studio-repl-selection.js +96 -0
- package/shared/studio-repl-tmux.js +216 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,28 @@ All notable changes to `pi-studio` are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.10.2] — 2026-10-19
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
- The selected REPL session follows what you choose. A Start, send or agent send that finishes later no longer takes the selection back if you've chosen another session since.
|
|
11
|
+
- **Runtime** follows the selected session. If the agent sends code to a Python session while you're on Julia, Studio switches to Python. After a reload, Studio shows the session it has selected, not just some session of the last Runtime.
|
|
12
|
+
- When the selected session ends outside Studio, Studio selects another session of the same runtime, or none.
|
|
13
|
+
- An agent send that names no session fails when Studio shows none, with "No REPL session is selected in Studio. Choose one under Session, or pass sessionName."
|
|
14
|
+
- A new dual workspace, or one reopened after switching mode, no longer says it was restored. Only a reload that brings back this tab's own copy says "Restored the Prompt and Document."
|
|
15
|
+
- Opening a file names it and where it went, such as "Opened notes.md in the Document.", or "Opened notes.md." with one editor.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- On Windows, `/studio` opens the whole Studio URL. `cmd /c start` cut it at the first `&`, which lost the workspace and gave "editing URL is not registered in dual editor mode" (#5).
|
|
19
|
+
- On Windows, opening a PDF, an export or a folder works when its path has `&`, `%`, `^`, `!` or a comma. Studio hands the path to Explorer instead of `cmd /c start`.
|
|
20
|
+
- Starting a REPL waits for the runtime's own prompt before saying it started. If the runtime exits while starting, Studio says so and shows the command; if the prompt hasn't appeared after 20 seconds, it asks you to check the pane before running code.
|
|
21
|
+
- Stopping a runtime's session ends its processes, including any that ignore the hangup, and reports it stopped only once they've exited. If Studio can't confirm that, it says so. Shell sessions still close like a terminal, so nohup and disowned jobs keep running.
|
|
22
|
+
- Code goes only to the session Studio names, never to one whose name merely starts the same way, and into that session's first pane, even when windows are numbered from 1 or a pane is opened above it.
|
|
23
|
+
- REPL sessions are listed and read correctly when no UTF-8 locale is set.
|
|
24
|
+
- Output from sent code no longer shows Studio's own loader line, even when IPython wraps it, or the prompt after it. Output that only looks like a prompt is kept.
|
|
25
|
+
- Julia code with `$` interpolation, such as `println("i=$i")`, runs as written. Studio's wrapper used to read the `$` first.
|
|
26
|
+
- GHCi reports completion even when the code reads input or leaves a `:{` block open.
|
|
27
|
+
- Studio no longer pastes code into a runtime it doesn't support. It says "Studio can't send code to … yet: it doesn't support that runtime."
|
|
28
|
+
|
|
7
29
|
## [0.10.1] — 2026-10-14
|
|
8
30
|
|
|
9
31
|
### Fixed
|
package/README.md
CHANGED
|
@@ -4,36 +4,31 @@ A browser workspace for [Pi](https://pi.dev). Write and run prompts, read respon
|
|
|
4
4
|
|
|
5
5
|
## Screenshots
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
The Prompt, with a section added from the Document:
|
|
8
8
|
|
|
9
|
-

|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
The Document:
|
|
12
12
|
|
|
13
|
-

|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
Zen mode, in rose-pine-dawn:
|
|
16
16
|
|
|
17
|
-

|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
Watch the [demo](https://github.com/omaclaren/pi-studio/releases/download/demo-prompt-document/pi-studio-2-demo.mp4) (1m36s, 2× speed, no audio). It shows browsing linked documents and bringing a Document selection into the Prompt.
|
|
20
20
|
|
|
21
21
|
## Features
|
|
22
22
|
|
|
23
23
|
- **Prompt and Document** — the default, dual editor mode. The Prompt holds what you send to Pi; the Document holds the file you're working on. Each keeps its own edits, preview and place. See [Single and dual editor mode](#single-and-dual-editor-mode).
|
|
24
24
|
- **Run, critique, annotate** — run the Prompt, ask for a critique, or annotate a response with `[an: ...]` markers and send it back.
|
|
25
|
-
- **Response history** — step back through earlier responses
|
|
26
|
-
- **
|
|
27
|
-
- **Rendering** — Markdown and LaTeX with math, syntax highlighting, Mermaid diagrams (with Lucide and Logos icons), CSV tables, interactive HTML and embedded PDFs, themed to match Pi.
|
|
25
|
+
- **Response history and Working** — step back through earlier responses and the prompts behind them, and follow Pi's thinking and tool use as it happens.
|
|
26
|
+
- **Rendering** — Markdown and LaTeX with math, syntax highlighting, Mermaid diagrams, CSV tables, interactive HTML and embedded PDFs, themed to match Pi.
|
|
28
27
|
- **Files and Changes** — browse folders you've allowed, open or preview files, and see the current git diff.
|
|
29
28
|
- **Comments and scratchpad** — comments anchored to lines or selections, and a scratchpad per document.
|
|
30
|
-
- **Side questions** — ask about the text, a folder or the repository in a separate thread that stays out of the main conversation,
|
|
29
|
+
- **Side questions and quizzes** — ask about the text, a folder or the repository in a separate thread that stays out of the main conversation, or quiz yourself on it.
|
|
31
30
|
- **REPL** — send code to tmux-backed Shell, Python, IPython, Julia, R, GHCi or Clojure sessions, shared with [pi-repl](https://github.com/omaclaren/pi-repl).
|
|
32
|
-
- **Quiz me, Show me, completions** — active-recall quizzes over the text or a folder, one-turn visual explanations, and short completions at the cursor.
|
|
33
31
|
- **Export** — PDF (Pandoc and LaTeX) or standalone HTML, from the preview, a file or the last response.
|
|
34
|
-
- **Careful saving** — a save checks the file hasn't changed on disk since you opened it, and asks if it has.
|
|
35
|
-
- **Quarto** — preview a saved Quarto document through `quarto preview`, without running its code.
|
|
36
|
-
- **Layout** — side by side or stacked panes, and a Zen mode that hides everything but the text.
|
|
37
32
|
|
|
38
33
|
## Requirements
|
|
39
34
|
|
|
@@ -97,7 +92,7 @@ Pi's agent can also use these tools:
|
|
|
97
92
|
|
|
98
93
|
**Single editor mode** is the one-editor Studio of earlier versions, where Run sends the editor's text.
|
|
99
94
|
|
|
100
|
-
To switch, use the footer button or `/studio --single` and
|
|
95
|
+
To switch, use the footer button or `/studio --single` and `--dual`. Studio restarts and reopens the tab with the Prompt's text, and remembers the choice. If switching would lose anything, it says so first.
|
|
101
96
|
|
|
102
97
|
## Details
|
|
103
98
|
|
|
@@ -117,7 +112,7 @@ watch: true
|
|
|
117
112
|
```
|
|
118
113
|
````
|
|
119
114
|
|
|
120
|
-
To view a PDF, run `/studio report.pdf`; it opens read-only, and `#page=N` picks the page. `/studio --watch report.pdf` also reloads it when the file changes
|
|
115
|
+
To view a PDF, run `/studio report.pdf`; it opens read-only, and `#page=N` picks the page. `/studio --watch report.pdf` also reloads it when the file changes; it waits until the file stops changing, so it doesn't reload half-written LaTeX output. (`/studio-pdf` is different: it exports to a new PDF.)
|
|
121
116
|
|
|
122
117
|
### Mermaid icons
|
|
123
118
|
|
|
@@ -136,7 +131,7 @@ Icons in exported PDFs need Mermaid CLI 11.12+ (`npm install -g @mermaid-js/merm
|
|
|
136
131
|
|
|
137
132
|
### Pi's input editor
|
|
138
133
|
|
|
139
|
-
**Load from pi editor** (Cmd/Ctrl+Shift+L) copies the draft in Pi's terminal input into Studio,
|
|
134
|
+
**Load from pi editor** (Cmd/Ctrl+Shift+L) copies the draft in Pi's terminal input into Studio, which suits tools like [pi-nvim-context](https://github.com/omaclaren/pi-nvim-context) that build that draft up. When Studio runs that text, it clears the terminal draft only if the draft hasn't changed since.
|
|
140
135
|
|
|
141
136
|
### REPL
|
|
142
137
|
|
package/client/studio-client.js
CHANGED
|
@@ -674,6 +674,18 @@
|
|
|
674
674
|
let replTmuxAvailable = null;
|
|
675
675
|
let replSessions = [];
|
|
676
676
|
let replActiveSessionName = "";
|
|
677
|
+
// Studio's selection as this page last adopted it, with its `shown`, and this page's
|
|
678
|
+
// own choices: while one is unanswered, other replies don't change what the page
|
|
679
|
+
// shows (Sol, Astra).
|
|
680
|
+
let replServerSelection = { sessionName: "", runtime: "" };
|
|
681
|
+
let replShownBasis = null;
|
|
682
|
+
let replChoiceRevision = 0;
|
|
683
|
+
let replChoiceAnswered = 0;
|
|
684
|
+
// The session as chosen, kept with the unanswered choice rather than read from the list.
|
|
685
|
+
let replChoicePin = null;
|
|
686
|
+
let replReconcileSent = "";
|
|
687
|
+
// The session lifetime the transcript belongs to.
|
|
688
|
+
let replTranscriptOwner = "";
|
|
677
689
|
let replRuntime = (() => {
|
|
678
690
|
try {
|
|
679
691
|
return (window.localStorage && window.localStorage.getItem("piStudio.replRuntime")) || "python";
|
|
@@ -721,6 +733,9 @@
|
|
|
721
733
|
if (!entry || typeof entry !== "object") return null;
|
|
722
734
|
const hasCompatibleOrigin = entry.origin === "pi-repl" || entry.origin === "pi-studio";
|
|
723
735
|
const sharedSynced = entry.sharedSynced === true || (entry.sharedSynced !== false && hasCompatibleOrigin);
|
|
736
|
+
// Whether the output was cleaned where it was captured is separate from whether
|
|
737
|
+
// it reached the shared record: a failed record write leaves clean output.
|
|
738
|
+
const outputClean = sharedSynced || entry.outputClean === true;
|
|
724
739
|
const normalized = {
|
|
725
740
|
id: typeof entry.id === "string" && entry.id ? entry.id : ("repl-journal-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8)),
|
|
726
741
|
requestId: typeof entry.requestId === "string" ? entry.requestId : "",
|
|
@@ -731,11 +746,14 @@
|
|
|
731
746
|
runtime: typeof entry.runtime === "string" ? entry.runtime : "python",
|
|
732
747
|
origin: hasCompatibleOrigin ? entry.origin : "",
|
|
733
748
|
legacyLocal: !sharedSynced,
|
|
749
|
+
outputClean,
|
|
734
750
|
label: typeof entry.label === "string" ? entry.label : "REPL send",
|
|
735
751
|
mode: typeof entry.mode === "string" ? entry.mode : "raw",
|
|
736
752
|
prose: typeof entry.prose === "string" ? entry.prose : "",
|
|
737
753
|
code: typeof entry.code === "string" ? entry.code : "",
|
|
738
|
-
|
|
754
|
+
// Studio's server and pi-repl record clean output; only this browser's
|
|
755
|
+
// older local entries can still hold a loader echo.
|
|
756
|
+
output: typeof entry.output === "string" ? (outputClean ? entry.output : stripStudioReplSubmissionEcho(entry.output)) : "",
|
|
739
757
|
beforeTranscript: "",
|
|
740
758
|
status: typeof entry.status === "string" ? entry.status : "sent",
|
|
741
759
|
skippedChunks: Math.max(0, Math.floor(Number(entry.skippedChunks) || 0)),
|
|
@@ -767,6 +785,7 @@
|
|
|
767
785
|
runtime: entry.runtime,
|
|
768
786
|
origin: entry.origin,
|
|
769
787
|
sharedSynced: !entry.legacyLocal,
|
|
788
|
+
outputClean: entry.outputClean === true,
|
|
770
789
|
label: entry.label,
|
|
771
790
|
mode: entry.mode,
|
|
772
791
|
prose: entry.prose,
|
|
@@ -786,7 +805,8 @@
|
|
|
786
805
|
let changed = false;
|
|
787
806
|
const next = [...replJournalEntries];
|
|
788
807
|
for (const rawEntry of entries) {
|
|
789
|
-
|
|
808
|
+
// These come from Studio's server, which has already cleaned their output.
|
|
809
|
+
const entry = normalizeReplJournalEntry(rawEntry && typeof rawEntry === "object" ? { ...rawEntry, outputClean: true } : rawEntry);
|
|
790
810
|
if (!entry) continue;
|
|
791
811
|
const existingIndex = next.findIndex((candidate) => (
|
|
792
812
|
(entry.requestId && candidate.requestId === entry.requestId)
|
|
@@ -1488,7 +1508,7 @@
|
|
|
1488
1508
|
if (!sessionName) return null;
|
|
1489
1509
|
return {
|
|
1490
1510
|
sessionName,
|
|
1491
|
-
target: typeof session.target === "string" ? session.target :
|
|
1511
|
+
target: typeof session.target === "string" ? session.target : "",
|
|
1492
1512
|
tmuxSessionId: typeof session.tmuxSessionId === "string" ? session.tmuxSessionId : "",
|
|
1493
1513
|
tmuxSessionCreatedAt: typeof session.tmuxSessionCreatedAt === "number" && Number.isFinite(session.tmuxSessionCreatedAt)
|
|
1494
1514
|
? session.tmuxSessionCreatedAt
|
|
@@ -1729,14 +1749,20 @@
|
|
|
1729
1749
|
function setReplSessions(sessions) {
|
|
1730
1750
|
const previous = serializeReplSessionsForCompare(replSessions);
|
|
1731
1751
|
const previousActive = replActiveSessionName;
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1752
|
+
// While a choice is unanswered, the chosen session keeps the lifetime it was chosen
|
|
1753
|
+
// with: no list, even one after a list that left it out, can swap in a replacement
|
|
1754
|
+
// with the same name (Astra, Sol).
|
|
1755
|
+
const chosen = isReplChoicePending() ? replChoicePin : null;
|
|
1756
|
+
replSessions = (Array.isArray(sessions) ? sessions.map(normalizeReplSession).filter(Boolean) : [])
|
|
1757
|
+
.map((session) => (chosen && session.sessionName === chosen.sessionName ? chosen : session));
|
|
1758
|
+
// The list may change while a choice is unanswered, but the selection waits for its reply.
|
|
1759
|
+
if (!isReplChoicePending()) {
|
|
1760
|
+
if (replActiveSessionName && !replSessions.some((session) => session.sessionName === replActiveSessionName)) {
|
|
1761
|
+
setActiveReplSession("");
|
|
1762
|
+
}
|
|
1763
|
+
if (!getActiveReplSessionForCurrentRuntime()) {
|
|
1764
|
+
selectReplSessionForRuntime(replRuntime, replActiveSessionName);
|
|
1765
|
+
}
|
|
1740
1766
|
}
|
|
1741
1767
|
return previous !== serializeReplSessionsForCompare(replSessions) || previousActive !== replActiveSessionName;
|
|
1742
1768
|
}
|
|
@@ -1789,7 +1815,7 @@
|
|
|
1789
1815
|
"The right pane is mirroring an active tmux-backed REPL session.",
|
|
1790
1816
|
"If the user refers to the active REPL, send code to this session rather than inventing a separate one.",
|
|
1791
1817
|
"Session name: " + session.sessionName,
|
|
1792
|
-
"tmux target: " + (session.target ||
|
|
1818
|
+
"tmux target: " + (session.target || session.sessionName),
|
|
1793
1819
|
"runtime: " + runtime,
|
|
1794
1820
|
"Use the studio_repl_send tool for code execution in this REPL. Pass sessionName when targeting this exact session.",
|
|
1795
1821
|
"Do not improvise raw tmux paste commands for multiline code; Studio handles runtime-specific safe submission.",
|
|
@@ -1804,21 +1830,94 @@
|
|
|
1804
1830
|
}
|
|
1805
1831
|
|
|
1806
1832
|
function setActiveReplSession(sessionName) {
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1833
|
+
replActiveSessionName = String(sessionName || "").trim();
|
|
1834
|
+
retireReplViewIfTargetChanged();
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
// The view belongs to one session lifetime, or to none. When the displayed target
|
|
1838
|
+
// changes, including to or from none or to a replacement with the same name, its
|
|
1839
|
+
// transcript, timestamp and live entry go with it (Sol).
|
|
1840
|
+
function retireReplViewIfTargetChanged() {
|
|
1841
|
+
const owner = getReplSessionLifetimeKey(getActiveReplSession());
|
|
1842
|
+
if (owner === replTranscriptOwner) return false;
|
|
1843
|
+
replTranscript = "";
|
|
1844
|
+
replCapturedAt = 0;
|
|
1845
|
+
activeReplJournalEntryId = "";
|
|
1846
|
+
replTranscriptOwner = owner;
|
|
1847
|
+
return true;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
function getReplSessionLifetimeKey(session) {
|
|
1851
|
+
return session ? [session.sessionName, session.tmuxSessionId || "", Number(session.tmuxSessionCreatedAt) || 0].join("\u001f") : "";
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
function upsertReplSession(value) {
|
|
1855
|
+
const session = normalizeReplSession(value);
|
|
1856
|
+
if (!session) return false;
|
|
1857
|
+
const index = replSessions.findIndex((candidate) => candidate.sessionName === session.sessionName);
|
|
1858
|
+
if (index < 0) {
|
|
1859
|
+
replSessions = [...replSessions, session];
|
|
1860
|
+
return true;
|
|
1815
1861
|
}
|
|
1816
|
-
if (
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1862
|
+
if (serializeReplSessionsForCompare([replSessions[index]]) === serializeReplSessionsForCompare([session])) return false;
|
|
1863
|
+
replSessions = replSessions.map((candidate, candidateIndex) => candidateIndex === index ? session : candidate);
|
|
1864
|
+
return true;
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
function isReplChoicePending() {
|
|
1868
|
+
return replChoiceAnswered < replChoiceRevision;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
// Only the reply to this page's latest choice ends the wait.
|
|
1872
|
+
function noteReplChoiceAnswer(message) {
|
|
1873
|
+
if (typeof message.choiceRevision === "number" && message.choiceRevision > replChoiceAnswered) {
|
|
1874
|
+
replChoiceAnswered = Math.min(message.choiceRevision, replChoiceRevision);
|
|
1820
1875
|
}
|
|
1821
|
-
|
|
1876
|
+
if (!isReplChoicePending()) replChoicePin = null;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
// Adopts Studio's selection from a message that states it. A session brings its
|
|
1880
|
+
// runtime; none comes for a runtime; with no preference yet, the page keeps its
|
|
1881
|
+
// runtime and picks within it (Sol, Astra).
|
|
1882
|
+
function adoptReplSelection(message) {
|
|
1883
|
+
const sessionName = typeof message.activeSessionName === "string" ? message.activeSessionName.trim() : "";
|
|
1884
|
+
const runtime = getKnownReplRuntime(message.activeRuntime);
|
|
1885
|
+
replServerSelection = { sessionName, runtime };
|
|
1886
|
+
if (typeof message.shown === "number") replShownBasis = message.shown;
|
|
1887
|
+
if (runtime && runtime !== replRuntime) setReplRuntime(runtime);
|
|
1888
|
+
if (sessionName) {
|
|
1889
|
+
setActiveReplSessionForCurrentRuntime(sessionName);
|
|
1890
|
+
} else {
|
|
1891
|
+
selectReplSessionForRuntime(replRuntime, runtime ? "" : replActiveSessionName);
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
// Reports a selection the page made itself, against the selection it last adopted.
|
|
1896
|
+
// Studio applies it only if nothing has changed since; it isn't a choice (Sol, Astra).
|
|
1897
|
+
function reconcileReplSelection() {
|
|
1898
|
+
if (replShownBasis === null || isReplChoicePending() || wsState === "Disconnected") return false;
|
|
1899
|
+
const session = getActiveReplSessionForCurrentRuntime();
|
|
1900
|
+
const matches = session
|
|
1901
|
+
? session.sessionName === replServerSelection.sessionName
|
|
1902
|
+
: !replServerSelection.sessionName && replServerSelection.runtime === replRuntime;
|
|
1903
|
+
if (matches) return false;
|
|
1904
|
+
const target = session
|
|
1905
|
+
? { sessionName: session.sessionName, tmuxSessionId: session.tmuxSessionId, tmuxSessionCreatedAt: session.tmuxSessionCreatedAt }
|
|
1906
|
+
: { runtime: replRuntime };
|
|
1907
|
+
const key = replShownBasis + "\u001f" + JSON.stringify(target);
|
|
1908
|
+
if (key === replReconcileSent) return false;
|
|
1909
|
+
replReconcileSent = key;
|
|
1910
|
+
return sendMessage({ type: "repl_reconcile_request", basis: replShownBasis, ...target });
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
// A new connection has no choice outstanding and no basis until it adopts one.
|
|
1914
|
+
function resetReplSelectionBasis() {
|
|
1915
|
+
replChoiceAnswered = replChoiceRevision;
|
|
1916
|
+
replChoicePin = null;
|
|
1917
|
+
replShownBasis = null;
|
|
1918
|
+
replReconcileSent = "";
|
|
1919
|
+
replServerSelection = { sessionName: "", runtime: "" };
|
|
1920
|
+
if (rightView === "repl") requestReplList();
|
|
1822
1921
|
}
|
|
1823
1922
|
|
|
1824
1923
|
function trimReplTranscript(text) {
|
|
@@ -1832,6 +1931,7 @@
|
|
|
1832
1931
|
return sendMessage({ type: "repl_list_request" });
|
|
1833
1932
|
}
|
|
1834
1933
|
|
|
1934
|
+
// A poll: the displayed session as it is now, or the list when there is none.
|
|
1835
1935
|
function requestReplCapture() {
|
|
1836
1936
|
if (wsState === "Disconnected") return false;
|
|
1837
1937
|
if (replActiveSessionName) {
|
|
@@ -1840,6 +1940,25 @@
|
|
|
1840
1940
|
return sendMessage({ type: "repl_list_request" });
|
|
1841
1941
|
}
|
|
1842
1942
|
|
|
1943
|
+
// A choice on the Session or Runtime selector, which Studio counts. Until its reply
|
|
1944
|
+
// arrives, other replies don't change what the page shows.
|
|
1945
|
+
function requestReplChoice() {
|
|
1946
|
+
if (wsState === "Disconnected") return false;
|
|
1947
|
+
const choiceRevision = replChoiceRevision + 1;
|
|
1948
|
+
const sent = sendMessage({
|
|
1949
|
+
type: "repl_capture_request",
|
|
1950
|
+
choice: true,
|
|
1951
|
+
choiceRevision,
|
|
1952
|
+
runtime: replRuntime,
|
|
1953
|
+
...(replActiveSessionName ? { sessionName: replActiveSessionName } : {}),
|
|
1954
|
+
});
|
|
1955
|
+
if (sent) {
|
|
1956
|
+
replChoiceRevision = choiceRevision;
|
|
1957
|
+
replChoicePin = getActiveReplSession();
|
|
1958
|
+
}
|
|
1959
|
+
return sent;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1843
1962
|
function isReplControlFocused() {
|
|
1844
1963
|
const activeEl = document.activeElement;
|
|
1845
1964
|
return activeEl instanceof Element && Boolean(activeEl.closest(".repl-controls, .repl-quick-composer"));
|
|
@@ -1905,6 +2024,8 @@
|
|
|
1905
2024
|
function startReplPolling() {
|
|
1906
2025
|
if (rightView !== "repl") return;
|
|
1907
2026
|
if (replPollTimer !== null) return;
|
|
2027
|
+
// Polls only observe, so the view starts from Studio's current selection.
|
|
2028
|
+
if (replActiveSessionName) requestReplList();
|
|
1908
2029
|
requestReplCapture();
|
|
1909
2030
|
replPollTimer = window.setInterval(() => {
|
|
1910
2031
|
if (rightView !== "repl") {
|
|
@@ -2182,6 +2303,7 @@
|
|
|
2182
2303
|
runtime: details.runtime || getActiveReplRuntime(),
|
|
2183
2304
|
origin: details.origin === "pi-repl" ? "pi-repl" : "pi-studio",
|
|
2184
2305
|
legacyLocal: !sharedSynced,
|
|
2306
|
+
outputClean: sharedSynced || details.outputClean === true,
|
|
2185
2307
|
label: details.label || "REPL send",
|
|
2186
2308
|
mode: details.mode || replSendMode,
|
|
2187
2309
|
prose: String(details.prose || ""),
|
|
@@ -2259,7 +2381,7 @@
|
|
|
2259
2381
|
return sent;
|
|
2260
2382
|
}
|
|
2261
2383
|
|
|
2262
|
-
function recordReplToolSend(message) {
|
|
2384
|
+
function recordReplToolSend(message, selects) {
|
|
2263
2385
|
const requestId = typeof message.toolCallId === "string" && message.toolCallId.trim()
|
|
2264
2386
|
? "tool:" + message.toolCallId.trim()
|
|
2265
2387
|
: (typeof message.requestId === "string" && message.requestId.trim() ? message.requestId.trim() : "");
|
|
@@ -2267,20 +2389,23 @@
|
|
|
2267
2389
|
if (!code.trim()) return false;
|
|
2268
2390
|
const runtime = normalizeReplRuntime(message.runtime || getActiveReplRuntime());
|
|
2269
2391
|
const sessionName = typeof message.sessionName === "string" ? message.sessionName : replActiveSessionName;
|
|
2270
|
-
|
|
2392
|
+
// The server has already cleaned this send's output; cleaning it again could drop real lines.
|
|
2393
|
+
const output = trimReplJournalOutput(String(message.output || "").trimEnd());
|
|
2271
2394
|
const details = {
|
|
2272
2395
|
requestId,
|
|
2273
2396
|
sessionName,
|
|
2274
2397
|
runtime,
|
|
2275
2398
|
origin: message.origin === "pi-repl" ? "pi-repl" : "pi-studio",
|
|
2276
2399
|
sharedSynced: message.sharedSynced === true,
|
|
2400
|
+
outputClean: true,
|
|
2277
2401
|
label: typeof message.label === "string" && message.label.trim() ? message.label.trim() : "Pi",
|
|
2278
2402
|
mode: "agent",
|
|
2279
2403
|
code,
|
|
2280
2404
|
output,
|
|
2281
2405
|
status: output.trim() ? "captured" : (message.timedOut ? "timeout" : "sent"),
|
|
2282
2406
|
};
|
|
2283
|
-
|
|
2407
|
+
// A result that doesn't select leaves this page's own entry tracking alone (Astra).
|
|
2408
|
+
if (selects !== false) activeReplJournalEntryId = "";
|
|
2284
2409
|
if (requestId) {
|
|
2285
2410
|
const existingIndex = replJournalEntries.findIndex((entry) => entry.requestId === requestId);
|
|
2286
2411
|
if (existingIndex >= 0) {
|
|
@@ -2375,7 +2500,8 @@
|
|
|
2375
2500
|
if (entryIndex < 0) return false;
|
|
2376
2501
|
const entry = replJournalEntries[entryIndex];
|
|
2377
2502
|
if (entry.sessionName && sessionName && entry.sessionName !== sessionName) return false;
|
|
2378
|
-
|
|
2503
|
+
// Finished output from Studio's server stands, whether or not the shared record took it.
|
|
2504
|
+
if ((entry.outputClean || !entry.legacyLocal) && (entry.status === "captured" || entry.status === "timeout" || entry.status === "error")) {
|
|
2379
2505
|
activeReplJournalEntryId = "";
|
|
2380
2506
|
return false;
|
|
2381
2507
|
}
|
|
@@ -11699,13 +11825,13 @@
|
|
|
11699
11825
|
selectReplSessionForRuntime(replRuntime, previousActive);
|
|
11700
11826
|
replError = "";
|
|
11701
11827
|
replMessage = "";
|
|
11702
|
-
if (replActiveSessionName) {
|
|
11703
|
-
requestReplCapture();
|
|
11704
|
-
} else {
|
|
11828
|
+
if (!replActiveSessionName) {
|
|
11705
11829
|
replTranscript = "";
|
|
11706
11830
|
replCapturedAt = 0;
|
|
11707
11831
|
syncActionButtons();
|
|
11708
11832
|
}
|
|
11833
|
+
// Counted even with no session, so nothing that finishes later takes it back.
|
|
11834
|
+
requestReplChoice();
|
|
11709
11835
|
renderReplViewIfActive({ force: true });
|
|
11710
11836
|
return;
|
|
11711
11837
|
}
|
|
@@ -11727,7 +11853,7 @@
|
|
|
11727
11853
|
replError = "";
|
|
11728
11854
|
replMessage = "";
|
|
11729
11855
|
replFollow = true;
|
|
11730
|
-
|
|
11856
|
+
requestReplChoice();
|
|
11731
11857
|
renderReplViewIfActive({ force: true });
|
|
11732
11858
|
}
|
|
11733
11859
|
}
|
|
@@ -17226,6 +17352,13 @@
|
|
|
17226
17352
|
},
|
|
17227
17353
|
replace: bufferId => retireStudioBufferSourceView(bufferId),
|
|
17228
17354
|
bind: () => bindSelectedStudioBuffer({ focusEditor: false }),
|
|
17355
|
+
opened: bufferId => {
|
|
17356
|
+
const buffer = bufferRecoveryClient?.snapshot()?.buffers.find(b => b.id === bufferId);
|
|
17357
|
+
const path = String(buffer?.sourceState.path || "");
|
|
17358
|
+
const name = path.split(/^(?:[A-Za-z]:\\|\\\\)/.test(path) ? /[\\/]/ : "/").pop();
|
|
17359
|
+
const where = !bufferSwitchingEnabled ? "" : buffer?.role === "document" ? " in the Document" : " in the Prompt";
|
|
17360
|
+
return name ? "Opened " + name + where + "." : "";
|
|
17361
|
+
},
|
|
17229
17362
|
reuse: async request => {
|
|
17230
17363
|
const result = await reuseHostedDocumentOwner(request.path, { sourcePath: request.sourcePath, resourceDir: request.resourceDir });
|
|
17231
17364
|
if (!result.handled) setStatus("The file owner is no longer available. Current work was kept; retry opening explicitly.", "warning");
|
|
@@ -28461,25 +28594,30 @@
|
|
|
28461
28594
|
const responseRequestId = typeof message.requestId === "string" ? message.requestId : "";
|
|
28462
28595
|
const settlesPendingRequest = Boolean(responseRequestId && responseRequestId === replPendingRequestId);
|
|
28463
28596
|
const previousActiveLifetimeKey = getReplQuickDraftKey(getActiveReplSession());
|
|
28597
|
+
const previousRuntime = replRuntime;
|
|
28598
|
+
// Every repl_state states Studio's selection; while this page's own choice is
|
|
28599
|
+
// unanswered, only that choice's reply changes what the page shows.
|
|
28600
|
+
noteReplChoiceAnswer(message);
|
|
28601
|
+
const present = !isReplChoicePending();
|
|
28464
28602
|
replTmuxAvailable = typeof message.tmuxAvailable === "boolean" ? message.tmuxAvailable : replTmuxAvailable;
|
|
28465
28603
|
const sessionsChanged = setReplSessions(message.sessions);
|
|
28466
|
-
if (
|
|
28467
|
-
setActiveReplSessionForCurrentRuntime(message.activeSessionName);
|
|
28468
|
-
}
|
|
28604
|
+
if (present) adoptReplSelection(message);
|
|
28469
28605
|
const journalChanged = mergeReplJournalEntries(message.journalEntries);
|
|
28470
28606
|
reconcileReplQuickPending();
|
|
28471
28607
|
maybeImportLegacyReplJournalEntries(replActiveSessionName);
|
|
28472
|
-
if (typeof message.transcript === "string") replTranscript = trimReplTranscript(message.transcript);
|
|
28473
|
-
if (typeof message.capturedAt === "number") replCapturedAt = message.capturedAt;
|
|
28608
|
+
if (present && typeof message.transcript === "string") replTranscript = trimReplTranscript(message.transcript);
|
|
28609
|
+
if (present && typeof message.capturedAt === "number") replCapturedAt = message.capturedAt;
|
|
28474
28610
|
replError = typeof message.replError === "string" ? message.replError : (typeof message.captureError === "string" ? message.captureError : "");
|
|
28475
28611
|
replMessage = typeof message.replMessage === "string" ? message.replMessage : "";
|
|
28476
28612
|
if (settlesPendingRequest && replPendingRequestId === responseRequestId) replPendingRequestId = "";
|
|
28477
28613
|
replBusy = Boolean(replPendingRequestId);
|
|
28614
|
+
if (present) reconcileReplSelection();
|
|
28478
28615
|
const activeSessionIdentityChanged = Boolean(previousActiveLifetimeKey)
|
|
28479
28616
|
&& previousActiveLifetimeKey !== getReplQuickDraftKey(getActiveReplSession());
|
|
28480
28617
|
const controlsChanged = wasBusy
|
|
28481
28618
|
|| sessionsChanged
|
|
28482
28619
|
|| previousTmuxAvailable !== replTmuxAvailable
|
|
28620
|
+
|| previousRuntime !== replRuntime
|
|
28483
28621
|
|| previousActiveSessionName !== replActiveSessionName;
|
|
28484
28622
|
if (controlsChanged) syncActionButtons();
|
|
28485
28623
|
const viewChanged = controlsChanged
|
|
@@ -28494,14 +28632,28 @@
|
|
|
28494
28632
|
}
|
|
28495
28633
|
|
|
28496
28634
|
if (message.type === "repl_tool_send") {
|
|
28497
|
-
if
|
|
28498
|
-
|
|
28635
|
+
// The agent's send selects only if nothing was chosen since Studio admitted it,
|
|
28636
|
+
// and not while this page's own choice is unanswered (Sol, Astra).
|
|
28637
|
+
const selects = message.select !== false && !isReplChoicePending();
|
|
28638
|
+
const previousRuntime = replRuntime;
|
|
28639
|
+
const previousActiveSessionName = replActiveSessionName;
|
|
28640
|
+
if (selects) {
|
|
28641
|
+
if (message.session) upsertReplSession(message.session);
|
|
28642
|
+
adoptReplSelection({
|
|
28643
|
+
activeSessionName: typeof message.activeSessionName === "string" ? message.activeSessionName : message.sessionName,
|
|
28644
|
+
activeRuntime: typeof message.activeRuntime === "string" ? message.activeRuntime : message.runtime,
|
|
28645
|
+
shown: message.shown,
|
|
28646
|
+
});
|
|
28499
28647
|
}
|
|
28500
|
-
const changed = recordReplToolSend(message);
|
|
28648
|
+
const changed = recordReplToolSend(message, selects);
|
|
28501
28649
|
const journalChanged = mergeReplJournalEntries(message.journalEntries);
|
|
28502
|
-
if (
|
|
28503
|
-
|
|
28504
|
-
|
|
28650
|
+
if (selects && message.sessionName === replActiveSessionName) {
|
|
28651
|
+
if (typeof message.transcript === "string") replTranscript = trimReplTranscript(message.transcript);
|
|
28652
|
+
if (typeof message.capturedAt === "number") replCapturedAt = message.capturedAt;
|
|
28653
|
+
}
|
|
28654
|
+
if (selects) reconcileReplSelection();
|
|
28655
|
+
if (previousRuntime !== replRuntime || previousActiveSessionName !== replActiveSessionName) syncActionButtons();
|
|
28656
|
+
if (changed || journalChanged || selects) renderReplViewIfActive({ force: true });
|
|
28505
28657
|
updateReferenceBadge();
|
|
28506
28658
|
return;
|
|
28507
28659
|
}
|
|
@@ -28516,41 +28668,55 @@
|
|
|
28516
28668
|
const responseRequestId = typeof message.requestId === "string" ? message.requestId : "";
|
|
28517
28669
|
const settlesPendingRequest = Boolean(responseRequestId && responseRequestId === replPendingRequestId);
|
|
28518
28670
|
const previousActiveLifetimeKey = getReplQuickDraftKey(getActiveReplSession());
|
|
28671
|
+
const previousRuntime = replRuntime;
|
|
28519
28672
|
let sessionsChanged = false;
|
|
28520
|
-
|
|
28521
|
-
|
|
28522
|
-
|
|
28523
|
-
|
|
28524
|
-
|
|
28525
|
-
|
|
28526
|
-
|
|
28527
|
-
|
|
28528
|
-
|
|
28529
|
-
|
|
28673
|
+
let applies = false;
|
|
28674
|
+
if (message.observation === true) {
|
|
28675
|
+
// A poll's reply shows one session as it is now. It applies only to the exact
|
|
28676
|
+
// session this page displays, checked before anything changes (Sol, Astra).
|
|
28677
|
+
const observed = normalizeReplSession(message.session);
|
|
28678
|
+
const displayed = getActiveReplSession();
|
|
28679
|
+
const observedName = observed ? observed.sessionName : (typeof message.sessionName === "string" ? message.sessionName : "");
|
|
28680
|
+
if (!isReplChoicePending() && displayed && observedName === displayed.sessionName) {
|
|
28681
|
+
if (!observed) {
|
|
28682
|
+
// Its capture failed; the list says what Studio selects now.
|
|
28683
|
+
applies = true;
|
|
28684
|
+
requestReplList();
|
|
28685
|
+
} else if (getReplSessionLifetimeKey(observed) === getReplSessionLifetimeKey(displayed)) {
|
|
28686
|
+
applies = true;
|
|
28687
|
+
sessionsChanged = upsertReplSession(observed);
|
|
28688
|
+
} else {
|
|
28689
|
+
requestReplList();
|
|
28530
28690
|
}
|
|
28531
28691
|
}
|
|
28532
|
-
}
|
|
28533
|
-
|
|
28534
|
-
|
|
28692
|
+
} else {
|
|
28693
|
+
// A choice's reply, or a result that selected: it states Studio's selection.
|
|
28694
|
+
noteReplChoiceAnswer(message);
|
|
28695
|
+
applies = !isReplChoicePending();
|
|
28696
|
+
if (applies && message.session) sessionsChanged = upsertReplSession(message.session);
|
|
28697
|
+
if (applies) adoptReplSelection(message);
|
|
28698
|
+
applies = applies && Boolean(message.session) && message.session.sessionName === replActiveSessionName;
|
|
28535
28699
|
}
|
|
28536
28700
|
let journalChanged = mergeReplJournalEntries(message.journalEntries);
|
|
28537
28701
|
reconcileReplQuickPending();
|
|
28538
28702
|
maybeImportLegacyReplJournalEntries(replActiveSessionName);
|
|
28539
|
-
if (typeof message.transcript === "string") {
|
|
28703
|
+
if (applies && typeof message.transcript === "string") {
|
|
28540
28704
|
replTranscript = trimReplTranscript(message.transcript);
|
|
28541
|
-
journalChanged = updateActiveReplJournalEntryFromTranscript(
|
|
28542
|
-
|
|
28543
|
-
|
|
28544
|
-
|
|
28705
|
+
journalChanged = updateActiveReplJournalEntryFromTranscript(replActiveSessionName, replTranscript) || journalChanged;
|
|
28706
|
+
} else if (applies && typeof message.captureError === "string") {
|
|
28707
|
+
replTranscript = "";
|
|
28708
|
+
}
|
|
28709
|
+
if (applies && typeof message.capturedAt === "number") replCapturedAt = message.capturedAt;
|
|
28710
|
+
if (applies || message.observation !== true) {
|
|
28711
|
+
replError = typeof message.replError === "string" ? message.replError : (typeof message.captureError === "string" ? message.captureError : "");
|
|
28545
28712
|
}
|
|
28546
|
-
if (typeof message.capturedAt === "number") replCapturedAt = message.capturedAt;
|
|
28547
|
-
replError = typeof message.replError === "string" ? message.replError : "";
|
|
28548
28713
|
if (typeof message.replMessage === "string") replMessage = message.replMessage;
|
|
28549
28714
|
if (settlesPendingRequest && replPendingRequestId === responseRequestId) replPendingRequestId = "";
|
|
28550
28715
|
replBusy = Boolean(replPendingRequestId);
|
|
28716
|
+
if (message.observation !== true && !isReplChoicePending()) reconcileReplSelection();
|
|
28551
28717
|
const activeSessionIdentityChanged = Boolean(previousActiveLifetimeKey)
|
|
28552
28718
|
&& previousActiveLifetimeKey !== getReplQuickDraftKey(getActiveReplSession());
|
|
28553
|
-
const controlsChanged = wasBusy || sessionsChanged || previousActiveSessionName !== replActiveSessionName;
|
|
28719
|
+
const controlsChanged = wasBusy || sessionsChanged || previousRuntime !== replRuntime || previousActiveSessionName !== replActiveSessionName;
|
|
28554
28720
|
if (controlsChanged) syncActionButtons();
|
|
28555
28721
|
const viewChanged = controlsChanged
|
|
28556
28722
|
|| previousTranscript !== replTranscript
|
|
@@ -29375,6 +29541,7 @@
|
|
|
29375
29541
|
setWsState("Ready");
|
|
29376
29542
|
setStatus(wasReconnect ? "Reconnected. Syncing…" : "Connected. Syncing…");
|
|
29377
29543
|
sendMessage({ type: "hello" });
|
|
29544
|
+
resetReplSelectionBasis();
|
|
29378
29545
|
if (isWatchedFilePreview && watchedFilePreviewState.path) {
|
|
29379
29546
|
watchedFilePreviewState.generation = 0;
|
|
29380
29547
|
sendMessage({
|
|
@@ -32159,10 +32326,13 @@
|
|
|
32159
32326
|
if (restored.restored) {
|
|
32160
32327
|
const snapshot = client.snapshot();
|
|
32161
32328
|
const selected = snapshot.buffers.find((entry) => entry.id === snapshot.selectedBufferId);
|
|
32162
|
-
applyPersistedWorkspaceState(restored.editor, {
|
|
32329
|
+
const applied = applyPersistedWorkspaceState(restored.editor, {
|
|
32163
32330
|
verifiedBuffer: documentHostingEnabled,
|
|
32164
32331
|
metadataAssociations: selected && selected.metadata,
|
|
32165
32332
|
});
|
|
32333
|
+
// A fresh dual-mode launch is registered on the server first; only an
|
|
32334
|
+
// applied copy kept by this tab (a reload) is reported as restored.
|
|
32335
|
+
workspaceRestoredFromBrowser = applied && restored.fromBrowser === true;
|
|
32166
32336
|
if (sourceState.path) fileBackedBaselineText = restored.baselineText;
|
|
32167
32337
|
if (selected) {
|
|
32168
32338
|
sourceTextEl.setSelectionRange(selected.view.selectionStart, selected.view.selectionEnd, selected.view.selectionDirection);
|
|
@@ -32216,7 +32386,7 @@
|
|
|
32216
32386
|
if (documentHostingEnabled) syncActionButtons();
|
|
32217
32387
|
persistWorkspaceStateNow({ skipServer: serverWorkspaceRecovery.status === "unavailable" });
|
|
32218
32388
|
if (workspaceRestoredFromBrowser) {
|
|
32219
|
-
setStatus(bufferSwitchingEnabled ? "Restored Prompt and Document
|
|
32389
|
+
setStatus(bufferSwitchingEnabled ? "Restored the Prompt and Document." : "Restored editor workspace from this browser tab. Use Reset editor to discard it.", "success");
|
|
32220
32390
|
}
|
|
32221
32391
|
connect();
|
|
32222
32392
|
if (documentHostingEnabled && bufferRecoveryClient) { await bufferRecoveryClient.settled(); await setupDocumentHosting(); }
|