agent-tempo 1.0.1

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 (484) hide show
  1. package/CLAUDE.md +213 -0
  2. package/LICENSE +21 -0
  3. package/README.md +289 -0
  4. package/assets/icon-32.png +0 -0
  5. package/assets/icon-512.png +0 -0
  6. package/assets/icon-64.png +0 -0
  7. package/assets/icon-dark-32.png +0 -0
  8. package/assets/icon-dark-64.png +0 -0
  9. package/assets/icon-dark.svg +9 -0
  10. package/assets/icon.svg +9 -0
  11. package/assets/logo-dark.svg +11 -0
  12. package/assets/logo-light.svg +11 -0
  13. package/dashboard/README.md +91 -0
  14. package/dashboard/dist/assets/index-CB78ToNE.css +2 -0
  15. package/dashboard/dist/assets/index-_5jV0Znu.js +62 -0
  16. package/dashboard/dist/assets/index-_5jV0Znu.js.map +1 -0
  17. package/dashboard/dist/index.html +21 -0
  18. package/dashboard/package.json +47 -0
  19. package/dist/activities/hard-terminate.d.ts +32 -0
  20. package/dist/activities/hard-terminate.js +460 -0
  21. package/dist/activities/maestro.d.ts +72 -0
  22. package/dist/activities/maestro.js +254 -0
  23. package/dist/activities/outbox.d.ts +188 -0
  24. package/dist/activities/outbox.js +849 -0
  25. package/dist/activities/resolve.d.ts +64 -0
  26. package/dist/activities/resolve.js +129 -0
  27. package/dist/activities/schedule-fire.d.ts +36 -0
  28. package/dist/activities/schedule-fire.js +147 -0
  29. package/dist/adapters/base.d.ts +426 -0
  30. package/dist/adapters/base.js +1270 -0
  31. package/dist/adapters/claude-api/adapter.d.ts +168 -0
  32. package/dist/adapters/claude-api/adapter.js +797 -0
  33. package/dist/adapters/claude-api/api-error.d.ts +96 -0
  34. package/dist/adapters/claude-api/api-error.js +191 -0
  35. package/dist/adapters/claude-api/index.d.ts +16 -0
  36. package/dist/adapters/claude-api/index.js +21 -0
  37. package/dist/adapters/claude-api/mcp-bridge.d.ts +50 -0
  38. package/dist/adapters/claude-api/mcp-bridge.js +157 -0
  39. package/dist/adapters/claude-code/adapter.d.ts +133 -0
  40. package/dist/adapters/claude-code/adapter.js +274 -0
  41. package/dist/adapters/claude-code/index.d.ts +15 -0
  42. package/dist/adapters/claude-code/index.js +20 -0
  43. package/dist/adapters/claude-code-headless/adapter.d.ts +131 -0
  44. package/dist/adapters/claude-code-headless/adapter.js +710 -0
  45. package/dist/adapters/claude-code-headless/error-mapper.d.ts +107 -0
  46. package/dist/adapters/claude-code-headless/error-mapper.js +281 -0
  47. package/dist/adapters/claude-code-headless/index.d.ts +17 -0
  48. package/dist/adapters/claude-code-headless/index.js +26 -0
  49. package/dist/adapters/claude-code-headless/pre-flight.d.ts +51 -0
  50. package/dist/adapters/claude-code-headless/pre-flight.js +207 -0
  51. package/dist/adapters/claude-code-headless/prompt.d.ts +93 -0
  52. package/dist/adapters/claude-code-headless/prompt.js +79 -0
  53. package/dist/adapters/claude-code-headless/stream-json.d.ts +242 -0
  54. package/dist/adapters/claude-code-headless/stream-json.js +208 -0
  55. package/dist/adapters/claude-code-headless/types.d.ts +28 -0
  56. package/dist/adapters/claude-code-headless/types.js +36 -0
  57. package/dist/adapters/copilot/adapter.d.ts +100 -0
  58. package/dist/adapters/copilot/adapter.js +730 -0
  59. package/dist/adapters/copilot/index.d.ts +15 -0
  60. package/dist/adapters/copilot/index.js +20 -0
  61. package/dist/adapters/index.d.ts +42 -0
  62. package/dist/adapters/index.js +115 -0
  63. package/dist/adapters/opencode/adapter.d.ts +82 -0
  64. package/dist/adapters/opencode/adapter.js +710 -0
  65. package/dist/adapters/opencode/config.d.ts +90 -0
  66. package/dist/adapters/opencode/config.js +137 -0
  67. package/dist/adapters/opencode/helpers.d.ts +40 -0
  68. package/dist/adapters/opencode/helpers.js +144 -0
  69. package/dist/adapters/opencode/index.d.ts +12 -0
  70. package/dist/adapters/opencode/index.js +17 -0
  71. package/dist/adapters/opencode/server-bridge.d.ts +124 -0
  72. package/dist/adapters/opencode/server-bridge.js +216 -0
  73. package/dist/adapters/sdk/base.d.ts +95 -0
  74. package/dist/adapters/sdk/base.js +134 -0
  75. package/dist/adapters/sdk/system-prompt.d.ts +64 -0
  76. package/dist/adapters/sdk/system-prompt.js +78 -0
  77. package/dist/adapters/terminal-error.d.ts +27 -0
  78. package/dist/adapters/terminal-error.js +39 -0
  79. package/dist/channel.d.ts +3 -0
  80. package/dist/channel.js +48 -0
  81. package/dist/cli/commands.d.ts +245 -0
  82. package/dist/cli/commands.js +2438 -0
  83. package/dist/cli/config-command.d.ts +8 -0
  84. package/dist/cli/config-command.js +254 -0
  85. package/dist/cli/daemon-command.d.ts +57 -0
  86. package/dist/cli/daemon-command.js +493 -0
  87. package/dist/cli/daemon.d.ts +217 -0
  88. package/dist/cli/daemon.js +632 -0
  89. package/dist/cli/dashboard-command.d.ts +20 -0
  90. package/dist/cli/dashboard-command.js +241 -0
  91. package/dist/cli/dev-banner.d.ts +107 -0
  92. package/dist/cli/dev-banner.js +190 -0
  93. package/dist/cli/dev-mode-bootstrap.d.ts +29 -0
  94. package/dist/cli/dev-mode-bootstrap.js +36 -0
  95. package/dist/cli/dev-verbs.d.ts +43 -0
  96. package/dist/cli/dev-verbs.js +254 -0
  97. package/dist/cli/help-text.d.ts +1 -0
  98. package/dist/cli/help-text.js +158 -0
  99. package/dist/cli/legacy-migration.d.ts +35 -0
  100. package/dist/cli/legacy-migration.js +335 -0
  101. package/dist/cli/mcp.d.ts +8 -0
  102. package/dist/cli/mcp.js +63 -0
  103. package/dist/cli/output.d.ts +12 -0
  104. package/dist/cli/output.js +37 -0
  105. package/dist/cli/preflight.d.ts +9 -0
  106. package/dist/cli/preflight.js +96 -0
  107. package/dist/cli/removed-verbs.d.ts +9 -0
  108. package/dist/cli/removed-verbs.js +78 -0
  109. package/dist/cli/sa-preflight.d.ts +99 -0
  110. package/dist/cli/sa-preflight.js +183 -0
  111. package/dist/cli/scenarios-command.d.ts +6 -0
  112. package/dist/cli/scenarios-command.js +167 -0
  113. package/dist/cli/startup.d.ts +112 -0
  114. package/dist/cli/startup.js +641 -0
  115. package/dist/cli/upgrade-command.d.ts +5 -0
  116. package/dist/cli/upgrade-command.js +240 -0
  117. package/dist/cli.d.ts +2 -0
  118. package/dist/cli.js +680 -0
  119. package/dist/client/core.d.ts +33 -0
  120. package/dist/client/core.js +1260 -0
  121. package/dist/client/ensure-conductor-spawned.d.ts +35 -0
  122. package/dist/client/ensure-conductor-spawned.js +48 -0
  123. package/dist/client/index.d.ts +32 -0
  124. package/dist/client/index.js +22 -0
  125. package/dist/client/interface.d.ts +461 -0
  126. package/dist/client/interface.js +2 -0
  127. package/dist/client/subscribe.d.ts +108 -0
  128. package/dist/client/subscribe.js +598 -0
  129. package/dist/client/with-spawn.d.ts +27 -0
  130. package/dist/client/with-spawn.js +87 -0
  131. package/dist/config.d.ts +323 -0
  132. package/dist/config.js +593 -0
  133. package/dist/connection.d.ts +7 -0
  134. package/dist/connection.js +46 -0
  135. package/dist/constants.d.ts +50 -0
  136. package/dist/constants.js +74 -0
  137. package/dist/copilot-bridge.d.ts +22 -0
  138. package/dist/copilot-bridge.js +565 -0
  139. package/dist/daemon-adapter-versions.d.ts +52 -0
  140. package/dist/daemon-adapter-versions.js +170 -0
  141. package/dist/daemon.d.ts +275 -0
  142. package/dist/daemon.js +989 -0
  143. package/dist/ensemble/agent-types.d.ts +23 -0
  144. package/dist/ensemble/agent-types.js +132 -0
  145. package/dist/ensemble/loader.d.ts +14 -0
  146. package/dist/ensemble/loader.js +140 -0
  147. package/dist/ensemble/saver.d.ts +49 -0
  148. package/dist/ensemble/saver.js +201 -0
  149. package/dist/ensemble/schema.d.ts +71 -0
  150. package/dist/ensemble/schema.js +3 -0
  151. package/dist/git-info.d.ts +4 -0
  152. package/dist/git-info.js +29 -0
  153. package/dist/http/aggregate.d.ts +319 -0
  154. package/dist/http/aggregate.js +684 -0
  155. package/dist/http/auth.d.ts +67 -0
  156. package/dist/http/auth.js +177 -0
  157. package/dist/http/body.d.ts +71 -0
  158. package/dist/http/body.js +121 -0
  159. package/dist/http/catalog.d.ts +67 -0
  160. package/dist/http/catalog.js +209 -0
  161. package/dist/http/cors.d.ts +42 -0
  162. package/dist/http/cors.js +111 -0
  163. package/dist/http/dashboard-pair.d.ts +94 -0
  164. package/dist/http/dashboard-pair.js +148 -0
  165. package/dist/http/dashboard.d.ts +20 -0
  166. package/dist/http/dashboard.js +160 -0
  167. package/dist/http/event-bus.d.ts +217 -0
  168. package/dist/http/event-bus.js +365 -0
  169. package/dist/http/event-id.d.ts +77 -0
  170. package/dist/http/event-id.js +117 -0
  171. package/dist/http/event-types.d.ts +348 -0
  172. package/dist/http/event-types.js +36 -0
  173. package/dist/http/fixtures/chat-stress.d.ts +8 -0
  174. package/dist/http/fixtures/chat-stress.js +63 -0
  175. package/dist/http/fixtures/conductor-leaving.d.ts +8 -0
  176. package/dist/http/fixtures/conductor-leaving.js +80 -0
  177. package/dist/http/fixtures/constants.d.ts +10 -0
  178. package/dist/http/fixtures/constants.js +13 -0
  179. package/dist/http/fixtures/eight-player-broadcast.d.ts +10 -0
  180. package/dist/http/fixtures/eight-player-broadcast.js +81 -0
  181. package/dist/http/fixtures/empty-ensemble.d.ts +6 -0
  182. package/dist/http/fixtures/empty-ensemble.js +26 -0
  183. package/dist/http/fixtures/index.d.ts +55 -0
  184. package/dist/http/fixtures/index.js +110 -0
  185. package/dist/http/fixtures/single-conductor.d.ts +7 -0
  186. package/dist/http/fixtures/single-conductor.js +46 -0
  187. package/dist/http/fixtures/sse-reconnect.d.ts +8 -0
  188. package/dist/http/fixtures/sse-reconnect.js +77 -0
  189. package/dist/http/index.d.ts +21 -0
  190. package/dist/http/index.js +61 -0
  191. package/dist/http/port-file.d.ts +22 -0
  192. package/dist/http/port-file.js +132 -0
  193. package/dist/http/responses.d.ts +27 -0
  194. package/dist/http/responses.js +40 -0
  195. package/dist/http/ring-buffer.d.ts +41 -0
  196. package/dist/http/ring-buffer.js +80 -0
  197. package/dist/http/server.d.ts +122 -0
  198. package/dist/http/server.js +459 -0
  199. package/dist/http/snapshot.d.ts +85 -0
  200. package/dist/http/snapshot.js +180 -0
  201. package/dist/http/sse-handler.d.ts +87 -0
  202. package/dist/http/sse-handler.js +294 -0
  203. package/dist/http/writes.d.ts +55 -0
  204. package/dist/http/writes.js +240 -0
  205. package/dist/palette/index.d.ts +138 -0
  206. package/dist/palette/index.js +221 -0
  207. package/dist/reconcile/orphans.d.ts +255 -0
  208. package/dist/reconcile/orphans.js +340 -0
  209. package/dist/scripts/258-spotcheck.js +303 -0
  210. package/dist/scripts/check-components-css-sync.js +199 -0
  211. package/dist/scripts/run-shard.js +121 -0
  212. package/dist/scripts/verify-daemon-isolation-guard.js +128 -0
  213. package/dist/server-tools.d.ts +87 -0
  214. package/dist/server-tools.js +146 -0
  215. package/dist/server.d.ts +2 -0
  216. package/dist/server.js +366 -0
  217. package/dist/spawn.d.ts +296 -0
  218. package/dist/spawn.js +747 -0
  219. package/dist/tools/agent-types.d.ts +2 -0
  220. package/dist/tools/agent-types.js +21 -0
  221. package/dist/tools/attachment-info.d.ts +4 -0
  222. package/dist/tools/attachment-info.js +48 -0
  223. package/dist/tools/broadcast.d.ts +4 -0
  224. package/dist/tools/broadcast.js +76 -0
  225. package/dist/tools/cancel-stage.d.ts +3 -0
  226. package/dist/tools/cancel-stage.js +20 -0
  227. package/dist/tools/clear-state.d.ts +3 -0
  228. package/dist/tools/clear-state.js +37 -0
  229. package/dist/tools/coat-check-evict.d.ts +4 -0
  230. package/dist/tools/coat-check-evict.js +43 -0
  231. package/dist/tools/coat-check-get.d.ts +4 -0
  232. package/dist/tools/coat-check-get.js +56 -0
  233. package/dist/tools/coat-check-list.d.ts +4 -0
  234. package/dist/tools/coat-check-list.js +60 -0
  235. package/dist/tools/coat-check-put.d.ts +4 -0
  236. package/dist/tools/coat-check-put.js +53 -0
  237. package/dist/tools/cue.d.ts +44 -0
  238. package/dist/tools/cue.js +201 -0
  239. package/dist/tools/destroy.d.ts +4 -0
  240. package/dist/tools/destroy.js +188 -0
  241. package/dist/tools/detach.d.ts +4 -0
  242. package/dist/tools/detach.js +45 -0
  243. package/dist/tools/encore.d.ts +4 -0
  244. package/dist/tools/encore.js +31 -0
  245. package/dist/tools/ensemble.d.ts +32 -0
  246. package/dist/tools/ensemble.js +198 -0
  247. package/dist/tools/evaluate-gate.d.ts +3 -0
  248. package/dist/tools/evaluate-gate.js +32 -0
  249. package/dist/tools/fetch-state.d.ts +13 -0
  250. package/dist/tools/fetch-state.js +78 -0
  251. package/dist/tools/gates.d.ts +3 -0
  252. package/dist/tools/gates.js +41 -0
  253. package/dist/tools/helpers.d.ts +21 -0
  254. package/dist/tools/helpers.js +25 -0
  255. package/dist/tools/hosts.d.ts +4 -0
  256. package/dist/tools/hosts.js +40 -0
  257. package/dist/tools/listen.d.ts +3 -0
  258. package/dist/tools/listen.js +22 -0
  259. package/dist/tools/load-lineup.d.ts +5 -0
  260. package/dist/tools/load-lineup.js +381 -0
  261. package/dist/tools/migrate.d.ts +4 -0
  262. package/dist/tools/migrate.js +60 -0
  263. package/dist/tools/pause-ensemble.d.ts +4 -0
  264. package/dist/tools/pause-ensemble.js +58 -0
  265. package/dist/tools/pause.d.ts +4 -0
  266. package/dist/tools/pause.js +36 -0
  267. package/dist/tools/play.d.ts +4 -0
  268. package/dist/tools/play.js +57 -0
  269. package/dist/tools/quality-gate.d.ts +3 -0
  270. package/dist/tools/quality-gate.js +26 -0
  271. package/dist/tools/recall.d.ts +3 -0
  272. package/dist/tools/recall.js +32 -0
  273. package/dist/tools/recruit.d.ts +38 -0
  274. package/dist/tools/recruit.js +447 -0
  275. package/dist/tools/release.d.ts +4 -0
  276. package/dist/tools/release.js +98 -0
  277. package/dist/tools/report.d.ts +3 -0
  278. package/dist/tools/report.js +29 -0
  279. package/dist/tools/resolve.d.ts +1 -0
  280. package/dist/tools/resolve.js +7 -0
  281. package/dist/tools/restart.d.ts +35 -0
  282. package/dist/tools/restart.js +131 -0
  283. package/dist/tools/restore.d.ts +4 -0
  284. package/dist/tools/restore.js +107 -0
  285. package/dist/tools/resume-ensemble.d.ts +4 -0
  286. package/dist/tools/resume-ensemble.js +79 -0
  287. package/dist/tools/save-lineup.d.ts +4 -0
  288. package/dist/tools/save-lineup.js +36 -0
  289. package/dist/tools/save-state.d.ts +3 -0
  290. package/dist/tools/save-state.js +57 -0
  291. package/dist/tools/schedule.d.ts +4 -0
  292. package/dist/tools/schedule.js +152 -0
  293. package/dist/tools/schedules.d.ts +4 -0
  294. package/dist/tools/schedules.js +54 -0
  295. package/dist/tools/set-ensemble-description.d.ts +4 -0
  296. package/dist/tools/set-ensemble-description.js +37 -0
  297. package/dist/tools/set-name.d.ts +4 -0
  298. package/dist/tools/set-name.js +45 -0
  299. package/dist/tools/set-part.d.ts +3 -0
  300. package/dist/tools/set-part.js +20 -0
  301. package/dist/tools/shutdown.d.ts +4 -0
  302. package/dist/tools/shutdown.js +54 -0
  303. package/dist/tools/stage.d.ts +3 -0
  304. package/dist/tools/stage.js +28 -0
  305. package/dist/tools/stages.d.ts +3 -0
  306. package/dist/tools/stages.js +35 -0
  307. package/dist/tools/stop.d.ts +4 -0
  308. package/dist/tools/stop.js +29 -0
  309. package/dist/tools/unschedule.d.ts +4 -0
  310. package/dist/tools/unschedule.js +35 -0
  311. package/dist/tools/who-am-i.d.ts +3 -0
  312. package/dist/tools/who-am-i.js +34 -0
  313. package/dist/tools/worktree.d.ts +4 -0
  314. package/dist/tools/worktree.js +181 -0
  315. package/dist/tui/App.d.ts +85 -0
  316. package/dist/tui/App.js +1791 -0
  317. package/dist/tui/bootstrap-types.d.ts +46 -0
  318. package/dist/tui/bootstrap-types.js +7 -0
  319. package/dist/tui/client.d.ts +6 -0
  320. package/dist/tui/client.js +9 -0
  321. package/dist/tui/commands.d.ts +71 -0
  322. package/dist/tui/commands.js +1375 -0
  323. package/dist/tui/components/ActivityLog.d.ts +16 -0
  324. package/dist/tui/components/ActivityLog.js +36 -0
  325. package/dist/tui/components/ChatView.d.ts +35 -0
  326. package/dist/tui/components/ChatView.js +54 -0
  327. package/dist/tui/components/CommandOverlay.d.ts +15 -0
  328. package/dist/tui/components/CommandOverlay.js +34 -0
  329. package/dist/tui/components/CommandPalette.d.ts +21 -0
  330. package/dist/tui/components/CommandPalette.js +67 -0
  331. package/dist/tui/components/ConductorChat.d.ts +16 -0
  332. package/dist/tui/components/ConductorChat.js +32 -0
  333. package/dist/tui/components/ConversationStream.d.ts +114 -0
  334. package/dist/tui/components/ConversationStream.js +307 -0
  335. package/dist/tui/components/CreateEnsembleWizard.d.ts +19 -0
  336. package/dist/tui/components/CreateEnsembleWizard.js +223 -0
  337. package/dist/tui/components/DestroyConfirmModal.d.ts +17 -0
  338. package/dist/tui/components/DestroyConfirmModal.js +62 -0
  339. package/dist/tui/components/EnsembleListView.d.ts +14 -0
  340. package/dist/tui/components/EnsembleListView.js +32 -0
  341. package/dist/tui/components/EnsemblePanel.d.ts +12 -0
  342. package/dist/tui/components/EnsemblePanel.js +40 -0
  343. package/dist/tui/components/ErrorView.d.ts +31 -0
  344. package/dist/tui/components/ErrorView.js +129 -0
  345. package/dist/tui/components/HomeView.d.ts +54 -0
  346. package/dist/tui/components/HomeView.js +306 -0
  347. package/dist/tui/components/InputBar.d.ts +13 -0
  348. package/dist/tui/components/InputBar.js +58 -0
  349. package/dist/tui/components/LoadLineupModal.d.ts +18 -0
  350. package/dist/tui/components/LoadLineupModal.js +79 -0
  351. package/dist/tui/components/MainView.d.ts +21 -0
  352. package/dist/tui/components/MainView.js +107 -0
  353. package/dist/tui/components/NewEnsembleModal.d.ts +9 -0
  354. package/dist/tui/components/NewEnsembleModal.js +73 -0
  355. package/dist/tui/components/Picker.d.ts +23 -0
  356. package/dist/tui/components/Picker.js +70 -0
  357. package/dist/tui/components/PlayerDetailView.d.ts +26 -0
  358. package/dist/tui/components/PlayerDetailView.js +118 -0
  359. package/dist/tui/components/PromptArea.d.ts +50 -0
  360. package/dist/tui/components/PromptArea.js +303 -0
  361. package/dist/tui/components/RecruitWizard.d.ts +17 -0
  362. package/dist/tui/components/RecruitWizard.js +221 -0
  363. package/dist/tui/components/RestoreConfirmModal.d.ts +18 -0
  364. package/dist/tui/components/RestoreConfirmModal.js +71 -0
  365. package/dist/tui/components/ScheduleOverlay.d.ts +13 -0
  366. package/dist/tui/components/ScheduleOverlay.js +113 -0
  367. package/dist/tui/components/ScheduleWizard.d.ts +19 -0
  368. package/dist/tui/components/ScheduleWizard.js +259 -0
  369. package/dist/tui/components/Splash.d.ts +23 -0
  370. package/dist/tui/components/Splash.js +221 -0
  371. package/dist/tui/components/StatusBar.d.ts +48 -0
  372. package/dist/tui/components/StatusBar.js +128 -0
  373. package/dist/tui/components/StatusOverlay.d.ts +15 -0
  374. package/dist/tui/components/StatusOverlay.js +76 -0
  375. package/dist/tui/components/TitleBar.d.ts +10 -0
  376. package/dist/tui/components/TitleBar.js +21 -0
  377. package/dist/tui/components/TopBar.d.ts +12 -0
  378. package/dist/tui/components/TopBar.js +15 -0
  379. package/dist/tui/core-api.d.ts +26 -0
  380. package/dist/tui/core-api.js +67 -0
  381. package/dist/tui/hooks/useEnsembleDiscovery.d.ts +3 -0
  382. package/dist/tui/hooks/useEnsembleDiscovery.js +30 -0
  383. package/dist/tui/hooks/useMaestroPoller.d.ts +3 -0
  384. package/dist/tui/hooks/useMaestroPoller.js +36 -0
  385. package/dist/tui/hooks/useSendCommand.d.ts +7 -0
  386. package/dist/tui/hooks/useSendCommand.js +29 -0
  387. package/dist/tui/index.d.ts +15 -0
  388. package/dist/tui/index.js +156 -0
  389. package/dist/tui/ink-context.d.ts +18 -0
  390. package/dist/tui/ink-context.js +59 -0
  391. package/dist/tui/ink-loader.d.ts +26 -0
  392. package/dist/tui/ink-loader.js +42 -0
  393. package/dist/tui/removed-commands.d.ts +9 -0
  394. package/dist/tui/removed-commands.js +22 -0
  395. package/dist/tui/sse-handler.d.ts +52 -0
  396. package/dist/tui/sse-handler.js +157 -0
  397. package/dist/tui/store.d.ts +598 -0
  398. package/dist/tui/store.js +753 -0
  399. package/dist/tui/utils/format.d.ts +56 -0
  400. package/dist/tui/utils/format.js +155 -0
  401. package/dist/tui/utils/fullscreen.d.ts +23 -0
  402. package/dist/tui/utils/fullscreen.js +71 -0
  403. package/dist/tui/utils/history.d.ts +10 -0
  404. package/dist/tui/utils/history.js +85 -0
  405. package/dist/tui/utils/platform.d.ts +45 -0
  406. package/dist/tui/utils/platform.js +258 -0
  407. package/dist/tui/utils/theme.d.ts +21 -0
  408. package/dist/tui/utils/theme.js +24 -0
  409. package/dist/types.d.ts +1020 -0
  410. package/dist/types.js +39 -0
  411. package/dist/utils/attachment-format.d.ts +22 -0
  412. package/dist/utils/attachment-format.js +32 -0
  413. package/dist/utils/default-part.d.ts +43 -0
  414. package/dist/utils/default-part.js +104 -0
  415. package/dist/utils/duration.d.ts +30 -0
  416. package/dist/utils/duration.js +69 -0
  417. package/dist/utils/ensemble-ops.d.ts +61 -0
  418. package/dist/utils/ensemble-ops.js +77 -0
  419. package/dist/utils/format-hosts.d.ts +21 -0
  420. package/dist/utils/format-hosts.js +73 -0
  421. package/dist/utils/hosts.d.ts +113 -0
  422. package/dist/utils/hosts.js +265 -0
  423. package/dist/utils/parent-death-watchdog.d.ts +1 -0
  424. package/dist/utils/parent-death-watchdog.js +47 -0
  425. package/dist/utils/query-timeout.d.ts +103 -0
  426. package/dist/utils/query-timeout.js +113 -0
  427. package/dist/utils/recall-format.d.ts +78 -0
  428. package/dist/utils/recall-format.js +105 -0
  429. package/dist/utils/restore-format.d.ts +49 -0
  430. package/dist/utils/restore-format.js +91 -0
  431. package/dist/utils/safe-path.d.ts +10 -0
  432. package/dist/utils/safe-path.js +43 -0
  433. package/dist/utils/sdk-probe.d.ts +9 -0
  434. package/dist/utils/sdk-probe.js +45 -0
  435. package/dist/utils/search-attributes.d.ts +76 -0
  436. package/dist/utils/search-attributes.js +86 -0
  437. package/dist/utils/validation.d.ts +113 -0
  438. package/dist/utils/validation.js +163 -0
  439. package/dist/utils/visibility-deadline.d.ts +186 -0
  440. package/dist/utils/visibility-deadline.js +158 -0
  441. package/dist/utils/worktree.d.ts +103 -0
  442. package/dist/utils/worktree.js +327 -0
  443. package/dist/worker.d.ts +14 -0
  444. package/dist/worker.js +146 -0
  445. package/dist/workflows/attachment-math.d.ts +56 -0
  446. package/dist/workflows/attachment-math.js +47 -0
  447. package/dist/workflows/index.d.ts +3 -0
  448. package/dist/workflows/index.js +11 -0
  449. package/dist/workflows/maestro-signals.d.ts +217 -0
  450. package/dist/workflows/maestro-signals.js +155 -0
  451. package/dist/workflows/maestro.d.ts +3 -0
  452. package/dist/workflows/maestro.js +812 -0
  453. package/dist/workflows/scheduler-signals.d.ts +10 -0
  454. package/dist/workflows/scheduler-signals.js +14 -0
  455. package/dist/workflows/scheduler.d.ts +17 -0
  456. package/dist/workflows/scheduler.js +143 -0
  457. package/dist/workflows/session.d.ts +2 -0
  458. package/dist/workflows/session.js +1638 -0
  459. package/dist/workflows/signals.d.ts +297 -0
  460. package/dist/workflows/signals.js +239 -0
  461. package/examples/agents/tempo-composer.md +56 -0
  462. package/examples/agents/tempo-conductor.md +117 -0
  463. package/examples/agents/tempo-critic.md +73 -0
  464. package/examples/agents/tempo-improv.md +74 -0
  465. package/examples/agents/tempo-liner.md +75 -0
  466. package/examples/agents/tempo-roadie.md +61 -0
  467. package/examples/agents/tempo-soloist.md +71 -0
  468. package/examples/agents/tempo-tuner.md +94 -0
  469. package/examples/ensembles/tempo-big-band.yaml +146 -0
  470. package/examples/ensembles/tempo-dev-team.yaml +58 -0
  471. package/examples/ensembles/tempo-headless-jam.yaml +77 -0
  472. package/examples/ensembles/tempo-jam-session.yaml +41 -0
  473. package/examples/ensembles/tempo-mock-jam.yaml +79 -0
  474. package/examples/ensembles/tempo-review-squad.yaml +32 -0
  475. package/package.json +172 -0
  476. package/packaging/launchd/com.agent.tempo.plist +46 -0
  477. package/packaging/systemd/agent-tempo.service +32 -0
  478. package/packaging/windows/install-task.ps1 +71 -0
  479. package/scenarios/conductor-recruit-mock.yaml +33 -0
  480. package/scenarios/echo-roundtrip.yaml +15 -0
  481. package/scenarios/multi-player-handoff.yaml +38 -0
  482. package/scenarios/recruit-cascade.yaml +38 -0
  483. package/scenarios/two-player-conversation.yaml +33 -0
  484. package/workflow-bundle.js +14146 -0
