@jwilger/pi-development-system 0.10.6 → 0.11.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 (34) hide show
  1. package/agents/architect.md +66 -0
  2. package/agents/coder.md +57 -0
  3. package/agents/researcher.md +60 -0
  4. package/agents/reviewer.md +53 -0
  5. package/agents/tasker.md +58 -0
  6. package/agents/writer.md +39 -0
  7. package/extensions/development-system.ts +2 -0
  8. package/package.json +5 -3
  9. package/src/subagents/VENDORED.md +38 -0
  10. package/src/subagents/index.ts +544 -0
  11. package/src/subagents/orch/inherited-tools.ts +85 -0
  12. package/src/subagents/orch/mailbox.ts +262 -0
  13. package/src/subagents/orch/manager.ts +765 -0
  14. package/src/subagents/orch/paths.ts +74 -0
  15. package/src/subagents/orch/prompt.ts +25 -0
  16. package/src/subagents/orch/runtime.ts +784 -0
  17. package/src/subagents/orch/tools.ts +166 -0
  18. package/src/subagents/orch/transcript.ts +187 -0
  19. package/src/subagents/prefs/agent-import.ts +246 -0
  20. package/src/subagents/prefs/config.ts +1029 -0
  21. package/src/subagents/prefs/import-discovery.ts +644 -0
  22. package/src/subagents/prefs/import-instructions.ts +157 -0
  23. package/src/subagents/prefs/models.ts +78 -0
  24. package/src/subagents/prefs/settings.ts +371 -0
  25. package/src/subagents/types.ts +200 -0
  26. package/src/subagents/ui/agent-navigation-editor.ts +210 -0
  27. package/src/subagents/ui/dialog.ts +651 -0
  28. package/src/subagents/ui/import-picker.ts +232 -0
  29. package/src/subagents/ui/live-agent-view.ts +572 -0
  30. package/src/subagents/ui/model-picker.ts +452 -0
  31. package/src/subagents/ui/settings-ui.ts +329 -0
  32. package/src/subagents/ui/status-ui.ts +545 -0
  33. package/src/subagents/ui/thread-tree.ts +135 -0
  34. package/src/subagents/ui/ui.ts +1507 -0
