@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
@@ -5,10 +5,10 @@
5
5
  * Uses the callback form of setWidget for themed rendering.
6
6
  */
7
7
 
8
- import { truncateToWidth } from "@earendil-works/pi-tui";
8
+ import { Editor, isKeyRelease, Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
9
9
  import type { AgentManager } from "../agent-manager.js";
10
10
  import { getConfig } from "../agent-types.js";
11
- import type { AgentInvocation, SubagentType, WidgetMode } from "../types.js";
11
+ import type { AgentInvocation, AgentRecord, SubagentType, WidgetMode } from "../types.js";
12
12
  import { getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, type SessionLike } from "../usage.js";
13
13
 
14
14
  // ---- Constants ----
@@ -52,6 +52,19 @@ export type Theme = {
52
52
  bold(text: string): string;
53
53
  };
54
54
 
55
+ export type AgentWidgetOpenMode = "live" | "history";
56
+ export type AgentWidgetOpenCallback = (record: AgentRecord, mode: AgentWidgetOpenMode) => void | Promise<void>;
57
+ export type AgentWidgetOptions = {
58
+ canOpenHistory: (record: AgentRecord) => boolean;
59
+ onOpen: AgentWidgetOpenCallback;
60
+ };
61
+ /** @deprecated Use AgentWidgetOpenMode. */
62
+ export type AgentOpenMode = AgentWidgetOpenMode;
63
+ /** @deprecated Use AgentWidgetOpenCallback. */
64
+ export type AgentOpenCallback = AgentWidgetOpenCallback;
65
+ /** @deprecated Use AgentWidgetOptions.canOpenHistory. */
66
+ export type AgentHistoryCapability = AgentWidgetOptions["canOpenHistory"];
67
+
55
68
  export type UICtx = {
56
69
  setStatus(key: string, text: string | undefined): void;
57
70
  setWidget(
@@ -59,6 +72,8 @@ export type UICtx = {
59
72
  content: undefined | ((tui: any, theme: Theme) => { render(): string[]; invalidate(): void }),
60
73
  options?: { placement?: "aboveEditor" | "belowEditor" },
61
74
  ): void;
75
+ onTerminalInput(handler: (data: string) => { consume?: boolean; data?: string } | undefined): () => void;
76
+ getEditorText(): string;
62
77
  };
63
78
 
64
79
  /** Per-agent live activity state. */
@@ -229,10 +244,13 @@ export class AgentWidget {
229
244
  private uiCtx: UICtx | undefined;
230
245
  private widgetFrame = 0;
231
246
  private widgetInterval: ReturnType<typeof setInterval> | undefined;
232
- /** Tracks how many turns each finished agent has survived. Key: agent ID, Value: turns since finished. */
233
- private finishedTurnAge = new Map<string, number>();
234
- /** How many extra turns errors/aborted agents linger (completed agents clear after 1 turn). */
235
- private static readonly ERROR_LINGER_TURNS = 2;
247
+ private inputUnsub: (() => void) | undefined;
248
+ /** Whether arrow keys currently navigate the agent roster. */
249
+ private navigationActive = false;
250
+ /** Stable identity of the selected row, so roster changes do not jump selection. */
251
+ private selectedAgentId: string | undefined;
252
+ /** First logical row currently represented by the bounded viewport. */
253
+ private viewportStart = 0;
236
254
 
237
255
  /** Whether the widget callback is currently registered with the TUI. */
238
256
  private widgetRegistered = false;
@@ -246,12 +264,12 @@ export class AgentWidget {
246
264
  constructor(
247
265
  private manager: AgentManager,
248
266
  private agentActivity: Map<string, AgentActivity>,
249
- /**
250
- * Read live at render time. Selects which agents the widget shows — see
251
- * `WidgetMode`. Defaults to `"all"` when a caller supplies no policy; the
252
- * extension supplies one defaulting to `"background"`.
253
- */
267
+ /** Read live at render time. Selects which agents the widget shows. */
254
268
  private mode: () => WidgetMode = () => "all",
269
+ private options: AgentWidgetOptions = {
270
+ canOpenHistory: (record) => record.session !== undefined || record.completedAt !== undefined,
271
+ onOpen: () => {},
272
+ },
255
273
  ) {}
256
274
 
257
275
  /**
@@ -278,13 +296,23 @@ export class AgentWidget {
278
296
  setUICtx(ctx: UICtx): boolean {
279
297
  if (ctx === this.uiCtx) return false;
280
298
 
281
- // UICtx changed — the widget registered on the old context is gone.
282
- // Force re-registration on next update().
299
+ // UICtx changed — the widget and input handler registered on the old
300
+ // context are gone. Re-register both on the next update().
301
+ this.inputUnsub?.();
302
+ this.inputUnsub = undefined;
283
303
  this.uiCtx = ctx;
284
304
  this.widgetRegistered = false;
285
305
  this.tui = undefined;
286
306
  this.lastStatusText = undefined;
287
307
  this.lastRenderKey = undefined;
308
+ this.navigationActive = false;
309
+ this.selectedAgentId = undefined;
310
+ this.viewportStart = 0;
311
+ // Print/RPC tests and lightweight embedders may provide only the widget
312
+ // surface; real interactive contexts always implement this hook.
313
+ if (typeof ctx.onTerminalInput === "function") {
314
+ this.inputUnsub = ctx.onTerminalInput(data => this.handleKey(data));
315
+ }
288
316
  return true;
289
317
  }
290
318
 
@@ -295,16 +323,8 @@ export class AgentWidget {
295
323
  return true;
296
324
  }
297
325
 
298
- /**
299
- * Called on each new turn (tool_execution_start).
300
- * Ages finished agents and clears those that have lingered long enough.
301
- */
326
+ /** Called on each new turn (tool_execution_start). */
302
327
  onTurnStart() {
303
- // Age all finished agents
304
- for (const [id, age] of this.finishedTurnAge) {
305
- this.finishedTurnAge.set(id, age + 1);
306
- }
307
- // Trigger a widget refresh (will filter out expired agents)
308
328
  this.update();
309
329
  }
310
330
 
@@ -312,7 +332,7 @@ export class AgentWidget {
312
332
  ensureTimer() {
313
333
  if (!this.uiCtx || !this.widgetAgents().some(a => a.status === "running")) return;
314
334
  if (!this.widgetInterval) {
315
- this.widgetInterval = setInterval(() => this.update(true), 80);
335
+ this.widgetInterval = setInterval(() => this.update(true), 250);
316
336
  }
317
337
  }
318
338
 
@@ -325,18 +345,117 @@ export class AgentWidget {
325
345
  }
326
346
  }
327
347
 
328
- /** Check if a finished agent should still be shown in the widget. */
329
- private shouldShowFinished(agentId: string, status: string): boolean {
330
- const age = this.finishedTurnAge.get(agentId) ?? 0;
331
- const maxAge = ERROR_STATUSES.has(status) ? AgentWidget.ERROR_LINGER_TURNS : 1;
332
- return age < maxAge;
348
+ /**
349
+ * Retained for the lifecycle call sites. Terminal visibility is determined
350
+ * by the manager record and the history capability, not a one-turn timer.
351
+ */
352
+ markFinished(_agentId: string) {}
353
+
354
+ /**
355
+ * Records represented by selectable rows in the above-editor widget.
356
+ *
357
+ * Keep this order as the single source of truth for both rendering and key
358
+ * navigation. `listAgents()` is newest-first; active rows come first so the
359
+ * panel exposes currently useful work before terminal history.
360
+ */
361
+ private roster(): AgentRecord[] {
362
+ const agents = this.widgetAgents();
363
+ const finished = agents.filter(record =>
364
+ record.status !== "running" && record.status !== "queued"
365
+ && record.completedAt !== undefined
366
+ && this.options.canOpenHistory(record),
367
+ );
368
+ const running = agents.filter(record => record.status === "running");
369
+ const queued = agents.filter(record => record.status === "queued");
370
+ return [...running, ...queued, ...finished];
371
+ }
372
+
373
+ /** True when pi's prompt editor owns the keyboard. */
374
+ private editorHasFocus(): boolean {
375
+ const focused = (this.tui as { focusedComponent?: unknown } | undefined)?.focusedComponent;
376
+ return focused == null || focused instanceof Editor;
333
377
  }
334
378
 
335
- /** Record an agent as finished (call when agent completes). */
336
- markFinished(agentId: string) {
337
- if (!this.finishedTurnAge.has(agentId)) {
338
- this.finishedTurnAge.set(agentId, 0);
379
+ private selectedIndexOf(records: readonly AgentRecord[]): number {
380
+ if (!this.selectedAgentId) return -1;
381
+ return records.findIndex(record => record.id === this.selectedAgentId);
382
+ }
383
+
384
+ private deactivate(): void {
385
+ this.navigationActive = false;
386
+ this.selectedAgentId = undefined;
387
+ this.viewportStart = 0;
388
+ this.update();
389
+ }
390
+
391
+ /** Move the selected row, activating only from an empty focused editor. */
392
+ private moveSelection(direction: -1 | 1): boolean {
393
+ const records = this.roster();
394
+ const ui = this.uiCtx;
395
+ if (records.length === 0 || !ui) return false;
396
+
397
+ if (!this.navigationActive) {
398
+ if (direction !== 1 || !this.editorHasFocus() || (ui.getEditorText?.() ?? "") !== "") return false;
399
+ this.navigationActive = true;
400
+ this.selectedAgentId = records[0].id;
401
+ this.viewportStart = 0;
402
+ this.update();
403
+ return true;
339
404
  }
405
+
406
+ const currentIndex = Math.max(0, this.selectedIndexOf(records));
407
+ if (direction === -1 && currentIndex === 0) {
408
+ this.deactivate();
409
+ return true;
410
+ }
411
+ const nextIndex = Math.max(0, Math.min(records.length - 1, currentIndex + direction));
412
+ this.selectedAgentId = records[nextIndex].id;
413
+ this.update();
414
+ return true;
415
+ }
416
+
417
+ private openSelected(): void {
418
+ const record = this.roster().find(candidate => candidate.id === this.selectedAgentId);
419
+ this.deactivate();
420
+ if (!record) return;
421
+ const mode: AgentWidgetOpenMode = record.status === "running" || record.status === "queued" ? "live" : "history";
422
+ void this.options.onOpen(record, mode);
423
+ }
424
+
425
+ /** Handle terminal input before it reaches the focused prompt editor. */
426
+ handleKey(data: string): { consume?: boolean; data?: string } | undefined {
427
+ if (!this.uiCtx || isKeyRelease(data)) return undefined;
428
+ if (!this.editorHasFocus()) {
429
+ if (this.navigationActive) this.deactivate();
430
+ return undefined;
431
+ }
432
+
433
+ if (!this.navigationActive) {
434
+ if (!matchesKey(data, "down") || (this.uiCtx.getEditorText?.() ?? "") !== "" || this.roster().length === 0) {
435
+ return undefined;
436
+ }
437
+ this.navigationActive = true;
438
+ this.selectedAgentId = this.roster()[0]?.id;
439
+ this.viewportStart = 0;
440
+ this.update();
441
+ return { consume: true };
442
+ }
443
+
444
+ if (matchesKey(data, "escape")) {
445
+ this.deactivate();
446
+ return { consume: true };
447
+ }
448
+ if (matchesKey(data, "up")) return this.moveSelection(-1) ? { consume: true } : undefined;
449
+ if (matchesKey(data, "down")) return this.moveSelection(1) ? { consume: true } : undefined;
450
+ if (matchesKey(data, Key.enter)) {
451
+ this.openSelected();
452
+ return { consume: true };
453
+ }
454
+
455
+ // Only ↑/↓ navigate. Other keys, including j/k/←/→, flow to the editor
456
+ // and leave navigation mode.
457
+ this.deactivate();
458
+ return undefined;
340
459
  }
341
460
 
342
461
  /** Render a finished agent line. */
@@ -385,10 +504,11 @@ export class AgentWidget {
385
504
  const running = allAgents.filter(a => a.status === "running");
386
505
  const queued = allAgents.filter(a => a.status === "queued");
387
506
  const finished = allAgents.filter(a =>
388
- a.status !== "running" && a.status !== "queued" && a.completedAt
389
- && this.shouldShowFinished(a.id, a.status),
507
+ a.status !== "running" && a.status !== "queued" && a.completedAt !== undefined
508
+ && this.options.canOpenHistory(a),
390
509
  );
391
510
 
511
+ const selectedId = this.navigationActive ? this.selectedAgentId : undefined;
392
512
  const hasActive = running.length > 0 || queued.length > 0;
393
513
  const hasFinished = finished.length > 0;
394
514
 
@@ -406,12 +526,16 @@ export class AgentWidget {
406
526
  // Build sections separately for overflow-aware assembly.
407
527
  // Each running agent = 2 lines (header + activity), finished = 1 line, queued = 1 line.
408
528
 
409
- const finishedLines: string[] = [];
529
+ const finishedLines: { record: AgentRecord; lines: string[] }[] = [];
410
530
  for (const a of finished) {
411
- finishedLines.push(truncate(theme.fg("dim", "├─") + " " + this.renderFinishedLine(a, theme)));
531
+ const marker = a.id === selectedId ? theme.fg("accent", "●") : theme.fg("dim", "○");
532
+ finishedLines.push({
533
+ record: a,
534
+ lines: [truncate(theme.fg("dim", "├─") + ` ${marker} ` + this.renderFinishedLine(a, theme))],
535
+ });
412
536
  }
413
537
 
414
- const runningLines: string[][] = []; // each entry is [header, activity]
538
+ const runningLines: { record: AgentRecord; lines: string[] }[] = []; // each entry is [header, activity]
415
539
  for (const a of running) {
416
540
  const name = getDisplayName(a.type);
417
541
  const modeLabel = getPromptModeLabel(a.type);
@@ -433,22 +557,32 @@ export class AgentWidget {
433
557
 
434
558
  const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…";
435
559
 
436
- runningLines.push([
437
- 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)}`),
438
- truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)),
439
- ]);
560
+ const marker = a.id === selectedId ? theme.fg("accent", "●") : theme.fg("dim", "○");
561
+ runningLines.push({
562
+ record: a,
563
+ lines: [
564
+ 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)}`),
565
+ truncate(theme.fg("dim", "│ ") + ` ${theme.fg("dim", `⎿ ${activity}`)}`),
566
+ ],
567
+ });
440
568
  }
441
569
 
442
- const queuedLine = queued.length > 0
443
- ? truncate(theme.fg("dim", "├─") + ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`)
444
- : undefined;
570
+ const queuedLines: { record: AgentRecord; lines: string[] }[] = queued.map(a => {
571
+ const marker = a.id === selectedId ? theme.fg("accent", "●") : theme.fg("dim", "○");
572
+ return {
573
+ record: a,
574
+ lines: [truncate(theme.fg("dim", "├─") + ` ${marker} ${theme.fg("muted", "◦")} ${theme.fg("dim", `${getDisplayName(a.type)} ${truncateLine(a.description)} · queued`)}`)],
575
+ };
576
+ });
445
577
 
446
578
  // Assemble with a responsive cap (heading + overflow indicator = 2
447
579
  // reserved lines when content exceeds the available body budget).
448
580
  const maxBody = maxLines - 1; // heading takes 1 line
449
- const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
581
+ const rows = [...runningLines, ...queuedLines, ...finishedLines];
582
+ const totalBody = rows.reduce((total, row) => total + row.lines.length, 0);
450
583
 
451
- const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"))];
584
+ const heading = "Agents ↑↓ select · enter view · esc back";
585
+ const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, heading))];
452
586
 
453
587
  if (maxLines === 1) {
454
588
  // There is room only for the heading; do not consume the editor/input row.
@@ -456,71 +590,70 @@ export class AgentWidget {
456
590
  }
457
591
 
458
592
  if (totalBody <= maxBody) {
459
- // Everything fits — add all lines and fix up connectors for the last item.
460
- lines.push(...finishedLines);
461
- for (const pair of runningLines) lines.push(...pair);
462
- if (queuedLine) lines.push(queuedLine);
463
-
464
- // Fix last connector: swap ├─ → └─ and │ → space for activity lines.
465
- if (lines.length > 1) {
466
- const last = lines.length - 1;
467
- lines[last] = lines[last].replace("├─", "└─");
468
- // If last item is a running agent activity line, fix indent of that line
469
- // and fix the header line above it.
470
- if (runningLines.length > 0 && !queuedLine) {
471
- // The last two lines are the last running agent's header + activity.
472
- if (last >= 2) {
473
- lines[last - 1] = lines[last - 1].replace("├─", "└─");
474
- lines[last] = lines[last].replace("│ ", " ");
475
- }
593
+ this.viewportStart = 0;
594
+ for (const row of rows) lines.push(...row.lines);
595
+ if (rows.length > 0) {
596
+ const lastRow = rows[rows.length - 1];
597
+ const lastStart = lines.length - lastRow.lines.length;
598
+ lines[lastStart] = lines[lastStart].replace("├─", "└─");
599
+ if (lastRow.lines.length === 2) {
600
+ lines[lastStart + 1] = lines[lastStart + 1].replace("│ ", " ");
476
601
  }
477
602
  }
478
603
  } else {
479
- // Overflow — prioritize: running > queued > finished.
480
- // Reserve 1 line for overflow indicator.
481
- let budget = maxBody - 1;
482
- let hiddenRunning = 0;
483
- let hiddenQueued = 0;
484
- let hiddenFinished = 0;
485
-
486
- // 1. Running agents (2 lines each)
487
- for (const pair of runningLines) {
488
- if (budget >= 2) {
489
- lines.push(...pair);
490
- budget -= 2;
491
- } else {
492
- hiddenRunning++;
604
+ // Reserve one line for a directional overflow summary. The viewport is
605
+ // a contiguous slice in roster order, so the same slice is navigable and
606
+ // renderable even when the selected row is currently hidden.
607
+ const contentBudget = Math.max(0, maxBody - 1);
608
+ const heightAt = (index: number) => rows[index]?.lines.length ?? 0;
609
+ const endFor = (start: number): number => {
610
+ let used = 0;
611
+ let end = start;
612
+ while (end < rows.length && used + heightAt(end) <= contentBudget) {
613
+ used += heightAt(end++);
493
614
  }
615
+ return end;
616
+ };
617
+
618
+ let start = Math.max(0, Math.min(this.viewportStart, Math.max(0, rows.length - 1)));
619
+ const selectedIndex = this.navigationActive && selectedId
620
+ ? rows.findIndex(row => row.record.id === selectedId)
621
+ : -1;
622
+ if (selectedIndex >= 0) {
623
+ if (selectedIndex < start) start = selectedIndex;
624
+ if (selectedIndex >= endFor(start)) start = selectedIndex;
494
625
  }
495
-
496
- // 2. Queued line
497
- if (queuedLine) {
498
- if (budget >= 1) {
499
- lines.push(queuedLine);
500
- budget--;
501
- } else {
502
- hiddenQueued = 1;
503
- }
626
+ this.viewportStart = start;
627
+
628
+ let used = 0;
629
+ let end = start;
630
+ while (end < rows.length && used + heightAt(end) <= contentBudget) {
631
+ lines.push(...rows[end].lines);
632
+ used += heightAt(end);
633
+ end++;
504
634
  }
505
-
506
- // 3. Finished agents
507
- for (const fl of finishedLines) {
508
- if (budget >= 1) {
509
- lines.push(fl);
510
- budget--;
511
- } else {
512
- hiddenFinished++;
513
- }
635
+ // A selected two-line row must remain addressable even if only one body
636
+ // line is available. Showing its header is preferable to hiding it.
637
+ if (end === start && rows[start] && contentBudget > 0) {
638
+ lines.push(rows[start].lines[0]);
639
+ end = start + 1;
514
640
  }
515
641
 
516
- // Overflow summary
517
- const overflowParts: string[] = [];
518
- if (hiddenRunning > 0) overflowParts.push(`${hiddenRunning} running`);
519
- if (hiddenQueued > 0) overflowParts.push(`${hiddenQueued} queued`);
520
- if (hiddenFinished > 0) overflowParts.push(`${hiddenFinished} finished`);
521
- const overflowText = overflowParts.join(", ");
522
- lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenQueued + hiddenFinished} more (${overflowText})`)}`)
523
- );
642
+ const hiddenBefore = start;
643
+ const hiddenAfter = Math.max(0, rows.length - end);
644
+ const hidden = hiddenBefore + hiddenAfter;
645
+ const hiddenRows = rows.filter((_row, index) => index < start || index >= end);
646
+ const categoryCounts: string[] = [
647
+ ["running", hiddenRows.filter(row => row.record.status === "running").length] as [string, number],
648
+ ["queued", hiddenRows.filter(row => row.record.status === "queued").length] as [string, number],
649
+ ["finished", hiddenRows.filter(row => row.record.status !== "running" && row.record.status !== "queued").length] as [string, number],
650
+ ].filter(([, count]) => count > 0).map(([label, count]) => `${count} ${label}`);
651
+ const direction = [
652
+ ...(hiddenBefore > 0 ? [`↑ ${hiddenBefore} more`] : []),
653
+ ...(hiddenAfter > 0 ? [`↓ ${hiddenAfter} more`] : []),
654
+ ].join(" · ");
655
+ const summary = `+${hidden} more (${direction}${categoryCounts.length > 0 ? `; ${categoryCounts.join(", ")}` : ""})`;
656
+ lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", summary)}`));
524
657
  }
525
658
 
526
659
  return lines;
@@ -555,9 +688,14 @@ export class AgentWidget {
555
688
  lifetimeUsage: a.lifetimeUsage,
556
689
  compactionCount: a.compactionCount,
557
690
  isBackground: a.isBackground,
691
+ hasSession: a.session !== undefined,
692
+ transcriptPath: a.transcriptPath,
693
+ openableHistory: this.options.canOpenHistory(a),
558
694
  })),
