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 +21 -0
- package/README.md +122 -0
- package/index.ts +615 -0
- package/package.json +36 -0
- package/pi-for-preview.png +0 -0
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
|
+

|
|
14
|
+

|
|
15
|
+

|
|
16
|
+

|
|
17
|
+

|
|
18
|
+

|
|
19
|
+

|
|
20
|
+

|
|
21
|
+

|
|
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
|