@pi-kaush/pi-agent-mode 0.1.0 → 0.1.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-kaush/pi-agent-mode",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Activate a configured Pi agent as a persistent mode in the current session.",
5
5
  "license": "MIT",
6
6
  "author": "Kaushik Gopal",
@@ -34,11 +34,15 @@
34
34
  ]
35
35
  },
36
36
  "peerDependencies": {
37
- "@earendil-works/pi-coding-agent": ">=0.80.6"
37
+ "@earendil-works/pi-coding-agent": ">=0.80.6",
38
+ "@earendil-works/pi-tui": ">=0.80.6"
38
39
  },
39
40
  "peerDependenciesMeta": {
40
41
  "@earendil-works/pi-coding-agent": {
41
42
  "optional": true
43
+ },
44
+ "@earendil-works/pi-tui": {
45
+ "optional": true
42
46
  }
43
47
  },
44
48
  "engines": {
@@ -82,31 +82,46 @@ function loadAgentsFromDir(
82
82
  continue;
83
83
  }
84
84
 
85
- const { frontmatter, body } = parseFrontmatter<AgentFrontmatter>(content);
86
-
87
- if (!frontmatter.name || !frontmatter.description) {
85
+ try {
86
+ const { frontmatter, body } = parseFrontmatter<AgentFrontmatter>(content);
87
+ const name =
88
+ typeof frontmatter.name === "string" ? frontmatter.name : undefined;
89
+ const description =
90
+ typeof frontmatter.description === "string"
91
+ ? frontmatter.description
92
+ : undefined;
93
+ if (!name || !description) continue;
94
+
95
+ const tools =
96
+ typeof frontmatter.tools === "string"
97
+ ? frontmatter.tools
98
+ .split(",")
99
+ .map((tool) => tool.trim())
100
+ .filter(Boolean)
101
+ : undefined;
102
+ const emoji =
103
+ typeof frontmatter.emoji === "string"
104
+ ? frontmatter.emoji.trim()
105
+ : undefined;
106
+ const model =
107
+ typeof frontmatter.model === "string" ? frontmatter.model : undefined;
108
+
109
+ agents.push({
110
+ name,
111
+ description,
112
+ ...(emoji ? { emoji } : {}),
113
+ ...(tools && tools.length > 0 ? { tools } : {}),
114
+ ...(model ? { model } : {}),
115
+ ...(typeof frontmatter.confirmProjectAgents === "boolean"
116
+ ? { confirmProjectAgents: frontmatter.confirmProjectAgents }
117
+ : {}),
118
+ systemPrompt: body,
119
+ source,
120
+ filePath,
121
+ });
122
+ } catch {
88
123
  continue;
89
124
  }
90
-
91
- const tools = frontmatter.tools
92
- ?.split(",")
93
- .map((t: string) => t.trim())
94
- .filter(Boolean);
95
-
96
- const emoji = frontmatter.emoji?.trim();
97
- agents.push({
98
- name: frontmatter.name,
99
- description: frontmatter.description,
100
- ...(emoji ? { emoji } : {}),
101
- ...(tools && tools.length > 0 ? { tools } : {}),
102
- ...(frontmatter.model ? { model: frontmatter.model } : {}),
103
- ...(typeof frontmatter.confirmProjectAgents === "boolean"
104
- ? { confirmProjectAgents: frontmatter.confirmProjectAgents }
105
- : {}),
106
- systemPrompt: body,
107
- source,
108
- filePath,
109
- });
110
125
  }
111
126
 
112
127
  return agents;
@@ -148,14 +163,8 @@ export function discoverAgents(
148
163
 
149
164
  const agentMap = new Map<string, AgentConfig>();
150
165
 
151
- if (scope === "both") {
152
- for (const agent of userAgents) agentMap.set(agent.name, agent);
153
- for (const agent of projectAgents) agentMap.set(agent.name, agent);
154
- } else if (scope === "user") {
155
- for (const agent of userAgents) agentMap.set(agent.name, agent);
156
- } else {
157
- for (const agent of projectAgents) agentMap.set(agent.name, agent);
158
- }
166
+ for (const agent of userAgents) agentMap.set(agent.name, agent);
167
+ for (const agent of projectAgents) agentMap.set(agent.name, agent);
159
168
 
160
169
  return { agents: Array.from(agentMap.values()), projectAgentsDir };
161
170
  }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * TUI picker for `/agent`: SelectList with per-item descriptions, theme
3
+ * colors, agent emoji kept in the label, and a ● marker on the active agent.
4
+ * Non-TUI modes fall back to plain ctx.ui.select in the command handler.
5
+ */
6
+
7
+ import {
8
+ DynamicBorder,
9
+ type ExtensionCommandContext,
10
+ } from "@earendil-works/pi-coding-agent";
11
+ import {
12
+ Container,
13
+ SelectList,
14
+ Text,
15
+ type SelectItem,
16
+ } from "@earendil-works/pi-tui";
17
+ import { formatAgentDisplayName, type AgentConfig } from "./agent-discovery.ts";
18
+
19
+ export const NONE_VALUE = "none";
20
+
21
+ export function buildAgentPickerItems(
22
+ agents: AgentConfig[],
23
+ activeName: string | undefined,
24
+ ): SelectItem[] {
25
+ const items: SelectItem[] = agents.map((agent) => {
26
+ const marker = agent.name === activeName ? "● " : "";
27
+ const origin = agent.source === "project" ? "Project agent. " : "";
28
+ return {
29
+ value: agent.name,
30
+ label: `${marker}${formatAgentDisplayName(agent)}`,
31
+ description: `${origin}${agent.description}`,
32
+ };
33
+ });
34
+ if (activeName) {
35
+ items.push({
36
+ value: NONE_VALUE,
37
+ label: "None",
38
+ description: "Restore the default model, thinking level, and tools",
39
+ });
40
+ }
41
+ return items;
42
+ }
43
+
44
+ /** Resolves with the picked agent name, NONE_VALUE, or null when cancelled. */
45
+ export async function showAgentPicker(
46
+ ctx: ExtensionCommandContext,
47
+ items: SelectItem[],
48
+ ): Promise<string | null> {
49
+ return ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
50
+ const container = new Container();
51
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
52
+ container.addChild(
53
+ new Text(theme.fg("accent", theme.bold("Activate agent")), 1, 0),
54
+ );
55
+
56
+ const list = new SelectList(items, Math.min(items.length, 10), {
57
+ selectedPrefix: (t: string) => theme.fg("accent", t),
58
+ selectedText: (t: string) => theme.fg("accent", t),
59
+ description: (t: string) => theme.fg("muted", t),
60
+ scrollInfo: (t: string) => theme.fg("dim", t),
61
+ noMatch: (t: string) => theme.fg("warning", t),
62
+ });
63
+ list.onSelect = (item: SelectItem) => done(item.value);
64
+ list.onCancel = () => done(null);
65
+ container.addChild(list);
66
+
67
+ container.addChild(
68
+ new Text(
69
+ theme.fg("dim", "↑↓ navigate • enter select • esc cancel ● active"),
70
+ 1,
71
+ 0,
72
+ ),
73
+ );
74
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
75
+
76
+ return {
77
+ render: (width: number) => container.render(width),
78
+ invalidate: () => container.invalidate(),
79
+ handleInput: (data: string) => {
80
+ list.handleInput(data);
81
+ tui.requestRender();
82
+ },
83
+ };
84
+ });
85
+ }
package/src/index.ts CHANGED
@@ -21,18 +21,19 @@ import {
21
21
  type AgentConfig,
22
22
  } from "./agent-discovery.ts";
