@pi-archimedes/subagent 2.10.3 → 2.11.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.
Files changed (46) hide show
  1. package/README.md +1 -1
  2. package/package.json +10 -10
  3. package/src/agent-panel.mcp.test.ts +76 -0
  4. package/src/agent-panel.tools.test.ts +162 -0
  5. package/src/agent-panel.ts +37 -8
  6. package/src/agent-panel.width-audit.test.ts +258 -0
  7. package/src/agents-parse.test.ts +252 -0
  8. package/src/agents.ts +72 -5
  9. package/src/compact.test.ts +136 -57
  10. package/src/compact.ts +57 -37
  11. package/src/dispatch.test.ts +73 -1
  12. package/src/dispatch.ts +20 -2
  13. package/src/execute.test.ts +9 -1
  14. package/src/execute.ts +26 -29
  15. package/src/expanded.test.ts +61 -0
  16. package/src/expanded.ts +96 -80
  17. package/src/format.test.ts +71 -29
  18. package/src/format.ts +71 -28
  19. package/src/frontmatter-io.test.ts +62 -0
  20. package/src/frontmatter-io.ts +16 -2
  21. package/src/handlers.test.ts +21 -3
  22. package/src/handlers.ts +22 -3
  23. package/src/index.output-schema.test.ts +317 -0
  24. package/src/index.test.ts +486 -0
  25. package/src/index.ts +168 -8
  26. package/src/render.ts +46 -36
  27. package/src/row-block.test.ts +195 -0
  28. package/src/row-block.ts +186 -0
  29. package/src/run-outcome.test.ts +71 -0
  30. package/src/run-outcome.ts +84 -0
  31. package/src/spawn.test.ts +204 -19
  32. package/src/spawn.ts +49 -17
  33. package/src/stream.test.ts +215 -17
  34. package/src/stream.ts +47 -9
  35. package/src/thinking-validation.test.ts +96 -0
  36. package/src/thinking-validation.ts +78 -0
  37. package/src/tool-declarations.test.ts +58 -0
  38. package/src/tool-row.width-audit.test.ts +720 -0
  39. package/src/tool-schema.ts +85 -0
  40. package/src/tool-validation.test.ts +321 -0
  41. package/src/tool-validation.ts +262 -0
  42. package/src/types.ts +86 -0
  43. package/src/usage.test.ts +95 -0
  44. package/src/usage.ts +87 -0
  45. package/src/cost.test.ts +0 -62
  46. package/src/cost.ts +0 -8
package/src/dispatch.ts CHANGED
@@ -32,6 +32,7 @@
32
32
 
33
33
  import { dispatch, BridgeTransportError, BridgeCancelledError } from "@pi-archimedes/core/bridge";
34
34
  import { resolveModel } from "./spawn.js";
35
+ import { resolveThinkingLevel } from "./thinking-validation.js";
35
36
  import type { ExecuteOptions } from "./execute.js";
36
37
  import type { SubagentProgress, SubagentResult } from "./types.js";
37
38
 
@@ -101,7 +102,9 @@ export function dispatchViaBridge(
101
102
  task: options.task,
102
103
  systemPrompt: options.agentConfig?.systemPrompt?.trim() || null,
103
104
  model: model ?? null,
104
- thinking: options.agentConfig?.thinking ?? null,
105
+ // The same rule the fork path applies: a level pi's `--thinking` would warn
106
+ // about and drop is not handed to the runner either (`thinking-validation.ts`).
107
+ thinking: resolveThinkingLevel(options.agentConfig?.thinking).level ?? null,
105
108
  tools: options.agentConfig?.tools?.length ? options.agentConfig.tools : null,
106
109
  };
107
110
 
