pi-for-each 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jejay
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,122 @@
1
+ # pi-for
2
+
3
+ A [pi](https://github.com/earendil-works/pi) extension that adds a `/for`
4
+ prompt-loop command with variable insertion over directory children or file
5
+ lines.
6
+
7
+ ## Why
8
+
9
+ Like subagents but much simpler, sequential and with more control for the user. Instead of describing the loop to the agent, just make a loop. No need to tell the agent about your control structure if you already know the control structure.
10
+
11
+ ## Demo
12
+
13
+ ![](https://raw.githubusercontent.com/jejay/pi-for-each/main/demo-imgs/pi-for-demo-1.png)
14
+ ![](https://raw.githubusercontent.com/jejay/pi-for-each/main/demo-imgs/pi-for-demo-2.png)
15
+ ![](https://raw.githubusercontent.com/jejay/pi-for-each/main/demo-imgs/pi-for-demo-3.png)
16
+ ![](https://raw.githubusercontent.com/jejay/pi-for-each/main/demo-imgs/pi-for-demo-4.png)
17
+ ![](https://raw.githubusercontent.com/jejay/pi-for-each/main/demo-imgs/pi-for-demo-5.png)
18
+ ![](https://raw.githubusercontent.com/jejay/pi-for-each/main/demo-imgs/pi-for-demo-6.png)
19
+ ![](https://raw.githubusercontent.com/jejay/pi-for-each/main/demo-imgs/pi-for-demo-7.png)
20
+ ![](https://raw.githubusercontent.com/jejay/pi-for-each/main/demo-imgs/pi-for-demo-8.png)
21
+ ![](https://raw.githubusercontent.com/jejay/pi-for-each/main/demo-imgs/pi-for-demo-9.png)
22
+
23
+ ## What it does
24
+
25
+ Compose a message that contains a `$each@<path>` token (dollar + `each` + at-sign
26
+ + a file or directory path), then invoke it as the **`/for` command**:
27
+
28
+ ```
29
+ /for Please reword the skill in $each@./skills/ and make it more polite
30
+ ```
31
+
32
+ While composing the message, typing `$each@` opens pi's fuzzy file/directory
33
+ search (the same one `@` after a space opens) so you can pick a path. The token
34
+ becomes `$each@<file-or-dir>`.
35
+
36
+ When the `/for` command runs, it executes a **prompt loop**:
37
+
38
+ - **Directory** — if the path points to a directory, the loop iterates over all
39
+ of its child elements (files and subdirectories, excluding dotfiles). Each
40
+ iteration replaces the full `$each@<dir>` token with `<dir>/<child>` (directories
41
+ keep a trailing slash), e.g. `$each@./skills/` → `./skills/karate/`.
42
+ - **File (lines)** — if the path points to a file, the loop iterates over every
43
+ line of the file. Each iteration replaces the full `$each@<file>` token with the
44
+ corresponding line.
45
+
46
+ Iterations run strictly sequentially (no parallelism). The first iteration is
47
+ sent as a normal message into the *current* (origin) session. Every following
48
+ iteration **forks** the session into a *new session file* so the previous
49
+ iteration's message is replaced while the earlier conversation context is
50
+ preserved, then sends the next replacement.
51
+
52
+ While the loop runs, a hint is shown in the same region the UI normally uses for
53
+ queued messages, e.g.:
54
+
55
+ ```
56
+ for-loop · 2/5 · directory
57
+ ↳ ./skills/baking
58
+ ```
59
+
60
+ ## Example
61
+
62
+ Given two subdirectories `karate` and `baking` inside `./skills/`:
63
+
64
+ ```
65
+ User: Have a look at the readme
66
+ Pi: (acknowledges)
67
+ User: /for Please reword the skill in $each@./skills/ and make it more polite
68
+ ```
69
+
70
+ This expands to:
71
+
72
+ ```
73
+ User: Please reword the skill in ./skills/karate/ and make it more polite
74
+ ... (wait for answer) ...
75
+ fork → replace last message (new session file)
76
+ User: Please reword the skill in ./skills/baking/ and make it more polite
77
+ ```
78
+
79
+ The origin session keeps the first iteration; each subsequent iteration lives in
80
+ its own forked session file, so the session list shows one session per
81
+ iteration.
82
+
83
+ ## Design notes
84
+
85
+ - This is driven by a real registered command, **`/for`**. The fork semantic
86
+ requires `ctx.fork()`, which is only available on `ExtensionCommandContext`
87
+ (the context passed to command handlers) — `ExtensionContext` (used by the
88
+ `input`/`session_start` handlers) does not expose it. So the loop runs inside
89
+ the `/for` command handler, where `cmdCtx.fork()` is available.
90
+ - The submitted message must start with `/for` so that pi's command pipeline
91
+ routes it to the handler. A bare `$each@<path>` message submitted as a normal
92
+ message is not a command and is intercepted with a hint to use `/for`.
93
+ - **Why the old approach failed:** `ExtensionAPI.sendUserMessage()` internally
94
+ calls `prompt(text, { expandPromptTemplates: false, … })`, and pi only runs
95
+ slash/extension commands when `expandPromptTemplates` is true. So sending
96
+ `"/clone"` via `sendUserMessage` never executed the command — it was just
97
+ appended as a literal user message into the *same* session, which is why every
98
+ iteration chained linearly into one session. The fix uses the real
99
+ `ctx.fork()` instead.
100
+ - The `$each@` fuzzy search reuses pi's built-in fuzzy file/directory provider
101
+ (`CombinedAutocompleteProvider.getFuzzyFileSuggestions`) via a wrapping
102
+ `AutocompleteProvider`, with a `readdir` fallback. The editor is extended so
103
+ that typing `$each@` opens that search exactly as `@` after a space does.
104
+ - The full token — starting at the dollar sign and including `for`, `@` and the
105
+ path, up to (but not including) the first following whitespace — is replaced by
106
+ the iteration value. Not just the path.
107
+
108
+ ## Install
109
+
110
+ ```bash
111
+ pi install npm:pi-for-each
112
+ ```
113
+
114
+ To try it without installing (from a clone):
115
+
116
+ ```bash
117
+ pi -e ./index.ts
118
+ ```
119
+
120
+ ## License
121
+
122
+ MIT
package/index.ts ADDED
@@ -0,0 +1,615 @@
1
+ /**
2
+ * pi-for — a pi extension that adds a `/for` prompt-loop command with variable
3
+ * insertion.
4
+ *
5
+ * ---------------------------------------------------------------------------
6
+ * What it does
7
+ * ---------------------------------------------------------------------------
8
+ * Write a message that contains `$each@<path>` (dollar + "each" + at-sign +
9
+ * a file or directory path), then invoke it as the `/for` command, e.g.:
10
+ *
11
+ * /for Please reword the skill in $each@./skills/ and make it more polite
12
+ *
13
+ * While composing the message, typing `$each@` opens pi's fuzzy file/directory
14
+ * search (the same one `@` after a space opens) so you can pick a path. The
15
+ * in-editor command becomes `$each@<file-or-dir>`.
16
+ *
17
+ * When the `/for` command runs, it executes a *prompt loop*:
18
+ *
19
+ * - If the path points to a DIRECTORY, the loop iterates over every child
20
+ * element (files and subdirectories, excluding dotfiles). Each iteration
21
+ * replaces the full `$each@<dir>` token with `<dir>/<child>` (directories
22
+ * keep a trailing slash).
23
+ * - If the path points to a FILE, the loop iterates over every line of the
24
+ * file. Each iteration replaces the full `$each@<file>` token with the
25
+ * corresponding line.
26
+ *
27
+ * Iterations are strictly sequential (no parallelism). The first iteration is
28
+ * sent as a normal message into the *current* (origin) session. Every following
29
+ * iteration **forks** the session from the message that preceded the loop, so
30
+ * each iteration lives in its own session file (the fork semantic: the origin
31
+ * survives with the first message, and every following iteration keeps the same
32
+ * base context while the previous iteration's session is replaced). While the
33
+ * loop runs, a hint is shown in the same region the UI normally uses for queued
34
+ * messages.
35
+ *
36
+ * ---------------------------------------------------------------------------
37
+ * Implementation notes
38
+ * ---------------------------------------------------------------------------
39
+ * - This is driven by a real registered command (`/for`). The fork is a hard
40
+ * requirement, and `ctx.fork()` is only available on `ExtensionCommandContext`
41
+ * (the context passed to command handlers) — `ExtensionContext` (used by the
42
+ * `input`/`session_start` handlers) does not expose it. So the loop runs
43
+ * inside the `/for` command handler, where `cmdCtx.fork()` is available.
44
+ * - `ExtensionAPI.sendUserMessage()` intentionally skips command handling
45
+ * (`expandPromptTemplates: false`), so the old approach of
46
+ * `sendUserMessage("/clone")` could never fork — `/clone` was just appended
47
+ * as a literal message into the same session. That is why the loop previously
48
+ * chained every iteration into one session. We now use the real `ctx.fork()`.
49
+ * - The `$each@` fuzzy search reuses pi's built-in fuzzy file/directory provider
50
+ * (`CombinedAutocompleteProvider.getFuzzyFileSuggestions`) via a wrapping
51
+ * `AutocompleteProvider`, plus a `readdir` fallback. The editor is extended so
52
+ * that typing `$each@` opens that search exactly as `@` after a space does.
53
+ */
54
+
55
+ import type {
56
+ ExtensionAPI,
57
+ ExtensionCommandContext,
58
+ InputEvent,
59
+ InputEventResult,
60
+ } from "@earendil-works/pi-coding-agent";
61
+ import { CustomEditor } from "@earendil-works/pi-coding-agent";
62
+ import type {
63
+ AutocompleteItem,
64
+ AutocompleteProvider,
65
+ AutocompleteSuggestions,
66
+ EditorComponent,
67
+ EditorTheme,
68
+ KeybindingsManager,
69
+ TUI,
70
+ } from "@earendil-works/pi-tui";
71
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
72
+ import { basename, dirname, join, resolve } from "node:path";
73
+
74
+ // `$each@` followed by a (possibly empty) token of non-`@`, non-space characters.
75
+ // Used to detect the trigger while typing / at the cursor and to open the search.
76
+ const FOR_CONTEXT_RE = /\$each@([^@\s]*)$/;
77
+ // Global variant used to find the token anywhere in a submitted message.
78
+ const FOR_TOKEN_RE = /\$each@(\S+)/;
79
+ // Bare trigger with no path (e.g. `$each@ ` or a message ending in `$each@`).
80
+ const FOR_BARE_RE = /\$each@(?=\s|$)/;
81
+
82
+ const WIDGET_KEY = "pi-for";
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Module-level state
86
+ // ---------------------------------------------------------------------------
87
+
88
+ let wordCwd = process.cwd();
89
+ let loopRunning = false;
90
+ /** Latest session context. Updated on every session_start so the loop can act
91
+ * on the *current* session after a fork switches the active runtime. */
92
+ let currentCtx: ExtensionCommandContext | null = null;
93
+ /** Resolvers for pending "wait until the agent has settled" promises. */
94
+ let settleWaiters: Array<() => void> = [];
95
+ /** Resolvers for pending session_start events, keyed by reason. */
96
+ let sessionStartWaiters: Array<(reason: string) => void> = [];
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Helpers
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /** Truncate a string for compact hint rendering. */
103
+ function truncate(s: string, max = 100): string {
104
+ const flat = s.replace(/\s+/g, " ").trim();
105
+ return flat.length > max ? flat.slice(0, max - 1) + "…" : flat;
106
+ }
107
+
108
+ /** Build the replacement value for a directory child. */
109
+ function childReplacement(
110
+ tokenPath: string,
111
+ childName: string,
112
+ isDir: boolean,
113
+ ): string {
114
+ let joined: string;
115
+ if (tokenPath === "" || tokenPath === ".") {
116
+ joined = childName;
117
+ } else if (tokenPath.endsWith("/")) {
118
+ joined = tokenPath + childName;
119
+ } else {
120
+ joined = tokenPath + "/" + childName;
121
+ }
122
+ // Directories keep a trailing slash so the resulting path is unambiguous.
123
+ return isDir ? (joined.endsWith("/") ? joined : joined + "/") : joined;
124
+ }
125
+
126
+ /** Resolve the loop kind and the ordered list of replacement values. */
127
+ function buildReplacements(
128
+ tokenPath: string,
129
+ cwd: string,
130
+ ): { kind: "directory" | "line"; kindLabel: string; replacements: string[] } {
131
+ const abs = resolve(cwd, tokenPath);
132
+ if (!existsSync(abs)) throw new Error(`${tokenPath} does not exist`);
133
+ const st = statSync(abs);
134
+ if (st.isDirectory()) {
135
+ const children = readdirSync(abs, { withFileTypes: true }).filter(
136
+ (e) => e.name !== "." && e.name !== ".." && !e.name.startsWith("."),
137
+ );
138
+ const replacements = children.map((e) => {
139
+ let isDir = e.isDirectory();
140
+ if (!isDir && e.isSymbolicLink()) {
141
+ try {
142
+ isDir = statSync(join(abs, e.name)).isDirectory();
143
+ } catch {
144
+ /* broken symlink — treat as file */
145
+ }
146
+ }
147
+ return childReplacement(tokenPath, e.name, isDir);
148
+ });
149
+ return { kind: "directory", kindLabel: "directory", replacements };
150
+ }
151
+ if (st.isFile()) {
152
+ const content = readFileSync(abs, "utf8");
153
+ const lines = content.split("\n");
154
+ // Drop a trailing empty line left by a final newline.
155
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
156
+ const replacements = lines.map((l) => l.replace(/\r$/, ""));
157
+ return { kind: "line", kindLabel: "line", replacements };
158
+ }
159
+ throw new Error(`${tokenPath} is neither a file nor a directory`);
160
+ }
161
+
162
+ /** Resolve on the next `agent_settled` event. */
163
+ function waitForSettle(): Promise<void> {
164
+ return new Promise((resolve) => {
165
+ settleWaiters.push(resolve);
166
+ });
167
+ }
168
+
169
+ /**
170
+ * Resolve on the next `session_start` whose reason matches. Used to detect when
171
+ * a fork has switched the active session to the new file.
172
+ * A timeout safety net prevents the loop from hanging if the switch never fires.
173
+ */
174
+ function waitForSessionStart(reason: string, timeoutMs = 30000): Promise<void> {
175
+ return new Promise((resolve, reject) => {
176
+ let done = false;
177
+ const finish = (r: string) => {
178
+ if (done || r !== reason) return;
179
+ done = true;
180
+ cleanup();
181
+ resolve();
182
+ };
183
+ const timer = setTimeout(() => {
184
+ if (done) return;
185
+ done = true;
186
+ cleanup();
187
+ reject(new Error("session switch did not complete in time"));
188
+ }, timeoutMs);
189
+ const cleanup = () => clearTimeout(timer);
190
+ sessionStartWaiters.push(finish);
191
+ });
192
+ }
193
+
194
+ /** Current UI context, preferring the live (post-fork) session context. */
195
+ function ui() {
196
+ return currentCtx?.ui;
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // The for-loop, driven from the `/for` command handler (has ctx.fork()).
201
+ // ---------------------------------------------------------------------------
202
+
203
+ async function runLoop(
204
+ pi: ExtensionAPI,
205
+ args: string,
206
+ cmdCtx: ExtensionCommandContext,
207
+ ): Promise<void> {
208
+ if (loopRunning) {
209
+ cmdCtx.ui.notify("pi-for: a for-loop is already running", "warning");
210
+ return;
211
+ }
212
+ loopRunning = true;
213
+
214
+ const showHint = (i: number, total: number, kindLabel: string, value: string) => {
215
+ if (!ui()) return;
216
+ ui()!.setWidget(WIDGET_KEY, [
217
+ `for-loop · ${i + 1}/${total} · ${kindLabel}`,
218
+ `↳ ${truncate(value)}`,
219
+ ]);
220
+ };
221
+ const clearHint = () => {
222
+ if (!ui()) return;
223
+ try {
224
+ ui()!.setWidget(WIDGET_KEY, undefined);
225
+ } catch {
226
+ /* ignore */
227
+ }
228
+ };
229
+
230
+ try {
231
+ const m = args.match(FOR_TOKEN_RE);
232
+ if (!m) {
233
+ cmdCtx.ui.notify(
234
+ "pi-for: no $each@<path> token found. Example: /for Review $each@./src/",
235
+ "error",
236
+ );
237
+ return;
238
+ }
239
+ const tokenPath = m[1];
240
+
241
+ let parsed: ReturnType<typeof buildReplacements>;
242
+ try {
243
+ parsed = buildReplacements(tokenPath, cmdCtx.cwd);
244
+ } catch (err) {
245
+ cmdCtx.ui.notify(
246
+ `pi-for: ${err instanceof Error ? err.message : String(err)}`,
247
+ "error",
248
+ );
249
+ return;
250
+ }
251
+
252
+ const { kindLabel, replacements } = parsed;
253
+ const total = replacements.length;
254
+ if (total === 0) {
255
+ cmdCtx.ui.notify(`pi-for: no iterations found for ${tokenPath}`, "warning");
256
+ return;
257
+ }
258
+
259
+ // The leaf of the conversation that existed *before* the loop started. Every
260
+ // subsequent iteration forks from this point, so each forked session keeps
261
+ // only this base context plus a single iteration. Capture it now, before we
262
+ // send iteration 0 (which would otherwise advance the leaf past it).
263
+ const preLoopLeafId = cmdCtx.sessionManager.getLeafId();
264
+
265
+ // Replace the *full* `$each@<path>` token (not just the path) with the value.
266
+ const replaceToken = (replacement: string) =>
267
+ args.replace(FOR_TOKEN_RE, replacement);
268
+
269
+ // Send a message into the *current* active session and wait for the agent
270
+ // to fully settle. Used for iteration 0 (sent into the origin session).
271
+ const sendAndWait = async (value: string) => {
272
+ const wait = waitForSettle();
273
+ pi.sendUserMessage(replaceToken(value));
274
+ await wait;
275
+ };
276
+
277
+ // Iterations 1..N-1 each fork into a *new session file*. Because forking
278
+ // disposes the previous session and invalidates the captured command ctx,
279
+ // we must NOT reuse `cmdCtx` across forks. Instead we fork from the live
280
+ // `ctx` passed in, and do all post-fork work inside the `withSession`
281
+ // callback, which receives a *fresh* `ReplacedSessionContext` (it even has
282
+ // its own `sendUserMessage`/`fork`). We then recurse using that fresh ctx
283
+ // for the next iteration — matching pi's documented fork pattern.
284
+ const forkChain = async (i: number, ctx: ExtensionCommandContext) => {
285
+ if (i >= total) return;
286
+
287
+ const swapped = waitForSessionStart("fork");
288
+ await ctx.fork(preLoopLeafId, {
289
+ position: "at",
290
+ withSession: async (ctx2) => {
291
+ // We are now inside the freshly forked session (its session_start has
292
+ // already fired and updated `currentCtx`). Set the hint HERE, on the
293
+ // new session's UI — not before the fork, or it would be written to
294
+ // the session that is about to be torn down and never shown.
295
+ showHint(i, total, kindLabel, replacements[i]);
296
+
297
+ // ctx2 is a fresh ReplacedSessionContext bound to the newly forked
298
+ // session (its type is inferred from fork()'s withSession signature).
299
+ const wait = waitForSettle();
300
+ ctx2.sendUserMessage(replaceToken(replacements[i]));
301
+ await wait;
302
+ // Continue the chain with the fresh context for the next iteration.
303
+ await forkChain(i + 1, ctx2);
304
+ },
305
+ });
306
+ // Wait until the runtime has actually switched to the new session file.
307
+ await swapped;
308
+ };
309
+
310
+ // Iteration 0 — sent into the current (origin) session, no fork. The origin
311
+ // thereby survives with this first message (fork semantic).
312
+ showHint(0, total, kindLabel, replacements[0]);
313
+ await sendAndWait(replacements[0]);
314
+
315
+ // No base message to fork from (empty session), or fork is unavailable in
316
+ // this mode: degrade to plain sequential sends rather than throwing inside
317
+ // the fork call.
318
+ if (!preLoopLeafId || typeof cmdCtx.fork !== "function") {
319
+ if (!preLoopLeafId) {
320
+ cmdCtx.ui.notify(
321
+ "pi-for: no base message to fork from; sending remaining iterations sequentially",
322
+ "warning",
323
+ );
324
+ } else {
325
+ cmdCtx.ui.notify(
326
+ "pi-for: fork is unavailable in this mode; sending remaining iterations sequentially",
327
+ "warning",
328
+ );
329
+ }
330
+ for (let i = 1; i < total; i++) {
331
+ showHint(i, total, kindLabel, replacements[i]);
332
+ await sendAndWait(replacements[i]);
333
+ }
334
+ return;
335
+ }
336
+
337
+ // Fork iterations 1..N-1, each into its own session file.
338
+ await forkChain(1, cmdCtx);
339
+ } catch (err) {
340
+ cmdCtx.ui.notify(
341
+ `pi-for: loop error: ${err instanceof Error ? err.message : String(err)}`,
342
+ "error",
343
+ );
344
+ } finally {
345
+ loopRunning = false;
346
+ clearHint();
347
+ }
348
+ }
349
+
350
+ // ---------------------------------------------------------------------------
351
+ // Autocomplete provider: makes `$each@` open pi's fuzzy file/directory search.
352
+ // ---------------------------------------------------------------------------
353
+
354
+ class ForAutocompleteProvider implements AutocompleteProvider {
355
+ triggerCharacters = ["@", "#"];
356
+ private readonly current: AutocompleteProvider;
357
+ private readonly getCwd: () => string;
358
+
359
+ constructor(current: AutocompleteProvider, getCwd: () => string) {
360
+ this.current = current;
361
+ this.getCwd = getCwd;
362
+ }
363
+
364
+ shouldTriggerFileCompletion(
365
+ lines: string[],
366
+ cursorLine: number,
367
+ cursorCol: number,
368
+ ): boolean {
369
+ const before = (lines[cursorLine] ?? "").slice(0, cursorCol);
370
+ if (FOR_CONTEXT_RE.test(before)) return true;
371
+ return this.current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
372
+ }
373
+
374
+ async getSuggestions(
375
+ lines: string[],
376
+ cursorLine: number,
377
+ cursorCol: number,
378
+ options: { signal: AbortSignal; force?: boolean },
379
+ ): Promise<AutocompleteSuggestions | null> {
380
+ const before = (lines[cursorLine] ?? "").slice(0, cursorCol);
381
+ const m = before.match(FOR_CONTEXT_RE);
382
+ if (!m) {
383
+ return this.current.getSuggestions(lines, cursorLine, cursorCol, options);
384
+ }
385
+
386
+ const partial = m[1];
387
+ const cwd = this.getCwd();
388
+ let items = await this.fuzzy(partial, options);
389
+ if (items.length === 0) items = this.fallback(partial, cwd);
390
+
391
+ // Strip the leading "@" pi's fuzzy provider adds so we can re-prefix with
392
+ // `$each@` in applyCompletion.
393
+ const transformed: AutocompleteItem[] = items.map((it) => ({
394
+ value: it.value && it.value.startsWith("@") ? it.value.slice(1) : it.value,
395
+ label: it.label,
396
+ description: it.description,
397
+ }));
398
+
399
+ return { items: transformed, prefix: "$each@" + partial };
400
+ }
401
+
402
+ applyCompletion(
403
+ lines: string[],
404
+ cursorLine: number,
405
+ cursorCol: number,
406
+ item: AutocompleteItem,
407
+ prefix: string,
408
+ ): { lines: string[]; cursorLine: number; cursorCol: number } {
409
+ if (prefix.startsWith("$each@")) {
410
+ const currentLine = lines[cursorLine] ?? "";
411
+ const beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
412
+ const afterCursor = currentLine.slice(cursorCol);
413
+ // Don't add a space after directories so the user can keep autocompleting.
414
+ const isDir = item.label.endsWith("/");
415
+ const suffix = isDir ? "" : " ";
416
+ const newLine = beforePrefix + "$each@" + item.value + suffix + afterCursor;
417
+ const newLines = lines.slice();
418
+ newLines[cursorLine] = newLine;
419
+ return {
420
+ lines: newLines,
421
+ cursorLine,
422
+ cursorCol: beforePrefix.length + "$each@".length + item.value.length + suffix.length,
423
+ };
424
+ }
425
+ return this.current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
426
+ }
427
+
428
+ /** Delegate to pi's real fuzzy file/directory search when available. */
429
+ private async fuzzy(
430
+ partial: string,
431
+ options: { signal: AbortSignal; force?: boolean },
432
+ ): Promise<AutocompleteItem[]> {
433
+ const fn = (this.current as unknown as {
434
+ getFuzzyFileSuggestions?: (
435
+ query: string,
436
+ opts: { signal: AbortSignal; isQuotedPrefix?: boolean },
437
+ ) => Promise<AutocompleteItem[] | null>;
438
+ }).getFuzzyFileSuggestions;
439
+ if (typeof fn !== "function") return [];
440
+ try {
441
+ const res = await fn.call(this.current, partial, { signal: options.signal });
442
+ return res ?? [];
443
+ } catch {
444
+ return [];
445
+ }
446
+ }
447
+
448
+ /** Synchronous directory listing fallback when no fuzzy finder is present. */
449
+ private fallback(partial: string, cwd: string): AutocompleteItem[] {
450
+ let searchDir: string;
451
+ let searchPrefix: string;
452
+ if (
453
+ partial === "" ||
454
+ partial === "./" ||
455
+ partial === "../" ||
456
+ partial === "~" ||
457
+ partial === "~/" ||
458
+ partial === "/"
459
+ ) {
460
+ searchDir = partial === "" ? cwd : partial.startsWith("/") ? partial : resolve(cwd, partial);
461
+ searchPrefix = "";
462
+ } else if (partial.endsWith("/")) {
463
+ searchDir = partial.startsWith("/") ? partial : resolve(cwd, partial);
464
+ searchPrefix = "";
465
+ } else {
466
+ const d = partial.startsWith("/") ? dirname(partial) : resolve(cwd, dirname(partial));
467
+ searchDir = d;
468
+ searchPrefix = basename(partial);
469
+ }
470
+
471
+ let entries: ReturnType<typeof readdirSync> | undefined;
472
+ try {
473
+ entries = readdirSync(searchDir, { withFileTypes: true });
474
+ } catch {
475
+ return [];
476
+ }
477
+
478
+ const items: AutocompleteItem[] = [];
479
+ for (const e of entries) {
480
+ if (e.name === "." || e.name === "..") continue;
481
+ if (e.name.startsWith(".")) continue;
482
+ if (searchPrefix && !e.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) continue;
483
+ let isDir = e.isDirectory();
484
+ if (!isDir && e.isSymbolicLink()) {
485
+ try {
486
+ isDir = statSync(join(searchDir, e.name)).isDirectory();
487
+ } catch {
488
+ /* ignore */
489
+ }
490
+ }
491
+ const value = isDir ? e.name + "/" : e.name;
492
+ items.push({
493
+ value,
494
+ label: isDir ? e.name + "/" : e.name,
495
+ description: isDir ? "directory" : "file",
496
+ });
497
+ }
498
+ items.sort((a, b) => {
499
+ const ad = a.value.endsWith("/") ? 0 : 1;
500
+ const bd = b.value.endsWith("/") ? 0 : 1;
501
+ if (ad !== bd) return ad - bd;
502
+ return a.label.localeCompare(b.label);
503
+ });
504
+ return items;
505
+ }
506
+ }
507
+
508
+ // ---------------------------------------------------------------------------
509
+ // Editor: extends the default editor so that typing `$each@` opens the search.
510
+ // ---------------------------------------------------------------------------
511
+
512
+ class ForEditor extends CustomEditor {
513
+ handleInput(data: string): void {
514
+ // Let the default editor handle everything (typing, native `@` after space,
515
+ // autocomplete continuation, etc.).
516
+ super.handleInput(data);
517
+
518
+ try {
519
+ const { line, col } = this.getCursor();
520
+ const lines = this.getLines();
521
+ const before = (lines[line] ?? "").slice(0, col);
522
+ if (FOR_CONTEXT_RE.test(before) && !this.isShowingAutocomplete()) {
523
+ // The built-in trigger only fires when `@` follows a space/tab, so we
524
+ // explicitly open the autocomplete for the `$each@` context. Once open,
525
+ // the editor keeps it updated as the user keeps typing.
526
+ const trigger = (
527
+ this as unknown as { tryTriggerAutocomplete?: () => void }
528
+ ).tryTriggerAutocomplete;
529
+ if (typeof trigger === "function") trigger.call(this);
530
+ }
531
+ } catch {
532
+ // Never let autocomplete wiring break normal editing.
533
+ }
534
+ }
535
+ }
536
+
537
+ // ---------------------------------------------------------------------------
538
+ // Extension entry point
539
+ // ---------------------------------------------------------------------------
540
+
541
+ export default function (pi: ExtensionAPI) {
542
+ // Register the `/for` command. Command handlers receive an
543
+ // `ExtensionCommandContext` that exposes `ctx.fork()`, which is what makes
544
+ // the per-iteration fork into separate session files possible.
545
+ pi.registerCommand("for", {
546
+ description:
547
+ "Run a prompt loop: fork a new session per iteration over a directory or file referenced by a $each@<path> token.",
548
+ handler: async (args: string, cmdCtx: ExtensionCommandContext) => {
549
+ await runLoop(pi, args, cmdCtx);
550
+ },
551
+ });
552
+
553
+ // Wrap the active autocomplete provider so `$each@` opens the file search, and
554
+ // extend the editor so typing `$each@` triggers the search. The wrapper list is
555
+ // reset on every session rebind (including forks), so re-wrapping here is safe
556
+ // and never nests.
557
+ pi.on("session_start", (_event, ctx) => {
558
+ wordCwd = ctx.cwd;
559
+ currentCtx = ctx as ExtensionCommandContext;
560
+ ctx.ui.addAutocompleteProvider(
561
+ (current) => new ForAutocompleteProvider(current, () => wordCwd),
562
+ );
563
+ ctx.ui.setEditorComponent(
564
+ (tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager): EditorComponent =>
565
+ new ForEditor(tui, theme, keybindings),
566
+ );
567
+ // Notify any pending loop that the session switched (e.g. after a fork).
568
+ const waiters = sessionStartWaiters;
569
+ sessionStartWaiters = [];
570
+ for (const w of waiters) w(_event.reason);
571
+ });
572
+
573
+ // Resolve any pending "wait for the agent to settle" promises.
574
+ pi.on("agent_settled", () => {
575
+ const waiters = settleWaiters;
576
+ settleWaiters = [];
577
+ for (const w of waiters) w();
578
+ });
579
+
580
+ // Drop in-flight waiters when the session ends. Note: during a fork the
581
+ // old session's `session_shutdown` fires *before* the new session's
582
+ // `session_start`, so we must NOT clear `sessionStartWaiters` here (that would
583
+ // orphan a pending fork-wait) and must NOT reset `currentCtx`/`loopRunning`
584
+ // (the loop continues in the forked session).
585
+ pi.on("session_shutdown", () => {
586
+ settleWaiters = [];
587
+ });
588
+
589
+ // Guard against the old `$each@`-as-a-plain-message syntax. A `$each@<path>`
590
+ // token only works inside the `/for` command now; if a user submits it as a
591
+ // normal message we explain the new invocation instead of silently sending an
592
+ // unresolved token to the model.
593
+ pi.on("input", (event, ctx): InputEventResult | void => {
594
+ if (event.source === "extension") return { action: "continue" };
595
+
596
+ const text = event.text;
597
+ // The `/for` command is handled by the command pipeline, not here.
598
+ if (text.startsWith("/for")) return { action: "continue" };
599
+
600
+ if (FOR_TOKEN_RE.test(text)) {
601
+ ctx.ui.notify(
602
+ "pi-for: use the /for command — e.g. /for Review $each@./src/",
603
+ "warning",
604
+ );
605
+ return { action: "handled" };
606
+ }
607
+
608
+ if (FOR_BARE_RE.test(text)) {
609
+ ctx.ui.notify("pi-for: $each@ needs a file or directory path", "warning");
610
+ return { action: "handled" };
611
+ }
612
+
613
+ return { action: "continue" };
614
+ });
615
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "pi-for-each",
3
+ "version": "0.1.0",
4
+ "description": "pi extension: a $each@ prompt-loop editor feature with variable insertion over directory children or file lines.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "prompt-loop",
10
+ "for-loop"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "jejay",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/jejay/pi-for-each.git"
17
+ },
18
+ "homepage": "https://github.com/jejay/pi-for-each#readme",
19
+ "main": "index.ts",
20
+ "files": [
21
+ "index.ts",
22
+ "README.md",
23
+ "pi-for-preview.png",
24
+ "LICENSE"
25
+ ],
26
+ "peerDependencies": {
27
+ "@earendil-works/pi-coding-agent": "*",
28
+ "@earendil-works/pi-tui": "*"
29
+ },
30
+ "pi": {
31
+ "extensions": [
32
+ "./index.ts"
33
+ ],
34
+ "image": "https://raw.githubusercontent.com/jejay/pi-for-each/main/pi-for-preview.png"
35
+ }
36
+ }
Binary file