23
23
  import { parseAgentModelSpec, type ThinkingLevel } from "./model-spec.ts";
24
+ import {
25
+ buildAgentPickerItems,
26
+ NONE_VALUE,
27
+ showAgentPicker,
28
+ } from "./agent-picker.ts";
24
29
 
25
30
  interface ModelReference {
26
31
  provider: string;
27
32
  id: string;
28
33
  }
29
34
 
30
- export interface AgentModelSpec {
31
- model: string;
32
- thinkingLevel?: ThinkingLevel;
33
- }
34
-
35
35
  export { parseAgentModelSpec };
36
+ export type { AgentModelSpec } from "./model-spec.ts";
36
37
 
37
38
  // Durable session state is namespaced so it cannot collide with other
38
39
  // packages. Legacy Aikado sessions wrote `active-agent-state`; restore reads
@@ -62,6 +63,42 @@ interface ActiveAgentState {
62
63
  baseline?: AgentBaseline;
63
64
  }
64
65
 
66
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
67
+ if (typeof value !== "object" || value === null) return false;
68
+ const prototype = Object.getPrototypeOf(value);
69
+ return prototype === Object.prototype || prototype === null;
70
+ }
71
+
72
+ function isActiveAgentState(value: unknown): value is ActiveAgentState {
73
+ if (!isPlainObject(value) || typeof value.active !== "boolean") return false;
74
+ if (
75
+ value.active &&
76
+ (typeof value.name !== "string" || value.name.length === 0)
77
+ )
78
+ return false;
79
+
80
+ if (value.baseline !== undefined) {
81
+ if (!isPlainObject(value.baseline)) return false;
82
+ if (typeof value.baseline.thinkingLevel !== "string") return false;
83
+ if (
84
+ !Array.isArray(value.baseline.tools) ||
85
+ !value.baseline.tools.every((tool) => typeof tool === "string")
86
+ )
87
+ return false;
88
+
89
+ if (value.baseline.model !== undefined) {
90
+ if (
91
+ !isPlainObject(value.baseline.model) ||
92
+ typeof value.baseline.model.provider !== "string" ||
93
+ typeof value.baseline.model.id !== "string"
94
+ )
95
+ return false;
96
+ }
97
+ }
98
+
99
+ return true;
100
+ }
101
+
65
102
  export function registerAgentMode(pi: ExtensionAPI) {
66
103
  let activeAgent: ActiveAgent | undefined;
67
104
 
@@ -192,8 +229,12 @@ export function registerAgentMode(pi: ExtensionAPI) {
192
229
  return false;
193
230
  }
194
231
  thinkingLevel = modelSpec.thinkingLevel ?? baseline.thinkingLevel;
195
- } else {
196
- await restoreModel(baseline.model, ctx);
232
+ } else if (!(await restoreModel(baseline.model, ctx))) {
233
+ ctx.ui.notify(
234
+ `Agent ${formatAgentDisplayName(agent)}: could not restore the baseline model; activation aborted.`,
235
+ "error",
236
+ );
237
+ return false;
197
238
  }
