@narumitw/pi-subagents 0.43.0 → 0.46.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.
@@ -0,0 +1,252 @@
1
+ import * as os from "node:os";
2
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
3
+ import {
4
+ keyHint,
5
+ type Theme,
6
+ type ThemeColor,
7
+ type ToolRenderResultOptions,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import { Text } from "@earendil-works/pi-tui";
10
+ import { boundedPrivateText, safeTerminalLine } from "./safe-text.js";
11
+
12
+ export const COLLAPSED_LIST_LIMIT = 5;
13
+ export const COLLAPSED_ANSWER_LINES = 3;
14
+
15
+ export interface ToolRendererContext<TArgs> {
16
+ args: TArgs;
17
+ isError: boolean;
18
+ }
19
+
20
+ export type RenderStatus =
21
+ | "starting"
22
+ | "running"
23
+ | "completed"
24
+ | "failed"
25
+ | "cancelled"
26
+ | "interrupted"
27
+ | "idle"
28
+ | "closed"
29
+ | "warning";
30
+
31
+ const STATUS_PRESENTATION: Record<
32
+ RenderStatus,
33
+ { icon: string; label: string; color: ThemeColor }
34
+ > = {
35
+ starting: { icon: "⏳", label: "Starting", color: "warning" },
36
+ running: { icon: "⏳", label: "Running", color: "warning" },
37
+ completed: { icon: "✓", label: "Completed", color: "success" },
38
+ failed: { icon: "✗", label: "Failed", color: "error" },
39
+ cancelled: { icon: "■", label: "Cancelled", color: "warning" },
40
+ interrupted: { icon: "■", label: "Interrupted", color: "warning" },
41
+ idle: { icon: "○", label: "Idle", color: "muted" },
42
+ closed: { icon: "✓", label: "Closed", color: "muted" },
43
+ warning: { icon: "◐", label: "Warning", color: "warning" },
44
+ };
45
+
46
+ export function recordValue(value: unknown): Record<string, unknown> | undefined {
47
+ return value !== null && typeof value === "object" && !Array.isArray(value)
48
+ ? (value as Record<string, unknown>)
49
+ : undefined;
50
+ }
51
+
52
+ export function recordList(value: unknown): Record<string, unknown>[] {
53
+ return Array.isArray(value)
54
+ ? value.flatMap((item) => {
55
+ const record = recordValue(item);
56
+ return record ? [record] : [];
57
+ })
58
+ : [];
59
+ }
60
+
61
+ export function stringValue(value: unknown, fallback = ""): string {
62
+ return typeof value === "string" ? value : fallback;
63
+ }
64
+
65
+ export function numberValue(value: unknown, fallback = 0): number {
66
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
67
+ }
68
+
69
+ export function booleanValue(value: unknown): boolean {
70
+ return value === true;
71
+ }
72
+
73
+ export function safeLine(value: unknown, fallback = "...", maxBytes = 2 * 1024): string {
74
+ if (typeof value !== "string" || !value.trim()) return fallback;
75
+ return safeTerminalLine(value, maxBytes) || fallback;
76
+ }
77
+
78
+ export function safeBlock(value: unknown, fallback = "", maxBytes = 50 * 1024): string {
79
+ if (typeof value !== "string" || !value) return fallback;
80
+ return boundedPrivateText(value, maxBytes);
81
+ }
82
+
83
+ export function previewLines(value: unknown, maxLines = COLLAPSED_ANSWER_LINES): string {
84
+ const text = safeBlock(value, "", 8 * 1024).trim();
85
+ return text.split("\n").slice(0, maxLines).join("\n");
86
+ }
87
+
88
+ export function textResult(result: AgentToolResult<unknown>): string {
89
+ return result.content
90
+ .flatMap((part) => (part.type === "text" ? [part.text] : []))
91
+ .join("\n")
92
+ .trim();
93
+ }
94
+
95
+ export function toolHeader(
96
+ theme: Theme,
97
+ toolName: string,
98
+ primary?: unknown,
99
+ metadata: readonly string[] = [],
100
+ ): string {
101
+ let text = theme.fg("toolTitle", theme.bold(`${toolName} `));
102
+ if (primary !== undefined) text += theme.fg("accent", safeLine(primary));
103
+ const safeMetadata = metadata.filter(Boolean).map((item) => safeLine(item, "", 512));
104
+ if (safeMetadata.length > 0) text += theme.fg("muted", ` · ${safeMetadata.join(" · ")}`);
105
+ return text.trimEnd();
106
+ }
107
+
108
+ export function statusBadge(theme: Theme, status: RenderStatus, suffix?: string): string {
109
+ const presentation = STATUS_PRESENTATION[status];
110
+ const label = suffix
111
+ ? `${presentation.label} · ${safeLine(suffix, "", 2 * 1024)}`
112
+ : presentation.label;
113
+ return `${theme.fg(presentation.color, presentation.icon)} ${theme.fg(presentation.color, label)}`;
114
+ }
115
+
116
+ export function renderFallbackResult(
117
+ result: AgentToolResult<unknown>,
118
+ options: ToolRenderResultOptions,
119
+ theme: Theme,
120
+ isError = false,
121
+ ) {
122
+ const status: RenderStatus = isError ? "failed" : options.isPartial ? "running" : "completed";
123
+ const content = safeBlock(textResult(result), "(no output)", 8 * 1024);
124
+ return new Text(`${statusBadge(theme, status)}\n${theme.fg("toolOutput", content)}`, 0, 0);
125
+ }
126
+
127
+ export function expansionHint(): string {
128
+ return keyHint("app.tools.expand", "to expand");
129
+ }
130
+
131
+ export interface RenderActivityItem {
132
+ type: "text" | "toolCall";
133
+ text?: string;
134
+ name?: string;
135
+ args?: Record<string, unknown>;
136
+ }
137
+
138
+ export function projectRenderActivity(value: unknown): RenderActivityItem[] {
139
+ if (!Array.isArray(value)) return [];
140
+ const items: RenderActivityItem[] = [];
141
+ for (const item of value) {
142
+ const record = recordValue(item);
143
+ if (!record) continue;
144
+ if (record.type === "text" && typeof record.text === "string") {
145
+ items.push({ type: "text", text: safeBlock(record.text, "", 1024) });
146
+ continue;
147
+ }
148
+ if (record.type === "toolCall" && typeof record.name === "string") {
149
+ items.push({
150
+ type: "toolCall",
151
+ name: safeLine(record.name, "tool", 256),
152
+ args: recordValue(record.args) ?? {},
153
+ });
154
+ }
155
+ }
156
+ return items;
157
+ }
158
+
159
+ export function renderActivityLines(
160
+ items: readonly RenderActivityItem[],
161
+ theme: Theme,
162
+ limit?: number,
163
+ total = items.length,
164
+ ): string {
165
+ const selected = limit === undefined ? items : items.slice(-limit);
166
+ const lines: string[] = [];
167
+ const skipped = Math.max(0, total - selected.length);
168
+ if (skipped > 0) lines.push(theme.fg("muted", `… ${skipped} earlier activities`));
169
+ for (const item of selected) {
170
+ if (item.type === "text") {
171
+ const text = safeBlock(item.text, "", 1024).trim();
172
+ if (text) lines.push(theme.fg("toolOutput", text));
173
+ } else {
174
+ lines.push(
175
+ theme.fg("muted", "→ ") +
176
+ formatToolActivity(item.name ?? "tool", item.args ?? {}, theme.fg.bind(theme)),
177
+ );
178
+ }
179
+ }
180
+ return lines.join("\n");
181
+ }
182
+
183
+ export function formatToolActivity(
184
+ toolNameValue: unknown,
185
+ argsValue: unknown,
186
+ themeFg: (color: ThemeColor, text: string) => string,
187
+ ): string {
188
+ const toolName = safeLine(toolNameValue, "tool", 256);
189
+ const args = recordValue(argsValue) ?? {};
190
+ const shortenPath = (value: unknown, fallback = ".") => {
191
+ const filePath = safeLine(value, fallback, 2 * 1024);
192
+ const home = os.homedir();
193
+ return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath;
194
+ };
195
+
196
+ switch (toolName) {
197
+ case "bash": {
198
+ const command = safeLine(args.command, "...", 512);
199
+ return themeFg("muted", "$ ") + themeFg("toolOutput", command);
200
+ }
201
+ case "read": {
202
+ const filePath = shortenPath(args.file_path ?? args.path, "...");
203
+ const offset = typeof args.offset === "number" ? args.offset : undefined;
204
+ const limit = typeof args.limit === "number" ? args.limit : undefined;
205
+ let text = themeFg("accent", filePath);
206
+ if (offset !== undefined || limit !== undefined) {
207
+ const startLine = offset ?? 1;
208
+ const endLine = limit !== undefined ? startLine + limit - 1 : "";
209
+ text += themeFg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
210
+ }
211
+ return themeFg("muted", "read ") + text;
212
+ }
213
+ case "write": {
214
+ const filePath = shortenPath(args.file_path ?? args.path, "...");
215
+ const content = safeBlock(args.content, "", 2 * 1024);
216
+ const lines = content ? content.split("\n").length : 0;
217
+ return (
218
+ themeFg("muted", "write ") +
219
+ themeFg("accent", filePath) +
220
+ (lines > 1 ? themeFg("dim", ` (${lines} lines)`) : "")
221
+ );
222
+ }
223
+ case "edit":
224
+ return (
225
+ themeFg("muted", "edit ") +
226
+ themeFg("accent", shortenPath(args.file_path ?? args.path, "..."))
227
+ );
228
+ case "ls":
229
+ return themeFg("muted", "ls ") + themeFg("accent", shortenPath(args.path));
230
+ case "find":
231
+ return (
232
+ themeFg("muted", "find ") +
233
+ themeFg("accent", safeLine(args.pattern, "*", 512)) +
234
+ themeFg("dim", ` in ${shortenPath(args.path)}`)
235
+ );
236
+ case "grep":
237
+ return (
238
+ themeFg("muted", "grep ") +
239
+ themeFg("accent", `/${safeLine(args.pattern, "", 512)}/`) +
240
+ themeFg("dim", ` in ${shortenPath(args.path)}`)
241
+ );
242
+ default: {
243
+ let serialized = "{}";
244
+ try {
245
+ serialized = JSON.stringify(args);
246
+ } catch {
247
+ serialized = "{…}";
248
+ }
249
+ return themeFg("accent", toolName) + themeFg("dim", ` ${safeLine(serialized, "{}", 512)}`);
250
+ }
251
+ }
252
+ }
package/src/render.ts CHANGED
@@ -1,4 +1,3 @@
1
- import * as os from "node:os";
2
1
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
3
2
  import type { Message } from "@earendil-works/pi-ai";
4
3
  import {
@@ -10,6 +9,7 @@ import {
10
9
  import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
11
10
  import type { AgentScope, SubagentThinkingLevel } from "./agents.js";
12
11
  import { hasUsableAggregator, type SubagentParams } from "./params.js";
12
+ import { expansionHint, formatToolActivity, safeBlock, safeLine } from "./render-common.js";
13
13
  import {
14
14
  getResultFinalOutput,
15
15
  isResultError,
@@ -17,15 +17,15 @@ import {
17
17
  type SubagentDetails,
18
18
  } from "./runner.js";
19
19
 
20
- const COLLAPSED_ITEM_COUNT = 10;
20
+ const COLLAPSED_ITEM_COUNT = 5;
21
21
 
22
22
  function previewTask(task: unknown, maxLength = 40): string {
23
- if (typeof task !== "string" || task.length === 0) return "...";
24
- return task.length > maxLength ? `${task.slice(0, maxLength)}...` : task;
23
+ const safe = safeLine(task, "...", 2 * 1024);
24
+ return safe.length > maxLength ? `${safe.slice(0, maxLength)}...` : safe;
25
25
  }
26
26
 
27
27
  function previewAgent(agent: unknown): string {
28
- return typeof agent === "string" && agent.length > 0 ? agent : "...";
28
+ return safeLine(agent, "...", 256);
29
29
  }
30
30
 
31
31
  export function formatTokens(count: number): string {
@@ -59,12 +59,12 @@ export function formatUsageStats(
59
59
  if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
60
60
  if (usage.contextTokens && usage.contextTokens > 0)
61
61
  parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
62
+ const safeProvider = actualProvider ? safeLine(actualProvider, "", 256) : undefined;
63
+ const safeModel = actualModel ? safeLine(actualModel, "", 256) : undefined;
62
64
  const actual =
63
- actualProvider && actualModel
64
- ? `${actualProvider}/${actualModel}`
65
- : (actualModel ?? actualProvider);
66
- if (actual ?? model) parts.push(actual ?? model ?? "");
67
- if (thinkingLevel) parts.push(`requested-thinking:${thinkingLevel}`);
65
+ safeProvider && safeModel ? `${safeProvider}/${safeModel}` : (safeModel ?? safeProvider);
66
+ if (actual ?? model) parts.push(actual ?? safeLine(model, "", 256));
67
+ if (thinkingLevel) parts.push(`requested-thinking:${safeLine(thinkingLevel, "", 128)}`);
68
68
  return parts.join(" ");
69
69
  }
70
70
 
@@ -83,71 +83,7 @@ function formatToolCall(
83
83
  args: Record<string, unknown>,
84
84
  themeFg: (color: ThemeColor, text: string) => string,
85
85
  ): string {
86
- const shortenPath = (p: string) => {
87
- const home = os.homedir();
88
- return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
89
- };
90
-
91
- switch (toolName) {
92
- case "bash": {
93
- const command = (args.command as string) || "...";
94
- const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
95
- return themeFg("muted", "$ ") + themeFg("toolOutput", preview);
96
- }
97
- case "read": {
98
- const rawPath = (args.file_path || args.path || "...") as string;
99
- const filePath = shortenPath(rawPath);
100
- const offset = args.offset as number | undefined;
101
- const limit = args.limit as number | undefined;
102
- let text = themeFg("accent", filePath);
103
- if (offset !== undefined || limit !== undefined) {
104
- const startLine = offset ?? 1;
105
- const endLine = limit !== undefined ? startLine + limit - 1 : "";
106
- text += themeFg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
107
- }
108
- return themeFg("muted", "read ") + text;
109
- }
110
- case "write": {
111
- const rawPath = (args.file_path || args.path || "...") as string;
112
- const filePath = shortenPath(rawPath);
113
- const content = (args.content || "") as string;
114
- const lines = content.split("\n").length;
115
- let text = themeFg("muted", "write ") + themeFg("accent", filePath);
116
- if (lines > 1) text += themeFg("dim", ` (${lines} lines)`);
117
- return text;
118
- }
119
- case "edit": {
120
- const rawPath = (args.file_path || args.path || "...") as string;
121
- return themeFg("muted", "edit ") + themeFg("accent", shortenPath(rawPath));
122
- }
123
- case "ls": {
124
- const rawPath = (args.path || ".") as string;
125
- return themeFg("muted", "ls ") + themeFg("accent", shortenPath(rawPath));
126
- }
127
- case "find": {
128
- const pattern = (args.pattern || "*") as string;
129
- const rawPath = (args.path || ".") as string;
130
- return (
131
- themeFg("muted", "find ") +
132
- themeFg("accent", pattern) +
133
- themeFg("dim", ` in ${shortenPath(rawPath)}`)
134
- );
135
- }
136
- case "grep": {
137
- const pattern = (args.pattern || "") as string;
138
- const rawPath = (args.path || ".") as string;
139
- return (
140
- themeFg("muted", "grep ") +
141
- themeFg("accent", `/${pattern}/`) +
142
- themeFg("dim", ` in ${shortenPath(rawPath)}`)
143
- );
144
- }
145
- default: {
146
- const argsStr = JSON.stringify(args);
147
- const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr;
148
- return themeFg("accent", toolName) + themeFg("dim", ` ${preview}`);
149
- }
150
- }
86
+ return formatToolActivity(toolName, args, themeFg);
151
87
  }
152
88
 
153
89
  type DisplayItem =
@@ -160,10 +96,14 @@ function getDisplayItems(messages: Message[]): DisplayItem[] {
160
96
  if (msg.role === "assistant") {
161
97
  for (const part of msg.content) {
162
98
  if (part.type === "text") {
163
- const text = part.text.trim();
99
+ const text = safeBlock(part.text, "", 8 * 1024).trim();
164
100
  if (text) items.push({ type: "text", text });
165
101
  } else if (part.type === "toolCall") {
166
- items.push({ type: "toolCall", name: part.name, args: part.arguments });
102
+ items.push({
103
+ type: "toolCall",
104
+ name: safeLine(part.name, "tool", 256),
105
+ args: part.arguments,
106
+ });
167
107
  }
168
108
  }
169
109
  }
@@ -181,6 +121,47 @@ function getCollapsedDisplayItems(result: SingleResult): { items: DisplayItem[];
181
121
  return { items, total: items.length };
182
122
  }
183
123
 
124
+ function sanitizeSingleResultForRender(result: SingleResult): SingleResult {
125
+ return {
126
+ ...result,
127
+ agent: safeLine(result.agent, "subagent", 256),
128
+ task: safeBlock(result.task, "", 50 * 1024),
129
+ stderr: safeBlock(result.stderr, "", 8 * 1024),
130
+ model: result.model ? safeLine(result.model, "", 256) : undefined,
131
+ actualProvider: result.actualProvider ? safeLine(result.actualProvider, "", 256) : undefined,
132
+ actualModel: result.actualModel ? safeLine(result.actualModel, "", 256) : undefined,
133
+ stopReason: result.stopReason ? safeLine(result.stopReason, "", 256) : undefined,
134
+ errorMessage: result.errorMessage ? safeBlock(result.errorMessage, "", 8 * 1024) : undefined,
135
+ finalOutput: result.finalOutput ? safeBlock(result.finalOutput, "", 50 * 1024) : undefined,
136
+ };
137
+ }
138
+
139
+ function renderResultStatus(result: SingleResult, isPartial: boolean): string {
140
+ if (isResultError(result)) {
141
+ return result.stopReason === "aborted" || result.aborted ? "Cancelled" : "Failed";
142
+ }
143
+ return isPartial || result.exitCode === -1 ? "Running" : "Completed";
144
+ }
145
+
146
+ function coloredStatus(theme: Theme, status: string): string {
147
+ const color: ThemeColor =
148
+ status === "Failed" ? "error" : status === "Completed" ? "success" : "warning";
149
+ return theme.fg(color, status);
150
+ }
151
+
152
+ function coloredResultStatus(theme: Theme, result: SingleResult, isPartial: boolean): string {
153
+ return coloredStatus(theme, renderResultStatus(result, isPartial));
154
+ }
155
+
156
+ function formatResultPolicy(result: SingleResult): string {
157
+ if (!result.policy) return "";
158
+ return [
159
+ `inherited: ${result.policy.inherited.map((tool) => safeLine(tool, "", 256)).join(", ") || "none"}`,
160
+ `overridden: ${result.policy.overridden.map((tool) => safeLine(tool, "", 256)).join(", ") || "none"}`,
161
+ `unsupported: ${result.policy.unsupported.map((tool) => safeLine(tool, "", 256)).join(", ") || "none"}`,
162
+ ].join("\n");
163
+ }
164
+
184
165
  export function renderSubagentCall(args: SubagentParams, theme: Theme) {
185
166
  const scope: AgentScope = args.agentScope ?? "user";
186
167
  if (args.chain && args.chain.length > 0) {
@@ -239,11 +220,22 @@ export function renderSubagentResult(
239
220
  { expanded, isPartial }: ToolRenderResultOptions,
240
221
  theme: Theme,
241
222
  ) {
242
- const details = result.details as SubagentDetails | undefined;
243
- if (!details || details.results.length === 0) {
223
+ const rawDetails = result.details as SubagentDetails | undefined;
224
+ if (!rawDetails || rawDetails.results.length === 0) {
244
225
  const text = result.content[0];
245
- return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
226
+ return new Text(
227
+ text?.type === "text" ? safeBlock(text.text, "(no output)", 8 * 1024) : "(no output)",
228
+ 0,
229
+ 0,
230
+ );
246
231
  }
232
+ const details: SubagentDetails = {
233
+ ...rawDetails,
234
+ results: rawDetails.results.map(sanitizeSingleResultForRender),
235
+ aggregator: rawDetails.aggregator
236
+ ? sanitizeSingleResultForRender(rawDetails.aggregator)
237
+ : undefined,
238
+ };
247
239
 
248
240
  const mdTheme = getMarkdownTheme();
249
241
 
@@ -254,7 +246,8 @@ export function renderSubagentResult(
254
246
  if (skipped > 0) text += theme.fg("muted", `... ${skipped} earlier items\n`);
255
247
  for (const item of toShow) {
256
248
  if (item.type === "text") {
257
- const preview = expanded ? item.text : item.text.split("\n").slice(0, 3).join("\n");
249
+ const safeText = safeBlock(item.text, "", 8 * 1024);
250
+ const preview = expanded ? safeText : safeText.split("\n").slice(0, 3).join("\n");
258
251
  text += `${theme.fg("toolOutput", preview)}\n`;
259
252
  } else {
260
253
  text += `${theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`;
@@ -276,7 +269,7 @@ export function renderSubagentResult(
276
269
 
277
270
  if (expanded) {
278
271
  const container = new Container();
279
- let header = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
272
+ let header = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${safeLine(r.agentSource, "unknown", 128)})`)} · ${coloredResultStatus(theme, r, isPartial)}`;
280
273
  if (isError && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
281
274
  container.addChild(new Text(header, 0, 0));
282
275
  if (isError && r.errorMessage)
@@ -284,6 +277,12 @@ export function renderSubagentResult(
284
277
  container.addChild(new Spacer(1));
285
278
  container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
286
279
  container.addChild(new Text(theme.fg("dim", r.task), 0, 0));
280
+ const policy = formatResultPolicy(r);
281
+ if (policy) {
282
+ container.addChild(new Spacer(1));
283
+ container.addChild(new Text(theme.fg("muted", "─── Policy ───"), 0, 0));
284
+ container.addChild(new Text(theme.fg("dim", policy), 0, 0));
285
+ }
287
286
  container.addChild(new Spacer(1));
288
287
  container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0));
289
288
  if (displayItems.length === 0 && !finalOutput) {
@@ -314,15 +313,16 @@ export function renderSubagentResult(
314
313
  }
315
314
 
316
315
  const collapsed = getCollapsedDisplayItems(r);
317
- let text = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
316
+ let text = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${safeLine(r.agentSource, "unknown", 128)})`)} · ${coloredResultStatus(theme, r, isPartial)}`;
318
317
  if (isError && r.stopReason) text += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
319
318
  if (isError && r.errorMessage) text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
320
319
  if (collapsed.items.length > 0) {
321
320
  text += `\n${renderDisplayItems(collapsed.items, COLLAPSED_ITEM_COUNT, collapsed.total)}`;
322
- if (collapsed.total > COLLAPSED_ITEM_COUNT)
323
- text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
321
+ if (collapsed.total > COLLAPSED_ITEM_COUNT) text += `\n${expansionHint()}`;
324
322
  } else if (finalOutput.trim()) {
325
- text += `\n${theme.fg("toolOutput", finalOutput.trim().split("\n").slice(0, 3).join("\n"))}`;
323
+ const outputLines = finalOutput.trim().split("\n");
324
+ text += `\n${theme.fg("toolOutput", outputLines.slice(0, 3).join("\n"))}`;
325
+ if (outputLines.length > 3) text += `\n${expansionHint()}`;
326
326
  } else if (!isError || !r.errorMessage) {
327
327
  text += `\n${theme.fg("muted", isPartial && !isError ? "(running...)" : "(no output)")}`;
328
328
  }
@@ -356,6 +356,11 @@ export function renderSubagentResult(
356
356
  : successCount === details.results.length
357
357
  ? theme.fg("success", "✓")
358
358
  : theme.fg("error", "✗");
359
+ const overallStatus = currentIsRunning
360
+ ? "Running"
361
+ : successCount === details.results.length
362
+ ? "Completed"
363
+ : "Failed";
359
364
 
360
365
  if (expanded) {
361
366
  const container = new Container();
@@ -364,7 +369,8 @@ export function renderSubagentResult(
364
369
  icon +
365
370
  " " +
366
371
  theme.fg("toolTitle", theme.bold("chain ")) +
367
- theme.fg("accent", `${successCount}/${details.results.length} steps`),
372
+ theme.fg("accent", `${successCount}/${details.results.length} steps`) +
373
+ ` · ${coloredStatus(theme, overallStatus)}`,
368
374
  0,
369
375
  0,
370
376
  ),
@@ -383,12 +389,17 @@ export function renderSubagentResult(
383
389
  container.addChild(new Spacer(1));
384
390
  container.addChild(
385
391
  new Text(
386
- `${theme.fg("muted", `─── Step ${r.step}: `) + theme.fg("accent", r.agent)} ${rIcon}`,
392
+ `${theme.fg("muted", `─── Step ${r.step}: `) + theme.fg("accent", r.agent)} ${rIcon} ${coloredResultStatus(theme, r, currentIsRunning && r === currentResult)}`,
387
393
  0,
388
394
  0,
389
395
  ),
390
396
  );
391
397
  container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0));
398
+ const policy = formatResultPolicy(r);
399
+ if (policy)
400
+ container.addChild(
401
+ new Text(theme.fg("muted", "Policy:\n") + theme.fg("dim", policy), 0, 0),
402
+ );
392
403
  if (rFailed && r.errorMessage)
393
404
  container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
394
405
 
@@ -429,7 +440,8 @@ export function renderSubagentResult(
429
440
  icon +
430
441
  " " +
431
442
  theme.fg("toolTitle", theme.bold("chain ")) +
432
- theme.fg("accent", `${successCount}/${details.results.length} steps`);
443
+ theme.fg("accent", `${successCount}/${details.results.length} steps`) +
444
+ ` · ${coloredStatus(theme, overallStatus)}`;
433
445
  for (const r of details.results) {
434
446
  const rFailed = isResultError(r);
435
447
  const rIcon = rFailed
@@ -439,7 +451,7 @@ export function renderSubagentResult(
439
451
  : theme.fg("success", "✓");
440
452
  const collapsed = getCollapsedDisplayItems(r);
441
453
  const finalOutput = getResultFinalOutput(r).trim();
442
- text += `\n\n${theme.fg("muted", `─── Step ${r.step}: `)}${theme.fg("accent", r.agent)} ${rIcon}`;
454
+ text += `\n\n${theme.fg("muted", `─── Step ${r.step}: `)}${theme.fg("accent", r.agent)} ${rIcon} ${coloredResultStatus(theme, r, currentIsRunning && r === currentResult)}`;
443
455
  if (rFailed && r.errorMessage) text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
444
456
  if (collapsed.items.length > 0)
445
457
  text += `\n${renderDisplayItems(collapsed.items, 5, collapsed.total)}`;
@@ -451,7 +463,7 @@ export function renderSubagentResult(
451
463
  }
452
464
  const usageStr = formatUsageStats(aggregateUsage(details.results));
453
465
  if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`;
454
- text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
466
+ text += `\n${expansionHint()}`;
455
467
  return new Text(text, 0, 0);
456
468
  }
457
469
 
@@ -485,12 +497,17 @@ export function renderSubagentResult(
485
497
  : aggregator
486
498
  ? `${successCount}/${details.results.length} tasks + fan-in`
487
499
  : `${successCount}/${details.results.length} tasks`;
500
+ const overallStatus = isRunning
501
+ ? "Running"
502
+ : failCount > 0 || aggregatorFailed
503
+ ? "Failed"
504
+ : "Completed";
488
505
 
489
506
  if (expanded && !isRunning) {
490
507
  const container = new Container();
491
508
  container.addChild(
492
509
  new Text(
493
- `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`,
510
+ `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)} · ${coloredStatus(theme, overallStatus)}`,
494
511
  0,
495
512
  0,
496
513
  ),
@@ -508,9 +525,18 @@ export function renderSubagentResult(
508
525
 
509
526
  container.addChild(new Spacer(1));
510
527
  container.addChild(
511
- new Text(`${theme.fg("muted", "─── ") + theme.fg("accent", r.agent)} ${rIcon}`, 0, 0),
528
+ new Text(
529
+ `${theme.fg("muted", "─── ") + theme.fg("accent", r.agent)} ${rIcon} ${coloredResultStatus(theme, r, resultIsRunning(r))}`,
530
+ 0,
531
+ 0,
532
+ ),
512
533
  );
513
534
  container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0));
535
+ const policy = formatResultPolicy(r);
536
+ if (policy)
537
+ container.addChild(
538
+ new Text(theme.fg("muted", "Policy:\n") + theme.fg("dim", policy), 0, 0),
539
+ );
514
540
  if (rFailed && r.errorMessage)
515
541
  container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
516
542
 
@@ -550,7 +576,7 @@ export function renderSubagentResult(
550
576
  container.addChild(new Spacer(1));
551
577
  container.addChild(
552
578
  new Text(
553
- `${theme.fg("muted", "─── fan-in → ") + theme.fg("accent", aggregator.agent)} ${rIcon}`,
579
+ `${theme.fg("muted", "─── fan-in → ") + theme.fg("accent", aggregator.agent)} ${rIcon} ${coloredResultStatus(theme, aggregator, aggregatorRunning)}`,
554
580
  0,
555
581
  0,
556
582
  ),
@@ -558,6 +584,11 @@ export function renderSubagentResult(
558
584
  container.addChild(
559
585
  new Text(theme.fg("muted", "Task: ") + theme.fg("dim", aggregator.task), 0, 0),
560
586
  );
587
+ const aggregatorPolicy = formatResultPolicy(aggregator);
588
+ if (aggregatorPolicy)
589
+ container.addChild(
590
+ new Text(theme.fg("muted", "Policy:\n") + theme.fg("dim", aggregatorPolicy), 0, 0),
591
+ );
561
592
  if (aggregatorFailed && aggregator.errorMessage)
562
593
  container.addChild(
563
594
  new Text(theme.fg("error", `Error: ${aggregator.errorMessage}`), 0, 0),
@@ -592,7 +623,7 @@ export function renderSubagentResult(
592
623
  }
593
624
 
594
625
  // Collapsed view (or still running)
595
- let text = `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`;
626
+ let text = `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)} · ${coloredStatus(theme, overallStatus)}`;
596
627
  for (const r of details.results) {
597
628
  const rFailed = isResultError(r);
598
629
  const rRunning = resultIsRunning(r);
@@ -603,7 +634,7 @@ export function renderSubagentResult(
603
634
  : theme.fg("success", "✓");
604
635
  const collapsed = getCollapsedDisplayItems(r);
605
636
  const finalOutput = getResultFinalOutput(r).trim();
606
- text += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", r.agent)} ${rIcon}`;
637
+ text += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", r.agent)} ${rIcon} ${coloredResultStatus(theme, r, rRunning)}`;
607
638
  if (rFailed && r.errorMessage) text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
608
639
  if (collapsed.items.length > 0)
609
640
  text += `\n${renderDisplayItems(collapsed.items, 5, collapsed.total)}`;
@@ -620,7 +651,7 @@ export function renderSubagentResult(
620
651
  : theme.fg("success", "✓");
621
652
  const collapsed = getCollapsedDisplayItems(aggregator);
622
653
  const finalOutput = getResultFinalOutput(aggregator).trim();
623
- text += `\n\n${theme.fg("muted", "─── fan-in → ")}${theme.fg("accent", aggregator.agent)} ${rIcon}`;
654
+ text += `\n\n${theme.fg("muted", "─── fan-in → ")}${theme.fg("accent", aggregator.agent)} ${rIcon} ${coloredResultStatus(theme, aggregator, aggregatorRunning)}`;
624
655
  if (aggregatorFailed && aggregator.errorMessage)
625
656
  text += `\n${theme.fg("error", `Error: ${aggregator.errorMessage}`)}`;
626
657
  if (collapsed.items.length > 0)
@@ -636,10 +667,14 @@ export function renderSubagentResult(
636
667
  const usageStr = formatUsageStats(aggregateUsage(usageResults));
637
668
  if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`;
638
669
  }
639
- if (!expanded) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
670
+ if (!expanded) text += `\n${expansionHint()}`;
640
671
  return new Text(text, 0, 0);
641
672
  }
642
673
 
643
674
  const text = result.content[0];
644
- return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
675
+ return new Text(
676
+ text?.type === "text" ? safeBlock(text.text, "(no output)", 8 * 1024) : "(no output)",
677
+ 0,
678
+ 0,
679
+ );
645
680
  }