@oh-my-pi/pi-coding-agent 17.2.8 → 17.2.10

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 (246) hide show
  1. package/CHANGELOG.md +94 -1
  2. package/dist/{CHANGELOG-0b0w17se.md → CHANGELOG-rhwzpexa.md} +94 -1
  3. package/dist/cli.js +13216 -15614
  4. package/dist/types/async/job-manager.d.ts +12 -0
  5. package/dist/types/cli/args.d.ts +1 -0
  6. package/dist/types/config/model-resolver.d.ts +13 -0
  7. package/dist/types/discovery/agents.d.ts +11 -0
  8. package/dist/types/extensibility/custom-commands/types.d.ts +5 -5
  9. package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
  10. package/dist/types/extensibility/extensions/loader.d.ts +2 -2
  11. package/dist/types/extensibility/extensions/types.d.ts +34 -8
  12. package/dist/types/extensibility/hooks/types.d.ts +5 -5
  13. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +10 -0
  14. package/dist/types/hindsight/state.d.ts +0 -1
  15. package/dist/types/index.d.ts +2 -2
  16. package/dist/types/lsp/utils.d.ts +8 -0
  17. package/dist/types/main.d.ts +1 -1
  18. package/dist/types/modes/acp/acp-agent.d.ts +1 -1
  19. package/dist/types/modes/acp/acp-client-bridge.d.ts +1 -1
  20. package/dist/types/modes/acp/acp-event-mapper.d.ts +1 -1
  21. package/dist/types/modes/acp/acp-mode.d.ts +1 -1
  22. package/dist/types/modes/components/agent-hub-projection.d.ts +38 -0
  23. package/dist/types/modes/components/agent-hub-renderer.d.ts +41 -0
  24. package/dist/types/modes/components/agent-hub.d.ts +12 -5
  25. package/dist/types/modes/components/model-browser.d.ts +9 -1
  26. package/dist/types/modes/interactive-mode.d.ts +8 -1
  27. package/dist/types/modes/session-observer-registry.d.ts +2 -0
  28. package/dist/types/registry/agent-lifecycle.d.ts +1 -1
  29. package/dist/types/registry/agent-registry.d.ts +47 -0
  30. package/dist/types/registry/persisted-agents.d.ts +3 -1
  31. package/dist/types/session/agent-session-types.d.ts +7 -0
  32. package/dist/types/session/agent-session.d.ts +8 -4
  33. package/dist/types/session/session-entries.d.ts +10 -0
  34. package/dist/types/session/session-loader.d.ts +11 -1
  35. package/dist/types/session/session-manager.d.ts +9 -1
  36. package/dist/types/session/session-tools.d.ts +10 -1
  37. package/dist/types/session/turn-recovery.d.ts +6 -2
  38. package/dist/types/slash-commands/acp-builtins.d.ts +1 -1
  39. package/dist/types/slash-commands/available-commands.d.ts +1 -1
  40. package/dist/types/task/executor.d.ts +10 -0
  41. package/dist/types/task/index.d.ts +2 -3
  42. package/dist/types/task/read-only-policy.d.ts +3 -0
  43. package/dist/types/task/structured-subagent.d.ts +2 -0
  44. package/dist/types/task/types.d.ts +4 -0
  45. package/dist/types/tools/bash.d.ts +3 -3
  46. package/dist/types/tools/browser/launch.d.ts +3 -0
  47. package/dist/types/tools/browser/tab-worker.d.ts +2 -2
  48. package/dist/types/tools/path-utils.d.ts +8 -0
  49. package/dist/types/tools/read.d.ts +3 -5
  50. package/dist/types/tools/run-scope.d.ts +27 -1
  51. package/dist/types/tools/shell-tokenize.d.ts +21 -3
  52. package/dist/types/tools/terminal-output.d.ts +1 -1
  53. package/dist/types/utils/changelog.d.ts +3 -2
  54. package/dist/types/utils/late-cleanup.d.ts +2 -0
  55. package/dist/types/utils/turndown.d.ts +1 -1
  56. package/dist/types/vibe/runtime.d.ts +4 -3
  57. package/dist/types/web/scrapers/readthedocs.d.ts +0 -3
  58. package/dist/types/web/search/providers/browser-headers.d.ts +2 -3
  59. package/examples/custom-tools/README.md +1 -1
  60. package/examples/extensions/README.md +1 -1
  61. package/examples/extensions/api-demo.ts +5 -6
  62. package/examples/extensions/chalk-logger.ts +1 -1
  63. package/examples/extensions/hello.ts +2 -2
  64. package/examples/extensions/reload-runtime.ts +1 -1
  65. package/examples/extensions/tools.ts +2 -2
  66. package/examples/extensions/with-deps/index.ts +1 -1
  67. package/examples/sdk/06-extensions.ts +1 -1
  68. package/package.json +15 -31
  69. package/scripts/bundle-dist.ts +1 -11
  70. package/scripts/omp +11 -2
  71. package/src/advisor/runtime.ts +54 -0
  72. package/src/async/job-manager.ts +26 -0
  73. package/src/cli/agents-cli.ts +1 -1
  74. package/src/cli/args.ts +24 -2
  75. package/src/cli/auth-broker-cli.ts +1 -1
  76. package/src/cli/auth-gateway-cli.ts +1 -1
  77. package/src/cli/bench-cli.ts +1 -1
  78. package/src/cli/claude-trace-cli.ts +1 -1
  79. package/src/cli/config-cli.ts +1 -1
  80. package/src/cli/dry-balance-cli.ts +1 -1
  81. package/src/cli/file-processor.ts +1 -1
  82. package/src/cli/flag-tables.ts +4 -0
  83. package/src/cli/grep-cli.ts +1 -1
  84. package/src/cli/grievances-cli.ts +1 -1
  85. package/src/cli/help-extra.ts +1 -1
  86. package/src/cli/models-cli.ts +1 -1
  87. package/src/cli/plugin-cli.ts +1 -1
  88. package/src/cli/read-cli.ts +1 -1
  89. package/src/cli/setup-cli.ts +1 -1
  90. package/src/cli/shell-cli.ts +1 -1
  91. package/src/cli/ssh-cli.ts +1 -1
  92. package/src/cli/stats-cli.ts +4 -4
  93. package/src/cli/tiny-models-cli.ts +1 -1
  94. package/src/cli/ttsr-cli.ts +1 -1
  95. package/src/cli/update-cli.ts +3 -57
  96. package/src/cli/usage-cli.ts +1 -1
  97. package/src/cli/web-search-cli.ts +1 -1
  98. package/src/cli/worktree-cli.ts +1 -1
  99. package/src/commands/say.ts +1 -1
  100. package/src/commands/token.ts +1 -1
  101. package/src/commit/agentic/agent.ts +1 -1
  102. package/src/commit/cli.ts +1 -1
  103. package/src/config/config-file.ts +2 -2
  104. package/src/config/keybindings.ts +3 -3
  105. package/src/config/model-discovery.ts +36 -2
  106. package/src/config/model-registry.ts +4 -1
  107. package/src/config/model-resolver.ts +54 -7
  108. package/src/discovery/agents.ts +73 -3
  109. package/src/discovery/builtin-rules/ts-no-inline-cast-access.md +5 -4
  110. package/src/discovery/builtin-rules/ts-no-tiny-functions.md +1 -1
  111. package/src/discovery/claude.ts +8 -5
  112. package/src/discovery/cursor.ts +8 -5
  113. package/src/discovery/gemini.ts +11 -8
  114. package/src/discovery/vscode.ts +1 -0
  115. package/src/discovery/windsurf.ts +4 -2
  116. package/src/edit/hashline/filesystem.ts +7 -7
  117. package/src/eval/agent-bridge.ts +1 -3
  118. package/src/eval/jl/prelude.jl +4 -4
  119. package/src/eval/js/shared/prelude.txt +2 -2
  120. package/src/eval/py/prelude.py +0 -3
  121. package/src/eval/py/runner.py +38 -1
  122. package/src/eval/rb/prelude.rb +1 -2
  123. package/src/extensibility/custom-commands/loader.ts +4 -4
  124. package/src/extensibility/custom-commands/types.ts +5 -5
  125. package/src/extensibility/custom-tools/loader.ts +4 -4
  126. package/src/extensibility/custom-tools/types.ts +1 -1
  127. package/src/extensibility/extensions/loader.ts +7 -6
  128. package/src/extensibility/extensions/types.ts +39 -8
  129. package/src/extensibility/hooks/loader.ts +4 -5
  130. package/src/extensibility/hooks/types.ts +5 -5
  131. package/src/extensibility/legacy-pi-coding-agent-shim.ts +23 -0
  132. package/src/extensibility/plugins/legacy-pi-compat.ts +53 -0
  133. package/src/hindsight/state.ts +2 -22
  134. package/src/index.ts +2 -2
  135. package/src/internal-urls/memory-protocol.ts +33 -0
  136. package/src/launch/terminal-output.ts +1 -1
  137. package/src/lsp/client.ts +2 -2
  138. package/src/lsp/utils.ts +29 -0
  139. package/src/main.ts +85 -21
  140. package/src/markit/converters/docx.ts +1 -1
  141. package/src/markit/converters/epub.ts +1 -1
  142. package/src/markit/converters/pptx.ts +1 -1
  143. package/src/markit/converters/xlsx.ts +1 -1
  144. package/src/modes/acp/acp-agent.ts +7 -6
  145. package/src/modes/acp/acp-client-bridge.ts +1 -1
  146. package/src/modes/acp/acp-event-mapper.ts +1 -1
  147. package/src/modes/acp/acp-mode.ts +1 -1
  148. package/src/modes/components/agent-hub-projection.ts +248 -0
  149. package/src/modes/components/agent-hub-renderer.ts +194 -0
  150. package/src/modes/components/agent-hub.ts +670 -192
  151. package/src/modes/components/agent-transcript-viewer.ts +1 -3
  152. package/src/modes/components/assistant-message.ts +1 -1
  153. package/src/modes/components/bordered-loader.ts +1 -1
  154. package/src/modes/components/custom-editor.ts +2 -2
  155. package/src/modes/components/extensions/inspector-panel.ts +11 -6
  156. package/src/modes/components/footer.ts +1 -3
  157. package/src/modes/components/hook-input.ts +1 -1
  158. package/src/modes/components/hook-selector.ts +2 -2
  159. package/src/modes/components/model-browser.ts +11 -3
  160. package/src/modes/components/model-hub.ts +4 -1
  161. package/src/modes/components/session-selector.ts +2 -2
  162. package/src/modes/components/status-line/segments.ts +4 -3
  163. package/src/modes/controllers/event-controller.ts +188 -7
  164. package/src/modes/controllers/extension-ui-controller.ts +4 -3
  165. package/src/modes/controllers/mcp-command-controller.ts +81 -20
  166. package/src/modes/controllers/selector-controller.ts +40 -49
  167. package/src/modes/controllers/streaming-reveal.ts +1 -1
  168. package/src/modes/interactive-mode.ts +13 -2
  169. package/src/modes/print-mode.ts +29 -12
  170. package/src/modes/rpc/rpc-mode.ts +2 -2
  171. package/src/modes/runtime-init.ts +1 -1
  172. package/src/modes/session-observer-registry.ts +5 -0
  173. package/src/modes/theme/theme.ts +2 -2
  174. package/src/prompts/tools/computer.md +1 -1
  175. package/src/registry/agent-lifecycle.ts +35 -10
  176. package/src/registry/agent-registry.ts +65 -2
  177. package/src/registry/persisted-agents.ts +341 -16
  178. package/src/sdk.ts +11 -5
  179. package/src/session/agent-session-types.ts +8 -0
  180. package/src/session/agent-session.ts +177 -188
  181. package/src/session/session-entries.ts +10 -0
  182. package/src/session/session-listing.ts +1 -1
  183. package/src/session/session-loader.ts +74 -8
  184. package/src/session/session-manager.ts +22 -2
  185. package/src/session/session-paths.ts +105 -110
  186. package/src/session/session-tools.ts +28 -1
  187. package/src/session/settings-stream-fn.ts +7 -3
  188. package/src/session/turn-recovery.ts +236 -18
  189. package/src/session/unexpected-stop-classifier.ts +12 -3
  190. package/src/slash-commands/acp-builtins.ts +1 -1
  191. package/src/slash-commands/available-commands.ts +1 -1
  192. package/src/slash-commands/helpers/stats-dashboard.ts +2 -1
  193. package/src/task/agents.ts +1 -1
  194. package/src/task/executor.ts +173 -47
  195. package/src/task/index.ts +9 -28
  196. package/src/task/isolation-runner.ts +43 -12
  197. package/src/task/persisted-revive.ts +12 -5
  198. package/src/task/read-only-policy.ts +27 -0
  199. package/src/task/structured-subagent.ts +31 -6
  200. package/src/task/types.ts +4 -0
  201. package/src/tiny/worker.ts +1 -1
  202. package/src/tools/auto-generated-guard.ts +1 -1
  203. package/src/tools/bash-interactive.ts +4 -4
  204. package/src/tools/bash-interceptor.ts +8 -4
  205. package/src/tools/bash.ts +6 -6
  206. package/src/tools/browser/cmux/cmux-tab.ts +66 -18
  207. package/src/tools/browser/launch.ts +76 -19
  208. package/src/tools/browser/readable.ts +9 -9
  209. package/src/tools/browser/tab-supervisor.ts +12 -3
  210. package/src/tools/browser/tab-worker-entry.ts +1 -1
  211. package/src/tools/browser/tab-worker.ts +101 -12
  212. package/src/tools/fetch.ts +1 -1
  213. package/src/tools/glob.ts +4 -2
  214. package/src/tools/path-utils.ts +22 -5
  215. package/src/tools/read.ts +22 -5
  216. package/src/tools/run-scope.ts +290 -4
  217. package/src/tools/shell-tokenize.ts +38 -9
  218. package/src/tools/terminal-output.ts +1 -1
  219. package/src/tools/todo.ts +1 -1
  220. package/src/utils/changelog.ts +5 -4
  221. package/src/utils/clipboard.ts +10 -2
  222. package/src/utils/external-editor.ts +4 -2
  223. package/src/utils/jj.ts +1 -1
  224. package/src/utils/late-cleanup.ts +17 -0
  225. package/src/utils/turndown.ts +1 -2
  226. package/src/vibe/runtime.ts +202 -58
  227. package/src/web/scrapers/arxiv.ts +1 -1
  228. package/src/web/scrapers/go-pkg.ts +1 -1
  229. package/src/web/scrapers/hackage.ts +1 -12
  230. package/src/web/scrapers/iacr.ts +1 -1
  231. package/src/web/scrapers/readthedocs.ts +2 -1
  232. package/src/web/scrapers/twitter.ts +2 -2
  233. package/src/web/scrapers/types.ts +1 -1
  234. package/src/web/scrapers/wikipedia.ts +1 -1
  235. package/src/web/search/providers/browser-headers.ts +12 -39
  236. package/src/web/search/providers/codex.ts +3 -26
  237. package/src/web/search/providers/duckduckgo.ts +1 -1
  238. package/src/web/search/providers/ecosia.ts +2 -2
  239. package/src/web/search/providers/exa.ts +1 -1
  240. package/src/web/search/providers/firecrawl.ts +1 -1
  241. package/src/web/search/providers/google.ts +1 -1
  242. package/src/web/search/providers/mojeek.ts +2 -2
  243. package/src/web/search/providers/perplexity.ts +1 -1
  244. package/src/web/search/providers/public.ts +1 -1
  245. package/src/web/search/providers/startpage.ts +2 -2
  246. package/src/markit/converters/mammoth.d.ts +0 -24
