@fitchmultz/pi-subagents 0.0.0-stage → 0.44.5

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 (347) hide show
  1. package/CHANGELOG.md +1654 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1533 -2
  4. package/agents/context-builder.md +97 -0
  5. package/agents/debugger.md +51 -0
  6. package/agents/delegate.md +13 -0
  7. package/agents/fixer.md +37 -0
  8. package/agents/oracle.md +71 -0
  9. package/agents/planner.md +72 -0
  10. package/agents/researcher.md +51 -0
  11. package/agents/reviewer-claude.md +69 -0
  12. package/agents/reviewer-gpt.md +68 -0
  13. package/agents/reviewer-ponytail.md +82 -0
  14. package/agents/reviewer-security.md +81 -0
  15. package/agents/reviewer.md +69 -0
  16. package/agents/scout.md +66 -0
  17. package/agents/ui-designer.md +51 -0
  18. package/agents/watcher.md +26 -0
  19. package/agents/worker.md +46 -0
  20. package/agents/writer.md +36 -0
  21. package/dist/agents/agent-management.js +923 -0
  22. package/dist/agents/agent-scope.js +7 -0
  23. package/dist/agents/agent-selection.js +20 -0
  24. package/dist/agents/agent-serializer.js +100 -0
  25. package/dist/agents/agents.js +752 -0
  26. package/dist/agents/chain-serializer.js +367 -0
  27. package/dist/agents/frontmatter.js +21 -0
  28. package/dist/agents/identity.js +29 -0
  29. package/dist/agents/skills.js +570 -0
  30. package/dist/extension/build-info.js +1 -0
  31. package/dist/extension/compact-tools.js +112 -0
  32. package/dist/extension/config.js +22 -0
  33. package/dist/extension/control-notices.js +29 -0
  34. package/dist/extension/doctor.js +143 -0
  35. package/dist/extension/fanout-child.js +326 -0
  36. package/dist/extension/index.js +577 -0
  37. package/dist/extension/schemas.js +416 -0
  38. package/dist/extension/tool-input.js +20 -0
  39. package/dist/extension/tool-result.js +14 -0
  40. package/dist/history/canonical-result.js +81 -0
  41. package/dist/history/database.js +72 -0
  42. package/dist/history/index.js +199 -0
  43. package/dist/history/ingest.js +468 -0
  44. package/dist/history/queries.js +350 -0
  45. package/dist/history/store.js +216 -0
  46. package/dist/history/types.js +4 -0
  47. package/dist/history/worker.js +466 -0
  48. package/dist/intercom/intercom-bridge.js +112 -0
  49. package/dist/intercom/live-intercom.js +94 -0
  50. package/dist/intercom/result-intercom.js +373 -0
  51. package/dist/pi-intercom/agent-dir.js +10 -0
  52. package/dist/pi-intercom/bridge.js +750 -0
  53. package/dist/pi-intercom/broker/broker.js +426 -0
  54. package/dist/pi-intercom/broker/client.js +492 -0
  55. package/dist/pi-intercom/broker/framing.js +65 -0
  56. package/dist/pi-intercom/broker/paths.js +42 -0
  57. package/dist/pi-intercom/broker/pid.js +66 -0
  58. package/dist/pi-intercom/broker/spawn.js +221 -0
  59. package/dist/pi-intercom/config.js +102 -0
  60. package/dist/pi-intercom/index.js +2942 -0
  61. package/dist/pi-intercom/reply-tracker.js +182 -0
  62. package/dist/pi-intercom/session-targets.js +128 -0
  63. package/dist/pi-intercom/subagent-live-events.js +132 -0
  64. package/dist/pi-intercom/topics.js +157 -0
  65. package/dist/pi-intercom/types.js +163 -0
  66. package/dist/pi-intercom/ui/compose.js +269 -0
  67. package/dist/pi-intercom/ui/inline-message.js +80 -0
  68. package/dist/pi-intercom/ui/session-list.js +124 -0
  69. package/dist/runs/background/async-control.js +95 -0
  70. package/dist/runs/background/async-execution.js +676 -0
  71. package/dist/runs/background/async-job-tracker.js +383 -0
  72. package/dist/runs/background/async-result-file.js +58 -0
  73. package/dist/runs/background/async-resume.js +305 -0
  74. package/dist/runs/background/async-status.js +442 -0
  75. package/dist/runs/background/completion-dedupe.js +69 -0
  76. package/dist/runs/background/completion-delivery.js +245 -0
  77. package/dist/runs/background/notify.js +71 -0
  78. package/dist/runs/background/parallel-groups.js +55 -0
  79. package/dist/runs/background/result-watcher.js +426 -0
  80. package/dist/runs/background/run-id-resolver.js +64 -0
  81. package/dist/runs/background/run-status.js +382 -0
  82. package/dist/runs/background/stale-run-reconciler.js +335 -0
  83. package/dist/runs/background/subagent-runner-launcher.js +55 -0
  84. package/dist/runs/background/subagent-runner.js +1872 -0
  85. package/dist/runs/background/top-level-async.js +5 -0
  86. package/dist/runs/foreground/chain-clarify.js +1152 -0
  87. package/dist/runs/foreground/clarify-invocation.js +72 -0
  88. package/dist/runs/foreground/execution-input.js +423 -0
  89. package/dist/runs/foreground/foreground-control.js +817 -0
  90. package/dist/runs/foreground/question-control.js +88 -0
  91. package/dist/runs/foreground/run-async-path.js +205 -0
  92. package/dist/runs/foreground/subagent-executor.js +442 -0
  93. package/dist/runs/foreground/subagent-params.js +165 -0
  94. package/dist/runs/foreground/wait-run.js +141 -0
  95. package/dist/runs/shared/acceptance-contract.js +253 -0
  96. package/dist/runs/shared/acceptance-evaluation.js +256 -0
  97. package/dist/runs/shared/acceptance-finalization.js +280 -0
  98. package/dist/runs/shared/acceptance-reports.js +147 -0
  99. package/dist/runs/shared/acceptance.js +4 -0
  100. package/dist/runs/shared/chain-outputs.js +110 -0
  101. package/dist/runs/shared/child-attempt.js +693 -0
  102. package/dist/runs/shared/child-execution-cwd.js +96 -0
  103. package/dist/runs/shared/child-observations.js +114 -0
  104. package/dist/runs/shared/claude-code.js +199 -0
  105. package/dist/runs/shared/completion-guard.js +31 -0
  106. package/dist/runs/shared/dynamic-fanout.js +274 -0
  107. package/dist/runs/shared/history-index.js +72 -0
  108. package/dist/runs/shared/mcp-direct-tool-allowlist.js +334 -0
  109. package/dist/runs/shared/model-fallback.js +185 -0
  110. package/dist/runs/shared/mutating-tool-guard.js +170 -0
  111. package/dist/runs/shared/native-finalization.js +101 -0
  112. package/dist/runs/shared/native-usage.js +153 -0
  113. package/dist/runs/shared/nested-events.js +700 -0
  114. package/dist/runs/shared/nested-path.js +48 -0
  115. package/dist/runs/shared/nested-render.js +108 -0
  116. package/dist/runs/shared/parallel-utils.js +61 -0
  117. package/dist/runs/shared/parent-receipts.js +68 -0
  118. package/dist/runs/shared/parent-usage.js +121 -0
  119. package/dist/runs/shared/pi-args.js +310 -0
  120. package/dist/runs/shared/pi-spawn.js +52 -0
  121. package/dist/runs/shared/run-history-queries.js +45 -0
  122. package/dist/runs/shared/run-history.js +110 -0
  123. package/dist/runs/shared/run-id-ambiguity.js +4 -0
  124. package/dist/runs/shared/run-records.js +679 -0
  125. package/dist/runs/shared/session-cwd-preload.js +33 -0
  126. package/dist/runs/shared/single-output.js +222 -0
  127. package/dist/runs/shared/streaming-text.js +15 -0
  128. package/dist/runs/shared/structured-output.js +55 -0
  129. package/dist/runs/shared/subagent-control.js +179 -0
  130. package/dist/runs/shared/subagent-prompt-runtime.js +197 -0
  131. package/dist/runs/shared/subagent-tool-loop-guard.js +66 -0
  132. package/dist/runs/shared/supervisor-questions.js +350 -0
  133. package/dist/runs/shared/workflow-graph.js +214 -0
  134. package/dist/runs/shared/workflow-policy.js +84 -0
  135. package/dist/runs/shared/worktree.js +525 -0
  136. package/dist/shared/agent-context-policy.js +126 -0
  137. package/dist/shared/artifacts.js +96 -0
  138. package/dist/shared/atomic-json.js +13 -0
  139. package/dist/shared/cooperative.js +20 -0
  140. package/dist/shared/execution-cwd.js +14 -0
  141. package/dist/shared/file-coalescer.js +21 -0
  142. package/dist/shared/fork-context.js +49 -0
  143. package/dist/shared/formatters.js +85 -0
  144. package/dist/shared/journal-reader.js +407 -0
  145. package/dist/shared/lazy-tools.js +32 -0
  146. package/dist/shared/model-info.js +68 -0
  147. package/dist/shared/native-session.js +15 -0
  148. package/dist/shared/native-tui.js +18 -0
  149. package/dist/shared/native-typebox.js +20 -0
  150. package/dist/shared/post-exit-stdio-guard.js +197 -0
  151. package/dist/shared/prompt-sections.js +8 -0
  152. package/dist/shared/session-entries.js +52 -0
  153. package/dist/shared/session-identity.js +14 -0
  154. package/dist/shared/settings.js +254 -0
  155. package/dist/shared/status-format.js +111 -0
  156. package/dist/shared/temp-root.js +114 -0
  157. package/dist/shared/types.js +207 -0
  158. package/dist/shared/ui-mode.js +3 -0
  159. package/dist/shared/utils.js +334 -0
  160. package/dist/slash/prompt-template-bridge.js +298 -0
  161. package/dist/slash/slash-bridge.js +116 -0
  162. package/dist/slash/slash-commands.js +607 -0
  163. package/dist/slash/slash-live-state.js +246 -0
  164. package/dist/tui/action-hints.js +75 -0
  165. package/dist/tui/agent-history.js +197 -0
  166. package/dist/tui/agent-view.js +1656 -0
  167. package/dist/tui/render.js +1515 -0
  168. package/docs/intercom-bridge.md +212 -0
  169. package/docs/intercom.md +581 -0
  170. package/docs/performance.md +121 -0
  171. package/package.json +102 -4
  172. package/prompts/gather-context-and-clarify.md +14 -0
  173. package/prompts/parallel-cleanup.md +62 -0
  174. package/prompts/parallel-context-build.md +56 -0
  175. package/prompts/parallel-handoff-plan.md +62 -0
  176. package/prompts/parallel-research.md +51 -0
  177. package/prompts/parallel-review.md +59 -0
  178. package/prompts/review-loop.md +42 -0
  179. package/scripts/build.mjs +134 -0
  180. package/scripts/check-compat.mjs +48 -0
  181. package/scripts/compat-host.mjs +19 -0
  182. package/scripts/intercom-bridge-credentials.mjs +143 -0
  183. package/scripts/intercom-remote.mjs +103 -0
  184. package/scripts/linux-smoke.sh +51 -0
  185. package/scripts/local-install-smoke.mjs +66 -0
  186. package/scripts/native-package-smoke.mjs +82 -0
  187. package/scripts/package-smoke.mjs +209 -0
  188. package/scripts/prepare.mjs +67 -0
  189. package/scripts/real-pi-smoke.mjs +415 -0
  190. package/scripts/run-tests.mjs +149 -0
  191. package/skills/pi-intercom/SKILL.md +113 -0
  192. package/skills/pi-intercom/evals/evals.json +29 -0
  193. package/skills/pi-intercom/evals/trigger-evals.json +47 -0
  194. package/skills/pi-intercom/references/peer-sessions.md +69 -0
  195. package/skills/pi-subagents/SKILL.md +76 -0
  196. package/skills/pi-subagents/evals/evals.json +29 -0
  197. package/skills/pi-subagents/evals/trigger-evals.json +42 -0
  198. package/skills/pi-subagents/references/full-orchestration-guide.md +863 -0
  199. package/src/agents/agent-management.ts +813 -0
  200. package/src/agents/agent-scope.ts +7 -0
  201. package/src/agents/agent-selection.ts +23 -0
  202. package/src/agents/agent-serializer.ts +99 -0
  203. package/src/agents/agents.ts +916 -0
  204. package/src/agents/chain-serializer.ts +329 -0
  205. package/src/agents/frontmatter.ts +21 -0
  206. package/src/agents/identity.ts +30 -0
  207. package/src/agents/skills.ts +637 -0
  208. package/src/extension/build-info.ts +2 -0
  209. package/src/extension/compact-tools.ts +114 -0
  210. package/src/extension/config.ts +23 -0
  211. package/src/extension/control-notices.ts +47 -0
  212. package/src/extension/doctor.ts +171 -0
  213. package/src/extension/fanout-child.ts +312 -0
  214. package/src/extension/index.ts +649 -0
  215. package/src/extension/schemas.ts +444 -0
  216. package/src/extension/tool-input.ts +19 -0
  217. package/src/extension/tool-result.ts +16 -0
  218. package/src/history/canonical-result.ts +62 -0
  219. package/src/history/database.ts +59 -0
  220. package/src/history/index.ts +156 -0
  221. package/src/history/ingest.ts +349 -0
  222. package/src/history/queries.ts +241 -0
  223. package/src/history/store.ts +203 -0
  224. package/src/history/types.ts +166 -0
  225. package/src/history/worker.ts +321 -0
  226. package/src/intercom/intercom-bridge.ts +123 -0
  227. package/src/intercom/live-intercom.ts +104 -0
  228. package/src/intercom/result-intercom.ts +429 -0
  229. package/src/pi-intercom/agent-dir.ts +9 -0
  230. package/src/pi-intercom/bridge.ts +668 -0
  231. package/src/pi-intercom/broker/broker.ts +463 -0
  232. package/src/pi-intercom/broker/client.ts +558 -0
  233. package/src/pi-intercom/broker/framing.ts +77 -0
  234. package/src/pi-intercom/broker/paths.ts +47 -0
  235. package/src/pi-intercom/broker/pid.ts +59 -0
  236. package/src/pi-intercom/broker/spawn.ts +248 -0
  237. package/src/pi-intercom/config.ts +142 -0
  238. package/src/pi-intercom/index.ts +2929 -0
  239. package/src/pi-intercom/reply-tracker.ts +211 -0
  240. package/src/pi-intercom/session-targets.ts +172 -0
  241. package/src/pi-intercom/subagent-live-events.ts +133 -0
  242. package/src/pi-intercom/topics.ts +125 -0
  243. package/src/pi-intercom/types.ts +271 -0
  244. package/src/pi-intercom/ui/compose.ts +289 -0
  245. package/src/pi-intercom/ui/inline-message.ts +92 -0
  246. package/src/pi-intercom/ui/session-list.ts +139 -0
  247. package/src/runs/background/async-control.ts +89 -0
  248. package/src/runs/background/async-execution.ts +850 -0
  249. package/src/runs/background/async-job-tracker.ts +385 -0
  250. package/src/runs/background/async-result-file.ts +62 -0
  251. package/src/runs/background/async-resume.ts +333 -0
  252. package/src/runs/background/async-status.ts +481 -0
  253. package/src/runs/background/completion-dedupe.ts +79 -0
  254. package/src/runs/background/completion-delivery.ts +195 -0
  255. package/src/runs/background/notify.ts +123 -0
  256. package/src/runs/background/parallel-groups.ts +53 -0
  257. package/src/runs/background/result-watcher.ts +423 -0
  258. package/src/runs/background/run-id-resolver.ts +69 -0
  259. package/src/runs/background/run-status.ts +384 -0
  260. package/src/runs/background/stale-run-reconciler.ts +369 -0
  261. package/src/runs/background/subagent-runner-launcher.ts +56 -0
  262. package/src/runs/background/subagent-runner.ts +2053 -0
  263. package/src/runs/background/top-level-async.ts +13 -0
  264. package/src/runs/foreground/chain-clarify.ts +1340 -0
  265. package/src/runs/foreground/clarify-invocation.ts +77 -0
  266. package/src/runs/foreground/execution-input.ts +467 -0
  267. package/src/runs/foreground/foreground-control.ts +821 -0
  268. package/src/runs/foreground/question-control.ts +82 -0
  269. package/src/runs/foreground/run-async-path.ts +243 -0
  270. package/src/runs/foreground/subagent-executor.ts +501 -0
  271. package/src/runs/foreground/subagent-params.ts +320 -0
  272. package/src/runs/foreground/wait-run.ts +124 -0
  273. package/src/runs/shared/acceptance-contract.ts +257 -0
  274. package/src/runs/shared/acceptance-evaluation.ts +275 -0
  275. package/src/runs/shared/acceptance-finalization.ts +353 -0
  276. package/src/runs/shared/acceptance-reports.ts +152 -0
  277. package/src/runs/shared/acceptance.ts +33 -0
  278. package/src/runs/shared/chain-outputs.ts +114 -0
  279. package/src/runs/shared/child-attempt.ts +623 -0
  280. package/src/runs/shared/child-execution-cwd.ts +91 -0
  281. package/src/runs/shared/child-observations.ts +99 -0
  282. package/src/runs/shared/claude-code.ts +243 -0
  283. package/src/runs/shared/completion-guard.ts +35 -0
  284. package/src/runs/shared/dynamic-fanout.ts +295 -0
  285. package/src/runs/shared/history-index.ts +69 -0
  286. package/src/runs/shared/mcp-direct-tool-allowlist.ts +376 -0
  287. package/src/runs/shared/model-fallback.ts +219 -0
  288. package/src/runs/shared/mutating-tool-guard.ts +194 -0
  289. package/src/runs/shared/native-finalization.ts +128 -0
  290. package/src/runs/shared/native-usage.ts +113 -0
  291. package/src/runs/shared/nested-events.ts +752 -0
  292. package/src/runs/shared/nested-path.ts +52 -0
  293. package/src/runs/shared/nested-render.ts +119 -0
  294. package/src/runs/shared/parallel-utils.ts +153 -0
  295. package/src/runs/shared/parent-receipts.ts +59 -0
  296. package/src/runs/shared/parent-usage.ts +121 -0
  297. package/src/runs/shared/pi-args.ts +350 -0
  298. package/src/runs/shared/pi-spawn.ts +52 -0
  299. package/src/runs/shared/run-history-queries.ts +44 -0
  300. package/src/runs/shared/run-history.ts +100 -0
  301. package/src/runs/shared/run-id-ambiguity.ts +4 -0
  302. package/src/runs/shared/run-records.ts +607 -0
  303. package/src/runs/shared/session-cwd-preload.ts +27 -0
  304. package/src/runs/shared/single-output.ts +267 -0
  305. package/src/runs/shared/streaming-text.ts +18 -0
  306. package/src/runs/shared/structured-output.ts +68 -0
  307. package/src/runs/shared/subagent-control.ts +219 -0
  308. package/src/runs/shared/subagent-prompt-runtime.ts +201 -0
  309. package/src/runs/shared/subagent-tool-loop-guard.ts +89 -0
  310. package/src/runs/shared/supervisor-questions.ts +415 -0
  311. package/src/runs/shared/workflow-graph.ts +220 -0
  312. package/src/runs/shared/workflow-policy.ts +125 -0
  313. package/src/runs/shared/worktree.ts +635 -0
  314. package/src/shared/agent-context-policy.ts +184 -0
  315. package/src/shared/artifacts.ts +95 -0
  316. package/src/shared/atomic-json.ts +16 -0
  317. package/src/shared/cooperative.ts +21 -0
  318. package/src/shared/execution-cwd.ts +14 -0
  319. package/src/shared/file-coalescer.ts +29 -0
  320. package/src/shared/fork-context.ts +68 -0
  321. package/src/shared/formatters.ts +84 -0
  322. package/src/shared/journal-reader.ts +333 -0
  323. package/src/shared/lazy-tools.ts +36 -0
  324. package/src/shared/model-info.ts +81 -0
  325. package/src/shared/native-session.ts +8 -0
  326. package/src/shared/native-tui.ts +10 -0
  327. package/src/shared/native-typebox.ts +14 -0
  328. package/src/shared/post-exit-stdio-guard.ts +204 -0
  329. package/src/shared/prompt-sections.ts +10 -0
  330. package/src/shared/session-entries.ts +49 -0
  331. package/src/shared/session-identity.ts +18 -0
  332. package/src/shared/settings.ts +384 -0
  333. package/src/shared/status-format.ts +123 -0
  334. package/src/shared/temp-root.ts +107 -0
  335. package/src/shared/types.ts +1260 -0
  336. package/src/shared/ui-mode.ts +5 -0
  337. package/src/shared/utils.ts +342 -0
  338. package/src/slash/prompt-template-bridge.ts +408 -0
  339. package/src/slash/slash-bridge.ts +178 -0
  340. package/src/slash/slash-commands.ts +617 -0
  341. package/src/slash/slash-live-state.ts +292 -0
  342. package/src/tui/action-hints.ts +69 -0
  343. package/src/tui/agent-history.ts +210 -0
  344. package/src/tui/agent-view.ts +1323 -0
  345. package/src/tui/render.ts +1550 -0
  346. package/tsconfig.build.json +12 -0
  347. package/tsconfig.json +19 -0
