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