brl-subagent 2.3.4

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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +257 -0
  3. package/package.json +49 -0
  4. package/presets/code-reviewer.md +37 -0
  5. package/presets/data-analyst.md +33 -0
  6. package/presets/debugger.md +36 -0
  7. package/presets/dev-agent.md +30 -0
  8. package/presets/rapid-prototyper.md +25 -0
  9. package/presets/refactorer.md +34 -0
  10. package/presets/security-auditor.md +39 -0
  11. package/presets/tech-writer.md +33 -0
  12. package/presets/test-engineer.md +34 -0
  13. package/src/concurrency.ts +186 -0
  14. package/src/diff.ts +102 -0
  15. package/src/event-bus.ts +86 -0
  16. package/src/git.ts +187 -0
  17. package/src/history.ts +177 -0
  18. package/src/index.ts +3824 -0
  19. package/src/logging.ts +135 -0
  20. package/src/messaging.ts +164 -0
  21. package/src/metrics.ts +221 -0
  22. package/src/model-availability.ts +73 -0
  23. package/src/notify-completion.ts +207 -0
  24. package/src/params.ts +225 -0
  25. package/src/preflight.ts +150 -0
  26. package/src/presets.ts +425 -0
  27. package/src/prompt.ts +169 -0
  28. package/src/reports.ts +325 -0
  29. package/src/router.ts +133 -0
  30. package/src/runner.ts +877 -0
  31. package/src/sanitize.ts +365 -0
  32. package/src/scheduler.ts +187 -0
  33. package/src/session-manager.ts +1065 -0
  34. package/src/state.ts +590 -0
  35. package/src/templates.ts +469 -0
  36. package/src/transcript-tail.ts +327 -0
  37. package/src/transcript.ts +119 -0
  38. package/src/tui-format.ts +51 -0
  39. package/src/tui.ts +3103 -0
  40. package/src/types.ts +803 -0
  41. package/src/unit-run.ts +269 -0
  42. package/src/update.ts +109 -0
  43. package/src/validate.ts +302 -0
  44. package/templates/analyze-data.md +9 -0
  45. package/templates/code-review.md +10 -0
  46. package/templates/debug-issue.md +9 -0
  47. package/templates/implement-feature.md +9 -0
  48. package/templates/prototype.md +9 -0
  49. package/templates/refactor.md +9 -0
  50. package/templates/security-audit.md +10 -0
  51. package/templates/write-docs.md +9 -0
  52. package/templates/write-tests.md +10 -0
