tumwater 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (361) hide show
  1. package/README.md +74 -108
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +24 -0
  4. package/dist/src/agent/agent-backend.js +2 -2
  5. package/dist/src/agent/backend-traits.js +60 -0
  6. package/dist/src/agent/claude/claude-account.js +61 -8
  7. package/dist/src/agent/claude/claude-args.js +12 -15
  8. package/dist/src/agent/claude/claude-backend.js +51 -41
  9. package/dist/src/agent/claude/claude-env.js +7 -3
  10. package/dist/src/agent/claude/claude-stream.js +7 -16
  11. package/dist/src/agent/image-input.js +107 -0
  12. package/dist/src/agent/notes-shim.js +41 -0
  13. package/dist/src/agent/opencode/opencode-args.js +61 -0
  14. package/dist/src/agent/opencode/opencode-backend.js +255 -0
  15. package/dist/src/agent/opencode/opencode-catalog.js +184 -0
  16. package/dist/src/agent/opencode/opencode-config.js +188 -0
  17. package/dist/src/agent/opencode/opencode-env.js +36 -0
  18. package/dist/src/agent/opencode/opencode-session-id.js +37 -0
  19. package/dist/src/agent/opencode/opencode-stream.js +278 -0
  20. package/dist/src/agent/role-notes-write.js +16 -32
  21. package/dist/src/agent/run-agent.js +95 -18
  22. package/dist/src/agent/session-ledger.js +57 -1
  23. package/dist/src/backlog/backlog-archive.js +296 -0
  24. package/dist/src/backlog/backlog-cli.js +573 -0
  25. package/dist/src/backlog/backlog-commit.js +31 -0
  26. package/dist/src/backlog/backlog-eligibility.js +357 -33
  27. package/dist/src/backlog/backlog-md.js +98 -18
  28. package/dist/src/backlog/backlog-render.js +62 -11
  29. package/dist/src/backlog/backlog-structure.js +261 -58
  30. package/dist/src/backlog/backlog-write.js +197 -319
  31. package/dist/src/backlog/backlog.js +113 -16
  32. package/dist/src/backlog/question-answer.js +339 -0
  33. package/dist/src/baseline/main-baseline.js +75 -88
  34. package/dist/src/baseline/main-red.js +38 -30
  35. package/dist/src/brief/brief-edit.js +43 -0
  36. package/dist/src/{brief.js → brief/brief.js} +66 -10
  37. package/dist/src/budget/budget.js +32 -15
  38. package/dist/src/budget/fallback-breaker.js +8 -7
  39. package/dist/src/build/build-check-detect.js +8 -7
  40. package/dist/src/build/build-check-events.js +68 -22
  41. package/dist/src/build/build-check-report.js +22 -23
  42. package/dist/src/build/build-check-scoped.js +51 -62
  43. package/dist/src/build/build-check.js +15 -14
  44. package/dist/src/build/build-info.js +2 -1
  45. package/dist/src/build/build-stage.js +23 -22
  46. package/dist/src/build/dep-install.js +22 -21
  47. package/dist/src/build/host-sleep.js +1 -1
  48. package/dist/src/change/change-data.js +25 -14
  49. package/dist/src/change/change-render.js +11 -10
  50. package/dist/src/cli/brief-command.js +58 -0
  51. package/dist/src/cli/cli-args.js +25 -5
  52. package/dist/src/cli/cli-command-args.js +46 -14
  53. package/dist/src/cli/cli-editor.js +64 -0
  54. package/dist/src/cli/cli-flag-specs.js +54 -16
  55. package/dist/src/cli/cli-marker-commands.js +13 -10
  56. package/dist/src/cli/cli-output.js +9 -0
  57. package/dist/src/cli/cli-query-commands.js +458 -49
  58. package/dist/src/cli/cli-repo-gate.js +1 -1
  59. package/dist/src/cli/cli-run.js +208 -59
  60. package/dist/src/cli/completion.js +207 -0
  61. package/dist/src/cli/config-commands.js +14 -11
  62. package/dist/src/cli/help.js +305 -110
  63. package/dist/src/cli/log-commands.js +127 -21
  64. package/dist/src/cli/notify-command.js +33 -18
  65. package/dist/src/cli/question-commands.js +29 -224
  66. package/dist/src/cli.js +121 -40
  67. package/dist/src/collections.js +66 -16
  68. package/dist/src/concurrency/check-permit.js +16 -17
  69. package/dist/src/concurrency/lock.js +6 -5
  70. package/dist/src/concurrency/provider-lease.js +69 -0
  71. package/dist/src/concurrency/semaphore.js +258 -36
  72. package/dist/src/config/config-aliases.js +106 -0
  73. package/dist/src/config/config-editable-keys.js +3 -3
  74. package/dist/src/config/config-fallbacks.js +379 -0
  75. package/dist/src/config/config-field-checks.js +19 -10
  76. package/dist/src/config/config-keys.js +160 -0
  77. package/dist/src/config/config-live.js +64 -12
  78. package/dist/src/config/config-schema.js +34 -22
  79. package/dist/src/config/config-validation.js +132 -48
  80. package/dist/src/config/config-views.js +119 -337
  81. package/dist/src/config/config-write.js +138 -177
  82. package/dist/src/config/config.js +60 -49
  83. package/dist/src/config/model-selector.js +28 -2
  84. package/dist/src/doctor/doctor-backlog.js +546 -49
  85. package/dist/src/doctor/doctor-checks.js +534 -56
  86. package/dist/src/doctor/doctor-holds.js +193 -53
  87. package/dist/src/doctor/doctor-install.js +30 -0
  88. package/dist/src/doctor/doctor-launch-services.js +2 -2
  89. package/dist/src/doctor/doctor-model-checks.js +549 -157
  90. package/dist/src/doctor/doctor-notify.js +27 -0
  91. package/dist/src/doctor/doctor-orphans.js +10 -9
  92. package/dist/src/doctor/doctor-render.js +36 -9
  93. package/dist/src/doctor/doctor.js +80 -22
  94. package/dist/src/doctor/image-probe.js +244 -0
  95. package/dist/src/doctor/probe.js +64 -0
  96. package/dist/src/events/event-format.js +226 -102
  97. package/dist/src/events/event-labels.js +116 -0
  98. package/dist/src/events/event-legacy.js +140 -0
  99. package/dist/src/events/event-read.js +58 -22
  100. package/dist/src/events/event-window.js +27 -7
  101. package/dist/src/events/events.js +18 -14
  102. package/dist/src/events/notify.js +137 -38
  103. package/dist/src/failure/failure-cluster.js +12 -9
  104. package/dist/src/failure/failure-data.js +96 -48
  105. package/dist/src/failure/failure-render.js +69 -58
  106. package/dist/src/failure/failure-state-change.js +33 -26
  107. package/dist/src/failure/time-spend.js +79 -59
  108. package/dist/src/files/file-queue.js +16 -4
  109. package/dist/src/files/files.js +18 -6
  110. package/dist/src/files/json-object.js +18 -3
  111. package/dist/src/files/tail.js +1 -1
  112. package/dist/src/gates/bootstrap-gates.js +5 -4
  113. package/dist/src/gates/budget-gates.js +250 -34
  114. package/dist/src/gates/disk-gate.js +18 -18
  115. package/dist/src/gates/gate-polls.js +117 -69
  116. package/dist/src/gates/maintenance-quota.js +29 -29
  117. package/dist/src/gates/pause-gates.js +11 -11
  118. package/dist/src/gates/readiness.js +2 -1
  119. package/dist/src/gates/role-cap-gates.js +16 -21
  120. package/dist/src/gates/startup-gate.js +75 -31
  121. package/dist/src/gates/streak-gate.js +18 -16
  122. package/dist/src/git/commit-message.js +12 -5
  123. package/dist/src/git/git-diff.js +16 -16
  124. package/dist/src/git/git-run.js +8 -8
  125. package/dist/src/git/git.js +86 -55
  126. package/dist/src/git/slots-state.js +14 -5
  127. package/dist/src/git/worktree-pool.js +25 -18
  128. package/dist/src/git/worktree-use.js +15 -14
  129. package/dist/src/git/worktree.js +18 -18
  130. package/dist/src/gui/gui-args.js +10 -14
  131. package/dist/src/gui/gui-backlog-commands.js +273 -0
  132. package/dist/src/gui/gui-command.js +1 -1
  133. package/dist/src/gui/gui-config-commands.js +440 -0
  134. package/dist/src/gui/gui-endpoint-commands.js +16 -669
  135. package/dist/src/gui/gui-endpoint-common.js +49 -0
  136. package/dist/src/gui/gui-endpoints.js +116 -56
  137. package/dist/src/gui/gui-operator-commands.js +145 -0
  138. package/dist/src/gui/gui-prompt-commands.js +234 -0
  139. package/dist/src/gui/gui-server.js +28 -8
  140. package/dist/src/gui/gui-team-commands.js +164 -0
  141. package/dist/src/gui/http-body.js +17 -1
  142. package/dist/src/history/history-data.js +194 -98
  143. package/dist/src/history/history-render.js +76 -0
  144. package/dist/src/history/history.js +37 -58
  145. package/dist/src/inbox/inbox-attachments.js +75 -14
  146. package/dist/src/inbox/inbox-cancel.js +7 -5
  147. package/dist/src/inbox/inbox-edit.js +1 -1
  148. package/dist/src/inbox/inbox-submit.js +44 -4
  149. package/dist/src/inbox/inbox-top.js +83 -0
  150. package/dist/src/inbox/inbox.js +8 -2
  151. package/dist/src/inbox/pending-prompt.js +1 -1
  152. package/dist/src/inbox/prompt-commands.js +170 -80
  153. package/dist/src/init/init-templates.js +16 -14
  154. package/dist/src/init/init.js +12 -11
  155. package/dist/src/loop/leftover.js +28 -27
  156. package/dist/src/loop/loop-fallback.js +2 -2
  157. package/dist/src/loop/loop-pi.js +106 -52
  158. package/dist/src/loop/loop-recovery.js +15 -15
  159. package/dist/src/loop/loop-state.js +139 -13
  160. package/dist/src/loop/loop-tick.js +194 -86
  161. package/dist/src/loop/loop.js +72 -36
  162. package/dist/src/loop/model-fallback.js +3 -2
  163. package/dist/src/loop/revision.js +16 -7
  164. package/dist/src/merge/backlog-conflicts.js +155 -0
  165. package/dist/src/{landing/landing-batch.js → merge/merge-batch.js} +90 -89
  166. package/dist/src/{landing/landing-check-failures.js → merge/merge-check-failures.js} +87 -41
  167. package/dist/src/{landing/landing-core.js → merge/merge-core.js} +119 -95
  168. package/dist/src/{landing/landing-diff.js → merge/merge-diff.js} +1 -1
  169. package/dist/src/merge/merge-drain.js +212 -0
  170. package/dist/src/{landing/landing-git.js → merge/merge-git.js} +94 -21
  171. package/dist/src/merge/merge-pipeline.js +99 -0
  172. package/dist/src/{landing/landing-questions.js → merge/merge-questions.js} +9 -6
  173. package/dist/src/merge/merge-queue-render.js +75 -0
  174. package/dist/src/{landing/landing-queue.js → merge/merge-queue.js} +95 -36
  175. package/dist/src/merge/merge-slot.js +340 -0
  176. package/dist/src/{landing/landing-stack.js → merge/merge-stack.js} +66 -62
  177. package/dist/src/{landing/landing-merge.js → merge/merge-to-main.js} +72 -62
  178. package/dist/src/merge/merge-verify.js +212 -0
  179. package/dist/src/notes-cli.js +97 -0
  180. package/dist/src/operator/operator-commands.js +154 -47
  181. package/dist/src/operator/operator-intent.js +87 -49
  182. package/dist/src/operator/operator-requests.js +52 -52
  183. package/dist/src/operator/retire.js +62 -27
  184. package/dist/src/operator/revert.js +71 -0
  185. package/dist/src/orchestrator/orchestrator-launch.js +74 -14
  186. package/dist/src/orchestrator/orchestrator-scheduling.js +129 -44
  187. package/dist/src/orchestrator/orchestrator.js +158 -100
  188. package/dist/src/orchestrator/retention.js +29 -12
  189. package/dist/src/orchestrator/startup-migration-steps.js +51 -0
  190. package/dist/src/orchestrator/startup-migrations.js +40 -0
  191. package/dist/src/paths.js +128 -37
  192. package/dist/src/pi/pi-args.js +26 -3
  193. package/dist/src/pi/pi-backend.js +15 -23
  194. package/dist/src/pi/pi-bin.js +18 -12
  195. package/dist/src/pi/pi-event-line.js +17 -1
  196. package/dist/src/pi/pi-failure.js +87 -0
  197. package/dist/src/pi/pi-models.js +126 -38
  198. package/dist/src/pi/pi-run-result.js +1 -1
  199. package/dist/src/pi/pi-settings.js +86 -0
  200. package/dist/src/pi/pi-stream.js +131 -107
  201. package/dist/src/pi/pi-watchdogs.js +47 -43
  202. package/dist/src/pi-extension/bounded-output.js +20 -5
  203. package/dist/src/pi-extension/context-budget.js +3 -1
  204. package/dist/src/pi-extension/context-shake.js +54 -21
  205. package/dist/src/pi-extension/context-window.js +37 -0
  206. package/dist/src/pi-extension/full-output.js +2 -2
  207. package/dist/src/pi-extension/strip-thinking.js +80 -0
  208. package/dist/src/pi-extension/trim-system-prompt.js +38 -0
  209. package/dist/src/process/launch-services.js +11 -10
  210. package/dist/src/process/process-table.js +1 -1
  211. package/dist/src/process/process.js +7 -7
  212. package/dist/src/process/run-marker.js +29 -6
  213. package/dist/src/process/supervisor.js +47 -25
  214. package/dist/src/prompt/dialect.js +30 -10
  215. package/dist/src/prompt/gate-prompts.js +170 -67
  216. package/dist/src/prompt/principles.js +1 -0
  217. package/dist/src/prompt/prompt-followup.js +35 -20
  218. package/dist/src/prompt/prompt.js +249 -91
  219. package/dist/src/redeploy/redeploy-policy.js +10 -10
  220. package/dist/src/redeploy/redeploy.js +6 -4
  221. package/dist/src/redeploy/redeployer.js +63 -55
  222. package/dist/src/redeploy/self-reload.js +1 -1
  223. package/dist/src/report/report-data.js +264 -255
  224. package/dist/src/report/report-fold.js +353 -0
  225. package/dist/src/report/report-render.js +270 -33
  226. package/dist/src/request-timeouts.js +1 -1
  227. package/dist/src/review/known-flakes.js +34 -14
  228. package/dist/src/review/protected-edits.js +33 -0
  229. package/dist/src/review/review-followup.js +16 -4
  230. package/dist/src/review/review-precheck.js +14 -13
  231. package/dist/src/review/review-verdict.js +14 -0
  232. package/dist/src/review/review.js +81 -39
  233. package/dist/src/review/secret-leaks.js +127 -0
  234. package/dist/src/review/suite-rerun.js +4 -3
  235. package/dist/src/roles/legacy-role-ids.js +21 -0
  236. package/dist/src/roles/legacy-role-migration.js +62 -0
  237. package/dist/src/roles/loop-ids.js +19 -2
  238. package/dist/src/roles/role-catalog.js +125 -135
  239. package/dist/src/roles/role-guidance.js +64 -31
  240. package/dist/src/roles/role-labels.js +10 -0
  241. package/dist/src/roles/role-render.js +194 -18
  242. package/dist/src/roles/role-view.js +216 -17
  243. package/dist/src/roles/roles.js +18 -15
  244. package/dist/src/scheduling/backoff.js +29 -19
  245. package/dist/src/scheduling/claims.js +25 -9
  246. package/dist/src/scheduling/once-round.js +11 -11
  247. package/dist/src/scheduling/quiet-hours.js +25 -32
  248. package/dist/src/scheduling/scheduling.js +43 -24
  249. package/dist/src/scheduling/{work-landed-cache.js → work-merged-cache.js} +14 -14
  250. package/dist/src/status/status-budget.js +115 -0
  251. package/dist/src/status/status-data.js +77 -184
  252. package/dist/src/status/status-inbox.js +47 -0
  253. package/dist/src/status/status-install.js +27 -0
  254. package/dist/src/status/status-polls.js +68 -33
  255. package/dist/src/status/status-roles.js +105 -0
  256. package/dist/src/{fleet → team}/error-storm.js +10 -10
  257. package/dist/src/{fleet → team}/failure-spread.js +8 -8
  258. package/dist/src/{fleet → team}/orchestrator-info.js +86 -18
  259. package/dist/src/{fleet → team}/reclaim.js +5 -4
  260. package/dist/src/{fleet/fleet-hold.js → team/team-hold.js} +55 -21
  261. package/dist/src/{fleet/fleet-polls.js → team/team-polls.js} +116 -65
  262. package/dist/src/{fleet/fleet-state.js → team/team-state.js} +62 -37
  263. package/dist/src/text/datetime.js +42 -2
  264. package/dist/src/text/format.js +10 -1
  265. package/dist/src/text/markdown.js +1 -1
  266. package/dist/src/text/phrases.js +35 -34
  267. package/dist/src/text/suggest.js +3 -2
  268. package/dist/src/text/text.js +2 -2
  269. package/dist/src/tick/qa-coverage.js +2 -1
  270. package/dist/src/tick/stage-check.js +147 -26
  271. package/dist/src/tick/telemetry-digest.js +10 -9
  272. package/dist/src/tick/tick-apply.js +101 -72
  273. package/dist/src/tick/tick-detail-data.js +32 -18
  274. package/dist/src/tick/tick-detail.js +62 -21
  275. package/dist/src/tick/tick-finalize.js +29 -22
  276. package/dist/src/tick/tick-outcome.js +2 -1
  277. package/dist/src/tick/tick-prompt.js +94 -26
  278. package/dist/src/tick/tick-resume.js +29 -13
  279. package/dist/src/tick/tick-stage.js +91 -47
  280. package/dist/src/tick/tick-timing.js +60 -59
  281. package/dist/src/tick/tick-usage.js +53 -23
  282. package/dist/src/tick/tick-verdict.js +76 -10
  283. package/dist/src/ui/badges.js +118 -67
  284. package/dist/src/ui/gui/gui-client-boot.js +209 -30
  285. package/dist/src/ui/gui/gui-client-composer.js +102 -23
  286. package/dist/src/ui/gui/gui-client-drawer.js +330 -75
  287. package/dist/src/ui/gui/gui-client-history.js +96 -38
  288. package/dist/src/ui/gui/gui-client-labels.js +24 -0
  289. package/dist/src/ui/gui/gui-client-loops.js +94 -52
  290. package/dist/src/ui/gui/gui-client-markdown.js +1 -0
  291. package/dist/src/ui/gui/gui-client-model.js +63 -43
  292. package/dist/src/ui/gui/gui-client-operator.js +129 -39
  293. package/dist/src/ui/gui/gui-client-pending.js +36 -32
  294. package/dist/src/ui/gui/gui-client-report.js +339 -46
  295. package/dist/src/ui/gui/gui-client-settings.js +148 -14
  296. package/dist/src/ui/gui/gui-client-sound.js +2 -1
  297. package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +196 -72
  298. package/dist/src/ui/gui/gui-client.js +26 -20
  299. package/dist/src/ui/gui/gui-page.js +19 -19
  300. package/dist/src/ui/gui/gui-styles.js +12 -4
  301. package/dist/src/ui/progress-data.js +5 -3
  302. package/dist/src/ui/status-model.js +184 -122
  303. package/dist/src/ui/status-payload.js +119 -32
  304. package/dist/src/ui/status-render.js +122 -52
  305. package/dist/src/ui/team-alerts.js +341 -0
  306. package/dist/src/ui/tick-progress-model.js +14 -14
  307. package/dist/src/ui/tone.js +11 -8
  308. package/dist/src/ui/transcript-tail.js +1 -0
  309. package/dist/src/ui/transcript.js +23 -16
  310. package/dist/src/ui/tui/tui-app.js +1 -0
  311. package/dist/src/ui/tui/tui-backlog.js +85 -11
  312. package/dist/src/ui/tui/tui-editor-answer.js +97 -0
  313. package/dist/src/ui/tui/tui-editor-attach.js +107 -0
  314. package/dist/src/ui/tui/tui-editor-brief.js +107 -0
  315. package/dist/src/ui/tui/tui-editor-budget.js +155 -0
  316. package/dist/src/ui/tui/tui-editor-ctx.js +1 -0
  317. package/dist/src/ui/tui/tui-editor-entry.js +230 -0
  318. package/dist/src/ui/tui/tui-editor-interval.js +122 -0
  319. package/dist/src/ui/tui/tui-editor-notes.js +114 -0
  320. package/dist/src/ui/tui/tui-editor-pause.js +266 -0
  321. package/dist/src/ui/tui/tui-editor-prompt.js +95 -0
  322. package/dist/src/ui/tui/tui-editor-queued.js +98 -0
  323. package/dist/src/ui/tui/tui-editor-quiet.js +106 -0
  324. package/dist/src/ui/tui/tui-editor-retire.js +83 -0
  325. package/dist/src/ui/tui/tui-editor-search.js +73 -0
  326. package/dist/src/ui/tui/tui-editor-session.js +97 -0
  327. package/dist/src/ui/tui/tui-editors.js +152 -0
  328. package/dist/src/ui/tui/tui-frame.js +67 -55
  329. package/dist/src/ui/tui/tui-history.js +12 -7
  330. package/dist/src/ui/tui/tui-input.js +49 -44
  331. package/dist/src/ui/tui/tui-keymap.js +1 -0
  332. package/dist/src/ui/tui/tui-keys.js +91 -1704
  333. package/dist/src/ui/tui/tui-pane-activity.js +99 -0
  334. package/dist/src/ui/tui/tui-pane-backlog.js +224 -0
  335. package/dist/src/ui/tui/tui-pane-brief.js +30 -0
  336. package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
  337. package/dist/src/ui/tui/tui-pane-health.js +124 -0
  338. package/dist/src/ui/tui/tui-pane-history.js +316 -0
  339. package/dist/src/ui/tui/tui-pane-pending.js +74 -0
  340. package/dist/src/ui/tui/tui-pane-queued.js +143 -0
  341. package/dist/src/ui/tui/tui-pane-report.js +122 -0
  342. package/dist/src/ui/tui/tui-pane-session.js +101 -0
  343. package/dist/src/ui/tui/tui-pane-transcript.js +150 -0
  344. package/dist/src/ui/tui/tui-pane.js +66 -28
  345. package/dist/src/ui/tui/tui-panes.js +294 -0
  346. package/dist/src/ui/tui/tui.js +45 -25
  347. package/dist/src/verdict/fix-claim.js +141 -38
  348. package/dist/src/verdict/no-change.js +19 -0
  349. package/dist/src/verdict/refusal.js +92 -8
  350. package/dist/src/verdict/reply-contract.js +17 -5
  351. package/dist/src/version.js +3 -2
  352. package/package.json +4 -2
  353. package/dist/src/agent/mcp/role-notes-server.js +0 -160
  354. package/dist/src/landing/backlog-conflicts.js +0 -116
  355. package/dist/src/landing/landing-drain.js +0 -204
  356. package/dist/src/landing/landing-pipeline.js +0 -96
  357. package/dist/src/landing/landing-queue-render.js +0 -31
  358. package/dist/src/landing/landing-slot.js +0 -246
  359. package/dist/src/landing/landing-vetting.js +0 -196
  360. package/dist/src/pi-extension/role-notes.js +0 -40
  361. package/dist/src/ui/fleet-alerts.js +0 -174
