@esso0428/pi-subagents 0.17.12 → 0.17.13

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.
@@ -5,9 +5,9 @@
5
5
  * Uses the callback form of setWidget for themed rendering.
6
6
  */
7
7
 
8
- import { Editor, isKeyRelease, Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
8
+ import { Editor, isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
9
  import { renderAgentName } from "../agent-color.js";
10
- import { type AgentManager, isTopLevelAgent } from "../agent-manager.js";
10
+ import type { AgentManager } from "../agent-manager.js";
11
11
  import { getConfig } from "../agent-types.js";
12
12
  import type { AgentInvocation, AgentRecord, SubagentType, WidgetMode } from "../types.js";
13
13
  import { getLifetimeCost, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, type SessionLike } from "../usage.js";
@@ -16,19 +16,17 @@ import { getLifetimeCost, getLifetimeTotal, getSessionContextPercent, type Lifet
16
16
 
17
17
  /** Maximum number of rendered lines before overflow collapse kicks in. */
18
18
  export const MAX_WIDGET_LINES = 12;
19
+ /** Keep a small editor/input area visible below the above-editor widget. */
20
+ const MIN_EDITOR_LINES = 4;
19
21
 
20
- /** Number of lines available above an editor, retaining room for the prompt. */
21
- export function getWidgetLineBudget(terminalRows: number): number {
22
- if (!Number.isFinite(terminalRows)) return MAX_WIDGET_LINES;
23
- return Math.min(MAX_WIDGET_LINES, Math.max(0, Math.floor(terminalRows) - 4));
24
- }
25
-
26
- export type AgentWidgetOpenMode = "live" | "history";
27
-
28
- export interface AgentWidgetOptions {
29
- canOpenHistory?: (record: import("../types.js").AgentRecord) => boolean;
30
- onOpen?: (record: import("../types.js").AgentRecord, mode: AgentWidgetOpenMode) => void;
31
- showCost?: () => boolean;
22
+ /**
23
+ * Derive the widget ceiling from the terminal height while retaining the
24
+ * historical MAX_WIDGET_LINES ceiling. Very short terminals may have no room
25
+ * for the widget; that is preferable to consuming the editor/input area.
26
+ */
27
+ export function getWidgetLineBudget(rows: number): number {
28
+ if (!Number.isFinite(rows)) return MAX_WIDGET_LINES;
29
+ return Math.min(MAX_WIDGET_LINES, Math.max(0, Math.floor(rows) - MIN_EDITOR_LINES));
32
30
  }
33
31
 
34
32
  /** Braille spinner frames for animated running indicator. */
@@ -55,6 +53,20 @@ export type Theme = {
55
53
  bold(text: string): string;
56
54
  };
57
55
 
56
+ export type AgentWidgetOpenMode = "live" | "history";
57
+ export type AgentWidgetOpenCallback = (record: AgentRecord, mode: AgentWidgetOpenMode) => void | Promise<void>;
58
+ export interface AgentWidgetOptions {
59
+ canOpenHistory?: (record: AgentRecord) => boolean;
60
+ onOpen?: AgentWidgetOpenCallback;
61
+ showCost?: () => boolean;
62
+ }
63
+ /** @deprecated Use AgentWidgetOpenMode. */
64
+ export type AgentOpenMode = AgentWidgetOpenMode;
65
+ /** @deprecated Use AgentWidgetOpenCallback. */
66
+ export type AgentOpenCallback = AgentWidgetOpenCallback;
67
+ /** @deprecated Use AgentWidgetOptions.canOpenHistory. */
68
+ export type AgentHistoryCapability = AgentWidgetOptions["canOpenHistory"];
69
+
58
70
  export type UICtx = {
59
71
  setStatus(key: string, text: string | undefined): void;
60
72
  setWidget(
@@ -76,7 +88,7 @@ export interface AgentActivity {
76
88
  turnCount: number;
77
89
  /** Effective max turns for this agent (undefined = unlimited). */
78
90
  maxTurns?: number;
79
- /** Lifetime token usage, including partial runs. */
91
+ /** Lifetime usage breakdown — see LifetimeUsage docs. */
80
92
  lifetimeUsage: LifetimeUsage;
81
93
  }
82
94
 
@@ -93,7 +105,7 @@ export interface AgentDetails {
93
105
  activity?: string;
94
106
  /** Current spinner frame index (for animated running indicator). */
95
107
  spinnerFrame?: number;
96
- /** Short label for the model the run used, e.g. "haiku 4.5". */
108
+ /** Short model name if different from parent (e.g. "haiku", "sonnet"). */
97
109
  modelName?: string;
98
110
  /** Notable config tags (e.g. ["thinking: high", "isolated"]). */
99
111
  tags?: string[];
@@ -123,31 +135,6 @@ export function formatTokens(count: number): string {
123
135
  return `${count} token`;
124
136
  }
125
137
 
126
- /**
127
- * Format a cost as `~$0.0042`, or "" when there is nothing to show.
128
- *
129
- * The tilde is load-bearing: this is pi's own estimate from the model's listed
130
- * rates, not a billed figure, and the surfaces that print it sit next to token
131
- * counts that ARE exact.
132
- *
133
- * Nothing is printed for zero, which is also what a model with no pricing data
134
- * reports: `$0.00` beside a local model's tokens would claim its cost was
135
- * measured and found to be nothing, rather than never measured at all. For the
136
- * same reason a real cost too small for four decimals reads `<$0.0001` — it was
137
- * measured, and rounding it to `~$0.0000` would say the opposite.
138
- */
139
- export function formatCost(cost: number): string {
140
- if (!(cost > 0)) return ""; // also catches NaN
141
- if (cost < 0.0001) return "<$0.0001";
142
- if (cost >= 1) return `~$${cost.toFixed(2)}`;
143
- // Under a dollar: cents at minimum, four decimals at most, nothing trailing.
144
- // Most single runs land between a tenth of a cent and a dime, where rounding
145
- // to cents would collapse a 4x difference in spend into the same figure.
146
- const rounded = Number(cost.toFixed(4));
147
- const decimals = (String(rounded).split(".")[1] ?? "").length;
148
- return `~$${rounded.toFixed(Math.max(2, decimals))}`;
149
- }
150
-
151
138
  /**
152
139
  * Token count with optional context-fill % and compaction-count annotations.
153
140
  * Thresholds for percent: <70% dim, 70–85% warning, ≥85% error.
@@ -193,6 +180,16 @@ export function formatDuration(startedAt: number, completedAt?: number): string
193
180
  return `${formatMs(Date.now() - startedAt)} (running)`;
194
181
  }
195
182
 
183
+ /** Format an estimated model cost without implying billing precision. */
184
+ export function formatCost(cost: number): string {
185
+ if (!(cost > 0)) return "";
186
+ if (cost < 0.0001) return "<$0.0001";
187
+ if (cost >= 1) return `~$${cost.toFixed(2)}`;
188
+ const rounded = Number(cost.toFixed(4));
189
+ const decimals = (String(rounded).split(".")[1] ?? "").length;
190
+ return `~$${rounded.toFixed(Math.max(2, decimals))}`;
191
+ }
192
+
196
193
  /** Get display name for any agent type (built-in or custom). */
197
194
  export function getDisplayName(type: SubagentType): string {
198
195
  return getConfig(type).displayName;
@@ -204,13 +201,7 @@ export function getPromptModeLabel(type: SubagentType): string | undefined {
204
201
  return config.promptMode === "append" ? "twin" : undefined;
205
202
  }
206
203
 
207
- /**
208
- * Mode label is not included — callers add it where they want it.
209
- *
210
- * Both model forms come back so each surface can pick by width; the
211
- * "(asked X)" annotation is applied here rather than by callers, so a value the
212
- * spawn did not honor cannot be rendered as though it had been (#182).
213
- */
204
+ /** Mode label is not included — callers add it where they want it. */
214
205
  export function buildInvocationTags(
215
206
  invocation: AgentInvocation | undefined,
216
207
  ): { modelName?: string; modelId?: string; tags: string[] } {
@@ -232,9 +223,9 @@ export function buildInvocationTags(
232
223
  };
233
224
  }
234
225
 
235
- /** Truncate text to a single line, max `len` chars. */
226
+ /** Normalize and truncate text so it can never add physical widget rows. */
236
227
  function truncateLine(text: string, len = 60): string {
237
- const line = text.split("\n").find(l => l.trim())?.trim() ?? "";
228
+ const line = text.replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim();
238
229
  if (line.length <= len) return line;
239
230
  return line.slice(0, len) + "…";
240
231
  }
@@ -273,10 +264,25 @@ export class AgentWidget {
273
264
  private uiCtx: UICtx | undefined;
274
265
  private widgetFrame = 0;
275
266
  private widgetInterval: ReturnType<typeof setInterval> | undefined;
276
- /** Tracks how many turns each finished agent has survived. Key: agent ID, Value: turns since finished. */
277
- private finishedTurnAge = new Map<string, number>();
278
- /** How many extra turns errors/aborted agents linger (completed agents clear after 1 turn). */
279
- private static readonly ERROR_LINGER_TURNS = 2;
267
+ private inputUnsub: (() => void) | undefined;
268
+ /** Whether arrow keys currently navigate the agent roster. */
269
+ private navigationActive = false;
270
+ /** Number of modal surfaces currently owning this listener's input suspension. */
271
+ private navigationSuspensionDepth = 0;
272
+ /** Stable identity of the selected row, so roster changes do not jump selection. */
273
+ private selectedAgentId: string | undefined;
274
+ /** Last logical roster index of the selected row, used when it disappears. */
275
+ private selectedRosterIndex = 0;
276
+ /** First logical row currently represented by the bounded viewport. */
277
+ private viewportStart = 0;
278
+
279
+ /** Cached records and roster. Rebuilt by update(), never by an arrow press. */
280
+ private cachedAgents: AgentRecord[] = [];
281
+ private cachedRoster: AgentRecord[] = [];
282
+ private rosterCacheKey: string | undefined;
283
+ private rosterCacheInitialized = false;
284
+ /** Terminal history capability is a potentially filesystem-backed lookup. */
285
+ private historyOpenabilityCache = new Map<string, { key: string; value: boolean }>();
280
286
 
281
287
  /** Whether the widget callback is currently registered with the TUI. */
282
288
  private widgetRegistered = false;
@@ -284,38 +290,17 @@ export class AgentWidget {
284
290
  private tui: any | undefined;
285
291
  /** Last status bar text, used to avoid redundant setStatus calls. */
286
292
  private lastStatusText: string | undefined;
287
- private lastSnapshot = "";
288
- private inputCleanup: (() => void) | undefined;
289
- private navigationSuspended = false;
290
- private navigationActive = false;
291
- private selectedAgentId: string | undefined;
292
- private viewportStart = 0;
293
- private selectedRosterIndex = 0;
294
- private openOptions: AgentWidgetOptions;
295
- private showCost: () => boolean;
296
- private showModel: () => boolean;
293
+ /** Snapshot of the state used for the last widget registration/render request. */
294
+ private lastRenderKey: string | undefined;
295
+ private readonly openOptions: AgentWidgetOptions;
296
+ private readonly showCost: () => boolean;
297
+ private readonly showModel: () => boolean;
297
298
 
298
299
  constructor(
299
300
  private manager: AgentManager,
300
301
  private agentActivity: Map<string, AgentActivity>,
301
- /**
302
- * Read live at render time. Selects which agents the widget shows — see
303
- * `WidgetMode`. Defaults to `"all"` when a caller supplies no policy; the
304
- * extension supplies one defaulting to `"background"`.
305
- */
302
+ /** Read live at render time. Selects which agents the widget shows. */
306
303
  private mode: () => WidgetMode = () => "all",
307
- /**
308
- * Read live at render time, like `mode`. Whether running agents show an
309
- * estimated cost beside their token count. Defaults to off — the extension
310
- * supplies the user's `showCost` setting.
311
- */
312
- /**
313
- * Read live at render time, like `mode`. Whether running agents name the
314
- * model driving them and the thinking level it is running at. Defaults to
315
- * off — the extension supplies the user's `showModel` setting — because the
316
- * row is already dense and the same pair is on the tool result and in the
317
- * conversation viewer unconditionally.
318
- */
319
304
  showCostOrOptions: (() => boolean) | AgentWidgetOptions = () => false,
320
305
  showModel: () => boolean = () => false,
321
306
  ) {
@@ -340,8 +325,8 @@ export class AgentWidget {
340
325
  * only proven-foreground runs drop out — nothing else silently vanishes.
341
326
  * - `all`: every agent.
342
327
  */
343
- private widgetAgents() {
344
- const all = this.manager.listAgents().filter(isTopLevelAgent);
328
+ private widgetAgentsFromManager(): AgentRecord[] {
329
+ const all = this.manager.listAgents();
345
330
  switch (this.mode()) {
346
331
  case "off": return [];
347
332
  case "background": return all.filter(a => a.isBackground !== false);
@@ -349,130 +334,257 @@ export class AgentWidget {
349
334
  }
350
335
  }
351
336
 
352
- /** Temporarily stop global navigation from consuming input handled by another UI surface. */
337
+ /** Records currently visible to the widget; safe for render and input paths. */
338
+ private widgetAgents(): AgentRecord[] {
339
+ return this.rosterCacheInitialized ? this.cachedAgents : this.widgetAgentsFromManager();
340
+ }
341
+
342
+ private canOpenHistory(record: AgentRecord): boolean {
343
+ return this.openOptions.canOpenHistory?.(record) ?? true;
344
+ }
345
+
346
+ private historyOpenabilityKey(record: AgentRecord): string {
347
+ return JSON.stringify([
348
+ record.status,
349
+ record.completedAt ?? null,
350
+ record.transcriptPath ?? null,
351
+ record.session !== undefined,
352
+ ]);
353
+ }
354
+
355
+ /**
356
+ * Cache terminal history capability by the record state that can affect it.
357
+ * The supplied predicate may call `existsSync`, so it must not run from a
358
+ * timer refresh, render-key calculation, or arrow-navigation path more than
359
+ * once for the same terminal record state.
360
+ */
361
+ private cachedCanOpenHistory(record: AgentRecord): boolean {
362
+ if (record.status === "running" || record.status === "queued") return true;
363
+
364
+ const key = this.historyOpenabilityKey(record);
365
+ const cached = this.historyOpenabilityCache.get(record.id);
366
+ if (cached?.key === key) return cached.value;
367
+
368
+ const value = this.canOpenHistory(record);
369
+ this.historyOpenabilityCache.set(record.id, { key, value });
370
+ return value;
371
+ }
372
+
373
+ /** Refresh the manager snapshot and openability cache at lifecycle/update time. */
374
+ private refreshRoster(): AgentRecord[] {
375
+ const agents = this.widgetAgentsFromManager();
376
+ const key = JSON.stringify({
377
+ mode: this.mode(),
378
+ agents: agents.map(record => [
379
+ record.id,
380
+ record.type,
381
+ record.description,
382
+ record.status,
383
+ record.completedAt ?? null,
384
+ record.startedAt,
385
+ record.toolUses,
386
+ record.error ?? null,
387
+ record.transcriptPath ?? null,
388
+ record.session !== undefined,
389
+ record.isBackground,
390
+ record.status !== "running" && record.status !== "queued"
391
+ ? this.cachedCanOpenHistory(record)
392
+ : undefined,
393
+ ]),
394
+ });
395
+ if (this.rosterCacheInitialized && key === this.rosterCacheKey) return this.cachedRoster;
396
+
397
+ const finished = agents.filter(record =>
398
+ record.status !== "running" && record.status !== "queued"
399
+ && record.completedAt !== undefined
400
+ && this.cachedCanOpenHistory(record),
401
+ );
402
+ const running = agents.filter(record => record.status === "running");
403
+ const queued = agents.filter(record => record.status === "queued");
404
+ this.cachedAgents = agents;
405
+ this.cachedRoster = [...running, ...queued, ...finished];
406
+ this.rosterCacheKey = key;
407
+ this.rosterCacheInitialized = true;
408
+ return this.cachedRoster;
409
+ }
410
+
411
+ /** Suspend only this listener's keyboard ownership; never alter the widget. */
353
412
  suspendNavigation(): void {
354
- this.navigationSuspended = true;
413
+ this.navigationSuspensionDepth++;
355
414
  }
356
415
 
357
- /** Resume global navigation after another UI surface has closed. */
416
+ /** Restore one layer of this listener's keyboard ownership. */
358
417
  resumeNavigation(): void {
359
- this.navigationSuspended = false;
418
+ if (this.navigationSuspensionDepth > 0) this.navigationSuspensionDepth--;
360
419
  }
361
420
 
362
421
  /** Set the UI context (grabbed from first tool execution). */
363
422
  setUICtx(ctx: UICtx): boolean {
364
423
  if (ctx === this.uiCtx) return false;
365
- this.inputCleanup?.();
366
- this.inputCleanup = undefined;
424
+
425
+ // UICtx changed — the widget and input handler registered on the old
426
+ // context are gone. Re-register both on the next update().
427
+ this.inputUnsub?.();
428
+ this.inputUnsub = undefined;
367
429
  this.uiCtx = ctx;
368
430
  this.widgetRegistered = false;
369
431
  this.tui = undefined;
370
432
  this.lastStatusText = undefined;
371
- this.navigationSuspended = false;
433
+ this.lastRenderKey = undefined;
434
+ this.navigationSuspensionDepth = 0;
372
435
  this.navigationActive = false;
373
436
  this.selectedAgentId = undefined;
437
+ this.selectedRosterIndex = 0;
374
438
  this.viewportStart = 0;
375
- if (ctx.onTerminalInput) this.inputCleanup = ctx.onTerminalInput(data => this.handleInput(data));
439
+ this.cachedAgents = [];
440
+ this.cachedRoster = [];
441
+ this.rosterCacheKey = undefined;
442
+ this.rosterCacheInitialized = false;
443
+ this.historyOpenabilityCache.clear();
444
+ // Print/RPC tests and lightweight embedders may provide only the widget
445
+ // surface; real interactive contexts always implement this hook.
446
+ if (typeof ctx.onTerminalInput === "function") {
447
+ this.inputUnsub = ctx.onTerminalInput(data => this.handleKey(data));
448
+ }
376
449
  return true;
377
450
  }
378
451
 
379
- /**
380
- * Called on each new turn (tool_execution_start).
381
- * Ages finished agents and clears those that have lingered long enough.
382
- */
452
+ /** Request a render on the currently registered TUI without touching input. */
453
+ requestUiRefresh(force = true): boolean {
454
+ if (!this.tui || typeof this.tui.requestRender !== "function") return false;
455
+ this.tui.requestRender(force);
456
+ return true;
457
+ }
458
+
459
+ /** Called on each new turn (tool_execution_start). */
383
460
  onTurnStart() {
384
- // Age all finished agents
385
- for (const [id, age] of this.finishedTurnAge) {
386
- this.finishedTurnAge.set(id, age + 1);
387
- }
388
- // Trigger a widget refresh (will filter out expired agents)
389
461
  this.update();
390
462
  }
391
463
 
392
- /** Ensure the widget update timer is running. */
464
+ /** Keep the spinner/elapsed-time timer alive only while a visible agent runs. */
393
465
  ensureTimer() {
466
+ if (!this.uiCtx) return;
467
+ const roster = this.rosterCacheInitialized ? this.cachedRoster : this.refreshRoster();
468
+ if (!roster.some(a => a.status === "running")) return;
394
469
  if (!this.widgetInterval) {
395
470
  this.widgetInterval = setInterval(() => this.update(true), 250);
396
471
  }
397
472
  }
398
473
 
399
- /** Check if a finished agent should still be shown in the widget. */
400
- private shouldShowFinished(agentId: string, status: string, durable = false): boolean {
401
- // A persisted transcript is a durable history row, not a transient
402
- // completion notification. Keep it navigable after the one-turn visual
403
- // linger expires; runtime GC must not make `/agents` history disappear.
404
- if (durable) return true;
405
- const age = this.finishedTurnAge.get(agentId) ?? 0;
406
- const maxAge = ERROR_STATUSES.has(status) ? AgentWidget.ERROR_LINGER_TURNS : 1;
407
- return age < maxAge;
408
- }
409
-
410
- /** Record an agent as finished (call when agent completes). */
411
- markFinished(agentId: string) {
412
- if (!this.finishedTurnAge.has(agentId)) {
413
- this.finishedTurnAge.set(agentId, 0);
474
+ private syncTimer(shouldRun: boolean): void {
475
+ if (shouldRun) {
476
+ this.ensureTimer();
477
+ } else if (this.widgetInterval) {
478
+ clearInterval(this.widgetInterval);
479
+ this.widgetInterval = undefined;
414
480
  }
415
481
  }
416
482
 
417
483
  /**
418
- * Drop an agent's finished-age (call when a settled agent starts running
419
- * again, i.e. a background resume). markFinished only seeds an age it has not
420
- * seen before, so a resumed agent would otherwise keep the age from its
421
- * previous run — already past the linger limit, hiding the new run's
422
- * completion line entirely.
484
+ * Retained for the lifecycle call sites. Terminal visibility is determined
485
+ * by the manager record and the history capability, not a one-turn timer.
423
486
  */
424
- markRunning(agentId: string) {
425
- this.finishedTurnAge.delete(agentId);
426
- }
487
+ markFinished(_agentId: string) {}
427
488
 
428
- private navigationAgents(): AgentRecord[] {
429
- const agents = this.widgetAgents().filter(agent =>
430
- agent.status === "running" || agent.status === "queued" || (agent.completedAt !== undefined && this.shouldShowFinished(agent.id, agent.status, agent.transcriptPath !== undefined)),
431
- );
432
- return agents.filter(agent =>
433
- agent.status === "running" || agent.status === "queued" || (this.openOptions.canOpenHistory?.(agent) ?? true),
434
- ).sort((a, b) => {
435
- const activeA = a.status === "running" || a.status === "queued" ? 0 : 1;
436
- const activeB = b.status === "running" || b.status === "queued" ? 0 : 1;
437
- return activeA - activeB;
438
- });
439
- }
489
+ /** Retained for lifecycle call sites; terminal rows are record-driven now. */
490
+ markRunning(_agentId: string) {}
440
491
 
441
- private updateViewport(roster: AgentRecord[]): void {
442
- const budget = getWidgetLineBudget(this.tui?.terminal?.rows ?? 24);
443
- const visibleRows = Math.max(1, budget - 1);
444
- if (this.selectedRosterIndex < this.viewportStart) this.viewportStart = this.selectedRosterIndex;
445
- if (this.selectedRosterIndex >= this.viewportStart + visibleRows) this.viewportStart = this.selectedRosterIndex - visibleRows + 1;
446
- this.viewportStart = Math.max(0, Math.min(this.viewportStart, Math.max(0, roster.length - visibleRows)));
492
+ /**
493
+ * Records represented by selectable rows in the above-editor widget.
494
+ *
495
+ * Keep this order as the single source of truth for both rendering and key
496
+ * navigation. `listAgents()` is newest-first; active rows come first so the
497
+ * panel exposes currently useful work before terminal history.
498
+ */
499
+ private roster(): AgentRecord[] {
500
+ return this.rosterCacheInitialized ? this.cachedRoster : this.refreshRoster();
447
501
  }
448
502
 
503
+ /** True when pi's prompt editor owns the keyboard. */
449
504
  private editorHasFocus(): boolean {
450
505
  const focused = (this.tui as { focusedComponent?: unknown } | undefined)?.focusedComponent;
451
506
  return focused == null || focused instanceof Editor;
452
507
  }
453
508
 
509
+ private selectedIndexOf(records: readonly AgentRecord[]): number {
510
+ if (!this.selectedAgentId) return -1;
511
+ return records.findIndex(record => record.id === this.selectedAgentId);
512
+ }
513
+
454
514
  private deactivate(): void {
515
+ // Keep the selected row and viewport so re-entering navigation can resume
516
+ // where the user left off. Lifecycle resets (context, dispose, empty
517
+ // roster) clear this state explicitly instead of treating every exit as a
518
+ // reset.
455
519
  this.navigationActive = false;
456
520
  this.requestUiRefresh();
457
521
  }
458
522
 
459
- private handleInput(data: string): { consume?: boolean; data?: string } | undefined {
523
+ /** Resume navigation from the retained row, or select the first row. */
524
+ private activate(records: readonly AgentRecord[]): void {
525
+ this.navigationActive = true;
526
+ const selectedIndex = this.selectedIndexOf(records);
527
+ if (selectedIndex >= 0) {
528
+ this.selectedRosterIndex = selectedIndex;
529
+ } else {
530
+ // The previously selected row may have been cleaned up while navigation
531
+ // was inactive. Resume at its old logical position, clamped to the new
532
+ // roster, rather than jumping back to the first row.
533
+ const fallbackIndex = Math.max(0, Math.min(this.selectedRosterIndex, records.length - 1));
534
+ this.selectedAgentId = records[fallbackIndex].id;
535
+ this.selectedRosterIndex = fallbackIndex;
536
+ this.viewportStart = Math.min(this.viewportStart, Math.max(0, records.length - 1));
537
+ }
538
+ this.requestUiRefresh();
539
+ }
540
+
541
+ /** Move the selected row, activating only from an empty focused editor. */
542
+ private moveSelection(direction: -1 | 1): boolean {
543
+ const records = this.roster();
544
+ const ui = this.uiCtx;
545
+ if (records.length === 0 || !ui) return false;
546
+
547
+ if (!this.navigationActive) {
548
+ if (direction !== 1 || !this.editorHasFocus() || (ui.getEditorText?.() ?? "") !== "") return false;
549
+ this.activate(records);
550
+ return true;
551
+ }
552
+
553
+ const currentIndex = Math.max(0, this.selectedIndexOf(records));
554
+ if (direction === -1 && currentIndex === 0) {
555
+ this.deactivate();
556
+ return true;
557
+ }
558
+ const nextIndex = Math.max(0, Math.min(records.length - 1, currentIndex + direction));
559
+ this.selectedAgentId = records[nextIndex].id;
560
+ this.selectedRosterIndex = nextIndex;
561
+ this.requestUiRefresh();
562
+ return true;
563
+ }
564
+
565
+ private openSelected(): void {
566
+ const record = this.roster().find(candidate => candidate.id === this.selectedAgentId);
567
+ this.deactivate();
568
+ if (!record) return;
569
+ const mode: AgentWidgetOpenMode = record.status === "running" || record.status === "queued" ? "live" : "history";
570
+ void this.openOptions.onOpen?.(record, mode);
571
+ }
572
+
573
+ /** Handle terminal input before it reaches the focused prompt editor. */
574
+ handleKey(data: string): { consume?: boolean; data?: string } | undefined {
460
575
  if (!this.uiCtx || isKeyRelease(data)) return undefined;
461
- if (this.navigationSuspended) return undefined;
576
+ if (this.navigationSuspensionDepth > 0) return undefined;
462
577
  if (!this.editorHasFocus()) {
463
578
  if (this.navigationActive) this.deactivate();
464
579
  return undefined;
465
580
  }
466
581
 
467
582
  if (!this.navigationActive) {
468
- const roster = this.navigationAgents();
469
- if (!matchesKey(data, "down") || (this.uiCtx.getEditorText?.() ?? "") !== "" || roster.length === 0) return undefined;
470
- this.navigationActive = true;
471
- const index = this.selectedAgentId ? roster.findIndex(agent => agent.id === this.selectedAgentId) : -1;
472
- this.selectedRosterIndex = index >= 0 ? index : 0;
473
- this.selectedAgentId = roster[this.selectedRosterIndex]?.id;
474
- this.updateViewport(roster);
475
- this.requestUiRefresh();
583
+ const records = this.roster();
584
+ if (!matchesKey(data, "down") || (this.uiCtx.getEditorText?.() ?? "") !== "" || records.length === 0) {
585
+ return undefined;
586
+ }
587
+ this.activate(records);
476
588
  return { consume: true };
477
589
  }
478
590
 
@@ -480,47 +592,19 @@ export class AgentWidget {
480
592
  this.deactivate();
481
593
  return { consume: true };
482
594
  }
483
- if (matchesKey(data, "up") || matchesKey(data, "down")) {
484
- const roster = this.navigationAgents();
485
- if (roster.length === 0) return { consume: true };
486
- if (matchesKey(data, "up") && this.selectedRosterIndex === 0) {
487
- this.deactivate();
488
- } else {
489
- this.selectedRosterIndex = Math.max(0, Math.min(roster.length - 1, this.selectedRosterIndex + (matchesKey(data, "down") ? 1 : -1)));
490
- this.selectedAgentId = roster[this.selectedRosterIndex]?.id;
491
- this.updateViewport(roster);
492
- this.requestUiRefresh();
493
- }
494
- return { consume: true };
495
- }
595
+ if (matchesKey(data, "up")) return this.moveSelection(-1) ? { consume: true } : undefined;
596
+ if (matchesKey(data, "down")) return this.moveSelection(1) ? { consume: true } : undefined;
496
597
  if (matchesKey(data, Key.enter)) {
497
- const record = this.navigationAgents()[this.selectedRosterIndex];
498
- if (record) {
499
- const terminal = record.status !== "running" && record.status !== "queued";
500
- if (!terminal || (this.openOptions.canOpenHistory?.(record) ?? true)) {
501
- this.openOptions.onOpen?.(record, terminal ? "history" : "live");
502
- }
503
- }
598
+ this.openSelected();
504
599
  return { consume: true };
505
600
  }
506
601
 
507
- // Only arrow keys navigate. Other keys flow to the editor and leave navigation mode.
602
+ // Only ↑/↓ navigate. Other keys, including j/k/←/→, flow to the editor
603
+ // and leave navigation mode.
508
604
  this.deactivate();
509
605
  return undefined;
510
606
  }
511
607
 
512
- private requestUiRefresh(force = true): boolean {
513
- if (!this.tui || typeof this.tui.requestRender !== "function") return false;
514
- this.tui.requestRender(force);
515
- return true;
516
- }
517
-
518
- private selectionBullet(agentId: string, theme: Theme): string {
519
- return this.navigationActive && this.selectedAgentId === agentId
520
- ? theme.fg("accent", "●")
521
- : theme.fg("dim", "○");
522
- }
523
-
524
608
  /** Render a finished agent line. */
525
609
  private renderFinishedLine(a: { id: string; type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt?: number; error?: string; lifetimeUsage?: LifetimeUsage }, theme: Theme): string {
526
610
  const modeLabel = getPromptModeLabel(a.type);
@@ -539,7 +623,7 @@ export class AgentWidget {
539
623
  statusText = theme.fg("dim", " stopped");
540
624
  } else if (a.status === "error") {
541
625
  icon = theme.fg("error", "✗");
542
- const errMsg = a.error ? `: ${a.error.slice(0, 60)}` : "";
626
+ const errMsg = a.error ? `: ${truncateLine(a.error)}` : "";
543
627
  statusText = theme.fg("error", ` error${errMsg}`);
544
628
  } else {
545
629
  // aborted
@@ -551,15 +635,12 @@ export class AgentWidget {
551
635
  const activity = this.agentActivity.get(a.id);
552
636
  if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns));
553
637
  if (a.toolUses > 0) parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
554
- // From the record, not the activity tracker: that entry is deleted the
555
- // moment an agent finishes, and "what did it cost" is a question asked
556
- // about finished agents.
557
- const costText = this.showCost() ? formatCost(getLifetimeCost(a.lifetimeUsage)) : "";
638
+ const costText = this.showCost() && a.lifetimeUsage ? formatCost(getLifetimeCost(a.lifetimeUsage)) : "";
558
639
  if (costText) parts.push(costText);
559
640
  parts.push(duration);
560
641
 
561
642
  const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
562
- const description = a.description.replace(/[\r\n]+/g, " ");
643
+ const description = truncateLine(a.description);
563
644
  return `${icon} ${renderAgentName(a.type, theme, { fallbackColor: "dim" })}${modeTag} ${theme.fg("dim", description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
564
645
  }
565
646
 
@@ -568,14 +649,12 @@ export class AgentWidget {
568
649
  * reading live state each time instead of capturing it in a closure.
569
650
  */
570
651
  private renderWidget(tui: any, theme: Theme): string[] {
571
- const allAgents = this.widgetAgents();
572
- const running = allAgents.filter(a => a.status === "running");
573
- const queued = allAgents.filter(a => a.status === "queued");
574
- const finished = allAgents.filter(a =>
575
- a.status !== "running" && a.status !== "queued" && a.completedAt
576
- && this.shouldShowFinished(a.id, a.status, a.transcriptPath !== undefined),
577
- );
652
+ const roster = this.roster();
653
+ const running = roster.filter(a => a.status === "running");
654
+ const queued = roster.filter(a => a.status === "queued");
655
+ const finished = roster.filter(a => a.status !== "running" && a.status !== "queued");
578
656
 
657
+ const selectedId = this.navigationActive ? this.selectedAgentId : undefined;
579
658
  const hasActive = running.length > 0 || queued.length > 0;
580
659
  const hasFinished = finished.length > 0;
581
660
 
@@ -583,9 +662,9 @@ export class AgentWidget {
583
662
  if (!hasActive && !hasFinished) return [];
584
663
 
585
664
  const w = tui.terminal.columns;
586
- const budget = getWidgetLineBudget(tui.terminal.rows ?? 24);
587
- if (budget === 0) return [];
588
- const truncate = (line: string) => truncateToWidth(line.replace(/[\r\n]+/g, " "), w);
665
+ const maxLines = getWidgetLineBudget(tui.terminal.rows);
666
+ if (maxLines === 0) return [];
667
+ const truncate = (line: string, width = w) => truncateToWidth(line, Math.max(0, width));
589
668
  const headingColor = hasActive ? "accent" : "dim";
590
669
  const headingIcon = hasActive ? "●" : "○";
591
670
  const frame = SPINNER[this.widgetFrame % SPINNER.length];
@@ -593,12 +672,18 @@ export class AgentWidget {
593
672
  // Build sections separately for overflow-aware assembly.
594
673
  // Each running agent = 2 lines (header + activity), finished = 1 line, queued = 1 line.
595
674
 
596
- const finishedLines: string[] = [];
675
+ type WidgetRow = { record: AgentRecord; lines: string[]; recordCount?: number };
676
+
677
+ const finishedLines: WidgetRow[] = [];
597
678
  for (const a of finished) {
598
- finishedLines.push(truncate(theme.fg("dim", "├─") + ` ${this.selectionBullet(a.id, theme)} ` + this.renderFinishedLine(a, theme)));
679
+ const marker = a.id === selectedId ? theme.fg("accent", "●") : theme.fg("dim", "○");
680
+ finishedLines.push({
681
+ record: a,
682
+ lines: [truncate(theme.fg("dim", "├─") + ` ${marker} ` + this.renderFinishedLine(a, theme))],
683
+ });
599
684
  }
600
685
 
601
- const runningLines: string[][] = []; // each entry is [header, activity]
686
+ const runningLines: WidgetRow[] = []; // each entry is [header, activity]
602
687
  for (const a of running) {
603
688
  const modeLabel = getPromptModeLabel(a.type);
604
689
  const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
@@ -607,9 +692,7 @@ export class AgentWidget {
607
692
  const bg = this.agentActivity.get(a.id);
608
693
  const toolUses = bg?.toolUses ?? a.toolUses;
609
694
  // Spend comes from the record, never from the activity tracker: the record
610
- // is the one that survives the agent finishing, and the one nested-tools
611
- // folds a hidden child's spend into. Reading the tracker while an agent
612
- // runs and the record once it stops made the figure jump at completion.
695
+ // survives the agent finishing and includes nested-child usage.
613
696
  const tokens = getLifetimeTotal(a.lifetimeUsage);
614
697
  const contextPercent = getSessionContextPercent(bg?.session);
615
698
  const tokenText = tokens > 0 ? formatSessionTokens(tokens, contextPercent, theme, a.compactionCount) : "";
@@ -617,9 +700,6 @@ export class AgentWidget {
617
700
 
618
701
  const parts: string[] = [];
619
702
  if (this.showModel()) {
620
- // Leading, and paired: a thinking level means nothing without the model
621
- // it applies to. The tag is taken from buildInvocationTags rather than
622
- // rebuilt so the "(asked X)" annotation survives.
623
703
  const { modelName, tags } = buildInvocationTags(a.invocation);
624
704
  if (modelName) parts.push(modelName);
625
705
  const thinkingTag = tags.find(tag => tag.startsWith("thinking: "));
@@ -632,159 +712,305 @@ export class AgentWidget {
632
712
  parts.push(elapsed);
633
713
  const statsText = parts.join(" · ");
634
714
 
635
- const activity = (bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…").replace(/[\r\n]+/g, " ");
715
+ const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…";
636
716
 
637
- runningLines.push([
638
- truncate(theme.fg("dim", "├─") + ` ${this.selectionBullet(a.id, theme)} ${theme.fg("accent", frame)} ${renderAgentName(a.type, theme, { bold: true })}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${fgPreservingNestedStyles(theme, "dim", statsText)}`),
639
- truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)),
640
- ]);
717
+ const marker = a.id === selectedId ? theme.fg("accent", "●") : theme.fg("dim", "○");
718
+ runningLines.push({
719
+ record: a,
720
+ lines: [
721
+ truncate(theme.fg("dim", "├─") + ` ${marker} ${theme.fg("accent", frame)} ${renderAgentName(a.type, theme, { bold: true })}${modeTag} ${theme.fg("muted", truncateLine(a.description))} ${theme.fg("dim", "·")} ${fgPreservingNestedStyles(theme, "dim", statsText)}`),
722
+ truncate(theme.fg("dim", "│ ") + ` ${theme.fg("dim", `⎿ ${truncateLine(activity)}`)}`),
723
+ ],
724
+ });
641
725
  }
642
726
 
643
- const selectedQueued = this.navigationActive && this.selectedAgentId !== undefined && queued.some(agent => agent.id === this.selectedAgentId);
644
- const queuedLine = queued.length > 0
645
- ? truncate(theme.fg("dim", "├─") + ` ${selectedQueued ? theme.fg("accent", "●") : theme.fg("dim", "○")} ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`)
646
- : undefined;
647
-
648
- // Assemble with overflow cap (heading + overflow indicator = 2 reserved lines).
649
- const maxBody = Math.max(1, budget - 1); // heading takes 1 line
650
- const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
727
+ const queuedLines: WidgetRow[] = queued.map(a => {
728
+ const marker = a.id === selectedId ? theme.fg("accent", "●") : theme.fg("dim", "○");
729
+ return {
730
+ record: a,
731
+ lines: [truncate(theme.fg("dim", "├─") + ` ${marker} ${theme.fg("muted", "◦")} ${theme.fg("dim", `${getDisplayName(a.type)} ${truncateLine(a.description)} · queued`)}`)],
732
+ };
733
+ });
651
734
 
652
- const hint = "↑↓ select · enter view · esc back";
653
- const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents") + " " + theme.fg("dim", hint))];
735
+ // Keep the idle widget compact: a queue is one logical status line until
736
+ // navigation starts. While navigating, expand it back to the same per-agent
737
+ // roster used by key handling so every selected queued record can render.
738
+ const queuedSummary: WidgetRow[] = queued.length > 0 && !this.navigationActive
739
+ ? [{
740
+ record: queued[0],
741
+ recordCount: queued.length,
742
+ lines: [truncate(theme.fg("dim", "├─") + ` ${theme.fg("dim", "○")} ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`)],
743
+ }]
744
+ : [];
745
+
746
+ // Assemble with a responsive cap (heading + overflow indicator = 2
747
+ // reserved lines when content exceeds the available body budget).
748
+ const maxBody = maxLines - 1; // heading takes 1 line
749
+ const rows: WidgetRow[] = [
750
+ ...runningLines,
751
+ ...(this.navigationActive ? queuedLines : queuedSummary),
752
+ ...finishedLines,
753
+ ];
754
+ const totalBody = rows.reduce((total, row) => total + row.lines.length, 0);
755
+
756
+ const heading = "Agents ↑↓ select · enter view · esc back";
757
+ const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, heading))];
758
+
759
+ if (maxLines === 1) {
760
+ // There is room only for the heading; do not consume the editor/input row.
761
+ return lines;
762
+ }
654
763
 
655
764
  if (totalBody <= maxBody) {
656
- // Everything fits — add all lines and fix up connectors for the last item.
657
- lines.push(...finishedLines);
658
- for (const pair of runningLines) lines.push(...pair);
659
- if (queuedLine) lines.push(queuedLine);
660
-
661
- // Fix last connector: swap ├─ → └─ and │ → space for activity lines.
662
- if (lines.length > 1) {
663
- const last = lines.length - 1;
664
- lines[last] = lines[last].replace("├─", "└─");
665
- // If last item is a running agent activity line, fix indent of that line
666
- // and fix the header line above it.
667
- if (runningLines.length > 0 && !queuedLine) {
668
- // The last two lines are the last running agent's header + activity.
669
- if (last >= 2) {
670
- lines[last - 1] = lines[last - 1].replace("├─", "└─");
671
- lines[last] = lines[last].replace("│ ", " ");
672
- }
765
+ this.viewportStart = 0;
766
+ for (const row of rows) lines.push(...row.lines);
767
+ if (rows.length > 0) {
768
+ const lastRow = rows[rows.length - 1];
769
+ const lastStart = lines.length - lastRow.lines.length;
770
+ // Keep the selected row's bullet adjacent to the normal branch marker.
771
+ // This makes the active row unambiguous even when it is the last row.
772
+ const lastRowSelected = this.navigationActive && lastRow.record.id === selectedId;
773
+ if (!lastRowSelected) lines[lastStart] = lines[lastStart].replace("├─", "└─");
774
+ if (lastRow.lines.length === 2) {
775
+ lines[lastStart + 1] = lines[lastStart + 1].replace("│ ", " ");
673
776
  }
674
777
  }
675
- } else {
676
- // Overflow — prioritize: running > queued > finished.
677
- // Reserve 1 line for overflow indicator.
678
- let budget = maxBody - 1;
679
- let hiddenRunning = 0;
680
- let hiddenFinished = 0;
681
-
682
- // Reserve the queued line's row up front. It is a single summary of N
683
- // waiting agents, so it cannot be folded into the "+N more" count (which
684
- // is denominated in agents) without either under-reporting it as 1 or
685
- // inflating the total with agents that were never getting their own rows.
686
- // Reserving costs at most one running agent — which IS counted below —
687
- // and makes the drop unreachable. It matters most exactly when it used to
688
- // vanish: the pool is saturated and the queue is what the user needs to see.
689
- const queuedReserve = queuedLine ? 1 : 0;
690
- budget -= queuedReserve;
691
-
692
- // 1. Running agents (2 lines each)
693
- for (const pair of runningLines) {
694
- if (budget >= 2) {
695
- lines.push(...pair);
696
- budget -= 2;
778
+ } else if (!this.navigationActive && queuedSummary.length > 0) {
779
+ // In the idle view the queue is deliberately one summary row. Preserve
780
+ // that row before spending space on terminal history, and account for
781
+ // the real number of queued records if the summary itself cannot fit.
782
+ // Navigation expands the queue into per-record rows and uses the full
783
+ // contiguous viewport below.
784
+ const visibleRows: WidgetRow[] = [];
785
+ const hiddenRows: WidgetRow[] = [];
786
+ let bodyBudget = Math.max(0, maxBody - 1); // reserve the footer
787
+ let runningBudget = Math.max(0, bodyBudget - 1); // reserve queued summary
788
+
789
+ for (const row of runningLines) {
790
+ const height = row.lines.length;
791
+ if (height <= runningBudget) {
792
+ visibleRows.push(row);
793
+ runningBudget -= height;
794
+ bodyBudget -= height;
697
795
  } else {
698
- hiddenRunning++;
796
+ hiddenRows.push(row);
699
797
  }
700
798
  }
701
799
 
702
- // 2. Queued line (always fits — its row was reserved above)
703
- if (queuedLine) {
704
- budget += queuedReserve;
705
- lines.push(queuedLine);
706
- budget--;
800
+ const summaryRow = queuedSummary[0];
801
+ if (bodyBudget > 0) {
802
+ visibleRows.push(summaryRow);
803
+ bodyBudget -= summaryRow.lines.length;
804
+ } else {
805
+ hiddenRows.push(summaryRow);
707
806
  }
708
807
 
709
- // 3. Finished agents
710
- for (const fl of finishedLines) {
711
- if (budget >= 1) {
712
- lines.push(fl);
713
- budget--;
808
+ for (const row of finishedLines) {
809
+ const height = row.lines.length;
810
+ if (height <= bodyBudget) {
811
+ visibleRows.push(row);
812
+ bodyBudget -= height;
714
813
  } else {
715
- hiddenFinished++;
814
+ hiddenRows.push(row);
716
815
  }
717
816
  }
718
817
 
719
- // Overflow summary
720
- // On extremely short terminals the queued summary itself consumes the
721
- // last useful row; keep its count in the compact footer. At the normal
722
- // 12-line ceiling the summary is a single visible row and must not be
723
- // counted as hidden agents.
724
- const hiddenQueued = queuedLine && (tui.terminal.rows ?? 24) < 10 ? queued.length : 0;
725
- const overflowParts: string[] = [];
726
- if (hiddenRunning > 0) overflowParts.push(`${hiddenRunning} running`);
727
- if (hiddenFinished > 0) overflowParts.push(`${hiddenFinished} finished`);
728
- if (hiddenQueued > 0) overflowParts.push(`${hiddenQueued} queued`);
729
- const hiddenTotal = hiddenRunning + hiddenFinished + hiddenQueued;
730
- const overflowText = overflowParts.join(", ");
731
- lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hiddenTotal} more (${overflowText})`)}`)
732
- );
733
- }
818
+ for (const row of visibleRows) lines.push(...row.lines);
819
+ if (visibleRows.length > 0) {
820
+ const lastRow = visibleRows[visibleRows.length - 1];
821
+ const lastStart = lines.length - lastRow.lines.length;
822
+ lines[lastStart] = lines[lastStart].replace("├─", "└─");
823
+ if (lastRow.lines.length === 2) {
824
+ lines[lastStart + 1] = lines[lastStart + 1].replace("│ ", " ");
825
+ }
826
+ }
827
+
828
+ const hiddenCount = (rows: readonly WidgetRow[]): number =>
829
+ rows.reduce((total, row) => total + (row.recordCount ?? 1), 0);
830
+ const categoryCount = (status: string): number => hiddenRows
831
+ .filter(row => row.record.status === status)
832
+ .reduce((total, row) => total + (row.recordCount ?? 1), 0);
833
+ const finishedCount = hiddenRows
834
+ .filter(row => row.record.status !== "running" && row.record.status !== "queued")
835
+ .reduce((total, row) => total + (row.recordCount ?? 1), 0);
836
+ const categoryCounts: string[] = [
837
+ ["running", categoryCount("running")] as [string, number],
838
+ ["queued", categoryCount("queued")] as [string, number],
839
+ ["finished", finishedCount] as [string, number],
840
+ ].filter(([, count]) => count > 0).map(([label, count]) => `${count} ${label}`);
841
+ const hidden = hiddenCount(hiddenRows);
842
+ lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hidden} more (${categoryCounts.join(", ")})`)}`));
843
+ } else {
844
+ // Reserve one line for a directional overflow summary. The viewport is
845
+ // a contiguous slice in roster order, so the same slice is navigable and
846
+ // renderable even when the selected row is currently hidden.
847
+ const baseContentBudget = Math.max(0, maxBody - 1);
848
+ const heightAt = (index: number) => rows[index]?.lines.length ?? 0;
849
+ const endFor = (start: number, budget: number): number => {
850
+ let used = 0;
851
+ let end = start;
852
+ while (end < rows.length && used + heightAt(end) <= budget) {
853
+ used += heightAt(end++);
854
+ }
855
+ return end;
856
+ };
734
857
 
735
- if (this.navigationActive) {
736
- const roster = this.navigationAgents();
737
- const selected = roster[this.selectedRosterIndex];
738
- const hasSelected = selected ? lines.some(line => line.includes(selected.description.replace(/[\r\n]+/g, " "))) : true;
739
- if (selected && !hasSelected) {
740
- const selectedLine = truncate(theme.fg("dim", "└─") + ` ${this.selectionBullet(selected.id, theme)} ${selected.status === "queued" ? "◦" : "⠋"} ${theme.fg("muted", selected.description.replace(/[\r\n]+/g, " "))}`);
741
- if (lines.length >= budget) lines[lines.length - 1] = selectedLine;
742
- else lines.push(selectedLine);
858
+ let start = Math.max(0, Math.min(this.viewportStart, Math.max(0, rows.length - 1)));
859
+ const selectedIndex = this.navigationActive && selectedId
860
+ ? rows.findIndex(row => row.record.id === selectedId)
861
+ : -1;
862
+ if (selectedIndex >= 0) {
863
+ if (selectedIndex < start) start = selectedIndex;
864
+ if (selectedIndex >= endFor(start, baseContentBudget)) start = selectedIndex;
743
865
  }
744
- if (this.viewportStart > 0) {
745
- if (lines.length >= budget) lines.splice(1, 1, truncate(theme.fg("dim", "↑ more")));
746
- else lines.splice(1, 0, truncate(theme.fg("dim", "↑ more")));
866
+
867
+ // Keep both directional affordances in the viewport chrome: the top
868
+ // count sits below the heading and the bottom count sits below the
869
+ // bounded roster. Each consumes one row when that direction is hidden.
870
+ let showTopMore = start > 0;
871
+ let contentBudget = Math.max(0, maxBody - (showTopMore ? 2 : 1));
872
+ if (selectedIndex >= 0 && selectedIndex >= endFor(start, contentBudget)) {
873
+ start = selectedIndex;
874
+ showTopMore = start > 0;
875
+ contentBudget = Math.max(0, maxBody - (showTopMore ? 2 : 1));
876
+ }
877
+ this.viewportStart = start;
878
+
879
+ let used = 0;
880
+ let end = start;
881
+ const visibleBody: string[] = [];
882
+ while (end < rows.length && used + heightAt(end) <= contentBudget) {
883
+ visibleBody.push(...rows[end].lines);
884
+ used += heightAt(end);
885
+ end++;
886
+ }
887
+ // A selected two-line row must remain addressable even if only one body
888
+ // line is available. Showing its header is preferable to hiding it.
889
+ if (end === start && rows[start] && contentBudget > 0) {
890
+ visibleBody.push(rows[start].lines[0]);
891
+ end = start + 1;
892
+ }
893
+
894
+ const countRows = (selectedRows: readonly WidgetRow[]): number =>
895
+ selectedRows.reduce((total, row) => total + (row.recordCount ?? 1), 0);
896
+ const hiddenBefore = countRows(rows.slice(0, start));
897
+ const hiddenAfter = countRows(rows.slice(end));
898
+ const hidden = hiddenBefore + hiddenAfter;
899
+ const hiddenRows = rows.filter((_row, index) => index < start || index >= end);
900
+ const categoryCount = (status: string): number => hiddenRows
901
+ .filter(row => row.record.status === status)
902
+ .reduce((total, row) => total + (row.recordCount ?? 1), 0);
903
+ const categoryCounts: string[] = [
904
+ ["running", categoryCount("running")] as [string, number],
905
+ ["queued", categoryCount("queued")] as [string, number],
906
+ ["finished", hiddenRows
907
+ .filter(row => row.record.status !== "running" && row.record.status !== "queued")
908
+ .reduce((total, row) => total + (row.recordCount ?? 1), 0)] as [string, number],
909
+ ].filter(([, count]) => count > 0).map(([label, count]) => `${count} ${label}`);
910
+ const direction = [
911
+ ...(hiddenBefore > 0 ? [`↑ ${hiddenBefore} more`] : []),
912
+ ...(hiddenAfter > 0 ? [`↓ ${hiddenAfter} more`] : []),
913
+ ].join(" · ");
914
+ const summary = `+${hidden} more (${direction}${categoryCounts.length > 0 ? `; ${categoryCounts.join(", ")}` : ""})`;
915
+
916
+ if (showTopMore) lines.push(truncate(theme.fg("dim", `↑ ${hiddenBefore} more`)));
917
+
918
+ // Fill the bounded viewport so the scrollbar track has a stable height.
919
+ while (visibleBody.length < contentBudget) visibleBody.push("");
920
+ const totalBody = rows.reduce((total, row) => total + row.lines.length, 0);
921
+ const startOffset = rows.slice(0, start).reduce((total, row) => total + row.lines.length, 0);
922
+ const trackHeight = Math.max(0, contentBudget);
923
+ const minThumbHeight = Math.min(2, trackHeight);
924
+ const thumbHeight = Math.max(
925
+ minThumbHeight,
926
+ Math.min(trackHeight, Math.round((trackHeight * trackHeight) / totalBody)),
927
+ );
928
+ const maxScrollTop = Math.max(0, totalBody - trackHeight);
929
+ const maxThumbTop = Math.max(0, trackHeight - thumbHeight);
930
+ const thumbOffset = maxScrollTop === 0
931
+ ? 0
932
+ : Math.round((startOffset / maxScrollTop) * maxThumbTop);
933
+ const contentWidth = Math.max(0, w - 1);
934
+ for (let index = 0; index < visibleBody.length; index++) {
935
+ const isThumb = index >= thumbOffset && index < thumbOffset + thumbHeight;
936
+ const scrollbar = isThumb
937
+ ? theme.fg("scrollbarThumb", this.navigationActive ? "█" : "┃")
938
+ : theme.fg("scrollbarTrack", "│");
939
+ const bodyLine = truncate(visibleBody[index] ?? "", contentWidth);
940
+ lines.push(bodyLine + " ".repeat(Math.max(0, contentWidth - visibleWidth(bodyLine))) + scrollbar);
747
941
  }
748
- const railStart = this.viewportStart > 0 ? 2 : 1;
749
- for (let index = railStart; index < lines.length; index++) {
750
- const plain = lines[index] ?? "";
751
- lines[index] = truncate(plain + (selected && plain.includes(selected.description.replace(/[\r\n]+/g, " ")) ? "┃" : "│"));
942
+
943
+ // The directional count is outside the viewport, like ScrollView's
944
+ // surrounding chrome, and therefore does not consume scrollbar track.
945
+ if (hiddenAfter > 0) {
946
+ lines.push(truncate(theme.fg("dim", `↓ ${hiddenAfter} more · +${hidden} more (${categoryCounts.length > 0 ? categoryCounts.join(", ") : ""})`)));
947
+ } else if (!showTopMore) {
948
+ lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", summary)}`));
949
+ } else {
950
+ // Keep a stable footer row when the selected viewport reaches the end.
951
+ lines.push(truncate(theme.fg("dim", "└─")));
752
952
  }
753
- if (lines.length > budget) lines.length = budget;
754
953
  }
954
+
755
955
  return lines;
756
956
  }
757
957
 
758
- /** Force an immediate widget update. */
958
+ /** Build a render-relevant snapshot without capturing mutable records. */
959
+ private renderKey(allAgents: ReturnType<AgentManager["listAgents"]>): string {
960
+ const activities = allAgents.map(a => {
961
+ const activity = this.agentActivity.get(a.id);
962
+ return activity ? {
963
+ id: a.id,
964
+ activeTools: [...activity.activeTools.entries()],
965
+ toolUses: activity.toolUses,
966
+ responseText: activity.responseText,
967
+ turnCount: activity.turnCount,
968
+ maxTurns: activity.maxTurns,
969
+ lifetimeUsage: activity.lifetimeUsage,
970
+ contextPercent: getSessionContextPercent(activity.session),
971
+ } : undefined;
972
+ });
973
+ return JSON.stringify({
974
+ frame: this.widgetFrame,
975
+ agents: allAgents.map(a => ({
976
+ id: a.id,
977
+ type: a.type,
978
+ description: a.description,
979
+ status: a.status,
980
+ toolUses: a.toolUses,
981
+ startedAt: a.startedAt,
982
+ completedAt: a.completedAt,
983
+ error: a.error,
984
+ lifetimeUsage: a.lifetimeUsage,
985
+ compactionCount: a.compactionCount,
986
+ isBackground: a.isBackground,
987
+ hasSession: a.session !== undefined,
988
+ transcriptPath: a.transcriptPath,
989
+ openableHistory: this.cachedCanOpenHistory(a),
990
+ })),
991
+ activities,
992
+ navigationActive: this.navigationActive,
993
+ selectedAgentId: this.selectedAgentId,
994
+ viewportStart: this.viewportStart,
995
+ });
996
+ }
997
+
998
+ /** Force an immediate widget update. `advanceSpinner` is reserved for the timer. */
759
999
  update(advanceSpinner = false) {
760
1000
  if (!this.uiCtx) return;
1001
+ // Refresh the manager snapshot once per lifecycle update. Arrow handling
1002
+ // only changes selection and requests a render; it never scans the manager.
1003
+ const roster = this.refreshRoster();
761
1004
  const allAgents = this.widgetAgents();
762
- if (this.navigationActive || this.selectedAgentId) {
763
- const roster = this.navigationAgents();
764
- const selectedIndex = this.selectedAgentId ? roster.findIndex(agent => agent.id === this.selectedAgentId) : -1;
765
- if (selectedIndex >= 0) {
766
- this.selectedRosterIndex = selectedIndex;
767
- } else if (roster.length > 0 && this.selectedAgentId) {
768
- this.selectedRosterIndex = Math.min(this.selectedRosterIndex, roster.length - 1);
769
- this.selectedAgentId = roster[this.selectedRosterIndex]?.id;
770
- } else if (roster.length === 0) {
771
- this.navigationActive = false;
772
- this.selectedAgentId = undefined;
773
- this.selectedRosterIndex = 0;
774
- this.viewportStart = 0;
775
- }
776
- this.updateViewport(roster);
777
- }
778
1005
 
779
1006
  // Lightweight existence checks — full categorization happens in renderWidget()
780
1007
  let runningCount = 0;
781
1008
  let queuedCount = 0;
782
- let hasFinished = false;
783
- for (const a of allAgents) {
784
- if (a.status === "running") { runningCount++; }
785
- else if (a.status === "queued") { queuedCount++; }
786
- else if (a.completedAt && this.shouldShowFinished(a.id, a.status, a.transcriptPath !== undefined)) { hasFinished = true; }
1009
+ for (const a of roster) {
1010
+ if (a.status === "running") runningCount++;
1011
+ else if (a.status === "queued") queuedCount++;
787
1012
  }
1013
+ const hasFinished = roster.some(a => a.status !== "running" && a.status !== "queued");
788
1014
  const hasActive = runningCount > 0 || queuedCount > 0;
789
1015
 
790
1016
  // Nothing to show — clear widget
@@ -798,11 +1024,12 @@ export class AgentWidget {
798
1024
  this.uiCtx.setStatus("subagents", undefined);
799
1025
  this.lastStatusText = undefined;
800
1026
  }
801
- if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; }
802
- // Clean up stale entries
803
- for (const [id] of this.finishedTurnAge) {
804
- if (!allAgents.some(a => a.id === id)) this.finishedTurnAge.delete(id);
805
- }
1027
+ this.lastRenderKey = undefined;
1028
+ this.navigationActive = false;
1029
+ this.selectedAgentId = undefined;
1030
+ this.selectedRosterIndex = 0;
1031
+ this.viewportStart = 0;
1032
+ this.syncTimer(false);
806
1033
  return;
807
1034
  }
808
1035
 
@@ -820,26 +1047,30 @@ export class AgentWidget {
820
1047
  this.lastStatusText = newStatusText;
821
1048
  }
822
1049
 
1050
+ // Spinner animation is driven only by the timer while a visible running
1051
+ // agent exists. Event-driven updates refresh changed stats without making
1052
+ // an otherwise idle widget advance.
823
1053
  if (advanceSpinner && runningCount > 0) this.widgetFrame++;
824
-
825
- const snapshot = allAgents.map(agent => {
826
- const activity = this.agentActivity.get(agent.id);
827
- return [
828
- agent.id,
829
- agent.status,
830
- agent.description,
831
- agent.toolUses,
832
- agent.completedAt ?? "",
833
- this.finishedTurnAge.get(agent.id) ?? "",
834
- getLifetimeTotal(agent.lifetimeUsage),
835
- activity?.toolUses ?? "",
836
- activity?.responseText ?? "",
837
- Math.floor((Date.now() - agent.startedAt) / 1000),
838
- ].join("\u0000");
839
- }).join("\u0001");
840
- if (this.widgetRegistered && snapshot === this.lastSnapshot && !advanceSpinner) return;
841
- this.lastSnapshot = snapshot;
842
- if (!advanceSpinner) this.widgetFrame++;
1054
+ this.syncTimer(runningCount > 0);
1055
+ const selectedIndex = this.selectedIndexOf(roster);
1056
+ if (selectedIndex >= 0) {
1057
+ this.selectedRosterIndex = selectedIndex;
1058
+ } else if (this.navigationActive) {
1059
+ if (roster.length === 0) {
1060
+ this.navigationActive = false;
1061
+ this.selectedAgentId = undefined;
1062
+ this.selectedRosterIndex = 0;
1063
+ this.viewportStart = 0;
1064
+ } else {
1065
+ // Keep the selection near the row that disappeared. Both the saved
1066
+ // logical index and viewport are clamped as the roster shrinks.
1067
+ const fallbackIndex = Math.max(0, Math.min(this.selectedRosterIndex, roster.length - 1));
1068
+ this.selectedAgentId = roster[fallbackIndex].id;
1069
+ this.selectedRosterIndex = fallbackIndex;
1070
+ this.viewportStart = Math.min(this.viewportStart, roster.length - 1);
1071
+ }
1072
+ }
1073
+ const renderKey = this.renderKey(allAgents);
843
1074
 
844
1075
  // Register widget callback once; subsequent updates use requestRender()
845
1076
  // which re-invokes render() without replacing the component (avoids layout thrashing).
@@ -852,23 +1083,20 @@ export class AgentWidget {
852
1083
  // Theme changed — force re-registration so factory captures fresh theme.
853
1084
  this.widgetRegistered = false;
854
1085
  this.tui = undefined;
1086
+ this.lastRenderKey = undefined;
855
1087
  },
856
1088
  };
857
1089
  }, { placement: "aboveEditor" });
858
1090
  this.widgetRegistered = true;
859
- } else {
860
- // Widget already registered — just request a re-render of existing components.
1091
+ } else if (renderKey !== this.lastRenderKey) {
1092
+ // Widget already registered — request a re-render only when visible state
1093
+ // changed (or the spinner timer advanced).
861
1094
  this.tui?.requestRender();
862
1095
  }
1096
+ this.lastRenderKey = renderKey;
863
1097
  }
864
1098
 
865
1099
  dispose() {
866
- this.inputCleanup?.();
867
- this.inputCleanup = undefined;
868
- this.navigationActive = false;
869
- this.selectedAgentId = undefined;
870
- this.viewportStart = 0;
871
- this.selectedRosterIndex = 0;
872
1100
  if (this.widgetInterval) {
873
1101
  clearInterval(this.widgetInterval);
874
1102
  this.widgetInterval = undefined;
@@ -877,9 +1105,22 @@ export class AgentWidget {
877
1105
  this.uiCtx.setWidget("agents", undefined);
878
1106
  this.uiCtx.setStatus("subagents", undefined);
879
1107
  }
1108
+ this.inputUnsub?.();
1109
+ this.inputUnsub = undefined;
880
1110
  this.widgetRegistered = false;
881
1111
  this.tui = undefined;
882
1112
  this.lastStatusText = undefined;
883
- this.lastSnapshot = "";
1113
+ this.lastRenderKey = undefined;
1114
+ this.navigationSuspensionDepth = 0;
1115
+ this.navigationActive = false;
1116
+ this.selectedAgentId = undefined;
1117
+ this.selectedRosterIndex = 0;
1118
+ this.viewportStart = 0;
1119
+ this.cachedAgents = [];
1120
+ this.cachedRoster = [];
1121
+ this.rosterCacheKey = undefined;
1122
+ this.rosterCacheInitialized = false;
1123
+ this.historyOpenabilityCache.clear();
1124
+ this.uiCtx = undefined;
884
1125
  }
885
1126
  }