@@ -0,0 +1,1323 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
3
+ import { AssistantMessageComponent, UserMessageComponent, ToolExecutionComponent, DynamicBorder, createBashToolDefinition, createEditToolDefinition, createFindToolDefinition, createGrepToolDefinition, createLsToolDefinition, createPowerShellToolDefinition, createReadToolDefinition, createWriteToolDefinition, getMarkdownTheme, getSelectListTheme, keyText, rawKeyHint, renderDiff, type ExtensionAPI, type ExtensionContext, type KeybindingsManager, type Theme } from "@earendil-works/pi-coding-agent";
4
+ import { Box, Container, CURSOR_MARKER, Editor, Input, MouseRegion, ScrollView, SelectList, Spacer, Text, getKeybindings, matchesKey, truncateToWidth, visibleWidth, type Component, type Keybinding, type OverlayOptions, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
5
+ import { resolveSubagentIntercomTarget } from "../intercom/intercom-bridge.ts";
6
+ import { loadConfig as loadIntercomConfig } from "../pi-intercom/config.ts";
7
+ import { sendLiveSubagentMessage } from "../intercom/live-intercom.ts";
8
+ import { ownedRunView } from "../runs/shared/run-records.ts";
9
+ import { listOwnedRunQuestions, questionProcessAlive, type SupervisorQuestionView } from "../runs/shared/supervisor-questions.ts";
10
+ import type { SubagentParamsLike } from "../runs/foreground/subagent-params.ts";
11
+ import { getSingleResultOutput } from "../shared/utils.ts";
12
+ import { formatModelThinking } from "../shared/formatters.ts";
13
+ import { isTuiContext } from "../shared/ui-mode.ts";
14
+ import { acceptanceHumanAction } from "../runs/shared/acceptance.ts";
15
+ import { stripAcceptanceReport } from "../runs/shared/acceptance-reports.ts";
16
+ import { formatAgentProcessExit } from "../shared/status-format.ts";
17
+ import { WIDGET_KEY, type OwnedRun, type OwnedRunView, type SubagentExecutionResult, type SubagentState } from "../shared/types.ts";
18
+ import { buildWidgetLines } from "./render.ts";
19
+ import { indexedHistory, readableText, withFinalResult, type AgentHistory, type AgentHistoryItem } from "./agent-history.ts";
20
+ import { runHistoryIndex } from "../runs/shared/history-index.ts";
21
+ import type { HistoryPage, HistoryPageInput, HistoryRunPage, HistoryRunRow } from "../history/index.ts";
22
+ import { actionHints, withMouseExpansion } from "./action-hints.ts";
23
+
24
+ const VIEW_ENTRY = "subagent-view";
25
+ const DIRECTION_MESSAGE = "subagent-human-direction";
26
+ const UNAVAILABLE_ASSIGNMENT = "Assignment identity unavailable. Your draft is kept; no message or Stop was sent. Inspect the run and choose a verified assignment before sending it.";
27
+ const PENDING_MESSAGE_NOTICE = "This message is already waiting for the child. You can keep working or write a different message.";
28
+ const CONTINUE_HINT = "Continue with this message (Alt+C)";
29
+ const CONTINUE_NOTICES = {
30
+ finished: "This agent has finished. Your draft is kept. Choose ",
31
+ blocked: "This agent needs your action; acceptance is incomplete. Your draft is kept. Choose ",
32
+ };
33
+ type ExecuteControl = (params: SubagentParamsLike, ctx: ExtensionContext) => Promise<SubagentExecutionResult>;
34
+ type Quote = { title: string; text: string };
35
+ type Anchor = { id: string; line: number };
36
+ interface OutgoingMessage {
37
+ id: string;
38
+ runId: string;
39
+ index: number;
40
+ text: string;
41
+ draft: string;
42
+ quote?: Quote;
43
+ at: number;
44
+ status: "sending" | "waiting" | "unconfirmed";
45
+ reason?: string;
46
+ }
47
+ export interface AgentVisit {
48
+ draft: string;
49
+ quote?: Quote;
50
+ anchor?: Anchor;
51
+ /** null records a visit before the first saved history entry. */
52
+ readThrough?: string | null;
53
+ seenActivityAt?: number;
54
+ outbox: OutgoingMessage[];
55
+ lastSentId?: string;
56
+ notice?: string | { continue: keyof typeof CONTINUE_NOTICES };
57
+ }
58
+ export interface AgentTask {
59
+ key: string;
60
+ label: string;
61
+ run: OwnedRunView;
62
+ child: HistoryRunRow["children"][number];
63
+ model: { summary: string; details: string };
64
+ question?: SupervisorQuestionView;
65
+ history: AgentHistoryItem[];
66
+ historyIds: string[];
67
+ finalId?: string;
68
+ unavailable?: string;
69
+ unread: boolean;
70
+ replied: boolean;
71
+ page?: HistoryPage;
72
+ historyLoading?: boolean;
73
+ metadataAt?: number;
74
+ }
75
+
76
+ const short = (text: string, width = 48) => truncateToWidth(readableText(text).replace(/\s+/g, " ").trim(), width);
77
+ const resultText = (result: SubagentExecutionResult) => result.content.map((part) => part.type === "text" ? part.text : "").join("\n");
78
+ function primaryKey(action: Keybinding): string {
79
+ const key = getKeybindings().getKeys(action)[0];
80
+ return ({ enter: "Enter", escape: "Esc", up: "↑", down: "↓" } as Record<string, string>)[key ?? ""] ?? readableText(rawKeyHint(key ?? "", "")).trim();
81
+ }
82
+ export function agentTaskLabel(child: OwnedRunView["children"][number]): string {
83
+ return readableText(child.label || child.task?.split("\n").find((line) => line.trim()) || `${child.agent} · assignment unavailable`).replace(/\s+/g, " ").trim();
84
+ }
85
+ function activity(task: AgentTask): string {
86
+ if (task.child.identityUnavailable) return "assignment unavailable";
87
+ if (task.question) return task.question.state === "answer_pending" ? "answer saved · waiting" : "needs an answer";
88
+ if (task.child.state === "blocked") return "needs your action · acceptance incomplete";
89
+ if (task.child.state !== "live") return task.child.state === "completed" ? "done" : task.child.state;
90
+ const live = task.child.activity;
91
+ if (live?.status === "pending") return "waiting to start";
92
+ return live?.currentTool ? short(`${live.currentTool} ${live.currentToolArgs || live.currentPath || ""}`, 42)
93
+ : live?.streamingText ? short(live.streamingText, 42) : "working";
94
+ }
95
+
96
+ function taskSummary(task: AgentTask): { status: string; badge: string } {
97
+ let status: string;
98
+ if (task.child.identityUnavailable) status = "unavailable";
99
+ else if (task.question) status = task.question.state === "answer_pending" ? "answer pending" : "needs answer";
100
+ else if (task.child.state === "blocked") status = "needs action";
101
+ else if (task.child.state === "live") status = task.child.activity?.status === "pending" ? "queued" : "working";
102
+ else status = task.child.state === "completed" ? "done" : task.child.state;
103
+ return { status, badge: task.unread ? task.replied ? " · replied" : " · new" : "" };
104
+ }
105
+
106
+ const unavailableModel = { summary: "model unavailable", details: "Model unavailable. No provider/model was recorded for this assignment." };
107
+
108
+ function agentModel(child: AgentTask["child"], native?: AgentHistory["configuration"]): AgentTask["model"] {
109
+ if (child.identityUnavailable) return unavailableModel;
110
+ const live = child.state === "live", selected = child.modelSelection;
111
+ // A continuation shares its file with the finished run; only the latter's frozen snapshot belongs to it.
112
+ const recorded = live ? native : native?.model ? { ...(child.savedConfiguration ?? child.launch), ...native } : child.savedConfiguration ?? child.launch;
113
+ const current = selected?.modelStartedAt !== undefined && (recorded?.modelRecordedAt ?? 0) > selected.modelStartedAt;
114
+ let source = "selected", value: { model?: string; thinking?: string } | undefined = selected;
115
+ if (recorded?.model && (current || !live && selected?.modelStartedAt === undefined)) {
116
+ value = recorded; source = live ? "session" : "saved";
117
+ } else if (!value?.model) {
118
+ value = live && native?.model ? native : child.launch?.model ? child.launch : child.result;
119
+ source = "saved";
120
+ }
121
+ if (!value?.model) return unavailableModel;
122
+ const formatted = readableText(formatModelThinking(value.model, value.thinking));
123
+ const selectedText = selected?.model && readableText(formatModelThinking(selected.model, selected.thinking));
124
+ const nativeText = native?.model && readableText(formatModelThinking(native.model, native.thinking));
125
+ const details = [`Model (${source}): ${formatted}`];
126
+ if (selectedText && selectedText !== formatted) details.push(`Selected model: ${selectedText}`);
127
+ if (live && nativeText && nativeText !== formatted) details.push(`Last saved session model (may precede this attempt): ${nativeText}`);
128
+ return { summary: source === "session" ? formatted : `${source}: ${formatted}`, details: details.join("\n") };
129
+ }
130
+
131
+ function runningIndicator(theme: Theme): string {
132
+ // Six seconds, sampled by the existing 500 ms refresh; typing cannot speed up the pulse.
133
+ const phase = Math.floor(Date.now() / 500) % 12;
134
+ const ansi = theme.getFgAnsi("success");
135
+ const rgb = theme.getColorMode() === "truecolor" && /^\x1b\[38;2;(\d+);(\d+);(\d+)m$/.exec(ansi);
136
+ if (!rgb) return theme.fg("success", phase < 6 ? theme.bold("●") : "●");
137
+ const brightness = 0.9 + 0.1 * Math.cos(phase * Math.PI / 6);
138
+ return theme.fg("success", "●").replace(ansi, `\x1b[38;2;${rgb.slice(1).map((value) => Math.round(Number(value) * brightness)).join(";")}m`);
139
+ }
140
+
141
+ /** One UI controller over the existing owned runs, session files, and executor. */
142
+ export class AgentViewController {
143
+ private ctx?: ExtensionContext;
144
+ private ownerSessionId?: string;
145
+ private generation = 0;
146
+ private pending = new AbortController();
147
+ private timer?: ReturnType<typeof setInterval>;
148
+ private saveTimer?: ReturnType<typeof setTimeout>;
149
+ private unsubscribe?: () => void;
150
+ private indexUnsubscribe?: () => void;
151
+ private refreshPromise?: Promise<void>;
152
+ private listRequest = 0;
153
+ private appliedListRequest = -1;
154
+ private filter = "";
155
+ private offset = 0;
156
+ private selectedKey?: string;
157
+ private dockTasks: AgentTask[] = [];
158
+ listPage?: HistoryRunPage;
159
+ listLoading = false;
160
+ listError?: string;
161
+ private taskKeys = new Map<string, string>();
162
+ private visits = new Map<string, AgentVisit>();
163
+ private busy = new Set<string>();
164
+ private lastSaved = "";
165
+ private render?: () => void;
166
+ private closeOverlay?: (next?: string) => void;
167
+ private widget?: Component;
168
+ readonly shortcut = loadIntercomConfig().shortcut;
169
+ private overlay?: AgentConversation | AgentPicker;
170
+ tasks: AgentTask[] = [];
171
+ pinned?: string;
172
+
173
+ private pi: ExtensionAPI;
174
+ private state: SubagentState;
175
+ private execute: ExecuteControl;
176
+
177
+ constructor(pi: ExtensionAPI, state: SubagentState, execute: ExecuteControl) {
178
+ this.pi = pi; this.state = state; this.execute = execute;
179
+ pi.registerCommand("agents", { description: "View, message, answer, stop, or continue your agents", handler: async (_args, ctx) => this.open(undefined, ctx) });
180
+ pi.registerMessageRenderer(DIRECTION_MESSAGE, withMouseExpansion((message, options, theme) => {
181
+ const details = message.details as { label: string; text: string; quote?: Quote };
182
+ return {
183
+ render(width) {
184
+ const key = keyText("app.tools.expand");
185
+ return new Text(options.expanded ? `User → ${details.label}\n${details.text}${details.quote ? `\n\nReplying to ${details.quote.title}:\n${details.quote.text}` : ""}`
186
+ : theme.fg("dim", `User → ${details.label}: ${short(details.text, 100)} · sent directly${key ? ` · ${key}` : ""}`), 0, 0).render(width);
187
+ },
188
+ invalidate() {},
189
+ };
190
+ }));
191
+ }
192
+
193
+ start(ctx: ExtensionContext): void {
194
+ this.dispose();
195
+ if (!isTuiContext(ctx)) return;
196
+ this.ctx = ctx;
197
+ this.pending = new AbortController();
198
+ const ownerSessionId = ctx.sessionManager.getSessionId();
199
+ this.ownerSessionId = ownerSessionId;
200
+ for (const entry of ctx.sessionManager.getEntries()) {
201
+ if (entry.type !== "custom" || entry.customType !== VIEW_ENTRY) continue;
202
+ const saved = entry.data as { ownerSessionId?: string; visits?: Array<[string, AgentVisit]>; pinned?: string };
203
+ if (saved?.ownerSessionId === ownerSessionId && Array.isArray(saved.visits)) {
204
+ this.visits = new Map(saved.visits);
205
+ this.pinned = saved.pinned;
206
+ }
207
+ }
208
+ for (const visit of this.visits.values()) for (const sent of visit.outbox) if (sent.status === "sending") sent.status = "unconfirmed";
209
+ this.unsubscribe = this.pi.events.on("subagent:open-agents", (data) => {
210
+ const request = data as { ctx?: ExtensionContext; handled?: boolean };
211
+ if (!this.live() || request.ctx?.sessionManager.getSessionId() !== ownerSessionId) return;
212
+ request.handled = true;
213
+ void this.open();
214
+ });
215
+ ctx.ui.setWidget(WIDGET_KEY, (tui, theme) => {
216
+ this.render = () => tui.requestRender();
217
+ let hits: Array<{ start: number; end: number; key?: string; unpin?: boolean; row: number }> = [];
218
+ const widget = new MouseRegion({
219
+ invalidate() {},
220
+ render: (width) => {
221
+ hits = [];
222
+ const active = this.dockTasks.filter((task) => task.child.state === "live" || task.child.state === "blocked" || task.question)
223
+ .map((task) => ({ task, state: task.child.state === "blocked" || task.question?.state === "awaiting_input" ? "needs action" : task.child.activity?.status === "pending" || task.question?.state === "answer_pending" ? "waiting" : "running" }));
224
+ if (!active.length) {
225
+ if (!this.listError && !this.listLoading && this.listPage?.freshness.state !== "catching-up") return [];
226
+ const label = this.listError ? "Agents history unavailable · /agents to retry" : "Agents · loading history…";
227
+ hits.push({ start: 0, end: width, row: 0 });
228
+ return [truncateToWidth(theme.fg(this.listError ? "warning" : "dim", label), width)];
229
+ }
230
+ const running = active.filter((row) => row.state === "running").length;
231
+ const waiting = active.filter((row) => row.state === "waiting").length;
232
+ const needsAction = active.filter((row) => row.state === "needs action").length;
233
+ const counts = `${running} running${waiting ? ` · ${waiting} waiting` : ""}${needsAction ? ` · ${needsAction} needs action` : ""}`;
234
+ const hint = readableText(rawKeyHint(this.shortcut, "")).trim();
235
+ const entrance = `${theme.fg("accent", theme.bold("Agents"))} · ${theme.fg(running ? "success" : "muted", counts)} ${theme.fg("dim", `[${hint}]`)}`;
236
+ const lines = [truncateToWidth(entrance, width)];
237
+ hits.push({ start: 0, end: Math.min(width, visibleWidth(entrance)), row: 0 });
238
+ const visible = active.slice(0, Math.max(1, Math.min(4, Math.floor(tui.terminal.rows / 5))));
239
+ const pinned = this.tasks.find((task) => task.key === this.pinned);
240
+ const details = ctx.ui.getToolsExpanded() ? buildWidgetLines([...this.state.asyncJobs.values()], theme, width, true) : [];
241
+ // MainScreen repaints all scrollback if an offscreen row's ANSI changes.
242
+ const pulseFrom = tui.mode === "regular" ? visible.length + details.length + Number(Boolean(pinned)) + Number(visible.length < active.length)
243
+ + (this.componentsBelowWidget(tui) ?? []).reduce((height, child) => height + child.render(width).length, 0) - tui.terminal.rows : 0;
244
+ for (const [index, { task, state }] of visible.entries()) {
245
+ const color = state === "running" ? "success" : state === "waiting" ? "dim" : "warning";
246
+ const symbol = state === "running" ? "●" : state === "waiting" ? "◷" : "!";
247
+ const indicator = state === "running" && index >= pulseFrom ? runningIndicator(theme) : theme.fg(color, symbol);
248
+ const { status, badge } = taskSummary(task);
249
+ const statusText = status === "working" ? "" : theme.fg(color, ` · ${status}`);
250
+ const available = width - visibleWidth(statusText + badge) - 4;
251
+ const modelWidth = available - Math.min(30, visibleWidth(task.label)) - 3;
252
+ const model = modelWidth >= 16 ? ` · ${short(task.model.summary, modelWidth)}` : "";
253
+ const label = short(task.label, Math.max(1, available - visibleWidth(model)));
254
+ hits.push({ start: 0, end: width, row: lines.length, key: task.key });
255
+ lines.push(truncateToWidth(` ${indicator} ${theme.bold(label)}${statusText}${theme.fg("accent", badge)}${theme.fg("dim", model)}`, width));
256
+ }
257
+ if (visible.length < active.length) {
258
+ const more = ` +${active.length - visible.length} more · /agents`, line = truncateToWidth(more, width, "...");
259
+ hits.push({ start: 2, end: visibleWidth(line) - (visibleWidth(more) > width ? 3 : 0), row: lines.length });
260
+ lines.push(theme.fg("dim", line));
261
+ }
262
+ if (pinned) {
263
+ const preview = pinned.child.identityUnavailable ? UNAVAILABLE_ASSIGNMENT : pinned.child.state === "live" ? activity(pinned) : (pinned.child.result && getSingleResultOutput(pinned.child.result)) || pinned.history.findLast((item) => item.kind === "assistant")?.text || activity(pinned);
264
+ const unpin = "[Unpin] ", row = lines.length;
265
+ lines.push(theme.fg("dim", truncateToWidth(`${unpin}Pinned · ${pinned.child.state === "completed" ? "finished · " : ""}${short(pinned.label, 30)}: ${short(preview, width)}`, width)));
266
+ hits.push({ start: 0, end: unpin.length, row, unpin: true }, { start: unpin.length, end: width, row, key: pinned.key });
267
+ }
268
+ lines.push(...details);
269
+ return lines;
270
+ },
271
+ }, (event) => {
272
+ if (tui.mode !== "fullscreen" || event.button !== "left" || !this.live()) return;
273
+ const hit = hits.find((hit) => hit.row === event.y && event.x >= hit.start && event.x < hit.end);
274
+ if (!hit) return;
275
+ if (event.type === "press") return { handled: true };
276
+ if (event.type !== "click") return;
277
+ if (hit.unpin) this.pin(undefined); else void this.open(hit.key);
278
+ return { handled: true };
279
+ });
280
+ this.widget = widget;
281
+ return widget;
282
+ });
283
+ void this.refresh();
284
+ this.timer = setInterval(() => {
285
+ if (!this.listError && (this.overlay || this.pinned || this.dockTasks.some((task) => task.child.state === "live" || task.question)
286
+ || this.tasks.some((task) => this.visits.get(task.key)?.outbox.length))) {
287
+ this.render?.(); void this.refresh();
288
+ }
289
+ }, 500);
290
+ this.timer.unref?.();
291
+ }
292
+
293
+ private live(generation = this.generation): boolean {
294
+ return generation === this.generation && Boolean(this.ctx && this.ctx.sessionManager.getSessionId() === this.ownerSessionId
295
+ && this.state.lastUiContext?.sessionManager.getSessionId() === this.ownerSessionId);
296
+ }
297
+
298
+ visit(key: string): AgentVisit {
299
+ let visit = this.visits.get(key);
300
+ if (!visit) { visit = { draft: "", outbox: [] }; this.visits.set(key, visit); }
301
+ return visit;
302
+ }
303
+
304
+ private taskKey(run: OwnedRun, child: Pick<OwnedRunView["children"][number], "index" | "workflowNodeId" | "sessionFile" | "identityUnavailable">): string {
305
+ const path = new Set<string>();
306
+ let key: string;
307
+ for (;;) {
308
+ const cacheKey = `${run.runId}:${child.workflowNodeId ?? (run.mode === "chain" ? child.sessionFile ?? child.index : child.index)}`, cached = this.taskKeys.get(cacheKey);
309
+ if (cached) { key = cached; break; }
310
+ if (path.has(cacheKey)) throw new Error("Cyclic saved continuation ancestry");
311
+ path.add(cacheKey);
312
+ const predecessor = run.predecessorRunId && this.state.ownedRuns?.get(run.predecessorRunId);
313
+ if (!predecessor) {
314
+ key = `${run.runId}:${child.workflowNodeId ?? (run.mode === "chain" && child.sessionFile && !child.identityUnavailable ? `session:${child.sessionFile}` : child.index)}`;
315
+ break;
316
+ }
317
+ const index = run.predecessorIndex ?? 0;
318
+ child = predecessor.children.find((candidate) => candidate.index === index) ?? { index };
319
+ run = predecessor;
320
+ }
321
+ for (const cacheKey of path) this.taskKeys.set(cacheKey, key);
322
+ return key;
323
+ }
324
+
325
+ private fromView(view: HistoryRunRow, questions = view.questions ?? [], canonical = false): AgentTask[] {
326
+ return view.children.filter((child) => !view.matchedChildIndexes || view.matchedChildIndexes.includes(child.index)).map((child) => {
327
+ const key = this.taskKey(view, child), prior = this.task(key) ?? this.dockTasks.find((task) => task.key === key), visit = this.visits.get(key);
328
+ const sameAttempt = prior?.run.runId === view.runId;
329
+ const observed = sameAttempt && prior.child.state === child.state && prior.metadataAt === view.updatedAt;
330
+ const selected = !canonical && this.selectedKey === key && sameAttempt;
331
+ const displayChild = selected ? { ...child, task: prior.child.task, launch: prior.child.launch, result: child.result && prior.run.updatedAt === view.updatedAt ? prior.child.result : child.result,
332
+ activity: { ...prior.child.activity, ...child.activity } } : child;
333
+ return { key, label: child.identityUnavailable ? "Saved assignment unavailable" : sameAttempt ? agentTaskLabel(child) : prior?.label ?? agentTaskLabel(child), run: view, child: displayChild,
334
+ model: agentModel(displayChild, child.nativeConfiguration ?? (canonical && sameAttempt && child.sessionFile === prior.child.sessionFile ? prior.page?.configuration : undefined)), history: sameAttempt ? prior.history : [], historyIds: sameAttempt ? prior.historyIds : [],
335
+ page: sameAttempt ? prior.page : undefined, metadataAt: observed ? prior.metadataAt : undefined, historyLoading: sameAttempt ? prior.historyLoading : false, finalId: sameAttempt ? prior.finalId : undefined, unavailable: child.missingSession && child.state !== "live" ? "Saved conversation unavailable." : view.diagnosis,
336
+ question: child.identityUnavailable ? undefined : questions.findLast((question) => question.index === child.index && ["awaiting_input", "answer_pending"].includes(question.state)),
337
+ unread: observed ? prior.unread : Boolean(visit && (child.activity?.lastActivityAt ?? view.updatedAt) > (visit.seenActivityAt ?? 0)), replied: sameAttempt ? prior.replied : false };
338
+ });
339
+ }
340
+
341
+ private unavailableTask(key: string, run: OwnedRunView): AgentTask {
342
+ return { key, label: "Saved assignment unavailable", run,
343
+ child: { agent: "unknown", index: -1, state: "unknown", configuration: "legacy-partial", identityUnavailable: true },
344
+ model: unavailableModel, history: [], historyIds: [], unavailable: UNAVAILABLE_ASSIGNMENT, unread: false, replied: false };
345
+ }
346
+
347
+ /** SQLite is browse-only. Recheck just this root lineage before any control. */
348
+ private refreshSelected(key: string): void {
349
+ const current = this.task(key), root = current?.run.rootRunId ?? this.state.ownedRuns?.get(key.split(":")[0]!)?.rootRunId;
350
+ if (!root || !this.live()) return;
351
+ this.taskKeys.clear();
352
+ let selected: AgentTask | undefined, rootView: OwnedRunView | undefined;
353
+ try {
354
+ for (const run of this.state.ownedRuns?.values() ?? []) {
355
+ if (run.rootRunId !== root || run.ownerSessionId !== this.ownerSessionId) continue;
356
+ const questions = listOwnedRunQuestions(run.ownerSessionId, run.runId);
357
+ const view = ownedRunView(run, this.state, { pendingInput: questions.some((question) => ["awaiting_input", "answer_pending"].includes(question.state)), includeContinuations: false, readConfiguration: false });
358
+ if (view.runId === key.split(":")[0]) rootView = view;
359
+ const candidate = this.fromView(view, questions, true).find((task) => task.key === key);
360
+ if (candidate && (!selected || selected.run.startedAt <= candidate.run.startedAt)) selected = candidate;
361
+ }
362
+ } catch (error) {
363
+ if (current) selected = { ...current, child: { ...current.child, state: "unknown", identityUnavailable: true }, question: undefined,
364
+ unavailable: `Run authority unavailable: ${String(error)}` };
365
+ }
366
+ if (!selected && current) selected = { ...current, child: { ...current.child, state: "unknown", identityUnavailable: true }, question: undefined, unavailable: UNAVAILABLE_ASSIGNMENT };
367
+ if (!selected && rootView && this.visits.has(key)) selected = this.unavailableTask(key, rootView);
368
+ if (selected) {
369
+ const position = this.tasks.findIndex((task) => task.key === key);
370
+ if (position < 0) this.tasks.push(selected);
371
+ else this.tasks[position] = selected;
372
+ this.dockTasks = this.dockTasks.map((task) => task.key === key ? selected! : task);
373
+ }
374
+ this.overlay?.refresh(); this.render?.();
375
+ }
376
+
377
+ /** Bounded asynchronous observations; no owner files or native bodies on this path. */
378
+ refresh(browse = false): Promise<void> {
379
+ if (!this.live()) return Promise.resolve();
380
+ if (!browse && !this.state.ownedRuns?.size && !this.state.historyIndex) return Promise.resolve();
381
+ if (this.refreshPromise) return this.refreshPromise;
382
+ const generation = this.generation, request = this.listRequest, offset = this.offset, text = this.filter;
383
+ this.listLoading = this.listPending;
384
+ this.refreshPromise = (async () => {
385
+ try {
386
+ const index = await runHistoryIndex(this.state);
387
+ if (!this.live(generation)) return;
388
+ this.indexUnsubscribe ??= index.onChanged(() => { void this.refresh(); });
389
+ const page = await index.listRuns({ offset, limit: 50, text: text || undefined, sort: "attention", latestTasksOnly: true });
390
+ const dock = text || offset ? await index.listRuns({ limit: 50, sort: "attention", latestTasksOnly: true }) : page;
391
+ if (!this.live(generation) || request !== this.listRequest) return;
392
+ this.listPage = page; this.appliedListRequest = request; this.listError = undefined;
393
+ const tasks = new Map<string, AgentTask>();
394
+ for (const row of page.rows) for (const task of this.fromView(row)) {
395
+ const prior = tasks.get(task.key);
396
+ if (!prior || prior.run.startedAt <= task.run.startedAt) tasks.set(task.key, task);
397
+ }
398
+ for (const [key, visit] of this.visits) {
399
+ if (tasks.has(key) || !visit.draft && !visit.quote && !visit.outbox.length && this.pinned !== key) continue;
400
+ const row = page.rows.find((run) => run.runId === key.split(":")[0]);
401
+ if (row) tasks.set(key, this.unavailableTask(key, row));
402
+ }
403
+ // Keep only the open conversation/pin, not every historical terminal view.
404
+ for (const key of [this.selectedKey, this.pinned]) {
405
+ const retained = key && this.task(key);
406
+ if (retained && !tasks.has(key!)) tasks.set(key!, retained);
407
+ }
408
+ this.tasks = [...tasks.values()];
409
+ this.dockTasks = dock.rows.flatMap((row) => this.fromView(row));
410
+ const markers = new Map([...this.tasks, ...this.dockTasks].map((task) => [task.key, task]));
411
+ for (const task of markers.values()) {
412
+ const visit = this.visits.get(task.key);
413
+ if (!visit || task.child.identityUnavailable) continue;
414
+ if (task.child.state !== "live" && !task.question && task.key !== this.selectedKey && task.key !== this.pinned
415
+ && !visit.outbox.length && task.metadataAt === task.run.updatedAt) continue;
416
+ try {
417
+ const facts = await index.historyPage({ ...this.historyInput(task), limit: 1 });
418
+ if (!this.live(generation) || request !== this.listRequest) return;
419
+ for (const observed of [...this.tasks, ...this.dockTasks].filter((candidate) => candidate.key === task.key)) this.applyMetadata(observed, facts);
420
+ } catch (error) {
421
+ if (!this.live(generation) || request !== this.listRequest) return;
422
+ task.unavailable = `Conversation metadata unavailable: ${error instanceof Error ? error.message : String(error)}`;
423
+ }
424
+ }
425
+ } catch (error) {
426
+ if (this.live(generation) && request === this.listRequest) this.listError = `Agents history unavailable: ${error instanceof Error ? error.message : String(error)}. Retry (F5).`;
427
+ } finally {
428
+ if (this.live(generation)) {
429
+ this.listLoading = false; this.refreshPromise = undefined;
430
+ this.overlay?.refresh(); this.render?.();
431
+ if (request !== this.listRequest) void this.refresh();
432
+ }
433
+ }
434
+ })();
435
+ return this.refreshPromise;
436
+ }
437
+
438
+ get listFilter(): string { return this.filter; }
439
+ get listPending(): boolean { return !this.listPage || this.appliedListRequest !== this.listRequest; }
440
+
441
+ filterTasks(text: string): void {
442
+ if (text === this.filter) return;
443
+ this.filter = text.slice(0, 256); this.offset = 0; this.listRequest++; this.tasks = this.tasks.filter((task) => task.key === this.selectedKey || task.key === this.pinned);
444
+ void this.refresh();
445
+ }
446
+
447
+ pageTasks(direction: "earlier" | "later"): void {
448
+ const next = direction === "later" ? this.listPage?.nextOffset : Math.max(0, this.offset - 50);
449
+ if (next === undefined || next === this.offset) return;
450
+ this.offset = next; this.listRequest++; void this.refresh();
451
+ }
452
+
453
+ async retry(key?: string): Promise<void> {
454
+ const generation = this.generation;
455
+ try { const index = await runHistoryIndex(this.state, true); await index.refresh(key ? this.task(key)?.run.runId : undefined); }
456
+ catch (error) { if (this.live(generation)) this.listError = `History refresh unavailable: ${String(error)}`; }
457
+ if (this.live(generation)) await this.refresh();
458
+ }
459
+
460
+ async historyPage(key: string, paging: Pick<HistoryPageInput, "before" | "after" | "cursor"> = {}, anchor?: string | null): Promise<{ history: AgentHistory; page: HistoryPage; latestPage: boolean; readThroughSequence?: number } | undefined> {
461
+ const task = this.task(key), generation = this.generation;
462
+ if (!task || !this.live() || task.child.identityUnavailable) return;
463
+ const input = this.historyInput(task), index = await runHistoryIndex(this.state);
464
+ const boundary = { runId: task.run.runId, index: task.child.index, terminalEntryId: input.terminalEntryId, endedAt: input.endedAt };
465
+ const marker = typeof input.readThrough === "string" && !input.readThrough.startsWith("result:")
466
+ ? await index.entry({ ...boundary, entryId: input.readThrough.replace(/:(?:\d+|error)$/, "") }) : undefined;
467
+ if (anchor === null) paging = { after: 0 };
468
+ else if (anchor && !anchor.startsWith("result:")) {
469
+ const entry = anchor === input.readThrough ? marker : await index.entry({ ...boundary, entryId: anchor.replace(/:(?:\d+|error)$/, "") });
470
+ if (entry) paging = { after: Math.max(0, entry.sequence - 1) };
471
+ }
472
+ const result = await indexedHistory(index, { ...input, limit: 100, ...paging });
473
+ if (!this.live(generation) || this.task(key)?.run.runId !== task.run.runId) return;
474
+ return { ...result, latestPage: paging.after !== undefined ? !result.page.hasMore : paging.before === undefined && paging.cursor === undefined,
475
+ readThroughSequence: marker?.sequence ?? (input.readThrough == null ? -1 : undefined) };
476
+ }
477
+
478
+ async savedResult(key: string, id: string): Promise<AgentHistoryItem> {
479
+ const task = this.task(key), generation = this.generation;
480
+ if (!task || !this.live()) throw new Error("The owning session is no longer available.");
481
+ const index = await runHistoryIndex(this.state);
482
+ const result = await index.result({ runId: task.run.runId, index: task.child.index });
483
+ if (!this.live(generation) || this.task(key)?.run.runId !== task.run.runId) throw new Error("The selected attempt changed; choose the report again.");
484
+ if (!result) throw new Error("The selected saved report is unavailable.");
485
+ return { id, kind: "assistant", title: "Saved result", text: readableText(result.text), timestamp: result.timestamp };
486
+ }
487
+
488
+ private historyInput(task: AgentTask): HistoryPageInput {
489
+ const visit = this.visits.get(task.key);
490
+ return { runId: task.run.runId, index: task.child.index,
491
+ messageIds: [...new Set([...(visit?.outbox.slice(0, 999).map((sent) => sent.id) ?? []), ...(visit?.lastSentId ? [visit.lastSentId] : [])])], readThrough: visit?.readThrough,
492
+ ...(task.child.state === "live" ? {} : { leaf: task.child.result?.terminalLeafId, terminalEntryId: task.child.result?.terminalEntryId, endedAt: task.child.result?.terminalEntryId ? undefined : task.run.updatedAt }) };
493
+ }
494
+
495
+ applyMetadata(task: AgentTask, page: HistoryPage): void {
496
+ const visit = this.visits.get(task.key), delivered = new Map(page.deliveredMessages);
497
+ if (page.freshness.state !== "catching-up") task.metadataAt = task.run.updatedAt;
498
+ task.model = agentModel(task.child, page.configuration);
499
+ task.unread = Boolean(page.unreadAfter || visit && (task.child.activity?.lastActivityAt ?? 0) > (visit.seenActivityAt ?? 0));
500
+ task.replied = Boolean(visit?.lastSentId && delivered.get(visit.lastSentId));
501
+ if (!visit) return;
502
+ if (visit.lastSentId && delivered.has(visit.lastSentId) && visit.notice === PENDING_MESSAGE_NOTICE) visit.notice = undefined;
503
+ for (const sent of visit.outbox) {
504
+ if (!delivered.has(sent.id)) continue;
505
+ if (visit.draft === sent.draft) { visit.draft = ""; visit.quote = undefined; if (this.selectedKey === task.key) this.overlay?.syncDraft(); }
506
+ }
507
+ visit.outbox = visit.outbox.filter((sent) => !delivered.has(sent.id));
508
+ }
509
+
510
+ task(key: string): AgentTask | undefined { return this.tasks.find((task) => task.key === key); }
511
+ isBusy(key: string): boolean { return this.busy.has(key); }
512
+
513
+ changed(): void {
514
+ if (!this.live()) return;
515
+ if (this.saveTimer) clearTimeout(this.saveTimer);
516
+ this.saveTimer = setTimeout(() => this.save(), 300);
517
+ this.saveTimer.unref?.();
518
+ this.render?.();
519
+ }
520
+
521
+ private save(): void {
522
+ if (this.saveTimer) clearTimeout(this.saveTimer);
523
+ this.saveTimer = undefined;
524
+ if (!this.live()) return;
525
+ const saved = { ownerSessionId: this.ctx!.sessionManager.getSessionId(), visits: [...this.visits], pinned: this.pinned };
526
+ const serialized = JSON.stringify(saved);
527
+ if (serialized === this.lastSaved) return;
528
+ this.pi.appendEntry(VIEW_ENTRY, saved);
529
+ this.lastSaved = serialized;
530
+ }
531
+
532
+ pin(key: string | undefined): void { this.pinned = key; this.save(); this.render?.(); }
533
+
534
+ private componentsBelowWidget(tui: TUI): Component[] | undefined {
535
+ if (!this.widget) return undefined;
536
+ // Public children work across loader boundaries; never render the parent history to measure the dock.
537
+ const parent = tui.children.find((child): child is Component & Pick<Container, "children"> => "children" in child && Array.isArray(child.children) && child.children.includes(this.widget));
538
+ return parent && [...parent.children.slice(parent.children.indexOf(this.widget) + 1), ...tui.children.slice(tui.children.indexOf(parent) + 1)];
539
+ }
540
+
541
+ availableHeight(tui: TUI): number {
542
+ if (tui.mode !== "fullscreen" || !this.widget) return Math.max(1, tui.terminal.rows - 2);
543
+ const below = this.componentsBelowWidget(tui);
544
+ if (!below) return Math.max(1, tui.terminal.rows - 2);
545
+ const dock = [this.widget, ...below].reduce((height, child) => height + child.render(tui.terminal.columns).length, 0);
546
+ return Math.max(1, tui.terminal.rows - dock - 1);
547
+ }
548
+
549
+ async open(key?: string, ctx = this.ctx): Promise<void> {
550
+ if (!ctx || !isTuiContext(ctx)) return;
551
+ if (!this.ctx || this.ctx.sessionManager.getSessionId() !== ctx.sessionManager.getSessionId()) this.start(ctx);
552
+ if (!this.live()) return;
553
+ if (this.overlay) {
554
+ this.closeOverlay?.(this.overlay instanceof AgentConversation && this.overlay.key === key ? undefined : key);
555
+ return;
556
+ }
557
+ const generation = this.generation;
558
+ if (!this.listPage) await this.refresh(true);
559
+ if (!this.live(generation)) return;
560
+ let selected = key ?? (!this.filter && this.tasks.length === 1 && this.listPage?.total === 1 ? this.tasks[0]!.key : undefined);
561
+ while (this.live(generation)) {
562
+ this.selectedKey = selected;
563
+ if (selected) this.refreshSelected(selected);
564
+ let height: (() => number) | undefined;
565
+ const overlayOptions: OverlayOptions = { width: "96%", margin: 1, anchor: "center", get maxHeight() { return height?.(); } };
566
+ const result = await ctx.ui.custom<string | undefined>((tui, theme, keys, done) => {
567
+ height = () => this.availableHeight(tui);
568
+ if (tui.mode === "fullscreen") overlayOptions.anchor = "top-center";
569
+ this.closeOverlay = (next) => done(next);
570
+ this.overlay = selected && this.task(selected)
571
+ ? new AgentConversation(tui, theme, this, selected, done, keys)
572
+ : new AgentPicker(tui, theme, this, done);
573
+ return this.overlay;
574
+ }, { overlay: true, overlayOptions });
575
+ if (!this.live(generation)) return;
576
+ this.overlay = undefined;
577
+ for (const task of [...this.tasks, ...this.dockTasks]) if (task.key === this.selectedKey) {
578
+ task.history = []; task.historyIds = []; task.page = undefined; task.finalId = undefined; task.historyLoading = false;
579
+ }
580
+ this.selectedKey = undefined;
581
+ this.closeOverlay = undefined;
582
+ this.save();
583
+ void this.refresh();
584
+ if (result === "peers") { this.pi.events.emit("intercom:open", {}); return; }
585
+ if (!result) return;
586
+ selected = result === "picker" ? undefined : result;
587
+ }
588
+ }
589
+
590
+ private breadcrumb(task: AgentTask, text: string, quote?: Quote): void {
591
+ const label = short(task.label, 42);
592
+ this.pi.sendMessage({ customType: DIRECTION_MESSAGE, display: true,
593
+ content: `For context only: the user sent this directly to ${label} (run ${task.run.runId}, child ${task.child.index}). No relay or approval is needed.\n\n${text}${quote ? `\n\nRegarding ${quote.title}:\n${quote.text}` : ""}`,
594
+ details: { label, text, quote, runId: task.run.runId, index: task.child.index } }, { triggerTurn: false });
595
+ }
596
+
597
+ async send(key: string, text: string, continueExplicitly = false): Promise<void> {
598
+ if (!text.trim() || !this.live() || this.busy.has(key)) return;
599
+ this.refreshSelected(key);
600
+ const task = this.task(key), visit = this.visit(key), generation = this.generation;
601
+ if (!task) return;
602
+ if (task.child.identityUnavailable) { visit.notice = UNAVAILABLE_ASSIGNMENT; this.changed(); return; }
603
+ if (task.child.activity?.status === "pending") return;
604
+ const question = task.question;
605
+ const liveQuestion = question && questionProcessAlive(question);
606
+ if (task.child.state !== "live" && !liveQuestion && !continueExplicitly) {
607
+ visit.notice = { continue: task.child.state === "blocked" ? "blocked" : "finished" };
608
+ this.changed(); return;
609
+ }
610
+ if (!liveQuestion && task.child.state !== "live" && (task.child.missingSession || (!task.child.sessionFile && continueExplicitly))) {
611
+ visit.notice = "The saved conversation is unavailable. No agent was started; your draft is kept.";
612
+ this.changed(); return;
613
+ }
614
+ if (continueExplicitly && task.child.state !== "live" && !task.child.launch) {
615
+ visit.notice = `This older run has no saved profile. Inspect it, then use agent_runs continue with agent: ${JSON.stringify(task.child.agent)} to explicitly choose the current profile. Your draft is kept.`;
616
+ this.changed(); return;
617
+ }
618
+ const draft = visit.draft, quote = visit.quote;
619
+ if (!continueExplicitly && visit.outbox.some((sent) => sent.status !== "unconfirmed" && sent.text === text && JSON.stringify(sent.quote) === JSON.stringify(quote))) {
620
+ visit.notice = PENDING_MESSAGE_NOTICE;
621
+ this.changed(); return;
622
+ }
623
+ const message = `${text}${quote ? `\n\nRegarding ${quote.title}:\n${quote.text}` : ""}`;
624
+ this.busy.add(key);
625
+ visit.notice = undefined;
626
+ this.changed();
627
+ try {
628
+ if (question || task.child.state !== "live") {
629
+ const result = await this.execute({ action: question ? "answer" : "resume", id: task.run.runId, index: task.child.index,
630
+ ...(question ? { questionId: question.questionId } : {}), message, messageOrigin: "human" }, this.ctx!);
631
+ if (!this.live(generation)) return;
632
+ visit.notice = result.isError ? resultText(result) : question ? "Answer saved; waiting for the child. This is not proof it has acted." : "Continuation started on the saved conversation.";
633
+ if (!result.isError) {
634
+ if (visit.draft === draft) { visit.draft = ""; visit.quote = undefined; }
635
+ visit.outbox = visit.outbox.filter((sent) => sent.draft !== draft);
636
+ this.breadcrumb(task, text, quote);
637
+ }
638
+ } else {
639
+ const sent: OutgoingMessage = { id: randomUUID(), runId: task.run.runId, index: task.child.index, text, draft, quote, at: Date.now(), status: "sending" };
640
+ visit.outbox.push(sent);
641
+ visit.lastSentId = sent.id;
642
+ this.save();
643
+ const receipt = await sendLiveSubagentMessage(this.pi.events, {
644
+ to: resolveSubagentIntercomTarget(task.run.runId, task.child.agent, task.child.index), message: text, delivery: "steer", timeoutMs: 15_000, signal: this.pending.signal,
645
+ extra: { messageId: sent.id, human: { ownerSessionId: this.ctx!.sessionManager.getSessionId(), runId: task.run.runId, index: task.child.index },
646
+ ...(quote ? { attachments: [{ type: "context", name: quote.title, content: quote.text }] } : {}) },
647
+ });
648
+ if (!this.live(generation)) return;
649
+ sent.status = receipt.accepted || receipt.delivered ? "waiting" : "unconfirmed";
650
+ sent.reason = receipt.reason;
651
+ if (receipt.accepted || receipt.delivered) this.breadcrumb(task, text, quote);
652
+ else visit.notice = `Delivery unconfirmed. Your draft is kept. ${receipt.reason ?? "Check the conversation before retrying."}`;
653
+ }
654
+ } catch (error) {
655
+ if (this.live(generation)) visit.notice = `Message not confirmed; draft kept. ${error instanceof Error ? error.message : String(error)}`;
656
+ } finally {
657
+ if (this.live(generation)) { this.busy.delete(key); this.refreshSelected(key); this.overlay?.syncDraft(); this.save(); }
658
+ }
659
+ }
660
+
661
+ async stop(key: string): Promise<void> {
662
+ if (!this.live() || this.busy.has(key)) return;
663
+ this.refreshSelected(key);
664
+ const task = this.task(key);
665
+ if (!task || task.child.identityUnavailable || (task.child.state !== "live" && !task.question)) return;
666
+ if (task.child.activity?.status === "pending") return;
667
+ const generation = this.generation;
668
+ this.busy.add(key);
669
+ try {
670
+ const result = await this.execute({ action: "interrupt", id: task.run.runId, index: task.child.index }, this.ctx!);
671
+ if (!this.live(generation)) return;
672
+ this.visit(key).notice = result.isError ? resultText(result) : "Stop requested for this child only. Independent siblings continue; dependent workflow steps may not run.";
673
+ } catch (error) {
674
+ if (this.live(generation)) this.visit(key).notice = `Stop not confirmed: ${error instanceof Error ? error.message : String(error)}`;
675
+ } finally { if (this.live(generation)) { this.busy.delete(key); this.refreshSelected(key); this.changed(); } }
676
+ }
677
+
678
+ async changes(key: string): Promise<AgentHistoryItem | undefined> {
679
+ const task = this.task(key), generation = this.generation;
680
+ if (!task || !this.live()) return;
681
+ const cwd = task.child.launch?.cwd ?? task.run.cwd;
682
+ const result = await this.pi.exec("git", ["--no-pager", "diff", "--no-ext-diff", "--no-textconv", "HEAD", "--"], { cwd, timeout: 10_000, signal: this.pending.signal });
683
+ if (!this.live(generation)) return;
684
+ return { id: "working-tree-changes", kind: "change", title: `Working tree changes · ${cwd}`, timestamp: Date.now(),
685
+ text: "This is the working tree, not a claim that this child made every change. Untracked files are not included; inspect write/tool results for their full content.",
686
+ diff: result.code === 0 ? readableText(result.stdout) || "No tracked changes." : undefined,
687
+ details: result.code === 0 ? undefined : readableText(result.stderr) };
688
+ }
689
+
690
+ dispose(): void {
691
+ this.save();
692
+ this.generation++;
693
+ this.pending.abort();
694
+ this.closeOverlay?.();
695
+ this.overlay?.dispose();
696
+ this.closeOverlay = undefined;
697
+ this.overlay = undefined;
698
+ this.unsubscribe?.(); this.unsubscribe = undefined;
699
+ this.indexUnsubscribe?.(); this.indexUnsubscribe = undefined;
700
+ this.refreshPromise = undefined; this.listRequest++;
701
+ this.listPage = undefined; this.listError = undefined; this.listLoading = false;
702
+ this.selectedKey = undefined; this.offset = 0; this.filter = "";
703
+ if (this.timer) clearInterval(this.timer);
704
+ if (this.saveTimer) clearTimeout(this.saveTimer);
705
+ this.timer = this.saveTimer = undefined;
706
+ this.render = undefined;
707
+ this.widget = undefined;
708
+ this.ctx = undefined;
709
+ this.ownerSessionId = undefined;
710
+ this.tasks = []; this.dockTasks = [];
711
+ this.taskKeys.clear(); this.visits.clear(); this.busy.clear();
712
+ this.pinned = undefined; this.lastSaved = "";
713
+ }
714
+ }
715
+
716
+ class AgentPicker extends Container {
717
+ private list?: SelectList;
718
+ private itemKeys: string[] = [];
719
+ private closed = false;
720
+ private search: Input;
721
+ private signature = "";
722
+ private wasPending = false;
723
+ private hasFocus = false;
724
+ private tui: TUI;
725
+ private theme: Theme;
726
+ private controller: AgentViewController;
727
+ private done: (key?: string) => void;
728
+ constructor(tui: TUI, theme: Theme, controller: AgentViewController, done: (key?: string) => void) {
729
+ super();
730
+ this.tui = tui; this.theme = theme; this.controller = controller; this.done = done;
731
+ this.search = new Input({ placeholder: "Filter agents or assignments…", placeholderStyle: (text) => theme.fg("dim", text) });
732
+ this.search.setValue(controller.listFilter);
733
+ this.render(tui.terminal.columns);
734
+ }
735
+ get focused(): boolean { return this.hasFocus; }
736
+ set focused(value: boolean) { this.hasFocus = value; this.search.focused = value; }
737
+ refresh(): void { this.tui.requestRender(); }
738
+ render(width: number): string[] {
739
+ const height = this.controller.availableHeight(this.tui), innerWidth = Math.max(1, width - 2);
740
+ const query = this.search.getValue();
741
+ const tasks = this.controller.tasks.filter((task) => this.controller.listPage?.rows.some((run) => run.runId === task.run.runId));
742
+ const items = tasks.map((task) => {
743
+ const { status, badge } = taskSummary(task);
744
+ return { value: task.key, label: `${task.child.agent} · ${task.label}`, description: status + badge };
745
+ });
746
+ if (this.controller.listPage?.offset) items.push({ value: "previous-page", label: "Previous agents", description: "PgUp" });
747
+ if (this.controller.listPage?.nextOffset !== undefined) items.push({ value: "next-page", label: "More agents", description: "PgDn" });
748
+ items.push({ value: "retry", label: "Refresh / retry history", description: "F5" });
749
+ if (!query) items.push({ value: "peers", label: "Other connected sessions", description: "All projects" });
750
+ const labelWidth = Math.min(Math.max(0, ...items.map((item) => visibleWidth(item.label))), Math.max(40, Math.floor(innerWidth / 2)));
751
+ for (const [index, task] of tasks.entries()) {
752
+ const item = items[index]!, modelWidth = innerWidth - labelWidth - visibleWidth(item.description) - 7;
753
+ if (modelWidth >= 16) item.description += ` · ${short(task.model.summary, modelWidth)}`;
754
+ }
755
+ this.itemKeys = items.map((item) => item.value);
756
+ const selected = this.controller.listPending || this.wasPending ? undefined : this.list?.getSelectedItem()?.value;
757
+ this.wasPending = this.controller.listPending;
758
+ const selectedIndex = Math.max(0, items.findIndex((item) => item.value === selected));
759
+ const task = this.controller.task(items[selectedIndex]?.value ?? "");
760
+ const status = this.controller.listError ?? (this.controller.listPage?.freshness.state === "catching-up" ? "Catching up · partial results" : this.controller.listPage?.freshness.state === "degraded" ? "Some history unavailable · F5 retry" : this.controller.listLoading ? "Loading…" : "");
761
+ const signature = JSON.stringify([items, query, width, height, status, this.controller.listPage?.total, items[selectedIndex]?.value, task && activity(task), task?.model, task?.child.task, task?.run.runId]);
762
+ if (signature !== this.signature) {
763
+ const compact = height < 16;
764
+ const header = new Container();
765
+ header.addChild(new Text(this.theme.fg("accent", this.theme.bold(truncateToWidth(`Agents · ${this.controller.listPage?.total ?? "…"} owned runs · page ${Math.floor((this.controller.listPage?.offset ?? 0) / 50) + 1}`, innerWidth))), 0, 0));
766
+ header.addChild(this.search);
767
+ if (status) header.addChild(new Text(this.theme.fg(this.controller.listError ? "warning" : "dim", truncateToWidth(status, innerWidth)), 0, 0));
768
+ if (!compact) header.addChild(new Spacer(1));
769
+ const footer = new Container();
770
+ if (!compact) footer.addChild(new Spacer(1));
771
+ if (task && !compact) {
772
+ footer.addChild(new Text(this.theme.fg("muted", short(`${task.child.agent} · ${activity(task)} · ${task.run.runId.slice(0, 8)}`, innerWidth)), 0, 0));
773
+ footer.addChild(new Text(this.theme.fg("dim", short(task.model.summary, innerWidth)), 0, 0));
774
+ const assignment = new Text(readableText(task.child.task ?? "Original assignment unavailable."), 0, 0).render(innerWidth);
775
+ footer.addChild(new Text(assignment.slice(0, 3).join("\n"), 0, 0));
776
+ }
777
+ const up = primaryKey("tui.select.up"), down = primaryKey("tui.select.down");
778
+ const choose = [{ text: up, run: () => this.act("up") }, up && down ? "/" : "", { text: `${down}${compact ? "" : " Choose"}`, run: () => this.act("down") }];
779
+ const open = { text: `${primaryKey("tui.select.confirm")}${compact ? "" : " Open"}`.trim(), run: () => this.act("open") };
780
+ const back = { text: `${primaryKey("tui.select.cancel")}${compact ? "" : " Back"}`.trim(), run: () => this.act("back") };
781
+ const filter = { text: "Type to filter", run: () => this.act("filter") };
782
+ const controls = compact ? [...choose, " · ", open, " · ", back]
783
+ : width < 60 ? [...choose, " · ", open, "\n", back, " · ", filter] : [filter, " · ", ...choose, " · ", open, " · ", back];
784
+ footer.addChild(actionHints(controls, (text) => this.theme.fg("dim", text)));
785
+ const visible = Math.max(1, height - 3 - header.render(innerWidth).length - footer.render(innerWidth).length);
786
+ // Native SelectList needs more than ten cells to show its description column.
787
+ const descriptionWidth = innerWidth > 40 ? Math.max(11, ...items.map((item) => visibleWidth(item.description ?? ""))) : 0;
788
+ const primaryWidth = Math.max(1, innerWidth - descriptionWidth - (descriptionWidth ? 4 : 2));
789
+ this.list = new SelectList(items, visible, getSelectListTheme(), { minPrimaryColumnWidth: Math.min(24, primaryWidth), maxPrimaryColumnWidth: primaryWidth, truncatePrimary: ({ text, maxWidth }) => truncateToWidth(text, maxWidth) });
790
+ this.list.setSelectedIndex(selectedIndex);
791
+ this.list.onSelect = (item) => {
792
+ if (this.controller.listPending && !["retry", "peers"].includes(item.value)) return;
793
+ if (item.value === "next-page" || item.value === "previous-page") this.controller.pageTasks(item.value === "next-page" ? "later" : "earlier");
794
+ else if (item.value === "retry") void this.controller.retry();
795
+ else this.done(item.value);
796
+ };
797
+ this.list.onCancel = () => this.act("back");
798
+ const body = new Box(1, 0, (text) => this.theme.bg("customMessageBg", text));
799
+ body.addChild(header);
800
+ body.addChild(items.length ? this.list : new Text(query ? "No matching agents." : "No agents owned by this session yet.", 0, 0));
801
+ body.addChild(footer);
802
+ this.clear();
803
+ this.addChild(new DynamicBorder((text) => this.theme.fg("borderAccent", text)));
804
+ this.addChild(body);
805
+ this.addChild(new DynamicBorder((text) => this.theme.fg("borderAccent", text)));
806
+ this.signature = signature;
807
+ }
808
+ return super.render(width);
809
+ }
810
+ invalidate(): void { this.signature = ""; super.invalidate(); }
811
+ syncDraft(): void {}
812
+ private act(action: "up" | "down" | "open" | "back" | "filter"): void {
813
+ if (this.closed) return;
814
+ if (action === "back") this.done();
815
+ else if (action === "filter") this.tui.setFocus(this);
816
+ else if (action === "open") { const item = this.list?.getSelectedItem(); if (item) this.list?.onSelect?.(item); }
817
+ else if (this.itemKeys.length) {
818
+ const index = this.itemKeys.indexOf(this.list?.getSelectedItem()?.value ?? "");
819
+ this.list?.setSelectedIndex((index + (action === "up" ? -1 : 1) + this.itemKeys.length) % this.itemKeys.length);
820
+ }
821
+ this.tui.requestRender();
822
+ }
823
+ handleInput(data: string): void {
824
+ if (this.closed) return;
825
+ if (matchesKey(data, this.controller.shortcut)) { this.done(); return; }
826
+ if (matchesKey(data, "pageDown") || matchesKey(data, "pageUp")) { this.controller.pageTasks(matchesKey(data, "pageDown") ? "later" : "earlier"); return; }
827
+ if (matchesKey(data, "f5")) { void this.controller.retry(); return; }
828
+ const keys = getKeybindings();
829
+ if (keys.matches(data, "tui.select.cancel")) { this.act("back"); return; }
830
+ if ((["tui.select.up", "tui.select.down", "tui.select.confirm"] as const).some((key) => keys.matches(data, key))) this.list?.handleInput(data);
831
+ else { const previous = this.search.getValue(); this.search.handleInput(data); if (this.search.getValue() !== previous) this.list = undefined; this.controller.filterTasks(this.search.getValue()); }
832
+ this.tui.requestRender();
833
+ }
834
+ dispose(): void { this.closed = true; }
835
+ }
836
+
837
+ /** Native ScrollView owns follow/scroll state; overlays only need a bounded render adapter. */
838
+ export class AgentConversation extends Container {
839
+ readonly editor: Editor;
840
+ readonly scroll: ScrollView;
841
+ private viewport: Component;
842
+ private editorViewport: Component;
843
+ private editorHeight = Infinity;
844
+ private editorFocus = true;
845
+ private hasFocus = false;
846
+ private closed = false;
847
+ private beforeInput = "";
848
+ private height = 1;
849
+ private lastWidth?: number;
850
+ private selectedId?: string;
851
+ private detail?: AgentHistoryItem;
852
+ private detailError?: string;
853
+ private menu?: SelectList;
854
+ private lines: Array<{ id: string; entryIds: string[]; start: number; contentStart: number; end: number }> = [];
855
+ private components = new Map<string, { signature: string; item: AgentHistoryItem; component: Component }>();
856
+ private initialPosition = true;
857
+ private initialVisit: Pick<AgentVisit, "anchor" | "readThrough">;
858
+ private restoreAnchor?: Anchor;
859
+ private conversationAnchor?: Anchor;
860
+ private contentItems: AgentHistoryItem[] = [];
861
+ private pendingDetail = false;
862
+ private loadingDetail = false;
863
+ private toolsExpanded = false;
864
+ private toolExpansion = new Map<string, boolean>();
865
+ private pageRequest = 0;
866
+ private detailRequest = 0;
867
+ private loadingPage = false;
868
+ private pageError?: string;
869
+ private latestPage = true;
870
+ private readSequence = -1;
871
+ private seenSource = false;
872
+ private toolDefinitions: Map<string, ConstructorParameters<typeof ToolExecutionComponent>[4]>;
873
+ private keys?: KeybindingsManager;
874
+
875
+ private tui: TUI;
876
+ private theme: Theme;
877
+ private controller: AgentViewController;
878
+ readonly key: string;
879
+ private done: (key?: string) => void;
880
+
881
+ constructor(tui: TUI, theme: Theme, controller: AgentViewController, key: string, done: (key?: string) => void, keys?: KeybindingsManager) {
882
+ super();
883
+ this.tui = tui; this.theme = theme; this.controller = controller; this.key = key; this.done = done; this.keys = keys;
884
+ this.seenSource = Boolean(this.task?.page?.count);
885
+ this.initialVisit = { anchor: this.visit.anchor, readThrough: this.visit.readThrough };
886
+ const cwd = this.task?.child.launch?.cwd ?? this.task?.run.cwd ?? process.cwd();
887
+ this.toolDefinitions = new Map([createReadToolDefinition, createBashToolDefinition, createEditToolDefinition, createWriteToolDefinition, createFindToolDefinition, createGrepToolDefinition, createLsToolDefinition, createPowerShellToolDefinition].map((create) => { const definition = create(cwd); return [definition.name, definition]; }));
888
+ this.editor = new Editor(tui, { borderColor: (text) => theme.fg("accent", text), selectList: getSelectListTheme() }, { paddingX: 0 });
889
+ this.editor.setText(this.visit.draft);
890
+ this.editor.onChange = () => { if (this.closed) return; this.visit.draft = this.editor.getExpandedText(); this.controller.changed(); };
891
+ this.editor.onSubmit = (text) => {
892
+ this.editor.setText(this.beforeInput);
893
+ if (!this.loadingDetail) void this.controller.send(this.key, text);
894
+ };
895
+ let editorOffset = 0;
896
+ this.editorViewport = {
897
+ invalidate: () => this.editor.invalidate(),
898
+ render: (width) => {
899
+ const lines = this.editor.render(width);
900
+ // Native overlays do not allocate editor height; keep Pi's cursor and input handling intact when space is short.
901
+ const cursor = lines.findIndex((line) => line.includes(CURSOR_MARKER));
902
+ if (cursor >= 0) editorOffset = Math.max(0, cursor - this.editorHeight + 1);
903
+ editorOffset = Math.max(0, Math.min(editorOffset, lines.length - this.editorHeight));
904
+ return lines.slice(editorOffset, editorOffset + this.editorHeight);
905
+ },
906
+ handleMouse: (event) => this.editor.handleMouse({ ...event, y: event.y + editorOffset }),
907
+ };
908
+ this.scroll = new ScrollView({ render: (width) => this.renderHistory(width), invalidate() {} }, { follow: "end", scrollbar: "hidden" });
909
+ this.viewport = { invalidate: () => this.scroll.invalidate(), render: (width) => {
910
+ if (this.lastWidth !== width && !this.scroll.isFollowingEnd) this.restoreAnchor ??= this.anchor();
911
+ this.lastWidth = width;
912
+ const lines = this.scroll.render(width);
913
+ this.scroll.updateLayout(lines.length, this.height, () => this.tui.requestRender());
914
+ if (this.initialPosition && !this.loadingPage && (this.task?.page || this.task?.child.identityUnavailable)) {
915
+ this.initialPosition = false;
916
+ const ids = this.task?.historyIds ?? [];
917
+ const readIndex = this.initialVisit.readThrough ? ids.indexOf(this.initialVisit.readThrough) : -1;
918
+ const unread = this.initialVisit.readThrough === null ? ids[0] : readIndex >= 0 ? ids[readIndex + 1] : undefined;
919
+ this.restoreAnchor = unread ? { id: unread, line: 0 } : this.initialVisit.anchor;
920
+ if (!this.restoreAnchor && this.task?.finalId) {
921
+ this.restoreAnchor = { id: this.task.finalId, line: 0 };
922
+ // The finished report is the starting point; earlier activity stays available above it.
923
+ this.visit.readThrough ??= ids[ids.indexOf(this.task.finalId) - 1] ?? null;
924
+ }
925
+ }
926
+ if (this.restoreAnchor) {
927
+ const line = this.lines.find((line) => line.entryIds.includes(this.restoreAnchor!.id));
928
+ if (line) this.scroll.scrollTo(line.start + Math.min(this.restoreAnchor.line, Math.max(0, line.end - line.start - 1)), { disableFollow: true });
929
+ this.restoreAnchor = undefined;
930
+ }
931
+ const visible = lines.slice(this.scroll.scrollTop, this.scroll.scrollTop + this.height);
932
+ while (visible.length < this.height) visible.push("");
933
+ if (!this.detail && !this.loadingPage && !this.initialPosition) {
934
+ this.visit.anchor = this.scroll.isFollowingEnd ? undefined : this.anchor();
935
+ const ids = this.task?.historyIds ?? [];
936
+ const seen = this.lines.filter((line) => line.end > this.scroll.scrollTop && line.end <= this.scroll.scrollTop + this.height).flatMap((line) => line.entryIds);
937
+ const last = ids.findLastIndex((id) => seen.includes(id));
938
+ if (last >= 0) {
939
+ const id = ids[last]!.startsWith("result:") ? this.task?.page?.latestEntryId ?? ids[last]! : ids[last]!;
940
+ const nativeId = id.replace(/:(?:\d+|error)$/, ""), sequence = this.task?.page?.entries.find((entry) => entry.id === nativeId)?.sequence;
941
+ if (sequence !== undefined && (sequence > this.readSequence || sequence === this.readSequence && last > (this.visit.readThrough ? ids.indexOf(this.visit.readThrough) : -1))) {
942
+ this.visit.readThrough = id; this.readSequence = sequence;
943
+ }
944
+ }
945
+ this.visit.readThrough ??= null;
946
+ if (this.scroll.isFollowingEnd) this.visit.seenActivityAt = this.task?.child.activity?.lastActivityAt ?? Date.now();
947
+ }
948
+ return visible;
949
+ }, handleMouse: (event) => this.historyMouse(event) };
950
+ void this.loadPage({}, this.visit.anchor?.id ?? this.visit.readThrough);
951
+ }
952
+
953
+ private async loadPage(paging: Pick<HistoryPageInput, "before" | "after" | "cursor"> = {}, anchor?: string | null): Promise<void> {
954
+ if (this.closed) return;
955
+ const request = ++this.pageRequest;
956
+ this.loadingPage = true; this.pageError = undefined;
957
+ if (this.task) this.task.historyLoading = true;
958
+ this.latestPage = paging.before === undefined && paging.after === undefined && paging.cursor === undefined;
959
+ this.tui.requestRender();
960
+ try {
961
+ let result = await this.controller.historyPage(this.key, paging, anchor);
962
+ if (this.closed || request !== this.pageRequest || !result || !this.task) return;
963
+ if (this.initialPosition && this.initialVisit.readThrough === undefined && !this.initialVisit.anchor && this.task.child.state !== "live" && result.history.finalId && !result.history.entryIds.includes(result.history.finalId)) {
964
+ const reportPage = await this.controller.historyPage(this.key, {}, result.history.finalId);
965
+ if (this.closed || request !== this.pageRequest || !this.task) return;
966
+ if (reportPage) result = reportPage;
967
+ }
968
+ let task = this.task;
969
+ if (result.readThroughSequence !== undefined) this.readSequence = result.readThroughSequence;
970
+ this.latestPage = result.latestPage;
971
+ let history = result.history;
972
+ if ((this.initialPosition || this.latestPage || history.finalId && history.entryIds.includes(history.finalId)) && task.child.state !== "live" && task.child.result && getSingleResultOutput(task.child.result)) {
973
+ const report = await this.controller.savedResult(this.key, `result:${task.run.runId}`);
974
+ const currentTask = this.task;
975
+ if (this.closed || request !== this.pageRequest || !currentTask || currentTask.run.runId !== task.run.runId) return;
976
+ task = currentTask;
977
+ history = withFinalResult(history, report.text, task.run.runId, task.run.updatedAt);
978
+ }
979
+ task.history = history.items; task.historyIds = history.entryIds; task.finalId = history.finalId; task.page = result.page;
980
+ for (const item of task.history) if (item.id === `result:${task.run.runId}`) item.load = () => this.controller.savedResult(this.key, item.id);
981
+ this.seenSource ||= result.page.count > 0;
982
+ task.unavailable = !this.seenSource && task.child.state === "live" && ["missing", "pending", "indexing", "unlinked"].includes(result.page.sourceState) ? undefined : history.unavailable;
983
+ this.controller.applyMetadata(task, result.page);
984
+ this.components.clear();
985
+ } catch (error) {
986
+ if (!this.closed && request === this.pageRequest) this.pageError = `History unavailable: ${error instanceof Error ? error.message : String(error)}. F5 retries the selected run.`;
987
+ } finally {
988
+ if (!this.closed && request === this.pageRequest) { this.loadingPage = false; if (this.task) this.task.historyLoading = false; this.tui.requestRender(); }
989
+ }
990
+ }
991
+
992
+ private get visit(): AgentVisit { return this.controller.visit(this.key); }
993
+ private get task(): AgentTask | undefined { return this.controller.task(this.key); }
994
+ get focused(): boolean { return this.hasFocus; }
995
+ set focused(value: boolean) { this.hasFocus = value; this.editor.focused = value && this.editorFocus && !this.detail && !this.menu; }
996
+ private anchor(): Anchor | undefined {
997
+ const line = this.lines.findLast((line) => line.start <= this.scroll.scrollTop);
998
+ return line ? { id: line.id, line: this.scroll.scrollTop - line.start } : undefined;
999
+ }
1000
+ refresh(): void {
1001
+ if (this.closed) return;
1002
+ if (this.detail?.id === "assignment" && this.task) this.detail = this.assignment();
1003
+ if (!this.initialPosition && !this.scroll.isFollowingEnd) this.restoreAnchor = this.anchor();
1004
+ if (!this.loadingPage && this.latestPage && this.scroll.isFollowingEnd && !this.detail && !this.pageError) void this.loadPage();
1005
+ this.tui.requestRender();
1006
+ }
1007
+ syncDraft(): void { if (!this.closed && this.editor.getExpandedText() !== this.visit.draft) this.editor.setText(this.visit.draft); }
1008
+
1009
+ private assignment(): AgentHistoryItem {
1010
+ const task = this.task!;
1011
+ return { id: "assignment", kind: "notice", title: task.child.identityUnavailable ? "Assignment unavailable" : `Assignment · ${task.child.agent}`, text: task.child.identityUnavailable ? UNAVAILABLE_ASSIGNMENT : `${task.model.details}\n\n${task.child.task ?? "The original per-child assignment was not saved for this older run."}`, timestamp: task.run.startedAt };
1012
+ }
1013
+
1014
+ private items(): AgentHistoryItem[] {
1015
+ const task = this.task;
1016
+ if (!task) return [{ id: "unavailable", kind: "notice", title: "Agent unavailable", text: "The owning session or run is no longer available.", timestamp: 0 }];
1017
+ if (this.detail) return [this.detail];
1018
+ const page = task.page;
1019
+ const status = this.pageError ?? (this.loadingPage && !task.page ? "Loading conversation…" : page?.freshness.state === "catching-up" ? "Catching up · partial history" : page?.freshness.state === "degraded" ? "Some saved history unavailable · F5 retry" : undefined);
1020
+ const items = [this.assignment(), ...(status ? [{ id: "history-loading", kind: "notice" as const, title: status, text: "", timestamp: 0 }] : []),
1021
+ ...(page && page.count > page.entries.length ? [{ id: "history-page", kind: "notice" as const, title: `History · ${page.entries.length} of ${page.count} native records${this.latestPage ? " · latest" : ""}`, text: "F2 Actions → Earlier history / Later history / Latest. Full details validates the selected native record.", timestamp: 0 }] : []),
1022
+ ...(!task.child.identityUnavailable && task.child.state !== "live" ? [{ id: "process-exit", kind: "notice" as const, title: `Agent: ${task.child.state}`, text: formatAgentProcessExit(task.child.result?.agentProcessExit), timestamp: task.run.updatedAt }] : []), ...task.history];
1023
+ const humanAction = task.child.humanAction ?? acceptanceHumanAction(task.child.result?.acceptance);
1024
+ if (humanAction && !task.child.identityUnavailable) items.push({ id: "human-action", kind: "notice", title: "Needs your action — acceptance incomplete", text: humanAction, timestamp: task.run.updatedAt });
1025
+ if (task.unavailable && !task.child.identityUnavailable) items.push({ id: "unavailable", kind: "notice", title: "Conversation unavailable", text: task.unavailable, timestamp: 0 });
1026
+ if (task.question) items.push({ id: `question:${task.question.questionId}`, kind: "notice", title: "Waiting for your answer", text: task.question.message, timestamp: task.question.createdAt });
1027
+ if (!task.child.identityUnavailable && task.child.state === "live" && task.child.activity?.streamingText) items.push({ id: `live:${task.run.runId}`, kind: "assistant", title: "Agent · writing", text: task.child.activity.streamingText, timestamp: task.child.activity.lastActivityAt ?? 0 });
1028
+ for (const sent of this.visit.outbox) items.push({ id: `outgoing:${sent.id}`, kind: "user", title: sent.status === "sending" ? "You · sending" : sent.status === "waiting" ? "You · waiting for the child / tool boundary" : "You · delivery unconfirmed",
1029
+ text: `${sent.text}${sent.quote ? `\n\nRegarding ${sent.quote.title}:\n${sent.quote.text}` : ""}${sent.reason ? `\n\n${sent.reason}` : ""}`, timestamp: sent.at });
1030
+ return items;
1031
+ }
1032
+
1033
+ private renderHistory(width: number): string[] {
1034
+ this.contentItems = this.items();
1035
+ const visibleIds = new Set(this.contentItems.map((item) => item.id));
1036
+ for (const id of this.components.keys()) if (!visibleIds.has(id)) this.components.delete(id);
1037
+ this.lines = [];
1038
+ const lines: string[] = [];
1039
+ for (const item of this.contentItems) {
1040
+ const selected = item.id === this.selectedId && !this.editorFocus;
1041
+ const expanded = Boolean(this.detail) || (this.toolExpansion.get(item.id) ?? this.toolsExpanded);
1042
+ // Native cards and selected details are immutable snapshots. Comparing
1043
+ // identity avoids evaluating/serializing their potentially large bodies.
1044
+ const signature = `${expanded}:${Boolean(this.detail)}`;
1045
+ let cached = this.components.get(item.id);
1046
+ const sameDisplay = cached && !item.load && !cached.item.load && !item.assistant && !cached.item.assistant && !item.call && !cached.item.call && !item.result && !cached.item.result
1047
+ && item.kind === cached.item.kind && item.title === cached.item.title && item.text === cached.item.text
1048
+ && item.details === cached.item.details && item.diff === cached.item.diff && item.model === cached.item.model
1049
+ && item.timestamp === cached.item.timestamp && item.messageId === cached.item.messageId;
1050
+ if (cached?.signature !== signature || cached.item !== item && !sameDisplay) {
1051
+ const component = new Container();
1052
+ if (this.detail && item.model) component.addChild(new Text(this.theme.fg("muted", `Message model: ${readableText(formatModelThinking(item.model))}`), 0, 0));
1053
+ if (item.call || item.result) {
1054
+ const name = item.call?.name ?? item.result!.toolName;
1055
+ const definition = (item.call && this.toolDefinitions.get(name)) || { renderCall: () => new Text(this.theme.fg("toolTitle", this.theme.bold(readableText(item.title))), 0, 0) };
1056
+ const options = { compactView: true, showImages: false };
1057
+ const tool = new ToolExecutionComponent(name, item.call?.id ?? item.result!.toolCallId, item.call?.arguments ?? {}, options, definition, this.tui, this.task?.child.launch?.cwd ?? this.task?.run.cwd ?? process.cwd());
1058
+ // Replay only saved data: execution/args-complete hooks would invent timing or re-read today's file for an edit preview.
1059
+ if (item.result) tool.updateResult(item.result);
1060
+ tool.setExpanded(expanded);
1061
+ component.addChild(tool);
1062
+ // Native edit cards already show their recorded diff.
1063
+ if (this.detail && item.diff && item.call?.name !== "edit") component.addChild(new Text(renderDiff(item.diff), 0, 0));
1064
+ if (!item.result) component.addChild(new Text(this.theme.fg("dim", "Result not recorded · exit unconfirmed"), 1, 0));
1065
+ } else if (item.kind === "assistant" || item.kind === "thinking") {
1066
+ // Live text and canonical reports are display-only, never saved as synthetic model messages.
1067
+ const message: AssistantMessage = item.assistant ?? { role: "assistant", content: [{ type: "text", text: item.text }], timestamp: item.timestamp,
1068
+ api: "", provider: "", model: "", stopReason: "stop", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
1069
+ component.addChild(new AssistantMessageComponent(this.detail ? message : { ...message, content: message.content.map((part) => part.type === "text" ? { ...part, text: stripAcceptanceReport(readableText(part.text)) } : part) }, !this.detail, getMarkdownTheme(), "Thinking · open details to read"));
1070
+ } else if (item.kind === "user") {
1071
+ if (item.id.startsWith("outgoing:") || item.messageId) component.addChild(new Text(this.theme.fg("muted", item.title), 0, 0));
1072
+ component.addChild(new UserMessageComponent(item.text, getMarkdownTheme(), 1));
1073
+ } else {
1074
+ component.addChild(new Text(this.theme.fg("muted", readableText(item.title)), 0, 0));
1075
+ component.addChild(new Text(item.text, 0, 0));
1076
+ if (item.diff) component.addChild(new Text(renderDiff(item.diff), 0, 0));
1077
+ }
1078
+ if (this.detail && item.details) component.addChild(new Text(item.details, 0, 1));
1079
+ component.addChild(new Spacer(1));
1080
+ cached = { signature, item, component }; this.components.set(item.id, cached);
1081
+ }
1082
+ const start = lines.length;
1083
+ if (selected) lines.push(this.theme.fg("accent", short(`› ${item.title}`, width)));
1084
+ const contentStart = lines.length;
1085
+ lines.push(...cached.component.render(width));
1086
+ this.lines.push({ id: item.id, entryIds: item.entryIds ?? [item.id], start, contentStart, end: lines.length });
1087
+ }
1088
+ return lines;
1089
+ }
1090
+
1091
+ render(width: number): string[] {
1092
+ const task = this.task, innerWidth = Math.max(1, width - 2);
1093
+ const height = this.controller.availableHeight(this.tui), compact = height < 16;
1094
+ const terminal = task?.child.state !== "live" && !task?.question;
1095
+ const primary = task?.child.identityUnavailable || task?.child.activity?.status === "pending" ? undefined : terminal ? "continue" : this.editorFocus ? "send" : "details";
1096
+ const actionHint = this.loadingDetail && !this.detail ? "Loading quoted context…" : task?.child.identityUnavailable ? "Assignment unavailable · draft kept" : task?.child.activity?.status === "pending" ? "Waiting to start · draft kept" : terminal ? width < 60 ? "Alt+C Continue" : "Alt+C Continue with message" : this.editorFocus ? `${primaryKey("tui.input.submit")} Send`.trim() : "Enter Details";
1097
+ const primaryHint = primary ? { text: actionHint, run: () => this.act(primary) } : actionHint;
1098
+ const actions = { text: compact ? "F2" : "F2 Actions", run: () => this.act("actions") };
1099
+ const focus = { text: compact ? "Tab" : "Tab Read/write", run: () => this.act("focus") };
1100
+ const reply = { text: compact ? "Alt+R" : "Alt+R Reply", run: () => this.act("reply") };
1101
+ const back = { text: this.menu ? `${primaryKey("tui.select.cancel")}${compact ? "" : this.detail ? " Back to details" : " Back to conversation"}`.trim() : compact ? "Esc" : "Esc Back", run: () => this.act("back") };
1102
+ const choose = { text: `${primaryKey("tui.select.confirm")}${compact ? "" : " Choose"}`.trim(), run: () => this.act("choose") };
1103
+ const controls = this.menu ? [choose, " · ", back] : this.detail ? [reply, " · ", actions, " · ", back] : compact ? [actions, " · ", focus, " · ", back]
1104
+ : width < 60 ? [actions, " · ", back, "\n", primaryHint, " · ", focus] : [primaryHint, " · ", actions, " · ", focus, " · ", back];
1105
+ const footer = actionHints(controls, this.menu ? undefined : (text) => this.theme.fg("dim", text));
1106
+ // Reserve title, controls and required content before spending rows on status or borders.
1107
+ const contentRows = this.menu ? this.menu.render(innerWidth).length : 1 + (this.detail ? 0 : 1 + Number(!compact) + Number(Boolean(this.visit.notice)) + Number(Boolean(this.visit.quote)));
1108
+ const requiredRows = 1 + footer.render(innerWidth).length + contentRows;
1109
+ const showStatus = height > requiredRows;
1110
+ const framed = height >= requiredRows + Number(showStatus) + 2;
1111
+ this.clear();
1112
+ const body = new Box(1, 0, (text) => this.theme.bg("customMessageBg", text));
1113
+ const header = new Container();
1114
+ header.addChild(new Text(this.theme.fg("accent", this.theme.bold(truncateToWidth(`${this.loadingPage && !task?.page && !this.detail ? "Loading conversation…" : this.pageError && !this.detail ? "History unavailable · F5 retry" : "Agents"} › ${task?.label ?? "unavailable"}${this.detail ? " › details" : ""}`, innerWidth))), 0, 0));
1115
+ if (showStatus) header.addChild(actionHints([
1116
+ ...(task?.unread && !this.scroll.isFollowingEnd ? ["New activity · ", ...(this.menu ? [] : [{ text: "Alt+L latest", run: () => this.act("latest") }, " · "])] : []),
1117
+ task ? `${task.child.agent} · ${activity(task)} · ${task.model.summary}` : "Unavailable",
1118
+ ], (text) => this.theme.fg("dim", text), "..."));
1119
+ body.addChild(header);
1120
+ if (this.menu) {
1121
+ body.addChild(this.menu);
1122
+ body.addChild(footer);
1123
+ } else {
1124
+ const bottom = new Container();
1125
+ if (!this.detail) {
1126
+ const notice = this.visit.notice;
1127
+ if (notice) bottom.addChild(actionHints(typeof notice === "string" ? [readableText(notice).replace(/\s+/g, " ").trim()]
1128
+ : [CONTINUE_NOTICES[notice.continue], { text: CONTINUE_HINT, run: () => this.act("continue") }, "."], (text) => this.theme.fg("warning", text), "..."));
1129
+ if (this.visit.quote) bottom.addChild(actionHints(["Quote · ", { text: "Alt+Q remove", run: () => this.act("unquote") }, ": ", this.visit.quote.title], (text) => this.theme.fg("dim", text), "..."));
1130
+ if (!compact) bottom.addChild(new Text(this.theme.fg("accent", truncateToWidth(`${task?.question ? "Answer" : "Message"} ${task?.label ?? "agent"}${this.controller.isBusy(this.key) ? " · sending" : ""}`, innerWidth)), 0, 0));
1131
+ bottom.addChild(this.editorViewport);
1132
+ }
1133
+ bottom.addChild(footer);
1134
+ const space = height - (framed ? 2 : 0) - header.render(innerWidth).length;
1135
+ this.editorHeight = Infinity;
1136
+ const bottomHeight = bottom.render(innerWidth).length;
1137
+ if (!this.detail) this.editorHeight = Math.max(1, space - (bottomHeight - this.editor.render(innerWidth).length) - 1);
1138
+ this.height = Math.max(1, space - bottom.render(innerWidth).length);
1139
+ body.addChild(this.viewport); body.addChild(bottom);
1140
+ }
1141
+ if (framed) this.addChild(new DynamicBorder((text) => this.theme.fg("borderAccent", text)));
1142
+ this.addChild(body);
1143
+ if (framed) this.addChild(new DynamicBorder((text) => this.theme.fg("borderAccent", text)));
1144
+ this.focused = this.hasFocus;
1145
+ return super.render(width);
1146
+ }
1147
+
1148
+ invalidate(): void { this.components.clear(); super.invalidate(); }
1149
+
1150
+ handleMouse(event: TuiMouseEvent) {
1151
+ const result = super.handleMouse(event);
1152
+ if (result?.target.component === this.editorViewport) { this.editorFocus = true; this.focused = this.hasFocus; }
1153
+ return result;
1154
+ }
1155
+
1156
+ private historyMouse(event: TuiMouseEvent) {
1157
+ if (event.type === "wheel") { this.scroll.scrollBy(event.wheelDelta ?? 0); this.restoreAnchor = this.anchor(); return { handled: true }; }
1158
+ if (event.type !== "click" || event.button !== "left") return;
1159
+ const line = this.lines.find((line) => line.start <= event.y + this.scroll.scrollTop && line.end > event.y + this.scroll.scrollTop);
1160
+ if (!line) return;
1161
+ this.restoreAnchor = this.anchor();
1162
+ this.editorFocus = false;
1163
+ this.selectedId = line.id;
1164
+ const item = this.selected();
1165
+ if ((item?.call || item?.result) && !this.detail) this.toolExpansion.set(line.id, !(this.toolExpansion.get(line.id) ?? this.toolsExpanded));
1166
+ else this.components.get(line.id)?.component.handleMouse?.({ ...event, y: event.y + this.scroll.scrollTop - line.contentStart, height: line.end - line.contentStart });
1167
+ return { handled: true, focus: true };
1168
+ }
1169
+
1170
+ private select(delta: number): void {
1171
+ const current = this.lines.findIndex((line) => line.id === this.selectedId);
1172
+ const index = Math.max(0, Math.min(this.lines.length - 1, (current < 0 ? this.lines.findIndex((line) => line.end > this.scroll.scrollTop) : current) + delta));
1173
+ const line = this.lines[index];
1174
+ if (!line) return;
1175
+ this.selectedId = line.id;
1176
+ if (line.start < this.scroll.scrollTop || line.start >= this.scroll.scrollTop + this.height) this.scroll.scrollTo(line.start, { disableFollow: true });
1177
+ this.restoreAnchor = this.anchor();
1178
+ }
1179
+
1180
+ private selected(): AgentHistoryItem | undefined { return this.detail ?? this.contentItems.find((item) => item.id === this.selectedId) ?? this.contentItems.findLast((item) => item.kind === "assistant"); }
1181
+ private async inspect(item: AgentHistoryItem): Promise<void> {
1182
+ const request = ++this.detailRequest, runId = this.task?.run.runId;
1183
+ this.conversationAnchor = this.scroll.isFollowingEnd ? undefined : this.anchor();
1184
+ this.loadingDetail = Boolean(item.load); this.detailError = undefined;
1185
+ this.detail = item.load ? { id: item.id, kind: "notice", title: "Loading selected details…", text: "Native record validation is running in the history process.", timestamp: item.timestamp } : item;
1186
+ this.menu = undefined; this.editorFocus = false;
1187
+ this.scroll.scrollToStart(); this.restoreAnchor = { id: item.id, line: 0 };
1188
+ this.tui.requestRender();
1189
+ try {
1190
+ const detail = item.load ? await item.load() : item;
1191
+ if (!this.closed && request === this.detailRequest && this.task?.run.runId === runId) this.detail = detail;
1192
+ } catch (error) {
1193
+ if (!this.closed && request === this.detailRequest && this.task?.run.runId === runId) {
1194
+ this.detailError = readableText(error instanceof Error ? error.message : String(error));
1195
+ this.detail = { id: item.id, kind: "notice", title: "Details unavailable", text: this.detailError, timestamp: item.timestamp };
1196
+ }
1197
+ }
1198
+ if (!this.closed && request === this.detailRequest) { this.loadingDetail = false; this.tui.requestRender(); }
1199
+ }
1200
+ private async reply(): Promise<void> {
1201
+ if (this.loadingDetail) return;
1202
+ if (this.detail && this.detailError) {
1203
+ this.visit.notice = `Quoted context unavailable; draft kept. ${this.detailError}`;
1204
+ this.act("back"); this.controller.changed(); return;
1205
+ }
1206
+ let item = this.selected();
1207
+ if (!item) return;
1208
+ const anchor = this.detail ? this.conversationAnchor : this.scroll.isFollowingEnd ? undefined : this.anchor();
1209
+ if (item.load && !this.detail) {
1210
+ const request = ++this.detailRequest, runId = this.task?.run.runId;
1211
+ this.loadingDetail = true; this.editorFocus = true; this.focused = this.hasFocus; this.tui.requestRender();
1212
+ try {
1213
+ item = await item.load();
1214
+ if (this.closed || request !== this.detailRequest || this.task?.run.runId !== runId) return;
1215
+ } catch (error) {
1216
+ if (!this.closed && request === this.detailRequest) { this.visit.notice = `Quoted context unavailable; draft kept. ${readableText(error instanceof Error ? error.message : String(error))}`; this.controller.changed(); }
1217
+ return;
1218
+ } finally {
1219
+ if (!this.closed && request === this.detailRequest) { this.loadingDetail = false; this.tui.requestRender(); }
1220
+ }
1221
+ }
1222
+ this.visit.quote = { title: item.title, text: (item.call || item.result ? [item.diff, item.details ?? item.text] : [item.text, item.diff, item.details]).filter(Boolean).join("\n\n") };
1223
+ this.detailRequest++; this.detail = undefined; this.menu = undefined; this.editorFocus = true;
1224
+ this.restoreAnchor = anchor; this.controller.changed();
1225
+ }
1226
+ private actions(): void {
1227
+ const task = this.task;
1228
+ const choices = [
1229
+ { value: "reply", label: "Reply to selected message / tool / change" },
1230
+ { value: "details", label: "Full details / diff" },
1231
+ { value: "expand", label: this.toolsExpanded ? "Collapse tool output" : "Expand tool output" },
1232
+ { value: "assignment", label: "Full original assignment" },
1233
+ { value: "changes", label: "Inspect working tree changes" },
1234
+ { value: "latest", label: "Jump to latest activity" },
1235
+ { value: "pin", label: this.controller.pinned === this.key ? "Unpin this agent" : "Keep this agent visible" },
1236
+ ...(this.visit.quote ? [{ value: "unquote", label: "Remove quoted context" }] : []),
1237
+ ...(task?.child.identityUnavailable || task?.child.activity?.status === "pending" ? [] : task?.child.state === "live" || task?.question ? [{ value: "stop", label: "Stop this agent only" }] : [{ value: "continue", label: "Continue with this message" }]),
1238
+ { value: "picker", label: "Your other agents" }, { value: "peers", label: "Other connected sessions" },
1239
+ { value: "earlier", label: "Earlier history" },
1240
+ { value: "later", label: "Later history" },
1241
+ { value: "retry", label: "Refresh / retry selected history" },
1242
+ ];
1243
+ this.menu = new SelectList(choices, Math.max(1, this.controller.availableHeight(this.tui) - 7), getSelectListTheme());
1244
+ this.menu.onSelect = (item) => { this.menu = undefined; this.act(item.value); };
1245
+ this.menu.onCancel = () => { this.menu = undefined; };
1246
+ }
1247
+ private act(action: string): void {
1248
+ if (this.closed) return;
1249
+ if (action === "back") {
1250
+ if (this.menu) this.menu.onCancel?.();
1251
+ else if (this.detail) { this.detailRequest++; this.loadingDetail = false; this.detail = undefined; this.editorFocus = true; this.restoreAnchor = this.conversationAnchor; if (!this.restoreAnchor) this.scroll.scrollToEnd(); }
1252
+ else this.finish();
1253
+ } else if (action === "choose") { const item = this.menu?.getSelectedItem(); if (item) this.menu?.onSelect?.(item); }
1254
+ else if (action === "actions") this.actions();
1255
+ else if (action === "focus" && !this.detail) { this.editorFocus = !this.editorFocus; if (!this.editorFocus) this.select(0); }
1256
+ else if (action === "send" && !this.loadingDetail) void this.controller.send(this.key, this.editor.getExpandedText().trim());
1257
+ else if (action === "reply") void this.reply();
1258
+ else if (action === "details") { const item = this.selected(); if (item) void this.inspect(item); }
1259
+ else if (action === "earlier" || action === "later") {
1260
+ const page = this.task?.page;
1261
+ if (action === "earlier" && page?.previousBefore !== undefined) void this.loadPage({ before: page.previousBefore });
1262
+ else if (action === "later" && !this.latestPage && page?.nextAfter !== undefined) void this.loadPage({ after: page.nextAfter });
1263
+ this.components.clear(); this.selectedId = undefined; this.scroll.scrollToEnd(); this.tui.requestRender();
1264
+ }
1265
+ else if (action === "retry") { void this.controller.retry(this.key).then(() => { if (!this.closed) void this.loadPage(); }); }
1266
+ else if (action === "assignment" && this.task) void this.inspect(this.assignment());
1267
+ else if (action === "expand") {
1268
+ if (!this.scroll.isFollowingEnd) this.restoreAnchor = this.anchor();
1269
+ this.toolsExpanded = !this.toolsExpanded; this.toolExpansion.clear();
1270
+ }
1271
+ else if (action === "changes" && !this.pendingDetail) {
1272
+ const request = ++this.detailRequest, runId = this.task?.run.runId;
1273
+ this.pendingDetail = true;
1274
+ void this.controller.changes(this.key).then((item) => { if (!this.closed && request === this.detailRequest && this.task?.run.runId === runId && item) { void this.inspect(item); this.tui.requestRender(); } })
1275
+ .catch((error) => { if (!this.closed && request === this.detailRequest && this.task?.run.runId === runId) this.visit.notice = `Changes unavailable: ${String(error)}`; }).finally(() => { this.pendingDetail = false; });
1276
+ } else if (action === "latest") {
1277
+ this.initialPosition = false;
1278
+ this.detailRequest++; this.loadingDetail = false; this.detail = undefined; this.components.clear();
1279
+ void this.loadPage();
1280
+ this.restoreAnchor = undefined; this.conversationAnchor = undefined; this.scroll.scrollToEnd();
1281
+ this.selectedId = this.editorFocus ? undefined : this.items().at(-1)?.id;
1282
+ }
1283
+ else if (action === "pin") this.controller.pin(this.controller.pinned === this.key ? undefined : this.key);
1284
+ else if (action === "unquote") { if (this.loadingDetail && !this.detail) { this.detailRequest++; this.loadingDetail = false; } this.visit.quote = undefined; this.controller.changed(); }
1285
+ else if (action === "stop") void this.controller.stop(this.key);
1286
+ else if (action === "continue" && !this.loadingDetail) void this.controller.send(this.key, this.editor.getExpandedText(), true);
1287
+ else if (action === "picker" || action === "peers") this.finish(action);
1288
+ this.focused = this.hasFocus;
1289
+ this.tui.requestRender();
1290
+ }
1291
+
1292
+ handleInput(data: string): void {
1293
+ if (this.closed) return;
1294
+ if (matchesKey(data, this.controller.shortcut)) { this.finish(); return; }
1295
+ if (this.menu) { this.menu.handleInput(data); this.tui.requestRender(); return; }
1296
+ if (matchesKey(data, "escape")) this.act("back");
1297
+ else if (this.keys?.matches(data, "app.tools.expand") ?? matchesKey(data, "ctrl+o")) this.act("expand");
1298
+ else if (matchesKey(data, "f2")) this.act("actions");
1299
+ else if (matchesKey(data, "f5")) this.act("retry");
1300
+ else if (matchesKey(data, "alt+r")) this.act("reply");
1301
+ else if (matchesKey(data, "alt+d") && (!this.editorFocus || this.detail)) this.act("details");
1302
+ else if (matchesKey(data, "alt+g")) this.act("changes");
1303
+ else if (matchesKey(data, "alt+l")) this.act("latest");
1304
+ else if (matchesKey(data, "alt+p")) this.act("pin");
1305
+ else if (matchesKey(data, "alt+q")) this.act("unquote");
1306
+ else if (matchesKey(data, "alt+s")) this.act("stop");
1307
+ else if (matchesKey(data, "alt+c")) this.act("continue");
1308
+ else if (matchesKey(data, "tab") && !this.detail) this.act("focus");
1309
+ else if (matchesKey(data, "pageUp") || matchesKey(data, "pageDown")) { this.scroll.scrollBy(matchesKey(data, "pageUp") ? -this.height : this.height); this.restoreAnchor = this.anchor(); }
1310
+ else if (!this.editorFocus || this.detail) {
1311
+ if (matchesKey(data, "up")) this.select(-1);
1312
+ else if (matchesKey(data, "down")) this.select(1);
1313
+ else if (matchesKey(data, "home")) { this.scroll.scrollToStart(); this.restoreAnchor = this.anchor(); }
1314
+ else if (matchesKey(data, "end")) this.act("latest");
1315
+ else if (matchesKey(data, "enter")) this.act("details");
1316
+ } else { this.beforeInput = this.editor.getExpandedText(); this.editor.handleInput(data); }
1317
+ this.focused = this.hasFocus;
1318
+ this.tui.requestRender();
1319
+ }
1320
+
1321
+ private finish(key?: string): void { this.controller.changed(); this.done(key); }
1322
+ dispose(): void { this.closed = true; this.pageRequest++; this.detailRequest++; this.editor.onChange = undefined; this.editor.onSubmit = undefined; this.components.clear(); }
1323
+ }