@@ -0,0 +1,1791 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.App = App;
37
+ exports.stripLeadingIcon = stripLeadingIcon;
38
+ exports.isHomeView = isHomeView;
39
+ exports.pinnedConfirmationLines = pinnedConfirmationLines;
40
+ exports.countPinnedConfirmationLines = countPinnedConfirmationLines;
41
+ exports.pinnedTipLine = pinnedTipLine;
42
+ exports.countPinnedTipLines = countPinnedTipLines;
43
+ /**
44
+ * Root TUI application component — chat-focused shell with slash commands.
45
+ *
46
+ * Layout (top to bottom):
47
+ * - TitleBar (pinned)
48
+ * - Divider
49
+ * - Static scroll-up history
50
+ * - Live content area (splash, main, chat, error)
51
+ * - Divider
52
+ * - PromptArea (pinned)
53
+ */
54
+ const react_1 = __importStar(require("react"));
55
+ const os_1 = require("os");
56
+ const ink_context_1 = require("./ink-context");
57
+ const store_1 = require("./store");
58
+ /**
59
+ * Track terminal rows so the root Box height stays < stdout.rows.
60
+ * Prevents Ink's fullscreen bypass (clearTerminal + full rewrite).
61
+ */
62
+ function useTerminalRows() {
63
+ const [rows, setRows] = (0, react_1.useState)(process.stdout.rows || 24);
64
+ (0, react_1.useEffect)(() => {
65
+ const onResize = () => setRows(process.stdout.rows || 24);
66
+ process.stdout.on('resize', onResize);
67
+ return () => { process.stdout.off('resize', onResize); };
68
+ }, []);
69
+ return rows;
70
+ }
71
+ const Splash_1 = require("./components/Splash");
72
+ const ChatView_1 = require("./components/ChatView");
73
+ const ErrorView_1 = require("./components/ErrorView");
74
+ const RecruitWizard_1 = require("./components/RecruitWizard");
75
+ const PromptArea_1 = require("./components/PromptArea");
76
+ const StatusBar_1 = require("./components/StatusBar");
77
+ const ScheduleWizard_1 = require("./components/ScheduleWizard");
78
+ const CreateEnsembleWizard_1 = require("./components/CreateEnsembleWizard");
79
+ const HomeView_1 = require("./components/HomeView");
80
+ const NewEnsembleModal_1 = require("./components/NewEnsembleModal");
81
+ const LoadLineupModal_1 = require("./components/LoadLineupModal");
82
+ const RestoreConfirmModal_1 = require("./components/RestoreConfirmModal");
83
+ const DestroyConfirmModal_1 = require("./components/DestroyConfirmModal");
84
+ const CommandPalette_1 = require("./components/CommandPalette");
85
+ const StatusOverlay_1 = require("./components/StatusOverlay");
86
+ const ConversationStream_1 = require("./components/ConversationStream");
87
+ const PlayerDetailView_1 = require("./components/PlayerDetailView");
88
+ const Picker_1 = require("./components/Picker");
89
+ const commands_1 = require("./commands");
90
+ const removed_commands_1 = require("./removed-commands");
91
+ const theme_1 = require("./utils/theme");
92
+ const format_1 = require("./utils/format");
93
+ const platform_1 = require("./utils/platform");
94
+ const format_2 = require("./utils/format");
95
+ const history_1 = require("./utils/history");
96
+ const sse_handler_1 = require("./sse-handler");
97
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
98
+ const packageVersion = require('../../package.json').version;
99
+ let staticIdCounter = 0;
100
+ function nextStaticId() {
101
+ return `static-${++staticIdCounter}`;
102
+ }
103
+ /** Color for static item text. */
104
+ function staticItemColor(item) {
105
+ switch (item.type) {
106
+ case 'error': return theme_1.THEME.error;
107
+ case 'message': return theme_1.THEME.accent;
108
+ case 'splash-done': return theme_1.THEME.success;
109
+ case 'info': return theme_1.THEME.textMuted;
110
+ case 'command-output': return theme_1.THEME.text;
111
+ default: return theme_1.THEME.text;
112
+ }
113
+ }
114
+ function App({ api, ensemble, defaultAgent }) {
115
+ const { Box, Text, useApp, useInput } = (0, ink_context_1.useInk)();
116
+ const [state, dispatch] = (0, react_1.useReducer)(store_1.tuiReducer, (0, store_1.initialState)(ensemble));
117
+ const { exit } = useApp();
118
+ const termRows = useTerminalRows();
119
+ // ── Persistent command history ──
120
+ const [cmdHistory] = react_1.default.useState(() => (0, history_1.loadHistory)());
121
+ // ── Prompt ref (uncontrolled — input lives in PromptArea, not parent state) ──
122
+ const promptRef = react_1.default.useRef(null);
123
+ // Input value ref for palette filtering (no dispatch per keystroke)
124
+ const inputValueRef = react_1.default.useRef('');
125
+ // ── Refs for values read by useInput/useCallback (avoids stale closures + excess re-renders) ──
126
+ const lastSeenMsgRef = react_1.default.useRef(state.lastSeenMessageId);
127
+ const lastSeenMaestroRef = react_1.default.useRef(undefined);
128
+ const stateRef = react_1.default.useRef(state);
129
+ stateRef.current = state; // Always current on every render
130
+ // Track which messages have been committed to Static (overflow from live area)
131
+ const overflowCommittedRef = react_1.default.useRef(new Set());
132
+ // Overflow data computed during render, committed to Static via useEffect
133
+ const overflowRef = react_1.default.useRef(null);
134
+ // Callback for picker selection — set before showing picker, called on Enter
135
+ const pickerCallbackRef = react_1.default.useRef(null);
136
+ // Picker items ref — synced from pickerItems memo so useInput reads sorted items
137
+ const pickerItemsRef = react_1.default.useRef([]);
138
+ // Reset stale refs when switching ensembles + add separator
139
+ const prevEnsembleRef = react_1.default.useRef(state.activeEnsemble);
140
+ (0, react_1.useEffect)(() => {
141
+ lastSeenMsgRef.current = undefined;
142
+ lastSeenMaestroRef.current = undefined;
143
+ overflowCommittedRef.current.clear();
144
+ // Add separator when switching between ensembles (not on initial load)
145
+ if (state.activeEnsemble && prevEnsembleRef.current !== state.activeEnsemble && prevEnsembleRef.current !== undefined) {
146
+ dispatch({
147
+ type: 'COMMIT_STATIC',
148
+ item: {
149
+ id: nextStaticId(),
150
+ type: 'info',
151
+ content: `\u2500\u2500 Switched to ensemble: ${state.activeEnsemble} \u2500\u2500`,
152
+ timestamp: Date.now(),
153
+ },
154
+ });
155
+ }
156
+ prevEnsembleRef.current = state.activeEnsemble;
157
+ }, [state.activeEnsemble]);
158
+ // Commit overflow messages to Static scrollback after render (not during render)
159
+ (0, react_1.useEffect)(() => {
160
+ const data = overflowRef.current;
161
+ if (!data)
162
+ return;
163
+ const { formatted, startIdx } = data;
164
+ const overflow = formatted.slice(0, startIdx);
165
+ for (const msg of overflow) {
166
+ const key = `${msg.direction}:${msg.time}:${msg.body.slice(0, 60)}`;
167
+ if (!overflowCommittedRef.current.has(key)) {
168
+ overflowCommittedRef.current.add(key);
169
+ // Blank separator
170
+ dispatch({ type: 'COMMIT_STATIC', item: { id: nextStaticId(), type: 'info', content: '', timestamp: Date.now() } });
171
+ // Cap third-party messages to 4 lines; direct messages uncapped
172
+ const lines = msg.body.split('\n');
173
+ const lineCap = msg.thirdParty ? 4 : lines.length;
174
+ let body = lines.slice(0, lineCap).join('\n');
175
+ if (lines.length > lineCap) {
176
+ body += `\n\u2026 (${lines.length - lineCap} more lines)`;
177
+ }
178
+ // Commit entire body as a single message item — static renderer word-wraps correctly
179
+ dispatch({ type: 'COMMIT_STATIC', item: {
180
+ id: nextStaticId(), type: 'message', content: body, timestamp: Date.now(),
181
+ msgDirection: msg.direction, msgSender: msg.sender, msgTime: msg.time,
182
+ msgThirdParty: msg.thirdParty, msgRouteLabel: msg.routeLabel,
183
+ } });
184
+ }
185
+ }
186
+ });
187
+ const handleHistoryUpdate = (0, react_1.useCallback)((entries) => {
188
+ (0, history_1.saveHistory)(entries);
189
+ }, []);
190
+ // ── Global keybindings (uses stateRef to avoid recreating on every poll) ──
191
+ useInput((0, react_1.useCallback)((input, key) => {
192
+ const s = stateRef.current;
193
+ if (key.ctrl && input === 'c') {
194
+ exit();
195
+ return;
196
+ }
197
+ // Scrollback navigation (Page Up/Down, Home/End)
198
+ // Scroll keys removed — terminal native scrollback via <Static> handles this
199
+ // Status overlay — Escape dismisses, ↑↓ scrolls
200
+ if (s.statusOverlay) {
201
+ if (key.escape) {
202
+ dispatch({ type: 'HIDE_STATUS' });
203
+ return;
204
+ }
205
+ if (key.upArrow) {
206
+ dispatch({ type: 'STATUS_SCROLL_UP' });
207
+ return;
208
+ }
209
+ if (key.downArrow) {
210
+ dispatch({ type: 'STATUS_SCROLL_DOWN' });
211
+ return;
212
+ }
213
+ return;
214
+ }
215
+ // Interactive overlay — Escape dismisses, ↑↓ selects, action keys per type
216
+ if (s.overlay) {
217
+ if (key.escape) {
218
+ dispatch({ type: 'HIDE_OVERLAY' });
219
+ return;
220
+ }
221
+ if (key.upArrow) {
222
+ dispatch({ type: 'OVERLAY_SELECT', direction: 'up' });
223
+ return;
224
+ }
225
+ if (key.downArrow) {
226
+ dispatch({ type: 'OVERLAY_SELECT', direction: 'down' });
227
+ return;
228
+ }
229
+ // Schedule overlay action keys
230
+ if (s.overlay.type === 'schedules') {
231
+ if (input === 'n' || input === 'N') {
232
+ dispatch({ type: 'HIDE_OVERLAY' });
233
+ dispatch({ type: 'ENTER_SCHEDULE_WIZARD' });
234
+ return;
235
+ }
236
+ }
237
+ // Gates/stages — Enter shows detail for selected item
238
+ if ((s.overlay.type === 'gates' || s.overlay.type === 'stages') && key.return) {
239
+ const selected = s.overlay.items[s.overlay.selectedIndex];
240
+ if (selected) {
241
+ const detail = selected.sublabel
242
+ ? `\n ${selected.label}\n\n ${selected.sublabel.split(' ').join('\n ')}`
243
+ : `\n ${selected.label}\n\n No details available.`;
244
+ dispatch({ type: 'SHOW_COMMAND_OVERLAY', title: selected.label.slice(0, 40), content: detail });
245
+ }
246
+ return;
247
+ }
248
+ return; // Swallow all other input while overlay is active
249
+ }
250
+ // Player detail view — Escape goes back, ↑↓ scrolls messages
251
+ if (s.view === 'player') {
252
+ if (key.escape) {
253
+ dispatch({ type: 'NAVIGATE_ENSEMBLE', ensemble: s.activeEnsemble });
254
+ return;
255
+ }
256
+ if (key.upArrow) {
257
+ dispatch({ type: 'PLAYER_SCROLL_UP' });
258
+ return;
259
+ }
260
+ if (key.downArrow) {
261
+ dispatch({ type: 'PLAYER_SCROLL_DOWN' });
262
+ return;
263
+ }
264
+ return;
265
+ }
266
+ // Picker overlay navigation
267
+ if (s.pickerVisible) {
268
+ if (key.escape) {
269
+ dispatch({ type: 'HIDE_PICKER' });
270
+ return;
271
+ }
272
+ if (key.upArrow) {
273
+ dispatch({ type: 'PICKER_UP' });
274
+ return;
275
+ }
276
+ if (key.downArrow) {
277
+ dispatch({ type: 'PICKER_DOWN' });
278
+ return;
279
+ }
280
+ if (key.return) {
281
+ const cb = pickerCallbackRef.current;
282
+ if (s.pickerType === 'players') {
283
+ const item = pickerItemsRef.current[s.pickerIndex];
284
+ if (item) {
285
+ dispatch({ type: 'HIDE_PICKER' });
286
+ if (cb) {
287
+ cb(item.id);
288
+ pickerCallbackRef.current = null;
289
+ }
290
+ else if (s.pickerIntent === 'navigate') {
291
+ // Navigate to player detail view
292
+ dispatch({ type: 'NAVIGATE_PLAYER', playerId: item.id });
293
+ }
294
+ else {
295
+ // Default: navigate to player detail view
296
+ dispatch({ type: 'NAVIGATE_PLAYER', playerId: item.id });
297
+ }
298
+ }
299
+ }
300
+ else if (s.pickerType === 'ensembles') {
301
+ const ensItem = pickerItemsRef.current[s.pickerIndex];
302
+ if (ensItem) {
303
+ dispatch({ type: 'HIDE_PICKER' });
304
+ if (ensItem.id === '__create__') {
305
+ // Launch the create-ensemble wizard
306
+ dispatch({ type: 'ENTER_CREATE_ENSEMBLE' });
307
+ }
308
+ else if (cb) {
309
+ cb(ensItem.id);
310
+ pickerCallbackRef.current = null;
311
+ }
312
+ else {
313
+ dispatch({ type: 'NAVIGATE_ENSEMBLE', ensemble: ensItem.id });
314
+ dispatch({
315
+ type: 'COMMIT_STATIC',
316
+ item: { id: nextStaticId(), type: 'info', content: `Switched to ensemble: ${ensItem.id}`, timestamp: Date.now() },
317
+ });
318
+ }
319
+ }
320
+ }
321
+ return;
322
+ }
323
+ return;
324
+ }
325
+ // Destroy confirmation mode (PR-H: was `/stop`; now `/destroy` and routed
326
+ // through TempoClient.destroy() — the V2 outbox path — instead of the
327
+ // legacy raw-Temporal `terminatePlayer` shim.
328
+ if (s.confirmingStop) {
329
+ if (input === 'y' || input === 'Y') {
330
+ const target = s.confirmingStop;
331
+ const reason = s.confirmingStopReason;
332
+ dispatch({ type: 'CANCEL_STOP' });
333
+ (async () => {
334
+ try {
335
+ const ensembles = await api.discoverEnsembles();
336
+ for (const ens of ensembles) {
337
+ try {
338
+ await api.destroy(ens.name, target, reason);
339
+ // #306: command-result summary as a bottom-pinned notification
340
+ // so the user actually sees the confirmation when chat is busy.
341
+ (0, commands_1.commitNotification)(dispatch, 'info', `\u2716 Destroyed ${target}${reason ? ` (${reason})` : ''}.`);
342
+ return;
343
+ }
344
+ catch {
345
+ // Try next ensemble
346
+ }
347
+ }
348
+ (0, commands_1.commitNotification)(dispatch, 'error', `\u2717 Player "${target}" not found in any ensemble.`);
349
+ }
350
+ catch (err) {
351
+ (0, commands_1.commitNotification)(dispatch, 'error', `\u2717 Failed to destroy ${target}: ${err}`);
352
+ }
353
+ })();
354
+ }
355
+ else if (input === 'n' || input === 'N' || key.escape) {
356
+ dispatch({ type: 'CANCEL_STOP' });
357
+ dispatch({
358
+ type: 'COMMIT_STATIC',
359
+ item: { id: nextStaticId(), type: 'info', content: 'Destroy cancelled.', timestamp: Date.now() },
360
+ });
361
+ }
362
+ return;
363
+ }
364
+ // Disband confirmation mode
365
+ if (s.confirmingDisband) {
366
+ if (input === 'y' || input === 'Y') {
367
+ const ensemble = s.confirmingDisband;
368
+ dispatch({ type: 'CANCEL_DISBAND' });
369
+ (async () => {
370
+ try {
371
+ const { terminated } = await api.disbandEnsemble(ensemble);
372
+ dispatch({
373
+ type: 'COMMIT_STATIC',
374
+ item: { id: nextStaticId(), type: 'info', content: `\u2714 Disbanded ensemble "${ensemble}" — terminated ${terminated} workflow${terminated !== 1 ? 's' : ''}.`, timestamp: Date.now() },
375
+ });
376
+ // Navigate back to home view
377
+ dispatch({ type: 'NAVIGATE_HOME' });
378
+ }
379
+ catch (err) {
380
+ (0, commands_1.commitNotification)(dispatch, 'error', `\u2717 Failed to disband "${ensemble}": ${err}`);
381
+ }
382
+ })();
383
+ }
384
+ else if (input === 'n' || input === 'N' || key.escape) {
385
+ dispatch({ type: 'CANCEL_DISBAND' });
386
+ dispatch({
387
+ type: 'COMMIT_STATIC',
388
+ item: { id: nextStaticId(), type: 'info', content: 'Disband cancelled.', timestamp: Date.now() },
389
+ });
390
+ }
391
+ return;
392
+ }
393
+ // Lineup confirmation mode
394
+ if (s.confirmingLineup) {
395
+ if (input === 'y' || input === 'Y') {
396
+ const { path: lineupPath } = s.confirmingLineup;
397
+ const activeEns = s.activeEnsemble;
398
+ dispatch({ type: 'CANCEL_LINEUP' });
399
+ if (!activeEns) {
400
+ (0, commands_1.commitNotification)(dispatch, 'error', 'No active ensemble.');
401
+ }
402
+ else {
403
+ (async () => {
404
+ try {
405
+ await api.sendCommand(activeEns, `/load_lineup ${lineupPath}`, 'maestro');
406
+ dispatch({
407
+ type: 'COMMIT_STATIC',
408
+ item: { id: nextStaticId(), type: 'info', content: `\u2714 Lineup load requested: ${lineupPath}`, timestamp: Date.now() },
409
+ });
410
+ }
411
+ catch (err) {
412
+ (0, commands_1.commitNotification)(dispatch, 'error', `\u2717 Failed to load lineup: ${err}`);
413
+ }
414
+ })();
415
+ }
416
+ }
417
+ else if (input === 'n' || input === 'N' || key.escape) {
418
+ dispatch({ type: 'CANCEL_LINEUP' });
419
+ dispatch({
420
+ type: 'COMMIT_STATIC',
421
+ item: { id: nextStaticId(), type: 'info', content: 'Lineup load cancelled.', timestamp: Date.now() },
422
+ });
423
+ }
424
+ return;
425
+ }
426
+ // #306: Esc dismisses the oldest live notification when no other
427
+ // Esc-consumer is active (overlays, pickers, confirmations all return
428
+ // early above). Filter-expired-first is handled in the reducer so this
429
+ // always acts on what the user is actually looking at. No-op when the
430
+ // stack is empty, so other Esc fallthroughs — like clearing a typed
431
+ // command — aren't disturbed.
432
+ if (key.escape && s.notifications.some(n => n.expiresAt > Date.now())) {
433
+ dispatch({ type: 'DISMISS_OLDEST_NOTIFICATION' });
434
+ return;
435
+ }
436
+ }, [exit, api])); // Stable deps only — reads stateRef.current for everything else
437
+ // ── Derived: conductor player id ──
438
+ // #358: single source of truth — derive the active conductor's playerId from
439
+ // the `players` array rather than caching it in a separate state field that
440
+ // only the snapshot path updated. Incremental SSE events (`player.added`,
441
+ // `player.removed`) update `players` directly, so the badge stays accurate
442
+ // between snapshots. `undefined` when no conductor is in the ensemble.
443
+ const conductorPlayerId = (0, react_1.useMemo)(() => state.players.find(p => p.isConductor)?.playerId, [state.players]);
444
+ // ── Context string for title bar ──
445
+ const contextString = (0, react_1.useMemo)(() => {
446
+ if (state.phase === 'splash')
447
+ return 'Starting up...';
448
+ if (state.phase === 'error')
449
+ return 'Error';
450
+ if (state.chatTarget) {
451
+ const isConductor = state.chatTarget === conductorPlayerId;
452
+ const player = state.players.find(p => p.playerId === state.chatTarget);
453
+ const status = (0, format_1.phaseToLabel)(player?.phase);
454
+ const icon = isConductor ? '\u2605' : '\u2022';
455
+ return `${icon} ${state.chatTarget} \u00b7 ${status}${state.activeEnsemble ? ` \u00b7 ${state.activeEnsemble}` : ''}`;
456
+ }
457
+ if (state.activeEnsemble) {
458
+ // Headline count excludes the maestro session (TUI's own dashboard
459
+ // attachment). The full list with the maestro is still available in
460
+ // `/players` and the status overlay.
461
+ const count = (0, format_1.filterRealPlayers)(state.players).length;
462
+ const conductorInfo = conductorPlayerId ? '' : ' \u00b7 No conductor';
463
+ return `${state.activeEnsemble} \u00b7 ${count} player${count !== 1 ? 's' : ''}${conductorInfo} \u00b7 Connected`;
464
+ }
465
+ const count = state.ensembles?.length ?? 0;
466
+ return count > 0 ? `${count} ensemble${count !== 1 ? 's' : ''} \u00b7 Connected` : 'Discovering ensembles...';
467
+ }, [state.phase, state.chatTarget, state.activeEnsemble, state.players, state.ensembles, conductorPlayerId]);
468
+ // ── Hint text for prompt area ──
469
+ const promptHints = (0, react_1.useMemo)(() => {
470
+ if (state.confirmingStop) {
471
+ return `Destroy ${state.confirmingStop}? This will terminally end their session workflow. [y/N]`;
472
+ }
473
+ if (state.confirmingDisband) {
474
+ return `Disband ensemble "${state.confirmingDisband}"? All sessions will be terminated. [y/N]`;
475
+ }
476
+ if (state.confirmingLineup) {
477
+ return `${state.confirmingLineup.summary} [y/N]`;
478
+ }
479
+ if (state.phase === 'recruit') {
480
+ return 'Follow the prompts above. Esc to cancel.';
481
+ }
482
+ if (state.chatTarget) {
483
+ return `Chatting with ${state.chatTarget}. /back to return.`;
484
+ }
485
+ if (state.activeEnsemble) {
486
+ return 'Type a message, or @player to message directly. /players to list.';
487
+ }
488
+ return '/help /quit';
489
+ }, [state.phase, state.chatTarget, state.confirmingStop, state.confirmingDisband, state.activeEnsemble]);
490
+ // ── Completion data for prompt ──
491
+ const commandNamesList = (0, react_1.useMemo)(() => (0, commands_1.getCommandNames)(), []);
492
+ const playerNamesList = (0, react_1.useMemo)(() => state.players.map(p => p.playerId), [state.players]);
493
+ // ── Picker items ──
494
+ const pickerItems = (0, react_1.useMemo)(() => {
495
+ if (!state.pickerVisible)
496
+ return [];
497
+ if (state.pickerType === 'players') {
498
+ // Apply status filter if set.
499
+ const filtered = state.pickerStatusFilter
500
+ ? state.players.filter(p => p.phase === state.pickerStatusFilter)
501
+ : state.players;
502
+ // Sort by type for grouping, conductor first
503
+ const sorted = [...filtered].sort((a, b) => {
504
+ if (a.isConductor !== b.isConductor)
505
+ return a.isConductor ? -1 : 1;
506
+ const typeA = a.playerType || a.agentType || '';
507
+ const typeB = b.playerType || b.agentType || '';
508
+ return typeA.localeCompare(typeB) || a.playerId.localeCompare(b.playerId);
509
+ });
510
+ // Resolve icons once for the whole map (not per-item) per
511
+ // docs/tui-performance.md — `statusIcons()` allocates a small object.
512
+ const icons = (0, platform_1.statusIcons)((0, platform_1.supportsUnicode)());
513
+ return sorted.map(p => ({
514
+ id: p.playerId,
515
+ label: p.playerId,
516
+ detail: `[${(0, format_1.phaseToLabel)(p.phase)}]`,
517
+ meta: p.part || undefined,
518
+ icon: p.isConductor ? '\u2605' : icons[(0, format_1.phaseToIconName)(p.phase)],
519
+ color: (0, format_1.phaseToColor)(p.phase),
520
+ current: p.playerId === state.chatTarget,
521
+ group: p.playerType || p.agentType || 'unknown',
522
+ }));
523
+ }
524
+ if (state.pickerType === 'ensembles') {
525
+ const items = (state.ensembles ?? []).map(ens => ({
526
+ id: ens.name,
527
+ label: ens.name,
528
+ detail: `${ens.playerCount} player${ens.playerCount !== 1 ? 's' : ''}`,
529
+ meta: ens.hasConductor ? '\u2605 conductor' : undefined,
530
+ current: ens.name === state.activeEnsemble,
531
+ }));
532
+ // Add "Create new ensemble" option at the bottom
533
+ items.push({
534
+ id: '__create__',
535
+ label: '+ Create new ensemble',
536
+ detail: 'launch wizard',
537
+ icon: '\u2795',
538
+ color: theme_1.THEME.accent,
539
+ });
540
+ return items;
541
+ }
542
+ return [];
543
+ }, [state.pickerVisible, state.pickerType, state.pickerStatusFilter, state.players, state.ensembles, state.chatTarget, state.activeEnsemble]);
544
+ pickerItemsRef.current = pickerItems;
545
+ // ── Command palette ──
546
+ const allPaletteCommands = (0, react_1.useMemo)(() => (0, commands_1.getCommandNames)().map(name => ({
547
+ name,
548
+ usage: commands_1.COMMANDS[name].usage,
549
+ description: commands_1.COMMANDS[name].description,
550
+ })), []);
551
+ // Palette filter state — updated via onInputChange ref callback (no dispatch per keystroke).
552
+ // Stores the full PaletteContext (mode + partial + replacePrefix) so the palette can
553
+ // show player names for `/restart <partial>`-style player-arg inputs, not just bare
554
+ // `/cmd` and `@name` inputs.
555
+ const [paletteCtx, setPaletteCtx] = (0, react_1.useState)(null);
556
+ const handleInputChange = (0, react_1.useCallback)((value) => {
557
+ inputValueRef.current = value;
558
+ const next = (0, commands_1.classifyPaletteInput)(value);
559
+ // Reference-equal no-op avoidance: only dispatch when mode/partial actually changed.
560
+ setPaletteCtx(prev => {
561
+ if (prev === next)
562
+ return prev;
563
+ if (prev && next && prev.mode === next.mode && prev.partial === next.partial && prev.replacePrefix === next.replacePrefix) {
564
+ return prev;
565
+ }
566
+ return next;
567
+ });
568
+ }, []);
569
+ // Player commands and subcommand map imported from commands.ts
570
+ const filteredPaletteCommands = (0, react_1.useMemo)(() => {
571
+ if (!state.paletteVisible || !paletteCtx)
572
+ return [];
573
+ if (paletteCtx.mode === 'player' || paletteCtx.mode === 'player-arg') {
574
+ return (0, commands_1.filterPlayerNames)(playerNamesList, paletteCtx.partial)
575
+ .map(n => ({ name: n, usage: `${paletteCtx.replacePrefix}${n}`, description: '' }));
576
+ }
577
+ // command mode
578
+ if (!paletteCtx.partial)
579
+ return allPaletteCommands;
580
+ return allPaletteCommands.filter(c => c.name.startsWith(paletteCtx.partial));
581
+ }, [state.paletteVisible, paletteCtx, allPaletteCommands, playerNamesList]);
582
+ // Clamp palette index
583
+ const clampedPaletteIndex = Math.min(state.paletteIndex, Math.max(0, filteredPaletteCommands.length - 1));
584
+ const handlePaletteToggle = (0, react_1.useCallback)((visible) => {
585
+ dispatch(visible ? { type: 'SHOW_PALETTE' } : { type: 'HIDE_PALETTE' });
586
+ }, []);
587
+ const handlePaletteUp = (0, react_1.useCallback)(() => {
588
+ dispatch({ type: 'PALETTE_UP' });
589
+ }, []);
590
+ const handlePaletteDown = (0, react_1.useCallback)(() => {
591
+ if (state.paletteIndex < filteredPaletteCommands.length - 1) {
592
+ dispatch({ type: 'PALETTE_DOWN' });
593
+ }
594
+ }, [state.paletteIndex, filteredPaletteCommands.length]);
595
+ const handlePaletteSelect = (0, react_1.useCallback)(() => {
596
+ if (filteredPaletteCommands.length > 0 && paletteCtx) {
597
+ const selected = filteredPaletteCommands[clampedPaletteIndex];
598
+ // replacePrefix already carries the right leading characters:
599
+ // command mode → '/' → `${/}recruit `
600
+ // player mode → '@' → `${@}conductor `
601
+ // player-arg mode → '/restart ' → `${/restart }conductor `
602
+ const value = `${paletteCtx.replacePrefix}${selected.name} `;
603
+ promptRef.current?.setValue(value);
604
+ inputValueRef.current = value;
605
+ dispatch({ type: 'HIDE_PALETTE' });
606
+ }
607
+ }, [filteredPaletteCommands, clampedPaletteIndex, paletteCtx]);
608
+ // ── Command submission handler ──
609
+ const handleSubmit = (0, react_1.useCallback)(async (input) => {
610
+ const trimmed = input.trim();
611
+ if (!trimmed)
612
+ return;
613
+ const s = stateRef.current;
614
+ // PromptArea clears itself on Enter (uncontrolled). Just clear our ref + palette.
615
+ inputValueRef.current = '';
616
+ if (s.paletteVisible)
617
+ dispatch({ type: 'HIDE_PALETTE' });
618
+ const parsed = (0, commands_1.parseCommand)(trimmed);
619
+ if (parsed) {
620
+ // Slash command
621
+ if (parsed.name === 'quit' || parsed.name === 'exit') {
622
+ exit();
623
+ return;
624
+ }
625
+ if (parsed.name === 'back') {
626
+ // Return to maestro view (exit any player chat)
627
+ if (s.chatTarget) {
628
+ dispatch({ type: 'EXIT_CHAT' });
629
+ dispatch({
630
+ type: 'COMMIT_STATIC',
631
+ item: { id: nextStaticId(), type: 'info', content: `\u2500\u2500 returned to maestro view \u2500\u2500`, timestamp: Date.now() },
632
+ });
633
+ }
634
+ else if (s.activeEnsemble) {
635
+ dispatch({ type: 'NAVIGATE_HOME' });
636
+ dispatch({
637
+ type: 'COMMIT_STATIC',
638
+ item: { id: nextStaticId(), type: 'info', content: `\u2500\u2500 returned to home view \u2500\u2500`, timestamp: Date.now() },
639
+ });
640
+ }
641
+ return;
642
+ }
643
+ if (parsed.name === 'help') {
644
+ if (parsed.args.length > 0) {
645
+ const cmdName = parsed.args[0].replace(/^\//, '');
646
+ const cmd = commands_1.COMMANDS[cmdName];
647
+ if (cmd) {
648
+ dispatch({ type: 'SHOW_COMMAND_OVERLAY', title: `Help \u00B7 /${cmdName}`, content: `\n ${cmd.description}\n\n Usage: ${cmd.usage}` });
649
+ }
650
+ else {
651
+ dispatch({ type: 'SHOW_COMMAND_OVERLAY', title: 'Help', content: `\n Unknown command: "${cmdName}"` });
652
+ }
653
+ }
654
+ else {
655
+ dispatch({ type: 'SHOW_COMMAND_OVERLAY', title: 'Help', content: (0, commands_1.formatHelpSummary)() });
656
+ }
657
+ return;
658
+ }
659
+ // Commands that open player picker when no args provided
660
+ const PICKER_COMMANDS = {
661
+ stop: (id) => {
662
+ dispatch({ type: 'COMMIT_STATIC', item: { id: nextStaticId(), type: 'info', content: `Stopping ${id}...`, timestamp: Date.now() } });
663
+ const cmd = commands_1.COMMANDS['stop'];
664
+ if (cmd?.handler)
665
+ cmd.handler([id], dispatch, api, { activeEnsemble: stateRef.current.activeEnsemble, defaultAgent });
666
+ },
667
+ players: (id) => {
668
+ dispatch({ type: 'NAVIGATE_PLAYER', playerId: id });
669
+ },
670
+ player: (id) => {
671
+ dispatch({ type: 'NAVIGATE_PLAYER', playerId: id });
672
+ },
673
+ };
674
+ if (PICKER_COMMANDS[parsed.name] && parsed.args.length === 0) {
675
+ pickerCallbackRef.current = PICKER_COMMANDS[parsed.name];
676
+ dispatch({ type: 'SHOW_PICKER', pickerType: 'players' });
677
+ return;
678
+ }
679
+ // Alias: /player → /players
680
+ if (parsed.name === 'player') {
681
+ parsed.name = 'players';
682
+ }
683
+ if (!(0, commands_1.isValidCommand)(parsed.name)) {
684
+ const migrationHint = (0, removed_commands_1.removedSlashCommandHelp)(parsed.name);
685
+ (0, commands_1.commitNotification)(dispatch, 'error', migrationHint ?? `Unknown command: /${parsed.name}. Type /help for available commands.`);
686
+ return;
687
+ }
688
+ // Command exists but handler not yet implemented
689
+ const cmd = commands_1.COMMANDS[parsed.name];
690
+ if (!cmd.handler) {
691
+ dispatch({
692
+ type: 'COMMIT_STATIC',
693
+ item: {
694
+ id: nextStaticId(),
695
+ type: 'info',
696
+ content: `/${parsed.name}: coming soon. Usage: ${cmd.usage}`,
697
+ timestamp: Date.now(),
698
+ },
699
+ });
700
+ return;
701
+ }
702
+ // Execute handler
703
+ try {
704
+ const ctx = { activeEnsemble: s.activeEnsemble, defaultAgent };
705
+ await cmd.handler(parsed.args, dispatch, api, ctx);
706
+ }
707
+ catch (err) {
708
+ (0, commands_1.commitNotification)(dispatch, 'error', `Error running /${parsed.name}: ${err}`);
709
+ }
710
+ }
711
+ else if (s.activeEnsemble) {
712
+ // Bare text → route via @player or to conductor
713
+ const atMatch = trimmed.match(/^@(\S+)\s+(.+)$/s);
714
+ try {
715
+ if (atMatch) {
716
+ // @player message → send directly to that player
717
+ const [, targetPlayer, message] = atMatch;
718
+ dispatch({ type: 'APPEND_SENT_MESSAGE', to: targetPlayer, text: `@${targetPlayer} ${message}` });
719
+ api.sendAsMaestro(s.activeEnsemble, targetPlayer, message).catch(err => (0, commands_1.commitNotification)(dispatch, 'error', `\u2717 Failed to deliver to @${targetPlayer}: ${err}`));
720
+ }
721
+ else {
722
+ // No @prefix → send to conductor.
723
+ // #358: derive from `players` (single source of truth) instead of
724
+ // a separate cached field. `hasConductor` is the snapshot-derived
725
+ // flag; we fall back to the legacy `'conductor'` literal when no
726
+ // playerId is yet known so a freshly-loaded ensemble with the
727
+ // hasConductor flag still routes correctly.
728
+ const conductorPid = s.players.find(p => p.isConductor)?.playerId;
729
+ if (!conductorPid && !s.hasConductor) {
730
+ // No conductor — show error
731
+ (0, commands_1.commitNotification)(dispatch, 'error', 'No conductor. Use @player to message directly, or /recruit a conductor.');
732
+ return;
733
+ }
734
+ const conductorTarget = conductorPid || 'conductor';
735
+ dispatch({ type: 'APPEND_SENT_MESSAGE', to: conductorTarget, text: trimmed });
736
+ api.sendCommand(s.activeEnsemble, trimmed, 'maestro').catch(err => (0, commands_1.commitNotification)(dispatch, 'error', `\u2717 Failed to deliver: ${err}`));
737
+ }
738
+ }
739
+ catch (err) {
740
+ (0, commands_1.commitNotification)(dispatch, 'error', `\u2717 Error: ${err}`);
741
+ }
742
+ }
743
+ else {
744
+ // Bare text in main mode — hint to use commands
745
+ dispatch({
746
+ type: 'COMMIT_STATIC',
747
+ item: {
748
+ id: nextStaticId(),
749
+ type: 'info',
750
+ content: 'Use /commands to interact. Type /help for available commands.',
751
+ timestamp: Date.now(),
752
+ },
753
+ });
754
+ }
755
+ }, [api, exit]); // Reads stateRef.current for chatTarget/activeEnsemble
756
+ // ── Lightweight startup: check connectivity + ensure maestro session ──
757
+ // Phase starts at 'main' — polling handles all data discovery.
758
+ (0, react_1.useEffect)(() => {
759
+ let cancelled = false;
760
+ (async () => {
761
+ try {
762
+ // Check Temporal connectivity
763
+ const connected = await api.isConnected();
764
+ if (cancelled)
765
+ return;
766
+ if (!connected) {
767
+ dispatch({ type: 'SET_PHASE', phase: 'error', error: 'Cannot connect to Temporal. Run `agent-tempo up` first.' });
768
+ return;
769
+ }
770
+ // Mark splash as connected (updates splash UI)
771
+ dispatch({ type: 'SET_SPLASH_CONNECTED' });
772
+ // Ensure maestro session (best effort)
773
+ const ens = stateRef.current.activeEnsemble;
774
+ if (ens) {
775
+ try {
776
+ await api.ensureMaestroSession(ens);
777
+ }
778
+ catch (err) {
779
+ console.error('[tui] Failed to create maestro session:', err);
780
+ }
781
+ }
782
+ }
783
+ catch (err) {
784
+ if (!cancelled) {
785
+ dispatch({ type: 'SET_PHASE', phase: 'error', error: String(err) });
786
+ }
787
+ }
788
+ })();
789
+ return () => { cancelled = true; };
790
+ }, [api]);
791
+ // ── Ensure maestro session exists when ensemble changes ──
792
+ (0, react_1.useEffect)(() => {
793
+ if (!state.activeEnsemble)
794
+ return;
795
+ api.ensureMaestroSession(state.activeEnsemble).catch(err => console.error('[tui] maestro session:', err));
796
+ }, [state.activeEnsemble, api]);
797
+ // ── #94/#95 PR-4a: data acquisition is split across three effects ──
798
+ //
799
+ // Previously a single 2 s `setInterval` fanned out 5 RPCs/tick (players,
800
+ // schedules, chat, paused, held) and conditionally drilled into a
801
+ // selected player. After PR-3 the per-ensemble surface is exposed via
802
+ // SSE, so we:
803
+ // 1. Keep a 2 s poll for the home view's ensemble list (no per-
804
+ // ensemble surface there; SSE wouldn't help).
805
+ // 2. Subscribe to the daemon's SSE event stream for the active
806
+ // ensemble so player/chat/flags/schedule updates land in
807
+ // sub-second latency rather than waiting for a poll tick.
808
+ // 3. Keep a 2 s poll for the player drill-in view — per
809
+ // docs/SSE-PROTOCOL.md §11 the per-player + per-message
810
+ // endpoints are intentionally Temporal-direct.
811
+ // PR-4b will replace the rendering primitives (chat scrollback +
812
+ // player list); this PR deliberately leaves layout untouched so a
813
+ // streaming regression is bisectable independent of scroll changes.
814
+ // Effect 1: home-view ensembles list polling.
815
+ (0, react_1.useEffect)(() => {
816
+ if (state.phase !== 'splash' && state.phase !== 'main' && state.phase !== 'chat')
817
+ return;
818
+ if (state.activeEnsemble)
819
+ return;
820
+ let cancelled = false;
821
+ const tick = async () => {
822
+ try {
823
+ const ensembles = await api.discoverEnsembles();
824
+ if (cancelled)
825
+ return;
826
+ // Intentionally no auto-select: HomeView is an explicit picker
827
+ // (Online / Paused / Offline, arrow keys + Enter). Auto-selecting
828
+ // on the poller was bouncing users back into a just-shut-down
829
+ // ensemble after `/shutdown`, `/back`, or `/disband`.
830
+ dispatch({ type: 'REFRESH_ENSEMBLES', ensembles });
831
+ }
832
+ catch (err) {
833
+ console.error('[tui:home-poll] error:', err);
834
+ }
835
+ };
836
+ void tick();
837
+ const interval = setInterval(tick, 2000);
838
+ return () => {
839
+ cancelled = true;
840
+ clearInterval(interval);
841
+ };
842
+ }, [state.phase, state.activeEnsemble, api]);
843
+ // Effect 2: active-ensemble SSE subscription.
844
+ (0, react_1.useEffect)(() => {
845
+ if (state.phase !== 'splash' && state.phase !== 'main' && state.phase !== 'chat')
846
+ return;
847
+ if (!state.activeEnsemble)
848
+ return;
849
+ const ensemble = state.activeEnsemble;
850
+ const controller = new AbortController();
851
+ void (async () => {
852
+ try {
853
+ for await (const event of api.subscribe(ensemble, { signal: controller.signal })) {
854
+ await (0, sse_handler_1.handleSseEvent)(event, dispatch, ensemble, api);
855
+ }
856
+ }
857
+ catch (err) {
858
+ // AbortError on teardown is expected — only log unexpected failures.
859
+ if (controller.signal.aborted)
860
+ return;
861
+ console.error('[tui:subscribe] error:', err);
862
+ }
863
+ })();
864
+ return () => controller.abort();
865
+ }, [state.phase, state.activeEnsemble, api]);
866
+ // Effect 3: player drill-in polling (per spec §11 — Temporal-direct).
867
+ (0, react_1.useEffect)(() => {
868
+ if (state.view !== 'player')
869
+ return;
870
+ if (!state.activeEnsemble || !state.activePlayer)
871
+ return;
872
+ const ensemble = state.activeEnsemble;
873
+ const playerId = state.activePlayer;
874
+ let cancelled = false;
875
+ const tick = async () => {
876
+ try {
877
+ const [metadata, messages] = await Promise.all([
878
+ api.getPlayerMetadata(ensemble, playerId),
879
+ api.getPlayerMessages(ensemble, playerId),
880
+ ]);
881
+ if (cancelled)
882
+ return;
883
+ dispatch({ type: 'REFRESH_PLAYER_DATA', metadata, messages });
884
+ }
885
+ catch {
886
+ // Best-effort — player may have been terminated mid-poll.
887
+ }
888
+ };
889
+ void tick();
890
+ const interval = setInterval(tick, 2000);
891
+ return () => {
892
+ cancelled = true;
893
+ clearInterval(interval);
894
+ };
895
+ }, [state.view, state.activeEnsemble, state.activePlayer, api]);
896
+ // ── Recruit wizard callbacks (must be before early return — Rules of Hooks) ──
897
+ const handleRecruitAnswer = (0, react_1.useCallback)((answer) => {
898
+ dispatch({ type: 'RECRUIT_NEXT_STEP', answer });
899
+ }, []);
900
+ const handleRecruitBack = (0, react_1.useCallback)(() => {
901
+ dispatch({ type: 'RECRUIT_PREV_STEP' });
902
+ }, []);
903
+ const handleRecruitConfirm = (0, react_1.useCallback)(async () => {
904
+ if (!state.recruitState)
905
+ return;
906
+ const activeEns = state.activeEnsemble;
907
+ if (!activeEns) {
908
+ dispatch({ type: 'RECRUIT_DONE', error: 'No active ensemble. Start one with: agent-tempo up <name>' });
909
+ return;
910
+ }
911
+ dispatch({ type: 'RECRUIT_SUBMIT' });
912
+ const a = state.recruitState.answers;
913
+ try {
914
+ // #306: Direct TempoClient path — submit the recruit entry on the
915
+ // TUI's own maestro session instead of round-tripping through the
916
+ // conductor's Claude Code session. Works when no conductor is present
917
+ // (the wizard's original use-case) and eliminates the 2-5s LLM hop.
918
+ await api.recruit(activeEns, {
919
+ name: a.name,
920
+ workDir: a.workDir,
921
+ agent: a.agent,
922
+ ...(a.playerType ? { playerType: a.playerType } : {}),
923
+ ...(a.host && a.host !== 'localhost' ? { host: a.host } : {}),
924
+ ...(a.initialMessage ? { initialMessage: a.initialMessage } : {}),
925
+ });
926
+ dispatch({ type: 'RECRUIT_DONE' });
927
+ dispatch({
928
+ type: 'COMMIT_STATIC',
929
+ item: {
930
+ id: nextStaticId(),
931
+ type: 'info',
932
+ content: `\u2714 Recruit requested: ${a.name} (${a.agent}${a.playerType ? ', type: ' + a.playerType : ''})`,
933
+ timestamp: Date.now(),
934
+ },
935
+ });
936
+ }
937
+ catch (err) {
938
+ dispatch({ type: 'RECRUIT_DONE', error: String(err) });
939
+ }
940
+ }, [state.recruitState, state.activeEnsemble, api]);
941
+ const handleRecruitCancel = (0, react_1.useCallback)(() => {
942
+ dispatch({ type: 'EXIT_RECRUIT' });
943
+ dispatch({
944
+ type: 'COMMIT_STATIC',
945
+ item: { id: nextStaticId(), type: 'info', content: 'Recruit cancelled.', timestamp: Date.now() },
946
+ });
947
+ }, []);
948
+ const handleRecruitDone = (0, react_1.useCallback)(() => {
949
+ dispatch({ type: 'EXIT_RECRUIT' });
950
+ }, []);
951
+ // ── Schedule wizard callbacks ──
952
+ const handleScheduleAnswer = (0, react_1.useCallback)((answer) => {
953
+ dispatch({ type: 'SCHEDULE_NEXT_STEP', answer });
954
+ }, []);
955
+ const handleScheduleBack = (0, react_1.useCallback)(() => {
956
+ dispatch({ type: 'SCHEDULE_PREV_STEP' });
957
+ }, []);
958
+ const handleScheduleConfirm = (0, react_1.useCallback)(async () => {
959
+ if (!state.scheduleWizard)
960
+ return;
961
+ const activeEns = state.activeEnsemble;
962
+ if (!activeEns) {
963
+ dispatch({ type: 'SCHEDULE_DONE', error: 'No active ensemble.' });
964
+ return;
965
+ }
966
+ dispatch({ type: 'SCHEDULE_SUBMIT' });
967
+ const a = state.scheduleWizard.answers;
968
+ try {
969
+ const parts = [`/schedule ${a.name} --to ${a.target}`];
970
+ if (a.schedType === 'delay')
971
+ parts.push(`--delay ${a.timing}`);
972
+ else if (a.schedType === 'at')
973
+ parts.push(`--at ${a.timing}`);
974
+ else if (a.schedType === 'every')
975
+ parts.push(`--every ${a.timing}`);
976
+ else if (a.schedType === 'cron') {
977
+ parts.push(`--cron "${a.timing}"`);
978
+ if (a.timezone)
979
+ parts.push(`--timezone ${a.timezone}`);
980
+ }
981
+ parts.push(a.message);
982
+ await api.sendCommand(activeEns, parts.join(' '), 'maestro');
983
+ dispatch({ type: 'SCHEDULE_DONE' });
984
+ dispatch({
985
+ type: 'COMMIT_STATIC',
986
+ item: { id: nextStaticId(), type: 'info', content: `\u2714 Schedule "${a.name}" creation requested.`, timestamp: Date.now() },
987
+ });
988
+ }
989
+ catch (err) {
990
+ dispatch({ type: 'SCHEDULE_DONE', error: String(err) });
991
+ }
992
+ }, [state.scheduleWizard, state.activeEnsemble, api]);
993
+ const handleScheduleCancel = (0, react_1.useCallback)(() => {
994
+ dispatch({ type: 'EXIT_SCHEDULE_WIZARD' });
995
+ dispatch({
996
+ type: 'COMMIT_STATIC',
997
+ item: { id: nextStaticId(), type: 'info', content: 'Schedule creation cancelled.', timestamp: Date.now() },
998
+ });
999
+ }, []);
1000
+ const handleScheduleDone = (0, react_1.useCallback)(() => {
1001
+ dispatch({ type: 'EXIT_SCHEDULE_WIZARD' });
1002
+ }, []);
1003
+ // ── Create ensemble wizard callbacks ──
1004
+ const handleCreateEnsAnswer = (0, react_1.useCallback)((answer) => {
1005
+ dispatch({ type: 'CREATE_ENSEMBLE_NEXT_STEP', answer });
1006
+ }, []);
1007
+ const handleCreateEnsBack = (0, react_1.useCallback)(() => {
1008
+ dispatch({ type: 'CREATE_ENSEMBLE_PREV_STEP' });
1009
+ }, []);
1010
+ const handleCreateEnsConfirm = (0, react_1.useCallback)(async () => {
1011
+ const wizState = stateRef.current.createEnsembleState;
1012
+ if (!wizState)
1013
+ return;
1014
+ dispatch({ type: 'CREATE_ENSEMBLE_SUBMIT' });
1015
+ const { name, workDir, lineup } = wizState.answers;
1016
+ try {
1017
+ await api.createEnsemble({ ensemble: name, workDir, ...(lineup ? { lineup } : {}) });
1018
+ dispatch({
1019
+ type: 'COMMIT_STATIC',
1020
+ item: { id: nextStaticId(), type: 'info', content: `\u2714 Ensemble "${name}" created.`, timestamp: Date.now() },
1021
+ });
1022
+ dispatch({ type: 'CREATE_ENSEMBLE_DONE', ensemble: name });
1023
+ }
1024
+ catch (err) {
1025
+ dispatch({ type: 'CREATE_ENSEMBLE_DONE', error: err instanceof Error ? err.message : String(err) });
1026
+ }
1027
+ }, [api]);
1028
+ const handleCreateEnsCancel = (0, react_1.useCallback)(() => {
1029
+ dispatch({ type: 'EXIT_CREATE_ENSEMBLE' });
1030
+ }, []);
1031
+ const handleCreateEnsDone = (0, react_1.useCallback)(() => {
1032
+ dispatch({ type: 'EXIT_CREATE_ENSEMBLE' });
1033
+ }, []);
1034
+ // ── Home view ─────────────────────────────────────────────────────────
1035
+ const [cwdGitRoot] = (0, react_1.useState)(() => {
1036
+ const { getGitInfo } = require('../git-info');
1037
+ return getGitInfo(process.cwd()).gitRoot ?? null;
1038
+ });
1039
+ const bootstrapInitial = (0, react_1.useMemo)(() => ({
1040
+ ensembles: state.ensembles ?? [],
1041
+ cwdGitRoot,
1042
+ badges: { orphanCount: 0 },
1043
+ }), [state.ensembles, cwdGitRoot]);
1044
+ const handleHomeEnter = (0, react_1.useCallback)((name) => {
1045
+ dispatch({ type: 'NAVIGATE_ENSEMBLE', ensemble: name });
1046
+ }, []);
1047
+ const handleHomeQuit = (0, react_1.useCallback)(() => {
1048
+ exit();
1049
+ }, [exit]);
1050
+ const handleHomeOpenNew = (0, react_1.useCallback)(() => {
1051
+ // #306: Use the full CreateEnsembleWizard (same as Splash's `+ Create new
1052
+ // ensemble` row) so the user gets the multi-step name → dir → lineup
1053
+ // flow instead of the bare single-prompt NewEnsembleModal.
1054
+ dispatch({ type: 'ENTER_CREATE_ENSEMBLE' });
1055
+ }, []);
1056
+ const handleHomeOpenLineup = (0, react_1.useCallback)(() => {
1057
+ dispatch({ type: 'OPEN_HOME_MODAL', modal: { type: 'lineup' } });
1058
+ }, []);
1059
+ const handleHomeOpenRestore = (0, react_1.useCallback)((ensembleName) => {
1060
+ const match = state.ensembles?.find((e) => e.name === ensembleName);
1061
+ dispatch({
1062
+ type: 'OPEN_HOME_MODAL',
1063
+ modal: {
1064
+ type: 'restore',
1065
+ ensemble: ensembleName,
1066
+ playerCount: Math.max(0, (match?.playerCount ?? 1) - (match?.hasConductor ? 1 : 0)),
1067
+ conductor: match?.hasConductor ? 'conductor' : undefined,
1068
+ },
1069
+ });
1070
+ }, [state.ensembles]);
1071
+ const handleHomeModalClose = (0, react_1.useCallback)(() => {
1072
+ dispatch({ type: 'CLOSE_HOME_MODAL' });
1073
+ }, []);
1074
+ const handleHomeNewSubmit = (0, react_1.useCallback)(async (name) => {
1075
+ dispatch({ type: 'SET_HOME_MODAL_STATUS', submitting: true });
1076
+ try {
1077
+ await api.createEnsemble({ ensemble: name });
1078
+ dispatch({ type: 'CLOSE_HOME_MODAL' });
1079
+ dispatch({ type: 'NAVIGATE_ENSEMBLE', ensemble: name });
1080
+ }
1081
+ catch (err) {
1082
+ dispatch({
1083
+ type: 'SET_HOME_MODAL_STATUS',
1084
+ submitting: false,
1085
+ error: err instanceof Error ? err.message : String(err),
1086
+ });
1087
+ }
1088
+ }, [api]);
1089
+ const handleHomeLineupSubmit = (0, react_1.useCallback)(async (args) => {
1090
+ dispatch({ type: 'SET_HOME_MODAL_STATUS', submitting: true });
1091
+ try {
1092
+ await api.createEnsemble({ ensemble: args.ensemble, lineup: args.lineupPath });
1093
+ dispatch({ type: 'CLOSE_HOME_MODAL' });
1094
+ dispatch({ type: 'NAVIGATE_ENSEMBLE', ensemble: args.ensemble });
1095
+ }
1096
+ catch (err) {
1097
+ dispatch({
1098
+ type: 'SET_HOME_MODAL_STATUS',
1099
+ submitting: false,
1100
+ error: err instanceof Error ? err.message : String(err),
1101
+ });
1102
+ }
1103
+ }, [api]);
1104
+ // /destroy <ensemble> typed-name confirmation handlers (#291)
1105
+ const handleEnsembleDestroyInput = (0, react_1.useCallback)((next) => {
1106
+ dispatch({ type: 'ENSEMBLE_DESTROY_INPUT', input: next });
1107
+ }, []);
1108
+ const handleEnsembleDestroyCancel = (0, react_1.useCallback)(() => {
1109
+ dispatch({ type: 'CANCEL_ENSEMBLE_DESTROY' });
1110
+ dispatch({
1111
+ type: 'COMMIT_STATIC',
1112
+ item: { id: nextStaticId(), type: 'info', content: 'Destroy cancelled.', timestamp: Date.now() },
1113
+ });
1114
+ }, []);
1115
+ const handleEnsembleDestroySubmit = (0, react_1.useCallback)(async () => {
1116
+ const pending = stateRef.current.confirmingEnsembleDestroy;
1117
+ if (!pending)
1118
+ return;
1119
+ if (pending.input !== pending.ensemble) {
1120
+ dispatch({ type: 'ENSEMBLE_DESTROY_MISMATCH' });
1121
+ return;
1122
+ }
1123
+ dispatch({ type: 'ENSEMBLE_DESTROY_SUBMIT_BUSY' });
1124
+ const target = pending.ensemble;
1125
+ try {
1126
+ const summary = await api.destroy(target);
1127
+ dispatch({ type: 'CANCEL_ENSEMBLE_DESTROY' });
1128
+ if (summary && 'details' in summary) {
1129
+ // #306: aggregate ensemble-destroy summary surfaces as a bottom-pinned
1130
+ // notification so it's still visible after we navigate the user home.
1131
+ (0, commands_1.commitNotification)(dispatch, summary.failed > 0 ? 'error' : 'info', `\u2714 Destroyed "${target}" \u2014 ${summary.destroyed} destroyed, ${summary.terminated} terminated, ${summary.failed} failed.`);
1132
+ }
1133
+ dispatch({ type: 'NAVIGATE_HOME' });
1134
+ }
1135
+ catch (err) {
1136
+ dispatch({ type: 'CANCEL_ENSEMBLE_DESTROY' });
1137
+ (0, commands_1.commitNotification)(dispatch, 'error', `\u2717 Destroy failed for "${target}": ${err instanceof Error ? err.message : String(err)}`);
1138
+ }
1139
+ }, [api]);
1140
+ const handleHomeRestoreConfirm = (0, react_1.useCallback)(async () => {
1141
+ const modal = stateRef.current.homeModal;
1142
+ if (!modal || modal.type !== 'restore')
1143
+ return;
1144
+ const target = modal.ensemble;
1145
+ dispatch({ type: 'SET_HOME_MODAL_STATUS', submitting: true });
1146
+ try {
1147
+ const summary = await api.restore(target);
1148
+ dispatch({ type: 'CLOSE_HOME_MODAL' });
1149
+ if (summary.failed > 0) {
1150
+ (0, commands_1.commitNotification)(dispatch, 'error', `Restore partial: ${summary.reattached} queued, ${summary.failed} failed, ${summary.skipped} skipped.`);
1151
+ }
1152
+ // Mirror the `/restore` slash two-op: ensure a conductor terminal is
1153
+ // live so the home-view restore path never strands the user on a
1154
+ // reattached-but-conductor-less ensemble.
1155
+ const { ensureConductorSpawned } = await Promise.resolve().then(() => __importStar(require('../client/ensure-conductor-spawned')));
1156
+ const conductorOutcome = await ensureConductorSpawned(target, api);
1157
+ if (!conductorOutcome.spawned && conductorOutcome.reason === 'spawnFailed') {
1158
+ (0, commands_1.commitNotification)(dispatch, 'error', `Conductor spawn failed for "${target}": ${conductorOutcome.error}`);
1159
+ }
1160
+ dispatch({ type: 'NAVIGATE_ENSEMBLE', ensemble: target });
1161
+ }
1162
+ catch (err) {
1163
+ dispatch({
1164
+ type: 'SET_HOME_MODAL_STATUS',
1165
+ submitting: false,
1166
+ error: err instanceof Error ? err.message : String(err),
1167
+ });
1168
+ }
1169
+ }, [api]);
1170
+ // ── Memoize chat messages (must be before early return — Rules of Hooks) ──
1171
+ const memoizedChatData = (0, react_1.useMemo)(() => {
1172
+ if (!state.chatTarget)
1173
+ return null;
1174
+ const isConductorChat = state.chatTarget === conductorPlayerId;
1175
+ let chatMessages;
1176
+ if (isConductorChat) {
1177
+ const fromHistory = state.conductorHistory.map(entry => {
1178
+ if (entry.type === 'command') {
1179
+ const cmd = entry.data;
1180
+ return { direction: 'sent', from: cmd.source || 'maestro', text: cmd.text, timestamp: entry.timestamp || cmd.timestamp };
1181
+ }
1182
+ else {
1183
+ const report = entry.data;
1184
+ return { direction: 'received', from: report.playerId, text: `[${report.type}] ${report.text}`, timestamp: entry.timestamp || report.timestamp };
1185
+ }
1186
+ });
1187
+ const fromSent = state.sentMessages
1188
+ .filter(m => m.to === state.chatTarget)
1189
+ .map(m => ({ direction: 'sent', from: 'maestro', text: m.text, timestamp: m.timestamp }));
1190
+ const seen = new Set();
1191
+ chatMessages = [...fromHistory, ...fromSent]
1192
+ .sort((a, b) => a.timestamp.localeCompare(b.timestamp))
1193
+ .filter(m => { const k = `${m.direction}:${m.timestamp}:${m.text.slice(0, 50)}`; if (seen.has(k))
1194
+ return false; seen.add(k); return true; });
1195
+ }
1196
+ else {
1197
+ const fromRelay = state.messages
1198
+ .filter(m => m.from === state.chatTarget || m.to === state.chatTarget)
1199
+ .map(m => ({ direction: m.to === state.chatTarget ? 'sent' : 'received', from: m.from, text: m.text, timestamp: m.timestamp }));
1200
+ const fromSent = state.sentMessages
1201
+ .filter(m => m.to === state.chatTarget)
1202
+ .map(m => ({ direction: 'sent', from: 'maestro', text: m.text, timestamp: m.timestamp }));
1203
+ const seen = new Set();
1204
+ chatMessages = [...fromRelay, ...fromSent]
1205
+ .sort((a, b) => a.timestamp.localeCompare(b.timestamp))
1206
+ .filter(m => { const k = `${m.direction}:${m.timestamp}:${m.text.slice(0, 50)}`; if (seen.has(k))
1207
+ return false; seen.add(k); return true; });
1208
+ }
1209
+ return {
1210
+ messages: chatMessages,
1211
+ received: chatMessages.filter(m => m.direction === 'received').length,
1212
+ sent: chatMessages.filter(m => m.direction === 'sent').length,
1213
+ isConductor: isConductorChat,
1214
+ };
1215
+ }, [state.chatTarget, conductorPlayerId, state.conductorHistory, state.messages, state.sentMessages]);
1216
+ // Note: relay messages are committed to staticItems directly in the poll loop.
1217
+ // Conductor history messages are committed when entering conductor chat mode.
1218
+ // ── Render ──
1219
+ // Divider — thin horizontal rule
1220
+ const dividerWidth = Math.max(20, (process.stdout.columns || 80) - 4);
1221
+ const dividerLine = '\u2500'.repeat(dividerWidth);
1222
+ // Splash → create ensemble handler: launch the create-ensemble wizard
1223
+ const handleSplashCreate = (0, react_1.useCallback)(() => {
1224
+ dispatch({ type: 'ENTER_CREATE_ENSEMBLE' });
1225
+ }, []);
1226
+ // Splash → main transition handler
1227
+ const handleSplashContinue = (0, react_1.useCallback)((selectedEnsemble) => {
1228
+ if (selectedEnsemble) {
1229
+ dispatch({ type: 'NAVIGATE_ENSEMBLE', ensemble: selectedEnsemble });
1230
+ dispatch({
1231
+ type: 'COMMIT_STATIC',
1232
+ item: { id: nextStaticId(), type: 'info', content: `\u2714 Connected to ensemble: ${selectedEnsemble}`, timestamp: Date.now() },
1233
+ });
1234
+ }
1235
+ dispatch({ type: 'SET_PHASE', phase: 'main' });
1236
+ }, []);
1237
+ function renderLiveContent() {
1238
+ // Terminal size warning (non-blocking)
1239
+ const termCols = process.stdout.columns || 80;
1240
+ if (termCols < 60 || termRows < 15) {
1241
+ return react_1.default.createElement(Text, { color: theme_1.THEME.warning }, `\n \u26A0 Terminal too small (${termCols}\u00D7${termRows}). Resize to at least 60\u00D715 for best experience.`);
1242
+ }
1243
+ // Splash screen — shown on startup when no ensemble is specified
1244
+ if (state.phase === 'splash') {
1245
+ return react_1.default.createElement(Splash_1.Splash, {
1246
+ status: state.splashStatus,
1247
+ version: packageVersion,
1248
+ connected: state.splashConnected,
1249
+ ensembles: (state.ensembles ?? undefined),
1250
+ onContinue: handleSplashContinue,
1251
+ onCreateEnsemble: handleSplashCreate,
1252
+ });
1253
+ }
1254
+ if (state.phase === 'error') {
1255
+ return react_1.default.createElement(ErrorView_1.ErrorView, {
1256
+ version: packageVersion,
1257
+ checks: [
1258
+ { label: `Cannot reach Temporal`, passed: false, detail: state.error },
1259
+ ],
1260
+ errorDetail: state.error,
1261
+ onQuit: () => { process.exitCode = 1; exit(); },
1262
+ });
1263
+ }
1264
+ // Picker takes over full content area
1265
+ if (state.pickerVisible) {
1266
+ const pickerTitle = state.pickerType === 'ensembles'
1267
+ ? 'Select Ensemble'
1268
+ : 'Select Player';
1269
+ return react_1.default.createElement(Picker_1.Picker, {
1270
+ title: pickerTitle,
1271
+ items: pickerItems,
1272
+ selectedIndex: state.pickerIndex,
1273
+ hint: '\u2191\u2193 navigate, Enter select, Esc dismiss',
1274
+ });
1275
+ }
1276
+ if (state.phase === 'recruit' && state.recruitState) {
1277
+ return react_1.default.createElement(RecruitWizard_1.RecruitWizard, {
1278
+ state: state.recruitState,
1279
+ onAnswer: handleRecruitAnswer,
1280
+ onBack: handleRecruitBack,
1281
+ onConfirm: handleRecruitConfirm,
1282
+ onCancel: handleRecruitCancel,
1283
+ onDone: handleRecruitDone,
1284
+ });
1285
+ }
1286
+ if (state.phase === 'schedule-create' && state.scheduleWizard) {
1287
+ return react_1.default.createElement(ScheduleWizard_1.ScheduleWizard, {
1288
+ state: state.scheduleWizard,
1289
+ onAnswer: handleScheduleAnswer,
1290
+ onBack: handleScheduleBack,
1291
+ onConfirm: handleScheduleConfirm,
1292
+ onCancel: handleScheduleCancel,
1293
+ onDone: handleScheduleDone,
1294
+ });
1295
+ }
1296
+ if (state.phase === 'create-ensemble' && state.createEnsembleState) {
1297
+ return react_1.default.createElement(CreateEnsembleWizard_1.CreateEnsembleWizard, {
1298
+ state: state.createEnsembleState,
1299
+ onAnswer: handleCreateEnsAnswer,
1300
+ onBack: handleCreateEnsBack,
1301
+ onConfirm: handleCreateEnsConfirm,
1302
+ onCancel: handleCreateEnsCancel,
1303
+ onDone: handleCreateEnsDone,
1304
+ });
1305
+ }
1306
+ // Home-view modals (#290). Render in place of HomeView so the modal
1307
+ // owns keyboard focus — HomeView's own useInput short-circuits when a
1308
+ // modal is up (matches the wizard pattern).
1309
+ if (state.homeModal?.type === 'new') {
1310
+ return react_1.default.createElement(NewEnsembleModal_1.NewEnsembleModal, {
1311
+ onSubmit: handleHomeNewSubmit,
1312
+ onCancel: handleHomeModalClose,
1313
+ submitting: state.homeModalSubmitting,
1314
+ error: state.homeModalError,
1315
+ });
1316
+ }
1317
+ if (state.homeModal?.type === 'lineup') {
1318
+ return react_1.default.createElement(LoadLineupModal_1.LoadLineupModal, {
1319
+ onSubmit: handleHomeLineupSubmit,
1320
+ onCancel: handleHomeModalClose,
1321
+ submitting: state.homeModalSubmitting,
1322
+ error: state.homeModalError,
1323
+ });
1324
+ }
1325
+ if (state.homeModal?.type === 'restore') {
1326
+ return react_1.default.createElement(RestoreConfirmModal_1.RestoreConfirmModal, {
1327
+ ensemble: state.homeModal.ensemble,
1328
+ playerCount: state.homeModal.playerCount,
1329
+ conductorName: state.homeModal.conductor,
1330
+ onConfirm: handleHomeRestoreConfirm,
1331
+ onCancel: handleHomeModalClose,
1332
+ submitting: state.homeModalSubmitting,
1333
+ error: state.homeModalError,
1334
+ });
1335
+ }
1336
+ // /destroy <ensemble> typed-name confirmation modal (#291)
1337
+ if (state.confirmingEnsembleDestroy) {
1338
+ return react_1.default.createElement(DestroyConfirmModal_1.DestroyConfirmModal, {
1339
+ ensemble: state.confirmingEnsembleDestroy.ensemble,
1340
+ input: state.confirmingEnsembleDestroy.input,
1341
+ error: state.confirmingEnsembleDestroy.error,
1342
+ submitting: state.confirmingEnsembleDestroy.submitting,
1343
+ onInput: handleEnsembleDestroyInput,
1344
+ onSubmit: handleEnsembleDestroySubmit,
1345
+ onCancel: handleEnsembleDestroyCancel,
1346
+ });
1347
+ }
1348
+ // Home view (#290) — renders when on the 'home' navigation and the
1349
+ // app is past the splash connection check. The view pre-renders from
1350
+ // the current `ensembles` snapshot and refreshes itself on a timer.
1351
+ if (state.phase === 'main' && state.view === 'home' && state.splashConnected) {
1352
+ return react_1.default.createElement(HomeView_1.HomeView, {
1353
+ initial: bootstrapInitial,
1354
+ client: api,
1355
+ onEnterEnsemble: handleHomeEnter,
1356
+ onCreateEnsemble: handleHomeOpenNew,
1357
+ onLoadLineup: handleHomeOpenLineup,
1358
+ onRestoreEnsemble: handleHomeOpenRestore,
1359
+ onQuit: handleHomeQuit,
1360
+ });
1361
+ }
1362
+ if (state.chatTarget && memoizedChatData) {
1363
+ const targetPlayer = state.players.find(p => p.playerId === state.chatTarget);
1364
+ return react_1.default.createElement(ChatView_1.ChatView, {
1365
+ targetPlayer: state.chatTarget,
1366
+ targetPart: targetPlayer?.part,
1367
+ targetBranch: targetPlayer?.gitBranch,
1368
+ targetStatus: targetPlayer?.phase,
1369
+ targetHost: targetPlayer?.hostname,
1370
+ localHost: (0, os_1.hostname)(),
1371
+ isConductor: memoizedChatData.isConductor,
1372
+ receivedCount: memoizedChatData.received,
1373
+ sentCount: memoizedChatData.sent,
1374
+ messages: memoizedChatData.messages,
1375
+ });
1376
+ }
1377
+ // Status overlay — card layout with scrolling
1378
+ if (state.statusOverlay && state.activeEnsemble) {
1379
+ return react_1.default.createElement(StatusOverlay_1.StatusOverlay, {
1380
+ players: state.players,
1381
+ ensemble: state.activeEnsemble,
1382
+ scrollOffset: state.statusScrollOffset,
1383
+ contentHeight,
1384
+ });
1385
+ }
1386
+ // Unified overlay — all overlay types rendered here
1387
+ if (state.overlay) {
1388
+ const ov = state.overlay;
1389
+ const children = [];
1390
+ children.push(react_1.default.createElement(Text, { key: 'ov-title', bold: true, color: theme_1.THEME.accent }, ` ${ov.title}`));
1391
+ if (ov.items.length === 0) {
1392
+ children.push('\n\n');
1393
+ children.push(react_1.default.createElement(Text, { key: 'ov-empty', color: theme_1.THEME.dim }, ' No items.'));
1394
+ }
1395
+ else {
1396
+ for (let i = 0; i < ov.items.length; i++) {
1397
+ const item = ov.items[i];
1398
+ const selected = i === ov.selectedIndex;
1399
+ const prefix = selected ? ' \u276F ' : ' ';
1400
+ children.push('\n\n');
1401
+ children.push(react_1.default.createElement(Text, { key: `ov-${i}`, color: selected ? theme_1.THEME.text : theme_1.THEME.dim, bold: selected }, `${prefix}${item.label}`));
1402
+ if (item.sublabel) {
1403
+ children.push('\n');
1404
+ children.push(react_1.default.createElement(Text, { key: `ovs-${i}`, color: theme_1.THEME.dim }, ` ${item.sublabel}`));
1405
+ }
1406
+ }
1407
+ }
1408
+ // Pad to fill contentHeight
1409
+ const usedLines = 1 + ov.items.reduce((n, item) => n + 2 + (item.sublabel ? 1 : 0), 0) + 2;
1410
+ const padLines = Math.max(0, contentHeight - usedLines);
1411
+ if (padLines > 0)
1412
+ children.push('\n'.repeat(padLines));
1413
+ children.push('\n');
1414
+ children.push(react_1.default.createElement(Text, { key: 'ov-hint', color: theme_1.THEME.dim }, ` ${ov.hint}`));
1415
+ return react_1.default.createElement(Text, null, ...children);
1416
+ }
1417
+ // Player detail view — shows player metadata + message history
1418
+ if (state.view === 'player' && state.activePlayer && state.activeEnsemble) {
1419
+ const player = state.players.find(p => p.playerId === state.activePlayer) || null;
1420
+ return react_1.default.createElement(PlayerDetailView_1.PlayerDetailView, {
1421
+ playerId: state.activePlayer,
1422
+ ensemble: state.activeEnsemble,
1423
+ player,
1424
+ metadata: state.playerMetadata,
1425
+ messages: state.playerMessages,
1426
+ scrollOffset: state.playerScrollOffset,
1427
+ localHost: (0, os_1.hostname)(),
1428
+ });
1429
+ }
1430
+ // Main view — conversation stream (like Claude Code)
1431
+ if (state.activeEnsemble) {
1432
+ // Show loading state until first poll completes
1433
+ if (state.conversation === null) {
1434
+ return react_1.default.createElement(Text, { color: theme_1.THEME.dim }, '\n \u27F3 Loading messages...');
1435
+ }
1436
+ return react_1.default.createElement(ConversationStream_1.ConversationStream, {
1437
+ conversation: state.conversation,
1438
+ sentMessages: state.sentMessages,
1439
+ contentHeight,
1440
+ overflowRef,
1441
+ conductorPlayerId,
1442
+ });
1443
+ }
1444
+ // No active ensemble — show ensemble list, loading state, or help
1445
+ // Still loading ensembles
1446
+ if (state.ensembles === null) {
1447
+ if (ensemble) {
1448
+ return react_1.default.createElement(Text, { color: theme_1.THEME.dim }, `\n \u27F3 Connecting to ${ensemble}...`);
1449
+ }
1450
+ return react_1.default.createElement(Text, { color: theme_1.THEME.dim }, '\n \u27F3 Discovering ensembles...');
1451
+ }
1452
+ if (state.ensembles.length > 0) {
1453
+ const ensLines = [
1454
+ react_1.default.createElement(Text, { key: 'eh', bold: true, color: theme_1.THEME.text }, `${state.ensembles.length} ensemble${state.ensembles.length !== 1 ? 's' : ''} running:`),
1455
+ ];
1456
+ for (const ens of state.ensembles) {
1457
+ ensLines.push('\n');
1458
+ ensLines.push(react_1.default.createElement(Text, { key: ens.name, color: theme_1.THEME.textMuted }, ` ${ens.name} (${ens.playerCount} player${ens.playerCount !== 1 ? 's' : ''})${ens.hasConductor ? ' \u2605' : ''}`));
1459
+ }
1460
+ ensLines.push('\n\n');
1461
+ ensLines.push(react_1.default.createElement(Text, { key: 'hint', color: theme_1.THEME.dim }, ' Type /ensemble <name> to connect, or /ensemble to browse'));
1462
+ return react_1.default.createElement(Text, null, ...ensLines);
1463
+ }
1464
+ // No ensembles running (single Text, 1 Yoga node)
1465
+ return react_1.default.createElement(Text, null, '\n', react_1.default.createElement(Text, { bold: true, color: theme_1.THEME.accent }, ' Getting Started'), '\n', '\n', react_1.default.createElement(Text, { color: theme_1.THEME.text }, ' No ensembles are running.'), '\n', '\n', react_1.default.createElement(Text, { color: theme_1.THEME.text }, ' Create an ensemble:'), '\n', react_1.default.createElement(Text, { color: theme_1.THEME.accent }, ' /ensemble → + Create new ensemble'), '\n', '\n', react_1.default.createElement(Text, { color: theme_1.THEME.text }, ' Or load a lineup:'), '\n', react_1.default.createElement(Text, { color: theme_1.THEME.accent }, ' /lineup load <file.yml>'), '\n', '\n', react_1.default.createElement(Text, { color: theme_1.THEME.dim }, ' The TUI will auto-detect ensembles as they start.'), '\n', react_1.default.createElement(Text, { color: theme_1.THEME.dim }, ' Type /help for all available commands.'));
1466
+ }
1467
+ // ── Notification expiry tick (#306) ──
1468
+ // A single interval keeps the notifications stack fresh — when any
1469
+ // notification exists, bump the tick counter every 500ms so the render
1470
+ // pass re-evaluates `expiresAt > Date.now()` and drops expired entries
1471
+ // from view. Auto-stops (cleared to 0) when the stack empties, avoiding
1472
+ // a background timer when there's nothing to watch. Cheap — one integer
1473
+ // diff per tick, and only while notifications are live.
1474
+ (0, react_1.useEffect)(() => {
1475
+ if (state.notifications.length === 0)
1476
+ return undefined;
1477
+ const id = setInterval(() => {
1478
+ dispatch({ type: 'NOTIFICATION_TICK' });
1479
+ }, 500);
1480
+ return () => clearInterval(id);
1481
+ }, [state.notifications.length]);
1482
+ // ── Static items — rendered once to stdout, become native terminal scrollback ──
1483
+ const { Static } = (0, ink_context_1.useInk)();
1484
+ // Layout: header (2 lines) + content (variable) + footer (dynamic)
1485
+ // Content height is calculated to guarantee footer is always visible.
1486
+ // When command palette is visible, footer grows to accommodate palette items.
1487
+ const paletteLines = (state.paletteVisible && filteredPaletteCommands.length > 0)
1488
+ ? Math.min(filteredPaletteCommands.length, 6) + (filteredPaletteCommands.length > 6 ? 2 : 0) // items + scroll indicators
1489
+ : 0;
1490
+ // #306: Notifications stack lives below the palette. Each live notification
1491
+ // takes one line; when the stack is empty, this contributes zero to the
1492
+ // footer so the main content area gets the full terminal height back.
1493
+ const now = Date.now();
1494
+ const notificationLines = state.notifications.filter(n => n.expiresAt > now).length;
1495
+ // Pinned confirmation lines — persist exactly as long as the state does,
1496
+ // no TTL. Each active confirmation state contributes one line above the
1497
+ // notifications stack. Keeps the y/N prompt anchored below the input so
1498
+ // it can't scroll away under new messages.
1499
+ const confirmationLines = countPinnedConfirmationLines(state);
1500
+ // #306 follow-up: Pinned paused/held tip — 1 row when an ensemble is
1501
+ // paused or held (or both), 0 otherwise. Same accounting pattern as
1502
+ // confirmationLines so the live content area reclaims the row when the
1503
+ // tip auto-clears on state change.
1504
+ const tipLines = countPinnedTipLines(state);
1505
+ // #306: Hide the chat prompt on the home view. Home is a wizard/picker
1506
+ // (arrow keys + Enter), not a chat target — there is no ensemble to talk
1507
+ // to, and a visible input box double-fires Enter (HomeView's own useInput
1508
+ // selects the row, PromptArea's useInput submits the empty buffer). The
1509
+ // Splash phase already follows this pattern; home now mirrors it. When
1510
+ // hidden we drop 2 lines from FOOTER_LINES (PromptArea row + the second
1511
+ // divider) so the live content area reclaims that space.
1512
+ const hidePrompt = isHomeView(state);
1513
+ const promptFooterLines = hidePrompt ? 0 : 2; // PromptArea + bottom divider
1514
+ const FOOTER_LINES = 2 + promptFooterLines + paletteLines + confirmationLines + tipLines + notificationLines; // StatusBar + divider + (PromptArea + bottom divider when shown) + palette + pinned confirmations + paused/held tip + notifications
1515
+ const contentHeight = Math.max(3, termRows - 1 - FOOTER_LINES);
1516
+ // Splash phase — full screen, no chrome (title/status/prompt hidden)
1517
+ if (state.phase === 'splash') {
1518
+ return react_1.default.createElement(Box, { flexDirection: 'column', height: termRows - 1, overflow: 'hidden' }, renderLiveContent());
1519
+ }
1520
+ // Root layout: <Static> items above, then live area constrained to terminal height
1521
+ return react_1.default.createElement(react_1.default.Fragment, null,
1522
+ // Static items — rendered once to stdout, become native terminal scrollback
1523
+ react_1.default.createElement(Static, { items: state.staticItems, children: (item) => {
1524
+ // Rich rendering for messages — header + indented body (matches live ConversationStream)
1525
+ if (item.type === 'message' && item.msgDirection) {
1526
+ const cols = process.stdout.columns || 80;
1527
+ const bodyWidth = Math.max(20, cols - 4);
1528
+ const wrapped = (0, format_2.wordWrap)(item.content, bodyWidth);
1529
+ if (item.msgDirection === 'out') {
1530
+ // Outbound: ♩ first line, then indented continuation (matches live)
1531
+ const firstLine = wrapped[0] || '';
1532
+ const pad = ' '.repeat(Math.max(0, cols - 2 - 3 - firstLine.length));
1533
+ const contLines = wrapped.slice(1).map(l => ` ${l}`.padEnd(cols - 2)).join('\n');
1534
+ const children = [
1535
+ react_1.default.createElement(Text, { backgroundColor: theme_1.THEME.inputBg, color: theme_1.THEME.accent, bold: true }, ' \u2669 '),
1536
+ react_1.default.createElement(Text, { backgroundColor: theme_1.THEME.inputBg, color: theme_1.THEME.text }, firstLine),
1537
+ react_1.default.createElement(Text, { backgroundColor: theme_1.THEME.inputBg, color: theme_1.THEME.dim }, pad),
1538
+ ];
1539
+ if (contLines) {
1540
+ children.push('\n');
1541
+ children.push(react_1.default.createElement(Text, { backgroundColor: theme_1.THEME.inputBg, color: theme_1.THEME.text }, contLines));
1542
+ }
1543
+ return react_1.default.createElement(Text, { key: item.id }, ...children);
1544
+ }
1545
+ else {
1546
+ // Inbound: header + 3-space indent body
1547
+ const isThirdParty = item.msgThirdParty;
1548
+ const headerLabel = item.msgRouteLabel || item.msgSender || '';
1549
+ const headerPrefix = isThirdParty ? ' ' : ' \u2190 ';
1550
+ const headerColor = isThirdParty ? theme_1.THEME.dim : theme_1.THEME.accent;
1551
+ const bodyColor = isThirdParty ? theme_1.THEME.textMuted : theme_1.THEME.text;
1552
+ const bodyLines = wrapped.map(l => ` ${l}`).join('\n');
1553
+ return react_1.default.createElement(Text, { key: item.id }, react_1.default.createElement(Text, { color: theme_1.THEME.dim }, headerPrefix), react_1.default.createElement(Text, { color: headerColor }, headerLabel), react_1.default.createElement(Text, { color: theme_1.THEME.dim }, ` ${item.msgTime || ''}`), '\n', react_1.default.createElement(Text, { color: bodyColor }, bodyLines));
1554
+ }
1555
+ }
1556
+ // Fallback for non-message items
1557
+ return react_1.default.createElement(Text, { key: item.id, color: staticItemColor(item) }, ` ${item.content}`);
1558
+ } }),
1559
+ // Live area — height constrained to termRows-1
1560
+ react_1.default.createElement(Box, { flexDirection: 'column', height: termRows - 1, overflow: 'hidden' },
1561
+ // Content area — full height above footer
1562
+ react_1.default.createElement(Box, { flexDirection: 'column', height: contentHeight, overflow: 'hidden' },
1563
+ // Live content area
1564
+ renderLiveContent()),
1565
+ // ── Footer (fixed height, always visible) ──
1566
+ // Status bar (1 Text node)
1567
+ react_1.default.createElement(StatusBar_1.StatusBar, {
1568
+ ensemble: state.activeEnsemble,
1569
+ players: state.players,
1570
+ playersLoaded: state.playersLoaded,
1571
+ scheduleCount: state.schedules.length,
1572
+ connected: true,
1573
+ ensemblePaused: state.ensemblePaused,
1574
+ ensembleHeld: state.ensembleHeld,
1575
+ }),
1576
+ // Bottom divider (1 Text node, no Box wrapper)
1577
+ react_1.default.createElement(Text, { color: theme_1.THEME.border }, ` ${dividerLine} `),
1578
+ // Prompt area + bottom divider — hidden on the home view (#306).
1579
+ // Home is a picker, not a chat target; the input would either eat keys
1580
+ // or double-fire Enter against HomeView's own useInput. Mirrors the
1581
+ // Splash phase, which renders no prompt at all.
1582
+ hidePrompt
1583
+ ? null
1584
+ : react_1.default.createElement(PromptArea_1.PromptArea, {
1585
+ hints: promptHints,
1586
+ onSubmit: handleSubmit,
1587
+ disabled: (state.phase !== 'main' && state.phase !== 'chat') || !!state.confirmingStop || !!state.confirmingDisband || !!state.confirmingEnsembleDestroy || !!state.confirmingLineup || state.pickerVisible || state.statusOverlay || !!state.overlay,
1588
+ commandNames: commandNamesList,
1589
+ playerNames: playerNamesList,
1590
+ initialHistory: cmdHistory,
1591
+ onHistoryUpdate: handleHistoryUpdate,
1592
+ onInputChange: handleInputChange,
1593
+ paletteVisible: state.paletteVisible,
1594
+ onPaletteToggle: handlePaletteToggle,
1595
+ onPaletteUp: handlePaletteUp,
1596
+ onPaletteDown: handlePaletteDown,
1597
+ onPaletteSelect: handlePaletteSelect,
1598
+ inputRef: promptRef,
1599
+ }),
1600
+ // Bottom divider (1 Text node) — also hidden when the prompt is hidden
1601
+ // so the footer accounting in FOOTER_LINES stays consistent.
1602
+ hidePrompt
1603
+ ? null
1604
+ : react_1.default.createElement(Text, { color: theme_1.THEME.border }, ` ${dividerLine} `),
1605
+ // Command palette (1 Text node when visible)
1606
+ state.paletteVisible && filteredPaletteCommands.length > 0
1607
+ ? react_1.default.createElement(CommandPalette_1.CommandPalette, {
1608
+ commands: filteredPaletteCommands,
1609
+ selectedIndex: clampedPaletteIndex,
1610
+ // Display prefix mirrors what the user's input would become on select.
1611
+ prefix: paletteCtx?.replacePrefix ?? '/',
1612
+ })
1613
+ : null,
1614
+ // Pinned confirmation prompts — sit directly below the prompt area,
1615
+ // above the notifications stack. Unlike notifications, these have no
1616
+ // TTL and persist exactly as long as the corresponding `confirming*`
1617
+ // state field is set. Keeps the y/N (or typed-name) prompt visible
1618
+ // even when new chat messages are flooding the scroll-up history.
1619
+ renderPinnedConfirmations(state, Box, Text),
1620
+ // #306 follow-up: paused/held informational tip. Dim color so it
1621
+ // sits behind the warning-yellow confirmations and red-error
1622
+ // notifications visually. Auto-clears on state change.
1623
+ renderPinnedTip(state, Box, Text),
1624
+ // #306: Bottom-pinned notifications — errors/warnings stay visible below
1625
+ // the prompt until they TTL out (8s for errors, 5s otherwise) or the user
1626
+ // hits Esc. Filters by `expiresAt` every render; the notificationTick
1627
+ // counter forces periodic re-renders while entries are live.
1628
+ renderNotifications(state.notifications, Box, Text))); // closes Fragment
1629
+ }
1630
+ /**
1631
+ * #306: Render the bottom-pinned notification stack. Kept as a free function
1632
+ * (not a component) so the caller composes Ink primitives directly — the
1633
+ * App's render tree is already heavy with createElement calls, and a
1634
+ * dedicated component for 10 lines of JSX would add a layout boundary for
1635
+ * no gain.
1636
+ */
1637
+ function renderNotifications(notifications, Box, Text) {
1638
+ const now = Date.now();
1639
+ const live = notifications.filter(n => n.expiresAt > now);
1640
+ if (live.length === 0)
1641
+ return null;
1642
+ return react_1.default.createElement(Box, { flexDirection: 'column', paddingLeft: 1, paddingRight: 1 }, ...live.map(n => {
1643
+ const icon = n.kind === 'error' ? '✗'
1644
+ : n.kind === 'warn' ? '⚠'
1645
+ : 'ⓘ';
1646
+ const color = n.kind === 'error' ? theme_1.THEME.error
1647
+ : n.kind === 'warn' ? theme_1.THEME.warning
1648
+ : theme_1.THEME.accent;
1649
+ return react_1.default.createElement(Text, { key: `notif-${n.id}`, color }, `${icon} ${stripLeadingIcon(n.content)}`);
1650
+ }));
1651
+ }
1652
+ /**
1653
+ * #306: Strip a leading kind-icon (`✗ `, `⚠ `, `ⓘ `) from notification
1654
+ * content. Defensive: many call sites historically prepended the icon into
1655
+ * the message string, and the renderer also prepends a kind-based icon —
1656
+ * without this normalization the user sees the icon twice (e.g.
1657
+ * `✗ ✗ Cannot destroy the conductor …`). Exported for unit testing.
1658
+ */
1659
+ function stripLeadingIcon(content) {
1660
+ return content.replace(/^[✗⚠ⓘ]\s+/u, '');
1661
+ }
1662
+ /**
1663
+ * #306: True when the TUI is on the home picker view — `phase === 'main'`
1664
+ * AND `view === 'home'`. The chat input has no target on this view (the
1665
+ * ensemble has not been entered yet), and HomeView owns Enter to navigate
1666
+ * its own row list. Render guard for PromptArea + the second divider so
1667
+ * the input cannot double-fire alongside HomeView's own `useInput`.
1668
+ *
1669
+ * Pure function, exported so tests can pin the guard's logic without
1670
+ * standing up an Ink render. Mirrors the splash-phase bypass pattern.
1671
+ */
1672
+ function isHomeView(state) {
1673
+ return state.phase === 'main' && state.view === 'home';
1674
+ }
1675
+ /**
1676
+ * #306: Build the pinned-confirmation line(s) for the current state. Returns
1677
+ * an array (possibly empty) of `{ key, text }` entries — one per active
1678
+ * `confirming*` state field. Rendered above the notifications stack by
1679
+ * `renderPinnedConfirmations` and sized by `countPinnedConfirmationLines`
1680
+ * so `FOOTER_LINES` reserves terminal rows correctly.
1681
+ *
1682
+ * Pure function, exported for unit testing — no Ink imports, no dispatch.
1683
+ * The render helper below is the only caller that wraps these in Text nodes.
1684
+ */
1685
+ function pinnedConfirmationLines(state) {
1686
+ const out = [];
1687
+ if (state.confirmingStop) {
1688
+ const reason = state.confirmingStopReason ? ` Reason: ${state.confirmingStopReason}.` : '';
1689
+ out.push({
1690
+ key: 'confirm-stop',
1691
+ text: `⚠ Destroy ${state.confirmingStop}? Press y to confirm, n to cancel.${reason}`,
1692
+ });
1693
+ }
1694
+ if (state.confirmingDisband) {
1695
+ out.push({
1696
+ key: 'confirm-disband',
1697
+ text: `⚠ Disband ensemble "${state.confirmingDisband}"? All sessions will be terminated. Press y to confirm, n to cancel.`,
1698
+ });
1699
+ }
1700
+ if (state.confirmingEnsembleDestroy) {
1701
+ // Typed-name gate — the detailed input UX lives in the full-screen
1702
+ // modal; this pinned reminder mirrors the modal's question so the
1703
+ // bottom of the screen still answers "what am I being asked?" at a
1704
+ // glance. Shown even while the modal is up for layout consistency
1705
+ // with the other confirmation states.
1706
+ out.push({
1707
+ key: 'confirm-ensemble-destroy',
1708
+ text: `⚠ Destroy ensemble "${state.confirmingEnsembleDestroy.ensemble}"? Type the ensemble name to confirm, Esc to cancel.`,
1709
+ });
1710
+ }
1711
+ if (state.confirmingLineup) {
1712
+ out.push({
1713
+ key: 'confirm-lineup',
1714
+ text: `⚠ ${state.confirmingLineup.summary}? Press y to confirm, n to cancel.`,
1715
+ });
1716
+ }
1717
+ return out;
1718
+ }
1719
+ /**
1720
+ * #306: Count of pinned confirmation lines — one per active `confirming*`
1721
+ * state field. Consumed by the `FOOTER_LINES` reservation so the live
1722
+ * content area shrinks when a confirmation is active, keeping the pinned
1723
+ * prompt on-screen.
1724
+ */
1725
+ function countPinnedConfirmationLines(state) {
1726
+ return pinnedConfirmationLines(state).length;
1727
+ }
1728
+ /**
1729
+ * #306 follow-up: Build the pinned tip line for the current paused/held
1730
+ * state. Returns `null` when neither flag is set — no tip should render.
1731
+ *
1732
+ * The tip appears below the input prompt in a dim color (informational,
1733
+ * not an error or warning) and tells the user which slash commands they
1734
+ * need to fully resume the ensemble. `/load_lineup` flips both flags;
1735
+ * `/play` clears only paused; `/go` clears only held — without this
1736
+ * tip users would unpause an ensemble and stare at frozen players.
1737
+ *
1738
+ * Pure function, exported for unit testing — no Ink imports.
1739
+ */
1740
+ function pinnedTipLine(state) {
1741
+ // Hide tips on the home view — there's no ensemble context to act on,
1742
+ // and the prompt itself is hidden there too (see `hidePrompt` in App).
1743
+ if (!state.activeEnsemble)
1744
+ return null;
1745
+ if (state.ensemblePaused && state.ensembleHeld) {
1746
+ return {
1747
+ key: 'tip-paused-held',
1748
+ text: 'Tip: Type /play to unpause + /go to release held players.',
1749
+ };
1750
+ }
1751
+ if (state.ensemblePaused) {
1752
+ return { key: 'tip-paused', text: 'Tip: Type /play to resume.' };
1753
+ }
1754
+ if (state.ensembleHeld) {
1755
+ return { key: 'tip-held', text: 'Tip: Type /go to release held players.' };
1756
+ }
1757
+ return null;
1758
+ }
1759
+ /**
1760
+ * Count of pinned tip lines (0 or 1). Mirrors
1761
+ * {@link countPinnedConfirmationLines} so `FOOTER_LINES` can reserve a
1762
+ * row for the tip without re-evaluating the state shape twice.
1763
+ */
1764
+ function countPinnedTipLines(state) {
1765
+ return pinnedTipLine(state) ? 1 : 0;
1766
+ }
1767
+ /**
1768
+ * #306: Render the pinned confirmation prompts as Ink Text nodes. Kept
1769
+ * free-function (mirroring `renderNotifications`) because the App's root
1770
+ * render tree is already `createElement`-heavy and a dedicated component
1771
+ * would add a layout boundary for zero gain. Uses THEME.warning so the
1772
+ * user's eye is drawn away from the chat scroll-up area.
1773
+ */
1774
+ function renderPinnedConfirmations(state, Box, Text) {
1775
+ const lines = pinnedConfirmationLines(state);
1776
+ if (lines.length === 0)
1777
+ return null;
1778
+ return react_1.default.createElement(Box, { flexDirection: 'column', paddingLeft: 1, paddingRight: 1 }, ...lines.map(line => react_1.default.createElement(Text, { key: line.key, color: theme_1.THEME.warning, bold: true }, line.text)));
1779
+ }
1780
+ /**
1781
+ * #306 follow-up: Render the pinned paused/held tip below the input. Dim
1782
+ * (THEME.dim) so it reads as informational and doesn't compete visually
1783
+ * with the yellow confirmation prompts above or red notifications below.
1784
+ * Auto-clears when the state changes — no user dismissal needed.
1785
+ */
1786
+ function renderPinnedTip(state, Box, Text) {
1787
+ const tip = pinnedTipLine(state);
1788
+ if (!tip)
1789
+ return null;
1790
+ return react_1.default.createElement(Box, { flexDirection: 'column', paddingLeft: 1, paddingRight: 1 }, react_1.default.createElement(Text, { key: tip.key, color: theme_1.THEME.dim }, tip.text));
1791
+ }