pi-agenticoding 0.1.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,809 @@
1
+ /**
2
+ * TUI rendering components for spawned child agent sessions.
3
+ *
4
+ * Provides the live-updating NestedAgentSessionComponent that renders a
5
+ * child agent's ongoing work in the parent's TUI, plus the renderCall
6
+ * and renderResult functions used by the spawn tool definitions.
7
+ */
8
+
9
+ import type { AgentSession } from "@earendil-works/pi-coding-agent";
10
+ import {
11
+ AssistantMessageComponent,
12
+ BashExecutionComponent,
13
+ CustomMessageComponent,
14
+ getMarkdownTheme,
15
+ keyHint,
16
+ parseSkillBlock,
17
+ SkillInvocationMessageComponent,
18
+ ToolExecutionComponent,
19
+ UserMessageComponent,
20
+ } from "@earendil-works/pi-coding-agent";
21
+ import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
22
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
23
+ import { Container, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
24
+ import type { TUI } from "@earendil-works/pi-tui";
25
+ import type { AgenticodingState } from "../state.js";
26
+ import {
27
+ getLastAssistantText,
28
+ type SpawnOutcome,
29
+ type SpawnResultDetails,
30
+ } from "./shared.js";
31
+
32
+ // ── Render-only constants ────────────────────────────────────────────
33
+
34
+ const COLLAPSED_PREVIEW_MAX_LINES = 5;
35
+ const INDENT_SPACES_PER_DEPTH = 4;
36
+ const PROMPT_PREVIEW_COLLAPSED_LINES = 3;
37
+ const TOOL_RESULT_PREVIEW_CHARS = 60;
38
+ const LIVE_TEXT_PREVIEW_CHARS = 80;
39
+ const COST_THRESHOLD_COMPACT = 1000;
40
+ const COST_THRESHOLD_DECIMAL = 10;
41
+
42
+ // ── Render-only types ────────────────────────────────────────────────
43
+
44
+ type ToolResultLike = {
45
+ content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
46
+ details?: unknown;
47
+ isError?: boolean;
48
+ };
49
+
50
+ /**
51
+ * Message shapes from a spawned child session.
52
+ * Covers both standard LLM messages and extension-injected custom types
53
+ * (bashExecution, custom) without depending on SDK module augmentation types.
54
+ */
55
+ type SpawnChildMessage = {
56
+ role: string;
57
+ content?: Array<{ type: string; text?: string; id?: string; name?: string; arguments?: Record<string, unknown> }>;
58
+ stopReason?: unknown;
59
+ errorMessage?: string;
60
+ toolCallId?: string;
61
+ command?: string;
62
+ output?: string;
63
+ exitCode?: number;
64
+ cancelled?: boolean;
65
+ truncated?: boolean;
66
+ fullOutputPath?: string;
67
+ excludeFromContext?: boolean;
68
+ customType?: string;
69
+ display?: boolean;
70
+ details?: unknown;
71
+ };
72
+
73
+ // ── Render-only helpers ──────────────────────────────────────────────
74
+
75
+ /** Runtime guard: validate that a value is structurally compatible with ToolResultLike. */
76
+ function asToolResult(value: unknown): ToolResultLike {
77
+ if (typeof value === "object" && value !== null && Array.isArray((value as any).content)) {
78
+ return value as ToolResultLike;
79
+ }
80
+ return { content: [] };
81
+ }
82
+
83
+ function getStopReasonOutcome(stopReason: unknown): SpawnOutcome | undefined {
84
+ if (stopReason === "aborted") return "aborted";
85
+ if (stopReason === "error") return "error";
86
+ return undefined;
87
+ }
88
+
89
+ function getOutcomeMarker(outcome: SpawnOutcome): string {
90
+ switch (outcome) {
91
+ case "success":
92
+ return "✅ ";
93
+ case "aborted":
94
+ return "✗ ";
95
+ case "error":
96
+ return "⚠ ";
97
+ default:
98
+ return "";
99
+ }
100
+ }
101
+
102
+ function getOutcomeStatusText(outcome: SpawnOutcome): string | undefined {
103
+ switch (outcome) {
104
+ case "success":
105
+ return "💬 done";
106
+ case "aborted":
107
+ return "💬 aborted";
108
+ case "error":
109
+ return "💬 error";
110
+ default:
111
+ return undefined;
112
+ }
113
+ }
114
+
115
+ function isExpectedToolComponentFailure(error: unknown): boolean {
116
+ return error instanceof Error && (
117
+ /missing tool definition/i.test(error.message)
118
+ || /theme not initialized/i.test(error.message)
119
+ );
120
+ }
121
+
122
+ function renderPromptPreview(prompt: string, expanded: boolean): { shown: string; remaining: number } {
123
+ const lines = prompt.split("\n");
124
+ const maxLines = expanded ? lines.length : PROMPT_PREVIEW_COLLAPSED_LINES;
125
+ return {
126
+ shown: lines.slice(0, maxLines).join("\n"),
127
+ remaining: Math.max(0, lines.length - maxLines),
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Safe wrapper around keyHint().
133
+ * keyHint() may throw when the TUI keybinding registry isn't initialized
134
+ * (e.g., during tests or headless mode). Returns the fallback in that case.
135
+ */
136
+ function safeKeyHint(action: string, fallback: string): string {
137
+ try {
138
+ return keyHint(action, fallback);
139
+ } catch {
140
+ return fallback;
141
+ }
142
+ }
143
+
144
+ // ── NestedAgentSessionComponent ───────────────────────────────────────
145
+
146
+ /**
147
+ * Renders a live child agent session in the parent's TUI.
148
+ *
149
+ * Three responsibilities:
150
+ * 1. Collapsed view — identity line with completion marker (✅ when done),
151
+ * live "last action" summary (tool name + result preview, or assistant
152
+ * text preview), 5-line preview of last assistant output when available,
153
+ * token/cost summary.
154
+ * 2. Expanded view — full chat history with 4-space indent per depth level.
155
+ * 3. Session lifecycle — subscribes to child session events, streams tool
156
+ * executions and assistant messages in real time, maintains live action
157
+ * tracking via lastAction field updated on every event.
158
+ *
159
+ * Render caching: caches output by width/expanded/showImages to avoid
160
+ * unnecessary re-renders when none of those inputs changed.
161
+ */
162
+ class NestedAgentSessionComponent extends Container {
163
+ private session?: AgentSession;
164
+ private pendingTools = new Map<string, ToolExecutionComponent>();
165
+ private toolComponents = new Set<ToolExecutionComponent>();
166
+ private streamingComponent?: AssistantMessageComponent;
167
+ private unsubscribe?: () => void;
168
+ private expanded = false;
169
+ private showImages = true;
170
+ private requestRender: () => void = () => {};
171
+ private readonly markdownTheme = getMarkdownTheme();
172
+ // Minimal TUI mock for ToolExecutionComponent/BashExecutionComponent.
173
+ // Spawn runs in-memory without a real TUI — only requestRender is needed
174
+ // to trigger parent re-renders when child events arrive.
175
+ private readonly fakeUi = {
176
+ requestRender: () => this.requestRender(),
177
+ } as { requestRender: () => void };
178
+ private details?: SpawnResultDetails;
179
+ private nestTheme?: Theme;
180
+ private ownedToolCallId?: string;
181
+ private liveChildSessions?: Map<string, AgentSession>;
182
+ private liveOutcome: SpawnOutcome = "running";
183
+ // States: "⏳ initializing…" → "💭 thinking…" → "[tool] …/preview" or live text → terminal outcome
184
+ private lastAction = "";
185
+ private toolNames = new Map<string, string>();
186
+ private toolComponentFailures = new Set<string>();
187
+ private cachedWidth?: number;
188
+ private cachedExpanded?: boolean;
189
+ private cachedLines?: string[];
190
+ private cachedShowImages?: boolean;
191
+
192
+ private clearRenderCache(): void {
193
+ this.cachedWidth = undefined;
194
+ this.cachedExpanded = undefined;
195
+ this.cachedLines = undefined;
196
+ this.cachedShowImages = undefined;
197
+ }
198
+
199
+ setRequestRender(requestRender: () => void): void {
200
+ this.requestRender = requestRender;
201
+ }
202
+
203
+ setExpanded(expanded: boolean): void {
204
+ if (this.expanded === expanded) return;
205
+ this.expanded = expanded;
206
+ this.clearRenderCache();
207
+ for (const component of this.toolComponents) {
208
+ component.setExpanded(expanded);
209
+ }
210
+ }
211
+
212
+ setShowImages(showImages: boolean): void {
213
+ if (this.showImages === showImages) return;
214
+ this.showImages = showImages;
215
+ this.clearRenderCache();
216
+ for (const component of this.toolComponents) {
217
+ component.setShowImages(showImages);
218
+ }
219
+ }
220
+
221
+ setDetails(details: SpawnResultDetails, theme: Theme): void {
222
+ const changed = this.details !== details || this.nestTheme !== theme;
223
+ this.details = details;
224
+ this.nestTheme = theme;
225
+ this.liveOutcome = details.outcome;
226
+ if (changed) this.clearRenderCache();
227
+ }
228
+
229
+ attachSession(toolCallId: string, session: AgentSession, liveChildSessions?: Map<string, AgentSession>): void {
230
+ if (
231
+ this.session === session
232
+ && this.ownedToolCallId === toolCallId
233
+ && this.liveChildSessions === liveChildSessions
234
+ ) {
235
+ return;
236
+ }
237
+
238
+ this.unsubscribe?.();
239
+ this.unsubscribe = undefined;
240
+ this.session = session;
241
+ this.ownedToolCallId = toolCallId;
242
+ this.liveChildSessions = liveChildSessions;
243
+ this.liveOutcome = this.details?.outcome ?? "running";
244
+ this.toolNames.clear();
245
+ this.toolComponentFailures.clear();
246
+ this.clearRenderCache();
247
+ this.rebuildFromSession();
248
+ try {
249
+ this.unsubscribe = typeof session.subscribe === "function"
250
+ ? session.subscribe((event) => {
251
+ this.handleEvent(event);
252
+ })
253
+ : undefined;
254
+ } catch (error) {
255
+ this.unsubscribe = undefined;
256
+ console.warn("[spawn] Failed to subscribe to child session events:", this.ownedToolCallId, error);
257
+ }
258
+ }
259
+
260
+ override invalidate(): void {
261
+ super.invalidate();
262
+ this.clearRenderCache();
263
+ if (this.session) {
264
+ this.rebuildFromSession();
265
+ }
266
+ }
267
+
268
+ hasSession(): boolean {
269
+ return !!this.session;
270
+ }
271
+
272
+ /**
273
+ * Returns true when the session held by this component has been replaced
274
+ * in the liveChildSessions map (e.g., after a resetState). When stale, the
275
+ * component silently drops all events to avoid operating on a different
276
+ * session than what it was attached to.
277
+ */
278
+ private isStaleSession(): boolean {
279
+ return !!(
280
+ this.session
281
+ && this.ownedToolCallId
282
+ && this.liveChildSessions
283
+ && this.liveChildSessions.get(this.ownedToolCallId) !== this.session
284
+ );
285
+ }
286
+
287
+ dispose(): void {
288
+ this.unsubscribe?.();
289
+ this.unsubscribe = undefined;
290
+ // Snapshot fields before clearing: if session.abort() triggers re-entrant
291
+ // dispose, the nulled-out fields prevent double-abort.
292
+ const session = this.session;
293
+ const ownedToolCallId = this.ownedToolCallId;
294
+ const liveChildSessions = this.liveChildSessions;
295
+ this.clearRenderCache();
296
+ this.details = undefined;
297
+ this.nestTheme = undefined;
298
+ this.liveOutcome = "running";
299
+ this.toolNames.clear();
300
+ this.toolComponentFailures.clear();
301
+ this.session = undefined;
302
+ this.ownedToolCallId = undefined;
303
+ this.liveChildSessions = undefined;
304
+ if (session && ownedToolCallId && liveChildSessions?.get(ownedToolCallId) === session) {
305
+ session.abort().catch(e => console.error("[spawn] abort failed:", ownedToolCallId, e));
306
+ liveChildSessions.delete(ownedToolCallId);
307
+ }
308
+ }
309
+
310
+ private addToolComponent(component?: ToolExecutionComponent): void {
311
+ if (!component) return;
312
+ component.setExpanded(this.expanded);
313
+ component.setShowImages(this.showImages);
314
+ this.toolComponents.add(component);
315
+ this.addChild(component);
316
+ }
317
+
318
+ private createToolComponent(toolName: string, toolCallId: string, args: Record<string, unknown>): ToolExecutionComponent | undefined {
319
+ try {
320
+ return new ToolExecutionComponent(
321
+ toolName,
322
+ toolCallId,
323
+ args,
324
+ { showImages: this.showImages },
325
+ this.session?.getToolDefinition(toolName),
326
+ this.fakeUi as unknown as TUI,
327
+ this.session?.sessionManager.getCwd() ?? process.cwd(),
328
+ );
329
+ } catch (error) {
330
+ if (isExpectedToolComponentFailure(error)) {
331
+ return undefined;
332
+ }
333
+ const failureKey = `${toolCallId}:${toolName}`;
334
+ if (!this.toolComponentFailures.has(failureKey)) {
335
+ this.toolComponentFailures.add(failureKey);
336
+ console.warn("[spawn] Failed to create tool component:", toolCallId, toolName, error);
337
+ }
338
+ return undefined;
339
+ }
340
+ }
341
+
342
+ private addMessageToChat(message: SpawnChildMessage): void {
343
+ switch (message.role) {
344
+ case "bashExecution": {
345
+ const component = new BashExecutionComponent(message.command, this.fakeUi as unknown as TUI, message.excludeFromContext);
346
+ if (message.output) {
347
+ component.appendOutput(message.output);
348
+ }
349
+ component.setComplete(message.exitCode, message.cancelled, message.truncated ? { truncated: true } : undefined, message.fullOutputPath);
350
+ this.addChild(component);
351
+ break;
352
+ }
353
+ case "custom": {
354
+ if (message.display) {
355
+ const component = new CustomMessageComponent(message, undefined, this.markdownTheme);
356
+ component.setExpanded(this.expanded);
357
+ this.addChild(component);
358
+ }
359
+ break;
360
+ }
361
+ case "user": {
362
+ const blocks = Array.isArray(message.content) ? message.content : [];
363
+ const text = blocks
364
+ .filter((block: { type: string; text?: string }) => block.type === "text" && typeof block.text === "string")
365
+ .map((block: { type: string; text?: string }) => block.text ?? "")
366
+ .join("\n")
367
+ .trim();
368
+ if (!text) break;
369
+ if (this.children.length > 0) {
370
+ this.addChild(new Spacer(1));
371
+ }
372
+ const skillBlock = parseSkillBlock(text);
373
+ if (skillBlock) {
374
+ const component = new SkillInvocationMessageComponent(skillBlock, this.markdownTheme);
375
+ component.setExpanded(this.expanded);
376
+ this.addChild(component);
377
+ if (skillBlock.userMessage) {
378
+ this.addChild(new UserMessageComponent(skillBlock.userMessage, this.markdownTheme));
379
+ }
380
+ } else {
381
+ this.addChild(new UserMessageComponent(text, this.markdownTheme));
382
+ }
383
+ break;
384
+ }
385
+ case "assistant": {
386
+ this.addChild(new AssistantMessageComponent(message, false, this.markdownTheme, "Thinking..."));
387
+ break;
388
+ }
389
+ case "toolResult": {
390
+ break;
391
+ }
392
+ }
393
+ }
394
+
395
+ private rebuildFromSession(): void {
396
+ if (!this.session) return;
397
+
398
+ this.clear();
399
+ this.pendingTools.clear();
400
+ this.toolComponents.clear();
401
+ this.streamingComponent = undefined;
402
+ this.liveOutcome = this.details?.outcome ?? "running";
403
+ this.lastAction = getOutcomeStatusText(this.liveOutcome) ?? "";
404
+ const renderedPendingTools = new Map<string, ToolExecutionComponent>();
405
+
406
+ for (const message of this.session.messages as SpawnChildMessage[]) {
407
+ if (message.role === "assistant") {
408
+ const stopOutcome = getStopReasonOutcome(message.stopReason);
409
+ if (stopOutcome) {
410
+ this.liveOutcome = stopOutcome;
411
+ this.lastAction = getOutcomeStatusText(stopOutcome) ?? this.lastAction;
412
+ }
413
+ this.addMessageToChat(message);
414
+ for (const content of message.content ?? []) {
415
+ if (content.type !== "toolCall") continue;
416
+ const component = this.createToolComponent(content.name, content.id, content.arguments ?? {});
417
+ this.addToolComponent(component);
418
+ if (!component) continue;
419
+ if (stopOutcome) {
420
+ const errorMessage = stopOutcome === "aborted"
421
+ ? message.errorMessage || "Operation aborted"
422
+ : message.errorMessage || "Error";
423
+ component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true });
424
+ } else {
425
+ renderedPendingTools.set(content.id, component);
426
+ }
427
+ }
428
+ continue;
429
+ }
430
+
431
+ if (message.role === "toolResult") {
432
+ const component = renderedPendingTools.get(message.toolCallId);
433
+ if (component) {
434
+ component.updateResult(message);
435
+ renderedPendingTools.delete(message.toolCallId);
436
+ }
437
+ continue;
438
+ }
439
+
440
+ this.addMessageToChat(message);
441
+ }
442
+
443
+ for (const [toolCallId, component] of renderedPendingTools) {
444
+ this.pendingTools.set(toolCallId, component);
445
+ }
446
+ }
447
+
448
+ override render(width: number): string[] {
449
+ if (
450
+ this.cachedLines
451
+ && this.cachedWidth === width
452
+ && this.cachedExpanded === this.expanded
453
+ && this.cachedShowImages === this.showImages
454
+ ) {
455
+ return this.cachedLines;
456
+ }
457
+ const lines = this.expanded ? this.renderExpanded(width) : this.renderCollapsed(width);
458
+ this.cachedWidth = width;
459
+ this.cachedExpanded = this.expanded;
460
+ this.cachedShowImages = this.showImages;
461
+ this.cachedLines = lines;
462
+ return lines;
463
+ }
464
+
465
+ private extractPreview(result: ToolResultLike): string {
466
+ const text = result.content?.find(c => c.type === "text" && c.text)?.text;
467
+ if (!text) return "";
468
+ return text.trim().split("\n")[0].slice(0, TOOL_RESULT_PREVIEW_CHARS);
469
+ }
470
+
471
+ private renderCollapsed(width: number): string[] {
472
+ const lines: string[] = [];
473
+ const details = this.details;
474
+ const theme = this.nestTheme;
475
+ const outcome = this.liveOutcome;
476
+ // Theme may be undefined in tests or before setDetails — fall back to plain text
477
+ const color = (name: ThemeColor, text: string) => theme ? theme.fg(name, text) : text;
478
+
479
+ // Identity line — distinguishes nested spawns in collapsed view
480
+ if (details) {
481
+ const depthLabel = details.depth > 0 ? `[depth ${details.depth}] ` : "";
482
+ lines.push(truncateToWidth(
483
+ color("dim", `${getOutcomeMarker(outcome)}${depthLabel}${details.model} • ${details.thinking}`),
484
+ width,
485
+ ));
486
+ }
487
+
488
+ if (outcome === "running") {
489
+ const liveSummary = this.lastAction || "⏳ initializing…";
490
+ lines.push(truncateToWidth(color("dim", liveSummary), width));
491
+ } else if (outcome !== "success") {
492
+ const outcomeText = getOutcomeStatusText(outcome);
493
+ if (outcomeText) {
494
+ lines.push(truncateToWidth(color(outcome === "error" ? "warning" : "dim", outcomeText), width));
495
+ }
496
+ }
497
+
498
+ // Preview last assistant output — 5 lines for context without noise
499
+ const summaryText = this.session ? getLastAssistantText(this.session.messages) : "";
500
+ if (summaryText) {
501
+ const textLines = summaryText.split("\n");
502
+ const maxLines = COLLAPSED_PREVIEW_MAX_LINES;
503
+ const shown = textLines.slice(0, maxLines);
504
+ for (const line of shown) {
505
+ lines.push(truncateToWidth(color("toolOutput", line), width));
506
+ }
507
+ const remaining = textLines.length - maxLines;
508
+ if (remaining > 0) {
509
+ lines.push(truncateToWidth(
510
+ color("muted", `... ${remaining} more lines`),
511
+ width,
512
+ ));
513
+ }
514
+ }
515
+
516
+ // Token/cost summary — quick usage check without expanding
517
+ if (details?.stats) {
518
+ const s = details.stats;
519
+ const cost = s.cost ?? 0;
520
+ const costStr = cost >= COST_THRESHOLD_COMPACT ? cost.toFixed(0) : cost >= COST_THRESHOLD_DECIMAL ? cost.toFixed(2) : cost.toFixed(4);
521
+ const truncated = details.truncated ? color("warning", " [truncated]") : "";
522
+ const statsLine = `tokens: ${s.inputTokens ?? "?"}/${s.outputTokens ?? "?"} · ${s.turns ?? "?"} turns · $${costStr}${truncated}`;
523
+ lines.push(truncateToWidth(color("dim", statsLine), width));
524
+ } else if (details?.statsUnavailable) {
525
+ lines.push(truncateToWidth(color("muted", "stats unavailable"), width));
526
+ }
527
+
528
+ return lines;
529
+ }
530
+
531
+ private renderExpanded(width: number): string[] {
532
+ // Renders children directly rather than via super.render() to apply
533
+ // depth-based indentation. Container.render() from pi-tui is a simple
534
+ // passthrough (no layout/decoration) so this is equivalent. If it ever
535
+ // adds padding or inter-child spacing, switch to super.render() and
536
+ // post-process lines to add indentation.
537
+ const depth = this.details?.depth ?? 0;
538
+ const indent = depth * INDENT_SPACES_PER_DEPTH;
539
+ const childWidth = Math.max(1, width - indent);
540
+ const leftPad = " ".repeat(indent);
541
+ const lines: string[] = [];
542
+
543
+ // Show identity header when expanded — anchors which nested session this is
544
+ const colorExpanded = (name: ThemeColor, text: string) => this.nestTheme ? this.nestTheme.fg(name, text) : text;
545
+ if (this.details) {
546
+ const header = `${getOutcomeMarker(this.liveOutcome)}${this.details.model} • ${this.details.thinking}`;
547
+ lines.push(leftPad + truncateToWidth(
548
+ colorExpanded("dim", header),
549
+ childWidth,
550
+ ));
551
+ }
552
+
553
+ for (const child of this.children) {
554
+ const childLines = child.render(childWidth);
555
+ for (const line of childLines) {
556
+ lines.push(leftPad + line);
557
+ }
558
+ }
559
+ return lines;
560
+ }
561
+
562
+ private resetStreamingComponent(error: unknown, eventType: string): void {
563
+ this.streamingComponent = undefined;
564
+ if (isExpectedToolComponentFailure(error)) {
565
+ return;
566
+ }
567
+ console.warn(`[spawn] streaming component error (${eventType}):`, this.ownedToolCallId, error);
568
+ }
569
+
570
+ private handleMessageStart(event: Extract<AgentSessionEvent, { type: "message_start" }>): void {
571
+ if (event.message.role === "custom" || event.message.role === "user") {
572
+ this.addMessageToChat(event.message);
573
+ return;
574
+ }
575
+ if (event.message.role === "assistant") {
576
+ this.liveOutcome = "running";
577
+ this.lastAction = "💭 thinking…";
578
+ try {
579
+ this.streamingComponent = new AssistantMessageComponent(undefined, false, this.markdownTheme, "Thinking...");
580
+ this.addChild(this.streamingComponent);
581
+ this.streamingComponent.updateContent(event.message);
582
+ } catch (error) {
583
+ this.resetStreamingComponent(error, "message_start");
584
+ }
585
+ }
586
+ }
587
+
588
+ private handleMessageUpdate(event: Extract<AgentSessionEvent, { type: "message_update" }>): void {
589
+ if (event.message.role !== "assistant") return;
590
+ if (this.streamingComponent) {
591
+ try {
592
+ this.streamingComponent.updateContent(event.message);
593
+ } catch (error) {
594
+ this.resetStreamingComponent(error, "message_update");
595
+ }
596
+ }
597
+ for (const content of event.message.content ?? []) {
598
+ if (content.type !== "toolCall") continue;
599
+ let component = this.pendingTools.get(content.id);
600
+ if (!component) {
601
+ component = this.createToolComponent(content.name, content.id, content.arguments ?? {});
602
+ this.addToolComponent(component);
603
+ if (component) {
604
+ this.pendingTools.set(content.id, component);
605
+ }
606
+ } else {
607
+ component.updateArgs(content.arguments ?? {});
608
+ }
609
+ }
610
+ const textBlock = event.message.content?.find(
611
+ (c: any) => c.type === "text" && c.text,
612
+ );
613
+ if (textBlock?.text) {
614
+ const firstLine = textBlock.text.trim().split("\n")[0];
615
+ if (firstLine) {
616
+ this.lastAction = firstLine.slice(0, LIVE_TEXT_PREVIEW_CHARS);
617
+ }
618
+ }
619
+ }
620
+
621
+ private handleMessageEnd(event: Extract<AgentSessionEvent, { type: "message_end" }>): void {
622
+ if (event.message.role !== "assistant") return;
623
+ if (this.streamingComponent) {
624
+ try {
625
+ this.streamingComponent.updateContent(event.message);
626
+ } catch (error) {
627
+ this.resetStreamingComponent(error, "message_end");
628
+ }
629
+ }
630
+ const stopOutcome = getStopReasonOutcome(event.message.stopReason);
631
+ if (stopOutcome) {
632
+ const errorMessage = stopOutcome === "aborted"
633
+ ? event.message.errorMessage || "Operation aborted"
634
+ : event.message.errorMessage || "Error";
635
+ this.liveOutcome = stopOutcome;
636
+ this.lastAction = getOutcomeStatusText(stopOutcome) ?? this.lastAction;
637
+ for (const component of this.pendingTools.values()) {
638
+ component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true });
639
+ }
640
+ this.pendingTools.clear();
641
+ } else {
642
+ this.liveOutcome = "success";
643
+ this.lastAction = "💬 done";
644
+ for (const component of this.pendingTools.values()) {
645
+ component.setArgsComplete();
646
+ }
647
+ }
648
+ this.streamingComponent = undefined;
649
+ }
650
+
651
+ private handleToolExecutionStart(event: Extract<AgentSessionEvent, { type: "tool_execution_start" }>): void {
652
+ this.liveOutcome = "running";
653
+ let component = this.pendingTools.get(event.toolCallId);
654
+ if (!component) {
655
+ component = this.createToolComponent(event.toolName, event.toolCallId, event.args ?? {});
656
+ this.addToolComponent(component);
657
+ if (component) {
658
+ this.pendingTools.set(event.toolCallId, component);
659
+ }
660
+ }
661
+ this.toolNames.set(event.toolCallId, event.toolName);
662
+ this.lastAction = `[${event.toolName}] …`;
663
+ component?.markExecutionStarted();
664
+ }
665
+
666
+ private handleToolExecutionUpdate(event: Extract<AgentSessionEvent, { type: "tool_execution_update" }>): void {
667
+ const component = this.pendingTools.get(event.toolCallId);
668
+ // Update live action and flush render cache even when the tool
669
+ // component isn't tracked (e.g. createToolComponent failed in
670
+ // test or degraded environment).
671
+ const name = this.toolNames.get(event.toolCallId) ?? "tool";
672
+ const preview = this.extractPreview(asToolResult(event.partialResult));
673
+ this.lastAction = preview
674
+ ? `[${name}] ${preview}`
675
+ : `[${name}] …`;
676
+ if (component) {
677
+ component.updateResult({ ...asToolResult(event.partialResult), isError: false }, true);
678
+ }
679
+ }
680
+
681
+ private handleToolExecutionEnd(event: Extract<AgentSessionEvent, { type: "tool_execution_end" }>): void {
682
+ const component = this.pendingTools.get(event.toolCallId);
683
+ // Update live action and flush render cache even without a
684
+ // tracked tool component, so the "✓"/"✗" state is always
685
+ // reflected in the next render.
686
+ const name = this.toolNames.get(event.toolCallId) ?? "tool";
687
+ this.toolNames.delete(event.toolCallId);
688
+ this.pendingTools.delete(event.toolCallId);
689
+ this.lastAction = event.isError
690
+ ? `[${name}] ✗`
691
+ : `[${name}] ✓`;
692
+ if (component) {
693
+ component.updateResult({ ...asToolResult(event.result), isError: event.isError });
694
+ }
695
+ }
696
+
697
+ private handleEvent(event: AgentSessionEvent): void {
698
+ if (this.isStaleSession()) {
699
+ return;
700
+ }
701
+
702
+ try {
703
+ switch (event.type) {
704
+ case "message_start": this.handleMessageStart(event); break;
705
+ case "message_update": this.handleMessageUpdate(event); break;
706
+ case "message_end": this.handleMessageEnd(event); break;
707
+ case "tool_execution_start": this.handleToolExecutionStart(event); break;
708
+ case "tool_execution_update": this.handleToolExecutionUpdate(event); break;
709
+ case "tool_execution_end": this.handleToolExecutionEnd(event); break;
710
+ }
711
+ this.clearRenderCache();
712
+ this.requestRender();
713
+ } catch (error) {
714
+ // Prevent a single bad event from killing the subscription.
715
+ // The TUI degrades gracefully — stale content until next successful event.
716
+ console.warn("[spawn] Event handler error:", event.type, this.ownedToolCallId, error);
717
+ }
718
+ }
719
+ }
720
+
721
+ // ── Spawn call/result renderers ───────────────────────────────────────
722
+
723
+ /**
724
+ * Renders the spawn tool call in the parent's TUI.
725
+ *
726
+ * Collapsed: shows up to PROMPT_PREVIEW_COLLAPSED_LINES of the prompt with
727
+ * "... N more lines, to expand" hint when truncated.
728
+ * Expanded: shows the full prompt text.
729
+ * Returns a static Text component — live updates come through renderResult.
730
+ */
731
+ function renderSpawnCall(args: any, theme: Theme, context: { expanded: boolean }): Text {
732
+ const prompt = typeof args.prompt === "string" ? args.prompt : "...";
733
+ const { shown, remaining } = renderPromptPreview(prompt, context.expanded);
734
+ let text = theme.fg("toolTitle", theme.bold("spawn ")) + theme.fg("accent", "child");
735
+ if (typeof args.thinking === "string") {
736
+ text += theme.fg("dim", ` [${args.thinking}]`);
737
+ }
738
+ text += `\n${theme.fg("dim", shown)}`;
739
+ if (remaining > 0) {
740
+ text += theme.fg("muted", `\n... (${remaining} more lines, ${safeKeyHint("app.tools.expand", "to expand")})`);
741
+ }
742
+ return new Text(text, 0, 0);
743
+ }
744
+
745
+ /**
746
+ * Renders the result of a spawn execution into a TUI component.
747
+ *
748
+ * Three return paths:
749
+ * 1. Live session in state → attach to component, delete from state
750
+ * (ownership transfer), return the component.
751
+ * 2. Component already has a session (from a prior render) → return as-is.
752
+ * 3. Neither → dispose component, return static Text with model/thinking + output.
753
+ *
754
+ * Side effect on path (1): mutates state.childSessions via .delete().
755
+ */
756
+ function renderSpawnResult(
757
+ result: { content: { type: string; text?: string }[]; details?: unknown },
758
+ expanded: boolean,
759
+ theme: Theme,
760
+ context: { toolCallId: string; lastComponent?: unknown; invalidate: () => void; showImages: boolean },
761
+ state: AgenticodingState,
762
+ ): NestedAgentSessionComponent | Text {
763
+ // Runtime guard — both parent and child use executeSpawn which produces matching shape,
764
+ // but an explicit check ensures we don't crash on unexpected input
765
+ const details: SpawnResultDetails | undefined = result.details && typeof result.details === "object"
766
+ ? (result.details as SpawnResultDetails)
767
+ : undefined;
768
+ const component = context.lastComponent instanceof NestedAgentSessionComponent
769
+ ? context.lastComponent
770
+ : new NestedAgentSessionComponent();
771
+ component.setRequestRender(context.invalidate);
772
+ component.setExpanded(expanded);
773
+ component.setShowImages(context.showImages);
774
+ if (details) {
775
+ component.setDetails(details, theme);
776
+ }
777
+ const child = state.childSessions.get(context.toolCallId);
778
+ if (child) {
779
+ const liveChildSessions = state.liveChildSessions.get(context.toolCallId) === child
780
+ ? state.liveChildSessions
781
+ : undefined;
782
+ component.attachSession(context.toolCallId, child, liveChildSessions);
783
+ state.childSessions.delete(context.toolCallId);
784
+ return component;
785
+ }
786
+ if (component.hasSession()) {
787
+ return component;
788
+ }
789
+
790
+ component.dispose();
791
+
792
+ const output = result.content
793
+ .filter((block): block is { type: string; text: string } => block.type === "text" && typeof block.text === "string")
794
+ .map((block) => block.text)
795
+ .join("\n\n")
796
+ .trim();
797
+ const summary = output || "(no output)";
798
+ const outcome = details?.outcome ?? "running";
799
+ const meta = details ? `${getOutcomeMarker(outcome)}${details.model} • ${details.thinking}` : "";
800
+ const status = getOutcomeStatusText(outcome);
801
+ const text = [
802
+ meta ? theme.fg("dim", meta) : "",
803
+ status ? theme.fg(outcome === "error" ? "warning" : "dim", status) : "",
804
+ theme.fg("toolOutput", summary),
805
+ ].filter(Boolean).join("\n");
806
+ return new Text(text, 0, 0);
807
+ }
808
+
809
+ export { NestedAgentSessionComponent, renderSpawnCall, renderSpawnResult };