@ferris1225/pi-subagents 4.1.21 → 4.1.24

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.
package/src/setup.ts CHANGED
@@ -248,6 +248,9 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
248
248
 
249
249
  const next: SubagentsConfig = {
250
250
  enabledAgents: enabled,
251
+ // The wizard surfaces every built-in, so an untoggled one was seen and
252
+ // deliberately left off — record them all as known.
253
+ knownAgents: [...BUILTIN_AGENT_NAMES],
251
254
  agentModels,
252
255
  // Full setup returns every agent to capability-aware Auto thinking.
253
256
  agentThinkingLevels: {},
@@ -283,23 +286,17 @@ async function runMenu(ctx: ExtensionCommandContext, configPath: string, config:
283
286
  const enabled = await pickEnabledAgents(ctx, config.enabledAgents);
284
287
  if (enabled === undefined) continue;
285
288
  next.enabledAgents = enabled;
286
- // A newly enabled role inherits a kindred role's configured model and
289
+ // A newly enabled role inherits explorer's configured model and
287
290
  // thinking level, so the file reflects what it will actually run
288
- // instead of silently falling back to the current main model: cleaner
289
- // follows the reviewer; documenter and synthesizer intentionally
290
- // follow the faster explorer route.
291
- const modelInheritance: ReadonlyArray<[agent: string, from: string]> = [
292
- ["cleaner", "reviewer"],
293
- ["documenter", "explorer"],
294
- ["synthesizer", "explorer"],
295
- ];
296
- for (const [agent, from] of modelInheritance) {
297
- if (config.enabledAgents.includes(agent) || !enabled.includes(agent)) continue;
298
- if (!next.agentModels[agent] && config.agentModels[from]) {
299
- next.agentModels[agent] = config.agentModels[from];
291
+ // instead of silently falling back to the current main model: these
292
+ // roles do light migration-grade work on the fast explorer lane.
293
+ for (const agent of enabled) {
294
+ if (agent === "explorer" || config.enabledAgents.includes(agent)) continue;
295
+ if (!next.agentModels[agent] && config.agentModels.explorer) {
296
+ next.agentModels[agent] = config.agentModels.explorer;
300
297
  }
301
- if (!next.agentThinkingLevels[agent] && config.agentThinkingLevels[from]) {
302
- next.agentThinkingLevels[agent] = config.agentThinkingLevels[from];
298
+ if (!next.agentThinkingLevels[agent] && config.agentThinkingLevels.explorer) {
299
+ next.agentThinkingLevels[agent] = config.agentThinkingLevels.explorer;
303
300
  }
304
301
  }
305
302
  next.agentModels = keepAgentEntries(next.agentModels, enabled);
package/src/widget.ts CHANGED
@@ -2,16 +2,24 @@
2
2
  * Compact, glanceable active-run widget for interactive Pi sessions.
3
3
  *
4
4
  * Layout contract (redesign):
5
- * - Aligned columns: `icon #id agent` pad to the widest displayed id and
6
- * agent so every label starts at the same column; a resumed thread carries
7
- * a dim `↻` inside the agent column.
8
- * - Visual hierarchy: the label (what the run owns) is plain, the live
9
- * activity after ` ` is dim, and all telemetry — worktree badge,
10
- * model/thinking, wait state, elapsed — is one dim right-aligned column,
11
- * so times and states line up at the right edge.
12
- * - Two lines per managed workflow: the stable parent line plus a `└`-connected
13
- * stage timeline; the live stage's activity/model/elapsed rides right-aligned
14
- * on the timeline line. Internal child rows are not repeated as extra lines.
5
+ * - Aligned identity columns: `icon #id agent` pad to the widest displayed
6
+ * id and agent so every label starts at the same column; a resumed thread
7
+ * carries a dim `↻` inside the agent column.
8
+ * - A live run owns two lines. Line 1 is what it is: identity, task label,
9
+ * then the telemetry flow (`provider/model`, token flow in the pi-footer
10
+ * vocabulary `↑in ↓out R/W cache`, cost, wait state, seconds-precision
11
+ * elapsed). Line 2 is what it is doing right now: the live activity, dim,
12
+ * indented under the label column behind a `↳` marker.
13
+ * - Telemetry drops leftmost-first under width pressure (badge, wait, usage,
14
+ * model); the elapsed survives every width.
15
+ * - First line of the widget is the parent pi session itself: what the current
16
+ * model is doing right now while its agent loop runs (model/thinking, live
17
+ * activity, loop elapsed), fed by the session's own extension events.
18
+ * - A managed workflow renders as a tree chain under its parent line: one
19
+ * `├`/`└`-connected row per stage, each carrying its own model, token flow,
20
+ * and elapsed — settled stages from the snapshot frozen at settlement, the
21
+ * live stage from its child row. An oversized chain keeps a window anchored
22
+ * on the live stage.
15
23
  * - Queued rows say what they actually wait for ("queued" for a process slot,
16
24
  * "repo lane" for shared-writer serialization, "starting" while the child
17
25
  * process launches) instead of one catch-all "queued".
@@ -20,14 +28,22 @@
20
28
  import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
21
29
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
22
30
  import {
31
+ formatDuration,
23
32
  formatElapsed,
24
33
  formatTaskSummary,
34
+ formatUsageTokens,
25
35
  isRunActiveStatus,
26
36
  monitor,
37
+ shrinkRunLabel,
27
38
  statusIcon,
39
+ sumUsage,
40
+ usageCostPart,
41
+ type MainActivity,
28
42
  type RunView,
29
43
  type WorkflowStage,
44
+ type WorkflowStageStatus,
30
45
  } from "./monitor.ts";
46
+ import type { UsageStats } from "./rpc-run.ts";
31
47
 
32
48
  export const SUBAGENTS_WIDGET_ID = "pi-subagents";
33
49
 
@@ -38,8 +54,10 @@ const MAX_WIDGET_LINES = 10;
38
54
  const SEPARATOR = " · ";
39
55
  /** Column gap between the identity block and the run's label. */
40
56
  const IDENTITY_GAP = " ";
41
- /** Splits "what this run is" from "what it is doing right now". */
57
+ /** Splits "what this run is" from "what it is doing right now" on a stage row. */
42
58
  const ACTIVITY_SEPARATOR = " — ";
59
+ /** Marker introducing the live-activity second line of a running row. */
60
+ const ACTIVITY_MARKER = "↳ ";
43
61
  /** Columns kept for left content before right-tail parts are dropped. */
44
62
  const LEFT_MIN_CONTENT = 8;
45
63
  /** Minimum useful width for a live-activity fragment. */
@@ -53,17 +71,13 @@ interface ColumnLayout {
53
71
  agentWidth: number;
54
72
  }
55
73
 
56
- /** Compose one widget line with a right-aligned telemetry column: the left
57
- * side truncates first, the right side stays put so elapsed times and badges
58
- * line up across rows. */
59
- function layoutLine(left: string, right: string, width: number): string {
60
- if (width <= 0) return "";
61
- if (!right) return truncateToWidth(left, width, "…");
62
- const rightWidth = visibleWidth(right);
63
- if (rightWidth >= width) return truncateToWidth(right, width, "");
64
- const leftBudget = width - rightWidth - 1;
65
- const leftText = visibleWidth(left) > leftBudget ? truncateToWidth(left, leftBudget, "…") : left;
66
- return `${leftText}${" ".repeat(Math.max(1, width - visibleWidth(leftText) - rightWidth))}${right}`;
74
+ /** Join left content and telemetry inline `left · telemetry` so a
75
+ * multi-line chain reads as one flowing sentence instead of leaving blank
76
+ * padding across the width; the whole line truncates as a last resort. */
77
+ function composeLine(left: string, tail: string, theme: Theme, width: number): string {
78
+ if (!tail) return truncateToWidth(left, width, "");
79
+ const line = `${left}${theme.fg("dim", SEPARATOR)}${theme.fg("dim", tail)}`;
80
+ return visibleWidth(line) <= width ? line : truncateToWidth(line, width, "…");
67
81
  }
68
82
 
69
83
  /** Short truthful wait word for a queued row, shown in the telemetry column. */
@@ -124,145 +138,229 @@ function identitySegment(run: RunView, theme: Theme, layout: ColumnLayout): stri
124
138
  return `${icon} ${theme.fg("dim", id)} ${name}${resumed}${pad}`;
125
139
  }
126
140
 
127
- /** One primary line per run. Left: identity label activity, where the
128
- * label stays plain and the live activity is dim. Right: one dim telemetry
129
- * column (worktree badge, model/thinking, wait state, elapsed). The activity
130
- * outranks the label when space runs out; the identity and elapsed survive
131
- * every width. */
132
- function primaryLine(run: RunView, theme: Theme, width: number, now: number, layout: ColumnLayout): string {
133
- const dim = (text: string): string => theme.fg("dim", text);
134
- const identity = identitySegment(run, theme, layout);
135
- const elapsed = formatElapsed(run, now);
136
- const modelId = run.model?.split("/").at(-1);
141
+ /** One footer-style usage part: token flow plus accrued cost, dropped as a
142
+ * unit before the model under width pressure. */
143
+ function usagePart(usage: UsageStats | undefined): string | undefined {
144
+ return [formatUsageTokens(usage), usageCostPart(usage)].filter(Boolean).join(" ") || undefined;
145
+ }
146
+
147
+ /** Telemetry tail parts shared by primary and stage rows: badge and wait word
148
+ * first (dropped first under pressure), then the usage part, the model, and
149
+ * the always-surviving elapsed. `usage` defaults to the run's own; the managed
150
+ * workflow parent overrides it with the workflow-wide aggregate. */
151
+ function telemetryTailParts(run: RunView, now: number, usage: UsageStats = run.usage): Array<string | undefined> {
137
152
  // Queued rows omit the model (the route is re-resolved at actual start);
138
- // workflow parents omit it too (each stage owns its own model).
139
- const modelPart = run.status === "queued" || run.managedWorkflow || !modelId
153
+ // workflow parents omit it too (each stage row owns its own model). The
154
+ // full provider/model ref is kept — "which provider served this run" is
155
+ // exactly what a multi-provider session needs to see.
156
+ const modelPart = run.status === "queued" || run.managedWorkflow || !run.model
140
157
  ? undefined
141
- : `${modelId}${run.thinking ? `/${run.thinking}` : ""}`;
158
+ : run.model;
142
159
  const badge = run.isolation === "worktree" ? worktreeBadge(run) : undefined;
143
160
  const wait = run.status === "queued" ? waitWord(run) : undefined;
144
- const tailBudget = Math.max(0, width - visibleWidth(identity) - LEFT_MIN_CONTENT);
145
- // Drop order under pressure: badge, then model, then wait word; elapsed
161
+ // Drop order under pressure: badge, wait word, usage, model; elapsed
146
162
  // survives every width the identity leaves room for.
147
- const tail = composeTail([badge, modelPart, wait, elapsed || undefined], tailBudget);
163
+ return [badge, wait, usagePart(usage), modelPart, formatElapsed(run, now) || undefined];
164
+ }
165
+
166
+ /** Two lines for a live run. Line 1 is what the run is: identity, task label,
167
+ * then the telemetry flow (worktree badge, token flow, cost, provider/model,
168
+ * wait state, elapsed). Line 2 is what it is doing right now: the live
169
+ * activity, dim, indented under the label column behind a `↳` marker. The
170
+ * label takes the full content budget on line 1; the identity and the elapsed
171
+ * survive every width. */
172
+ function primaryLine(
173
+ run: RunView,
174
+ theme: Theme,
175
+ width: number,
176
+ now: number,
177
+ layout: ColumnLayout,
178
+ usage: UsageStats = run.usage,
179
+ ): string[] {
180
+ const identity = identitySegment(run, theme, layout);
181
+ const tailBudget = Math.max(0, width - visibleWidth(identity) - LEFT_MIN_CONTENT);
182
+ const tail = composeTail(telemetryTailParts(run, now, usage), tailBudget);
148
183
 
149
184
  // A chain child rendered at root level (its parent row is gone) keeps its
150
185
  // workflow relation; the templated brief itself would only repeat content.
151
186
  const label = run.parentRunId !== undefined
152
187
  ? [run.relationLabel, run.label].filter((part): part is string => Boolean(part)).join(SEPARATOR)
153
188
  : run.label ?? formatTaskSummary(run.task, 48);
189
+ const contentBudget = width
190
+ - visibleWidth(identity)
191
+ - (tail ? visibleWidth(tail) + visibleWidth(SEPARATOR) : 0)
192
+ - visibleWidth(IDENTITY_GAP);
193
+ // The label is already fragment-extracted (runLabel); narrowing it keeps
194
+ // its tail so a second squeeze never trades away the recognisable
195
+ // filename, and no second head…tail ellipsis stacks on top of it.
196
+ const content = label && contentBudget > 0 ? shrinkRunLabel(label, contentBudget) : "";
197
+ const left = content ? `${identity}${IDENTITY_GAP}${content}` : identity;
198
+ const lines = [composeLine(left, tail, theme, width)];
199
+
154
200
  // The parent's own activity is a placeholder while a managed workflow runs;
155
- // the timeline line below carries the live stage instead.
201
+ // the timeline chain below carries the live stage instead.
156
202
  const activity = !run.managedWorkflow && (run.status === "running" || run.status === "interrupting")
157
203
  ? run.activity?.trim()
158
204
  : undefined;
159
-
160
- const contentBudget = width
161
- - visibleWidth(identity)
162
- - (tail ? visibleWidth(tail) + 1 : 0)
163
- - visibleWidth(IDENTITY_GAP);
164
- let content = "";
165
- if (contentBudget > 0) {
166
- const labelBudget = activity
167
- ? Math.min(visibleWidth(label), Math.max(12, Math.floor(contentBudget * 0.4)))
168
- : contentBudget;
169
- // The label is already fragment-extracted (runLabel); plain right
170
- // truncation avoids stacking a second head…tail ellipsis on top of it.
171
- const labelText = label
172
- ? visibleWidth(label) > labelBudget ? truncateToWidth(label, labelBudget, "…") : label
173
- : "";
174
- if (activity) {
175
- const activityBudget = contentBudget
176
- - visibleWidth(labelText)
177
- - (labelText ? visibleWidth(ACTIVITY_SEPARATOR) : 0);
178
- content = activityBudget >= ACTIVITY_MIN_WIDTH
179
- ? `${labelText}${labelText ? dim(ACTIVITY_SEPARATOR) : ""}${dim(formatTaskSummary(activity, activityBudget))}`
180
- // Too narrow for both: the live activity is the stronger signal.
181
- : dim(formatTaskSummary(activity, contentBudget));
182
- } else {
183
- content = labelText;
205
+ if (activity) {
206
+ const indent = visibleWidth(identity) + visibleWidth(IDENTITY_GAP);
207
+ const activityBudget = width - indent - visibleWidth(ACTIVITY_MARKER);
208
+ if (activityBudget >= ACTIVITY_MIN_WIDTH) {
209
+ lines.push(
210
+ `${" ".repeat(indent)}${theme.fg("dim", `${ACTIVITY_MARKER}${formatTaskSummary(activity, activityBudget)}`)}`,
211
+ );
184
212
  }
185
213
  }
186
-
187
- const left = content ? `${identity}${IDENTITY_GAP}${content}` : identity;
188
- return layoutLine(left, tail ? dim(tail) : "", width);
214
+ return lines;
189
215
  }
190
216
 
191
- function workflowStageToken(stage: WorkflowStage, theme: Theme): string {
192
- const content = (icon: string): string => `${icon} ${stage.relation}`;
193
- switch (stage.status) {
217
+ function stageIcon(status: WorkflowStageStatus, theme: Theme): string {
218
+ switch (status) {
194
219
  case "done":
195
- return theme.fg("success", content("✓"));
220
+ return theme.fg("success", "✓");
196
221
  case "active":
197
- return theme.fg("accent", theme.bold(content("●")));
222
+ return theme.fg("accent", theme.bold("●"));
198
223
  case "changes":
199
- return theme.fg("warning", content("!"));
224
+ return theme.fg("warning", "!");
200
225
  case "failed":
201
- return theme.fg("error", content("✗"));
226
+ return theme.fg("error", "✗");
202
227
  default:
203
- return theme.fg("dim", content("○"));
228
+ return theme.fg("dim", "○");
204
229
  }
205
230
  }
206
231
 
207
- /** Stage timeline, sliced from the active (or next actionable) stage when the
208
- * full sequence does not fit, so the current position always survives. */
209
- function timelineSegment(stages: readonly WorkflowStage[], theme: Theme, width: number): string {
210
- const separator = theme.fg("dim", " ─ ");
211
- const render = (items: readonly WorkflowStage[]): string =>
212
- items.map((stage) => workflowStageToken(stage, theme)).join(separator);
213
- const full = render(stages);
214
- if (visibleWidth(full) <= width) return full;
215
- let focusIndex = stages.findIndex((stage) => stage.status === "active");
216
- if (focusIndex === -1) {
217
- focusIndex = stages.findLastIndex((stage) => stage.status === "changes" || stage.status === "failed");
232
+ /** Stage telemetry source: the live child while the stage runs, the frozen
233
+ * snapshot once it has settled. */
234
+ interface StageTelemetry {
235
+ model?: string;
236
+ thinking?: string;
237
+ usage?: UsageStats;
238
+ elapsed: string;
239
+ activity?: string;
240
+ }
241
+
242
+ function stageTelemetry(
243
+ stage: WorkflowStage,
244
+ live: RunView | undefined,
245
+ now: number,
246
+ ): StageTelemetry {
247
+ if (live) {
248
+ return {
249
+ model: live.model,
250
+ thinking: live.thinking,
251
+ usage: live.usage,
252
+ elapsed: formatElapsed(live, now),
253
+ activity: live.activity?.trim() || undefined,
254
+ };
218
255
  }
219
- if (focusIndex === -1) focusIndex = stages.findIndex((stage) => stage.status === "pending");
220
- if (focusIndex === -1) focusIndex = stages.length - 1;
221
- const omittedPrefix = focusIndex > 0 ? theme.fg("dim", "… ─ ") : "";
222
- return truncateToWidth(`${omittedPrefix}${render(stages.slice(focusIndex))}`, width, "…");
256
+ return { model: stage.model, usage: stage.usage, elapsed: stage.elapsedMs !== undefined ? formatDuration(stage.elapsedMs) : "" };
223
257
  }
224
258
 
225
- /** Timeline line under a managed workflow parent, tied to it with a dim `└`.
226
- * The live stage's telemetry (its activity or model, plus stage elapsed) rides
227
- * right-aligned, so the two workflow lines replace what used to be four
228
- * (parent, timeline, child row, child activity). */
229
- function workflowTimelineLine(
230
- run: RunView,
259
+ /** The parent pi session's own line what the current model is doing right
260
+ * now while its agent loop runs: full model/thinking ref, live activity, loop
261
+ * elapsed. Not a run, so it owns no `#id`; it joins the identity layout so its
262
+ * label column lines up with the run rows. */
263
+ function mainLine(main: MainActivity, theme: Theme, width: number, now: number, layout: ColumnLayout): string {
264
+ const dim = (text: string): string => theme.fg("dim", text);
265
+ const icon = theme.fg("accent", "●");
266
+ const name = theme.fg("accent", theme.bold("pi"));
267
+ const pad = " ".repeat(Math.max(0, layout.agentWidth - visibleWidth("pi")));
268
+ const identity = `${icon} ${" ".repeat(layout.idWidth + 1)}${name}${pad}`;
269
+ const modelPart = main.model ? `${main.model}${main.thinking ? `/${main.thinking}` : ""}` : undefined;
270
+ const elapsed = formatDuration(Math.max(0, now - main.activeSince));
271
+ const tail = composeTail([modelPart, elapsed], Math.max(0, width - visibleWidth(identity) - LEFT_MIN_CONTENT));
272
+ const contentBudget = width
273
+ - visibleWidth(identity)
274
+ - (tail ? visibleWidth(tail) + visibleWidth(SEPARATOR) : 0)
275
+ - visibleWidth(IDENTITY_GAP);
276
+ let left = identity;
277
+ if (main.activity?.trim() && contentBudget >= ACTIVITY_MIN_WIDTH) {
278
+ left = `${identity}${IDENTITY_GAP}${dim(formatTaskSummary(main.activity.trim(), contentBudget))}`;
279
+ }
280
+ return composeLine(left, tail, theme, width);
281
+ }
282
+
283
+ /** One `├`/`└`-connected row per managed-workflow stage: status icon,
284
+ * relation, live activity for the running stage, and its own model/token/
285
+ * cost/elapsed telemetry flowing inline. The chain hangs off the parent line,
286
+ * so who dispatched what stays visible while the auto-fix workflow progresses. */
287
+ function workflowStageLines(
288
+ stages: readonly WorkflowStage[],
231
289
  children: readonly RunView[],
232
290
  theme: Theme,
233
291
  width: number,
234
292
  now: number,
235
- ): string | undefined {
236
- const stages = run.workflowStages;
237
- const indent = ` ${theme.fg("dim", "└")} `;
238
- const budget = width - visibleWidth(indent);
239
- if (!stages || stages.length === 0 || budget <= 0) return undefined;
240
- const child = children.find((candidate) => candidate.status === "running" || candidate.status === "interrupting")
293
+ ): { lines: string[]; activeIndex: number } {
294
+ const live = children.find((candidate) => candidate.status === "running" || candidate.status === "interrupting")
241
295
  ?? children.at(-1);
242
- const childDoing = child?.activity?.trim() || child?.model?.split("/").at(-1) || "";
243
- const childElapsed = child ? formatElapsed(child, now) : "";
244
- const timeline = timelineSegment(stages, theme, budget);
245
- const room = budget - visibleWidth(timeline) - 1;
246
- let tail = "";
247
- if (room >= ACTIVITY_MIN_WIDTH) {
248
- const doingBudget = room - (childElapsed ? visibleWidth(childElapsed) + (childDoing ? visibleWidth(SEPARATOR) : 0) : 0);
249
- const doingText = childDoing && doingBudget >= ACTIVITY_MIN_WIDTH
250
- ? formatTaskSummary(childDoing, doingBudget)
251
- : "";
252
- const parts = [doingText, childElapsed].filter(Boolean);
253
- if (parts.length > 0) tail = theme.fg("dim", parts.join(SEPARATOR));
296
+ const activeIndex = stages.findIndex((stage) => stage.status === "active");
297
+ const lines: string[] = [];
298
+ for (const [index, stage] of stages.entries()) {
299
+ const telemetry = stageTelemetry(stage, index === activeIndex ? live : undefined, now);
300
+ const connector = theme.fg("dim", index === stages.length - 1 ? "└" : "├");
301
+ const indent = ` ${connector} `;
302
+ const budget = width - visibleWidth(indent);
303
+ if (budget <= 0) break;
304
+ const icon = stageIcon(stage.status, theme);
305
+ const label = `${icon} ${stage.relation}`;
306
+ const modelPart = telemetry.model
307
+ ? `${telemetry.model}${telemetry.thinking ? `/${telemetry.thinking}` : ""}`
308
+ : undefined;
309
+ const parts = [
310
+ usagePart(telemetry.usage),
311
+ modelPart,
312
+ telemetry.elapsed || undefined,
313
+ ];
314
+ const tailBudget = Math.max(0, budget - visibleWidth(label) - ACTIVITY_MIN_WIDTH);
315
+ const tail = composeTail(parts, tailBudget);
316
+ const contentBudget = budget - (tail ? visibleWidth(tail) + visibleWidth(SEPARATOR) : 0);
317
+ let left = label;
318
+ if (telemetry.activity && contentBudget - visibleWidth(label) - visibleWidth(ACTIVITY_SEPARATOR) >= ACTIVITY_MIN_WIDTH) {
319
+ const activityBudget = contentBudget - visibleWidth(label) - visibleWidth(ACTIVITY_SEPARATOR);
320
+ left = `${label}${theme.fg("dim", ACTIVITY_SEPARATOR)}${theme.fg("dim", formatTaskSummary(telemetry.activity, activityBudget))}`;
321
+ }
322
+ lines.push(`${indent}${composeLine(left, tail, theme, budget)}`);
254
323
  }
255
- return `${indent}${layoutLine(timeline, tail, budget)}`;
324
+ return { lines, activeIndex };
325
+ }
326
+
327
+ /** Fit one workflow group into the remaining line budget: the primary lines
328
+ * (identity line plus the live-activity line) always survive, and the stage
329
+ * window anchors on the live stage — settled stages above the window collapse
330
+ * into one `… +N` marker, never the live stage itself. */
331
+ function fitGroupLines(
332
+ primary: readonly string[],
333
+ stages: readonly string[],
334
+ activeIndex: number,
335
+ remaining: number,
336
+ theme: Theme,
337
+ ): string[] {
338
+ if (primary.length > remaining) return primary.slice(0, Math.max(1, remaining));
339
+ if (stages.length === 0 || stages.length + primary.length <= remaining) return [...primary, ...stages];
340
+ const slots = remaining - primary.length;
341
+ // Reserve one line for an overflow marker so a cut is always announced.
342
+ const room = Math.max(1, slots - 1);
343
+ const anchor = activeIndex >= 0 ? activeIndex : 0;
344
+ const start = Math.max(0, Math.min(anchor, stages.length - room));
345
+ const window = stages.slice(start, start + room);
346
+ const parts = [
347
+ ...(start > 0 ? [theme.fg("dim", `… +${start}`)] : []),
348
+ ...window,
349
+ ...(start + room < stages.length ? [theme.fg("dim", `… +${stages.length - start - room}`)] : []),
350
+ ];
351
+ while (parts.length > slots) parts.pop();
352
+ return [...primary, ...parts];
256
353
  }
257
354
 
258
- /** Render active runs as compact per-run line groups: one line per simple run,
259
- * two per managed workflow. Internal stage children fold into their parent's
260
- * timeline instead of adding rows, and all rows share one column layout. */
355
+ /** Render active runs as compact per-run line groups: the parent model's own
356
+ * line first, then one two-line group per simple run, a tree chain per managed
357
+ * workflow (parent line + one row per stage). All rows share one column layout. */
261
358
  export function formatActiveRunLines(
262
359
  runs: readonly RunView[],
263
360
  theme: Theme,
264
361
  width: number,
265
362
  now: number = Date.now(),
363
+ main?: MainActivity,
266
364
  ): string[] {
267
365
  const active = runs.filter((run) => isRunActiveStatus(run.status));
268
366
  const activeIds = new Set(active.map((run) => run.id));
@@ -279,24 +377,39 @@ export function formatActiveRunLines(
279
377
  }
280
378
  const layout: ColumnLayout = {
281
379
  idWidth: Math.max(...roots.map((root) => visibleWidth(`#${root.id}`)), 0),
282
- agentWidth: Math.max(...roots.map((root) => visibleWidth(agentColumnText(root))), 0),
380
+ agentWidth: Math.max(...roots.map((root) => visibleWidth(agentColumnText(root))), main ? visibleWidth("pi") : 0),
283
381
  };
284
- const groups: string[][] = roots.map((root) => {
285
- const lines = [primaryLine(root, theme, width, now, layout)];
286
- const timeline = root.managedWorkflow
287
- ? workflowTimelineLine(root, childrenOf.get(root.id) ?? [], theme, width, now)
288
- : undefined;
289
- if (timeline) lines.push(timeline);
290
- return lines;
382
+ const groups: Array<{ primary: readonly string[]; stages: readonly string[]; activeIndex: number }> = roots.map((root) => {
383
+ const children = childrenOf.get(root.id) ?? [];
384
+ // Workflow-wide tokens/cost on the parent line: every stage snapshot plus
385
+ // the live child (whose snapshot is frozen only at settlement). Without
386
+ // a stage projection the parent's own usage stands in.
387
+ let usage = root.usage;
388
+ if (root.managedWorkflow && root.workflowStages) {
389
+ const settled = root.workflowStages.map((stage) => stage.usage).filter((u): u is UsageStats => Boolean(u));
390
+ const live = children.find((candidate) => candidate.usage.input || candidate.usage.output || candidate.usage.cost);
391
+ usage = sumUsage([...(settled.length > 0 ? settled : [root.usage]), ...(live ? [live.usage] : [])]);
392
+ }
393
+ const primary = primaryLine(root, theme, width, now, layout, usage);
394
+ let activeIndex = -1;
395
+ let stages: readonly string[] = [];
396
+ if (root.managedWorkflow && root.workflowStages && root.workflowStages.length > 0) {
397
+ const rendered = workflowStageLines(root.workflowStages, children, theme, width, now);
398
+ stages = rendered.lines;
399
+ activeIndex = rendered.activeIndex;
400
+ }
401
+ return { primary, stages, activeIndex };
291
402
  });
292
403
 
293
404
  const lines: string[] = [];
405
+ if (main && MAX_WIDGET_LINES > 1) {
406
+ lines.push(mainLine(main, theme, width, now, layout));
407
+ }
294
408
  let shownRoots = 0;
295
409
  for (const group of groups) {
296
410
  const remaining = MAX_WIDGET_LINES - 1 - lines.length;
297
411
  if (remaining <= 0) break;
298
- // A group that no longer fits whole keeps its primary line only.
299
- lines.push(...(group.length <= remaining ? group : group.slice(0, remaining)));
412
+ lines.push(...fitGroupLines(group.primary, group.stages, group.activeIndex, remaining, theme));
300
413
  shownRoots++;
301
414
  }
302
415
  const hiddenRoots = roots.length - shownRoots;
@@ -307,13 +420,13 @@ export function formatActiveRunLines(
307
420
  }
308
421
 
309
422
  function hasTickingRun(): boolean {
310
- return monitor.getRuns().some(
311
- (run) => isRunActiveStatus(run.status) && run.activeSince !== undefined,
312
- );
423
+ return monitor.isMainAgentActive()
424
+ || monitor.getRuns().some((run) => isRunActiveStatus(run.status) && run.activeSince !== undefined);
313
425
  }
314
426
 
315
- /** Install the widget for one TUI session. Its timer exists only while at least
316
- * one active run has started and is disposed with the widget. */
427
+ /** Install the widget for one TUI session. Its timer exists only while at
428
+ * least one active run has started or the parent agent loop is running, and
429
+ * is disposed with the widget. */
317
430
  export function installActiveRunsWidget(ctx: Pick<ExtensionContext, "mode" | "ui">): void {
318
431
  if (ctx.mode !== "tui") return;
319
432
  ctx.ui.setWidget(
@@ -341,7 +454,7 @@ export function installActiveRunsWidget(ctx: Pick<ExtensionContext, "mode" | "ui
341
454
  syncTimer();
342
455
 
343
456
  return {
344
- render: (width: number) => formatActiveRunLines(monitor.getRuns(), theme, width),
457
+ render: (width: number) => formatActiveRunLines(monitor.getRuns(), theme, width, Date.now(), monitor.getMainActivity()),
345
458
  invalidate() {},
346
459
  dispose() {
347
460
  disposed = true;