pi-open-agents 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/ATTRIBUTION.md +38 -0
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/index.ts +8 -0
- package/package.json +61 -0
- package/src/config/frontmatter.ts +247 -0
- package/src/config/paths.ts +120 -0
- package/src/config/schema.ts +313 -0
- package/src/discovery/loader.ts +180 -0
- package/src/index.ts +204 -0
- package/src/permission/evaluator.ts +160 -0
- package/src/permission/matcher.ts +99 -0
- package/src/permission/parser.ts +81 -0
- package/src/primary/executor.ts +167 -0
- package/src/primary/manager.ts +147 -0
- package/src/subagent/env.ts +116 -0
- package/src/subagent/executor.ts +679 -0
- package/src/subagent/prompt.ts +79 -0
- package/src/subagent/render.ts +251 -0
- package/src/subagent/skills.ts +274 -0
- package/src/subagent/tool.ts +299 -0
- package/src/tui/banner.ts +49 -0
- package/src/tui/selector.ts +331 -0
- package/src/tui/tools.ts +147 -0
- package/src/types.ts +152 -0
- package/src/utils/format.ts +99 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent tool registration.
|
|
3
|
+
*
|
|
4
|
+
* Registers the "subagent" tool that the LLM can call to delegate tasks
|
|
5
|
+
* to named subagents running in isolated child pi processes.
|
|
6
|
+
*
|
|
7
|
+
* The tool:
|
|
8
|
+
* - Filters available agents by mode (subagent|all) and allowedAgents
|
|
9
|
+
* - Checks recursion depth via PI_OPEN_AGENTS_* env vars
|
|
10
|
+
* - Spawns the child process via the executor
|
|
11
|
+
* - Renders progress and results in the TUI
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
AgentToolUpdateCallback,
|
|
16
|
+
ExtensionAPI,
|
|
17
|
+
ExtensionContext,
|
|
18
|
+
ThemeColor,
|
|
19
|
+
} from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { getMarkdownTheme, keyHint } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
22
|
+
import { Type } from "typebox";
|
|
23
|
+
|
|
24
|
+
import type { AgentDefinition } from "../types.ts";
|
|
25
|
+
import { spawnableAgents } from "../discovery/loader.ts";
|
|
26
|
+
import {
|
|
27
|
+
availableSubagentsForAgent,
|
|
28
|
+
resolveSubagentSession,
|
|
29
|
+
runSubagent,
|
|
30
|
+
type AgentProgress,
|
|
31
|
+
type AgentResult,
|
|
32
|
+
type SubagentSessionMode,
|
|
33
|
+
} from "./executor.ts";
|
|
34
|
+
import {
|
|
35
|
+
contextUsageSeverity,
|
|
36
|
+
formatSubagentCall,
|
|
37
|
+
formatResultLines,
|
|
38
|
+
formatUsage,
|
|
39
|
+
} from "./render.ts";
|
|
40
|
+
import { allowedAgentNames, isPastMaxDepth, type RecursionEnv } from "./env.ts";
|
|
41
|
+
import { preview, shortenPath, stringArg } from "../utils/format.ts";
|
|
42
|
+
|
|
43
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
interface SubagentParams {
|
|
46
|
+
agent: string;
|
|
47
|
+
task: string;
|
|
48
|
+
cwd?: string;
|
|
49
|
+
session?: SubagentSessionMode;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
type ThemeLike = {
|
|
55
|
+
fg: (name: ThemeColor, text: string) => string;
|
|
56
|
+
bold: (text: string) => string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function availableAgentsText(agents: AgentDefinition[]): string {
|
|
60
|
+
return (
|
|
61
|
+
agents
|
|
62
|
+
.map((agent) => agent.name)
|
|
63
|
+
.sort()
|
|
64
|
+
.join(", ") || "none"
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function toToolResult(result: AgentResult) {
|
|
69
|
+
return {
|
|
70
|
+
content: [{ type: "text" as const, text: result.output || "(no output)" }],
|
|
71
|
+
details: result,
|
|
72
|
+
isError: result.isError,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function toProgressResult(progress: AgentProgress) {
|
|
77
|
+
return {
|
|
78
|
+
content: [{ type: "text" as const, text: progress.output || "(running...)" }],
|
|
79
|
+
details: progress,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── Styled Rendering ────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
function styledPathArg(args: Record<string, unknown>, theme: ThemeLike, fallback?: string): string {
|
|
86
|
+
const p = shortenPath(args.path ?? args.file_path) ?? fallback;
|
|
87
|
+
return p ? theme.fg("accent", p) : theme.fg("toolOutput", "...");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function styledLineRange(args: Record<string, unknown>, theme: ThemeLike): string {
|
|
91
|
+
if (args.offset === undefined && args.limit === undefined) return "";
|
|
92
|
+
const start = (args.offset as number) ?? 1;
|
|
93
|
+
const limit = args.limit as number | undefined;
|
|
94
|
+
const end = limit !== undefined ? start + limit - 1 : undefined;
|
|
95
|
+
return theme.fg("warning", `:${start}${end !== undefined ? `-${end}` : ""}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function styledToolTitle(name: string, args: Record<string, unknown>, theme: ThemeLike): string {
|
|
99
|
+
switch (name) {
|
|
100
|
+
case "read":
|
|
101
|
+
return `${theme.fg("toolTitle", theme.bold("read"))} ${styledPathArg(args, theme)}${styledLineRange(args, theme)}`;
|
|
102
|
+
case "bash": {
|
|
103
|
+
const cmd = stringArg(args, "command") ?? "";
|
|
104
|
+
return `${theme.fg("toolTitle", theme.bold(`$ ${cmd || "..."}`))}`;
|
|
105
|
+
}
|
|
106
|
+
case "edit":
|
|
107
|
+
return `${theme.fg("toolTitle", theme.bold("edit"))} ${styledPathArg(args, theme)}`;
|
|
108
|
+
case "write":
|
|
109
|
+
return `${theme.fg("toolTitle", theme.bold("write"))} ${styledPathArg(args, theme)}`;
|
|
110
|
+
case "grep": {
|
|
111
|
+
const pattern = stringArg(args, "pattern") ?? "";
|
|
112
|
+
return `${theme.fg("toolTitle", theme.bold("grep"))} ${theme.fg("syntaxKeyword", `/${pattern}/`)}`;
|
|
113
|
+
}
|
|
114
|
+
case "subagent": {
|
|
115
|
+
const agent = stringArg(args, "agent") ?? "";
|
|
116
|
+
const task = stringArg(args, "task");
|
|
117
|
+
return `${theme.fg("toolTitle", theme.bold("subagent"))} ${theme.fg("accent", agent)}${task ? ` ${theme.fg("dim", JSON.stringify(preview(task, 60)))}` : ""}`.trimEnd();
|
|
118
|
+
}
|
|
119
|
+
default: {
|
|
120
|
+
const values = Object.values(args).filter((v) => typeof v === "string") as string[];
|
|
121
|
+
return `${theme.fg("toolTitle", theme.bold(name))}${values.length > 0 ? ` ${theme.fg("dim", JSON.stringify(preview(values[0], 80)))}` : ""}`;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function styledCollapsedLine(
|
|
127
|
+
line: { kind: string; text: string; tool?: { name: string; args: Record<string, unknown>; status: string } },
|
|
128
|
+
details: AgentResult | AgentProgress,
|
|
129
|
+
theme: ThemeLike,
|
|
130
|
+
): string {
|
|
131
|
+
if (line.kind === "status")
|
|
132
|
+
return theme.fg(details.status === "error" ? "error" : "success", line.text);
|
|
133
|
+
if (line.kind === "hint") return theme.fg("dim", line.text);
|
|
134
|
+
if (line.kind === "tool" && line.tool) {
|
|
135
|
+
const prefix = line.tool.status === "running" ? theme.fg("warning", "▸") : " ";
|
|
136
|
+
return `${prefix} ${styledToolTitle(line.tool.name, line.tool.args, theme)}`;
|
|
137
|
+
}
|
|
138
|
+
if (line.kind === "usage") return theme.fg(contextUsageSeverity(details.usage), line.text);
|
|
139
|
+
return line.text;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function renderResultLines(
|
|
143
|
+
details: AgentResult | AgentProgress,
|
|
144
|
+
theme: ThemeLike,
|
|
145
|
+
options: { expanded: boolean; suppressOutput?: boolean; suppressUsage?: boolean; expandHint?: string },
|
|
146
|
+
): Container {
|
|
147
|
+
const container = new Container();
|
|
148
|
+
for (const line of formatResultLines(details, options)) {
|
|
149
|
+
if (line.kind === "blank") {
|
|
150
|
+
container.addChild(new Spacer(1));
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
container.addChild(new Text(styledCollapsedLine(line, details, theme), 0, 0));
|
|
154
|
+
}
|
|
155
|
+
return container;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─── Tool Registration ───────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
export interface RegisterSubagentToolOptions {
|
|
161
|
+
agents: AgentDefinition[];
|
|
162
|
+
run?: typeof runSubagent;
|
|
163
|
+
env?: RecursionEnv;
|
|
164
|
+
agentDir?: string;
|
|
165
|
+
/** Returns the currently active primary agent, if any. Used for allowedAgents filtering. */
|
|
166
|
+
getActiveAgent?: () => AgentDefinition | undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Register the subagent tool.
|
|
171
|
+
*
|
|
172
|
+
* Does nothing if past max recursion depth or no spawnable agents.
|
|
173
|
+
*/
|
|
174
|
+
export function registerSubagentTool(
|
|
175
|
+
pi: ExtensionAPI,
|
|
176
|
+
options: RegisterSubagentToolOptions,
|
|
177
|
+
): void {
|
|
178
|
+
const env: RecursionEnv = options.env ?? process.env;
|
|
179
|
+
if (isPastMaxDepth(env)) return;
|
|
180
|
+
|
|
181
|
+
// Filter by parent's allowed agents
|
|
182
|
+
const allowed = allowedAgentNames(env);
|
|
183
|
+
const allSpawnable = spawnableAgents(options.agents);
|
|
184
|
+
const agents = allowed
|
|
185
|
+
? allSpawnable.filter((a) => allowed.has(a.name))
|
|
186
|
+
: allSpawnable;
|
|
187
|
+
|
|
188
|
+
if (agents.length === 0) return;
|
|
189
|
+
|
|
190
|
+
const runner = options.run ?? runSubagent;
|
|
191
|
+
const availableSubagents = agents.map((a) => a.name);
|
|
192
|
+
const agentNames = [...availableSubagents].sort().join(", ");
|
|
193
|
+
const sessionGuideline =
|
|
194
|
+
'Use session: "fork" when the delegated task depends on the current conversation, prior discussion, or parent session history. Use the default session: "none" for self-contained tasks.';
|
|
195
|
+
const promptGuidelines =
|
|
196
|
+
agentNames.length > 0 ? [`Available subagents: ${agentNames}`, sessionGuideline] : undefined;
|
|
197
|
+
|
|
198
|
+
pi.registerTool({
|
|
199
|
+
name: "subagent",
|
|
200
|
+
label: "Subagent",
|
|
201
|
+
description: "Delegate a task to a named sub-agent running in an isolated pi process.",
|
|
202
|
+
promptSnippet: "Delegate isolated tasks with subagent({ agent, task, cwd?, session? }).",
|
|
203
|
+
promptGuidelines,
|
|
204
|
+
parameters: Type.Object({
|
|
205
|
+
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
206
|
+
task: Type.String({ description: "Task to delegate to the agent" }),
|
|
207
|
+
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
|
|
208
|
+
session: Type.Optional(
|
|
209
|
+
Type.Union([Type.Literal("none"), Type.Literal("fork")], {
|
|
210
|
+
description: 'Parent session handling: none starts a fresh subagent session, fork branches from the current session',
|
|
211
|
+
}),
|
|
212
|
+
),
|
|
213
|
+
}),
|
|
214
|
+
|
|
215
|
+
async execute(
|
|
216
|
+
_toolCallId: string,
|
|
217
|
+
params: SubagentParams,
|
|
218
|
+
signal: AbortSignal | undefined,
|
|
219
|
+
onUpdate: AgentToolUpdateCallback<AgentProgress> | undefined,
|
|
220
|
+
ctx: ExtensionContext,
|
|
221
|
+
) {
|
|
222
|
+
const agent = agents.find((a) => a.name === params.agent);
|
|
223
|
+
if (!agent) {
|
|
224
|
+
throw new Error(
|
|
225
|
+
`Unknown agent: ${params.agent}. Available agents: ${availableAgentsText(agents)}.`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Check active agent's allowedAgents whitelist
|
|
230
|
+
const activeAgent = options.getActiveAgent?.();
|
|
231
|
+
if (activeAgent?.allowedAgents && activeAgent.allowedAgents.length > 0) {
|
|
232
|
+
if (!activeAgent.allowedAgents.includes(agent.name)) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
`Agent "${activeAgent.name}" can only delegate to: ${activeAgent.allowedAgents.join(", ")}. "${agent.name}" is not in the list.`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const childCwd = params.cwd ?? ctx.cwd;
|
|
240
|
+
const session = resolveSubagentSession(
|
|
241
|
+
params.session,
|
|
242
|
+
childCwd,
|
|
243
|
+
ctx.sessionManager?.getSessionFile(),
|
|
244
|
+
ctx.sessionManager?.getLeafId(),
|
|
245
|
+
options.agentDir,
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const result = await runner({
|
|
249
|
+
agent,
|
|
250
|
+
task: params.task,
|
|
251
|
+
cwd: childCwd,
|
|
252
|
+
signal,
|
|
253
|
+
depth: Number(env.PI_OPEN_AGENTS_DEPTH ?? "0") + 1,
|
|
254
|
+
availableAgents: availableSubagentsForAgent(agent, availableSubagents),
|
|
255
|
+
agentDir: options.agentDir,
|
|
256
|
+
session,
|
|
257
|
+
onProgress: (progress) => onUpdate?.(toProgressResult(progress)),
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
return toToolResult(result);
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
renderCall(args, theme, context) {
|
|
264
|
+
const typedArgs = args as SubagentParams;
|
|
265
|
+
const title = `${theme.fg("toolTitle", theme.bold("subagent"))} ${theme.fg("accent", typedArgs.agent ?? "...")}`;
|
|
266
|
+
if (context.expanded) return new Text(`${title}\n${theme.fg("dim", typedArgs.task ?? "...")}`, 0, 0);
|
|
267
|
+
const callText = formatSubagentCall({ agent: typedArgs.agent, task: typedArgs.task });
|
|
268
|
+
return new Text(`${title} ${theme.fg("dim", callText.replace(/^subagent \S+ /, ""))}`, 0, 0);
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
renderResult(result, renderOpts, theme) {
|
|
272
|
+
const details = result.details as AgentResult | AgentProgress | undefined;
|
|
273
|
+
if (!details) {
|
|
274
|
+
const first = result.content[0];
|
|
275
|
+
return new Text(first?.type === "text" ? first.text : "(no output)", 0, 0);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (renderOpts.expanded) {
|
|
279
|
+
const container = renderResultLines(details, theme, {
|
|
280
|
+
expanded: true,
|
|
281
|
+
suppressOutput: true,
|
|
282
|
+
suppressUsage: true,
|
|
283
|
+
});
|
|
284
|
+
container.addChild(new Spacer(1));
|
|
285
|
+
container.addChild(new Markdown(details.output || "(no output)", 0, 0, getMarkdownTheme()));
|
|
286
|
+
container.addChild(new Spacer(1));
|
|
287
|
+
container.addChild(
|
|
288
|
+
new Text(theme.fg(contextUsageSeverity(details.usage), formatUsage(details)), 0, 0),
|
|
289
|
+
);
|
|
290
|
+
return container;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return renderResultLines(details, theme, {
|
|
294
|
+
expanded: false,
|
|
295
|
+
expandHint: keyHint("app.tools.expand", "to expand"),
|
|
296
|
+
});
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status banner widget for the TUI.
|
|
3
|
+
*
|
|
4
|
+
* Shows the active agent name, model, and description above the editor.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import type { AgentDefinition } from "../types.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Update the status widget to reflect the active agent.
|
|
12
|
+
* Pass undefined to show "no agent selected" hint.
|
|
13
|
+
*/
|
|
14
|
+
export function updateBanner(
|
|
15
|
+
ui: ExtensionUIContext,
|
|
16
|
+
activeAgent: AgentDefinition | undefined,
|
|
17
|
+
selectableCount: number,
|
|
18
|
+
): void {
|
|
19
|
+
if (activeAgent) {
|
|
20
|
+
const parts: string[] = [];
|
|
21
|
+
parts.push(ui.theme.bold(ui.theme.fg("accent", activeAgent.name)));
|
|
22
|
+
|
|
23
|
+
if (activeAgent.description) {
|
|
24
|
+
parts.push(ui.theme.fg("muted", `— ${activeAgent.description}`));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (activeAgent.model) {
|
|
28
|
+
parts.push(ui.theme.fg("dim", `[${activeAgent.model}]`));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (activeAgent.thinking !== "off") {
|
|
32
|
+
parts.push(ui.theme.fg("dim", `{${activeAgent.thinking}}`));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const banner = ui.theme.fg("accent", "▸ ") + parts.join(" ");
|
|
36
|
+
ui.setWidget("open-agents-banner", [banner]);
|
|
37
|
+
} else {
|
|
38
|
+
// Show hint when agents are available but none active
|
|
39
|
+
if (selectableCount > 0) {
|
|
40
|
+
const hint = ui.theme.fg(
|
|
41
|
+
"dim",
|
|
42
|
+
"[No agent selected — /agent, Ctrl+Shift+M, Alt+S]",
|
|
43
|
+
);
|
|
44
|
+
ui.setWidget("open-agents-banner", [hint]);
|
|
45
|
+
} else {
|
|
46
|
+
ui.setWidget("open-agents-banner", undefined);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI selectors for agent switching.
|
|
3
|
+
*
|
|
4
|
+
* Implements:
|
|
5
|
+
* - Agent picker (selectlist with primary|all agents)
|
|
6
|
+
* - Cycle shortcut (Ctrl+Shift+M)
|
|
7
|
+
* - Search (Alt+S, /agent-search)
|
|
8
|
+
* - Commands (/agent, /agents, /agent-search)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import {
|
|
14
|
+
Container,
|
|
15
|
+
Key,
|
|
16
|
+
type SelectItem,
|
|
17
|
+
SelectList,
|
|
18
|
+
Text,
|
|
19
|
+
} from "@earendil-works/pi-tui";
|
|
20
|
+
import type { AgentDefinition } from "../types.ts";
|
|
21
|
+
import { selectableAgents } from "../discovery/loader.ts";
|
|
22
|
+
import type { AgentManager } from "../primary/manager.ts";
|
|
23
|
+
import { updateBanner } from "./banner.ts";
|
|
24
|
+
|
|
25
|
+
// ─── Search Helpers ──────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
interface SearchResult {
|
|
28
|
+
name: string;
|
|
29
|
+
agent: AgentDefinition;
|
|
30
|
+
score: number;
|
|
31
|
+
snippets: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Score an agent against a search query.
|
|
36
|
+
*/
|
|
37
|
+
function scoreAgent(agent: AgentDefinition, query: string): number {
|
|
38
|
+
const q = query.toLowerCase();
|
|
39
|
+
let score = 0;
|
|
40
|
+
|
|
41
|
+
if (agent.name.toLowerCase() === q) score += 100;
|
|
42
|
+
else if (agent.name.toLowerCase().includes(q)) score += 50;
|
|
43
|
+
|
|
44
|
+
if (agent.description?.toLowerCase().includes(q)) score += 30;
|
|
45
|
+
if (agent.model?.toLowerCase().includes(q)) score += 20;
|
|
46
|
+
|
|
47
|
+
const bodyLower = agent.prompt.toLowerCase();
|
|
48
|
+
const bodyMatches = (bodyLower.match(new RegExp(q, "g")) || []).length;
|
|
49
|
+
score += Math.min(bodyMatches * 5, 25);
|
|
50
|
+
|
|
51
|
+
return score;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Extract context snippets around query matches.
|
|
56
|
+
*/
|
|
57
|
+
function extractSnippets(text: string, query: string, max = 2, len = 40): string[] {
|
|
58
|
+
const q = query.toLowerCase();
|
|
59
|
+
const lower = text.toLowerCase();
|
|
60
|
+
const snippets: string[] = [];
|
|
61
|
+
let pos = 0;
|
|
62
|
+
|
|
63
|
+
while (pos < lower.length && snippets.length < max) {
|
|
64
|
+
const idx = lower.indexOf(q, pos);
|
|
65
|
+
if (idx === -1) break;
|
|
66
|
+
|
|
67
|
+
const start = Math.max(0, idx - len);
|
|
68
|
+
const end = Math.min(text.length, idx + q.length + len);
|
|
69
|
+
let snippet = text.slice(start, end).replace(/\n/g, " ");
|
|
70
|
+
if (start > 0) snippet = "…" + snippet;
|
|
71
|
+
if (end < text.length) snippet += "…";
|
|
72
|
+
|
|
73
|
+
snippets.push(snippet);
|
|
74
|
+
pos = idx + q.length;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return snippets;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Search agents and return ranked results.
|
|
82
|
+
*/
|
|
83
|
+
function searchAgents(agents: AgentDefinition[], query: string, min = 1): SearchResult[] {
|
|
84
|
+
if (!query.trim()) return [];
|
|
85
|
+
|
|
86
|
+
const results: SearchResult[] = [];
|
|
87
|
+
for (const agent of agents) {
|
|
88
|
+
const score = scoreAgent(agent, query);
|
|
89
|
+
if (score >= min) {
|
|
90
|
+
const snippets: string[] = [];
|
|
91
|
+
if (agent.description) {
|
|
92
|
+
snippets.push(...extractSnippets(agent.description, query, 1, 30));
|
|
93
|
+
}
|
|
94
|
+
snippets.push(...extractSnippets(agent.prompt, query, 2, 35));
|
|
95
|
+
results.push({ name: agent.name, agent, score, snippets });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return results.sort((a, b) => b.score - a.score);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── Description Builder ─────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
function buildDescription(agent: AgentDefinition): string {
|
|
105
|
+
const parts: string[] = [];
|
|
106
|
+
if (agent.model) parts.push(agent.model);
|
|
107
|
+
if (agent.thinking !== "off") parts.push(`thinking:${agent.thinking}`);
|
|
108
|
+
if (agent.description) parts.push(agent.description);
|
|
109
|
+
return parts.join(" | ") || "No description";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── Picker UI ───────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Render a generic agent picker using pi's custom component API.
|
|
116
|
+
*/
|
|
117
|
+
async function showPicker(
|
|
118
|
+
ctx: ExtensionContext,
|
|
119
|
+
items: SelectItem[],
|
|
120
|
+
headerText: string,
|
|
121
|
+
maxVisible: number,
|
|
122
|
+
): Promise<string | null> {
|
|
123
|
+
return ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
124
|
+
const container = new Container();
|
|
125
|
+
container.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
|
|
126
|
+
container.addChild(new Text(theme.fg("accent", theme.bold(headerText))));
|
|
127
|
+
|
|
128
|
+
const selectList = new SelectList(items, Math.min(items.length, maxVisible), {
|
|
129
|
+
selectedPrefix: (text: string) => theme.fg("accent", text),
|
|
130
|
+
selectedText: (text: string) => theme.fg("accent", text),
|
|
131
|
+
description: (text: string) => theme.fg("muted", text),
|
|
132
|
+
scrollInfo: (text: string) => theme.fg("dim", text),
|
|
133
|
+
noMatch: (text: string) => theme.fg("warning", text),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
selectList.onSelect = (item) => done(item.value);
|
|
137
|
+
selectList.onCancel = () => done(null);
|
|
138
|
+
container.addChild(selectList);
|
|
139
|
+
container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel")));
|
|
140
|
+
container.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
render(width: number) {
|
|
144
|
+
return container.render(width);
|
|
145
|
+
},
|
|
146
|
+
invalidate() {
|
|
147
|
+
container.invalidate();
|
|
148
|
+
},
|
|
149
|
+
handleInput(data: string) {
|
|
150
|
+
selectList.handleInput(data);
|
|
151
|
+
tui.requestRender();
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ─── Selection Handlers ──────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
async function handleSelection(
|
|
160
|
+
result: string | null,
|
|
161
|
+
manager: AgentManager,
|
|
162
|
+
pi: ExtensionAPI,
|
|
163
|
+
ctx: ExtensionContext,
|
|
164
|
+
): Promise<void> {
|
|
165
|
+
if (!result) return;
|
|
166
|
+
|
|
167
|
+
if (result === "(none)") {
|
|
168
|
+
await manager.clear(pi, ctx);
|
|
169
|
+
ctx.ui.notify("Agent cleared, defaults restored", "info");
|
|
170
|
+
updateBanner(ctx.ui, manager.getActive(), selectableAgents(manager.getAgents()).length);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const success = await manager.apply(result, pi, ctx);
|
|
175
|
+
if (success) {
|
|
176
|
+
updateBanner(ctx.ui, manager.getActive(), selectableAgents(manager.getAgents()).length);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─── Public Functions ────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Show the agent selector UI (all primary|all agents).
|
|
184
|
+
*/
|
|
185
|
+
export async function showAgentSelector(
|
|
186
|
+
manager: AgentManager,
|
|
187
|
+
pi: ExtensionAPI,
|
|
188
|
+
ctx: ExtensionContext,
|
|
189
|
+
): Promise<void> {
|
|
190
|
+
const agents = selectableAgents(manager.getAgents());
|
|
191
|
+
|
|
192
|
+
if (agents.length === 0) {
|
|
193
|
+
ctx.ui.notify(
|
|
194
|
+
"No selectable agents found. Create agent .md files in ~/.pi/agent/agents/ or .pi/agents/",
|
|
195
|
+
"warning",
|
|
196
|
+
);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const items: SelectItem[] = agents.map((agent) => ({
|
|
201
|
+
value: agent.name,
|
|
202
|
+
label: manager.isActive(agent.name) ? `${agent.name} (active)` : agent.name,
|
|
203
|
+
description: buildDescription(agent),
|
|
204
|
+
}));
|
|
205
|
+
|
|
206
|
+
items.push({
|
|
207
|
+
value: "(none)",
|
|
208
|
+
label: "(none)",
|
|
209
|
+
description: "Clear active agent, restore defaults",
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const result = await showPicker(ctx, items, "Select Agent", 10);
|
|
213
|
+
await handleSelection(result, manager, pi, ctx);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Show search results UI.
|
|
218
|
+
*/
|
|
219
|
+
export async function showSearchResults(
|
|
220
|
+
manager: AgentManager,
|
|
221
|
+
pi: ExtensionAPI,
|
|
222
|
+
ctx: ExtensionContext,
|
|
223
|
+
query: string,
|
|
224
|
+
): Promise<void> {
|
|
225
|
+
const agents = selectableAgents(manager.getAgents());
|
|
226
|
+
const results = searchAgents(agents, query);
|
|
227
|
+
|
|
228
|
+
if (results.length === 0) {
|
|
229
|
+
ctx.ui.notify(`No agents found matching "${query}"`, "warning");
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const items: SelectItem[] = results.map((r) => {
|
|
234
|
+
const snippet = r.snippets.length > 0 ? r.snippets[0].slice(0, 80) : buildDescription(r.agent);
|
|
235
|
+
const active = manager.isActive(r.name) ? " (active)" : "";
|
|
236
|
+
return {
|
|
237
|
+
value: r.name,
|
|
238
|
+
label: `${r.name}${active} [${r.score}]`,
|
|
239
|
+
description: snippet,
|
|
240
|
+
};
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const result = await showPicker(ctx, items, `Search: "${query}" (${results.length} found)`, 8);
|
|
244
|
+
await handleSelection(result, manager, pi, ctx);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ─── Command + Shortcut Registration ─────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Register all TUI commands, shortcuts, and CLI flags.
|
|
251
|
+
*/
|
|
252
|
+
export function registerTuiControls(manager: AgentManager, pi: ExtensionAPI): void {
|
|
253
|
+
// Ctrl+Shift+M: Cycle agents
|
|
254
|
+
pi.registerShortcut(Key.ctrlShift("m"), {
|
|
255
|
+
description: "Cycle agents",
|
|
256
|
+
handler: async (ctx) => {
|
|
257
|
+
await manager.cycle(pi, ctx);
|
|
258
|
+
updateBanner(ctx.ui, manager.getActive(), selectableAgents(manager.getAgents()).length);
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// Alt+S: Search agents
|
|
263
|
+
pi.registerShortcut("alt+s", {
|
|
264
|
+
description: "Search agents",
|
|
265
|
+
handler: async (ctx) => {
|
|
266
|
+
const query = await ctx.ui.input("Search agents:", "name, description, or content");
|
|
267
|
+
if (query?.trim()) {
|
|
268
|
+
await showSearchResults(manager, pi, ctx, query.trim());
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// /agent command
|
|
274
|
+
pi.registerCommand("agent", {
|
|
275
|
+
description: "Switch active agent",
|
|
276
|
+
handler: async (args, ctx: ExtensionCommandContext) => {
|
|
277
|
+
if (args?.trim()) {
|
|
278
|
+
const name = args.trim();
|
|
279
|
+
if (name === "none" || name === "clear") {
|
|
280
|
+
await manager.clear(pi, ctx);
|
|
281
|
+
ctx.ui.notify("Agent cleared, defaults restored", "info");
|
|
282
|
+
updateBanner(ctx.ui, manager.getActive(), selectableAgents(manager.getAgents()).length);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const success = await manager.apply(name, pi, ctx);
|
|
286
|
+
if (!success) {
|
|
287
|
+
const available = manager.getAgents().map((a) => a.name).join(", ") || "(none defined)";
|
|
288
|
+
ctx.ui.notify(`Unknown agent "${name}". Available: ${available}`, "error");
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
updateBanner(ctx.ui, manager.getActive(), selectableAgents(manager.getAgents()).length);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
await showAgentSelector(manager, pi, ctx);
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// /agents command
|
|
299
|
+
pi.registerCommand("agents", {
|
|
300
|
+
description: "List available agents",
|
|
301
|
+
handler: async (_args, ctx) => {
|
|
302
|
+
const agents = manager.getAgents();
|
|
303
|
+
if (agents.length === 0) {
|
|
304
|
+
ctx.ui.notify("No agents found.", "warning");
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const lines = agents.map((a) => {
|
|
308
|
+
const marker = manager.isActive(a.name) ? "● " : "○ ";
|
|
309
|
+
const mode = a.mode === "all" ? "" : ` [${a.mode}]`;
|
|
310
|
+
const desc = a.description ? ` - ${a.description}` : "";
|
|
311
|
+
return `${marker}${a.name}${mode}${desc}`;
|
|
312
|
+
});
|
|
313
|
+
ctx.ui.notify(`Available agents:\n${lines.join("\n")}`, "info");
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// /agent-search command
|
|
318
|
+
pi.registerCommand("agent-search", {
|
|
319
|
+
description: "Search agents by name, description, or content",
|
|
320
|
+
handler: async (args, ctx: ExtensionCommandContext) => {
|
|
321
|
+
if (args?.trim()) {
|
|
322
|
+
await showSearchResults(manager, pi, ctx, args.trim());
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const query = await ctx.ui.input("Search agents:", "name, description, or content");
|
|
326
|
+
if (query?.trim()) {
|
|
327
|
+
await showSearchResults(manager, pi, ctx, query.trim());
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
}
|