@esso0428/pi-subagents 0.15.17 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +10 -12
  3. package/dist/agent-history-list.d.ts.map +1 -1
  4. package/dist/agent-history-list.js.map +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +20 -40
  7. package/dist/index.js.map +1 -1
  8. package/dist/output-file.js +3 -3
  9. package/dist/output-file.js.map +1 -1
  10. package/dist/settings.d.ts +1 -8
  11. package/dist/settings.d.ts.map +1 -1
  12. package/dist/settings.js +0 -5
  13. package/dist/settings.js.map +1 -1
  14. package/dist/ui/agent-widget.d.ts +66 -28
  15. package/dist/ui/agent-widget.d.ts.map +1 -1
  16. package/dist/ui/agent-widget.js +325 -134
  17. package/dist/ui/agent-widget.js.map +1 -1
  18. package/dist/ui/ccstyle/diff/ansi-utils.js +2 -2
  19. package/dist/ui/ccstyle/diff/ansi-utils.js.map +1 -1
  20. package/dist/ui/ccstyle/diff/diff-presentation.d.ts.map +1 -1
  21. package/dist/ui/ccstyle/diff/diff-presentation.js +0 -1
  22. package/dist/ui/ccstyle/diff/diff-presentation.js.map +1 -1
  23. package/dist/ui/ccstyle/diff/diff-renderer.d.ts +1 -1
  24. package/dist/ui/ccstyle/diff/diff-renderer.d.ts.map +1 -1
  25. package/dist/ui/ccstyle/diff/diff-renderer.js +15 -10
  26. package/dist/ui/ccstyle/diff/diff-renderer.js.map +1 -1
  27. package/dist/ui/ccstyle/diff/line-width-safety.d.ts.map +1 -1
  28. package/dist/ui/ccstyle/diff/line-width-safety.js +1 -2
  29. package/dist/ui/ccstyle/diff/line-width-safety.js.map +1 -1
  30. package/dist/ui/ccstyle/diff/shiki-highlight.d.ts +19 -0
  31. package/dist/ui/ccstyle/diff/shiki-highlight.d.ts.map +1 -0
  32. package/dist/ui/ccstyle/diff/shiki-highlight.js +85 -0
  33. package/dist/ui/ccstyle/diff/shiki-highlight.js.map +1 -0
  34. package/dist/ui/ccstyle/tool-renderer.d.ts +1 -1
  35. package/dist/ui/ccstyle/tool-renderer.d.ts.map +1 -1
  36. package/dist/ui/ccstyle/tool-renderer.js +1 -1
  37. package/dist/ui/ccstyle/tool-renderer.js.map +1 -1
  38. package/dist/ui/ccstyle/tool-result.d.ts +9 -1
  39. package/dist/ui/ccstyle/tool-result.d.ts.map +1 -1
  40. package/dist/ui/ccstyle/tool-result.js +137 -5
  41. package/dist/ui/ccstyle/tool-result.js.map +1 -1
  42. package/dist/ui/conversation-blocks.d.ts.map +1 -1
  43. package/dist/ui/conversation-blocks.js +14 -13
  44. package/dist/ui/conversation-blocks.js.map +1 -1
  45. package/dist/ui/conversation-role.d.ts +1 -1
  46. package/dist/ui/conversation-role.d.ts.map +1 -1
  47. package/dist/ui/conversation-search.js +4 -4
  48. package/dist/ui/conversation-search.js.map +1 -1
  49. package/dist/ui/conversation-timeline.d.ts +17 -1
  50. package/dist/ui/conversation-timeline.d.ts.map +1 -1
  51. package/dist/ui/conversation-timeline.js +116 -43
  52. package/dist/ui/conversation-timeline.js.map +1 -1
  53. package/dist/ui/conversation-viewer.d.ts +5 -1
  54. package/dist/ui/conversation-viewer.d.ts.map +1 -1
  55. package/dist/ui/conversation-viewer.js +67 -26
  56. package/dist/ui/conversation-viewer.js.map +1 -1
  57. package/dist/write-execution.d.ts.map +1 -1
  58. package/dist/write-execution.js +1 -1
  59. package/dist/write-execution.js.map +1 -1
  60. package/package.json +6 -5
  61. package/src/agent-history-list.ts +1 -1
  62. package/src/index.ts +19 -73
  63. package/src/output-file.ts +3 -3
  64. package/src/settings.ts +1 -12
  65. package/src/ui/agent-widget.ts +264 -114
  66. package/src/ui/ccstyle/diff/ansi-utils.ts +2 -2
  67. package/src/ui/ccstyle/diff/diff-presentation.ts +0 -1
  68. package/src/ui/ccstyle/diff/diff-renderer.ts +22 -21
  69. package/src/ui/ccstyle/diff/line-width-safety.ts +1 -2
  70. package/src/ui/ccstyle/diff/shiki-highlight.ts +97 -0
  71. package/src/ui/ccstyle/tool-renderer.ts +3 -3
  72. package/src/ui/ccstyle/tool-result.ts +175 -7
  73. package/src/ui/conversation-role.ts +1 -1
  74. package/src/ui/conversation-search.ts +4 -4
  75. package/src/ui/conversation-timeline.ts +7 -7
  76. package/src/ui/conversation-viewer.ts +16 -7
  77. package/src/write-execution.ts +2 -2
  78. package/dist/ui/fleet-list.d.ts +0 -122
  79. package/dist/ui/fleet-list.d.ts.map +0 -1
  80. package/dist/ui/fleet-list.js +0 -375
  81. package/dist/ui/fleet-list.js.map +0 -1
  82. package/src/ui/deferred-ui-refresh.ts +0 -72
  83. package/src/ui/fleet-list.ts +0 -585
  84. package/test/agent-history-list.test.ts +0 -121
  85. package/test/agent-manager-history.test.ts +0 -88
  86. package/test/agent-menu-history-navigation.test.ts +0 -86
  87. package/test/output-file-history.test.ts +0 -50
  88. package/test/ui/agent-widget.test.ts +0 -172
  89. package/test/ui/conversation-blocks.test.ts +0 -103
  90. package/test/ui/conversation-diff.test.ts +0 -91
  91. package/test/ui/conversation-search.test.ts +0 -55
  92. package/test/ui/conversation-viewer.test.ts +0 -438
  93. package/test/ui/deferred-ui-refresh.test.ts +0 -55
  94. package/test/ui/fleet-list.test.ts +0 -389
  95. package/test/write-execution.test.ts +0 -83
  96. package/tsconfig.json +0 -16
