@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.
@@ -10,13 +10,15 @@ import {
10
10
  type Component,
11
11
  type TUI,
12
12
  } from "@earendil-works/pi-tui";
13
+ import { joinInline, widgetLines } from "@ian-pascoe/pi-utils/ui";
13
14
  import type { MinimalSubagentsCoordinator } from "./minimal-subagents-coordinator.js";
14
15
  import {
15
16
  formatSubagentDuration,
16
17
  orderActiveAgentSubtrees,
17
18
  renderSubagentStatusLabel,
18
- renderSubagentStatusSymbol,
19
+ renderSubagentStatusMark,
19
20
  subagentStatusLadder,
21
+ treeRowPrefixes,
20
22
  } from "./minimal-subagents-rendering.js";
21
23
  import type {
22
24
  AgentSummary,
@@ -25,7 +27,7 @@ import type {
25
27
  TurnStatus,
26
28
  } from "./minimal-subagents-types.js";
27
29
 
28
- const MINIMAL_SUBAGENTS_UI_KEY = "minimal-subagents";
30
+ const MINIMAL_SUBAGENTS_UI_KEY = "pi-minimal-subagents";
29
31
  const MINIMAL_SUBAGENTS_RECENT_LIMIT = 3;
30
32
  const MINIMAL_SUBAGENTS_WIDGET_ROW_LIMIT = 8;
31
33
  const MINIMAL_SUBAGENTS_REFRESH_MS = 1_000;
@@ -154,24 +156,7 @@ export function buildMinimalSubagentsWidgetView(
154
156
  };
155
157
  }
156
158
 
157
- const MINIMAL_SUBAGENTS_WIDGET_SEPARATOR_TEXT = " · ";
158
- const MINIMAL_SUBAGENTS_WIDGET_ELLIPSIS = "…";
159
-
160
- interface MinimalSubagentsWidgetRowParts {
161
- identity: string;
162
- status: string;
163
- profile: string;
164
- task?: string;
165
- }
166
-
167
- function joinMinimalSubagentsWidgetRow(
168
- parts: MinimalSubagentsWidgetRowParts,
169
- separator: string,
170
- ): string {
171
- return [parts.identity, parts.status, parts.profile, parts.task]
172
- .filter((part): part is string => part !== undefined)
173
- .join(separator);
174
- }
159
+ const MINIMAL_SUBAGENTS_WIDGET_ELLIPSIS = "...";
175
160
 
