@pi-spice/minimal-subagents 0.1.1 → 0.2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @pi-spice/minimal-subagents
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 349aa7f: Restyle the `spawn_agents` transcript display: a one-line call header, then one block per agent — status, duration, tool count, and the first line of the task as a stable identifier, with a third line for live activity while running. Call totals (tokens/cost) sit on a summary line after completion; the hint line is running-only. Expanded view shows final outputs only. Transcript lines are truncated by display width — long commands and CJK text no longer break the layout.
8
+
3
9
  ## 0.1.1
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -24,12 +24,12 @@ Quick test from this repo: `pi -e ./extensions/minimal-subagents`
24
24
  | `name` | — | `agent-<index>` |
25
25
 
26
26
  - **Failures don't cancel siblings** — every agent runs to completion; each result is a `### [name] completed/failed` section with the agent's final output (50 KB cap; full transcripts stay in the tool details). `isError` only when all fail.
27
- - **Live progress** — a one-line-per-agent scoreboard in the transcript (status, turns, latest activity); `alt+a` for the live detail panel, `Ctrl+O` after completion for the full archive.
27
+ - **Live progress** — a one-line call header (`spawn_agents (3 agents)`), then one block per agent: glyph + name + duration/tools, then the first line of the task (always — so agents stay distinguishable even when names are opaque). Running agents grow a third line with the latest tool call; failed agents put the error on the header. A dim summary line (multi-agent, finished) carries the call totals (wall-clock, tools, tokens, cost); `alt+a live details` is shown only while something is still running. `alt+a` opens the live detail panel; `Ctrl+O` after completion expands to each agent's final output.
28
28
  - **Abort** returns partial results — finished agents keep their output, the rest are marked `aborted`; the whole child process group is killed (`SIGTERM`, then `SIGKILL` after 5 s).
29
29
 
30
30
  ## Details panel (`alt+a`)
31
31
 
32
- - One tab per sub-agent (`←`/`→` or `1`-`8`; the tab bar compacts automatically on narrow panels), labeled with name and live status (⏳/✓/✗).
32
+ - One tab per sub-agent (`←`/`→` or `1`-`8`; the tab bar compacts automatically on narrow panels), labeled with name and live status (`✻`/`·`/`✓`/`✗`).
33
33
  - Each tab is the agent's full timeline: task, tool calls, tool-result previews (first 10 lines), assistant output rendered as markdown, usage. Thinking is not shown.
34
34
  - Terminal-style scrolling: `↑/↓`, `PgUp/PgDn`, `Home`/`g`, `End`/`G`, mouse wheel — pinned to the bottom while following new output, scrolling up pauses, `End` resumes. `alt+a` toggles (same key opens and closes); `Esc` also closes.
35
35
  - Pressing `alt+a` before any `spawn_agents` run shows pi's notify message above the input instead of opening an empty panel.
