@leing2021/super-pi 0.23.9 → 0.23.10

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.
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Spawn-based JSON runner for ce_subagent / ce_parallel_subagent.
3
+ *
4
+ * Spawns `pi --mode json --no-session ... -p <prompt>`, parses stdout
5
+ * JSON events in real-time, and triggers an onUpdate callback for
6
+ * live TUI progress.
7
+ *
8
+ * Key design decisions:
9
+ * - Per-process env via spawn options, NO global process.env mutation.
10
+ * - Injectable spawnFactory for testing.
11
+ * - AbortSignal support for Ctrl+C cancellation.
12
+ */
13
+
14
+ import { type ChildProcess, spawn } from "node:child_process"
15
+ import {
16
+ type SingleResult,
17
+ parseJsonEvent,
18
+ applyEventToResult,
19
+ makeInitialResult,
20
+ } from "./subagent-events"
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Types
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export interface SpawnInput {
27
+ cwd?: string
28
+ env?: Record<string, string | undefined>
29
+ stdio?: any
30
+ shell?: boolean
31
+ }
32
+
33
+ export type SpawnFactory = (
34
+ command: string,
35
+ args: string[],
36
+ options: SpawnInput,
37
+ ) => ChildProcessLike
38
+
39
+ export interface ChildProcessLike {
40
+ stdout: { on(event: string, cb: (data: Buffer) => void): void }
41
+ stderr: { on(event: string, cb: (data: Buffer) => void): void }
42
+ on(event: string, cb: (code: number) => void): void
43
+ on(event: string, cb: () => void): void
44
+ kill(signal?: string): void
45
+ readonly killed: boolean
46
+ }
47
+
48
+ export interface JsonRunnerOptions {
49
+ /** Override spawn for testing. Defaults to real child_process.spawn */
50
+ spawnFactory?: SpawnFactory
51
+ }
52
+
53
+ export interface JsonRunConfig {
54
+ prompt: string
55
+ agent: string
56
+ task: string
57
+ cwd: string
58
+ step?: number
59
+ extraFlags?: string[]
60
+ extraEnv?: Record<string, string>
61
+ signal?: AbortSignal
62
+ onUpdate?: (partial: SingleResult) => void
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Default spawn factory
67
+ // ---------------------------------------------------------------------------
68
+
69
+ const realSpawn: SpawnFactory = (
70
+ command: string,
71
+ args: string[],
72
+ options: SpawnInput,
73
+ ): ChildProcessLike => {
74
+ return spawn(command, args, {
75
+ cwd: options.cwd,
76
+ env: options.env as Record<string, string>,
77
+ stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
78
+ shell: options.shell ?? false,
79
+ }) as unknown as ChildProcessLike
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Runner
84
+ // ---------------------------------------------------------------------------
85
+
86
+ export function createJsonRunner(opts: JsonRunnerOptions = {}) {
87
+ const doSpawn = opts.spawnFactory ?? realSpawn
88
+
89
+ async function run(config: JsonRunConfig): Promise<SingleResult> {
90
+ const result = makeInitialResult(config.agent, config.task, config.step)
91
+
92
+ const args = buildArgs(config.prompt, config.extraFlags)
93
+ const env = buildEnv(config.extraEnv)
94
+
95
+ const emitUpdate = () => {
96
+ config.onUpdate?.(result)
97
+ }
98
+
99
+ let wasAborted = false
100
+
101
+ const exitCode = await new Promise<number>((resolve) => {
102
+ const proc = doSpawn("pi", args, {
103
+ cwd: config.cwd,
104
+ env,
105
+ })
106
+
107
+ let buffer = ""
108
+
109
+ const processLine = (line: string) => {
110
+ const event = parseJsonEvent(line)
111
+ if (!event) return
112
+ applyEventToResult(result, event)
113
+ emitUpdate()
114
+ }
115
+
116
+ proc.stdout.on("data", (data: Buffer) => {
117
+ buffer += data.toString()
118
+ const lines = buffer.split("\n")
119
+ buffer = lines.pop() || ""
120
+ for (const line of lines) processLine(line)
121
+ })
122
+
123
+ proc.stderr.on("data", (data: Buffer) => {
124
+ result.stderr += data.toString()
125
+ })
126
+
127
+ proc.on("close", (code: number) => {
128
+ if (buffer.trim()) processLine(buffer)
129
+ resolve(code ?? 0)
130
+ })
131
+
132
+ proc.on("error", () => {
133
+ resolve(1)
134
+ })
135
+
136
+ if (config.signal) {
137
+ const killProc = () => {
138
+ wasAborted = true
139
+ proc.kill("SIGTERM")
140
+ setTimeout(() => {
141
+ if (!proc.killed) proc.kill("SIGKILL")
142
+ }, 5000)
143
+ }
144
+ if (config.signal.aborted) killProc()
145
+ else config.signal.addEventListener("abort", killProc, { once: true })
146
+ }
147
+ })
148
+
149
+ result.exitCode = exitCode
150
+ if (wasAborted) throw new Error("Subagent was aborted")
151
+ return result
152
+ }
153
+
154
+ return { run }
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Helpers
159
+ // ---------------------------------------------------------------------------
160
+
161
+ function buildArgs(prompt: string, extraFlags?: string[]): string[] {
162
+ const args: string[] = ["--mode", "json", "--no-session"]
163
+ if (extraFlags && extraFlags.length > 0) {
164
+ args.push(...extraFlags)
165
+ }
166
+ args.push("-p", prompt)
167
+ return args
168
+ }
169
+
170
+ function buildEnv(extraEnv?: Record<string, string>): Record<string, string | undefined> {
171
+ return {
172
+ ...process.env,
173
+ ...(extraEnv ?? {}),
174
+ }
175
+ }
@@ -0,0 +1,453 @@
1
+ /**
2
+ * TUI renderers for ce_subagent and ce_parallel_subagent.
3
+ *
4
+ * Displays real-time status in pi tool frames:
5
+ * - Collapsed: status icon + agent name + recent tool calls + usage
6
+ * - Expanded (Ctrl+O): full tool calls + final output + per-task usage
7
+ *
8
+ * Tool call formatting (formatToolCall) adapted directly from pi official
9
+ * subagent example with super-pi skill semantic adaptations.
10
+ */
11
+
12
+ import * as os from "node:os"
13
+ import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"
14
+ import {
15
+ type SingleResult,
16
+ getFinalOutput,
17
+ getDisplayItems,
18
+ isFailedResult,
19
+ formatUsageStats,
20
+ type DisplayItem,
21
+ } from "./subagent-events"
22
+ import type { SubagentLiveDetails } from "./subagent"
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Constants
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const COLLAPSED_ITEM_COUNT = 10
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Tool call formatting (from pi official subagent example)
32
+ // ---------------------------------------------------------------------------
33
+
34
+ export function formatToolCall(
35
+ toolName: string,
36
+ args: Record<string, unknown>,
37
+ themeFg: (color: any, text: string) => string,
38
+ ): string {
39
+ const shortenPath = (p: string) => {
40
+ const home = os.homedir()
41
+ return p.startsWith(home) ? `~${p.slice(home.length)}` : p
42
+ }
43
+
44
+ switch (toolName) {
45
+ case "bash": {
46
+ const command = (args.command as string) || "..."
47
+ const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command
48
+ return themeFg("muted", "$ ") + themeFg("toolOutput", preview)
49
+ }
50
+ case "read": {
51
+ const rawPath = (args.file_path || args.path || "...") as string
52
+ const filePath = shortenPath(rawPath)
53
+ const offset = args.offset as number | undefined
54
+ const limit = args.limit as number | undefined
55
+ let text = themeFg("accent", filePath)
56
+ if (offset !== undefined || limit !== undefined) {
57
+ const startLine = offset ?? 1
58
+ const endLine = limit !== undefined ? startLine + limit - 1 : ""
59
+ text += themeFg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`)
60
+ }
61
+ return themeFg("muted", "read ") + text
62
+ }
63
+ case "write": {
64
+ const rawPath = (args.file_path || args.path || "...") as string
65
+ const filePath = shortenPath(rawPath)
66
+ const content = (args.content || "") as string
67
+ const lines = content.split("\n").length
68
+ let text = themeFg("muted", "write ") + themeFg("accent", filePath)
69
+ if (lines > 1) text += themeFg("dim", ` (${lines} lines)`)
70
+ return text
71
+ }
72
+ case "edit": {
73
+ const rawPath = (args.file_path || args.path || "...") as string
74
+ return themeFg("muted", "edit ") + themeFg("accent", shortenPath(rawPath))
75
+ }
76
+ case "ls": {
77
+ const rawPath = (args.path || ".") as string
78
+ return themeFg("muted", "ls ") + themeFg("accent", shortenPath(rawPath))
79
+ }
80
+ case "find": {
81
+ const pattern = (args.pattern || "*") as string
82
+ const rawPath = (args.path || ".") as string
83
+ return themeFg("muted", "find ") + themeFg("accent", pattern) + themeFg("dim", ` in ${shortenPath(rawPath)}`)
84
+ }
85
+ case "grep": {
86
+ const pattern = (args.pattern || "") as string
87
+ const rawPath = (args.path || ".") as string
88
+ return (
89
+ themeFg("muted", "grep ") +
90
+ themeFg("accent", `/${pattern}/`) +
91
+ themeFg("dim", ` in ${shortenPath(rawPath)}`)
92
+ )
93
+ }
94
+ default: {
95
+ const argsStr = JSON.stringify(args)
96
+ const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr
97
+ return themeFg("accent", toolName) + themeFg("dim", ` ${preview}`)
98
+ }
99
+ }
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Theme type
104
+ // ---------------------------------------------------------------------------
105
+
106
+ interface Theme {
107
+ fg: (color: any, text: string) => string
108
+ bold: (text: string) => string
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // renderCall
113
+ // ---------------------------------------------------------------------------
114
+
115
+ export function renderSubagentCall(
116
+ args: {
117
+ agent?: string
118
+ task?: string
119
+ chain?: Array<{ agent: string; task: string }>
120
+ tasks?: Array<{ agent: string; task: string }>
121
+ },
122
+ theme: Theme,
123
+ ): any {
124
+ if (args.chain && args.chain.length > 0) {
125
+ let text =
126
+ theme.fg("toolTitle", theme.bold("ce_subagent ")) +
127
+ theme.fg("accent", `chain (${args.chain.length} steps)`)
128
+ for (let i = 0; i < Math.min(args.chain.length, 3); i++) {
129
+ const step = args.chain[i]
130
+ const cleanTask = step.task.replace(/\{previous\}/g, "").trim()
131
+ const preview = cleanTask.length > 40 ? `${cleanTask.slice(0, 40)}...` : cleanTask
132
+ text +=
133
+ "\n " +
134
+ theme.fg("muted", `${i + 1}.`) +
135
+ " " +
136
+ theme.fg("accent", step.agent) +
137
+ theme.fg("dim", ` ${preview}`)
138
+ }
139
+ if (args.chain.length > 3) text += `\n ${theme.fg("muted", `... +${args.chain.length - 3} more`)}`
140
+ return new Text(text, 0, 0)
141
+ }
142
+
143
+ if (args.tasks && args.tasks.length > 0) {
144
+ let text =
145
+ theme.fg("toolTitle", theme.bold("ce_parallel_subagent ")) +
146
+ theme.fg("accent", `parallel (${args.tasks.length} tasks)`)
147
+ for (const t of args.tasks.slice(0, 3)) {
148
+ const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task
149
+ text += `\n ${theme.fg("accent", t.agent)}${theme.fg("dim", ` ${preview}`)}`
150
+ }
151
+ if (args.tasks.length > 3) text += `\n ${theme.fg("muted", `... +${args.tasks.length - 3} more`)}`
152
+ return new Text(text, 0, 0)
153
+ }
154
+
155
+ // Single mode
156
+ const agentName = args.agent || "..."
157
+ const preview = args.task ? (args.task.length > 60 ? `${args.task.slice(0, 60)}...` : args.task) : "..."
158
+ let text =
159
+ theme.fg("toolTitle", theme.bold("ce_subagent ")) +
160
+ theme.fg("accent", agentName)
161
+ text += `\n ${theme.fg("dim", preview)}`
162
+ return new Text(text, 0, 0)
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // renderResult
167
+ // ---------------------------------------------------------------------------
168
+
169
+ interface RenderContext {
170
+ expanded: boolean
171
+ }
172
+
173
+ export function renderSubagentResult(
174
+ details: SubagentLiveDetails,
175
+ context: RenderContext,
176
+ theme: Theme,
177
+ ): any {
178
+ if (!details || !details.results || details.results.length === 0) {
179
+ return new Text("(no output)", 0, 0)
180
+ }
181
+
182
+ const mdTheme = getMarkdownTheme()
183
+
184
+ const renderDisplayItems = (items: DisplayItem[], limit?: number) => {
185
+ const toShow = limit ? items.slice(-limit) : items
186
+ const skipped = limit && items.length > limit ? items.length - limit : 0
187
+ let text = ""
188
+ if (skipped > 0) text += theme.fg("muted", `... ${skipped} earlier items\n`)
189
+ for (const item of toShow) {
190
+ if (item.type === "text") {
191
+ const preview = context.expanded ? item.text : item.text.split("\n").slice(0, 3).join("\n")
192
+ text += `${theme.fg("toolOutput", preview)}\n`
193
+ } else {
194
+ text += `${theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`
195
+ }
196
+ }
197
+ return text.trimEnd()
198
+ }
199
+
200
+ // --- Single mode ---
201
+ if (details.mode === "single" && details.results.length === 1) {
202
+ return renderSingleResult(details.results[0], context, theme, mdTheme, renderDisplayItems)
203
+ }
204
+
205
+ // --- Chain mode ---
206
+ if (details.mode === "chain") {
207
+ return renderChainResult(details.results, context, theme, mdTheme, renderDisplayItems)
208
+ }
209
+
210
+ // --- Parallel mode ---
211
+ if (details.mode === "parallel") {
212
+ return renderParallelResult(details.results, context, theme, mdTheme, renderDisplayItems)
213
+ }
214
+
215
+ return new Text("(no output)", 0, 0)
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Single result renderer
220
+ // ---------------------------------------------------------------------------
221
+
222
+ function renderSingleResult(
223
+ r: SingleResult,
224
+ context: RenderContext,
225
+ theme: Theme,
226
+ mdTheme: any,
227
+ renderDisplayItems: (items: DisplayItem[], limit?: number) => string,
228
+ ): any {
229
+ const isError = isFailedResult(r)
230
+ const icon = isError ? theme.fg("error", "✗") : r.exitCode === -1 ? theme.fg("warning", "⏳") : theme.fg("success", "✓")
231
+ const displayItems = getDisplayItems(r.messages)
232
+ const finalOutput = getFinalOutput(r.messages)
233
+
234
+ if (context.expanded) {
235
+ const container = new Container()
236
+ let header = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}`
237
+ if (isError && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`
238
+ container.addChild(new Text(header, 0, 0))
239
+ if (isError && r.errorMessage)
240
+ container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0))
241
+ container.addChild(new Spacer(1))
242
+ container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0))
243
+ container.addChild(new Text(theme.fg("dim", r.task), 0, 0))
244
+ container.addChild(new Spacer(1))
245
+ container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0))
246
+ if (displayItems.length === 0 && !finalOutput) {
247
+ container.addChild(new Text(theme.fg("muted", r.exitCode === -1 ? "(running...)" : "(no output)"), 0, 0))
248
+ } else {
249
+ for (const item of displayItems) {
250
+ if (item.type === "toolCall")
251
+ container.addChild(
252
+ new Text(theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)), 0, 0),
253
+ )
254
+ }
255
+ if (finalOutput) {
256
+ container.addChild(new Spacer(1))
257
+ container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme))
258
+ }
259
+ }
260
+ const usageStr = formatUsageStats(r.usage, r.model)
261
+ if (usageStr) {
262
+ container.addChild(new Spacer(1))
263
+ container.addChild(new Text(theme.fg("dim", usageStr), 0, 0))
264
+ }
265
+ return container
266
+ }
267
+
268
+ // Collapsed
269
+ let text = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}`
270
+ if (isError && r.stopReason) text += ` ${theme.fg("error", `[${r.stopReason}]`)}`
271
+ if (isError && r.errorMessage) text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`
272
+ else if (r.exitCode === -1) text += `\n${theme.fg("muted", "(running...)")}`
273
+ else if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`
274
+ else {
275
+ text += `\n${renderDisplayItems(displayItems, COLLAPSED_ITEM_COUNT)}`
276
+ if (displayItems.length > COLLAPSED_ITEM_COUNT) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`
277
+ }
278
+ const usageStr = formatUsageStats(r.usage, r.model)
279
+ if (usageStr) text += `\n${theme.fg("dim", usageStr)}`
280
+ return new Text(text, 0, 0)
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // Chain result renderer
285
+ // ---------------------------------------------------------------------------
286
+
287
+ function aggregateUsage(results: SingleResult[]) {
288
+ const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 }
289
+ for (const r of results) {
290
+ total.input += r.usage.input
291
+ total.output += r.usage.output
292
+ total.cacheRead += r.usage.cacheRead
293
+ total.cacheWrite += r.usage.cacheWrite
294
+ total.cost += r.usage.cost
295
+ total.contextTokens += r.usage.contextTokens
296
+ total.turns += r.usage.turns
297
+ }
298
+ return total
299
+ }
300
+
301
+ function renderChainResult(
302
+ results: SingleResult[],
303
+ context: RenderContext,
304
+ theme: Theme,
305
+ mdTheme: any,
306
+ renderDisplayItems: (items: DisplayItem[], limit?: number) => string,
307
+ ): any {
308
+ const successCount = results.filter(r => r.exitCode === 0).length
309
+ const icon = successCount === results.length ? theme.fg("success", "✓") : theme.fg("error", "✗")
310
+
311
+ if (context.expanded) {
312
+ const container = new Container()
313
+ container.addChild(
314
+ new Text(
315
+ `${icon} ${theme.fg("toolTitle", theme.bold("chain "))}${theme.fg("accent", `${successCount}/${results.length} steps`)}`,
316
+ 0, 0,
317
+ ),
318
+ )
319
+ for (const r of results) {
320
+ const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗")
321
+ const displayItems = getDisplayItems(r.messages)
322
+ const finalOutput = getFinalOutput(r.messages)
323
+
324
+ container.addChild(new Spacer(1))
325
+ container.addChild(new Text(`${theme.fg("muted", `─── Step ${r.step ?? "?"}: `)}${theme.fg("accent", r.agent)} ${rIcon}`, 0, 0))
326
+ container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0))
327
+ for (const item of displayItems) {
328
+ if (item.type === "toolCall")
329
+ container.addChild(new Text(theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)), 0, 0))
330
+ }
331
+ if (finalOutput) {
332
+ container.addChild(new Spacer(1))
333
+ container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme))
334
+ }
335
+ const stepUsage = formatUsageStats(r.usage, r.model)
336
+ if (stepUsage) container.addChild(new Text(theme.fg("dim", stepUsage), 0, 0))
337
+ }
338
+ const usageStr = formatUsageStats(aggregateUsage(results))
339
+ if (usageStr) {
340
+ container.addChild(new Spacer(1))
341
+ container.addChild(new Text(theme.fg("dim", `Total: ${usageStr}`), 0, 0))
342
+ }
343
+ return container
344
+ }
345
+
346
+ // Collapsed
347
+ let text = `${icon} ${theme.fg("toolTitle", theme.bold("chain "))}${theme.fg("accent", `${successCount}/${results.length} steps`)}`
348
+ for (const r of results) {
349
+ const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗")
350
+ const displayItems = getDisplayItems(r.messages)
351
+ text += `\n\n${theme.fg("muted", `─── Step ${r.step ?? "?"}: `)}${theme.fg("accent", r.agent)} ${rIcon}`
352
+ if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`
353
+ else text += `\n${renderDisplayItems(displayItems, 5)}`
354
+ }
355
+ const usageStr = formatUsageStats(aggregateUsage(results))
356
+ if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`
357
+ text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`
358
+ return new Text(text, 0, 0)
359
+ }
360
+
361
+ // ---------------------------------------------------------------------------
362
+ // Parallel result renderer
363
+ // ---------------------------------------------------------------------------
364
+
365
+ function renderParallelResult(
366
+ results: SingleResult[],
367
+ context: RenderContext,
368
+ theme: Theme,
369
+ mdTheme: any,
370
+ renderDisplayItems: (items: DisplayItem[], limit?: number) => string,
371
+ ): any {
372
+ const running = results.filter(r => r.exitCode === -1).length
373
+ const successCount = results.filter(r => r.exitCode !== -1 && !isFailedResult(r)).length
374
+ const failCount = results.filter(r => r.exitCode !== -1 && isFailedResult(r)).length
375
+ const isRunning = running > 0
376
+ const icon = isRunning
377
+ ? theme.fg("warning", "⏳")
378
+ : failCount > 0
379
+ ? theme.fg("warning", "◐")
380
+ : theme.fg("success", "✓")
381
+ const status = isRunning
382
+ ? `${successCount + failCount}/${results.length} done, ${running} running`
383
+ : `${successCount}/${results.length} tasks`
384
+
385
+ if (context.expanded && !isRunning) {
386
+ const container = new Container()
387
+ container.addChild(new Text(`${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`, 0, 0))
388
+
389
+ for (const r of results) {
390
+ const rIcon = isFailedResult(r) ? theme.fg("error", "✗") : theme.fg("success", "✓")
391
+ const displayItems = getDisplayItems(r.messages)
392
+ const finalOutput = getFinalOutput(r.messages)
393
+
394
+ container.addChild(new Spacer(1))
395
+ container.addChild(new Text(`${theme.fg("muted", "─── ")}${theme.fg("accent", r.agent)} ${rIcon}`, 0, 0))
396
+ container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0))
397
+ for (const item of displayItems) {
398
+ if (item.type === "toolCall")
399
+ container.addChild(new Text(theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)), 0, 0))
400
+ }
401
+ if (finalOutput) {
402
+ container.addChild(new Spacer(1))
403
+ container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme))
404
+ }
405
+ const taskUsage = formatUsageStats(r.usage, r.model)
406
+ if (taskUsage) container.addChild(new Text(theme.fg("dim", taskUsage), 0, 0))
407
+ }
408
+
409
+ const usageStr = formatUsageStats(aggregateUsage(results))
410
+ if (usageStr) {
411
+ container.addChild(new Spacer(1))
412
+ container.addChild(new Text(theme.fg("dim", `Total: ${usageStr}`), 0, 0))
413
+ }
414
+ return container
415
+ }
416
+
417
+ // Collapsed (or still running)
418
+ let text = `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`
419
+ for (const r of results) {
420
+ const rIcon =
421
+ r.exitCode === -1
422
+ ? theme.fg("warning", "⏳")
423
+ : isFailedResult(r)
424
+ ? theme.fg("error", "✗")
425
+ : theme.fg("success", "✓")
426
+ const displayItems = getDisplayItems(r.messages)
427
+ text += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", r.agent)} ${rIcon}`
428
+ if (displayItems.length === 0)
429
+ text += `\n${theme.fg("muted", r.exitCode === -1 ? "(running...)" : "(no output)")}`
430
+ else text += `\n${renderDisplayItems(displayItems, 5)}`
431
+ }
432
+ if (!isRunning) {
433
+ const usageStr = formatUsageStats(aggregateUsage(results))
434
+ if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`
435
+ }
436
+ if (!context.expanded) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`
437
+ return new Text(text, 0, 0)
438
+ }
439
+
440
+ // ---------------------------------------------------------------------------
441
+ // Markdown theme helper
442
+ // ---------------------------------------------------------------------------
443
+
444
+ function getMarkdownTheme(): any {
445
+ // Return a simple theme for testing / default usage
446
+ // In production, pi provides getMarkdownTheme()
447
+ try {
448
+ const { getMarkdownTheme: gmt } = require("@mariozechner/pi-coding-agent")
449
+ return gmt()
450
+ } catch {
451
+ return undefined
452
+ }
453
+ }