198
239
 
199
240
  pi.setThinkingLevel(thinkingLevel);
@@ -210,15 +251,17 @@ export function registerAgentMode(pi: ExtensionAPI) {
210
251
  return;
211
252
  }
212
253
 
213
- await restoreModel(activeAgent.baseline.model, ctx);
254
+ const modelRestored = await restoreModel(activeAgent.baseline.model, ctx);
214
255
  pi.setThinkingLevel(activeAgent.baseline.thinkingLevel);
215
256
  pi.setActiveTools(activeAgent.baseline.tools);
216
257
  activeAgent = undefined;
217
258
  updateAgentStatus(ctx);
218
259
  persistActiveAgent();
219
260
  ctx.ui.notify(
220
- "Active agent cleared; previous model and tools restored.",
221
- "info",
261
+ modelRestored
262
+ ? "Active agent cleared; previous model and tools restored."
263
+ : "Active agent cleared, but the previous model could not be restored.",
264
+ modelRestored ? "info" : "warning",
222
265
  );
223
266
  }
224
267
 
@@ -272,13 +315,52 @@ export function registerAgentMode(pi: ExtensionAPI) {
272
315
  );
273
316
  return;
274
317
  }
275
- const choices = agents.map(
276
- (candidate) =>
277
- `${formatAgentDisplayName(candidate)} — ${candidate.description}`,
278
- );
279
- const choice = await ctx.ui.select("Activate agent:", choices);
280
- if (!choice) return;
281
- agent = agents[choices.indexOf(choice)];
318
+ let picked: string | null;
319
+ if (ctx.mode === "tui") {
320
+ picked = await showAgentPicker(
321
+ ctx,
322
+ buildAgentPickerItems(agents, activeAgent?.agent.name),
323
+ );
324
+ } else {
325
+ const items = buildAgentPickerItems(agents, activeAgent?.agent.name);
326
+ const baseLabels = items.map(
327
+ (item) => `${item.label} — ${item.description}`,
328
+ );
329
+ const labelCounts = new Map<string, number>();
330
+ for (const label of baseLabels) {
331
+ labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
332
+ }
333
+ const labels = baseLabels.map((label, index) =>
334
+ labelCounts.get(label)! > 1
335
+ ? `${label} [${items[index]!.value}]`
336
+ : label,
337
+ );
338
+ const choice = await ctx.ui.select("Activate agent:", labels);
339
+ if (choice === undefined) {
340
+ picked = null;
341
+ } else {
342
+ const firstMatch = labels.indexOf(choice);
343
+ if (
344
+ firstMatch !== -1 &&
345
+ labels.lastIndexOf(choice) !== firstMatch
346
+ ) {
347
+ ctx.ui.notify(
348
+ "That selection matches more than one agent; rename one of the duplicates.",
349
+ "warning",
350
+ );
351
+ picked = null;
352
+ } else {
353
+ picked =
354
+ firstMatch === -1 ? null : (items[firstMatch]?.value ?? null);
355
+ }
356
+ }
357
+ }
358
+ if (!picked) return;
359
+ if (picked === NONE_VALUE) {
360
+ await clearActiveAgent(ctx);
361
+ return;
362
+ }
363
+ agent = agents.find((candidate) => candidate.name === picked);
282
364
  }
283
365
 
284
366
  if (!agent || !(await confirmProjectAgent(agent, ctx))) return;
@@ -302,7 +384,8 @@ export function registerAgentMode(pi: ExtensionAPI) {
302
384
  .filter(
303
385
  (entry): entry is CustomEntry<ActiveAgentState> =>
304
386
  entry.type === "custom" &&
305
- ACTIVE_AGENT_STATE_TYPES.includes(entry.customType),
387
+ ACTIVE_AGENT_STATE_TYPES.includes(entry.customType) &&
388
+ isActiveAgentState(entry.data),
306
389
  )
307
390
  .pop();
308
391
  const state = stateEntry?.data;