@@ -4,7 +4,7 @@
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 { truncateToWidth } from "@earendil-works/pi-tui";
7
+ import { Editor, isKeyRelease, Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
8
8
  import { getConfig } from "../agent-types.js";
9
9
  import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
10
10
  // ---- Constants ----
@@ -155,29 +155,35 @@ export class AgentWidget {
155
155
  manager;
156
156
  agentActivity;
157
157
  mode;
158
+ options;
158
159
  uiCtx;
159
160
  widgetFrame = 0;
160
161
  widgetInterval;
161
- /** Tracks how many turns each finished agent has survived. Key: agent ID, Value: turns since finished. */
162
- finishedTurnAge = new Map();
163
- /** How many extra turns errors/aborted agents linger (completed agents clear after 1 turn). */
164
- static ERROR_LINGER_TURNS = 2;
162
+ inputUnsub;
163
+ /** Whether arrow keys currently navigate the agent roster. */
164
+ navigationActive = false;
165
+ /** Stable identity of the selected row, so roster changes do not jump selection. */
166
+ selectedAgentId;
167
+ /** First logical row currently represented by the bounded viewport. */
168
+ viewportStart = 0;
165
169
  /** Whether the widget callback is currently registered with the TUI. */
166
170
  widgetRegistered = false;
167
171
  /** Cached TUI reference from widget factory callback, used for requestRender(). */
168
172
  tui;
169
173
  /** Last status bar text, used to avoid redundant setStatus calls. */
170
174
  lastStatusText;
175
+ /** Snapshot of the state used for the last widget registration/render request. */
176
+ lastRenderKey;
171
177
  constructor(manager, agentActivity,
172
- /**
173
- * Read live at render time. Selects which agents the widget shows — see
174
- * `WidgetMode`. Defaults to `"all"` when a caller supplies no policy; the
175
- * extension supplies one defaulting to `"background"`.
176
- */
177
- mode = () => "all") {
178
+ /** Read live at render time. Selects which agents the widget shows. */
179
+ mode = () => "all", options = {
180
+ canOpenHistory: (record) => record.session !== undefined || record.completedAt !== undefined,
181
+ onOpen: () => { },
182
+ }) {
178
183
  this.manager = manager;
179
184
  this.agentActivity = agentActivity;
180
185
  this.mode = mode;
186
+ this.options = options;
181
187
  }
182
188
  /**
183
189
  * Agents eligible for the widget, per the current `WidgetMode`:
@@ -200,44 +206,160 @@ export class AgentWidget {
200
206
  }
201
207
  /** Set the UI context (grabbed from first tool execution). */
202
208
  setUICtx(ctx) {
203
- if (ctx !== this.uiCtx) {
204
- // UICtx changed — the widget registered on the old context is gone.
205
- // Force re-registration on next update().
206
- this.uiCtx = ctx;
207
- this.widgetRegistered = false;
208
- this.tui = undefined;
209
- this.lastStatusText = undefined;
209
+ if (ctx === this.uiCtx)
210
+ return false;
211
+ // UICtx changed — the widget and input handler registered on the old
212
+ // context are gone. Re-register both on the next update().
213
+ this.inputUnsub?.();
214
+ this.inputUnsub = undefined;
215
+ this.uiCtx = ctx;
216
+ this.widgetRegistered = false;
217
+ this.tui = undefined;
218
+ this.lastStatusText = undefined;
219
+ this.lastRenderKey = undefined;
220
+ this.navigationActive = false;
221
+ this.selectedAgentId = undefined;
222
+ this.viewportStart = 0;
223
+ // Print/RPC tests and lightweight embedders may provide only the widget
224
+ // surface; real interactive contexts always implement this hook.
225
+ if (typeof ctx.onTerminalInput === "function") {
226
+ this.inputUnsub = ctx.onTerminalInput(data => this.handleKey(data));
210
227
  }
228
+ return true;
211
229
  }
212
- /**
213
- * Called on each new turn (tool_execution_start).
214
- * Ages finished agents and clears those that have lingered long enough.
215
- */
230
+ /** Request a render on the currently registered TUI without touching input. */
231
+ requestUiRefresh(force = true) {
232
+ if (!this.tui)
233
+ return false;
234
+ this.tui.requestRender(force);
235
+ return true;
236
+ }
237
+ /** Called on each new turn (tool_execution_start). */
216
238
  onTurnStart() {
217
- // Age all finished agents
218
- for (const [id, age] of this.finishedTurnAge) {
219
- this.finishedTurnAge.set(id, age + 1);
220
- }
221
- // Trigger a widget refresh (will filter out expired agents)
222
239
  this.update();
223
240
  }
224
- /** Ensure the widget update timer is running. */
241
+ /** Keep the spinner/elapsed-time timer alive only while a visible agent runs. */
225
242
  ensureTimer() {
243
+ if (!this.uiCtx || !this.widgetAgents().some(a => a.status === "running"))
244
+ return;
226
245
  if (!this.widgetInterval) {
227
- this.widgetInterval = setInterval(() => this.update(), 80);
246
+ this.widgetInterval = setInterval(() => this.update(true), 250);
228
247
  }
229
248
  }
230
- /** Check if a finished agent should still be shown in the widget. */
231
- shouldShowFinished(agentId, status) {
232
- const age = this.finishedTurnAge.get(agentId) ?? 0;
233
- const maxAge = ERROR_STATUSES.has(status) ? AgentWidget.ERROR_LINGER_TURNS : 1;
234
- return age < maxAge;
249
+ syncTimer(shouldRun) {
250
+ if (shouldRun) {
251
+ this.ensureTimer();
252
+ }
253
+ else if (this.widgetInterval) {
254
+ clearInterval(this.widgetInterval);
255
+ this.widgetInterval = undefined;
256
+ }
257
+ }
258
+ /**
259
+ * Retained for the lifecycle call sites. Terminal visibility is determined
260
+ * by the manager record and the history capability, not a one-turn timer.
261
+ */
262
+ markFinished(_agentId) { }
263
+ /**
264
+ * Records represented by selectable rows in the above-editor widget.
265
+ *
266
+ * Keep this order as the single source of truth for both rendering and key
267
+ * navigation. `listAgents()` is newest-first; active rows come first so the
268
+ * panel exposes currently useful work before terminal history.
269
+ */
270
+ roster() {
271
+ const agents = this.widgetAgents();
272
+ const finished = agents.filter(record => record.status !== "running" && record.status !== "queued"
273
+ && record.completedAt !== undefined
274
+ && this.options.canOpenHistory(record));
275
+ const running = agents.filter(record => record.status === "running");
276
+ const queued = agents.filter(record => record.status === "queued");
277
+ return [...running, ...queued, ...finished];
235
278
  }
236
- /** Record an agent as finished (call when agent completes). */
237
- markFinished(agentId) {
238
- if (!this.finishedTurnAge.has(agentId)) {
239
- this.finishedTurnAge.set(agentId, 0);
279
+ /** True when pi's prompt editor owns the keyboard. */
280
+ editorHasFocus() {
281
+ const focused = this.tui?.focusedComponent;
282
+ return focused == null || focused instanceof Editor;
283
+ }
284
+ selectedIndexOf(records) {
285
+ if (!this.selectedAgentId)
286
+ return -1;
287
+ return records.findIndex(record => record.id === this.selectedAgentId);
288
+ }
289
+ deactivate() {
290
+ this.navigationActive = false;
291
+ this.selectedAgentId = undefined;
292
+ this.viewportStart = 0;
293
+ this.update();
294
+ }
295
+ /** Move the selected row, activating only from an empty focused editor. */
296
+ moveSelection(direction) {
297
+ const records = this.roster();
298
+ const ui = this.uiCtx;
299
+ if (records.length === 0 || !ui)
300
+ return false;
301
+ if (!this.navigationActive) {
302
+ if (direction !== 1 || !this.editorHasFocus() || (ui.getEditorText?.() ?? "") !== "")
303
+ return false;
304
+ this.navigationActive = true;
305
+ this.selectedAgentId = records[0].id;
306
+ this.viewportStart = 0;
307
+ this.update();
308
+ return true;
309
+ }
310
+ const currentIndex = Math.max(0, this.selectedIndexOf(records));
311
+ if (direction === -1 && currentIndex === 0) {
312
+ this.deactivate();
313
+ return true;
240
314
  }
315
+ const nextIndex = Math.max(0, Math.min(records.length - 1, currentIndex + direction));
316
+ this.selectedAgentId = records[nextIndex].id;
317
+ this.update();
318
+ return true;
319
+ }
320
+ openSelected() {
321
+ const record = this.roster().find(candidate => candidate.id === this.selectedAgentId);
322
+ this.deactivate();
323
+ if (!record)
324
+ return;
325
+ const mode = record.status === "running" || record.status === "queued" ? "live" : "history";
326
+ void this.options.onOpen(record, mode);
327
+ }
328
+ /** Handle terminal input before it reaches the focused prompt editor. */
329
+ handleKey(data) {
330
+ if (!this.uiCtx || isKeyRelease(data))
331
+ return undefined;
332
+ if (!this.editorHasFocus()) {
333
+ if (this.navigationActive)
334
+ this.deactivate();
335
+ return undefined;
336
+ }
337
+ if (!this.navigationActive) {
338
+ if (!matchesKey(data, "down") || (this.uiCtx.getEditorText?.() ?? "") !== "" || this.roster().length === 0) {
339
+ return undefined;
340
+ }
341
+ this.navigationActive = true;
342
+ this.selectedAgentId = this.roster()[0]?.id;
343
+ this.viewportStart = 0;
344
+ this.update();
345
+ return { consume: true };
346
+ }
347
+ if (matchesKey(data, "escape")) {
348
+ this.deactivate();
349
+ return { consume: true };
350
+ }
351
+ if (matchesKey(data, "up"))
352
+ return this.moveSelection(-1) ? { consume: true } : undefined;
353
+ if (matchesKey(data, "down"))
354
+ return this.moveSelection(1) ? { consume: true } : undefined;
355
+ if (matchesKey(data, Key.enter)) {
356
+ this.openSelected();
357
+ return { consume: true };
358
+ }
359
+ // Only ↑/↓ navigate. Other keys, including j/k/←/→, flow to the editor
360
+ // and leave navigation mode.
361
+ this.deactivate();
362
+ return undefined;
241
363
  }
242
364
  /** Render a finished agent line. */
243
365
  renderFinishedLine(a, theme) {
@@ -286,8 +408,9 @@ export class AgentWidget {
286
408
  const allAgents = this.widgetAgents();
287
409
  const running = allAgents.filter(a => a.status === "running");
288
410
  const queued = allAgents.filter(a => a.status === "queued");
289
- const finished = allAgents.filter(a => a.status !== "running" && a.status !== "queued" && a.completedAt
290
- && this.shouldShowFinished(a.id, a.status));
411
+ const finished = allAgents.filter(a => a.status !== "running" && a.status !== "queued" && a.completedAt !== undefined
412
+ && this.options.canOpenHistory(a));
413
+ const selectedId = this.navigationActive ? this.selectedAgentId : undefined;
291
414
  const hasActive = running.length > 0 || queued.length > 0;
292
415
  const hasFinished = finished.length > 0;
293
416
  // Nothing to show — return empty (widget will be unregistered by update())
@@ -305,7 +428,11 @@ export class AgentWidget {
305
428
  // Each running agent = 2 lines (header + activity), finished = 1 line, queued = 1 line.
306
429
  const finishedLines = [];
307
430
  for (const a of finished) {
308
- finishedLines.push(truncate(theme.fg("dim", "├─") + " " + this.renderFinishedLine(a, theme)));
431
+ const marker = a.id === selectedId ? theme.fg("accent", "●") : theme.fg("dim", "○");
432
+ finishedLines.push({
433
+ record: a,
434
+ lines: [truncate(theme.fg("dim", "├─") + ` ${marker} ` + this.renderFinishedLine(a, theme))],
435
+ });
309
436
  }
310
437
  const runningLines = []; // each entry is [header, activity]
311
438
  for (const a of running) {
@@ -328,115 +455,157 @@ export class AgentWidget {
328
455
  parts.push(elapsed);
329
456
  const statsText = parts.join(" · ");
330
457
  const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…";
331
- runningLines.push([
332
- truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${theme.bold(name)}${modeTag} ${theme.fg("muted", truncateLine(a.description))} ${theme.fg("dim", "·")} ${fgPreservingNestedStyles(theme, "dim", statsText)}`),
333
- truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)),
334
- ]);
335
- }
336
- const queuedLine = queued.length > 0
337
- ? truncate(theme.fg("dim", "├─") + ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`)
338
- : undefined;
458
+ const marker = a.id === selectedId ? theme.fg("accent", "●") : theme.fg("dim", "○");
459
+ runningLines.push({
460
+ record: a,
461
+ lines: [
462
+ truncate(theme.fg("dim", "├─") + ` ${marker} ${theme.fg("accent", frame)} ${theme.bold(name)}${modeTag} ${theme.fg("muted", truncateLine(a.description))} ${theme.fg("dim", "·")} ${fgPreservingNestedStyles(theme, "dim", statsText)}`),
463
+ truncate(theme.fg("dim", "│ ") + ` ${theme.fg("dim", `⎿ ${activity}`)}`),
464
+ ],
465
+ });
466
+ }
467
+ const queuedLines = queued.map(a => {
468
+ const marker = a.id === selectedId ? theme.fg("accent", "●") : theme.fg("dim", "○");
469
+ return {
470
+ record: a,
471
+ lines: [truncate(theme.fg("dim", "├─") + ` ${marker} ${theme.fg("muted", "◦")} ${theme.fg("dim", `${getDisplayName(a.type)} ${truncateLine(a.description)} · queued`)}`)],
472
+ };
473
+ });
339
474
  // Assemble with a responsive cap (heading + overflow indicator = 2
340
475
  // reserved lines when content exceeds the available body budget).
341
476
  const maxBody = maxLines - 1; // heading takes 1 line
342
- const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
343
- const lines = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"))];
477
+ const rows = [...runningLines, ...queuedLines, ...finishedLines];
478
+ const totalBody = rows.reduce((total, row) => total + row.lines.length, 0);
479
+ const heading = "Agents ↑↓ select · enter view · esc back";
480
+ const lines = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, heading))];
344
481
  if (maxLines === 1) {
345
482
  // There is room only for the heading; do not consume the editor/input row.
346
483
  return lines;
347
484
  }
348
485
  if (totalBody <= maxBody) {
349
- // Everything fits — add all lines and fix up connectors for the last item.
350
- lines.push(...finishedLines);
351
- for (const pair of runningLines)
352
- lines.push(...pair);
353
- if (queuedLine)
354
- lines.push(queuedLine);
355
- // Fix last connector: swap ├─ → └─ and │ → space for activity lines.
356
- if (lines.length > 1) {
357
- const last = lines.length - 1;
358
- lines[last] = lines[last].replace("├─", "└─");
359
- // If last item is a running agent activity line, fix indent of that line
360
- // and fix the header line above it.
361
- if (runningLines.length > 0 && !queuedLine) {
362
- // The last two lines are the last running agent's header + activity.
363
- if (last >= 2) {
364
- lines[last - 1] = lines[last - 1].replace("├─", "└─");
365
- lines[last] = lines[last].replace("│ ", " ");
366
- }
486
+ this.viewportStart = 0;
487
+ for (const row of rows)
488
+ lines.push(...row.lines);
489
+ if (rows.length > 0) {
490
+ const lastRow = rows[rows.length - 1];
491
+ const lastStart = lines.length - lastRow.lines.length;
492
+ lines[lastStart] = lines[lastStart].replace("├─", "└─");
493
+ if (lastRow.lines.length === 2) {
494
+ lines[lastStart + 1] = lines[lastStart + 1].replace("│ ", " ");
367
495
  }
368
496
  }
369
497
  }
370
498
  else {
371
- // Overflow — prioritize: running > queued > finished.
372
- // Reserve 1 line for overflow indicator.
373
- let budget = maxBody - 1;
374
- let hiddenRunning = 0;
375
- let hiddenQueued = 0;
376
- let hiddenFinished = 0;
377
- // 1. Running agents (2 lines each)
378
- for (const pair of runningLines) {
379
- if (budget >= 2) {
380
- lines.push(...pair);
381
- budget -= 2;
382
- }
383
- else {
384
- hiddenRunning++;
499
+ // Reserve one line for a directional overflow summary. The viewport is
500
+ // a contiguous slice in roster order, so the same slice is navigable and
501
+ // renderable even when the selected row is currently hidden.
502
+ const contentBudget = Math.max(0, maxBody - 1);
503
+ const heightAt = (index) => rows[index]?.lines.length ?? 0;
504
+ const endFor = (start) => {
505
+ let used = 0;
506
+ let end = start;
507
+ while (end < rows.length && used + heightAt(end) <= contentBudget) {
508
+ used += heightAt(end++);
385
509
  }
510
+ return end;
511
+ };
512
+ let start = Math.max(0, Math.min(this.viewportStart, Math.max(0, rows.length - 1)));
513
+ const selectedIndex = this.navigationActive && selectedId
514
+ ? rows.findIndex(row => row.record.id === selectedId)
515
+ : -1;
516
+ if (selectedIndex >= 0) {
517
+ if (selectedIndex < start)
518
+ start = selectedIndex;
519
+ if (selectedIndex >= endFor(start))
520
+ start = selectedIndex;
386
521
  }
387
- // 2. Queued line
388
- if (queuedLine) {
389
- if (budget >= 1) {
390
- lines.push(queuedLine);
391
- budget--;
392
- }
393
- else {
394
- hiddenQueued = 1;
395
- }
522
+ this.viewportStart = start;
523
+ let used = 0;
524
+ let end = start;
525
+ while (end < rows.length && used + heightAt(end) <= contentBudget) {
526
+ lines.push(...rows[end].lines);
527
+ used += heightAt(end);
528
+ end++;
396
529
  }
397
- // 3. Finished agents
398
- for (const fl of finishedLines) {
399
- if (budget >= 1) {
400
- lines.push(fl);
401
- budget--;
402
- }
403
- else {
404
- hiddenFinished++;
405
- }
530
+ // A selected two-line row must remain addressable even if only one body
531
+ // line is available. Showing its header is preferable to hiding it.
532
+ if (end === start && rows[start] && contentBudget > 0) {
533
+ lines.push(rows[start].lines[0]);
534
+ end = start + 1;
406
535
  }
407
- // Overflow summary
408
- const overflowParts = [];
409
- if (hiddenRunning > 0)
410
- overflowParts.push(`${hiddenRunning} running`);
411
- if (hiddenQueued > 0)
412
- overflowParts.push(`${hiddenQueued} queued`);
413
- if (hiddenFinished > 0)
414
- overflowParts.push(`${hiddenFinished} finished`);
415
- const overflowText = overflowParts.join(", ");
416
- lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenQueued + hiddenFinished} more (${overflowText})`)}`));
536
+ const hiddenBefore = start;
537
+ const hiddenAfter = Math.max(0, rows.length - end);
538
+ const hidden = hiddenBefore + hiddenAfter;
539
+ const hiddenRows = rows.filter((_row, index) => index < start || index >= end);
540
+ const categoryCounts = [
541
+ ["running", hiddenRows.filter(row => row.record.status === "running").length],
542
+ ["queued", hiddenRows.filter(row => row.record.status === "queued").length],
543
+ ["finished", hiddenRows.filter(row => row.record.status !== "running" && row.record.status !== "queued").length],
544
+ ].filter(([, count]) => count > 0).map(([label, count]) => `${count} ${label}`);
545
+ const direction = [
546
+ ...(hiddenBefore > 0 ? [`↑ ${hiddenBefore} more`] : []),
547
+ ...(hiddenAfter > 0 ? [`↓ ${hiddenAfter} more`] : []),
548
+ ].join(" · ");
549
+ const summary = `+${hidden} more (${direction}${categoryCounts.length > 0 ? `; ${categoryCounts.join(", ")}` : ""})`;
550
+ lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", summary)}`));
417
551
  }
418
552
  return lines;
419
553
  }
420
- /** Force an immediate widget update. */
421
- update() {
554
+ /** Build a render-relevant snapshot without capturing mutable records. */
555
+ renderKey(allAgents) {
556
+ const activities = allAgents.map(a => {
557
+ const activity = this.agentActivity.get(a.id);
558
+ return activity ? {
559
+ id: a.id,
560
+ activeTools: [...activity.activeTools.entries()],
561
+ toolUses: activity.toolUses,
562
+ responseText: activity.responseText,
563
+ turnCount: activity.turnCount,
564
+ maxTurns: activity.maxTurns,
565
+ lifetimeUsage: activity.lifetimeUsage,
566
+ contextPercent: getSessionContextPercent(activity.session),
567
+ } : undefined;
568
+ });
569
+ return JSON.stringify({
570
+ frame: this.widgetFrame,
571
+ agents: allAgents.map(a => ({
572
+ id: a.id,
573
+ type: a.type,
574
+ description: a.description,
575
+ status: a.status,
576
+ toolUses: a.toolUses,
577
+ startedAt: a.startedAt,
578
+ completedAt: a.completedAt,
579
+ error: a.error,
580
+ lifetimeUsage: a.lifetimeUsage,
581
+ compactionCount: a.compactionCount,
582
+ isBackground: a.isBackground,
583
+ hasSession: a.session !== undefined,
584
+ transcriptPath: a.transcriptPath,
585
+ openableHistory: this.options.canOpenHistory(a),
586
+ })),
587
+ activities,
588
+ navigationActive: this.navigationActive,
589
+ selectedAgentId: this.selectedAgentId,
590
+ viewportStart: this.viewportStart,
591
+ });
592
+ }
593
+ /** Force an immediate widget update. `advanceSpinner` is reserved for the timer. */
594
+ update(advanceSpinner = false) {
422
595
  if (!this.uiCtx)
423
596
  return;
424
597
  const allAgents = this.widgetAgents();
598
+ const roster = this.roster();
425
599
  // Lightweight existence checks — full categorization happens in renderWidget()
426
600
  let runningCount = 0;
427
601
  let queuedCount = 0;
428
- let hasFinished = false;
429
- for (const a of allAgents) {
430
- if (a.status === "running") {
602
+ for (const a of roster) {
603
+ if (a.status === "running")
431
604
  runningCount++;
432
- }
433
- else if (a.status === "queued") {
605
+ else if (a.status === "queued")
434
606
  queuedCount++;
435
- }
436
- else if (a.completedAt && this.shouldShowFinished(a.id, a.status)) {
437
- hasFinished = true;
438
- }
439
607
  }
608
+ const hasFinished = roster.some(a => a.status !== "running" && a.status !== "queued");
440
609
  const hasActive = runningCount > 0 || queuedCount > 0;
441
610
  // Nothing to show — clear widget
442
611
  if (!hasActive && !hasFinished) {
@@ -449,15 +618,11 @@ export class AgentWidget {
449
618
  this.uiCtx.setStatus("subagents", undefined);
450
619
  this.lastStatusText = undefined;
451
620
  }
452
- if (this.widgetInterval) {
453
- clearInterval(this.widgetInterval);
454
- this.widgetInterval = undefined;
455
- }
456
- // Clean up stale entries
457
- for (const [id] of this.finishedTurnAge) {
458
- if (!allAgents.some(a => a.id === id))
459
- this.finishedTurnAge.delete(id);
460
- }
621
+ this.lastRenderKey = undefined;
622
+ this.navigationActive = false;
623
+ this.selectedAgentId = undefined;
624
+ this.viewportStart = 0;
625
+ this.syncTimer(false);
461
626
  return;
462
627
  }
463
628
  // Status bar — only call setStatus when the text actually changes
@@ -475,7 +640,23 @@ export class AgentWidget {
475
640
  this.uiCtx.setStatus("subagents", newStatusText);
476
641
  this.lastStatusText = newStatusText;
477
642
  }
478
- this.widgetFrame++;
643
+ // Spinner animation is driven only by the timer while a visible running
644
+ // agent exists. Event-driven updates refresh changed stats without making
645
+ // an otherwise idle widget advance.
646
+ if (advanceSpinner && runningCount > 0)
647
+ this.widgetFrame++;
648
+ this.syncTimer(runningCount > 0);
649
+ if (this.navigationActive) {
650
+ if (roster.length === 0) {
651
+ this.navigationActive = false;
652
+ this.selectedAgentId = undefined;
653
+ this.viewportStart = 0;
654
+ }
655
+ else if (this.selectedIndexOf(roster) < 0) {
656
+ this.selectedAgentId = roster[Math.min(this.viewportStart, roster.length - 1)].id;
657
+ }
658
+ }
659
+ const renderKey = this.renderKey(allAgents);
479
660
  // Register widget callback once; subsequent updates use requestRender()
480
661
  // which re-invokes render() without replacing the component (avoids layout thrashing).
481
662
  if (!this.widgetRegistered) {
@@ -487,15 +668,18 @@ export class AgentWidget {
487
668
  // Theme changed — force re-registration so factory captures fresh theme.
488
669
  this.widgetRegistered = false;
489
670
  this.tui = undefined;
671
+ this.lastRenderKey = undefined;
490
672
  },
491
673
  };
492
674
  }, { placement: "aboveEditor" });
493
675
  this.widgetRegistered = true;
494
676
  }
495
- else {
496
- // Widget already registered — just request a re-render of existing components.
677
+ else if (renderKey !== this.lastRenderKey) {
678
+ // Widget already registered — request a re-render only when visible state
679
+ // changed (or the spinner timer advanced).
497
680
  this.tui?.requestRender();
498
681
  }
682
+ this.lastRenderKey = renderKey;
499
683
  }
500
684
  dispose() {
501
685
  if (this.widgetInterval) {
@@ -506,9 +690,16 @@ export class AgentWidget {
506
690
  this.uiCtx.setWidget("agents", undefined);
507
691
  this.uiCtx.setStatus("subagents", undefined);
508
692
  }
693
+ this.inputUnsub?.();
694
+ this.inputUnsub = undefined;
509
695
  this.widgetRegistered = false;
510
696
  this.tui = undefined;
511
697
  this.lastStatusText = undefined;
698
+ this.lastRenderKey = undefined;
699
+ this.navigationActive = false;
700
+ this.selectedAgentId = undefined;
701
+ this.viewportStart = 0;
702
+ this.uiCtx = undefined;
512
703
  }
513
704
  }
514
705
  //# sourceMappingURL=agent-widget.js.map