176
161
  function formatMinimalSubagentsRuntimeProfile(
177
162
  profile: RuntimeProfile,
@@ -193,143 +178,72 @@ function formatMinimalSubagentsRuntimeProfile(
193
178
  return `${prefix}${MINIMAL_SUBAGENTS_WIDGET_ELLIPSIS}${suffix}`;
194
179
  }
195
180
 
196
- function renderMinimalSubagentsWidgetRowParts(
181
+ /** One widget row: tree prefix, Status Mark, id, status word and duration, model, then task. */
182
+ function renderMinimalSubagentsWidgetRowText(
197
183
  row: MinimalSubagentsWidgetRow,
198
- task: string | undefined,
199
- duration: string | undefined,
200
- profile: string,
184
+ prefix: string,
185
+ parts: { task?: string; duration?: string; profile: string },
201
186
  theme: MinimalSubagentsWidgetTheme,
202
- ): MinimalSubagentsWidgetRowParts {
203
- const branch = row.depth > 0 ? `${" ".repeat(row.depth)}╰─ ` : " ";
204
- const styledBranch = theme.fg("borderMuted", branch);
187
+ ): string {
205
188
  const agentId = row.structural ? theme.fg("muted", row.agentId) : theme.bold(row.agentId);
206
- const identity = `${styledBranch}${renderSubagentStatusSymbol(theme, row.status)} ${agentId}`;
189
+ const identity = `${prefix ? theme.fg("dim", prefix) : ""}${renderSubagentStatusMark(theme, row.status)} ${agentId}`;
207
190
  const status = `${renderSubagentStatusLabel(theme, row.status)}${
208
- duration ? ` ${theme.fg("muted", duration)}` : ""
191
+ parts.duration ? ` ${theme.fg("muted", parts.duration)}` : ""
209
192
  }`;
210
- return {
193
+ return joinInline(theme, [
211
194
  identity,
212
195
  status,
213
- profile: theme.fg("muted", profile),
214
- task: task ? theme.fg("muted", task) : undefined,
215
- };
216
- }
217
-
218
- function minimalSubagentsWidgetRowFits(
219
- parts: MinimalSubagentsWidgetRowParts,
220
- separator: string,
221
- width: number,
222
- ): boolean {
223
- return visibleWidth(joinMinimalSubagentsWidgetRow(parts, separator)) <= width;
224
- }
225
-
226
- function minimalSubagentsWidgetProfileBudget(
227
- row: MinimalSubagentsWidgetRow,
228
- task: string | undefined,
229
- duration: string | undefined,
230
- separator: string,
231
- theme: MinimalSubagentsWidgetTheme,
232
- width: number,
233
- ): number {
234
- const fixedParts = renderMinimalSubagentsWidgetRowParts(row, task, duration, "", theme);
235
- return width - visibleWidth(joinMinimalSubagentsWidgetRow(fixedParts, separator));
196
+ theme.fg("muted", parts.profile),
197
+ parts.task ? theme.fg("muted", parts.task) : undefined,
198
+ ]);
236
199
  }
237
200
 
238
201
  function renderMinimalSubagentsWidgetRow(
239
202
  row: MinimalSubagentsWidgetRow,
203
+ prefix: string,
240
204
  width: number,
241
205
  theme: MinimalSubagentsWidgetTheme,
242
206
  ): string {
243
- const separator = theme.fg("dim", MINIMAL_SUBAGENTS_WIDGET_SEPARATOR_TEXT);
244
207
  const task = row.task?.replace(/\s+/g, " ").trim() || undefined;
245
208
  const duration = row.status === "unavailable" ? undefined : formatSubagentDuration(row.elapsedMs);
246
209
  const fullProfile = formatMinimalSubagentsRuntimeProfile(row.runtimeProfile);
247
210
  if (!fullProfile) return "";
211
+ const build = (parts: { task?: string; duration?: string; profile: string }) =>
212
+ renderMinimalSubagentsWidgetRowText(row, prefix, parts, theme);
213
+ const fits = (text: string) => visibleWidth(text) <= width;
214
+ // Width left for the profile once everything else is placed; a one-column stand-in keeps its separator.
215
+ const profileBudget = (parts: { duration?: string }) =>
216
+ width - visibleWidth(build({ ...parts, profile: "x" })) + 1;
248
217
 
249
- const completeParts = renderMinimalSubagentsWidgetRowParts(
250
- row,
251
- task,
252
- duration,
253
- fullProfile,
254
- theme,
255
- );
256
- if (minimalSubagentsWidgetRowFits(completeParts, separator, width)) {
257
- return joinMinimalSubagentsWidgetRow(completeParts, separator);
258
- }
218
+ const complete = build({ task, duration, profile: fullProfile });
219
+ if (fits(complete)) return complete;
259
220
 
260
- const partsWithoutTask = renderMinimalSubagentsWidgetRowParts(
261
- row,
262
- undefined,
263
- duration,
264
- fullProfile,
265
- theme,
266
- );
267
- if (minimalSubagentsWidgetRowFits(partsWithoutTask, separator, width)) {
268
- return joinMinimalSubagentsWidgetRow(partsWithoutTask, separator);
269
- }
221
+ const withoutTask = build({ duration, profile: fullProfile });
222
+ if (fits(withoutTask)) return withoutTask;
270
223
 
271
- const profileBudget = minimalSubagentsWidgetProfileBudget(
272
- row,
273
- undefined,
274
- duration,
275
- separator,
276
- theme,
277
- width,
224
+ const shortenedProfile = formatMinimalSubagentsRuntimeProfile(
225
+ row.runtimeProfile,
226
+ profileBudget({ duration }),
278
227
  );
279
- const shortenedProfile = formatMinimalSubagentsRuntimeProfile(row.runtimeProfile, profileBudget);
280
228
  if (shortenedProfile) {
281
- const shortenedParts = renderMinimalSubagentsWidgetRowParts(
282
- row,
283
- undefined,
284
- duration,
285
- shortenedProfile,
286
- theme,
287
- );
288
- if (minimalSubagentsWidgetRowFits(shortenedParts, separator, width)) {
289
- return joinMinimalSubagentsWidgetRow(shortenedParts, separator);
290
- }
229
+ const shortened = build({ duration, profile: shortenedProfile });
230
+ if (fits(shortened)) return shortened;
291
231
  }
292
232
 
293
233
  if (duration) {
294
- const noDurationBudget = minimalSubagentsWidgetProfileBudget(
295
- row,
296
- undefined,
297
- undefined,
298
- separator,
299
- theme,
300
- width,
301
- );
302
234
  const noDurationProfile = formatMinimalSubagentsRuntimeProfile(
303
235
  row.runtimeProfile,
304
- noDurationBudget,
236
+ profileBudget({}),
305
237
  );
306
238
  if (noDurationProfile) {
307
- const noDurationParts = renderMinimalSubagentsWidgetRowParts(
308
- row,
309
- undefined,
310
- undefined,
311
- noDurationProfile,
312
- theme,
313
- );
314
- if (minimalSubagentsWidgetRowFits(noDurationParts, separator, width)) {
315
- return joinMinimalSubagentsWidgetRow(noDurationParts, separator);
316
- }
239
+ const noDuration = build({ profile: noDurationProfile });
240
+ if (fits(noDuration)) return noDuration;
317
241
  }
318
242
  }
319
243
 
320
- const shortestProfile = formatMinimalSubagentsRuntimeProfile(
321
- row.runtimeProfile,
322
- visibleWidth(`${MINIMAL_SUBAGENTS_WIDGET_ELLIPSIS}:${row.runtimeProfile.thinking_level}`),
323
- )!;
324
- const lastResortParts = renderMinimalSubagentsWidgetRowParts(
325
- row,
326
- undefined,
327
- undefined,
328
- shortestProfile,
329
- theme,
330
- );
244
+ const shortestProfile = `${MINIMAL_SUBAGENTS_WIDGET_ELLIPSIS}:${row.runtimeProfile.thinking_level}`;
331
245
  return truncateToWidth(
332
- joinMinimalSubagentsWidgetRow(lastResortParts, separator),
246
+ build({ profile: shortestProfile }),
333
247
  width,
334
248
  MINIMAL_SUBAGENTS_WIDGET_ELLIPSIS,
335
249
  );
@@ -342,37 +256,20 @@ export function renderMinimalSubagentsWidgetLines(
342
256
  theme: MinimalSubagentsWidgetTheme,
343
257
  ): string[] {
344
258
  if (width <= 0) return [];
345
- const separator = theme.fg("dim", MINIMAL_SUBAGENTS_WIDGET_SEPARATOR_TEXT);
346
- const activity =
347
- view.runningCount > 0
348
- ? theme.fg("accent", `${view.runningCount} running`)
349
- : theme.fg("dim", "idle");
350
- const lines = [
351
- truncateToWidth(
352
- [
353
- theme.fg("toolTitle", theme.bold("Subagents")),
354
- activity,
355
- width >= 44 && view.recentCount > 0
356
- ? theme.fg("muted", `${view.recentCount} recent`)
357
- : undefined,
358
- ]
359
- .filter((part): part is string => Boolean(part))
360
- .join(separator),
361
- width,
362
- MINIMAL_SUBAGENTS_WIDGET_ELLIPSIS,
363
- ),
364
- ];
365
- for (const row of view.rows) lines.push(renderMinimalSubagentsWidgetRow(row, width, theme));
366
- if (view.overflowCount > 0) {
367
- lines.push(
368
- truncateToWidth(
369
- theme.fg("dim", ` … +${view.overflowCount} more`),
370
- width,
371
- MINIMAL_SUBAGENTS_WIDGET_ELLIPSIS,
372
- ),
373
- );
374
- }
375
- return lines;
259
+ const counts = [
260
+ view.runningCount > 0 ? `${view.runningCount} running` : "idle",
261
+ width >= 44 && view.recentCount > 0 ? `${view.recentCount} recent` : undefined,
262
+ ]
263
+ .filter((part): part is string => part !== undefined)
264
+ .join(" · ");
265
+ const prefixes = treeRowPrefixes(view.rows.map((row) => row.depth));
266
+ const rows = view.rows.map((row, index) =>
267
+ renderMinimalSubagentsWidgetRow(row, prefixes[index] ?? "", width, theme),
268
+ );
269
+ if (view.overflowCount > 0) rows.push(theme.fg("muted", `... ${view.overflowCount} more`));
270
+ return widgetLines(theme, { title: "Subagents", counts, rows }).map((line) =>
271
+ truncateToWidth(line, width, MINIMAL_SUBAGENTS_WIDGET_ELLIPSIS),
272
+ );
376
273
  }
377
274
 
378
275
  class MinimalSubagentsWidgetComponent implements Component {
@@ -33,3 +33,33 @@ export function addMinimalSubagentsUsage(
33
33
  },
34
34
  };
35
35
  }
36
+
37
+ /**
38
+ * USD decimal places kept in model-visible and structured usage costs. Six places is one
39
+ * millionth of a dollar: finer than any single turn's cost is worth reading, yet coarse enough to
40
+ * drop the binary-float tails that summing many per-message costs produces.
41
+ */
42
+ export const MINIMAL_SUBAGENTS_COST_DECIMALS = 6;
43
+
44
+ const COST_SCALE = 10 ** MINIMAL_SUBAGENTS_COST_DECIMALS;
45
+
46
+ function roundCost(value: number): number {
47
+ return Number.isFinite(value) ? Math.round(value * COST_SCALE) / COST_SCALE : value;
48
+ }
49
+
50
+ /**
51
+ * Return a clone whose cost fields are rounded for presentation. Each field rounds independently
52
+ * from its exact value, so `total` stays within half a unit per summed field of the rounded sum.
53
+ */
54
+ export function roundMinimalSubagentsUsageCosts(usage: Usage): Usage {
55
+ return {
56
+ ...usage,
57
+ cost: {
58
+ input: roundCost(usage.cost.input),
59
+ output: roundCost(usage.cost.output),
60
+ cacheRead: roundCost(usage.cost.cacheRead),
61
+ cacheWrite: roundCost(usage.cost.cacheWrite),
62
+ total: roundCost(usage.cost.total),
63
+ },
64
+ };
65
+ }