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
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Studio's selected REPL session (Sol, Astra).
|
|
2
|
+
//
|
|
3
|
+
// The selection is a session, by its full tmux lifetime; none for a runtime; or no
|
|
4
|
+
// preference yet, from Studio's start until something is selected.
|
|
5
|
+
//
|
|
6
|
+
// Explicit actions (a choice on the page, a send, an interrupt, the agent's send and a
|
|
7
|
+
// Start request) are counted, and each takes the count as its ticket when admitted. An
|
|
8
|
+
// action that finishes later selects only if its ticket is still current, so it can't
|
|
9
|
+
// undo anything chosen since it began.
|
|
10
|
+
//
|
|
11
|
+
// `shown` changes whenever the selection changes, by any route. A page reports a
|
|
12
|
+
// selection it made itself only against the `shown` it last adopted.
|
|
13
|
+
|
|
14
|
+
/** @typedef {{ sessionName: string, tmuxSessionId: string, tmuxSessionCreatedAt: number, runtime: string }} ReplSessionLifetime */
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {ReplSessionLifetime | null | undefined} left
|
|
18
|
+
* @param {ReplSessionLifetime | null | undefined} right
|
|
19
|
+
*/
|
|
20
|
+
export function sameReplSessionLifetime(left, right) {
|
|
21
|
+
return Boolean(left && right)
|
|
22
|
+
&& left.sessionName === right.sessionName
|
|
23
|
+
&& left.tmuxSessionId === right.tmuxSessionId
|
|
24
|
+
&& left.tmuxSessionCreatedAt === right.tmuxSessionCreatedAt;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @template {ReplSessionLifetime} S @param {{ session?: S | null, runtime?: string | null } | null | undefined} target @returns {{ session?: S, runtime?: string | null }} */
|
|
28
|
+
const toSelection = (target) => (target?.session ? { session: target.session } : { runtime: target?.runtime || null });
|
|
29
|
+
|
|
30
|
+
/** @param {{ session?: ReplSessionLifetime, runtime?: string | null }} left @param {{ session?: ReplSessionLifetime, runtime?: string | null }} right */
|
|
31
|
+
const sameSelection = (left, right) => (left.session || right.session
|
|
32
|
+
? sameReplSessionLifetime(left.session, right.session)
|
|
33
|
+
: left.runtime === right.runtime);
|
|
34
|
+
|
|
35
|
+
/** @template {ReplSessionLifetime} S */
|
|
36
|
+
export function createReplSelection() {
|
|
37
|
+
/** @type {{ session?: S, runtime?: string | null }} */
|
|
38
|
+
let selected = { runtime: null };
|
|
39
|
+
let shown = 0;
|
|
40
|
+
let actions = 0;
|
|
41
|
+
/** @type {{ session?: S, runtime?: string | null, start?: boolean } | null} */
|
|
42
|
+
let latest = null;
|
|
43
|
+
|
|
44
|
+
/** @param {{ session?: S | null, runtime?: string | null }} target */
|
|
45
|
+
const show = (target) => {
|
|
46
|
+
const next = toSelection(target);
|
|
47
|
+
if (!sameSelection(selected, next)) shown += 1;
|
|
48
|
+
selected = next;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
/** @returns {S | null} */
|
|
53
|
+
get session() { return selected.session ?? null; },
|
|
54
|
+
// The selected session's runtime, or the runtime selected with no session.
|
|
55
|
+
/** @returns {string | null} */
|
|
56
|
+
get runtime() { return selected.session ? selected.session.runtime : (selected.runtime ?? null); },
|
|
57
|
+
get shown() { return shown; },
|
|
58
|
+
get hasPreference() { return Boolean(selected.session || selected.runtime); },
|
|
59
|
+
// An explicit action that selects: { session } or { runtime } for none.
|
|
60
|
+
/** @param {{ session?: S | null, runtime?: string | null }} target */
|
|
61
|
+
choose(target) {
|
|
62
|
+
actions += 1;
|
|
63
|
+
latest = toSelection(target);
|
|
64
|
+
show(target);
|
|
65
|
+
return actions;
|
|
66
|
+
},
|
|
67
|
+
// A Start request counts but selects nothing yet.
|
|
68
|
+
startRequested() {
|
|
69
|
+
actions += 1;
|
|
70
|
+
latest = { start: true };
|
|
71
|
+
return actions;
|
|
72
|
+
},
|
|
73
|
+
/** @param {number} ticket */
|
|
74
|
+
isCurrent(ticket) { return ticket === actions; },
|
|
75
|
+
// Whether the latest explicit action chose this session, e.g. one still starting.
|
|
76
|
+
/** @param {S} session */
|
|
77
|
+
latestChose(session) { return Boolean(latest?.session) && sameReplSessionLifetime(latest.session, session); },
|
|
78
|
+
// Selects without counting: a completion that passed its fence, or a fallback.
|
|
79
|
+
show,
|
|
80
|
+
// A page's own selection, applied only if the page saw the latest `shown`.
|
|
81
|
+
/** @param {number} basis @param {{ session?: S | null, runtime?: string | null }} target */
|
|
82
|
+
reconcile(basis, target) {
|
|
83
|
+
if (basis !== shown) return false;
|
|
84
|
+
show(target);
|
|
85
|
+
return true;
|
|
86
|
+
},
|
|
87
|
+
// When the selected session has gone, another of its runtime, else none for it.
|
|
88
|
+
/** @param {S[]} sessions */
|
|
89
|
+
resolve(sessions) {
|
|
90
|
+
const current = selected.session;
|
|
91
|
+
if (!current || sessions.some((session) => sameReplSessionLifetime(session, current))) return;
|
|
92
|
+
const next = sessions.find((session) => session.runtime === current.runtime) ?? null;
|
|
93
|
+
show(next ? { session: next } : { runtime: current.runtime });
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -1,7 +1,20 @@
|
|
|
1
|
+
import { stripReplSubmissionDisplay } from "./repl-submission-display.js";
|
|
2
|
+
|
|
1
3
|
const STUDIO_REPL_PROMPT_COMMAND_ENVIRONMENT = "PROMPT_COMMAND=";
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
|
-
*
|
|
6
|
+
* Studio's own sessions: pi-studio-repl-<runtime>, then an optional custom-command
|
|
7
|
+
* hash and an optional new-session suffix. Verified Stop accepts only these.
|
|
8
|
+
*/
|
|
9
|
+
export const STUDIO_REPL_SESSION_NAME = /^pi-studio-repl-(?:shell|python|ipython|julia|r|ghci|clojure)(?:-[a-f0-9]{8})?(?:-[a-z0-9]+-[a-f0-9]{6})?$/;
|
|
10
|
+
|
|
11
|
+
/** Fields read for each pane, in the order firstStudioReplPanes expects. */
|
|
12
|
+
export const STUDIO_REPL_PANE_FORMAT = "#{window_index}\t#{pane_index}\t#{pane_id}\t#{session_name}\t#{session_id}\t#{session_created}\t#{@pi_repl_runtime}\t#{@pi_repl_record_id}\t#{@pi_repl_record_version}";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build the tmux arguments for a Studio-owned REPL session. tmux prints the new
|
|
16
|
+
* session's ID and its first pane's ID, so Studio can address both even if
|
|
17
|
+
* another session takes the name or another window appears.
|
|
5
18
|
*
|
|
6
19
|
* PROMPT_COMMAND contains shell-local function names on some terminals (notably
|
|
7
20
|
* cmux/Ghostty with macOS Bash 3.2). The variable may be exported to Pi while
|
|
@@ -17,6 +30,9 @@ export function buildStudioReplTmuxStartArgs(sessionName, cwd, command) {
|
|
|
17
30
|
return [
|
|
18
31
|
"new-session",
|
|
19
32
|
"-d",
|
|
33
|
+
"-P",
|
|
34
|
+
"-F",
|
|
35
|
+
"#{session_id}\t#{pane_id}",
|
|
20
36
|
"-s",
|
|
21
37
|
sessionName,
|
|
22
38
|
"-c",
|
|
@@ -26,3 +42,202 @@ export function buildStudioReplTmuxStartArgs(sessionName, cwd, command) {
|
|
|
26
42
|
command,
|
|
27
43
|
];
|
|
28
44
|
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* tmux arguments that close a REPL pane whose runtime exited while starting, in
|
|
48
|
+
* one step, and only if it is still dead, still the process Studio saw and still
|
|
49
|
+
* in the session Studio started. A pane respawned or moved since is left alone.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} sessionId
|
|
52
|
+
* @param {string} paneId
|
|
53
|
+
* @param {string} panePid the exited runtime's process ID, as Studio saw it
|
|
54
|
+
* @returns {string[] | null} null if any of them isn't a tmux ID or process ID
|
|
55
|
+
*/
|
|
56
|
+
export function buildStudioReplDeadPaneCloseArgs(sessionId, paneId, panePid) {
|
|
57
|
+
if (!/^\$\d+$/.test(sessionId) || !/^%\d+$/.test(paneId) || !/^\d+$/.test(panePid)) return null;
|
|
58
|
+
return [
|
|
59
|
+
"if-shell", "-F", "-t", paneId,
|
|
60
|
+
`#{&&:#{pane_dead},#{&&:#{==:#{pane_pid},${panePid}},#{==:#{session_id},${sessionId}}}}`,
|
|
61
|
+
`kill-pane -t '${paneId}'`,
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A whole session by its exact name or tmux ID. A bare name would also match a
|
|
67
|
+
* longer name that starts with it: pi-repl-r would find pi-repl-ruby.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} session
|
|
70
|
+
* @returns {string}
|
|
71
|
+
*/
|
|
72
|
+
export function studioReplSessionTarget(session) {
|
|
73
|
+
return /^\$\d+$/.test(session) ? session : `=${session}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @typedef {{ windowIndex: number, paneIndex: number, paneId: string, sessionName: string, sessionId: string,
|
|
78
|
+
* createdAt: string, runtime: string, recordId: string, version: string }} StudioReplPane
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Each session's first pane, by its actual window and pane numbers (tmux's
|
|
83
|
+
* base-index can be 1), from list-panes output in STUDIO_REPL_PANE_FORMAT.
|
|
84
|
+
* Sessions keep the order they were listed in.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} stdout
|
|
87
|
+
* @returns {StudioReplPane[]}
|
|
88
|
+
*/
|
|
89
|
+
export function firstStudioReplPanes(stdout) {
|
|
90
|
+
/** @type {Map<string, StudioReplPane>} */
|
|
91
|
+
const first = new Map();
|
|
92
|
+
for (const line of String(stdout || "").split(/\r?\n/)) {
|
|
93
|
+
const [windowIndex = "", paneIndex = "", paneId = "", sessionName = "", sessionId = "", createdAt = "", runtime = "", recordId = "", version = ""] = line.split("\t");
|
|
94
|
+
if (!/^\d+$/.test(windowIndex) || !/^\d+$/.test(paneIndex) || !/^%\d+$/.test(paneId) || !/^\$\d+$/.test(sessionId) || !sessionName) continue;
|
|
95
|
+
const pane = { windowIndex: Number(windowIndex), paneIndex: Number(paneIndex), paneId, sessionName, sessionId, createdAt, runtime, recordId, version };
|
|
96
|
+
const known = first.get(sessionId);
|
|
97
|
+
if (!known || pane.windowIndex < known.windowIndex || (pane.windowIndex === known.windowIndex && pane.paneIndex < known.paneIndex)) {
|
|
98
|
+
first.set(sessionId, pane);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return [...first.values()];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const STUDIO_REPL_READY_PROMPTS = {
|
|
105
|
+
python: /^>>>[ \t]*$/,
|
|
106
|
+
ipython: /^In \[\d+\]:[ \t]*$/,
|
|
107
|
+
julia: /^julia>[ \t]*$/,
|
|
108
|
+
r: /^>[ \t]*$/,
|
|
109
|
+
ghci: /^\*?[A-Za-z0-9_.:]+(?: \*?[A-Za-z0-9_.:]+)*>[ \t]*$/,
|
|
110
|
+
clojure: /^[^\s>]+=>[ \t]*$/,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Whether the cursor row shows the runtime's normal prompt, as pi-repl checks
|
|
115
|
+
* it. Python and IPython accept each other's prompt. Shell prompts are the
|
|
116
|
+
* user's own, so none is recognised.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} runtime
|
|
119
|
+
* @param {string} line the cursor row
|
|
120
|
+
* @returns {boolean}
|
|
121
|
+
*/
|
|
122
|
+
export function isStudioReplReadyLine(runtime, line) {
|
|
123
|
+
const value = String(line ?? "").replace(/\r/g, "");
|
|
124
|
+
if (value.includes("\n")) return false;
|
|
125
|
+
if (runtime === "python" || runtime === "ipython") {
|
|
126
|
+
return STUDIO_REPL_READY_PROMPTS.python.test(value) || STUDIO_REPL_READY_PROMPTS.ipython.test(value);
|
|
127
|
+
}
|
|
128
|
+
const prompt = STUDIO_REPL_READY_PROMPTS[/** @type {keyof typeof STUDIO_REPL_READY_PROMPTS} */ (runtime)];
|
|
129
|
+
return Boolean(prompt && prompt.test(value));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** @param {string} line */
|
|
133
|
+
function isStudioReplPrompt(line) {
|
|
134
|
+
return /^(?:>>>|In \[\d+\]:|\.\.\.:|>|\+)\s*$/.test(line)
|
|
135
|
+
|| /^(?:ghci|Prelude|\*?[A-Za-z0-9_.:]+)>\s*$/.test(line)
|
|
136
|
+
|| /^[^\s>]+=>\s*$/.test(line);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** @param {string} text */
|
|
140
|
+
const compact = (text) => text.replace(/\s+/g, "");
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Remove this submission's loader echo from the start of the lines, once. The
|
|
144
|
+
* loader names a private file with a random token, so only this submission's
|
|
145
|
+
* echo matches. IPython can wrap a long echo onto "...:" continuation rows.
|
|
146
|
+
*
|
|
147
|
+
* @param {string[]} lines
|
|
148
|
+
* @param {string} submissionLine
|
|
149
|
+
* @param {boolean} anyPrefix Shell prompts are the user's own
|
|
150
|
+
* @returns {boolean} whether the echo was found and removed
|
|
151
|
+
*/
|
|
152
|
+
function removeLoaderEcho(lines, submissionLine, anyPrefix) {
|
|
153
|
+
const first = lines[0] ?? "";
|
|
154
|
+
const prefixOk = (/** @type {number} */ index) => {
|
|
155
|
+
const prefix = first.slice(0, index).trim();
|
|
156
|
+
return anyPrefix || !prefix || isStudioReplPrompt(prefix);
|
|
157
|
+
};
|
|
158
|
+
const index = first.indexOf(submissionLine);
|
|
159
|
+
if (index >= 0 && prefixOk(index)) {
|
|
160
|
+
// Some input editors join output to the echo; keep the rest of that row.
|
|
161
|
+
const remainder = first.slice(index + submissionLine.length);
|
|
162
|
+
if (remainder.trim()) lines[0] = remainder;
|
|
163
|
+
else lines.shift();
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
// A wrapped echo starts on a row no wider than a terminal; skip long output rows.
|
|
167
|
+
if (first.length > 2048) return false;
|
|
168
|
+
const want = compact(submissionLine);
|
|
169
|
+
const head = compact(submissionLine.slice(0, 12));
|
|
170
|
+
for (let start = 0; start < first.length; start += 1) {
|
|
171
|
+
const piece = compact(first.slice(start));
|
|
172
|
+
if (!piece.startsWith(head) || !want.startsWith(piece) || !prefixOk(start)) continue;
|
|
173
|
+
if (piece === want) {
|
|
174
|
+
lines.shift();
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
let joined = piece;
|
|
178
|
+
for (let row = 1; row < Math.min(lines.length, 8); row += 1) {
|
|
179
|
+
joined += compact((lines[row] ?? "").replace(/^\s*\.\.\.:?/, ""));
|
|
180
|
+
if (joined === want) {
|
|
181
|
+
lines.splice(0, row + 1);
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
if (!want.startsWith(joined)) return false;
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Clean one submission's pane output for Studio's record and tool result, as
|
|
193
|
+
* pi-repl does. Remove only this submission's scaffolding: its loader echo
|
|
194
|
+
* once, one trailing prompt once it has finished, and Clojure's silent result.
|
|
195
|
+
* Output that looks like a prompt or a loader is kept.
|
|
196
|
+
*
|
|
197
|
+
* @param {string} delta pane text added since the submission
|
|
198
|
+
* @param {{ submissionLine: string, runtime?: string, display?: any, promptLine?: string, completed?: boolean }} details
|
|
199
|
+
* promptLine is the pane's prompt row before the submission.
|
|
200
|
+
* @returns {string}
|
|
201
|
+
*/
|
|
202
|
+
export function cleanStudioReplOutput(delta, details) {
|
|
203
|
+
const lines = String(delta || "").replace(/\r\n?/g, "\n").split("\n");
|
|
204
|
+
const dropBlankEdges = () => {
|
|
205
|
+
while (lines.length && !lines[0].trim()) lines.shift();
|
|
206
|
+
while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
|
|
207
|
+
};
|
|
208
|
+
dropBlankEdges();
|
|
209
|
+
// Prompt rows before this submission's echo are scaffolding. Without an echo
|
|
210
|
+
// (terminal echo off, say) they may be output, so they stay.
|
|
211
|
+
let lead = 0;
|
|
212
|
+
while (lead < lines.length && (!lines[lead].trim() || isStudioReplPrompt(lines[lead].trim()))) lead += 1;
|
|
213
|
+
if (lead < lines.length && details.submissionLine) {
|
|
214
|
+
const rest = lines.slice(lead);
|
|
215
|
+
if (removeLoaderEcho(rest, details.submissionLine, details.runtime === "shell")) lines.splice(0, lines.length, ...rest);
|
|
216
|
+
}
|
|
217
|
+
const silent = /:pi-studio\/silent\)$/.test(details.submissionLine || "") ? ":pi-studio/silent" : "";
|
|
218
|
+
const promptLine = String(details.promptLine || "").trim();
|
|
219
|
+
// Until the code has finished, its prompt and Clojure's silent result can't have
|
|
220
|
+
// appeared yet; anything that looks like them is output.
|
|
221
|
+
let removedPrompt = details.completed === false;
|
|
222
|
+
let removedSilent = !silent || details.completed === false;
|
|
223
|
+
while (lines.length) {
|
|
224
|
+
const last = lines[lines.length - 1].trim();
|
|
225
|
+
if (!last) { lines.pop(); continue; }
|
|
226
|
+
if (!removedPrompt && (isStudioReplPrompt(last) || (promptLine && last === promptLine))) {
|
|
227
|
+
lines.pop();
|
|
228
|
+
removedPrompt = true;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (!removedSilent && last === silent) {
|
|
232
|
+
lines.pop();
|
|
233
|
+
removedSilent = true;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
const cleaned = lines.join("\n");
|
|
239
|
+
const shown = details.display ? stripReplSubmissionDisplay(cleaned, details.display) : cleaned;
|
|
240
|
+
lines.splice(0, lines.length, ...shown.split("\n"));
|
|
241
|
+
dropBlankEdges();
|
|
242
|
+
return lines.join("\n").replace(/[\t ]+$/gm, "");
|
|
243
|
+
}
|