@gotgenes/pi-subagents 17.3.0 → 17.5.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.
@@ -14,12 +14,31 @@ import type { CreateSubagentSessionParams } from "#src/lifecycle/create-subagent
14
14
  import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
15
15
  import { Subagent, type SubagentLifecycleObserver } from "#src/lifecycle/subagent";
16
16
  import type { SubagentSession } from "#src/lifecycle/subagent-session";
17
- import { SubagentState } from "#src/lifecycle/subagent-state";
17
+ import { SubagentState, type SubagentStatus } from "#src/lifecycle/subagent-state";
18
18
  import type { WorkspaceProvider } from "#src/lifecycle/workspace";
19
19
 
20
20
  import type { RunConfig } from "#src/runtime";
21
21
  import type { AgentInvocation, CompactionInfo, ParentSessionInfo, SubagentType, ThinkingLevel } from "#src/types";
22
22
 
23
+ /**
24
+ * A lightweight snapshot of a subagent evicted by the 10-minute cleanup sweep.
25
+ *
26
+ * The sweep frees the heavy in-memory session (its message history included);
27
+ * this descriptor retains only the fields the session navigator needs to label
28
+ * the agent in the picker, plus the persisted `outputFile` to source its
29
+ * transcript from disk. Carries no messages, so memory stays bounded.
30
+ */
31
+ export interface EvictedSubagent {
32
+ readonly id: string;
33
+ readonly type: SubagentType;
34
+ readonly description: string;
35
+ readonly status: SubagentStatus;
36
+ readonly startedAt: number;
37
+ readonly completedAt: number | undefined;
38
+ readonly toolUses: number;
39
+ readonly outputFile: string;
40
+ }
41
+
23
42
  /** Observer interface for agent lifecycle notifications. */
24
43
  export interface SubagentManagerObserver {
25
44
  onSubagentStarted(record: Subagent): void;
@@ -65,6 +84,8 @@ export interface AgentSpawnConfig {
65
84
 
66
85
  export class SubagentManager {
67
86
  private agents = new Map<string, Subagent>();
87
+ /** Descriptors of agents removed by the cleanup sweep, keyed by id — navigable from disk. */
88
+ private readonly evicted = new Map<string, EvictedSubagent>();
68
89
  private cleanupInterval: ReturnType<typeof setInterval>;
69
90
  private readonly observer?: SubagentManagerObserver;
70
91
  private readonly createSubagentSession: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
@@ -220,6 +241,11 @@ export class SubagentManager {
220
241
  );
221
242
  }
222
243
 
244
+ /** Descriptors of agents evicted by the cleanup sweep, most recent first. */
245
+ listEvicted(): EvictedSubagent[] {
246
+ return [...this.evicted.values()].sort((a, b) => b.startedAt - a.startedAt);
247
+ }
248
+
223
249
  abort(id: string): boolean {
224
250
  const record = this.agents.get(id);
225
251
  if (!record) return false;
@@ -245,6 +271,9 @@ export class SubagentManager {
245
271
  for (const [id, record] of this.agents) {
246
272
  if (record.status === "running" || record.status === "queued") continue;
247
273
  if ((record.completedAt ?? 0) >= cutoff) continue;
274
+ // Retain a navigable descriptor before freeing the heavy session. Only an
275
+ // agent with a persisted file can be sourced from disk after eviction.
276
+ if (record.outputFile) this.evicted.set(id, toEvictedSubagent(record, record.outputFile));
248
277
  this.removeRecord(id, record);
249
278
  }
250
279
  }
@@ -258,6 +287,8 @@ export class SubagentManager {
258
287
  if (record.status === "running" || record.status === "queued") continue;
259
288
  this.removeRecord(id, record);
260
289
  }
290
+ // Evicted descriptors belong to the session that swept them — a new session starts empty.
291
+ this.evicted.clear();
261
292
  }
262
293
 
263
294
  /** Whether any agents are still running or queued. */
@@ -314,5 +345,20 @@ export class SubagentManager {
314
345
  record.disposeSession();
315
346
  }
316
347
  this.agents.clear();
348
+ this.evicted.clear();
317
349
  }
318
350
  }
