@jwilger/pi-development-system 0.48.0 → 0.51.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.
@@ -1,10 +1,15 @@
1
- // @ts-nocheck: vendored upstream pi-subagent-manager 0.14.0 compiled under looser options; see src/subagents/VENDORED.md
1
+ // biome-ignore-all lint/complexity/noExcessiveCognitiveComplexity: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
2
+ // biome-ignore-all lint/style/noNestedTernary: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
3
+ // biome-ignore-all lint/suspicious/noControlCharactersInRegex: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
4
+ // biome-ignore-all lint/suspicious/noEmptyBlockStatements: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
5
+ // biome-ignore-all lint/suspicious/noShadow: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
6
+ // biome-ignore-all lint/suspicious/noUnnecessaryConditions: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
7
+ // biome-ignore-all lint/suspicious/useAwait: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
2
8
  import { spawnSync } from "node:child_process";
3
9
  import { existsSync } from "node:fs";
4
10
  import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
5
11
  import { tmpdir } from "node:os";
6
12
  import { join, resolve } from "node:path";
7
- import { parse as parseShell } from "shell-quote";
8
13
  import {
9
14
  type ExtensionCommandContext,
10
15
  type ExtensionContext,
@@ -14,40 +19,36 @@ import {
14
19
  } from "@earendil-works/pi-coding-agent";
15
20
  import {
16
21
  Container,
17
- SelectList,
18
- Text,
19
22
  colorToRgb,
20
23
  rgbColor,
24
+ SelectList,
21
25
  stripTerminalSequences,
26
+ Text,
22
27
  truncateToWidth,
23
28
  visibleWidth,
24
29
  } from "@earendil-works/pi-tui";
30
+ import { parse as parseShell } from "shell-quote";
25
31
  import {
26
32
  AGENT_COLORS,
27
- ConfigStore,
33
+ type ConfigStore,
28
34
  diffAgentSettings,
29
35
  parseAgentType,
30
36
  serializeAgentType,
31
37
  } from "../prefs/config.ts";
32
- import { editModelPreferences, MODEL_EDITOR_CANCEL } from "./model-picker.ts";
38
+ import { getModelPreferences } from "../prefs/models.ts";
39
+ import type { WidgetMode } from "../prefs/settings.ts";
40
+ import { type AgentType, THINKING_LEVELS, type ThreadService, type ThreadView } from "../types.ts";
33
41
  import {
34
42
  canOpenDialog,
43
+ DIALOG_OPTIONS,
35
44
  dialogEditor,
36
- dialogMenu,
37
45
  dialogHeight,
46
+ dialogMenu,
38
47
  frameDialog,
39
- DIALOG_OPTIONS,
40
48
  withDialogSession,
41
49
  } from "./dialog.ts";
42
- import { getModelPreferences } from "../prefs/models.ts";
43
- import type { WidgetMode } from "../prefs/settings.ts";
50
+ import { editModelPreferences, MODEL_EDITOR_CANCEL } from "./model-picker.ts";
44
51
  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
 
52
53
  export type ThreadController = Pick<
53
54
  ThreadService,
@@ -69,19 +70,14 @@ function agentColorToken(color: string | undefined): ThemeColor {
69
70
  }
70
71
 
71
72
  /** 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 {
73
+ function contrastPill(label: string, color: string | undefined, theme: AgentBadgeTheme): string {
77
74
  const background = theme.colors[agentColorToken(color)];
78
75
  const { r, g, b } = colorToRgb(background);
79
76
  const linear = (channel: number) => {
80
77
  const value = channel / 255;
81
78
  return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
82
79
  };
83
- const luminance =
84
- 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
80
+ const luminance = 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
85
81
  // Pick whichever of black/white has the higher WCAG contrast ratio.
86
82
  const foreground = luminance > Math.sqrt(0.0525) - 0.05 ? 0 : 255;
87
83
  return theme.style(` ${sanitizeText(label)} `, {
@@ -93,8 +89,8 @@ function contrastPill(
93
89
 
94
90
  /** Invalid/untrusted saved icons never reach the terminal. Names remain readable. */
95
91
  export function agentTypeLabel(type: string, icon?: string, nerdFontIcons = false): string {
96
- const prefix = nerdFontIcons && typeof icon === "string" && /^\p{Co}$/u.test(icon)
97
- ? `${icon} ` : "";
92
+ const prefix =
93
+ nerdFontIcons && typeof icon === "string" && /^\p{Co}$/u.test(icon) ? `${icon} ` : "";
98
94
  return `${prefix}${sanitizeText(type)}`;
99
95
  }
100
96
 
@@ -115,7 +111,8 @@ export function agentProgressIcon(
115
111
  ): string | undefined {
116
112
  if (!nerdFontIcons) return undefined;
117
113
  if (animate && (thread.state === "starting" || thread.state === "running")) {
118
- const frame = Math.floor(Math.max(0, now) / AGENT_PROGRESS_INTERVAL) % AGENT_PROGRESS_FRAMES.length;
114
+ const frame =
115
+ Math.floor(Math.max(0, now) / AGENT_PROGRESS_INTERVAL) % AGENT_PROGRESS_FRAMES.length;
119
116
  return AGENT_PROGRESS_FRAMES[frame];
120
117
  }
121
118
  return thread.icon;
@@ -131,18 +128,12 @@ export function agentTypeBadge(
131
128
  return contrastPill(agentTypeLabel(type, icon, nerdFontIcons), color, theme);
132
129
  }
133
130
 
134
- function agentPath(
135
- path: string,
136
- color: string | undefined,
137
- theme: AgentBadgeTheme,
138
- ): string {
131
+ function agentPath(path: string, color: string | undefined, theme: AgentBadgeTheme): string {
139
132
  return theme.fg(agentColorToken(color), sanitizeText(path));
140
133
  }
141
134
 
142
135
  function metricCount(value: number | undefined): number {
143
- return typeof value === "number" && Number.isFinite(value)
144
- ? Math.max(0, Math.floor(value))
145
- : 0;
136
+ return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
146
137
  }
147
138
 
148
139
  /** Settled active time plus the current live run, only while starting or running. */
@@ -194,13 +185,10 @@ function fitLine(left: string, right: string, width: number): string {
194
185
  }
195
186
 
196
187
  function isLive(thread: ThreadView): boolean {
197
- return (
198
- thread.path !== "/root" &&
199
- (thread.state === "starting" || thread.state === "running")
200
- );
188
+ return thread.path !== "/root" && (thread.state === "starting" || thread.state === "running");
201
189
  }
202
190
 
203
- function renderThreads(
191
+ function _renderThreads(
204
192
  threads: ThreadView[],
205
193
  width: number,
206
194
  theme: AgentBadgeTheme,
@@ -215,38 +203,28 @@ function renderThreads(
215
203
  const visible = threads
216
204
  .filter((thread) => thread.path !== "/root")
217
205
  .sort(
218
- (a, b) =>
219
- priority(a) - priority(b) ||
220
- b.updatedAt - a.updatedAt ||
221
- b.createdAt - a.createdAt,
206
+ (a, b) => priority(a) - priority(b) || b.updatedAt - a.updatedAt || b.createdAt - a.createdAt,
222
207
  );
223
208
  const limit = visible.length > 8 ? 7 : 8;
224
209
  const lines = visible.slice(0, limit).map((thread) => {
225
210
  const stateColor =
226
- thread.state === "failed"
227
- ? "error"
228
- : thread.state === "paused"
229
- ? "warning"
230
- : "accent";
211
+ thread.state === "failed" ? "error" : thread.state === "paused" ? "warning" : "accent";
231
212
  const badge = agentTypeBadge(
232
- thread.type, thread.color, theme, agentProgressIcon(thread, nerdFontIcons), nerdFontIcons,
213
+ thread.type,
214
+ thread.color,
215
+ theme,
216
+ agentProgressIcon(thread, nerdFontIcons),
217
+ nerdFontIcons,
233
218
  );
234
219
  const path = agentPath(thread.path, thread.color, theme);
235
220
  const state = theme.fg(stateColor, `[${sanitizeText(thread.state)}]`);
236
221
  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
- );
222
+ return fitLine(left, theme.fg("muted", threadMetrics(thread)), Math.max(0, width));
242
223
  });
243
224
  if (visible.length > limit) {
244
225
  lines.push(
245
226
  truncateToWidth(
246
- theme.fg(
247
- "muted",
248
- `+${visible.length - limit} more threads · /agents tree`,
249
- ),
227
+ theme.fg("muted", `+${visible.length - limit} more threads · /agents tree`),
250
228
  Math.max(0, width),
251
229
  ),
252
230
  );
@@ -281,7 +259,7 @@ function agentWidgetStatus(
281
259
  ].filter(Boolean);
282
260
  return truncateToWidth(
283
261
  theme.fg("accent", `${running} running`) +
284
- (details.length ? theme.fg("muted", ` · ${details.join(" · ")}`) : ""),
262
+ (details.length > 0 ? theme.fg("muted", ` · ${details.join(" · ")}`) : ""),
285
263
  width,
286
264
  "",
287
265
  );
@@ -293,26 +271,19 @@ function statePriority(state: ThreadView["state"] | undefined): number {
293
271
  return 2;
294
272
  }
295
273
 
296
- function explicitParent(
297
- path: string,
298
- threadsByPath: Map<string, ThreadView>,
299
- ): string | null {
274
+ function explicitParent(path: string, threadsByPath: Map<string, ThreadView>): string | null {
300
275
  if (path === WIDGET_ROOT) return null;
301
276
  const thread = threadsByPath.get(path);
302
- if (thread)
303
- return thread.parent && thread.parent !== path ? thread.parent : null;
277
+ if (thread) return thread.parent && thread.parent !== path ? thread.parent : null;
304
278
  const index = path.lastIndexOf("/");
305
279
  return index > 0 ? path.slice(0, index) : null;
306
280
  }
307
281
 
308
282
  /** Lowest state in the branch. Parent links define ancestry; cycles are guarded. */
309
- function branchPriorityComparator(
310
- threads: ThreadView[],
311
- ): (a: string, b: string) => number {
283
+ function branchPriorityComparator(threads: ThreadView[]): (a: string, b: string) => number {
312
284
  const threadsByPath = new Map<string, ThreadView>();
313
285
  for (const thread of threads) {
314
- if (thread?.path && !threadsByPath.has(thread.path))
315
- threadsByPath.set(thread.path, thread);
286
+ if (thread?.path && !threadsByPath.has(thread.path)) threadsByPath.set(thread.path, thread);
316
287
  }
317
288
  const children = new Map<string, string[]>();
318
289
  const link = (path: string, seen: Set<string>) => {
@@ -337,8 +308,7 @@ function branchPriorityComparator(
337
308
  if (stack.has(path)) return own;
338
309
  stack.add(path);
339
310
  let best = own;
340
- for (const child of children.get(path) ?? [])
341
- best = Math.min(best, subtreeOf(child, stack));
311
+ for (const child of children.get(path) ?? []) best = Math.min(best, subtreeOf(child, stack));
342
312
  stack.delete(path);
343
313
  subtree.set(path, best);
344
314
  return best;
@@ -363,14 +333,8 @@ function branchPriorityComparator(
363
333
  (a < b ? -1 : a > b ? 1 : 0);
364
334
  }
365
335
 
366
- function agentStateColor(
367
- state: ThreadView["state"],
368
- ): "error" | "warning" | "accent" {
369
- return state === "failed"
370
- ? "error"
371
- : state === "paused"
372
- ? "warning"
373
- : "accent";
336
+ function agentStateColor(state: ThreadView["state"]): "error" | "warning" | "accent" {
337
+ return state === "failed" ? "error" : state === "paused" ? "warning" : "accent";
374
338
  }
375
339
 
376
340
  function agentLine(
@@ -380,20 +344,20 @@ function agentLine(
380
344
  nerdFontIcons: boolean,
381
345
  animate: boolean,
382
346
  ): string {
383
- const thread = row.thread!;
347
+ const { thread } = row;
348
+ if (thread === undefined) return "";
384
349
  const badge = agentTypeBadge(
385
- thread.type, thread.color, theme,
386
- agentProgressIcon(thread, nerdFontIcons, Date.now(), animate), nerdFontIcons,
350
+ thread.type,
351
+ thread.color,
352
+ theme,
353
+ agentProgressIcon(thread, nerdFontIcons, Date.now(), animate),
354
+ nerdFontIcons,
387
355
  );
388
356
  const left = `${row.prefix}${badge} ${agentPath(thread.path, thread.color, theme)} ${theme.fg(agentStateColor(thread.state), `[${sanitizeText(thread.state)}]`)} ${sanitizeText(thread.task)}`;
389
357
  return fitLine(left, theme.fg("muted", threadMetrics(thread)), width);
390
358
  }
391
359
 
392
- function placeholderLine(
393
- row: StatusRow,
394
- width: number,
395
- theme: AgentBadgeTheme,
396
- ): string {
360
+ function placeholderLine(row: StatusRow, width: number, theme: AgentBadgeTheme): string {
397
361
  return truncateToWidth(
398
362
  `${row.prefix}${theme.fg("muted", `${sanitizeText(row.path)} missing parent`)}`,
399
363
  width,
@@ -405,33 +369,29 @@ function takeWidgetRows(rows: StatusRow[], budget: number): StatusRow[] {
405
369
  const parents = new Map<number, number[]>();
406
370
  const stack: number[] = [];
407
371
  for (const [index, row] of rows.entries()) {
408
- while (
409
- stack.length &&
410
- rows[stack[stack.length - 1]!]!.prefix.length >= row.prefix.length
411
- )
372
+ while (stack.length > 0 && (rows[stack.at(-1) ?? -1]?.prefix.length ?? 0) >= row.prefix.length)
412
373
  stack.pop();
413
374
  parents.set(index, [...stack]);
414
375
  stack.push(index);
415
376
  }
416
- const rowCost = (row: StatusRow) =>
417
- row.path === WIDGET_ROOT ? 0 : 1;
377
+ const rowCost = (row: StatusRow) => (row.path === WIDGET_ROOT ? 0 : 1);
418
378
  const candidates = rows
419
379
  .map((row, index) => ({ row, index }))
420
380
  .filter(({ row }) => row.thread && row.path !== WIDGET_ROOT)
421
381
  .sort(
422
382
  (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,
383
+ statePriority(a.row.thread?.state) - statePriority(b.row.thread?.state) ||
384
+ (b.row.thread?.createdAt ?? 0) - (a.row.thread?.createdAt ?? 0) ||
385
+ a.index - b.index,
426
386
  );
427
387
  const chosen = new Set<number>();
428
388
  let used = 0;
429
389
  for (const { index } of candidates) {
430
- const needed = [...parents.get(index)!, index].filter(
390
+ const needed = [...(parents.get(index) ?? []), index].filter(
431
391
  (ancestor) => !chosen.has(ancestor),
432
392
  );
433
393
  const cost = needed.reduce(
434
- (total, ancestor) => total + rowCost(rows[ancestor]!),
394
+ (total, ancestor) => total + (rows[ancestor] ? rowCost(rows[ancestor]) : 0),
435
395
  0,
436
396
  );
437
397
  if (used + cost > budget) continue;
@@ -439,7 +399,7 @@ function takeWidgetRows(rows: StatusRow[], budget: number): StatusRow[] {
439
399
  used += cost;
440
400
  }
441
401
  // If a missing-parent chain cannot fit its agent, retain a useful tree prefix.
442
- if (!chosen.size) {
402
+ if (chosen.size === 0) {
443
403
  for (const [index, row] of rows.entries()) {
444
404
  const cost = rowCost(row);
445
405
  if (used + cost > budget) break;
@@ -447,9 +407,7 @@ function takeWidgetRows(rows: StatusRow[], budget: number): StatusRow[] {
447
407
  used += cost;
448
408
  }
449
409
  }
450
- return rows.filter(
451
- (row, index) => row.path !== WIDGET_ROOT && chosen.has(index),
452
- );
410
+ return rows.filter((row, index) => row.path !== WIDGET_ROOT && chosen.has(index));
453
411
  }
454
412
 
455
413
  /** Themed agent tree above the editor. Synthetic /root is the main conversation and is omitted. */
@@ -460,18 +418,13 @@ function renderAgentTree(
460
418
  { showBrowserHint = true, nerdFontIcons = false, animate = true }: AgentWidgetRenderOptions = {},
461
419
  ): string[] {
462
420
  const columns = Math.max(0, width);
463
- const rows = buildStatusTree(
464
- threads,
465
- new Set(),
466
- branchPriorityComparator(threads),
467
- );
421
+ const rows = buildStatusTree(threads, new Set(), branchPriorityComparator(threads));
468
422
  const agents = rows.filter((row) => row.thread && row.path !== WIDGET_ROOT);
469
- if (!agents.length) return [];
423
+ if (agents.length === 0) return [];
470
424
  const live = agents.filter(
471
- (row) =>
472
- row.thread!.state === "starting" || row.thread!.state === "running",
425
+ (row) => row.thread?.state === "starting" || row.thread?.state === "running",
473
426
  ).length;
474
- const paused = agents.filter((row) => row.thread!.state === "paused").length;
427
+ const paused = agents.filter((row) => row.thread?.state === "paused").length;
475
428
  const heading = fitLine(
476
429
  theme.fg("accent", "Agents"),
477
430
  theme.fg("muted", `${live} live · ${paused} paused`),
@@ -504,7 +457,7 @@ function renderAgentSummary(
504
457
  if (thread?.path && thread.path !== WIDGET_ROOT && !agents.has(thread.path))
505
458
  agents.set(thread.path, thread);
506
459
  }
507
- if (!agents.size) return [];
460
+ if (agents.size === 0) return [];
508
461
 
509
462
  const counts: Record<Exclude<ThreadView["state"], "starting">, number> = {
510
463
  running: 0,
@@ -529,14 +482,20 @@ function renderAgentSummary(
529
482
  ["paused", "warning"],
530
483
  ["completed", "success"],
531
484
  ];
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", ", ")) +
485
+ const progress =
486
+ counts.running > 0
487
+ ? agentProgressIcon({ state: "running" }, nerdFontIcons, Date.now(), animate)
488
+ : undefined;
489
+ const left =
490
+ statuses
491
+ .filter(([state]) => state === "running" || counts[state] > 0)
492
+ .map(([state, color]) =>
493
+ theme.fg(
494
+ color,
495
+ `${state === "running" && progress ? `${progress} ` : ""}${counts[state]} ${state}`,
496
+ ),
497
+ )
498
+ .join(theme.fg("muted", ", ")) +
540
499
  (showBrowserHint && counts.running > 0 ? theme.fg("muted", " · ← browser") : "");
541
500
  const right = theme.fg("muted", `↑${formatCount(input)} ↓${formatCount(output)}`);
542
501
  return [fitLine(left, right, Math.max(0, width))];
@@ -572,14 +531,18 @@ export function updateWidget(
572
531
  (tui) => {
573
532
  let timer: ReturnType<typeof setInterval> | undefined;
574
533
  if (snapshot.some(isLive) && (mode === "full" || nerdFontIcons)) {
575
- timer = setInterval(() => tui.requestRender(), nerdFontIcons ? AGENT_PROGRESS_INTERVAL : 1000);
534
+ timer = setInterval(
535
+ () => tui.requestRender(),
536
+ nerdFontIcons ? AGENT_PROGRESS_INTERVAL : 1000,
537
+ );
576
538
  timer.unref();
577
539
  }
578
540
  return {
579
- render: (width) => render(snapshot, width, ctx.ui.theme, {
580
- showBrowserHint: !settled,
581
- nerdFontIcons,
582
- }),
541
+ render: (width) =>
542
+ render(snapshot, width, ctx.ui.theme, {
543
+ showBrowserHint: !settled,
544
+ nerdFontIcons,
545
+ }),
583
546
  invalidate: () => {},
584
547
  dispose: () => {
585
548
  if (timer) clearInterval(timer);
@@ -601,10 +564,8 @@ export async function showThreads(
601
564
  while (true) {
602
565
  try {
603
566
  if (!selectedPath) {
604
- const threads = controller
605
- .list()
606
- .filter((thread) => thread.path !== "/root");
607
- if (!threads.length) {
567
+ const threads = controller.list().filter((thread) => thread.path !== "/root");
568
+ if (threads.length === 0) {
608
569
  ctx.ui.notify("No subagent threads.", "info");
609
570
  return;
610
571
  }
@@ -620,16 +581,14 @@ export async function showThreads(
620
581
  if (selectedPath === "/root")
621
582
  throw new Error("/root is the current Pi thread, not a subagent.");
622
583
  const thread = controller.get(selectedPath);
623
- const children = controller
624
- .list()
625
- .filter((child) => child.parent === selectedPath);
584
+ const children = controller.list().filter((child) => child.parent === selectedPath);
626
585
  const title = `${sanitizeText(thread.path)} [${sanitizeText(thread.state)}] ${sanitizeText(thread.status)}`;
627
586
  const action = await ctx.ui.select(title, [
628
587
  "View output",
629
588
  "View transcript",
630
589
  "Send input / resume",
631
590
  "Stop",
632
- ...(children.length ? ["Children"] : []),
591
+ ...(children.length > 0 ? ["Children"] : []),
633
592
  "Back",
634
593
  ]);
635
594
  if (!action || action === "Back") {
@@ -648,10 +607,7 @@ export async function showThreads(
648
607
  text.split("\n").map(sanitizeText).join("\n"),
649
608
  );
650
609
  } else if (action === "Send input / resume") {
651
- const message = await ctx.ui.editor(
652
- "Send input / resume retained session",
653
- "",
654
- );
610
+ const message = await ctx.ui.editor("Send input / resume retained session", "");
655
611
  if (message?.trim()) await controller.steer(selectedPath, message);
656
612
  } else if (action === "Stop") {
657
613
  if (
@@ -668,14 +624,10 @@ export async function showThreads(
668
624
  );
669
625
  const selected = await ctx.ui.select("Children", labels);
670
626
  if (selected !== undefined)
671
- selectedPath =
672
- children[labels.indexOf(selected)]?.path ?? selectedPath;
627
+ selectedPath = children[labels.indexOf(selected)]?.path ?? selectedPath;
673
628
  }
674
629
  } catch (error) {
675
- ctx.ui.notify(
676
- sanitizeText(error instanceof Error ? error.message : String(error)),
677
- "error",
678
- );
630
+ ctx.ui.notify(sanitizeText(error instanceof Error ? error.message : String(error)), "error");
679
631
  return;
680
632
  }
681
633
  }
@@ -684,7 +636,7 @@ export async function showThreads(
684
636
  /** Parse editor argv, rejecting shell operators instead of evaluating a shell command. */
685
637
  function editorArguments(command: string): string[] {
686
638
  const args = parseShell(command, process.env);
687
- if (!args.length || args.some((arg) => typeof arg !== "string") || !args[0]) {
639
+ if (args.length === 0 || args.some((arg) => typeof arg !== "string") || !args[0]) {
688
640
  throw new Error(
689
641
  "VISUAL/EDITOR must be an executable and arguments, without shell operators or globs.",
690
642
  );
@@ -692,15 +644,10 @@ function editorArguments(command: string): string[] {
692
644
  return args as string[];
693
645
  }
694
646
 
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
- );
647
+ async function externalEdit(ctx: ExtensionCommandContext, text: string): Promise<string> {
648
+ if (ctx.mode !== "tui") throw new Error("External editing requires interactive TUI mode.");
649
+ const [command, ...args] = editorArguments(process.env.VISUAL || process.env.EDITOR || "vi");
650
+ if (command === undefined) throw new Error("No editor command is configured.");
704
651
  const directory = await mkdtemp(join(tmpdir(), "pi-subagent-"));
705
652
  const file = join(directory, "agent.md");
706
653
  try {
@@ -708,15 +655,13 @@ async function externalEdit(
708
655
  await ctx.ui.custom<void>((tui, _theme, _keys, done) => {
709
656
  tui.stop();
710
657
  try {
711
- const result = spawnSync(command!, [...args, file], {
658
+ const result = spawnSync(command, [...args, file], {
712
659
  stdio: "inherit",
713
660
  env: process.env,
714
661
  });
715
662
  if (result.error) throw result.error;
716
663
  if (result.status !== 0)
717
- throw new Error(
718
- `Editor exited with status ${result.status ?? result.signal}.`,
719
- );
664
+ throw new Error(`Editor exited with status ${result.status ?? result.signal}.`);
720
665
  } finally {
721
666
  tui.start();
722
667
  tui.requestRender(true);
@@ -735,23 +680,14 @@ async function editToolList(
735
680
  type: AgentType,
736
681
  field: "allow" | "block",
737
682
  ): Promise<void> {
738
- const modes = [
739
- "Unset (use default policy)",
740
- "Empty list",
741
- "Enter exact tool names",
742
- ];
683
+ const modes = ["Unset (use default policy)", "Empty list", "Enter exact tool names"];
743
684
  const current = type.tools?.[field];
744
685
  const mode = await dialogMenu(
745
686
  ctx,
746
687
  `tools.${field} (${sanitizeText(toolListMenuValue(current))})`,
747
688
  modes.map((label) => ({ id: label, label })),
748
689
  {
749
- selectedId:
750
- current === undefined
751
- ? modes[0]
752
- : current.length === 0
753
- ? modes[1]
754
- : modes[2],
690
+ selectedId: current === undefined ? modes[0] : current.length === 0 ? modes[1] : modes[2],
755
691
  },
756
692
  );
757
693
  if (!mode) return;
@@ -773,7 +709,7 @@ async function editToolList(
773
709
  .map((name) => name.trim())
774
710
  .filter(Boolean);
775
711
  }
776
- if (type.tools && !Object.keys(type.tools).length) delete type.tools;
712
+ if (type.tools && Object.keys(type.tools).length === 0) type.tools = undefined;
777
713
  }
778
714
 
779
715
  const AGENT_COLOR_DEFAULT = "__default__";
@@ -789,18 +725,29 @@ class AgentColorPickerComponent extends Container {
789
725
  private readonly theme: Theme;
790
726
  readonly selectList: SelectList;
791
727
 
728
+ private readonly tui: {
729
+ requestRender(force?: boolean): void;
730
+ terminal?: { rows: number };
731
+ };
732
+
733
+ private readonly agentName: string;
734
+
792
735
  constructor(
793
- private readonly tui: {
736
+ tui: {
794
737
  requestRender(force?: boolean): void;
795
738
  terminal?: { rows: number };
796
739
  },
797
740
  theme: Theme,
798
741
  currentColor: AgentType["color"] | undefined,
799
- private readonly agentName: string,
742
+ agentName: string,
800
743
  onSelect: (color: AgentType["color"] | undefined) => void,
801
744
  onCancel: () => void,
802
745
  ) {
803
746
  super();
747
+
748
+ this.tui = tui;
749
+
750
+ this.agentName = agentName;
804
751
  this.theme = theme;
805
752
  this.items = [
806
753
  {
@@ -827,17 +774,11 @@ class AgentColorPickerComponent extends Container {
827
774
  this.updatePreview(tui, item.value);
828
775
  };
829
776
  this.selectList.onSelect = (item) => {
830
- onSelect(
831
- item.value === AGENT_COLOR_DEFAULT
832
- ? undefined
833
- : (item.value as AgentType["color"]),
834
- );
777
+ onSelect(item.value === AGENT_COLOR_DEFAULT ? undefined : (item.value as AgentType["color"]));
835
778
  };
836
779
  this.selectList.onCancel = onCancel;
837
- this.updatePreview(
838
- tui,
839
- this.items[selectedIndex >= 0 ? selectedIndex : 0]!.value,
840
- );
780
+ const initial = this.items[selectedIndex >= 0 ? selectedIndex : 0];
781
+ if (initial !== undefined) this.updatePreview(tui, initial.value);
841
782
  }
842
783
 
843
784
  getItems(): readonly {
@@ -854,10 +795,7 @@ class AgentColorPickerComponent extends Container {
854
795
  const selected = this.items.findIndex(
855
796
  (item) => item.value === this.selectList.getSelectedItem()?.value,
856
797
  );
857
- const start = Math.max(
858
- 0,
859
- Math.min(selected - budget + 1, this.items.length - budget),
860
- );
798
+ const start = Math.max(0, Math.min(selected - budget + 1, this.items.length - budget));
861
799
  const body = [
862
800
  ` ${truncateToWidth(
863
801
  this.preview
@@ -898,10 +836,7 @@ class AgentColorPickerComponent extends Container {
898
836
  this.selectList.handleInput(keyData);
899
837
  }
900
838
 
901
- private updatePreview(
902
- tui: { requestRender(force?: boolean): void },
903
- value: string,
904
- ): void {
839
+ private updatePreview(tui: { requestRender(force?: boolean): void }, value: string): void {
905
840
  const color = value === AGENT_COLOR_DEFAULT ? undefined : value;
906
841
  const badge = agentTypeBadge(this.agentName, color, this.theme);
907
842
  const path = agentPath("/root/example-task", color, this.theme);
@@ -956,13 +891,8 @@ async function editDocument(
956
891
  source: type.source,
957
892
  };
958
893
  } 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
- );
894
+ const diagnostic = sanitizeText(error instanceof Error ? error.message : String(error));
895
+ ctx.ui.notify(`Edit not accepted: ${diagnostic}. Original definition is unchanged.`, "error");
966
896
  const action = await dialogMenu(ctx, "Invalid edit — retry?", [
967
897
  {
968
898
  id: "Retry",
@@ -991,16 +921,12 @@ function assertSaveDestination(
991
921
  kind: AgentEditMode = "fork",
992
922
  ): void {
993
923
  if (kind === "override" && original && type.name !== original.name) {
994
- throw new Error(
995
- "Settings overrides cannot rename the agent; fork it instead.",
996
- );
924
+ throw new Error("Settings overrides cannot rename the agent; fork it instead.");
997
925
  }
998
926
  const entries = store.list();
999
927
  const sameName = entries.find((entry) => entry.name === type.name);
1000
928
  if (sameName && (!original || type.name !== original.name)) {
1001
- throw new Error(
1002
- `Agent type ${type.name} already exists; choose a different name.`,
1003
- );
929
+ throw new Error(`Agent type ${type.name} already exists; choose a different name.`);
1004
930
  }
1005
931
  const destination = store.destination(type.name, scope, original, kind);
1006
932
  if (
@@ -1080,19 +1006,15 @@ const EDIT_FIELD_HELP: Record<EditMenuAction, string> = {
1080
1006
  modelSuggestions:
1081
1007
  "Advisory display names to help pick a model. Not provider/model pins; never selected or required. One name per line; empty clears.",
1082
1008
  thinkingLevel: "Reasoning effort. Default follows the parent session.",
1083
- "tools.allow":
1084
- "Unset uses default policy; an empty list explicitly allows no tools.",
1009
+ "tools.allow": "Unset uses default policy; an empty list explicitly allows no tools.",
1085
1010
  "tools.block": "Exact tool names to remove from the allowed set.",
1086
1011
  color: "Thread widget color with live preview.",
1087
1012
  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
1013
  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.",
1014
+ "Save scope": "Project definitions override global definitions with the same name.",
1015
+ Source: "Read-only source. Bundled definitions are copied, never overwritten.",
1093
1016
  "Edit frontmatter YAML": "Edit metadata as YAML, preserving the prompt body.",
1094
- "External editor (entire Markdown)":
1095
- "Edit the complete definition using $VISUAL / $EDITOR.",
1017
+ "External editor (entire Markdown)": "Edit the complete definition using $VISUAL / $EDITOR.",
1096
1018
  Save: "Validate and save. Existing sessions keep their original configuration.",
1097
1019
  Cancel: "Close without saving any edits.",
1098
1020
  };
@@ -1102,10 +1024,7 @@ async function chooseSaveScope(
1102
1024
  store: ConfigStore,
1103
1025
  current: "user" | "project",
1104
1026
  ): Promise<"user" | "project" | undefined> {
1105
- const choices = [
1106
- "Global",
1107
- ...(store.canSaveProject() ? ["Trusted project"] : []),
1108
- ];
1027
+ const choices = ["Global", ...(store.canSaveProject() ? ["Trusted project"] : [])];
1109
1028
  const value = await dialogMenu(
1110
1029
  ctx,
1111
1030
  "Save scope",
@@ -1131,10 +1050,7 @@ function modelSuggestionPrefill(suggestions: string[] | undefined): string {
1131
1050
  return (suggestions ?? []).map((name) => sanitizeText(name)).join("\n");
1132
1051
  }
1133
1052
 
1134
- function customizationMenuValue(
1135
- editMode: AgentEditMode | undefined,
1136
- isNew: boolean,
1137
- ): string {
1053
+ function customizationMenuValue(editMode: AgentEditMode | undefined, isNew: boolean): string {
1138
1054
  if (isNew) return "New full definition (.md)";
1139
1055
  if (editMode === "override") return "Settings override (.yml)";
1140
1056
  if (editMode === "fork") return "Full copy (.md)";
@@ -1228,14 +1144,14 @@ async function editAgentTypesDialog(
1228
1144
  ...types.map((type) => ({
1229
1145
  id: type.name,
1230
1146
  label: type.name,
1231
- renderLabel: (label: string, theme: Theme) => agentTypeBadge(label, type.color, theme, type.icon, nerdFontIcons),
1147
+ renderLabel: (label: string, theme: Theme) =>
1148
+ agentTypeBadge(label, type.color, theme, type.icon, nerdFontIcons),
1232
1149
  value: type.description,
1233
1150
  help: `${type.source ?? "user"} · ${type.filePath ?? ""}`,
1234
1151
  })),
1235
1152
  ]);
1236
1153
  if (selection === undefined) return;
1237
- const original =
1238
- selection === "Create new type" ? undefined : store.get(selection);
1154
+ const original = selection === "Create new type" ? undefined : store.get(selection);
1239
1155
  const isNew = !original;
1240
1156
  let draft: AgentType = original
1241
1157
  ? structuredClone(original)
@@ -1243,9 +1159,7 @@ async function editAgentTypesDialog(
1243
1159
  const originalContent = serializeAgentType(draft);
1244
1160
  let selectedId: string | undefined;
1245
1161
  let saveScope: "user" | "project" =
1246
- original?.source === "project" && store.canSaveProject()
1247
- ? "project"
1248
- : "user";
1162
+ original?.source === "project" && store.canSaveProject() ? "project" : "user";
1249
1163
  // Bundled definitions stay read-only until the user picks how to own them.
1250
1164
  let editMode: AgentEditMode | undefined = isNew
1251
1165
  ? "fork"
@@ -1273,16 +1187,16 @@ async function editAgentTypesDialog(
1273
1187
  return {
1274
1188
  id: action,
1275
1189
  label: EDIT_FIELD_LABELS[action],
1276
- valueColor: action === "Save" && dirty ? "warning" as const : undefined,
1190
+ valueColor: action === "Save" && dirty ? ("warning" as const) : undefined,
1277
1191
  value:
1278
1192
  action === "Customization"
1279
1193
  ? customizationMenuValue(editMode, isNew)
1280
1194
  : action === "Save" && dirty
1281
1195
  ? "(changes)"
1282
1196
  : action === "Source"
1283
- ? (original?.customization
1197
+ ? original?.customization
1284
1198
  ? `${original.customization.scope} ${original.customization.kind} over ${original.baseSource ?? "bundled"}`
1285
- : (original?.source ?? "New draft"))
1199
+ : (original?.source ?? "New draft")
1286
1200
  : action === "Save scope"
1287
1201
  ? saveScope === "user"
1288
1202
  ? "Global"
@@ -1294,10 +1208,10 @@ async function editAgentTypesDialog(
1294
1208
  : "",
1295
1209
  help:
1296
1210
  action === "Source"
1297
- ? (original?.customization
1211
+ ? original?.customization
1298
1212
  ? `Settings file: ${original.customization.filePath}. Base prompt: ${original.baseFilePath ?? "(bundled)"}.`
1299
1213
  : (original?.filePath ??
1300
- "Not saved yet. Bundled definitions are copied, never overwritten."))
1214
+ "Not saved yet. Bundled definitions are copied, never overwritten.")
1301
1215
  : EDIT_FIELD_HELP[action],
1302
1216
  };
1303
1217
  }),
@@ -1329,7 +1243,7 @@ async function editAgentTypesDialog(
1329
1243
  editMode = mode;
1330
1244
  continue;
1331
1245
  }
1332
- if (!isNew && !editMode) {
1246
+ if (!(isNew || editMode)) {
1333
1247
  ctx.ui.notify(
1334
1248
  "Pick Customization first: tweak settings (.yml) or fork the agent (.md).",
1335
1249
  "error",
@@ -1391,16 +1305,10 @@ async function editAgentTypesDialog(
1391
1305
  continue;
1392
1306
  }
1393
1307
  if (field === "Source") {
1394
- ctx.ui.notify(
1395
- original?.filePath ?? "This definition has not been saved yet.",
1396
- "info",
1397
- );
1308
+ ctx.ui.notify(original?.filePath ?? "This definition has not been saved yet.", "info");
1398
1309
  continue;
1399
1310
  }
1400
- if (
1401
- field === "External editor (entire Markdown)" ||
1402
- field === "Edit frontmatter YAML"
1403
- ) {
1311
+ if (field === "External editor (entire Markdown)" || field === "Edit frontmatter YAML") {
1404
1312
  const edited = await editDocument(
1405
1313
  ctx,
1406
1314
  draft,
@@ -1410,11 +1318,7 @@ async function editAgentTypesDialog(
1410
1318
  continue;
1411
1319
  }
1412
1320
  const candidate = structuredClone(draft);
1413
- if (
1414
- field === "name" ||
1415
- field === "description" ||
1416
- field === "systemPrompt"
1417
- ) {
1321
+ if (field === "name" || field === "description" || field === "systemPrompt") {
1418
1322
  const originalValue = candidate[field] ?? "";
1419
1323
  // Editor prefill must not carry terminal controls. An unmodified or
1420
1324
  // SDK-trimmed submit keeps the original, including whitespace and CRLF.
@@ -1426,15 +1330,16 @@ async function editAgentTypesDialog(
1426
1330
  .join("\n");
1427
1331
  const value = await dialogEditor(ctx, `Agent ${field}`, prefill);
1428
1332
  if (value === undefined) continue;
1429
- candidate[field] =
1430
- value === prefill || value === prefill.trim()
1431
- ? originalValue
1432
- : value;
1333
+ candidate[field] = value === prefill || value === prefill.trim() ? originalValue : value;
1433
1334
  } else if (field === "icon") {
1434
- const value = await dialogEditor(ctx, "Agent icon (Nerd Font glyph; blank to remove)", candidate.icon ?? "");
1335
+ const value = await dialogEditor(
1336
+ ctx,
1337
+ "Agent icon (Nerd Font glyph; blank to remove)",
1338
+ candidate.icon ?? "",
1339
+ );
1435
1340
  if (value === undefined) continue;
1436
1341
  const icon = value.trim();
1437
- if (!icon) delete candidate.icon;
1342
+ if (!icon) candidate.icon = undefined;
1438
1343
  else candidate.icon = icon;
1439
1344
  } else if (field === "models") {
1440
1345
  const value = await editModelPreferences(
@@ -1444,11 +1349,11 @@ async function editAgentTypesDialog(
1444
1349
  );
1445
1350
  if (value === MODEL_EDITOR_CANCEL) continue;
1446
1351
  if (value.length === 0) {
1447
- delete candidate.models;
1448
- delete candidate.model;
1352
+ candidate.models = undefined;
1353
+ candidate.model = undefined;
1449
1354
  } else {
1450
1355
  candidate.models = [...value];
1451
- delete candidate.model;
1356
+ candidate.model = undefined;
1452
1357
  }
1453
1358
  } else if (field === "modelSuggestions") {
1454
1359
  const prefill = modelSuggestionPrefill(candidate.modelSuggestions);
@@ -1463,7 +1368,7 @@ async function editAgentTypesDialog(
1463
1368
  .split("\n")
1464
1369
  .map((line) => line.trim())
1465
1370
  .filter((line) => line.length > 0);
1466
- if (names.length === 0) delete candidate.modelSuggestions;
1371
+ if (names.length === 0) candidate.modelSuggestions = undefined;
1467
1372
  else candidate.modelSuggestions = [...names];
1468
1373
  } else if (field === "thinkingLevel") {
1469
1374
  const value = await dialogMenu(
@@ -1476,23 +1381,15 @@ async function editAgentTypesDialog(
1476
1381
  { selectedId: candidate.thinkingLevel ?? "Default (inherit)" },
1477
1382
  );
1478
1383
  if (!value) continue;
1479
- if (value === "Default (inherit)") delete candidate.thinkingLevel;
1384
+ if (value === "Default (inherit)") candidate.thinkingLevel = undefined;
1480
1385
  else candidate.thinkingLevel = value as AgentType["thinkingLevel"];
1481
1386
  } else if (field === "color") {
1482
- const value = await selectAgentColor(
1483
- ctx,
1484
- candidate.color,
1485
- candidate.name,
1486
- );
1387
+ const value = await selectAgentColor(ctx, candidate.color, candidate.name);
1487
1388
  if (value === AGENT_COLOR_CANCEL) continue;
1488
- if (value === undefined) delete candidate.color;
1389
+ if (value === undefined) candidate.color = undefined;
1489
1390
  else candidate.color = value;
1490
1391
  } else if (field === "tools.allow" || field === "tools.block") {
1491
- await editToolList(
1492
- ctx,
1493
- candidate,
1494
- field === "tools.allow" ? "allow" : "block",
1495
- );
1392
+ await editToolList(ctx, candidate, field === "tools.allow" ? "allow" : "block");
1496
1393
  }
1497
1394
  parseAgentType(serializeAgentType(candidate));
1498
1395
  draft = candidate;