pi-soly 0.4.0 → 0.5.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/switch/index.ts +131 -176
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-soly",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Project management for pi — plans, state, subagent-driven execution. Inspired by GSD.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
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
- // - Header bar above chat (Claude Code-style, dim, persistent)
7
- // - Ctrl+Shift+S to cycle (Shift+Tab is taken by pi's thinking-level cycler)
8
- // - /agent slash command: show current + available, or set explicitly
9
- // - Persists current agent to .soly/agent (shared with soly) or ~/.pi-switch/agent
10
- // - Exposes `globalThis.__PI_SWITCH_AGENT__` for other extensions to read
11
- // - Injects a system-prompt section so the LLM knows when to use which agent
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,9 @@ export default function piSwitchExtension(pi: ExtensionAPI) {
54
55
  function rerender(): void {
55
56
  if (!lastUi) return;
56
57
  try {
57
- setHeaderBar(lastUi, () => formatHeaderLine(currentAgent));
58
- const badge = formatAgentBadge(currentAgent);
59
- lastUi.setStatus("pi-switch", badge ?? undefined);
58
+ const meta = getAgentMeta(currentAgent);
59
+ const pill = `${meta.emoji} ${currentAgent}`;
60
+ lastUi.setStatus("pi-switch", currentAgent === DEFAULT_AGENT ? null : pill);
60
61
  } catch { /* no ui yet */ }
61
62
  }
62
63
 
@@ -68,11 +69,15 @@ export default function piSwitchExtension(pi: ExtensionAPI) {
68
69
  if (cwd) saveAgent(cwd, next);
69
70
  rerender();
70
71
  if (lastUi) {
71
- lastUi.notify(formatAgentSwitchNotify(prev, next), "info");
72
+ const m = getAgentMeta(next);
73
+ lastUi.notify(
74
+ `${m.emoji} ${next} · ${m.description}${m.writesFiles ? "" : " · read-only"}`,
75
+ "info",
76
+ );
72
77
  }
73
78
  }
74
79
 
75
- // ----- session_start: load persisted agent + set initial header -----
80
+ // ----- session_start: load persisted agent + set initial pill -----
76
81
  pi.on("session_start", async (_event, ctx) => {
77
82
  cwd = ctx.cwd;
78
83
  lastUi = ctx.ui;
@@ -87,27 +92,25 @@ export default function piSwitchExtension(pi: ExtensionAPI) {
87
92
  // ----- before_agent_start: inject system-prompt section -----
88
93
  pi.on("before_agent_start", async (event, ctx) => {
89
94
  lastUi = ctx.ui;
90
- // Re-render the header on each turn (in case agent was changed via /agent)
91
95
  rerender();
92
96
  return {
93
97
  systemPrompt: event.systemPrompt + buildPiSwitchSection(),
94
98
  };
95
99
  });
96
100
 
97
- // ----- Ctrl+Shift+S: cycle to next agent -----
101
+ // ----- Ctrl+Shift+S: hot cycle (no popup, no confirmation) -----
98
102
  pi.registerShortcut("ctrl+shift+s", {
99
- description: "Cycle pi-switch agent: worker → oracle → user-defined…",
103
+ description: "Cycle to next agent (worker → oracle → scout → )",
100
104
  handler: (sctx) => {
101
105
  lastUi = sctx.ui;
102
106
  refreshCycle();
103
- const next = nextAgent(currentAgent, cycle);
104
- setAgent(next);
107
+ setAgent(nextAgent(currentAgent, cycle));
105
108
  },
106
109
  });
107
110
 
108
- // ----- /agent slash command (also handles subcommands: create, doctor) -----
111
+ // ----- /agent: open picker, or subcommands (create / doctor / recommend / set) -----
109
112
  pi.registerCommand("agent", {
110
- description: "show or set the active subagent, or `create <name>` to scaffold, or `doctor` to diagnose",
113
+ description: "open agent picker, or `set <name>`, `create`, `doctor`, `recommend <task>`",
111
114
  handler: async (args, ctx) => {
112
115
  lastUi = ctx.ui;
113
116
  refreshCycle();
@@ -115,115 +118,120 @@ export default function piSwitchExtension(pi: ExtensionAPI) {
115
118
  const subcommand = parts[0]?.toLowerCase();
116
119
  const arg = parts[1];
117
120
 
118
- // Subcommand: create
119
- if (subcommand === "create") {
120
- if (!arg) {
121
- ctx.ui.notify("pi-switch: usage `/agent create <name>`", "info");
122
- return;
123
- }
124
- createAgent(arg, { ui: ctx.ui, cwd });
125
- return;
126
- }
121
+ if (subcommand === "create") return createAgent(arg, ctx.ui, cwd);
122
+ if (subcommand === "doctor") return ctx.ui.notify(doctorReport(), "info");
123
+ if (subcommand === "recommend") return handleRecommend(parts.slice(1).join(" "), ctx.ui);
124
+ if (subcommand === "set" && arg) return handleSet(arg, ctx.ui);
127
125
 
128
- // Subcommand: doctor
129
- if (subcommand === "doctor") {
130
- ctx.ui.notify(doctorReport(), "info");
131
- return;
132
- }
126
+ // Direct agent name → set
127
+ if (subcommand && cycle.includes(subcommand)) return setAgent(subcommand);
128
+ if (arg && !subcommand) return handleSet(arg, ctx.ui);
133
129
 
134
- // Subcommand: recommend
135
- if (subcommand === "recommend") {
136
- const task = parts.slice(1).join(" ");
137
- if (!task) {
138
- ctx.ui.notify("pi-switch: usage — `/agent recommend <task description>`", "info");
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
- }
130
+ // No arg: open picker modal
131
+ openPicker(ctx.ui);
132
+ },
133
+ });
134
+ }
152
135
 
153
- // Direct agent name → set (handles `/agent <name>` as a single-arg shortcut).
154
- // Check FIRST: if parts[0] is a valid agent name, treat as set even
155
- // if it happens to be a "subcommand-looking" word. This way
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
- }
136
+ // ---------------------------------------------------------------------------
137
+ // Picker modal (TUI SelectList)
138
+ // ---------------------------------------------------------------------------
179
139
 
180
- // No arg: show current + grouped available
181
- const curMeta = getAgentMeta(currentAgent);
182
- const groups = groupedAvailableAgents();
183
- const lines: string[] = [
184
- `current: ${curMeta.emoji} ${currentAgent} (${curMeta.description})`,
185
- "",
186
- "available:",
187
- "",
188
- ];
189
- for (const g of groups) {
190
- lines.push(`─── ${g.header} ${"".repeat(Math.max(0, 40 - g.header.length))}`);
191
- for (const a of g.agents) {
192
- const meta = getAgentMeta(a);
193
- const marker = a === currentAgent ? "→" : " ";
194
- lines.push(` ${marker} ${meta.emoji} ${a.padEnd(16)} ${meta.description}`);
195
- }
196
- lines.push("");
140
+ function openPicker(ui: ExtensionUIContext): void {
141
+ refreshAndBuild(ui, (groups) => {
142
+ const all: Array<{ value: string; label: string; description: string; isCurrent: boolean }> = [];
143
+ for (const g of groups) {
144
+ all.push({ value: "__sep__", label: `── ${g.header} `, description: "", isCurrent: false });
145
+ for (const a of g.agents) {
146
+ const m = getAgentMeta(a);
147
+ all.push({
148
+ value: a,
149
+ label: `${m.emoji} ${a}`,
150
+ description: `${m.description}${m.writesFiles ? "" : " · read-only"}`,
151
+ isCurrent: a === currentAgentRef(),
152
+ });
197
153
  }
198
- lines.push("cycle with Ctrl+Shift+S, or `/agent <name>`");
199
- lines.push("subcommands: `/agent create <name>`, `/agent doctor`, `/agent recommend <task>`");
200
- ctx.ui.notify(lines.join("\n"), "info");
201
- },
154
+ }
155
+ return all;
156
+ }, ui, (choice) => {
157
+ if (choice && choice !== "__sep__") setAgentRef(choice);
202
158
  });
203
159
  }
204
160
 
161
+ function handleSet(name: string, ui: ExtensionUIContext): void {
162
+ const target = parseAgentName(name);
163
+ if (!target) return ui.notify(`pi-switch: invalid name "${name}".`, "error");
164
+ if (!availableAgents().includes(target)) {
165
+ return ui.notify(`pi-switch: unknown "${target}". available: ${availableAgents().join(", ")}`, "error");
166
+ }
167
+ setAgentRef(target);
168
+ }
169
+
170
+ function handleRecommend(task: string, ui: ExtensionUIContext): void {
171
+ if (!task) return ui.notify("pi-switch: usage — `/agent recommend <task>`", "info");
172
+ const rec = recommendAgent(task);
173
+ if (!rec) return ui.notify(`pi-switch: no clear match for: "${task}"`, "info");
174
+ ui.notify(`${rec.emoji} ${rec.agent} · why: ${rec.why}\n → /agent ${rec.agent} to switch`, "info");
175
+ }
176
+
205
177
  // ---------------------------------------------------------------------------
206
- // Header bar (Claude Code-style, persistent above chat)
178
+ // setAgent / currentAgent — module-scope so the modal can mutate them
207
179
  // ---------------------------------------------------------------------------
208
180
 
209
- function setHeaderBar(ui: ExtensionUIContext, getLine: () => string): void {
210
- // ui.setHeader takes a factory. The factory is called fresh on each
211
- // render of the TUI. We return a Text whose content is read on each
212
- // render, so updating `currentAgent` automatically reflects in the header.
213
- ui.setHeader((_tui, _theme) => {
214
- // The Text component reads getLine() at render time.
215
- // We use a closure over a getter to read the current value.
216
- const text = new Text(getLine(), 1, 0);
217
- // Text satisfies Component & { dispose?() } — cast to satisfy TS.
218
- return text as unknown as Parameters<typeof ui.setHeader>[0] extends ((t: infer T, th: infer Th) => infer R) ? R : never;
219
- });
181
+ let currentAgentRef: () => string = () => DEFAULT_AGENT;
182
+ let setAgentRef: (next: string) => void = () => {};
183
+
184
+ // The picker and the main extension share state via these refs.
185
+ // We patch them in `wire()` at the top of the default export.
186
+ function wire(get: () => string, set: (n: string) => void): void {
187
+ currentAgentRef = get;
188
+ setAgentRef = set;
189
+ }
190
+
191
+ function refreshAndBuild<T>(
192
+ ui: ExtensionUIContext,
193
+ build: (groups: ReturnType<typeof groupedAvailableAgents>) => T,
194
+ _ui: ExtensionUIContext,
195
+ _onSelect: (value: string) => void,
196
+ ): void {
197
+ // Currently unused: we build inline in openPicker. Kept for future.
198
+ void build;
220
199
  }
221
200
 
222
201
  // ---------------------------------------------------------------------------
223
- // /agent create — scaffold a new agent .md file
202
+ // /agent create — scaffold a new agent .md
224
203
  // ---------------------------------------------------------------------------
225
204
 
226
- /** Template for a new user agent. User edits the system prompt to specialize. */
205
+ function createAgent(
206
+ name: string | undefined,
207
+ ui: { notify: (t: string, k?: "info" | "warning" | "error") => void; input: (t: string, p?: string) => Promise<string | undefined> },
208
+ cwd: string,
209
+ ): void {
210
+ if (!name) {
211
+ ui.notify("pi-switch: usage — `/agent create <name>`", "info");
212
+ return;
213
+ }
214
+ if (!parseAgentName(name)) {
215
+ ui.notify(`pi-switch: invalid name "${name}". Use letters/digits/dashes/underscores, ≤64 chars.`, "error");
216
+ return;
217
+ }
218
+ const userDir = path.join(os.homedir(), ".pi", "agent", "agents");
219
+ fs.mkdirSync(userDir, { recursive: true });
220
+ const file = path.join(userDir, `${name}.md`);
221
+ if (fs.existsSync(file)) {
222
+ ui.notify(`pi-switch: ${file} already exists. edit it directly.`, "warning");
223
+ return;
224
+ }
225
+ void ui.input(`description for "${name}":`, "one-liner that shows in the picker")?.then((desc) => {
226
+ const description = desc?.trim() || `custom agent (${name})`;
227
+ fs.writeFileSync(file, agentTemplate(name, description), "utf-8");
228
+ ui.notify(
229
+ `pi-switch: created ${file}\n → next Ctrl+Shift+S to see it in the cycle\n → edit the system prompt to specialize`,
230
+ "info",
231
+ );
232
+ });
233
+ }
234
+
227
235
  function agentTemplate(name: string, description: string): string {
228
236
  return `---
229
237
  name: ${name}
@@ -259,65 +267,24 @@ you have a specific reason to change it.
259
267
  `;
260
268
  }
261
269
 
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
270
  function doctorReport(): string {
295
271
  const cycle = availableAgents();
296
272
  const userDir = path.join(os.homedir(), ".pi", "agent", "agents");
297
273
  const lines: string[] = ["pi-switch doctor:", ""];
298
-
299
- // Cycle stats
300
274
  const builtins = cycle.filter((a) => BUILTIN_AGENTS.includes(a));
301
275
  const users = cycle.filter((a) => !BUILTIN_AGENTS.includes(a));
302
276
  lines.push(`cycle: ${cycle.length} agents (${builtins.length} built-in, ${users.length} user)`);
303
277
  lines.push("");
304
-
305
- // User dir check
306
278
  if (!fs.existsSync(userDir)) {
307
- lines.push(`user dir: ${userDir} (does not exist — user agents won't be discovered)`);
279
+ lines.push(`user dir: ${userDir} (does not exist)`);
308
280
  } else {
309
281
  const files = fs.readdirSync(userDir).filter((f) => f.endsWith(".md"));
310
282
  lines.push(`user dir: ${userDir} (${files.length} file(s))`);
311
-
312
- // Validate each user agent
313
283
  const issues: string[] = [];
314
284
  for (const f of files) {
315
285
  try {
316
286
  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
- }
287
+ if (!raw.startsWith("---\n")) { issues.push(`${f}: no YAML frontmatter`); continue; }
321
288
  const m = raw.match(/^---\n([\s\S]*?)\n---/);
322
289
  if (!m) { issues.push(`${f}: malformed frontmatter`); continue; }
323
290
  const fm = m[1] ?? "";
@@ -327,19 +294,7 @@ function doctorReport(): string {
327
294
  issues.push(`${f}: read error: ${(e as Error).message}`);
328
295
  }
329
296
  }
330
- if (issues.length === 0) {
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
- }
297
+ lines.push(issues.length === 0 ? "validation: all user agents OK ✓" : "validation issues:\n - " + issues.join("\n - "));
336
298
  }
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
299
  return lines.join("\n");
345
300
  }