pi-repl-py 0.6.7 → 0.6.9
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/README.md +11 -8
- package/docs/ARCHITECTURE.md +16 -11
- package/docs/helpers.md +18 -5
- package/index.ts +3 -1
- package/package.json +1 -1
- package/src/engine/helpers-locate.ts +24 -0
- package/src/engine/index.ts +19 -3
- package/src/engine/kernel.ts +20 -13
- package/src/extension/helpers.ts +32 -19
- package/src/extension/preview/candidates.ts +68 -107
- package/src/extension/preview/index.ts +7 -17
- package/src/extension/preview/types.ts +5 -18
- package/src/extension/prompt.ts +12 -5
- package/src/extension/render-core.ts +184 -46
- package/src/extension/render.ts +13 -16
- package/src/extension/tool-meta.ts +4 -4
- package/src/extension/preview/scan.ts +0 -59
- package/src/extension/preview/shell.ts +0 -156
package/README.md
CHANGED
|
@@ -64,22 +64,25 @@ On **Termux (Android)**, the `postinstall` venv build can fail because `ipykerne
|
|
|
64
64
|
|
|
65
65
|
A **helper** is a `.py` file that gets exec'd into every kernel, so whatever it defines
|
|
66
66
|
(like functions, classes, constants, imports, or a module that manages a tricky piece of
|
|
67
|
-
complexity) is available in the workspace. Drop a file in
|
|
68
|
-
|
|
69
|
-
`double
|
|
70
|
-
|
|
71
|
-
|
|
67
|
+
complexity) is available in the workspace. Drop a file in a `.pi/helpers/` directory in
|
|
68
|
+
your project (or `~/.pi/agent/pi-repl/helpers/` for every project) and restart the session;
|
|
69
|
+
e.g. `helpers/double.py` defining `def double(x)` becomes callable as `double(...)`. Global
|
|
70
|
+
helpers ship **empty** (shell and file IO are already plain Python), so a fresh install
|
|
71
|
+
preloads nothing until you add one. Project helpers shadow same-named global ones. Each
|
|
72
|
+
helper's `helper_description` is shown to the model verbatim; the full contract lives in
|
|
73
|
+
[docs/helpers.md](docs/helpers.md).
|
|
72
74
|
|
|
73
|
-
|
|
75
|
+
The extension keeps its runtime under one folder in your home directory:
|
|
74
76
|
|
|
75
77
|
```
|
|
76
78
|
~/.pi/agent/pi-repl/
|
|
77
79
|
venv/ the Python interpreter + ipykernel
|
|
78
|
-
helpers/
|
|
80
|
+
helpers/ global helpers (created empty on install; every *.py loads)
|
|
79
81
|
state/ per-session namespace snapshots
|
|
80
82
|
```
|
|
81
83
|
|
|
82
|
-
|
|
84
|
+
Project helpers live in `<project>/.pi/helpers/` instead; both tiers are scanned with the
|
|
85
|
+
project one first. No config file.
|
|
83
86
|
|
|
84
87
|
Changing a helper (adding/removing a file, renaming one with a `_` prefix) needs a
|
|
85
88
|
**session restart / `/reload`**: the prompt list is built when `execute` is registered and
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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
|
|
102
|
-
|
|
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
|
|
107
|
-
`~/.pi/agent/pi-repl/helpers
|
|
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
|
|
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
|
|
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 |
|
|
@@ -175,11 +178,13 @@ watchdog timeout.
|
|
|
175
178
|
state/ per-session namespace snapshots
|
|
176
179
|
```
|
|
177
180
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
181
|
+
Helpers merge project and global dirs: `resolveHelperDirs` walks from the working
|
|
182
|
+
directory up to the git root collecting `.pi/helpers/`, then appends
|
|
183
|
+
`~/.pi/agent/pi-repl/helpers`. Both the prompt loader and the kernel's `readHelperSources`
|
|
184
|
+
walk the same ordered list with first-seen-wins, so a project helper shadows the same-named
|
|
185
|
+
global one and both sides are guaranteed to agree. The venv is built automatically, and the
|
|
186
|
+
interpreter follows the order above. No setting is needed. The per-cell silence watchdog is
|
|
187
|
+
off by default (`PI_REPL_TIMEOUT_MS=0`: a silent but working cell may run on).
|
|
183
188
|
|
|
184
189
|
## Reference documentation
|
|
185
190
|
|
package/docs/helpers.md
CHANGED
|
@@ -9,13 +9,26 @@ name or any other public name in the file.
|
|
|
9
9
|
|
|
10
10
|
## Where helpers live
|
|
11
11
|
|
|
12
|
+
Helpers come from two places: a **project** directory and a **global** directory.
|
|
13
|
+
|
|
12
14
|
```text
|
|
13
|
-
|
|
15
|
+
<project>/.pi/helpers/ project helpers (looked up from the working dir)
|
|
16
|
+
~/.pi/agent/pi-repl/helpers/ global helpers (every project)
|
|
14
17
|
```
|
|
15
18
|
|
|
16
|
-
The directory is created empty when pi-repl is installed.
|
|
19
|
+
The global directory is created empty when pi-repl is installed. In a project, any
|
|
20
|
+
`.pi/helpers/` directory is picked up by walking up from the working directory to the
|
|
21
|
+
git repo root, so a helper works no matter how deep in the project you are.
|
|
22
|
+
|
|
23
|
+
Every `.py` file found is loaded when the evaluator starts; files whose names begin with
|
|
24
|
+
`_` are ignored.
|
|
25
|
+
|
|
26
|
+
The two tiers merge: a project helper **shadows** a same-named global helper, and global
|
|
27
|
+
helpers fill in whatever the project does not define. One file name appears once in the
|
|
28
|
+
tool prompt and once in the kernel.
|
|
17
29
|
|
|
18
|
-
After adding, changing, renaming, or disabling a helper, run `/reload` or start a new
|
|
30
|
+
After adding, changing, renaming, or disabling a helper, run `/reload` or start a new
|
|
31
|
+
`pi --repl` session. The running evaluator does not watch the directories for changes.
|
|
19
32
|
|
|
20
33
|
## A small function helper
|
|
21
34
|
|
|
@@ -109,7 +122,7 @@ Use docstrings for argument details, defaults, return values, errors, environmen
|
|
|
109
122
|
|
|
110
123
|
## How loading works
|
|
111
124
|
|
|
112
|
-
At startup, two parts of pi-repl read the same helper
|
|
125
|
+
At startup, two parts of pi-repl read the same merged helper list (project dirs first, global last):
|
|
113
126
|
|
|
114
127
|
1. The kernel executes each eligible `.py` file. Its definitions become names in the Python workspace.
|
|
115
128
|
2. The host reads `helper_description` to build the helper guidance shown to the model.
|
|
@@ -196,7 +209,7 @@ The loader skips it. Rename it back and reload when you want it again.
|
|
|
196
209
|
|
|
197
210
|
## Checklist
|
|
198
211
|
|
|
199
|
-
- [ ] The file is in `~/.pi/agent/pi-repl/helpers
|
|
212
|
+
- [ ] The file is in `~/.pi/agent/pi-repl/helpers/` (global) or in `<project>/.pi/helpers/` (project-scoped).
|
|
200
213
|
- [ ] Its public names and call shapes are clear.
|
|
201
214
|
- [ ] `helper_description` is short enough for every-turn context.
|
|
202
215
|
- [ ] Detailed behavior is in docstrings.
|
package/index.ts
CHANGED
|
@@ -125,16 +125,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
125
125
|
label: "execute",
|
|
126
126
|
description: EXECUTE_DESCRIPTION,
|
|
127
127
|
promptSnippet: EXECUTE_PROMPT_SNIPPET,
|
|
128
|
-
promptGuidelines: buildExecutePromptGuidelines(),
|
|
128
|
+
promptGuidelines: buildExecutePromptGuidelines(process.cwd()),
|
|
129
129
|
parameters: executeSchema,
|
|
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.
|
|
3
|
+
"version": "0.6.9",
|
|
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": [
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// --- shared helper-dir resolution: prompt and kernel must read the same ordered list ---
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
const GLOBAL_HELPERS_DIR = join(homedir(), ".pi", "agent", "pi-repl", "helpers");
|
|
7
|
+
|
|
8
|
+
/** Ordered candidate dirs: nearest .pi/helpers up to the git root, then the global dir last. */
|
|
9
|
+
export function resolveHelperDirs(cwd?: string, globalDir?: string): string[] {
|
|
10
|
+
const dirs: string[] = [];
|
|
11
|
+
if (cwd) {
|
|
12
|
+
let cur = resolve(cwd);
|
|
13
|
+
for (;;) {
|
|
14
|
+
const d = join(cur, ".pi", "helpers");
|
|
15
|
+
if (existsSync(d)) dirs.push(d);
|
|
16
|
+
if (existsSync(join(cur, ".git"))) break;
|
|
17
|
+
const parent = dirname(cur);
|
|
18
|
+
if (parent === cur) break;
|
|
19
|
+
cur = parent;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
dirs.push(globalDir ?? GLOBAL_HELPERS_DIR);
|
|
23
|
+
return dirs;
|
|
24
|
+
}
|
package/src/engine/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
370
|
+
};
|
|
371
|
+
this.snapshotTimer = setTimeout(fire, quiet);
|
|
356
372
|
this.snapshotTimer.unref?.();
|
|
357
373
|
}
|
|
358
374
|
|
package/src/engine/kernel.ts
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
import { type ChildProcess, spawn } from "node:child_process";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
6
|
-
import {
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
+
import { resolveHelperDirs } from "./helpers-locate.js";
|
|
8
9
|
import {
|
|
9
10
|
type ConnectionFile,
|
|
10
11
|
executeRequest,
|
|
@@ -62,18 +63,22 @@ function resolveCwd(requested?: string): string {
|
|
|
62
63
|
if (requested && existsSync(requested)) return requested;
|
|
63
64
|
return process.cwd();
|
|
64
65
|
}
|
|
65
|
-
function readHelperSources(
|
|
66
|
-
// ---
|
|
67
|
-
const
|
|
68
|
-
if (!existsSync(d)) return [];
|
|
66
|
+
function readHelperSources(dirs: string[]): { name: string; source: string }[] {
|
|
67
|
+
// --- merged dirs come pre-ordered (project first, global last); first-seen name wins ---
|
|
68
|
+
const seen = new Set<string>();
|
|
69
69
|
const out: { name: string; source: string }[] = [];
|
|
70
|
-
for (const
|
|
71
|
-
if (!
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
70
|
+
for (const d of dirs) {
|
|
71
|
+
if (!existsSync(d)) continue;
|
|
72
|
+
for (const file of readdirSync(d).sort()) {
|
|
73
|
+
if (!file.endsWith(".py")) continue;
|
|
74
|
+
const name = file.slice(0, -3);
|
|
75
|
+
if (!/^[A-Za-z_]\w*$/.test(name) || name.startsWith("_")) continue;
|
|
76
|
+
if (seen.has(name)) continue;
|
|
77
|
+
seen.add(name);
|
|
78
|
+
try {
|
|
79
|
+
out.push({ name, source: readFileSync(join(d, file), "utf8") });
|
|
80
|
+
} catch {}
|
|
81
|
+
}
|
|
77
82
|
}
|
|
78
83
|
return out;
|
|
79
84
|
}
|
|
@@ -184,7 +189,9 @@ export class KernelClient {
|
|
|
184
189
|
|
|
185
190
|
private constructor(conn: ConnectionFile, opts: KernelOptions) {
|
|
186
191
|
this.session = new JupyterSession({ key: conn.key });
|
|
187
|
-
this.helperSources =
|
|
192
|
+
this.helperSources = opts.env?.PI_HELPERS_DIR
|
|
193
|
+
? readHelperSources([opts.env.PI_HELPERS_DIR])
|
|
194
|
+
: readHelperSources(resolveHelperDirs(opts.cwd, opts.env?.PI_HELPERS_GLOBAL_DIR));
|
|
188
195
|
this.timeoutMs = opts.timeoutMs ?? 0;
|
|
189
196
|
}
|
|
190
197
|
|
package/src/extension/helpers.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
/** Loads helpers from
|
|
1
|
+
/** Loads helpers from project then global dirs; `helper_description` surfaces verbatim (no signature parsing). */
|
|
2
2
|
|
|
3
3
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
+
import { resolveHelperDirs } from "../engine/helpers-locate.js";
|
|
6
7
|
|
|
7
8
|
const DEFAULT_HELPERS_DIR = join(homedir(), ".pi", "agent", "pi-repl", "helpers");
|
|
8
9
|
|
|
@@ -11,35 +12,47 @@ interface HelperEntry {
|
|
|
11
12
|
description: string; // full helper_description body, "" if absent
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
/** Extract `helper_description
|
|
15
|
+
/** Extract `helper_description` verbatim; no signature parsing. */
|
|
15
16
|
function parseDescription(source: string): string {
|
|
16
17
|
const m = source.match(/helper_description\s*=\s*("""|''')([\s\S]*?)\1/);
|
|
17
18
|
return m ? m[2].trim() : "";
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
/**
|
|
21
|
-
function loadHelperEntries(
|
|
22
|
-
const
|
|
23
|
-
if (!existsSync(d)) return [];
|
|
21
|
+
/** Merge entries from ordered dirs; first-seen name wins, so a project helper shadows the global one. */
|
|
22
|
+
function loadHelperEntries(dirs: string[]): HelperEntry[] {
|
|
23
|
+
const seen = new Set<string>();
|
|
24
24
|
const entries: HelperEntry[] = [];
|
|
25
|
-
for (const
|
|
26
|
-
if (!
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
25
|
+
for (const d of dirs) {
|
|
26
|
+
if (!existsSync(d)) continue;
|
|
27
|
+
for (const file of readdirSync(d).sort()) {
|
|
28
|
+
if (!file.endsWith(".py")) continue;
|
|
29
|
+
const name = file.slice(0, -3);
|
|
30
|
+
if (!/^[A-Za-z_]\w*$/.test(name)) continue;
|
|
31
|
+
// --- underscore-prefixed files are neither loaded nor advertised ---
|
|
32
|
+
if (name.startsWith("_")) continue;
|
|
33
|
+
if (seen.has(name)) continue;
|
|
34
|
+
seen.add(name);
|
|
35
|
+
try {
|
|
36
|
+
const source = readFileSync(join(d, file), "utf8");
|
|
37
|
+
entries.push({ name, description: parseDescription(source) });
|
|
38
|
+
} catch {}
|
|
39
|
+
}
|
|
35
40
|
}
|
|
36
41
|
return entries;
|
|
37
42
|
}
|
|
38
43
|
|
|
39
|
-
/** The prompt-facing list
|
|
44
|
+
/** The prompt-facing list for ONE dir: verbatim description, or an introspection pointer. */
|
|
40
45
|
export function buildHelpersMap(dir?: string): string[] {
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
return loadHelperEntries([dir ?? DEFAULT_HELPERS_DIR]).map((t) =>
|
|
47
|
+
t.description
|
|
48
|
+
? t.description.replace(/\n/g, "\n ")
|
|
49
|
+
: `${t.name} (no description, inspect it with print(${t.name}.__doc__))`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The prompt-facing list at a cwd: project .pi/helpers first (up to the git root), global fallback, project shadows. */
|
|
54
|
+
export function buildHelpersMapForCwd(cwd: string, globalDir?: string): string[] {
|
|
55
|
+
return loadHelperEntries(resolveHelperDirs(cwd, globalDir)).map((t) =>
|
|
43
56
|
t.description
|
|
44
57
|
? t.description.replace(/\n/g, "\n ")
|
|
45
58
|
: `${t.name} (no description, inspect it with print(${t.name}.__doc__))`,
|
|
@@ -1,126 +1,87 @@
|
|
|
1
|
-
// --- candidates:
|
|
1
|
+
// --- candidates: a Python-flavored file detector plus generic line scoring ---
|
|
2
2
|
|
|
3
3
|
import { descriptor } from "./descriptor.js";
|
|
4
|
-
import {
|
|
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
|
-
|
|
6
|
+
// --- Python file effects: verb + literal path, in the idioms this evaluator runs ---
|
|
9
7
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
55
|
+
export function pythonFileCandidates(source: string): Candidate[] {
|
|
103
56
|
const candidates: Candidate[] = [];
|
|
104
|
-
for (const match of source.matchAll(
|
|
105
|
-
const spec =
|
|
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
|
|
108
|
-
const
|
|
109
|
-
|
|
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 = /^(
|
|
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(
|
|
109
|
+
export function genericCandidates(source: string): Candidate[] {
|
|
149
110
|
const candidates: Candidate[] = [];
|
|
150
|
-
for (const [index, rawLine] of
|
|
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({
|
|
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 {
|
|
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
|
|
8
|
+
export { descriptor };
|
|
11
9
|
|
|
12
10
|
export function previewCell(code: string): CellPreview {
|
|
13
11
|
const source = code.trimEnd();
|
|
14
|
-
if (!source) return {
|
|
15
|
-
|
|
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
|
-
|
|
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 ?? {
|
|
20
|
+
return best ?? { text: "" };
|
|
31
21
|
}
|
|
@@ -1,23 +1,10 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
7
|
+
/** The one-line semantic summary a collapsed cell shows. */
|
|
8
|
+
export interface CellPreview {
|
|
9
|
+
text: string;
|
|
10
|
+
}
|
package/src/extension/prompt.ts
CHANGED
|
@@ -12,12 +12,19 @@ export const executePromptSnippet = "Execute Python in a persistent shell (read,
|
|
|
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
|
-
"
|
|
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
|
+
"Find, filter, fetch, sample: narrow the output in Python, then print only the exact slice you need.",
|
|
18
16
|
"Make surgical, precise changes over rewrites or whole-file dumps: a small unique anchor, replace, verify, read the file back before trusting it.",
|
|
19
|
-
"
|
|
20
|
-
|
|
17
|
+
"Reference what the persistent shell already holds, don't redefine it.",
|
|
18
|
+
"Write modern idiomatic Python.",
|
|
19
|
+
"If output begins with <repl_engine_reset>, the kernel rebuilt; re-verify a revived variable.",
|
|
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
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
function
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
351
|
-
if (
|
|
352
|
-
|
|
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. */
|
package/src/extension/render.ts
CHANGED
|
@@ -30,21 +30,13 @@ function makeDeps(theme: Theme): RenderDeps {
|
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 =
|
|
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,15 +1,15 @@
|
|
|
1
1
|
// --- tool-meta: thin surface assembling the execute tool's prompt from pure modules ---
|
|
2
2
|
// --- the model contract lives in prompt.ts; only the helpers wiring stays here ---
|
|
3
3
|
|
|
4
|
-
import { buildHelpersMap } from "./helpers.js";
|
|
4
|
+
import { buildHelpersMap, buildHelpersMapForCwd } from "./helpers.js";
|
|
5
5
|
import { buildPromptGuidelines, executePromptSnippet, executeToolDescription } from "./prompt.js";
|
|
6
6
|
|
|
7
7
|
export const EXECUTE_DESCRIPTION = executeToolDescription;
|
|
8
8
|
export const EXECUTE_PROMPT_SNIPPET = executePromptSnippet;
|
|
9
9
|
|
|
10
|
-
// --- build the guidelines from
|
|
11
|
-
export function buildExecutePromptGuidelines(): string[] {
|
|
12
|
-
const map = buildHelpersMap();
|
|
10
|
+
// --- build the guidelines from project + global helper dirs ---
|
|
11
|
+
export function buildExecutePromptGuidelines(cwd?: string): string[] {
|
|
12
|
+
const map = cwd ? buildHelpersMapForCwd(cwd) : buildHelpersMap();
|
|
13
13
|
const preloaded = map.length > 0 ? map : [];
|
|
14
14
|
return buildPromptGuidelines(preloaded);
|
|
15
15
|
}
|
|
@@ -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
|
-
}
|