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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/switch/index.ts +135 -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.1",
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,13 @@ 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
+ // 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
- lastUi.notify(formatAgentSwitchNotify(prev, next), "info");
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 header -----
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 to next agent -----
105
+ // ----- Ctrl+Shift+S: hot cycle (no popup, no confirmation) -----
98
106
  pi.registerShortcut("ctrl+shift+s", {
99
- description: "Cycle pi-switch agent: worker → oracle → user-defined…",
107
+ description: "Cycle to next agent (worker → oracle → scout → )",
100
108
  handler: (sctx) => {
101
109
  lastUi = sctx.ui;
102
110
  refreshCycle();
103
- const next = nextAgent(currentAgent, cycle);
104
- setAgent(next);
111
+ setAgent(nextAgent(currentAgent, cycle));
105
112
  },
106
113
  });
107
114
 
108
- // ----- /agent slash command (also handles subcommands: create, doctor) -----
115
+ // ----- /agent: open picker, or subcommands (create / doctor / recommend / set) -----
109
116
  pi.registerCommand("agent", {
110
- description: "show or set the active subagent, or `create <name>` to scaffold, or `doctor` to diagnose",
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
- // 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
- }
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
- // Subcommand: doctor
129
- if (subcommand === "doctor") {
130
- ctx.ui.notify(doctorReport(), "info");
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
- // 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
- }
134
+ // No arg: open picker modal
135
+ openPicker(ctx.ui);
136
+ },
137
+ });
138
+ }
152
139
 
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
- }
140
+ // ---------------------------------------------------------------------------
141
+ // Picker modal (TUI SelectList)
142
+ // ---------------------------------------------------------------------------
179
143
 
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("");
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
- 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
- },
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
- // Header bar (Claude Code-style, persistent above chat)
182
+ // setAgent / currentAgent — module-scope so the modal can mutate them
207
183
  // ---------------------------------------------------------------------------
208
184
 
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
- });
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 file
206
+ // /agent create — scaffold a new agent .md
224
207
  // ---------------------------------------------------------------------------
225
208
 
226
- /** Template for a new user agent. User edits the system prompt to specialize. */
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 — user agents won't be discovered)`);
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
- 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
- }
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
  }