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.
- package/LICENSE +21 -0
- package/README.md +257 -0
- package/package.json +49 -0
- package/presets/code-reviewer.md +37 -0
- package/presets/data-analyst.md +33 -0
- package/presets/debugger.md +36 -0
- package/presets/dev-agent.md +30 -0
- package/presets/rapid-prototyper.md +25 -0
- package/presets/refactorer.md +34 -0
- package/presets/security-auditor.md +39 -0
- package/presets/tech-writer.md +33 -0
- package/presets/test-engineer.md +34 -0
- package/src/concurrency.ts +186 -0
- package/src/diff.ts +102 -0
- package/src/event-bus.ts +86 -0
- package/src/git.ts +187 -0
- package/src/history.ts +177 -0
- package/src/index.ts +3824 -0
- package/src/logging.ts +135 -0
- package/src/messaging.ts +164 -0
- package/src/metrics.ts +221 -0
- package/src/model-availability.ts +73 -0
- package/src/notify-completion.ts +207 -0
- package/src/params.ts +225 -0
- package/src/preflight.ts +150 -0
- package/src/presets.ts +425 -0
- package/src/prompt.ts +169 -0
- package/src/reports.ts +325 -0
- package/src/router.ts +133 -0
- package/src/runner.ts +877 -0
- package/src/sanitize.ts +365 -0
- package/src/scheduler.ts +187 -0
- package/src/session-manager.ts +1065 -0
- package/src/state.ts +590 -0
- package/src/templates.ts +469 -0
- package/src/transcript-tail.ts +327 -0
- package/src/transcript.ts +119 -0
- package/src/tui-format.ts +51 -0
- package/src/tui.ts +3103 -0
- package/src/types.ts +803 -0
- package/src/unit-run.ts +269 -0
- package/src/update.ts +109 -0
- package/src/validate.ts +302 -0
- package/templates/analyze-data.md +9 -0
- package/templates/code-review.md +10 -0
- package/templates/debug-issue.md +9 -0
- package/templates/implement-feature.md +9 -0
- package/templates/prototype.md +9 -0
- package/templates/refactor.md +9 -0
- package/templates/security-audit.md +10 -0
- package/templates/write-docs.md +9 -0
- 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
|
+
}
|