@@ -1,238 +1,41 @@
1
- import { collectReport, collectReportSince } from "../../report/report-data.js";
2
- import { renderReportMarkdown, renderSinceReportMarkdown } from "../../report/report-render.js";
3
- import { REPORT_DEFAULT_DAYS, REPORT_SINCE_MAX_MS, SPARSE_WINDOW_NOTE } from "../../events/event-window.js";
4
- import { collectFailureReport, collectFailureReportSince } from "../../failure/failure-data.js";
5
- import { renderFailureMarkdown } from "../../failure/failure-render.js";
6
- import { HISTORY_DEFAULT_TICKS, HISTORY_RESULT_CLASSES, historyClass, readTickRows, readTickRowsSince, tickRowsMatchingGrep, } from "../../history/history-data.js";
7
- import { historyPaneText } from "./tui-history.js";
8
- import { readTickDetail } from "../../tick/tick-detail-data.js";
9
- import { renderTickDetail } from "../../tick/tick-detail.js";
10
- import { collectFleetChanges, collectRoleChange } from "../../change/change-data.js";
11
- import { renderFleetChange, renderRoleChange } from "../../change/change-render.js";
12
- import { readLandingQueue } from "../../landing/landing-queue.js";
13
- import { renderLandingQueue } from "../../landing/landing-queue-render.js";
14
- import { promptLengthProblem, submitPrompt } from "../../inbox/inbox-submit.js";
15
- import { listWideScope, queuedListRows, renderQueuedList } from "../../inbox/inbox.js";
16
- import { fileEntryWithBody, OPERATOR_FILE_KINDS, tryCancelEntry, tryEditEntry } from "../../backlog/backlog-write.js";
17
- import { stripEntryStamp } from "../../backlog/backlog-structure.js";
18
- import { cancelRolePrompt } from "../../inbox/inbox-cancel.js";
19
- import { durationLabel } from "../../cli/cli-args.js";
20
- import { tryAnswerQuestion, UNCLOSED_FENCE_MESSAGE } from "../../cli/question-commands.js";
21
- import { editQueuedFile, readQueuedPromptBody } from "../../inbox/inbox-edit.js";
22
- import { setDailyBudgetUsd } from "../../config/config-write.js";
23
- import { isFleetPaused, pausedRoles, pauseFleet, pauseRole, resumeFleet, resumeRole, } from "../../fleet/fleet-state.js";
24
- import { requestAbort, requestWake, cancelWakes, rolePauseMessage, roleResumeMessage, submitRolePromptAndWake, } from "../../operator/operator-intent.js";
25
- import { collapseWhitespace, errorMessage } from "../../text/text.js";
26
- import { saveRoleNote } from "../../agent/role-notes-write.js";
27
- import { roleNotesPath } from "../../paths.js";
28
- import { fleetPausePhrase, fleetResumePhrase } from "../../text/phrases.js";
29
- import { usdCap } from "../../text/format.js";
1
+ import { tickDetailStat } from "../../tick/tick-detail.js";
2
+ import { collectTeamChanges, collectRoleChange } from "../../change/change-data.js";
3
+ import { runDoctor } from "../../doctor/doctor.js";
4
+ import { commitOperatorBacklogEdit } from "../../backlog/backlog-commit.js";
5
+ import { stampedSubject } from "../../git/commit-message.js";
6
+ import { requestAbort } from "../../operator/operator-intent.js";
7
+ import { errorMessage } from "../../text/text.js";
30
8
  import { DIRECTOR_ROLE } from "../../roles/roles.js";
31
- import { baseRoleOf } from "../../roles/loop-ids.js";
32
- import { applyKey, parseBudgetInput, parseDeferInput, parsePauseInput, parseRolePauseInput, parseRoleWakeInput, parseRolePromptInput, } from "./tui-input.js";
33
- import { newPromptHistory, pushPromptHistory, recallPromptHistory, resetPromptRecall, settlePromptRecall, } from "./tui-prompt-history.js";
34
- import { labeledBacklogEntries, moveEntrySelection, stepEntryScroll } from "./tui-backlog.js";
35
- import { activityEvents, roleInspectMarkdown } from "./tui-pane.js";
36
- import { arrowDir, pageDir } from "./tui-keymap.js";
9
+ import { browsableBacklogEntries } from "./tui-backlog.js";
10
+ import { arrowDir } from "./tui-keymap.js";
11
+ import { createTuiEditors } from "./tui-editors.js";
12
+ import { createTuiPanes } from "./tui-panes.js";
37
13
  /** How long a TUI flash notice stays visible (ms). */
38
14
  const FLASH_MS = 3000;
