pi-fast-subagent 0.8.0 → 0.9.1

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/loader-pool.ts ADDED
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Pooled ResourceLoader for subagent sessions.
3
+ *
4
+ * Loading the full extension/resource graph is expensive, so each unique
5
+ * (cwd, agentDir, noExtensions) tuple is warmed once and the underlying
6
+ * loader is reused across subagent runs. The pool is intentionally simple:
7
+ * one-loader-per-tuple with a FIFO idle queue.
8
+ */
9
+
10
+ import { DefaultResourceLoader } from "@mariozechner/pi-coding-agent";
11
+ import type { ResourceLoader } from "@mariozechner/pi-coding-agent";
12
+
13
+ type DefaultResourceLoaderOptions = ConstructorParameters<typeof DefaultResourceLoader>[0];
14
+
15
+ /** Minimum surface the pool needs from a loader. */
16
+ export interface PoolableLoader extends ResourceLoader {
17
+ reload(): Promise<void>;
18
+ }
19
+
20
+ export type LoaderFactory = (options: DefaultResourceLoaderOptions) => PoolableLoader;
21
+
22
+ interface LoaderPoolEntry {
23
+ idle: PoolableLoader[];
24
+ active: Set<PoolableLoader>;
25
+ warming: Set<Promise<void>>;
26
+ }
27
+
28
+ export interface LoaderLease {
29
+ loader: ResourceLoader;
30
+ release: () => void;
31
+ }
32
+
33
+ export class AgentPromptResourceLoader implements ResourceLoader {
34
+ constructor(
35
+ private readonly base: ResourceLoader,
36
+ private readonly systemPromptOverride: string | undefined,
37
+ ) {}
38
+
39
+ getExtensions() { return this.base.getExtensions(); }
40
+ getSkills() { return this.base.getSkills(); }
41
+ getPrompts() { return this.base.getPrompts(); }
42
+ getThemes() { return this.base.getThemes(); }
43
+ getAgentsFiles() { return this.base.getAgentsFiles(); }
44
+ getSystemPrompt() { return this.systemPromptOverride ?? this.base.getSystemPrompt(); }
45
+ getAppendSystemPrompt() { return this.base.getAppendSystemPrompt(); }
46
+ extendResources(paths: Parameters<ResourceLoader["extendResources"]>[0]): void {
47
+ this.base.extendResources(paths);
48
+ }
49
+ reload(): Promise<void> { return this.base.reload(); }
50
+ }
51
+
52
+ export function makeLoaderOptions(
53
+ cwd: string,
54
+ agentDir: string,
55
+ noExtensions: boolean,
56
+ ): DefaultResourceLoaderOptions {
57
+ return {
58
+ cwd,
59
+ agentDir,
60
+ noExtensions,
61
+ noContextFiles: true,
62
+ noSkills: true,
63
+ };
64
+ }
65
+
66
+ export class LoaderPool {
67
+ private entries = new Map<string, LoaderPoolEntry>();
68
+
69
+ constructor(
70
+ private readonly factory: LoaderFactory = (opts) => new DefaultResourceLoader(opts) as PoolableLoader,
71
+ ) {}
72
+
73
+ private key(cwd: string, agentDir: string, noExtensions: boolean): string {
74
+ return `${cwd}\0${agentDir}\0${noExtensions ? "noext" : "ext"}`;
75
+ }
76
+
77
+ private getEntry(cwd: string, agentDir: string, noExtensions: boolean): LoaderPoolEntry {
78
+ const k = this.key(cwd, agentDir, noExtensions);
79
+ let entry = this.entries.get(k);
80
+ if (!entry) {
81
+ entry = { idle: [], active: new Set(), warming: new Set() };
82
+ this.entries.set(k, entry);
83
+ }
84
+ return entry;
85
+ }
86
+
87
+ isWarm(cwd: string, agentDir: string, noExtensions: boolean): boolean {
88
+ const entry = this.entries.get(this.key(cwd, agentDir, noExtensions));
89
+ return !!entry && entry.idle.length > 0;
90
+ }
91
+
92
+ async acquire(
93
+ cwd: string,
94
+ agentDir: string,
95
+ noExtensions: boolean,
96
+ systemPromptOverride: string | undefined,
97
+ ): Promise<LoaderLease> {
98
+ const entry = this.getEntry(cwd, agentDir, noExtensions);
99
+
100
+ while (true) {
101
+ const cached = entry.idle.pop();
102
+ if (cached) {
103
+ entry.active.add(cached);
104
+ let released = false;
105
+ return {
106
+ loader: new AgentPromptResourceLoader(cached, systemPromptOverride),
107
+ release: () => {
108
+ if (released) return;
109
+ released = true;
110
+ entry.active.delete(cached);
111
+ entry.idle.push(cached);
112
+ },
113
+ };
114
+ }
115
+
116
+ const warming = entry.warming.values().next().value as Promise<void> | undefined;
117
+ if (warming) {
118
+ await warming;
119
+ continue;
120
+ }
121
+
122
+ const loader = this.factory(makeLoaderOptions(cwd, agentDir, noExtensions));
123
+ const warmPromise = loader
124
+ .reload()
125
+ .then(() => {
126
+ entry.idle.push(loader);
127
+ })
128
+ .finally(() => {
129
+ entry.warming.delete(warmPromise);
130
+ });
131
+ entry.warming.add(warmPromise);
132
+ await warmPromise;
133
+ }
134
+ }
135
+
136
+ warm(cwd: string, agentDir: string, noExtensions: boolean): void {
137
+ const entry = this.getEntry(cwd, agentDir, noExtensions);
138
+ if (entry.idle.length > 0 || entry.active.size > 0 || entry.warming.size > 0) return;
139
+ const loader = this.factory(makeLoaderOptions(cwd, agentDir, noExtensions));
140
+ const warmPromise = loader
141
+ .reload()
142
+ .then(() => {
143
+ entry.idle.push(loader);
144
+ })
145
+ .catch(() => {
146
+ /* ignore warm failures; foreground call reports real error */
147
+ })
148
+ .finally(() => {
149
+ entry.warming.delete(warmPromise);
150
+ });
151
+ entry.warming.add(warmPromise);
152
+ }
153
+
154
+ clear(): void {
155
+ this.entries.clear();
156
+ }
157
+
158
+ /** Test-only inspection helpers. */
159
+ _sizes(cwd: string, agentDir: string, noExtensions: boolean): { idle: number; active: number; warming: number } {
160
+ const entry = this.entries.get(this.key(cwd, agentDir, noExtensions));
161
+ if (!entry) return { idle: 0, active: 0, warming: 0 };
162
+ return { idle: entry.idle.length, active: entry.active.size, warming: entry.warming.size };
163
+ }
164
+ }
165
+
166
+ /** Default singleton used by the extension at runtime. */
167
+ export const defaultLoaderPool = new LoaderPool();
168
+
169
+ export async function allowUiPaint(coldLoader: boolean): Promise<void> {
170
+ await new Promise<void>((resolve) => setImmediate(resolve));
171
+ if (!coldLoader) return;
172
+ // Give pi's TUI render timer a real timers-phase turn before CPU-heavy extension loading.
173
+ await new Promise<void>((resolve) => setTimeout(resolve, 50));
174
+ await new Promise<void>((resolve) => setImmediate(resolve));
175
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fast-subagent",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "In-process subagent delegation for pi with single, parallel, and background modes",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -36,9 +36,19 @@
36
36
  "agents.ts",
37
37
  "background-job-manager.ts",
38
38
  "background-types.ts",
39
+ "format.ts",
40
+ "loader-pool.ts",
41
+ "render.ts",
42
+ "runner.ts",
43
+ "schemas.ts",
44
+ "types.ts",
39
45
  "agents/*.md",
40
46
  "README.md"
41
47
  ],
