pi-repl-py 0.6.8 → 0.6.10

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.
@@ -98,20 +98,23 @@ are announced with explicit markers so the model knows output was cut.
98
98
 
99
99
  **Cancellation is real.** An abort sends an `interrupt_request` on the control channel,
100
100
  which raises a genuine `KeyboardInterrupt` in the running cell; the namespace survives. As a
101
- backstop for cells wedged in C code (which ignore interrupts), the engine's abort grace then
102
- kills the kernel after 500 ms, and the next call rebuilds it from the last snapshot.
101
+ backstop for cells wedged in C code (which ignore interrupts), the engine gives an aborted
102
+ cell up to 20 seconds to settle and keeps the kernel if it does; only a cell that is still
103
+ running after that grace is killed, and the next call rebuilds from the last snapshot.
103
104
 
104
105
  ## Helpers loading
105
106
 
106
- At boot, the kernel and the host both read the same helpers directory:
107
- `~/.pi/agent/pi-repl/helpers`. It is created empty on install. No shipped toolbox is merged in.
107
+ At boot, the kernel and the host both read the same merged helper list (project
108
+ `.pi/helpers/` directories plus the global `~/.pi/agent/pi-repl/helpers/`), so what the
109
+ prompt advertises is what the kernel holds. Both directories are optional; nothing ships
110
+ with the package. The exact merge order is under "The fixed layout" below.
108
111
 
109
112
  - **The kernel** executes each eligible `*.py` file in its namespace, so the file's definitions
110
113
  and imports become available.
111
114
  - **The host** reads the same files to build the helper list shown in the `execute` tool's
112
115
  prompt, so the model sees each `helper_description` verbatim.
113
116
 
114
- Both sides read the same directory, so the names described to the model come from files the
117
+ Both sides read the same list, so the names described to the model come from files the
115
118
  kernel also loads. A file renamed with a `_` prefix is skipped by both sides. The
116
119
  `promptGuidelines` are built once, when the `execute` tool is registered, so a helpers
117
120
  change needs a **session restart or `/reload`** to reach the prompt. The kernel also loads
@@ -145,7 +148,7 @@ what was lost, so the model re-verifies before reusing state that may be gone.
145
148
  | Failure | Behaviour |
146
149
  | --- | --- |
147
150
  | Cell throws | `error` status with traceback; kernel namespace intact |
148
- | Cell silent or wedged | the watchdog sends an `interrupt_request`; a caller abort then kills the kernel after a 500 ms grace |
151
+ | Cell silent or wedged | the watchdog sends an `interrupt_request`; a caller abort kills the kernel only if it is still running after a 20-second grace |
149
152
  | Kernel dies | the running cell settles with an error; the next call builds a fresh kernel and restores the last snapshot |
150
153
  | Host exits | `process.on("exit")` SIGKILLs live kernels (a child does not die with its parent) |
151
154
  | Output flood | capped per channel, truncation announced |
