pi-subagents-lite 0.2.0 → 0.3.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/src/menus.ts ADDED
@@ -0,0 +1,866 @@
1
+ /**
2
+ * menus.ts — /agents command menu system.
3
+ *
4
+ * All menu-related functions extracted from index.ts.
5
+ * Imports shared state (config, manager, piInstance) from index.ts.
6
+ */
7
+
8
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
9
+ import { getAgentConfig, getAvailableTypes, getAllTypes } from "./agent-types.js";
10
+ import type { AgentRecord } from "./types.js";
11
+ import { ModelSelectorDialog, type ModelOption } from "./model-selector.js";
12
+ import { ResultViewer, type ResultViewerStats } from "./result-viewer.js";
13
+ import { getDisplayName } from "./ui/agent-widget.js";
14
+ import { buildSnapshotMarkdown } from "./context.js";
15
+
16
+ import { parseModelKey, errorMessage } from "./utils.js";
17
+ import {
18
+ __config,
19
+ sessionOverrides,
20
+ manager,
21
+ piInstance,
22
+ } from "./index.js";
23
+ import { resolveModel } from "./model-precedence.js";
24
+ import { saveConfigAtomic } from "./config-io.js";
25
+
26
+ // ============================================================================
27
+ // Helpers
28
+ // ============================================================================
29
+
30
+ /**
31
+ * Build ModelOption[] from raw "provider/model-id" strings.
32
+ * Includes "(inherits parent)" as the first option.
33
+ */
34
+ function buildModelOptions(rawOptions: string[]): ModelOption[] {
35
+ const items: ModelOption[] = [
36
+ { value: "(inherits parent)", label: "(inherits parent)", provider: "" },
37
+ ];
38
+
39
+ for (const opt of rawOptions) {
40
+ const parsed = parseModelKey(opt);
41
+ if (!parsed) continue;
42
+ items.push({ value: opt, label: parsed.modelId, provider: parsed.provider });
43
+ }
44
+ return items;
45
+ }
46
+
47
+ /**
48
+ * Show the ModelSelectorDialog and return the chosen model string, or null.
49
+ */
50
+ async function promptModelSelection(
51
+ ctx: ExtensionCommandContext,
52
+ modelOptions: string[],
53
+ currentValue: string,
54
+ ): Promise<string | null> {
55
+ return ctx.ui.custom<string | null>(
56
+ (_tui, theme, _kb, done) => {
57
+ const opts = buildModelOptions(modelOptions);
58
+ return new ModelSelectorDialog(opts, currentValue, {
59
+ onSelect: (m) => done(m),
60
+ onCancel: () => done(null),
61
+ }, theme);
62
+ }, // no overlay — renders inline below editor, matching pi's model selector look and feel
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Prompt user to choose between session-only or permanent persistence.
68
+ * When showClear is true, also offers "Clear".
69
+ * Returns "session", "permanent", "clear", or null if cancelled.
70
+ */
71
+ async function promptOverrideMode(
72
+ ctx: ExtensionCommandContext,
73
+ showClear: boolean = false,
74
+ ): Promise<"session" | "permanent" | "clear" | null> {
75
+ const choices: string[] = [
76
+ "Set for this session (not saved)",
77
+ "Set permanently (saved to config)",
78
+ ];
79
+ if (showClear) {
80
+ choices.push("Clear");
81
+ }
82
+ const choice = await ctx.ui.select("Save mode", choices);
83
+ if (choice === undefined) return null;
84
+ if (choice.startsWith("Set for this session")) return "session";
85
+ if (choice.startsWith("Set permanently")) return "permanent";
86
+ return "clear";
87
+ }
88
+
89
+ /**
90
+ * Prompt for a model selection and apply it as an override.
91
+ * "(inherits parent)" clears the override (sets to null).
92
+ * The caller is responsible for persistence (saveConfigAtomic).
93
+ */
94
+ async function applyModelOverride(
95
+ ctx: ExtensionCommandContext,
96
+ modelOptions: string[],
97
+ label: string,
98
+ currentValue: string,
99
+ apply: (chosen: string | null) => void,
100
+ ): Promise<void> {
101
+ const chosen = await promptModelSelection(ctx, modelOptions, currentValue);
102
+ if (chosen === null) return;
103
+
104
+ const effective = chosen === "(inherits parent)" ? null : chosen;
105
+ apply(effective);
106
+ ctx.ui.notify(
107
+ effective === null
108
+ ? `${label} inherits parent model`
109
+ : `${label} model set to ${effective}`,
110
+ "info",
111
+ );
112
+ }
113
+
114
+ /**
115
+ * Persist concurrency config to disk and apply to the running manager.
116
+ */
117
+ function applyConcurrencyConfig(): void {
118
+ saveConfigAtomic(__config);
119
+ manager?.setConcurrency(__config.concurrency);
120
+ }
121
+
122
+ /**
123
+ * Parse a concurrency input: prompt, validate (integer ≥ 1), return parsed value or undefined.
124
+ */
125
+ async function parseConcurrencyInput(
126
+ ctx: ExtensionCommandContext,
127
+ label: string,
128
+ initialValue: string,
129
+ ): Promise<number | undefined> {
130
+ const input = await ctx.ui.input(label, initialValue);
131
+ if (input === undefined) return undefined;
132
+ const parsed = parseInt(input.trim(), 10);
133
+ if (isNaN(parsed) || parsed < 1) {
134
+ ctx.ui.notify("Invalid value — must be a number ≥ 1", "error");
135
+ return undefined;
136
+ }
137
+ return parsed;
138
+ }
139
+
140
+ /**
141
+ * Prompt for a concurrency value, validate, save and apply.
142
+ * Used for editing an existing concurrency limit.
143
+ */
144
+ async function promptConcurrencyInput(
145
+ ctx: ExtensionCommandContext,
146
+ label: string,
147
+ currentValue: number,
148
+ apply: (value: number) => void,
149
+ ): Promise<void> {
150
+ const parsed = await parseConcurrencyInput(ctx, label, String(currentValue));
151
+ if (parsed === undefined) return;
152
+ apply(parsed);
153
+ applyConcurrencyConfig();
154
+ ctx.ui.notify(
155
+ `${label.replace("Concurrency slots for ", "")} concurrency set to ${parsed}`,
156
+ "info",
157
+ );
158
+ }
159
+
160
+ /**
161
+ * Prompt to add a new concurrency limit for a named entity.
162
+ */
163
+ async function promptAddConcurrencyLimit(
164
+ ctx: ExtensionCommandContext,
165
+ label: string,
166
+ apply: (key: string, value: number) => void,
167
+ ): Promise<void> {
168
+ const parsed = await parseConcurrencyInput(ctx, "Concurrency slots", "1");
169
+ if (parsed === undefined) return;
170
+ apply(label, parsed);
171
+ applyConcurrencyConfig();
172
+ ctx.ui.notify(`${label} concurrency set to ${parsed}`, "info");
173
+ }
174
+
175
+ /**
176
+ * Show a select menu once, dispatch the chosen action.
177
+ * Used by the per-agent action sub-menu (single-shot, not a loop).
178
+ */
179
+ async function runMenu(
180
+ ctx: ExtensionCommandContext,
181
+ title: string,
182
+ items: string[],
183
+ actions: Array<() => Promise<void>>,
184
+ ): Promise<void> {
185
+ const choice = await ctx.ui.select(title, items);
186
+ if (choice === undefined) return;
187
+ const idx = items.indexOf(choice);
188
+ if (idx >= 0 && idx < actions.length) {
189
+ await actions[idx]();
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Loop a menu until the user presses Escape or selects "Back".
195
+ * Rebuilds items/actions each iteration so the display stays fresh.
196
+ * Appends blank spacer + "Back" automatically.
197
+ * Used by model settings, concurrency settings, and running agents menus.
198
+ */
199
+ async function runMenuLoop(
200
+ ctx: ExtensionCommandContext,
201
+ title: string,
202
+ build: () => { items: string[]; actions: Array<() => Promise<void>> },
203
+ ): Promise<void> {
204
+ while (true) {
205
+ const { items, actions } = build();
206
+ items.push("");
207
+ actions.push(async () => {});
208
+ items.push("Back");
209
+ actions.push(async () => {});
210
+
211
+ const choice = await ctx.ui.select(title, items);
212
+ if (choice === undefined || choice === "Back") return;
213
+ const idx = items.indexOf(choice);
214
+ if (idx >= 0 && idx < actions.length) {
215
+ await actions[idx]();
216
+ }
217
+ }
218
+ }
219
+
220
+ // ============================================================================
221
+ // /agents command handler
222
+ // ============================================================================
223
+
224
+ export async function showModelSettingsMenu(
225
+ ctx: ExtensionCommandContext,
226
+ modelOptions: string[],
227
+ ): Promise<void> {
228
+ return runMenuLoop(ctx, "Model Settings", () => {
229
+ const items: string[] = [];
230
+ const actions: Array<() => Promise<void>> = [];
231
+
232
+ // ── Session overrides section ──
233
+ const hasSessionOverrides = Object.entries(sessionOverrides).some(
234
+ ([, v]) => v != null,
235
+ );
236
+
237
+ const buildOverrideAction = (
238
+ label: string,
239
+ targetKey: string,
240
+ currentValue: string,
241
+ hasPermanentOverride: boolean = false,
242
+ ) => async () => {
243
+ const mode = await promptOverrideMode(ctx, hasPermanentOverride);
244
+ if (mode === null) return;
245
+
246
+ // Handle "clear" — remove all overrides (session + config) and save
247
+ if (mode === "clear") {
248
+ delete __config.agent[targetKey];
249
+ if (targetKey !== "default") {
250
+ delete sessionOverrides[targetKey];
251
+ } else {
252
+ sessionOverrides.default = null;
253
+ }
254
+ saveConfigAtomic(__config);
255
+ ctx.ui.notify(`${label} overrides cleared`, "info");
256
+ return;
257
+ }
258
+
259
+ const isSession = mode === "session";
260
+ await applyModelOverride(
261
+ ctx, modelOptions, label,
262
+ currentValue,
263
+ isSession
264
+ ? (chosen) => { sessionOverrides[targetKey] = chosen; }
265
+ : (chosen) => {
266
+ __config.agent[targetKey] = chosen;
267
+ },
268
+ );
269
+ if (!isSession) {
270
+ saveConfigAtomic(__config);
271
+ }
272
+ };
273
+
274
+ // Global default — show session value if present
275
+ const hasSessionGlobal = sessionOverrides.default != null;
276
+ const globalLabel = hasSessionGlobal
277
+ ? `Global default model · ${sessionOverrides.default} [session]`
278
+ : __config.agent.default
279
+ ? `Global default model · ${__config.agent.default}`
280
+ : "Global default model · (inherits parent)";
281
+ items.push(globalLabel);
282
+ actions.push(buildOverrideAction(
283
+ "Global default", "default",
284
+ hasSessionGlobal
285
+ ? sessionOverrides.default!
286
+ : __config.agent.default ?? "(inherits parent)",
287
+ ));
288
+
289
+ // Force background toggle
290
+ const forceBgLabel = __config.agent.forceBackground
291
+ ? "Force background · ON"
292
+ : "Force background · OFF";
293
+ items.push(forceBgLabel);
294
+ actions.push(async () => {
295
+ __config.agent.forceBackground = !__config.agent.forceBackground;
296
+ saveConfigAtomic(__config);
297
+ ctx.ui.notify(
298
+ `Force background ${__config.agent.forceBackground ? "ON" : "OFF"}`,
299
+ "info",
300
+ );
301
+ });
302
+
303
+ items.push("");
304
+ actions.push(async () => {});
305
+ items.push("─── per-type overrides ───");
306
+ actions.push(async () => {}); // separator
307
+
308
+ // Per-type overrides — show only types with an explicit override (session or config)
309
+ // All others inherit the global default; accessible via "Override another type..."
310
+ const types = getAllTypes();
311
+ const typeEntries = types.map((typeName) => {
312
+ const cfg = getAgentConfig(typeName);
313
+ const sessionOverride = sessionOverrides[typeName];
314
+ const configOverride = __config.agent[typeName];
315
+ const hasSession = sessionOverride != null;
316
+ const hasConfigOverride = configOverride != null && typeof configOverride === "string";
317
+ const effectiveModel = resolveModel({
318
+ subagentType: typeName,
319
+ agentConfig: cfg,
320
+ config: __config,
321
+ parentModelId: "(inherits parent)",
322
+ sessionOverrides,
323
+ });
324
+ return { typeName, cfg, sessionOverride, configOverride, hasSession, hasConfigOverride, effectiveModel };
325
+ });
326
+
327
+ const overridden = typeEntries.filter(e => e.hasSession || e.hasConfigOverride);
328
+ const nonOverridden = typeEntries.filter(e => !e.hasSession && !e.hasConfigOverride);
329
+
330
+ if (overridden.length === 0) {
331
+ items.push(" (all inherit global default)");
332
+ actions.push(async () => {}); // no-op
333
+ } else {
334
+ overridden.sort((a, b) => a.effectiveModel.localeCompare(b.effectiveModel));
335
+ const padLen = Math.max(...types.map(t => t.length));
336
+ for (const { typeName, cfg, sessionOverride, configOverride, hasSession, effectiveModel } of overridden) {
337
+ const frontmatterHint = !hasSession && configOverride && cfg?.model ? `${cfg.model} → ` : "";
338
+ const displayModel = hasSession ? `${sessionOverride} [session]` : effectiveModel;
339
+ items.push(`${typeName.padEnd(padLen)} · ${frontmatterHint}${displayModel}`);
340
+
341
+ const currentValue = hasSession ? sessionOverride! : effectiveModel;
342
+ actions.push(buildOverrideAction(typeName, typeName, currentValue, !!configOverride));
343
+ }
344
+ }
345
+
346
+ // Add override for a type that currently inherits
347
+ if (nonOverridden.length > 0) {
348
+ items.push("Override another type...");
349
+ actions.push(async () => {
350
+ const typeNames = nonOverridden.map(e => e.typeName);
351
+ const chosen = await ctx.ui.select("Select agent type", typeNames);
352
+ if (chosen === undefined) return;
353
+ const entry = nonOverridden.find(e => e.typeName === chosen)!;
354
+ const action = buildOverrideAction(chosen, chosen, entry.effectiveModel, false);
355
+ await action();
356
+ });
357
+ }
358
+
359
+ // Clear session overrides
360
+ if (hasSessionOverrides) {
361
+ items.push("Clear session overrides");
362
+ actions.push(async () => {
363
+ sessionOverrides.default = null;
364
+ for (const key of Object.keys(sessionOverrides)) {
365
+ if (key !== "default") {
366
+ delete sessionOverrides[key];
367
+ }
368
+ }
369
+ ctx.ui.notify("Session overrides cleared", "info");
370
+ });
371
+ }
372
+
373
+ // Clear all overrides
374
+ items.push("Clear all overrides");
375
+ actions.push(async () => {
376
+ const hasOverrides = Object.entries(__config.agent).some(
377
+ ([k, v]) => k !== "default" && k !== "forceBackground" && v != null,
378
+ );
379
+ if (!hasOverrides && __config.agent.default === null) {
380
+ ctx.ui.notify("No overrides to clear", "info");
381
+ return;
382
+ }
383
+ __config.agent = { default: __config.agent.default, forceBackground: __config.agent.forceBackground };
384
+ saveConfigAtomic(__config);
385
+ ctx.ui.notify("All model overrides cleared", "info");
386
+ });
387
+
388
+ return { items, actions };
389
+ });
390
+ }
391
+
392
+ export async function showAgentsMainMenu(
393
+ ctx: ExtensionCommandContext,
394
+ modelOptions: string[],
395
+ ): Promise<void> {
396
+ const menuItems = [
397
+ "1. Model settings — Set global default and per-type model overrides",
398
+ "2. Concurrency settings — Set per-model slot limits",
399
+ "3. Running agents — List running/queued agents",
400
+ "4. Debug — Agent types, briefing, diagnostics",
401
+ "",
402
+ "Press Escape to close",
403
+ ];
404
+
405
+ // Loop so sub-menus navigate back to root; only Escape at root closes
406
+ while (true) {
407
+ const choice = await ctx.ui.select("Subagents Management", menuItems);
408
+ if (choice === undefined || choice === "Press Escape to close") return;
409
+
410
+ if (choice.startsWith("1.")) {
411
+ await showModelSettingsMenu(ctx, modelOptions);
412
+ } else if (choice.startsWith("2.")) {
413
+ await showConcurrencySettingsMenu(ctx, modelOptions);
414
+ } else if (choice.startsWith("3.")) {
415
+ await showRunningAgentsMenu(ctx);
416
+ } else if (choice.startsWith("4.")) {
417
+ await showDebugMenu(ctx);
418
+ }
419
+ }
420
+ }
421
+
422
+ async function showDebugMenu(ctx: ExtensionCommandContext): Promise<void> {
423
+ const menuItems = [
424
+ "1. Agent types — List available agent types and their configs",
425
+ "2. Agent briefing — Send agent types/capabilities info to LLM (Optional, if having issues)",
426
+ ];
427
+
428
+ while (true) {
429
+ const choice = await ctx.ui.select("Debug", menuItems);
430
+ if (choice === undefined) return;
431
+
432
+ if (choice.startsWith("1.")) {
433
+ await showAgentTypes(ctx);
434
+ } else if (choice.startsWith("2.")) {
435
+ await handleAgentBriefing(ctx);
436
+ }
437
+ }
438
+ }
439
+
440
+ async function handleAgentBriefing(ctx: ExtensionCommandContext): Promise<void> {
441
+ const types = getAvailableTypes();
442
+ const agents = types.map((t) => ({ name: t, config: getAgentConfig(t) }));
443
+
444
+ const lines: string[] = [
445
+ "# Agent Types and Capabilities\n",
446
+ "The following agent types are available. Use the `agent` parameter to select one.\n",
447
+ ];
448
+
449
+ for (const { name, config } of agents) {
450
+ if (!config) continue;
451
+ lines.push(`## ${config.displayName ?? name}`);
452
+ lines.push(config.description);
453
+ lines.push("");
454
+
455
+ if (config.builtinToolNames) {
456
+ lines.push(`**Tools:** ${config.builtinToolNames.join(", ")}`);
457
+ }
458
+ if (config.model) {
459
+ lines.push(`**Default model:** ${config.model}`);
460
+ }
461
+ if (config.maxTurns) {
462
+ lines.push(`**Max turns:** ${config.maxTurns}`);
463
+ }
464
+ lines.push("");
465
+ }
466
+
467
+ // Parameter descriptions
468
+ lines.push("## Agent Tool Parameters\n");
469
+ lines.push("| Parameter | Description |");
470
+ lines.push("|-----------|-------------|");
471
+ lines.push("| `prompt` | The task for the agent (required) |");
472
+ lines.push("| `description` | One-line summary of what the agent should do (required) |");
473
+ lines.push("| `agent` | Which agent type to use (default: general-purpose) |");
474
+ lines.push("| `thinking` | Optional thinking mode override (e.g., `off`, `minimal`, `low`, `medium`, `high`, `xhigh`) |");
475
+ lines.push("| `run_in_background` | When `true`, result is auto-delivered — do NOT poll. Continue working while waiting. |");
476
+ lines.push("| `resume` | Agent ID to resume from; when set, `prompt` is appended to the previous conversation |");
477
+ lines.push("");
478
+
479
+ // Usage guidelines
480
+ lines.push("## Usage Guidelines\n");
481
+ lines.push("- Agents start fresh with their config — they do NOT inherit the parent conversation");
482
+ lines.push("- For parallel tasks, spawn multiple `run_in_background: true` agents in one turn");
483
+ lines.push(" → Results are auto-delivered — do NOT poll, the result will arrive when ready");
484
+ lines.push("- Use `resume` to continue an incomplete agent's conversation");
485
+ piInstance.sendUserMessage(lines.join("\n"));
486
+ ctx.ui.notify("Agent briefing sent to LLM", "info");
487
+ }
488
+
489
+ /**
490
+ * Build a sub-menu for a single per-provider or per-model entry:
491
+ * "Edit limit" to change the value, or "Remove limit" to delete it.
492
+ */
493
+ async function editOrRemoveConcurrencyEntry(
494
+ ctx: ExtensionCommandContext,
495
+ label: string,
496
+ entityType: "provider" | "model",
497
+ entityKey: string,
498
+ currentValue: number,
499
+ applyUpdate: (value: number) => void,
500
+ applyRemove: () => void,
501
+ ): Promise<void> {
502
+ await runMenu(ctx, `${entityKey} concurrency`, [
503
+ "Edit limit",
504
+ "Remove limit",
505
+ ], [
506
+ async () => {
507
+ await promptConcurrencyInput(
508
+ ctx, label, currentValue,
509
+ applyUpdate,
510
+ );
511
+ },
512
+ async () => {
513
+ applyRemove();
514
+ applyConcurrencyConfig();
515
+ ctx.ui.notify(
516
+ `Removed per-${entityType} limit for ${entityKey}`,
517
+ "info",
518
+ );
519
+ },
520
+ ]);
521
+ }
522
+
523
+ export async function showConcurrencySettingsMenu(
524
+ ctx: ExtensionCommandContext,
525
+ modelOptions: string[],
526
+ ): Promise<void> {
527
+ const providers = [...new Set(modelOptions.map((m) => m.split("/")[0]))].sort();
528
+
529
+ return runMenuLoop(ctx, "Concurrency Settings", () => {
530
+ const items: string[] = [];
531
+ const actions: Array<() => Promise<void>> = [];
532
+
533
+ // Global default
534
+ items.push(`Default concurrency limit · ${__config.concurrency.default}`);
535
+ actions.push(async () => {
536
+ await promptConcurrencyInput(
537
+ ctx, "Default concurrency limit", __config.concurrency.default,
538
+ (value) => { __config.concurrency.default = value; },
539
+ );
540
+ });
541
+
542
+ // Reset all to defaults
543
+ items.push("Reset all to defaults");
544
+ actions.push(async () => {
545
+ __config.concurrency = { default: 4 };
546
+ applyConcurrencyConfig();
547
+ ctx.ui.notify("Concurrency reset to defaults", "info");
548
+ });
549
+
550
+ // ── Per-provider limits ──
551
+ const providerLimits = __config.concurrency.providers ?? {};
552
+ const configuredProviders = Object.keys(providerLimits);
553
+ if (configuredProviders.length > 0) {
554
+ items.push("");
555
+ actions.push(async () => {});
556
+ items.push("─── per-provider limits ───");
557
+ actions.push(async () => {}); // separator
558
+
559
+ for (const provider of configuredProviders) {
560
+ const limit = providerLimits[provider];
561
+ items.push(`${provider} · ${limit} slots`);
562
+ actions.push(async () => {
563
+ await editOrRemoveConcurrencyEntry(
564
+ ctx,
565
+ `Concurrency slots for ${provider}`,
566
+ "provider",
567
+ provider,
568
+ limit,
569
+ (value) => {
570
+ const current = __config.concurrency.providers ?? {};
571
+ __config.concurrency.providers = { ...current, [provider]: value };
572
+ },
573
+ () => {
574
+ const providers = __config.concurrency.providers;
575
+ if (providers) {
576
+ delete providers[provider];
577
+ }
578
+ },
579
+ );
580
+ });
581
+ }
582
+ }
583
+
584
+ // Add per-provider limit
585
+ items.push("Add per-provider limit...");
586
+ actions.push(async () => {
587
+ const provider = await ctx.ui.select("Select provider", providers);
588
+ if (provider === undefined) return;
589
+ await promptAddConcurrencyLimit(
590
+ ctx, provider,
591
+ (key, value) => {
592
+ const current = __config.concurrency.providers ?? {};
593
+ __config.concurrency.providers = { ...current, [key]: value };
594
+ },
595
+ );
596
+ });
597
+
598
+ // ── Per-model limits ──
599
+ const models = __config.concurrency.models ?? {};
600
+ const modelKeys = Object.keys(models);
601
+ if (modelKeys.length > 0) {
602
+ items.push("");
603
+ actions.push(async () => {});
604
+ items.push("─── per-model limits ───");
605
+ actions.push(async () => {}); // separator
606
+
607
+ for (const modelKey of modelKeys) {
608
+ const limit = models[modelKey];
609
+ items.push(`${modelKey} · ${limit} slots`);
610
+ actions.push(async () => {
611
+ await editOrRemoveConcurrencyEntry(
612
+ ctx,
613
+ `Concurrency slots for ${modelKey}`,
614
+ "model",
615
+ modelKey,
616
+ limit,
617
+ (value) => {
618
+ const current = __config.concurrency.models ?? {};
619
+ __config.concurrency.models = { ...current, [modelKey]: value };
620
+ },
621
+ () => {
622
+ const models = __config.concurrency.models;
623
+ if (models) {
624
+ delete models[modelKey];
625
+ }
626
+ },
627
+ );
628
+ });
629
+ }
630
+ }
631
+
632
+ // Add per-model limit
633
+ items.push("Add per-model limit...");
634
+ actions.push(async () => {
635
+ const modelKey = await promptModelSelection(
636
+ ctx, modelOptions, __config.agent.default ?? "(inherits parent)",
637
+ );
638
+ if (modelKey === null) return;
639
+ await promptAddConcurrencyLimit(
640
+ ctx, modelKey.trim(),
641
+ (key, value) => {
642
+ const current = __config.concurrency.models ?? {};
643
+ __config.concurrency.models = { ...current, [key]: value };
644
+ },
645
+ );
646
+ });
647
+
648
+ return { items, actions };
649
+ });
650
+ }
651
+
652
+ async function showRunningAgentsMenu(
653
+ ctx: ExtensionCommandContext,
654
+ ): Promise<void> {
655
+ const records = manager?.listAgents() ?? [];
656
+ if (records.length === 0) {
657
+ ctx.ui.notify("No agents have been spawned this session", "info");
658
+ return;
659
+ }
660
+
661
+ return runMenuLoop(ctx, "Running Agents", () => {
662
+ const records = manager?.listAgents() ?? [];
663
+ const running = records.filter((r) => r.status === "running" || r.status === "queued");
664
+
665
+ const items: string[] = [];
666
+ const actions: Array<() => Promise<void>> = [];
667
+
668
+ for (const record of records) {
669
+ const elapsed = Math.round((Date.now() - record.startedAt) / 1000);
670
+ const statusIcon = record.status === "running" ? "▶" :
671
+ record.status === "completed" ? "✓" :
672
+ record.status === "queued" ? "⏳" :
673
+ record.status === "error" ? "✗" : "•";
674
+ items.push(
675
+ `${statusIcon} ${record.id.slice(0, 8)} ${record.type} ${record.status} ${elapsed}s`,
676
+ );
677
+
678
+ actions.push(async () => {
679
+ await showAgentActions(ctx, record);
680
+ });
681
+ }
682
+
683
+ if (running.length > 0) {
684
+ items.push("");
685
+ actions.push(async () => {});
686
+ items.push("─── actions ───");
687
+ actions.push(async () => {}); // separator
688
+
689
+ items.push(`Stop ${running.length} running agent(s)`);
690
+ actions.push(async () => {
691
+ for (const record of running) {
692
+ manager?.abort(record.id);
693
+ }
694
+ ctx.ui.notify(`Stopped ${running.length} agent(s)`, "info");
695
+ });
696
+ }
697
+
698
+ return { items, actions };
699
+ });
700
+ }
701
+
702
+ /**
703
+ * Show a ResultViewer for an agent's result, error, or snapshot.
704
+ * @param kind — "result", "error", or "snapshot" — used for the title suffix
705
+ */
706
+ async function showResultViewer(
707
+ ctx: ExtensionCommandContext,
708
+ record: AgentRecord,
709
+ kind: "result" | "error" | "snapshot",
710
+ text: string,
711
+ ): Promise<void> {
712
+ const titleSuffix = kind === "result"
713
+ ? record.id.slice(0, 8)
714
+ : kind === "snapshot"
715
+ ? `snapshot \u00b7 ${record.id.slice(0, 8)}`
716
+ : "Error";
717
+ const stats: ResultViewerStats = {
718
+ lifetimeUsage: record.lifetimeUsage,
719
+ turnCount: record.turnCount,
720
+ durationMs: (record.completedAt ?? Date.now()) - record.startedAt,
721
+ };
722
+ const refreshCallback =
723
+ kind === "snapshot" && record.session
724
+ ? () => buildSnapshotMarkdown(record.session!.messages)
725
+ : undefined;
726
+
727
+ await ctx.ui.custom<void>(
728
+ (tui, theme, _kb, done) =>
729
+ new ResultViewer(
730
+ `${getDisplayName(record.type)} · ${titleSuffix}`,
731
+ text,
732
+ { onClose: () => done(), onRefresh: refreshCallback },
733
+ theme,
734
+ tui.terminal.rows,
735
+ stats,
736
+ ),
737
+ );
738
+ }
739
+
740
+ /**
741
+ * Send a steer message to a specific agent. Used by the per-agent action menu.
742
+ */
743
+ async function steerAgentById(
744
+ agentId: string,
745
+ ctx: ExtensionCommandContext,
746
+ ): Promise<void> {
747
+ const record = manager?.getRecord(agentId);
748
+ if (!record) {
749
+ ctx.ui.notify("Agent not found", "error");
750
+ return;
751
+ }
752
+
753
+ const message = await ctx.ui.input(`Steer ${record.type}`);
754
+ if (!message?.trim()) return;
755
+
756
+ try {
757
+ if (!record.session) {
758
+ if (!record.pendingSteers) {
759
+ record.pendingSteers = [];
760
+ }
761
+ record.pendingSteers.push(message.trim());
762
+ ctx.ui.notify(`Steer message queued for ${record.id.slice(0, 8)}…`, "info");
763
+ } else {
764
+ await record.session.steer(message.trim());
765
+ ctx.ui.notify(`Steer sent to ${record.id.slice(0, 8)}…`, "info");
766
+ }
767
+ } catch (err) {
768
+ ctx.ui.notify(`Steer failed: ${errorMessage(err)}`, "error");
769
+ }
770
+ }
771
+
772
+ /**
773
+ * Sub-menu with actions for a single agent. Replaces the old showAgentDetail
774
+ * notify popup — clicking an agent in the running agents menu opens actions.
775
+ */
776
+ export async function showAgentActions(
777
+ ctx: ExtensionCommandContext,
778
+ record: AgentRecord,
779
+ ): Promise<void> {
780
+ const items: string[] = [];
781
+ const actions: Array<() => Promise<void>> = [];
782
+
783
+ const isRunning = record.status === "running" || record.status === "queued";
784
+ const hasSession = !!record.session;
785
+ const hasResult = !!record.result && record.result.length > 0;
786
+ const hasError = !!record.error && record.error.length > 0;
787
+
788
+ // View actions first
789
+ if (record.status === "running" && hasSession) {
790
+ items.push("View snapshot");
791
+ actions.push(async () => {
792
+ const messages = record.session!.messages;
793
+ const markdown = buildSnapshotMarkdown(messages);
794
+ await showResultViewer(ctx, record, "snapshot", markdown);
795
+ });
796
+ }
797
+
798
+ if (hasResult) {
799
+ items.push("View result");
800
+ actions.push(async () => {
801
+ await showResultViewer(ctx, record, "result", record.result!);
802
+ });
803
+ }
804
+
805
+ if (hasError) {
806
+ items.push("View error");
807
+ actions.push(async () => {
808
+ await showResultViewer(ctx, record, "error", record.error!);
809
+ });
810
+ }
811
+
812
+ // Then control actions
813
+ if (isRunning) {
814
+ items.push("Steer");
815
+ actions.push(async () => {
816
+ await steerAgentById(record.id, ctx);
817
+ });
818
+
819
+ items.push("Stop");
820
+ actions.push(async () => {
821
+ manager?.abort(record.id);
822
+ ctx.ui.notify(`Stopped ${record.id.slice(0, 8)}`, "info");
823
+ });
824
+ }
825
+
826
+ if (items.length === 0) {
827
+ ctx.ui.notify(`Agent ${record.id.slice(0, 8)} — no actions available`, "info");
828
+ return;
829
+ }
830
+
831
+ // Append blank spacer + "Back" as the last items
832
+ items.push("");
833
+ actions.push(async () => {});
834
+ items.push("Back");
835
+ actions.push(async () => {});
836
+
837
+ await runMenu(ctx, `Agent ${record.id.slice(0, 8)}`, items, actions);
838
+ }
839
+
840
+ async function showAgentTypes(ctx: ExtensionCommandContext): Promise<void> {
841
+ const types = getAllTypes();
842
+ if (types.length === 0) {
843
+ ctx.ui.notify("No agent types available", "info");
844
+ return;
845
+ }
846
+
847
+ const lines: string[] = ["Available agent types:\n"];
848
+ for (const name of types) {
849
+ const cfg = getAgentConfig(name);
850
+ if (!cfg) continue;
851
+ const disabled = cfg.enabled === false ? " [DISABLED]" : "";
852
+ const model = cfg.model ? ` Model: ${cfg.model}` : "";
853
+ const tools = cfg.builtinToolNames
854
+ ? ` Tools: ${cfg.builtinToolNames.join(", ")}`
855
+ : " Tools: all built-in tools";
856
+ const source = cfg.source ? ` Source: ${cfg.source}` : "";
857
+ lines.push(` ${name}${disabled}`);
858
+ lines.push(` ${cfg.description}`);
859
+ if (model) lines.push(model);
860
+ lines.push(tools);
861
+ if (source) lines.push(source);
862
+ lines.push("");
863
+ }
864
+
865
+ ctx.ui.notify(lines.join("\n"), "info");
866
+ }