@@ -4,8 +4,8 @@
4
4
  * One overlay, two views:
5
5
  * - Table view: every registered agent except Main (Main IS the ambient
6
6
  * chat), live from the global AgentRegistry — status, unread irc count,
7
- * current/last task, last activity. Select with j/k, Enter opens a chat,
8
- * `r` revives a parked agent, `x` aborts + releases one.
7
+ * current/last task, last activity. Navigate with keys, wheel, hover, and
8
+ * click; `r` revives a parked agent, `x` aborts + releases one.
9
9
  * - Chat view: per-agent transcript (incremental session-file tail, absorbed
10
10
  * from the old session observer overlay) plus an input line. Submitting
11
11
  * revives a parked agent, then prompts/steers it; the message lands in the
@@ -13,108 +13,80 @@
13
13
  *
14
14
  * Replaces the old SessionObserverOverlayComponent (ctrl+s observer).
15
15
  */
16
- import { type AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
17
- import { Container, Ellipsis, matchesKey, type OverlayHandle, padding, type TUI, visibleWidth } from "@oh-my-pi/pi-tui";
18
- import { formatAge, getProjectDir, logger } from "@oh-my-pi/pi-utils";
16
+ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
17
+ import {
18
+ Container,
19
+ matchesKey,
20
+ type OverlayHandle,
21
+ padding,
22
+ routeSelectListMouse,
23
+ routeSgrMouseInput,
24
+ type SelectListMouseTarget,
25
+ type TUI,
26
+ visibleWidth,
27
+ wrapTextWithAnsi,
28
+ } from "@oh-my-pi/pi-tui";
29
+ import { formatAge, formatNumber, getProjectDir, logger } from "@oh-my-pi/pi-utils";
19
30
  import type { KeyId } from "../../config/keybindings";
31
+ import type { Settings } from "../../config/settings";
20
32
  import type { MessageRenderer } from "../../extensibility/extensions/types";
21
33
  import { IrcBus } from "../../irc/bus";
22
34
  import { AgentLifecycleManager } from "../../registry/agent-lifecycle";
23
35
  import { type AgentRef, AgentRegistry, type AgentStatus, MAIN_AGENT_ID } from "../../registry/agent-registry";
24
36
  import { registerPersistedSubagents } from "../../registry/persisted-agents";
25
37
  import { USER_INTERRUPT_LABEL } from "../../session/messages";
26
- import { parseThinkingLevel } from "../../thinking";
27
- import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
38
+ import { shortenPath, truncateToWidth } from "../../tools/render-utils";
28
39
  import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
29
40
  import { theme } from "../theme/theme";
30
41
  import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
42
+ import {
43
+ type AgentMetrics,
44
+ type AggregateMetrics,
45
+ aggregateMetrics,
46
+ progressMetrics,
47
+ projectAgentTree,
48
+ STATUS_ORDER,
49
+ } from "./agent-hub-projection";
50
+ import {
51
+ clampHubLine,
52
+ contextGauge,
53
+ formatChildIds,
54
+ formatCost,
55
+ formatMetricDuration,
56
+ formatMetrics,
57
+ formatRoleBadge,
58
+ modelBadge,
59
+ type RosterRender,
60
+ sanitizeDisplayText,
61
+ sanitizeLine,
62
+ statusGlyph,
63
+ statusText,
64
+ treeBranch,
65
+ } from "./agent-hub-renderer";
31
66
  import { AgentTranscriptViewer } from "./agent-transcript-viewer";
32
- import { DynamicBorder } from "./dynamic-border";
67
+ import {
68
+ bottomBorder,
69
+ divider,
70
+ dividerSplit,
71
+ row,
72
+ splitBodyWidth,
73
+ splitRow,
74
+ topBorder,
75
+ topBorderSplit,
76
+ } from "./overlay-box";
33
77
 
34
- /** Refresh cadence for the relative-time column */
78
+ type HubViewMode = "roster" | "tree";
79
+
80
+ /** Refresh cadence for the relative-time column. */
35
81
  const AGE_TICK_MS = 5_000;
36
82
  const DATA_CHANGE_RENDER_COALESCE_MS = 100;
37
83
  /** Double-tap window for the table's left-left "close hub" gesture. */
38
84
  const LEFT_TAP_WINDOW_MS = 500;
39
85
 
40
- /** Compute the max content width for the current terminal, accounting for chrome. */
41
- function contentWidth(): number {
42
- return Math.max(TRUNCATE_LENGTHS.SHORT, (process.stdout.columns || 80) - 6);
43
- }
44
-
45
- /** Sanitize a line for TUI display: replace tabs, then truncate to viewport width. */
46
- function sanitizeLine(text: string, maxWidth?: number): string {
47
- const singleLine = replaceTabs(text).replace(/[\r\n]+/g, " ");
48
- return truncateToWidth(singleLine, maxWidth ?? contentWidth());
49
- }
50
-
51
- function clampHubLine(line: string, width: number): string {
52
- return truncateToWidth(line.replace(/[\r\n]+/g, " "), Math.max(1, width - 2), Ellipsis.Omit);
53
- }
54
-
55
- const STATUS_ORDER: Record<AgentStatus, number> = { running: 0, idle: 1, parked: 2, aborted: 3 };
56
-
57
- /** Status glyph, colored per theme status conventions. The title-line counts spell out the words. */
58
- function statusGlyph(status: AgentStatus): string {
59
- switch (status) {
60
- case "running":
61
- return theme.fg("accent", theme.status.running);
62
- case "idle":
63
- return theme.fg("success", theme.status.enabled);
64
- case "parked":
65
- return theme.fg("muted", theme.status.shadowed);
66
- case "aborted":
67
- return theme.fg("error", theme.status.aborted);
68
- }
69
- }
70
-
71
- /** Model id + thinking level (`sonnet-4-6 ◒ high`), level colored per theme. */
72
- function formatModelBadge(modelId: string, level: ThinkingLevel | undefined): string {
73
- const model = theme.fg("muted", replaceTabs(modelId));
74
- if (!level || level === ThinkingLevel.Off || level === ThinkingLevel.Inherit) return model;
75
- const display = theme.thinking[level as keyof typeof theme.thinking] ?? level;
76
- return `${model} ${theme.getThinkingBorderColor(level)(display)}`;
77
- }
78
-
79
- /** Format a resolved selector, preserving provider identity when requested. */
80
- function formatResolvedModelBadge(resolved: string, preserveProvider = false): string {
81
- // Model ids may themselves contain colons (`qwen3:14b`), so only treat the
82
- // suffix as a thinking level when it parses as one.
83
- const colon = resolved.lastIndexOf(":");
84
- const level = colon >= 0 ? parseThinkingLevel(resolved.slice(colon + 1)) : undefined;
85
- const selector = level !== undefined ? resolved.slice(0, colon) : resolved;
86
- const label = preserveProvider ? selector : selector.slice(selector.indexOf("/") + 1);
87
- return formatModelBadge(label, level);
88
- }
89
-
90
- /**
91
- * Active model + reasoning level for a hub row: live session state when the
92
- * agent is attached, else the executor-reported `resolvedModel` selector
93
- * (`provider/id`, optionally `:<level>`). Active retry fallbacks retain their
94
- * provider and carry an explicit marker. Undefined when no model is known
95
- * (e.g. a parked historical agent restored from disk).
96
- */
97
- function modelBadge(ref: AgentRef, observed: ObservableSession | undefined): string | undefined {
98
- const progress = observed?.progress;
99
- // Prefer the live session's own resolved fallback selector; else honor the
100
- // executor-reported fallback flag. The latter covers observer-only rows (no
101
- // live session) AND live rows whose fallback armed no session retry state —
102
- // e.g. the Fireworks Fast → base degrade, which emits `retry_fallback_applied`
103
- // without populating `#activeRetryFallback`, so `retryFallbackModel` is undefined.
104
- const fallbackSelector =
105
- ref.session?.retryFallbackModel ?? (progress?.resolvedModelIsFallback ? progress.resolvedModel : undefined);
106
- if (fallbackSelector) {
107
- return `${theme.fg("warning", "fallback →")} ${formatResolvedModelBadge(fallbackSelector, true)}`;
108
- }
109
- const model = ref.session?.model;
110
- if (model) {
111
- const level = model.thinking ? ref.session?.thinkingLevel : undefined;
112
- return formatModelBadge(model.id, level);
113
- }
114
- const resolved = progress?.resolvedModel;
115
- return resolved ? formatResolvedModelBadge(resolved) : undefined;
116
- }
117
-
86
+ /** Two-pane mode needs a useful roster and a readable inspector. */
87
+ const SPLIT_MIN_WIDTH = 96;
88
+ const DETAIL_MIN_WIDTH = 34;
89
+ const ROSTER_MIN_WIDTH = 48;
118
90
  /** Result of one host-backed transcript read for the Agent Hub viewer. */
119
91
  export interface AgentHubRemoteTranscript {
120
92
  text: string;
@@ -135,6 +107,8 @@ export interface AgentHubRemote {
135
107
  export interface AgentHubDeps {
136
108
  /** Progress/status snapshot source (task lifecycle + progress channels). */
137
109
  observers: SessionObserverRegistry;
110
+ /** Production settings used to resolve textual model-role tags. */
111
+ settings?: Settings;
138
112
  /** Keys that toggle the hub closed from inside (app.agents.hub + app.session.observe). */
139
113
  hubKeys: KeyId[];
140
114
  onDone: () => void;
@@ -162,13 +136,15 @@ export interface AgentHubDeps {
162
136
  focusAgent?: (id: string) => Promise<void>;
163
137
  /** Current main session file; used to seed parked historical subagents after restart. */
164
138
  sessionFile?: string | null;
139
+
165
140
  /** Collab guest: route actions/transcripts to the host instead of local sessions. */
166
141
  remote?: AgentHubRemote;
167
142
  }
168
143
 
169
- export class AgentHubOverlayComponent extends Container {
144
+ export class AgentHubOverlayComponent extends Container implements SelectListMouseTarget {
170
145
  #registry: AgentRegistry;
171
146
  #observers: SessionObserverRegistry;
147
+ #settings: Settings | undefined;
172
148
  #irc: IrcBus;
173
149
  #lifecycle: () => AgentLifecycleManager;
174
150
  #onDone: () => void;
@@ -178,17 +154,54 @@ export class AgentHubOverlayComponent extends Container {
178
154
  #ageTimer: NodeJS.Timeout | undefined;
179
155
  #dataChangeTimer?: NodeJS.Timeout;
180
156
  #remote: AgentHubRemote | undefined;
157
+ #disposed = false;
181
158
  /** Resolves after persisted historical subagents have been registered and rows refreshed. */
182
159
  readonly persistedSubagentsReady: Promise<void>;
160
+ /** Prevent the async persisted-session scan from flashing a false empty state. */
161
+ #loadingPersistedSubagents = false;
183
162
 
184
163
  // Table state
185
164
  #rows: AgentRef[] = [];
165
+ #statusCounts: Record<AgentStatus, number> = { running: 0, idle: 0, parked: 0, aborted: 0 };
186
166
  #selectedRow = 0;
167
+ #hoveredRow: number | null = null;
168
+ /** Per-render screen-line to agent-row map, shared by click and hover routing. */
169
+ #hitRows: Array<number | undefined> = [];
187
170
  #notice: string | undefined;
188
171
  /** Captured row order from the first refresh; keeps the hub stable while open. */
189
172
  #rowOrder: Map<string, number> | undefined;
173
+ #nextRowOrder = 0;
190
174
  /** Double-tap window state for the table's left-left "close hub" gesture. */
191
175
  #lastLeftTap = 0;
176
+ /** Operational ordering by default; tree mode groups descendants under their spawner. */
177
+ #viewMode: HubViewMode = "roster";
178
+ #treeDepthById = new Map<string, number>();
179
+ #treeParentById = new Map<string, string>();
180
+ #treeLastSiblingById = new Map<string, boolean>();
181
+ /** Current observer index and summary data, rebuilt on source changes rather than every paint. */
182
+ #observedById = new Map<string, ObservableSession>();
183
+ #aggregate: AggregateMetrics = {
184
+ tokens: 0,
185
+ requests: 0,
186
+ tools: 0,
187
+ cost: 0,
188
+ durationMs: 0,
189
+ durationKind: "active",
190
+ reportedAgents: 0,
191
+ activeDurationAgents: 0,
192
+ };
193
+ #childrenByParent = new Map<string, AgentRef[]>();
194
+ /** Transcript-derived fallback stats are sampled only on the bounded age cadence. */
195
+ #sessionMetrics = new WeakMap<object, { metrics: AgentMetrics | undefined }>();
196
+ /** Avoid a cadence-time row scan for the common persisted-only roster. */
197
+ #hasFallbackLiveSessions = false;
198
+ /** On narrow terminals Tab replaces the roster with the selected-agent inspector. */
199
+ #narrowDetailsOpen = false;
200
+ #lastRenderWasSplit = false;
201
+ #lastSplitRosterWidth: number | undefined;
202
+ /** Scroll offset for the selected-agent inspector when its content overflows. */
203
+ #detailScrollOffset = 0;
204
+ #detailAgentId: string | undefined;
192
205
 
193
206
  // Transcript-viewer launch deps (passed through to AgentTranscriptViewer).
194
207
  #ui: TUI;
@@ -208,6 +221,7 @@ export class AgentHubOverlayComponent extends Container {
208
221
  super();
209
222
  this.#registry = deps.registry ?? AgentRegistry.global();
210
223
  this.#observers = deps.observers;
224
+ this.#settings = deps.settings;
211
225
  this.#irc = deps.irc ?? IrcBus.global();
212
226
  // Lazy: the lifecycle global self-constructs against the global
213
227
  // registry, so only touch it when revive/kill actually needs it.
@@ -216,6 +230,7 @@ export class AgentHubOverlayComponent extends Container {
216
230
  this.#requestRender = deps.requestRender;
217
231
  this.#hubKeys = deps.hubKeys;
218
232
  this.#remote = deps.remote;
233
+ this.#loadingPersistedSubagents = !this.#remote && Boolean(deps.sessionFile?.endsWith(".jsonl"));
219
234
  this.#ui =
220
235
  deps.ui ??
221
236
  ({
@@ -232,19 +247,29 @@ export class AgentHubOverlayComponent extends Container {
232
247
 
233
248
  this.#unsubscribers.push(this.#registry.onChange(() => this.#scheduleDataChange()));
234
249
  this.#unsubscribers.push(this.#observers.onChange(() => this.#scheduleDataChange()));
235
- this.#ageTimer = setInterval(() => this.#requestRender(), AGE_TICK_MS);
250
+ this.#ageTimer = setInterval(() => {
251
+ if (this.#hasFallbackLiveSessions) {
252
+ this.#refreshAggregate(true);
253
+ }
254
+ this.#requestRender();
255
+ }, AGE_TICK_MS);
236
256
  this.#ageTimer.unref?.();
237
257
 
238
258
  this.persistedSubagentsReady = this.#remote
239
259
  ? Promise.resolve()
240
- : registerPersistedSubagents(this.#registry, deps.sessionFile)
260
+ : registerPersistedSubagents(this.#registry, deps.sessionFile, {
261
+ shouldContinue: () => !this.#disposed,
262
+ })
263
+ .then(() => {
264
+ if (!this.#disposed) this.#refreshRows();
265
+ })
241
266
  .catch((error: unknown) => {
242
267
  logger.warn("Failed to register persisted subagents", { error });
243
268
  })
244
- .then(() => {
245
- this.#refreshRows();
246
- })
247
- .finally(() => this.#requestRender());
269
+ .finally(() => {
270
+ this.#loadingPersistedSubagents = false;
271
+ if (!this.#disposed) this.#requestRender();
272
+ });
248
273
  this.#refreshRows();
249
274
  }
250
275
 
@@ -258,7 +283,9 @@ export class AgentHubOverlayComponent extends Container {
258
283
  }
259
284
 
260
285
  /** Tear down every subscription and timer. Called by the overlay owner on close. */
261
- dispose(): void {
286
+ override dispose(): void {
287
+ if (this.#disposed) return;
288
+ this.#disposed = true;
262
289
  for (const unsubscribe of this.#unsubscribers.splice(0)) unsubscribe();
263
290
  if (this.#ageTimer) {
264
291
  clearInterval(this.#ageTimer);
@@ -272,10 +299,28 @@ export class AgentHubOverlayComponent extends Container {
272
299
  }
273
300
 
274
301
  override render(width: number): readonly string[] {
275
- return this.#renderTable(width).map(line => clampHubLine(line, width));
302
+ const termHeight = this.#ui.terminal?.rows || process.stdout.rows || 40;
303
+ const frame = this.#renderTable(width, termHeight).map(line => clampHubLine(line, width));
304
+ if (frame.length <= termHeight) return frame;
305
+
306
+ // A tiny terminal can leave less room than the fixed chrome needs. Keep
307
+ // the title and footer visible instead of spilling into scrollback.
308
+ const footerLines = Math.min(3, frame.length);
309
+ const bodyEnd = Math.max(0, termHeight - footerLines);
310
+ return [...frame.slice(0, bodyEnd), ...frame.slice(-footerLines)].slice(0, termHeight);
276
311
  }
277
312
 
278
313
  handleInput(keyData: string): void {
314
+ if (
315
+ routeSgrMouseInput(keyData, event => {
316
+ const split = this.#lastSplitRosterWidth;
317
+ if (split !== undefined && event.wheel === null && event.col > split + 2) return false;
318
+ return routeSelectListMouse(this, event, event.row);
319
+ })
320
+ ) {
321
+ return;
322
+ }
323
+
279
324
  // The hub/observe keys always close the overlay (toggle semantics)
280
325
  for (const key of this.#hubKeys) {
281
326
  if (matchesKey(keyData, key)) {
@@ -306,11 +351,12 @@ export class AgentHubOverlayComponent extends Container {
306
351
  * restored when the viewer closes. No-op without a real TUI (render-only test stub).
307
352
  */
308
353
  openChat(id: string): void {
309
- if (!this.#registry.get(id)) return;
354
+ if (this.#disposed || !this.#registry.get(id)) return;
310
355
  if (typeof this.#ui.showOverlay !== "function") return;
311
356
  this.#closeTranscriptOverlay();
312
357
  this.#notice = undefined;
313
- const viewer = new AgentTranscriptViewer({
358
+ let viewer: AgentTranscriptViewer;
359
+ viewer = new AgentTranscriptViewer({
314
360
  agentId: id,
315
361
  registry: this.#registry,
316
362
  remote: this.#remote,
@@ -325,10 +371,11 @@ export class AgentHubOverlayComponent extends Container {
325
371
  expandKeys: this.#expandKeys,
326
372
  hubKeys: this.#hubKeys,
327
373
  requestRender: this.#requestRender,
328
- onClose: () => this.#closeTranscriptOverlay(),
374
+ onClose: () => this.#closeTranscriptOverlay(viewer),
329
375
  onHubClose: () => {
330
- this.#closeTranscriptOverlay();
331
- this.#onDone();
376
+ if (this.#disposed) return;
377
+ this.#closeTranscriptOverlay(viewer);
378
+ if (!this.#disposed) this.#onDone();
332
379
  },
333
380
  });
334
381
  this.#transcriptViewer = viewer;
@@ -338,13 +385,19 @@ export class AgentHubOverlayComponent extends Container {
338
385
  }
339
386
 
340
387
  /** Close and dispose the transcript overlay, restoring focus to the hub table. */
341
- #closeTranscriptOverlay(): void {
342
- this.#transcriptOverlay?.hide();
388
+ #closeTranscriptOverlay(expectedViewer?: AgentTranscriptViewer): void {
389
+ if (expectedViewer && this.#transcriptViewer !== expectedViewer) return;
390
+ const overlay = this.#transcriptOverlay;
391
+ const viewer = this.#transcriptViewer;
392
+ if (!overlay && !viewer) return;
393
+ overlay?.hide();
343
394
  this.#transcriptOverlay = undefined;
344
- this.#transcriptViewer?.dispose();
395
+ viewer?.dispose();
345
396
  this.#transcriptViewer = undefined;
346
- if (typeof this.#ui.setFocus === "function") this.#ui.setFocus(this);
347
- this.#requestRender();
397
+ if (!this.#disposed) {
398
+ if (typeof this.#ui.setFocus === "function") this.#ui.setFocus(this);
399
+ this.#requestRender();
400
+ }
348
401
  }
349
402
 
350
403
  // ========================================================================
@@ -368,123 +421,459 @@ export class AgentHubOverlayComponent extends Container {
368
421
  #refreshRows(): void {
369
422
  const selectedId = this.#rows[this.#selectedRow]?.id;
370
423
  const refs = this.#registry.list().filter(ref => ref.id !== MAIN_AGENT_ID);
371
-
372
- if (!this.#rowOrder) {
373
- // First refresh (usually the constructor): order by status, then recency.
374
- this.#rows = refs.sort(
375
- (a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status] || b.lastActivity - a.lastActivity,
424
+ this.#observedById = new Map();
425
+ for (const session of this.#observers.getSessions()) this.#observedById.set(session.id, session);
426
+ const rowOrder = this.#rowOrder;
427
+ let rosterRows: AgentRef[];
428
+ if (!rowOrder) {
429
+ rosterRows = refs.sort(
430
+ (a, b) =>
431
+ STATUS_ORDER[a.status] - STATUS_ORDER[b.status] ||
432
+ b.lastActivity - a.lastActivity ||
433
+ a.id.localeCompare(b.id),
376
434
  );
377
- this.#rowOrder = new Map(this.#rows.map((ref, i) => [ref.id, i]));
435
+ this.#rowOrder = new Map();
436
+ for (const ref of rosterRows) this.#rowOrder.set(ref.id, this.#nextRowOrder++);
378
437
  } else {
379
- // After the hub is open, freeze the relative order so keyboard selection
380
- // does not jump around as agents heartbeat or update activity. New agents
381
- // are appended at the end and then stay put.
382
- this.#rows = refs.sort((a, b) => {
383
- const statusDiff = STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
384
- if (statusDiff !== 0) return statusDiff;
385
- const aOrder = this.#rowOrder!.get(a.id) ?? Number.MAX_SAFE_INTEGER;
386
- const bOrder = this.#rowOrder!.get(b.id) ?? Number.MAX_SAFE_INTEGER;
387
- return aOrder - bOrder;
388
- });
389
- for (const ref of this.#rows) {
390
- if (!this.#rowOrder.has(ref.id)) {
391
- this.#rowOrder.set(ref.id, this.#rowOrder.size);
392
- }
438
+ rosterRows = refs.sort(
439
+ (a, b) => (rowOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (rowOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER),
440
+ );
441
+ for (const ref of rosterRows) {
442
+ if (!rowOrder.has(ref.id)) rowOrder.set(ref.id, this.#nextRowOrder++);
393
443
  }
394
444
  }
395
445
 
446
+ if (this.#viewMode === "tree") {
447
+ const tree = projectAgentTree(rosterRows);
448
+ this.#rows = tree.rows;
449
+ this.#treeDepthById = tree.depthById;
450
+ this.#treeParentById = tree.parentById;
451
+ this.#treeLastSiblingById = tree.lastSiblingById;
452
+ } else {
453
+ this.#rows = rosterRows;
454
+ this.#treeDepthById.clear();
455
+ this.#treeParentById.clear();
456
+ this.#treeLastSiblingById.clear();
457
+ }
396
458
  const keptIndex = selectedId ? this.#rows.findIndex(ref => ref.id === selectedId) : -1;
397
459
  this.#selectedRow = keptIndex >= 0 ? keptIndex : Math.min(this.#selectedRow, Math.max(0, this.#rows.length - 1));
460
+ const detailAgentId = this.#rows[this.#selectedRow]?.id;
461
+ if (detailAgentId !== this.#detailAgentId) {
462
+ this.#detailAgentId = detailAgentId;
463
+ this.#detailScrollOffset = 0;
464
+ }
465
+
466
+ this.#childrenByParent.clear();
467
+ for (const ref of rosterRows) {
468
+ const parent = ref.parentId ?? MAIN_AGENT_ID;
469
+ const children = this.#childrenByParent.get(parent);
470
+ if (children) children.push(ref);
471
+ else this.#childrenByParent.set(parent, [ref]);
472
+ }
473
+ this.#statusCounts = { running: 0, idle: 0, parked: 0, aborted: 0 };
474
+ for (const ref of rosterRows) this.#statusCounts[ref.status]++;
475
+ this.#refreshAggregate();
398
476
  }
399
477
 
400
- #observableFor(id: string): ObservableSession | undefined {
401
- return this.#observers.getSessions().find(s => s.id === id);
478
+ #metricsFor(ref: AgentRef, observed: ObservableSession | undefined): AgentMetrics | undefined {
479
+ if (observed?.progress) return progressMetrics(observed);
480
+ if (ref.history?.metrics) return ref.history.metrics;
481
+ const session = this.#fallbackStatsSession(ref, observed);
482
+ return session ? this.#sessionMetrics.get(session)?.metrics : undefined;
483
+ }
484
+
485
+ #fallbackStatsSession(
486
+ ref: AgentRef,
487
+ observed: ObservableSession | undefined,
488
+ ): NonNullable<AgentRef["session"]> | undefined {
489
+ if (observed?.progress) return undefined;
490
+ const session = ref.session;
491
+ return session && typeof session.getSessionStats === "function" ? session : undefined;
402
492
  }
403
493
 
404
494
  // ========================================================================
405
495
  // Table view
406
496
  // ========================================================================
407
497
 
408
- #renderTable(width: number): string[] {
498
+ #renderTable(width: number, termHeight: number): string[] {
499
+ this.#hitRows.length = 0;
500
+ const contentRows = Math.max(1, termHeight - 4);
501
+ const observedById = this.#observedById;
502
+ const split = this.#splitRosterWidth(width);
503
+ this.#lastRenderWasSplit = split !== undefined;
504
+ this.#lastSplitRosterWidth = split;
505
+ const selected = this.#rows[this.#selectedRow];
409
506
  const lines: string[] = [];
410
- lines.push(...new DynamicBorder().render(width));
411
- const counts = this.#statusSummary();
412
- lines.push(` ${theme.fg("accent", "Agent Hub")}${counts ? theme.fg("dim", `${theme.sep.dot}${counts}`) : ""}`);
413
- lines.push(...new DynamicBorder().render(width));
414
507
 
415
- if (this.#rows.length === 0) {
416
- lines.push(` ${theme.fg("dim", "no subagents yet — task spawns appear here")}`);
508
+ if (split !== undefined) {
509
+ const detailWidth = splitBodyWidth(width, split);
510
+ const roster = this.#renderRosterPanel(split, contentRows, observedById);
511
+ const details = this.#renderDetailPanel(selected, detailWidth, contentRows, observedById);
512
+ lines.push(topBorderSplit(width, "Agent Hub", split));
513
+ for (let i = 0; i < contentRows; i++) {
514
+ const hit = roster.hitRows[i];
515
+ if (hit !== undefined) this.#hitRows[lines.length] = hit;
516
+ lines.push(splitRow(roster.lines[i] ?? "", details[i] ?? "", width, split));
517
+ }
518
+ lines.push(dividerSplit(width, split));
519
+ lines.push(row(this.#footer(false, Math.max(1, width - 4)), width));
520
+ lines.push(bottomBorder(width));
521
+ return lines;
522
+ }
523
+
524
+ const innerWidth = Math.max(1, width - 4);
525
+ if (this.#narrowDetailsOpen && selected) {
526
+ const details = this.#renderDetailPanel(selected, innerWidth, contentRows, observedById);
527
+ lines.push(topBorder(width, `Agent Hub · ${selected.id}`));
528
+ for (const detail of details) lines.push(row(detail, width));
417
529
  } else {
418
- const termHeight = process.stdout.rows || 40;
419
- // Chrome: 2 borders + title + notice? + blank + hints + border
420
- const budget = Math.max(4, termHeight - 7 - (this.#notice ? 1 : 0));
421
- const entries = this.#rows.map((ref, i) => this.#renderEntry(ref, i === this.#selectedRow, width));
422
- // Entries are 1-2 lines tall; grow a window around the selection until
423
- // the line budget is spent, so the selected entry stays centered.
424
- let start = this.#selectedRow;
425
- let end = this.#selectedRow + 1;
426
- let used = entries[start]?.length ?? 0;
427
- for (let grew = true; grew; ) {
428
- grew = false;
429
- if (end < entries.length && used + entries[end].length <= budget) {
430
- used += entries[end].length;
530
+ const roster = this.#renderRosterPanel(innerWidth, contentRows, observedById);
531
+ lines.push(topBorder(width, "Agent Hub"));
532
+ for (let i = 0; i < contentRows; i++) {
533
+ const hit = roster.hitRows[i];
534
+ if (hit !== undefined) this.#hitRows[lines.length] = hit;
535
+ lines.push(row(roster.lines[i] ?? "", width));
536
+ }
537
+ }
538
+ lines.push(divider(width));
539
+ lines.push(row(this.#footer(this.#narrowDetailsOpen, innerWidth), width));
540
+ lines.push(bottomBorder(width));
541
+ return lines;
542
+ }
543
+
544
+ #splitRosterWidth(width: number): number | undefined {
545
+ if (width < SPLIT_MIN_WIDTH) return undefined;
546
+ const rosterWidth = Math.max(ROSTER_MIN_WIDTH, Math.min(Math.floor(width * 0.58), width - DETAIL_MIN_WIDTH - 7));
547
+ return splitBodyWidth(width, rosterWidth) >= DETAIL_MIN_WIDTH ? rosterWidth : undefined;
548
+ }
549
+
550
+ #footer(showingNarrowDetails: boolean, availableWidth: number): string {
551
+ const nextView = this.#viewMode === "roster" ? "by parent" : "flat";
552
+ if (showingNarrowDetails) {
553
+ return theme.fg("dim", `Tab:roster PgUp/PgDn:scroll Enter:open t:${nextView} Esc:roster`);
554
+ }
555
+ if (availableWidth < 96) {
556
+ return theme.fg("dim", `j/k:select Enter:open t:${nextView} Tab:details r/x:manage Esc:close`);
557
+ }
558
+ return theme.fg(
559
+ "dim",
560
+ `j/k/wheel:select PgUp/PgDn:details Enter/click:open t:${nextView} r:revive x:kill Esc:close`,
561
+ );
562
+ }
563
+
564
+ #renderRosterPanel(width: number, rows: number, observedById: ReadonlyMap<string, ObservableSession>): RosterRender {
565
+ const lines = this.#summaryLines(width);
566
+ const hitRows: Array<number | undefined> = Array.from({ length: lines.length });
567
+ if (rows >= 8) {
568
+ lines.push("");
569
+ hitRows.push(undefined);
570
+ }
571
+
572
+ const noticeLines = this.#notice ? [theme.fg("error", sanitizeLine(this.#notice, Math.max(10, width)))] : [];
573
+ const budget = Math.max(0, rows - lines.length - noticeLines.length);
574
+ if (this.#rows.length === 0) {
575
+ if (this.#loadingPersistedSubagents) {
576
+ if (budget > 0) {
577
+ lines.push(`${statusGlyph("running")} ${theme.fg("accent", "Loading saved agents…")}`);
578
+ hitRows.push(undefined);
579
+ }
580
+ } else {
581
+ const emptyState = [
582
+ `${theme.fg("muted", theme.status.shadowed)} ${theme.bold("No agents in this session")}`,
583
+ theme.fg("dim", "Finished, parked, and killed subagents remain with the session that created them."),
584
+ theme.fg("dim", "Resume that session with omp-dev --continue, or spawn a task here."),
585
+ ];
586
+ for (const line of emptyState.slice(0, budget)) {
587
+ lines.push(line);
588
+ hitRows.push(undefined);
589
+ }
590
+ }
591
+ } else if (budget > 0) {
592
+ const window = this.#renderRosterWindow(width, budget, observedById);
593
+ lines.push(...window.lines);
594
+ hitRows.push(...window.hitRows);
595
+ }
596
+ for (const notice of noticeLines) {
597
+ lines.push(notice);
598
+ hitRows.push(undefined);
599
+ }
600
+ while (lines.length < rows) {
601
+ lines.push("");
602
+ hitRows.push(undefined);
603
+ }
604
+ return { lines: lines.slice(0, rows), hitRows: hitRows.slice(0, rows) };
605
+ }
606
+
607
+ #renderRosterWindow(
608
+ width: number,
609
+ budget: number,
610
+ _observedById: ReadonlyMap<string, ObservableSession>,
611
+ ): RosterRender {
612
+ const lines: string[] = [];
613
+ const hitRows: Array<number | undefined> = [];
614
+ const rendered = new Map<number, string[]>();
615
+ const entryAt = (index: number): string[] => {
616
+ const cached = rendered.get(index);
617
+ if (cached) return cached;
618
+ const entry = this.#renderEntry(
619
+ this.#rows[index],
620
+ index === this.#selectedRow,
621
+ width,
622
+ this.#observableFor(this.#rows[index].id),
623
+ index === this.#hoveredRow,
624
+ );
625
+ rendered.set(index, entry);
626
+ return entry;
627
+ };
628
+ const appendEntry = (index: number, entry = entryAt(index)): void => {
629
+ for (const line of entry) {
630
+ lines.push(line);
631
+ hitRows.push(index);
632
+ }
633
+ };
634
+
635
+ let start = this.#selectedRow;
636
+ let end = this.#selectedRow + 1;
637
+ let used = entryAt(this.#selectedRow).length;
638
+ if (used > budget) {
639
+ appendEntry(this.#selectedRow, entryAt(this.#selectedRow).slice(0, budget));
640
+ return { lines, hitRows };
641
+ }
642
+
643
+ // Grow a window around the selection. Only visible entries are rendered,
644
+ // so the 5,000-agent Hub retains bounded paint cost.
645
+ for (let grew = true; grew; ) {
646
+ grew = false;
647
+ if (end < this.#rows.length) {
648
+ const next = entryAt(end);
649
+ if (used + next.length <= budget) {
650
+ used += next.length;
431
651
  end++;
432
652
  grew = true;
433
653
  }
434
- if (start > 0 && used + entries[start - 1].length <= budget) {
654
+ }
655
+ if (start > 0) {
656
+ const previous = entryAt(start - 1);
657
+ if (used + previous.length <= budget) {
435
658
  start--;
436
- used += entries[start].length;
659
+ used += previous.length;
437
660
  grew = true;
438
661
  }
439
662
  }
440
- if (start > 0) {
441
- lines.push(` ${theme.fg("dim", `… ${start} more`)}`);
442
- }
443
- for (let i = start; i < end; i++) {
444
- lines.push(...entries[i]);
445
- }
446
- if (end < this.#rows.length) {
447
- lines.push(` ${theme.fg("dim", `… ${this.#rows.length - end} more`)}`);
663
+ }
664
+ // Overflow labels consume real rows. Trim the farthest visible neighbors
665
+ // before painting them so the selected entry and both labels fit.
666
+ for (
667
+ let markerRows = Number(start > 0) + Number(end < this.#rows.length);
668
+ used + markerRows > budget && start < end;
669
+ markerRows = Number(start > 0) + Number(end < this.#rows.length)
670
+ ) {
671
+ if (end - 1 > this.#selectedRow) {
672
+ end--;
673
+ used -= entryAt(end).length;
674
+ } else if (start < this.#selectedRow) {
675
+ used -= entryAt(start).length;
676
+ start++;
677
+ } else {
678
+ break;
448
679
  }
449
680
  }
681
+ const showTopOverflow = start > 0 && used < budget;
682
+ const showBottomOverflow = end < this.#rows.length && used + Number(showTopOverflow) < budget;
683
+ if (showTopOverflow) {
684
+ lines.push(theme.fg("dim", `… ${start} more`));
685
+ hitRows.push(undefined);
686
+ }
687
+ for (let i = start; i < end; i++) appendEntry(i);
688
+ if (showBottomOverflow) {
689
+ lines.push(theme.fg("dim", `… ${this.#rows.length - end} more`));
690
+ hitRows.push(undefined);
691
+ }
692
+ return { lines, hitRows };
693
+ }
450
694
 
451
- if (this.#notice) {
452
- lines.push(` ${theme.fg("error", sanitizeLine(this.#notice, Math.max(10, width - 2)))}`);
695
+ #summaryLines(width: number): string[] {
696
+ const active = (label: string): string => theme.bg("selectedBg", theme.bold(theme.fg("accent", ` ${label} `)));
697
+ const inactive = (label: string): string => theme.fg("muted", ` ${label} `);
698
+ const projection =
699
+ this.#viewMode === "roster"
700
+ ? `${active("Flat")}${theme.fg("dim", "/")}${inactive("By parent")}`
701
+ : `${inactive("Flat")}${theme.fg("dim", "/")}${active("By parent")}`;
702
+ const counts = this.#statusSummary();
703
+ const header = `${theme.bold("Roster")}${theme.fg("dim", theme.sep.dot)}${projection}${counts ? theme.fg("dim", theme.sep.dot) + counts : ""}`;
704
+ const lines = wrapTextWithAnsi(header, Math.max(1, width));
705
+
706
+ const metrics = this.#aggregate;
707
+ if (metrics.reportedAgents === 0) {
708
+ lines.push(
709
+ ...wrapTextWithAnsi(
710
+ theme.fg("dim", `Usage —${theme.sep.dot}0/${this.#rows.length} measured`),
711
+ Math.max(1, width),
712
+ ),
713
+ );
714
+ return lines;
453
715
  }
454
- lines.push("");
455
- lines.push(` ${theme.fg("dim", "j/k:select Enter:open r:revive x:kill Esc/←←:close")}`);
456
- lines.push(...new DynamicBorder().render(width));
716
+ const activeTime = formatMetricDuration(metrics);
717
+ const usage = [
718
+ theme.fg("statusLineCost", formatCost(metrics.cost)),
719
+ theme.fg("dim", activeTime ? `${activeTime} agent time` : "agent time —"),
720
+ theme.fg("dim", `${formatNumber(metrics.requests)} req`),
721
+ theme.fg("dim", `${formatNumber(metrics.tools)} tools`),
722
+ theme.fg("dim", `${formatNumber(metrics.tokens)} tok`),
723
+ theme.fg("dim", `${metrics.activeDurationAgents}/${metrics.reportedAgents} timed`),
724
+ theme.fg("dim", `${metrics.reportedAgents}/${this.#rows.length} measured`),
725
+ ].join(theme.fg("dim", theme.sep.dot));
726
+ lines.push(...wrapTextWithAnsi(usage, Math.max(1, width)));
457
727
  return lines;
458
728
  }
459
729
 
460
730
  #statusSummary(): string {
461
- const counts: Record<AgentStatus, number> = { running: 0, idle: 0, parked: 0, aborted: 0 };
462
- for (const ref of this.#rows) {
463
- counts[ref.status]++;
464
- }
465
731
  const parts: string[] = [];
466
732
  for (const status of ["running", "idle", "parked", "aborted"] as const) {
467
- const count = counts[status];
468
- if (count > 0) parts.push(`${count} ${status}`);
733
+ const count = this.#statusCounts[status];
734
+ if (count > 0) parts.push(`${statusGlyph(status)} ${statusText(status, `${count} ${status}`)}`);
469
735
  }
470
736
  return parts.join(theme.sep.dot);
471
737
  }
472
738
 
739
+ #refreshAggregate(refreshFallback = false): void {
740
+ const result = aggregateMetrics({
741
+ rows: this.#rows,
742
+ observedById: this.#observedById,
743
+ metricsFor: (ref, observed) => this.#metricsFor(ref, observed),
744
+ fallbackStatsSession: (ref, observed) => this.#fallbackStatsSession(ref, observed),
745
+ sessionMetrics: this.#sessionMetrics,
746
+ refreshFallback,
747
+ });
748
+ this.#aggregate = result.metrics;
749
+ this.#hasFallbackLiveSessions = result.hasFallbackLiveSessions;
750
+ }
751
+
752
+ #observableFor(id: string): ObservableSession | undefined {
753
+ return this.#observedById.get(id) ?? this.#observers.getSession(id);
754
+ }
755
+
756
+ #renderDetailPanel(
757
+ ref: AgentRef | undefined,
758
+ width: number,
759
+ rows: number,
760
+ _observedById: ReadonlyMap<string, ObservableSession>,
761
+ ): string[] {
762
+ if (!ref) return [theme.fg("dim", "Select an agent to inspect"), ...Array.from({ length: rows - 1 }, () => "")];
763
+ const observed = this.#observableFor(ref.id);
764
+ const progress = observed?.progress;
765
+ const metrics = this.#metricsFor(ref, observed);
766
+ const children = this.#childrenByParent.get(ref.id) ?? [];
767
+ const lines: string[] = [];
768
+ const add = (line = ""): void => {
769
+ lines.push(truncateToWidth(line, width));
770
+ };
771
+ const addWrapped = (text: string, maxRows = 2): void => {
772
+ for (const wrapped of wrapTextWithAnsi(sanitizeLine(text), Math.max(1, width)).slice(0, maxRows)) add(wrapped);
773
+ };
774
+ const section = (label: string, contentRows = 0): void => {
775
+ if (lines.length > 0 && lines.length + 1 + contentRows < rows) add();
776
+ add(theme.bold(theme.fg("accent", label)));
777
+ };
778
+
779
+ add(`${statusGlyph(ref.status)} ${theme.bold(sanitizeDisplayText(ref.displayName || ref.id))}`);
780
+ if (ref.displayName && ref.displayName !== ref.id) add(theme.fg("dim", sanitizeDisplayText(ref.id)));
781
+ const lifecycleDetails = [
782
+ metrics ? formatMetricDuration(metrics) : undefined,
783
+ `active ${formatAge(Math.max(1, Math.round((Date.now() - ref.lastActivity) / 1000)))}`,
784
+ ].filter(Boolean);
785
+ add(
786
+ `${statusText(ref.status, ref.status)}${theme.fg("dim", `${theme.sep.dot}${lifecycleDetails.join(theme.sep.dot)}`)}`,
787
+ );
788
+ const modelDetails: string[] = [];
789
+ const modelRole = progress?.modelRole ?? ref.history?.modelRole;
790
+ if (modelRole && this.#settings) modelDetails.push(formatRoleBadge(modelRole, this.#settings));
791
+ const badge = modelBadge(ref, observed);
792
+ if (badge) modelDetails.push(badge);
793
+ if (modelDetails.length > 0) add(modelDetails.join(theme.sep.dot));
794
+
795
+ const task = observed?.description ?? progress?.task ?? ref.activity;
796
+ if (task) {
797
+ section("Task");
798
+ addWrapped(task);
799
+ }
800
+
801
+ const current = progress?.currentTool
802
+ ? `${progress.currentTool}${progress.currentToolArgs ? ` · ${progress.currentToolArgs}` : ""}`
803
+ : (progress?.lastIntent ?? ref.activity);
804
+ if (current) {
805
+ section("Current");
806
+ addWrapped(current);
807
+ if (progress?.retryState) {
808
+ add(theme.fg("warning", `retry ${progress.retryState.attempt}/${progress.retryState.maxAttempts}`));
809
+ }
810
+ }
811
+
812
+ section("Usage", 1);
813
+ if (metrics) {
814
+ addWrapped(formatMetrics(metrics), 3);
815
+ if (metrics.contextTokens !== undefined && metrics.contextWindow) {
816
+ add(contextGauge(metrics.contextTokens, metrics.contextWindow));
817
+ }
818
+ } else {
819
+ add(theme.fg("dim", "usage —"));
820
+ }
821
+
822
+ section("Lineage");
823
+ add(
824
+ `Spawned by ${sanitizeDisplayText(ref.parentId ?? MAIN_AGENT_ID)}${children.length > 0 ? ` · ${children.length} children` : ""}`,
825
+ );
826
+ if (children.length > 0) add(theme.fg("dim", formatChildIds(children, width)));
827
+ add(theme.fg("dim", `Registered ${new Date(ref.createdAt).toISOString().slice(0, 16).replace("T", " ")}Z`));
828
+
829
+ section("Changes");
830
+ add(
831
+ theme.fg(
832
+ "dim",
833
+ ref.kind === "advisor" || ref.history?.readOnly
834
+ ? "Read-only · 0 LoC"
835
+ : "Shared workspace · per-agent LoC not attributable",
836
+ ),
837
+ );
838
+ const artifacts = ref.history;
839
+ if (artifacts?.outputPath) addWrapped(`Output ${shortenPath(artifacts.outputPath)}`);
840
+ if (artifacts?.patchPath) addWrapped(`Patch ${shortenPath(artifacts.patchPath)}`);
841
+ if (artifacts?.branchName) addWrapped(`Worktree branch ${artifacts.branchName}`);
842
+
843
+ const maxScroll = Math.max(0, lines.length - rows);
844
+ this.#detailScrollOffset = Math.min(this.#detailScrollOffset, maxScroll);
845
+ const visible = lines.slice(this.#detailScrollOffset, this.#detailScrollOffset + rows);
846
+ while (visible.length < rows) visible.push("");
847
+ return visible;
848
+ }
849
+
473
850
  /**
474
- * One agent entry, 1-2 lines:
475
- * `❯ Name type ↳ parent ⧉ 2 ········ model level · age` identity
476
- * left, metadata right-aligned (inlined when the terminal is too narrow) —
477
- * plus an indented dim task line when the agent's work is known.
851
+ * One agent entry keeps identity/model metadata on one line when it fits,
852
+ * then packs task and all five usage metrics together below. Narrow rows wrap
853
+ * only those dense secondary fields.
478
854
  */
479
- #renderEntry(ref: AgentRef, selected: boolean, width: number): string[] {
480
- const max = Math.max(1, width - 2);
855
+ #renderEntry(
856
+ ref: AgentRef,
857
+ selected: boolean,
858
+ width: number,
859
+ observed: ObservableSession | undefined,
860
+ hovered = false,
861
+ ): string[] {
862
+ const max = Math.max(1, width);
481
863
  const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ";
482
- const fields: string[] = [`${cursor} ${statusGlyph(ref.status)} ${theme.bold(replaceTabs(ref.id))}`];
864
+ const depth = this.#viewMode === "tree" ? (this.#treeDepthById.get(ref.id) ?? 0) : 0;
865
+ const branch =
866
+ this.#viewMode === "tree"
867
+ ? treeBranch(ref, max, this.#treeDepthById, this.#treeParentById, this.#treeLastSiblingById)
868
+ : "";
869
+ const id = sanitizeDisplayText(ref.id);
870
+ const styledId = selected ? theme.bold(theme.fg("accent", id)) : theme.bold(id);
871
+ const fields: string[] = [`${cursor} ${statusGlyph(ref.status)} ${branch}${styledId}`];
483
872
  if (ref.displayName && ref.displayName !== ref.id) {
484
- fields.push(theme.fg("dim", replaceTabs(ref.displayName)));
873
+ fields.push(theme.fg("dim", sanitizeDisplayText(ref.displayName)));
485
874
  }
486
- if (ref.parentId && ref.parentId !== MAIN_AGENT_ID) {
487
- fields.push(theme.fg("dim", `↳ ${replaceTabs(ref.parentId)}`));
875
+ if (this.#viewMode === "roster" && ref.parentId && ref.parentId !== MAIN_AGENT_ID) {
876
+ fields.push(theme.fg("dim", `↳ ${sanitizeDisplayText(ref.parentId)}`));
488
877
  }
489
878
  if (ref.kind === "advisor") {
490
879
  fields.push(theme.fg("warning", "read-only"));
@@ -493,10 +882,13 @@ export class AgentHubOverlayComponent extends Container {
493
882
  if (unread > 0) {
494
883
  fields.push(theme.fg("warning", `⧉ ${unread}`));
495
884
  }
496
- const left = ` ${fields.join(" ")}`;
885
+ const left = fields.join(" ");
497
886
 
498
- const observed = this.#observableFor(ref.id);
499
887
  const meta: string[] = [];
888
+ const modelRole = observed?.progress?.modelRole ?? ref.history?.modelRole;
889
+ if (modelRole && this.#settings) {
890
+ meta.push(formatRoleBadge(modelRole, this.#settings));
891
+ }
500
892
  const badge = modelBadge(ref, observed);
501
893
  if (badge) meta.push(badge);
502
894
  meta.push(theme.fg("dim", formatAge(Math.max(1, Math.round((Date.now() - ref.lastActivity) / 1000)))));
@@ -504,25 +896,110 @@ export class AgentHubOverlayComponent extends Container {
504
896
 
505
897
  const leftWidth = visibleWidth(left);
506
898
  const rightWidth = visibleWidth(right);
507
- const line =
508
- leftWidth + 2 + rightWidth <= max
509
- ? left + padding(max - leftWidth - rightWidth) + right
510
- : truncateToWidth(`${left} ${right}`.replace(/[\r\n]+/g, " "), max);
511
- const entry = [line];
899
+ const entry: string[] = [];
900
+ const detailIndent = Math.min(max - 1, 4 + depth * 2);
901
+ if (leftWidth + 2 + rightWidth <= max) {
902
+ entry.push(left + padding(max - leftWidth - rightWidth) + right);
903
+ } else {
904
+ entry.push(truncateToWidth(left.replace(/[\r\n]+/g, " "), max));
905
+ entry.push(`${padding(Math.max(0, detailIndent))}${truncateToWidth(right, Math.max(1, max - detailIndent))}`);
906
+ }
512
907
 
908
+ const metrics = this.#metricsFor(ref, observed);
909
+ const usage = metrics ? theme.fg("dim", formatMetrics(metrics)) : theme.fg("dim", "usage —");
513
910
  const task = observed?.description ?? observed?.progress?.task ?? ref.activity;
514
- if (task) {
515
- entry.push(` ${theme.fg("muted", sanitizeLine(task, Math.max(10, max - 5)))}`);
911
+ const detailWidth = Math.max(1, max - detailIndent);
912
+ const details = task
913
+ ? `${theme.fg("muted", sanitizeLine(task, detailWidth))}${theme.fg("dim", theme.sep.dot)}${usage}`
914
+ : usage;
915
+ for (const wrapped of wrapTextWithAnsi(details, detailWidth)) {
916
+ entry.push(`${padding(Math.max(0, detailIndent))}${wrapped}`);
516
917
  }
517
- return entry;
918
+ if (!hovered) return entry;
919
+ return entry.map(lineRow => {
920
+ const rowWidth = visibleWidth(lineRow);
921
+ return theme.bg("selectedBg", rowWidth < max ? lineRow + padding(max - rowWidth) : lineRow);
922
+ });
923
+ }
924
+
925
+ #scrollDetails(direction: -1 | 1): void {
926
+ this.#detailScrollOffset = Math.max(0, this.#detailScrollOffset + direction * 5);
927
+ this.#requestRender();
928
+ }
929
+
930
+ #selectRow(index: number): void {
931
+ if (index !== this.#selectedRow) {
932
+ this.#detailScrollOffset = 0;
933
+ this.#detailAgentId = this.#rows[index]?.id;
934
+ }
935
+ this.#selectedRow = index;
936
+ }
937
+
938
+ handleWheel(delta: -1 | 1): void {
939
+ this.#hoveredRow = null;
940
+ if (this.#rows.length > 0) {
941
+ this.#selectRow(Math.max(0, Math.min(this.#selectedRow + delta, this.#rows.length - 1)));
942
+ }
943
+ this.#requestRender();
944
+ }
945
+
946
+ hitTest(line: number): number | undefined {
947
+ return this.#hitRows[line];
948
+ }
949
+
950
+ setHoverIndex(index: number | null): void {
951
+ if (index === this.#hoveredRow) return;
952
+ this.#hoveredRow = index;
953
+ this.#requestRender();
954
+ }
955
+
956
+ clickItem(index: number): void {
957
+ const selected = this.#rows[index];
958
+ if (!selected) return;
959
+ this.#hoveredRow = index;
960
+ this.#selectRow(index);
961
+ this.#requestRender();
962
+ this.#activateAgent(selected);
518
963
  }
519
964
 
520
965
  #handleTableInput(keyData: string): void {
521
966
  if (matchesKey(keyData, "escape")) {
522
- this.#onDone();
967
+ if (this.#narrowDetailsOpen && !this.#lastRenderWasSplit) {
968
+ this.#narrowDetailsOpen = false;
969
+ this.#requestRender();
970
+ } else {
971
+ this.#onDone();
972
+ }
973
+ return;
974
+ }
975
+ if ((matchesKey(keyData, "tab") || keyData === "\t") && !this.#lastRenderWasSplit) {
976
+ if (this.#rows.length > 0) this.#narrowDetailsOpen = !this.#narrowDetailsOpen;
977
+ this.#requestRender();
978
+ return;
979
+ }
980
+ if (this.#lastRenderWasSplit || this.#narrowDetailsOpen) {
981
+ if (matchesKey(keyData, "pageUp")) {
982
+ this.#scrollDetails(-1);
983
+ return;
984
+ }
985
+ if (matchesKey(keyData, "pageDown")) {
986
+ this.#scrollDetails(1);
987
+ return;
988
+ }
989
+ }
990
+ if (keyData === "t") {
991
+ this.#hoveredRow = null;
992
+ this.#viewMode = this.#viewMode === "roster" ? "tree" : "roster";
993
+ this.#refreshRows();
994
+ this.#requestRender();
523
995
  return;
524
996
  }
525
997
  if (matchesKey(keyData, "left")) {
998
+ if (this.#narrowDetailsOpen && !this.#lastRenderWasSplit) {
999
+ this.#narrowDetailsOpen = false;
1000
+ this.#requestRender();
1001
+ return;
1002
+ }
526
1003
  const now = Date.now();
527
1004
  if (now - this.#lastLeftTap < LEFT_TAP_WINDOW_MS) {
528
1005
  this.#lastLeftTap = 0;
@@ -532,16 +1009,17 @@ export class AgentHubOverlayComponent extends Container {
532
1009
  }
533
1010
  return;
534
1011
  }
1012
+ this.#hoveredRow = null;
535
1013
  if (matchesKey(keyData, "j") || matchesSelectDown(keyData)) {
536
1014
  if (this.#rows.length > 0) {
537
- this.#selectedRow = Math.min(this.#selectedRow + 1, this.#rows.length - 1);
1015
+ this.#selectRow(Math.min(this.#selectedRow + 1, this.#rows.length - 1));
538
1016
  }
539
1017
  this.#requestRender();
540
1018
  return;
541
1019
  }
542
1020
  if (matchesKey(keyData, "k") || matchesSelectUp(keyData)) {
543
1021
  if (this.#rows.length > 0) {
544
- this.#selectedRow = Math.max(this.#selectedRow - 1, 0);
1022
+ this.#selectRow(Math.max(this.#selectedRow - 1, 0));
545
1023
  }
546
1024
  this.#requestRender();
547
1025
  return;