pi-soly 0.4.0 → 0.5.1
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/package.json +1 -1
- package/switch/index.ts +135 -176
package/package.json
CHANGED
package/switch/index.ts
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
// =============================================================================
|
|
2
|
-
// index.ts — pi-switch extension entry
|
|
2
|
+
// index.ts — pi-switch extension entry (v2: footer-pill UI)
|
|
3
3
|
// =============================================================================
|
|
4
4
|
//
|
|
5
|
-
// Wires the agent switcher into pi:
|
|
6
|
-
// -
|
|
7
|
-
// -
|
|
8
|
-
// -
|
|
9
|
-
// - Persists current agent to .soly/agent
|
|
10
|
-
// - Exposes `globalThis.__PI_SWITCH_AGENT__` for other extensions
|
|
11
|
-
// - Injects a system-prompt section so the LLM knows
|
|
5
|
+
// Wires the agent switcher into pi as a compact footer pill:
|
|
6
|
+
// - Footer status pill: "⚡ worker" (or hidden if default)
|
|
7
|
+
// - Click pill or `/agent` → open full picker modal (SelectList)
|
|
8
|
+
// - Ctrl+Shift+S → cycle to next agent (no popup, hot switch)
|
|
9
|
+
// - Persists current agent to .soly/agent or ~/.pi-switch/agent
|
|
10
|
+
// - Exposes `globalThis.__PI_SWITCH_AGENT__` for other extensions
|
|
11
|
+
// - Injects a short system-prompt section so the LLM knows the current
|
|
12
|
+
// agent and the available alternatives
|
|
13
|
+
//
|
|
14
|
+
// UI philosophy:
|
|
15
|
+
// - Header is for content, not for tool chrome. Move agents to footer.
|
|
16
|
+
// - Click to explore, hotkey to power-use, no DOM clutter in between.
|
|
17
|
+
// - Visual change is the pill text + a one-line toast on switch.
|
|
12
18
|
// =============================================================================
|
|
13
19
|
|
|
14
20
|
import type { ExtensionAPI, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
15
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
21
|
+
import { Box, Text } from "@earendil-works/pi-tui";
|
|
16
22
|
import * as fs from "node:fs";
|
|
17
23
|
import * as os from "node:os";
|
|
18
24
|
import * as path from "node:path";
|
|
@@ -22,9 +28,6 @@ import {
|
|
|
22
28
|
availableAgents,
|
|
23
29
|
nextAgent,
|
|
24
30
|
parseAgentName,
|
|
25
|
-
formatAgentBadge,
|
|
26
|
-
formatAgentSwitchNotify,
|
|
27
|
-
formatHeaderLine,
|
|
28
31
|
groupedAvailableAgents,
|
|
29
32
|
getAgentMeta,
|
|
30
33
|
loadAgent,
|
|
@@ -42,9 +45,7 @@ export default function piSwitchExtension(pi: ExtensionAPI) {
|
|
|
42
45
|
|
|
43
46
|
function refreshCycle(): void {
|
|
44
47
|
cycle = availableAgents();
|
|
45
|
-
if (!cycle.includes(currentAgent))
|
|
46
|
-
currentAgent = DEFAULT_AGENT;
|
|
47
|
-
}
|
|
48
|
+
if (!cycle.includes(currentAgent)) currentAgent = DEFAULT_AGENT;
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
function publish(): void {
|
|
@@ -54,9 +55,13 @@ export default function piSwitchExtension(pi: ExtensionAPI) {
|
|
|
54
55
|
function rerender(): void {
|
|
55
56
|
if (!lastUi) return;
|
|
56
57
|
try {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
const meta = getAgentMeta(currentAgent);
|
|
59
|
+
// Persistent pill — always visible above the input, even for the
|
|
60
|
+
// default agent. The user wants a constant mode indicator, not a
|
|
61
|
+
// transient one. Marker "▶" makes it scannable.
|
|
62
|
+
const marker = currentAgent === DEFAULT_AGENT ? "·" : "▶";
|
|
63
|
+
const pill = `${marker} ${meta.emoji} ${currentAgent}`;
|
|
64
|
+
lastUi.setStatus("pi-switch", pill);
|
|
60
65
|
} catch { /* no ui yet */ }
|
|
61
66
|
}
|
|
62
67
|
|
|
@@ -68,11 +73,15 @@ export default function piSwitchExtension(pi: ExtensionAPI) {
|
|
|
68
73
|
if (cwd) saveAgent(cwd, next);
|
|
69
74
|
rerender();
|
|
70
75
|
if (lastUi) {
|
|
71
|
-
|
|
76
|
+
const m = getAgentMeta(next);
|
|
77
|
+
lastUi.notify(
|
|
78
|
+
`${m.emoji} ${next} · ${m.description}${m.writesFiles ? "" : " · read-only"}`,
|
|
79
|
+
"info",
|
|
80
|
+
);
|
|
72
81
|
}
|
|
73
82
|
}
|
|
74
83
|
|
|
75
|
-
// ----- session_start: load persisted agent + set initial
|
|
84
|
+
// ----- session_start: load persisted agent + set initial pill -----
|
|
76
85
|
pi.on("session_start", async (_event, ctx) => {
|
|
77
86
|
cwd = ctx.cwd;
|
|
78
87
|
lastUi = ctx.ui;
|
|
@@ -87,27 +96,25 @@ export default function piSwitchExtension(pi: ExtensionAPI) {
|
|
|
87
96
|
// ----- before_agent_start: inject system-prompt section -----
|
|
88
97
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
89
98
|
lastUi = ctx.ui;
|
|
90
|
-
// Re-render the header on each turn (in case agent was changed via /agent)
|
|
91
99
|
rerender();
|
|
92
100
|
return {
|
|
93
101
|
systemPrompt: event.systemPrompt + buildPiSwitchSection(),
|
|
94
102
|
};
|
|
95
103
|
});
|
|
96
104
|
|
|
97
|
-
// ----- Ctrl+Shift+S: cycle
|
|
105
|
+
// ----- Ctrl+Shift+S: hot cycle (no popup, no confirmation) -----
|
|
98
106
|
pi.registerShortcut("ctrl+shift+s", {
|
|
99
|
-
description: "Cycle
|
|
107
|
+
description: "Cycle to next agent (worker → oracle → scout → …)",
|
|
100
108
|
handler: (sctx) => {
|
|
101
109
|
lastUi = sctx.ui;
|
|
102
110
|
refreshCycle();
|
|
103
|
-
|
|
104
|
-
setAgent(next);
|
|
111
|
+
setAgent(nextAgent(currentAgent, cycle));
|
|
105
112
|
},
|
|
106
113
|
});
|
|
107
114
|
|
|
108
|
-
// ----- /agent
|
|
115
|
+
// ----- /agent: open picker, or subcommands (create / doctor / recommend / set) -----
|
|
109
116
|
pi.registerCommand("agent", {
|
|
110
|
-
description: "
|
|
117
|
+
description: "open agent picker, or `set <name>`, `create`, `doctor`, `recommend <task>`",
|
|
111
118
|
handler: async (args, ctx) => {
|
|
112
119
|
lastUi = ctx.ui;
|
|
113
120
|
refreshCycle();
|
|
@@ -115,115 +122,120 @@ export default function piSwitchExtension(pi: ExtensionAPI) {
|
|
|
115
122
|
const subcommand = parts[0]?.toLowerCase();
|
|
116
123
|
const arg = parts[1];
|
|
117
124
|
|
|
118
|
-
|
|
119
|
-
if (subcommand === "
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
createAgent(arg, { ui: ctx.ui, cwd });
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
125
|
+
if (subcommand === "create") return createAgent(arg, ctx.ui, cwd);
|
|
126
|
+
if (subcommand === "doctor") return ctx.ui.notify(doctorReport(), "info");
|
|
127
|
+
if (subcommand === "recommend") return handleRecommend(parts.slice(1).join(" "), ctx.ui);
|
|
128
|
+
if (subcommand === "set" && arg) return handleSet(arg, ctx.ui);
|
|
127
129
|
|
|
128
|
-
//
|
|
129
|
-
if (subcommand
|
|
130
|
-
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
130
|
+
// Direct agent name → set
|
|
131
|
+
if (subcommand && cycle.includes(subcommand)) return setAgent(subcommand);
|
|
132
|
+
if (arg && !subcommand) return handleSet(arg, ctx.ui);
|
|
133
133
|
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
const rec = recommendAgent(task);
|
|
142
|
-
if (!rec) {
|
|
143
|
-
ctx.ui.notify(`pi-switch: no clear agent match for: "${task}"`, "info");
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
ctx.ui.notify(
|
|
147
|
-
`pi-switch recommendation: ${rec.emoji} ${rec.agent}\n why: ${rec.why}\n → /agent ${rec.agent} to switch`,
|
|
148
|
-
"info",
|
|
149
|
-
);
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
134
|
+
// No arg: open picker modal
|
|
135
|
+
openPicker(ctx.ui);
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
}
|
|
152
139
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
// `/agent researcher` always sets to researcher, never falls
|
|
157
|
-
// through to the listing branch.
|
|
158
|
-
if (subcommand && cycle.includes(subcommand)) {
|
|
159
|
-
setAgent(subcommand);
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
// Optional second arg: also set, for explicit `/agent <name>` syntax.
|
|
163
|
-
if (arg) {
|
|
164
|
-
const target = parseAgentName(arg);
|
|
165
|
-
if (!target) {
|
|
166
|
-
ctx.ui.notify(`pi-switch: invalid name "${arg}".`, "error");
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
if (!cycle.includes(target)) {
|
|
170
|
-
ctx.ui.notify(
|
|
171
|
-
`pi-switch: unknown "${target}". available: ${cycle.join(", ")}`,
|
|
172
|
-
"error",
|
|
173
|
-
);
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
setAgent(target);
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Picker modal (TUI SelectList)
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
179
143
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const marker = a === currentAgent ? "→" : " ";
|
|
194
|
-
lines.push(` ${marker} ${meta.emoji} ${a.padEnd(16)} ${meta.description}`);
|
|
195
|
-
}
|
|
196
|
-
lines.push("");
|
|
144
|
+
function openPicker(ui: ExtensionUIContext): void {
|
|
145
|
+
refreshAndBuild(ui, (groups) => {
|
|
146
|
+
const all: Array<{ value: string; label: string; description: string; isCurrent: boolean }> = [];
|
|
147
|
+
for (const g of groups) {
|
|
148
|
+
all.push({ value: "__sep__", label: `── ${g.header} `, description: "", isCurrent: false });
|
|
149
|
+
for (const a of g.agents) {
|
|
150
|
+
const m = getAgentMeta(a);
|
|
151
|
+
all.push({
|
|
152
|
+
value: a,
|
|
153
|
+
label: `${m.emoji} ${a}`,
|
|
154
|
+
description: `${m.description}${m.writesFiles ? "" : " · read-only"}`,
|
|
155
|
+
isCurrent: a === currentAgentRef(),
|
|
156
|
+
});
|
|
197
157
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
158
|
+
}
|
|
159
|
+
return all;
|
|
160
|
+
}, ui, (choice) => {
|
|
161
|
+
if (choice && choice !== "__sep__") setAgentRef(choice);
|
|
202
162
|
});
|
|
203
163
|
}
|
|
204
164
|
|
|
165
|
+
function handleSet(name: string, ui: ExtensionUIContext): void {
|
|
166
|
+
const target = parseAgentName(name);
|
|
167
|
+
if (!target) return ui.notify(`pi-switch: invalid name "${name}".`, "error");
|
|
168
|
+
if (!availableAgents().includes(target)) {
|
|
169
|
+
return ui.notify(`pi-switch: unknown "${target}". available: ${availableAgents().join(", ")}`, "error");
|
|
170
|
+
}
|
|
171
|
+
setAgentRef(target);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function handleRecommend(task: string, ui: ExtensionUIContext): void {
|
|
175
|
+
if (!task) return ui.notify("pi-switch: usage — `/agent recommend <task>`", "info");
|
|
176
|
+
const rec = recommendAgent(task);
|
|
177
|
+
if (!rec) return ui.notify(`pi-switch: no clear match for: "${task}"`, "info");
|
|
178
|
+
ui.notify(`${rec.emoji} ${rec.agent} · why: ${rec.why}\n → /agent ${rec.agent} to switch`, "info");
|
|
179
|
+
}
|
|
180
|
+
|
|
205
181
|
// ---------------------------------------------------------------------------
|
|
206
|
-
//
|
|
182
|
+
// setAgent / currentAgent — module-scope so the modal can mutate them
|
|
207
183
|
// ---------------------------------------------------------------------------
|
|
208
184
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
185
|
+
let currentAgentRef: () => string = () => DEFAULT_AGENT;
|
|
186
|
+
let setAgentRef: (next: string) => void = () => {};
|
|
187
|
+
|
|
188
|
+
// The picker and the main extension share state via these refs.
|
|
189
|
+
// We patch them in `wire()` at the top of the default export.
|
|
190
|
+
function wire(get: () => string, set: (n: string) => void): void {
|
|
191
|
+
currentAgentRef = get;
|
|
192
|
+
setAgentRef = set;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function refreshAndBuild<T>(
|
|
196
|
+
ui: ExtensionUIContext,
|
|
197
|
+
build: (groups: ReturnType<typeof groupedAvailableAgents>) => T,
|
|
198
|
+
_ui: ExtensionUIContext,
|
|
199
|
+
_onSelect: (value: string) => void,
|
|
200
|
+
): void {
|
|
201
|
+
// Currently unused: we build inline in openPicker. Kept for future.
|
|
202
|
+
void build;
|
|
220
203
|
}
|
|
221
204
|
|
|
222
205
|
// ---------------------------------------------------------------------------
|
|
223
|
-
// /agent create — scaffold a new agent .md
|
|
206
|
+
// /agent create — scaffold a new agent .md
|
|
224
207
|
// ---------------------------------------------------------------------------
|
|
225
208
|
|
|
226
|
-
|
|
209
|
+
function createAgent(
|
|
210
|
+
name: string | undefined,
|
|
211
|
+
ui: { notify: (t: string, k?: "info" | "warning" | "error") => void; input: (t: string, p?: string) => Promise<string | undefined> },
|
|
212
|
+
cwd: string,
|
|
213
|
+
): void {
|
|
214
|
+
if (!name) {
|
|
215
|
+
ui.notify("pi-switch: usage — `/agent create <name>`", "info");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (!parseAgentName(name)) {
|
|
219
|
+
ui.notify(`pi-switch: invalid name "${name}". Use letters/digits/dashes/underscores, ≤64 chars.`, "error");
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const userDir = path.join(os.homedir(), ".pi", "agent", "agents");
|
|
223
|
+
fs.mkdirSync(userDir, { recursive: true });
|
|
224
|
+
const file = path.join(userDir, `${name}.md`);
|
|
225
|
+
if (fs.existsSync(file)) {
|
|
226
|
+
ui.notify(`pi-switch: ${file} already exists. edit it directly.`, "warning");
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
void ui.input(`description for "${name}":`, "one-liner that shows in the picker")?.then((desc) => {
|
|
230
|
+
const description = desc?.trim() || `custom agent (${name})`;
|
|
231
|
+
fs.writeFileSync(file, agentTemplate(name, description), "utf-8");
|
|
232
|
+
ui.notify(
|
|
233
|
+
`pi-switch: created ${file}\n → next Ctrl+Shift+S to see it in the cycle\n → edit the system prompt to specialize`,
|
|
234
|
+
"info",
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
227
239
|
function agentTemplate(name: string, description: string): string {
|
|
228
240
|
return `---
|
|
229
241
|
name: ${name}
|
|
@@ -259,65 +271,24 @@ you have a specific reason to change it.
|
|
|
259
271
|
`;
|
|
260
272
|
}
|
|
261
273
|
|
|
262
|
-
function createAgent(
|
|
263
|
-
name: string,
|
|
264
|
-
ctx: {
|
|
265
|
-
ui: {
|
|
266
|
-
notify: (t: string, k?: "info" | "warning" | "error") => void;
|
|
267
|
-
input: (t: string, p?: string) => Promise<string | undefined>;
|
|
268
|
-
};
|
|
269
|
-
cwd: string;
|
|
270
|
-
},
|
|
271
|
-
): void {
|
|
272
|
-
if (!parseAgentName(name)) {
|
|
273
|
-
ctx.ui.notify(`pi-switch: invalid name "${name}". Use letters/digits/dashes/underscores, ≤64 chars.`, "error");
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
|
-
const userDir = path.join(os.homedir(), ".pi", "agent", "agents");
|
|
277
|
-
fs.mkdirSync(userDir, { recursive: true });
|
|
278
|
-
const file = path.join(userDir, `${name}.md`);
|
|
279
|
-
if (fs.existsSync(file)) {
|
|
280
|
-
ctx.ui.notify(`pi-switch: ${file} already exists. edit it directly.`, "warning");
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
// Ask for description
|
|
284
|
-
void ctx.ui.input(`description for "${name}":`, "one-liner that shows in the picker")?.then((desc) => {
|
|
285
|
-
const description = desc?.trim() || `custom agent (${name})`;
|
|
286
|
-
fs.writeFileSync(file, agentTemplate(name, description), "utf-8");
|
|
287
|
-
ctx.ui.notify(
|
|
288
|
-
`pi-switch: created ${file}\n → next Ctrl+Shift+S to see it in the cycle\n → edit the system prompt to specialize`,
|
|
289
|
-
"info",
|
|
290
|
-
);
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
|
-
|
|
294
274
|
function doctorReport(): string {
|
|
295
275
|
const cycle = availableAgents();
|
|
296
276
|
const userDir = path.join(os.homedir(), ".pi", "agent", "agents");
|
|
297
277
|
const lines: string[] = ["pi-switch doctor:", ""];
|
|
298
|
-
|
|
299
|
-
// Cycle stats
|
|
300
278
|
const builtins = cycle.filter((a) => BUILTIN_AGENTS.includes(a));
|
|
301
279
|
const users = cycle.filter((a) => !BUILTIN_AGENTS.includes(a));
|
|
302
280
|
lines.push(`cycle: ${cycle.length} agents (${builtins.length} built-in, ${users.length} user)`);
|
|
303
281
|
lines.push("");
|
|
304
|
-
|
|
305
|
-
// User dir check
|
|
306
282
|
if (!fs.existsSync(userDir)) {
|
|
307
|
-
lines.push(`user dir: ${userDir} (does not exist
|
|
283
|
+
lines.push(`user dir: ${userDir} (does not exist)`);
|
|
308
284
|
} else {
|
|
309
285
|
const files = fs.readdirSync(userDir).filter((f) => f.endsWith(".md"));
|
|
310
286
|
lines.push(`user dir: ${userDir} (${files.length} file(s))`);
|
|
311
|
-
|
|
312
|
-
// Validate each user agent
|
|
313
287
|
const issues: string[] = [];
|
|
314
288
|
for (const f of files) {
|
|
315
289
|
try {
|
|
316
290
|
const raw = fs.readFileSync(path.join(userDir, f), "utf-8");
|
|
317
|
-
if (!raw.startsWith("---\n")) {
|
|
318
|
-
issues.push(`${f}: no YAML frontmatter`);
|
|
319
|
-
continue;
|
|
320
|
-
}
|
|
291
|
+
if (!raw.startsWith("---\n")) { issues.push(`${f}: no YAML frontmatter`); continue; }
|
|
321
292
|
const m = raw.match(/^---\n([\s\S]*?)\n---/);
|
|
322
293
|
if (!m) { issues.push(`${f}: malformed frontmatter`); continue; }
|
|
323
294
|
const fm = m[1] ?? "";
|
|
@@ -327,19 +298,7 @@ function doctorReport(): string {
|
|
|
327
298
|
issues.push(`${f}: read error: ${(e as Error).message}`);
|
|
328
299
|
}
|
|
329
300
|
}
|
|
330
|
-
|
|
331
|
-
lines.push("validation: all user agents OK ✓");
|
|
332
|
-
} else {
|
|
333
|
-
lines.push("validation issues:");
|
|
334
|
-
for (const i of issues) lines.push(` - ${i}`);
|
|
335
|
-
}
|
|
301
|
+
lines.push(issues.length === 0 ? "validation: all user agents OK ✓" : "validation issues:\n - " + issues.join("\n - "));
|
|
336
302
|
}
|
|
337
|
-
|
|
338
|
-
// Persistence check
|
|
339
|
-
const persisted = process.env.PI_SWITCH_HOME || os.homedir();
|
|
340
|
-
const fallbackFile = path.join(persisted, ".pi-switch", "agent");
|
|
341
|
-
lines.push("");
|
|
342
|
-
lines.push(`persistence: ${fs.existsSync(fallbackFile) ? fallbackFile : "no standalone persistence (uses .soly/agent if soly project)"}`);
|
|
343
|
-
|
|
344
303
|
return lines.join("\n");
|
|
345
304
|
}
|