@ferris1225/pi-subagents 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-zh.md +134 -0
- package/README.md +143 -0
- package/agents/explore.md +42 -0
- package/agents/plan.md +41 -0
- package/agents/reviewer.md +45 -0
- package/agents/worker.md +44 -0
- package/package.json +54 -0
- package/src/agents.ts +157 -0
- package/src/config.ts +155 -0
- package/src/index.ts +307 -0
- package/src/prompt.ts +57 -0
- package/src/setup.ts +222 -0
- package/src/spawn.ts +308 -0
- package/src/ui.ts +231 -0
package/src/ui.ts
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI pickers for /subagents-setup, built on @earendil-works/pi-tui.
|
|
3
|
+
*
|
|
4
|
+
* A single self-contained `Picker` component powers both selectors:
|
|
5
|
+
* - single-select (model picker): type to fuzzy-filter, arrows to move,
|
|
6
|
+
* PageUp/PageDown to page, Enter to choose, Esc to cancel.
|
|
7
|
+
* - multi-select (module picker): same navigation, Space toggles a checkbox,
|
|
8
|
+
* Enter confirms the selection set.
|
|
9
|
+
*
|
|
10
|
+
* pi-tui's built-in SelectList only handles up/down/confirm/cancel (no paging),
|
|
11
|
+
* so we render the list ourselves and drive it with getKeybindings(). Every line
|
|
12
|
+
* is passed through truncateToWidth() — pi hard-crashes if a rendered line is
|
|
13
|
+
* wider than the terminal.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
fuzzyFilter,
|
|
18
|
+
getKeybindings,
|
|
19
|
+
truncateToWidth,
|
|
20
|
+
type Component,
|
|
21
|
+
type Focusable,
|
|
22
|
+
type SelectItem,
|
|
23
|
+
type TUI,
|
|
24
|
+
} from "@earendil-works/pi-tui";
|
|
25
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
26
|
+
|
|
27
|
+
/** Rows shown at once; longer lists are reached with PageUp/PageDown. */
|
|
28
|
+
export const PAGE_SIZE = 8;
|
|
29
|
+
|
|
30
|
+
export interface PickerStyles {
|
|
31
|
+
border: (t: string) => string;
|
|
32
|
+
title: (t: string) => string;
|
|
33
|
+
hint: (t: string) => string;
|
|
34
|
+
cursorMark: (t: string) => string;
|
|
35
|
+
selectedLabel: (t: string) => string;
|
|
36
|
+
label: (t: string) => string;
|
|
37
|
+
dim: (t: string) => string;
|
|
38
|
+
checked: (t: string) => string;
|
|
39
|
+
unchecked: (t: string) => string;
|
|
40
|
+
filterEcho: (t: string) => string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface PickerCallbacks {
|
|
44
|
+
/** single-select: fired with the highlighted value on Enter. */
|
|
45
|
+
onSelect?: (value: string) => void;
|
|
46
|
+
/** multi-select: fired with the full chosen set on Enter. */
|
|
47
|
+
onConfirm?: (values: string[]) => void;
|
|
48
|
+
onCancel: () => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class Picker implements Component, Focusable {
|
|
52
|
+
private _focused = false;
|
|
53
|
+
private query = "";
|
|
54
|
+
private cursor = 0;
|
|
55
|
+
private filtered: SelectItem[];
|
|
56
|
+
|
|
57
|
+
constructor(
|
|
58
|
+
private readonly items: SelectItem[],
|
|
59
|
+
private readonly multi: boolean,
|
|
60
|
+
private readonly selected: Set<string>,
|
|
61
|
+
private readonly styles: PickerStyles,
|
|
62
|
+
private readonly headerLines: string[],
|
|
63
|
+
private readonly tui: TUI,
|
|
64
|
+
private readonly cb: PickerCallbacks,
|
|
65
|
+
) {
|
|
66
|
+
this.filtered = items;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get focused(): boolean {
|
|
70
|
+
return this._focused;
|
|
71
|
+
}
|
|
72
|
+
set focused(value: boolean) {
|
|
73
|
+
this._focused = value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private recompute(): void {
|
|
77
|
+
const q = this.query.trim();
|
|
78
|
+
this.filtered = q ? fuzzyFilter(this.items, q, (i) => `${i.value} ${i.label}`) : this.items;
|
|
79
|
+
this.cursor = Math.max(0, Math.min(this.cursor, this.filtered.length - 1));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
render(width: number): string[] {
|
|
83
|
+
const s = this.styles;
|
|
84
|
+
const fit = (line: string): string => truncateToWidth(line, width, "");
|
|
85
|
+
const border = fit(s.border("─".repeat(Math.max(1, width))));
|
|
86
|
+
|
|
87
|
+
const lines: string[] = [border];
|
|
88
|
+
for (const h of this.headerLines) lines.push(fit(h));
|
|
89
|
+
lines.push(fit(this.query ? s.filterEcho(`filter: ${this.query}`) : s.dim("filter: (type to narrow)")));
|
|
90
|
+
lines.push(border);
|
|
91
|
+
|
|
92
|
+
if (this.filtered.length === 0) {
|
|
93
|
+
lines.push(fit(s.dim(" (no matches)")));
|
|
94
|
+
} else {
|
|
95
|
+
const start = Math.max(
|
|
96
|
+
0,
|
|
97
|
+
Math.min(this.cursor - Math.floor(PAGE_SIZE / 2), this.filtered.length - PAGE_SIZE),
|
|
98
|
+
);
|
|
99
|
+
const visible = this.filtered.slice(start, start + PAGE_SIZE);
|
|
100
|
+
for (let i = 0; i < visible.length; i++) {
|
|
101
|
+
const item = visible[i];
|
|
102
|
+
const isCursor = start + i === this.cursor;
|
|
103
|
+
const mark = isCursor ? s.cursorMark("❯ ") : " ";
|
|
104
|
+
const label = isCursor ? s.selectedLabel(item.label) : s.label(item.label);
|
|
105
|
+
const line = this.multi
|
|
106
|
+
? mark + (this.selected.has(item.value) ? s.checked("[x] ") : s.unchecked("[ ] ")) + label
|
|
107
|
+
: mark + label;
|
|
108
|
+
lines.push(fit(line));
|
|
109
|
+
}
|
|
110
|
+
const more = this.filtered.length > PAGE_SIZE ? " ↑/↓ move • PgUp/PgDn page" : "";
|
|
111
|
+
lines.push(fit(s.dim(` (${this.cursor + 1}/${this.filtered.length})${more}`)));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
lines.push(border);
|
|
115
|
+
return lines;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
handleInput(data: string): void {
|
|
119
|
+
const kb = getKeybindings();
|
|
120
|
+
if (kb.matches(data, "tui.select.up")) {
|
|
121
|
+
if (this.filtered.length > 0) this.cursor = this.cursor === 0 ? this.filtered.length - 1 : this.cursor - 1;
|
|
122
|
+
} else if (kb.matches(data, "tui.select.down")) {
|
|
123
|
+
if (this.filtered.length > 0) this.cursor = this.cursor === this.filtered.length - 1 ? 0 : this.cursor + 1;
|
|
124
|
+
} else if (kb.matches(data, "tui.select.pageUp")) {
|
|
125
|
+
this.cursor = Math.max(0, this.cursor - PAGE_SIZE);
|
|
126
|
+
} else if (kb.matches(data, "tui.select.pageDown")) {
|
|
127
|
+
this.cursor = Math.min(Math.max(0, this.filtered.length - 1), this.cursor + PAGE_SIZE);
|
|
128
|
+
} else if (kb.matches(data, "tui.select.confirm")) {
|
|
129
|
+
if (this.multi) this.cb.onConfirm?.([...this.selected]);
|
|
130
|
+
else {
|
|
131
|
+
const item = this.filtered[this.cursor];
|
|
132
|
+
if (item) this.cb.onSelect?.(item.value);
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
} else if (kb.matches(data, "tui.select.cancel")) {
|
|
136
|
+
this.cb.onCancel();
|
|
137
|
+
return;
|
|
138
|
+
} else if (data === "\x7f" || data === "\b") {
|
|
139
|
+
this.query = this.query.slice(0, -1);
|
|
140
|
+
this.cursor = 0;
|
|
141
|
+
this.recompute();
|
|
142
|
+
} else if (data === " ") {
|
|
143
|
+
if (this.multi) {
|
|
144
|
+
const item = this.filtered[this.cursor];
|
|
145
|
+
if (item) {
|
|
146
|
+
if (this.selected.has(item.value)) this.selected.delete(item.value);
|
|
147
|
+
else this.selected.add(item.value);
|
|
148
|
+
}
|
|
149
|
+
} else {
|
|
150
|
+
this.query += data;
|
|
151
|
+
this.cursor = 0;
|
|
152
|
+
this.recompute();
|
|
153
|
+
}
|
|
154
|
+
} else if (isPrintable(data)) {
|
|
155
|
+
this.query += data;
|
|
156
|
+
this.cursor = 0;
|
|
157
|
+
this.recompute();
|
|
158
|
+
}
|
|
159
|
+
this.tui.requestRender();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
invalidate(): void {}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function isPrintable(data: string): boolean {
|
|
166
|
+
if (data.length === 0) return false;
|
|
167
|
+
// Reject ESC-led escape sequences and other control characters.
|
|
168
|
+
return data.charCodeAt(0) >= 0x20;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Build style functions from the pi theme. `any` on the color param dodges a
|
|
172
|
+
* strict contravariance error when assigning the theme's narrow color union. */
|
|
173
|
+
function makeStyles(theme: { fg: (color: any, text: string) => string; bold: (text: string) => string }): PickerStyles {
|
|
174
|
+
return {
|
|
175
|
+
border: (t) => theme.fg("accent", t),
|
|
176
|
+
title: (t) => theme.fg("accent", theme.bold(t)),
|
|
177
|
+
hint: (t) => theme.fg("dim", t),
|
|
178
|
+
cursorMark: (t) => theme.fg("accent", t),
|
|
179
|
+
selectedLabel: (t) => theme.fg("accent", theme.bold(t)),
|
|
180
|
+
label: (t) => t,
|
|
181
|
+
dim: (t) => theme.fg("dim", t),
|
|
182
|
+
checked: (t) => theme.fg("accent", t),
|
|
183
|
+
unchecked: (t) => theme.fg("dim", t),
|
|
184
|
+
filterEcho: (t) => theme.fg("accent", t),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function requireTui(ctx: ExtensionCommandContext): boolean {
|
|
189
|
+
if (ctx.mode !== "tui") {
|
|
190
|
+
ctx.ui.notify("/subagents-setup requires Pi's interactive TUI.", "error");
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Single-select with fuzzy filter + paging. Resolves undefined on Esc. */
|
|
197
|
+
export function promptSelectOne(
|
|
198
|
+
ctx: ExtensionCommandContext,
|
|
199
|
+
title: string,
|
|
200
|
+
hint: string,
|
|
201
|
+
items: SelectItem[],
|
|
202
|
+
): Promise<string | undefined> {
|
|
203
|
+
if (!requireTui(ctx)) return Promise.resolve(undefined);
|
|
204
|
+
return ctx.ui.custom<string | undefined>((tui, theme, _kb, done) => {
|
|
205
|
+
const styles = makeStyles(theme);
|
|
206
|
+
const header = [styles.title(title), styles.hint(hint)];
|
|
207
|
+
return new Picker(items, false, new Set<string>(), styles, header, tui, {
|
|
208
|
+
onSelect: (value) => done(value),
|
|
209
|
+
onCancel: () => done(undefined),
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Multi-select with fuzzy filter + paging. Resolves undefined on Esc. */
|
|
215
|
+
export function promptSelectMany(
|
|
216
|
+
ctx: ExtensionCommandContext,
|
|
217
|
+
title: string,
|
|
218
|
+
hint: string,
|
|
219
|
+
items: SelectItem[],
|
|
220
|
+
initialSelected: readonly string[],
|
|
221
|
+
): Promise<string[] | undefined> {
|
|
222
|
+
if (!requireTui(ctx)) return Promise.resolve(undefined);
|
|
223
|
+
return ctx.ui.custom<string[] | undefined>((tui, theme, _kb, done) => {
|
|
224
|
+
const styles = makeStyles(theme);
|
|
225
|
+
const header = [styles.title(title), styles.hint(hint)];
|
|
226
|
+
return new Picker(items, true, new Set<string>(initialSelected), styles, header, tui, {
|
|
227
|
+
onConfirm: (values) => done(values),
|
|
228
|
+
onCancel: () => done(undefined),
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
}
|