@ian-pascoe/pi-minimal-subagents 0.11.0 → 0.13.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.
@@ -9,6 +9,7 @@ import {
9
9
  type ToolDefinition,
10
10
  type ToolRenderResultOptions,
11
11
  } from "@earendil-works/pi-coding-agent";
12
+ import type { TUI } from "@earendil-works/pi-tui";
12
13
  import type { MinimalSubagentsCoordinator } from "./minimal-subagents-coordinator.js";
13
14
  import { withTroubleshootingHint } from "./troubleshooting-skill.js";
14
15
  import type { MinimalSubagentsModelRole } from "./minimal-subagents-config.js";
@@ -16,13 +17,21 @@ import {
16
17
  renderCoordinatorToolCall,
17
18
  renderCoordinatorToolResult,
18
19
  type CoordinatorToolName,
20
+ type LiveTurnRenderer,
19
21
  } from "./minimal-subagents-rendering.js";
22
+ import {
23
+ createTranscriptRenderCache,
24
+ TranscriptRail,
25
+ type TranscriptRenderCache,
26
+ } from "./minimal-subagents-transcript.js";
20
27
  import {
21
28
  CoordinatorToolOutputSchemas,
22
29
  type CoordinatorToolCallInput,
23
30
  } from "./minimal-subagents-render-contract.js";
24
31
  import type { createCoordinatorToolSchemas } from "./minimal-subagents-tool-schemas.js";