559
695
  activities,
560
- finishedTurnAge: [...this.finishedTurnAge.entries()],
696
+ navigationActive: this.navigationActive,
697
+ selectedAgentId: this.selectedAgentId,
698
+ viewportStart: this.viewportStart,
561
699
  });
562
700
  }
563
701
 
@@ -565,16 +703,16 @@ export class AgentWidget {
565
703
  update(advanceSpinner = false) {
566
704
  if (!this.uiCtx) return;
567
705
  const allAgents = this.widgetAgents();
706
+ const roster = this.roster();
568
707
 
569
708
  // Lightweight existence checks — full categorization happens in renderWidget()
570
709
  let runningCount = 0;
571
710
  let queuedCount = 0;
572
- let hasFinished = false;
573
- for (const a of allAgents) {
574
- if (a.status === "running") { runningCount++; }
575
- else if (a.status === "queued") { queuedCount++; }
576
- else if (a.completedAt && this.shouldShowFinished(a.id, a.status)) { hasFinished = true; }
711
+ for (const a of roster) {
712
+ if (a.status === "running") runningCount++;
713
+ else if (a.status === "queued") queuedCount++;
577
714
  }
715
+ const hasFinished = roster.some(a => a.status !== "running" && a.status !== "queued");
578
716
  const hasActive = runningCount > 0 || queuedCount > 0;
579
717
 
580
718
  // Nothing to show — clear widget
@@ -589,11 +727,10 @@ export class AgentWidget {
589
727
  this.lastStatusText = undefined;
590
728
  }
591
729
  this.lastRenderKey = undefined;
730
+ this.navigationActive = false;
731
+ this.selectedAgentId = undefined;
732
+ this.viewportStart = 0;
592
733
  this.syncTimer(false);
593
- // Clean up stale entries
594
- for (const [id] of this.finishedTurnAge) {
595
- if (!allAgents.some(a => a.id === id)) this.finishedTurnAge.delete(id);
596
- }
597
734
  return;
598
735
  }
599
736
 
@@ -616,6 +753,15 @@ export class AgentWidget {
616
753
  // an otherwise idle widget advance.
617
754
  if (advanceSpinner && runningCount > 0) this.widgetFrame++;
618
755
  this.syncTimer(runningCount > 0);
756
+ if (this.navigationActive) {
757
+ if (roster.length === 0) {
758
+ this.navigationActive = false;
759
+ this.selectedAgentId = undefined;
760
+ this.viewportStart = 0;
761
+ } else if (this.selectedIndexOf(roster) < 0) {
762
+ this.selectedAgentId = roster[Math.min(this.viewportStart, roster.length - 1)].id;
763
+ }
764
+ }
619
765
  const renderKey = this.renderKey(allAgents);
620
766
 
621
767
  // Register widget callback once; subsequent updates use requestRender()
@@ -651,11 +797,15 @@ export class AgentWidget {
651
797
  this.uiCtx.setWidget("agents", undefined);
652
798
  this.uiCtx.setStatus("subagents", undefined);
653
799
  }
800
+ this.inputUnsub?.();
801
+ this.inputUnsub = undefined;
654
802
  this.widgetRegistered = false;
655
803
  this.tui = undefined;
656
804
  this.lastStatusText = undefined;
657
805
  this.lastRenderKey = undefined;
806
+ this.navigationActive = false;
807
+ this.selectedAgentId = undefined;
808
+ this.viewportStart = 0;
658
809
  this.uiCtx = undefined;
659
- this.finishedTurnAge.clear();
660
810
  }
661
811
  }
@@ -90,7 +90,7 @@ export function stripBackgroundSgrParams(params: readonly number[]): number[] {
90
90
  }
91
91
 
92
92
  export function filterSgrSequences(text: string, filter: (params: number[]) => number[]): string {
93
- if (!text || !text.includes("\x1b[")) {
93
+ if (!text?.includes("\x1b[")) {
94
94
  return text;
95
95
  }
96
96
 
@@ -159,7 +159,7 @@ function sanitizeColonSgr(rawParams: string): string {
159
159
  }
160
160
 
161
161
  export function sanitizeAnsiForThemedOutput(text: string): string {
162
- if (!text || !text.includes("\x1b[")) return text;
162
+ if (!text?.includes("\x1b[")) return text;
163
163
  return text.replace(ANSI_SGR_WITH_COLON_PATTERN, (sequence, rawParams: string) =>
164
164
  rawParams.includes(":")
165
165
  ? sanitizeColonSgr(rawParams)
@@ -39,7 +39,6 @@ export function resolveDiffPresentationMode(
39
39
  return canRenderSplitLayout ? "split" : "unified";
40
40
  case "unified":
41
41
  return "unified";
42
- case "auto":
43
42
  default:
44
43
  return safeWidth >= (config.diffSplitMinWidth ?? 120) && canRenderSplitLayout ? "split" : "unified";
45
44
  }