package/src/tui.ts ADDED
@@ -0,0 +1,3103 @@
1
+ /**
2
+ * brl-subagent — TUI Components
3
+ *
4
+ * All TUI rendering: select lists, configuration menus, model/thinking selectors,
5
+ * preset management, run history viewer, live monitor, retry menu, and
6
+ * delegate_task result rendering.
7
+ */
8
+
9
+ import type {
10
+ ExtensionContext,
11
+ ExtensionAPI,
12
+ Theme,
13
+ ToolRenderResultOptions,
14
+ } from "@earendil-works/pi-coding-agent";
15
+ import { DynamicBorder, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
16
+ import { Container, type SelectItem, SelectList, Spacer, Text, Markdown } from "@earendil-works/pi-tui";
17
+ import type { Api, Model } from "@earendil-works/pi-ai";
18
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
19
+ import type {
20
+ SubagentPreset,
21
+ TaskTemplate,
22
+ SubagentResult,
23
+ SubagentRun,
24
+ SubTaskParams,
25
+ MultiSubagentDetails,
26
+ ChainDetails,
27
+ ParallelDetails,
28
+ GraphDetails,
29
+ DelegateTaskDetails,
30
+ GraphTask,
31
+ SubTaskResult,
32
+ ThinkingLevel,
33
+ UsageStats,
34
+ FileDiff,
35
+ CompletionNotifyMode,
36
+ } from "./types";
37
+ import {
38
+ THINKING_LEVELS,
39
+ NAV_FOOTER,
40
+ COMPLETION_NOTIFY_MODES,
41
+ TASK_PREVIEW_MAX_LENGTH,
42
+ COLLAPSED_OUTPUT_LINES,
43
+ COLLAPSED_DIFF_FILES_PREVIEW,
44
+ EXPANDED_HUNKS_PER_FILE,
45
+ RESERVED_NAME_PATTERN,
46
+ RESERVED_COMMAND_NAMES,
47
+ formatTokens,
48
+ formatUsageStats,
49
+ formatModel,
50
+ formatMaxParallel,
51
+ getFinalOutput,
52
+ isSubagentError,
53
+ isMultiSubagentDetails,
54
+ isGraphDetails,
55
+ } from "./types";
56
+ import { buildFileAccessReport, buildSecretsExposureReport, generateComplianceSummary } from "./reports";
57
+ import { extractParamNames, loadAllTemplates, validateTemplatePresetRefs } from "./templates";
58
+ import { parseDiff } from "./diff";
59
+ import { formatPresetSummary, getPreset, getAllPresets, writePresetFile, loadCustomPresets, parseFrontmatter } from "./presets";
60
+ import { formatRunDuration } from "./history";
61
+ import * as fs from "node:fs";
62
+ import * as path from "node:path";
63
+ import { sweepStaleLiveSubagents, type SessionState } from "./state";
64
+ import { getAgent } from "./session-manager";
65
+ import { computeSLAMetrics, computeCostTrend, formatSparkline } from "./metrics";
66
+ import { formatElapsed, liveRowName, liveSpinner, formatLiveRowDim } from "./tui-format";
67
+ import {
68
+ renderTranscriptMessages,
69
+ type TranscriptLineStyle,
70
+ type TranscriptMessage,
71
+ } from "./transcript-tail";
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // SelectList helper
75
+ // ---------------------------------------------------------------------------
76
+
77
+ function makeSelectListTheme(theme: Theme) {
78
+ return {
79
+ selectedPrefix: (t: string) => theme.fg("accent", t),
80
+ selectedText: (t: string) => theme.fg("accent", t),
81
+ description: (t: string) => theme.fg("muted", t),
82
+ scrollInfo: (t: string) => theme.fg("dim", t),
83
+ noMatch: (t: string) => theme.fg("warning", t),
84
+ };
85
+ }
86
+
87
+ export async function showSelectList(
88
+ ctx: ExtensionContext,
89
+ title: string,
90
+ items: SelectItem[],
91
+ maxItems: number,
92
+ ): Promise<string | null> {
93
+ return ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
94
+ const container = new Container();
95
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
96
+ container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
97
+
98
+ const selectList = new SelectList(
99
+ items,
100
+ Math.min(items.length, maxItems),
101
+ makeSelectListTheme(theme),
102
+ );
103
+ selectList.onSelect = (item) => done(item.value);
104
+ selectList.onCancel = () => done(null);
105
+
106
+ container.addChild(selectList);
107
+ container.addChild(new Text(theme.fg("dim", NAV_FOOTER), 1, 0));
108
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
109
+
110
+ return {
111
+ render: (w: number) => container.render(w),
112
+ invalidate: () => container.invalidate(),
113
+ handleInput: (data: string) => {
114
+ selectList.handleInput(data);
115
+ tui.requestRender();
116
+ },
117
+ };
118
+ });
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Model selector
123
+ // ---------------------------------------------------------------------------
124
+
125
+ export async function showModelSelector(
126
+ ctx: ExtensionContext,
127
+ state: SessionState,
128
+ onConfigChanged: (ctx: ExtensionContext, msg: string) => void,
129
+ ): Promise<void> {
130
+ const availableModels = await ctx.modelRegistry.getAvailable();
131
+
132
+ if (!availableModels || availableModels.length === 0) {
133
+ ctx.ui.notify(
134
+ "No models available. Configure API keys first (use /login or set environment variables).",
135
+ "warning",
136
+ );
137
+ return;
138
+ }
139
+
140
+ const items: SelectItem[] = availableModels.map((m: Model<Api>) => ({
141
+ value: `${m.provider}/${m.id}`,
142
+ label: `${m.provider}/${m.id}`,
143
+ description: m.name ?? `context: ${(m.contextWindow ?? 0) / 1000}K`,
144
+ }));
145
+
146
+ const result = await showSelectList(ctx, "Select Subagent Model", items, 15);
147
+ if (!result) return;
148
+
149
+ const slashIdx = result.indexOf("/");
150
+ state.config.model = { provider: result.slice(0, slashIdx), id: result.slice(slashIdx + 1) };
151
+ onConfigChanged(ctx, `Subagent model set to ${result}`);
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Thinking level selector
156
+ // ---------------------------------------------------------------------------
157
+
158
+ export async function showThinkingSelector(
159
+ ctx: ExtensionContext,
160
+ state: SessionState,
161
+ onConfigChanged: (ctx: ExtensionContext, msg: string) => void,
162
+ ): Promise<void> {
163
+ const items: SelectItem[] = THINKING_LEVELS.map((level) => ({
164
+ value: level,
165
+ label: level === state.config.maxThinkingLevel ? `${level} (current - ceiling)` : level,
166
+ }));
167
+
168
+ const result = await showSelectList(ctx, "Select Max Thinking Level", items, 10);
169
+ if (!result) return;
170
+
171
+ state.config.maxThinkingLevel = result as ThinkingLevel;
172
+ onConfigChanged(
173
+ ctx,
174
+ `Subagent max thinking level set to ${result} (subagents can request up to ${result})`,
175
+ );
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Concurrency input
180
+ // ---------------------------------------------------------------------------
181
+
182
+ export async function showConcurrencyInput(
183
+ ctx: ExtensionContext,
184
+ state: SessionState,
185
+ onConfigChanged: (ctx: ExtensionContext, msg: string) => void,
186
+ ): Promise<void> {
187
+ const result = await ctx.ui.input(`Max parallel subagents (0 = unlimited, current ${formatMaxParallel(state.config.maxParallel)}):`);
188
+ if (result == null) return;
189
+ const num = parseInt(result, 10);
190
+ if (isNaN(num) || num < 0) {
191
+ ctx.ui.notify("Invalid number. Must be >= 0.", "error");
192
+ return;
193
+ }
194
+ state.config.maxParallel = num;
195
+ onConfigChanged(ctx, `Max parallel subagents set to ${formatMaxParallel(num)}`);
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // Recursion depth input
200
+ // ---------------------------------------------------------------------------
201
+
202
+ export async function showDepthInput(
203
+ ctx: ExtensionContext,
204
+ state: SessionState,
205
+ onConfigChanged: (ctx: ExtensionContext, msg: string) => void,
206
+ ): Promise<void> {
207
+ const result = await ctx.ui.input(`Max subagent recursion depth (0 = cannot delegate, 1 = one level, current ${state.config.maxSubagentDepth}):`);
208
+ if (result == null) return;
209
+ const num = parseInt(result, 10);
210
+ if (isNaN(num) || num < 0) {
211
+ ctx.ui.notify("Invalid number. Must be >= 0.", "error");
212
+ return;
213
+ }
214
+ state.config.maxSubagentDepth = num;
215
+ const desc = num === 0
216
+ ? "subagents cannot delegate"
217
+ : `subagents can delegate up to ${num} level${num > 1 ? "s" : ""} deep`;
218
+ onConfigChanged(ctx, `Max recursion depth set to ${num} (${desc})`);
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // History entries input — R2: Disk usage policy
223
+ // ---------------------------------------------------------------------------
224
+
225
+ export async function showHistoryEntriesInput(
226
+ ctx: ExtensionContext,
227
+ state: SessionState,
228
+ onConfigChanged: (ctx: ExtensionContext, msg: string) => void,
229
+ ): Promise<void> {
230
+ const current =
231
+ state.config.maxHistoryEntries === 0
232
+ ? "unlimited"
233
+ : String(state.config.maxHistoryEntries);
234
+ const result = await ctx.ui.input(`Max run history entries (0 = unlimited, default 500, current ${current}):`);
235
+ if (result == null) return;
236
+ const num = parseInt(result, 10);
237
+ if (isNaN(num) || num < 0) {
238
+ ctx.ui.notify("Invalid number. Must be >= 0.", "error");
239
+ return;
240
+ }
241
+ state.config.maxHistoryEntries = num;
242
+ onConfigChanged(
243
+ ctx,
244
+ num === 0
245
+ ? "Run history set to unlimited (no pruning)"
246
+ : `Run history entries set to ${num} (oldest entries will be pruned)`,
247
+ );
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Cost limit input — R5: Cost governance
252
+ // ---------------------------------------------------------------------------
253
+
254
+ export async function showCostLimitInput(
255
+ ctx: ExtensionContext,
256
+ state: SessionState,
257
+ onConfigChanged: (ctx: ExtensionContext, msg: string) => void,
258
+ ): Promise<void> {
259
+ const current =
260
+ state.config.sessionCostLimit === 0
261
+ ? "0 (unlimited)"
262
+ : String(state.config.sessionCostLimit);
263
+ const result = await ctx.ui.input(`Session cost limit in USD (0 = unlimited, e.g. 1.00 for $1, current ${current}):`);
264
+ if (result == null) return;
265
+ const num = parseFloat(result);
266
+ if (isNaN(num) || num < 0) {
267
+ ctx.ui.notify("Invalid number. Must be >= 0.", "error");
268
+ return;
269
+ }
270
+ state.config.sessionCostLimit = num;
271
+ onConfigChanged(
272
+ ctx,
273
+ num === 0
274
+ ? "Session cost limit set to unlimited"
275
+ : `Session cost limit set to $${num.toFixed(2)}`,
276
+ );
277
+ }
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // Approval mode selector — P4
281
+ // ---------------------------------------------------------------------------
282
+
283
+ export async function showApprovalModeSelector(
284
+ ctx: ExtensionContext,
285
+ state: SessionState,
286
+ onConfigChanged: (ctx: ExtensionContext, msg: string) => void,
287
+ ): Promise<void> {
288
+ const items: SelectItem[] = [
289
+ {
290
+ value: "auto",
291
+ label: "auto",
292
+ description: "Never ask - auto-approve",
293
+ },
294
+ {
295
+ value: "writes",
296
+ label: "writes",
297
+ description: "Ask when files changed - default",
298
+ },
299
+ {
300
+ value: "always",
301
+ label: "always",
302
+ description: "Ask every time",
303
+ },
304
+ ];
305
+
306
+ const result = await showSelectList(ctx, "Select Change Approval Mode", items, 5);
307
+ if (!result) return;
308
+
309
+ state.config.approvalMode = result as "auto" | "writes" | "always";
310
+ onConfigChanged(ctx, `Change approval mode set to ${result}`);
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // Completion notify mode selector (issue #147)
315
+ // ---------------------------------------------------------------------------
316
+
317
+ export async function showCompletionNotifySelector(
318
+ ctx: ExtensionContext,
319
+ state: SessionState,
320
+ onConfigChanged: (ctx: ExtensionContext, msg: string) => void,
321
+ ): Promise<void> {
322
+ const modeDescriptions: Record<CompletionNotifyMode, string> = {
323
+ all: "Wake on every terminal state (completed, failed, stopped) - default",
324
+ failed: "Wake only on failed / stopped runs",
325
+ off: "Never wake (still delivered passively at the next user prompt)",
326
+ };
327
+ const items: SelectItem[] = COMPLETION_NOTIFY_MODES.map((mode) => ({
328
+ value: mode,
329
+ label: mode,
330
+ description: modeDescriptions[mode],
331
+ }));
332
+
333
+ const result = await showSelectList(ctx, "Completion Notify Mode", items, 5);
334
+ if (!result) return;
335
+
336
+ state.config.completionNotify = result as CompletionNotifyMode;
337
+ onConfigChanged(ctx, `Completion notify mode set to ${result}`);
338
+ }
339
+
340
+ // ---------------------------------------------------------------------------
341
+ // Update check toggle
342
+ // ---------------------------------------------------------------------------
343
+
344
+ export async function showUpdateCheckToggle(
345
+ ctx: ExtensionContext,
346
+ state: SessionState,
347
+ onConfigChanged: (ctx: ExtensionContext, msg: string) => void,
348
+ ): Promise<void> {
349
+ const items: SelectItem[] = [
350
+ { value: "enabled", label: "Enabled", description: "Check for updates on session start" },
351
+ { value: "disabled", label: "Disabled", description: "Skip update check" },
352
+ ];
353
+
354
+ const result = await showSelectList(ctx, "Update Check", items, 5);
355
+ if (!result) return;
356
+
357
+ state.config.updateCheckEnabled = result === "enabled";
358
+ onConfigChanged(ctx, `Update check ${result}`);
359
+ }
360
+
361
+ // ---------------------------------------------------------------------------
362
+ // ---------------------------------------------------------------------------
363
+ // E4: SLA tracking configuration
364
+ // ---------------------------------------------------------------------------
365
+
366
+ export async function showSLAConfig(
367
+ ctx: ExtensionContext,
368
+ state: SessionState,
369
+ onConfigChanged: (ctx: ExtensionContext, msg: string) => void,
370
+ ): Promise<void> {
371
+ // Step 1: Enable/disable toggle
372
+ const enableItems: SelectItem[] = [
373
+ { value: "disabled", label: "Disabled", description: "No SLA tracking" },
374
+ { value: "enabled", label: "Enabled", description: "Track performance metrics and detect degradation" },
375
+ ];
376
+ const enableResult = await showSelectList(ctx, "SLA Tracking", enableItems, 5);
377
+ if (!enableResult) return;
378
+
379
+ const enabled = enableResult === "enabled";
380
+ state.config.slaTrackingEnabled = enabled;
381
+
382
+ if (!enabled) {
383
+ onConfigChanged(ctx, "SLA tracking disabled");
384
+ return;
385
+ }
386
+
387
+ // Step 2: Window size input
388
+ const sizeResult = await ctx.ui.input(`Window size (10-500, current ${state.config.slaWindowSize}):`);
389
+ if (sizeResult == null) return;
390
+ const size = parseInt(sizeResult, 10);
391
+ if (isNaN(size) || size < 10 || size > 500) {
392
+ ctx.ui.notify("Invalid window size. Must be 10-500.", "error");
393
+ return;
394
+ }
395
+ state.config.slaWindowSize = size;
396
+ onConfigChanged(ctx, `SLA tracking enabled with window size ${size}`);
397
+ }
398
+
399
+ export async function showSLAStats(
400
+ ctx: ExtensionContext,
401
+ state: SessionState,
402
+ ): Promise<void> {
403
+ const runs = state.getRunEntries(ctx).slice(0, state.config.slaWindowSize);
404
+ if (runs.length === 0) {
405
+ ctx.ui.notify("No runs to analyze. Delegate a task first.", "info");
406
+ return;
407
+ }
408
+
409
+ const { computeSLAMetrics } = await import("./metrics");
410
+ const metrics = computeSLAMetrics(runs);
411
+
412
+ const lines: string[] = [
413
+ `SLA Metrics (last ${metrics.totalRuns} runs)`,
414
+ "",
415
+ ` Success rate: ${(metrics.successRate * 100).toFixed(1)}%`,
416
+ ` Failure rate: ${(metrics.failureRate * 100).toFixed(1)}%`,
417
+ "",
418
+ ` Duration (ms):`,
419
+ ` Average: ${metrics.averageDurationMs.toFixed(0)}`,
420
+ ` p50: ${metrics.p50DurationMs.toFixed(0)}`,
421
+ ` p95: ${metrics.p95DurationMs.toFixed(0)}`,
422
+ ` p99: ${metrics.p99DurationMs.toFixed(0)}`,
423
+ "",
424
+ ` Cost:`,
425
+ ` Total: $${metrics.totalCost.toFixed(4)}`,
426
+ ` Average: $${metrics.averageCost.toFixed(4)}`,
427
+ ];
428
+
429
+ if (Object.keys(metrics.errorCategoryBreakdown).length > 0) {
430
+ lines.push("", " Errors by category:");
431
+ for (const [cat, count] of Object.entries(metrics.errorCategoryBreakdown)) {
432
+ lines.push(` ${cat}: ${count}`);
433
+ }
434
+ }
435
+
436
+ ctx.ui.custom((tui, theme, _kb, done) => {
437
+ const container = new Container();
438
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
439
+ container.addChild(new Text(theme.fg("accent", theme.bold("SLA Statistics")), 1, 0));
440
+ container.addChild(new Text("", 0, 0));
441
+ for (const line of lines) {
442
+ container.addChild(new Text(theme.fg("dim", line), 1, 0));
443
+ }
444
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
445
+ return {
446
+ render: (w: number) => container.render(w),
447
+ invalidate: () => container.invalidate(),
448
+ handleInput: (data: string) => {
449
+ if (data === "q" || data === "\x1b") done(undefined);
450
+ },
451
+ };
452
+ });
453
+ }
454
+
455
+ // Preset management UI
456
+ // ---------------------------------------------------------------------------
457
+
458
+ // ---------------------------------------------------------------------------
459
+ // E5: Compliance Reports
460
+ // ---------------------------------------------------------------------------
461
+
462
+ export async function showFileAccessReport(
463
+ ctx: ExtensionContext,
464
+ state: SessionState,
465
+ ): Promise<void> {
466
+ const runs = state.getRunEntries(ctx);
467
+ if (runs.length === 0) {
468
+ ctx.ui.notify("No runs to analyze. Delegate a task first.", "info");
469
+ return;
470
+ }
471
+
472
+ const report = buildFileAccessReport(runs);
473
+ const fileCount = Object.keys(report.files).length;
474
+
475
+ if (fileCount === 0) {
476
+ ctx.ui.notify("No file access detected in run outputs.", "info");
477
+ return;
478
+ }
479
+
480
+ const lines: string[] = [
481
+ `File Access Report (${fileCount} files)`,
482
+ "",
483
+ ];
484
+
485
+ // Sort files alphabetically for consistent output
486
+ const sortedFiles = Object.keys(report.files).sort();
487
+
488
+ for (const file of sortedFiles) {
489
+ const runIds = report.files[file];
490
+ lines.push(` ${file}`);
491
+ lines.push(` Runs: ${runIds.join(", ")}`);
492
+ }
493
+
494
+ ctx.ui.custom((tui, theme, _kb, done) => {
495
+ const container = new Container();
496
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
497
+ container.addChild(new Text(theme.fg("accent", theme.bold("File Access Report")), 1, 0));
498
+ container.addChild(new Text("", 0, 0));
499
+ for (const line of lines) {
500
+ container.addChild(new Text(theme.fg("dim", line), 1, 0));
501
+ }
502
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
503
+ return {
504
+ render: (w: number) => container.render(w),
505
+ invalidate: () => container.invalidate(),
506
+ handleInput: (data: string) => {
507
+ if (data === "q" || data === "\x1b") done(undefined);
508
+ },
509
+ };
510
+ });
511
+ }
512
+
513
+ export async function showSecretsExposureReport(
514
+ ctx: ExtensionContext,
515
+ state: SessionState,
516
+ ): Promise<void> {
517
+ const runs = state.getRunEntries(ctx);
518
+ if (runs.length === 0) {
519
+ ctx.ui.notify("No runs to analyze. Delegate a task first.", "info");
520
+ return;
521
+ }
522
+
523
+ const report = buildSecretsExposureReport(runs);
524
+ const exposureCount = report.exposures.length;
525
+
526
+ if (exposureCount === 0) {
527
+ ctx.ui.notify("No sensitive file access detected.", "info");
528
+ return;
529
+ }
530
+
531
+ const lines: string[] = [
532
+ `Secrets Exposure Report (${exposureCount} findings)`,
533
+ "",
534
+ ];
535
+
536
+ // Group by severity
537
+ const highSeverity = report.exposures.filter((e) => e.severity === "high");
538
+ const mediumSeverity = report.exposures.filter((e) => e.severity === "medium");
539
+ const lowSeverity = report.exposures.filter((e) => e.severity === "low");
540
+
541
+ if (highSeverity.length > 0) {
542
+ lines.push(" HIGH SEVERITY:");
543
+ for (const exposure of highSeverity) {
544
+ lines.push(` ${exposure.file}`);
545
+ lines.push(` Run: ${exposure.runId}${exposure.runLabel ? ` (${exposure.runLabel})` : ""}`);
546
+ }
547
+ lines.push("");
548
+ }
549
+
550
+ if (mediumSeverity.length > 0) {
551
+ lines.push(" MEDIUM SEVERITY:");
552
+ for (const exposure of mediumSeverity) {
553
+ lines.push(` ${exposure.file}`);
554
+ lines.push(` Run: ${exposure.runId}${exposure.runLabel ? ` (${exposure.runLabel})` : ""}`);
555
+ }
556
+ lines.push("");
557
+ }
558
+
559
+ if (lowSeverity.length > 0) {
560
+ lines.push(" LOW SEVERITY:");
561
+ for (const exposure of lowSeverity) {
562
+ lines.push(` ${exposure.file}`);
563
+ lines.push(` Run: ${exposure.runId}${exposure.runLabel ? ` (${exposure.runLabel})` : ""}`);
564
+ }
565
+ }
566
+
567
+ ctx.ui.custom((tui, theme, _kb, done) => {
568
+ const container = new Container();
569
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
570
+ container.addChild(new Text(theme.fg("accent", theme.bold("Secrets Exposure Report")), 1, 0));
571
+ container.addChild(new Text("", 0, 0));
572
+ for (const line of lines) {
573
+ container.addChild(new Text(theme.fg("dim", line), 1, 0));
574
+ }
575
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
576
+ return {
577
+ render: (w: number) => container.render(w),
578
+ invalidate: () => container.invalidate(),
579
+ handleInput: (data: string) => {
580
+ if (data === "q" || data === "\x1b") done(undefined);
581
+ },
582
+ };
583
+ });
584
+ }
585
+
586
+ export async function showFullComplianceSummary(
587
+ ctx: ExtensionContext,
588
+ state: SessionState,
589
+ ): Promise<void> {
590
+ const runs = state.getRunEntries(ctx);
591
+ if (runs.length === 0) {
592
+ ctx.ui.notify("No runs to analyze. Delegate a task first.", "info");
593
+ return;
594
+ }
595
+
596
+ const summary = generateComplianceSummary(runs);
597
+
598
+ const lines: string[] = [
599
+ `Compliance Summary`,
600
+ "",
601
+ ` Total Runs: ${summary.totalRuns}`,
602
+ ` Date Range: ${summary.dateRange.earliest ? new Date(summary.dateRange.earliest).toLocaleDateString() : "N/A"} - ${summary.dateRange.latest ? new Date(summary.dateRange.latest).toLocaleDateString() : "N/A"}`,
603
+ "",
604
+ ` SLA Metrics:`,
605
+ ` Success Rate: ${(summary.metrics.successRate * 100).toFixed(1)}%`,
606
+ ` Failure Rate: ${(summary.metrics.failureRate * 100).toFixed(1)}%`,
607
+ ` Avg Duration: ${summary.metrics.averageDurationMs.toFixed(0)}ms`,
608
+ ` p95 Duration: ${summary.metrics.p95DurationMs.toFixed(0)}ms`,
609
+ ` Total Cost: $${summary.metrics.totalCost.toFixed(4)}`,
610
+ "",
611
+ ` File Access: ${summary.filesAccessed} files`,
612
+ ` Sensitive Files: ${summary.sensitiveFilesTouched}`,
613
+ ` High Severity: ${summary.highSeverityFindings}`,
614
+ ];
615
+
616
+ ctx.ui.custom((tui, theme, _kb, done) => {
617
+ const container = new Container();
618
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
619
+ container.addChild(new Text(theme.fg("accent", theme.bold("Compliance Summary")), 1, 0));
620
+ container.addChild(new Text("", 0, 0));
621
+ for (const line of lines) {
622
+ container.addChild(new Text(theme.fg("dim", line), 1, 0));
623
+ }
624
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
625
+ return {
626
+ render: (w: number) => container.render(w),
627
+ invalidate: () => container.invalidate(),
628
+ handleInput: (data: string) => {
629
+ if (data === "q" || data === "\x1b") done(undefined);
630
+ },
631
+ };
632
+ });
633
+ }
634
+
635
+ export async function showComplianceMenu(
636
+ ctx: ExtensionContext,
637
+ state: SessionState,
638
+ ): Promise<void> {
639
+ const items: SelectItem[] = [
640
+ { value: "file-access", label: "File Access Report", description: "Shows file → subagent run mapping" },
641
+ { value: "secrets", label: "Secrets Exposure Report", description: "Shows sensitive file access findings" },
642
+ { value: "full", label: "Full Compliance Summary", description: "Combined summary with metrics" },
643
+ ];
644
+
645
+ const result = await showSelectList(ctx, "Compliance Reports", items, 5);
646
+ if (!result) return;
647
+
648
+ switch (result) {
649
+ case "file-access":
650
+ await showFileAccessReport(ctx, state);
651
+ break;
652
+ case "secrets":
653
+ await showSecretsExposureReport(ctx, state);
654
+ break;
655
+ case "full":
656
+ await showFullComplianceSummary(ctx, state);
657
+ break;
658
+ }
659
+ }
660
+
661
+ // Preset management UI
662
+
663
+ export async function showAddPreset(
664
+ ctx: ExtensionContext,
665
+ state: SessionState,
666
+ ): Promise<void> {
667
+ // 1. Prompt for name
668
+ const name = await ctx.ui.input("Preset name (e.g., read-only-audit):");
669
+ if (!name?.trim()) return;
670
+ const trimmedName = name.trim();
671
+
672
+ // 2. Validate name (reserved patterns, existing presets)
673
+ if (RESERVED_NAME_PATTERN.test(trimmedName)) {
674
+ ctx.ui.notify("Names starting and ending with '__' are reserved.", "error");
675
+ return;
676
+ }
677
+ if (RESERVED_COMMAND_NAMES.has(trimmedName)) {
678
+ ctx.ui.notify("Name '" + trimmedName + "' is reserved for a command.", "error");
679
+ return;
680
+ }
681
+ if (getPreset(trimmedName, state.builtinPresets, state.customPresets)) {
682
+ ctx.ui.notify(`Preset "${trimmedName}" already exists.`, "error");
683
+ return;
684
+ }
685
+
686
+ // 3. Description
687
+ const description = await ctx.ui.input("Description (optional):");
688
+
689
+ // 4. Thinking level
690
+ const thinkingItems: SelectItem[] = [
691
+ { value: "", label: "(not set — use conductor's choice)" },
692
+ ...THINKING_LEVELS.map((level) => ({ value: level, label: level })),
693
+ ];
694
+ const thinkingResult = await showSelectList(ctx, "Default Thinking Level", thinkingItems, 8);
695
+
696
+ // 5. Tool scope
697
+ const scopeItems: SelectItem[] = [
698
+ { value: "all", label: "All tools (default)" },
699
+ { value: "readonly", label: "Read-only (read, grep, find, ls)" },
700
+ { value: "custom", label: "Custom tool list..." },
701
+ ];
702
+ const scopeResult = await showSelectList(ctx, "Tool Scope", scopeItems, 5);
703
+
704
+ let tools: string[] | undefined;
705
+ let excludeTools: string[] | undefined;
706
+ let noBuiltinTools: boolean | undefined;
707
+
708
+ if (scopeResult === "readonly") {
709
+ tools = ["read", "grep", "find", "ls"];
710
+ excludeTools = ["write", "edit", "bash"];
711
+ } else if (scopeResult === "custom") {
712
+ const toolsStr = await ctx.ui.input("Tools (comma-separated):");
713
+ if (toolsStr?.trim()) tools = toolsStr.split(",").map((t) => t.trim()).filter(Boolean);
714
+ }
715
+
716
+ // 6. Inheritance
717
+ const inheritItems: SelectItem[] = [
718
+ { value: "true", label: "Inherit system prompt (default)" },
719
+ { value: "false", label: "No inheritance (standalone)" },
720
+ ];
721
+ const inheritResult = await showSelectList(ctx, "System Prompt Inheritance", inheritItems, 3);
722
+
723
+ // 7. System prompt body
724
+ const systemPrompt = await ctx.ui.input("System prompt (optional, multi-line not supported yet):");
725
+
726
+ // 8. Build preset object
727
+ const preset: SubagentPreset = {
728
+ name: trimmedName,
729
+ description: description?.trim() || undefined,
730
+ thinkingLevel: thinkingResult || undefined,
731
+ inheritSystemPrompt: inheritResult === "false" ? false : undefined,
732
+ tools,
733
+ excludeTools,
734
+ noBuiltinTools,
735
+ systemPrompt: systemPrompt?.trim() || undefined,
736
+ };
737
+
738
+ // 9. Ask where to save
739
+ const locationItems: SelectItem[] = [
740
+ { value: "project", label: "Project (.pi/brl-subagent/presets/)" },
741
+ { value: "global", label: "Global (~/.pi/agent/brl-subagent/presets/)" },
742
+ ];
743
+ const location = await showSelectList(ctx, "Save Location", locationItems, 3);
744
+ if (!location) return;
745
+
746
+ const homedir = process.env.HOME || process.env.USERPROFILE || "";
747
+ const targetDir = location === "project"
748
+ ? path.join(ctx.cwd, ".pi", "brl-subagent", "presets")
749
+ : path.join(homedir, ".pi", "agent", "brl-subagent", "presets");
750
+
751
+ // 10. Write file
752
+ try {
753
+ writePresetFile(preset, targetDir);
754
+ // 11. Refresh custom presets
755
+ state.customPresets = loadCustomPresets(ctx.cwd, state.log);
756
+ // Reload templates alongside presets — both are file-derived, and a
757
+ // preset add/remove may fix (or break) a template's `preset:` reference.
758
+ // Full stack: custom (project+global) merged over builtins.
759
+ state.config.templates = loadAllTemplates(ctx.cwd, state.log);
760
+ // Issue #81: re-check template `preset:` refs after the mutation — warn
761
+ // (never skip) for dangling references instead of preset-less runs.
762
+ validateTemplatePresetRefs(
763
+ state.config.templates,
764
+ getAllPresets(state.builtinPresets, state.customPresets),
765
+ state.log,
766
+ );
767
+ ctx.ui.notify(`Preset "${trimmedName}" saved to ${location === "project" ? "project" : "global"} directory`, "info");
768
+ } catch (err) {
769
+ ctx.ui.notify(`Failed to save preset: ${(err as Error).message}`, "error");
770
+ }
771
+ }
772
+
773
+ export async function showRemovePreset(
774
+ ctx: ExtensionContext,
775
+ state: SessionState,
776
+ ): Promise<void> {
777
+ const homedir = process.env.HOME || process.env.USERPROFILE || "";
778
+ const projectDir = path.join(ctx.cwd, ".pi", "brl-subagent", "presets");
779
+ const globalDir = path.join(homedir, ".pi", "agent", "brl-subagent", "presets");
780
+
781
+ // Scan both directories for .md files
782
+ const entries: Array<{ name: string; source: "project" | "global"; filePath: string }> = [];
783
+
784
+ for (const dir of [projectDir, globalDir]) {
785
+ const source = dir === projectDir ? "project" : "global";
786
+ try {
787
+ const files = fs.readdirSync(dir);
788
+ for (const file of files) {
789
+ if (!file.endsWith(".md")) continue;
790
+ const filePath = path.join(dir, file);
791
+ try {
792
+ const content = fs.readFileSync(filePath, "utf-8");
793
+ const { meta } = parseFrontmatter(content);
794
+ if (meta.name && typeof meta.name === "string") {
795
+ entries.push({ name: meta.name as string, source, filePath });
796
+ }
797
+ } catch {
798
+ // Skip unparseable files
799
+ }
800
+ }
801
+ } catch {
802
+ // Directory doesn't exist — fine
803
+ }
804
+ }
805
+
806
+ if (entries.length === 0) {
807
+ ctx.ui.notify("No custom presets to remove.", "info");
808
+ return;
809
+ }
810
+
811
+ // Show list with source indicators
812
+ const items: SelectItem[] = entries.map((e) => ({
813
+ value: e.filePath,
814
+ label: `[${e.source === "project" ? "P" : "G"}] ${e.name}`,
815
+ description: e.source === "project" ? "Project-local" : "Global",
816
+ }));
817
+
818
+ const result = await showSelectList(ctx, "Remove Preset", items, 10);
819
+ if (!result) return;
820
+
821
+ // Delete the file
822
+ try {
823
+ fs.unlinkSync(result);
824
+ state.customPresets = loadCustomPresets(ctx.cwd, state.log);
825
+ // Reload templates too — a removed preset may orphan a template's
826
+ // `preset:` reference, so both file-derived collections refresh together.
827
+ // Full stack: custom (project+global) merged over builtins.
828
+ state.config.templates = loadAllTemplates(ctx.cwd, state.log);
829
+ // Issue #81: re-check template `preset:` refs after the mutation — warn
830
+ // (never skip) for dangling references instead of preset-less runs.
831
+ validateTemplatePresetRefs(
832
+ state.config.templates,
833
+ getAllPresets(state.builtinPresets, state.customPresets),
834
+ state.log,
835
+ );
836
+ ctx.ui.notify("Preset removed", "info");
837
+ } catch (err) {
838
+ ctx.ui.notify(`Failed to remove preset: ${(err as Error).message}`, "error");
839
+ }
840
+ }
841
+
842
+ export async function showPresetManager(
843
+ ctx: ExtensionContext,
844
+ state: SessionState,
845
+ ): Promise<void> {
846
+ const builtinItems: SelectItem[] = state.builtinPresets.map((p) => ({
847
+ value: `__builtin__:${p.name}`,
848
+ label: `[B] ${p.name}`,
849
+ description: p.description || formatPresetSummary(p),
850
+ }));
851
+
852
+ // Custom presets from state (loaded from files on session start)
853
+ const customItems: SelectItem[] = state.customPresets.map((p) => ({
854
+ value: `__custom__:${p.name}`,
855
+ label: `[${findPresetSource(p.name, ctx) === "project" ? "P" : "G"}] ${p.name}`,
856
+ description: p.description || formatPresetSummary(p),
857
+ }));
858
+
859
+ const items: SelectItem[] = [
860
+ ...builtinItems,
861
+ ...(customItems.length > 0
862
+ ? [
863
+ {
864
+ value: "__divider__",
865
+ label: "\u2500\u2500\u2500 Custom Presets \u2500\u2500\u2500",
866
+ description: "",
867
+ },
868
+ ]
869
+ : []),
870
+ ...customItems,
871
+ ];
872
+
873
+ const result = await showSelectList(
874
+ ctx,
875
+ `Presets (${state.builtinPresets.length} built-in, ${state.customPresets.length} custom)`,
876
+ items,
877
+ 15,
878
+ );
879
+ if (!result || result === "__divider__") return;
880
+
881
+ {
882
+ // Extract preset name from value
883
+ const isBuiltin = result.startsWith("__builtin__:");
884
+ const isCustom = result.startsWith("__custom__:");
885
+ const presetName = isBuiltin
886
+ ? result.slice("__builtin__:".length)
887
+ : isCustom
888
+ ? result.slice("__custom__:".length)
889
+ : result;
890
+
891
+ const preset = getPreset(presetName, state.builtinPresets, state.customPresets);
892
+ if (preset) {
893
+ await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
894
+ const container = new Container();
895
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
896
+ container.addChild(
897
+ new Text(
898
+ theme.fg("accent", theme.bold(`Preset: ${preset.name}`)) +
899
+ (isBuiltin ? theme.fg("dim", " (built-in)") : ""),
900
+ 1,
901
+ 0,
902
+ ),
903
+ );
904
+ if (preset.description)
905
+ container.addChild(new Text(theme.fg("dim", preset.description), 1, 0));
906
+ container.addChild(new Text("", 0, 0));
907
+ container.addChild(
908
+ new Text(theme.fg("dim", `Thinking: ${preset.thinkingLevel || "(not set)"}`), 1, 0),
909
+ );
910
+ container.addChild(
911
+ new Text(
912
+ theme.fg(
913
+ "dim",
914
+ `Inherit: ${preset.inheritSystemPrompt === false ? "no" : "yes (default)"}`,
915
+ ),
916
+ 1,
917
+ 0,
918
+ ),
919
+ );
920
+ if (preset.tools)
921
+ container.addChild(
922
+ new Text(theme.fg("dim", `Tools: ${preset.tools.join(", ")}`), 1, 0),
923
+ );
924
+ if (preset.excludeTools)
925
+ container.addChild(
926
+ new Text(theme.fg("dim", `Exclude: ${preset.excludeTools.join(", ")}`), 1, 0),
927
+ );
928
+ if (preset.systemPrompt)
929
+ container.addChild(
930
+ new Text(theme.fg("dim", `System prompt: ${preset.systemPrompt.slice(0, 100)}`), 1, 0),
931
+ );
932
+ container.addChild(new Text("", 0, 0));
933
+ container.addChild(new Text(theme.fg("dim", "Press any key to close"), 1, 0));
934
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
935
+ return {
936
+ render: (w: number) => container.render(w),
937
+ invalidate: () => container.invalidate(),
938
+ handleInput: (_data: string) => done(),
939
+ };
940
+ });
941
+ }
942
+ }
943
+ }
944
+
945
+ /**
946
+ * Determine whether a custom preset comes from the project or global directory.
947
+ * Checks state.customPresets source by scanning file directories.
948
+ */
949
+ function findPresetSource(name: string, ctx: ExtensionContext): "project" | "global" {
950
+ const homedir = process.env.HOME || process.env.USERPROFILE || "";
951
+ const projectDir = path.join(ctx.cwd, ".pi", "brl-subagent", "presets");
952
+ const globalDir = path.join(homedir, ".pi", "agent", "brl-subagent", "presets");
953
+ // Prefer project over global (project has higher priority in loadCustomPresets)
954
+ const sanitizedName = name.replace(/[^a-zA-Z0-9_-]/g, "_").toLowerCase();
955
+ if (fs.existsSync(path.join(projectDir, sanitizedName + ".md"))) return "project";
956
+ if (fs.existsSync(path.join(globalDir, sanitizedName + ".md"))) return "global";
957
+ return "project";
958
+ }
959
+
960
+ /**
961
+ * Determine which tier a template comes from: project, global, or builtin.
962
+ * Scans the project templates dir first, then the user-global dir; a name
963
+ * found in neither is a builtin. Mirrors findPresetSource.
964
+ */
965
+ function findTemplateSource(
966
+ name: string,
967
+ ctx: ExtensionContext,
968
+ ): "project" | "global" | "builtin" {
969
+ const homedir = process.env.HOME || process.env.USERPROFILE || "";
970
+ const projectDir = path.join(ctx.cwd, ".pi", "brl-subagent", "templates");
971
+ const globalDir = path.join(homedir, ".pi", "agent", "brl-subagent", "templates");
972
+ // Prefer project over global (project has higher priority in loadCustomTemplates)
973
+ const sanitizedName = name.replace(/[^a-zA-Z0-9_-]/g, "_").toLowerCase();
974
+ if (fs.existsSync(path.join(projectDir, sanitizedName + ".md"))) return "project";
975
+ if (fs.existsSync(path.join(globalDir, sanitizedName + ".md"))) return "global";
976
+ return "builtin";
977
+ }
978
+
979
+ // ---------------------------------------------------------------------------
980
+ // Template management UI
981
+ // ---------------------------------------------------------------------------
982
+
983
+ export function getTemplate(
984
+ name: string,
985
+ templates: TaskTemplate[],
986
+ ): TaskTemplate | undefined {
987
+ return templates.find((t) => t.name === name);
988
+ }
989
+
990
+ export async function showViewTemplate(
991
+ ctx: ExtensionContext,
992
+ state: SessionState,
993
+ name: string,
994
+ ): Promise<void> {
995
+ const template = getTemplate(name, state.config.templates);
996
+ if (!template) return;
997
+
998
+ // Tier suffix mirrors the preset view's dim "(built-in)" treatment, extended
999
+ // to project/global so the detail view matches the manager's tier labels.
1000
+ const templateTier = findTemplateSource(template.name, ctx);
1001
+ const tierSuffix =
1002
+ templateTier === "builtin"
1003
+ ? " (built-in)"
1004
+ : templateTier === "project"
1005
+ ? " (project)"
1006
+ : " (global)";
1007
+
1008
+ const paramNames = extractParamNames(template.task + (template.outputFile || ""));
1009
+
1010
+ await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
1011
+ const container = new Container();
1012
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1013
+ container.addChild(
1014
+ new Text(
1015
+ theme.fg("accent", theme.bold(`Template: ${template.name}`)) +
1016
+ theme.fg("dim", tierSuffix),
1017
+ 1,
1018
+ 0,
1019
+ ),
1020
+ );
1021
+ if (template.description)
1022
+ container.addChild(new Text(theme.fg("dim", template.description), 1, 0));
1023
+ container.addChild(new Text("", 0, 0));
1024
+ container.addChild(
1025
+ new Text(theme.fg("toolTitle", "Task:") + " " + theme.fg("toolOutput", template.task), 1, 0),
1026
+ );
1027
+ if (paramNames.length > 0) {
1028
+ container.addChild(
1029
+ new Text(theme.fg("dim", `Parameters: ${paramNames.join(", ")}`), 1, 0),
1030
+ );
1031
+ }
1032
+ if (template.preset)
1033
+ container.addChild(
1034
+ new Text(theme.fg("dim", `Preset: ${template.preset}`), 1, 0),
1035
+ );
1036
+ if (template.thinkingLevel)
1037
+ container.addChild(
1038
+ new Text(theme.fg("dim", `Thinking: ${template.thinkingLevel}`), 1, 0),
1039
+ );
1040
+ if (template.outputFile)
1041
+ container.addChild(
1042
+ new Text(theme.fg("dim", `Output file: ${template.outputFile}`), 1, 0),
1043
+ );
1044
+ if (template.timeout)
1045
+ container.addChild(
1046
+ new Text(theme.fg("dim", `Timeout: ${template.timeout}ms`), 1, 0),
1047
+ );
1048
+ container.addChild(new Text("", 0, 0));
1049
+ container.addChild(new Text(theme.fg("dim", "Press any key to close"), 1, 0));
1050
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1051
+ return {
1052
+ render: (w: number) => container.render(w),
1053
+ invalidate: () => container.invalidate(),
1054
+ handleInput: (_data: string) => done(),
1055
+ };
1056
+ });
1057
+ }
1058
+
1059
+ export async function showTemplateManager(
1060
+ ctx: ExtensionContext,
1061
+ state: SessionState,
1062
+ ): Promise<void> {
1063
+ if (state.config.templates.length === 0) {
1064
+ ctx.ui.notify(
1065
+ "No task templates loaded — built-in templates ship with the extension and " +
1066
+ "should always be present. Add custom .md files in " +
1067
+ "~/.pi/agent/brl-subagent/templates/ or <project>/.pi/brl-subagent/templates/.",
1068
+ "info",
1069
+ );
1070
+ return;
1071
+ }
1072
+
1073
+ // Tier-prefixed labels mirror showPresetManager: [P]roject / [G]lobal / [B]uiltin.
1074
+ const templateItems: SelectItem[] = state.config.templates.map((t) => {
1075
+ const source = findTemplateSource(t.name, ctx);
1076
+ const tierLabel = source === "project" ? "P" : source === "global" ? "G" : "B";
1077
+ return {
1078
+ value: t.name,
1079
+ label: `[${tierLabel}] ${t.name}`,
1080
+ description: t.description || t.task.slice(0, 60),
1081
+ };
1082
+ });
1083
+
1084
+ const result = await showSelectList(
1085
+ ctx,
1086
+ `Templates (${state.config.templates.length})`,
1087
+ templateItems,
1088
+ 15,
1089
+ );
1090
+ if (!result) return;
1091
+
1092
+ await showViewTemplate(ctx, state, result);
1093
+ }
1094
+
1095
+ // ---------------------------------------------------------------------------
1096
+ // Configuration menu
1097
+ // ---------------------------------------------------------------------------
1098
+
1099
+ export function getConfigMenuItems(state: SessionState): SelectItem[] {
1100
+ return [
1101
+ {
1102
+ value: "model",
1103
+ label: "Select Model",
1104
+ description: state.config.model
1105
+ ? `${state.config.model.provider}/${state.config.model.id}`
1106
+ : "Not set (will use main agent\u2019s model by default)",
1107
+ },
1108
+ {
1109
+ value: "thinking",
1110
+ label: "Select Max Thinking Level",
1111
+ description: state.config.maxThinkingLevel,
1112
+ },
1113
+ {
1114
+ value: "concurrency",
1115
+ label: "Set Max Parallel Subagents",
1116
+ description: formatMaxParallel(state.config.maxParallel),
1117
+ },
1118
+ {
1119
+ value: "depth",
1120
+ label: "Set Max Recursion Depth",
1121
+ description: state.config.maxSubagentDepth === 0
1122
+ ? "No delegation from subagents"
1123
+ : `Subagents can delegate up to ${state.config.maxSubagentDepth} level${state.config.maxSubagentDepth > 1 ? "s" : ""} deep`,
1124
+ },
1125
+ {
1126
+ value: "approval",
1127
+ label: "Set Change Approval Mode",
1128
+ description: state.config.approvalMode === "auto"
1129
+ ? "Never ask"
1130
+ : state.config.approvalMode === "writes"
1131
+ ? "Ask when files changed"
1132
+ : "Ask every time",
1133
+ },
1134
+ {
1135
+ value: "completionnotify",
1136
+ label: "Set Completion Notify Mode",
1137
+ description: state.config.completionNotify === "all"
1138
+ ? "Wake on every terminal state"
1139
+ : state.config.completionNotify === "failed"
1140
+ ? "Wake only on failed/stopped"
1141
+ : "Never wake (passive delivery)",
1142
+ },
1143
+
1144
+ {
1145
+ value: "historyentries",
1146
+ label: "Set Max History Entries",
1147
+ description: state.config.maxHistoryEntries === 0
1148
+ ? "Unlimited (no pruning)"
1149
+ : `${state.config.maxHistoryEntries} entries kept`,
1150
+ },
1151
+ {
1152
+ value: "costlimit",
1153
+ label: "Set Session Cost Limit",
1154
+ description: state.config.sessionCostLimit === 0
1155
+ ? "Unlimited"
1156
+ : `$${state.config.sessionCostLimit.toFixed(2)}`,
1157
+ },
1158
+ {
1159
+ value: "reset",
1160
+ label: "Reset to Default",
1161
+ description: "Clear subagent configuration",
1162
+ },
1163
+ {
1164
+ value: "history",
1165
+ label: "View Run History",
1166
+ description: "Browse past subagent runs",
1167
+ },
1168
+ {
1169
+ value: "monitor",
1170
+ label: "Live Monitor",
1171
+ description: "Watch running subagents in real-time",
1172
+ },
1173
+ {
1174
+ value: "dashboard",
1175
+ label: "Observability Dashboard",
1176
+ description: "Real-time metrics, SLA, errors, and cost trends",
1177
+ },
1178
+ {
1179
+ value: "preset",
1180
+ label: "Manage Presets",
1181
+ description: `${state.builtinPresets.length} built-in, ${state.customPresets.length} custom`,
1182
+ },
1183
+ {
1184
+ value: "templates",
1185
+ label: "Manage Templates",
1186
+ description: state.config.templates.length === 0
1187
+ ? "None saved"
1188
+ : `${state.config.templates.length} saved`,
1189
+ },
1190
+ {
1191
+ value: "retry",
1192
+ label: "Retry Failed Run",
1193
+ description: "Select a failed subagent to retry",
1194
+ },
1195
+ {
1196
+ value: "sla",
1197
+ label: "SLA Tracking",
1198
+ description: state.config.slaTrackingEnabled
1199
+ ? `Enabled (window: ${state.config.slaWindowSize})`
1200
+ : "Disabled",
1201
+ },
1202
+ {
1203
+ value: "update-check",
1204
+ label: "Update Check",
1205
+ description: state.config.updateCheckEnabled ? "Enabled" : "Disabled",
1206
+ },
1207
+ ];
1208
+ }
1209
+
1210
+ export async function showConfigMenu(
1211
+ ctx: ExtensionContext,
1212
+ state: SessionState,
1213
+ actions: Record<string, () => Promise<void> | void>,
1214
+ ): Promise<void> {
1215
+ while (true) {
1216
+ const items = [
1217
+ ...getConfigMenuItems(state),
1218
+ {
1219
+ value: "__done__",
1220
+ label: "Done",
1221
+ description: "Close this menu",
1222
+ },
1223
+ ];
1224
+
1225
+ const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
1226
+ const container = new Container();
1227
+
1228
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1229
+ container.addChild(
1230
+ new Text(theme.fg("accent", theme.bold("brl-subagent Configuration")), 1, 0),
1231
+ );
1232
+
1233
+ container.addChild(
1234
+ new Text(theme.fg("dim", `Model: ${formatModel(state.config.model)}`), 1, 0),
1235
+ );
1236
+ container.addChild(
1237
+ new Text(
1238
+ theme.fg("dim", `Max Thinking: ${state.config.maxThinkingLevel}`),
1239
+ 1,
1240
+ 0,
1241
+ ),
1242
+ );
1243
+ container.addChild(
1244
+ new Text(
1245
+ theme.fg("dim", `Max parallel: ${formatMaxParallel(state.config.maxParallel)}`),
1246
+ 1,
1247
+ 0,
1248
+ ),
1249
+ );
1250
+ if (state.config.sessionCostLimit > 0) {
1251
+ container.addChild(
1252
+ new Text(
1253
+ theme.fg("dim", `Cost limit: $${state.config.sessionCostLimit.toFixed(2)}`),
1254
+ 1,
1255
+ 0,
1256
+ ),
1257
+ );
1258
+ }
1259
+ container.addChild(new Text("", 0, 0));
1260
+
1261
+ const selectList = new SelectList(
1262
+ items,
1263
+ Math.min(items.length, 10),
1264
+ makeSelectListTheme(theme),
1265
+ );
1266
+ selectList.onSelect = (item) => done(item.value);
1267
+ selectList.onCancel = () => done(null);
1268
+
1269
+ container.addChild(selectList);
1270
+ container.addChild(new Text(theme.fg("dim", NAV_FOOTER), 1, 0));
1271
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1272
+
1273
+ return {
1274
+ render: (w: number) => container.render(w),
1275
+ invalidate: () => container.invalidate(),
1276
+ handleInput: (data: string) => {
1277
+ selectList.handleInput(data);
1278
+ tui.requestRender();
1279
+ },
1280
+ };
1281
+ });
1282
+
1283
+ if (!result || result === "__done__") return;
1284
+
1285
+ if (result in actions) {
1286
+ await actions[result]();
1287
+ }
1288
+ }
1289
+ }
1290
+
1291
+ // ---------------------------------------------------------------------------
1292
+ // Run history viewer
1293
+ // ---------------------------------------------------------------------------
1294
+
1295
+ export async function showRunHistory(
1296
+ ctx: ExtensionContext,
1297
+ state: SessionState,
1298
+ persistState: () => void,
1299
+ ): Promise<void> {
1300
+ const runs = state.getRunEntries(ctx).reverse();
1301
+
1302
+ if (runs.length === 0) {
1303
+ ctx.ui.notify("No subagent runs recorded yet. Delegate a task to see history.", "info");
1304
+ return;
1305
+ }
1306
+
1307
+ const statusIcon: Record<string, string> = {
1308
+ running: ctx.ui.theme.fg("accent", "◉"),
1309
+ done: ctx.ui.theme.fg("success", "✓"),
1310
+ failed: ctx.ui.theme.fg("error", "✗"),
1311
+ };
1312
+
1313
+ const items: SelectItem[] = runs.map((r) => {
1314
+ const icon = statusIcon[r.status] || "·";
1315
+ const name = r.label || r.task.slice(0, 60);
1316
+ const model = r.model.split("/").pop() || r.model;
1317
+ const when = r.finishedAt ? formatRunDuration(r.durationMs || 0) : "running...";
1318
+ const cost = r.cost ? ` $${r.cost.toFixed(4)}` : "";
1319
+ const desc = `${r.thinkingLevel} · ${model} · ${when}${cost}`;
1320
+ return { value: r.id, label: `${icon} ${name}`, description: desc };
1321
+ });
1322
+
1323
+ const selectedId = await showSelectList(ctx, "Subagent History", items, 15);
1324
+ if (!selectedId) return;
1325
+
1326
+ const run = runs.find((r) => r.id === selectedId);
1327
+ if (!run) return;
1328
+
1329
+ // Mark as seen
1330
+ if (state.markRunSeen(run.id)) {
1331
+ persistState();
1332
+ import("./concurrency").then(({ updateProgressStatus }) => {
1333
+ updateProgressStatus(state, ctx);
1334
+ });
1335
+ }
1336
+
1337
+ // Show detail view
1338
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
1339
+ const container = new Container();
1340
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1341
+
1342
+ const statusLabel =
1343
+ run.status === "done" ? "Completed" : run.status === "failed" ? "Failed" : "Running";
1344
+ container.addChild(
1345
+ new Text(
1346
+ theme.fg(
1347
+ "toolTitle",
1348
+ theme.bold(`${run.label || "Subagent"} — ${statusLabel}`),
1349
+ ),
1350
+ 1,
1351
+ 0,
1352
+ ),
1353
+ );
1354
+ container.addChild(new Text("", 0, 0));
1355
+ container.addChild(
1356
+ new Text(theme.fg("dim", `Task: ${run.task.slice(0, 200)}`), 1, 0),
1357
+ );
1358
+ container.addChild(
1359
+ new Text(
1360
+ theme.fg("dim", `Model: ${run.model} · Thinking: ${run.thinkingLevel}`),
1361
+ 1,
1362
+ 0,
1363
+ ),
1364
+ );
1365
+ container.addChild(new Text(theme.fg("dim", `Started: ${run.startedAt}`), 1, 0));
1366
+ if (run.finishedAt) {
1367
+ container.addChild(
1368
+ new Text(
1369
+ theme.fg(
1370
+ "dim",
1371
+ `Finished: ${run.finishedAt} (${formatRunDuration(run.durationMs || 0)})`,
1372
+ ),
1373
+ 1,
1374
+ 0,
1375
+ ),
1376
+ );
1377
+ }
1378
+ if (run.cost) {
1379
+ container.addChild(
1380
+ new Text(
1381
+ theme.fg(
1382
+ "dim",
1383
+ `Cost: $${run.cost.toFixed(4)} · ↑${formatTokens(run.tokensIn || 0)} ↓${formatTokens(run.tokensOut || 0)}`,
1384
+ ),
1385
+ 1,
1386
+ 0,
1387
+ ),
1388
+ );
1389
+ }
1390
+ if (run.errorMessage) {
1391
+ container.addChild(new Spacer(1));
1392
+ container.addChild(
1393
+ new Text(theme.fg("error", `Error: ${run.errorMessage}`), 1, 0),
1394
+ );
1395
+ }
1396
+ if (run.outputSummary) {
1397
+ container.addChild(new Spacer(1));
1398
+ container.addChild(
1399
+ new Text(
1400
+ theme.fg("muted", "\u2500\u2500\u2500 Output Preview \u2500\u2500\u2500"),
1401
+ 1,
1402
+ 0,
1403
+ ),
1404
+ );
1405
+ container.addChild(
1406
+ new Text(theme.fg("toolOutput", run.outputSummary), 1, 0),
1407
+ );
1408
+ if ((run.outputSummary || "").length >= 200) {
1409
+ container.addChild(new Text(theme.fg("dim", "(first 200 characters)"), 1, 0));
1410
+ }
1411
+ }
1412
+
1413
+ container.addChild(new Spacer(1));
1414
+ container.addChild(new Text(theme.fg("dim", "Press any key to close"), 1, 0));
1415
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1416
+
1417
+ return {
1418
+ render: (w: number) => container.render(w),
1419
+ invalidate: () => container.invalidate(),
1420
+ handleInput: (_data: string) => done(),
1421
+ };
1422
+ });
1423
+ }
1424
+
1425
+ // ---------------------------------------------------------------------------
1426
+ // Live monitor dashboard
1427
+ // ---------------------------------------------------------------------------
1428
+
1429
+ export async function showMonitor(
1430
+ ctx: ExtensionContext,
1431
+ state: SessionState,
1432
+ ): Promise<void> {
1433
+ // Issue #52: self-heal stale live entries BEFORE the empty guard — a map
1434
+ // holding only terminal entries (poller died mid-run) must render as
1435
+ // empty, not as a live monitor full of ghosts.
1436
+ sweepStaleLiveSubagents(state, getAgent);
1437
+
1438
+ if (state.subagentSessions.size === 0) {
1439
+ ctx.ui.notify(
1440
+ "No subagents are currently running. Delegate a task to see live activity.",
1441
+ "info",
1442
+ );
1443
+ return;
1444
+ }
1445
+
1446
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
1447
+ // Selected row index (0-based). Clamped to the map size on every
1448
+ // render — entries can be swept while the monitor is open.
1449
+ let selectedIndex = 0;
1450
+ // Guards against stacking drill-in overlays on repeated Enter.
1451
+ let detailOpen = false;
1452
+
1453
+ const buildView = () => {
1454
+ // Issue #52: continuous self-heal — a background agent can complete
1455
+ // (poller dead) while the monitor is already open; sweep on every
1456
+ // render pass. Idempotent, so racing the poller is safe.
1457
+ sweepStaleLiveSubagents(state, getAgent);
1458
+
1459
+ const container = new Container();
1460
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1461
+ container.addChild(
1462
+ new Text(
1463
+ theme.fg(
1464
+ "accent",
1465
+ theme.bold(`Subagent Monitor (${state.subagentSessions.size} active)`),
1466
+ ),
1467
+ 1,
1468
+ 0,
1469
+ ),
1470
+ );
1471
+ container.addChild(
1472
+ new Text(theme.fg("dim", "esc close · ↑/↓ select · enter drill-in"), 1, 0),
1473
+ );
1474
+ container.addChild(new Text("", 0, 0));
1475
+
1476
+ const entries = Array.from(state.subagentSessions.entries());
1477
+ const selIdx = Math.min(selectedIndex, Math.max(0, entries.length - 1));
1478
+
1479
+ let idx = 0;
1480
+ for (const [id, session] of entries) {
1481
+ const elapsedStr = formatElapsed(Date.now() - session.startedAt);
1482
+ const name = liveRowName(session.label, session.task);
1483
+ const spinner = liveSpinner(Date.now());
1484
+ // Selection marker: a "›" prefix on the selected row only. The
1485
+ // rest of the row (spinner/name/dim/muted lines) is unchanged.
1486
+ const marker = idx === selIdx ? "› " : "";
1487
+
1488
+ container.addChild(
1489
+ new Text(
1490
+ theme.fg("accent", `${marker}${spinner} ${name}`) +
1491
+ theme.fg(
1492
+ "dim",
1493
+ formatLiveRowDim(id, session.usage, elapsedStr),
1494
+ ),
1495
+ 1,
1496
+ 0,
1497
+ ),
1498
+ );
1499
+ container.addChild(
1500
+ new Text(
1501
+ theme.fg(
1502
+ "muted",
1503
+ ` ${session.thinkingLevel} \u00b7 ${session.model.split("/").pop() || session.model}`,
1504
+ ),
1505
+ 1,
1506
+ 0,
1507
+ ),
1508
+ );
1509
+
1510
+ if (session.liveOutput) {
1511
+ const lastLine =
1512
+ session.liveOutput
1513
+ .split("\n")
1514
+ .filter(Boolean)
1515
+ .pop() || "";
1516
+ if (lastLine) {
1517
+ container.addChild(
1518
+ new Text(theme.fg("toolOutput", ` ${lastLine.slice(0, 60)}`), 1, 0),
1519
+ );
1520
+ }
1521
+ }
1522
+
1523
+ idx++;
1524
+ if (idx < entries.length) container.addChild(new Text("", 0, 0));
1525
+ }
1526
+
1527
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1528
+ return container;
1529
+ };
1530
+
1531
+ const interval = setInterval(() => tui.requestRender(), 200);
1532
+ const cleanup = () => clearInterval(interval);
1533
+
1534
+ return {
1535
+ render: (w: number) => buildView().render(w),
1536
+ invalidate: () => {},
1537
+ handleInput: (data: string) => {
1538
+ // esc / q: close the monitor (existing behavior).
1539
+ if (_kb.matches(data, "tui.select.cancel") || data === "q") {
1540
+ cleanup();
1541
+ done();
1542
+ return;
1543
+ }
1544
+ // up / down: move the selection, wrapping like SelectList.
1545
+ if (_kb.matches(data, "tui.select.up")) {
1546
+ const count = state.subagentSessions.size;
1547
+ selectedIndex = count > 0 ? (selectedIndex - 1 + count) % count : 0;
1548
+ tui.requestRender();
1549
+ return;
1550
+ }
1551
+ if (_kb.matches(data, "tui.select.down")) {
1552
+ const count = state.subagentSessions.size;
1553
+ selectedIndex = count > 0 ? (selectedIndex + 1) % count : 0;
1554
+ tui.requestRender();
1555
+ return;
1556
+ }
1557
+ // enter: open the full-screen drill-in overlay for the selected agent.
1558
+ // The overlay's done() returns to the list — the monitor stays open
1559
+ // beneath it (fire-and-forget; handleInput is synchronous).
1560
+ if (_kb.matches(data, "tui.select.confirm")) {
1561
+ const ids = Array.from(state.subagentSessions.keys());
1562
+ const id = ids[Math.min(selectedIndex, ids.length - 1)];
1563
+ if (id && !detailOpen) {
1564
+ detailOpen = true;
1565
+ showAgentDetail(ctx, state, id).finally(() => {
1566
+ detailOpen = false;
1567
+ });
1568
+ }
1569
+ }
1570
+ },
1571
+ };
1572
+ });
1573
+ }
1574
+
1575
+ // ---------------------------------------------------------------------------
1576
+ // Drill-in overlay: full-screen transcript tail for one agent
1577
+ // ---------------------------------------------------------------------------
1578
+
1579
+ /** Map a planned transcript line style to a theme color name. */
1580
+ function styleToColor(
1581
+ theme: Theme,
1582
+ style: TranscriptLineStyle,
1583
+ text: string,
1584
+ ): string {
1585
+ switch (style) {
1586
+ case "user":
1587
+ return theme.fg("muted", text);
1588
+ case "thinking":
1589
+ return theme.fg("dim", text);
1590
+ case "text":
1591
+ return theme.fg("toolOutput", text);
1592
+ case "toolCall":
1593
+ return theme.fg("accent", text);
1594
+ case "toolResult":
1595
+ return theme.fg("muted", text);
1596
+ case "note":
1597
+ return theme.fg("dim", text);
1598
+ case "streaming":
1599
+ return theme.fg("accent", text);
1600
+ }
1601
+ return theme.fg("dim", text);
1602
+ }
1603
+
1604
+ /**
1605
+ * Full-screen drill-in: the live transcript TAIL of one running agent.
1606
+ *
1607
+ * Rendered via ctx.ui.custom with a full-screen overlay (width "100%",
1608
+ * maxHeight "100%"; the component pads itself to the terminal height). The
1609
+ * overlay tracks the agent's session ref (`getAgent(id)._sessionRef`) and
1610
+ * refreshes at 200ms while open. Esc returns to the list monitor beneath
1611
+ * (this overlay's done() only closes the overlay).
1612
+ *
1613
+ * Access path verified: `getAgent(agentId)` (session-manager) returns the
1614
+ * BackgroundAgent whose `_sessionRef` is the live AgentSession; the transcript
1615
+ * is `_sessionRef.messages`, the in-progress message is
1616
+ * `_sessionRef.state.streamingMessage`, and liveness is `_sessionRef.isStreaming`.
1617
+ * When the agent completes, the settle handler releases `_sessionRef`
1618
+ * (issue #31) — the overlay then renders a cached tail once and stops
1619
+ * refreshing.
1620
+ */
1621
+ export async function showAgentDetail(
1622
+ ctx: ExtensionContext,
1623
+ state: SessionState,
1624
+ agentId: string,
1625
+ ): Promise<void> {
1626
+ await ctx.ui.custom<void>(
1627
+ (tui, theme, _kb, done) => {
1628
+ // Set once the session ref is gone (agent finished / record removed):
1629
+ // renders the finished view once and stops the refresh loop.
1630
+ let finished = false;
1631
+ // Tail captured at finish time for the one-shot "finished" render.
1632
+ let cachedTail = "";
1633
+ // Last seen live transcript (foreground runs — issue #105), cached so
1634
+ // the one-shot finished render still shows it after the live entry is
1635
+ // finalized and deleted.
1636
+ let cachedTranscript: TranscriptMessage[] | undefined;
1637
+
1638
+ const buildView = () => {
1639
+ const agent = getAgent(agentId);
1640
+ const session = agent?._sessionRef;
1641
+ const live = state.subagentSessions.get(agentId);
1642
+
1643
+ const container = new Container();
1644
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1645
+
1646
+ const name = liveRowName(
1647
+ live?.label,
1648
+ live?.task ?? agent?.task ?? "",
1649
+ );
1650
+ const shortId = agentId.slice(0, 8);
1651
+ const modelShort =
1652
+ (live?.model ?? agent?.model ?? "").split("/").pop() ||
1653
+ live?.model ||
1654
+ agent?.model ||
1655
+ "";
1656
+ const thinkingLevel = live?.thinkingLevel ?? agent?.thinkingLevel ?? "";
1657
+ const priority = live?.priority ?? agent?.priority;
1658
+ const startedAt = live?.startedAt ?? agent?.startedAt ?? Date.now();
1659
+ const elapsed = formatElapsed(Date.now() - startedAt);
1660
+ // Issue #114: per-unit priority is part of the identity line when the
1661
+ // run carried one (p:critical etc.); absent → no segment at all.
1662
+ const metaSegment = [modelShort, thinkingLevel, priority ? `p:${priority}` : null]
1663
+ .filter(Boolean)
1664
+ .join(" · ");
1665
+
1666
+ container.addChild(
1667
+ new Text(
1668
+ theme.fg("accent", theme.bold(name)) +
1669
+ theme.fg(
1670
+ "dim",
1671
+ ` [${shortId}] ${metaSegment} · ${elapsed}`,
1672
+ ),
1673
+ 1,
1674
+ 0,
1675
+ ),
1676
+ );
1677
+ container.addChild(new Text(theme.fg("dim", "esc close"), 1, 0));
1678
+ container.addChild(new Spacer(1));
1679
+
1680
+ if (!agent) {
1681
+ // Foreground run (issue #105): no BackgroundAgent record exists —
1682
+ // the live transcript comes from the streaming JSON pipeline,
1683
+ // threaded through the live entry, and is rendered through the
1684
+ // SAME planner the background path uses. The refresh loop keeps
1685
+ // ticking while the live entry exists.
1686
+ if (live) {
1687
+ const transcript = live.transcript ?? [];
1688
+ cachedTranscript = transcript;
1689
+ cachedTail = live.liveOutput;
1690
+ container.addChild(new Spacer(1));
1691
+ if (transcript.length === 0) {
1692
+ // No messages yet — the agent is still in preflight.
1693
+ container.addChild(
1694
+ new Text(theme.fg("dim", "waiting for first output…"), 1, 0),
1695
+ );
1696
+ } else {
1697
+ const planned = renderTranscriptMessages(transcript, {
1698
+ maxLines: Math.max(6, tui.terminal.rows - 5),
1699
+ maxWidth: Math.max(20, tui.terminal.columns - 4),
1700
+ streaming: !state.isLiveEntryFinalized(agentId),
1701
+ spinner: liveSpinner(Date.now()),
1702
+ });
1703
+ for (const line of planned) {
1704
+ container.addChild(
1705
+ new Text(styleToColor(theme, line.style, line.text), 1, 0),
1706
+ );
1707
+ }
1708
+ }
1709
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1710
+ return container;
1711
+ }
1712
+
1713
+ // The foreground run completed and its live entry was finalized
1714
+ // and deleted — render the last captured transcript once and stop.
1715
+ if (!finished) {
1716
+ finished = true;
1717
+ }
1718
+ container.addChild(
1719
+ new Text(theme.fg("dim", "agent finished — transcript ends here"), 1, 0),
1720
+ );
1721
+ if (cachedTranscript && cachedTranscript.length > 0) {
1722
+ container.addChild(new Spacer(1));
1723
+ const planned = renderTranscriptMessages(cachedTranscript, {
1724
+ maxLines: Math.max(6, tui.terminal.rows - 5),
1725
+ maxWidth: Math.max(20, tui.terminal.columns - 4),
1726
+ });
1727
+ for (const line of planned) {
1728
+ container.addChild(
1729
+ new Text(styleToColor(theme, line.style, line.text), 1, 0),
1730
+ );
1731
+ }
1732
+ } else {
1733
+ // R3 (review): stale foreground selection — the live entry was
1734
+ // finalized and deleted before first render, so neither the
1735
+ // transcript nor the live tail were ever captured here. Fall
1736
+ // back to the persisted run record's fullOutput (the completion
1737
+ // path persists it), mirroring the background path's
1738
+ // `agent?.finalOutput` fallback.
1739
+ const runOutput =
1740
+ cachedTail || state.findRunById(ctx, agentId)?.fullOutput || "";
1741
+ const tailLines = runOutput.split("\n").filter(Boolean).slice(-10);
1742
+ if (tailLines.length > 0) {
1743
+ container.addChild(new Spacer(1));
1744
+ for (const line of tailLines) {
1745
+ container.addChild(new Text(theme.fg("toolOutput", line), 1, 0));
1746
+ }
1747
+ }
1748
+ }
1749
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1750
+ return container;
1751
+ }
1752
+
1753
+ if (!session) {
1754
+ // The agent completed (or its record vanished) while the overlay
1755
+ // was open — capture the last known output once and stop.
1756
+ if (!finished) {
1757
+ finished = true;
1758
+ cachedTail = live?.liveOutput ?? agent?.finalOutput ?? "";
1759
+ }
1760
+ container.addChild(
1761
+ new Text(theme.fg("dim", "agent finished — transcript ends here"), 1, 0),
1762
+ );
1763
+ const tailLines = cachedTail.split("\n").filter(Boolean).slice(-10);
1764
+ if (tailLines.length > 0) {
1765
+ container.addChild(new Spacer(1));
1766
+ for (const line of tailLines) {
1767
+ container.addChild(new Text(theme.fg("toolOutput", line), 1, 0));
1768
+ }
1769
+ }
1770
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1771
+ return container;
1772
+ }
1773
+
1774
+ const messages = session.messages ?? [];
1775
+ const streaming = session.state?.streamingMessage;
1776
+ const planned = renderTranscriptMessages(
1777
+ streaming ? [...messages, streaming] : messages,
1778
+ {
1779
+ maxLines: Math.max(6, tui.terminal.rows - 5),
1780
+ maxWidth: Math.max(20, tui.terminal.columns - 4),
1781
+ streaming: Boolean(streaming),
1782
+ spinner: liveSpinner(Date.now()),
1783
+ },
1784
+ );
1785
+
1786
+ if (planned.length === 0) {
1787
+ // No messages yet — the agent is still in preflight.
1788
+ container.addChild(
1789
+ new Text(theme.fg("dim", "waiting for first output…"), 1, 0),
1790
+ );
1791
+ } else {
1792
+ for (const line of planned) {
1793
+ container.addChild(new Text(styleToColor(theme, line.style, line.text), 1, 0));
1794
+ }
1795
+ }
1796
+
1797
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1798
+ return container;
1799
+ };
1800
+
1801
+ // Live refresh while open (D2b — the overlay tracks the agent).
1802
+ const interval = setInterval(() => {
1803
+ if (finished) return; // terminal view rendered once — no more redraws
1804
+ tui.requestRender();
1805
+ }, 200);
1806
+ const cleanup = () => clearInterval(interval);
1807
+
1808
+ return {
1809
+ render: (w: number) => {
1810
+ const lines = buildView().render(w);
1811
+ // Full-screen: pad the overlay content to the terminal height so
1812
+ // the drill-in occupies the whole screen (the tui overlay sizes
1813
+ // to its rendered content otherwise).
1814
+ const rows = tui.terminal.rows;
1815
+ for (let i = lines.length; i < rows; i++) lines.push(" ".repeat(w));
1816
+ return lines;
1817
+ },
1818
+ invalidate: () => {},
1819
+ handleInput: (data: string) => {
1820
+ if (_kb.matches(data, "tui.select.cancel") || data === "q") {
1821
+ cleanup();
1822
+ done();
1823
+ }
1824
+ },
1825
+ };
1826
+ },
1827
+ {
1828
+ overlay: true,
1829
+ overlayOptions: {
1830
+ width: "100%",
1831
+ maxHeight: "100%",
1832
+ anchor: "center",
1833
+ },
1834
+ },
1835
+ );
1836
+ }
1837
+
1838
+ // ---------------------------------------------------------------------------
1839
+ // E1: Observability Dashboard
1840
+ // ---------------------------------------------------------------------------
1841
+
1842
+ /**
1843
+ * Horizontal bar for error breakdown using Unicode block characters.
1844
+ */
1845
+ function buildHorizontalBar(count: number, maxCount: number, maxWidth: number): string {
1846
+ if (maxCount === 0 || count === 0) return '';
1847
+ const barLen = Math.max(1, Math.round((count / maxCount) * maxWidth));
1848
+ return '\u2588'.repeat(barLen);
1849
+ }
1850
+
1851
+ /**
1852
+ * Format a duration in ms to a compact string for the recent runs table.
1853
+ */
1854
+ function compactDuration(ms: number): string {
1855
+ if (ms < 1000) return `${ms}ms`;
1856
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
1857
+ return `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s`;
1858
+ }
1859
+
1860
+ export async function showDashboard(
1861
+ ctx: ExtensionContext,
1862
+ state: SessionState,
1863
+ ): Promise<void> {
1864
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
1865
+ const buildView = () => {
1866
+ // Issue #52: self-heal stale live entries on every render pass —
1867
+ // an agent whose poller died shows as "running" forever otherwise.
1868
+ sweepStaleLiveSubagents(state, getAgent);
1869
+
1870
+ const container = new Container();
1871
+ const now = new Date();
1872
+ const refreshStr = `refreshing \u00b7 ${now.toLocaleTimeString()}`;
1873
+
1874
+ // ---- Panel 1: Header ----
1875
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1876
+ container.addChild(
1877
+ new Text(
1878
+ theme.fg("accent", theme.bold("brl-subagent Dashboard")) +
1879
+ theme.fg("dim", ` ${refreshStr}`),
1880
+ 1,
1881
+ 0,
1882
+ ),
1883
+ );
1884
+ container.addChild(new Text("", 0, 0));
1885
+
1886
+ // ---- Panel 2: Active subagents ----
1887
+ const activeCount = state.subagentSessions.size;
1888
+ container.addChild(
1889
+ new Text(
1890
+ theme.fg("accent", theme.bold(`Active Subagents (${activeCount})`)),
1891
+ 1,
1892
+ 0,
1893
+ ),
1894
+ );
1895
+ if (activeCount === 0) {
1896
+ container.addChild(new Text(theme.fg("dim", " None running"), 1, 0));
1897
+ } else {
1898
+ let idx = 0;
1899
+ for (const [id, session] of state.subagentSessions) {
1900
+ const elapsedStr = formatElapsed(Date.now() - session.startedAt);
1901
+ const name = liveRowName(session.label, session.task);
1902
+ const spinner = liveSpinner(Date.now());
1903
+ container.addChild(
1904
+ new Text(
1905
+ theme.fg("accent", ` ${spinner} ${name}`) +
1906
+ theme.fg(
1907
+ "dim",
1908
+ formatLiveRowDim(id, session.usage, elapsedStr),
1909
+ ),
1910
+ 1,
1911
+ 0,
1912
+ ),
1913
+ );
1914
+ idx++;
1915
+ if (idx < activeCount) container.addChild(new Text("", 0, 0));
1916
+ }
1917
+ }
1918
+ container.addChild(new Text("", 0, 0));
1919
+
1920
+ // ---- Panel 3: SLA Summary ----
1921
+ const allRuns = state.getRunEntries(ctx);
1922
+ const recentRuns = allRuns.slice(0, state.config.slaWindowSize || 50);
1923
+ const metrics = recentRuns.length > 0 ? computeSLAMetrics(recentRuns) : null;
1924
+
1925
+ container.addChild(
1926
+ new Text(
1927
+ theme.fg("accent", theme.bold(`SLA Summary (last ${recentRuns.length} runs)`)),
1928
+ 1,
1929
+ 0,
1930
+ ),
1931
+ );
1932
+ if (metrics && metrics.totalRuns > 0) {
1933
+ const successPct = (metrics.successRate * 100).toFixed(1);
1934
+ const successColor = metrics.successRate >= 0.9 ? "success" : metrics.successRate >= 0.7 ? "warning" : "error";
1935
+ container.addChild(
1936
+ new Text(
1937
+ theme.fg("dim", " Success: ") +
1938
+ theme.fg(successColor, `${successPct}%`) +
1939
+ theme.fg("dim", ` \u00b7 p50: ${compactDuration(metrics.p50DurationMs)} \u00b7 p95: ${compactDuration(metrics.p95DurationMs)} \u00b7 p99: ${compactDuration(metrics.p99DurationMs)}`),
1940
+ 1,
1941
+ 0,
1942
+ ),
1943
+ );
1944
+ container.addChild(
1945
+ new Text(
1946
+ theme.fg("dim", " Cost: ") +
1947
+ theme.fg("dim", `total $${metrics.totalCost.toFixed(4)} \u00b7 avg $${metrics.averageCost.toFixed(4)}`),
1948
+ 1,
1949
+ 0,
1950
+ ),
1951
+ );
1952
+ } else {
1953
+ container.addChild(new Text(theme.fg("dim", " No completed runs yet"), 1, 0));
1954
+ }
1955
+ container.addChild(new Text("", 0, 0));
1956
+
1957
+ // ---- Panel 4: Recent Runs (last 10) ----
1958
+ const recent10 = allRuns.slice(0, 10);
1959
+ container.addChild(
1960
+ new Text(
1961
+ theme.fg("accent", theme.bold("Recent Runs")),
1962
+ 1,
1963
+ 0,
1964
+ ),
1965
+ );
1966
+ if (recent10.length === 0) {
1967
+ container.addChild(new Text(theme.fg("dim", " No runs recorded"), 1, 0));
1968
+ } else {
1969
+ for (const run of recent10) {
1970
+ const icon =
1971
+ run.status === "done"
1972
+ ? theme.fg("success", "\u2713")
1973
+ : run.status === "failed"
1974
+ ? theme.fg("error", "\u2717")
1975
+ : theme.fg("accent", "\u25CF");
1976
+ const name = run.label || run.task.slice(0, 50);
1977
+ const dur = run.durationMs ? compactDuration(run.durationMs) : "...";
1978
+ const cost = run.cost ? ` $${run.cost.toFixed(4)}` : "";
1979
+ container.addChild(
1980
+ new Text(
1981
+ ` ${icon} ${theme.fg("dim", name.slice(0, 50))}` +
1982
+ theme.fg("dim", ` ${dur}${cost}`),
1983
+ 1,
1984
+ 0,
1985
+ ),
1986
+ );
1987
+ }
1988
+ }
1989
+ container.addChild(new Text("", 0, 0));
1990
+
1991
+ // ---- Panel 5: Error Breakdown ----
1992
+ container.addChild(
1993
+ new Text(
1994
+ theme.fg("accent", theme.bold("Error Breakdown")),
1995
+ 1,
1996
+ 0,
1997
+ ),
1998
+ );
1999
+ if (metrics && Object.keys(metrics.errorCategoryBreakdown).length > 0) {
2000
+ const cats = Object.entries(metrics.errorCategoryBreakdown).sort(
2001
+ (a, b) => b[1] - a[1],
2002
+ );
2003
+ const maxCount = cats[0][1];
2004
+ for (const [cat, count] of cats) {
2005
+ const bar = buildHorizontalBar(count, maxCount, 20);
2006
+ container.addChild(
2007
+ new Text(
2008
+ theme.fg("dim", ` ${cat.padEnd(18)} `) +
2009
+ theme.fg("error", bar) +
2010
+ theme.fg("dim", ` ${count}`),
2011
+ 1,
2012
+ 0,
2013
+ ),
2014
+ );
2015
+ }
2016
+ } else {
2017
+ container.addChild(new Text(theme.fg("success", " No errors \u2713"), 1, 0));
2018
+ }
2019
+ container.addChild(new Text("", 0, 0));
2020
+
2021
+ // ---- Panel 6: Cost Trend (sparkline) ----
2022
+ container.addChild(
2023
+ new Text(
2024
+ theme.fg("accent", theme.bold("Cost Trend (last 20 runs)")),
2025
+ 1,
2026
+ 0,
2027
+ ),
2028
+ );
2029
+ const costTrend = computeCostTrend(allRuns, 20);
2030
+ if (costTrend.length > 0) {
2031
+ const sparkline = formatSparkline(costTrend);
2032
+ const totalCost = costTrend.reduce((s, c) => s + c, 0);
2033
+ container.addChild(
2034
+ new Text(
2035
+ theme.fg("dim", " ") + theme.fg("accent", sparkline),
2036
+ 1,
2037
+ 0,
2038
+ ),
2039
+ );
2040
+ container.addChild(
2041
+ new Text(
2042
+ theme.fg("dim", ` ${costTrend.length} runs \u00b7 total $${totalCost.toFixed(4)} \u00b7 avg $${(totalCost / costTrend.length).toFixed(4)}`),
2043
+ 1,
2044
+ 0,
2045
+ ),
2046
+ );
2047
+ } else {
2048
+ container.addChild(new Text(theme.fg("dim", " No cost data"), 1, 0));
2049
+ }
2050
+
2051
+ container.addChild(new Text("", 0, 0));
2052
+ container.addChild(new Text(theme.fg("dim", "q / esc to close"), 1, 0));
2053
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
2054
+
2055
+ return container;
2056
+ };
2057
+
2058
+ // Auto-refresh every 2 seconds
2059
+ const interval = setInterval(() => tui.requestRender(), 2000);
2060
+ const cleanup = () => clearInterval(interval);
2061
+
2062
+ return {
2063
+ render: (w: number) => buildView().render(w),
2064
+ invalidate: () => {},
2065
+ handleInput: (_data: string) => {
2066
+ cleanup();
2067
+ done();
2068
+ },
2069
+ };
2070
+ });
2071
+ }
2072
+
2073
+ // ---------------------------------------------------------------------------
2074
+ // Retry menu
2075
+ // ---------------------------------------------------------------------------
2076
+
2077
+ export async function showRetryMenu(ctx: ExtensionContext, state: SessionState): Promise<void> {
2078
+ const failedRuns = state.getRunEntries(ctx).filter((r) => r.status === "failed").reverse();
2079
+
2080
+ if (failedRuns.length === 0) {
2081
+ ctx.ui.notify("No failed subagent runs to retry.", "info");
2082
+ return;
2083
+ }
2084
+
2085
+ const items: SelectItem[] = failedRuns.map((r) => {
2086
+ const name = r.label || r.task.slice(0, 60);
2087
+ const model = r.model.split("/").pop() || r.model;
2088
+ const err = r.errorMessage ? r.errorMessage.slice(0, 40) : "error";
2089
+ return {
2090
+ value: r.id,
2091
+ label: `✗ ${name}`,
2092
+ description: `${model} · ${err}`,
2093
+ };
2094
+ });
2095
+
2096
+ const selectedId = await showSelectList(ctx, "Retry Failed Run", items, 10);
2097
+ if (!selectedId) return;
2098
+
2099
+ ctx.ui.notify(
2100
+ `Ask the LLM to retry: "Use delegate_task to retry failed run ${selectedId.slice(0, 8)}..."`,
2101
+ "info",
2102
+ );
2103
+ }
2104
+
2105
+ // ---------------------------------------------------------------------------
2106
+ // delegate_task result rendering (renderCall / renderResult)
2107
+ // ---------------------------------------------------------------------------
2108
+
2109
+ function buildDelegateLabel(
2110
+ args: { inheritSystemPrompt?: boolean; systemPrompt?: string },
2111
+ theme: Theme,
2112
+ ): string {
2113
+ const inherit = args.inheritSystemPrompt !== false;
2114
+ const hasCustom = Boolean(args.systemPrompt);
2115
+ const badge = inherit
2116
+ ? hasCustom
2117
+ ? "[inherit+custom]"
2118
+ : null
2119
+ : hasCustom
2120
+ ? "[custom]"
2121
+ : "[no-inherit]";
2122
+ return badge
2123
+ ? theme.fg("accent", "delegate_task ") + theme.fg("muted", badge)
2124
+ : theme.fg("toolTitle", theme.bold("delegate_task "));
2125
+ }
2126
+
2127
+ function buildScopeLabel(
2128
+ args: { tools?: string[]; excludeTools?: string[]; noBuiltinTools?: boolean },
2129
+ theme: Theme,
2130
+ ): string {
2131
+ if (args.noBuiltinTools) return theme.fg("muted", " [no-builtins]");
2132
+ if (args.tools?.length) return theme.fg("muted", ` [tools:${args.tools.join(",")}]`);
2133
+ if (args.excludeTools?.length) return theme.fg("muted", ` [-${args.excludeTools.join(",")}]`);
2134
+ return "";
2135
+ }
2136
+
2137
+ function renderExpandedResult(
2138
+ details: SubagentResult,
2139
+ isError: boolean,
2140
+ icon: string,
2141
+ finalOutput: string,
2142
+ theme: Theme,
2143
+ mdTheme: ReturnType<typeof getMarkdownTheme>,
2144
+ ): Container {
2145
+ const container = new Container();
2146
+ const labelText = details.label ? theme.fg("accent", ` [${details.label}]`) : "";
2147
+ container.addChild(
2148
+ new Text(
2149
+ `${icon} ${theme.fg("toolTitle", theme.bold("subagent"))}` +
2150
+ labelText +
2151
+ (details.model ? theme.fg("muted", ` (${details.model})`) : ""),
2152
+ 0,
2153
+ 0,
2154
+ ),
2155
+ );
2156
+ if (isError && details.errorMessage) {
2157
+ container.addChild(
2158
+ new Text(theme.fg("error", `Error: ${details.errorMessage}`), 0, 0),
2159
+ );
2160
+ }
2161
+ if (finalOutput) {
2162
+ container.addChild(new Spacer(1));
2163
+ container.addChild(
2164
+ new Text(theme.fg("muted", "\u2500\u2500\u2500 Output \u2500\u2500\u2500"), 0, 0),
2165
+ );
2166
+ container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
2167
+ }
2168
+ // P5: Expanded diff section
2169
+ addExpandedDiffSection(container, details.gitDiff, theme);
2170
+ const usageStr = formatUsageStats(details.usage, details.model);
2171
+ if (usageStr) {
2172
+ container.addChild(new Spacer(1));
2173
+ container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
2174
+ }
2175
+ return container;
2176
+ }
2177
+
2178
+ function renderCollapsedText(
2179
+ details: SubagentResult,
2180
+ isError: boolean,
2181
+ icon: string,
2182
+ finalOutput: string,
2183
+ theme: Theme,
2184
+ ): string {
2185
+ let text =
2186
+ `${icon} ${theme.fg("toolTitle", theme.bold("subagent"))}` +
2187
+ (details.label ? theme.fg("accent", ` [${details.label}]`) : "") +
2188
+ (details.model ? theme.fg("muted", ` (${details.model})`) : "");
2189
+ if (isError && details.errorMessage) {
2190
+ text += `\n${theme.fg("error", `Error: ${details.errorMessage}`)}`;
2191
+ } else if (finalOutput) {
2192
+ const preview = finalOutput
2193
+ .split("\n")
2194
+ .slice(0, COLLAPSED_OUTPUT_LINES)
2195
+ .join("\n");
2196
+ text += `\n${theme.fg("toolOutput", preview)}`;
2197
+ if (finalOutput.split("\n").length > COLLAPSED_OUTPUT_LINES) {
2198
+ text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
2199
+ }
2200
+ }
2201
+ // P5: Collapsed diff file summary
2202
+ const diffSummary = renderCollapsedDiffSummary(details.gitDiff, theme);
2203
+ if (diffSummary) text += `\n${diffSummary}`;
2204
+ const usageStr = formatUsageStats(details.usage, details.model);
2205
+ if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
2206
+ return text;
2207
+ }
2208
+
2209
+ // ---------------------------------------------------------------------------
2210
+ // Chain / parallel helper: one-line subtask summary
2211
+ // ---------------------------------------------------------------------------
2212
+
2213
+ function renderSubTaskSummary(
2214
+ result: SubTaskResult,
2215
+ theme: {
2216
+ fg: (color: string, text: string) => string;
2217
+ },
2218
+ maxLines: number,
2219
+ ): string {
2220
+ const isError =
2221
+ result.exitCode !== 0 ||
2222
+ result.stopReason === "error" ||
2223
+ result.stopReason === "aborted";
2224
+ const icon = isError
2225
+ ? theme.fg("error", "\u2717")
2226
+ : theme.fg("success", "\u2713");
2227
+ const label = result.label
2228
+ ? theme.fg("accent", `${result.label}: `)
2229
+ : "";
2230
+ const output = getFinalOutput(result.messages);
2231
+ const preview = output.split("\n").slice(0, maxLines).join("\n");
2232
+ return `${icon} ${label}${theme.fg("toolOutput", preview)}`;
2233
+ }
2234
+
2235
+ // ---------------------------------------------------------------------------
2236
+ // Chain / parallel collapsed rendering
2237
+ // ---------------------------------------------------------------------------
2238
+
2239
+ function renderCollapsedChain(
2240
+ details: ChainDetails,
2241
+ theme: Theme,
2242
+ ): string {
2243
+ const allSucceeded = details.results.every(
2244
+ (r) =>
2245
+ r.exitCode === 0 &&
2246
+ r.stopReason !== "error" &&
2247
+ r.stopReason !== "aborted",
2248
+ );
2249
+ const icon = allSucceeded
2250
+ ? theme.fg("success", "\u2713")
2251
+ : theme.fg("error", "\u2717");
2252
+ const stoppedEarly = details.stoppedEarly ? " (stopped early)" : "";
2253
+ let text = `${icon} ${theme.fg("toolTitle", theme.bold("chain"))} ${theme.fg("muted", `${details.completedSteps}/${details.totalSteps} steps${stoppedEarly}`)}`;
2254
+
2255
+ for (const r of details.results) {
2256
+ const isErr =
2257
+ r.exitCode !== 0 ||
2258
+ r.stopReason === "error" ||
2259
+ r.stopReason === "aborted";
2260
+ const statusIcon = isErr
2261
+ ? theme.fg("error", "\u2717")
2262
+ : theme.fg("success", "\u2713");
2263
+ const stepNum =
2264
+ r.step !== undefined ? theme.fg("muted", `${r.step}. `) : "";
2265
+ const label = r.label ? theme.fg("accent", r.label) : "";
2266
+ const output = getFinalOutput(r.messages);
2267
+ const preview = output.split("\n").slice(0, 2).join("\n");
2268
+ const sep = label ? ": " : "";
2269
+ text += `\n${stepNum}${statusIcon} ${label}${sep}${theme.fg("toolOutput", preview)}`;
2270
+ if (r.errorMessage) {
2271
+ text += `\n ${theme.fg("error", r.errorMessage)}`;
2272
+ }
2273
+ // P5: Collapsed diff file summary per step
2274
+ const diffSummary = renderCollapsedDiffSummary(r.gitDiff, theme);
2275
+ if (diffSummary) text += `\n ${diffSummary}`;
2276
+ }
2277
+
2278
+ text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
2279
+ return text;
2280
+ }
2281
+
2282
+ function renderCollapsedParallel(
2283
+ details: ParallelDetails,
2284
+ theme: Theme,
2285
+ ): string {
2286
+ const icon =
2287
+ details.failed === 0
2288
+ ? theme.fg("success", "\u2713")
2289
+ : theme.fg("error", "\u2717");
2290
+ let text = `${icon} ${theme.fg("toolTitle", theme.bold("parallel"))} ${theme.fg("muted", `${details.succeeded}/${details.failed}/${details.results.length} total`)}`;
2291
+
2292
+ for (const r of details.results) {
2293
+ const isErr =
2294
+ r.exitCode !== 0 ||
2295
+ r.stopReason === "error" ||
2296
+ r.stopReason === "aborted";
2297
+ const statusIcon = isErr
2298
+ ? theme.fg("error", "\u2717")
2299
+ : theme.fg("success", "\u2713");
2300
+ const label = r.label ? theme.fg("accent", r.label) : "";
2301
+ const output = getFinalOutput(r.messages);
2302
+ const preview = output.split("\n").slice(0, 2).join("\n");
2303
+ const sep = label ? ": " : " ";
2304
+ text += `\n${statusIcon} ${label}${sep}${theme.fg("toolOutput", preview)}`;
2305
+ if (r.errorMessage) {
2306
+ text += `\n ${theme.fg("error", r.errorMessage)}`;
2307
+ }
2308
+ // P5: Collapsed diff file summary per task
2309
+ const diffSummary = renderCollapsedDiffSummary(r.gitDiff, theme);
2310
+ if (diffSummary) text += `\n ${diffSummary}`;
2311
+ }
2312
+
2313
+ text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
2314
+ return text;
2315
+ }
2316
+
2317
+ // ---------------------------------------------------------------------------
2318
+ // Chain / parallel expanded rendering
2319
+ // ---------------------------------------------------------------------------
2320
+
2321
+ function renderExpandedChain(
2322
+ details: ChainDetails,
2323
+ theme: Theme,
2324
+ mdTheme: ReturnType<typeof getMarkdownTheme>,
2325
+ ): Container {
2326
+ const container = new Container();
2327
+ const stoppedEarly = details.stoppedEarly ? " (stopped early)" : "";
2328
+ container.addChild(
2329
+ new Text(
2330
+ theme.fg("accent", theme.bold("Chain")) +
2331
+ theme.fg(
2332
+ "muted",
2333
+ ` ${details.completedSteps}/${details.totalSteps} steps${stoppedEarly}`,
2334
+ ),
2335
+ 0,
2336
+ 0,
2337
+ ),
2338
+ );
2339
+ container.addChild(new Spacer(1));
2340
+
2341
+ for (const r of details.results) {
2342
+ const isErr =
2343
+ r.exitCode !== 0 ||
2344
+ r.stopReason === "error" ||
2345
+ r.stopReason === "aborted";
2346
+ const statusIcon = isErr
2347
+ ? theme.fg("error", "\u2717")
2348
+ : theme.fg("success", "\u2713");
2349
+ const stepNum = r.step !== undefined ? `${r.step}. ` : "";
2350
+ const label = r.label ? `[${r.label}] ` : "";
2351
+ container.addChild(
2352
+ new Text(
2353
+ `${statusIcon} ${stepNum}${theme.fg("toolTitle", theme.bold(label))}` +
2354
+ (r.model ? theme.fg("muted", ` (${r.model})`) : ""),
2355
+ 0,
2356
+ 0,
2357
+ ),
2358
+ );
2359
+ container.addChild(
2360
+ new Text(theme.fg("dim", `Task: ${r.task.slice(0, 200)}`), 0, 0),
2361
+ );
2362
+
2363
+ if (isErr && r.errorMessage) {
2364
+ container.addChild(
2365
+ new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0),
2366
+ );
2367
+ }
2368
+
2369
+ const output = getFinalOutput(r.messages);
2370
+ if (output) {
2371
+ container.addChild(
2372
+ new Text(theme.fg("muted", "\u2500\u2500\u2500 Output \u2500\u2500\u2500"), 0, 0),
2373
+ );
2374
+ container.addChild(new Markdown(output.trim(), 0, 0, mdTheme));
2375
+ }
2376
+
2377
+ // P5: Expanded diff section per step
2378
+ addExpandedDiffSection(container, r.gitDiff, theme);
2379
+
2380
+ const usageStr = formatUsageStats(r.usage, r.model);
2381
+ if (usageStr) {
2382
+ container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
2383
+ }
2384
+ container.addChild(new Spacer(1));
2385
+ }
2386
+
2387
+ // Aggregated totals
2388
+ const aggUsage: UsageStats = {
2389
+ input: details.totalInput,
2390
+ output: details.totalOutput,
2391
+ cacheRead: 0,
2392
+ cacheWrite: 0,
2393
+ cost: details.totalCost,
2394
+ contextTokens: 0,
2395
+ turns: details.totalTurns,
2396
+ };
2397
+ const aggStr = formatUsageStats(aggUsage);
2398
+ if (aggStr) {
2399
+ container.addChild(
2400
+ new Text(theme.fg("dim", `Totals: ${aggStr}`), 0, 0),
2401
+ );
2402
+ }
2403
+
2404
+ return container;
2405
+ }
2406
+
2407
+ function renderExpandedParallel(
2408
+ details: ParallelDetails,
2409
+ theme: Theme,
2410
+ mdTheme: ReturnType<typeof getMarkdownTheme>,
2411
+ ): Container {
2412
+ const container = new Container();
2413
+ container.addChild(
2414
+ new Text(
2415
+ theme.fg("accent", theme.bold("Parallel")) +
2416
+ theme.fg(
2417
+ "muted",
2418
+ ` ${details.succeeded} succeeded, ${details.failed} failed, ${details.results.length} total`,
2419
+ ),
2420
+ 0,
2421
+ 0,
2422
+ ),
2423
+ );
2424
+ container.addChild(new Spacer(1));
2425
+
2426
+ for (const r of details.results) {
2427
+ const isErr =
2428
+ r.exitCode !== 0 ||
2429
+ r.stopReason === "error" ||
2430
+ r.stopReason === "aborted";
2431
+ const statusIcon = isErr
2432
+ ? theme.fg("error", "\u2717")
2433
+ : theme.fg("success", "\u2713");
2434
+ const label = r.label ? `[${r.label}] ` : "";
2435
+ container.addChild(
2436
+ new Text(
2437
+ `${statusIcon} ${theme.fg("toolTitle", theme.bold(label))}` +
2438
+ (r.model ? theme.fg("muted", ` (${r.model})`) : ""),
2439
+ 0,
2440
+ 0,
2441
+ ),
2442
+ );
2443
+ container.addChild(
2444
+ new Text(theme.fg("dim", `Task: ${r.task.slice(0, 200)}`), 0, 0),
2445
+ );
2446
+
2447
+ if (isErr && r.errorMessage) {
2448
+ container.addChild(
2449
+ new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0),
2450
+ );
2451
+ }
2452
+
2453
+ const output = getFinalOutput(r.messages);
2454
+ if (output) {
2455
+ container.addChild(
2456
+ new Text(theme.fg("muted", "\u2500\u2500\u2500 Output \u2500\u2500\u2500"), 0, 0),
2457
+ );
2458
+ container.addChild(new Markdown(output.trim(), 0, 0, mdTheme));
2459
+ }
2460
+
2461
+ // P5: Expanded diff section per task
2462
+ addExpandedDiffSection(container, r.gitDiff, theme);
2463
+
2464
+ const usageStr = formatUsageStats(r.usage, r.model);
2465
+ if (usageStr) {
2466
+ container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
2467
+ }
2468
+ container.addChild(new Spacer(1));
2469
+ }
2470
+
2471
+ // Aggregated totals
2472
+ const aggUsage: UsageStats = {
2473
+ input: details.totalInput,
2474
+ output: details.totalOutput,
2475
+ cacheRead: 0,
2476
+ cacheWrite: 0,
2477
+ cost: details.totalCost,
2478
+ contextTokens: 0,
2479
+ turns: details.totalTurns,
2480
+ };
2481
+ const aggStr = formatUsageStats(aggUsage);
2482
+ if (aggStr) {
2483
+ container.addChild(
2484
+ new Text(theme.fg("dim", `Totals: ${aggStr}`), 0, 0),
2485
+ );
2486
+ }
2487
+
2488
+ return container;
2489
+ }
2490
+
2491
+ // ---------------------------------------------------------------------------
2492
+ // P4: Approval dialog — user reviews diff before merging
2493
+ // ---------------------------------------------------------------------------
2494
+
2495
+ /**
2496
+ * Show approval dialog for subagent changes.
2497
+ * Returns "apply" to merge the branch, "discard" to delete it, or null if cancelled.
2498
+ */
2499
+ export async function showApprovalDialog(
2500
+ ctx: ExtensionContext,
2501
+ label: string | undefined,
2502
+ gitDiff: string,
2503
+ gitBranch: string,
2504
+ ): Promise<"apply" | "discard" | null> {
2505
+ const diffLines = gitDiff.split("\n");
2506
+ const filesChanged = (gitDiff.match(/^diff --git /gm) || []).length;
2507
+ const hasMoreLines = diffLines.length > 20;
2508
+ const previewLines = diffLines.slice(0, 20).join("\n");
2509
+
2510
+ return ctx.ui.custom<"apply" | "discard" | null>((tui, theme, _kb, done) => {
2511
+ let currentView: "menu" | "diff" = "menu";
2512
+ let selectList: SelectList | null = null;
2513
+
2514
+ const items: SelectItem[] = [
2515
+ {
2516
+ value: "apply",
2517
+ label: "[Y] Apply changes",
2518
+ description: "Merge branch into working branch",
2519
+ },
2520
+ {
2521
+ value: "viewdiff",
2522
+ label: "[D] View full diff",
2523
+ description: "Open full diff in a scrollable view",
2524
+ },
2525
+ {
2526
+ value: "discard",
2527
+ label: "[N] Discard changes",
2528
+ description: "Delete branch without merging",
2529
+ },
2530
+ ];
2531
+
2532
+ const buildMenuView = () => {
2533
+ const container = new Container();
2534
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
2535
+ container.addChild(
2536
+ new Text(
2537
+ theme.fg("accent", theme.bold(`[\u2691] subagent${label ? ` [${label}]` : ""} completed`)),
2538
+ 1,
2539
+ 0,
2540
+ ),
2541
+ );
2542
+ container.addChild(
2543
+ new Text(
2544
+ theme.fg("dim", `${filesChanged} file(s) changed, branch: ${gitBranch}`),
2545
+ 1,
2546
+ 0,
2547
+ ),
2548
+ );
2549
+ container.addChild(new Text("", 0, 0));
2550
+
2551
+ if (gitDiff.trim()) {
2552
+ container.addChild(
2553
+ new Text(theme.fg("muted", "\u2500\u2500\u2500 Diff Preview \u2500\u2500\u2500"), 1, 0),
2554
+ );
2555
+ container.addChild(new Text(theme.fg("toolOutput", previewLines), 1, 0));
2556
+ if (hasMoreLines) {
2557
+ container.addChild(
2558
+ new Text(theme.fg("dim", `... ${diffLines.length - 20} more lines`), 1, 0),
2559
+ );
2560
+ }
2561
+ container.addChild(new Text("", 0, 0));
2562
+ } else {
2563
+ container.addChild(
2564
+ new Text(theme.fg("dim", "(no changes detected)"), 1, 0),
2565
+ );
2566
+ }
2567
+
2568
+ selectList = new SelectList(items, 3, makeSelectListTheme(theme));
2569
+ selectList.onSelect = (item) => {
2570
+ if (item.value === "viewdiff") {
2571
+ currentView = "diff";
2572
+ tui.requestRender();
2573
+ } else if (item.value === "apply" || item.value === "discard") {
2574
+ done(item.value);
2575
+ }
2576
+ };
2577
+ selectList.onCancel = () => done(null);
2578
+
2579
+ container.addChild(selectList);
2580
+ container.addChild(new Text(theme.fg("dim", NAV_FOOTER), 1, 0));
2581
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
2582
+
2583
+ return container;
2584
+ };
2585
+
2586
+ const buildDiffView = () => {
2587
+ const container = new Container();
2588
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
2589
+ container.addChild(
2590
+ new Text(
2591
+ theme.fg("accent", theme.bold(`Full Diff — ${gitBranch}`)),
2592
+ 1,
2593
+ 0,
2594
+ ),
2595
+ );
2596
+ container.addChild(new Text("", 0, 0));
2597
+ container.addChild(new Text(theme.fg("toolOutput", gitDiff), 1, 0));
2598
+ container.addChild(new Text("", 0, 0));
2599
+ container.addChild(
2600
+ new Text(theme.fg("dim", "Press any key to return to menu"), 1, 0),
2601
+ );
2602
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
2603
+ return container;
2604
+ };
2605
+
2606
+ return {
2607
+ render: (w: number) => {
2608
+ if (currentView === "diff") {
2609
+ return buildDiffView().render(w);
2610
+ }
2611
+ return buildMenuView().render(w);
2612
+ },
2613
+ invalidate: () => {
2614
+ // No-op: rebuild on each render
2615
+ },
2616
+ handleInput: (data: string) => {
2617
+ if (currentView === "diff") {
2618
+ currentView = "menu";
2619
+ tui.requestRender();
2620
+ return;
2621
+ }
2622
+
2623
+ // Keyboard shortcuts
2624
+ if (data === "y" || data === "Y") {
2625
+ done("apply");
2626
+ return;
2627
+ }
2628
+ if (data === "d" || data === "D") {
2629
+ currentView = "diff";
2630
+ tui.requestRender();
2631
+ return;
2632
+ }
2633
+ if (data === "n" || data === "N") {
2634
+ done("discard");
2635
+ return;
2636
+ }
2637
+
2638
+ selectList?.handleInput(data);
2639
+ tui.requestRender();
2640
+ },
2641
+ };
2642
+ });
2643
+ }
2644
+
2645
+ // ---------------------------------------------------------------------------
2646
+ // P5: Diff rendering helpers
2647
+ // ---------------------------------------------------------------------------
2648
+
2649
+ /**
2650
+ * Render a collapsed one-line summary of changed files from a git diff.
2651
+ * Returns empty string if no diff or no files changed.
2652
+ */
2653
+ function renderCollapsedDiffSummary(
2654
+ gitDiff: string | undefined,
2655
+ theme: Theme,
2656
+ ): string {
2657
+ if (!gitDiff?.trim()) return "";
2658
+ const files = parseDiff(gitDiff);
2659
+ if (files.length === 0) return "";
2660
+
2661
+ const fileEntries = files
2662
+ .slice(0, COLLAPSED_DIFF_FILES_PREVIEW)
2663
+ .map((f) =>
2664
+ theme.fg("accent", `${f.path}`) + theme.fg("accent", ` (+${f.additions} -${f.deletions})`),
2665
+ );
2666
+
2667
+ let text = theme.fg("muted", "\u2500\u2500\u2500 Files: ") + fileEntries.join(theme.fg("muted", ", "));
2668
+
2669
+ if (files.length > COLLAPSED_DIFF_FILES_PREVIEW) {
2670
+ text += theme.fg("muted", ` +${files.length - COLLAPSED_DIFF_FILES_PREVIEW} more`);
2671
+ }
2672
+
2673
+ text += theme.fg("muted", " \u2500\u2500\u2500");
2674
+ return text;
2675
+ }
2676
+
2677
+ /**
2678
+ * Add a structured diff section to an expanded view Container.
2679
+ * Returns true if any file has truncated hunks (needs a "Press D" hint).
2680
+ */
2681
+ function addExpandedDiffSection(
2682
+ container: Container,
2683
+ gitDiff: string | undefined,
2684
+ theme: Theme,
2685
+ ): boolean {
2686
+ if (!gitDiff?.trim()) return false;
2687
+ const files = parseDiff(gitDiff);
2688
+ if (files.length === 0) return false;
2689
+
2690
+ let anyTruncated = false;
2691
+
2692
+ container.addChild(new Spacer(1));
2693
+ container.addChild(
2694
+ new Text(theme.fg("muted", `\u2500\u2500\u2500 Files changed (${files.length}) \u2500\u2500\u2500`), 0, 0),
2695
+ );
2696
+
2697
+ for (const file of files) {
2698
+ container.addChild(new Text("", 0, 0));
2699
+ container.addChild(
2700
+ new Text(
2701
+ theme.fg("accent", file.path) +
2702
+ theme.fg("dim", ` (+${file.additions} -${file.deletions})`),
2703
+ 0,
2704
+ 0,
2705
+ ),
2706
+ );
2707
+ container.addChild(new Text(theme.fg("muted", "\u2500".repeat(50)), 0, 0));
2708
+
2709
+ const hunksToShow = file.hunks.slice(0, EXPANDED_HUNKS_PER_FILE);
2710
+ for (const hunk of hunksToShow) {
2711
+ container.addChild(new Text(theme.fg("toolOutput", hunk), 0, 0));
2712
+ }
2713
+
2714
+ if (file.totalHunks > EXPANDED_HUNKS_PER_FILE) {
2715
+ anyTruncated = true;
2716
+ container.addChild(
2717
+ new Text(
2718
+ theme.fg(
2719
+ "dim",
2720
+ `(${file.totalHunks - EXPANDED_HUNKS_PER_FILE} more hunks \u2014 press D for full diff)`,
2721
+ ),
2722
+ 0,
2723
+ 0,
2724
+ ),
2725
+ );
2726
+ }
2727
+ }
2728
+
2729
+ if (anyTruncated) {
2730
+ container.addChild(new Text("", 0, 0));
2731
+ container.addChild(
2732
+ new Text(theme.fg("dim", "Press D to view full raw diff"), 0, 0),
2733
+ );
2734
+ }
2735
+
2736
+ return anyTruncated;
2737
+ }
2738
+
2739
+ /**
2740
+ * Wrap an expanded view Container in a Component that intercepts 'D'/'d' keys
2741
+ * to toggle a full raw diff view. Pressing any key in the full diff view
2742
+ * returns to the normal expanded view.
2743
+ */
2744
+ function withDiffKeybinding(
2745
+ container: Container,
2746
+ gitDiff: string | undefined,
2747
+ theme: Theme,
2748
+ ): Container | { render(w: number): string[]; handleInput(d: string): void; invalidate(): void } {
2749
+ if (!gitDiff?.trim()) return container;
2750
+
2751
+ let showFullDiff = false;
2752
+
2753
+ return {
2754
+ render(width: number) {
2755
+ if (showFullDiff) {
2756
+ const dc = new Container();
2757
+ dc.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
2758
+ dc.addChild(new Text(theme.fg("accent", theme.bold("Full Diff")), 1, 0));
2759
+ dc.addChild(new Text("", 0, 0));
2760
+ dc.addChild(new Text(theme.fg("toolOutput", gitDiff), 1, 0));
2761
+ dc.addChild(new Text("", 0, 0));
2762
+ dc.addChild(new Text(theme.fg("dim", "Press any key to return"), 1, 0));
2763
+ dc.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
2764
+ return dc.render(width);
2765
+ }
2766
+ return container.render(width);
2767
+ },
2768
+ handleInput(data: string) {
2769
+ if (showFullDiff) {
2770
+ showFullDiff = false;
2771
+ return;
2772
+ }
2773
+ if (data === "d" || data === "D") {
2774
+ showFullDiff = true;
2775
+ return;
2776
+ }
2777
+ // @ts-expect-error — Genuine SDK gap (issue #124): the SDK's Container class (pi-tui) has no
2778
+ // handleInput — only addChild/removeChild/clear/invalidate/render (verified in the installed
2779
+ // 0.84.2 type + runtime). The expanded-result container holds interactive children that could
2780
+ // consume keys; the SDK exposes no forwarding API, so this optional call is a runtime no-op.
2781
+ // Kept (not cast) so key-forwarding activates if the SDK ever grows Container.handleInput.
2782
+ container.handleInput?.(data);
2783
+ },
2784
+ invalidate() {
2785
+ container.invalidate();
2786
+ },
2787
+ };
2788
+ }
2789
+
2790
+ // ---------------------------------------------------------------------------
2791
+ // P10: Graph collapsed / expanded rendering
2792
+ // ---------------------------------------------------------------------------
2793
+
2794
+ /**
2795
+ * Render a collapsed one-line summary of a graph execution.
2796
+ * Shows wave count, per-wave status icons, and task labels.
2797
+ */
2798
+ function renderCollapsedGraph(
2799
+ details: GraphDetails,
2800
+ theme: Theme,
2801
+ ): string {
2802
+ const totalTasks = details.waves.reduce((s, w) => s + w.tasks.length, 0);
2803
+ const allSucceeded = details.waves.every((w) =>
2804
+ w.tasks.every(
2805
+ (r) =>
2806
+ r.exitCode === 0 &&
2807
+ r.stopReason !== "error" &&
2808
+ r.stopReason !== "aborted",
2809
+ ),
2810
+ );
2811
+ const icon = allSucceeded
2812
+ ? theme.fg("success", "\u2713")
2813
+ : theme.fg("error", "\u2717");
2814
+ let text = `${icon} ${theme.fg("toolTitle", theme.bold("graph"))} ${theme.fg("muted", `${totalTasks} tasks in ${details.waves.length} waves`)}`;
2815
+
2816
+ const maxWavesToShow = 3;
2817
+ const wavesToRender = details.waves.slice(0, maxWavesToShow);
2818
+
2819
+ for (const wave of wavesToRender) {
2820
+ const waveOk = wave.tasks.every(
2821
+ (r) =>
2822
+ r.exitCode === 0 &&
2823
+ r.stopReason !== "error" &&
2824
+ r.stopReason !== "aborted",
2825
+ );
2826
+ const waveIcon = waveOk
2827
+ ? theme.fg("success", "\u2713")
2828
+ : theme.fg("error", "\u2717");
2829
+ const modeLabel = wave.parallel ? "parallel" : "serial";
2830
+ const taskLabels = wave.tasks.map((r) => {
2831
+ const label = r.label || r.task.slice(0, 30);
2832
+ const taskIcon =
2833
+ r.exitCode === 0 && r.stopReason !== "error" && r.stopReason !== "aborted"
2834
+ ? theme.fg("success", "\u2713")
2835
+ : theme.fg("error", "\u2717");
2836
+ return `${taskIcon} ${theme.fg("dim", label)}`;
2837
+ });
2838
+ text += `
2839
+ ${waveIcon} Wave ${wave.waveIndex} (${theme.fg("muted", modeLabel)}): ${taskLabels.join(", ")}`;
2840
+ }
2841
+
2842
+ if (details.waves.length > maxWavesToShow) {
2843
+ text += `
2844
+ ${theme.fg("dim", `+${details.waves.length - maxWavesToShow} more waves`)}`;
2845
+ }
2846
+
2847
+ text += `
2848
+ ${theme.fg("muted", "(Ctrl+O to expand)")}`;
2849
+ return text;
2850
+ }
2851
+
2852
+ /**
2853
+ * Render an expanded view of a graph execution.
2854
+ * Full Container with per-wave per-task details and aggregated totals.
2855
+ */
2856
+ function renderExpandedGraph(
2857
+ details: GraphDetails,
2858
+ theme: Theme,
2859
+ mdTheme: ReturnType<typeof getMarkdownTheme>,
2860
+ ): Container {
2861
+ const container = new Container();
2862
+ const totalTasks = details.waves.reduce((s, w) => s + w.tasks.length, 0);
2863
+ container.addChild(
2864
+ new Text(
2865
+ theme.fg("accent", theme.bold(`Graph: ${totalTasks} tasks in ${details.waves.length} waves`)),
2866
+ 0,
2867
+ 0,
2868
+ ),
2869
+ );
2870
+ container.addChild(new Spacer(1));
2871
+
2872
+ for (const wave of details.waves) {
2873
+ const waveOk = wave.tasks.every(
2874
+ (r) =>
2875
+ r.exitCode === 0 &&
2876
+ r.stopReason !== "error" &&
2877
+ r.stopReason !== "aborted",
2878
+ );
2879
+ const waveIcon = waveOk
2880
+ ? theme.fg("success", "\u2713")
2881
+ : theme.fg("error", "\u2717");
2882
+ const modeLabel = wave.parallel ? "parallel" : "serial";
2883
+ container.addChild(
2884
+ new Text(
2885
+ `${waveIcon} ${theme.fg("toolTitle", theme.bold(`Wave ${wave.waveIndex}`))} ${theme.fg("muted", `(${modeLabel}, ${wave.tasks.length} task${wave.tasks.length > 1 ? "s" : ""})`)}`,
2886
+ 0,
2887
+ 0,
2888
+ ),
2889
+ );
2890
+
2891
+ for (const r of wave.tasks) {
2892
+ const isErr =
2893
+ r.exitCode !== 0 ||
2894
+ r.stopReason === "error" ||
2895
+ r.stopReason === "aborted";
2896
+ const statusIcon = isErr
2897
+ ? theme.fg("error", "\u2717")
2898
+ : theme.fg("success", "\u2713");
2899
+ const label = r.label ? `[${r.label}] ` : "";
2900
+
2901
+ container.addChild(
2902
+ new Text(
2903
+ `${statusIcon} ${theme.fg("toolTitle", theme.bold(label))}` +
2904
+ (r.model ? theme.fg("muted", ` (${r.model})`) : ""),
2905
+ 0,
2906
+ 0,
2907
+ ),
2908
+ );
2909
+ container.addChild(
2910
+ new Text(theme.fg("dim", `Task: ${r.task.slice(0, 200)}`), 0, 0),
2911
+ );
2912
+
2913
+ if (isErr && r.errorMessage) {
2914
+ container.addChild(
2915
+ new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0),
2916
+ );
2917
+ }
2918
+
2919
+ const output = getFinalOutput(r.messages);
2920
+ if (output) {
2921
+ container.addChild(
2922
+ new Text(theme.fg("muted", "\u2500\u2500\u2500 Output \u2500\u2500\u2500"), 0, 0),
2923
+ );
2924
+ container.addChild(new Markdown(output.trim(), 0, 0, mdTheme));
2925
+ }
2926
+
2927
+ addExpandedDiffSection(container, r.gitDiff, theme);
2928
+
2929
+ const usageStr = formatUsageStats(r.usage, r.model);
2930
+ if (usageStr) {
2931
+ container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
2932
+ }
2933
+ container.addChild(new Spacer(1));
2934
+ }
2935
+ }
2936
+
2937
+ // Aggregated totals
2938
+ const aggUsage: UsageStats = {
2939
+ input: details.totalInput,
2940
+ output: details.totalOutput,
2941
+ cacheRead: 0,
2942
+ cacheWrite: 0,
2943
+ cost: details.totalCost,
2944
+ contextTokens: 0,
2945
+ turns: details.totalTurns,
2946
+ };
2947
+ const aggStr = formatUsageStats(aggUsage);
2948
+ if (aggStr) {
2949
+ container.addChild(
2950
+ new Text(theme.fg("dim", `Totals: ${aggStr}`), 0, 0),
2951
+ );
2952
+ }
2953
+
2954
+ return container;
2955
+ }
2956
+
2957
+ // ---------------------------------------------------------------------------
2958
+ // Public rendering entry points (used by delegate_task tool)
2959
+ // ---------------------------------------------------------------------------
2960
+
2961
+ export function renderDelegateCall(
2962
+ args: {
2963
+ task?: string;
2964
+ label?: string;
2965
+ systemPrompt?: string;
2966
+ inheritSystemPrompt?: boolean;
2967
+ thinkingLevel?: string;
2968
+ outputFile?: string;
2969
+ timeout?: number;
2970
+ cwd?: string;
2971
+ tools?: string[];
2972
+ excludeTools?: string[];
2973
+ noBuiltinTools?: boolean;
2974
+ chain?: SubTaskParams[];
2975
+ tasks?: SubTaskParams[];
2976
+ graph?: GraphTask[];
2977
+ },
2978
+ theme: Theme,
2979
+ ): Text {
2980
+ // Graph mode
2981
+ if (args.graph && args.graph.length > 0) {
2982
+ const n = args.graph.length;
2983
+ let text = `${theme.fg("accent", "delegate_task")} ${theme.fg("muted", `graph (${n} task${n > 1 ? "s" : ""})`)}`;
2984
+ const preview = args.graph.slice(0, 3);
2985
+ for (let i = 0; i < preview.length; i++) {
2986
+ const t = preview[i];
2987
+ const label = t.label || t.id;
2988
+ const deps = t.dependsOn?.length ? theme.fg("dim", ` [depends on: ${t.dependsOn.join(", ")}]`) : "";
2989
+ text += `
2990
+ ${theme.fg("muted", `${i + 1}.`)} ${theme.fg("dim", label)}${deps}`;
2991
+ }
2992
+ if (n > 3) {
2993
+ text += `
2994
+ ${theme.fg("dim", `+${n - 3} more`)}`;
2995
+ }
2996
+ return new Text(text, 0, 0);
2997
+ }
2998
+
2999
+ // Chain mode
3000
+ if (args.chain && args.chain.length > 0) {
3001
+ const n = args.chain.length;
3002
+ let text = `${theme.fg("accent", "delegate_task")} ${theme.fg("muted", `chain (${n} step${n > 1 ? "s" : ""})`)}`;
3003
+ const preview = args.chain.slice(0, 3);
3004
+ for (let i = 0; i < preview.length; i++) {
3005
+ const s = preview[i];
3006
+ const label = s.label || s.task.slice(0, 60);
3007
+ text += `\n${theme.fg("muted", `${i + 1}.`)} ${theme.fg("dim", label)}`;
3008
+ }
3009
+ if (n > 3) {
3010
+ text += `\n${theme.fg("dim", `+${n - 3} more`)}`;
3011
+ }
3012
+ return new Text(text, 0, 0);
3013
+ }
3014
+
3015
+ // Parallel mode
3016
+ if (args.tasks && args.tasks.length > 0) {
3017
+ const n = args.tasks.length;
3018
+ let text = `${theme.fg("accent", "delegate_task")} ${theme.fg("muted", `parallel (${n} task${n > 1 ? "s" : ""})`)}`;
3019
+ const preview = args.tasks.slice(0, 3);
3020
+ for (let i = 0; i < preview.length; i++) {
3021
+ const t = preview[i];
3022
+ const label = t.label || t.task.slice(0, 60);
3023
+ text += `\n${theme.fg("muted", `${i + 1}.`)} ${theme.fg("dim", label)}`;
3024
+ }
3025
+ if (n > 3) {
3026
+ text += `\n${theme.fg("dim", `+${n - 3} more`)}`;
3027
+ }
3028
+ return new Text(text, 0, 0);
3029
+ }
3030
+
3031
+ // Single mode (original)
3032
+ const dl = buildDelegateLabel(args, theme);
3033
+ const scopeLabel = buildScopeLabel(args, theme);
3034
+ const nameLabel = args.label ? theme.fg("accent", `[${args.label}] `) : "";
3035
+ const preview = args.task
3036
+ ? args.task.length > TASK_PREVIEW_MAX_LENGTH
3037
+ ? `${args.task.slice(0, TASK_PREVIEW_MAX_LENGTH)}\u2026`
3038
+ : args.task
3039
+ : "\u2026";
3040
+ return new Text(dl + scopeLabel + nameLabel + theme.fg("dim", preview), 0, 0);
3041
+ }
3042
+
3043
+ export function renderDelegateResult(
3044
+ result: AgentToolResult<DelegateTaskDetails>,
3045
+ options: ToolRenderResultOptions,
3046
+ theme: Theme,
3047
+ ): Container | Text | ReturnType<typeof withDiffKeybinding> {
3048
+ const details = result.details;
3049
+
3050
+ // Check for graph mode
3051
+ if (details && isGraphDetails(details)) {
3052
+ const gd = details as GraphDetails;
3053
+ if (options.expanded) {
3054
+ return renderExpandedGraph(gd, theme, getMarkdownTheme());
3055
+ }
3056
+ return new Text(renderCollapsedGraph(gd, theme), 0, 0);
3057
+ }
3058
+
3059
+ // Check for multi-subagent (chain / parallel) mode
3060
+ if (details && isMultiSubagentDetails(details)) {
3061
+ if (details.mode === "chain") {
3062
+ const cd = details as ChainDetails;
3063
+ if (options.expanded) {
3064
+ return renderExpandedChain(cd, theme, getMarkdownTheme());
3065
+ }
3066
+ return new Text(renderCollapsedChain(cd, theme), 0, 0);
3067
+ } else {
3068
+ const pd = details as ParallelDetails;
3069
+ if (options.expanded) {
3070
+ return renderExpandedParallel(pd, theme, getMarkdownTheme());
3071
+ }
3072
+ return new Text(renderCollapsedParallel(pd, theme), 0, 0);
3073
+ }
3074
+ }
3075
+
3076
+ // Single subagent mode (original)
3077
+ if (!details || details.exitCode === -1) {
3078
+ const text = result.content[0];
3079
+ return new Text(text?.type === "text" ? text.text : "(running\u2026)", 0, 0);
3080
+ }
3081
+
3082
+ const isError = isSubagentError(details);
3083
+ const icon = isError ? theme.fg("error", "\u2717") : theme.fg("success", "\u2713");
3084
+ const finalOutput = getFinalOutput(details.messages);
3085
+
3086
+ if (options.expanded) {
3087
+ const container = renderExpandedResult(
3088
+ details,
3089
+ isError,
3090
+ icon,
3091
+ finalOutput,
3092
+ theme,
3093
+ getMarkdownTheme(),
3094
+ );
3095
+ // P5: Wrap with keybinding for full diff view
3096
+ return withDiffKeybinding(container, details.gitDiff, theme);
3097
+ }
3098
+ return new Text(
3099
+ renderCollapsedText(details, isError, icon, finalOutput, theme),
3100
+ 0,
3101
+ 0,
3102
+ );
3103
+ }