351
+
352
+ /** Capture an evicted agent's navigable fields from its record. */
353
+ function toEvictedSubagent(record: Subagent, outputFile: string): EvictedSubagent {
354
+ return {
355
+ id: record.id,
356
+ type: record.type,
357
+ description: record.description,
358
+ status: record.status,
359
+ startedAt: record.startedAt,
360
+ completedAt: record.completedAt,
361
+ toolUses: record.toolUses,
362
+ outputFile,
363
+ };
364
+ }
@@ -13,6 +13,7 @@
13
13
  import {
14
14
  type AgentSession,
15
15
  type AgentSessionEvent,
16
+ type ToolDefinition,
16
17
  } from "@earendil-works/pi-coding-agent";
17
18
  import type { ChildLifecyclePublisher } from "#src/lifecycle/child-lifecycle";
18
19
  import { normalizeMaxTurns } from "#src/lifecycle/turn-limits";
@@ -184,6 +185,11 @@ export class SubagentSession {
184
185
  return this._session.messages;
185
186
  }
186
187
 
188
+ /** Resolve a registered tool definition by name, for Pi's tool-execution components. */
189
+ getToolDefinition(name: string): ToolDefinition | undefined {
190
+ return this._session.getToolDefinition(name);
191
+ }
192
+
187
193
  /** Tear down: session.dispose() + emit `disposed` (registry unregister). */
