@ian-pascoe/pi-minimal-subagents 0.15.0 → 0.17.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.
@@ -1,5 +1,5 @@
1
- import type { JsonValue } from "@earendil-works/pi-ai";
2
1
  import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
2
+ import { rewriteNamespaceDocument } from "@ian-pascoe/pi-utils/layered-settings";
3
3
  import { updateFileLocked } from "@ian-pascoe/pi-utils/locked-file-update";
4
4
  import { resolve } from "node:path";
5
5
 
@@ -12,31 +12,6 @@ export interface MinimalSubagentsSettingsWriteContext {
12
12
  isProjectTrusted(): boolean;
13
13
  }
14
14
 
15
- type SettingsJsonObject = Record<string, JsonValue>;
16
-
17
- function isSettingsJsonObject(value: JsonValue | undefined): value is SettingsJsonObject {
18
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- JSON.parse already established JSON data; distinguish object roots and settings blocks from primitives and arrays.
19
- return value !== null && typeof value === "object" && !Array.isArray(value);
20
- }
21
-
22
- function parseSettings(content: string | undefined, path: string): SettingsJsonObject {
23
- if (content === undefined) return {};
24
- // JSON.parse is the provenance for this JSON type; the object shape is checked below.
25
- let parsed: JsonValue;
26
- try {
27
- parsed = JSON.parse(content.charCodeAt(0) === 0xfeff ? content.slice(1) : content);
28
- } catch (cause) {
29
- throw new Error(`Minimal subagents settings JSON is malformed at ${path}`, { cause });
30
- }
31
- if (!isSettingsJsonObject(parsed)) {
32
- throw new Error(`Minimal subagents settings at ${path} must have an object root`);
33
- }
34
- if (parsed.minimalSubagents !== undefined && !isSettingsJsonObject(parsed.minimalSubagents)) {
35
- throw new Error(`Minimal subagents settings at ${path} must have an object minimalSubagents`);
36
- }
37
- return parsed;
38
- }
39
-
40
15
  /**
41
16
  * Set or remove `minimalSubagents.enabled` in global or trusted-project Pi settings,
42
17
  * preserving every unrelated setting. Writes re-read the file under Pi's settings lock.
@@ -58,15 +33,26 @@ export async function writeMinimalSubagentsEnabled(
58
33
  );
59
34
  }
60
35
 
61
- await updateFileLocked(path, (content) => {
62
- const settings = parseSettings(content, path);
63
- const current = settings.minimalSubagents;
64
- const minimalSubagents = isSettingsJsonObject(current) ? { ...current } : {};
65
- if (enabled === undefined) delete minimalSubagents.enabled;
66
- else minimalSubagents.enabled = enabled;
67
- if (Object.keys(minimalSubagents).length === 0) delete settings.minimalSubagents;
68
- else settings.minimalSubagents = minimalSubagents;
69
- return `${JSON.stringify(settings, undefined, 2)}\n`;
70
- });
36
+ await updateFileLocked(path, (current) =>
37
+ rewriteNamespaceDocument({
38
+ current,
39
+ namespace: "minimalSubagents",
40
+ invalid: (reason, cause) => {
41
+ if (reason === "malformed")
42
+ return new Error(`Minimal subagents settings JSON is malformed at ${path}`, { cause });
43
+ return new Error(
44
+ reason === "root"
45
+ ? `Minimal subagents settings at ${path} must have an object root`
46
+ : `Minimal subagents settings at ${path} must have an object minimalSubagents`,
47
+ );
48
+ },
49
+ update: (stored) => {
50
+ const minimalSubagents = { ...stored };
51
+ if (enabled === undefined) delete minimalSubagents.enabled;
52
+ else minimalSubagents.enabled = enabled;
53
+ return minimalSubagents;
54
+ },
55
+ }),
56
+ );
71
57
  return path;
72
58
  }
@@ -1,5 +1,6 @@
1
1
  import { stripVTControlCharacters } from "node:util";
2
2
  import {
3
+ DynamicBorder,
3
4
  type ExtensionContext,
4
5
  type KeybindingsManager,
5
6
  type Theme,
@@ -8,16 +9,20 @@ import {
8
9
  Text,
9
10
  matchesKey,
10
11
  truncateToWidth,
11
- visibleWidth,
12
+ wrapTextWithAnsi,
12
13
  type Component,
13
14
  type OverlayHandle,
14
15
  type TUI,
15
16
  } from "@earendil-works/pi-tui";
17
+ import { hintLine, joinInline, noticeText } from "@ian-pascoe/pi-utils/ui";
16
18
  import type { MinimalSubagentsCoordinator } from "./minimal-subagents-coordinator.js";
17
19
  import {
18
20
  formatSubagentDuration,
19
21
  orderActiveAgentSubtrees,
22
+ renderSubagentStatusLabel,
23
+ renderSubagentStatusMark,
20
24
  subagentStatusLadder,
25
+ treeRowPrefixes,
21
26
  } from "./minimal-subagents-rendering.js";
22
27
  import { COORDINATOR_TOOL_NAMES } from "./minimal-subagents-capabilities.js";
23
28
  import {
@@ -82,21 +87,11 @@ function statusAccessSourceLabel(source: SubagentAccessSnapshot["source"]): stri
82
87
  }
83
88
  }
84
89
 
85
- const BACKGROUND_CLEARING_SEQUENCES = ["\u001b[0m", "\u001b[m", "\u001b[49m"] as const;
86
-
87
- /**
88
- * Re-open the panel background after each sequence that clears it. Truncation ends its cut text
89
- * and ellipsis with full resets, and embedded native components may reset or close their own
90
- * backgrounds; without this the rest of the row (ellipsis, padding, border) loses the panel fill.
91
- */
92
- function keepBackgroundThroughResets(text: string, backgroundOpen: string): string {
93
- if (!backgroundOpen) return text;
94
- let result = text;
95
- for (const sequence of BACKGROUND_CLEARING_SEQUENCES) {
96
- result = result.replaceAll(sequence, `${sequence}${backgroundOpen}`);
97
- }
98
- return result;
99
- }
90
+ /** The panel's ellipsis, in Pi's three-dot form. */
91
+ const ELLIPSIS = "...";
92
+
93
+ /** The theme operations the panel draws with; Pi's `Theme` satisfies it. */
94
+ type StatusPanelTheme = Pick<Theme, "fg" | "bold">;
100
95
 