package/index.ts CHANGED
@@ -118,6 +118,7 @@ export default function (pi: ExtensionAPI) {
118
118
  name: params.agents[i].name ?? `agent-${i + 1}`,
119
119
  task: params.agents[i].task,
120
120
  exitCode: -1, // -1 = still running
121
+ startedAt: Date.now(),
121
122
  messages: [],
122
123
  stderr: "",
123
124
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
@@ -151,6 +152,7 @@ export default function (pi: ExtensionAPI) {
151
152
  const partial = allResults[index];
152
153
  partial.exitCode = 1;
153
154
  partial.stopReason = "aborted";
155
+ partial.endedAt = Date.now();
154
156
  return partial;
155
157
  };
156
158
  if (signal?.aborted) return abortPlaceholder();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-spice/minimal-subagents",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Create sub-agents dynamically and run them in parallel; single blocking tool, no orchestration, nesting prevented",
5
5
  "keywords": ["pi-package"],
6
6
  "license": "MIT",
package/panel.ts CHANGED
@@ -86,7 +86,8 @@ function isAgentRunning(result: SingleResult): boolean {
86
86
  }
87
87
 
88
88
  function statusIcon(result: SingleResult, theme: RenderTheme): string {
89
- if (isAgentRunning(result)) return theme.fg("warning", "⏳");
89
+ if (isAgentRunning(result))
90
+ return result.messages.length === 0 ? theme.fg("muted", "·") : theme.fg("warning", "✻");
90
91
  return isFailedResult(result) ? theme.fg("error", "✗") : theme.fg("success", "✓");
91
92
  }
92
93
 
package/render.ts CHANGED
@@ -11,7 +11,7 @@ import * as os from "node:os";
11
11
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
12
12
  import type { Message } from "@earendil-works/pi-ai";
13
13
  import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
14
- import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
14
+ import { Container, Markdown, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
15
15
  import {
16
16
  getFinalOutput,
17
17
  isFailedResult,
@@ -62,6 +62,22 @@ export function formatUsageStats(
62
62
  return parts.join(" ");
63
63
  }
64
64
 
65
+ /** Terminal width for transcript lines — the render hooks get no width from the host. */
66
+ function terminalColumns(): number {
67
+ return process.stdout.columns ?? 80;
68
+ }
69
+
70
+ /**
71
+ * Width-aware truncation for possibly ANSI-styled text: `truncateToWidth`
72
+ * measures display columns (CJK = 2, escapes = 0) and never slices an escape
73
+ * sequence or grapheme cluster in half. Hand-assembled transcript lines must
74
+ * go through this — never `String.slice`, which counts UTF-16 units and
75
+ * overflows on CJK text or styled strings.
76
+ */
77
+ function truncateVisual(text: string, maxCols: number): string {
78
+ return truncateToWidth(text, maxCols, "…");
79
+ }
80
+
65
81
  export function formatToolCall(
66
82
  toolName: string,
67
83
  args: Record<string, unknown>,
@@ -75,7 +91,7 @@ export function formatToolCall(
75
91
  switch (toolName) {
76
92
  case "bash": {
77
93
  const command = (args.command as string) || "...";
78
- const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
94
+ const preview = truncateVisual(command, 60);
79
95
  return themeFg("muted", "$ ") + themeFg("toolOutput", preview);
80
96
  }
81
97
  case "read": {
@@ -123,9 +139,11 @@ export function formatToolCall(
123
139
  );
124
140
  }
125
141
  default: {
126
- const argsStr = JSON.stringify(args);
127
- const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr;
128
- return themeFg("accent", toolName) + themeFg("dim", ` ${preview}`);
142
+ // Unknown tools: preview the first string argument (a path, pattern,
143
+ // command whatever it is) instead of dumping raw JSON.
144
+ const firstString = Object.values(args).find((v): v is string => typeof v === "string" && v.length > 0);
145
+ const preview = firstString ? ` ${truncateVisual(firstString, 50)}` : "";
146
+ return themeFg("accent", toolName) + themeFg("dim", preview);
129
147
  }
130
148
  }
131
149
  }
@@ -146,56 +164,168 @@ function getDisplayItems(messages: Message[]): DisplayItem[] {
146
164
  }
147
165
 
148
166
  export function renderSpawnCall(args: SpawnAgentsArgs, theme: RenderTheme): Text {
149
- if (args.agents?.length) {
150
- let text =
167
+ // One line only: agent names and task previews live in the result blocks
168
+ // (seeded the moment the tool starts), so repeating them here would just
169
+ // duplicate the list below.
170
+ const count = args.agents?.length;
171
+ if (count) {
172
+ const text =
151
173
  theme.fg("toolTitle", theme.bold("spawn_agents ")) +
152
- theme.fg("accent", `(${args.agents.length} agent${args.agents.length > 1 ? "s" : ""})`);
153
- for (const a of args.agents.slice(0, 3)) {
154
- const preview = a.task.length > 40 ? `${a.task.slice(0, 40)}...` : a.task;
155
- const model = a.model ? theme.fg("dim", ` [${a.model}]`) : "";
156
- text += `\n ${theme.fg("accent", a.name || "agent")}${model}${theme.fg("dim", ` ${preview}`)}`;
157
- }
158
- if (args.agents.length > 3) text += `\n ${theme.fg("muted", `... +${args.agents.length - 3} more`)}`;
174
+ theme.fg("accent", `(${count} agent${count > 1 ? "s" : ""})`);
159
175
  return new Text(text, 0, 0);
160
176
  }
161
177
  return new Text(theme.fg("toolTitle", theme.bold("spawn_agents")), 0, 0);
162
178
  }
163
179
 
164
- function truncatePlain(text: string, max: number): string {
165
- return text.length > max ? `${text.slice(0, max - 1)}…` : text;
166
- }
167
-
168
180
  function isAgentRunning(details: SubagentDetails): boolean {
169
181
  return details.results.some((r) => r.exitCode === -1);
170
182
  }
171
183
 
172
- /** One glanceable scoreboard line: icon, name, turns, latest activity. */
173
- function scoreboardLine(r: SingleResult, theme: RenderTheme): string {
174
- const icon =
175
- r.exitCode === -1 && r.messages.length === 0
176
- ? theme.fg("muted", "▢")
177
- : r.exitCode === -1
178
- ? theme.fg("warning", "")
179
- : isFailedResult(r)
180
- ? theme.fg("error", "✗")
181
- : theme.fg("success", "✓");
182
-
183
- let activity: string;
184
- if (r.exitCode === -1 && r.messages.length === 0) {
185
- activity = theme.fg("muted", "queued");
186
- } else if (isFailedResult(r)) {
187
- const reason = (r.errorMessage || r.stderr || r.stopReason || "error").split("\n")[0];
188
- activity = theme.fg("error", truncatePlain(reason, 60));
189
- } else {
190
- const items = getDisplayItems(r.messages);
191
- const last = items[items.length - 1];
192
- if (!last) activity = theme.fg("muted", r.exitCode === -1 ? "starting…" : "(no output)");
193
- else if (last.type === "toolCall") activity = formatToolCall(last.name, last.args, theme.fg.bind(theme));
194
- else activity = theme.fg("toolOutput", truncatePlain(last.text.split("\n")[0], 60));
184
+ // --- glyph system ------------------------------------------------------------
185
+ // Single-width glyphs only ( renders emoji-wide on many terminals and breaks
186
+ // column alignment): ✻ running · queued ✓ done ✗ failed.
187
+
188
+ function statusGlyph(r: SingleResult, theme: RenderTheme): string {
189
+ if (r.exitCode === -1) return r.messages.length === 0 ? theme.fg("muted", "·") : theme.fg("warning", "✻");
190
+ return isFailedResult(r) ? theme.fg("error", "") : theme.fg("success", "✓");
191
+ }
192
+
193
+ function toolUseCount(messages: Message[]): number {
194
+ let n = 0;
195
+ for (const msg of messages) {
196
+ if (msg.role !== "assistant") continue;
197
+ for (const part of msg.content as any[]) if (part.type === "toolCall") n++;
198
+ }
199
+ return n;
200
+ }
201
+
202
+ export function formatDuration(ms: number): string {
203
+ const s = Math.max(0, Math.round(ms / 1000));
204
+ if (s < 60) return `${s}s`;
205
+ const m = Math.floor(s / 60);
206
+ if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`;
207
+ return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`;
208
+ }
209
+
210
+ /** Agent wall time; while running, elapsed since start (recomputed each render). */
211
+ function agentDuration(r: SingleResult): number {
212
+ return Math.max(0, (r.endedAt ?? Date.now()) - r.startedAt);
213
+ }
214
+
215
+ /** Whole-call wall time: earliest start to latest end. */
216
+ function callDuration(details: SubagentDetails): number {
217
+ const start = Math.min(...details.results.map((r) => r.startedAt));
218
+ const end = Math.max(...details.results.map((r) => r.endedAt ?? Date.now()));
219
+ return Math.max(0, end - start);
220
+ }
221
+
222
+ function aggregateUsage(results: SingleResult[]) {
223
+ const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
224
+ for (const r of results) {
225
+ total.input += r.usage.input;
226
+ total.output += r.usage.output;
227
+ total.cacheRead += r.usage.cacheRead;
228
+ total.cacheWrite += r.usage.cacheWrite;
229
+ total.cost += r.usage.cost;
230
+ total.turns += r.usage.turns;
231
+ }
232
+ return total;
233
+ }
234
+
235
+ /** Duration + tool count + (once finished) tokens and cost — expanded headers. */
236
+ function headerStats(r: SingleResult): string {
237
+ const parts = [formatDuration(agentDuration(r))];
238
+ const tools = toolUseCount(r.messages);
239
+ if (tools) parts.push(`${tools} tool${tools > 1 ? "s" : ""}`);
240
+ if (r.exitCode !== -1) {
241
+ if (r.usage.output) parts.push(`↓${formatTokens(r.usage.output)}`);
242
+ if (r.usage.cost) parts.push(`$${r.usage.cost.toFixed(4)}`);
243
+ }
244
+ return parts.join(" · ");
245
+ }
246
+
247
+ /** Duration + tool count only — collapsed headers; tokens/cost live on the summary line. */
248
+ function collapsedStats(r: SingleResult): string {
249
+ const parts = [formatDuration(agentDuration(r))];
250
+ const tools = toolUseCount(r.messages);
251
+ if (tools) parts.push(`${tools} tool${tools > 1 ? "s" : ""}`);
252
+ return parts.join(" · ");
253
+ }
254
+
255
+ function failReason(r: SingleResult): string {
256
+ return (r.errorMessage || r.stderr || r.stopReason || "error").split("\n")[0];
257
+ }
258
+
259
+ function taskFirstLine(task: string): string {
260
+ return task.split("\n").find((l) => l.trim().length > 0) ?? "";
261
+ }
262
+
263
+ /** Latest tool call or assistant text; only meaningful while the agent is in flight. */
264
+ function runningActivity(r: SingleResult, theme: RenderTheme): string | null {
265
+ if (r.exitCode !== -1 || isQueued(r)) return null;
266
+ const items = getDisplayItems(r.messages);
267
+ const last = items[items.length - 1];
268
+ if (!last) return theme.fg("muted", "starting…");
269
+ if (last.type === "toolCall") return formatToolCall(last.name, last.args, theme.fg.bind(theme));
270
+ return theme.fg("toolOutput", last.text.split("\n")[0]);
271
+ }
272
+
273
+ function isQueued(r: SingleResult): boolean {
274
+ return r.exitCode === -1 && r.messages.length === 0;
275
+ }
276
+
277
+ /**
278
+ * The collapsed transcript block under the one-line call header
279
+ * (`spawn_agents (N agents)`). Every agent is a 2-line block — glyph + name
280
+ * + stats, then the first line of its task (the stable identifier, even
281
+ * when names are opaque). Running agents grow a third line with the latest
282
+ * tool call. Failed agents put the error on the header; tokens/cost live
283
+ * only on the call-total summary (multi-agent, finished). The hint line is
284
+ * running-only. The panel (alt+a) is the live timeline; this stays a summary.
285
+ */
286
+ function scoreboardView(details: SubagentDetails, theme: RenderTheme): Text {
287
+ const results = details.results;
288
+ const running = results.filter((r) => r.exitCode === -1).length;
289
+ const successCount = results.filter((r) => r.exitCode !== -1 && !isFailedResult(r)).length;
290
+ const failCount = results.length - running - successCount;
291
+ const isRunning = running > 0;
292
+
293
+ let summary = "";
294
+ if (!isRunning && results.length > 1) {
295
+ const parts = [`${successCount}/${results.length}`];
296
+ if (failCount > 0) parts.push(`${failCount} failed`);
297
+ parts.push(formatDuration(callDuration(details)));
298
+ const total = aggregateUsage(results);
299
+ const totalTools = results.reduce((n, r) => n + toolUseCount(r.messages), 0);
300
+ if (totalTools > 0) parts.push(`${totalTools} tools`);
301
+ if (total.output) parts.push(`↓${formatTokens(total.output)}`);
302
+ if (total.cost) parts.push(`$${total.cost.toFixed(4)}`);
303
+ summary = parts.join(" · ");
195
304
  }
196
305
 
197
- const turns = r.usage.turns > 0 ? theme.fg("dim", ` ${r.usage.turns}t`) : "";
198
- return ` ${icon} ${theme.fg("accent", r.name)}${turns} ${activity}`;
306
+ // Label models only when the call mixes them a uniform call would
307
+ // just repeat the parent's model on every block.
308
+ const models = new Set(results.map((r) => r.model).filter(Boolean));
309
+ const showModels = models.size > 1;
310
+ const cols = terminalColumns();
311
+
312
+ const lines: string[] = [];
313
+ for (const r of results) {
314
+ let header = `${statusGlyph(r, theme)} ${theme.fg("toolTitle", theme.bold(r.name))}`;
315
+ if (!isQueued(r)) header += ` ${theme.fg("dim", collapsedStats(r))}`;
316
+ if (showModels && r.model) header += ` ${theme.fg("dim", r.model.split("/").pop() ?? r.model)}`;
317
+ if (isFailedResult(r)) header += ` ${theme.fg("error", failReason(r))}`;
318
+ lines.push(truncateVisual(header, cols));
319
+
320
+ const task = taskFirstLine(r.task);
321
+ if (task) lines.push(` ${truncateVisual(theme.fg("dim", task), cols - 2)}`);
322
+
323
+ const activity = runningActivity(r, theme);
324
+ if (activity) lines.push(` ${truncateVisual(activity, cols - 2)}`);
325
+ }
326
+ if (summary) lines.push(theme.fg("dim", summary));
327
+ if (isRunning) lines.push(theme.fg("muted", "alt+a live details"));
328
+ return new Text(lines.join("\n"), 0, 0);
199
329
  }
200
330
 
201
331
  export function renderSpawnResult(
@@ -211,129 +341,42 @@ export function renderSpawnResult(
211
341
 
212
342
  const mdTheme = getMarkdownTheme();
213
343
 
214
- if (expanded && !isAgentRunning(details) && details.results.length === 1) {
215
- const r = details.results[0];
216
- const isError = isFailedResult(r);
217
- const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
218
- const displayItems = getDisplayItems(r.messages);
219
- const finalOutput = getFinalOutput(r.messages);
220
-
344
+ if (expanded && !isAgentRunning(details)) {
345
+ // Expanded (ctrl+o, after completion): final outputs only — one block
346
+ // per agent with a quantified header and the answer rendered as
347
+ // markdown. The per-tool timeline is the panel's job (alt+a); the
348
+ // transcript archive does not duplicate it.
221
349
  const container = new Container();
222
- let header = `${icon} ${theme.fg("toolTitle", theme.bold(r.name))}`;
223
- if (isError && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
224
- container.addChild(new Text(header, 0, 0));
225
- if (isError && r.errorMessage)
226
- container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
227
- container.addChild(new Spacer(1));
228
- container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
229
- container.addChild(new Text(theme.fg("dim", r.task), 0, 0));
230
- container.addChild(new Spacer(1));
231
- container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0));
232
- if (displayItems.length === 0 && !finalOutput) {
233
- container.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
234
- } else {
235
- for (const item of displayItems) {
236
- if (item.type === "toolCall")
237
- container.addChild(
238
- new Text(
239
- theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)),
240
- 0, 0,
241
- ),
242
- );
243
- }
244
- if (finalOutput) {
245
- container.addChild(new Spacer(1));
246
- container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
247
- }
248
- }
249
- const usageStr = formatUsageStats(r.usage, r.model);
250
- if (usageStr) {
251
- container.addChild(new Spacer(1));
252
- container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
253
- }
254
- return container;
255
- }
256
-
257
- const aggregateUsage = (results: SingleResult[]) => {
258
- const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
259
- for (const r of results) {
260
- total.input += r.usage.input;
261
- total.output += r.usage.output;
262
- total.cacheRead += r.usage.cacheRead;
263
- total.cacheWrite += r.usage.cacheWrite;
264
- total.cost += r.usage.cost;
265
- total.turns += r.usage.turns;
266
- }
267
- return total;
268
- };
269
-
270
- const running = details.results.filter((r) => r.exitCode === -1).length;
271
- const successCount = details.results.filter((r) => r.exitCode !== -1 && !isFailedResult(r)).length;
272
- const failCount = details.results.filter((r) => r.exitCode !== -1 && isFailedResult(r)).length;
273
- const isRunning = running > 0;
274
- const icon = isRunning
275
- ? theme.fg("warning", "⏳")
276
- : failCount > 0
277
- ? theme.fg("warning", "◐")
278
- : theme.fg("success", "✓");
279
- const status = isRunning
280
- ? `${successCount + failCount}/${details.results.length} done, ${running} running`
281
- : `${successCount}/${details.results.length} succeeded${failCount > 0 ? `, ${failCount} failed` : ""}`;
282
-
283
- if (expanded && !isRunning) {
284
- const container = new Container();
285
- container.addChild(
286
- new Text(`${icon} ${theme.fg("toolTitle", theme.bold("spawn_agents "))}${theme.fg("accent", status)}`, 0, 0),
287
- );
288
-
289
- for (const r of details.results) {
290
- const rIcon = isFailedResult(r) ? theme.fg("error", "✗") : theme.fg("success", "✓");
291
- const displayItems = getDisplayItems(r.messages);
292
- const finalOutput = getFinalOutput(r.messages);
293
-
294
- container.addChild(new Spacer(1));
295
- container.addChild(new Text(`${theme.fg("muted", "─── ") + theme.fg("accent", r.name)} ${rIcon}`, 0, 0));
350
+ details.results.forEach((r, i) => {
351
+ if (i > 0) container.addChild(new Spacer(1));
352
+ const isError = isFailedResult(r);
353
+ let header = `${statusGlyph(r, theme)} ${theme.fg("toolTitle", theme.bold(r.name))} ${theme.fg("dim", headerStats(r))}`;
354
+ if (isError && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
355
+ container.addChild(new Text(header, 0, 0));
356
+ if (isError && r.errorMessage)
357
+ container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
296
358
  container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0));
297
-
298
- // Show tool calls
299
- for (const item of displayItems) {
300
- if (item.type === "toolCall") {
301
- container.addChild(
302
- new Text(theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)), 0, 0),
303
- );
304
- }
305
- }
306
-
307
- // Show final output as markdown
308
- if (finalOutput) {
359
+ const finalOutput = getFinalOutput(r.messages);
360
+ if (finalOutput.trim()) {
309
361
  container.addChild(new Spacer(1));
310
362
  container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
363
+ } else {
364
+ container.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
311
365
  }
312
-
313
- const taskUsage = formatUsageStats(r.usage, r.model);
314
- if (taskUsage) container.addChild(new Text(theme.fg("dim", taskUsage), 0, 0));
315
- }
316
-
317
- const usageStr = formatUsageStats(aggregateUsage(details.results));
318
- if (usageStr) {
366
+ });
367
+ if (details.results.length > 1) {
368
+ // Same shape as the collapsed header, prefixed with Total.
369
+ const total = aggregateUsage(details.results);
370
+ const totalTools = toolUseCount(details.results.flatMap((r) => r.messages));
371
+ const headParts = [formatDuration(callDuration(details))];
372
+ if (totalTools > 0) headParts.push(`${totalTools} tools`);
373
+ if (total.output) headParts.push(`↓${formatTokens(total.output)}`);
374
+ if (total.cost) headParts.push(`$${total.cost.toFixed(4)}`);
319
375
  container.addChild(new Spacer(1));
320
- container.addChild(new Text(theme.fg("dim", `Total: ${usageStr}`), 0, 0));
376
+ container.addChild(new Text(theme.fg("dim", `Total: ${headParts.join(" · ")}`), 0, 0));
321
377
  }
322
378
  return container;
323
379
  }
324
380
 
325
- // --- scoreboard: one glanceable line per agent -----------------------
326
- // The panel (alt+a) is the live view; the collapsed transcript block is a
327
- // summary, not a competing log. Expanded (Ctrl+O, after completion) is the
328
- // archive.
329
- let text = `${icon} ${theme.fg("toolTitle", theme.bold("spawn_agents "))}${theme.fg("accent", status)}`;
330
- for (const r of details.results) text += `\n${scoreboardLine(r, theme)}`;
331
- if (!isRunning) {
332
- const usageStr = formatUsageStats(aggregateUsage(details.results));
333
- if (usageStr) text += `\n${theme.fg("dim", `Total: ${usageStr}`)}`;
334
- text += `\n${theme.fg("muted", "(alt+a · Ctrl+O)")}`;
335
- } else {
336
- text += `\n${theme.fg("muted", "(alt+a · live details)")}`;
337
- }
338
- return new Text(text, 0, 0);
381
+ return scoreboardView(details, theme);
339
382
  }
package/spawn.ts CHANGED
@@ -30,6 +30,9 @@ export interface SingleResult {
30
30
  task: string;
31
31
  /** -1 while the agent is still running */
32
32
  exitCode: number;
33
+ /** Wall-clock timestamps: call time to agent end (unset while running). */
34
+ startedAt: number;
35
+ endedAt?: number;
33
36
  messages: Message[];
34
37
  stderr: string;
35
38
  usage: UsageStats;
@@ -162,6 +165,7 @@ export async function runSpec(
162
165
  name: displayName,
163
166
  task: spec.task,
164
167
  exitCode: -1, // -1 = still running; real exit code set on close
168
+ startedAt: Date.now(),
165
169
  messages: [],
166
170
  stderr: "",
167
171
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
@@ -281,6 +285,7 @@ export async function runSpec(
281
285
  });
282
286
 
283
287
  currentResult.exitCode = exitCode;
288
+ currentResult.endedAt = Date.now();
284
289
  if (wasAborted) throw new Error("Subagent was aborted");
285
290
  return currentResult;
286
291
  }