@oh-my-pi/pi-coding-agent 17.2.9 → 17.2.11

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