package/index.ts CHANGED
@@ -130,11 +130,13 @@ export default function (pi: ExtensionAPI) {
130
130
  renderShell: "self",
131
131
  renderCall(args, theme, context) {
132
132
  const state = syncRenderState(context.state, { ...context, args });
133
+ state.version = (state.version ?? 0) + 1;
133
134
  // --- compact header lives in the call slot ---
134
135
  return new ExecuteCellComponent(state, theme, "header");
135
136
  },
136
137
  renderResult(result, options, _theme, context) {
137
138
  const state = syncRenderState(context.state, context);
139
+ state.version = (state.version ?? 0) + 1;
138
140
  state.hasResult = true;
139
141
  state.isPartial = options.isPartial;
140
142
  state.expanded = options.expanded;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-repl-py",
3
- "version": "0.6.8",
3
+ "version": "0.6.10",
4
4
  "type": "module",
5
5
  "description": "A pi extension with a single tool: execute, running a TypeScript host with a persistent Python (ipykernel) evaluator and a user-configurable toolbox of functions.",
6
6
  "keywords": [
@@ -116,6 +116,8 @@ export class EngineManager {
116
116
  private startPromise?: Promise<void>;
117
117
  private executionQueue: Promise<unknown> = Promise.resolve();
118
118
  private snapshotTimer?: ReturnType<typeof setTimeout>;
119
+ /** User cells currently running on the kernel; the debounced snapshot never cuts in front of one. */
120
+ private inFlightCells = 0;
119
121
  /** Last-seen top-level namespace names; snapshots are gated on this set changing. */
120
122
  private lastNamespaceNames?: string[];
121
123
  private pythonPath?: string;
@@ -254,13 +256,16 @@ export class EngineManager {
254
256
  };
255
257
  opts.signal?.addEventListener("abort", onAbort, { once: true });
256
258
 
259
+ this.inFlightCells++;
257
260
  try {
258
261
  const r = await this.kernel!.executeCell(code, {
259
262
  signal: opts.signal,
260
263
  onStream: opts.onStream,
261
264
  maxOutputChars: maxChars,
262
265
  });
263
- if (r.status === "ok") void this.scheduleSnapshotIfChanged();
266
+ // --- names gate runs off the critical path so the next execute's kernel
267
+ // --- request enqueues before the list-names hop, not behind it ---
268
+ if (r.status === "ok") setImmediate(() => void this.scheduleSnapshotIfChanged());
264
269
  const status: ExecuteResult["status"] = opts.signal?.aborted ? "aborted" : r.status;
265
270
  // Channel cap (truncateWithMarker), then per-line cap; both append a marker so truncation is explicit.
266
271
  const finalize = (text: string, channelTruncated: boolean): string => {
@@ -286,6 +291,7 @@ export class EngineManager {
286
291
  } finally {
287
292
  opts.signal?.removeEventListener("abort", onAbort);
288
293
  if (graceTimer) clearTimeout(graceTimer);
294
+ this.inFlightCells--;
289
295
  }
290
296
  } finally {
291
297
  release();
@@ -349,10 +355,20 @@ export class EngineManager {
349
355
  const config = this.options.snapshot;
350
356
  if (!config) return;
351
357
  this.clearSnapshotTimer();
352
- this.snapshotTimer = setTimeout(() => {
358
+ const quiet = config.debounceMs ?? DEFAULT_SNAPSHOT_DEBOUNCE_MS;
359
+ const fire = () => {
353
360
  this.snapshotTimer = undefined;
361
+ if (this.inFlightCells > 0) {
362
+ // --- a pickling cell would wait ahead of the user's next request on the
363
+ // --- kernel's single queue; the snapshot only lands in a real quiet gap,
364
+ // --- so re-arm the full quiet window and let activity settle instead ---
365
+ this.snapshotTimer = setTimeout(fire, quiet);
366
+ this.snapshotTimer.unref?.();
367
+ return;
368
+ }
354
369
  void this.snapshotState();
355
- }, config.debounceMs ?? DEFAULT_SNAPSHOT_DEBOUNCE_MS);
370
+ };
371
+ this.snapshotTimer = setTimeout(fire, quiet);
356
372
  this.snapshotTimer.unref?.();
357
373
  }
358
374
 
@@ -1,126 +1,87 @@
1
- // --- candidates: the five detectors that name a cell's intent, plus generic scoring ---
1
+ // --- candidates: a Python-flavored file detector plus generic line scoring ---
2
2
 
3
3
  import { descriptor } from "./descriptor.js";
4
- import { maskSpan, scanTemplate, substituteVars } from "./scan.js";
5
- import { previewShellCommand, previewShellCommandScored, SHELL_SETUP_WORDS, shellWords } from "./shell.js";
6
- import { BACKTICK, type Candidate } from "./types.js";
4
+ import type { Candidate } from "./types.js";
7
5
 
8
- const SHELL_OPEN_PATTERN = new RegExp(`Bun\\s*\\.\\s*\\$\\s*(?:\\([^)]*\\)\\s*)?${BACKTICK}`, "g");
6
+ // --- Python file effects: verb + literal path, in the idioms this evaluator runs ---
9
7
 
10
- export function shellCandidates(
11
- source: string,
12
- vars: ReadonlyMap<string, string>,
13
- ): { candidates: Candidate[]; masked: string } {
14
- const candidates: Candidate[] = [];
15
- let masked = source;
16
- SHELL_OPEN_PATTERN.lastIndex = 0;
17
- let match = SHELL_OPEN_PATTERN.exec(masked);
18
- while (match) {
19
- const span = scanTemplate(masked, match.index + match[0].length - 1);
20
- const command = previewShellCommandScored(substituteVars(span.body, vars));
21
- // --- the command's own strength breaks ties; setup-only drops lower ---
22
- if (command.text) {
23
- const setupOnly = SHELL_SETUP_WORDS.has(shellWords(command.text)[0] ?? "");
24
- const score = setupOnly ? 72 : 90 + Math.min(command.strength, 200) / 25;
25
- candidates.push({ kind: "shell", text: command.text, score });
26
- }
27
- masked = maskSpan(masked, span);
28
- SHELL_OPEN_PATTERN.lastIndex = span.end;
29
- match = SHELL_OPEN_PATTERN.exec(masked);
30
- }
31
- return { candidates, masked };
32
- }
33
-
34
- const FILE_EFFECT_PATTERN =
35
- /(?:Bun\.write|\b(?:fs|fsp|promises)\.(?:writeFileSync|writeFile|appendFileSync|appendFile|mkdirSync|mkdir|rmSync|rmdirSync|unlinkSync|unlink|renameSync|rename|copyFileSync|copyFile|cpSync|cp)|\b(?:writeFileSync|writeFile|appendFileSync|mkdirSync|rmSync|unlinkSync|renameSync|copyFileSync))\s*\(\s*([^,)\n]+)/g;
36
-
37
- const FILE_EFFECT_VERBS: ReadonlyArray<[string, string]> = [
38
- ["Bun.write", "write"],
39
- ["writeFileSync", "write"],
40
- ["writeFile", "write"],
41
- ["appendFileSync", "append"],
42
- ["appendFile", "append"],
43
- ["mkdirSync", "mkdir"],
44
- ["mkdir", "mkdir"],
45
- ["rmdirSync", "delete"],
46
- ["rmSync", "delete"],
47
- ["rm", "delete"],
48
- ["unlinkSync", "delete"],
49
- ["unlink", "delete"],
50
- ["renameSync", "rename"],
51
- ["rename", "rename"],
52
- ["copyFileSync", "copy"],
53
- ["copyFile", "copy"],
54
- ["cpSync", "copy"],
55
- ["cp", "copy"],
8
+ const PY_CHAINED_METHOD: ReadonlyArray<[string, string, number]> = [
9
+ ["write_text", "write", 95],
10
+ ["write_bytes", "write", 95],
11
+ ["append_text", "append", 90],
12
+ ["read_text", "read", 70],
13
+ ["read_bytes", "read", 70],
14
+ ["mkdir", "mkdir", 80],
15
+ ["rmdir", "delete", 85],
16
+ ["unlink", "delete", 85],
17
+ ["touch", "touch", 80],
18
+ ["write", "write", 95],
19
+ ["read", "read", 70],
56
20
  ];
57
21
 
58
- // --- resolve a quoted literal, a known const, or an interpolated template into a plain string ---
59
- function resolveArgText(arg: string, vars: ReadonlyMap<string, string>): string | undefined {
60
- const trimmed = arg.trim();
61
- const literalPattern = new RegExp(`^["'${BACKTICK}]([^"'${BACKTICK}]*)["'${BACKTICK}]$`);
62
- const literal = trimmed.match(literalPattern);
63
- if (literal?.[1]) return literal[1];
64
- if (/^[A-Za-z_$][\w$]*$/.test(trimmed)) return vars.get(trimmed);
65
- if (trimmed.startsWith(BACKTICK)) return substituteVars(trimmed.slice(1, -1), vars);
66
- return undefined;
67
- }
22
+ const PY_METHOD_VERB: Record<string, [string, number]> = Object.fromEntries(
23
+ PY_CHAINED_METHOD.map(([method, verb, score]) => [method, [verb, score]]),
24
+ );
68
25
 
69
- const FILE_READ_PATTERN = /Bun\.file\s*\(\s*([^,)\n]+?)\s*\)\s*\.\s*(?:text|json|arrayBuffer|bytes|stream)\s*\(/g;
26
+ /** `Path("p").method(...)` / `open("p"[, "mode"]).method(...)` — mode overrides the method verb. */
27
+ const PY_CHAINED_PATTERN = new RegExp(
28
+ "(?:Path|open)\\s*\\(\\s*([rf]?[\"'])([^\"']+)\\1\\s*(?:,\\s*[\"']([rawx])[\"'])?\\s*\\)\\s*\\.\\s*(" +
29
+ PY_CHAINED_METHOD.map(([method]) => method).join("|") +
30
+ ")\\s*\\(",
31
+ "g",
32
+ );
70
33
 
71
- export function fileCandidates(source: string, vars: ReadonlyMap<string, string>): Candidate[] {
72
- const candidates: Candidate[] = [];
73
- for (const match of source.matchAll(FILE_EFFECT_PATTERN)) {
74
- const call = match[0];
75
- const verb = FILE_EFFECT_VERBS.find(([name]) => call.includes(name))?.[1];
76
- if (!verb) continue;
77
- const path = resolveArgText(match[1] ?? "", vars);
78
- if (path) candidates.push({ kind: "ts", text: descriptor(`${verb} ${path}`), score: 95 });
79
- }
80
- for (const match of source.matchAll(FILE_READ_PATTERN)) {
81
- const path = resolveArgText(match[1] ?? "", vars);
82
- if (path) candidates.push({ kind: "ts", text: descriptor(`read ${path}`), score: 70 });
83
- }
84
- for (const match of source.matchAll(/\bfetch\s*\(\s*([^,)\n]+)/g)) {
85
- const url = resolveArgText(match[1] ?? "", vars);
86
- if (url) candidates.push({ kind: "ts", text: descriptor(`fetch ${url}`), score: 75 });
87
- }
88
- return candidates;
89
- }
34
+ /** A bare `open("p", "mode")`, including `with open(...)` blocks. */
35
+ const PY_OPEN_PATTERN = /open\s*\(\s*([rf]?["'])([^"']+)\1\s*,\s*["']([rawx])["']\s*\)/g;
90
36
 
91
- // --- per-tool: which arg names the target, the verb shown, and its scoring band ---
92
- const BRIDGED_TOOLS: Record<string, { arg: string; verb: string; score: number }> = {
93
- read: { arg: "path", verb: "read", score: 70 },
94
- bash: { arg: "command", verb: "", score: 88 },
95
- edit: { arg: "path", verb: "edit", score: 95 },
96
- write: { arg: "path", verb: "write", score: 95 },
97
- grep: { arg: "pattern", verb: "grep", score: 68 },
98
- find: { arg: "pattern", verb: "find", score: 68 },
99
- ls: { arg: "path", verb: "ls", score: 68 },
37
+ /** Function-form effects from the stdlib modules this repl's cells actually use. */
38
+ const PY_FN_PATTERN =
39
+ /((?:shutil|os)\.(?:copy|copytree|move|rmtree|remove|unlink|rmdir|mkdir|makedirs|rename|replace))\s*\(\s*([rf]?["'])([^"']+)\2(?:\s*,\s*([rf]?["'])([^"']+)\4)?/g;
40
+
41
+ const PY_FN_VERB: Record<string, [string, number]> = {
42
+ "shutil.copy": ["copy", 85],
43
+ "shutil.copytree": ["copy", 85],
44
+ "shutil.move": ["move", 85],
45
+ "shutil.rmtree": ["delete", 90],
46
+ "os.remove": ["delete", 85],
47
+ "os.unlink": ["delete", 85],
48
+ "os.rmdir": ["delete", 85],
49
+ "os.mkdir": ["mkdir", 80],
50
+ "os.makedirs": ["mkdir", 80],
51
+ "os.rename": ["rename", 85],
52
+ "os.replace": ["rename", 85],
100
53
  };
101
54
 
102
- export function bridgedToolCandidates(source: string, vars: ReadonlyMap<string, string>): Candidate[] {
55
+ export function pythonFileCandidates(source: string): Candidate[] {
103
56
  const candidates: Candidate[] = [];
104
- for (const match of source.matchAll(/\btools\.(\w+)\s*\(\s*\{([^}]*)\}/g)) {
105
- const spec = BRIDGED_TOOLS[match[1] ?? ""];
57
+ for (const match of source.matchAll(PY_CHAINED_PATTERN)) {
58
+ const spec = PY_METHOD_VERB[match[4] ?? ""];
59
+ if (!spec) continue;
60
+ const mode = match[3];
61
+ const verb = mode === "a" ? "append" : mode === "w" || mode === "x" ? "write" : mode === "r" ? "read" : spec[0];
62
+ candidates.push({ text: descriptor(`${verb} ${match[2]}`), score: mode === undefined ? spec[1] : 95 });
63
+ }
64
+ for (const match of source.matchAll(PY_OPEN_PATTERN)) {
65
+ const verb = match[3] === "a" ? "append" : match[3] === "r" ? "read" : "write";
66
+ candidates.push({ text: descriptor(`${verb} ${match[2]}`), score: 95 });
67
+ }
68
+ for (const match of source.matchAll(PY_FN_PATTERN)) {
69
+ const spec = PY_FN_VERB[match[1] ?? ""];
106
70
  if (!spec) continue;
107
- const props = match[2] ?? "";
108
- const argMatch = props.match(new RegExp(`${spec.arg}\\s*:\\s*([^,}]+)`));
109
- const target = argMatch ? resolveArgText(argMatch[1] ?? "", vars) : undefined;
110
- if (!target) continue;
111
- // --- a bridged bash call is a command like any other ---
112
- const text = spec.verb ? `${spec.verb} ${target}` : previewShellCommand(target) || target;
113
- candidates.push({ kind: "ts", text: descriptor(text), score: spec.score });
71
+ const target = match[5];
72
+ const text = target ? `${spec[0]} ${match[3]} → ${target}` : `${spec[0]} ${match[3]}`;
73
+ candidates.push({ text: descriptor(text), score: spec[1] });
114
74
  }
115
75
  return candidates;
116
76
  }
117
77
 
118
- const SKIP_LINE_PATTERN = /^(?:$|\/\/|\/\*|\*|import\s|export\s+(?:type\s|\{)|[})\];,]+$)/;
119
- const DEFINITION_PATTERN = /^(?:export\s+)?(?:async\s+)?(?:function\s|class\s|interface\s|type\s+\w+\s*=)/;
78
+ const SKIP_LINE_PATTERN = /^(?:$|#|\/\/|\/\*|\*|import\s|from\s+\S+\s+import|export\s+(?:type\s|\{)|[})\];,]+$)/;
79
+ const DEFINITION_PATTERN = /^(?:export\s+)?(?:async\s+)?(?:function\s|def\s|class\s|interface\s|type\s+\w+\s*=)/;
120
80
  const ARROW_DEFINITION_PATTERN = /^(?:const|let)\s+[A-Za-z_$][\w$]*\s*=\s*(?:async\s*)?\(?[^)=]*\)?\s*=>/;
121
81
  const CONTROL_PATTERN = /^(?:if|for|while|switch|try|do)\b/;
122
82
  const CALL_STATEMENT_PATTERN = /^(?:await\s+)?[A-Za-z_$][\w$.]*\s*\(/;
123
- const ASSIGNMENT_CALL_PATTERN = /^(?:const|let|var)\s+[^=]{1,60}=\s*(?:await\s+)?(?:new\s+)?[A-Za-z_$][\w$.]*\s*\(/;
83
+ const ASSIGNMENT_CALL_PATTERN = /^(?:const|let|var|def)\s+[^=]{1,60}=\s*(?:await\s+)?(?:new\s+)?[A-Za-z_$][\w$.]*\s*\(/;
84
+ const PY_ASSIGNMENT_CALL_PATTERN = /^[A-Za-z_][\w]*\s*=\s*(?:await\s+)?[A-Za-z_$][\w$.]*\s*\(/;
124
85
  const LOW_SIGNAL_CALL_PATTERN =
125
86
  /^(?:await\s+)?(?:console\.\w+|String|Number|Boolean|JSON\.stringify|JSON\.parse|structuredClone)\s*\(/;
126
87
  const LOW_SIGNAL_ASSIGNMENT_PATTERN =
@@ -139,21 +100,21 @@ function genericLineScore(line: string): number {
139
100
  if (DEFINITION_PATTERN.test(line) || ARROW_DEFINITION_PATTERN.test(line)) return 50;
140
101
  if (CONTROL_PATTERN.test(line)) return 20;
141
102
  if (/^(?:return|throw)\b/.test(line)) return 45;
142
- if (ASSIGNMENT_CALL_PATTERN.test(line)) return 60;
103
+ if (ASSIGNMENT_CALL_PATTERN.test(line) || PY_ASSIGNMENT_CALL_PATTERN.test(line)) return 60;
143
104
  if (CALL_STATEMENT_PATTERN.test(line)) return 65;
144
105
  if (/^(?:const|let|var)\s/.test(line)) return 22;
145
106
  return 30;
146
107
  }
147
108
 
148
- export function genericCandidates(masked: string): Candidate[] {
109
+ export function genericCandidates(source: string): Candidate[] {
149
110
  const candidates: Candidate[] = [];
150
- for (const [index, rawLine] of masked.split("\n").entries()) {
111
+ for (const [index, rawLine] of source.split("\n").entries()) {
151
112
  const line = rawLine.trim();
152
113
  const score = genericLineScore(line);
153
114
  if (score < 0) continue;
154
115
  const text = consoleInnerCall(line) ?? line;
155
116
  // --- later lines win ties: cells read as setup-then-act, and the act is the story ---
156
- candidates.push({ kind: "ts", text: descriptor(text), score: score + Math.min(index, 90) / 100 });
117
+ candidates.push({ text: descriptor(text), score: score + Math.min(index, 90) / 100 });
157
118
  }
158
119
  return candidates;
159
120
  }
@@ -1,31 +1,21 @@
1
1
  // --- preview entry: score the whole cell for its one truthful line ---
2
2
 
3
- import { bridgedToolCandidates, fileCandidates, genericCandidates, shellCandidates } from "./candidates.js";
3
+ import { genericCandidates, pythonFileCandidates } from "./candidates.js";
4
4
  import { descriptor } from "./descriptor.js";
5
- import { stringConsts } from "./scan.js";
6
- import { previewShellCommand } from "./shell.js";
7
5
  import type { CellPreview } from "./types.js";
8
6
 
9
7
  export type { CellPreview };
10
- export { descriptor, previewShellCommand };
8
+ export { descriptor };
11
9
 
12
10
  export function previewCell(code: string): CellPreview {
13
11
  const source = code.trimEnd();
14
- if (!source) return { kind: "ts", text: "" };
15
- const vars = stringConsts(source);
12
+ if (!source) return { text: "" };
13
+ // --- file effects carry the most signal; generic line shape fills the rest ---
14
+ const candidates = [...pythonFileCandidates(source), ...genericCandidates(source)];
16
15
 
17
- // --- scan order: shell masks shell-looking syntax, then file/tool/generic ---
18
- const shell = shellCandidates(source, vars);
19
- const candidates = [
20
- ...shell.candidates,
21
- ...fileCandidates(shell.masked, vars),
22
- ...bridgedToolCandidates(shell.masked, vars),
23
- ...genericCandidates(shell.masked),
24
- ];
25
-
26
- let best: { kind: CellPreview["kind"]; text: string; score: number } | undefined;
16
+ let best: { text: string; score: number } | undefined;
27
17
  for (const candidate of candidates) {
28
18
  if (candidate.text && (!best || candidate.score > best.score)) best = candidate;
29
19
  }
30
- return best ?? { kind: "ts", text: "" };
20
+ return best ?? { text: "" };
31
21
  }
@@ -1,23 +1,10 @@
1
- // --- shared preview types; tiny module so every consumer imports only the shape it needs ---
2
- type CellPreviewKind = "shell" | "ts";
3
-
4
- export interface CellPreview {
5
- kind: CellPreviewKind;
6
- text: string;
7
- }
8
-
9
- // --- a [start, end) slice of the source with its captured body ---
10
- export interface Span {
11
- start: number;
12
- end: number;
13
- body: string;
14
- }
15
-
16
- // --- the winner of each detector, ranked by score in the orchestration ---
1
+ /** A scored preview candidate for one cell in the transcript header. */
17
2
  export interface Candidate {
18
- kind: CellPreviewKind;
19
3
  text: string;
20
4
  score: number;
21
5
  }
22
6
 
23
- export const BACKTICK = "\u0060";
7
+ /** The one-line semantic summary a collapsed cell shows. */
8
+ export interface CellPreview {
9
+ text: string;
10
+ }
@@ -2,22 +2,29 @@
2
2
  // description = rich short behavior; promptSnippet = one-liner; guidelines = flat bullets.
3
3
 
4
4
  export const executeToolDescription =
5
- "Execute Python cells in a persistent Python shell that is your entire workspace: it is where you read, " +
6
- "write, run, and move, all in one instrument. The state you build, files, and subprocesses survive " +
7
- "from one call to the next. Returns stdout, stderr, and the value of the last expression. Output is " +
8
- "truncated to 45K with a marker.";
5
+ "Execute Python in a Jupyter notebook. Your workspace is one notebook where every cell runs in a " +
6
+ "shared Python environment: variables, functions, imports, classes, and data defined in one cell " +
7
+ "stay available to later cells for the life of the notebook. Cells return their last expression " +
8
+ "(auto-displayed) plus stdout/stderr; output is truncated to 45K with a marker.";
9
9
 
10
- export const executePromptSnippet = "Execute Python in a persistent shell (read, write, run, search, and more)";
10
+ export const executePromptSnippet = "Run Python cells in a Jupyter notebook (read, write, run, search, and more)";
11
11
 
12
12
  // --- the model-facing guidelines, flat bullets like pi's own tool contributions ---
13
13
  export function buildPromptGuidelines(preloaded: string[]): string[] {
14
14
  return [
15
- "State persists across cells, so keep building on it.",
16
- "Find, filter, fetch, read: narrow the output in Python, then print the exact slice you need.",
17
- "Keep the result in a variable and reuse it, instead of re-fetching the same thing.",
15
+ "Write modern idiomatic Python.",
16
+ "Find, filter, fetch, sample: narrow the output in Python, then print only the window that decides the next step (a head, a shape, a slice), not the whole.",
18
17
  "Make surgical, precise changes over rewrites or whole-file dumps: a small unique anchor, replace, verify, read the file back before trusting it.",
19
- "The evaluator runs in a project-local venv. Do not install a project's dependencies into it; run external projects through their own interface. If output begins with <repl_engine_reset>, the kernel rebuilt; re-verify a revived variable.",
20
- ...(preloaded.length ? ["Preloaded helpers, use them as any loaded function or variable:", ...preloaded] : []),
18
+ "Prefer to reuse existing variables, functions, imports, classes, and data from prior cells/namespaces over recomputing.",
19
+ "If output begins with <repl_engine_reset>, the runtime rebuilt and the notebook was restored from the last snapshot; reverify surviving names before building on them.",
20
+ ...(preloaded.length
21
+ ? [
22
+ [
23
+ "Preloaded helpers, use them as any loaded function or variable:",
24
+ ...preloaded.map((line) => ` - ${line.replace(/\n/g, "\n ")}`),
25
+ ].join("\n"),
26
+ ]
27
+ : []),
21
28
  "Be concise.",
22
29
  ];
23
30
  }
@@ -19,6 +19,8 @@ export interface ExecuteRenderState {
19
19
  expanded: boolean;
20
20
  executionStarted: boolean;
21
21
  hasResult: boolean;
22
+ /** Monotonic dirty counter, bumped by the host on every content/status change. */
23
+ version?: number;
22
24
  }
23
25
 
24
26
  import { previewCell } from "./preview/index.js";
@@ -185,8 +187,7 @@ function outputText(state: ExecuteRenderState): string {
185
187
  function topLine(state: ExecuteRenderState, width: number, deps: RenderDeps): string {
186
188
  const code = state.code.trimEnd();
187
189
  const preview = previewCell(code);
188
- const language = preview.kind === "shell" ? "repl · shell" : "repl";
189
- const prefix = `${marker(state, deps)} ${deps.fg("muted", language)}`;
190
+ const prefix = `${marker(state, deps)} ${deps.fg("muted", "repl")}`;
190
191
 
191
192
  // --- suffix priority: expand hint > error > duration > counts, so truncation never hides the expand key ---
192
193
  const suffixParts: string[] = [];
@@ -222,10 +223,7 @@ function topLine(state: ExecuteRenderState, width: number, deps: RenderDeps): st
222
223
  // --- a semantic preview is a one-line summary; highlight Python code, accent shell intent ---
223
224
  let middle = "";
224
225
  if (preview.text) {
225
- const previewText =
226
- preview.kind === "ts"
227
- ? (deps.highlight(preview.text)[0] ?? deps.fg("accent", preview.text))
228
- : deps.fg("accent", preview.text);
226
+ const previewText = deps.highlight(preview.text)[0] ?? deps.fg("accent", preview.text);
229
227
  middle = deps.truncateToWidth(previewText, previewBudget, "…");
230
228
  } else if (!state.executionStarted) {
231
229
  middle = deps.fg("muted", "waiting for code");
@@ -244,20 +242,24 @@ function sanitizeTuiOutput(text: string): string {
244
242
  .replace(/[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f-\x9f]/g, "�");
245
243
  }
246
244
 
247
- type PreviewEntry = { kind: "line"; line: string } | { kind: "hidden"; hidden: number };
248
-
249
- /** Head/tail truncation with a hidden-line marker, adapted from pi-fabric. */
250
- function selectPreviewLines(lines: string[], limit: number): PreviewEntry[] {
251
- const lineEntry = (line: string): PreviewEntry => ({ kind: "line", line });
252
- if (lines.length <= limit || limit <= 0) return lines.map(lineEntry);
253
- if (limit < 8) return [...lines.slice(0, limit).map(lineEntry), { kind: "hidden", hidden: lines.length - limit }];
254
- const head = Math.ceil(limit * 0.65);
255
- const tail = Math.max(1, limit - head - 1);
256
- return [
257
- ...lines.slice(0, head).map(lineEntry),
258
- { kind: "hidden", hidden: lines.length - head - tail },
259
- ...lines.slice(-tail).map(lineEntry),
260
- ];
245
+ /** Wrap a ready-colored span into `lines`: first row takes the prefix, wrapped
246
+ * continuations take indent (and an optional extra indent), each row is
247
+ * truncated to pane width and closed if it ends on an open SGR color. */
248
+ function pushWrappedLines(
249
+ lines: string[],
250
+ prefix: string,
251
+ coloredText: string,
252
+ width: number,
253
+ deps: RenderDeps,
254
+ indentAfter?: number,
255
+ ): void {
256
+ const available = Math.max(1, width - 1 - deps.visibleWidth(prefix));
257
+ const wrapped = deps.wrapTextWithAnsi(coloredText, available);
258
+ for (const [index, line] of (wrapped.length > 0 ? wrapped : [""]).entries()) {
259
+ const linePrefix = index === 0 ? prefix : " ".repeat(deps.visibleWidth(prefix));
260
+ const continuationIndent = index > 0 && indentAfter ? " ".repeat(indentAfter) : "";
261
+ lines.push(deps.truncateToWidth(` ${linePrefix}${continuationIndent}${closeOpenSgr(line)}`, width, ""));
262
+ }
261
263
  }
262
264
 
263
265
  function addWrapped(
@@ -269,13 +271,7 @@ function addWrapped(
269
271
  options: { sanitize?: boolean; indentAfter?: number } = {},
270
272
  ): void {
271
273
  const safe = options.sanitize === false ? text : sanitizeTuiOutput(text);
272
- const available = Math.max(1, width - 1 - deps.visibleWidth(prefix));
273
- const wrapped = deps.wrapTextWithAnsi(safe, available);
274
- for (const [index, line] of (wrapped.length > 0 ? wrapped : [""]).entries()) {
275
- const linePrefix = index === 0 ? prefix : " ".repeat(deps.visibleWidth(prefix));
276
- const continuationIndent = index > 0 && options.indentAfter ? " ".repeat(options.indentAfter) : "";
277
- lines.push(deps.truncateToWidth(` ${linePrefix}${continuationIndent}${closeOpenSgr(line)}`, width, ""));
278
- }
274
+ pushWrappedLines(lines, prefix, safe, width, deps, options.indentAfter);
279
275
  }
280
276
 
281
277
  function renderCode(state: ExecuteRenderState, lines: string[], width: number, deps: RenderDeps): boolean {
@@ -296,7 +292,162 @@ function renderCode(state: ExecuteRenderState, lines: string[], width: number, d
296
292
  return true;
297
293
  }
298
294
 
299
- const MAX_OUTPUT_LINES = 50;
295
+ /**
296
+ * Streaming output is append-only, so a wrapped blob only changes at its tail.
297
+ * The cache lives on the (persistent) state object via a WeakMap, letting an
298
+ * updated body re-wrap just the appended delta: O(chunk) instead of O(output).
299
+ * Fresh states (unit tests, first render) fall back to a full wrap.
300
+ */
301
+ interface BlobWrapEntry {
302
+ text: string;
303
+ color: string;
304
+ lines: string[];
305
+ /** Last raw line when `text` has no trailing newline; it may still grow. */
306
+ partial?: { raw: string; wrapped: string[] };
307
+ }
308
+
309
+ const blobWrapCache = new WeakMap<ExecuteRenderState, Map<number, BlobWrapEntry>>();
310
+
311
+ const CJK_WIDE_RE =
312
+ /[\p{Script_Extensions=Han}\p{Script_Extensions=Hiragana}\p{Script_Extensions=Katakana}\p{Script_Extensions=Hangul}\p{Script_Extensions=Bopomofo}]/u;
313
+
314
+ function widthOf(ch: string): number {
315
+ const code = ch.charCodeAt(0);
316
+ return code < 0x80 ? 1 : CJK_WIDE_RE.test(ch) ? 2 : 1;
317
+ }
318
+
319
+ /** Fast single-pass word wrap for ANSI-free text: pi-tui's ANSI-aware wrap is
320
+ * ~45ms on a 45K blob; sanitized output needs no ANSI handling, so this splits
321
+ * at spaces with an O(width) scan per row and hard-breaks overlong words. */
322
+ function wrapPlainText(text: string, width: number): string[] {
323
+ if (width <= 0 || text.length <= width) return [text];
324
+ // --- fast path: pure ASCII, break by char index ---
325
+ let ascii = true;
326
+ for (let i = 0; i < text.length; i++) {
327
+ if (text.charCodeAt(i) >= 0x80) {
328
+ ascii = false;
329
+ break;
330
+ }
331
+ }
332
+ const rows: string[] = [];
333
+ if (ascii) {
334
+ let i = 0;
335
+ for (;;) {
336
+ const end = i + width;
337
+ if (end >= text.length) {
338
+ rows.push(text.slice(i));
339
+ break;
340
+ }
341
+ const brk = text.lastIndexOf(" ", end);
342
+ if (brk > i) {
343
+ rows.push(text.slice(i, brk));
344
+ i = brk + 1;
345
+ } else {
346
+ rows.push(text.slice(i, end));
347
+ i = end;
348
+ }
349
+ }
350
+ return rows;
351
+ }
352
+ // --- wide characters: accumulate visible width, track a last-space break ---
353
+ let row = "";
354
+ let rowVis = 0;
355
+ let lastSpace = -1;
356
+ let lastSpaceVis = 0;
357
+ const visRaw: number[] = [];
358
+ const repoint = (start: number) => {
359
+ visRaw.length = 0;
360
+ let v = 0;
361
+ for (let k = start; k < row.length; k++) {
362
+ visRaw[v] = k;
363
+ v += widthOf(row[k]);
364
+ }
365
+ };
366
+ for (let i = 0; i < text.length; i++) {
367
+ const ch = text[i];
368
+ const w = widthOf(ch);
369
+ visRaw[rowVis] = row.length;
370
+ row += ch;
371
+ rowVis += w;
372
+ if (ch === " ") {
373
+ lastSpace = row.length;
374
+ lastSpaceVis = rowVis;
375
+ }
376
+ if (rowVis > width) {
377
+ if (lastSpace !== -1) {
378
+ rows.push(row.slice(0, lastSpace - 1));
379
+ row = row.slice(lastSpace);
380
+ rowVis -= lastSpaceVis;
381
+ lastSpace = -1;
382
+ lastSpaceVis = 0;
383
+ repoint(0);
384
+ } else {
385
+ const cut = visRaw[width] ?? row.length;
386
+ rows.push(row.slice(0, cut));
387
+ row = row.slice(cut);
388
+ rowVis -= width;
389
+ repoint(0);
390
+ }
391
+ }
392
+ }
393
+ if (row) rows.push(row);
394
+ return rows;
395
+ }
396
+
397
+ /** Wrap a raw output line exactly the way the section loops did: sanitize, wrap (fast),
398
+ * then colorize row-by-row so the color span never bleeds across rows. */
399
+ function wrapRawOutputLine(lines: string[], raw: string, color: string, width: number, deps: RenderDeps): string[] {
400
+ const before = lines.length;
401
+ const safe = sanitizeTuiOutput(raw || " ");
402
+ const available = Math.max(1, width - 1 - deps.visibleWidth(OUTPUT_INDENT));
403
+ const rows = wrapPlainText(safe, available);
404
+ for (const [index, row] of (rows.length > 0 ? rows : [""]).entries()) {
405
+ const linePrefix = index === 0 ? OUTPUT_INDENT : " ".repeat(deps.visibleWidth(OUTPUT_INDENT));
406
+ lines.push(deps.truncateToWidth(` ${linePrefix}${closeOpenSgr(deps.fg(color, row))}`, width, ""));
407
+ }
408
+ return lines.slice(before);
409
+ }
410
+
411
+ function wrapBlob(state: ExecuteRenderState, width: number, text: string, color: string, deps: RenderDeps): string[] {
412
+ let perWidth = blobWrapCache.get(state);
413
+ if (!perWidth) {
414
+ perWidth = new Map();
415
+ blobWrapCache.set(state, perWidth);
416
+ }
417
+ const fresh = (): BlobWrapEntry => {
418
+ const lines: string[] = [];
419
+ let partial: { raw: string; wrapped: string[] } | undefined;
420
+ const parts = text.split("\n");
421
+ for (let i = 0; i < parts.length; i++) {
422
+ const wrapped = wrapRawOutputLine(lines, parts[i], color, width, deps);
423
+ if (i === parts.length - 1 && !text.endsWith("\n")) {
424
+ partial = { raw: parts[i], wrapped };
425
+ }
426
+ }
427
+ const entry: BlobWrapEntry = { text, color, lines, partial };
428
+ perWidth.set(width, entry);
429
+ return entry;
430
+ };
431
+ const entry = perWidth.get(width);
432
+ if (!entry || entry.color !== color) return fresh().lines;
433
+ if (entry.text === text) return entry.lines;
434
+ if (text.startsWith(entry.text)) {
435
+ // --- append: cached prefix rows stay; re-wrap only the growing tail ---
436
+ const delta = text.slice(entry.text.length);
437
+ const combined = entry.partial ? entry.partial.raw + delta : delta;
438
+ if (entry.partial) entry.lines.length -= entry.partial.wrapped.length;
439
+ const parts = combined.split("\n");
440
+ for (let i = 0; i < parts.length; i++) {
441
+ const wrapped = wrapRawOutputLine(entry.lines, parts[i], color, width, deps);
442
+ if (i === parts.length - 1) {
443
+ entry.partial = combined.endsWith("\n") ? undefined : { raw: parts[i], wrapped };
444
+ }
445
+ }
446
+ entry.text = text;
447
+ return entry.lines;
448
+ }
449
+ return fresh().lines;
450
+ }
300
451
 
301
452
  function renderOutput(
302
453
  state: ExecuteRenderState,
@@ -319,19 +470,13 @@ function renderOutput(
319
470
  if (!text?.trim()) continue;
320
471
  renderedText = true;
321
472
  output.push(` ${OUTPUT_INDENT}${deps.fg("dim", `${label}:`)}`);
322
- for (const line of text.split("\n")) {
323
- const safe = sanitizeTuiOutput(line || " ");
324
- addWrapped(output, OUTPUT_INDENT, deps.fg(color, safe), width, deps, { sanitize: false });
325
- }
473
+ output.push(...wrapBlob(state, width, text, color, deps));
326
474
  }
327
475
 
328
476
  if (!renderedText && !details && state.contentText?.trim()) {
329
477
  renderedText = true;
330
478
  const color = state.isError ? "error" : "toolOutput";
331
- for (const line of state.contentText.trim().split("\n")) {
332
- const safe = sanitizeTuiOutput(line || " ");
333
- addWrapped(output, OUTPUT_INDENT, deps.fg(color, safe), width, deps, { sanitize: false });
334
- }
479
+ output.push(...wrapBlob(state, width, state.contentText.trim(), color, deps));
335
480
  }
336
481
 
337
482
  if (details?.errorStack && details.errorStack.length > 0) {
@@ -347,16 +492,9 @@ function renderOutput(
347
492
  addWrapped(output, OUTPUT_INDENT, deps.fg("muted", message), width, deps, { sanitize: false });
348
493
  }
349
494
 
350
- const entries = selectPreviewLines(output, MAX_OUTPUT_LINES);
351
- if (entries.length > 0 && hasCode) lines.push("");
352
- for (const entry of entries) {
353
- if (entry.kind === "hidden") {
354
- const marker = ` … ${entry.hidden} line${entry.hidden === 1 ? "" : "s"} hidden … `;
355
- lines.push(` ${OUTPUT_INDENT}${deps.fg("muted", marker)}`);
356
- } else {
357
- lines.push(entry.line);
358
- }
359
- }
495
+ // --- expanded cells render the whole output: the data cap bounds it ---
496
+ if (output.length > 0 && hasCode) lines.push("");
497
+ lines.push(...output);
360
498
  }
361
499
 
362
500
  /** Paint the status-matched panel background across the row, surviving inner SGR resets. */
@@ -30,21 +30,13 @@ function makeDeps(theme: Theme): RenderDeps {
30
30
  };
31
31
  }
32
32
 
33
- /** The layout only changes on state/spinner change, but the TUI repaints every frame; key by both to avoid recompute flicker. */
34
- function renderVersion(state: ExecuteRenderState): string {
35
- const details = state.details ? JSON.stringify(state.details) : "";
36
- return [
37
- state.code.length,
38
- state.contentText?.length ?? 0,
39
- details.length,
40
- state.isPartial,
41
- state.isError,
42
- state.expanded,
43
- state.executionStarted,
44
- state.hasResult,
45
- // --- fold the animation frame in while running so the spinner still turns ---
46
- statusKind(state) === "running" ? Math.floor(Date.now() / 120) % 4 : -1,
47
- ].join("|");
33
+ /** O(1) key: a host-bumped dirty counter plus mode state. `withSpinner` folds
34
+ * the animation frame so the header alone animates while running; the body key
35
+ * excludes it, so a running cell only redraws when output actually changes
36
+ * instead of re-wrapping the whole body every 120ms. */
37
+ function renderVersion(state: ExecuteRenderState, withSpinner: boolean): string {
38
+ const spinner = withSpinner && statusKind(state) === "running" ? Math.floor(Date.now() / 120) % 4 : -1;
39
+ return `${state.version ?? 0}|${state.expanded}|${spinner}`;
48
40
  }
49
41
 
50
42
  export class ExecuteCellComponent {
@@ -66,7 +58,12 @@ export class ExecuteCellComponent {
66
58
  }
67
59
 
68
60
  render(width: number): string[] {
69
- const key = `${renderVersion(this.state)}|${this.mode}`;
61
+ const key =
62
+ this.mode === "header"
63
+ ? `${renderVersion(this.state, true)}|header`
64
+ : this.mode === "body"
65
+ ? `${renderVersion(this.state, false)}|body`
66
+ : `${renderVersion(this.state, true)}|cell`;
70
67
  if (this.cachedLines && this.cachedWidth === width && this.cachedKey === key) {
71
68
  return this.cachedLines;
72
69
  }
@@ -1,59 +0,0 @@
1
- // --- scan: tokenizer source → template spans, string constants, and masks ---
2
- import { BACKTICK, type Span } from "./types.js";
3
-
4
- // --- capture the template opened at start; tracks escapes + interpolation nesting so a shell command reads whole, and an unclosed template returns the rest (partial is better than none) ---
5
- export function scanTemplate(source: string, start: number): Span {
6
- let depth = 0;
7
- let inNested = false;
8
- for (let i = start + 1; i < source.length; i++) {
9
- const ch = source[i];
10
- if (ch === "\\") {
11
- i += 1;
12
- continue;
13
- }
14
- if (ch === BACKTICK) {
15
- if (depth === 0 && !inNested) return { start, end: i + 1, body: source.slice(start + 1, i) };
16
- inNested = !inNested;
17
- continue;
18
- }
19
- if (!inNested && ch === "$" && source[i + 1] === "{") {
20
- depth += 1;
21
- i += 1;
22
- continue;
23
- }
24
- if (!inNested && depth > 0 && ch === "}") depth -= 1;
25
- }
26
- return { start, end: source.length, body: source.slice(start + 1) };
27
- }
28
-
29
- const CONST_STRING_PATTERN = new RegExp(
30
- '(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*(?:"([^"\\n]*)"|' +
31
- "'([^'\\n]*)'|" +
32
- BACKTICK +
33
- "([^" +
34
- BACKTICK +
35
- "$\\n]*)" +
36
- BACKTICK +
37
- ")",
38
- "g",
39
- );
40
-
41
- // --- collected simple string constants, for resolving interpolations and path args ---
42
- export function stringConsts(source: string): Map<string, string> {
43
- const vars = new Map<string, string>();
44
- for (const match of source.matchAll(CONST_STRING_PATTERN)) {
45
- const name = match[1];
46
- const value = match[2] ?? match[3] ?? match[4];
47
- if (name && value !== undefined) vars.set(name, value);
48
- }
49
- return vars;
50
- }
51
-
52
- export function substituteVars(text: string, vars: ReadonlyMap<string, string>): string {
53
- return text.replace(/\$\{\s*([A-Za-z_$][\w$]*)\s*\}/g, (whole, name: string) => vars.get(name) ?? whole);
54
- }
55
-
56
- // --- blank a claimed span so later detectors don't re-read what an earlier one took ---
57
- export function maskSpan(source: string, span: Span): string {
58
- return source.slice(0, span.start) + " ".repeat(span.end - span.start) + source.slice(span.end);
59
- }
@@ -1,156 +0,0 @@
1
- // --- shell: resolve the strongest single line of a (possibly chained) shell command ---
2
- import { descriptor } from "./descriptor.js";
3
-
4
- const CD_PREFIX_PATTERN = /^\s*cd\s+([^&;|]+?)\s*(?:&&|;)\s*/;
5
- const SHELL_SETUP_PATTERN = /^(?:export\s+\w+=|set\s+[-+]|source\s+\S+|\.\s+\S+)/;
6
- const HEREDOC_PATTERN = /<<-?\s*['"]?([A-Za-z_][A-Za-z0-9_]*)['"]?/;
7
-
8
- export function shellWords(line: string): string[] {
9
- const words: string[] = [];
10
- for (const match of line.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)) {
11
- words.push(match[1] ?? match[2] ?? match[3] ?? "");
12
- }
13
- return words;
14
- }
15
-
16
- function pathTail(path: string): string {
17
- const cleaned = path.replace(/\/+$/, "");
18
- const tail = cleaned.slice(cleaned.lastIndexOf("/") + 1);
19
- return tail || cleaned;
20
- }
21
-
22
- function simplifyRunnerCommand(line: string): string | undefined {
23
- const words = shellWords(line);
24
- if (words[0] === "npm" || words[0] === "pnpm") {
25
- const runIndex = words.indexOf("run");
26
- if (runIndex >= 0 && words[runIndex + 1]) {
27
- return `${words[0]} ${words.slice(runIndex + 1).join(" ")}`.trim();
28
- }
29
- }
30
- if (line.includes("node_modules/.bin/")) {
31
- return line.replace(/\S*node_modules\/\.bin\//g, "");
32
- }
33
- return undefined;
34
- }
35
-
36
- function simplifyMutationCommand(line: string): string | undefined {
37
- const words = shellWords(line);
38
- if (words.length === 0) return undefined;
39
- if (words[0] === "cat" && words[1] === ">" && words[2]) return `write ${pathTail(words[2])}`;
40
- if (words[0] === "tee" && words.at(-1)) {
41
- return (words.includes("-a") ? "append " : "write ") + pathTail(words.at(-1) ?? "");
42
- }
43
- return undefined;
44
- }
45
-
46
- // --- collapse noisier command forms (runners, writes) down to the intent ---
47
- function simplifyShellLine(line: string): string {
48
- return simplifyRunnerCommand(line) ?? simplifyMutationCommand(line) ?? line;
49
- }
50
-
51
- // --- commands that prepare the ground; the shell only wins when it is the story ---
52
- export const SHELL_SETUP_WORDS = new Set([
53
- "mkdir",
54
- "cd",
55
- "export",
56
- "touch",
57
- "chmod",
58
- "chown",
59
- "ln",
60
- "echo",
61
- "true",
62
- "sleep",
63
- "which",
64
- "sync",
65
- ]);
66
-
67
- const SHELL_ACTION_WORDS = new Set([
68
- "rm",
69
- "mv",
70
- "cp",
71
- "git",
72
- "npm",
73
- "pnpm",
74
- "bun",
75
- "bunx",
76
- "npx",
77
- "make",
78
- "cargo",
79
- "docker",
80
- "curl",
81
- "gh",
82
- "pi",
83
- ]);
84
-
85
- function shellLineScore(line: string, index: number): number {
86
- const simplified = simplifyShellLine(line);
87
- const words = shellWords(line);
88
- let score = 30;
89
- if (simplified !== line) score += 40;
90
- if (SHELL_ACTION_WORDS.has(words[0] ?? "")) score += 20;
91
- if (/\b(?:rm|mv|cp|git\s+(?:add|commit|push)|sed\s+-i|perl\s+-pi|tee|cat\s*>)\b/.test(line)) score += 40;
92
- return score + index;
93
- }
94
-
95
- function heredocBody(lines: readonly string[], startIndex: number, delimiter: string): string | undefined {
96
- const body: string[] = [];
97
- for (let i = startIndex + 1; i < lines.length; i++) {
98
- if ((lines[i] ?? "").trim() === delimiter) return body.join("\n");
99
- body.push(lines[i] ?? "");
100
- }
101
- return body.length > 0 ? body.join("\n") : undefined;
102
- }
103
-
104
- function previewHeredoc(lines: readonly string[]): string | undefined {
105
- for (let i = 0; i < lines.length; i++) {
106
- const line = (lines[i] ?? "").trim();
107
- const delimiter = line.match(HEREDOC_PATTERN)?.[1];
108
- if (!delimiter) continue;
109
- const body = heredocBody(lines, i, delimiter);
110
- if (!body) continue;
111
- // --- the write target is the story; the body is detail for the expanded view ---
112
- const catWrite = line.match(/\b(?:cat|tee)\b.*(?:>|\s)(\S+)\s*<<-?/);
113
- if (catWrite?.[1]) return (line.includes("tee -a") ? "append " : "write ") + pathTail(catWrite[1]);
114
- return descriptor(body);
115
- }
116
- return undefined;
117
- }
118
-
119
- export function previewShellCommand(command: string): string {
120
- return previewShellCommandScored(command).text;
121
- }
122
-
123
- // --- like previewShellCommand but keeps the winning line's strength so several shell calls can rank ---
124
- export function previewShellCommandScored(command: string): { text: string; strength: number } {
125
- const lines = command.split("\n");
126
- const heredoc = previewHeredoc(lines);
127
- if (heredoc) return { text: descriptor(heredoc), strength: 90 };
128
-
129
- let best: { text: string; score: number } | undefined;
130
- let cwdSuffix: string | undefined;
131
- let index = 0;
132
- for (const rawLine of lines) {
133
- for (const rawPart of rawLine.split(/\s*(?:&&|;)\s*/)) {
134
- let part = rawPart.trim();
135
- if (!part || part.startsWith("#") || SHELL_SETUP_PATTERN.test(part)) continue;
136
- const cd = part.match(CD_PREFIX_PATTERN);
137
- if (cd?.[1]) {
138
- cwdSuffix = pathTail(cd[1].trim());
139
- part = part.replace(CD_PREFIX_PATTERN, "").trim();
140
- } else if (/^cd\s+\S+$/.test(part)) {
141
- cwdSuffix = pathTail(part.slice(2).trim());
142
- continue;
143
- }
144
- if (!part) continue;
145
- const candidate = { text: simplifyShellLine(part), score: shellLineScore(part, index) };
146
- if (!best || candidate.score > best.score) best = candidate;
147
- index += 1;
148
- }
149
- }
150
- if (!best) return { text: "", strength: 0 };
151
- // --- trailing redirections are plumbing, not intent ---
152
- const cleaned = best.text.replace(/(?:\s*(?:2>&1|[12]?>\s*\/dev\/null|&>\s*\/dev\/null))+\s*$/, "");
153
- // --- a stripped cd prefix still matters when it names a non-default dir ---
154
- const text = cwdSuffix && !cleaned.includes(cwdSuffix) ? `${cleaned} (${cwdSuffix})` : cleaned;
155
- return { text: descriptor(text), strength: best.score };
156
- }