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.
@@ -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
- * Build the tmux arguments for a Studio-owned REPL session.
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
+ }