@ian-pascoe/pi-minimal-subagents 0.16.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,7 +9,8 @@ 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";
@@ -134,6 +135,13 @@ interface CoordinatorToolRowState {
134
135
  liveTurnCache?: TranscriptRenderCache;
135
136
  }
136
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
+
137
145
  function createCoordinatorToolRendering(
138
146
  options: CoordinatorToolDefinitionOptions,
139
147
  toolName: CoordinatorToolName,
@@ -142,8 +150,13 @@ function createCoordinatorToolRendering(
142
150
  renderCall: (
143
151
  args: CoordinatorToolCallInput,
144
152
  theme: Theme,
145
- context: { expanded: boolean } | undefined,
146
- ) => 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
+ },
147
160
  renderResult: (
148
161
  result: AgentToolResult<CoordinatorToolResultDetails>,
149
162
  renderOptions: ToolRenderResultOptions,
@@ -168,7 +181,7 @@ function createCoordinatorToolRendering(
168
181
  theme,
169
182
  );
170
183
  };
171
- return renderCoordinatorToolResult(
184
+ const rendered = renderCoordinatorToolResult(
172
185
  toolName,
173
186
  result,
174
187
  renderOptions,
@@ -177,6 +190,10 @@ function createCoordinatorToolRendering(
177
190
  context.isError,
178
191
  toolName === "subagent_wait" ? renderLiveTurn : undefined,
179
192
  );
193
+ const container = new Container();
194
+ container.addChild(rendered);
195
+ appendDurationFooter(container, theme, context, { isPartial: renderOptions.isPartial });
196
+ return container;
180
197
  },
181
198
  };
182
199
  }
@@ -475,9 +492,7 @@ export function createCoordinatorToolDefinitions(
475
492
  parameters.recursive ?? true,
476
493
  );
477
494
  if (result.failures.length > 0) {
478
- options.onAttention?.(
479
- `Minimal subagents deletion partially failed for ${parameters.agent_id}`,
480
- );
495
+ options.onAttention?.(`Deletion partially failed for ${parameters.agent_id}`);
481
496
  return failedStructuredToolResult("Minimal subagents deletion partially failed", result, {
482
497
  troubleshootingHint: true,
483
498
  });
@@ -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 {