25
32
  import type {
33
+ ActiveTurnProgress,
34
+ AgentDetail,
26
35
  AgentMessageResult,
27
36
  CallerSnapshot,
28
37
  CancelResult,
@@ -41,7 +50,15 @@ const ORDINARY_CHILD_COORDINATOR_TOOL_NAMES = new Set([
41
50
  /** Coordinator operations consumed by the six public coordinator tool definitions. */
42
51
  export type CoordinatorToolOperations = Pick<
43
52
  MinimalSubagentsCoordinator,
44
- "spawn" | "inspectStatus" | "sendAgentMessage" | "wait" | "status" | "cancel" | "delete"
53
+ | "spawn"
54
+ | "inspectStatus"
55
+ | "previewActiveTurn"
56
+ | "inspectActiveTurnTranscript"
57
+ | "sendAgentMessage"
58
+ | "wait"
59
+ | "status"
60
+ | "cancel"
61
+ | "delete"
45
62
  >;
46
63
 
47
64
  /** Dependencies and caller policy used to create caller-bound coordinator tools. */
@@ -81,37 +98,84 @@ async function runCoordinatorToolActivity<T>(
81
98
  }
82
99
  }
83
100
 
101
+ /** The compact launch result, plus the full agent detail its transcript renderer displays. */
102
+ type SpawnResultDetails = SpawnResult & { agent?: AgentDetail };
103
+
84
104
  type CoordinatorToolResultDetails =
85
- | SpawnResult
105
+ | SpawnResultDetails
86
106
  | AgentMessageResult
87
107
  | WaitResult
88
108
  | StatusResult
89
109
  | CancelResult
90
110
  | DeleteResult
91
- | { agent_id: string; status: "waiting"; elapsed_ms: number };
111
+ | WaitProgressDetails;
112
+
113
+ /** Partial `subagent_wait` details: elapsed wait time plus the child's running-turn progress. */
114
+ type WaitProgressDetails = {
115
+ agent_id: string;
116
+ status: "waiting";
117
+ elapsed_ms: number;
118
+ } & Partial<ActiveTurnProgress>;
92
119
 
93
120
  type CoordinatorToolDefinition = ToolDefinition & {
94
121
  readonly outputSchema: (typeof CoordinatorToolOutputSchemas)[keyof typeof CoordinatorToolOutputSchemas];
95
122
  };
96
123
 
97
- function createCoordinatorToolRendering(toolName: CoordinatorToolName) {
124
+ /** Native transcript components are re-rendered on each wait update, so their render requests are not needed. */
125
+ const NON_RENDERING_TUI: Pick<TUI, "requestRender"> = { requestRender: () => undefined };
126
+
127
+ /** Pi's per-call render context; the installed Pi does not export its named type. */
128
+ type CoordinatorToolRenderContext = Parameters<NonNullable<ToolDefinition["renderResult"]>>[3];
129
+
130
+ /** Renderer state Pi keeps for one tool row across its partial and final renders. */
131
+ interface CoordinatorToolRowState {
132
+ liveTurnCache?: TranscriptRenderCache;
133
+ }
134
+
135
+ function createCoordinatorToolRendering(
136
+ options: CoordinatorToolDefinitionOptions,
137
+ toolName: CoordinatorToolName,
138
+ ) {
98
139
  return {
99
- renderCall: (args: CoordinatorToolCallInput, theme: Theme) =>
100
- renderCoordinatorToolCall(toolName, args, theme),
140
+ renderCall: (
141
+ args: CoordinatorToolCallInput,
142
+ theme: Theme,
143
+ context: { expanded: boolean } | undefined,
144
+ ) => renderCoordinatorToolCall(toolName, args, theme, context?.expanded ?? false),
101
145
  renderResult: (
102
146
  result: AgentToolResult<CoordinatorToolResultDetails>,
103
147
  renderOptions: ToolRenderResultOptions,
104
148
  theme: Theme,
105
- context: { args: CoordinatorToolCallInput; isError: boolean },
106
- ) =>
107
- renderCoordinatorToolResult(
149
+ context: CoordinatorToolRenderContext,
150
+ ) => {
151
+ const rowState: CoordinatorToolRowState = context.state;
152
+ const renderLiveTurn: LiveTurnRenderer = (agentId, turnId, expanded) => {
153
+ const snapshot = options.coordinator.inspectActiveTurnTranscript(
154
+ options.callerId,
155
+ agentId,
156
+ turnId,
157
+ );
158
+ if (!snapshot || snapshot.messages.length === 0) return undefined;
159
+ return new TranscriptRail(
160
+ snapshot,
161
+ // SAFETY: Native transcript components only call requestRender on the TUI they receive.
162
+ NON_RENDERING_TUI as TUI,
163
+ context.cwd,
164
+ expanded,
165
+ (rowState.liveTurnCache ??= createTranscriptRenderCache()),
166
+ theme,
167
+ );
168
+ };
169
+ return renderCoordinatorToolResult(
108
170
  toolName,
109
171
  result,
110
172
  renderOptions,
111
173
  theme,
112
174
  context.args,
113
175
  context.isError,
114
- ),
176
+ toolName === "subagent_wait" ? renderLiveTurn : undefined,
177
+ );
178
+ },
115
179
  };
116
180
  }
117
181
 
@@ -130,13 +194,31 @@ function structuredToolResult<TDetails extends CoordinatorToolResultDetails>(
130
194
  };
131
195
  }
132
196
 
133
- function failedStructuredOperation(prefix: string, result: DeleteResult): never {
134
- const json = JSON.stringify(result);
135
- const truncated = truncateHead(json, {
136
- maxBytes: DEFAULT_MAX_BYTES,
137
- maxLines: DEFAULT_MAX_LINES,
138
- });
139
- throw new Error(withTroubleshootingHint(`${prefix}: ${truncated.content}`));
197
+ /**
198
+ * Report a failed operation that still carries declared output: the model sees an error and
199
+ * codemode scripts receive `structuredContent` instead of a data-less rejection.
200
+ */
201
+ function failedStructuredToolResult<TDetails extends CoordinatorToolResultDetails>(
202
+ prefix: string,
203
+ result: TDetails,
204
+ options: { troubleshootingHint: boolean },
205
+ ): AgentToolResult<TDetails> & { isError: true } {
206
+ const success = structuredToolResult(result);
207
+ const text = `${prefix}:\n${textOf(success)}`;
208
+ return {
209
+ ...success,
210
+ content: [
211
+ {
212
+ type: "text" as const,
213
+ text: options.troubleshootingHint ? withTroubleshootingHint(text) : text,
214
+ },
215
+ ],
216
+ isError: true,
217
+ };
218
+ }
219
+
220
+ function textOf(result: AgentToolResult<unknown>): string {
221
+ return result.content.map((part) => (part.type === "text" ? part.text : "")).join("\n");
140
222
  }
141
223
 
142
224
  function callerSourceTurnId(
@@ -160,10 +242,18 @@ export function createCoordinatorToolDefinitions(
160
242
  name: "subagent",
161
243
  label: "Subagent",
162
244
  description:
163
- "Create a persistent nested agent asynchronously. Returns its canonical agent ID and active turn ID immediately. Root-child IDs omit the root prefix; nested IDs retain the parent path.",
245
+ "Create a persistent nested agent asynchronously. Returns its canonical agent ID, active turn ID, and resolved model and tools immediately. Root-child IDs omit the root prefix; nested IDs retain the parent path. Children start without your conversation unless session_context opts in, so the task must be self-contained. Its final response is delivered automatically unless you claim it with subagent_wait. To continue an idle child later, send it agent_message; that starts a new turn you can wait on.",
164
246
  promptSnippet: "Spawn a persistent child with a prefix-free root-child ID",
165
247
  promptGuidelines: modelRolePromptGuidelines,
166
248
  parameters: options.schemas.subagent,
249
+ // A spawn must finish registering before a wait or message in the same batch can target it.
250
+ executionMode: "sequential",
251
+ annotations: {
252
+ readOnlyHint: false,
253
+ destructiveHint: true,
254
+ idempotentHint: false,
255
+ openWorldHint: true,
256
+ },
167
257
  async execute(_toolCallId, parameters, _signal, _onUpdate, context) {
168
258
  return runCoordinatorToolActivity(options, async () => {
169
259
  const result = await options.coordinator.spawn(
@@ -171,26 +261,30 @@ export function createCoordinatorToolDefinitions(
171
261
  parameters,
172
262
  options.captureCaller(context),
173
263
  );
264
+ // The model and codemode receive the compact launch result; the transcript renderer
265
+ // additionally receives the full detail it displays.
174
266
  const status = options.coordinator.inspectStatus(result.agent_id);
175
- const details: SpawnResult & {
176
- agent?: import("./minimal-subagents-types.js").AgentDetail;
177
- } = {
178
- ...result,
179
- };
180
- if (status && "agent" in status) details.agent = status.agent;
181
- return structuredToolResult(details);
267
+ const details: SpawnResultDetails = { ...result };
268
+ if ("agent" in status) details.agent = status.agent;
269
+ return { ...structuredToolResult(result), details };
182
270
  });
183
271
  },
184
- ...createCoordinatorToolRendering("subagent"),
272
+ ...createCoordinatorToolRendering(options, "subagent"),
185
273
  });
186
274
 
187
275
  const messageTool = defineTool({
188
276
  name: "agent_message",
189
277
  label: "Agent Message",
190
278
  description:
191
- "Send one mid-turn coordination message to a direct parent, direct sibling, or direct child. The result says whether it was delivered through an active wait, queued for the recipient, or failed.",
279
+ "Send one coordination message to a direct parent, direct sibling, or direct child. The result says whether it was delivered through an active wait, queued into the recipient's active turn, started a new turn on an idle child (with its turn_id), or failed.",
192
280
  promptSnippet: "Coordinate required mid-turn action with one adjacent agent",
193
281
  parameters: options.schemas.agent_message,
282
+ annotations: {
283
+ readOnlyHint: false,
284
+ destructiveHint: false,
285
+ idempotentHint: false,
286
+ openWorldHint: false,
287
+ },
194
288
  async execute(toolCallId, parameters) {
195
289
  return runCoordinatorToolActivity(options, async () => {
196
290
  const result = await options.coordinator.sendAgentMessage(
@@ -201,30 +295,48 @@ export function createCoordinatorToolDefinitions(
201
295
  },
202
296
  callerSourceTurnId(options.coordinator, options.callerId, toolCallId),
203
297
  );
204
- return structuredToolResult(result);
298
+ return result.disposition === "failed"
299
+ ? failedStructuredToolResult("Minimal subagents message delivery failed", result, {
300
+ // The coordinator already appends the hint to `error`.
301
+ troubleshootingHint: false,
302
+ })
303
+ : structuredToolResult(result);
205
304
  });
206
305
  },
207
- ...createCoordinatorToolRendering("agent_message"),
306
+ ...createCoordinatorToolRendering(options, "agent_message"),
208
307
  });
209
308
 
210
309
  const waitTool = defineTool({
211
310
  name: "subagent_wait",
212
311
  label: "Subagent Wait",
213
312
  description:
214
- "Wait for one direct child's oldest observable turn, or select an exact retained turn_id. An active child may first return event=message; later unconsumed items still fall back automatically. An already settled turn returns event=turn once with queued messages in messages. Timeout returns event=timeout with detailed child status and never cancels the child.",
313
+ "Wait for one direct child's oldest observable turn, or select an exact retained turn_id. An active child may first return event=message; later unconsumed items still fall back automatically. A settled turn returns event=turn, with any queued messages in messages; waiting again returns the same result. Timeout returns event=timeout with detailed child status and never cancels the child.",
215
314
  promptSnippet: "Wait for one direct child's exact turn",
216
315
  parameters: options.schemas.subagent_wait,
316
+ annotations: {
317
+ readOnlyHint: true,
318
+ destructiveHint: false,
319
+ idempotentHint: false,
320
+ openWorldHint: false,
321
+ },
217
322
  async execute(_toolCallId, parameters, signal, onUpdate) {
218
323
  const startedAt = Date.now();
219
- const updateWaitingResult = () =>
324
+ const updateWaitingResult = () => {
325
+ const details: WaitProgressDetails = {
326
+ agent_id: parameters.agent_id,
327
+ status: "waiting",
328
+ elapsed_ms: Date.now() - startedAt,
329
+ ...options.coordinator.previewActiveTurn(
330
+ options.callerId,
331
+ parameters.agent_id,
332
+ parameters.turn_id,
333
+ ),
334
+ };
220
335
  onUpdate?.({
221
336
  content: [{ type: "text", text: `Waiting for ${parameters.agent_id}` }],
222
- details: {
223
- agent_id: parameters.agent_id,
224
- status: "waiting",
225
- elapsed_ms: Date.now() - startedAt,
226
- },
337
+ details,
227
338
  });
339
+ };
228
340
  updateWaitingResult();
229
341
  const waitingInterval = setInterval(updateWaitingResult, 1_000);
230
342
  waitingInterval.unref?.();
@@ -252,22 +364,28 @@ export function createCoordinatorToolDefinitions(
252
364
  clearInterval(waitingInterval);
253
365
  }
254
366
  },
255
- ...createCoordinatorToolRendering("subagent_wait"),
367
+ ...createCoordinatorToolRendering(options, "subagent_wait"),
256
368
  });
257
369
 
258
370
  const statusTool = defineTool({
259
371
  name: "subagent_status",
260
372
  label: "Subagent Status",
261
373
  description:
262
- "List direct children when agent_id is omitted, or inspect one direct child's launch contract, result, usage, dependencies, and bounded recent activity including message text and reasoning.",
374
+ "List direct children when agent_id is omitted, or inspect one direct child's launch contract, result, usage, dependencies, and bounded recent activity including message text and reasoning. The root may inspect any descendant.",
263
375
  promptSnippet: "Inspect direct child state",
264
376
  parameters: options.schemas.subagent_status,
377
+ annotations: {
378
+ readOnlyHint: true,
379
+ destructiveHint: false,
380
+ idempotentHint: true,
381
+ openWorldHint: false,
382
+ },
265
383
  async execute(_toolCallId, parameters) {
266
384
  return runCoordinatorToolActivity(options, () =>
267
385
  structuredToolResult(options.coordinator.status(options.callerId, parameters.agent_id)),
268
386
  );
269
387
  },
270
- ...createCoordinatorToolRendering("subagent_status"),
388
+ ...createCoordinatorToolRendering(options, "subagent_status"),
271
389
  });
272
390
 
273
391
  const cancelTool = defineTool({
@@ -277,6 +395,12 @@ export function createCoordinatorToolDefinitions(
277
395
  "Abort active work for one direct child while preserving sessions for later continuation. Recursive cancellation includes its subtree and defaults to true.",
278
396
  promptSnippet: "Cancel active subagent turns without deleting sessions",
279
397
  parameters: options.schemas.subagent_cancel,
398
+ annotations: {
399
+ readOnlyHint: false,
400
+ destructiveHint: false,
401
+ idempotentHint: true,
402
+ openWorldHint: false,
403
+ },
280
404
  async execute(_toolCallId, parameters) {
281
405
  return runCoordinatorToolActivity(options, async () =>
282
406
  structuredToolResult(
@@ -288,7 +412,7 @@ export function createCoordinatorToolDefinitions(
288
412
  ),
289
413
  );
290
414
  },
291
- ...createCoordinatorToolRendering("subagent_cancel"),
415
+ ...createCoordinatorToolRendering(options, "subagent_cancel"),
292
416
  });
293
417
 
294
418
  const deleteTool = defineTool({
@@ -298,6 +422,12 @@ export function createCoordinatorToolDefinitions(
298
422
  "Delete one direct child's persistent session and retain durable ID tombstones. Recursive deletion includes its subtree and defaults to true.",
299
423
  promptSnippet: "Delete subagent sessions and tombstone their IDs",
300
424
  parameters: options.schemas.subagent_delete,
425
+ annotations: {
426
+ readOnlyHint: false,
427
+ destructiveHint: true,
428
+ idempotentHint: true,
429
+ openWorldHint: false,
430
+ },
301
431
  async execute(_toolCallId, parameters) {
302
432
  return runCoordinatorToolActivity(options, async () => {
303
433
  const result = await options.coordinator.delete(
@@ -309,12 +439,14 @@ export function createCoordinatorToolDefinitions(
309
439
  options.onAttention?.(
310
440
  `Minimal subagents deletion partially failed for ${parameters.agent_id}`,
311
441
  );
312
- failedStructuredOperation("Minimal subagents deletion partially failed", result);
442
+ return failedStructuredToolResult("Minimal subagents deletion partially failed", result, {
443
+ troubleshootingHint: true,
444
+ });
313
445
  }
314
446
  return structuredToolResult(result);
315
447
  });
316
448
  },
317
- ...createCoordinatorToolRendering("subagent_delete"),
449
+ ...createCoordinatorToolRendering(options, "subagent_delete"),
318
450
  });
319
451
 
320
452
  const coordinatorTools: CoordinatorToolDefinition[] = [
@@ -0,0 +1,296 @@
1
+ import { stripVTControlCharacters } from "node:util";
2
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
+ import { contentText } from "@earendil-works/pi-ai";
4
+ import {
5
+ AssistantMessageComponent,
6
+ BashExecutionComponent,
7
+ BranchSummaryMessageComponent,
8
+ CompactionSummaryMessageComponent,
9
+ CustomMessageComponent,
10
+ ToolExecutionComponent,
11
+ UserMessageComponent,
12
+ type TruncationResult,
13
+ } from "@earendil-works/pi-coding-agent";
14
+ import { Container, Text, type Component, type TUI } from "@earendil-works/pi-tui";
15
+ import {
16
+ ACTIVITY_RAIL_WIDTH,
17
+ drawActivityRail,
18
+ renderMinimalSubagentsMessage,
19
+ renderMinimalSubagentsResult,
20
+ type MinimalSubagentsRenderTheme,
21
+ } from "./minimal-subagents-rendering.js";
22
+ import type { ChildAgentTranscriptSnapshot } from "./minimal-subagents-types.js";
23
+
24
+ interface CachedTranscriptMessage {
25
+ container: Container;
26
+ tools: Map<string, ToolExecutionComponent>;
27
+ expanded: boolean;
28
+ streaming: boolean;
29
+ }
30
+
31
+ /** Rendered transcript lines and the first line of each message. */
32
+ export interface TranscriptLayout {
33
+ lines: string[];
34
+ messageStarts: number[];
35
+ /** First line of each native component, such as one assistant reply or one tool execution. */
36
+ itemStarts: number[];
37
+ }
38
+
39
+ /** Native components reused across renders while their source messages are unchanged. */
40
+ export interface TranscriptRenderCache {
41
+ messages: WeakMap<AgentMessage, CachedTranscriptMessage>;
42
+ results: WeakMap<ToolExecutionComponent, AgentMessage>;
43
+ }
44
+
45
+ /** Render a Child Session Transcript with Pi's native message and tool components. */
46
+ export function renderTranscriptSnapshot(
47
+ snapshot: ChildAgentTranscriptSnapshot,
48
+ tui: TUI,
49
+ cwd: string,
50
+ expanded: boolean,
51
+ width: number,
52
+ cache: TranscriptRenderCache,
53
+ ): TranscriptLayout {
54
+ if (snapshot.messages.length === 0) {
55
+ return {
56
+ lines: new Text(snapshot.fallback || "No conversation messages yet.", 3, 0).render(width),
57
+ messageStarts: [0],
58
+ itemStarts: [0],
59
+ };
60
+ }
61
+ const blocks: Container[] = [];
62
+ const tools = new Map(
63
+ snapshot.toolDefinitions.map((definition) => [definition.name, definition]),
64
+ );
65
+ const pendingTools = new Map<string, ToolExecutionComponent>();
66
+ const currentMessages = new Set(snapshot.messages);
67
+
68
+ for (const [messageIndex, message] of snapshot.messages.entries()) {
69
+ if (message.role === "toolResult") {
70
+ const paired = pendingTools.get(message.toolCallId);
71
+ if (paired) {
72
+ if (cache.results.get(paired) !== message) {
73
+ paired.updateResult(message);
74
+ cache.results.set(paired, message);
75
+ }
76
+ pendingTools.delete(message.toolCallId);
77
+ blocks.push(new Container());
78
+ continue;
79
+ }
80
+ }
81
+ const streaming = messageIndex === snapshot.streamingAssistantIndex;
82
+ const cached = cache.messages.get(message);
83
+ const staleResult =
84
+ cached &&
85
+ [...cached.tools.values()].some((tool) => {
86
+ const result = cache.results.get(tool);
87
+ return result !== undefined && !currentMessages.has(result);
88
+ });
89
+ if (cached && !staleResult && cached.expanded === expanded && cached.streaming === streaming) {
90
+ blocks.push(cached.container);
91
+ for (const [id, tool] of cached.tools) pendingTools.set(id, tool);
92
+ continue;
93
+ }
94
+ const container = new Container();
95
+ const messageTools = new Map<string, ToolExecutionComponent>();
96
+ switch (message.role) {
97
+ case "user": {
98
+ const text = contentText(message.content, "\n\n");
99
+ if (text) container.addChild(new UserMessageComponent(text));
100
+ break;
101
+ }
102
+ case "assistant": {
103
+ const assistant = new AssistantMessageComponent(message);
104
+ assistant.updateContent(message, messageIndex === snapshot.streamingAssistantIndex);
105
+ container.addChild(assistant);
106
+ for (const content of message.content) {
107
+ if (content.type !== "toolCall") continue;
108
+ const tool = new ToolExecutionComponent(
109
+ content.name,
110
+ content.id,
111
+ content.arguments,
112
+ { showImages: false },
113
+ tools.get(content.name) ?? {},
114
+ tui,
115
+ cwd,
116
+ );
117
+ tool.setExpanded(expanded);
118
+ container.addChild(tool);
119
+ if (message.stopReason === "aborted" || message.stopReason === "error") {
120
+ tool.updateResult({
121
+ content: [
122
+ {
123
+ type: "text",
124
+ text:
125
+ message.stopReason === "aborted"
126
+ ? "Operation aborted"
127
+ : (message.errorMessage ?? "Error"),
128
+ },
129
+ ],
130
+ isError: true,
131
+ });
132
+ } else {
133
+ pendingTools.set(content.id, tool);
134
+ messageTools.set(content.id, tool);
135
+ }
136
+ }
137
+ break;
138
+ }
139
+ case "toolResult": {
140
+ const inherited = new ToolExecutionComponent(
141
+ message.toolName,
142
+ message.toolCallId,
143
+ {},
144
+ { showImages: false },
145
+ {},
146
+ tui,
147
+ cwd,
148
+ );
149
+ inherited.setExpanded(expanded);
150
+ inherited.updateResult(message);
151
+ container.addChild(inherited);
152
+ break;
153
+ }
154
+ case "custom": {
155
+ if (!message.display) break;
156
+ const renderer =
157
+ message.customType === "minimal-subagents.message"
158
+ ? renderMinimalSubagentsMessage
159
+ : message.customType === "minimal-subagents.result"
160
+ ? renderMinimalSubagentsResult
161
+ : undefined;
162
+ const custom = new CustomMessageComponent(message, renderer);
163
+ custom.setExpanded(expanded);
164
+ container.addChild(custom);
165
+ break;
166
+ }
167
+ case "bashExecution": {
168
+ const bash = new BashExecutionComponent(message.command, tui, message.excludeFromContext);
169
+ if (message.output) bash.appendOutput(message.output);
170
+ bash.setComplete(
171
+ message.exitCode,
172
+ message.cancelled,
173
+ // SAFETY: Persisted bash messages retain only the truncation flag; BashExecutionComponent reads that flag here.
174
+ message.truncated ? ({ truncated: true } as TruncationResult) : undefined,
175
+ message.fullOutputPath,
176
+ );
177
+ bash.setExpanded(expanded);
178
+ container.addChild(bash);
179
+ break;
180
+ }
181
+ case "branchSummary": {
182
+ const summary = new BranchSummaryMessageComponent(message);
183
+ summary.setExpanded(expanded);
184
+ container.addChild(summary);
185
+ break;
186
+ }
187
+ case "compactionSummary": {
188
+ const summary = new CompactionSummaryMessageComponent(message);
189
+ summary.setExpanded(expanded);
190
+ container.addChild(summary);
191
+ break;
192
+ }
193
+ }
194
+ cache.messages.set(message, { container, tools: messageTools, expanded, streaming });
195
+ blocks.push(container);
196
+ }
197
+ let length = 0;
198
+ const messageStarts: number[] = [];
199
+ const itemStarts: number[] = [];
200
+ // Containers concatenate their children, so rendering children one by one yields the same lines.
201
+ const lines = blocks.flatMap((block) => {
202
+ messageStarts.push(length);
203
+ return block.children.flatMap((child) => {
204
+ itemStarts.push(length);
205
+ // Native user-message prompt zones belong to the main terminal, not an embedded overlay.
206
+ const rendered = child
207
+ .render(width)
208
+ .map((line) =>
209
+ line
210
+ .replaceAll("\x1b]133;A\x07", "")
211
+ .replaceAll("\x1b]133;B\x07", "")
212
+ .replaceAll("\x1b]133;C\x07", ""),
213
+ );
214
+ length += rendered.length;
215
+ return rendered;
216
+ });
217
+ });
218
+ return { lines, messageStarts, itemStarts };
219
+ }
220
+
221
+ /** Create an empty cache for {@link renderTranscriptSnapshot}. */
222
+ export function createTranscriptRenderCache(): TranscriptRenderCache {
223
+ return { messages: new WeakMap(), results: new WeakMap() };
224
+ }
225
+
226
+ /** Collapsed live views keep at most this many lines of the child's recent work. */
227
+ const COLLAPSED_TRANSCRIPT_RAIL_LINES = 20;
228
+
229
+ function isSpacerLine(line: string): boolean {
230
+ // Includes a tool box's coloured padding rows, so each rail connector meets visible content.
231
+ return stripVTControlCharacters(line).trim().length === 0;
232
+ }
233
+
234
+ function withoutSpacerEdges(lines: string[]): string[] {
235
+ const start = lines.findIndex((line) => !isSpacerLine(line));
236
+ if (start < 0) return [];
237
+ const end = lines.findLastIndex((line) => !isSpacerLine(line));
238
+ return lines.slice(start, end + 1);
239
+ }
240
+
241
+ /**
242
+ * A child's recent transcript drawn with Pi's native message and tool components, one rail item
243
+ * per component. Collapsed, it keeps the latest items that fit the line budget.
244
+ */
245
+ export class TranscriptRail implements Component {
246
+ constructor(
247
+ private readonly snapshot: ChildAgentTranscriptSnapshot,
248
+ private readonly tui: TUI,
249
+ private readonly cwd: string,
250
+ private readonly expanded: boolean,
251
+ private readonly cache: TranscriptRenderCache,
252
+ private readonly theme: MinimalSubagentsRenderTheme,
253
+ ) {}
254
+
255
+ render(width: number): string[] {
256
+ const layout = renderTranscriptSnapshot(
257
+ this.snapshot,
258
+ this.tui,
259
+ this.cwd,
260
+ this.expanded,
261
+ Math.max(1, width - ACTIVITY_RAIL_WIDTH),
262
+ this.cache,
263
+ );
264
+ const items = layout.itemStarts
265
+ .map((start, index) =>
266
+ withoutSpacerEdges(layout.lines.slice(start, layout.itemStarts[index + 1])),
267
+ )
268
+ .filter((lines) => lines.length > 0);
269
+ if (this.expanded) return drawActivityRail(items, this.theme);
270
+ let shown = 0;
271
+ let lineCount = 0;
272
+ for (const item of items.toReversed()) {
273
+ if (shown > 0 && lineCount + item.length > COLLAPSED_TRANSCRIPT_RAIL_LINES) break;
274
+ shown++;
275
+ lineCount += item.length;
276
+ }
277
+ const kept = items
278
+ .slice(items.length - shown)
279
+ .map((lines) => lines.slice(0, COLLAPSED_TRANSCRIPT_RAIL_LINES));
280
+ const earlier = items.length - shown;
281
+ return drawActivityRail(
282
+ earlier > 0
283
+ ? [
284
+ [this.theme.fg("dim", `… ${earlier} earlier ${earlier === 1 ? "step" : "steps"}`)],
285
+ ...kept,
286
+ ]
287
+ : kept,
288
+ this.theme,
289
+ );
290
+ }
291
+
292
+ invalidate(): void {
293
+ this.cache.messages = new WeakMap();
294
+ this.cache.results = new WeakMap();
295
+ }
296
+ }