101
96
  function transcriptText(line: string): string {
102
97
  return stripVTControlCharacters(line).replace(/\s/g, "");
@@ -152,14 +147,15 @@ export class MinimalSubagentsStatusPanelComponent implements Component {
152
147
  private toolOutputExpanded = false;
153
148
  private disposed = false;
154
149
  private readonly stopRefresh: () => void;
150
+ private readonly border = new DynamicBorder((text) => this.theme.fg("border", text));
155
151
 
156
152
  /** Bind one live status component to its coordinator, terminal, and explicit refresh owner. */
157
153
  constructor(
158
154
  private readonly coordinator: MinimalSubagentsCoordinator,
159
155
  private readonly getAccess: () => MinimalSubagentsStatusAccess,
160
156
  private readonly tui: TUI,
161
- private readonly theme: Theme,
162
- private readonly keybindings: KeybindingsManager,
157
+ private readonly theme: StatusPanelTheme,
158
+ private readonly keybindings: Pick<KeybindingsManager, "matches" | "getKeys">,
163
159
  private readonly cwd: string,
164
160
  private readonly onClose: () => void,
165
161
  startRefresh: StartStatusPanelRefresh = startStatusPanelRefresh,
@@ -208,7 +204,7 @@ export class MinimalSubagentsStatusPanelComponent implements Component {
208
204
  this.tui.requestRender();
209
205
  }
210
206
 
211
- /** Render one framed, terminal-bounded tree or Child Session Transcript. */
207
+ /** Render the tree or Child Session Transcript in Pi's selector frame, within the terminal. */
212
208
  render(width: number): string[] {
213
209
  if (width <= 0) return [];
214
210
  const height = Math.max(
@@ -219,24 +215,24 @@ export class MinimalSubagentsStatusPanelComponent implements Component {
219
215
  ),
220
216
  );
221
217
  if (width < 6 || height < 5) {
222
- return new Text("Esc back · Enlarge terminal", 0, 0).render(width).slice(0, height);
218
+ const cancelKey = this.keybindings.getKeys("tui.select.cancel").join("/");
219
+ return new Text(this.theme.fg("muted", `${cancelKey} back · Enlarge terminal`), 0, 0)
220
+ .render(width)
221
+ .slice(0, height);
223
222
  }
224
- const innerWidth = width - 4;
223
+ const innerWidth = width - 2;
225
224
  const selected = this.flattened.find(({ agent }) => agent.agent_id === this.selectedAgentId);
226
225
  const transcriptView = this.view === "transcript" && this.transcript;
227
226
  const header = transcriptView
228
227
  ? [
229
- this.theme.bold(`Transcript · ${this.selectedAgentId}`),
230
- selected ? this.renderAgentRow(selected.agent, 0, innerWidth) : "",
228
+ this.theme.fg("accent", this.theme.bold(`Transcript · ${this.selectedAgentId}`)),
229
+ selected ? this.renderAgentRow(selected.agent, "", false) : "",
231
230
  ]
232
- : this.renderHeader(innerWidth);
233
- const toolKey = this.keybindings.getKeys("app.tools.expand").join("/");
234
- const helpText = transcriptView
235
- ? `Esc tree · End live · ${toolKey} tools · ↑↓/PgUp/PgDn scroll · ${this.following ? "Following" : "Paused"}`
236
- : "Esc close · Enter transcript · ↑↓ select · PgUp/PgDn page";
237
- const help = new Text(this.theme.fg("text", helpText), 0, 0)
238
- .render(innerWidth)
239
- .slice(0, Math.min(2, height - 4));
231
+ : this.renderHeader();
232
+ const help = [
233
+ "",
234
+ ...wrapTextWithAnsi(this.renderHints(Boolean(transcriptView)), innerWidth),
235
+ ].slice(0, Math.min(3, height - 4));
240
236
  const visibleHeader = header.slice(0, Math.max(1, height - help.length - 3));
241
237
  this.bodyHeight = Math.max(1, height - 2 - visibleHeader.length - help.length);
242
238
  let body: string[];
@@ -262,10 +258,11 @@ export class MinimalSubagentsStatusPanelComponent implements Component {
262
258
  const maximum = Math.max(0, body.length - this.bodyHeight);
263
259
  this.scrollOffset = this.following ? maximum : Math.min(this.scrollOffset, maximum);
264
260
  } else {
265
- body = this.flattened.map(({ agent, depth }) =>
266
- this.renderAgentRow(agent, depth, innerWidth),
261
+ const prefixes = treeRowPrefixes(this.flattened.map(({ depth }) => depth));
262
+ body = this.flattened.map(({ agent }, index) =>
263
+ this.renderAgentRow(agent, prefixes[index] ?? "", agent.agent_id === this.selectedAgentId),
267
264
  );
268
- if (body.length === 0) body.push("No Child Agents yet.");
265
+ if (body.length === 0) body.push(this.theme.fg("muted", "No Child Agents yet."));
269
266
  const selectedLine = this.flattened.findIndex(
270
267
  ({ agent }) => agent.agent_id === this.selectedAgentId,
271
268
  );
@@ -279,19 +276,12 @@ export class MinimalSubagentsStatusPanelComponent implements Component {
279
276
  }
280
277
  const visibleBody = body.slice(this.scrollOffset, this.scrollOffset + this.bodyHeight);
281
278
  while (visibleBody.length < this.bodyHeight) visibleBody.push("");
282
- const border = (text: string) => this.theme.fg("border", text);
283
- const backgroundOpen = this.theme.getBgAnsi("customMessageBg");
284
- const rows = [...visibleHeader, ...visibleBody, ...help].map((line) => {
285
- const content = keepBackgroundThroughResets(
286
- truncateToWidth(line, innerWidth, "…"),
287
- backgroundOpen,
288
- );
289
- return this.theme.bg(
290
- "customMessageBg",
291
- `${border("│")} ${content}${" ".repeat(innerWidth - visibleWidth(content))} ${border("│")}`,
292
- );
293
- });
294
- return [border(`╭${"─".repeat(width - 2)}╮`), ...rows, border(`╰${"─".repeat(width - 2)}╯`)];
279
+ // Each row keeps Pi's one-column margin and fills the pane so the overlay covers what is under it.
280
+ const rows = [...visibleHeader, ...visibleBody, ...help].map((line) =>
281
+ truncateToWidth(` ${line}`, width, ELLIPSIS, true),
282
+ );
283
+ const [border = ""] = this.border.render(width);
284
+ return [border, ...rows, border];
295
285
  }
296
286
 
297
287
  /** Rebuild native transcript components when their theme changes. */
@@ -326,7 +316,34 @@ export class MinimalSubagentsStatusPanelComponent implements Component {
326
316
  }
327
317
  }
328
318
 
329
- private renderHeader(width: number): string[] {
319
+ private keyLabel(binding: Parameters<KeybindingsManager["getKeys"]>[0]): string {
320
+ return this.keybindings.getKeys(binding).join("/");
321
+ }
322
+
323
+ private renderHints(transcript: boolean): string {
324
+ const page = `${this.keyLabel("tui.select.pageUp")}/${this.keyLabel("tui.select.pageDown")}`;
325
+ const hints = transcript
326
+ ? hintLine(this.theme, [
327
+ { key: this.keyLabel("tui.select.cancel"), description: "tree" },
328
+ { key: "end", description: "live" },
329
+ { key: this.keyLabel("app.tools.expand"), description: "tools" },
330
+ { key: "\u2191\u2193", description: "scroll" },
331
+ { key: page, description: "page" },
332
+ ])
333
+ : hintLine(this.theme, [
334
+ { key: "\u2191\u2193", description: "select" },
335
+ { key: this.keyLabel("tui.select.confirm"), description: "transcript" },
336
+ { key: page, description: "page" },
337
+ { key: this.keyLabel("tui.select.cancel"), description: "close" },
338
+ ]);
339
+ if (!transcript) return hints;
340
+ const following = this.following
341
+ ? this.theme.fg("muted", "following")
342
+ : this.theme.fg("warning", "paused");
343
+ return `${hints} ${following}`;
344
+ }
345
+
346
+ private renderHeader(): string[] {
330
347
  const direct = "agents" in this.status ? this.status.agents : [this.status.agent];
331
348
  const running = direct.filter((agent) => agent.state === "running").length;
332
349
  const idle = direct.length - running;
@@ -340,34 +357,41 @@ export class MinimalSubagentsStatusPanelComponent implements Component {
340
357
  const projectValue = this.access.projectTrusted
341
358
  ? authoredAccessValue(this.access.projectEnabled)
342
359
  : "unavailable (untrusted)";
360
+ const field = (label: string, ...values: string[]) =>
361
+ `${this.theme.fg("muted", `${label}:`)} ${joinInline(this.theme, values)}`;
343
362
  return [
344
- truncateToWidth(this.theme.bold("Subagents status"), width, "…"),
345
- truncateToWidth(
346
- `Access: ${accessState} · ${statusAccessSourceLabel(this.access.source)}`,
347
- width,
348
- "…",
349
- ),
350
- truncateToWidth(
351
- `Defaults: branch ${this.access.branchOverride} · project ${projectValue} · global ${authoredAccessValue(this.access.globalEnabled)}`,
352
- width,
353
- "…",
363
+ this.theme.fg("accent", this.theme.bold("Subagents status")),
364
+ field("Access", accessState, statusAccessSourceLabel(this.access.source)),
365
+ field(
366
+ "Defaults",
367
+ `branch ${this.access.branchOverride}`,
368
+ `project ${projectValue}`,
369
+ `global ${authoredAccessValue(this.access.globalEnabled)}`,
354
370
  ),
355
- truncateToWidth(`Coordinator Tools: ${toolState}`, width, "…"),
356
- truncateToWidth(`Direct Children: ${running} running · ${idle} idle`, width, "…"),
357
- this.theme.fg("warning", this.notice),
371
+ field("Coordinator Tools", toolState),
372
+ field("Direct Children", `${running} running`, `${idle} idle`),
373
+ ...(this.notice ? [this.theme.fg("warning", this.notice)] : []),
358
374
  ];
359
375
  }
360
376
 
361
- private renderAgentRow(agent: AgentSummary, depth: number, width: number): string {
362
- const selected = agent.agent_id === this.selectedAgentId;
363
- const disclosure = "▸";
377
+ /** One row of the hierarchy: selection marker, tree prefix, Status Mark, id, status, profile, task. */
378
+ private renderAgentRow(agent: AgentSummary, prefix: string, selected: boolean): string {
364
379
  const status = subagentStatusLadder(agent);
365
380
  const elapsed = formatSubagentDuration(agent.elapsed_ms);
366
381
  const task = agent.task?.replace(/\s+/g, " ").trim();
367
- const line = `${selected ? ">" : " "} ${" ".repeat(depth)}${disclosure} ${agent.agent_id} · ${status}${
368
- elapsed ? ` ${elapsed}` : ""
369
- } · ${agent.model}:${agent.thinking_level}${task ? ` · ${task}` : ""}`;
370
- return truncateToWidth(selected ? this.theme.fg("accent", line) : line, width, "…");
382
+ const identity = `${renderSubagentStatusMark(this.theme, status)} ${
383
+ selected ? this.theme.fg("accent", agent.agent_id) : agent.agent_id
384
+ }`;
385
+ const details = joinInline(this.theme, [
386
+ identity,
387
+ `${renderSubagentStatusLabel(this.theme, status)}${
388
+ elapsed ? ` ${this.theme.fg("muted", elapsed)}` : ""
389
+ }`,
390
+ this.theme.fg("muted", `${agent.model}:${agent.thinking_level}`),
391
+ task ? this.theme.fg("muted", task) : undefined,
392
+ ]);
393
+ const marker = selected ? this.theme.fg("accent", "→ ") : " ";
394
+ return `${marker}${prefix ? this.theme.fg("dim", prefix) : ""}${details}`;
371
395
  }
372
396
 
373
397
  private scroll(delta: number): void {
@@ -491,7 +515,7 @@ export class MinimalSubagentsStatusPanelController {
491
515
  },
492
516
  )
493
517
  .catch(() => {
494
- this.context.ui.notify("Subagents status view failed.", "error");
518
+ this.context.ui.notify(noticeText("Subagents", "Status view failed."), "error");
495
519
  })
496
520
  .finally(() => {
497
521
  this.activePanel?.dispose();
@@ -9,10 +9,12 @@ 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
+ import { Container, type Component, type TUI } from "@earendil-works/pi-tui";
13
+ import { appendDurationFooter, callDurationFooter } from "@ian-pascoe/pi-utils/ui";
13
14
  import type { MinimalSubagentsCoordinator } from "./minimal-subagents-coordinator.js";
14
15
  import { withTroubleshootingHint } from "./troubleshooting-skill.js";
15
16
  import type { MinimalSubagentsModelRole } from "./minimal-subagents-config.js";
17
+ import { roundMinimalSubagentsUsageCosts } from "./minimal-subagents-usage.js";
16
18
  import {
17
19
  renderCoordinatorToolCall,
18
20
  renderCoordinatorToolResult,
@@ -42,11 +44,8 @@ import type {
42
44
  WaitResult,
43
45
  } from "./minimal-subagents-types.js";
44
46
 
45
- const ORDINARY_CHILD_COORDINATOR_TOOL_NAMES = new Set([
46
- "agent_message",
47
- "subagent_wait",
48
- "subagent_status",
49
- ]);
47
+ /** A child that cannot spawn can never have children, so only adjacent messaging is useful. */
48
+ const NON_SPAWNING_CHILD_COORDINATOR_TOOL_NAMES = new Set(["agent_message"]);
50
49
 
51
50
  /** Coordinator operations consumed by the six public coordinator tool definitions. */
52
51
  export type CoordinatorToolOperations = Pick<
@@ -136,6 +135,13 @@ interface CoordinatorToolRowState {
136
135
  liveTurnCache?: TranscriptRenderCache;
137
136
  }
138
137
 
138
+ function withFooter(body: Component, footer: Component): Component {
139
+ const container = new Container();
140
+ container.addChild(body);
141
+ container.addChild(footer);
142
+ return container;
143
+ }
144
+
139
145
  function createCoordinatorToolRendering(
140
146
  options: CoordinatorToolDefinitionOptions,
141
147
  toolName: CoordinatorToolName,
@@ -144,8 +150,13 @@ function createCoordinatorToolRendering(
144
150
  renderCall: (
145
151
  args: CoordinatorToolCallInput,
146
152
  theme: Theme,
147
- context: { expanded: boolean } | undefined,
148
- ) => renderCoordinatorToolCall(toolName, args, theme, context?.expanded ?? false),
153
+ context: CoordinatorToolRenderContext | undefined,
154
+ ) => {
155
+ const call = renderCoordinatorToolCall(toolName, args, theme, context?.expanded ?? false);
156
+ // A wait streams partial results, whose footer already shows its running time.
157
+ if (!context || toolName === "subagent_wait") return call;
158
+ return withFooter(call, callDurationFooter(theme, context));
159
+ },
149
160
  renderResult: (
150
161
  result: AgentToolResult<CoordinatorToolResultDetails>,
151
162
  renderOptions: ToolRenderResultOptions,
@@ -170,7 +181,7 @@ function createCoordinatorToolRendering(
170
181
  theme,
171
182
  );
172
183
  };
173
- return renderCoordinatorToolResult(
184
+ const rendered = renderCoordinatorToolResult(
174
185
  toolName,
175
186
  result,
176
187
  renderOptions,
@@ -179,14 +190,36 @@ function createCoordinatorToolRendering(
179
190
  context.isError,
180
191
  toolName === "subagent_wait" ? renderLiveTurn : undefined,
181
192
  );
193
+ const container = new Container();
194
+ container.addChild(rendered);
195
+ appendDurationFooter(container, theme, context, { isPartial: renderOptions.isPartial });
196
+ return container;
182
197
  },
183
198
  };
184
199
  }
185
200
 
201
+ /**
202
+ * Serialize a tool result with every nested `usage` cost rounded for presentation. Exact values
203
+ * stay in `details`, the Registry, and session data, which never pass through here.
204
+ */
205
+ function stringifyWithRoundedUsageCosts(
206
+ result: CoordinatorToolResultDetails,
207
+ space?: number,
208
+ ): string {
209
+ // `JSON.stringify` types replacer values as `any`; a usage is recognized by key and `cost` object.
210
+ return JSON.stringify(
211
+ result,
212
+ (key, value) =>
213
+ key === "usage" && value?.cost ? roundMinimalSubagentsUsageCosts(value) : value,
214
+ space,
215
+ );
216
+ }
217
+
186
218
  function structuredToolResult<TDetails extends CoordinatorToolResultDetails>(
187
219
  result: TDetails,
188
220
  ): AgentToolResult<TDetails> {
189
- const json = JSON.stringify(result, null, 2);
221
+ // Presentation only: `details` keeps the exact values the session persists.
222
+ const json = stringifyWithRoundedUsageCosts(result, 2);
190
223
  const truncated = truncateHead(json, {
191
224
  maxBytes: DEFAULT_MAX_BYTES,
192
225
  maxLines: DEFAULT_MAX_LINES,
@@ -380,7 +413,7 @@ export function createCoordinatorToolDefinitions(
380
413
  ? alreadyDeliveredContent(result)
381
414
  : structuredToolResult(result).content,
382
415
  details,
383
- structuredContent: JSON.parse(JSON.stringify(details)),
416
+ structuredContent: JSON.parse(stringifyWithRoundedUsageCosts(details)),
384
417
  };
385
418
  });
386
419
  } finally {
@@ -459,9 +492,7 @@ export function createCoordinatorToolDefinitions(
459
492
  parameters.recursive ?? true,
460
493
  );
461
494
  if (result.failures.length > 0) {
462
- options.onAttention?.(
463
- `Minimal subagents deletion partially failed for ${parameters.agent_id}`,
464
- );
495
+ options.onAttention?.(`Deletion partially failed for ${parameters.agent_id}`);
465
496
  return failedStructuredToolResult("Minimal subagents deletion partially failed", result, {
466
497
  troubleshootingHint: true,
467
498
  });
@@ -483,5 +514,5 @@ export function createCoordinatorToolDefinitions(
483
514
  const allowFanoutTools = options.allowFanoutTools ?? options.callerId === "root";
484
515
  return allowFanoutTools
485
516
  ? coordinatorTools
486
- : coordinatorTools.filter((tool) => ORDINARY_CHILD_COORDINATOR_TOOL_NAMES.has(tool.name));
517
+ : coordinatorTools.filter((tool) => NON_SPAWNING_CHILD_COORDINATOR_TOOL_NAMES.has(tool.name));
487
518
  }
@@ -11,7 +11,14 @@ import {
11
11
  UserMessageComponent,
12
12
  type TruncationResult,
13
13
  } from "@earendil-works/pi-coding-agent";
14
- import { Container, Text, type Component, type TUI } from "@earendil-works/pi-tui";
14
+ import {
15
+ Container,
16
+ Text,
17
+ wrapTextWithAnsi,
18
+ type Component,
19
+ type TUI,
20
+ } from "@earendil-works/pi-tui";
21
+ import { COLLAPSED_LINES, expandHint } from "@ian-pascoe/pi-utils/ui";
15
22
  import {
16
23
  ACTIVITY_RAIL_WIDTH,
17
24
  drawActivityRail,
@@ -224,7 +231,7 @@ export function createTranscriptRenderCache(): TranscriptRenderCache {
224
231
  }
225
232
 
226
233
  /** Collapsed live views keep at most this many lines of the child's recent work. */
227
- const COLLAPSED_TRANSCRIPT_RAIL_LINES = 20;
234
+ const COLLAPSED_TRANSCRIPT_RAIL_LINES = COLLAPSED_LINES.fallback;
228
235
 
229
236
  function isSpacerLine(line: string): boolean {
230
237
  // Includes a tool box's coloured padding rows, so each rail connector meets visible content.
@@ -277,16 +284,14 @@ export class TranscriptRail implements Component {
277
284
  const kept = items
278
285
  .slice(items.length - shown)
279
286
  .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,
287
+ const hidden =
288
+ items.reduce((total, lines) => total + lines.length, 0) -
289
+ kept.reduce((total, lines) => total + lines.length, 0);
290
+ const hint = wrapTextWithAnsi(
291
+ expandHint(this.theme, hidden, "earlier"),
292
+ Math.max(1, width - ACTIVITY_RAIL_WIDTH),
289
293
  );
294
+ return drawActivityRail(hidden > 0 ? [hint, ...kept] : kept, this.theme);
290
295
  }
291
296
 
292
297
  invalidate(): void {