188
194
  dispose(): void {
189
195
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- dispose may not exist on all session implementations
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { Model } from "@earendil-works/pi-ai";
10
- import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
10
+ import type { AgentSessionEvent, ToolDefinition } from "@earendil-works/pi-coding-agent";
11
11
  import { debugLog } from "#src/debug";
12
12
  import type { CreateSubagentSessionParams } from "#src/lifecycle/create-subagent-session";
13
13
  import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
@@ -167,6 +167,11 @@ export class Subagent {
167
167
  return this.subagentSession?.agentMessages ?? [];
168
168
  }
169
169
 
170
+ /** Resolve a registered tool definition by name, or undefined if no session. */
171
+ getToolDefinition(name: string): ToolDefinition | undefined {
172
+ return this.subagentSession?.getToolDefinition(name);
173
+ }
174
+
170
175
  constructor(init: SubagentInit) {
171
176
  // Identity
172
177
  this.id = init.id;
@@ -2,20 +2,22 @@
2
2
  * session-navigation.ts — Pure selection and transcript-sourcing for native session navigation.
3
3
  *
4
4
  * Splits the unit-testable core of the `/subagent-sessions` command from its TUI
5
- * wiring (`session-navigator.ts`): which subagents are navigable, how a picked
6
- * agent's transcript is sourced (live, in this slice), and how the transcript
7
- * renders to plain text via Pi's own `serializeConversation`.
5
+ * wiring (`session-navigator.ts`): which subagents are navigable and how a picked
6
+ * agent's transcript is sourced (live, in this slice).
8
7
  *
9
8
  * The `TranscriptSource` seam decouples *how messages are sourced* (live record
10
- * here; a file snapshot in a follow-up) from *how they render* (text here; Pi's
11
- * per-entry components in a follow-up). The renderer talks only to this seam.
9
+ * here; a file snapshot in a follow-up) from *how they render* the renderer
10
+ * (`session-navigator.ts`, which mounts Pi's per-entry components) talks only to
11
+ * this seam. Rendering lives in the SDK/TUI module because the per-entry
12
+ * components require a `TUI`, `cwd`, and markdown theme.
12
13
  */
13
14
 
14
- import { serializeConversation } from "@earendil-works/pi-coding-agent";
15
+ import { buildSessionContext, parseSessionEntries, type SessionEntry, type ToolDefinition } from "@earendil-works/pi-coding-agent";
15
16
  import type { AgentConfigLookup } from "#src/config/agent-types";
17
+ import type { EvictedSubagent } from "#src/lifecycle/subagent-manager";
16
18
  import type { SubagentStatus } from "#src/lifecycle/subagent-state";
17
19
  import type { AgentSessionEvent, SessionMessage, SubagentType } from "#src/types";
18
- import { describeActivity, formatDuration, getDisplayName } from "#src/ui/display";
20
+ import { formatDuration, getDisplayName } from "#src/ui/display";
19
21
 
20
22
  // ─────────────────────────────────────────────────────────────────────────────
21
23
 
@@ -33,12 +35,27 @@ export interface NavigableSubagent {
33
35
  readonly agentMessages: readonly SessionMessage[];
34
36
  isSessionReady(): boolean;
35
37
  subscribeToUpdates(fn: (event: AgentSessionEvent) => void): (() => void) | undefined;
38
+ getToolDefinition(name: string): ToolDefinition | undefined;
36
39
  }
37
40
 
38
- /** A navigable entry: a record plus the label shown in the picker. */
39
- export interface NavigationEntry {
40
- readonly record: NavigableSubagent;
41
- readonly label: string;
41
+ /**
42
+ * A navigable entry plus the label shown in the picker.
43
+ *
44
+ * A `live` entry sources its transcript from the in-memory record; an `evicted`
45
+ * entry sources it from the persisted session file (the record is gone).
46
+ */
47
+ export type NavigationEntry =
48
+ | { readonly kind: "live"; readonly label: string; readonly record: NavigableSubagent }
49
+ | { readonly kind: "evicted"; readonly label: string; readonly outputFile: string };
50
+
51
+ /** The fields `buildLabel` reads — shared by a live record and an evicted descriptor. */
52
+ interface LabelFields {
53
+ readonly type: SubagentType;
54
+ readonly description: string;
55
+ readonly status: SubagentStatus;
56
+ readonly startedAt: number;
57
+ readonly completedAt: number | undefined;
58
+ readonly toolUses: number;
42
59
  }
43
60
 
44
61
  /** Running-agent streaming state, surfaced by a live source. */
@@ -55,16 +72,56 @@ export interface TranscriptSource {
55
72
  subscribe(onChange: () => void): (() => void) | undefined;
56
73
  /** Running-agent streaming state, or undefined when not streaming. */
57
74
  streaming(): StreamingState | undefined;
75
+ /** Resolve a registered tool definition by name, for Pi's tool-execution components. */
76
+ getToolDefinition(name: string): ToolDefinition | undefined;
58
77
  }
59
78
 
60
- /** Filter the agents to those with a viewable session and label each for the picker. */
79
+ /**
80
+ * Label every navigable subagent for the picker: live records with a viewable
81
+ * session, then agents evicted by the cleanup sweep (deduped against live ids).
82
+ */
61
83
  export function listNavigableAgents(
62
84
  agents: readonly NavigableSubagent[],
85
+ evicted: readonly EvictedSubagent[],
63
86
  registry: AgentConfigLookup,
64
87
  ): NavigationEntry[] {
65
- return agents
88
+ const live = agents
66
89
  .filter((record) => record.isSessionReady())
67
- .map((record) => ({ record, label: buildLabel(record, registry) }));
90
+ .map((record): NavigationEntry => ({ kind: "live", record, label: buildLabel(record, registry) }));
91
+ const liveIds = new Set(agents.map((record) => record.id));
92
+ const evictedEntries = evicted
93
+ .filter((descriptor) => !liveIds.has(descriptor.id))
94
+ .map((descriptor): NavigationEntry => ({
95
+ kind: "evicted",
96
+ outputFile: descriptor.outputFile,
97
+ label: buildLabel(descriptor, registry, true),
98
+ }));
99
+ return [...live, ...evictedEntries];
100
+ }
101
+
102
+ /**
103
+ * Source a transcript from a persisted child-session JSONL snapshot.
104
+ *
105
+ * For an agent evicted from the manager's map by the 10-minute cleanup sweep:
106
+ * the in-memory record (and its message history) is gone, but the session file
107
+ * survives on disk. Reads the file, drops the `SessionHeader`, and resolves the
108
+ * message list via Pi's own parser. A static snapshot — no subscription, no
109
+ * streaming, no live tool registry. `readFile` is injected so this module makes
110
+ * no `fs` calls.
111
+ */
112
+ export function fileSnapshotSource(
113
+ outputFile: string,
114
+ readFile: (path: string) => string,
115
+ ): TranscriptSource {
116
+ const entries = parseSessionEntries(readFile(outputFile));
117
+ const sessionEntries = entries.filter((entry): entry is SessionEntry => entry.type !== "session");
118
+ const { messages } = buildSessionContext(sessionEntries);
119
+ return {
120
+ getMessages: () => messages,
121
+ subscribe: () => undefined,
122
+ streaming: () => undefined,
123
+ getToolDefinition: () => undefined,
124
+ };
68
125
  }
69
126
 
70
127
  /** Source a transcript live from an in-memory record (this slice's only source). */
@@ -76,39 +133,13 @@ export function liveSource(record: NavigableSubagent): TranscriptSource {
76
133
  record.status === "running"
77
134
  ? { activeTools: record.activeTools, responseText: record.responseText }
78
135
  : undefined,
136
+ getToolDefinition: (name) => record.getToolDefinition(name),
79
137
  };
80
138
  }
81
139
 
82
- /** Render a source's transcript to plain text lines via Pi's `serializeConversation`. */
83
- export function renderTranscriptLines(source: TranscriptSource): string[] {
84
- const messages = source.getMessages();
85
- const lines =
86
- messages.length === 0 ? ["(no messages yet)"] : serializeConversation(toMessages(messages)).split("\n");
87
-
88
- const streaming = source.streaming();
89
- if (streaming) {
90
- lines.push("", `◍ ${describeActivity(streaming.activeTools, streaming.responseText)}`);
91
- }
92
- return lines;
93
- }
94
-
95
- /**
96
- * Bridge the session's `AgentMessage[]` to `serializeConversation`'s `Message[]`.
97
- *
98
- * `AgentMessage` is a superset of `Message` (it adds session-display variants such
99
- * as `BashExecutionMessage`); `serializeConversation` renders the shared shape and
100
- * best-effort text for the rest. `Message` is not re-exported from the public
101
- * `@earendil-works/pi-ai` barrel, so the parameter type is referenced via the
102
- * function signature rather than imported by name.
103
- */
104
- function toMessages(
105
- messages: readonly SessionMessage[],
106
- ): Parameters<typeof serializeConversation>[0] {
107
- return messages as unknown as Parameters<typeof serializeConversation>[0];
108
- }
109
-
110
- function buildLabel(record: NavigableSubagent, registry: AgentConfigLookup): string {
111
- const name = getDisplayName(record.type, registry);
112
- const duration = formatDuration(record.startedAt, record.completedAt);
113
- return `${name} (${record.description}) · ${record.toolUses} tools · ${record.status} · ${duration}`;
140
+ function buildLabel(fields: LabelFields, registry: AgentConfigLookup, evicted = false): string {
141
+ const name = getDisplayName(fields.type, registry);
142
+ const duration = formatDuration(fields.startedAt, fields.completedAt);
143
+ const marker = evicted ? " · evicted (snapshot)" : "";
144
+ return `${name} (${fields.description}) · ${fields.toolUses} tools · ${fields.status} · ${duration}${marker}`;
114
145
  }
@@ -1,26 +1,47 @@
1
1
  /**
2
2
  * session-navigator.ts — The `/subagent-sessions` command: pick a subagent and
3
- * read its transcript through Pi's own session-rendering text.
3
+ * read its transcript through Pi's own per-entry session components.
4
4
  *
5
5
  * SDK/TUI consumer half of native session navigation. The unit-testable core
6
- * (selection, sourcing, text rendering) lives in `session-navigation.ts`; this
7
- * module wires that core to the command picker and a read-only scrollable overlay.
6
+ * (selection, sourcing) lives in `session-navigation.ts`; this module wires that
7
+ * core to the command picker and a read-only scrollable overlay, and owns the
8
+ * renderer — it mounts Pi's interactive components (`AssistantMessageComponent`,
9
+ * `ToolExecutionComponent`, …) into a `Container`, mirroring Pi's own
10
+ * `renderSessionContext` mapping. Rendering lives here, not in the pure module,
11
+ * because the components require a `TUI`, `cwd`, and markdown theme.
8
12
  *
9
13
  * The overlay is strictly read-only — steering stays in the `steer_subagent` tool
10
- * and the widget. It consumes a `TranscriptSource`, so the renderer-upgrade and
11
- * evicted-agent-source follow-ups swap the source/renderer without touching it.
14
+ * and the widget. It consumes a `TranscriptSource`, so the evicted-agent-source
15
+ * follow-up swaps the source without touching the renderer or the overlay.
12
16
  */
13
17
 
14
- import { type Component, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
15
- import type { AgentConfigLookup } from "#src/config/agent-types";
16
- import type { Theme } from "#src/ui/display";
17
18
  import {
18
- listNavigableAgents,
19
- liveSource,
20
- type NavigableSubagent,
21
- renderTranscriptLines,
22
- type TranscriptSource,
23
- } from "#src/ui/session-navigation";
19
+ AssistantMessageComponent,
20
+ BashExecutionComponent,
21
+ BranchSummaryMessageComponent,
22
+ CompactionSummaryMessageComponent,
23
+ getMarkdownTheme,
24
+ parseSkillBlock,
25
+ SkillInvocationMessageComponent,
26
+ type ToolDefinition,
27
+ ToolExecutionComponent,
28
+ UserMessageComponent,
29
+ } from "@earendil-works/pi-coding-agent";
30
+ import {
31
+ type Component,
32
+ Container,
33
+ type MarkdownTheme,
34
+ matchesKey,
35
+ Spacer,
36
+ type TUI,
37
+ truncateToWidth,
38
+ visibleWidth,
39
+ } from "@earendil-works/pi-tui";
40
+ import type { AgentConfigLookup } from "#src/config/agent-types";
41
+ import type { EvictedSubagent } from "#src/lifecycle/subagent-manager";
42
+ import type { SessionMessage } from "#src/types";
43
+ import { describeActivity, type Theme } from "#src/ui/display";
44
+ import { fileSnapshotSource, listNavigableAgents, liveSource, type NavigableSubagent, type TranscriptSource } from "#src/ui/session-navigation";
24
45
 
25
46
  // ─────────────────────────────────────────────────────────────────────────────
26
47
 
@@ -48,7 +69,13 @@ export interface SessionNavigatorUI {
48
69
  export interface SessionNavigatorParams {
49
70
  ui: SessionNavigatorUI;
50
71
  agents: readonly NavigableSubagent[];
72
+ /** Descriptors of agents evicted by the cleanup sweep, sourced from disk when picked. */
73
+ evicted: readonly EvictedSubagent[];
51
74
  registry: AgentConfigLookup;
75
+ /** Working directory for tool-call rendering (relative path display). */
76
+ cwd: string;
77
+ /** Reads a persisted session file for the file-snapshot source. */
78
+ readFile: (path: string) => string;
52
79
  }
53
80
 
54
81
  /** Options for the read-only transcript overlay. */
@@ -57,7 +84,8 @@ export interface TranscriptOverlayOptions {
57
84
  theme: Theme;
58
85
  source: TranscriptSource;
59
86
  done: (result: undefined) => void;
60
- wrapText: (text: string, width: number) => string[];
87
+ cwd: string;
88
+ markdownTheme: MarkdownTheme;
61
89
  }
62
90
 
63
91
  /**
@@ -68,8 +96,8 @@ export interface TranscriptOverlayOptions {
68
96
  * manager, so it stays a reactive consumer with no inbound call into the core.
69
97
  */
70
98
  export class SessionNavigatorHandler {
71
- async handle({ ui, agents, registry }: SessionNavigatorParams): Promise<void> {
72
- const entries = listNavigableAgents(agents, registry);
99
+ async handle({ ui, agents, evicted, registry, cwd, readFile }: SessionNavigatorParams): Promise<void> {
100
+ const entries = listNavigableAgents(agents, evicted, registry);
73
101
  if (entries.length === 0) {
74
102
  ui.notify("No subagent sessions to view.", "info");
75
103
  return;
@@ -82,10 +110,17 @@ export class SessionNavigatorHandler {
82
110
  const entry = entries.find((candidate) => candidate.label === choice);
83
111
  if (!entry) return;
84
112
 
85
- const source = liveSource(entry.record);
113
+ let source: TranscriptSource;
114
+ try {
115
+ source = entry.kind === "live" ? liveSource(entry.record) : fileSnapshotSource(entry.outputFile, readFile);
116
+ } catch {
117
+ ui.notify("Could not read the session transcript file.", "error");
118
+ return;
119
+ }
120
+ const markdownTheme = getMarkdownTheme();
86
121
  await ui.custom<undefined>(
87
122
  (tui, theme, _keybindings, done) =>
88
- new TranscriptOverlay({ tui, theme, source, done, wrapText: wrapTextWithAnsi }),
123
+ new TranscriptOverlay({ tui, theme, source, done, cwd, markdownTheme }),
89
124
  {
90
125
  overlay: true,
91
126
  overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
@@ -97,9 +132,11 @@ export class SessionNavigatorHandler {
97
132
  /**
98
133
  * Read-only scrollable transcript overlay.
99
134
  *
100
- * Re-renders on every source update (live agents); the transcript text is Pi's
101
- * `serializeConversation` output from `renderTranscriptLines`this class owns
102
- * only scroll state and chrome, no message formatting.
135
+ * Caches a `Container` of Pi's per-entry components and rebuilds it only when the
136
+ * source changes (live agents)each paint reuses the cached tree, so markdown
137
+ * highlighting does not re-run per frame. This class owns scroll state, chrome,
138
+ * and the running-agent streaming indicator; the component mapping lives in
139
+ * `buildTranscriptComponents`.
103
140
  */
104
141
  export class TranscriptOverlay implements Component {
105
142
  private scrollOffset = 0;
@@ -111,16 +148,21 @@ export class TranscriptOverlay implements Component {
111
148
  private readonly theme: Theme;
112
149
  private readonly source: TranscriptSource;
113
150
  private readonly done: (result: undefined) => void;
114
- private readonly wrapText: (text: string, width: number) => string[];
151
+ private readonly cwd: string;
152
+ private readonly markdownTheme: MarkdownTheme;
153
+ private content: Container;
115
154
 
116
- constructor({ tui, theme, source, done, wrapText }: TranscriptOverlayOptions) {
155
+ constructor({ tui, theme, source, done, cwd, markdownTheme }: TranscriptOverlayOptions) {
117
156
  this.tui = tui;
118
157
  this.theme = theme;
119
158
  this.source = source;
120
159
  this.done = done;
121
- this.wrapText = wrapText;
160
+ this.cwd = cwd;
161
+ this.markdownTheme = markdownTheme;
162
+ this.content = this.rebuild();
122
163
  this.unsubscribe = source.subscribe(() => {
123
164
  if (this.closed) return;
165
+ this.content = this.rebuild();
124
166
  this.tui.requestRender();
125
167
  });
126
168
  }
@@ -199,7 +241,7 @@ export class TranscriptOverlay implements Component {
199
241
 
200
242
  // fallow-ignore-next-line unused-class-member
201
243
  invalidate(): void {
202
- /* no cached state to clear */
244
+ this.content.invalidate();
203
245
  }
204
246
 
205
247
  // fallow-ignore-next-line unused-class-member
@@ -224,14 +266,139 @@ export class TranscriptOverlay implements Component {
224
266
 
225
267
  private buildContentLines(innerW: number): string[] {
226
268
  if (innerW <= 0) return [];
227
- const wrapped: string[] = [];
228
- for (const line of renderTranscriptLines(this.source)) {
229
- if (line === "") {
230
- wrapped.push("");
231
- continue;
269
+ const lines = this.content.render(innerW);
270
+ const streaming = this.source.streaming();
271
+ if (streaming) {
272
+ lines.push("", `◍ ${describeActivity(streaming.activeTools, streaming.responseText)}`);
273
+ }
274
+ return lines.map((l) => truncateToWidth(l, innerW));
275
+ }
276
+
277
+ private rebuild(): Container {
278
+ return buildTranscriptComponents(this.source.getMessages(), {
279
+ tui: this.tui,
280
+ cwd: this.cwd,
281
+ markdownTheme: this.markdownTheme,
282
+ getToolDefinition: (name) => this.source.getToolDefinition(name),
283
+ });
284
+ }
285
+ }
286
+
287
+ /** Dependencies the per-entry component tree needs from the SDK/TUI environment. */
288
+ interface TranscriptRenderOptions {
289
+ tui: TUI;
290
+ cwd: string;
291
+ markdownTheme: MarkdownTheme;
292
+ getToolDefinition: (name: string) => ToolDefinition | undefined;
293
+ }
294
+
295
+ /**
296
+ * Build a `Container` of Pi's per-entry components from a message snapshot,
297
+ * mirroring Pi's own interactive-mode `renderSessionContext` mapping. Tool
298
+ * results are matched to their tool-call components by id, exactly as Pi does.
299
+ * `custom`-role messages are skipped — rendering them needs the child session's
300
+ * message-renderer registry, which the navigator does not hold.
301
+ */
302
+ function buildTranscriptComponents(
303
+ messages: readonly SessionMessage[],
304
+ opts: TranscriptRenderOptions,
305
+ ): Container {
306
+ const container = new Container();
307
+ const pendingTools = new Map<string, ToolExecutionComponent>();
308
+ for (const message of messages) {
309
+ addMessageComponents(container, message, pendingTools, opts);
310
+ }
311
+ return container;
312
+ }
313
+
314
+ function addMessageComponents(
315
+ container: Container,
316
+ message: SessionMessage,
317
+ pendingTools: Map<string, ToolExecutionComponent>,
318
+ opts: TranscriptRenderOptions,
319
+ ): void {
320
+ switch (message.role) {
321
+ case "assistant": {
322
+ container.addChild(new AssistantMessageComponent(message, false, opts.markdownTheme));
323
+ for (const content of message.content) {
324
+ if (content.type !== "toolCall") continue;
325
+ const tool = new ToolExecutionComponent(
326
+ content.name,
327
+ content.id,
328
+ content.arguments,
329
+ { showImages: false },
330
+ opts.getToolDefinition(content.name),
331
+ opts.tui,
332
+ opts.cwd,
333
+ );
334
+ tool.setExpanded(true);
335
+ container.addChild(tool);
336
+ pendingTools.set(content.id, tool);
232
337
  }
233
- wrapped.push(...this.wrapText(line, innerW));
338
+ break;
339
+ }
340
+ case "toolResult": {
341
+ pendingTools.get(message.toolCallId)?.updateResult(message);
342
+ pendingTools.delete(message.toolCallId);
343
+ break;
344
+ }
345
+ case "user": {
346
+ addUserComponents(container, message.content, opts.markdownTheme);
347
+ break;
348
+ }
349
+ case "bashExecution": {
350
+ const bash = new BashExecutionComponent(message.command, opts.tui, message.excludeFromContext);
351
+ if (message.output) bash.appendOutput(message.output);
352
+ bash.setComplete(message.exitCode, message.cancelled, undefined, message.fullOutputPath);
353
+ container.addChild(bash);
354
+ break;
355
+ }
356
+ case "compactionSummary": {
357
+ container.addChild(new Spacer(1));
358
+ const summary = new CompactionSummaryMessageComponent(message, opts.markdownTheme);
359
+ summary.setExpanded(true);
360
+ container.addChild(summary);
361
+ break;
234
362
  }
235
- return wrapped.map((l) => truncateToWidth(l, innerW));
363
+ case "branchSummary": {
364
+ container.addChild(new Spacer(1));
365
+ const summary = new BranchSummaryMessageComponent(message, opts.markdownTheme);
366
+ summary.setExpanded(true);
367
+ container.addChild(summary);
368
+ break;
369
+ }
370
+ }
371
+ }
372
+
373
+ /** Render a user message (skill block + text) into the container, mirroring Pi. */
374
+ function addUserComponents(
375
+ container: Container,
376
+ content: string | readonly { type: string; text?: string }[],
377
+ markdownTheme: MarkdownTheme,
378
+ ): void {
379
+ const text = userMessageText(content);
380
+ if (!text) return;
381
+ if (container.children.length > 0) container.addChild(new Spacer(1));
382
+
383
+ const skillBlock = parseSkillBlock(text);
384
+ if (!skillBlock) {
385
+ container.addChild(new UserMessageComponent(text, markdownTheme));
386
+ return;
387
+ }
388
+ const skill = new SkillInvocationMessageComponent(skillBlock, markdownTheme);
389
+ skill.setExpanded(true);
390
+ container.addChild(skill);
391
+ if (skillBlock.userMessage) {
392
+ container.addChild(new Spacer(1));
393
+ container.addChild(new UserMessageComponent(skillBlock.userMessage, markdownTheme));
236
394
  }
237
395
  }
396
+
397
+ /** Concatenate the text blocks of a user message's content (mirrors Pi). */
398
+ function userMessageText(content: string | readonly { type: string; text?: string }[]): string {
399
+ if (typeof content === "string") return content;
400
+ return content
401
+ .filter((block) => block.type === "text")
402
+ .map((block) => block.text ?? "")
403
+ .join("");
404
+ }