@gotgenes/pi-subagents 17.2.0 → 17.4.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.
@@ -13,12 +13,14 @@
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";
19
20
  import { getSessionContextPercent, type SessionStatsLike } from "#src/lifecycle/usage";
20
21
  import { extractText } from "#src/session/context";
21
22
  import { getAgentConversation } from "#src/session/conversation";
23
+ import type { SessionMessage } from "#src/types";
22
24
 
23
25
  /** Outcome of one turn loop. */
24
26
  export interface TurnLoopResult {
@@ -178,6 +180,16 @@ export class SubagentSession {
178
180
  return this._session.messages as readonly unknown[];
179
181
  }
180
182
 
183
+ /** The session's message history, typed for Pi's session-rendering machinery. */
184
+ get agentMessages(): readonly SessionMessage[] {
185
+ return this._session.messages;
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
+
181
193
  /** Tear down: session.dispose() + emit `disposed` (registry unregister). */
182
194
  dispose(): void {
183
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";
@@ -20,7 +20,7 @@ import { WorkspaceBracket } from "#src/lifecycle/workspace-bracket";
20
20
  import { NotificationState } from "#src/observation/notification-state";
21
21
  import { subscribeSubagentObserver } from "#src/observation/record-observer";
22
22
  import type { RunConfig } from "#src/runtime";
23
- import type { AgentInvocation, CompactionInfo, ParentSessionInfo, SubagentType, ThinkingLevel } from "#src/types";
23
+ import type { AgentInvocation, CompactionInfo, ParentSessionInfo, SessionMessage, SubagentType, ThinkingLevel } from "#src/types";
24
24
 
25
25
  /** Per-subagent lifecycle observer — created by SubagentManager for each spawn. */
26
26
  export interface SubagentLifecycleObserver {
@@ -162,6 +162,16 @@ export class Subagent {
162
162
  return this.subagentSession?.messages ?? [];
163
163
  }
164
164
 
165
+ /** The session's message history typed for Pi's session-rendering machinery, or empty if no session. */
166
+ get agentMessages(): readonly SessionMessage[] {
167
+ return this.subagentSession?.agentMessages ?? [];
168
+ }
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
+
165
175
  constructor(init: SubagentInit) {
166
176
  // Identity
167
177
  this.id = init.id;
package/src/types.ts CHANGED
@@ -3,13 +3,22 @@
3
3
  */
4
4
 
5
5
  import type { ThinkingLevel } from "@earendil-works/pi-ai";
6
- import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
6
+ import type { AgentSessionEvent, SessionContext as SdkSessionContext } from "@earendil-works/pi-coding-agent";
7
7
  import type { ModelRegistry } from "#src/session/model-resolver";
8
8
 
9
9
 
10
10
  export { Subagent } from "#src/lifecycle/subagent";
11
11
  export type { AgentSessionEvent, ThinkingLevel };
12
12
 
13
+ /**
14
+ * One message in a child session's history, typed from Pi's `SessionContext`.
15
+ *
16
+ * Derived from the barrel-exported `SessionContext` (whose `messages` field is
17
+ * `AgentMessage[]`) so the package needs no direct dependency on
18
+ * `@earendil-works/pi-agent-core`, which is not re-exported from the public barrel.
19
+ */
20
+ export type SessionMessage = SdkSessionContext["messages"][number];
21
+
13
22
  /**
14
23
  * Narrow session interface for event subscription.
15
24
  * Used by record-observer — only the subscribe method is needed.
@@ -0,0 +1,91 @@
1
+ /**
2
+ * session-navigation.ts — Pure selection and transcript-sourcing for native session navigation.
3
+ *
4
+ * Splits the unit-testable core of the `/subagent-sessions` command from its TUI
5
+ * wiring (`session-navigator.ts`): which subagents are navigable and how a picked
6
+ * agent's transcript is sourced (live, in this slice).
7
+ *
8
+ * The `TranscriptSource` seam decouples *how messages are sourced* (live record
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.
13
+ */
14
+
15
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
16
+ import type { AgentConfigLookup } from "#src/config/agent-types";
17
+ import type { SubagentStatus } from "#src/lifecycle/subagent-state";
18
+ import type { AgentSessionEvent, SessionMessage, SubagentType } from "#src/types";
19
+ import { formatDuration, getDisplayName } from "#src/ui/display";
20
+
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+
23
+ /** The record fields the navigator reads to label and live-source a transcript. */
24
+ export interface NavigableSubagent {
25
+ readonly id: string;
26
+ readonly type: SubagentType;
27
+ readonly description: string;
28
+ readonly status: SubagentStatus;
29
+ readonly startedAt: number;
30
+ readonly completedAt: number | undefined;
31
+ readonly toolUses: number;
32
+ readonly activeTools: ReadonlyMap<string, string>;
33
+ readonly responseText: string;
34
+ readonly agentMessages: readonly SessionMessage[];
35
+ isSessionReady(): boolean;
36
+ subscribeToUpdates(fn: (event: AgentSessionEvent) => void): (() => void) | undefined;
37
+ getToolDefinition(name: string): ToolDefinition | undefined;
38
+ }
39
+
40
+ /** A navigable entry: a record plus the label shown in the picker. */
41
+ export interface NavigationEntry {
42
+ readonly record: NavigableSubagent;
43
+ readonly label: string;
44
+ }
45
+
46
+ /** Running-agent streaming state, surfaced by a live source. */
47
+ export interface StreamingState {
48
+ readonly activeTools: ReadonlyMap<string, string>;
49
+ readonly responseText: string;
50
+ }
51
+
52
+ /** Liveness-agnostic transcript source consumed by the renderer. */
53
+ export interface TranscriptSource {
54
+ /** Current message history. */
55
+ getMessages(): readonly SessionMessage[];
56
+ /** Subscribe to changes; returns an unsubscribe, or undefined for a static snapshot. */
57
+ subscribe(onChange: () => void): (() => void) | undefined;
58
+ /** Running-agent streaming state, or undefined when not streaming. */
59
+ streaming(): StreamingState | undefined;
60
+ /** Resolve a registered tool definition by name, for Pi's tool-execution components. */
61
+ getToolDefinition(name: string): ToolDefinition | undefined;
62
+ }
63
+
64
+ /** Filter the agents to those with a viewable session and label each for the picker. */
65
+ export function listNavigableAgents(
66
+ agents: readonly NavigableSubagent[],
67
+ registry: AgentConfigLookup,
68
+ ): NavigationEntry[] {
69
+ return agents
70
+ .filter((record) => record.isSessionReady())
71
+ .map((record) => ({ record, label: buildLabel(record, registry) }));
72
+ }
73
+
74
+ /** Source a transcript live from an in-memory record (this slice's only source). */
75
+ export function liveSource(record: NavigableSubagent): TranscriptSource {
76
+ return {
77
+ getMessages: () => record.agentMessages,
78
+ subscribe: (onChange) => record.subscribeToUpdates(() => onChange()),
79
+ streaming: () =>
80
+ record.status === "running"
81
+ ? { activeTools: record.activeTools, responseText: record.responseText }
82
+ : undefined,
83
+ getToolDefinition: (name) => record.getToolDefinition(name),
84
+ };
85
+ }
86
+
87
+ function buildLabel(record: NavigableSubagent, registry: AgentConfigLookup): string {
88
+ const name = getDisplayName(record.type, registry);
89
+ const duration = formatDuration(record.startedAt, record.completedAt);
90
+ return `${name} (${record.description}) · ${record.toolUses} tools · ${record.status} · ${duration}`;
91
+ }
@@ -0,0 +1,393 @@
1
+ /**
2
+ * session-navigator.ts — The `/subagent-sessions` command: pick a subagent and
3
+ * read its transcript through Pi's own per-entry session components.
4
+ *
5
+ * SDK/TUI consumer half of native session navigation. The unit-testable core
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.
12
+ *
13
+ * The overlay is strictly read-only — steering stays in the `steer_subagent` tool
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.
16
+ */
17
+
18
+ import {
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 { SessionMessage } from "#src/types";
42
+ import { describeActivity, type Theme } from "#src/ui/display";
43
+ import { listNavigableAgents, liveSource, type NavigableSubagent, type TranscriptSource } from "#src/ui/session-navigation";
44
+
45
+ // ─────────────────────────────────────────────────────────────────────────────
46
+
47
+ /** Chrome lines: top border + header + header sep + footer sep + footer + bottom border. */
48
+ const CHROME_LINES = 6;
49
+ const MIN_VIEWPORT = 3;
50
+ const VIEWPORT_HEIGHT_PCT = 70;
51
+
52
+ /** Component factory shape Pi's `ui.custom` invokes to mount an overlay. */
53
+ export type OverlayComponentFactory<R> = (
54
+ tui: TUI,
55
+ theme: Theme,
56
+ keybindings: unknown,
57
+ done: (result: R) => void,
58
+ ) => Component;
59
+
60
+ /** Narrow UI interface — only the `ctx.ui` methods the navigator calls. */
61
+ export interface SessionNavigatorUI {
62
+ select(title: string, options: string[]): Promise<string | undefined>;
63
+ notify(message: string, level: "info" | "warning" | "error"): void;
64
+ custom<R>(component: OverlayComponentFactory<R>, options?: unknown): Promise<R>;
65
+ }
66
+
67
+ /** Parameters for one `/subagent-sessions` invocation. */
68
+ export interface SessionNavigatorParams {
69
+ ui: SessionNavigatorUI;
70
+ agents: readonly NavigableSubagent[];
71
+ registry: AgentConfigLookup;
72
+ /** Working directory for tool-call rendering (relative path display). */
73
+ cwd: string;
74
+ }
75
+
76
+ /** Options for the read-only transcript overlay. */
77
+ export interface TranscriptOverlayOptions {
78
+ tui: TUI;
79
+ theme: Theme;
80
+ source: TranscriptSource;
81
+ done: (result: undefined) => void;
82
+ cwd: string;
83
+ markdownTheme: MarkdownTheme;
84
+ }
85
+
86
+ /**
87
+ * Handler for the `/subagent-sessions` slash command.
88
+ *
89
+ * Lists navigable subagents, lets the operator pick one, and opens its transcript
90
+ * read-only. Receives the agent snapshot (`manager.listAgents()`) rather than the
91
+ * manager, so it stays a reactive consumer with no inbound call into the core.
92
+ */
93
+ export class SessionNavigatorHandler {
94
+ async handle({ ui, agents, registry, cwd }: SessionNavigatorParams): Promise<void> {
95
+ const entries = listNavigableAgents(agents, registry);
96
+ if (entries.length === 0) {
97
+ ui.notify("No subagent sessions to view.", "info");
98
+ return;
99
+ }
100
+
101
+ const choice = await ui.select(
102
+ "Subagent sessions",
103
+ entries.map((entry) => entry.label),
104
+ );
105
+ const entry = entries.find((candidate) => candidate.label === choice);
106
+ if (!entry) return;
107
+
108
+ const source = liveSource(entry.record);
109
+ const markdownTheme = getMarkdownTheme();
110
+ await ui.custom<undefined>(
111
+ (tui, theme, _keybindings, done) =>
112
+ new TranscriptOverlay({ tui, theme, source, done, cwd, markdownTheme }),
113
+ {
114
+ overlay: true,
115
+ overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
116
+ },
117
+ );
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Read-only scrollable transcript overlay.
123
+ *
124
+ * Caches a `Container` of Pi's per-entry components and rebuilds it only when the
125
+ * source changes (live agents) — each paint reuses the cached tree, so markdown
126
+ * highlighting does not re-run per frame. This class owns scroll state, chrome,
127
+ * and the running-agent streaming indicator; the component mapping lives in
128
+ * `buildTranscriptComponents`.
129
+ */
130
+ export class TranscriptOverlay implements Component {
131
+ private scrollOffset = 0;
132
+ private autoScroll = true;
133
+ private unsubscribe: (() => void) | undefined;
134
+ private closed = false;
135
+
136
+ private readonly tui: TUI;
137
+ private readonly theme: Theme;
138
+ private readonly source: TranscriptSource;
139
+ private readonly done: (result: undefined) => void;
140
+ private readonly cwd: string;
141
+ private readonly markdownTheme: MarkdownTheme;
142
+ private content: Container;
143
+
144
+ constructor({ tui, theme, source, done, cwd, markdownTheme }: TranscriptOverlayOptions) {
145
+ this.tui = tui;
146
+ this.theme = theme;
147
+ this.source = source;
148
+ this.done = done;
149
+ this.cwd = cwd;
150
+ this.markdownTheme = markdownTheme;
151
+ this.content = this.rebuild();
152
+ this.unsubscribe = source.subscribe(() => {
153
+ if (this.closed) return;
154
+ this.content = this.rebuild();
155
+ this.tui.requestRender();
156
+ });
157
+ }
158
+
159
+ // fallow-ignore-next-line unused-class-member
160
+ handleInput(data: string): void {
161
+ if (matchesKey(data, "escape") || matchesKey(data, "q")) {
162
+ this.closed = true;
163
+ this.done(undefined);
164
+ return;
165
+ }
166
+
167
+ const totalLines = this.buildContentLines(this.innerWidth()).length;
168
+ const viewportHeight = this.viewportHeight();
169
+ const maxScroll = Math.max(0, totalLines - viewportHeight);
170
+
171
+ if (matchesKey(data, "up") || matchesKey(data, "k")) {
172
+ this.scrollOffset = Math.max(0, this.scrollOffset - 1);
173
+ this.autoScroll = this.scrollOffset >= maxScroll;
174
+ } else if (matchesKey(data, "down") || matchesKey(data, "j")) {
175
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
176
+ this.autoScroll = this.scrollOffset >= maxScroll;
177
+ } else if (matchesKey(data, "pageUp") || matchesKey(data, "shift+up")) {
178
+ this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
179
+ this.autoScroll = false;
180
+ } else if (matchesKey(data, "pageDown") || matchesKey(data, "shift+down")) {
181
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
182
+ this.autoScroll = this.scrollOffset >= maxScroll;
183
+ } else if (matchesKey(data, "home")) {
184
+ this.scrollOffset = 0;
185
+ this.autoScroll = false;
186
+ } else if (matchesKey(data, "end")) {
187
+ this.scrollOffset = maxScroll;
188
+ this.autoScroll = true;
189
+ }
190
+ }
191
+
192
+ render(width: number): string[] {
193
+ if (width < 6) return [];
194
+ const th = this.theme;
195
+ const innerW = width - 4;
196
+ const lines: string[] = [];
197
+
198
+ const pad = (s: string, len: number): string => s + " ".repeat(Math.max(0, len - visibleWidth(s)));
199
+ const row = (content: string): string =>
200
+ th.fg("border", "│") + " " + truncateToWidth(pad(content, innerW), innerW) + " " + th.fg("border", "│");
201
+ const hrTop = th.fg("border", `╭${"─".repeat(width - 2)}╮`);
202
+ const hrBot = th.fg("border", `╰${"─".repeat(width - 2)}╯`);
203
+ const hrMid = row(th.fg("dim", "─".repeat(innerW)));
204
+
205
+ lines.push(hrTop);
206
+ lines.push(row(th.bold("Subagent session")));
207
+ lines.push(hrMid);
208
+
209
+ const contentLines = this.buildContentLines(innerW);
210
+ const viewportHeight = this.viewportHeight();
211
+ const maxScroll = Math.max(0, contentLines.length - viewportHeight);
212
+ if (this.autoScroll) this.scrollOffset = maxScroll;
213
+ const visibleStart = Math.min(this.scrollOffset, maxScroll);
214
+ const visible = contentLines.slice(visibleStart, visibleStart + viewportHeight);
215
+ for (let i = 0; i < viewportHeight; i++) lines.push(row(visible[i] ?? ""));
216
+
217
+ lines.push(hrMid);
218
+ const scrollPct =
219
+ contentLines.length <= viewportHeight
220
+ ? "100%"
221
+ : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
222
+ const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
223
+ const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn · Esc close");
224
+ const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
225
+ lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
226
+ lines.push(hrBot);
227
+
228
+ return lines;
229
+ }
230
+
231
+ // fallow-ignore-next-line unused-class-member
232
+ invalidate(): void {
233
+ this.content.invalidate();
234
+ }
235
+
236
+ // fallow-ignore-next-line unused-class-member
237
+ dispose(): void {
238
+ this.closed = true;
239
+ if (this.unsubscribe) {
240
+ this.unsubscribe();
241
+ this.unsubscribe = undefined;
242
+ }
243
+ }
244
+
245
+ // ---- Private ----
246
+
247
+ private innerWidth(): number {
248
+ return Math.max(0, this.tui.terminal.columns - 4);
249
+ }
250
+
251
+ private viewportHeight(): number {
252
+ const maxRows = Math.floor((this.tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100);
253
+ return Math.max(MIN_VIEWPORT, maxRows - CHROME_LINES);
254
+ }
255
+
256
+ private buildContentLines(innerW: number): string[] {
257
+ if (innerW <= 0) return [];
258
+ const lines = this.content.render(innerW);
259
+ const streaming = this.source.streaming();
260
+ if (streaming) {
261
+ lines.push("", `◍ ${describeActivity(streaming.activeTools, streaming.responseText)}`);
262
+ }
263
+ return lines.map((l) => truncateToWidth(l, innerW));
264
+ }
265
+
266
+ private rebuild(): Container {
267
+ return buildTranscriptComponents(this.source.getMessages(), {
268
+ tui: this.tui,
269
+ cwd: this.cwd,
270
+ markdownTheme: this.markdownTheme,
271
+ getToolDefinition: (name) => this.source.getToolDefinition(name),
272
+ });
273
+ }
274
+ }
275
+
276
+ /** Dependencies the per-entry component tree needs from the SDK/TUI environment. */
277
+ interface TranscriptRenderOptions {
278
+ tui: TUI;
279
+ cwd: string;
280
+ markdownTheme: MarkdownTheme;
281
+ getToolDefinition: (name: string) => ToolDefinition | undefined;
282
+ }
283
+
284
+ /**
285
+ * Build a `Container` of Pi's per-entry components from a message snapshot,
286
+ * mirroring Pi's own interactive-mode `renderSessionContext` mapping. Tool
287
+ * results are matched to their tool-call components by id, exactly as Pi does.
288
+ * `custom`-role messages are skipped — rendering them needs the child session's
289
+ * message-renderer registry, which the navigator does not hold.
290
+ */
291
+ function buildTranscriptComponents(
292
+ messages: readonly SessionMessage[],
293
+ opts: TranscriptRenderOptions,
294
+ ): Container {
295
+ const container = new Container();
296
+ const pendingTools = new Map<string, ToolExecutionComponent>();
297
+ for (const message of messages) {
298
+ addMessageComponents(container, message, pendingTools, opts);
299
+ }
300
+ return container;
301
+ }
302
+
303
+ function addMessageComponents(
304
+ container: Container,
305
+ message: SessionMessage,
306
+ pendingTools: Map<string, ToolExecutionComponent>,
307
+ opts: TranscriptRenderOptions,
308
+ ): void {
309
+ switch (message.role) {
310
+ case "assistant": {
311
+ container.addChild(new AssistantMessageComponent(message, false, opts.markdownTheme));
312
+ for (const content of message.content) {
313
+ if (content.type !== "toolCall") continue;
314
+ const tool = new ToolExecutionComponent(
315
+ content.name,
316
+ content.id,
317
+ content.arguments,
318
+ { showImages: false },
319
+ opts.getToolDefinition(content.name),
320
+ opts.tui,
321
+ opts.cwd,
322
+ );
323
+ tool.setExpanded(true);
324
+ container.addChild(tool);
325
+ pendingTools.set(content.id, tool);
326
+ }
327
+ break;
328
+ }
329
+ case "toolResult": {
330
+ pendingTools.get(message.toolCallId)?.updateResult(message);
331
+ pendingTools.delete(message.toolCallId);
332
+ break;
333
+ }
334
+ case "user": {
335
+ addUserComponents(container, message.content, opts.markdownTheme);
336
+ break;
337
+ }
338
+ case "bashExecution": {
339
+ const bash = new BashExecutionComponent(message.command, opts.tui, message.excludeFromContext);
340
+ if (message.output) bash.appendOutput(message.output);
341
+ bash.setComplete(message.exitCode, message.cancelled, undefined, message.fullOutputPath);
342
+ container.addChild(bash);
343
+ break;
344
+ }
345
+ case "compactionSummary": {
346
+ container.addChild(new Spacer(1));
347
+ const summary = new CompactionSummaryMessageComponent(message, opts.markdownTheme);
348
+ summary.setExpanded(true);
349
+ container.addChild(summary);
350
+ break;
351
+ }
352
+ case "branchSummary": {
353
+ container.addChild(new Spacer(1));
354
+ const summary = new BranchSummaryMessageComponent(message, opts.markdownTheme);
355
+ summary.setExpanded(true);
356
+ container.addChild(summary);
357
+ break;
358
+ }
359
+ }
360
+ }
361
+
362
+ /** Render a user message (skill block + text) into the container, mirroring Pi. */
363
+ function addUserComponents(
364
+ container: Container,
365
+ content: string | readonly { type: string; text?: string }[],
366
+ markdownTheme: MarkdownTheme,
367
+ ): void {
368
+ const text = userMessageText(content);
369
+ if (!text) return;
370
+ if (container.children.length > 0) container.addChild(new Spacer(1));
371
+
372
+ const skillBlock = parseSkillBlock(text);
373
+ if (!skillBlock) {
374
+ container.addChild(new UserMessageComponent(text, markdownTheme));
375
+ return;
376
+ }
377
+ const skill = new SkillInvocationMessageComponent(skillBlock, markdownTheme);
378
+ skill.setExpanded(true);
379
+ container.addChild(skill);
380
+ if (skillBlock.userMessage) {
381
+ container.addChild(new Spacer(1));
382
+ container.addChild(new UserMessageComponent(skillBlock.userMessage, markdownTheme));
383
+ }
384
+ }
385
+
386
+ /** Concatenate the text blocks of a user message's content (mirrors Pi). */
387
+ function userMessageText(content: string | readonly { type: string; text?: string }[]): string {
388
+ if (typeof content === "string") return content;
389
+ return content
390
+ .filter((block) => block.type === "text")
391
+ .map((block) => block.text ?? "")
392
+ .join("");
393
+ }