48
+ "scripts": {
49
+ "typecheck": "tsc --noEmit",
50
+ "test": "node --import tsx --test tests/*.test.ts"
51
+ },
42
52
  "pi": {
43
53
  "extensions": [
44
54
  "./index.ts"
package/render.ts ADDED
@@ -0,0 +1,459 @@
1
+ /**
2
+ * Render function for the `subagent` tool's result panel.
3
+ */
4
+
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+
8
+ import { getAgentDir, Theme, truncateToVisualLines, keyHint } from "@mariozechner/pi-coding-agent";
9
+ import type { AgentToolResult, ToolRenderResultOptions } from "@mariozechner/pi-coding-agent";
10
+ import { truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
11
+ import type { Component } from "@mariozechner/pi-tui";
12
+
13
+ import { formatDuration, formatUsage } from "./format.js";
14
+ import type { SubagentDetails, ToolCallEntry } from "./types.js";
15
+
16
+ import type { ExecutionEvent } from "./types.js";
17
+
18
+ const DEFAULT_PREVIEW_LINES = 12;
19
+ const DEFAULT_PROMPT_PREVIEW_LINES = 12;
20
+
21
+ /**
22
+ * From an ordered event log, find tool calls that ran AFTER the last text_delta.
23
+ * Returns { trailingToolIds, hasAnyText }. When no text has been emitted yet,
24
+ * hasAnyText is false and callers should show all tool calls instead.
25
+ */
26
+ export function findTrailingTools(events: ExecutionEvent[]): {
27
+ trailingToolIds: string[];
28
+ hasAnyText: boolean;
29
+ } {
30
+ if (events.length === 0) return { trailingToolIds: [], hasAnyText: false };
31
+ let lastTextIdx = -1;
32
+ let hasAnyText = false;
33
+ for (let i = events.length - 1; i >= 0; i--) {
34
+ if (events[i]!.type === "text_delta") { lastTextIdx = i; hasAnyText = true; break; }
35
+ }
36
+ const seen = new Set<string>();
37
+ const trailingToolIds: string[] = [];
38
+ for (let i = lastTextIdx + 1; i < events.length; i++) {
39
+ const evt = events[i]!;
40
+ if (evt.type === "tool_start" && !seen.has(evt.toolCallId)) {
41
+ seen.add(evt.toolCallId);
42
+ trailingToolIds.push(evt.toolCallId);
43
+ }
44
+ }
45
+ return { trailingToolIds, hasAnyText };
46
+ }
47
+
48
+ let _settingsCache: { previewLines: number; promptPreviewLines: number; readAt: number } | null = null;
49
+ const SETTINGS_TTL_MS = 2000;
50
+
51
+ function readPreviewSettings(): { previewLines: number; promptPreviewLines: number } {
52
+ const now = Date.now();
53
+ if (_settingsCache && now - _settingsCache.readAt < SETTINGS_TTL_MS) return _settingsCache;
54
+ let previewLines = DEFAULT_PREVIEW_LINES;
55
+ let promptPreviewLines = DEFAULT_PROMPT_PREVIEW_LINES;
56
+ try {
57
+ const path = join(getAgentDir(), "settings.json");
58
+ if (existsSync(path)) {
59
+ const settings = JSON.parse(readFileSync(path, "utf-8")) as {
60
+ fastSubagent?: { previewLines?: number; promptPreviewLines?: number };
61
+ };
62
+ const fs = settings.fastSubagent;
63
+ if (fs && typeof fs.previewLines === "number" && fs.previewLines > 0) {
64
+ previewLines = Math.floor(fs.previewLines);
65
+ }
66
+ if (fs && typeof fs.promptPreviewLines === "number" && fs.promptPreviewLines > 0) {
67
+ promptPreviewLines = Math.floor(fs.promptPreviewLines);
68
+ }
69
+ }
70
+ } catch {
71
+ // fall through to defaults
72
+ }
73
+ _settingsCache = { previewLines, promptPreviewLines, readAt: now };
74
+ return _settingsCache;
75
+ }
76
+
77
+ interface SubagentCallArgs {
78
+ agent?: unknown;
79
+ task?: unknown;
80
+ tasks?: unknown;
81
+ action?: unknown;
82
+ background?: unknown;
83
+ }
84
+
85
+ interface SubagentRenderCallContext {
86
+ state: Record<string, unknown>;
87
+ executionStarted: boolean;
88
+ argsComplete: boolean;
89
+ }
90
+
91
+ function asString(v: unknown): string | undefined {
92
+ return typeof v === "string" ? v : undefined;
93
+ }
94
+
95
+ function taskPreviewLines(task: string, width: number, maxLines: number): { lines: string[]; skipped: number } {
96
+ const innerWidth = Math.max(1, width - 2);
97
+ const visual: string[] = [];
98
+ for (const raw of task.split("\n")) {
99
+ try {
100
+ for (const w of wrapTextWithAnsi(raw, innerWidth)) visual.push(w);
101
+ } catch {
102
+ visual.push(truncateToWidth(raw, innerWidth, "..."));
103
+ }
104
+ }
105
+ const lines = visual.slice(0, maxLines).map((line) => truncateToWidth(` ${line}`, width, "..."));
106
+ return { lines, skipped: Math.max(0, visual.length - lines.length) };
107
+ }
108
+
109
+ /**
110
+ * Render tool-call args while provider is still streaming them. This makes long
111
+ * subagent prompt generation visible before execute() can start.
112
+ */
113
+ export function renderSubagentCall(
114
+ args: SubagentCallArgs,
115
+ theme: Theme,
116
+ context: SubagentRenderCallContext,
117
+ ): Component {
118
+ const cache = context.state as {
119
+ callWidth?: number;
120
+ callLines?: string[];
121
+ callKey?: string;
122
+ };
123
+
124
+ return {
125
+ invalidate() {
126
+ cache.callWidth = undefined;
127
+ cache.callLines = undefined;
128
+ cache.callKey = undefined;
129
+ },
130
+ render(width: number): string[] {
131
+ const key = JSON.stringify({ args, executionStarted: context.executionStarted, argsComplete: context.argsComplete, width });
132
+ if (cache.callWidth === width && cache.callKey === key && cache.callLines) return cache.callLines;
133
+
134
+ const out: string[] = [];
135
+ const agent = asString(args.agent);
136
+ const action = asString(args.action);
137
+ const task = asString(args.task);
138
+ const tasks = Array.isArray(args.tasks) ? args.tasks as Array<Record<string, unknown>> : undefined;
139
+ const isParallel = !!tasks?.length;
140
+ const status = context.executionStarted
141
+ ? "running"
142
+ : context.argsComplete
143
+ ? "starting"
144
+ : task || isParallel
145
+ ? "writing prompt"
146
+ : "waiting for prompt";
147
+ const mode = isParallel ? `Parallel (${tasks!.length})` : "Subagent";
148
+ const bg = args.background === true ? " · background" : "";
149
+ const target = agent ? ` ${agent}` : action ? ` ${action}` : "";
150
+ out.push(truncateToWidth(`${theme.fg("toolTitle", mode)}${target}${bg} · ${theme.fg("dim", status)}`, width, "..."));
151
+
152
+ // Once execution starts, result renderer owns prompt display. Keep call row compact.
153
+ if (context.executionStarted) {
154
+ cache.callWidth = width;
155
+ cache.callKey = key;
156
+ cache.callLines = out;
157
+ return out;
158
+ }
159
+
160
+ const maxLines = readPreviewSettings().promptPreviewLines;
161
+ if (task) {
162
+ out.push(truncateToWidth("Prompt:", width, "..."));
163
+ const preview = taskPreviewLines(task, width, maxLines);
164
+ out.push(...preview.lines);
165
+ if (preview.skipped > 0) out.push(truncateToWidth(theme.fg("muted", ` … (${preview.skipped} more lines)`), width, "..."));
166
+ } else if (tasks?.length) {
167
+ const maxRows = Math.max(1, Math.min(maxLines, tasks.length));
168
+ for (let i = 0; i < maxRows; i++) {
169
+ const t = tasks[i]!;
170
+ const rowAgent = asString(t.agent) ?? "?";
171
+ const rowTask = asString(t.task) ?? "";
172
+ out.push(truncateToWidth(` [${rowAgent}] ${rowTask || theme.fg("dim", "writing prompt...")}`, width, "..."));
173
+ }
174
+ if (tasks.length > maxRows) out.push(truncateToWidth(theme.fg("muted", ` … (${tasks.length - maxRows} more task${tasks.length - maxRows === 1 ? "" : "s"})`), width, "..."));
175
+ } else {
176
+ out.push(truncateToWidth(theme.fg("dim", " waiting for streamed tool arguments..."), width, "..."));
177
+ }
178
+
179
+ cache.callWidth = width;
180
+ cache.callKey = key;
181
+ cache.callLines = out;
182
+ return out;
183
+ },
184
+ };
185
+ }
186
+
187
+ export function renderSubagentResult(
188
+ result: AgentToolResult<unknown>,
189
+ { isPartial, expanded }: ToolRenderResultOptions,
190
+ theme: Theme,
191
+ ) {
192
+ const agentText = result.content?.[0]?.type === "text" ? (result.content[0] as any).text as string : "";
193
+ const details = (result.details ?? {}) as SubagentDetails;
194
+ const toolCalls = details.toolCalls ?? [];
195
+
196
+ // ── Parallel mode render ──────────────────────────────────────
197
+ if (details.mode === "parallel" && details.parallelAgents) {
198
+ const agents = details.parallelAgents;
199
+ const doneCount = agents.filter((a) => a.status === "done" || a.status === "error").length;
200
+
201
+ function agentToolRow(t: ToolCallEntry): string {
202
+ const arg = t.argSummary || "";
203
+ const call = `${t.name}(${arg})`;
204
+ if (t.result === undefined) return theme.fg("dim", call);
205
+ const dur = t.durMs != null ? (t.durMs < 1000 ? ` ${t.durMs}ms` : ` ${(t.durMs / 1000).toFixed(1)}s`) : "";
206
+ return `${call}${t.isError ? " ✗" : ` ✓${dur}`}`;
207
+ }
208
+
209
+ function wrapL(text: string, w: number): string[] {
210
+ try { return wrapTextWithAnsi(text, w); } catch { return [truncateToWidth(text, w, "...")]; }
211
+ }
212
+
213
+ const cache: { width?: number } = {};
214
+ return {
215
+ invalidate() { cache.width = undefined; },
216
+ render(width: number): string[] {
217
+ const out: string[] = [];
218
+ const header = details.running
219
+ ? `Parallel (${doneCount}/${agents.length} done)`
220
+ : `Parallel: ${agents.filter((a) => a.status === "done").length}/${agents.length} succeeded`;
221
+ out.push(truncateToWidth(header, width, "..."));
222
+
223
+ for (const a of agents) {
224
+ const dur = a.durMs != null ? (a.durMs < 1000 ? ` ${a.durMs}ms` : ` ${(a.durMs / 1000).toFixed(1)}s`) : "";
225
+ const mark = a.status === "pending" ? theme.fg("dim", "⋅")
226
+ : a.status === "running" ? theme.fg("dim", "→")
227
+ : a.status === "done" ? `✓${dur}` : `✗${dur}`;
228
+
229
+ if (expanded) {
230
+ out.push("");
231
+ out.push(truncateToWidth(`[${a.name}] ${mark}`, width, "..."));
232
+ out.push(truncateToWidth(`Prompt:`, width, "..."));
233
+ out.push(truncateToWidth(` ${a.taskSummary}`, width, "..."));
234
+ for (const t of a.toolCalls ?? []) {
235
+ out.push(truncateToWidth(agentToolRow(t), width, "..."));
236
+ }
237
+ if (a.responseText) {
238
+ out.push("Response:");
239
+ const preview = truncateToVisualLines(a.responseText, 6, width - 2);
240
+ for (const l of preview.visualLines) out.push(truncateToWidth(" " + l, width, "..."));
241
+ if (preview.skippedCount > 0) out.push(truncateToWidth(theme.fg("dim", ` … ${preview.skippedCount} more lines`), width, "..."));
242
+ } else if (a.status === "running") {
243
+ out.push(theme.fg("dim", " running..."));
244
+ }
245
+ } else {
246
+ const row = ` [${a.name}] ${mark} ${a.taskSummary}`;
247
+ out.push(truncateToWidth(row, width, "..."));
248
+ for (const t of a.toolCalls ?? []) {
249
+ out.push(truncateToWidth(` ${agentToolRow(t)}`, width, "..."));
250
+ }
251
+ if (a.responseText && (a.status === "done" || a.status === "error")) {
252
+ const preview = truncateToVisualLines(a.responseText, 2, width - 4);
253
+ for (const l of preview.visualLines) out.push(truncateToWidth(" " + l, width, "..."));
254
+ }
255
+ }
256
+ }
257
+
258
+ out.push("");
259
+ const status = details.running
260
+ ? ["running", details.usage?.turns ? `${details.usage.turns} turn${details.usage.turns > 1 ? "s" : ""}` : ""].filter(Boolean).join(" · ")
261
+ : formatUsage(details.usage ?? { input: 0, output: 0, cost: 0, turns: 0 }, details.model);
262
+ const expandHint = !expanded ? keyHint("app.tools.expand", "expand for full output") : "";
263
+ out.push(truncateToWidth([status, expandHint].filter(Boolean).join(" "), width, "..."));
264
+ // Suppress unused warning
265
+ void wrapL;
266
+ return out;
267
+ },
268
+ };
269
+ }
270
+
271
+ // ── Single mode render ────────────────────────────────────────
272
+
273
+ function statusLine(): string {
274
+ if (details.backgroundJobId) return `moved to background · ${details.backgroundJobId}`;
275
+ if (details.running) {
276
+ const parts: string[] = ["running"];
277
+ if (details.usage?.turns) parts.push(`${details.usage.turns} turn${details.usage.turns > 1 ? "s" : ""}`);
278
+ if (details.elapsedMs != null) parts.push(formatDuration(details.elapsedMs));
279
+ if (details.model) parts.push(details.model);
280
+ return parts.join(" · ");
281
+ }
282
+ return formatUsage(details.usage ?? { input: 0, output: 0, cost: 0, turns: 0 }, details.model);
283
+ }
284
+
285
+ function toolRow(t: ToolCallEntry): string {
286
+ const arg = t.argSummary ? t.argSummary : "";
287
+ const call = `${t.name}(${arg})`;
288
+ if (t.result === undefined) return theme.fg("dim", call);
289
+ const dur = t.durMs != null
290
+ ? t.durMs < 1000 ? ` ${t.durMs}ms` : ` ${(t.durMs / 1000).toFixed(1)}s`
291
+ : "";
292
+ return `${call}${t.isError ? " ✗" : ` ✓${dur}`}`;
293
+ }
294
+
295
+ function wrapLine(text: string, w: number): string[] {
296
+ try { return wrapTextWithAnsi(text, w); } catch { return [truncateToWidth(text, w, "...")]; }
297
+ }
298
+
299
+ const cache: {
300
+ width?: number;
301
+ promptLines?: string[];
302
+ promptSkipped?: number;
303
+ responseLines?: string[];
304
+ skipped?: number;
305
+ } = {};
306
+
307
+ function renderExpandedChronological(width: number): string[] {
308
+ const out: string[] = [];
309
+ const indent = " ";
310
+ const events = details.executionEvents || [];
311
+ const toolLineMap = new Map<string, number>(); // toolCallId → line index where tool_start was rendered
312
+
313
+ if (details.task) {
314
+ out.push("Prompt:");
315
+ for (const line of details.task.split("\n")) {
316
+ for (const w of wrapLine(indent + line, width)) out.push(w);
317
+ }
318
+ }
319
+
320
+ if (events.length === 0) {
321
+ // Fallback: no events, render tool calls then response
322
+ for (const t of toolCalls) {
323
+ out.push(truncateToWidth(toolRow(t), width, "..."));
324
+ if (t.result !== undefined) {
325
+ for (const line of t.result.split("\n")) {
326
+ for (const w of wrapLine(theme.fg("dim", indent + line), width)) out.push(w);
327
+ }
328
+ }
329
+ }
330
+ const responseText = agentText || "";
331
+ if (responseText) {
332
+ for (const line of responseText.split("\n")) {
333
+ for (const w of wrapLine(indent + line, width)) out.push(w);
334
+ }
335
+ }
336
+ } else {
337
+ // Render events chronologically
338
+ let lastWasText = false;
339
+ const agentLabel = `${details.agentName ?? "Agent"}:`;
340
+ for (const evt of events) {
341
+ if (evt.type === "tool_start") {
342
+ lastWasText = false;
343
+ const call = `${evt.toolName}(${evt.argSummary})`;
344
+ toolLineMap.set(evt.toolCallId, out.length);
345
+ out.push(truncateToWidth(call, width, "..."));
346
+ } else if (evt.type === "text_delta") {
347
+ if (!lastWasText) {
348
+ out.push(truncateToWidth(theme.fg("toolTitle", agentLabel), width, "..."));
349
+ lastWasText = true;
350
+ }
351
+ for (const line of evt.text.split("\n")) {
352
+ for (const w of wrapLine(indent + line, width)) out.push(w);
353
+ }
354
+ } else if (evt.type === "tool_end") {
355
+ lastWasText = false;
356
+ const toolLineIdx = toolLineMap.get(evt.toolCallId);
357
+ const dur = evt.durMs != null
358
+ ? evt.durMs < 1000 ? ` ${evt.durMs}ms` : ` ${(evt.durMs / 1000).toFixed(1)}s`
359
+ : "";
360
+ const statusMark = evt.isError ? " ✗" : ` ✓${dur}`;
361
+ if (toolLineIdx != null) {
362
+ out[toolLineIdx] = truncateToWidth((out[toolLineIdx] ?? "") + statusMark, width, "...");
363
+ }
364
+ if (evt.result) {
365
+ for (const line of evt.result.split("\n")) {
366
+ for (const w of wrapLine(theme.fg("dim", indent + line), width)) out.push(w);
367
+ }
368
+ }
369
+ }
370
+ }
371
+ }
372
+
373
+ return out;
374
+ }
375
+
376
+ return {
377
+ invalidate() { cache.width = undefined; },
378
+ render(width: number): string[] {
379
+ const out: string[] = [];
380
+ const indent = " ";
381
+ const ellipsisLine = (count: number) =>
382
+ theme.fg("muted", `${indent}… (${count} more line${count === 1 ? "" : "s"})`);
383
+
384
+ if (expanded) {
385
+ // Expanded: render chronologically from events
386
+ const expandedOut = renderExpandedChronological(width);
387
+ expandedOut.push("");
388
+ const status = statusLine();
389
+ if (status) expandedOut.push(truncateToWidth(status, width, "..."));
390
+ if (details.running && !details.backgroundJobId) {
391
+ expandedOut.push(truncateToWidth(theme.fg("dim", "Ctrl+Shift+B: move to background"), width, "..."));
392
+ }
393
+ return expandedOut;
394
+ }
395
+
396
+ // Collapsed view
397
+ if (details.task) {
398
+ out.push("Prompt:");
399
+ const PROMPT_PREVIEW_LINES = readPreviewSettings().promptPreviewLines;
400
+ if (cache.width !== width || cache.promptLines === undefined) {
401
+ const innerWidth = Math.max(1, width - indent.length);
402
+ const allVisual: string[] = [];
403
+ for (const raw of details.task.split("\n")) {
404
+ for (const w of wrapLine(raw, innerWidth)) allVisual.push(w);
405
+ }
406
+ const head = allVisual.slice(0, PROMPT_PREVIEW_LINES);
407
+ cache.promptLines = head.map((l) => truncateToWidth(indent + l, width, "..."));
408
+ cache.promptSkipped = Math.max(0, allVisual.length - head.length);
409
+ }
410
+ out.push(...cache.promptLines);
411
+ if ((cache.promptSkipped ?? 0) > 0) {
412
+ out.push(truncateToWidth(ellipsisLine(cache.promptSkipped!), width, "..."));
413
+ }
414
+ }
415
+
416
+ // Find trailing tool calls (those that ran AFTER the last text_delta)
417
+ const { trailingToolIds, hasAnyText } = findTrailingTools(details.executionEvents || []);
418
+
419
+ const responseText = agentText || (isPartial ? "" : "");
420
+ if (responseText || isPartial) {
421
+ const agentLabel = `${details.agentName ?? "Agent"}:`;
422
+ out.push(truncateToWidth(theme.fg("toolTitle", agentLabel), width, "..."));
423
+ const PREVIEW_LINES = readPreviewSettings().previewLines;
424
+ if (cache.width !== width) {
425
+ const preview = truncateToVisualLines(responseText, PREVIEW_LINES, width - indent.length);
426
+ cache.responseLines = preview.visualLines.map((l) => truncateToWidth(indent + l, width, "..."));
427
+ cache.skipped = preview.skippedCount;
428
+ cache.width = width;
429
+ }
430
+ out.push(...(cache.responseLines ?? []));
431
+ if ((cache.skipped ?? 0) > 0) {
432
+ out.push(truncateToWidth(ellipsisLine(cache.skipped!), width, "..."));
433
+ }
434
+ }
435
+
436
+ // Show trailing tool calls (those that ran after the last text block)
437
+ // If no text yet, show all tools to indicate progress.
438
+ const toolsToShow = hasAnyText
439
+ ? toolCalls.filter((t) => trailingToolIds.includes(t.id))
440
+ : toolCalls;
441
+ for (let i = 0; i < toolsToShow.length; i++) {
442
+ const t = toolsToShow[i]!;
443
+ const isLast = i === toolsToShow.length - 1;
444
+ const connector = theme.fg("muted", isLast ? "└─ " : "├─ ");
445
+ out.push(truncateToWidth(indent + connector + toolRow(t), width, "..."));
446
+ }
447
+
448
+ const status = statusLine();
449
+ const totalSkipped = (cache.skipped ?? 0) + (cache.promptSkipped ?? 0);
450
+ const expandHint = !expanded ? keyHint("app.tools.expand", "verbose") : "";
451
+ const statusWithHint = [status, expandHint].filter(Boolean).join(" ");
452
+ if (statusWithHint) out.push(truncateToWidth(statusWithHint, width, "..."));
453
+ if (details.running && !details.backgroundJobId)
454
+ out.push(truncateToWidth(theme.fg("dim", "Ctrl+Shift+B: move to background"), width, "..."));
455
+
456
+ return out;
457
+ },
458
+ };
459
+ }