oira666_pi-subagent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "oira666_pi-subagent",
3
+ "version": "0.1.0",
4
+ "description": "Subagent extension for Pi coding agent. Delegate tasks to specialized agents.",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "files": [
8
+ "index.ts",
9
+ "agents.ts",
10
+ "runner.ts",
11
+ "render.ts",
12
+ "types.ts",
13
+ "agents/*.md",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "pi": {
18
+ "extensions": [
19
+ "./index.ts"
20
+ ]
21
+ },
22
+ "keywords": [
23
+ "pi",
24
+ "subagent",
25
+ "delegation",
26
+ "pi-package"
27
+ ],
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/gee666/pi-subagent.git"
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/gee666/pi-subagent/issues"
34
+ },
35
+ "homepage": "https://github.com/gee666/pi-subagent#readme",
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "license": "MIT",
40
+ "devDependencies": {
41
+ "@types/node": "^25.2.3",
42
+ "typescript": "^5.9.3"
43
+ },
44
+ "peerDependencies": {
45
+ "@mariozechner/pi-agent-core": ">=0.37.0",
46
+ "@mariozechner/pi-ai": ">=0.37.0",
47
+ "@mariozechner/pi-coding-agent": ">=0.37.0",
48
+ "@mariozechner/pi-tui": ">=0.37.0",
49
+ "@sinclair/typebox": ">=0.34.0"
50
+ },
51
+ "peerDependenciesMeta": {
52
+ "@mariozechner/pi-agent-core": {
53
+ "optional": true
54
+ },
55
+ "@mariozechner/pi-coding-agent": {
56
+ "optional": true
57
+ },
58
+ "@mariozechner/pi-tui": {
59
+ "optional": true
60
+ },
61
+ "@mariozechner/pi-ai": {
62
+ "optional": true
63
+ },
64
+ "@sinclair/typebox": {
65
+ "optional": true
66
+ }
67
+ }
68
+ }
package/render.ts ADDED
@@ -0,0 +1,595 @@
1
+ /**
2
+ * TUI rendering for subagent tool calls and results.
3
+ */
4
+
5
+ import * as os from "node:os";
6
+ import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";
7
+ import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
8
+ import {
9
+ type DelegationMode,
10
+ type DisplayItem,
11
+ type NestedSubagentResult,
12
+ type SingleResult,
13
+ type SubagentDetails,
14
+ type UsageStats,
15
+ DEFAULT_DELEGATION_MODE,
16
+ aggregateUsage,
17
+ getDisplayItems,
18
+ getFinalOutput,
19
+ getNestedSubagentResults,
20
+ isResultError,
21
+ } from "./types.js";
22
+
23
+ const COLLAPSED_LINE_COUNT = 10;
24
+ const COLLAPSED_PARALLEL_LINE_COUNT = 5;
25
+ const NESTED_PREVIEW_LINE_COUNT = 6;
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Formatting helpers
29
+ // ---------------------------------------------------------------------------
30
+
31
+ function formatTokens(count: number): string {
32
+ if (count < 1000) return count.toString();
33
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
34
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
35
+ return `${(count / 1000000).toFixed(1)}M`;
36
+ }
37
+
38
+ function formatUsage(usage: Partial<UsageStats>, model?: string): string {
39
+ const parts: string[] = [];
40
+ const totalTokens =
41
+ (usage.input || 0) +
42
+ (usage.output || 0) +
43
+ (usage.cacheRead || 0) +
44
+ (usage.cacheWrite || 0);
45
+ if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
46
+ if (totalTokens > 0) parts.push(`tokens:${formatTokens(totalTokens)}`);
47
+ if (usage.input) parts.push(`in:${formatTokens(usage.input)}`);
48
+ if (usage.output) parts.push(`out:${formatTokens(usage.output)}`);
49
+ if (usage.cacheRead) parts.push(`cacheR:${formatTokens(usage.cacheRead)}`);
50
+ if (usage.cacheWrite) parts.push(`cacheW:${formatTokens(usage.cacheWrite)}`);
51
+ if (usage.cost) parts.push(`cost:$${usage.cost.toFixed(4)}`);
52
+ if (usage.contextTokens && usage.contextTokens > 0) parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
53
+ if (model) parts.push(model);
54
+ return parts.join(" ");
55
+ }
56
+
57
+ function truncate(text: string, maxLen: number): string {
58
+ return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text;
59
+ }
60
+
61
+ function shortenPath(p: string): string {
62
+ const home = os.homedir();
63
+ return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
64
+ }
65
+
66
+ function normalizeDelegationMode(raw: unknown): DelegationMode {
67
+ return raw === "fork" ? "fork" : DEFAULT_DELEGATION_MODE;
68
+ }
69
+
70
+ type ThemeFg = (color: string, text: string) => string;
71
+
72
+ function firstNonEmptyLine(text: string): string {
73
+ for (const line of splitOutputLines(text)) {
74
+ if (line.trim()) return line.trim();
75
+ }
76
+ return "";
77
+ }
78
+
79
+ function formatToolCall(toolName: string, args: Record<string, unknown>, fg: ThemeFg): string {
80
+ const pathArg = (args.file_path || args.path || "...") as string;
81
+
82
+ switch (toolName) {
83
+ case "subagent": {
84
+ const mode = normalizeDelegationMode(args.mode);
85
+ const tasks = Array.isArray(args.tasks) ? args.tasks : [];
86
+ if (tasks.length > 1) {
87
+ const agents = tasks
88
+ .slice(0, 3)
89
+ .map((task) => typeof task?.agent === "string" ? task.agent : "...")
90
+ .join(", ");
91
+ const extra = tasks.length > 3 ? ` +${tasks.length - 3}` : "";
92
+ return fg("toolTitle", "subagent ") + fg("accent", `parallel (${tasks.length})`) + fg("muted", ` [${mode}]`) + fg("dim", agents ? ` ${agents}${extra}` : "");
93
+ }
94
+ const task = tasks[0] as { agent?: string; task?: string } | undefined;
95
+ return fg("toolTitle", "subagent ") + fg("accent", task?.agent || "...") + fg("muted", ` [${mode}]`) + fg("dim", task?.task ? ` ${truncate(task.task, 48)}` : "");
96
+ }
97
+ case "bash": {
98
+ const cmd = (args.command as string) || "...";
99
+ return fg("muted", "$ ") + fg("toolOutput", truncate(cmd, 60));
100
+ }
101
+ case "read": {
102
+ let text = fg("accent", shortenPath(pathArg));
103
+ const offset = args.offset as number | undefined;
104
+ const limit = args.limit as number | undefined;
105
+ if (offset !== undefined || limit !== undefined) {
106
+ const start = offset ?? 1;
107
+ const end = limit !== undefined ? start + limit - 1 : "";
108
+ text += fg("warning", `:${start}${end ? `-${end}` : ""}`);
109
+ }
110
+ return fg("muted", "read ") + text;
111
+ }
112
+ case "write": {
113
+ const lines = ((args.content || "") as string).split("\n").length;
114
+ let text = fg("muted", "write ") + fg("accent", shortenPath(pathArg));
115
+ if (lines > 1) text += fg("dim", ` (${lines} lines)`);
116
+ return text;
117
+ }
118
+ case "edit":
119
+ return fg("muted", "edit ") + fg("accent", shortenPath(pathArg));
120
+ case "ls":
121
+ return fg("muted", "ls ") + fg("accent", shortenPath((args.path || ".") as string));
122
+ case "find":
123
+ return fg("muted", "find ") + fg("accent", (args.pattern || "*") as string) + fg("dim", ` in ${shortenPath((args.path || ".") as string)}`);
124
+ case "grep":
125
+ return fg("muted", "grep ") + fg("accent", `/${(args.pattern || "") as string}/`) + fg("dim", ` in ${shortenPath((args.path || ".") as string)}`);
126
+ default:
127
+ return fg("accent", toolName) + fg("dim", ` ${truncate(JSON.stringify(args), 50)}`);
128
+ }
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Shared rendering building blocks
133
+ // ---------------------------------------------------------------------------
134
+
135
+ function splitOutputLines(text: string): string[] {
136
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
137
+ if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
138
+ return lines;
139
+ }
140
+
141
+ function countDisplayLines(items: DisplayItem[]): number {
142
+ let count = 0;
143
+ for (const item of items) {
144
+ count += item.type === "text" ? splitOutputLines(item.text).length : 1;
145
+ }
146
+ return count;
147
+ }
148
+
149
+ function renderDisplayItems(
150
+ items: DisplayItem[],
151
+ expanded: boolean,
152
+ theme: { fg: ThemeFg },
153
+ limit?: number,
154
+ ): string {
155
+ const lines: string[] = [];
156
+ for (const item of items) {
157
+ if (item.type === "text") {
158
+ for (const line of splitOutputLines(item.text)) {
159
+ lines.push(theme.fg("toolOutput", line));
160
+ }
161
+ } else {
162
+ lines.push(theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)));
163
+ }
164
+ }
165
+
166
+ const shouldTail = !expanded && typeof limit === "number";
167
+ const toShow = shouldTail ? lines.slice(-limit) : lines;
168
+ const skipped = shouldTail && lines.length > limit ? lines.length - limit : 0;
169
+
170
+ let text = "";
171
+ if (skipped > 0) text += theme.fg("muted", `... ${skipped} earlier lines\n`);
172
+ text += toShow.join("\n");
173
+ return text.trimEnd();
174
+ }
175
+
176
+ function statusIcon(r: SingleResult, theme: { fg: ThemeFg }): string {
177
+ if (r.exitCode === -1) return theme.fg("warning", "⏳");
178
+ return isResultError(r) ? theme.fg("error", "✗") : theme.fg("success", "✓");
179
+ }
180
+
181
+ function parallelStatus(details: SubagentDetails): {
182
+ icon: string;
183
+ status: string;
184
+ isRunning: boolean;
185
+ } {
186
+ const running = details.results.filter((r) => r.exitCode === -1).length;
187
+ const successCount = details.results.filter((r) => r.exitCode === 0).length;
188
+ const failCount = details.results.filter((r) => r.exitCode > 0).length;
189
+ const isRunning = running > 0;
190
+ const icon = isRunning ? "⏳" : failCount > 0 ? "◐" : "✓";
191
+ const status = isRunning
192
+ ? `${successCount + failCount}/${details.results.length} done, ${running} running`
193
+ : `${successCount}/${details.results.length} tasks`;
194
+ return { icon, status, isRunning };
195
+ }
196
+
197
+ function renderNestedDelegationLines(
198
+ items: NestedSubagentResult[],
199
+ theme: { fg: ThemeFg },
200
+ expanded: boolean,
201
+ depth = 0,
202
+ ): string[] {
203
+ const lines: string[] = [];
204
+ for (const item of items) {
205
+ lines.push(...renderNestedDetailsLines(item.details, theme, expanded, depth));
206
+ }
207
+ return lines;
208
+ }
209
+
210
+ function renderNestedDetailsLines(
211
+ details: SubagentDetails,
212
+ theme: { fg: ThemeFg },
213
+ expanded: boolean,
214
+ depth = 0,
215
+ ): string[] {
216
+ const indent = " ".repeat(depth);
217
+ if (details.mode === "single") {
218
+ const result = details.results[0];
219
+ if (!result) return [];
220
+
221
+ const lines: string[] = [];
222
+ let header = `${indent}${theme.fg("muted", "↳ ")}${theme.fg("toolTitle", "subagent ")}${theme.fg("accent", result.agent)} ${statusIcon(result, theme)}${theme.fg("muted", ` [${details.delegationMode}]`)}`;
223
+ const usage = formatUsage(result.usage, result.model);
224
+ if (usage) header += ` ${theme.fg("dim", usage)}`;
225
+ lines.push(header);
226
+
227
+ if (!expanded) return lines;
228
+
229
+ lines.push(`${indent} ${theme.fg("muted", "task: ")}${theme.fg("dim", truncate(result.task, 96))}`);
230
+ const nested = getNestedSubagentResults(result.messages);
231
+ if (nested.length > 0) {
232
+ lines.push(...renderNestedDelegationLines(nested, theme, true, depth + 1));
233
+ }
234
+ const finalLine = firstNonEmptyLine(getFinalOutput(result.messages));
235
+ if (finalLine) {
236
+ lines.push(`${indent} ${theme.fg("muted", "final: ")}${theme.fg("toolOutput", truncate(finalLine, 96))}`);
237
+ }
238
+ return lines;
239
+ }
240
+
241
+ const { icon, status } = parallelStatus(details);
242
+ const lines: string[] = [];
243
+ let header = `${indent}${theme.fg("muted", "↳ ")}${theme.fg("toolTitle", "parallel delegation ")}${theme.fg("accent", status)} ${theme.fg("muted", `[${details.delegationMode}]`)} ${theme.fg(icon === "✓" ? "success" : icon === "⏳" ? "warning" : "warning", icon)}`;
244
+ const totalUsage = formatUsage(aggregateUsage(details.results));
245
+ if (totalUsage) header += ` ${theme.fg("dim", totalUsage)}`;
246
+ lines.push(header);
247
+
248
+ for (const result of details.results) {
249
+ let line = `${indent} ${statusIcon(result, theme)} ${theme.fg("accent", result.agent)}`;
250
+ const usage = formatUsage(result.usage, result.model);
251
+ if (usage) line += ` ${theme.fg("dim", usage)}`;
252
+ lines.push(line);
253
+ if (expanded) {
254
+ lines.push(`${indent} ${theme.fg("muted", "task: ")}${theme.fg("dim", truncate(result.task, 88))}`);
255
+ const nested = getNestedSubagentResults(result.messages);
256
+ if (nested.length > 0) {
257
+ lines.push(...renderNestedDelegationLines(nested, theme, true, depth + 2));
258
+ }
259
+ const finalLine = firstNonEmptyLine(getFinalOutput(result.messages));
260
+ if (finalLine) {
261
+ lines.push(`${indent} ${theme.fg("muted", "final: ")}${theme.fg("toolOutput", truncate(finalLine, 88))}`);
262
+ }
263
+ }
264
+ }
265
+
266
+ return lines;
267
+ }
268
+
269
+ function nestedDelegationText(
270
+ messages: SingleResult["messages"],
271
+ theme: { fg: ThemeFg },
272
+ expanded: boolean,
273
+ ): string {
274
+ const nested = getNestedSubagentResults(messages);
275
+ if (nested.length === 0) return "";
276
+ const lines = renderNestedDelegationLines(nested, theme, expanded);
277
+ if (expanded) return lines.join("\n");
278
+ if (lines.length <= NESTED_PREVIEW_LINE_COUNT) return lines.join("\n");
279
+ return [
280
+ ...lines.slice(0, NESTED_PREVIEW_LINE_COUNT),
281
+ theme.fg("muted", `... ${lines.length - NESTED_PREVIEW_LINE_COUNT} more delegation lines`),
282
+ ].join("\n");
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // renderCall — shown while the tool is being invoked
287
+ // ---------------------------------------------------------------------------
288
+
289
+ export function renderCall(args: Record<string, any>, theme: { fg: ThemeFg; bold: (s: string) => string }): Text {
290
+ const delegationMode = normalizeDelegationMode(args.mode);
291
+ const modeBadge = theme.fg("muted", ` [${delegationMode}]`);
292
+ const tasks = Array.isArray(args.tasks) ? args.tasks : [];
293
+
294
+ if (tasks.length > 1) {
295
+ let text =
296
+ theme.fg("toolTitle", theme.bold("subagent ")) +
297
+ theme.fg("accent", `parallel (${tasks.length} tasks)`) +
298
+ modeBadge;
299
+ for (const t of tasks.slice(0, 3)) {
300
+ text += `\n ${theme.fg("accent", t.agent)}${theme.fg("dim", ` ${truncate(t.task, 40)}`)}`;
301
+ }
302
+ if (tasks.length > 3) text += `\n ${theme.fg("muted", `... +${tasks.length - 3} more`)}`;
303
+ return new Text(text, 0, 0);
304
+ }
305
+
306
+ const singleTask = tasks[0];
307
+ const agentName = singleTask?.agent || args.agent || "...";
308
+ const preview = singleTask?.task ? truncate(singleTask.task, 60) : args.task ? truncate(args.task, 60) : "...";
309
+ let text =
310
+ theme.fg("toolTitle", theme.bold("subagent ")) +
311
+ theme.fg("accent", agentName) +
312
+ modeBadge;
313
+ text += `\n ${theme.fg("dim", preview)}`;
314
+ return new Text(text, 0, 0);
315
+ }
316
+
317
+ // ---------------------------------------------------------------------------
318
+ // renderResult — shown after the tool completes
319
+ // ---------------------------------------------------------------------------
320
+
321
+ export function renderResult(
322
+ result: { content: Array<{ type: string; text?: string }>; details?: unknown },
323
+ expanded: boolean,
324
+ theme: { fg: ThemeFg; bold: (s: string) => string },
325
+ ): Container | Text {
326
+ const details = result.details as SubagentDetails | undefined;
327
+ if (!details || details.results.length === 0) {
328
+ const first = result.content[0];
329
+ return new Text(first?.type === "text" && first.text ? first.text : "(no output)", 0, 0);
330
+ }
331
+
332
+ const delegationMode = normalizeDelegationMode(
333
+ (details as Partial<SubagentDetails>).delegationMode,
334
+ );
335
+ if (details.mode === "single") {
336
+ return renderSingleResult(details.results[0], delegationMode, expanded, theme);
337
+ }
338
+ return renderParallelResult(details, delegationMode, expanded, theme);
339
+ }
340
+
341
+ // ---------------------------------------------------------------------------
342
+ // Single-mode result
343
+ // ---------------------------------------------------------------------------
344
+
345
+ function renderSingleResult(
346
+ r: SingleResult,
347
+ delegationMode: DelegationMode,
348
+ expanded: boolean,
349
+ theme: { fg: ThemeFg; bold: (s: string) => string },
350
+ ): Container | Text {
351
+ const error = isResultError(r);
352
+ const icon = statusIcon(r, theme);
353
+ const displayItems = getDisplayItems(r.messages);
354
+ const finalOutput = getFinalOutput(r.messages);
355
+ const nestedDelegations = nestedDelegationText(r.messages, theme, expanded);
356
+
357
+ if (expanded) {
358
+ return renderSingleExpanded(
359
+ r,
360
+ delegationMode,
361
+ icon,
362
+ error,
363
+ displayItems,
364
+ finalOutput,
365
+ nestedDelegations,
366
+ theme,
367
+ );
368
+ }
369
+ return renderSingleCollapsed(r, delegationMode, icon, error, displayItems, nestedDelegations, theme);
370
+ }
371
+
372
+ function renderSingleExpanded(
373
+ r: SingleResult,
374
+ delegationMode: DelegationMode,
375
+ icon: string,
376
+ error: boolean,
377
+ displayItems: DisplayItem[],
378
+ finalOutput: string,
379
+ nestedDelegations: string,
380
+ theme: { fg: ThemeFg; bold: (s: string) => string },
381
+ ): Container {
382
+ const mdTheme = getMarkdownTheme();
383
+ const container = new Container();
384
+
385
+ // Header
386
+ let header = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource}, ${delegationMode})`)}`;
387
+ if (error && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
388
+ container.addChild(new Text(header, 0, 0));
389
+ if (error && r.errorMessage) {
390
+ container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
391
+ }
392
+
393
+ // Task
394
+ container.addChild(new Spacer(1));
395
+ container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
396
+ container.addChild(new Text(theme.fg("dim", r.task), 0, 0));
397
+
398
+ if (nestedDelegations) {
399
+ container.addChild(new Spacer(1));
400
+ container.addChild(new Text(theme.fg("muted", "─── Delegation tree ───"), 0, 0));
401
+ container.addChild(new Text(nestedDelegations, 0, 0));
402
+ }
403
+
404
+ // Output
405
+ container.addChild(new Spacer(1));
406
+ container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0));
407
+ if (displayItems.length === 0 && !finalOutput) {
408
+ container.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
409
+ } else {
410
+ for (const item of displayItems) {
411
+ if (item.type === "toolCall") {
412
+ container.addChild(new Text(theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)), 0, 0));
413
+ }
414
+ }
415
+ if (finalOutput) {
416
+ container.addChild(new Spacer(1));
417
+ container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
418
+ }
419
+ }
420
+
421
+ // Usage
422
+ const usageStr = formatUsage(r.usage, r.model);
423
+ if (usageStr) {
424
+ container.addChild(new Spacer(1));
425
+ container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
426
+ }
427
+
428
+ return container;
429
+ }
430
+
431
+ function renderSingleCollapsed(
432
+ r: SingleResult,
433
+ delegationMode: DelegationMode,
434
+ icon: string,
435
+ error: boolean,
436
+ displayItems: DisplayItem[],
437
+ nestedDelegations: string,
438
+ theme: { fg: ThemeFg; bold: (s: string) => string },
439
+ ): Text {
440
+ let text = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource}, ${delegationMode})`)}`;
441
+ if (error && r.stopReason) text += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
442
+
443
+ if (error && r.errorMessage) {
444
+ text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
445
+ } else if (displayItems.length === 0) {
446
+ text += `\n${theme.fg("muted", "(no output)")}`;
447
+ } else {
448
+ text += `\n${renderDisplayItems(displayItems, false, theme, COLLAPSED_LINE_COUNT)}`;
449
+ if (countDisplayLines(displayItems) > COLLAPSED_LINE_COUNT) {
450
+ text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
451
+ }
452
+ }
453
+
454
+ if (nestedDelegations) {
455
+ text += `\n\n${theme.fg("muted", "Delegation tree:")}`;
456
+ text += `\n${nestedDelegations}`;
457
+ }
458
+
459
+ const usageStr = formatUsage(r.usage, r.model);
460
+ if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
461
+ return new Text(text, 0, 0);
462
+ }
463
+
464
+ // ---------------------------------------------------------------------------
465
+ // Parallel-mode result
466
+ // ---------------------------------------------------------------------------
467
+
468
+ function renderParallelResult(
469
+ details: SubagentDetails,
470
+ delegationMode: DelegationMode,
471
+ expanded: boolean,
472
+ theme: { fg: ThemeFg; bold: (s: string) => string },
473
+ ): Container | Text {
474
+ const running = details.results.filter((r) => r.exitCode === -1).length;
475
+ const successCount = details.results.filter((r) => r.exitCode === 0).length;
476
+ const failCount = details.results.filter((r) => r.exitCode > 0).length;
477
+ const isRunning = running > 0;
478
+
479
+ const icon = isRunning
480
+ ? theme.fg("warning", "⏳")
481
+ : failCount > 0
482
+ ? theme.fg("warning", "◐")
483
+ : theme.fg("success", "✓");
484
+
485
+ const status = isRunning
486
+ ? `${successCount + failCount}/${details.results.length} done, ${running} running`
487
+ : `${successCount}/${details.results.length} tasks`;
488
+
489
+ if (expanded && !isRunning) {
490
+ return renderParallelExpanded(details, delegationMode, icon, status, theme);
491
+ }
492
+ return renderParallelCollapsed(
493
+ details,
494
+ delegationMode,
495
+ icon,
496
+ status,
497
+ isRunning,
498
+ expanded,
499
+ theme,
500
+ );
501
+ }
502
+
503
+ function renderParallelExpanded(
504
+ details: SubagentDetails,
505
+ delegationMode: DelegationMode,
506
+ icon: string,
507
+ status: string,
508
+ theme: { fg: ThemeFg; bold: (s: string) => string },
509
+ ): Container {
510
+ const mdTheme = getMarkdownTheme();
511
+ const container = new Container();
512
+ container.addChild(
513
+ new Text(
514
+ `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}${theme.fg("muted", ` [${delegationMode}]`)}`,
515
+ 0,
516
+ 0,
517
+ ),
518
+ );
519
+
520
+ for (const r of details.results) {
521
+ const rIcon = statusIcon(r, theme);
522
+ const displayItems = getDisplayItems(r.messages);
523
+ const finalOutput = getFinalOutput(r.messages);
524
+ const nestedDelegations = nestedDelegationText(r.messages, theme, true);
525
+
526
+ container.addChild(new Spacer(1));
527
+ container.addChild(new Text(`${theme.fg("muted", "─── ")}${theme.fg("accent", r.agent)} ${rIcon}`, 0, 0));
528
+ container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0));
529
+
530
+ if (nestedDelegations) {
531
+ container.addChild(new Text(theme.fg("muted", "Delegation tree:"), 0, 0));
532
+ container.addChild(new Text(nestedDelegations, 0, 0));
533
+ }
534
+
535
+ for (const item of displayItems) {
536
+ if (item.type === "toolCall") {
537
+ container.addChild(new Text(theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)), 0, 0));
538
+ }
539
+ }
540
+
541
+ if (finalOutput) {
542
+ container.addChild(new Spacer(1));
543
+ container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
544
+ }
545
+
546
+ const taskUsage = formatUsage(r.usage, r.model);
547
+ if (taskUsage) container.addChild(new Text(theme.fg("dim", taskUsage), 0, 0));
548
+ }
549
+
550
+ const totalUsage = formatUsage(aggregateUsage(details.results));
551
+ if (totalUsage) {
552
+ container.addChild(new Spacer(1));
553
+ container.addChild(new Text(theme.fg("dim", `Total: ${totalUsage}`), 0, 0));
554
+ }
555
+
556
+ return container;
557
+ }
558
+
559
+ function renderParallelCollapsed(
560
+ details: SubagentDetails,
561
+ delegationMode: DelegationMode,
562
+ icon: string,
563
+ status: string,
564
+ isRunning: boolean,
565
+ expanded: boolean,
566
+ theme: { fg: ThemeFg; bold: (s: string) => string },
567
+ ): Text {
568
+ let text = `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}${theme.fg("muted", ` [${delegationMode}]`)}`;
569
+
570
+ for (const r of details.results) {
571
+ const rIcon = statusIcon(r, theme);
572
+ const displayItems = getDisplayItems(r.messages);
573
+ const nestedDelegations = nestedDelegationText(r.messages, theme, false);
574
+ text += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", r.agent)} ${rIcon}`;
575
+ if (displayItems.length === 0) {
576
+ text += `\n${theme.fg("muted", r.exitCode === -1 ? "(running...)" : "(no output)")}`;
577
+ } else {
578
+ text += `\n${renderDisplayItems(displayItems, false, theme, COLLAPSED_PARALLEL_LINE_COUNT)}`;
579
+ }
580
+ if (nestedDelegations) {
581
+ text += `\n${theme.fg("muted", "Delegation tree:")}`;
582
+ text += `\n${nestedDelegations}`;
583
+ }
584
+ const taskUsage = formatUsage(r.usage, r.model);
585
+ if (taskUsage) text += `\n${theme.fg("dim", taskUsage)}`;
586
+ }
587
+
588
+ if (!isRunning) {
589
+ const totalUsage = formatUsage(aggregateUsage(details.results));
590
+ if (totalUsage) text += `\n\n${theme.fg("dim", `Total: ${totalUsage}`)}`;
591
+ }
592
+ if (!expanded) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
593
+
594
+ return new Text(text, 0, 0);
595
+ }