39
- /** Every Ctrl+<key> cycle walks its own ReadonlyArray<[key, label]> table one slot at a time,
40
- * wrapping past the last back to the first. Each handler holds a key the table carries (a null
41
- * default slot included), so findIndex always hits. The null fallback in cycleLabel keeps a
42
- * would-be stale key from rendering "undefined". */
43
- function nextInCycle(table, current) {
44
- const i = table.findIndex(([key]) => key === current);
45
- return table[(i + 1) % table.length][0];
46
- }
47
- function cycleLabel(table, current) {
48
- return table.find(([key]) => key === current)?.[1] ?? null;
49
- }
50
- /** The History pane's session window cycle, Ctrl+S: the default newest-N read, then the same
51
- * trailing windows the dashboard's History view and `tumwater history --since` offer — 1h,
52
- * 6h, 1d, and the 7d cap the CLI's flag and the dashboard both stop at. The label is what the
53
- * hint line names; the length is what readTickRowsSince takes. `null` is the default read
54
- * through readTickRows, kept first so the pane opens on today's behavior. */
55
- const HISTORY_WINDOWS = [
56
- [null, "default"],
57
- [3_600_000, "1h"],
58
- [21_600_000, "6h"],
59
- [86_400_000, "1d"],
60
- [604_800_000, "7d"],
61
- ];
62
- /** The Ctrl+G scope cycle: fleet-wide (null), then each unique base role the roster names, in
63
- * roster order. An instance id (`feature-2`) folds into its base role because the scoped
64
- * collectors aggregate by base role — report-data.ts's role scope groups an instance's usage
65
- * with its siblings' — so cycling through the instance ids would relabel the same body with
66
- * names that collect it identically. `feature`/`feature-2` therefore offer one slot, and the
67
- * name the tab strip and hint show is the name the collector honors. */
68
- function scopeCycle(roleIds) {
69
- const bases = [];
70
- for (const id of roleIds) {
71
- const base = baseRoleOf(id);
72
- if (!bases.includes(base))
73
- bases.push(base);
74
- }
75
- return [null, ...bases];
76
- }
77
- /** The History pane's session outcome cycle, Ctrl+O: all ticks, then the canonical classes
78
- * `tumwater history --result` and the dashboard's History filter speak. The class list comes
79
- * from history-data.ts (HISTORY_RESULT_CLASSES) rather than a copy, so the TUI, the CLI, and
80
- * the dashboard bucket a result the same way; the class id doubles as the label. `null` is no
81
- * filter, kept first so the pane opens on today's behavior. */
82
- const HISTORY_RESULTS = [
83
- [null, "all"],
84
- ...HISTORY_RESULT_CLASSES.map((c) => [c, c]),
85
- ];
86
- /** The History pane's body for one trailing window, one outcome class, one exact-loop scope,
87
- * and one free-text search: the paneCache the Ctrl+T/Ctrl+S/Ctrl+O/Ctrl+G/`/` handlers store.
88
- * The default (null/null/null/null) read is byte-for-byte the newest-N pane that predates the
89
- * window, outcome, loop, and search cycles — readTickRows with HISTORY_DEFAULT_TICKS,
90
- * unfiltered. A trailing window reads through the same readTickRowsSince collector `tumwater
91
- * history --since` uses, a class filters through history-data.ts's canonical historyClass rule,
92
- * and the search filters through tickRowGrepHaystack — the same haystack and case-insensitive
93
- * substring rule `tumwater history --grep` and the dashboard's History search use — so the TUI,
94
- * the CLI, and the dashboard cannot disagree about the rows. The role is the exact loop id the
95
- * raw roster (and `history --role`, and the dashboard picker) names — readTickRows filters
96
- * `e.loop === role`, so an instance id is its own scope, never folded into a base role. An empty
97
- * read with no class and no search returns "" so the pane keeps its own no-ticks note instead of
98
- * a lone header; when a class or a search is active and the read returned rows, an empty
99
- * filtered body names whichever filter the CLI's precedence blames, so the pane states the
100
- * filter instead of claiming the log is empty. Otherwise the body is prefixed with the active
101
- * role/window/result/search labels and, when the read cannot prove full coverage, the same
102
- * sparse-window note every windowed render prints (SPARSE_WINDOW_NOTE). */
103
- function historyPaneCache(root, sinceMs, resultClass, role, grep) {
104
- const { rows, covered } = sinceMs === null
105
- ? { rows: readTickRows(root, HISTORY_DEFAULT_TICKS, role), covered: true }
106
- : readTickRowsSince(root, sinceMs, role);
107
- // The search runs first, exactly as `tumwater history` orders --grep ahead of --result, and
108
- // its admitted count is saved so the empty-body wording blames the filter that actually
109
- // emptied the set (`afterGrep > 0` means rows matched the pattern and the class then removed
110
- // them). The search applies history-data.ts's shared tick-grep rule (tickRowsMatchingGrep),
111
- // the same one the CLI and the dashboard search use, so the three surfaces cannot disagree.
112
- const grepped = grep === null ? rows : tickRowsMatchingGrep(rows, grep);
113
- const afterGrep = grepped.length;
114
- const filtered = resultClass === null ? grepped : grepped.filter((r) => historyClass(r.result) === resultClass);
115
- const body = historyPaneText(filtered);
116
- // null when no class is active, so the header/empty-body paths below only name a result
117
- // filter the session actually holds (cycleLabel(HISTORY_RESULTS, null) is the "all" label).
118
- const resultLabel = resultClass === null ? null : cycleLabel(HISTORY_RESULTS, resultClass);
119
- // The role first, then the window/result/search labels: the loop is the coarsest cut, and a
120
- // role-only read (no window, no class) still prints its scope line above the rows.
121
- const header = [
122
- ...(role === null ? [] : ["role: " + role]),
123
- ...(sinceMs === null ? [] : ["window: last " + (cycleLabel(HISTORY_WINDOWS, sinceMs) ?? "default")]),
124
- ...(resultLabel === null ? [] : ["result: " + resultLabel]),
125
- ...(grep === null ? [] : ["search: " + grep]),
126
- ].join("\n");
127
- if (!body) {
128
- // The class names itself only when it emptied a set grep admitted (`afterGrep > 0`) — the
129
- // CLI's condition and the dashboard's `hasRows` draw the same line — so an empty log or
130
- // window keeps the pane's own no-ticks note instead of blaming a filter that removed
131
- // nothing. A pattern that admitted nothing names itself next, with the CLI's wording. An
132
- // empty read with neither filter returns "" so the pane keeps its own note, and a role-only
133
- // empty read returns "" too (the CLI's empty-state wording does not name --role), so the
134
- // pane never grows a lone `role:` header.
135
- if (resultClass !== null && afterGrep > 0) {
136
- return header + "\nno ticks with result \"" + resultLabel + "\"";
137
- }
138
- if (grep !== null)
139
- return header + "\nno ticks matching \"" + grep + "\"";
140
- return "";
141
- }
142
- return (header ? header + "\n" : "") + body + (sinceMs !== null && !covered ? "\n" + SPARSE_WINDOW_NOTE : "");
143
- }
144
- /** The Usage and Failures panes' session window cycle, Ctrl+E: the default whole-day report,
145
- * then 1h, 6h, 1d, and the REPORT_SINCE_MAX_MS 7d cap — the trailing windows
146
- * `tumwater report --since` / `tumwater report --failures --since` collect. (The dashboard's
147
- * report tabs stop at 1d.) The label is what the hint line names; the
148
- * length is what collectReportSince / collectFailureReportSince take. `null` is the default
149
- * REPORT_DEFAULT_DAYS shape, kept first so the panes open on today's behavior. */
150
- const REPORT_WINDOWS = [
151
- [null, "default"],
152
- [3_600_000, "1h"],
153
- [21_600_000, "6h"],
154
- [86_400_000, "1d"],
155
- [REPORT_SINCE_MAX_MS, "7d"],
156
- ];
157
- /** The Usage pane's body for one window and role scope: the paneCache the Ctrl+T/Ctrl+G/Ctrl+E
158
- * handlers store. The default (null) read is byte-for-byte the day report the pane showed
159
- * before the window cycle — collectReport at REPORT_DEFAULT_DAYS; a trailing window reads
160
- * through the same collectReportSince collector `tumwater report --since` uses, so the TUI,
161
- * CLI, and dashboard cannot disagree. Both honor the Ctrl+G role scope, so the two session
162
- * cycles compose. */
163
- function usagePaneCache(root, sinceMs, scope) {
164
- if (sinceMs === null)
165
- return renderReportMarkdown(collectReport(root, REPORT_DEFAULT_DAYS, scope));
166
- return renderSinceReportMarkdown(collectReportSince(root, sinceMs, scope));
167
- }
168
- /** The Failures pane's body for one window and role scope, the digest sibling of
169
- * usagePaneCache; renderFailureMarkdown renders both the day and --since shapes. */
170
- function failuresPaneCache(root, sinceMs, scope) {
171
- if (sinceMs === null)
172
- return renderFailureMarkdown(collectFailureReport(root, REPORT_DEFAULT_DAYS, scope));
173
- return renderFailureMarkdown(collectFailureReportSince(root, sinceMs, scope));
174
- }
175
15
  /** The framework-free owner of the TUI's keypress handling (extracted from runTui, and fed
176
16
  * by ink's `useInput` through tui-keymap.ts's inkKeyToReadline adapter): the factory owns
177
- * every mutable local the keypress dispatch used to keep in runTui's closure — the prompt line,
178
- * budget-edit and role-prompt modes, the session history, the flash notice, the
179
- * view/selection/scroll state, and the line budgets render feeds back. It takes only the
180
- * seams it cannot own itself: the repo root the disk actions run against, the quit callback
181
- * (Ctrl+D), a render request callback (every branch that painted a frame), and an injectable clock
182
- * so the flash expiry is testable without waiting the real 3 s. Rendering (src/ui/tui/tui.tsx)
183
- * stays the caller: it syncs snapshot data and line budgets in, and reads the resulting state
184
- * out to compose the frame — the same data flow, one module boundary added. */
17
+ * every mutable local the keypress dispatch used to keep in runTui's closure — the prompt line
18
+ * and its modal editors (owned by the tui-editors.ts sub-factory it constructs), the view and
19
+ * pane-navigation state, the flash notice, and the line budgets render feeds back. It takes
20
+ * only the seams it cannot own itself: the repo root the disk actions run against, the quit
21
+ * callback (Ctrl+D), a render request callback (every branch that painted a frame), and an
22
+ * injectable clock so the flash expiry is testable without waiting the real 3 s. Rendering
23
+ * (src/ui/tui/tui.tsx) stays the caller: it syncs snapshot data and line budgets in, and reads
24
+ * the resulting state out to compose the frame — the same data flow, one module boundary added.
25
+ * The prompt-line editor half of this closure lives in tui-editors.ts and the pane-navigation
26
+ * half in the tui-panes.ts factory beside its per-pane modules; this file keeps the outer
27
+ * dispatch, the flash notice, and the director gate. */
185
28
  export function createTuiKeys(deps) {
186
29
  const now = deps.now ?? Date.now;
187
30
  const { root } = deps;
188
- const collectPending = deps.collectPending ?? collectFleetChanges;
31
+ const collectPending = deps.collectPending ?? collectTeamChanges;
32
+ const runDoctorSeam = deps.runDoctor ?? ((r) => runDoctor(r, undefined, undefined, "never"));
189
33
  const collectChange = deps.collectChange ?? collectRoleChange;
190
- // The project-status pane's flat entry list (plans, then bugs, then questions), read fresh —
191
- // tui-backlog.ts's one definition, shared with the render so stale-selection clamping cannot
192
- // drift.
193
- const flat = () => labeledBacklogEntries(root);
194
- let input = "";
195
- let cursor = 0;
196
- // Ctrl+B budget-edit mode on the prompt line: while set, the line edits the daily cost cap
197
- // (pre-filled with the current cap — empty when disabled) instead of a director prompt.
198
- // The previous prompt text is saved so leaving the mode restores it byte-for-byte.
199
- let budgetMode = false;
200
- let savedInput = "";
201
- let savedCursor = 0;
202
- // Ctrl+R role-prompt mode on the prompt line: while set (to the viewed loop's role id), the
203
- // line edits a prompt for that one loop instead of a director prompt. It keeps its OWN saved
204
- // pair, separate from budget mode's: the two modes are mutually exclusive (each refuses to
205
- // open while the other holds the line), and with separate pairs neither can clobber the
206
- // other's saved draft even if a guard is ever loosened.
207
- let rolePromptFor = null;
208
- let roleSavedInput = "";
209
- let roleSavedCursor = 0;
210
- // Ctrl+L defer mode on the prompt line: while set, the line edits a duration that rides
211
- // into the next delivered prompt as the same `tumwater:not-before` marker `tumwater prompt
212
- // --at` and the dashboard composer write. deferMs is a DURATION, not an epoch: submit
213
- // computes now() + deferMs, so a prompt queued later still defers by the full span. It is
214
- // session-only and one-shot: a successful submit or an empty line clears it. Its own saved
215
- // pair, mutually exclusive with the budget/role/search editors on the one prompt line.
216
- let deferMs = null;
217
- let deferMode = false;
218
- let deferSavedInput = "";
219
- let deferSavedCursor = 0;
220
- let pauseEditMode = false;
221
- let pauseEditRole = null;
222
- let pauseEditWake = false;
223
- let pauseSavedInput = "";
224
- let pauseSavedCursor = 0;
225
- // The prompt line's session history (tui-prompt-history.ts's rules): every successfully submitted
226
- // prompt — director and per-role alike — joins it, and Up/Down walk it the way readline
227
- // does. Not persisted: a TUI session starts blank, like a fresh shell.
228
- let promptHistory = newPromptHistory();
229
- // The last snapshot's cap, captured by render so the Ctrl+B handler can pre-fill without
230
- // re-reading config itself (render already polls snapshot every second).
231
- let currentCapUsd = 0;
232
- // The last snapshot's free flag: true when every model the fleet could use is unpriced,
233
- // so a cap could never bind — the Ctrl+B handler flashes a notice instead of opening
234
- // the editor (BUGS.md 2026-09-14), leaving the prompt line untouched.
235
- let currentBudgetFree = false;
34
+ const tickStat = deps.tickStat ?? tickDetailStat;
35
+ // The project-status pane's flat entry list (plans, bugs, questions, then the newest done plans
36
+ // and fixed bugs), read fresh — tui-backlog.ts's one definition, shared with the render so
37
+ // stale-selection clamping cannot drift.
38
+ const flat = () => browsableBacklogEntries(root);
236
39
  let flash = "";
237
40
  let flashUntil = 0;
238
41
  // Flash a one-line notice on the prompt line: the single owner of the message+expiry
@@ -247,277 +50,53 @@ export function createTuiKeys(deps) {
247
50
  const flashError = (err) => {
248
51
  flashMessage(`error: ${errorMessage(err)}`);
249
52
  };
250
- // The activity pane cycles: 0 = recent events, then one transcript per loop, then project
251
- // status (planned features + open bugs + open questions), then the usage report, the failure
252
- // digest, the completed-tick history, the pending-change roster, and the queued prompts —
253
- // Ctrl+T.
254
- let view = 0;
255
- // The session-scoped role the Usage and Failures panes collect for, cycled by Ctrl+G, null
256
- // for the fleet. Session-only, like the view/selection/scroll state: a fresh TUI starts
257
- // fleet-wide, and syncSnapshot clears it when the roster drops the role.
258
- let scopeRole = null;
259
- // The project-status pane's entry selection (null = list mode): the flat index of the
260
- // entry shown in full — plans first, then bugs, then questions. Cleared on every Ctrl+T,
261
- // so cycling back into the view always starts at today's heading list.
262
- let selectedEntry = null;
263
- /** The role whose notebook a second Ctrl+X would clear, null once any other key lands. */
264
- let notesClearArmed = null;
265
- let cancelArmed = null;
266
- // The Queued pane's row selection (null = list mode): the index into queuedRows of the
267
- // prompt shown in full. Cleared on every Ctrl+T, like selectedEntry, so cycling back into
268
- // the pane starts at today's list.
269
- let selectedQueued = null;
270
- // The Queued pane's cached rows, the same queuedListRows traversal the cached
271
- // renderQueuedList string was built from, so a selection addresses a row's role/position
272
- // without re-reading the queue on every frame.
273
- let queuedRows = [];
274
- // The Queued pane's in-place editor (Ctrl+E): the role and queue-file basename of the row
275
- // the prompt line is rewriting, with its own saved-draft pair exactly like the budget and
276
- // role-prompt editors. All four editors share the one prompt line and are mutually
277
- // exclusive, so no editor can clobber another's saved draft.
278
- let queuedEditFor = null;
279
- let queuedSavedInput = "";
280
- let queuedSavedCursor = 0;
281
- let answerFor = null;
282
- let answerSavedInput = "";
283
- let answerSavedCursor = 0;
284
- let entryEditFor = null;
285
- let entryEditSavedInput = "";
286
- let entryEditSavedCursor = 0;
287
- // The within-body scroll offset (line index of the window head, 0 = head) for the selected
288
- // entry's body — PgDn/PgUp in project-status entry mode. Reset whenever the selection
289
- // changes or clears, so every newly opened entry starts at its head.
290
- let entryScroll = 0;
291
- // The Markdown/row panes' (usage report, failures, history) rendered bodies, computed once
292
- // per activation: the Ctrl+T handler sets it when cycling into one and nulls it on leaving.
293
- // collectReport/collectFailureReport tail-scan events.jsonl and readTickRows reads the log,
294
- // so they must not run on every second's re-render — those only re-window this string.
295
- let paneCache = null;
296
- // Ctrl+N toggles the viewed loop's pane between its live transcript and a read-only
297
- // notebook view (its note and next tick's prompt). Like paneCache, noteCache is computed
298
- // once on entry and only re-windowed each frame; both reset when the mode changes or the
299
- // view cycles.
300
- let noteMode = false;
301
- let noteCache = null;
302
- // Ctrl+N on the History pane swaps the row list for the newest completed tick's full event
303
- // trail (renderTickDetail over readTickDetail) — the same text `tumwater tick --last` and
304
- // the dashboard's History drill-down serve. Session-only like the other pane snapshots:
305
- // computed once on entry and only re-windowed each frame.
306
- let trailMode = false;
307
- let trailCache = null;
308
- // Ctrl+N's third step: the viewed loop's collected unlanded change (the CLI's
309
- // `renderRoleChange` text). changeCache is null until the async collector resolves, and the
310
- // pane paints a loading note meanwhile; both reset on leaving the mode or cycling the view.
311
- let changeMode = false;
312
- let changeCache = null;
313
- // The within-body scroll offset (line index of the window head) shared by the two Markdown
314
- // panes and the notebook view — PgDn/PgUp there. Reset to the head whenever the view is
315
- // entered, like entryScroll.
316
- let paneScroll = 0;
317
- // The Activity pane's body-window start into the loaded event tail, or null to follow the
318
- // live tail (the newest eventBudget events). PgUp pauses follow and pages back; PgDn returns
319
- // toward the tail and re-enables follow at the bottom. Reset on every Ctrl+T view cycle, so
320
- // re-entering Activity always starts at the live tail.
321
- let activityScroll = null;
322
- // The Activity pane's session exact-loop scope (Ctrl+G), a raw roster id or null for the
323
- // fleet's full tail. Session-only like the view/scroll state; syncSnapshot clears it when the
324
- // roster drops the id. The raw ids, not scopeCycle's base roles: the feed filters by exact
325
- // loop (eventRole), so `feature-2` is its own slot.
326
- let activityRole = null;
327
- // The Activity pane's session free-text search (the `/` prompt-line editor), a
328
- // case-insensitive substring over eventMatchesGrep's type-prefixed rendered line — the same
329
- // rule `tumwater logs --grep` and the dashboard search use. null is no search; session-only
330
- // like the scope, so the next read computes with it.
331
- let activityGrep = null;
332
- // The History pane's session trailing window (Ctrl+S), a HISTORY_WINDOWS length or null for
333
- // the default newest-N read. Session-only like the view/scroll state; the next Ctrl+T into
334
- // History computes the cache with it.
335
- let historyWindow = null;
336
- // The Usage and Failures panes' session trailing window (Ctrl+E), a REPORT_WINDOWS length or
337
- // null for the default day report. Session-only like the view/scroll state; the next Ctrl+T
338
- // into either pane computes the cache with it.
339
- let reportWindow = null;
340
- // The History pane's session outcome filter (Ctrl+O), a HISTORY_RESULTS class or null for no
341
- // filter. Session-only like the window; the next Ctrl+T into History computes with it.
342
- let historyResult = null;
343
- // The History pane's session exact-loop scope (Ctrl+G), a raw roster id or null for the
344
- // fleet's full read. Session-only like the window/result; syncSnapshot clears it when the
345
- // roster drops the id. The raw ids, not scopeCycle's base roles: History filters by exact
346
- // loop (`history --role`), so `feature-2` is its own slot.
347
- let historyRole = null;
348
- // The History pane's session free-text search (the `/` prompt-line editor), a
349
- // case-insensitive substring over history-data.ts's tickRowGrepHaystack — the same rule
350
- // `tumwater history --grep` and the dashboard search use. null is no search; session-only
351
- // like the window/result/role, so the next Ctrl+T into History computes the cache with it.
352
- let historyGrep = null;
353
- // The History search editor on the prompt line: `/` enters it (only on History with an empty
354
- // line), pre-filling the applied pattern; Enter applies it, Esc restores the draft. Its own
355
- // saved-draft pair, exactly like the budget and role-prompt editors: the three share one
356
- // prompt line and are mutually exclusive, so no editor can clobber another's saved draft.
357
- let searchMode = false;
358
- // Which pane the shared `/` editor feeds: History's tick search or Activity's event search.
359
- // The target is captured when `/` opens so a roster change mid-edit cannot retarget Enter.
360
- let searchFor = "history";
361
- let searchSavedInput = "";
362
- let searchSavedCursor = 0;
363
- // The activity pane's current line budget, refreshed by every render so keypress handlers
364
- // can page within it without re-deriving the height math. An open backlog entry spends the
365
- // pane's first line on its title, so its body pages by one line less (entryBudget).
366
- let eventBudget = 0;
367
- let entryBudget = 0;
368
- let roleIds = [];
53
+ // Commit an operator's backlog edit to main and flash when the commit did not land: the
54
+ // one home for the "written but NOT committed" warning every edit, file, cancel, and
55
+ // answer arm shares.
56
+ const commitBacklogEdit = (file, subject) => {
57
+ void commitOperatorBacklogEdit(root, file, stampedSubject("operator", subject)).then((committed) => {
58
+ if (!committed)
59
+ flashMessage(`${file} written but NOT committed to main — commit it before the next fast-forward`);
60
+ });
61
+ };
62
+ // The pane-navigation state is constructed first because the prompt-line editors take its
63
+ // leaves (the queued refresh, the selection clear, the applied searches).
64
+ const panes = createTuiPanes({
65
+ root,
66
+ now,
67
+ requestRender: deps.requestRender,
68
+ flat,
69
+ flashMessage,
70
+ flashError,
71
+ commitBacklogEdit,
72
+ collectPending,
73
+ runDoctor: runDoctorSeam,
74
+ collectChange,
75
+ tickStat,
76
+ });
369
77
  // Whether the director's status row is in flight, fed by render each frame: the gate for
370
78
  // Ctrl+C's interrupt — with no director tick running, Ctrl+C flashes a notice instead of
371
79
  // writing an abort marker nothing would consume.
372
80
  let directorInFlight = false;
373
- // Leave budget-edit mode (Esc, Ctrl+B again, or Ctrl+T): restore the saved prompt text.
374
- // The recall state resets with it: the restored text is the live draft now, so the next
375
- // Up starts a fresh browse instead of resuming whatever the cap editor's line left behind.
376
- const exitBudgetMode = () => {
377
- if (!budgetMode)
378
- return;
379
- budgetMode = false;
380
- input = savedInput;
381
- cursor = savedCursor;
382
- promptHistory = resetPromptRecall(promptHistory);
383
- };
384
- // Leave role-prompt mode (Esc, Ctrl+R again, or Ctrl+T): restore the saved prompt text,
385
- // and reset the recall state with it — the saved draft was saved settled (see the Ctrl+R
386
- // handler), so resuming a browse entered in role mode would restore role-mode text onto
387
- // the director line and lose the director draft the arrows had saved.
388
- const exitRolePromptMode = () => {
389
- if (!rolePromptFor)
390
- return;
391
- rolePromptFor = null;
392
- input = roleSavedInput;
393
- cursor = roleSavedCursor;
394
- promptHistory = resetPromptRecall(promptHistory);
395
- };
396
- // Leave defer-edit mode (Esc, Ctrl+L again, or Ctrl+T): restore the saved prompt text. The
397
- // pending deferMs itself is untouched — Esc cancels the edit, not a delay already saved.
398
- const exitDeferMode = () => {
399
- if (!deferMode)
400
- return;
401
- deferMode = false;
402
- input = deferSavedInput;
403
- cursor = deferSavedCursor;
404
- promptHistory = resetPromptRecall(promptHistory);
405
- };
406
- // Leave pause-edit mode (Esc, Ctrl+F again, a written pause, or Ctrl+T): restore the draft.
407
- const exitPauseEditMode = () => {
408
- if (!pauseEditMode)
409
- return;
410
- pauseEditMode = false;
411
- pauseEditRole = null;
412
- pauseEditWake = false;
413
- input = pauseSavedInput;
414
- cursor = pauseSavedCursor;
415
- promptHistory = resetPromptRecall(promptHistory);
416
- };
417
- // Leave the backlog pane's answer editor (Esc, a recorded answer, or Ctrl+T): restore the
418
- // saved prompt text and reset the recall state, mirroring exitQueuedEdit.
419
- const exitAnswerMode = () => {
420
- if (!answerFor)
421
- return;
422
- answerFor = null;
423
- input = answerSavedInput;
424
- cursor = answerSavedCursor;
425
- promptHistory = resetPromptRecall(promptHistory);
426
- };
427
- // Leave the backlog pane's title editor (Esc, a saved edit, or Ctrl+T): the same settle
428
- // contract as exitAnswerMode.
429
- const exitEntryEdit = () => {
430
- if (!entryEditFor)
431
- return;
432
- entryEditFor = null;
433
- input = entryEditSavedInput;
434
- cursor = entryEditSavedCursor;
435
- promptHistory = resetPromptRecall(promptHistory);
436
- };
437
- // Leave the Queued pane's in-place editor (Esc, a saved edit, or Ctrl+T): restore the saved
438
- // prompt text, and reset the recall state with it — the same settle contract the other
439
- // editors honor.
440
- const exitQueuedEdit = () => {
441
- if (!queuedEditFor)
442
- return;
443
- queuedEditFor = null;
444
- input = queuedSavedInput;
445
- cursor = queuedSavedCursor;
446
- promptHistory = resetPromptRecall(promptHistory);
447
- };
448
- /** Recompute the active pane's cached Markdown after `changed` state moved, re-entering the
449
- * body at its head, and return whether the current view holds such a pane. "report" covers
450
- * the Usage/Failures panes (fed by the report window and base-role scope); "history" the
451
- * History pane (its window, outcome filter, exact-loop scope, and free-text search); "all"
452
- * either. A view that is not the named pane is left untouched, so advancing one window
453
- * redraws only the pane it feeds. The one home of the view-index→collector mapping the pane
454
- * key handlers and syncSnapshot share. */
455
- const refreshPaneCache = (changed) => {
456
- const isReport = view === roleIds.length + 2 || view === roleIds.length + 3;
457
- const isHistory = view === roleIds.length + 4;
458
- if ((changed === "report" || changed === "all") && isReport) {
459
- paneCache = view === roleIds.length + 2
460
- ? usagePaneCache(root, reportWindow, scopeRole ?? undefined)
461
- : failuresPaneCache(root, reportWindow, scopeRole ?? undefined);
462
- }
463
- else if ((changed === "history" || changed === "all") && isHistory) {
464
- paneCache = historyPaneCache(root, historyWindow, historyResult, historyRole, historyGrep);
465
- }
466
- else {
467
- return false;
468
- }
469
- paneScroll = 0;
470
- return true;
471
- };
472
- // Re-read the pane-scoped newest completed tick into trailCache for an on-screen trail
473
- // (Ctrl+N), so the trail cannot keep showing a retired loop's tick after Ctrl+G or a roster
474
- // change drops the History scope. The reader throw is caught and flashed rather than escaping
475
- // a key or sync path, the same guard the initial Ctrl+N entry uses.
476
- const refreshTrail = () => {
477
- try {
478
- const row = readTickRows(root, 1, historyRole)[0];
479
- const detail = row === undefined ? null : readTickDetail(root, row.loop, row.tick);
480
- trailCache = detail === null ? null : renderTickDetail(detail);
481
- }
482
- catch (err) {
483
- trailCache = null;
484
- flashError(err);
485
- }
486
- };
487
- // Re-read the Queued pane's rows and cached list after the queue changed (entry or cancel)
488
- // and clamp the row selection to the shortened queue — a selection past the new end lands
489
- // on the last remaining prompt, never on a stale role/position. An emptied queue clears it
490
- // back to list mode. The refreshed list and any clamped-in row share paneScroll, so the
491
- // offset resets too: a cancel that moves the selection opens the newly selected prompt at
492
- // its head. The one home of the queuedRows/paneCache pairing the Ctrl+T entry and the Ctrl+X
493
- // cancel share, so the pane's list and its selectable rows cannot drift.
494
- const refreshQueued = () => {
495
- queuedRows = queuedListRows(root, null, listWideScope(root));
496
- paneCache = renderQueuedList(queuedRows, now());
497
- selectedQueued = selectedQueued === null || queuedRows.length === 0
498
- ? null
499
- : Math.min(selectedQueued, queuedRows.length - 1);
500
- paneScroll = 0;
501
- };
502
- // Leave the shared search editor (Enter applies, Esc cancels, Ctrl+T cycles): restore the
503
- // saved prompt text, and reset the recall state with it — the same settle contract the budget
504
- // and role-prompt editors honor. The applied activityGrep/historyGrep itself persists across
505
- // view cycling, like historyWindow/historyResult.
506
- const exitHistorySearch = () => {
507
- if (!searchMode)
508
- return;
509
- searchMode = false;
510
- input = searchSavedInput;
511
- cursor = searchSavedCursor;
512
- promptHistory = resetPromptRecall(promptHistory);
513
- };
81
+ // The prompt-line editors, constructed with the pane-side leaves they call on commit/exit:
82
+ // the disk writes, the flash pair, the selections/caches they return to, and the clock.
83
+ const editors = createTuiEditors({
84
+ root,
85
+ now,
86
+ flat,
87
+ refreshQueued: panes.refreshQueued,
88
+ refreshNotes: panes.refreshNotes,
89
+ refreshBrief: panes.refreshBrief,
90
+ clearBacklogSelection: panes.clearBacklogSelection,
91
+ applyHistorySearch: panes.applyHistorySearch,
92
+ applyActivitySearch: panes.applyActivitySearch,
93
+ flashMessage,
94
+ flashError,
95
+ commitBacklogEdit,
96
+ });
514
97
  const handleKey = (str, key) => {
515
- // Every key starts disarmed; only the backlog Ctrl+X branch reads `armed` and only the
516
- // notebook Ctrl+X branch reads `armedNotes`.
517
- const armed = cancelArmed;
518
- cancelArmed = null;
519
- const armedNotes = notesClearArmed;
520
- notesClearArmed = null;
98
+ // Every key starts disarmed, whichever arm then consumes it.
99
+ panes.beginKey();
521
100
  if (key.ctrl && key.name === "d") {
522
101
  deps.quit();
523
102
  return;
@@ -527,7 +106,7 @@ export function createTuiKeys(deps) {
527
106
  // Ctrl+D): when the director row is in flight, write the same abort marker the
528
107
  // CLI's `abort --role director` does, through the shared requestAbort core so the
529
108
  // marker format and wording cannot drift; when none is, flash a notice and leave
530
- // the fleet untouched. A failed marker write flashes the reason instead of
109
+ // the team untouched. A failed marker write flashes the reason instead of
531
110
  // escaping the handler — the same contract the per-loop controls above honor.
532
111
  if (!directorInFlight) {
533
112
  flashMessage("no director task in flight");
@@ -544,1237 +123,45 @@ export function createTuiKeys(deps) {
544
123
  deps.requestRender();
545
124
  return;
546
125
  }
547
- if (key.ctrl && key.name === "b") {
548
- // Ctrl+B toggles budget-edit mode on the prompt line (mnemonic for *b*udget): entering
549
- // pre-fills the current cap (empty when disabled — empty means "no cap" on save) and
550
- // saves the prompt text; leaving restores it. Esc cancels the same way.
551
- if (rolePromptFor || searchMode || deferMode || pauseEditMode || queuedEditFor || answerFor || entryEditFor) {
552
- // Mutually exclusive with the role-prompt, History-search, defer, and Queued-edit
553
- // editors: all five share one prompt line, so a mode may not enter while another
554
- // holds it. Flash the way out instead of silently dropping the held mode's saved
555
- // draft (or parsing its text as a cap on Enter).
556
- flashMessage(searchMode
557
- ? "finish or cancel the search first (Esc cancels)"
558
- : pauseEditMode
559
- ? "finish or cancel the pause first (Esc cancels)"
560
- : deferMode
561
- ? "finish or cancel the defer edit first (Esc cancels)"
562
- : queuedEditFor
563
- ? "finish or cancel the queued edit first (Esc cancels)"
564
- : answerFor
565
- ? "finish or cancel the answer first (Esc cancels)"
566
- : entryEditFor
567
- ? "finish or cancel the entry edit first (Esc cancels)"
568
- : `finish or cancel the prompt for ${rolePromptFor} first (Esc cancels)`);
569
- deps.requestRender();
570
- return;
571
- }
572
- if (budgetMode) {
573
- exitBudgetMode();
574
- }
575
- else if (currentBudgetFree) {
576
- // An all-free fleet has no spend a cap could bind: the editor would pre-fill a
577
- // value that can never take effect, so Ctrl+B flashes a one-line notice and
578
- // leaves the prompt line untouched (toggle/Esc logic unchanged).
579
- flashMessage("budget n/a — all models free");
580
- }
581
- else {
582
- // Mid-recall, hand the arrows' saved draft back before the cap editor takes the
583
- // line — the same settle the role editor does — so leaving budget mode restores
584
- // the draft, not a history entry, with no stale recall state riding along.
585
- const settled = settlePromptRecall(promptHistory, input, cursor);
586
- promptHistory = settled.history;
587
- savedInput = settled.text;
588
- savedCursor = settled.cursor;
589
- budgetMode = true;
590
- input = currentCapUsd > 0 ? String(currentCapUsd) : "";
591
- cursor = input.length;
592
- flashMessage("edit daily cost budget (USD): Enter to save, Esc to cancel");
593
- }
594
- deps.requestRender();
595
- return;
596
- }
597
- if (key.ctrl && key.name === "l") {
598
- // Ctrl+L toggles defer-edit mode on the prompt line (mnemonic: **l**ater): the line edits
599
- // a duration that rides into the next delivered prompt as the same `tumwater:not-before`
600
- // marker `tumwater prompt --at` and the dashboard composer write. Entering pre-fills the
601
- // pending delay (empty when none) and saves the prompt text; leaving restores it. Empty
602
- // means "deliver now", so Esc cancels the edit without clearing an already-saved delay.
603
- if (budgetMode || rolePromptFor || searchMode || pauseEditMode || queuedEditFor || answerFor || entryEditFor) {
604
- // Mutually exclusive with the budget, role-prompt, History-search, pause, and Queued-edit
605
- // editors: all five share one prompt line, so a mode may not enter while another
606
- // holds it. Flash the way out instead of silently dropping the held mode's saved draft.
607
- flashMessage(budgetMode
608
- ? "finish or cancel the budget edit first (Esc cancels)"
609
- : pauseEditMode
610
- ? "finish or cancel the pause first (Esc cancels)"
611
- : searchMode
612
- ? "finish or cancel the search first (Esc cancels)"
613
- : queuedEditFor
614
- ? "finish or cancel the queued edit first (Esc cancels)"
615
- : answerFor
616
- ? "finish or cancel the answer first (Esc cancels)"
617
- : entryEditFor
618
- ? "finish or cancel the entry edit first (Esc cancels)"
619
- : `finish or cancel the prompt for ${rolePromptFor} first (Esc cancels)`);
620
- deps.requestRender();
621
- return;
622
- }
623
- if (deferMode) {
624
- exitDeferMode();
625
- }
626
- else {
627
- // Mid-recall, hand the arrows' saved draft back before the defer editor takes the
628
- // line — the same settle the budget and role editors do — so leaving restores the
629
- // draft, not a history entry, with no stale recall state riding along.
630
- const settled = settlePromptRecall(promptHistory, input, cursor);
631
- promptHistory = settled.history;
632
- deferSavedInput = settled.text;
633
- deferSavedCursor = settled.cursor;
634
- deferMode = true;
635
- input = deferMs === null ? "" : durationLabel(deferMs);
636
- cursor = input.length;
637
- flashMessage("defer the next prompt: a duration like 45m (empty sends now) — Enter to save, Esc to cancel");
638
- }
639
- deps.requestRender();
640
- return;
641
- }
642
- if (key.ctrl && key.name === "f" && !budgetMode && !rolePromptFor && !searchMode && !deferMode && !queuedEditFor && !answerFor && !entryEditFor) {
643
- // Ctrl+F toggles the whole-fleet pause (mnemonic: **F**leet) through the same
644
- // pauseFleet/resumeFleet marker writers `tumwater pause`/`resume` and the dashboard's
645
- // /api/pause call use, so the surfaces cannot drift on marker format or idempotence.
646
- // The shared fleetPausePhrase/fleetResumePhrase base lines keep the flash wording the
647
- // event feed and failure digest already read; a no-op flashes the same
648
- // already-paused/was-not-paused contract the CLI prints. Guarded out of the budget and
649
- // role-prompt editors, whose prompt line the fleet toggle must not disturb — the same
650
- // inertness the per-loop keys honor below. The write can fail (a lock timeout, a torn
651
- // fs), so flash the reason instead of letting the throw kill the TUI.
652
- if (pauseEditMode) {
653
- exitPauseEditMode();
654
- deps.requestRender();
655
- return;
656
- }
657
- try {
658
- if (isFleetPaused(root)) {
659
- flashMessage(resumeFleet(root) ? fleetResumePhrase() : "fleet was not paused");
660
- }
661
- else {
662
- // An unpaused fleet opens the one-line editor: "[duration] [reason]", empty pauses
663
- // until resumed. A paused fleet resumes at once.
664
- const settled = settlePromptRecall(promptHistory, input, cursor);
665
- promptHistory = settled.history;
666
- pauseSavedInput = settled.text;
667
- pauseSavedCursor = settled.cursor;
668
- pauseEditMode = true;
669
- input = "";
670
- cursor = 0;
671
- flashMessage('pause the fleet: [duration] [reason], e.g. "2h deploy freeze" (empty pauses until resumed) — Enter to pause, Esc to cancel');
672
- }
673
- }
674
- catch (err) {
675
- flashError(err);
676
- }
677
- deps.requestRender();
678
- return;
679
- }
680
- // Per-loop controls on the transcript pane: Ctrl+P
681
- // toggles the viewed loop's pause, Ctrl+A aborts its in-flight tick, Ctrl+W opens the wake editor,
682
- // and Ctrl+R opens the role-prompt editor for it.
683
- // They call the same marker-writing/submit cores the CLI's --role flags do, so the
684
- // surfaces cannot drift on marker format, idempotence, or wording. Guarded to the
685
- // transcript views and out of budget-edit mode; everywhere else the keys fall through
686
- // to applyKey, where only Ctrl+U (kill to line start) and Ctrl+K (kill to line end)
687
- // edit the prompt line and the rest of these ctrl-key presses stay inert.
688
- // Every branch is a disk write that can fail (a lock timeout, a torn fs), and an
689
- // unguarded throw would escape this keypress handler and kill the TUI — flash the
690
- // reason instead, the same contract the prompt-submit path below honors.
691
- if (key.ctrl && (key.name === "p" || key.name === "w") && pauseEditRole !== null) {
692
- // A second Ctrl+P or Ctrl+W closes the per-loop pause/wake editor, mirroring Ctrl+F's second press.
693
- exitPauseEditMode();
694
- deps.requestRender();
695
- return;
696
- }
697
- if (!budgetMode &&
698
- !deferMode && !pauseEditMode &&
699
- !queuedEditFor && !answerFor && !entryEditFor &&
700
- roleIds.length > 0 &&
701
- view >= 1 && view <= roleIds.length &&
702
- key.ctrl && (key.name === "p" || key.name === "a" || key.name === "w" || key.name === "r")) {
703
- const role = roleIds[view - 1]; // defined: view is clamped inside the transcript range
704
- try {
705
- if (key.name === "r") {
706
- // Ctrl+R toggles role-prompt mode for the viewed loop (mnemonic: p**R**ompt —
707
- // Ctrl+P is taken by pause): entering saves the director draft in this mode's own
708
- // pair and blanks the line; entering again (or Esc) restores it. Mutually
709
- // exclusive with budget mode by the outer guard and the mirrored refusal in the
710
- // Ctrl+B branch.
711
- if (rolePromptFor) {
712
- exitRolePromptMode();
713
- flashMessage("role prompt cancelled");
714
- }
715
- else {
716
- // Mid-recall, the line holds a history entry while the real draft sits in the
717
- // recall state: settle first, so the role editor saves (and later restores) the
718
- // draft the operator was writing, and no stale index/draft crosses modes —
719
- // otherwise Esc here would surface role-mode recalls on the director line.
720
- const settled = settlePromptRecall(promptHistory, input, cursor);
721
- promptHistory = settled.history;
722
- roleSavedInput = settled.text;
723
- roleSavedCursor = settled.cursor;
724
- rolePromptFor = role;
725
- input = "";
726
- cursor = 0;
727
- flashMessage(`prompt for ${role}: Enter to send, Esc to cancel`);
728
- }
729
- }
730
- else if (key.name === "p") {
731
- // Toggle by the marker's current state, read fresh: pauseRole/resumeRole's false
732
- // return means another window raced us to the same state, and the wording
733
- // helpers render that honestly — the changed-state contract the CLI prints.
734
- if (pausedRoles(root).includes(role)) {
735
- flashMessage(roleResumeMessage(root, role, resumeRole(root, role)));
736
- }
737
- else if (rolePromptFor || searchMode) {
738
- flashMessage(rolePromptFor
739
- ? "finish or cancel the role prompt first (Esc cancels)"
740
- : "finish or cancel the search first (Esc cancels)");
741
- }
742
- else {
743
- // An unpaused loop opens the one-line duration editor; nothing is written until
744
- // Enter. Empty pauses until resumed.
745
- const settled = settlePromptRecall(promptHistory, input, cursor);
746
- promptHistory = settled.history;
747
- pauseSavedInput = settled.text;
748
- pauseSavedCursor = settled.cursor;
749
- pauseEditMode = true;
750
- pauseEditRole = role;
751
- input = "";
752
- cursor = 0;
753
- flashMessage(`pause ${role}: [duration], e.g. "2h" (empty pauses until resumed) — Enter to pause, Esc to cancel`);
754
- }
755
- }
756
- else if (key.name === "a") {
757
- const result = requestAbort(root, role);
758
- flashMessage(result.ok ? result.message : `error: ${result.error}`);
759
- }
760
- else if (rolePromptFor || searchMode) {
761
- flashMessage(rolePromptFor
762
- ? "finish or cancel the role prompt first (Esc cancels)"
763
- : "finish or cancel the search first (Esc cancels)");
764
- }
765
- else {
766
- // Ctrl+W opens the one-line wake editor; nothing is written until Enter. Empty wakes now.
767
- const settled = settlePromptRecall(promptHistory, input, cursor);
768
- promptHistory = settled.history;
769
- pauseSavedInput = settled.text;
770
- pauseSavedCursor = settled.cursor;
771
- pauseEditMode = true;
772
- pauseEditRole = role;
773
- pauseEditWake = true;
774
- input = "";
775
- cursor = 0;
776
- flashMessage(`wake ${role}: [duration], e.g. "2h", or "cancel" (empty wakes now) — Enter to apply, Esc to cancel`);
777
- }
778
- }
779
- catch (err) {
780
- flashError(err);
781
- }
782
- deps.requestRender();
783
- return;
784
- }
785
- if (key.ctrl && key.name === "n") {
786
- // On the Backlog view Ctrl+N files a new entry: it opens the title editor with a null
787
- // position (new plan), and pressed again inside that editor flips plan <-> bug.
788
- if (entryEditFor !== null && entryEditFor.position === null) {
789
- const other = entryEditFor.kind === "plan" ? "bug" : "plan";
790
- entryEditFor = { ...entryEditFor, kind: other };
791
- flashMessage(`new ${other} — Enter to file, Ctrl+N switches to ${other === "plan" ? "bug" : "plan"}, Esc cancels`);
792
- deps.requestRender();
793
- return;
794
- }
795
- if (view === roleIds.length + 1 &&
796
- !budgetMode && !rolePromptFor && !searchMode && !deferMode && !pauseEditMode && !queuedEditFor && !answerFor && !entryEditFor) {
797
- const settled = settlePromptRecall(promptHistory, input, cursor);
798
- promptHistory = settled.history;
799
- entryEditSavedInput = settled.text;
800
- entryEditSavedCursor = settled.cursor;
801
- entryEditFor = { kind: "plan", position: null, title: "" };
802
- input = "";
803
- cursor = 0;
804
- flashMessage("new plan — Enter to file, Ctrl+N switches to bug, Esc cancels");
805
- deps.requestRender();
806
- return;
807
- }
808
- // Ctrl+N cycles the viewed loop's pane live transcript → notebook → change → transcript
809
- // (PLANS.md "Inspect a loop's notebook and next tick prompt in the TUI" and "Show a
810
- // loop's pending change in the TUI transcript pane"). The change step renders the same
811
- // collectRoleChange/renderRoleChange view the CLI's `tumwater diff --role <id>` and the
812
- // dashboard drawer use. Guarded to a transcript view and out of budget-edit and
813
- // role-prompt modes, the same way the per-loop controls below are; everywhere else the
814
- // key falls through to applyKey. On the History pane the same key swaps the row list for
815
- // one tick's full event trail instead (the CLI's `tumwater tick --last` text), so it is
816
- // handled before the transcript guard below.
817
- if (view === roleIds.length + 4 && !budgetMode && !rolePromptFor && !searchMode && !deferMode && !pauseEditMode && !queuedEditFor && !answerFor && !entryEditFor) {
818
- if (trailMode) {
819
- trailMode = false;
820
- trailCache = null;
821
- }
822
- else {
823
- // The newest completed tick in the pane's scope: the loop's when Ctrl+G set
824
- // historyRole, else the fleet's. A log holding none flashes and leaves the rows.
825
- const row = readTickRows(root, 1, historyRole)[0];
826
- if (row === undefined) {
827
- flashMessage("no completed ticks to inspect");
828
- }
829
- else {
830
- // readTickDetail reads the log and renderTickDetail renders it; a throw (an
831
- // injected seam or an unexpected failure) flashes the reason and leaves the rows
832
- // shown rather than escaping the handler and killing the TUI.
833
- try {
834
- const detail = readTickDetail(root, row.loop, row.tick);
835
- trailCache = detail === null ? null : renderTickDetail(detail);
836
- trailMode = true;
837
- }
838
- catch (err) {
839
- trailCache = null;
840
- flashError(err);
841
- }
842
- }
843
- }
844
- paneScroll = 0; // either way the pane starts at its head
845
- deps.requestRender();
846
- return;
847
- }
848
- if (budgetMode || rolePromptFor || deferMode || pauseEditMode || queuedEditFor || answerFor || entryEditFor || roleIds.length === 0 || view < 1 || view > roleIds.length) {
849
- deps.requestRender();
850
- return;
851
- }
852
- const role = roleIds[view - 1]; // defined: view is clamped inside the transcript range
853
- if (changeMode) {
854
- changeMode = false;
855
- changeCache = null;
856
- }
857
- else if (noteMode) {
858
- // notebook → change: reset the notebook snapshot and kick off the collector
859
- // fire-and-forget, caching the rendered text when it resolves (the Pending pane's
860
- // pattern). Guard on the change pane still being current — changeMode on, same role
861
- // at the same index — so a cycle away or a roster shift cannot overwrite the pane now
862
- // shown. The default collector degrades rather than throws, so the rejection arm only
863
- // covers an injected seam (or an unexpected throw).
864
- noteMode = false;
865
- noteCache = null;
866
- changeMode = true;
867
- changeCache = null;
868
- const target = role;
869
- void collectChange(root, target).then((change) => {
870
- if (!changeMode || roleIds[view - 1] !== target)
871
- return;
872
- changeCache = renderRoleChange(change);
873
- deps.requestRender();
874
- }, (err) => {
875
- if (!changeMode || roleIds[view - 1] !== target)
876
- return;
877
- flashError(err);
878
- deps.requestRender();
879
- });
880
- }
881
- else {
882
- // rolePayload reads the notebook and assembles the next tick's prompt off disk; a
883
- // throw leaves the transcript shown (the mode stays off) and flashes the reason
884
- // instead of escaping the handler and killing the TUI.
885
- try {
886
- noteCache = roleInspectMarkdown(root, role);
887
- noteMode = true;
888
- }
889
- catch (err) {
890
- noteCache = null;
891
- flashError(err);
892
- }
893
- }
894
- paneScroll = 0; // either way the pane starts at its head
895
- deps.requestRender();
896
- return;
897
- }
898
- if (key.ctrl && key.name === "g" && !budgetMode && !rolePromptFor && !searchMode && !deferMode && !pauseEditMode && !queuedEditFor && !answerFor && !entryEditFor) {
899
- // Ctrl+G cycles the active pane's loop scope (mnemonic: the **g**lobal scope / the
900
- // **g**roup). Guarded out of the budget and role-prompt editors, whose prompt line the
901
- // key must not disturb — the same inertness the per-loop keys honor. On the History
902
- // pane it walks the raw roster ids (`[null, ...roleIds]`) so an instance id is its own
903
- // slot, matching `history --role` and the dashboard picker; on every other view it cycles
904
- // the base-role report scope the Usage and Failures collectors aggregate by, exactly as
905
- // before. A pane showing the scope recomputes its cached Markdown and re-enters the body
906
- // at its head; every other view keeps the scope for the Ctrl+T entry that later computes
907
- // with it.
908
- if (view === 0) {
909
- // The Activity feed scopes over the same raw roster ids, so `feature-2` is its own
910
- // slot; the filter reads exact loop ids through eventRole. The scroll resets because
911
- // its offset indexed the old (unfiltered or differently filtered) list.
912
- const slots = [null, ...roleIds];
913
- const slot = slots.indexOf(activityRole);
914
- activityRole = slots[(slot + 1) % slots.length] ?? null;
915
- activityScroll = null;
916
- }
917
- else if (view === roleIds.length + 4) {
918
- const slots = [null, ...roleIds];
919
- const slot = slots.indexOf(historyRole);
920
- historyRole = slots[(slot + 1) % slots.length] ?? null;
921
- // A trail already on screen follows the scope: re-read the new scope's newest
922
- // completed tick (or drop to the pane's no-trail note when it has none) so the trail
923
- // and the tab strip cannot name different loops.
924
- if (trailMode)
925
- refreshTrail();
926
- }
927
- else {
928
- const slots = scopeCycle(roleIds);
929
- const slot = slots.indexOf(scopeRole);
930
- scopeRole = slots[(slot + 1) % slots.length] ?? null;
931
- }
932
- refreshPaneCache("all");
933
- deps.requestRender();
934
- return;
935
- }
936
- if (key.ctrl && key.name === "e" &&
937
- !budgetMode && !rolePromptFor && !searchMode && !deferMode && !pauseEditMode && queuedEditFor === null && !answerFor && !entryEditFor &&
938
- view === roleIds.length + 6 && selectedQueued !== null) {
939
- // Ctrl+E opens the Queued pane's in-place editor on the selected row (mnemonic:
940
- // **E**dit) through the same readQueuedPromptBody/editQueuedFile pair the dashboard's
941
- // Edit uses: the body is pre-filled marker- and attachment-stripped, and Enter writes
942
- // it back to the same queue file, so its position, enqueue stamp, deferral, and
943
- // attached images survive. A row that vanished between selection and this press (the
944
- // loop dequeued it, or another operator cancelled it) flashes the gone notice and
945
- // refreshes the list. A no-selection press (list mode) falls through to the report
946
- // window below, exactly as before.
947
- try {
948
- const position = Math.min(selectedQueued, queuedRows.length - 1);
949
- const row = queuedRows[position];
950
- if (row !== undefined) {
951
- const body = readQueuedPromptBody(root, row.role, row.file);
952
- if (body === null) {
953
- flashMessage(`queued prompt #${row.position} for ${row.role} is gone`);
954
- refreshQueued();
955
- }
956
- else {
957
- // Mid-recall, the line holds a history entry while the real draft sits in the
958
- // recall state: settle first, so the queued editor saves (and later restores)
959
- // the draft the operator was writing.
960
- const settled = settlePromptRecall(promptHistory, input, cursor);
961
- promptHistory = settled.history;
962
- queuedSavedInput = settled.text;
963
- queuedSavedCursor = settled.cursor;
964
- queuedEditFor = { role: row.role, file: row.file };
965
- input = body;
966
- cursor = body.length;
967
- flashMessage(`editing queued prompt #${row.position} for ${row.role} — Enter to save, Esc to cancel`);
968
- }
969
- }
970
- }
971
- catch (err) {
972
- flashError(err);
973
- }
974
- deps.requestRender();
975
- return;
976
- }
977
- if (key.ctrl && key.name === "a" &&
978
- !budgetMode && !rolePromptFor && !searchMode && !deferMode && !pauseEditMode && !queuedEditFor && !answerFor && !entryEditFor &&
979
- view === roleIds.length + 1 && selectedEntry !== null) {
980
- // Ctrl+A opens the answer editor on the selected open question (mnemonic: **A**nswer);
981
- // Enter records the decision through tryAnswerQuestion, the core `tumwater questions
982
- // answer` and the dashboard's form share. The position is the question's 1-based index
983
- // among the open questions: labeledBacklogEntries lists plans, then bugs, then questions.
984
- try {
985
- const entries = flat();
986
- const sel = Math.min(selectedEntry, entries.length - 1);
987
- const entry = entries[sel];
988
- if (entry?.label !== "question") {
989
- flashMessage("only an open question can be answered here");
990
- }
991
- else {
992
- const position = sel - entries.filter((e) => e.label !== "question").length + 1;
993
- const settled = settlePromptRecall(promptHistory, input, cursor);
994
- promptHistory = settled.history;
995
- answerSavedInput = settled.text;
996
- answerSavedCursor = settled.cursor;
997
- answerFor = { position, title: entry.title };
998
- input = "";
999
- cursor = 0;
1000
- flashMessage(`answering question #${position} — Enter to record, Esc to cancel`);
1001
- }
1002
- }
1003
- catch (err) {
1004
- flashError(err);
1005
- }
1006
- deps.requestRender();
1007
- return;
1008
- }
1009
- if (key.ctrl && key.name === "e" &&
1010
- !budgetMode && !rolePromptFor && !searchMode && !deferMode && !pauseEditMode && !queuedEditFor && !answerFor && !entryEditFor &&
1011
- view === roleIds.length + 1 && selectedEntry !== null) {
1012
- // Ctrl+E opens the title editor on the selected plan or bug (mnemonic: **E**dit); Enter
1013
- // saves through tryEditEntry, the non-exiting core the dashboard drawer shares, keeping
1014
- // the body and stamp. The prompt line is single-line, so only the title is editable here.
1015
- try {
1016
- const entries = flat();
1017
- const sel = Math.min(selectedEntry, entries.length - 1);
1018
- const picked = entries[sel];
1019
- if (picked === undefined) {
1020
- // an emptied list has nothing to edit
1021
- }
1022
- else if (picked.label === "question") {
1023
- flashMessage("only a plan or open bug can be edited here");
1024
- }
1025
- else {
1026
- const kind = picked.label === "plan" ? "plan" : "bug";
1027
- const position = kind === "plan" ? sel + 1 : sel - entries.filter((e) => e.label === "plan").length + 1;
1028
- const settled = settlePromptRecall(promptHistory, input, cursor);
1029
- promptHistory = settled.history;
1030
- entryEditSavedInput = settled.text;
1031
- entryEditSavedCursor = settled.cursor;
1032
- entryEditFor = { kind, position, title: picked.title };
1033
- input = stripEntryStamp(picked.title);
1034
- cursor = input.length;
1035
- flashMessage(`editing ${kind} #${position} — Enter to save, Esc to cancel`);
1036
- }
1037
- }
1038
- catch (err) {
1039
- flashError(err);
1040
- }
1041
- deps.requestRender();
1042
- return;
1043
- }
1044
- if (key.ctrl && key.name === "e" && !budgetMode && !rolePromptFor && !deferMode && !pauseEditMode && !queuedEditFor && !answerFor && !entryEditFor) {
1045
- // Ctrl+E advances the Usage and Failures panes' trailing report window (mnemonic:
1046
- // **E**lapsed). Guarded out of the budget and role-prompt editors like the other
1047
- // mode-sensitive keys. The window is session state: advancing it off either report pane
1048
- // only changes the label, and the next Ctrl+T into one computes the cache with it; on a
1049
- // report pane this handler recomputes the cached Markdown from the newly windowed
1050
- // collector and re-enters the body at its head.
1051
- reportWindow = nextInCycle(REPORT_WINDOWS, reportWindow);
1052
- refreshPaneCache("report");
1053
- deps.requestRender();
1054
- return;
1055
- }
1056
- if (key.ctrl && key.name === "s" && !budgetMode && !rolePromptFor && !searchMode && !deferMode && !pauseEditMode && !queuedEditFor && !answerFor && !entryEditFor) {
1057
- // Ctrl+S advances the History pane's trailing window (mnemonic: **S**ince). Guarded out
1058
- // of budget-edit and role-prompt modes like the other mode-sensitive keys. The window is
1059
- // session state: advancing it off the History view only changes the label, and the next
1060
- // Ctrl+T into History computes the cache with it; on the History view this handler
1061
- // re-reads the rows now and resets the scroll so the changed body re-enters at its head.
1062
- historyWindow = nextInCycle(HISTORY_WINDOWS, historyWindow);
1063
- refreshPaneCache("history");
1064
- deps.requestRender();
1065
- return;
1066
- }
1067
- if (key.ctrl && key.name === "o" && !budgetMode && !rolePromptFor && !searchMode && !deferMode && !pauseEditMode && !queuedEditFor && !answerFor && !entryEditFor) {
1068
- // Ctrl+O advances the History pane's outcome filter (mnemonic: **O**utcome). Guarded out
1069
- // of budget-edit and role-prompt modes like Ctrl+S beside it. The class is session state:
1070
- // advancing it off the History view only changes the label, and the next Ctrl+T into
1071
- // History computes the cache with it; on the History view this handler re-reads and
1072
- // re-filters the rows now and resets the scroll so the changed body re-enters at its head.
1073
- historyResult = nextInCycle(HISTORY_RESULTS, historyResult);
1074
- refreshPaneCache("history");
1075
- deps.requestRender();
1076
- return;
1077
- }
1078
- if (key.ctrl && key.name === "x" && noteMode &&
1079
- !budgetMode && !rolePromptFor && !searchMode && !deferMode && !pauseEditMode && !queuedEditFor && !answerFor && !entryEditFor &&
1080
- view >= 1 && view <= roleIds.length) {
1081
- // Ctrl+X twice clears the viewed loop's notebook through the writer the CLI's
1082
- // `--clear-notes` and the dashboard drawer share, then re-renders the notebook view.
1083
- const role = roleIds[view - 1];
1084
- if (armedNotes === role) {
1085
- try {
1086
- flashMessage(`${role}: ${saveRoleNote(roleNotesPath(root, role), "")}`);
1087
- noteCache = roleInspectMarkdown(root, role);
1088
- paneScroll = 0;
1089
- }
1090
- catch (err) {
1091
- flashError(err);
1092
- }
1093
- }
1094
- else {
1095
- notesClearArmed = role;
1096
- flashMessage(`press Ctrl+X again to clear ${role}'s notebook — any other key keeps it`);
1097
- }
1098
- deps.requestRender();
1099
- return;
1100
- }
1101
- if (key.ctrl && key.name === "x" &&
1102
- !budgetMode && !rolePromptFor && !searchMode && !deferMode && !pauseEditMode && !queuedEditFor && !answerFor && !entryEditFor &&
1103
- view === roleIds.length + 6 && selectedQueued !== null) {
1104
- // Ctrl+X cancels the selected queued prompt through the CLI's own per-loop path
1105
- // (`tumwater prompt --cancel <n> --role <id>`): cancelRolePrompt removes that loop's
1106
- // Nth queue file and logs the prompt_cancelled event, so the pane, the CLI, and the
1107
- // dashboard cancel cannot drift. Then re-read the queue so the pane's list and rows
1108
- // reflect the removal and the selection clamps. A no-selection press (list mode), and
1109
- // every other view, leaves the queue untouched.
1110
- try {
1111
- const row = queuedRows[Math.min(selectedQueued, queuedRows.length - 1)];
1112
- if (row !== undefined) {
1113
- const outcome = cancelRolePrompt(root, row.role, row.position);
1114
- // A gone outcome is the concurrent-dequeue race (cancelRolePrompt's own contract),
1115
- // not a failure: flash it honestly and still refresh the listing.
1116
- flashMessage(outcome.status === "cancelled"
1117
- ? `cancelled queued prompt #${row.position} for ${row.role}`
1118
- : `queued prompt #${row.position} for ${row.role} was already gone`);
1119
- refreshQueued();
1120
- }
1121
- }
1122
- catch (err) {
1123
- flashError(err);
1124
- }
1125
- deps.requestRender();
1126
- return;
1127
- }
1128
- if (key.ctrl && key.name === "x" &&
1129
- !budgetMode && !rolePromptFor && !searchMode && !deferMode && !pauseEditMode && !queuedEditFor && !answerFor && !entryEditFor &&
1130
- view === roleIds.length + 1) {
1131
- // Ctrl+X twice cancels the selected plan or bug through the non-exiting core the
1132
- // dashboard drawer uses; the title check inside tryCancelEntry refuses a stale selection.
1133
- if (selectedEntry !== null) {
1134
- const entries = flat();
1135
- const sel = Math.min(selectedEntry, entries.length - 1);
1136
- const picked = entries[sel];
1137
- if (picked === undefined) {
1138
- // an emptied list has nothing to cancel
1139
- }
1140
- else if (picked.label === "question") {
1141
- flashMessage("only a plan or open bug can be cancelled here");
1142
- }
1143
- else {
1144
- const kind = picked.label === "plan" ? "plan" : "bug";
1145
- const position = kind === "plan" ? sel + 1 : sel - entries.filter((e) => e.label === "plan").length + 1;
1146
- const target = { kind, position, title: picked.title };
1147
- if (armed !== null && armed.kind === kind && armed.position === position) {
1148
- // The armed title (not the re-read one) goes to tryCancelEntry, so an entry that
1149
- // changed since the arm reports `moved` instead of being cancelled.
1150
- try {
1151
- const outcome = tryCancelEntry(root, kind, position, armed.title);
1152
- selectedEntry = null;
1153
- entryScroll = 0;
1154
- flashMessage(outcome.status === "cancelled"
1155
- ? `cancelled ${kind} #${position}: ${target.title}`
1156
- : `that ${kind} was already removed or moved — nothing cancelled`);
1157
- }
1158
- catch (err) {
1159
- flashError(err);
1160
- }
1161
- }
1162
- else {
1163
- cancelArmed = target;
1164
- flashMessage(`press Ctrl+X again to cancel ${kind} #${position} — any other key keeps it`);
1165
- }
1166
- }
1167
- }
126
+ // The prompt-line editors own every arm that reads or writes the one prompt line — the
127
+ // toggles and per-loop controls that open them, the escape/Enter arms that close and
128
+ // commit them, and the `/` search. Route the key to them first; when they consumed it,
129
+ // the dispatch is done (they always render on a handled key).
130
+ if (editors.handleKey(str, key, panes.editorCtx())) {
1168
131
  deps.requestRender();
1169
132
  return;
1170
133
  }
1171
134
  if (key.ctrl && key.name === "t") {
1172
- // Ctrl+T exits budget-edit mode too — view cycling is orthogonal to it, so a cycle
1173
- // never strands the editor with its pre-filled cap in the prompt line. Role-prompt
1174
- // mode composes the same way: cycling away restores the saved director draft.
1175
- exitBudgetMode();
1176
- exitRolePromptMode();
1177
- exitDeferMode();
1178
- exitPauseEditMode();
1179
- exitHistorySearch();
1180
- exitQueuedEdit();
1181
- exitAnswerMode();
1182
- exitEntryEdit();
1183
- noteMode = false; // a cycle away leaves notebook and change modes and drops their snapshots
1184
- noteCache = null;
1185
- changeMode = false;
1186
- changeCache = null;
1187
- trailMode = false; // the tick trail is History-only; a cycle away drops its snapshot too
1188
- trailCache = null;
1189
- view = (view + 1) % (roleIds.length + 7); // events → each loop's transcript → project status → usage report → failures → history → pending → queued → events
1190
- selectedEntry = null; // leaving a view drops any entry selection…
1191
- selectedQueued = null; // …and any queued-row selection, so re-entering starts at the list
1192
- entryScroll = 0; // …and its within-body scroll, so re-entering starts at the list/head
1193
- paneScroll = 0; // the Markdown panes always re-enter at their head
1194
- activityScroll = null; // Activity always re-enters at the live tail
1195
- // Compute the pane's Markdown once per activation (this handler renders immediately), and
1196
- // drop it on leaving — a visit's first frame is fresh, later frames only re-window.
1197
- if (!refreshPaneCache("all")) {
1198
- if (view === roleIds.length + 5) {
1199
- // Pending is the cycle's one async collector: collectFleetChanges awaits git plumbing,
1200
- // so kick it off fire-and-forget, cache the rendered text when it resolves, and repaint.
1201
- // The durable land queue needs no git plumbing, so read and render it through the CLI's
1202
- // own pair (`tumwater queue`) UP FRONT: the queue paints on entry, and the git-backed
1203
- // roster is appended below it when the collector resolves, so the three surfaces
1204
- // (CLI, dashboard, TUI) cannot drift on order, age, skips, or wording.
1205
- // Guard on the PENDING pane still being current — view === roleIds.length + 5 — not just
1206
- // on the raw index: an operator who cycled away, or a roster change that shifted which
1207
- // pane the same index names, must not have this resolution overwrite the pane now shown.
1208
- // The default collector degrades rather than throws, so the rejection arm only covers an
1209
- // injected seam (or an unexpected throw).
1210
- const queueText = renderLandingQueue(readLandingQueue(root), now());
1211
- paneCache = queueText;
1212
- void collectPending(root).then((fleet) => {
1213
- if (view !== roleIds.length + 5)
1214
- return;
1215
- paneCache = queueText + "\n\n" + renderFleetChange(fleet);
1216
- deps.requestRender();
1217
- }, (err) => {
1218
- if (view !== roleIds.length + 5)
1219
- return;
1220
- // Keep the queue visible rather than replacing it with a loading note: it is
1221
- // already known and unaffected by the git read that failed.
1222
- flashError(err);
1223
- deps.requestRender();
1224
- });
1225
- }
1226
- else if (view === roleIds.length + 6) {
1227
- // Queued is a synchronous read of the same queue list `tumwater prompt --list`
1228
- // prints (renderQueuedList over queuedListRows), so it paints on entry with no
1229
- // async collector. The now() clock keeps the age/countdown suffix testable.
1230
- refreshQueued();
1231
- }
1232
- else {
1233
- paneCache = null;
1234
- }
1235
- }
1236
- deps.requestRender();
135
+ // Ctrl+T exits every open editor too — view cycling is orthogonal to them, so a cycle
136
+ // never strands an editor's pre-filled text in the prompt line. Each editor restores its
137
+ // saved draft. Role-prompt mode composes the same way: cycling away restores the saved
138
+ // director draft.
139
+ editors.exitAll();
140
+ panes.cycleView();
1237
141
  return;
1238
142
  }
1239
- if (!key.ctrl && str === "/" && !searchMode && !budgetMode && !rolePromptFor && !deferMode && !pauseEditMode && !queuedEditFor && !answerFor && !entryEditFor &&
1240
- (view === 0 || view === roleIds.length + 4) && input === "") {
1241
- // `/` opens a free-text search on the prompt line (the pager's search key; this project
1242
- // already reserves `/` for a meta action): over the History pane's tick rows on History,
1243
- // over the Activity feed's events on Activity. Guarded to those two views and to an empty
1244
- // line, so elsewhere it types into the prompt as today and a director draft is never
1245
- // disturbed. Pre-fills the applied pattern so Enter re-submits it and the operator can
1246
- // edit it; Esc cancels without applying.
1247
- const settled = settlePromptRecall(promptHistory, input, cursor);
1248
- promptHistory = settled.history;
1249
- searchSavedInput = settled.text;
1250
- searchSavedCursor = settled.cursor;
1251
- searchFor = view === 0 ? "activity" : "history";
1252
- searchMode = true;
1253
- input = (searchFor === "activity" ? activityGrep : historyGrep) ?? "";
1254
- cursor = input.length;
1255
- flashMessage(searchFor === "activity" ? "search events: Enter to filter, Esc to cancel" : "search ticks: Enter to filter, Esc to cancel");
1256
- deps.requestRender();
143
+ if (panes.handleKey(key, editors.state()))
1257
144
  return;
1258
- }
1259
145
  const arrow = arrowDir(key.name);
1260
- const page = pageDir(key.name);
1261
- if (view === roleIds.length + 1 && arrow !== null) {
1262
- // In the project-status pane, up/down browse entries in full instead of editing the
1263
- // prompt; every other view keeps today's behavior (arrows are ignored by applyKey).
1264
- // An open answer editor pins the selection (its position was taken from it).
1265
- if (!answerFor && !entryEditFor) {
1266
- const count = flat().length;
1267
- selectedEntry = moveEntrySelection(count, selectedEntry, arrow);
1268
- entryScroll = 0; // a newly opened entry starts at its body's head
1269
- }
1270
- deps.requestRender();
1271
- return;
1272
- }
1273
- if (view === roleIds.length + 6 && arrow !== null) {
1274
- // In the Queued pane, up/down select a queued prompt row in full (wrapping, the backlog
1275
- // pane's rule) instead of editing the prompt; an empty queue has nothing to select, so
1276
- // moveEntrySelection clears the selection. Every other view keeps today's behavior.
1277
- // The selected body shares paneScroll with the list, so a newly opened row must start
1278
- // at its head (the status pane's entryScroll reset on the same move), or it renders
1279
- // scrolled into the middle of the previous row's body.
1280
- selectedQueued = moveEntrySelection(queuedRows.length, selectedQueued, arrow);
1281
- paneScroll = 0;
1282
- deps.requestRender();
1283
- return;
1284
- }
1285
- if (page !== null && view === 0) {
1286
- // The Activity pane keeps the live tail by default; PgUp pauses follow and pages back
1287
- // through the loaded event tail, PgDn returns toward it and re-enables follow once the
1288
- // window reaches the tail. Both directions clamp to [0, total - eventBudget], and a log
1289
- // with eventBudget or fewer events stays on the tail. The read is the pane's own fixed
1290
- // tail term (and active loop/text filter), so it hits readEvents' stat cache rather than
1291
- // rescanning the log.
1292
- const total = activityEvents(root, activityRole, activityGrep).length;
1293
- const maxStart = Math.max(0, total - eventBudget);
1294
- const current = activityScroll ?? maxStart;
1295
- const next = stepEntryScroll(current, total, eventBudget, page);
1296
- activityScroll = next >= maxStart ? null : next;
1297
- deps.requestRender();
1298
- return;
1299
- }
1300
- if (page !== null && (noteMode || changeMode || trailMode)) {
1301
- // Within-body scroll in the notebook, change, and tick-trail views: PgDn/PgUp page the
1302
- // cached inspection Markdown, clamped at both ends, exactly like the usage/failures
1303
- // panes below.
1304
- const total = (noteMode ? (noteCache ?? "") : trailMode ? (trailCache ?? "") : (changeCache ?? "")).split("\n").length;
1305
- paneScroll = stepEntryScroll(paneScroll, total, eventBudget, page);
146
+ if (arrow !== null && editors.onArrow(arrow)) {
1306
147
  deps.requestRender();
1307
148
  return;
1308
149
  }
1309
- if (view >= roleIds.length + 2 && page !== null) {
1310
- // Within-body scroll in the Markdown panes (usage report, failures): PgDn/PgUp page the
1311
- // cached string, clamped at both ends. The total is derived from the cache, mirroring how
1312
- // entry mode derives it from e.body; every other view ignores these keys as today.
1313
- const total = (paneCache ?? "").split("\n").length;
1314
- paneScroll = stepEntryScroll(paneScroll, total, eventBudget, page);
1315
- deps.requestRender();
1316
- return;
1317
- }
1318
- if (view === roleIds.length + 1 && page !== null) {
1319
- // Within-body scroll in project-status ENTRY mode: PgDn/PgUp page the selected entry's
1320
- // body, clamped at both ends. No-op in list mode and for bodies that fit — every other
1321
- // view ignores these keys as today (applyKey drops them).
1322
- if (selectedEntry !== null && !answerFor && !entryEditFor) {
1323
- const entries = flat();
1324
- const sel = entries.length > 0 ? Math.min(selectedEntry, entries.length - 1) : null;
1325
- const body = sel === null ? "" : entries[sel].body;
1326
- entryScroll = stepEntryScroll(entryScroll, body ? body.split("\n").length : 0, entryBudget, page);
1327
- }
1328
- deps.requestRender();
1329
- return;
1330
- }
1331
- if (!budgetMode && !deferMode && !pauseEditMode && !searchMode && arrow !== null) {
1332
- // Up/Down walk the submitted-prompt history the way readline does (tui-prompt-history.ts's
1333
- // recall rules). The backlog pane keeps the arrows for entry browsing (its branch
1334
- // above already returned), and budget and defer modes keep them out — recalling a
1335
- // prompt into the cap or delay field would be a paste, not a recall. No history in
1336
- // this direction: fall through to applyKey, which ignores arrows as before.
1337
- const recalled = recallPromptHistory(promptHistory, input, cursor, key.name === "up" ? "older" : "newer");
1338
- if (recalled) {
1339
- promptHistory = recalled.history;
1340
- input = recalled.text;
1341
- cursor = recalled.cursor;
1342
- }
1343
- }
1344
- if (key.name === "escape" && selectedQueued !== null && view === roleIds.length + 6 &&
1345
- !budgetMode && !deferMode && !pauseEditMode && !rolePromptFor && !searchMode && !queuedEditFor && !answerFor && !entryEditFor) {
1346
- // Esc on the Queued pane returns it to list mode; the cached list is untouched (nothing
1347
- // was cancelled), so the pane shows the same renderQueuedList it entered with. The
1348
- // shared paneScroll resets with the body switch, like a selection move.
1349
- selectedQueued = null;
1350
- paneScroll = 0;
1351
- deps.requestRender();
1352
- return;
1353
- }
1354
- if (key.name === "escape" && searchMode) {
1355
- // Esc cancels the History search without applying it; the applied pattern is unchanged.
1356
- exitHistorySearch();
1357
- deps.requestRender();
1358
- return;
1359
- }
1360
- if (key.name === "escape" && budgetMode) {
1361
- // Esc cancels budget-edit mode, restoring the previous prompt text.
1362
- exitBudgetMode();
1363
- deps.requestRender();
1364
- return;
1365
- }
1366
- if (key.name === "escape" && rolePromptFor) {
1367
- // Esc cancels role-prompt mode the same way, restoring the previous prompt text.
1368
- exitRolePromptMode();
1369
- deps.requestRender();
1370
- return;
1371
- }
1372
- if (key.name === "escape" && pauseEditMode) {
1373
- // Esc cancels the pause editor without writing, restoring the previous prompt text.
1374
- exitPauseEditMode();
1375
- deps.requestRender();
1376
- return;
1377
- }
1378
- if (key.name === "escape" && deferMode) {
1379
- // Esc cancels the defer editor, restoring the previous prompt text and leaving any
1380
- // already-saved pending delay untouched.
1381
- exitDeferMode();
1382
- deps.requestRender();
1383
- return;
1384
- }
1385
- if (key.name === "escape" && entryEditFor) {
1386
- // Esc cancels the title editor, restoring the previous prompt text; nothing is written.
1387
- const filing = entryEditFor.position === null;
1388
- exitEntryEdit();
1389
- flashMessage(filing ? "new entry cancelled" : "edit cancelled");
1390
- deps.requestRender();
1391
- return;
1392
- }
1393
- if (key.name === "escape" && answerFor) {
1394
- // Esc cancels the answer editor, restoring the previous prompt text; nothing is written.
1395
- exitAnswerMode();
1396
- flashMessage("answer cancelled");
1397
- deps.requestRender();
1398
- return;
1399
- }
1400
- if (key.name === "escape" && queuedEditFor) {
1401
- // Esc cancels the Queued pane's in-place editor, restoring the previous prompt text.
1402
- exitQueuedEdit();
1403
- deps.requestRender();
1404
- return;
1405
- }
1406
- if (key.name === "return") {
1407
- if (searchMode) {
1408
- // Enter applies the trimmed line to the pane the editor opened over: Activity sets
1409
- // activityGrep (an empty line clears it) and resets the scroll to the live tail — the
1410
- // offset indexed the old filtered list; History recomputes its row cache now (the pane
1411
- // is showing) and re-enters at its head. A successful apply restores the saved prompt
1412
- // draft through exitHistorySearch, the shared editor's exit.
1413
- const pattern = input.trim();
1414
- if (searchFor === "activity") {
1415
- activityGrep = pattern === "" ? null : pattern;
1416
- exitHistorySearch();
1417
- activityScroll = null;
1418
- flashMessage(activityGrep === null ? "search cleared" : `search: "${activityGrep}"`);
1419
- }
1420
- else {
1421
- historyGrep = pattern === "" ? null : pattern;
1422
- exitHistorySearch();
1423
- paneCache = historyPaneCache(root, historyWindow, historyResult, historyRole, historyGrep);
1424
- paneScroll = 0;
1425
- flashMessage(historyGrep === null ? "search cleared" : `search: "${historyGrep}"`);
1426
- }
1427
- }
1428
- else if (budgetMode) {
1429
- // Enter in budget mode parses + saves the cap through the shared setter (which
1430
- // writes tumwater.json atomically; the running orchestrator picks it up on its next
1431
- // ~2 s poll). An invalid value flashes and STAYS in edit mode so the operator can
1432
- // fix it; success restores the saved prompt text.
1433
- const parsed = parseBudgetInput(input);
1434
- if (!parsed.ok) {
1435
- flashMessage(parsed.error);
1436
- }
1437
- else {
1438
- const result = setDailyBudgetUsd(root, parsed.value);
1439
- if (result.ok) {
1440
- exitBudgetMode();
1441
- currentCapUsd = parsed.value; // the next render's snapshot agrees within ~2 s
1442
- flashMessage(parsed.value === 0
1443
- ? "budget disabled"
1444
- : `budget set to ${usdCap(parsed.value)}`);
1445
- }
1446
- else {
1447
- flashMessage(result.error); // broken config or write failure — stay in edit mode
1448
- }
1449
- }
1450
- }
1451
- else if (pauseEditMode) {
1452
- // Enter in pause-edit mode parses "[duration] [reason]" and writes the same marker
1453
- // `tumwater pause --for/--reason` does. An invalid entry flashes and STAYS in the editor.
1454
- const target = pauseEditRole;
1455
- const wakeParsed = pauseEditWake && target !== null ? parseRoleWakeInput(input) : null;
1456
- const parsed = target !== null ? parseRolePauseInput(input) : parsePauseInput(input);
1457
- if (wakeParsed) {
1458
- if (!wakeParsed.ok) {
1459
- flashMessage(wakeParsed.error);
1460
- }
1461
- else {
1462
- try {
1463
- const message = wakeParsed.cancel ? cancelWakes(root, target) : requestWake(root, [target], wakeParsed.inMs);
1464
- exitPauseEditMode();
1465
- flashMessage(message);
1466
- }
1467
- catch (err) {
1468
- flashError(err);
1469
- }
1470
- }
1471
- }
1472
- else if (!parsed.ok) {
1473
- flashMessage(parsed.error);
1474
- }
1475
- else if (target !== null) {
1476
- try {
1477
- const at = now();
1478
- const until = parsed.forMs === undefined ? undefined : at + parsed.forMs;
1479
- const changed = pauseRole(root, target, until);
1480
- exitPauseEditMode();
1481
- flashMessage(rolePauseMessage(root, target, changed, until === undefined ? undefined : { ms: parsed.forMs, untilMs: until }, at));
1482
- }
1483
- catch (err) {
1484
- flashError(err);
1485
- }
1486
- }
1487
- else {
1488
- const fleet = parsed;
1489
- try {
1490
- const until = fleet.forMs === undefined ? undefined : now() + fleet.forMs;
1491
- const changed = pauseFleet(root, until, fleet.reason);
1492
- exitPauseEditMode();
1493
- const forSuffix = fleet.forMs === undefined ? "" : ` for ${durationLabel(fleet.forMs)}`;
1494
- flashMessage(changed || until !== undefined
1495
- ? `${fleetPausePhrase()}${forSuffix}${changed && fleet.reason ? `: ${fleet.reason}` : ""}`
1496
- : "fleet is already paused");
1497
- }
1498
- catch (err) {
1499
- flashError(err);
1500
- }
1501
- }
1502
- }
1503
- else if (deferMode) {
1504
- // Enter in defer mode parses the duration: empty clears the pending delay (deliver
1505
- // now), a valid value stores the session duration (applied as now() + deferMs at
1506
- // submit), and an invalid spelling or an over-cap value flashes and STAYS in the
1507
- // editor so the operator can fix it. Either way the saved draft is restored.
1508
- const parsed = parseDeferInput(input);
1509
- if (!parsed.ok) {
1510
- flashMessage(parsed.error);
1511
- }
1512
- else {
1513
- deferMs = parsed.ms;
1514
- exitDeferMode();
1515
- flashMessage(deferMs === null ? "defer cleared" : `next prompt delivers in ${durationLabel(deferMs)}`);
1516
- }
1517
- }
1518
- else if (rolePromptFor) {
1519
- // Enter in role-prompt mode submits for the viewed loop through the same path
1520
- // `tumwater prompt --role` uses: the shared submitRolePromptAndWake enqueues into
1521
- // that loop's own queue (length-capped by the shared rule), logs under the loop,
1522
- // and wakes it so a live fleet's loop comes in within one poll. An invalid
1523
- // (whitespace-only) line flashes and STAYS in edit mode; a failed queue write
1524
- // flashes the reason and keeps the text, the same contract the director path
1525
- // below honors; success restores the saved draft.
1526
- const parsed = parseRolePromptInput(input);
1527
- if (!parsed.ok) {
1528
- flashMessage(parsed.error);
1529
- }
1530
- else {
1531
- const role = rolePromptFor;
1532
- try {
1533
- const pending = deferMs;
1534
- submitRolePromptAndWake(root, role, parsed.value, undefined, pending === null ? undefined : now() + pending);
1535
- promptHistory = pushPromptHistory(promptHistory, parsed.value);
1536
- deferMs = null;
1537
- exitRolePromptMode();
1538
- flashMessage(pending === null
1539
- ? `queued for the ${role} loop`
1540
- : `queued for the ${role} loop — delivers in ${durationLabel(pending)}`);
1541
- }
1542
- catch (err) {
1543
- flashError(err);
1544
- }
1545
- }
1546
- }
1547
- else if (entryEditFor) {
1548
- // Enter saves the new title through tryEditEntry with the body re-read now, so a
1549
- // concurrent body edit is not clobbered by a stale copy. The entry is matched by flat
1550
- // index and title; a mismatch is treated as moved without writing. A blank title and a
1551
- // throw flash and STAY in the editor so the text survives.
1552
- const target = entryEditFor;
1553
- if (!input.trim()) {
1554
- flashMessage("an entry needs a title");
1555
- }
1556
- else if (target.position === null) {
1557
- // New entry (Ctrl+N): file by title with an empty body and wake its loop, the same
1558
- // steps handleBacklogFile takes; a length problem or a throw stays in the editor.
1559
- try {
1560
- const { role, wakeText } = OPERATOR_FILE_KINDS[target.kind];
1561
- const problem = promptLengthProblem(wakeText(collapseWhitespace(input)), role);
1562
- if (problem) {
1563
- flashMessage(problem);
1564
- }
1565
- else {
1566
- const filed = fileEntryWithBody(root, target.kind, input, "");
1567
- submitRolePromptAndWake(root, role, wakeText(filed.title));
1568
- exitEntryEdit();
1569
- flashMessage(`filed ${target.kind}: ${filed.title} — woke ${role}`);
1570
- }
1571
- }
1572
- catch (err) {
1573
- flashError(err);
1574
- }
1575
- }
1576
- else {
1577
- try {
1578
- const entries = flat();
1579
- const plans = entries.filter((e) => e.label === "plan").length;
1580
- const entry = entries[target.kind === "plan" ? target.position - 1 : plans + target.position - 1];
1581
- const outcome = entry?.label === target.kind && entry.title === target.title
1582
- ? tryEditEntry(root, target.kind, target.position, target.title, input, entry.body)
1583
- : { status: "moved", title: "" };
1584
- exitEntryEdit();
1585
- if (outcome.status === "edited") {
1586
- flashMessage(`edited ${target.kind} #${target.position}: ${outcome.title}`);
1587
- }
1588
- else {
1589
- selectedEntry = null;
1590
- entryScroll = 0;
1591
- flashMessage(`that ${target.kind} was already removed or moved — nothing edited`);
1592
- }
1593
- }
1594
- catch (err) {
1595
- flashError(err);
1596
- }
1597
- }
1598
- }
1599
- else if (answerFor) {
1600
- // Enter records the answer through tryAnswerQuestion, titled so a question that moved
1601
- // since Ctrl+A is refused. A whitespace-only line, an unclosed fence, and a throw all
1602
- // flash and STAY in the editor so the text survives; answered and missing/moved both
1603
- // leave the editor and return the pane to list mode (the entry left the list).
1604
- const target = answerFor;
1605
- if (!input.trim()) {
1606
- flashMessage("questions answer needs a decision");
1607
- }
1608
- else {
1609
- try {
1610
- const outcome = tryAnswerQuestion(root, target.position, input, target.title);
1611
- if (outcome.status === "fenced") {
1612
- flashMessage(UNCLOSED_FENCE_MESSAGE);
1613
- }
1614
- else {
1615
- exitAnswerMode();
1616
- selectedEntry = null;
1617
- flashMessage(outcome.status === "answered"
1618
- ? `answered question #${target.position}: ${outcome.title}`
1619
- : "that question was already answered or moved — nothing recorded");
1620
- }
1621
- }
1622
- catch (err) {
1623
- flashError(err);
1624
- }
1625
- }
1626
- }
1627
- else if (queuedEditFor) {
1628
- // Enter saves the queued editor's line through the same file-addressed
1629
- // editQueuedFile pair the dashboard's Edit uses: the write targets the queue file
1630
- // the row named, so a concurrent dequeue cannot land it on the wrong prompt. An
1631
- // invalid (whitespace-only) line flashes and STAYS in the editor; a gone outcome
1632
- // is the concurrent-dequeue race, flashed honestly and returned to the list; a
1633
- // throw keeps the text in the editor for a retry.
1634
- const parsed = parseRolePromptInput(input);
1635
- if (!parsed.ok) {
1636
- flashMessage(parsed.error);
1637
- }
1638
- else {
1639
- const target = queuedEditFor;
1640
- try {
1641
- const outcome = editQueuedFile(root, target.role, target.file, parsed.value);
1642
- exitQueuedEdit();
1643
- refreshQueued();
1644
- flashMessage(outcome.status === "edited"
1645
- ? `edited queued prompt for ${target.role}`
1646
- : "queued prompt was already gone");
1647
- }
1648
- catch (err) {
1649
- flashError(err);
1650
- }
1651
- }
1652
- }
1653
- else {
1654
- const prompt = input.trim();
1655
- if (!prompt) {
1656
- // Whitespace-only input queues nothing; drop it like an empty line.
1657
- input = "";
1658
- cursor = 0;
1659
- }
1660
- else {
1661
- try {
1662
- const pending = deferMs;
1663
- submitPrompt(root, prompt, undefined, pending === null ? undefined : now() + pending);
1664
- promptHistory = pushPromptHistory(promptHistory, prompt);
1665
- deferMs = null;
1666
- input = "";
1667
- cursor = 0;
1668
- flashMessage(pending === null
1669
- ? "queued for the director loop"
1670
- : `queued for the director loop — delivers in ${durationLabel(pending)}`);
1671
- }
1672
- catch (err) {
1673
- // The queue write failed (disk full, permissions): keep the operator's text so
1674
- // it can be resubmitted, and flash the reason — the same contract the GUI's
1675
- // prompt form honors. Clearing the line first would silently lose the prompt,
1676
- // and an unguarded throw would escape the keypress handler and kill the TUI.
1677
- flashError(err);
1678
- }
1679
- }
1680
- }
1681
- }
1682
- else {
1683
- const next = applyKey(input, cursor, str, key);
1684
- input = next.text;
1685
- cursor = next.cursor;
1686
- }
150
+ editors.apply(str, key);
1687
151
  deps.requestRender();
1688
152
  };
1689
153
  return {
1690
154
  handleKey,
1691
155
  syncSnapshot(capUsd, budgetFree, roles, directorRunning = false) {
1692
- currentCapUsd = capUsd;
1693
- currentBudgetFree = budgetFree;
1694
- const previousCount = roleIds.length;
1695
- roleIds = roles;
156
+ editors.sync(capUsd, budgetFree);
1696
157
  directorInFlight = directorRunning;
1697
- // The panes from project status on are anchored at roleIds.length, so a roster-size
1698
- // change moves every one of their indices by the same delta: shift the view with them,
1699
- // or an unadjusted index silently renames to a different pane while its cached body
1700
- // stays, showing the wrong body under the tab now selected. A transcript view
1701
- // (1..previousCount) keeps its index — it does not depend on the roster size — and the
1702
- // clamp below handles one the shrunken roster no longer has.
1703
- if (roleIds.length !== previousCount && view > previousCount)
1704
- view += roleIds.length - previousCount;
1705
- view = Math.min(view, roleIds.length + 6); // clamp a stale index if roles changed
1706
- // A retired or renamed loop must not leave the report panes silently scoped to an
1707
- // absent role — drop the scope with the stale view index. The tab strip and hint then
1708
- // read "fleet", so a report pane still on screen must recompute its cached Markdown
1709
- // from the fleet-wide collector (and re-enter at the body's head) to match; the other
1710
- // views compute with the scope on their next Ctrl+T.
1711
- if (scopeRole !== null && !roleIds.includes(scopeRole)) {
1712
- scopeRole = null;
1713
- refreshPaneCache("report");
1714
- }
1715
- // The same drop for History's exact-loop scope: a retired or renamed loop must not keep
1716
- // labeling an empty pane. An on-screen History pane recomputes from the fleet-wide read
1717
- // so its rows and its `role:` header clear together.
1718
- if (historyRole !== null && !roleIds.includes(historyRole)) {
1719
- historyRole = null;
1720
- refreshPaneCache("history");
1721
- // The retired scope may have been feeding an on-screen trail: re-read it from the
1722
- // fleet so the pane and the tab strip cannot name a loop the roster no longer has.
1723
- if (trailMode)
1724
- refreshTrail();
1725
- }
1726
- // The same drop for the Activity feed's exact-loop scope: a retired or renamed loop must
1727
- // not leave the pane filtering on an id the roster no longer names. The scroll resets
1728
- // with it — the offset indexed the old filtered list.
1729
- if (activityRole !== null && !roleIds.includes(activityRole)) {
1730
- activityRole = null;
1731
- activityScroll = null;
1732
- }
1733
- },
1734
- setLineBudgets(evBudget, enBudget) {
1735
- eventBudget = evBudget;
1736
- entryBudget = enBudget;
158
+ panes.syncRoster(roles);
1737
159
  },
160
+ setLineBudgets: panes.setLineBudgets,
1738
161
  state() {
1739
162
  return {
1740
- input,
1741
- cursor,
1742
- budgetMode,
1743
- rolePromptFor,
1744
- deferMode,
1745
- pauseEditMode,
1746
- pauseEditRole,
1747
- pauseEditWake,
1748
- defer: deferMs === null ? null : durationLabel(deferMs),
1749
- view,
1750
- scopeRole,
1751
- historyRole,
1752
- selectedEntry,
1753
- cancelArmed,
1754
- entryScroll,
1755
- selectedQueued,
1756
- queuedEditFor,
1757
- answerFor,
1758
- entryEditFor,
1759
- queuedRows,
1760
- paneCache,
1761
- noteMode,
1762
- noteCache,
1763
- trailMode,
1764
- trailCache,
1765
- changeMode,
1766
- changeCache,
1767
- paneScroll,
1768
- activityScroll,
1769
- activityRole,
1770
- activityGrep,
1771
- historySince: historyWindow === null ? null : cycleLabel(HISTORY_WINDOWS, historyWindow),
1772
- reportSince: reportWindow === null ? null : cycleLabel(REPORT_WINDOWS, reportWindow),
1773
- historyResult: cycleLabel(HISTORY_RESULTS, historyResult),
1774
- historyGrep,
1775
- eventBudget,
1776
- entryBudget,
1777
- roleIds,
163
+ ...editors.state(),
164
+ ...panes.state(),
1778
165
  directorInFlight,
1779
166
  flash: flash && now() < flashUntil ? flash : null,
1780
167
  };