@@ -114,6 +117,11 @@ export function dispatchViaBridge(
114
117
  const progress: SubagentProgress = {
115
118
  agent: agentName,
116
119
  status: "completed",
120
+ // The Client ran the session and reported it finished; that is a
121
+ // completed run in this vocabulary. It has no stopReason to expose and
122
+ // no cancellation of its own to report — a local cancel settles below
123
+ // with BridgeCancelledError, which is a failure by contract.
124
+ outcome: "completed",
117
125
  task: options.task,
118
126
  currentTool: undefined,
119
127
  currentToolArgs: undefined,
@@ -134,6 +142,7 @@ export function dispatchViaBridge(
134
142
  agent: agentName,
135
143
  task: options.task,
136
144
  exitCode: 0,
145
+ outcome: "completed",
137
146
  usage: {
138
147
  input: res.metrics.inputTokens,
139
148
  output: res.metrics.outputTokens,
@@ -142,6 +151,9 @@ export function dispatchViaBridge(
142
151
  cost: res.metrics.cost,
143
152
  turns: 0,
144
153
  },
154
+ // The Client ran this as a session of its own and books its usage there;
155
+ // this session must not also report it. See `SubagentResult`.
156
+ usageAccountedElsewhere: true,
145
157
  model,
146
158
  finalOutput: res.output,
147
159
  error: undefined,
@@ -169,7 +181,13 @@ export function dispatchViaBridge(
169
181
  const isLocalCancel = err instanceof BridgeCancelledError;
170
182
  const errorMessage = isLocalCancel ? "cancelled" : err instanceof Error ? err.message : String(err);
171
183
  const durationMs = Date.now() - startTime;
172
- return buildFailedResult(agentName, options.task, errorMessage, model, durationMs);
184
+ // Same ownership rule as the success path above: this settled from a
185
+ // desktop-side session (or a cancel of one), so its usage is that
186
+ // session's to report even if it ended badly.
187
+ return {
188
+ ...buildFailedResult(agentName, options.task, errorMessage, model, durationMs),
189
+ usageAccountedElsewhere: true,
190
+ };
173
191
  },
174
192
  );
175
193
  }
@@ -43,7 +43,9 @@ function activateBridge(socketPath: string): void {
43
43
  process.env.PI_ARCHIMEDES_BRIDGE_SESSION = "test-desktop-session";
44
44
  process.env.PI_ARCHIMEDES_BRIDGE_SERVER_PID = "12345";
45
45
  const onSpy = vi.fn();
46
- registerBridge({ on: onSpy } as unknown as ExtensionAPI);
46
+ // The handle the relay subscribes on — inert here (these tests are about fork
47
+ // vs dispatch, not about the relay), but a real session always hands one over.
48
+ registerBridge({ on: onSpy, events: { emit: () => {}, on: () => () => {} } } as unknown as ExtensionAPI);
47
49
  const calls = onSpy.mock.calls as Array<[string, (e: unknown, ctx: unknown) => void]>;
48
50
  const startCall = calls.find((c) => c[0] === "session_start");
49
51
  expect(startCall).toBeTruthy();
@@ -75,6 +77,9 @@ function makeOptions(overrides: Partial<ExecuteOptions> = {}): ExecuteOptions {
75
77
  cwd: undefined,
76
78
  signal: undefined,
77
79
  onUpdate: undefined,
80
+ // These tests never fork, so the handle is inert — present because a real
81
+ // ExecuteOptions always carries one.
82
+ events: { emit: () => {}, on: () => () => {} },
78
83
  toolCallId: "tc-1",
79
84
  ...overrides,
80
85
  };
@@ -168,6 +173,9 @@ describe("executeSubagent (bridge branch)", () => {
168
173
  expect(result.error).toBe("cancelled");
169
174
  expect(result.progress!.status).toBe("failed");
170
175
  expect(result.progress!.error).toBe("cancelled");
176
+ // Bridge-mode bookkeeping: whatever the desktop did with the task, its
177
+ // session owns the usage — the parent must not fold it into its own totals.
178
+ expect(result.usageAccountedElsewhere).toBe(true);
171
179
  expect(spawnSpy).not.toHaveBeenCalled();
172
180
  // Exactly ONE update: the final-failure progress (no "dispatched"
173
181
  // placeholder — a cancelled task does not claim it was dispatched).
package/src/execute.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { getBridge } from "@pi-archimedes/core/bridge";
2
+ import type { EventBus } from "@earendil-works/pi-coding-agent";
2
3
  import { spawnSubagent, resolveModel } from "./spawn.js";
3
4
  import { dispatchViaBridge, buildFailedResult } from "./dispatch.js";
4
5
  import { streamEvents } from "./stream.js";
5
- import { emitCostUpdate } from "./cost.js";
6
6
  import type { AgentConfig } from "./agents.js";
7
7
  import type { SubagentProgress, SubagentResult } from "./types.js";
8
8
 
@@ -15,6 +15,10 @@ export interface ExecuteOptions {
15
15
  cwd: string | undefined;
16
16
  signal: AbortSignal | undefined;
17
17
  onUpdate: ((progress: SubagentProgress) => void) | undefined;
18
+ /** The parent's host handle (`pi.events`) — forwarded to the ask bridge and to
19
+ * the todo announcements in `streamEvents`. Neither is an extension, so
20
+ * neither can reach a handle of its own. */
21
+ events: EventBus;
18
22
  /** The subagent tool-call id (the tool's `_id`) — rides the dispatch frame
19
23
  * for Client correlation; `undefined` → the frame's `toolCallId` is absent.
20
24
  * In `executeParallel` all N tasks share the single subagent tool-call's id. */
@@ -49,7 +53,11 @@ export async function executeSubagent(options: ExecuteOptions): Promise<Subagent
49
53
  // so executeParallel's progress slot updates from the pending placeholder
50
54
  // to "failed" instead of a stale "Starting...".
51
55
  if (options.signal?.aborted) {
52
- const result = buildFailedResult(agentName, options.task, "cancelled", model, Date.now() - startTime);
56
+ const result = {
57
+ ...buildFailedResult(agentName, options.task, "cancelled", model, Date.now() - startTime),
58
+ // Bridge mode: any usage this run ever had belongs to a desktop session.
59
+ usageAccountedElsewhere: true,
60
+ };
53
61
  options.onUpdate?.(result.progress!);
54
62
  return result;
55
63
  }
@@ -81,23 +89,21 @@ export async function executeSubagent(options: ExecuteOptions): Promise<Subagent
81
89
  if (!("fallback" in outcome)) {
82
90
  // Bridge used (success or a response-carrying error, incl. the terminal
83
91
  // "cancelled" frame — the desktop is alive, its outcome is authoritative):
84
- // return it — NO fork fallback, and NO emitCostUpdate here — the PARENT
85
- // never pushes the subagent's usage on the PARENT's bus (the fork-delta
86
- // semantics: only the fork path reports a child's usage, source
87
- // `subagent:<name>`). The subagent process self-emits its own usage to
88
- // its OWN bridge (the core self-usage emitter,
89
- // `subagent-metrics-cost-push` Task 1) — a different bus, a different
90
- // session; no double-count.
92
+ // return it, with no cost report of its own. The PARENT never reports a
93
+ // child's usage for a run it did not perform: the subagent process emits
94
+ // its OWN usage to its OWN bridge (the core self-usage emitter), which is a
95
+ // different session's total. Every result `dispatchViaBridge` settles
96
+ // carries `usageAccountedElsewhere`, which is how the tool knows to leave
97
+ // it off the tool result — pi folds that `usage` into THIS session's
98
+ // totals, so copying it across would bill the parent twice. The fork path
99
+ // below does report, through the tool result's native `usage`.
91
100
  return outcome;
92
101
  }
93
102
  // { fallback: true } — unreachable bridge: fall through to the fork path.
94
103
  }
95
104
 
96
- // Track previously emitted values to only emit deltas
97
- let lastEmittedInput = 0;
98
- let lastEmittedOutput = 0;
99
- let lastEmittedCost = 0;
100
-
105
+ // Progress is passed straight through: cost is reported once, on the tool
106
+ // result's native `usage`, so there is no delta bookkeeping left to do here.
101
107
  try {
102
108
  const child = spawnSubagent({
103
109
  task: options.task,
@@ -106,26 +112,14 @@ export async function executeSubagent(options: ExecuteOptions): Promise<Subagent
106
112
  cwd: options.cwd,
107
113
  signal: options.signal,
108
114
  agent: options.agentConfig,
115
+ events: options.events,
109
116
  });
110
117
 
111
118
  const result = await streamEvents(child, {
112
119
  agent: agentName,
113
120
  task: options.task,
121
+ events: options.events,
114
122
  onProgress: (progress: SubagentProgress) => {
115
- // Emit only deltas to avoid double-counting in CostAccumulator
116
- const deltaInput = progress.inputTokens - lastEmittedInput;
117
- const deltaOutput = progress.outputTokens - lastEmittedOutput;
118
- const deltaCost = progress.cost - lastEmittedCost;
119
- if (deltaInput > 0 || deltaOutput > 0 || deltaCost > 0) {
120
- emitCostUpdate(agentName, {
121
- inputTokens: deltaInput,
122
- outputTokens: deltaOutput,
123
- cost: deltaCost,
124
- });
125
- lastEmittedInput = progress.inputTokens;
126
- lastEmittedOutput = progress.outputTokens;
127
- lastEmittedCost = progress.cost;
128
- }
129
123
  options.onUpdate?.(progress);
130
124
  },
131
125
  });
@@ -142,7 +136,8 @@ export async function executeSubagent(options: ExecuteOptions): Promise<Subagent
142
136
  // synthesize one so the parallel renderer stays aligned with results.
143
137
  {
144
138
  agent: agentName,
145
- status: result.exitCode === 0 ? "completed" : "failed",
139
+ status: result.outcome || result.exitCode === 0 ? "completed" : "failed",
140
+ ...(result.outcome ? { outcome: result.outcome } : {}),
146
141
  task: options.task,
147
142
  currentTool: undefined,
148
143
  currentToolArgs: undefined,
@@ -187,6 +182,7 @@ export async function executeParallel(options: {
187
182
  tasks: Array<{ agent: string | undefined; agentConfig: AgentConfig | undefined; task: string; model: string | undefined; activeModel: string | undefined; cwd: string | undefined; toolCallId?: string }>;
188
183
  signal: AbortSignal | undefined;
189
184
  onUpdate: ((progress: SubagentProgress[]) => void) | undefined;
185
+ events: EventBus;
190
186
  }): Promise<SubagentResult[]> {
191
187
  // Pre-fill one pending slot per task, keyed by task index (NOT agent name).
192
188
  // This keeps all N lines stacked from t=0 in stable task order, with no
@@ -218,6 +214,7 @@ export async function executeParallel(options: {
218
214
  executeSubagent({
219
215
  ...taskDef,
220
216
  signal: options.signal,
217
+ events: options.events,
221
218
  onUpdate: (progress: SubagentProgress) => {
222
219
  // Store latest progress in this task's stable slot (by index).
223
220
  latestProgress[index] = progress;
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildExpandedText } from "./expanded.js";
3
+ import type { RowSegment } from "./row-block.js";
4
+ import type { SubagentResult } from "./types.js";
5
+
6
+ // The expanded row is what the user reads after `ctrl+o`; the compact row is
7
+ // what they see while it runs. Both have to carry the *Run outcome* vocabulary
8
+ // (CONTEXT.md) rather than collapsing it into done/failed.
9
+
10
+ const theme = { fg: (token: string, text: string) => `[${token}]${text}`, bold: (t: string) => t };
11
+
12
+ /** The content these segments carry, ignoring how each is laid out at render time. */
13
+ const drawnText = (segments: RowSegment[]): string => segments.map((s) => s.text).join("\n");
14
+
15
+ function makeResult(overrides: Partial<SubagentResult> = {}): SubagentResult {
16
+ return {
17
+ agent: "reviewer",
18
+ task: "review the diff",
19
+ exitCode: 0,
20
+ usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: 0.01, turns: 1 },
21
+ model: "anthropic/claude-opus-4.5",
22
+ finalOutput: "the answer",
23
+ error: undefined,
24
+ progress: undefined,
25
+ progressSummary: { toolCount: 2, tokens: 15, durationMs: 1000 },
26
+ ...overrides,
27
+ };
28
+ }
29
+
30
+ describe("expanded view: run outcomes", () => {
31
+ it("still says Done for a completed run", () => {
32
+ expect(drawnText(buildExpandedText(makeResult({ outcome: "completed" }), undefined, theme))).toContain("✓ Done");
33
+ });
34
+
35
+ it("says Truncated — and not Failed — for a cut-off answer", () => {
36
+ const out = drawnText(buildExpandedText(
37
+ makeResult({ outcome: "truncated", exitCode: 1, finalOutput: "partial" }),
38
+ undefined,
39
+ theme,
40
+ ));
41
+ expect(out).toMatch(/truncated/i);
42
+ expect(out).not.toMatch(/Failed/);
43
+ });
44
+
45
+ it("says Aborted for a cancelled run", () => {
46
+ const out = drawnText(buildExpandedText(
47
+ makeResult({ outcome: "aborted", exitCode: 143, error: undefined }),
48
+ undefined,
49
+ theme,
50
+ ));
51
+ expect(out).toMatch(/aborted/i);
52
+ expect(out).not.toMatch(/Failed/);
53
+ });
54
+
55
+ it("keeps reporting a real error as an error", () => {
56
+ const failed = makeResult({ exitCode: 1, error: "401 unauthorized" });
57
+ delete failed.outcome; // a failed run claims no outcome
58
+ const out = drawnText(buildExpandedText(failed, undefined, theme));
59
+ expect(out).toContain("401 unauthorized");
60
+ });
61
+ });
package/src/expanded.ts CHANGED
@@ -1,10 +1,18 @@
1
- import { Text } from "@earendil-works/pi-tui";
2
- import type { SubagentDetails, SubagentProgress, SubagentResult, SubagentToolCall } from "./types.js";
3
- import { formatDuration, truncLine, buildStatsLine, buildAgentLabel } from "./format.js";
1
+ import type { RunOutcome, SubagentDetails, SubagentProgress, SubagentResult, SubagentToolCall } from "./types.js";
2
+ import { formatDuration, buildStatsLine, buildAgentLabel, buildOutcomeLine } from "./format.js";
3
+ import { block, row, type RowBlock, type RowSegment } from "./row-block.js";
4
4
  import { renderToolCallLine } from "@pi-archimedes/core/tool-render";
5
5
 
6
6
  type Theme = { fg: (token: string, text: string) => string; bold: (text: string) => string };
7
7
 
8
+ // The expanded view mixes two kinds of line and says which is which (see
9
+ // `row-block.ts`): a **row** (label, activity, outcome) stays one line and fills
10
+ // the frame; a **block** (the stats line, the task text, the tool-call history,
11
+ // the output) keeps wrapping, because the expanded view is where all of it is
12
+ // meant to be readable (ADR 0020). The stats line is a block and not a row
13
+ // because nothing else repeats its numbers: cut at the frame, its tail — tokens,
14
+ // duration, cost — would simply be gone on a narrow terminal.
15
+
8
16
  // ── Helpers ─────────────────────────────────────────────────────────────────
9
17
 
10
18
  // A completed tool call renders with a status glyph (✓ ok / ✗ error) and its
@@ -19,17 +27,31 @@ function formatToolCall(call: SubagentToolCall | string, theme: Theme): string {
19
27
  return renderToolCallLine(status, call.name, suffix, theme);
20
28
  }
21
29
 
30
+ // The bottom row of a finished view. The *Run outcome* wins over the done/failed
31
+ // pair: a truncated answer is not a failed run, and labelling it "✗ Failed" is
32
+ // how a useful answer gets thrown away by whoever reads it.
33
+ function outcomeRow(
34
+ data: { outcome?: RunOutcome | undefined; error: string | undefined },
35
+ theme: Theme,
36
+ fallback: { failed: string },
37
+ ): string {
38
+ const line = buildOutcomeLine({ outcome: data.outcome, error: data.error }, theme);
39
+ if (line) return line;
40
+ if (data.error) return theme.fg("error", "✗ " + data.error);
41
+ return theme.fg("error", "✗ " + fallback.failed);
42
+ }
43
+
22
44
  // ── Expanded completed result ───────────────────────────────────────────────
23
45
 
24
46
  export function buildExpandedText(
25
47
  result: SubagentResult,
26
48
  progress: SubagentProgress | undefined,
27
49
  theme: Theme,
28
- ): string {
29
- const lines: string[] = [];
50
+ ): RowSegment[] {
51
+ const lines: RowSegment[] = [];
30
52
 
31
- lines.push(buildAgentLabel(result.agent, result.task, theme));
32
- lines.push("");
53
+ lines.push(row(buildAgentLabel(result.agent, result.task, theme)));
54
+ lines.push(row(""));
33
55
 
34
56
  // Stats line (same as compact view)
35
57
  const modelName = progress?.model ?? result.model;
@@ -43,69 +65,73 @@ export function buildExpandedText(
43
65
  }, theme);
44
66
  const statsParts = [modelLabel, statsLine].filter(Boolean);
45
67
  if (statsParts.length > 0) {
46
- lines.push(statsParts.join(" "));
68
+ // A block, not a row: this is the only place the run's numbers appear, and
69
+ // the expanded view is where they are meant to be readable however narrow
70
+ // the frame is (ADR 0020).
71
+ lines.push(block(statsParts.join(" ")));
47
72
  }
48
73
 
49
74
  // Task
50
75
  if (result.task) {
51
- lines.push("");
52
- lines.push(theme.fg("dim", "Task: " + result.task));
76
+ lines.push(row(""));
77
+ lines.push(block(theme.fg("dim", "Task: " + result.task)));
53
78
  }
54
79
 
55
80
  // Tool calls history
56
81
  const toolCalls = progress?.toolCalls;
57
82
  if (toolCalls && toolCalls.length > 0) {
58
- lines.push("");
83
+ lines.push(row(""));
59
84
  for (const call of toolCalls) {
60
- lines.push(formatToolCall(call, theme));
85
+ lines.push(block(formatToolCall(call, theme)));
61
86
  }
62
87
  }
63
88
 
64
89
  // Output - show live streaming output or final output
65
90
  const outputText = progress?.output ?? result.finalOutput;
66
91
  if (outputText) {
67
- lines.push("");
68
- lines.push(outputText);
92
+ lines.push(row(""));
93
+ lines.push(block(outputText));
69
94
  }
70
95
 
71
- // Error
72
- if (result.error) {
73
- lines.push("");
74
- lines.push(theme.fg("error", "✗ " + result.error));
75
- } else if (result.exitCode === 0) {
76
- lines.push("");
77
- lines.push(theme.fg("success", "✓ Done"));
96
+ // Error / outcome at the bottom
97
+ if (result.error || result.outcome || result.exitCode === 0) {
98
+ lines.push(row(""));
99
+ if (result.outcome || result.error) {
100
+ lines.push(row(outcomeRow({ outcome: result.outcome, error: result.error }, theme, { failed: "Failed" })));
101
+ } else {
102
+ lines.push(row(theme.fg("success", "✓ Done")));
103
+ }
78
104
  } else {
79
- lines.push("");
80
- lines.push(theme.fg("error", "✗ Failed"));
105
+ lines.push(row(""));
106
+ lines.push(row(theme.fg("error", "✗ Failed")));
81
107
  }
82
108
 
83
- return lines.join("\n");
109
+ return lines;
84
110
  }
85
111
 
86
112
  export function renderExpanded(
87
- text: Text,
113
+ rows: RowBlock,
88
114
  result: SubagentResult,
89
115
  progress: SubagentProgress | undefined,
90
116
  theme: Theme,
91
- ): Text {
92
- text.setText(buildExpandedText(result, progress, theme));
93
- return text;
117
+ ): RowBlock {
118
+ rows.setSegments(buildExpandedText(result, progress, theme));
119
+ return rows;
94
120
  }
95
121
 
96
122
  // ── Expanded streaming progress ─────────────────────────────────────────────
97
123
 
98
124
  export function renderProgressExpanded(
99
- text: Text,
125
+ rows: RowBlock,
100
126
  progress: SubagentProgress,
101
127
  theme: Theme,
102
- ): Text {
103
- const lines: string[] = [];
128
+ ): RowBlock {
129
+ const lines: RowSegment[] = [];
104
130
 
105
- lines.push(buildAgentLabel(progress.agent, progress.task, theme));
106
- lines.push("");
131
+ lines.push(row(buildAgentLabel(progress.agent, progress.task, theme)));
132
+ lines.push(row(""));
107
133
 
108
- // Stats line (same as compact view)
134
+ // Stats line (same as compact view) — a block: see buildExpandedText.
109
135
  const modelLabel = progress.model ? theme.fg("accent", progress.model) : "";
110
136
  const statsLine = buildStatsLine({
111
137
  turns: undefined,
@@ -116,51 +142,46 @@ export function renderProgressExpanded(
116
142
  }, theme);
117
143
  const statsParts = [modelLabel, statsLine].filter(Boolean);
118
144
  if (statsParts.length > 0) {
119
- lines.push(statsParts.join(" "));
145
+ lines.push(block(statsParts.join(" ")));
120
146
  }
121
147
 
122
148
  // Task
123
149
  if (progress.task) {
124
- lines.push("");
125
- lines.push(theme.fg("dim", "Task: " + progress.task));
150
+ lines.push(row(""));
151
+ lines.push(block(theme.fg("dim", "Task: " + progress.task)));
126
152
  }
127
153
 
128
154
  // Tool calls history
129
155
  if (progress.toolCalls && progress.toolCalls.length > 0) {
130
- lines.push("");
156
+ lines.push(row(""));
131
157
  for (const call of progress.toolCalls) {
132
- lines.push(formatToolCall(call, theme));
158
+ lines.push(block(formatToolCall(call, theme)));
133
159
  }
134
160
  }
135
161
 
136
162
  // Activity (current tool, marked with the running glyph ▸)
137
163
  if (progress.currentTool) {
138
- lines.push("");
139
- const argsPreview = progress.currentToolArgs
140
- ? truncLine(progress.currentToolArgs, 60)
141
- : "";
164
+ lines.push(row(""));
165
+ const argsPreview = progress.currentToolArgs ?? "";
142
166
  const durationPart = progress.currentToolStartedAt
143
167
  ? " | " + formatDuration(Date.now() - progress.currentToolStartedAt)
144
168
  : "";
145
169
  const suffix = (argsPreview ? ": " + argsPreview : "") + durationPart;
146
- lines.push(renderToolCallLine("running", progress.currentTool, suffix, theme));
170
+ lines.push(row(renderToolCallLine("running", progress.currentTool, suffix, theme)));
147
171
  }
148
172
 
149
173
  // Status at bottom
150
- if (progress.status === "completed") {
151
- lines.push("");
152
- lines.push(theme.fg("success", "✓ Done"));
174
+ if (progress.status === "completed" || progress.outcome) {
175
+ lines.push(row(""));
176
+ const outcomeLine = buildOutcomeLine({ outcome: progress.outcome, error: progress.error }, theme);
177
+ lines.push(row(outcomeLine ?? theme.fg("success", "✓ Done")));
153
178
  } else if (progress.status === "failed") {
154
- lines.push("");
155
- if (progress.error) {
156
- lines.push(theme.fg("error", "✗ " + progress.error));
157
- } else {
158
- lines.push(theme.fg("error", "✗ Failed"));
159
- }
179
+ lines.push(row(""));
180
+ lines.push(row(outcomeRow({ outcome: undefined, error: progress.error }, theme, { failed: "Failed" })));
160
181
  }
161
182
 
162
- text.setText(lines.join("\n"));
163
- return text;
183
+ rows.setSegments(lines);
184
+ return rows;
164
185
  }
165
186
 
166
187
  // ── Expanded parallel streaming progress ────────────────────────────────────
@@ -168,13 +189,13 @@ export function renderProgressExpanded(
168
189
  export function buildProgressExpandedText(
169
190
  progress: SubagentProgress,
170
191
  theme: Theme,
171
- ): string {
172
- const lines: string[] = [];
192
+ ): RowSegment[] {
193
+ const lines: RowSegment[] = [];
173
194
 
174
- lines.push(buildAgentLabel(progress.agent, progress.task, theme));
175
- lines.push("");
195
+ lines.push(row(buildAgentLabel(progress.agent, progress.task, theme)));
196
+ lines.push(row(""));
176
197
 
177
- // Stats line (same as compact view)
198
+ // Stats line (same as compact view) — a block: see buildExpandedText.
178
199
  const modelLabel = progress.model ? theme.fg("accent", progress.model) : "";
179
200
  const statsLine = buildStatsLine({
180
201
  turns: undefined,
@@ -185,28 +206,26 @@ export function buildProgressExpandedText(
185
206
  }, theme);
186
207
  const statsParts = [modelLabel, statsLine].filter(Boolean);
187
208
  if (statsParts.length > 0) {
188
- lines.push(statsParts.join(" "));
209
+ lines.push(block(statsParts.join(" ")));
189
210
  }
190
211
 
191
212
  // Task
192
213
  if (progress.task) {
193
- lines.push("");
194
- lines.push(theme.fg("dim", "Task: " + progress.task));
214
+ lines.push(row(""));
215
+ lines.push(block(theme.fg("dim", "Task: " + progress.task)));
195
216
  }
196
217
 
197
218
  // Tool calls history
198
219
  if (progress.toolCalls && progress.toolCalls.length > 0) {
199
- lines.push("");
220
+ lines.push(row(""));
200
221
  for (const call of progress.toolCalls) {
201
- lines.push(formatToolCall(call, theme));
222
+ lines.push(block(formatToolCall(call, theme)));
202
223
  }
203
224
  }
204
225
 
205
226
  // Activity
206
227
  if (progress.currentTool) {
207
- const argsPreview = progress.currentToolArgs
208
- ? truncLine(progress.currentToolArgs, 60)
209
- : "";
228
+ const argsPreview = progress.currentToolArgs ?? "";
210
229
  const durationPart = progress.currentToolStartedAt
211
230
  ? " | " + formatDuration(Date.now() - progress.currentToolStartedAt)
212
231
  : "";
@@ -217,22 +236,19 @@ export function buildProgressExpandedText(
217
236
  if (durationPart) {
218
237
  line += theme.fg("dim", durationPart);
219
238
  }
220
- lines.push("");
221
- lines.push(line);
239
+ lines.push(row(""));
240
+ lines.push(row(line));
222
241
  }
223
242
 
224
243
  // Status at bottom
225
- if (progress.status === "completed") {
226
- lines.push("");
227
- lines.push(theme.fg("success", "✓ Done"));
244
+ if (progress.status === "completed" || progress.outcome) {
245
+ lines.push(row(""));
246
+ const outcomeLine = buildOutcomeLine({ outcome: progress.outcome, error: progress.error }, theme);
247
+ lines.push(row(outcomeLine ?? theme.fg("success", "✓ Done")));
228
248
  } else if (progress.status === "failed") {
229
- lines.push("");
230
- if (progress.error) {
231
- lines.push(theme.fg("error", "✗ " + progress.error));
232
- } else {
233
- lines.push(theme.fg("error", "✗ Failed"));
234
- }
249
+ lines.push(row(""));
250
+ lines.push(row(outcomeRow({ outcome: undefined, error: progress.error }, theme, { failed: "Failed" })));
235
251
  }
236
252
 
237
- return lines.join("\n");
253
+ return lines;
238
254
  }