@@ -0,0 +1,1507 @@
1
+ // @ts-nocheck: vendored upstream pi-subagent-manager 0.14.0 compiled under looser options; see src/subagents/VENDORED.md
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { join, resolve } from "node:path";
7
+ import { parse as parseShell } from "shell-quote";
8
+ import {
9
+ type ExtensionCommandContext,
10
+ type ExtensionContext,
11
+ getSelectListTheme,
12
+ type Theme,
13
+ type ThemeColor,
14
+ } from "@earendil-works/pi-coding-agent";
15
+ import {
16
+ Container,
17
+ SelectList,
18
+ Text,
19
+ colorToRgb,
20
+ rgbColor,
21
+ stripTerminalSequences,
22
+ truncateToWidth,
23
+ visibleWidth,
24
+ } from "@earendil-works/pi-tui";
25
+ import {
26
+ AGENT_COLORS,
27
+ ConfigStore,
28
+ diffAgentSettings,
29
+ parseAgentType,
30
+ serializeAgentType,
31
+ } from "../prefs/config.ts";
32
+ import { editModelPreferences, MODEL_EDITOR_CANCEL } from "./model-picker.ts";
33
+ import {
34
+ canOpenDialog,
35
+ dialogEditor,
36
+ dialogMenu,
37
+ dialogHeight,
38
+ frameDialog,
39
+ DIALOG_OPTIONS,
40
+ withDialogSession,
41
+ } from "./dialog.ts";
42
+ import { getModelPreferences } from "../prefs/models.ts";
43
+ import type { WidgetMode } from "../prefs/settings.ts";
44
+ import { buildStatusTree, type StatusRow } from "./thread-tree.ts";
45
+ import {
46
+ THINKING_LEVELS,
47
+ type AgentType,
48
+ type ThreadService,
49
+ type ThreadView,
50
+ } from "../types.ts";
51
+
52
+ export type ThreadController = Pick<
53
+ ThreadService,
54
+ "list" | "get" | "output" | "transcript" | "observeTranscript" | "steer" | "stop"
55
+ >;
56
+
57
+ /** Plain terminal-safe text: never pass agent-supplied terminal commands through. */
58
+ export function sanitizeText(text: string): string {
59
+ return stripTerminalSequences(text).replace(/[\x00-\x1f\x7f-\x9f]/g, " ");
60
+ }
61
+
62
+ type AgentBadgeTheme = Pick<Theme, "fg" | "colors" | "style">;
63
+
64
+ /** Selected type token, or accent when unset or unknown. Shared by the pill and path. */
65
+ function agentColorToken(color: string | undefined): ThemeColor {
66
+ return AGENT_COLORS.includes(color as (typeof AGENT_COLORS)[number])
67
+ ? (color as ThemeColor)
68
+ : "accent";
69
+ }
70
+
71
+ /** Background pill with bold contrasting text and padding. Label is sanitized. */
72
+ function contrastPill(
73
+ label: string,
74
+ color: string | undefined,
75
+ theme: AgentBadgeTheme,
76
+ ): string {
77
+ const background = theme.colors[agentColorToken(color)];
78
+ const { r, g, b } = colorToRgb(background);
79
+ const linear = (channel: number) => {
80
+ const value = channel / 255;
81
+ return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
82
+ };
83
+ const luminance =
84
+ 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
85
+ // Pick whichever of black/white has the higher WCAG contrast ratio.
86
+ const foreground = luminance > Math.sqrt(0.0525) - 0.05 ? 0 : 255;
87
+ return theme.style(` ${sanitizeText(label)} `, {
88
+ bg: background,
89
+ fg: rgbColor(foreground, foreground, foreground),
90
+ bold: true,
91
+ });
92
+ }
93
+
94
+ /** Invalid/untrusted saved icons never reach the terminal. Names remain readable. */
95
+ export function agentTypeLabel(type: string, icon?: string, nerdFontIcons = false): string {
96
+ const prefix = nerdFontIcons && typeof icon === "string" && /^\p{Co}$/u.test(icon)
97
+ ? `${icon} ` : "";
98
+ return `${prefix}${sanitizeText(type)}`;
99
+ }
100
+
101
+ /** Nerd Fonts md-circle-slice-1..8: an equal-width, shape-changing progress cycle.
102
+ * Verified against https://raw.githubusercontent.com/ryanoasis/nerd-fonts/master/glyphnames.json.
103
+ */
104
+ export const AGENT_PROGRESS_INTERVAL = 180;
105
+ const AGENT_PROGRESS_FRAMES = Array.from({ length: 8 }, (_, index) =>
106
+ String.fromCodePoint(0xf0a9e + index),
107
+ );
108
+
109
+ /** Keep the configured role icon for settled threads and non-animated/RPC output. */
110
+ export function agentProgressIcon(
111
+ thread: Pick<ThreadView, "state" | "icon">,
112
+ nerdFontIcons: boolean,
113
+ now = Date.now(),
114
+ animate = true,
115
+ ): string | undefined {
116
+ if (!nerdFontIcons) return undefined;
117
+ if (animate && (thread.state === "starting" || thread.state === "running")) {
118
+ const frame = Math.floor(Math.max(0, now) / AGENT_PROGRESS_INTERVAL) % AGENT_PROGRESS_FRAMES.length;
119
+ return AGENT_PROGRESS_FRAMES[frame];
120
+ }
121
+ return thread.icon;
122
+ }
123
+
124
+ export function agentTypeBadge(
125
+ type: string,
126
+ color: string | undefined,
127
+ theme: AgentBadgeTheme,
128
+ icon?: string,
129
+ nerdFontIcons = false,
130
+ ): string {
131
+ return contrastPill(agentTypeLabel(type, icon, nerdFontIcons), color, theme);
132
+ }
133
+
134
+ function agentPath(
135
+ path: string,
136
+ color: string | undefined,
137
+ theme: AgentBadgeTheme,
138
+ ): string {
139
+ return theme.fg(agentColorToken(color), sanitizeText(path));
140
+ }
141
+
142
+ function metricCount(value: number | undefined): number {
143
+ return typeof value === "number" && Number.isFinite(value)
144
+ ? Math.max(0, Math.floor(value))
145
+ : 0;
146
+ }
147
+
148
+ /** Settled active time plus the current live run, only while starting or running. */
149
+ function elapsedTotal(thread: ThreadView, now = Date.now()): number {
150
+ const live =
151
+ typeof thread.startedAt === "number" &&
152
+ Number.isFinite(thread.startedAt) &&
153
+ (thread.state === "starting" || thread.state === "running")
154
+ ? now - thread.startedAt
155
+ : 0;
156
+ const total = metricCount(thread.elapsedMs) + live;
157
+ return Number.isFinite(total) ? total : 0;
158
+ }
159
+
160
+ function formatDuration(ms: number): string {
161
+ const total = Math.max(0, Math.floor(ms / 1000));
162
+ const seconds = total % 60;
163
+ const minutes = Math.floor(total / 60) % 60;
164
+ const hours = Math.floor(total / 3600) % 24;
165
+ const days = Math.floor(total / 86400);
166
+ if (days > 0) return `${days}d${hours}h`;
167
+ if (hours > 0) return `${hours}h${minutes}m`;
168
+ if (minutes > 0) return `${minutes}m${seconds}s`;
169
+ return `${seconds}s`;
170
+ }
171
+
172
+ const compactCount = new Intl.NumberFormat("en", {
173
+ notation: "compact",
174
+ maximumFractionDigits: 1,
175
+ });
176
+
177
+ function formatCount(value: number | undefined): string {
178
+ // Intl emits uppercase units (`1.2K`); the widget uses lowercase.
179
+ return compactCount.format(metricCount(value)).toLowerCase();
180
+ }
181
+
182
+ export function threadMetrics(thread: ThreadView, now = Date.now()): string {
183
+ return `${formatDuration(elapsedTotal(thread, now))} ↑${formatCount(thread.inputTokens)} ↓${formatCount(thread.outputTokens)}`;
184
+ }
185
+
186
+ /** Keep the right-hand counters intact; truncate the left side to the remaining columns. */
187
+ function fitLine(left: string, right: string, width: number): string {
188
+ if (width <= 0) return "";
189
+ const rightWidth = visibleWidth(right);
190
+ if (rightWidth >= width) return truncateToWidth(right, width, "");
191
+ const fitted = truncateToWidth(left, width - rightWidth - 1);
192
+ const pad = width - visibleWidth(fitted) - rightWidth;
193
+ return fitted + " ".repeat(Math.max(0, pad)) + right;
194
+ }
195
+
196
+ function isLive(thread: ThreadView): boolean {
197
+ return (
198
+ thread.path !== "/root" &&
199
+ (thread.state === "starting" || thread.state === "running")
200
+ );
201
+ }
202
+
203
+ export function renderThreads(
204
+ threads: ThreadView[],
205
+ width: number,
206
+ theme: AgentBadgeTheme,
207
+ nerdFontIcons = false,
208
+ ): string[] {
209
+ const priority = (thread: ThreadView) =>
210
+ thread.state === "starting" || thread.state === "running"
211
+ ? 0
212
+ : thread.state === "paused"
213
+ ? 1
214
+ : 2;
215
+ const visible = threads
216
+ .filter((thread) => thread.path !== "/root")
217
+ .sort(
218
+ (a, b) =>
219
+ priority(a) - priority(b) ||
220
+ b.updatedAt - a.updatedAt ||
221
+ b.createdAt - a.createdAt,
222
+ );
223
+ const limit = visible.length > 8 ? 7 : 8;
224
+ const lines = visible.slice(0, limit).map((thread) => {
225
+ const stateColor =
226
+ thread.state === "failed"
227
+ ? "error"
228
+ : thread.state === "paused"
229
+ ? "warning"
230
+ : "accent";
231
+ const badge = agentTypeBadge(
232
+ thread.type, thread.color, theme, agentProgressIcon(thread, nerdFontIcons), nerdFontIcons,
233
+ );
234
+ const path = agentPath(thread.path, thread.color, theme);
235
+ const state = theme.fg(stateColor, `[${sanitizeText(thread.state)}]`);
236
+ const left = `${badge} ${path} ${state} ${sanitizeText(thread.status || thread.task)}`;
237
+ return fitLine(
238
+ left,
239
+ theme.fg("muted", threadMetrics(thread)),
240
+ Math.max(0, width),
241
+ );
242
+ });
243
+ if (visible.length > limit) {
244
+ lines.push(
245
+ truncateToWidth(
246
+ theme.fg(
247
+ "muted",
248
+ `+${visible.length - limit} more threads · /agents tree`,
249
+ ),
250
+ Math.max(0, width),
251
+ ),
252
+ );
253
+ }
254
+ return lines;
255
+ }
256
+
257
+ const WIDGET_ROOT = "/root";
258
+ const MAX_WIDGET_LINES = 12;
259
+ const AGENT_WIDGET_PLACEMENT = { placement: "aboveEditor" as const };
260
+ const AGENT_BROWSER_HINT = "Press ← to open subagent browser";
261
+
262
+ interface AgentWidgetRenderOptions {
263
+ /** RPC widgets cannot open a terminal browser. */
264
+ showBrowserHint?: boolean;
265
+ nerdFontIcons?: boolean;
266
+ /** RPC snapshots use static role icons and never start an animation. */
267
+ animate?: boolean;
268
+ }
269
+
270
+ function agentWidgetStatus(
271
+ running: number,
272
+ omitted: number,
273
+ width: number,
274
+ theme: Pick<Theme, "fg">,
275
+ showBrowserHint: boolean,
276
+ ): string {
277
+ const details = [
278
+ omitted > 0 ? `+${omitted} more agents` : "",
279
+ showBrowserHint && running > 0 ? AGENT_BROWSER_HINT : "",
280
+ showBrowserHint ? "→ collapse" : "",
281
+ ].filter(Boolean);
282
+ return truncateToWidth(
283
+ theme.fg("accent", `${running} running`) +
284
+ (details.length ? theme.fg("muted", ` · ${details.join(" · ")}`) : ""),
285
+ width,
286
+ "",
287
+ );
288
+ }
289
+
290
+ function statePriority(state: ThreadView["state"] | undefined): number {
291
+ if (state === "starting" || state === "running") return 0;
292
+ if (state === "paused") return 1;
293
+ return 2;
294
+ }
295
+
296
+ function explicitParent(
297
+ path: string,
298
+ threadsByPath: Map<string, ThreadView>,
299
+ ): string | null {
300
+ if (path === WIDGET_ROOT) return null;
301
+ const thread = threadsByPath.get(path);
302
+ if (thread)
303
+ return thread.parent && thread.parent !== path ? thread.parent : null;
304
+ const index = path.lastIndexOf("/");
305
+ return index > 0 ? path.slice(0, index) : null;
306
+ }
307
+
308
+ /** Lowest state in the branch. Parent links define ancestry; cycles are guarded. */
309
+ function branchPriorityComparator(
310
+ threads: ThreadView[],
311
+ ): (a: string, b: string) => number {
312
+ const threadsByPath = new Map<string, ThreadView>();
313
+ for (const thread of threads) {
314
+ if (thread?.path && !threadsByPath.has(thread.path))
315
+ threadsByPath.set(thread.path, thread);
316
+ }
317
+ const children = new Map<string, string[]>();
318
+ const link = (path: string, seen: Set<string>) => {
319
+ if (!path || seen.has(path)) return;
320
+ seen.add(path);
321
+ const parent = explicitParent(path, threadsByPath);
322
+ if (!parent || parent === path) return;
323
+ const list = children.get(parent) ?? [];
324
+ if (!list.includes(path)) list.push(path);
325
+ children.set(parent, list);
326
+ link(parent, seen);
327
+ };
328
+ link(WIDGET_ROOT, new Set());
329
+ for (const path of threadsByPath.keys()) link(path, new Set());
330
+
331
+ const subtree = new Map<string, number>();
332
+ const ancestors = new Map<string, number>();
333
+ const subtreeOf = (path: string, stack: Set<string>): number => {
334
+ const cached = subtree.get(path);
335
+ if (cached !== undefined) return cached;
336
+ const own = statePriority(threadsByPath.get(path)?.state);
337
+ if (stack.has(path)) return own;
338
+ stack.add(path);
339
+ let best = own;
340
+ for (const child of children.get(path) ?? [])
341
+ best = Math.min(best, subtreeOf(child, stack));
342
+ stack.delete(path);
343
+ subtree.set(path, best);
344
+ return best;
345
+ };
346
+ const ancestorOf = (path: string): number => {
347
+ const cached = ancestors.get(path);
348
+ if (cached !== undefined) return cached;
349
+ let best = 2;
350
+ const seen = new Set<string>();
351
+ let current: string | null = path;
352
+ while (current && !seen.has(current)) {
353
+ seen.add(current);
354
+ best = Math.min(best, statePriority(threadsByPath.get(current)?.state));
355
+ current = explicitParent(current, threadsByPath);
356
+ }
357
+ ancestors.set(path, best);
358
+ return best;
359
+ };
360
+ return (a, b) =>
361
+ subtreeOf(a, new Set()) - subtreeOf(b, new Set()) ||
362
+ ancestorOf(a) - ancestorOf(b) ||
363
+ (a < b ? -1 : a > b ? 1 : 0);
364
+ }
365
+
366
+ function agentStateColor(
367
+ state: ThreadView["state"],
368
+ ): "error" | "warning" | "accent" {
369
+ return state === "failed"
370
+ ? "error"
371
+ : state === "paused"
372
+ ? "warning"
373
+ : "accent";
374
+ }
375
+
376
+ function agentLine(
377
+ row: StatusRow,
378
+ width: number,
379
+ theme: AgentBadgeTheme,
380
+ nerdFontIcons: boolean,
381
+ animate: boolean,
382
+ ): string {
383
+ const thread = row.thread!;
384
+ const badge = agentTypeBadge(
385
+ thread.type, thread.color, theme,
386
+ agentProgressIcon(thread, nerdFontIcons, Date.now(), animate), nerdFontIcons,
387
+ );
388
+ const left = `${row.prefix}${badge} ${agentPath(thread.path, thread.color, theme)} ${theme.fg(agentStateColor(thread.state), `[${sanitizeText(thread.state)}]`)} ${sanitizeText(thread.task)}`;
389
+ return fitLine(left, theme.fg("muted", threadMetrics(thread)), width);
390
+ }
391
+
392
+ function placeholderLine(
393
+ row: StatusRow,
394
+ width: number,
395
+ theme: AgentBadgeTheme,
396
+ ): string {
397
+ return truncateToWidth(
398
+ `${row.prefix}${theme.fg("muted", `${sanitizeText(row.path)} missing parent`)}`,
399
+ width,
400
+ "",
401
+ );
402
+ }
403
+
404
+ function takeWidgetRows(rows: StatusRow[], budget: number): StatusRow[] {
405
+ const parents = new Map<number, number[]>();
406
+ const stack: number[] = [];
407
+ for (const [index, row] of rows.entries()) {
408
+ while (
409
+ stack.length &&
410
+ rows[stack[stack.length - 1]!]!.prefix.length >= row.prefix.length
411
+ )
412
+ stack.pop();
413
+ parents.set(index, [...stack]);
414
+ stack.push(index);
415
+ }
416
+ const rowCost = (row: StatusRow) =>
417
+ row.path === WIDGET_ROOT ? 0 : 1;
418
+ const candidates = rows
419
+ .map((row, index) => ({ row, index }))
420
+ .filter(({ row }) => row.thread && row.path !== WIDGET_ROOT)
421
+ .sort(
422
+ (a, b) =>
423
+ statePriority(a.row.thread!.state) -
424
+ statePriority(b.row.thread!.state) ||
425
+ b.row.thread!.createdAt - a.row.thread!.createdAt || a.index - b.index,
426
+ );
427
+ const chosen = new Set<number>();
428
+ let used = 0;
429
+ for (const { index } of candidates) {
430
+ const needed = [...parents.get(index)!, index].filter(
431
+ (ancestor) => !chosen.has(ancestor),
432
+ );
433
+ const cost = needed.reduce(
434
+ (total, ancestor) => total + rowCost(rows[ancestor]!),
435
+ 0,
436
+ );
437
+ if (used + cost > budget) continue;
438
+ for (const ancestor of needed) chosen.add(ancestor);
439
+ used += cost;
440
+ }
441
+ // If a missing-parent chain cannot fit its agent, retain a useful tree prefix.
442
+ if (!chosen.size) {
443
+ for (const [index, row] of rows.entries()) {
444
+ const cost = rowCost(row);
445
+ if (used + cost > budget) break;
446
+ chosen.add(index);
447
+ used += cost;
448
+ }
449
+ }
450
+ return rows.filter(
451
+ (row, index) => row.path !== WIDGET_ROOT && chosen.has(index),
452
+ );
453
+ }
454
+
455
+ /** Themed agent tree above the editor. Synthetic /root is the main conversation and is omitted. */
456
+ export function renderAgentTree(
457
+ threads: ThreadView[],
458
+ width: number,
459
+ theme: AgentBadgeTheme,
460
+ { showBrowserHint = true, nerdFontIcons = false, animate = true }: AgentWidgetRenderOptions = {},
461
+ ): string[] {
462
+ const columns = Math.max(0, width);
463
+ const rows = buildStatusTree(
464
+ threads,
465
+ new Set(),
466
+ branchPriorityComparator(threads),
467
+ );
468
+ const agents = rows.filter((row) => row.thread && row.path !== WIDGET_ROOT);
469
+ if (!agents.length) return [];
470
+ const live = agents.filter(
471
+ (row) =>
472
+ row.thread!.state === "starting" || row.thread!.state === "running",
473
+ ).length;
474
+ const paused = agents.filter((row) => row.thread!.state === "paused").length;
475
+ const heading = fitLine(
476
+ theme.fg("accent", "Agents"),
477
+ theme.fg("muted", `${live} live · ${paused} paused`),
478
+ columns,
479
+ );
480
+ // Reserve the last line for status and browser entry, even with no omissions.
481
+ const visible = takeWidgetRows(rows, MAX_WIDGET_LINES - 2);
482
+ const omitted = agents.length - visible.filter((row) => row.thread).length;
483
+ const lines = [heading];
484
+ for (const row of visible) {
485
+ if (!row.thread) {
486
+ lines.push(placeholderLine(row, columns, theme));
487
+ continue;
488
+ }
489
+ lines.push(agentLine(row, columns, theme, nerdFontIcons, animate));
490
+ }
491
+ lines.push(agentWidgetStatus(live, omitted, columns, theme, showBrowserHint));
492
+ return lines;
493
+ }
494
+
495
+ /** One-line counts for real agents; starting agents count as running, including tokens. */
496
+ export function renderAgentSummary(
497
+ threads: ThreadView[],
498
+ width: number,
499
+ theme: Pick<Theme, "fg">,
500
+ { showBrowserHint = true, nerdFontIcons = false, animate = true }: AgentWidgetRenderOptions = {},
501
+ ): string[] {
502
+ const agents = new Map<string, ThreadView>();
503
+ for (const thread of threads) {
504
+ if (thread?.path && thread.path !== WIDGET_ROOT && !agents.has(thread.path))
505
+ agents.set(thread.path, thread);
506
+ }
507
+ if (!agents.size) return [];
508
+
509
+ const counts: Record<Exclude<ThreadView["state"], "starting">, number> = {
510
+ running: 0,
511
+ stopped: 0,
512
+ failed: 0,
513
+ paused: 0,
514
+ completed: 0,
515
+ };
516
+ let input = 0;
517
+ let output = 0;
518
+ for (const thread of agents.values()) {
519
+ counts[thread.state === "starting" ? "running" : thread.state] += 1;
520
+ if (isLive(thread)) {
521
+ input += metricCount(thread.inputTokens);
522
+ output += metricCount(thread.outputTokens);
523
+ }
524
+ }
525
+ const statuses: [keyof typeof counts, ThemeColor][] = [
526
+ ["running", "accent"],
527
+ ["stopped", "muted"],
528
+ ["failed", "error"],
529
+ ["paused", "warning"],
530
+ ["completed", "success"],
531
+ ];
532
+ const progress = counts.running > 0
533
+ ? agentProgressIcon({ state: "running" }, nerdFontIcons, Date.now(), animate)
534
+ : undefined;
535
+ const left = statuses
536
+ .filter(([state]) => state === "running" || counts[state] > 0)
537
+ .map(([state, color]) => theme.fg(color,
538
+ `${state === "running" && progress ? `${progress} ` : ""}${counts[state]} ${state}`))
539
+ .join(theme.fg("muted", ", ")) +
540
+ (showBrowserHint && counts.running > 0 ? theme.fg("muted", " · ← browser") : "");
541
+ const right = theme.fg("muted", `↑${formatCount(input)} ↓${formatCount(output)}`);
542
+ return [fitLine(left, right, Math.max(0, width))];
543
+ }
544
+
545
+ export function updateWidget(
546
+ ctx: ExtensionContext,
547
+ threads: ThreadView[],
548
+ mode: WidgetMode = "full",
549
+ nerdFontIcons = false,
550
+ rootTurnEnded = false,
551
+ ): void {
552
+ if (!ctx.hasUI) return;
553
+ if (!threads.some((thread) => thread.path !== "/root")) {
554
+ ctx.ui.setWidget("pi-subagent", undefined, AGENT_WIDGET_PLACEMENT);
555
+ return;
556
+ }
557
+ // Retain the tree while either the main turn or any subagent is still active.
558
+ const settled = rootTurnEnded && !threads.some(isLive);
559
+ const render = mode === "minimal" || settled ? renderAgentSummary : renderAgentTree;
560
+ // RPC hosts accept string widgets only; a component factory is ignored.
561
+ if (ctx.mode === "rpc") {
562
+ ctx.ui.setWidget(
563
+ "pi-subagent",
564
+ render(threads, 80, ctx.ui.theme, { showBrowserHint: false, nerdFontIcons, animate: false }),
565
+ AGENT_WIDGET_PLACEMENT,
566
+ );
567
+ return;
568
+ }
569
+ const snapshot = threads.map((thread) => ({ ...thread }));
570
+ ctx.ui.setWidget(
571
+ "pi-subagent",
572
+ (tui) => {
573
+ let timer: ReturnType<typeof setInterval> | undefined;
574
+ if (snapshot.some(isLive) && (mode === "full" || nerdFontIcons)) {
575
+ timer = setInterval(() => tui.requestRender(), nerdFontIcons ? AGENT_PROGRESS_INTERVAL : 1000);
576
+ timer.unref();
577
+ }
578
+ return {
579
+ render: (width) => render(snapshot, width, ctx.ui.theme, {
580
+ showBrowserHint: !settled,
581
+ nerdFontIcons,
582
+ }),
583
+ invalidate: () => {},
584
+ dispose: () => {
585
+ if (timer) clearInterval(timer);
586
+ timer = undefined;
587
+ },
588
+ };
589
+ },
590
+ AGENT_WIDGET_PLACEMENT,
591
+ );
592
+ }
593
+
594
+ export async function showThreads(
595
+ ctx: ExtensionContext,
596
+ controller: ThreadController,
597
+ path?: string,
598
+ ): Promise<void> {
599
+ if (!ctx.hasUI) return;
600
+ let selectedPath = path;
601
+ while (true) {
602
+ try {
603
+ if (!selectedPath) {
604
+ const threads = controller
605
+ .list()
606
+ .filter((thread) => thread.path !== "/root");
607
+ if (!threads.length) {
608
+ ctx.ui.notify("No subagent threads.", "info");
609
+ return;
610
+ }
611
+ const labels = threads.map(
612
+ (thread) =>
613
+ `${sanitizeText(thread.path)} [${sanitizeText(thread.state)}] ${sanitizeText(thread.status)}`,
614
+ );
615
+ const selected = await ctx.ui.select("Subagent threads", labels);
616
+ if (selected === undefined) return;
617
+ selectedPath = threads[labels.indexOf(selected)]?.path;
618
+ if (!selectedPath) return;
619
+ }
620
+ if (selectedPath === "/root")
621
+ throw new Error("/root is the current Pi thread, not a subagent.");
622
+ const thread = controller.get(selectedPath);
623
+ const children = controller
624
+ .list()
625
+ .filter((child) => child.parent === selectedPath);
626
+ const title = `${sanitizeText(thread.path)} [${sanitizeText(thread.state)}] ${sanitizeText(thread.status)}`;
627
+ const action = await ctx.ui.select(title, [
628
+ "View output",
629
+ "View transcript",
630
+ "Send input / resume",
631
+ "Stop",
632
+ ...(children.length ? ["Children"] : []),
633
+ "Back",
634
+ ]);
635
+ if (!action || action === "Back") {
636
+ if (path) return;
637
+ selectedPath = undefined;
638
+ continue;
639
+ }
640
+ if (action === "View output" || action === "View transcript") {
641
+ const text =
642
+ action === "View output"
643
+ ? controller.output(selectedPath)
644
+ : await controller.transcript(selectedPath);
645
+ // Built-in editor is a portable viewer; edits are explicitly discarded.
646
+ await ctx.ui.editor(
647
+ `${sanitizeText(selectedPath)} — READ ONLY (changes discarded)`,
648
+ text.split("\n").map(sanitizeText).join("\n"),
649
+ );
650
+ } else if (action === "Send input / resume") {
651
+ const message = await ctx.ui.editor(
652
+ "Send input / resume retained session",
653
+ "",
654
+ );
655
+ if (message?.trim()) await controller.steer(selectedPath, message);
656
+ } else if (action === "Stop") {
657
+ if (
658
+ await ctx.ui.confirm(
659
+ "Stop thread",
660
+ `Stop ${sanitizeText(selectedPath)} and its descendants?`,
661
+ )
662
+ )
663
+ await controller.stop(selectedPath);
664
+ } else if (action === "Children") {
665
+ const labels = children.map(
666
+ (child) =>
667
+ `${sanitizeText(child.path)} [${sanitizeText(child.state)}] ${sanitizeText(child.status)}`,
668
+ );
669
+ const selected = await ctx.ui.select("Children", labels);
670
+ if (selected !== undefined)
671
+ selectedPath =
672
+ children[labels.indexOf(selected)]?.path ?? selectedPath;
673
+ }
674
+ } catch (error) {
675
+ ctx.ui.notify(
676
+ sanitizeText(error instanceof Error ? error.message : String(error)),
677
+ "error",
678
+ );
679
+ return;
680
+ }
681
+ }
682
+ }
683
+
684
+ /** Parse editor argv, rejecting shell operators instead of evaluating a shell command. */
685
+ export function editorArguments(command: string): string[] {
686
+ const args = parseShell(command, process.env);
687
+ if (!args.length || args.some((arg) => typeof arg !== "string") || !args[0]) {
688
+ throw new Error(
689
+ "VISUAL/EDITOR must be an executable and arguments, without shell operators or globs.",
690
+ );
691
+ }
692
+ return args as string[];
693
+ }
694
+
695
+ async function externalEdit(
696
+ ctx: ExtensionCommandContext,
697
+ text: string,
698
+ ): Promise<string> {
699
+ if (ctx.mode !== "tui")
700
+ throw new Error("External editing requires interactive TUI mode.");
701
+ const [command, ...args] = editorArguments(
702
+ process.env.VISUAL || process.env.EDITOR || "vi",
703
+ );
704
+ const directory = await mkdtemp(join(tmpdir(), "pi-subagent-"));
705
+ const file = join(directory, "agent.md");
706
+ try {
707
+ await writeFile(file, text, "utf8");
708
+ await ctx.ui.custom<void>((tui, _theme, _keys, done) => {
709
+ tui.stop();
710
+ try {
711
+ const result = spawnSync(command!, [...args, file], {
712
+ stdio: "inherit",
713
+ env: process.env,
714
+ });
715
+ if (result.error) throw result.error;
716
+ if (result.status !== 0)
717
+ throw new Error(
718
+ `Editor exited with status ${result.status ?? result.signal}.`,
719
+ );
720
+ } finally {
721
+ tui.start();
722
+ tui.requestRender(true);
723
+ }
724
+ done();
725
+ return { render: () => [], invalidate: () => {} };
726
+ });
727
+ return await readFile(file, "utf8");
728
+ } finally {
729
+ await rm(directory, { recursive: true, force: true });
730
+ }
731
+ }
732
+
733
+ async function editToolList(
734
+ ctx: ExtensionCommandContext,
735
+ type: AgentType,
736
+ field: "allow" | "block",
737
+ ): Promise<void> {
738
+ const modes = [
739
+ "Unset (use default policy)",
740
+ "Empty list",
741
+ "Enter exact tool names",
742
+ ];
743
+ const current = type.tools?.[field];
744
+ const mode = await dialogMenu(
745
+ ctx,
746
+ `tools.${field} (${sanitizeText(toolListMenuValue(current))})`,
747
+ modes.map((label) => ({ id: label, label })),
748
+ {
749
+ selectedId:
750
+ current === undefined
751
+ ? modes[0]
752
+ : current.length === 0
753
+ ? modes[1]
754
+ : modes[2],
755
+ },
756
+ );
757
+ if (!mode) return;
758
+ if (mode === "Unset (use default policy)") {
759
+ if (type.tools) delete type.tools[field];
760
+ } else {
761
+ const value =
762
+ mode === "Empty list"
763
+ ? ""
764
+ : await dialogEditor(
765
+ ctx,
766
+ `tools.${field}: comma-separated exact names`,
767
+ type.tools?.[field]?.join(", ") ?? "",
768
+ );
769
+ if (value === undefined) return;
770
+ type.tools ??= {};
771
+ type.tools[field] = value
772
+ .split(",")
773
+ .map((name) => name.trim())
774
+ .filter(Boolean);
775
+ }
776
+ if (type.tools && !Object.keys(type.tools).length) delete type.tools;
777
+ }
778
+
779
+ const AGENT_COLOR_DEFAULT = "__default__";
780
+ const AGENT_COLOR_CANCEL = Symbol("agent-color-cancel");
781
+
782
+ class AgentColorPickerComponent extends Container {
783
+ private readonly items: {
784
+ value: string;
785
+ label: string;
786
+ description?: string;
787
+ }[];
788
+ private readonly preview: Text;
789
+ private readonly theme: Theme;
790
+ readonly selectList: SelectList;
791
+
792
+ constructor(
793
+ private readonly tui: {
794
+ requestRender(force?: boolean): void;
795
+ terminal?: { rows: number };
796
+ },
797
+ theme: Theme,
798
+ currentColor: AgentType["color"] | undefined,
799
+ private readonly agentName: string,
800
+ onSelect: (color: AgentType["color"] | undefined) => void,
801
+ onCancel: () => void,
802
+ ) {
803
+ super();
804
+ this.theme = theme;
805
+ this.items = [
806
+ {
807
+ value: AGENT_COLOR_DEFAULT,
808
+ label: "Default (inherit)",
809
+ description: "Use the default accent background for the type pill",
810
+ },
811
+ ...AGENT_COLORS.map((color) => ({
812
+ value: color,
813
+ label: contrastPill(color, color, theme),
814
+ description: `Type pill background: ${color}`,
815
+ })),
816
+ ];
817
+ this.preview = new Text();
818
+ this.selectList = new SelectList(this.items, 10, getSelectListTheme(), {
819
+ minPrimaryColumnWidth: 18,
820
+ maxPrimaryColumnWidth: 28,
821
+ });
822
+ const selectedIndex = this.items.findIndex(
823
+ (item) => item.value === (currentColor ?? AGENT_COLOR_DEFAULT),
824
+ );
825
+ this.selectList.setSelectedIndex(selectedIndex >= 0 ? selectedIndex : 0);
826
+ this.selectList.onSelectionChange = (item) => {
827
+ this.updatePreview(tui, item.value);
828
+ };
829
+ this.selectList.onSelect = (item) => {
830
+ onSelect(
831
+ item.value === AGENT_COLOR_DEFAULT
832
+ ? undefined
833
+ : (item.value as AgentType["color"]),
834
+ );
835
+ };
836
+ this.selectList.onCancel = onCancel;
837
+ this.updatePreview(
838
+ tui,
839
+ this.items[selectedIndex >= 0 ? selectedIndex : 0]!.value,
840
+ );
841
+ }
842
+
843
+ getItems(): readonly {
844
+ value: string;
845
+ label: string;
846
+ description?: string;
847
+ }[] {
848
+ return this.items;
849
+ }
850
+
851
+ render(width: number): string[] {
852
+ const height = dialogHeight(this.tui);
853
+ const budget = Math.max(1, height - 7);
854
+ const selected = this.items.findIndex(
855
+ (item) => item.value === this.selectList.getSelectedItem()?.value,
856
+ );
857
+ const start = Math.max(
858
+ 0,
859
+ Math.min(selected - budget + 1, this.items.length - budget),
860
+ );
861
+ const body = [
862
+ ` ${truncateToWidth(
863
+ this.preview
864
+ .render(1000)
865
+ .map((line) => line.trim())
866
+ .filter(Boolean)
867
+ .join(" "),
868
+ Math.max(1, width - 4),
869
+ "",
870
+ )}`,
871
+ "",
872
+ ...this.items
873
+ .slice(start, start + budget)
874
+ .map(
875
+ (item, i) =>
876
+ `${start + i === selected ? "›" : " "} ${item.label} ${this.theme.fg("muted", item.description ?? "")}`,
877
+ ),
878
+ ];
879
+ return frameDialog(
880
+ this.theme,
881
+ width,
882
+ height,
883
+ "Agent color",
884
+ body,
885
+ "↑↓ preview · Enter apply · Esc cancel",
886
+ );
887
+ }
888
+
889
+ getPreview(): Text {
890
+ return this.preview;
891
+ }
892
+
893
+ getSelectList(): SelectList {
894
+ return this.selectList;
895
+ }
896
+
897
+ handleInput(keyData: string): void {
898
+ this.selectList.handleInput(keyData);
899
+ }
900
+
901
+ private updatePreview(
902
+ tui: { requestRender(force?: boolean): void },
903
+ value: string,
904
+ ): void {
905
+ const color = value === AGENT_COLOR_DEFAULT ? undefined : value;
906
+ const badge = agentTypeBadge(this.agentName, color, this.theme);
907
+ const path = agentPath("/root/example-task", color, this.theme);
908
+ this.preview.setText(
909
+ `Preview: ${badge} ${path} ${this.theme.fg("accent", "[running]")} Working`,
910
+ );
911
+ tui.requestRender();
912
+ }
913
+ }
914
+
915
+ async function selectAgentColor(
916
+ ctx: ExtensionCommandContext,
917
+ currentColor: AgentType["color"] | undefined,
918
+ agentName: string,
919
+ ): Promise<AgentType["color"] | undefined | typeof AGENT_COLOR_CANCEL> {
920
+ return ctx.ui.custom((tui, theme, _keys, done) => {
921
+ return new AgentColorPickerComponent(
922
+ tui,
923
+ theme,
924
+ currentColor,
925
+ agentName,
926
+ (value) => done(value),
927
+ () => done(AGENT_COLOR_CANCEL),
928
+ );
929
+ }, DIALOG_OPTIONS);
930
+ }
931
+
932
+ async function editDocument(
933
+ ctx: ExtensionCommandContext,
934
+ type: AgentType,
935
+ external: boolean,
936
+ ): Promise<AgentType | undefined> {
937
+ const original = serializeAgentType(type);
938
+ let text = original;
939
+ while (true) {
940
+ try {
941
+ if (external) {
942
+ text = await externalEdit(ctx, text);
943
+ } else {
944
+ const end = text.indexOf("\n---", 4);
945
+ const yaml = await dialogEditor(
946
+ ctx,
947
+ "Agent frontmatter YAML (Markdown body preserved)",
948
+ text.slice(4, end),
949
+ );
950
+ if (yaml === undefined) return;
951
+ text = `---\n${yaml.replace(/\n?$/, "\n")}---\n${type.systemPrompt}`;
952
+ }
953
+ return {
954
+ ...parseAgentType(text),
955
+ filePath: type.filePath,
956
+ source: type.source,
957
+ };
958
+ } catch (error) {
959
+ const diagnostic = sanitizeText(
960
+ error instanceof Error ? error.message : String(error),
961
+ );
962
+ ctx.ui.notify(
963
+ `Edit not accepted: ${diagnostic}. Original definition is unchanged.`,
964
+ "error",
965
+ );
966
+ const action = await dialogMenu(ctx, "Invalid edit — retry?", [
967
+ {
968
+ id: "Retry",
969
+ label: "Retry",
970
+ value: "Continue editing",
971
+ help: diagnostic,
972
+ },
973
+ {
974
+ id: "Cancel",
975
+ label: "Cancel",
976
+ value: "Restore previous definition",
977
+ help: diagnostic,
978
+ },
979
+ ]);
980
+ if (action !== "Retry") return;
981
+ }
982
+ }
983
+ }
984
+
985
+ /** Avoid treating a renamed definition as permission to overwrite another file. */
986
+ function assertSaveDestination(
987
+ store: ConfigStore,
988
+ type: AgentType,
989
+ original: AgentType | undefined,
990
+ scope: "user" | "project",
991
+ kind: AgentEditMode = "fork",
992
+ ): void {
993
+ if (kind === "override" && original && type.name !== original.name) {
994
+ throw new Error(
995
+ "Settings overrides cannot rename the agent; fork it instead.",
996
+ );
997
+ }
998
+ const entries = store.list();
999
+ const sameName = entries.find((entry) => entry.name === type.name);
1000
+ if (sameName && (!original || type.name !== original.name)) {
1001
+ throw new Error(
1002
+ `Agent type ${type.name} already exists; choose a different name.`,
1003
+ );
1004
+ }
1005
+ const destination = store.destination(type.name, scope, original, kind);
1006
+ if (
1007
+ existsSync(destination) &&
1008
+ (!original?.filePath || resolve(original.filePath) !== destination)
1009
+ ) {
1010
+ throw new Error(
1011
+ `Refusing to overwrite existing definition ${destination}. Edit that definition instead.`,
1012
+ );
1013
+ }
1014
+ // A scope cannot hold both a fork and an override for one name (fail-closed).
1015
+ const otherKind: AgentEditMode = kind === "fork" ? "override" : "fork";
1016
+ const otherDestination = store.destination(type.name, scope, undefined, otherKind);
1017
+ if (
1018
+ otherDestination !== destination &&
1019
+ existsSync(otherDestination) &&
1020
+ (!original?.filePath || resolve(original.filePath) !== otherDestination)
1021
+ ) {
1022
+ const kept = kind === "fork" ? ".md (full fork)" : ".yml (settings override)";
1023
+ throw new Error(
1024
+ `Agent type ${type.name} already has a ${otherKind === "fork" ? ".md fork" : ".yml override"} in this scope (${otherDestination}). Remove it before saving a ${kept}.`,
1025
+ );
1026
+ }
1027
+ }
1028
+
1029
+ const EDIT_MENU_ACTIONS = [
1030
+ "Customization",
1031
+ "name",
1032
+ "description",
1033
+ "models",
1034
+ "modelSuggestions",
1035
+ "thinkingLevel",
1036
+ "tools.allow",
1037
+ "tools.block",
1038
+ "color",
1039
+ "icon",
1040
+ "systemPrompt",
1041
+ "Save scope",
1042
+ "Source",
1043
+ "Edit frontmatter YAML",
1044
+ "External editor (entire Markdown)",
1045
+ "Save",
1046
+ "Cancel",
1047
+ ] as const;
1048
+
1049
+ type AgentEditMode = "fork" | "override";
1050
+
1051
+ type EditMenuAction = (typeof EDIT_MENU_ACTIONS)[number];
1052
+
1053
+ const EDIT_FIELD_LABELS: Record<EditMenuAction, string> = {
1054
+ Customization: "Customization",
1055
+ name: "Name",
1056
+ description: "Description",
1057
+ models: "Models",
1058
+ modelSuggestions: "Model suggestions",
1059
+ thinkingLevel: "Thinking level",
1060
+ "tools.allow": "Allowed tools",
1061
+ "tools.block": "Blocked tools",
1062
+ color: "Color",
1063
+ icon: "[labs] Icon",
1064
+ systemPrompt: "System prompt",
1065
+ "Save scope": "Save scope",
1066
+ Source: "Source (read-only)",
1067
+ "Edit frontmatter YAML": "Frontmatter YAML",
1068
+ "External editor (entire Markdown)": "External editor",
1069
+ Save: "Save",
1070
+ Cancel: "Cancel",
1071
+ };
1072
+
1073
+ const EDIT_FIELD_HELP: Record<EditMenuAction, string> = {
1074
+ Customization:
1075
+ "Settings override tweaks bundled settings via <name>.yml and keeps receiving prompt updates. Fork copies everything into <name>.md and you own the prompt.",
1076
+ name: "Unique type identifier. Renaming retains the original file.",
1077
+ description: "Description shown when browsing and spawning agents.",
1078
+ models:
1079
+ "Ordered model preferences. Unavailable models are skipped at runtime. Advisory names in model suggestions never pin a model.",
1080
+ modelSuggestions:
1081
+ "Advisory display names to help pick a model. Not provider/model pins; never selected or required. One name per line; empty clears.",
1082
+ thinkingLevel: "Reasoning effort. Default follows the parent session.",
1083
+ "tools.allow":
1084
+ "Unset uses default policy; an empty list explicitly allows no tools.",
1085
+ "tools.block": "Exact tool names to remove from the allowed set.",
1086
+ color: "Thread widget color with live preview.",
1087
+ icon: "Paste a single Nerd Font glyph from nerdfonts.com/cheat-sheet (not its name or codepoint).\nLeave blank to remove. Display requires [labs] Nerd Font icons in settings and a Nerd Font in your terminal.",
1088
+ systemPrompt: "Edit the Markdown prompt body in a multiline dialog.",
1089
+ "Save scope":
1090
+ "Project definitions override global definitions with the same name.",
1091
+ Source:
1092
+ "Read-only source. Bundled definitions are copied, never overwritten.",
1093
+ "Edit frontmatter YAML": "Edit metadata as YAML, preserving the prompt body.",
1094
+ "External editor (entire Markdown)":
1095
+ "Edit the complete definition using $VISUAL / $EDITOR.",
1096
+ Save: "Validate and save. Existing sessions keep their original configuration.",
1097
+ Cancel: "Close without saving any edits.",
1098
+ };
1099
+
1100
+ async function chooseSaveScope(
1101
+ ctx: ExtensionCommandContext,
1102
+ store: ConfigStore,
1103
+ current: "user" | "project",
1104
+ ): Promise<"user" | "project" | undefined> {
1105
+ const choices = [
1106
+ "Global",
1107
+ ...(store.canSaveProject() ? ["Trusted project"] : []),
1108
+ ];
1109
+ const value = await dialogMenu(
1110
+ ctx,
1111
+ "Save scope",
1112
+ choices.map((label) => ({ id: label, label })),
1113
+ { selectedId: current === "user" ? "Global" : "Trusted project" },
1114
+ );
1115
+ return value ? (value === "Global" ? "user" : "project") : undefined;
1116
+ }
1117
+
1118
+ /** Omitted tool lists use the default policy; [] is an explicit empty list, not inheritance. */
1119
+ function toolListMenuValue(names: string[] | undefined): string {
1120
+ if (names === undefined) return "Unset (use default policy)";
1121
+ if (names.length === 0) return "Empty list";
1122
+ return names.join(", ");
1123
+ }
1124
+
1125
+ function modelSuggestionLabel(suggestions: string[] | undefined): string {
1126
+ if (suggestions === undefined || suggestions.length === 0) return "None";
1127
+ return suggestions.join(", ");
1128
+ }
1129
+
1130
+ function modelSuggestionPrefill(suggestions: string[] | undefined): string {
1131
+ return (suggestions ?? []).map((name) => sanitizeText(name)).join("\n");
1132
+ }
1133
+
1134
+ function customizationMenuValue(
1135
+ editMode: AgentEditMode | undefined,
1136
+ isNew: boolean,
1137
+ ): string {
1138
+ if (isNew) return "New full definition (.md)";
1139
+ if (editMode === "override") return "Settings override (.yml)";
1140
+ if (editMode === "fork") return "Full copy (.md)";
1141
+ return "Choose to unlock editing";
1142
+ }
1143
+
1144
+ /** Bundled definitions are read-only until the user picks fork or override. */
1145
+ function isPureBundled(original: AgentType | undefined): boolean {
1146
+ return !!original && !original.customization && original.source === "bundled";
1147
+ }
1148
+
1149
+ async function chooseCustomizationMode(
1150
+ ctx: ExtensionCommandContext,
1151
+ agentName: string,
1152
+ current?: AgentEditMode,
1153
+ ): Promise<AgentEditMode | undefined> {
1154
+ const value = await dialogMenu(
1155
+ ctx,
1156
+ `Customize ${sanitizeText(agentName)}`,
1157
+ [
1158
+ {
1159
+ id: "override",
1160
+ label: "Tweak settings",
1161
+ value: `${agentName}.yml`,
1162
+ help: "Override only settings (models, tools, thinking, color). The prompt stays bundled and keeps receiving updates. System prompt and name stay locked.",
1163
+ },
1164
+ {
1165
+ id: "fork",
1166
+ label: "Fork agent",
1167
+ value: `${agentName}.md`,
1168
+ help: "Copy the full definition including the system prompt. You own the prompt; bundled updates no longer apply. Everything is editable.",
1169
+ },
1170
+ ],
1171
+ { selectedId: current },
1172
+ );
1173
+ return value === "override" || value === "fork" ? value : undefined;
1174
+ }
1175
+
1176
+ function editMenuLabel(action: EditMenuAction, draft: AgentType): string {
1177
+ switch (action) {
1178
+ case "name":
1179
+ return `name: ${sanitizeText(draft.name)}`;
1180
+ case "description":
1181
+ return `description: ${sanitizeText(draft.description)}`;
1182
+ case "models": {
1183
+ const models = getModelPreferences(draft);
1184
+ return `models: ${sanitizeText(models?.join(", ") ?? "Default (inherit)")}`;
1185
+ }
1186
+ case "modelSuggestions":
1187
+ return `modelSuggestions: ${sanitizeText(modelSuggestionLabel(draft.modelSuggestions))}`;
1188
+ case "thinkingLevel":
1189
+ return `thinkingLevel: ${sanitizeText(draft.thinkingLevel ?? "Default (inherit)")}`;
1190
+ case "tools.allow":
1191
+ return `tools.allow: ${sanitizeText(toolListMenuValue(draft.tools?.allow))}`;
1192
+ case "tools.block":
1193
+ return `tools.block: ${sanitizeText(toolListMenuValue(draft.tools?.block))}`;
1194
+ case "color":
1195
+ return `color: ${sanitizeText(draft.color ?? "Default (inherit)")}`;
1196
+ case "icon":
1197
+ return `icon: ${draft.icon ? "Configured" : "None"}`;
1198
+ case "systemPrompt":
1199
+ return `systemPrompt: ${draft.systemPrompt.split("\n").length} lines · ${draft.systemPrompt.length} characters`;
1200
+ default:
1201
+ return action;
1202
+ }
1203
+ }
1204
+
1205
+ export async function editAgentTypes(
1206
+ ctx: ExtensionCommandContext,
1207
+ store: ConfigStore,
1208
+ nerdFontIcons = false,
1209
+ ): Promise<void> {
1210
+ if (!canOpenDialog(ctx)) return;
1211
+ await withDialogSession(ctx, (scoped) => editAgentTypesDialog(scoped, store, nerdFontIcons));
1212
+ }
1213
+
1214
+ async function editAgentTypesDialog(
1215
+ ctx: ExtensionCommandContext,
1216
+ store: ConfigStore,
1217
+ nerdFontIcons: boolean,
1218
+ ): Promise<void> {
1219
+ while (true) {
1220
+ const types = store.list();
1221
+ const selection = await dialogMenu(ctx, "Agent types", [
1222
+ {
1223
+ id: "Create new type",
1224
+ label: "Create new type",
1225
+ value: "New definition",
1226
+ help: "Nothing is written until you save.",
1227
+ },
1228
+ ...types.map((type) => ({
1229
+ id: type.name,
1230
+ label: type.name,
1231
+ renderLabel: (label: string, theme: Theme) => agentTypeBadge(label, type.color, theme, type.icon, nerdFontIcons),
1232
+ value: type.description,
1233
+ help: `${type.source ?? "user"} · ${type.filePath ?? ""}`,
1234
+ })),
1235
+ ]);
1236
+ if (selection === undefined) return;
1237
+ const original =
1238
+ selection === "Create new type" ? undefined : store.get(selection);
1239
+ const isNew = !original;
1240
+ let draft: AgentType = original
1241
+ ? structuredClone(original)
1242
+ : { name: "new-agent", description: "New agent", systemPrompt: "" };
1243
+ const originalContent = serializeAgentType(draft);
1244
+ let selectedId: string | undefined;
1245
+ let saveScope: "user" | "project" =
1246
+ original?.source === "project" && store.canSaveProject()
1247
+ ? "project"
1248
+ : "user";
1249
+ // Bundled definitions stay read-only until the user picks how to own them.
1250
+ let editMode: AgentEditMode | undefined = isNew
1251
+ ? "fork"
1252
+ : (original?.customization?.kind ?? undefined);
1253
+ if (!isNew && isPureBundled(original)) {
1254
+ const mode = await chooseCustomizationMode(ctx, original.name);
1255
+ if (!mode) continue;
1256
+ editMode = mode;
1257
+ }
1258
+ while (true) {
1259
+ const baseForDiff =
1260
+ editMode === "override" && original
1261
+ ? (store.getBase(original.name) ?? original)
1262
+ : undefined;
1263
+ const dirty =
1264
+ editMode === "override" && baseForDiff
1265
+ ? Object.keys(diffAgentSettings(baseForDiff, draft)).length > 1
1266
+ : !original || serializeAgentType(draft) !== originalContent;
1267
+ const field = await dialogMenu(
1268
+ ctx,
1269
+ `Edit ${sanitizeText(draft.name)} (unsaved)`,
1270
+ EDIT_MENU_ACTIONS.map((action) => {
1271
+ const decorated = editMenuLabel(action, draft);
1272
+ const separator = decorated.indexOf(": ");
1273
+ return {
1274
+ id: action,
1275
+ label: EDIT_FIELD_LABELS[action],
1276
+ valueColor: action === "Save" && dirty ? "warning" as const : undefined,
1277
+ value:
1278
+ action === "Customization"
1279
+ ? customizationMenuValue(editMode, isNew)
1280
+ : action === "Save" && dirty
1281
+ ? "(changes)"
1282
+ : action === "Source"
1283
+ ? (original?.customization
1284
+ ? `${original.customization.scope} ${original.customization.kind} over ${original.baseSource ?? "bundled"}`
1285
+ : (original?.source ?? "New draft"))
1286
+ : action === "Save scope"
1287
+ ? saveScope === "user"
1288
+ ? "Global"
1289
+ : "Trusted project"
1290
+ : action === "icon" && nerdFontIcons && draft.icon
1291
+ ? draft.icon
1292
+ : separator >= 0
1293
+ ? decorated.slice(separator + 2)
1294
+ : "",
1295
+ help:
1296
+ action === "Source"
1297
+ ? (original?.customization
1298
+ ? `Settings file: ${original.customization.filePath}. Base prompt: ${original.baseFilePath ?? "(bundled)"}.`
1299
+ : (original?.filePath ??
1300
+ "Not saved yet. Bundled definitions are copied, never overwritten."))
1301
+ : EDIT_FIELD_HELP[action],
1302
+ };
1303
+ }),
1304
+ {
1305
+ selectedId,
1306
+ saveId: "Save",
1307
+ footer: "↑↓/Tab fields · Enter edit · Ctrl+S save · Esc cancel",
1308
+ },
1309
+ );
1310
+ selectedId = field;
1311
+ if (!field || field === "Cancel") break;
1312
+ try {
1313
+ if (field === "Customization") {
1314
+ if (isNew) {
1315
+ ctx.ui.notify("New types are always full definitions (.md).", "info");
1316
+ continue;
1317
+ }
1318
+ const mode = await chooseCustomizationMode(ctx, draft.name, editMode);
1319
+ if (!mode) continue;
1320
+ if (mode !== editMode && mode === "override" && original) {
1321
+ // Overrides carry no prompt body; drop any prompt edits.
1322
+ const base = store.getBase(original.name) ?? original;
1323
+ draft.systemPrompt = base.systemPrompt;
1324
+ ctx.ui.notify(
1325
+ "Switched to settings override: prompt edits were dropped (the bundled prompt applies).",
1326
+ "info",
1327
+ );
1328
+ }
1329
+ editMode = mode;
1330
+ continue;
1331
+ }
1332
+ if (!isNew && !editMode) {
1333
+ ctx.ui.notify(
1334
+ "Pick Customization first: tweak settings (.yml) or fork the agent (.md).",
1335
+ "error",
1336
+ );
1337
+ continue;
1338
+ }
1339
+ if (
1340
+ editMode === "override" &&
1341
+ (field === "name" ||
1342
+ field === "systemPrompt" ||
1343
+ field === "Edit frontmatter YAML" ||
1344
+ field === "External editor (entire Markdown)")
1345
+ ) {
1346
+ ctx.ui.notify(
1347
+ field === "name"
1348
+ ? "Settings overrides cannot rename the agent; fork it instead."
1349
+ : "The system prompt lives with the bundled definition; fork the agent to edit it.",
1350
+ "error",
1351
+ );
1352
+ continue;
1353
+ }
1354
+ if (field === "Save") {
1355
+ if (!editMode) continue;
1356
+ // Round-trip before saving: no partial or invalid configuration is accepted.
1357
+ const validated = parseAgentType(serializeAgentType(draft));
1358
+ const scope = await chooseSaveScope(ctx, store, saveScope);
1359
+ if (!scope) continue;
1360
+ assertSaveDestination(store, validated, original, scope, editMode);
1361
+ if (editMode === "override") {
1362
+ if (
1363
+ original?.customization?.kind === "fork" &&
1364
+ original.customization.scope === scope
1365
+ ) {
1366
+ store.removeCustomization(original.name, scope);
1367
+ }
1368
+ const saved = store.saveOverride(draft.name, validated, scope, original);
1369
+ ctx.ui.notify(
1370
+ `Saved settings override ${sanitizeText(saved.filePath ?? saved.name)}. Prompt and unset fields follow the base definition.`,
1371
+ "info",
1372
+ );
1373
+ break;
1374
+ }
1375
+ if (
1376
+ original?.customization?.kind === "override" &&
1377
+ original.customization.scope === scope
1378
+ ) {
1379
+ store.removeCustomization(original.name, scope);
1380
+ }
1381
+ const saved = store.save(validated, scope, original);
1382
+ ctx.ui.notify(
1383
+ `Saved ${sanitizeText(saved.filePath ?? saved.name)}${original && original.name !== saved.name ? " (original file retained)" : ""}.`,
1384
+ "info",
1385
+ );
1386
+ break;
1387
+ }
1388
+ if (field === "Save scope") {
1389
+ const scope = await chooseSaveScope(ctx, store, saveScope);
1390
+ if (scope) saveScope = scope;
1391
+ continue;
1392
+ }
1393
+ if (field === "Source") {
1394
+ ctx.ui.notify(
1395
+ original?.filePath ?? "This definition has not been saved yet.",
1396
+ "info",
1397
+ );
1398
+ continue;
1399
+ }
1400
+ if (
1401
+ field === "External editor (entire Markdown)" ||
1402
+ field === "Edit frontmatter YAML"
1403
+ ) {
1404
+ const edited = await editDocument(
1405
+ ctx,
1406
+ draft,
1407
+ field === "External editor (entire Markdown)",
1408
+ );
1409
+ if (edited) draft = edited;
1410
+ continue;
1411
+ }
1412
+ const candidate = structuredClone(draft);
1413
+ if (
1414
+ field === "name" ||
1415
+ field === "description" ||
1416
+ field === "systemPrompt"
1417
+ ) {
1418
+ const originalValue = candidate[field] ?? "";
1419
+ // Editor prefill must not carry terminal controls. An unmodified or
1420
+ // SDK-trimmed submit keeps the original, including whitespace and CRLF.
1421
+ const prefill = originalValue
1422
+ .replace(/\r\n/g, "\n")
1423
+ .replace(/\r/g, "\n")
1424
+ .split("\n")
1425
+ .map(sanitizeText)
1426
+ .join("\n");
1427
+ const value = await dialogEditor(ctx, `Agent ${field}`, prefill);
1428
+ if (value === undefined) continue;
1429
+ candidate[field] =
1430
+ value === prefill || value === prefill.trim()
1431
+ ? originalValue
1432
+ : value;
1433
+ } else if (field === "icon") {
1434
+ const value = await dialogEditor(ctx, "Agent icon (Nerd Font glyph; blank to remove)", candidate.icon ?? "");
1435
+ if (value === undefined) continue;
1436
+ const icon = value.trim();
1437
+ if (!icon) delete candidate.icon;
1438
+ else candidate.icon = icon;
1439
+ } else if (field === "models") {
1440
+ const value = await editModelPreferences(
1441
+ ctx,
1442
+ getModelPreferences(candidate),
1443
+ candidate.modelSuggestions,
1444
+ );
1445
+ if (value === MODEL_EDITOR_CANCEL) continue;
1446
+ if (value.length === 0) {
1447
+ delete candidate.models;
1448
+ delete candidate.model;
1449
+ } else {
1450
+ candidate.models = [...value];
1451
+ delete candidate.model;
1452
+ }
1453
+ } else if (field === "modelSuggestions") {
1454
+ const prefill = modelSuggestionPrefill(candidate.modelSuggestions);
1455
+ const value = await dialogEditor(
1456
+ ctx,
1457
+ "Agent modelSuggestions (one display name per line)",
1458
+ prefill,
1459
+ );
1460
+ if (value === undefined) continue;
1461
+ if (value === prefill || value === prefill.trim()) continue;
1462
+ const names = value
1463
+ .split("\n")
1464
+ .map((line) => line.trim())
1465
+ .filter((line) => line.length > 0);
1466
+ if (names.length === 0) delete candidate.modelSuggestions;
1467
+ else candidate.modelSuggestions = [...names];
1468
+ } else if (field === "thinkingLevel") {
1469
+ const value = await dialogMenu(
1470
+ ctx,
1471
+ "Thinking level",
1472
+ ["Default (inherit)", ...THINKING_LEVELS].map((label) => ({
1473
+ id: label,
1474
+ label,
1475
+ })),
1476
+ { selectedId: candidate.thinkingLevel ?? "Default (inherit)" },
1477
+ );
1478
+ if (!value) continue;
1479
+ if (value === "Default (inherit)") delete candidate.thinkingLevel;
1480
+ else candidate.thinkingLevel = value as AgentType["thinkingLevel"];
1481
+ } else if (field === "color") {
1482
+ const value = await selectAgentColor(
1483
+ ctx,
1484
+ candidate.color,
1485
+ candidate.name,
1486
+ );
1487
+ if (value === AGENT_COLOR_CANCEL) continue;
1488
+ if (value === undefined) delete candidate.color;
1489
+ else candidate.color = value;
1490
+ } else if (field === "tools.allow" || field === "tools.block") {
1491
+ await editToolList(
1492
+ ctx,
1493
+ candidate,
1494
+ field === "tools.allow" ? "allow" : "block",
1495
+ );
1496
+ }
1497
+ parseAgentType(serializeAgentType(candidate));
1498
+ draft = candidate;
1499
+ } catch (error) {
1500
+ ctx.ui.notify(
1501
+ sanitizeText(error instanceof Error ? error.message : String(error)),
1502
+ "error",
1503
+ );
1504
+ }
1505
+ }
1506
+ }
1507
+ }