pi-repl-py 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +141 -0
- package/LICENSE +21 -0
- package/README.md +82 -0
- package/docs/how-to-functions.md +107 -0
- package/docs/philosophy.md +88 -0
- package/index.ts +220 -0
- package/package.json +57 -0
- package/scripts/setup-venv.mjs +71 -0
- package/src/engine/guest.py +317 -0
- package/src/engine/index.ts +656 -0
- package/src/engine/protocol.ts +66 -0
- package/src/engine/toolbox/bash.py +72 -0
- package/src/engine/toolbox/edit.py +37 -0
- package/src/engine/toolbox/read.py +26 -0
- package/src/engine/toolbox/write.py +23 -0
- package/src/extension/config.ts +65 -0
- package/src/extension/preview-core.ts +518 -0
- package/src/extension/render-core.ts +348 -0
- package/src/extension/render.ts +93 -0
- package/src/extension/session-engine.ts +155 -0
- package/src/extension/tool-meta.ts +58 -0
- package/src/extension/toolbox.ts +74 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// --- pure layout, free of pi imports so unit tests can drive it directly ---
|
|
2
|
+
|
|
3
|
+
export interface ExecuteDetails {
|
|
4
|
+
status?: "ok" | "error" | "aborted" | string;
|
|
5
|
+
durationMs?: number;
|
|
6
|
+
errorName?: string;
|
|
7
|
+
stdout?: string;
|
|
8
|
+
stderr?: string;
|
|
9
|
+
result?: string;
|
|
10
|
+
errorStack?: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ExecuteRenderState {
|
|
14
|
+
code: string;
|
|
15
|
+
details?: ExecuteDetails;
|
|
16
|
+
contentText?: string;
|
|
17
|
+
isPartial: boolean;
|
|
18
|
+
isError: boolean;
|
|
19
|
+
expanded: boolean;
|
|
20
|
+
executionStarted: boolean;
|
|
21
|
+
hasResult: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
import { previewCell } from "./preview-core.js";
|
|
25
|
+
|
|
26
|
+
export type StatusKind = "error" | "aborted" | "running" | "queued" | "done";
|
|
27
|
+
export type BgKind = "toolPendingBg" | "toolSuccessBg" | "toolErrorBg";
|
|
28
|
+
|
|
29
|
+
export interface RenderDeps {
|
|
30
|
+
fg(color: string, text: string): string;
|
|
31
|
+
getBgAnsi(bg: BgKind): string;
|
|
32
|
+
highlight(code: string): string[];
|
|
33
|
+
keyHint(expanded: boolean): string;
|
|
34
|
+
visibleWidth(text: string): number;
|
|
35
|
+
truncateToWidth(text: string, width: number, ellipsis: string): string;
|
|
36
|
+
wrapTextWithAnsi(text: string, width: number): string[];
|
|
37
|
+
/** Injected for deterministic spinner frames in tests. */
|
|
38
|
+
now?(): number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const OUTPUT_INDENT = " ";
|
|
42
|
+
const SPINNER_FRAMES = ["◐", "◓", "◑", "◒"];
|
|
43
|
+
|
|
44
|
+
export function formatDuration(durationMs: number | undefined): string | undefined {
|
|
45
|
+
if (durationMs === undefined) return undefined;
|
|
46
|
+
if (durationMs < 1000) return `${Math.round(durationMs)}ms`;
|
|
47
|
+
return `${(durationMs / 1000).toFixed(1)}s`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const SGR_PATTERN = /\x1b\[([0-9;]*)m/g;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Append a reset when `line` ends with a foreground or background color still
|
|
54
|
+
* open, so a span that wrapping split across lines cannot bleed into the
|
|
55
|
+
* trailing padding or the next row.
|
|
56
|
+
*/
|
|
57
|
+
export function closeOpenSgr(line: string): string {
|
|
58
|
+
let fgOpen = false;
|
|
59
|
+
let bgOpen = false;
|
|
60
|
+
for (const match of line.matchAll(SGR_PATTERN)) {
|
|
61
|
+
const params = match[1] === "" ? ["0"] : (match[1] ?? "").split(";");
|
|
62
|
+
for (let i = 0; i < params.length; i++) {
|
|
63
|
+
const code = Number(params[i]);
|
|
64
|
+
if (code === 0) {
|
|
65
|
+
fgOpen = false;
|
|
66
|
+
bgOpen = false;
|
|
67
|
+
} else if (code === 38 || code === 48) {
|
|
68
|
+
// Skip the payload of 38;5;n / 38;2;r;g;b so a component (e.g. 38)
|
|
69
|
+
// is not read as another SGR code.
|
|
70
|
+
if (code === 38) fgOpen = true;
|
|
71
|
+
else bgOpen = true;
|
|
72
|
+
const mode = Number(params[i + 1]);
|
|
73
|
+
i += mode === 2 ? 4 : mode === 5 ? 2 : 1;
|
|
74
|
+
} else if (code === 39) {
|
|
75
|
+
fgOpen = false;
|
|
76
|
+
} else if (code === 49) {
|
|
77
|
+
bgOpen = false;
|
|
78
|
+
} else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {
|
|
79
|
+
fgOpen = true;
|
|
80
|
+
} else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) {
|
|
81
|
+
bgOpen = true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return fgOpen || bgOpen ? `${line}\x1b[0m` : line;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function statusKind(state: ExecuteRenderState): StatusKind {
|
|
89
|
+
const status = state.details?.status;
|
|
90
|
+
if (state.isError || status === "error") return "error";
|
|
91
|
+
if (status === "aborted") return "aborted";
|
|
92
|
+
if (!state.isPartial && (status !== undefined || state.hasResult)) return "done";
|
|
93
|
+
if (state.isPartial || state.executionStarted) return "running";
|
|
94
|
+
return "queued";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function backgroundFor(kind: StatusKind): BgKind {
|
|
98
|
+
if (kind === "error" || kind === "aborted") return "toolErrorBg";
|
|
99
|
+
if (kind === "done") return "toolSuccessBg";
|
|
100
|
+
return "toolPendingBg";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function marker(state: ExecuteRenderState, deps: RenderDeps): string {
|
|
104
|
+
switch (statusKind(state)) {
|
|
105
|
+
case "error":
|
|
106
|
+
return deps.fg("error", "✗");
|
|
107
|
+
case "aborted":
|
|
108
|
+
return deps.fg("warning", "✗");
|
|
109
|
+
case "done":
|
|
110
|
+
return deps.fg("success", "✓");
|
|
111
|
+
case "running": {
|
|
112
|
+
const now = deps.now?.() ?? Date.now();
|
|
113
|
+
return deps.fg("accent", SPINNER_FRAMES[Math.floor(now / 160) % SPINNER_FRAMES.length]);
|
|
114
|
+
}
|
|
115
|
+
default:
|
|
116
|
+
return deps.fg("muted", "◇");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function highlightLines(code: string, deps: RenderDeps): string[] {
|
|
121
|
+
if (!code) return [];
|
|
122
|
+
return deps.highlight(code);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function outputText(state: ExecuteRenderState): string {
|
|
126
|
+
const details = state.details;
|
|
127
|
+
if (details && (details.stdout || details.stderr || details.result)) {
|
|
128
|
+
return [details.stdout, details.stderr, details.result]
|
|
129
|
+
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
|
130
|
+
.join("\n");
|
|
131
|
+
}
|
|
132
|
+
return state.contentText?.trim() ?? "";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function topLine(state: ExecuteRenderState, width: number, deps: RenderDeps): string {
|
|
136
|
+
const code = state.code.trimEnd();
|
|
137
|
+
const preview = previewCell(code);
|
|
138
|
+
const language = preview.kind === "shell" ? "repl · shell" : preview.kind === "agent" ? "repl · agent" : "repl";
|
|
139
|
+
const prefix = `${marker(state, deps)} ${deps.fg("muted", language)}`;
|
|
140
|
+
|
|
141
|
+
// Fixed metadata after the preview must always survive; the preview
|
|
142
|
+
// absorbs all truncation. Counts settle-only: live updates jitter the header.
|
|
143
|
+
// Suffix order is by priority: the expand hint must survive first, then the
|
|
144
|
+
// error, then duration, then counts. Truncation happens from the right, so
|
|
145
|
+
// low-priority items are elided before the user loses the expand keybinding.
|
|
146
|
+
const suffixParts: string[] = [];
|
|
147
|
+
suffixParts.push(deps.keyHint(state.expanded));
|
|
148
|
+
|
|
149
|
+
const errorName = !state.isPartial ? state.details?.errorName : undefined;
|
|
150
|
+
if (errorName) {
|
|
151
|
+
// --- the error message usually beats a bare name when it fits ---
|
|
152
|
+
const summary = sanitizeTuiOutput(state.details?.errorStack?.[0] ?? "");
|
|
153
|
+
suffixParts.push(deps.fg("error", summary && deps.visibleWidth(summary) <= 48 ? summary : errorName));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const duration = formatDuration(state.details?.durationMs);
|
|
157
|
+
if (duration) suffixParts.push(deps.fg("muted", duration));
|
|
158
|
+
|
|
159
|
+
// --- counts settle-only: live-updating them mid-stream jitters the header ---
|
|
160
|
+
if (!state.isPartial && statusKind(state) !== "running") {
|
|
161
|
+
const inputLines = code.split("\n").filter((line) => line.trim().length > 0).length;
|
|
162
|
+
const output = outputText(state);
|
|
163
|
+
const outputLines = output ? output.split("\n").length : 0;
|
|
164
|
+
const counts: string[] = [];
|
|
165
|
+
if (inputLines > 0) counts.push(`↑ ${inputLines}`);
|
|
166
|
+
if (outputLines > 0) counts.push(`↓ ${outputLines}`);
|
|
167
|
+
if (counts.length > 0) suffixParts.push(deps.fg("muted", `${counts.join(" ")} lines`));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const separator = deps.fg("dim", " · ");
|
|
171
|
+
const separatorWidth = deps.visibleWidth(separator);
|
|
172
|
+
const suffix = suffixParts.join(separator);
|
|
173
|
+
// Budget: total width minus leading space, prefix, suffix, separators.
|
|
174
|
+
const fixed = 1 + deps.visibleWidth(prefix) + separatorWidth + deps.visibleWidth(suffix);
|
|
175
|
+
const previewBudget = Math.max(8, width - fixed - separatorWidth);
|
|
176
|
+
// A semantic preview is a one-line summary of the code. Highlight Python
|
|
177
|
+
// code the same way the expanded block is highlighted; shell/agent previews
|
|
178
|
+
// stay accent-colored so they read as intent, not syntax.
|
|
179
|
+
let middle = "";
|
|
180
|
+
if (preview.text) {
|
|
181
|
+
const previewText =
|
|
182
|
+
preview.kind === "ts"
|
|
183
|
+
? (deps.highlight(preview.text)[0] ?? deps.fg("accent", preview.text))
|
|
184
|
+
: deps.fg("accent", preview.text);
|
|
185
|
+
middle = deps.truncateToWidth(previewText, previewBudget, "…");
|
|
186
|
+
} else if (!state.executionStarted) {
|
|
187
|
+
middle = deps.fg("muted", "waiting for code");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return [prefix, ...(middle ? [middle] : []), suffix].join(separator);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function sanitizeTuiOutput(text: string): string {
|
|
194
|
+
// Terminal escape sequences and control characters from user code output can
|
|
195
|
+
// move the cursor, change colors, or print zero-width glyphs that break the
|
|
196
|
+
// TUI layout. Color SGR / CSI sequences (e.g. IPython's colored tracebacks)
|
|
197
|
+
// are STRIPPED so text stays readable; a remaining lone escape byte and other
|
|
198
|
+
// control chars are shown as Unicode control pictures so nothing is silently
|
|
199
|
+
// eaten. Tabs expand to 4 spaces; CR becomes ␍.
|
|
200
|
+
return text
|
|
201
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
202
|
+
.replace(/\x1b/g, "␛")
|
|
203
|
+
.replace(/\r/g, "␍")
|
|
204
|
+
.replace(/\t/g, " ")
|
|
205
|
+
.replace(/[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f-\x9f]/g, "�");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
type PreviewEntry = { kind: "line"; line: string } | { kind: "hidden"; hidden: number };
|
|
209
|
+
|
|
210
|
+
/** Head/tail truncation with a hidden-line marker, adapted from pi-fabric. */
|
|
211
|
+
function selectPreviewLines(lines: string[], limit: number): PreviewEntry[] {
|
|
212
|
+
const lineEntry = (line: string): PreviewEntry => ({ kind: "line", line });
|
|
213
|
+
if (lines.length <= limit || limit <= 0) return lines.map(lineEntry);
|
|
214
|
+
if (limit < 8) return [...lines.slice(0, limit).map(lineEntry), { kind: "hidden", hidden: lines.length - limit }];
|
|
215
|
+
const head = Math.ceil(limit * 0.65);
|
|
216
|
+
const tail = Math.max(1, limit - head - 1);
|
|
217
|
+
return [
|
|
218
|
+
...lines.slice(0, head).map(lineEntry),
|
|
219
|
+
{ kind: "hidden", hidden: lines.length - head - tail },
|
|
220
|
+
...lines.slice(-tail).map(lineEntry),
|
|
221
|
+
];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function addWrapped(
|
|
225
|
+
lines: string[],
|
|
226
|
+
prefix: string,
|
|
227
|
+
text: string,
|
|
228
|
+
width: number,
|
|
229
|
+
deps: RenderDeps,
|
|
230
|
+
options: { sanitize?: boolean } = {},
|
|
231
|
+
): void {
|
|
232
|
+
const safe = options.sanitize === false ? text : sanitizeTuiOutput(text);
|
|
233
|
+
const available = Math.max(1, width - 1 - deps.visibleWidth(prefix));
|
|
234
|
+
const wrapped = deps.wrapTextWithAnsi(safe, available);
|
|
235
|
+
for (const [index, line] of (wrapped.length > 0 ? wrapped : [""]).entries()) {
|
|
236
|
+
const linePrefix = index === 0 ? prefix : " ".repeat(deps.visibleWidth(prefix));
|
|
237
|
+
lines.push(deps.truncateToWidth(` ${linePrefix}${closeOpenSgr(line)}`, width, ""));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function renderCode(state: ExecuteRenderState, lines: string[], width: number, deps: RenderDeps): boolean {
|
|
242
|
+
const code = state.code.trimEnd();
|
|
243
|
+
if (!code) return false;
|
|
244
|
+
lines.push("");
|
|
245
|
+
const highlighted = highlightLines(code, deps);
|
|
246
|
+
for (const [index, rawLine] of code.split("\n").entries()) {
|
|
247
|
+
const prefix = index === 0 ? deps.fg("dim", "› ") : deps.fg("dim", " ");
|
|
248
|
+
// Code is already syntax-highlighted; don't strip its ANSI.
|
|
249
|
+
addWrapped(lines, prefix, highlighted[index] ?? rawLine, width, deps, { sanitize: false });
|
|
250
|
+
}
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const MAX_OUTPUT_LINES = 50;
|
|
255
|
+
|
|
256
|
+
function renderOutput(
|
|
257
|
+
state: ExecuteRenderState,
|
|
258
|
+
lines: string[],
|
|
259
|
+
width: number,
|
|
260
|
+
hasCode: boolean,
|
|
261
|
+
deps: RenderDeps,
|
|
262
|
+
): void {
|
|
263
|
+
const details = state.details;
|
|
264
|
+
const output: string[] = [];
|
|
265
|
+
|
|
266
|
+
// stdout/stderr/result are color-coded and labeled so you can tell which
|
|
267
|
+
// stream a line came from at a glance. Sanitize the raw text before
|
|
268
|
+
// applying the section color, or our own ANSI gets escaped as user output.
|
|
269
|
+
const sections: Array<{ text: string | undefined; color: string; label: string }> = [
|
|
270
|
+
{ text: details?.stdout, color: "toolOutput", label: "stdout" },
|
|
271
|
+
{ text: details?.stderr, color: "warning", label: "stderr" },
|
|
272
|
+
{ text: details?.result, color: "accent", label: "result" },
|
|
273
|
+
];
|
|
274
|
+
let renderedText = false;
|
|
275
|
+
for (const { text, color, label } of sections) {
|
|
276
|
+
if (!text?.trim()) continue;
|
|
277
|
+
renderedText = true;
|
|
278
|
+
output.push(` ${OUTPUT_INDENT}${deps.fg("dim", `${label}:`)}`);
|
|
279
|
+
for (const line of text.split("\n")) {
|
|
280
|
+
const safe = sanitizeTuiOutput(line || " ");
|
|
281
|
+
addWrapped(output, OUTPUT_INDENT, deps.fg(color, safe), width, deps, { sanitize: false });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (!renderedText && !details && state.contentText?.trim()) {
|
|
286
|
+
renderedText = true;
|
|
287
|
+
const color = state.isError ? "error" : "toolOutput";
|
|
288
|
+
for (const line of state.contentText.trim().split("\n")) {
|
|
289
|
+
const safe = sanitizeTuiOutput(line || " ");
|
|
290
|
+
addWrapped(output, OUTPUT_INDENT, deps.fg(color, safe), width, deps, { sanitize: false });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (details?.errorStack && details.errorStack.length > 0) {
|
|
295
|
+
output.push(` ${OUTPUT_INDENT}${deps.fg("dim", "traceback:")}`);
|
|
296
|
+
for (const line of details.errorStack) {
|
|
297
|
+
const safe = sanitizeTuiOutput(line || " ");
|
|
298
|
+
addWrapped(output, OUTPUT_INDENT, deps.fg("error", safe), width, deps, { sanitize: false });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (!renderedText) {
|
|
303
|
+
const message = state.isPartial || statusKind(state) === "running" ? "waiting for output..." : "no output";
|
|
304
|
+
addWrapped(output, OUTPUT_INDENT, deps.fg("muted", message), width, deps, { sanitize: false });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const entries = selectPreviewLines(output, MAX_OUTPUT_LINES);
|
|
308
|
+
if (entries.length > 0 && hasCode) lines.push("");
|
|
309
|
+
for (const entry of entries) {
|
|
310
|
+
if (entry.kind === "hidden") {
|
|
311
|
+
const marker = ` … ${entry.hidden} line${entry.hidden === 1 ? "" : "s"} hidden … `;
|
|
312
|
+
lines.push(` ${OUTPUT_INDENT}${deps.fg("muted", marker)}`);
|
|
313
|
+
} else {
|
|
314
|
+
lines.push(entry.line);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Paint the status-matched panel background across the row, surviving inner SGR resets. */
|
|
320
|
+
export function paintBackground(line: string, width: number, kind: StatusKind, deps: RenderDeps): string {
|
|
321
|
+
const bgAnsi = deps.getBgAnsi(backgroundFor(kind));
|
|
322
|
+
const padded = line + " ".repeat(Math.max(0, width - deps.visibleWidth(line)));
|
|
323
|
+
const rearmed = padded.replaceAll("\x1b[0m", `\x1b[0m${bgAnsi}`);
|
|
324
|
+
return `${bgAnsi}${rearmed}\x1b[0m`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function renderExecuteHeader(state: ExecuteRenderState, width: number, deps: RenderDeps): string[] {
|
|
328
|
+
const safeWidth = Math.max(1, width);
|
|
329
|
+
const line = deps.truncateToWidth(` ${topLine(state, safeWidth, deps)}`, safeWidth, "");
|
|
330
|
+
const kind = statusKind(state);
|
|
331
|
+
return [paintBackground(line, safeWidth, kind, deps)];
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function renderExecuteBody(state: ExecuteRenderState, width: number, deps: RenderDeps): string[] {
|
|
335
|
+
if (!state.expanded) return [];
|
|
336
|
+
const safeWidth = Math.max(1, width);
|
|
337
|
+
const lines: string[] = [];
|
|
338
|
+
const hasCode = renderCode(state, lines, safeWidth, deps);
|
|
339
|
+
renderOutput(state, lines, safeWidth, hasCode, deps);
|
|
340
|
+
// A thin bottom border separates the expanded cell from whatever follows.
|
|
341
|
+
if (lines.length > 0) lines.push(` ${deps.fg("dim", "─".repeat(Math.max(1, safeWidth - 1)))}`);
|
|
342
|
+
const kind = statusKind(state);
|
|
343
|
+
return lines.map((line) => paintBackground(line, safeWidth, kind, deps));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function renderExecuteCell(state: ExecuteRenderState, width: number, deps: RenderDeps): string[] {
|
|
347
|
+
return [...renderExecuteHeader(state, width, deps), ...renderExecuteBody(state, width, deps)];
|
|
348
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI adapter for the `execute` cell renderer.
|
|
3
|
+
*
|
|
4
|
+
* Binds pi's theme, syntax highlighting, key hints, and width primitives to the
|
|
5
|
+
* pure layout in render-core.ts, which is unit-tested outside pi's runtime.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { highlightCode, keyHint, keyText, rawKeyHint, type Theme } from "@mariozechner/pi-coding-agent";
|
|
9
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
|
10
|
+
import {
|
|
11
|
+
type BgKind,
|
|
12
|
+
type ExecuteRenderState,
|
|
13
|
+
type RenderDeps,
|
|
14
|
+
renderExecuteBody,
|
|
15
|
+
renderExecuteCell,
|
|
16
|
+
renderExecuteHeader,
|
|
17
|
+
statusKind,
|
|
18
|
+
} from "./render-core.js";
|
|
19
|
+
|
|
20
|
+
export type { ExecuteDetails, ExecuteRenderState } from "./render-core.js";
|
|
21
|
+
|
|
22
|
+
function makeDeps(theme: Theme): RenderDeps {
|
|
23
|
+
return {
|
|
24
|
+
fg: (color, text) => theme.fg(color as Parameters<Theme["fg"]>[0], text),
|
|
25
|
+
getBgAnsi: (bg: BgKind) => theme.getBgAnsi(bg),
|
|
26
|
+
highlight: (code) => highlightCode(code, "python"),
|
|
27
|
+
keyHint: (expanded) => {
|
|
28
|
+
const text = expanded ? "to collapse" : "to expand";
|
|
29
|
+
const key = keyText("app.tools.expand");
|
|
30
|
+
return key ? keyHint("app.tools.expand", text) : rawKeyHint("ctrl+o", text);
|
|
31
|
+
},
|
|
32
|
+
visibleWidth,
|
|
33
|
+
truncateToWidth,
|
|
34
|
+
wrapTextWithAnsi,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The layout of a cell only changes when its state or the spinner frame does,
|
|
40
|
+
* but the TUI repaints on every frame. Rendering from a key of both stops the
|
|
41
|
+
* recompute-per-frame (and with it, flicker on wide panes).
|
|
42
|
+
*/
|
|
43
|
+
function renderVersion(state: ExecuteRenderState): string {
|
|
44
|
+
const details = state.details ? JSON.stringify(state.details) : "";
|
|
45
|
+
return [
|
|
46
|
+
state.code.length,
|
|
47
|
+
state.contentText?.length ?? 0,
|
|
48
|
+
details.length,
|
|
49
|
+
state.isPartial,
|
|
50
|
+
state.isError,
|
|
51
|
+
state.expanded,
|
|
52
|
+
state.executionStarted,
|
|
53
|
+
state.hasResult,
|
|
54
|
+
// --- fold the animation frame in while running so the spinner still turns ---
|
|
55
|
+
statusKind(state) === "running" ? Math.floor(Date.now() / 160) % 4 : -1,
|
|
56
|
+
].join("|");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class ExecuteCellComponent {
|
|
60
|
+
private readonly deps: RenderDeps;
|
|
61
|
+
private cachedKey = "";
|
|
62
|
+
private cachedWidth = -1;
|
|
63
|
+
private cachedLines?: string[];
|
|
64
|
+
|
|
65
|
+
constructor(
|
|
66
|
+
private readonly state: ExecuteRenderState,
|
|
67
|
+
theme: Theme,
|
|
68
|
+
private readonly mode: "cell" | "header" | "body" = "cell",
|
|
69
|
+
) {
|
|
70
|
+
this.deps = makeDeps(theme);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
invalidate(): void {
|
|
74
|
+
this.cachedLines = undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
render(width: number): string[] {
|
|
78
|
+
const key = `${renderVersion(this.state)}|${this.mode}`;
|
|
79
|
+
if (this.cachedLines && this.cachedWidth === width && this.cachedKey === key) {
|
|
80
|
+
return this.cachedLines;
|
|
81
|
+
}
|
|
82
|
+
const lines =
|
|
83
|
+
this.mode === "header"
|
|
84
|
+
? renderExecuteHeader(this.state, width, this.deps)
|
|
85
|
+
: this.mode === "body"
|
|
86
|
+
? renderExecuteBody(this.state, width, this.deps)
|
|
87
|
+
: renderExecuteCell(this.state, width, this.deps);
|
|
88
|
+
this.cachedKey = key;
|
|
89
|
+
this.cachedWidth = width;
|
|
90
|
+
this.cachedLines = lines;
|
|
91
|
+
return lines;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// A session may get teardown without session_start on reload, so revival is part of create() (was a real defect)
|
|
2
|
+
|
|
3
|
+
import type { RestoreResult } from "../engine/index.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A revived session can carry hundreds of variables; listing them all turns
|
|
7
|
+
* the banner and the reset notice into a wall. Show enough to orient, then
|
|
8
|
+
* count the rest.
|
|
9
|
+
*/
|
|
10
|
+
export function summarizeNames(names: readonly string[], limit: number): string {
|
|
11
|
+
if (names.length <= limit) return names.join(", ");
|
|
12
|
+
return `${names.slice(0, limit).join(", ")} … and ${names.length - limit} more`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The part of EngineManager this lifecycle needs; narrowed so tests can fake it. */
|
|
16
|
+
export interface RevivableEngine {
|
|
17
|
+
restoreState(): Promise<RestoreResult | null>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface EngineLifecycleDeps<E extends RevivableEngine> {
|
|
21
|
+
/** Builds a fresh engine. Called at most once per lifecycle generation. */
|
|
22
|
+
create(): E;
|
|
23
|
+
/** Tears the current engine down, flushing its final snapshot. */
|
|
24
|
+
dispose(engine: E): Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Tears down an engine that cannot cooperate — a wedged guest cannot serve
|
|
27
|
+
* the snapshot flush dispose would ask of it. Falls back to dispose.
|
|
28
|
+
*/
|
|
29
|
+
discard?(engine: E): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Why an engine came into existence. `startup` is the expected path and is
|
|
34
|
+
* already announced in the transcript; `cell` means an engine had to be built
|
|
35
|
+
* to serve a tool call, which only happens when the previous one went away
|
|
36
|
+
* mid-session — the case the model needs told about in-band.
|
|
37
|
+
*/
|
|
38
|
+
export type AcquireOrigin = "startup" | "cell";
|
|
39
|
+
|
|
40
|
+
function formatEngineResetNotice(restore: RestoreResult | null): string {
|
|
41
|
+
const lines = ["<rlm_engine_reset>"];
|
|
42
|
+
if (!restore) {
|
|
43
|
+
// --- no snapshot at all: namespace is genuinely empty ---
|
|
44
|
+
lines.push(
|
|
45
|
+
"The evaluator restarted and its namespace is empty; no snapshot was available to revive.",
|
|
46
|
+
"Every variable from earlier in this session is gone. Rebuild what you need before using it.",
|
|
47
|
+
);
|
|
48
|
+
} else if (restore.restored.length === 0) {
|
|
49
|
+
// --- a snapshot existed but restored nothing; say why, don't claim "no snapshot" ---
|
|
50
|
+
lines.push(
|
|
51
|
+
"The evaluator restarted and a snapshot was found, but nothing in it could be revived.",
|
|
52
|
+
restore.failed.length > 0
|
|
53
|
+
? `Failed to revive (${restore.failed.length}): ${summarizeNames(
|
|
54
|
+
restore.failed.map((f) => f.name),
|
|
55
|
+
20,
|
|
56
|
+
)}`
|
|
57
|
+
: "The snapshot was empty.",
|
|
58
|
+
"Every variable from earlier in this session is gone. Rebuild what you need before using it.",
|
|
59
|
+
);
|
|
60
|
+
} else {
|
|
61
|
+
lines.push(
|
|
62
|
+
"The evaluator restarted. Its namespace was rebuilt from the last snapshot, so it may be behind.",
|
|
63
|
+
`Revived (${restore.restored.length}): ${summarizeNames(restore.restored, 20)}`,
|
|
64
|
+
);
|
|
65
|
+
if (restore.failed.length > 0) {
|
|
66
|
+
lines.push(
|
|
67
|
+
`Lost (${restore.failed.length}): ${summarizeNames(
|
|
68
|
+
restore.failed.map((f) => f.name),
|
|
69
|
+
20,
|
|
70
|
+
)}`,
|
|
71
|
+
"Functions, classes, and live handles cannot be snapshotted; redefine them.",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
lines.push("Anything defined after the last snapshot is also gone.");
|
|
75
|
+
}
|
|
76
|
+
lines.push("Re-verify a variable before reusing it, especially inside shell interpolation.", "</rlm_engine_reset>");
|
|
77
|
+
return lines.join("\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class EngineLifecycle<E extends RevivableEngine> {
|
|
81
|
+
private engine?: E;
|
|
82
|
+
private revival?: Promise<RestoreResult | null>;
|
|
83
|
+
private pendingNotice?: string;
|
|
84
|
+
/** Teardown in progress; a rebuild must not overlap the final snapshot flush. */
|
|
85
|
+
private teardown?: Promise<void>;
|
|
86
|
+
/** First-build in progress: concurrent acquire() must not spawn two engines. */
|
|
87
|
+
private acquiring?: Promise<{ engine: E; restore: RestoreResult | null; created: boolean }>;
|
|
88
|
+
|
|
89
|
+
constructor(private readonly deps: EngineLifecycleDeps<E>) {}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The live engine, built and revived if it does not exist yet.
|
|
93
|
+
* Revival is awaited here so a caller never sees an un-revived namespace.
|
|
94
|
+
*/
|
|
95
|
+
async acquire(origin: AcquireOrigin): Promise<{ engine: E; restore: RestoreResult | null; created: boolean }> {
|
|
96
|
+
if (this.engine) {
|
|
97
|
+
return { engine: this.engine, restore: await this.revival!, created: false };
|
|
98
|
+
}
|
|
99
|
+
// --- two concurrent acquires on an empty engine must share one build ---
|
|
100
|
+
if (this.acquiring) return this.acquiring;
|
|
101
|
+
const build = (async () => {
|
|
102
|
+
// --- a teardown flushing its final snapshot must finish before the rebuild reads it ---
|
|
103
|
+
while (this.teardown) await this.teardown;
|
|
104
|
+
if (this.engine) {
|
|
105
|
+
const held: E = this.engine;
|
|
106
|
+
return { engine: held, restore: await this.revival!, created: false };
|
|
107
|
+
}
|
|
108
|
+
const engine = this.deps.create();
|
|
109
|
+
this.engine = engine;
|
|
110
|
+
this.revival = engine.restoreState().catch(() => null);
|
|
111
|
+
const restore = await this.revival;
|
|
112
|
+
if (origin === "cell") this.pendingNotice = formatEngineResetNotice(restore);
|
|
113
|
+
return { engine, restore, created: true };
|
|
114
|
+
})();
|
|
115
|
+
this.acquiring = build;
|
|
116
|
+
try {
|
|
117
|
+
return await build;
|
|
118
|
+
} finally {
|
|
119
|
+
if (this.acquiring === build) this.acquiring = undefined;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Returns the pending reset notice exactly once, then clears it. */
|
|
124
|
+
takeResetNotice(): string | undefined {
|
|
125
|
+
const notice = this.pendingNotice;
|
|
126
|
+
this.pendingNotice = undefined;
|
|
127
|
+
return notice;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async shutdown(): Promise<void> {
|
|
131
|
+
await this.teardownWith((engine) => this.deps.dispose(engine));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Teardown for an engine that cannot cooperate (e.g. wedged in synchronous
|
|
136
|
+
* code). Skips the snapshot flush a graceful dispose would attempt; the next
|
|
137
|
+
* acquire builds a fresh engine revived from the last completed snapshot.
|
|
138
|
+
*/
|
|
139
|
+
async discard(): Promise<void> {
|
|
140
|
+
await this.teardownWith((engine) => (this.deps.discard ?? this.deps.dispose)(engine));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private async teardownWith(run: (engine: E) => Promise<void>): Promise<void> {
|
|
144
|
+
const engine = this.engine;
|
|
145
|
+
this.engine = undefined;
|
|
146
|
+
this.revival = undefined;
|
|
147
|
+
this.pendingNotice = undefined;
|
|
148
|
+
if (!engine) return;
|
|
149
|
+
const teardown = run(engine).finally(() => {
|
|
150
|
+
if (this.teardown === teardown) this.teardown = undefined;
|
|
151
|
+
});
|
|
152
|
+
this.teardown = teardown;
|
|
153
|
+
await teardown;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `execute` tool's prompt surface.
|
|
3
|
+
*
|
|
4
|
+
* pi's default system prompt is used as-is; all REPL knowledge rides on the
|
|
5
|
+
* tool via these fields, so index.ts stays thin.
|
|
6
|
+
*
|
|
7
|
+
* - description — working summary (schema card).
|
|
8
|
+
* - promptSnippet — one line in the default `Available tools`.
|
|
9
|
+
* - promptGuidelines — the function doctrine + tokens + safety.
|
|
10
|
+
*
|
|
11
|
+
* The function map is derived from the toolbox source via buildToolboxMap()
|
|
12
|
+
* (function_description docstring + def-signature regex), so it always matches
|
|
13
|
+
* what the kernel loads.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { buildToolboxMap } from "./toolbox.js";
|
|
17
|
+
|
|
18
|
+
export const EXECUTE_DESCRIPTION =
|
|
19
|
+
"Execute Python to a persistent evaluator: the session's working memory. " +
|
|
20
|
+
"Variables, imports, functions, and data survive across calls. There are no " +
|
|
21
|
+
"separate file or shell tools; read, write, edit, bash, and anything you build " +
|
|
22
|
+
"are Python functions you call inside a cell. A cell returns its final " +
|
|
23
|
+
"expression; anything else is printed. Build one reusable function per routine " +
|
|
24
|
+
"and call it by arguments, since a new def overwrites the previous one; don't " +
|
|
25
|
+
"narrate that machinery to the user. Runs in a project-local venv, so a command " +
|
|
26
|
+
"that starts python or pip must target that venv.";
|
|
27
|
+
|
|
28
|
+
export const EXECUTE_PROMPT_SNIPPET =
|
|
29
|
+
"Execute Python in a persistent evaluator whose variables, imports, and functions " +
|
|
30
|
+
"survive across calls; preloaded functions plus any you define and reuse as " +
|
|
31
|
+
"callable tools; ls() lists them, help(name) shows usage";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* promptGuidelines for the execute tool. `toolboxDir` is optional; it defaults to
|
|
35
|
+
* the shipped toolbox.
|
|
36
|
+
*/
|
|
37
|
+
export function buildExecutePromptGuidelines(toolboxDir?: string): string[] {
|
|
38
|
+
const map = buildToolboxMap(toolboxDir);
|
|
39
|
+
const preloaded = map.length > 0 ? map : ["(none preloaded: define your own)"];
|
|
40
|
+
return [
|
|
41
|
+
"Preloaded functions available in every kernel:",
|
|
42
|
+
...preloaded,
|
|
43
|
+
"ls() prints what is loaded; help(name) shows a function's signature and notes. Use them instead of guessing.",
|
|
44
|
+
"Functions you define are reusable tools: one parameterized helper per task, call it by arguments. Never write a routine twice and never fork a duplicate; extend the existing `def` (a new `def` of the same name overwrites).",
|
|
45
|
+
"Before a multi-line cell, ask whether you will run that shape again with different inputs. If yes, define the function now so each later request is one call.",
|
|
46
|
+
"Good, defined once then called by arguments only:",
|
|
47
|
+
"def fetch_news(query, hl='en', gl='US', ceid='US:en', limit=15):\n <fetch + parse to a list>\nfetch_news('Turkey')\nfetch_news('Nigeria', hl='en-NG')",
|
|
48
|
+
"Don't build a near-copy (avoid fetch_news and fetch_news_region); add the varying bits to the original `def` and let it supersede the old.",
|
|
49
|
+
"Other reusable shapes build the same way:",
|
|
50
|
+
"def find_files(pred, root='.'):\n <walk root, filter by pred>\nfind_files('*.csv')\nfind_files('*.py', root='src')\ndef count_lines(paths): ... # compose: count_lines(find_files('*.csv'))",
|
|
51
|
+
"Use functions proportionally: build one when it will be reused, otherwise run it in a plain cell. Don't wrap a one-off and don't over-engineer.",
|
|
52
|
+
"Never narrate your mechanism to the user (don't say 'I defined a function' or 'I built a tool'). Do the job, then answer with the result.",
|
|
53
|
+
"Be token efficient: everything a cell prints is context for the rest of the turn. When reading or searching, print slices, matches, or counts rather than whole files, and keep large values in variables.",
|
|
54
|
+
"For whole-filesystem or large-dir scans, use the kernel's tools via bash, not a Python walk: find, du, fd, grep. Chain them (find -xdev -type f -size +100M | sort -rn | head; du -x | sort -h | tail) and prune descent by skipping node_modules, .git, caches, venvs. A Python os.walk + lstat loop pays a slow syscall per file and runs minutes to 10+ min on a big tree; reserve Python for analysing the results, not for enumerating the disk.",
|
|
55
|
+
"If the output starts with <rlm_engine_reset>, the kernel was rebuilt: only data is restored, your functions are gone. Recreate any helper you need and re-verify a variable before trusting it.",
|
|
56
|
+
"Don't install packages into the evaluator; the standard library is available. Run out-of-tree projects through their own environment.",
|
|
57
|
+
];
|
|
58
|
+
}
|