@d3ara1n/pi-subagent 0.10.3 → 1.0.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/src/output.ts CHANGED
@@ -60,6 +60,13 @@ export async function compressOutput(
60
60
  }
61
61
  }
62
62
 
63
+ /** First line of the output as a short summary, truncated to ~65 chars. */
64
+ function firstLineSummary(outputText: string): string | undefined {
65
+ const firstLine = outputText.trim().split("\n")[0];
66
+ if (!firstLine) return undefined;
67
+ return firstLine.length <= 65 ? firstLine : firstLine.slice(0, 62) + "...";
68
+ }
69
+
63
70
  export async function generateSummary(
64
71
  rolesApi: ModelRolesAPI,
65
72
  outputText: string,
@@ -68,11 +75,7 @@ export async function generateSummary(
68
75
  if (!summaryConfig.enabled || !outputText.trim()) return undefined;
69
76
 
70
77
  // Short outputs don't justify an extra API call — reuse the first line directly
71
- const shortTrimmed = outputText.trim();
72
- if (shortTrimmed.length <= 150) {
73
- const firstLine = shortTrimmed.split("\n")[0];
74
- return firstLine.length <= 65 ? firstLine : firstLine.slice(0, 62) + "...";
75
- }
78
+ if (outputText.trim().length <= 150) return firstLineSummary(outputText);
76
79
 
77
80
  try {
78
81
  if (!rolesApi.resolveRole(summaryConfig.role).model) return undefined;
@@ -106,11 +109,7 @@ export async function generateSummary(
106
109
 
107
110
  return text || undefined;
108
111
  } catch {
109
- // Fall back to manual truncation: use first line of output as summary
110
- const trimmed = outputText.trim();
111
- if (!trimmed) return undefined;
112
- const firstLine = trimmed.split("\n")[0];
113
- if (firstLine.length <= 65) return firstLine;
114
- return firstLine.slice(0, 62) + "...";
112
+ // Fall back to the first-line summary
113
+ return firstLineSummary(outputText);
115
114
  }
116
115
  }
@@ -0,0 +1,324 @@
1
+ /**
2
+ * TUI rendering for background delegation: the background delegate input
3
+ * block, the wait live view, and the check snapshot view.
4
+ *
5
+ * This module is deliberately independent of ./render.ts (the foreground
6
+ * delegate family): the two presentation shapes evolve separately and share
7
+ * only the pure helpers from ./utils.ts (per-item formatters, icons, the
8
+ * result-line chain, and the elapsed-time timer).
9
+ *
10
+ * Layout contract (mirrors how foreground delegate rows decompose):
11
+ * - background delegate row = INPUT only (static — the run outlives the call)
12
+ * - wait row = per-run STATUS line + PROCESS stream + usage bar. When a run
13
+ * finishes, its result line carries only the status ("finished" / stop
14
+ * reason) — the conclusion itself is check's job, for the LLM and the user
15
+ * - check row = the same block shape, but the single-run snapshot whose result
16
+ * line/expanded view DO show the actual output (check is the result-fetcher)
17
+ *
18
+ * Icon discipline (parity with the foreground row): the status line shows an
19
+ * icon only while queued/running; once terminal it goes bare and the result
20
+ * line takes over the icon — never both.
21
+ */
22
+
23
+ import { getMarkdownTheme, type ToolDefinition } from "@earendil-works/pi-coding-agent";
24
+ import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
25
+ import type {
26
+ BackgroundDelegateDetails,
27
+ CheckDetails,
28
+ RunViewEntry,
29
+ SubagentResult,
30
+ WaitDetails,
31
+ } from "./types.ts";
32
+ import {
33
+ buildDisplayItems,
34
+ clearElapsedTimer,
35
+ contentText,
36
+ deriveRunState,
37
+ ensureElapsedTimer,
38
+ formatFallback,
39
+ formatThinking,
40
+ formatTimePart,
41
+ formatToolCall,
42
+ formatUsageStats,
43
+ renderDisplayItems,
44
+ runIcon,
45
+ statusStyle,
46
+ taskPreview,
47
+ terminalResultLine,
48
+ } from "./utils.ts";
49
+
50
+ // Contextual types derived from ToolDefinition so we don't depend on
51
+ // non-root-exported render types (ToolRenderContext is internal).
52
+ type RenderCallFn = NonNullable<ToolDefinition["renderCall"]>;
53
+ type RenderResultFn = NonNullable<ToolDefinition["renderResult"]>;
54
+
55
+ type Fg = (color: string, text: string) => string;
56
+
57
+ // ── Small shared pieces (same presentation family) ─────────────
58
+
59
+ /** Usage line: elapsed/budget(+grace) prefix + token/turn/cost stats. */
60
+ function runUsageLine(r: SubagentResult): string | null {
61
+ const stats = formatUsageStats(r.usage, r.model);
62
+ return [formatTimePart(r), stats].filter(Boolean).join(" \u00b7 ") || null;
63
+ }
64
+
65
+ /** Full activity stream as container rows (shared by the expanded views). */
66
+ function addActivityRows(container: Container, r: SubagentResult, fg: Fg): void {
67
+ const activity = buildDisplayItems(r.activityLog);
68
+ if (activity.length === 0) {
69
+ const state = deriveRunState(r);
70
+ const label =
71
+ state === "queued" ? "(queued — waiting for a concurrency slot...)" : "(no activity yet)";
72
+ container.addChild(new Text(fg("muted", label), 0, 0));
73
+ return;
74
+ }
75
+ for (const item of activity) {
76
+ if (item.type === "thinking") {
77
+ container.addChild(new Text(formatThinking(item.status, fg), 0, 0));
78
+ } else {
79
+ const { prefix, color } = statusStyle(item.status, fg);
80
+ container.addChild(new Text(prefix + formatToolCall(item.name, item.args, color), 0, 0));
81
+ }
82
+ }
83
+ }
84
+
85
+ /** Fallback trace line, shown while a retry is in flight and after it lands. */
86
+ function addFallbackRow(container: Container, r: SubagentResult, fg: Fg): void {
87
+ if (r.fallbackFrom) {
88
+ container.addChild(new Text(fg("warning", `\u26a0 fallback: ${formatFallback(r.fallbackFrom)}`), 0, 0));
89
+ }
90
+ }
91
+
92
+ // ── wait entries: id'd status line, process stream, status-only result line ──
93
+
94
+ /** wait status line: `<icon> <id> (running|queued) <preview>` live; bare `<id> <preview>` once terminal. */
95
+ function waitStatusLine(entry: RunViewEntry, fg: Fg): string {
96
+ const r = entry.result;
97
+ const state = deriveRunState(r);
98
+ if (state === "queued" || state === "running") {
99
+ const label = state === "queued" ? "(queued)" : "(running)";
100
+ return `${runIcon(r, fg)} ${fg("accent", entry.id)} ${fg("dim", label)} ${fg("text", taskPreview(r.task))}`;
101
+ }
102
+ // Terminal: no icon — the result line takes over the status display.
103
+ return `${fg("accent", entry.id)} ${fg("text", taskPreview(r.task))}`;
104
+ }
105
+
106
+ function waitEntryCollapsedText(entry: RunViewEntry, fg: Fg): string {
107
+ const r = entry.result;
108
+ const state = deriveRunState(r);
109
+ let text = waitStatusLine(entry, fg);
110
+
111
+ if (state === "running") {
112
+ const activity = buildDisplayItems(r.activityLog);
113
+ if (activity.length === 0) {
114
+ text += `\n${fg("muted", "(running...)")}`;
115
+ } else {
116
+ const rendered = renderDisplayItems(activity, 5, fg);
117
+ if (rendered) text += `\n${rendered}`;
118
+ }
119
+ } else if (state !== "queued") {
120
+ // The process stream becomes the result line once the run finishes.
121
+ // "finished" — wait never shows output; the conclusion is check's job.
122
+ text += `\n${terminalResultLine(r, fg, "finished")}`;
123
+ }
124
+
125
+ const usage = runUsageLine(r);
126
+ if (usage) text += `\n${fg("dim", usage)}`;
127
+ return text;
128
+ }
129
+
130
+ function waitEntryExpandedContainer(entry: RunViewEntry, fg: Fg): Container {
131
+ const r = entry.result;
132
+ const state = deriveRunState(r);
133
+ const container = new Container();
134
+
135
+ container.addChild(new Text(waitStatusLine(entry, fg), 0, 0));
136
+ addFallbackRow(container, r, fg);
137
+ container.addChild(new Spacer(1));
138
+ if (state === "finished" || state === "failed") {
139
+ container.addChild(new Text(terminalResultLine(r, fg, "finished"), 0, 0));
140
+ container.addChild(new Spacer(1));
141
+ }
142
+ // Process stream in full — no output text here even when finished.
143
+ addActivityRows(container, r, fg);
144
+
145
+ const usage = runUsageLine(r);
146
+ if (usage) {
147
+ container.addChild(new Spacer(1));
148
+ container.addChild(new Text(fg("dim", usage), 0, 0));
149
+ }
150
+ return container;
151
+ }
152
+
153
+ // ── check entry: no id (single run), result line + expanded view show output ──
154
+
155
+ /** check status line: `<icon> (running|queued) <preview>` live; bare `<preview>` once terminal. No id — there is only one. */
156
+ function checkStatusLine(r: SubagentResult, fg: Fg): string {
157
+ const state = deriveRunState(r);
158
+ if (state === "queued" || state === "running") {
159
+ const label = state === "queued" ? "(queued)" : "(running)";
160
+ return `${runIcon(r, fg)} ${fg("dim", label)} ${fg("text", taskPreview(r.task))}`;
161
+ }
162
+ // Terminal: no icon — the result line takes over the status display.
163
+ return fg("text", taskPreview(r.task));
164
+ }
165
+
166
+ function checkEntryCollapsedText(r: SubagentResult, fg: Fg): string {
167
+ const state = deriveRunState(r);
168
+ let text = checkStatusLine(r, fg);
169
+
170
+ if (state === "running") {
171
+ const activity = buildDisplayItems(r.activityLog);
172
+ if (activity.length === 0) {
173
+ text += `\n${fg("muted", "(running...)")}`;
174
+ } else {
175
+ const rendered = renderDisplayItems(activity, 5, fg);
176
+ if (rendered) text += `\n${rendered}`;
177
+ }
178
+ } else if (state !== "queued") {
179
+ // check is the result-fetcher: same chain as the foreground row.
180
+ text += `\n${terminalResultLine(r, fg)}`;
181
+ }
182
+
183
+ const usage = runUsageLine(r);
184
+ if (usage) text += `\n${fg("dim", usage)}`;
185
+ return text;
186
+ }
187
+
188
+ function checkEntryExpandedContainer(r: SubagentResult, fg: Fg): Container {
189
+ const state = deriveRunState(r);
190
+ const container = new Container();
191
+
192
+ container.addChild(new Text(checkStatusLine(r, fg), 0, 0));
193
+ addFallbackRow(container, r, fg);
194
+ container.addChild(new Spacer(1));
195
+
196
+ if (state === "finished" || state === "failed") {
197
+ container.addChild(new Text(terminalResultLine(r, fg), 0, 0));
198
+ // check is the result-fetcher: the full output lives here.
199
+ container.addChild(new Spacer(1));
200
+ if (r.output.trim()) {
201
+ container.addChild(new Markdown(r.output.trim(), 0, 0, getMarkdownTheme()));
202
+ if (r.outputMethod === "compressed") {
203
+ container.addChild(
204
+ new Text(fg("muted", "(output compressed by summary model \u2014 full text in history)"), 0, 0),
205
+ );
206
+ } else if (r.outputMethod === "truncated") {
207
+ container.addChild(new Text(fg("muted", "(output truncated \u2014 full text in history)"), 0, 0));
208
+ }
209
+ } else {
210
+ container.addChild(new Text(fg("muted", "(no output \u2014 the run produced no text)"), 0, 0));
211
+ }
212
+ } else {
213
+ addActivityRows(container, r, fg);
214
+ }
215
+
216
+ const usage = runUsageLine(r);
217
+ if (usage) {
218
+ container.addChild(new Spacer(1));
219
+ container.addChild(new Text(fg("dim", usage), 0, 0));
220
+ }
221
+ return container;
222
+ }
223
+
224
+ // ── Background delegate: static input block ────────────────────
225
+
226
+ export const renderBackgroundDelegateCall: RenderCallFn = (args, theme) => {
227
+ const roleName = (args as any).role || "...";
228
+ const text =
229
+ theme.fg("toolTitle", theme.bold("subagent_delegate ")) +
230
+ theme.fg("accent", roleName) +
231
+ theme.fg("dim", " (background)");
232
+ return new Text(text, 0, 0);
233
+ };
234
+
235
+ export const renderBackgroundDelegateResult: RenderResultFn = (result, { expanded }, theme) => {
236
+ const details = result.details as BackgroundDelegateDetails | undefined;
237
+ if (!details) return new Text(contentText(result), 0, 0);
238
+
239
+ const fg = theme.fg.bind(theme) as Fg;
240
+ // One-line anchor: marker + id + task preview. The run's live state is NOT
241
+ // shown here — the row is written the moment the tool returns and the run
242
+ // progresses invisibly until a wait/check row picks it up.
243
+ const summaryLine = `${fg("accent", "\u25B6")} ${fg("dim", details.id)} ${fg("text", taskPreview(details.task))}`;
244
+
245
+ if (!expanded) return new Text(summaryLine, 0, 0);
246
+
247
+ // Expanded: full input — reference files, context size, task text.
248
+ const container = new Container();
249
+ container.addChild(new Text(summaryLine, 0, 0));
250
+ container.addChild(new Spacer(1));
251
+ if (details.files) {
252
+ for (const f of details.files) {
253
+ container.addChild(new Text(fg("dim", `@${f}`), 0, 0));
254
+ }
255
+ }
256
+ if (details.context) {
257
+ container.addChild(new Text(fg("dim", `ctx ${details.context.length} chars`), 0, 0));
258
+ }
259
+ container.addChild(new Text(fg("dim", details.task), 0, 0));
260
+ return container;
261
+ };
262
+
263
+ // ── wait: live multi-run view ──────────────────────────────────
264
+
265
+ export const renderWaitCall: RenderCallFn = (args, theme) => {
266
+ const ids = ((args as any).ids as string[] | undefined) ?? [];
267
+ const label = ids.length > 0 ? ids.join(", ") : "(all)";
268
+ const text = theme.fg("toolTitle", theme.bold("subagent_wait ")) + theme.fg("accent", label);
269
+ return new Text(text, 0, 0);
270
+ };
271
+
272
+ export const renderWaitResult: RenderResultFn = (result, { expanded }, theme, context) => {
273
+ const details = result.details as WaitDetails | undefined;
274
+ if (!details || details.entries.length === 0) {
275
+ return new Text(contentText(result), 0, 0);
276
+ }
277
+
278
+ // Tick while any watched run is still live. A timed-out wait freezes the
279
+ // view instead — the runs keep going, but this row is done.
280
+ const anyLive =
281
+ !details.timedOut &&
282
+ details.entries.some((e) => {
283
+ const s = deriveRunState(e.result);
284
+ return s === "queued" || s === "running";
285
+ });
286
+ if (anyLive) {
287
+ ensureElapsedTimer(context);
288
+ } else {
289
+ clearElapsedTimer(context);
290
+ }
291
+
292
+ const fg = theme.fg.bind(theme) as Fg;
293
+
294
+ if (expanded) {
295
+ const container = new Container();
296
+ details.entries.forEach((entry, i) => {
297
+ if (i > 0) container.addChild(new Spacer(1));
298
+ container.addChild(waitEntryExpandedContainer(entry, fg));
299
+ });
300
+ return container;
301
+ }
302
+
303
+ const text = details.entries.map((e) => waitEntryCollapsedText(e, fg)).join("\n\n");
304
+ return new Text(text, 0, 0);
305
+ };
306
+
307
+ // ── check: frozen single-run snapshot ──────────────────────────
308
+
309
+ export const renderCheckCall: RenderCallFn = (args, theme) => {
310
+ const id = (args as any).id || "...";
311
+ const text = theme.fg("toolTitle", theme.bold("subagent_check ")) + theme.fg("accent", id);
312
+ return new Text(text, 0, 0);
313
+ };
314
+
315
+ export const renderCheckResult: RenderResultFn = (result, { expanded }, theme, _context) => {
316
+ const details = result.details as CheckDetails | undefined;
317
+ if (!details) return new Text(contentText(result), 0, 0);
318
+
319
+ const fg = theme.fg.bind(theme) as Fg;
320
+ // Static snapshot — never starts the animation timer (the execute layer
321
+ // freezes the frame before handing it over).
322
+ if (expanded) return checkEntryExpandedContainer(details.result, fg);
323
+ return new Text(checkEntryCollapsedText(details.result, fg), 0, 0);
324
+ };
package/src/render.ts CHANGED
@@ -1,76 +1,40 @@
1
1
  /**
2
- * TUI rendering for the delegate tool: the call row (`delegate <role>`) and the
3
- * result view (collapsed and expanded), plus the render-side elapsed-time timer.
2
+ * TUI rendering for the delegate tool: the call row (`subagent_delegate <role>`)
3
+ * and the result view (collapsed and expanded). Shared composition helpers
4
+ * (icons, result lines, timers) live in ./utils.ts and are used by
5
+ * ./render-async.ts too, so every view renders an outcome the same way.
4
6
  */
5
7
 
6
- import {
7
- getMarkdownTheme,
8
- type ThemeColor,
9
- type ToolDefinition,
10
- } from "@earendil-works/pi-coding-agent";
8
+ import { getMarkdownTheme, type ToolDefinition } from "@earendil-works/pi-coding-agent";
11
9
  import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
12
10
  import type { SubagentDetails } from "./types.ts";
13
-
14
- // Contextual types derived from ToolDefinition so we don't depend on
15
- // non-root-exported render types (ToolRenderContext is internal).
16
- type RenderCallFn = NonNullable<ToolDefinition["renderCall"]>;
17
- type RenderResultFn = NonNullable<ToolDefinition["renderResult"]>;
18
11
  import {
19
12
  buildDisplayItems,
20
- formatUsageStats,
21
- elapsedSeconds,
22
- formatToolCall,
23
- statusStyle,
13
+ clearElapsedTimer,
14
+ contentText,
15
+ ensureElapsedTimer,
16
+ formatFallback,
24
17
  formatThinking,
18
+ formatTimePart,
19
+ formatToolCall,
20
+ formatUsageStats,
25
21
  renderDisplayItems,
26
- isFailedResult,
22
+ runIcon,
23
+ statusStyle,
24
+ taskPreview,
25
+ terminalResultLine,
27
26
  } from "./utils.ts";
28
27
 
29
- // ── Elapsed-time animation (render-side timer) ───────────────
30
-
31
- /**
32
- * Per-row render state slot holding the elapsed-time animation timer.
33
- * The handle lives in context.state so it is scoped to one tool row.
34
- */
35
- interface DelegateRenderState {
36
- elapsedTimer?: ReturnType<typeof setInterval>;
37
- }
38
-
39
- /**
40
- * While a delegate is running, force a TUI repaint every second so the
41
- * elapsed time ticks up even when the child process is idle. Uses
42
- * context.invalidate() (pi's official re-render hook) rather than pushing
43
- * data via onUpdate — the render recomputes elapsed time fresh from Date.now().
44
- */
45
- function ensureElapsedTimer(context: {
46
- state: Record<string, unknown>;
47
- invalidate?: () => void;
48
- }): void {
49
- const state = context.state as DelegateRenderState;
50
- if (state.elapsedTimer) return;
51
- if (typeof context.invalidate !== "function") return;
52
- state.elapsedTimer = setInterval(() => {
53
- try {
54
- context.invalidate?.();
55
- } catch {
56
- /* ignore — invalidate must never break rendering */
57
- }
58
- }, 1000);
59
- }
60
-
61
- /** Stop the elapsed-time animation once the run reaches a terminal state. */
62
- function clearElapsedTimer(context: { state: Record<string, unknown> }): void {
63
- const state = context.state as DelegateRenderState;
64
- if (!state.elapsedTimer) return;
65
- clearInterval(state.elapsedTimer);
66
- state.elapsedTimer = undefined;
67
- }
28
+ // Contextual types derived from ToolDefinition so we don't depend on
29
+ // non-root-exported render types (ToolRenderContext is internal).
30
+ type RenderCallFn = NonNullable<ToolDefinition["renderCall"]>;
31
+ type RenderResultFn = NonNullable<ToolDefinition["renderResult"]>;
68
32
 
69
33
  // ── renderCall: what the user sees when the tool is invoked ─────
70
34
 
71
35
  export const renderDelegateCall: RenderCallFn = (args, theme, _context) => {
72
36
  const roleName = (args as any).role || "...";
73
- const text = theme.fg("toolTitle", theme.bold("delegate ")) + theme.fg("accent", roleName);
37
+ const text = theme.fg("toolTitle", theme.bold("subagent_delegate ")) + theme.fg("accent", roleName);
74
38
  return new Text(text, 0, 0);
75
39
  };
76
40
 
@@ -93,91 +57,51 @@ export const renderDelegateResult: RenderResultFn = (result, { expanded }, theme
93
57
  }
94
58
 
95
59
  if (!details || details.results.length === 0) {
96
- const text = result.content[0];
97
- return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
60
+ return new Text(contentText(result), 0, 0);
98
61
  }
99
62
 
100
63
  const r = details.results[0];
101
- const isError = !isRunning && isFailedResult(r);
102
- const isTimeout = !isRunning && r.stopReason === "timeout";
103
- const isBudget = !isRunning && r.stopReason === "budget_exceeded";
104
- const isFailedState = isError || isTimeout || isBudget;
105
-
106
- // Status icon. ⏳ running / ⏸ queued (pause) / ⏱ timeout / ⏲ budget / ✗ error / ✓ ok
107
- let icon: string;
108
- if (isRunning) {
109
- icon = r.queued ? theme.fg("warning", "\u23F8") : theme.fg("warning", "\u23F3");
110
- } else if (isTimeout) {
111
- icon = theme.fg("warning", "\u23F1");
112
- } else if (isBudget) {
113
- icon = theme.fg("warning", "\u23F2");
114
- } else if (isError) {
115
- icon = theme.fg("error", "\u2717");
116
- } else {
117
- icon = theme.fg("success", "\u2713");
118
- }
119
-
64
+ const fg = theme.fg.bind(theme) as (color: string, text: string) => string;
65
+ const icon = runIcon(r, fg);
120
66
  const displayItems = buildDisplayItems(r.activityLog);
121
67
  const mdTheme = getMarkdownTheme();
122
- const fg = theme.fg.bind(theme) as (color: string, text: string) => string;
123
68
 
124
- // Task preview: first line, truncated to one row (always-visible anchor).
125
- const firstLine = r.task.split("\n")[0];
126
- const taskPreview = firstLine.length > 70 ? `${firstLine.slice(0, 70)}...` : firstLine;
127
69
  // taskline: indicator prefix while running/queued; bare text once finished.
70
+ const preview = taskPreview(r.task);
128
71
  let taskline: string;
129
72
  if (isRunning) {
130
73
  const label = r.queued ? "(queued)" : "(running)";
131
- taskline = `${icon} ${theme.fg("dim", label)} ${theme.fg("text", taskPreview)}`;
74
+ taskline = `${icon} ${theme.fg("dim", label)} ${theme.fg("text", preview)}`;
132
75
  } else {
133
- taskline = theme.fg("text", taskPreview);
76
+ taskline = theme.fg("text", preview);
134
77
  }
135
78
 
136
- // usage line: elapsed/budget(+grace) prefix + existing stats.
137
- const secs = elapsedSeconds(r);
79
+ // usage line: elapsed/budget(+grace) prefix + stats.
138
80
  const stats = formatUsageStats(r.usage, r.model);
139
- const budgetSec = r.budgetMs ? Math.round(r.budgetMs / 1000) : 0;
140
- const liveGraceMs = (r.graceMs ?? 0) + (r.pauseStart ? Date.now() - r.pauseStart : 0);
141
- const graceSec = Math.round(liveGraceMs / 1000);
142
- let timePart: string | null = null;
143
- if (secs != null) {
144
- timePart =
145
- budgetSec > 0
146
- ? graceSec > 0
147
- ? `${secs}s/${budgetSec}s(+${graceSec}s)`
148
- : `${secs}s/${budgetSec}s`
149
- : `${secs}s`;
150
- }
151
- const usageLine = [timePart, stats].filter(Boolean).join(" \u00b7 ");
81
+ const usageLine = [formatTimePart(r), stats].filter(Boolean).join(" \u00b7 ");
152
82
 
153
- // resultline: fixed line on terminal frames — `<icon> <content>` colored by outcome.
154
- // success AI summary, else first line of output (truncated), else a placeholder never blank.
155
- // error/timeout/budget errorMessage (or a default label).
156
- let resultline: string | undefined;
157
- if (!isRunning) {
158
- if (isFailedState) {
159
- const content =
160
- r.errorMessage || (isTimeout ? "Timed out" : isBudget ? "Budget exceeded" : "failed");
161
- const col: ThemeColor = isTimeout || isBudget ? "warning" : "error";
162
- resultline = `${icon} ${theme.fg(col, content)}`;
163
- } else {
164
- // success fallback chain: summary → output first line → placeholder.
165
- const firstLine = r.output.trim().split("\n")[0] ?? "";
166
- const preview = firstLine.length > 70 ? `${firstLine.slice(0, 70)}...` : firstLine;
167
- const content = r.summary || preview;
168
- const col: ThemeColor = content ? "text" : "muted";
169
- resultline = `${icon} ${theme.fg(col, content || "(no output)")}`;
170
- }
171
- }
83
+ // resultline: fixed line on terminal frames — the shared chain (failure or
84
+ // budget reason on stops, summary first output line placeholder on success).
85
+ const resultline = isRunning ? undefined : terminalResultLine(r, fg);
86
+
87
+ // Fallback trace: the role's primary model hit a provider error and the run is
88
+ // being / was retried on the fallback role — warn, don't error (the retry may
89
+ // still succeed). Also shown while the retry is running.
90
+ const fallbackLine = r.fallbackFrom
91
+ ? theme.fg("warning", `\u26a0 fallback: ${formatFallback(r.fallbackFrom)}`)
92
+ : undefined;
172
93
 
173
94
  if (expanded) {
174
95
  const container = new Container();
175
96
 
176
- // Header: taskline + resultline (summary on success, error message on failure).
97
+ // Header: taskline + resultline (summary on success, reason on failure).
177
98
  container.addChild(new Text(taskline, 0, 0));
178
99
  if (resultline) {
179
100
  container.addChild(new Text(resultline, 0, 0));
180
101
  }
102
+ if (fallbackLine) {
103
+ container.addChild(new Text(fallbackLine, 0, 0));
104
+ }
181
105
 
182
106
  // Input block: reference files + context char count + task full text,
183
107
  // grouped without inner spacing (they are all subagent input).
@@ -194,10 +118,7 @@ export const renderDelegateResult: RenderResultFn = (result, { expanded }, theme
194
118
 
195
119
  // Activity stream (shown while running and after completion).
196
120
  container.addChild(new Spacer(1));
197
- const activity = displayItems.filter(
198
- (item) => item.type === "toolCall" || item.type === "thinking",
199
- );
200
- if (activity.length === 0) {
121
+ if (displayItems.length === 0) {
201
122
  const runningLabel = isRunning
202
123
  ? r.queued
203
124
  ? "(queued \u2014 waiting for a concurrency slot...)"
@@ -205,7 +126,7 @@ export const renderDelegateResult: RenderResultFn = (result, { expanded }, theme
205
126
  : "(none)";
206
127
  container.addChild(new Text(theme.fg("muted", runningLabel), 0, 0));
207
128
  } else {
208
- for (const item of activity) {
129
+ for (const item of displayItems) {
209
130
  if (item.type === "thinking") {
210
131
  container.addChild(new Text(formatThinking(item.status, fg), 0, 0));
211
132
  } else {
@@ -258,20 +179,18 @@ export const renderDelegateResult: RenderResultFn = (result, { expanded }, theme
258
179
  // Collapsed view.
259
180
  let text = taskline;
260
181
  if (!isRunning) {
261
- // resultline (shared computation above).
182
+ // Terminal: taskline + resultline + usage — no activity replay.
262
183
  if (resultline) text += `\n${resultline}`;
263
184
  } else if (!r.queued) {
264
185
  // Running (not queued): show recent activity only.
265
- const activity = displayItems.filter(
266
- (item) => item.type === "toolCall" || item.type === "thinking",
267
- );
268
- if (activity.length === 0) {
186
+ if (displayItems.length === 0) {
269
187
  text += `\n${theme.fg("muted", "(running...)")}`;
270
188
  } else {
271
- const rendered = renderDisplayItems(activity, 5, fg);
189
+ const rendered = renderDisplayItems(displayItems, 5, fg);
272
190
  if (rendered) text += `\n${rendered}`;
273
191
  }
274
192
  }
193
+ if (fallbackLine) text += `\n${fallbackLine}`;
275
194
  if (usageLine) text += `\n${theme.fg("dim", usageLine)}`;
276
195
  return new Text(text, 0, 0);
277
196
  };
package/src/roles.ts CHANGED
@@ -56,10 +56,10 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
56
56
  role: "default",
57
57
  timeout: 2400,
58
58
  description:
59
- "the ONLY role that can MODIFY files — edit, write, refactor, fix, implement. Tools: read, bash, edit, write, grep, find, delegate. Can delegate to explorer/researcher.",
59
+ "the ONLY role that can MODIFY files — edit, write, refactor, fix, implement. Tools: read, bash, edit, write, grep, find, subagent_delegate. Can delegate to explorer/researcher.",
60
60
  examples: ["Rename all snake_case fields to camelCase", "Add input validation to POST /login"],
61
61
  decisionTrigger: "Task modifies files?",
62
- tools: ["read", "bash", "edit", "write", "grep", "find", "delegate"],
62
+ tools: ["read", "bash", "edit", "write", "grep", "find", "subagent_delegate"],
63
63
  subagentRoles: ["explorer", "researcher"],
64
64
  systemPrompt: [
65
65
  "Implementation worker. Work autonomously — all context is in the task description.",
@@ -67,9 +67,9 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
67
67
  "After each change, validate: run tests, check syntax, verify behavior.",
68
68
  "",
69
69
  "## Protecting your context",
70
- "You have a `delegate` tool. Use it to offload exploration and research:",
71
- "- delegate(role=explorer) when you need to map unfamiliar code before editing",
72
- "- delegate(role=researcher) when you need external docs or library references",
70
+ "You have a `subagent_delegate` tool. Use it to offload exploration and research:",
71
+ "- subagent_delegate(role=explorer) when you need to map unfamiliar code before editing",
72
+ "- subagent_delegate(role=researcher) when you need external docs or library references",
73
73
  "Don't delegate tasks you can do with a single read or grep.",
74
74
  "",
75
75
  "Output format (be brief — summarize, don't paste full diffs):",
@@ -82,10 +82,10 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
82
82
  fallbackRole: "default",
83
83
  timeout: 2400,
84
84
  description:
85
- "the ONLY role with WEB ACCESS — search docs, fetch pages, analyze GitHub repos. Tools: web_search, fetch_content, read, bash, delegate. Can clone repos & delegate to explorer.",
85
+ "the ONLY role with WEB ACCESS — search docs, fetch pages, analyze GitHub repos. Tools: web_search, fetch_content, read, bash, subagent_delegate. Can clone repos & delegate to explorer.",
86
86
  examples: ["Find the React 19 migration guide", "Check GitHub issue #1234 for context"],
87
87
  decisionTrigger: "Task searches web or GitHub?",
88
- tools: ["web_search", "fetch_content", "read", "bash", "delegate"],
88
+ tools: ["web_search", "fetch_content", "read", "bash", "subagent_delegate"],
89
89
  subagentRoles: ["explorer"],
90
90
  systemPrompt: [
91
91
  "Web researcher. Search with varied angles, prefer official docs over blogs.",
@@ -94,7 +94,7 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
94
94
  "## GitHub repo analysis",
95
95
  "When the task requires analyzing a GitHub repo:",
96
96
  "1. git clone the repo into PI_SUBAGENT_TMPDIR (must exist)",
97
- "2. Use `delegate` with role=explorer to investigate the cloned codebase — pass the repo path and the research question",
97
+ "2. Use `subagent_delegate` with role=explorer to investigate the cloned codebase — pass the repo path and the research question",
98
98
  "3. Combine explorer findings with any web search results",
99
99
  "",
100
100
  "bash is for git clone and read-only commands only. Never modify files.",