tumwater 0.4.2 → 0.4.4

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 (354) hide show
  1. package/README.md +6 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +6 -6
  4. package/dist/src/agent/{run-agent.js → agent-run.js} +27 -13
  5. package/dist/src/agent/{run-settle.js → agent-settle.js} +48 -24
  6. package/dist/src/agent/{run-spawn.js → agent-spawn.js} +21 -12
  7. package/dist/src/agent/{run-stream.js → agent-stream.js} +4 -4
  8. package/dist/src/agent/backend-traits.js +2 -2
  9. package/dist/src/agent/claude/claude-account.js +6 -27
  10. package/dist/src/agent/claude/claude-backend.js +3 -3
  11. package/dist/src/agent/claude/claude-env.js +27 -31
  12. package/dist/src/agent/claude/claude-stream.js +111 -54
  13. package/dist/src/agent/codex/codex-account.js +8 -0
  14. package/dist/src/agent/codex/codex-args.js +5 -5
  15. package/dist/src/agent/codex/codex-backend.js +2 -2
  16. package/dist/src/agent/codex/codex-env.js +28 -15
  17. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  18. package/dist/src/agent/codex/codex-rollout.js +93 -56
  19. package/dist/src/agent/codex/codex-stream.js +1 -1
  20. package/dist/src/agent/env-scrub.js +18 -0
  21. package/dist/src/agent/image-input.js +68 -11
  22. package/dist/src/agent/notes-shim.js +2 -2
  23. package/dist/src/agent/opencode/opencode-backend.js +26 -24
  24. package/dist/src/agent/opencode/opencode-catalog.js +85 -36
  25. package/dist/src/agent/opencode/opencode-config.js +32 -14
  26. package/dist/src/agent/opencode/opencode-env.js +12 -13
  27. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  28. package/dist/src/agent/session-ledger.js +23 -27
  29. package/dist/src/agent/subscription-account.js +2 -83
  30. package/dist/src/backlog/backlog-archive.js +3 -4
  31. package/dist/src/backlog/backlog-cli-view.js +81 -41
  32. package/dist/src/backlog/backlog-cli.js +57 -26
  33. package/dist/src/backlog/backlog-eligibility.js +56 -122
  34. package/dist/src/backlog/backlog-md.js +5 -5
  35. package/dist/src/backlog/backlog-parts.js +65 -27
  36. package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +1 -1
  37. package/dist/src/backlog/backlog-release.js +12 -12
  38. package/dist/src/backlog/backlog-structure.js +114 -61
  39. package/dist/src/backlog/backlog.js +3 -2
  40. package/dist/src/baseline/main-baseline.js +148 -84
  41. package/dist/src/baseline/main-guardrails.js +2 -2
  42. package/dist/src/baseline/main-watch.js +84 -50
  43. package/dist/src/brief/brief.js +26 -13
  44. package/dist/src/budget/budget-fallback-view.js +100 -243
  45. package/dist/src/budget/budget-wiring.js +29 -65
  46. package/dist/src/budget/budget.js +4 -5
  47. package/dist/src/budget/fallback-breaker.js +2 -2
  48. package/dist/src/build/build-check-detect.js +43 -14
  49. package/dist/src/build/build-check-events.js +2 -2
  50. package/dist/src/build/build-check-report.js +10 -0
  51. package/dist/src/build/build-check-scoped.js +40 -18
  52. package/dist/src/build/build-check.js +13 -5
  53. package/dist/src/build/build-stage.js +6 -4
  54. package/dist/src/change/change-data.js +1 -4
  55. package/dist/src/cli/cli-command-args.js +89 -52
  56. package/dist/src/cli/cli-completion-command.js +75 -0
  57. package/dist/src/cli/{config-commands.js → cli-config-commands.js} +82 -71
  58. package/dist/src/cli/cli-doctor-command.js +103 -0
  59. package/dist/src/cli/cli-flag-specs.js +42 -22
  60. package/dist/src/cli/cli-init.js +36 -14
  61. package/dist/src/cli/{log-commands.js → cli-log-commands.js} +43 -23
  62. package/dist/src/cli/{notify-command.js → cli-notify-command.js} +2 -2
  63. package/dist/src/cli/cli-query-commands.js +2 -2
  64. package/dist/src/cli/{question-commands.js → cli-question-commands.js} +86 -60
  65. package/dist/src/cli/cli-repo-gate.js +2 -2
  66. package/dist/src/cli/cli-role-command.js +2 -41
  67. package/dist/src/cli/cli-start-summary.js +4 -3
  68. package/dist/src/cli/cli-start-supervisor.js +190 -0
  69. package/dist/src/cli/cli-start.js +94 -244
  70. package/dist/src/cli/completion.js +85 -49
  71. package/dist/src/cli/follow-view.js +20 -2
  72. package/dist/src/cli/help.js +6 -13
  73. package/dist/src/cli.js +70 -176
  74. package/dist/src/collections.js +39 -27
  75. package/dist/src/concurrency/check-slots.js +70 -0
  76. package/dist/src/concurrency/lock.js +23 -1
  77. package/dist/src/concurrency/run-permit.js +49 -0
  78. package/dist/src/concurrency/semaphore.js +46 -250
  79. package/dist/src/config/config-editable-keys.js +4 -7
  80. package/dist/src/config/config-fallbacks.js +81 -101
  81. package/dist/src/config/config-field-checks.js +66 -37
  82. package/dist/src/config/config-keys.js +66 -40
  83. package/dist/src/config/config-live.js +4 -28
  84. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  85. package/dist/src/config/config-request.js +163 -102
  86. package/dist/src/config/config-schema.js +10 -12
  87. package/dist/src/config/config-selector-checks.js +1 -1
  88. package/dist/src/config/config-validation-agents.js +2 -2
  89. package/dist/src/config/config-validation-roles.js +8 -72
  90. package/dist/src/config/config-validation.js +4 -39
  91. package/dist/src/config/config-views.js +26 -50
  92. package/dist/src/config/config-write.js +128 -102
  93. package/dist/src/config/config.js +5 -14
  94. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  95. package/dist/src/doctor/doctor-backlog-plans.js +53 -120
  96. package/dist/src/doctor/doctor-backlog.js +16 -14
  97. package/dist/src/doctor/doctor-check-names.js +3 -6
  98. package/dist/src/doctor/doctor-checks.js +8 -8
  99. package/dist/src/doctor/doctor-claude-checks.js +70 -36
  100. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  101. package/dist/src/doctor/doctor-contention-checks.js +57 -117
  102. package/dist/src/doctor/doctor-fallback-checks.js +48 -26
  103. package/dist/src/doctor/doctor-holds.js +8 -47
  104. package/dist/src/doctor/doctor-install.js +1 -1
  105. package/dist/src/doctor/doctor-launch-services.js +1 -1
  106. package/dist/src/doctor/doctor-model-checks.js +178 -94
  107. package/dist/src/doctor/doctor-notify.js +2 -2
  108. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  109. package/dist/src/doctor/doctor-orphans.js +122 -21
  110. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  111. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  112. package/dist/src/doctor/doctor-principles.js +3 -3
  113. package/dist/src/doctor/doctor-project-checks.js +24 -11
  114. package/dist/src/doctor/doctor-role-notes.js +1 -1
  115. package/dist/src/doctor/doctor.js +8 -11
  116. package/dist/src/events/event-format.js +44 -64
  117. package/dist/src/events/event-labels.js +1 -5
  118. package/dist/src/events/event-legacy.js +3 -5
  119. package/dist/src/events/{notify.js → event-notify.js} +6 -6
  120. package/dist/src/events/event-read.js +1 -1
  121. package/dist/src/events/event-window.js +1 -1
  122. package/dist/src/events/events.js +8 -0
  123. package/dist/src/failure/failure-cluster.js +9 -18
  124. package/dist/src/failure/failure-data.js +12 -8
  125. package/dist/src/failure/failure-render.js +46 -36
  126. package/dist/src/failure/failure-state-change.js +54 -120
  127. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  128. package/dist/src/files/files.js +30 -0
  129. package/dist/src/files/json-object.js +8 -8
  130. package/dist/src/files/stat-cache.js +12 -8
  131. package/dist/src/gates/budget-gates.js +85 -84
  132. package/dist/src/gates/gate-polls.js +32 -74
  133. package/dist/src/gates/pause-gates.js +16 -2
  134. package/dist/src/gates/readiness.js +1 -1
  135. package/dist/src/gates/role-cap-gates.js +44 -35
  136. package/dist/src/gates/{startup-gate.js → startup-gates.js} +9 -8
  137. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  138. package/dist/src/git/git-diff.js +6 -2
  139. package/dist/src/git/git.js +75 -45
  140. package/dist/src/git/worktree-pool.js +3 -6
  141. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  142. package/dist/src/gui/gui-args.js +12 -0
  143. package/dist/src/gui/gui-backlog-commands.js +4 -4
  144. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  145. package/dist/src/gui/gui-command.js +43 -24
  146. package/dist/src/gui/gui-config-commands.js +2 -64
  147. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  148. package/dist/src/gui/gui-endpoint-common.js +19 -1
  149. package/dist/src/gui/gui-history-endpoints.js +100 -52
  150. package/dist/src/gui/gui-operator-commands.js +43 -48
  151. package/dist/src/gui/gui-prompt-commands.js +25 -25
  152. package/dist/src/gui/gui-report-endpoints.js +1 -1
  153. package/dist/src/gui/gui-server.js +1 -2
  154. package/dist/src/gui/gui-team-commands.js +2 -1
  155. package/dist/src/history/history-data.js +71 -44
  156. package/dist/src/history/history-queued.js +49 -27
  157. package/dist/src/history/history.js +54 -36
  158. package/dist/src/inbox/prompt-commands.js +59 -42
  159. package/dist/src/init/init.js +88 -40
  160. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  161. package/dist/src/merge/merge-check-failures.js +12 -9
  162. package/dist/src/merge/merge-core.js +7 -32
  163. package/dist/src/merge/merge-drain.js +11 -13
  164. package/dist/src/merge/merge-pipeline.js +6 -7
  165. package/dist/src/merge/merge-post-review-drain.js +11 -14
  166. package/dist/src/merge/merge-queue-render.js +3 -3
  167. package/dist/src/merge/merge-requeue.js +1 -1
  168. package/dist/src/merge/merge-slot.js +0 -8
  169. package/dist/src/merge/merge-stack.js +17 -18
  170. package/dist/src/merge/merge-to-main.js +13 -58
  171. package/dist/src/merge/merge-verify.js +12 -21
  172. package/dist/src/notes-cli.js +52 -42
  173. package/dist/src/operator/operator-commands.js +80 -47
  174. package/dist/src/operator/operator-intent.js +3 -3
  175. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  176. package/dist/src/orchestrator/orchestrator-poll.js +44 -69
  177. package/dist/src/orchestrator/orchestrator-scheduling.js +5 -9
  178. package/dist/src/orchestrator/orchestrator.js +8 -21
  179. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  180. package/dist/src/orchestrator/scheduling-scope.js +18 -27
  181. package/dist/src/orchestrator/scheduling-skip.js +10 -14
  182. package/dist/src/orchestrator/startup-migrations.js +1 -1
  183. package/dist/src/paths.js +10 -9
  184. package/dist/src/pi/pi-args.js +5 -2
  185. package/dist/src/pi/pi-backend.js +2 -2
  186. package/dist/src/pi/pi-bin.js +3 -3
  187. package/dist/src/pi/pi-event-line.js +28 -1
  188. package/dist/src/pi/pi-listed-models.js +13 -0
  189. package/dist/src/pi/pi-models.js +43 -29
  190. package/dist/src/pi/pi-settings.js +43 -20
  191. package/dist/src/pi/pi-stream.js +41 -8
  192. package/dist/src/pi/pi-watchdogs.js +43 -15
  193. package/dist/src/pi/pi.js +2 -2
  194. package/dist/src/pi-extension/context-shake.js +32 -20
  195. package/dist/src/pi-extension/kill-guard.js +69 -0
  196. package/dist/src/process/process-group.js +51 -26
  197. package/dist/src/process/process.js +20 -12
  198. package/dist/src/process/run-marker.js +33 -15
  199. package/dist/src/process/supervisor.js +40 -28
  200. package/dist/src/prompt/dialect.js +1 -1
  201. package/dist/src/prompt/gate-notes.js +2 -2
  202. package/dist/src/prompt/gate-prompts.js +1 -44
  203. package/dist/src/prompt/notes-backlog.js +66 -37
  204. package/dist/src/prompt/principles.js +6 -4
  205. package/dist/src/prompt/prompt-followup.js +12 -4
  206. package/dist/src/prompt/prompt.js +88 -38
  207. package/dist/src/redeploy/redeployer.js +21 -1
  208. package/dist/src/report/report-data.js +53 -37
  209. package/dist/src/report/report-fold.js +16 -9
  210. package/dist/src/report/report-render.js +21 -194
  211. package/dist/src/report/report-since-data.js +4 -3
  212. package/dist/src/report/report-since-render.js +173 -0
  213. package/dist/src/report/report-usage-fold.js +8 -8
  214. package/dist/src/report/report.js +46 -22
  215. package/dist/src/review/diff-rules.js +35 -21
  216. package/dist/src/review/post-merge-review-render.js +9 -3
  217. package/dist/src/review/post-merge-review.js +48 -41
  218. package/dist/src/review/review-precheck.js +14 -4
  219. package/dist/src/review/review.js +48 -170
  220. package/dist/src/review/secret-leaks.js +26 -16
  221. package/dist/src/roles/legacy-role-migration.js +6 -3
  222. package/dist/src/roles/role-catalog.js +11 -3
  223. package/dist/src/roles/role-guidance.js +16 -12
  224. package/dist/src/roles/role-render.js +7 -46
  225. package/dist/src/roles/role-view.js +10 -33
  226. package/dist/src/run/acceptance-greps.js +88 -45
  227. package/dist/src/run/ci-parity.js +19 -8
  228. package/dist/src/run/run-apply.js +12 -15
  229. package/dist/src/run/run-detail-data.js +121 -68
  230. package/dist/src/run/run-detail-render.js +92 -35
  231. package/dist/src/run/run-detail.js +72 -41
  232. package/dist/src/run/run-prompt.js +63 -50
  233. package/dist/src/run/run-resume.js +76 -29
  234. package/dist/src/run/run-stage.js +141 -90
  235. package/dist/src/run/run-timing.js +8 -2
  236. package/dist/src/run/run-usage.js +31 -1
  237. package/dist/src/run/run-verdict.js +128 -50
  238. package/dist/src/run/stage-check.js +60 -50
  239. package/dist/src/run/stage-checks-shared.js +28 -0
  240. package/dist/src/run/stage-name-checks.js +111 -92
  241. package/dist/src/run/stage-path-checks.js +69 -42
  242. package/dist/src/run/stage-prose-checks.js +55 -40
  243. package/dist/src/runner/{leftover.js → runner-leftover.js} +9 -26
  244. package/dist/src/runner/runner-outcome.js +7 -2
  245. package/dist/src/runner/runner-pi.js +9 -22
  246. package/dist/src/runner/runner-recovery.js +5 -13
  247. package/dist/src/runner/{revision.js → runner-revision.js} +1 -2
  248. package/dist/src/runner/runner-run.js +103 -50
  249. package/dist/src/runner/runner-state-load.js +18 -28
  250. package/dist/src/runner/runner-state.js +0 -14
  251. package/dist/src/runner/runner.js +41 -25
  252. package/dist/src/scheduling/backoff.js +8 -3
  253. package/dist/src/scheduling/claim-entries.js +2 -1
  254. package/dist/src/scheduling/claims.js +0 -4
  255. package/dist/src/scheduling/scheduling.js +53 -66
  256. package/dist/src/status/status-budget.js +4 -43
  257. package/dist/src/status/status-data.js +35 -39
  258. package/dist/src/status/status-role-notes.js +2 -2
  259. package/dist/src/status/status-roles.js +9 -18
  260. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  261. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  262. package/dist/src/team/team-hold.js +105 -70
  263. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +55 -36
  264. package/dist/src/team/team-polls.js +53 -201
  265. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  266. package/dist/src/team/team-state.js +4 -13
  267. package/dist/src/text/datetime.js +16 -7
  268. package/dist/src/text/markdown.js +1 -1
  269. package/dist/src/text/phrases.js +29 -15
  270. package/dist/src/text/suggest.js +1 -1
  271. package/dist/src/text/text.js +6 -5
  272. package/dist/src/ui/alert-feeds.js +46 -111
  273. package/dist/src/ui/badges.js +17 -88
  274. package/dist/src/ui/gui/gui-client-boot.js +2 -8
  275. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  276. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -33
  277. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  278. package/dist/src/ui/gui/gui-client-feed.js +1 -1
  279. package/dist/src/ui/gui/gui-client-history.js +28 -14
  280. package/dist/src/ui/gui/gui-client-model.js +0 -12
  281. package/dist/src/ui/gui/gui-client-operator.js +7 -5
  282. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  283. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  284. package/dist/src/ui/gui/gui-client-report.js +1 -1
  285. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  286. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  287. package/dist/src/ui/gui/gui-client-team.js +12 -6
  288. package/dist/src/ui/gui/gui-client.js +7 -1
  289. package/dist/src/ui/progress-data.js +107 -44
  290. package/dist/src/ui/run-progress-model.js +20 -4
  291. package/dist/src/ui/status-model.js +2 -24
  292. package/dist/src/ui/status-payload.js +11 -30
  293. package/dist/src/ui/status-render.js +13 -24
  294. package/dist/src/ui/team-alert-backlog.js +14 -204
  295. package/dist/src/ui/team-alert-shared.js +6 -29
  296. package/dist/src/ui/team-alert-status.js +9 -170
  297. package/dist/src/ui/team-alert-tail.js +19 -215
  298. package/dist/src/ui/team-alerts.js +8 -15
  299. package/dist/src/ui/transcript-run.js +27 -16
  300. package/dist/src/ui/tui/tui-backlog.js +2 -2
  301. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  302. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  303. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  304. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  305. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  306. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  307. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  308. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  309. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  310. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  311. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  312. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  313. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  314. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  315. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  316. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  317. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  318. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  319. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  320. package/dist/src/ui/tui/tui-editors.js +5 -10
  321. package/dist/src/ui/tui/tui-frame.js +182 -134
  322. package/dist/src/ui/tui/tui-guard.js +20 -0
  323. package/dist/src/ui/tui/tui-input.js +167 -98
  324. package/dist/src/ui/tui/tui-keymap.js +51 -29
  325. package/dist/src/ui/tui/tui-keys.js +1 -1
  326. package/dist/src/ui/tui/tui-pane-activity.js +5 -4
  327. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  328. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  329. package/dist/src/ui/tui/tui-pane-health.js +252 -230
  330. package/dist/src/ui/tui/tui-pane-history.js +67 -74
  331. package/dist/src/ui/tui/tui-pane-pending.js +288 -247
  332. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  333. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  334. package/dist/src/ui/tui/tui-pane-session.js +15 -10
  335. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  336. package/dist/src/ui/tui/tui-pane.js +14 -11
  337. package/dist/src/ui/tui/tui-panes.js +38 -33
  338. package/dist/src/ui/tui/tui.js +4 -7
  339. package/dist/src/verdict/fix-claim.js +107 -56
  340. package/dist/src/verdict/refusal.js +1 -1
  341. package/dist/src/verdict/reply-contract.js +3 -4
  342. package/package.json +5 -5
  343. package/dist/src/concurrency/provider-lease.js +0 -105
  344. package/dist/src/review/review-followup.js +0 -87
  345. package/dist/src/review/review-run.js +0 -395
  346. package/dist/src/runner/model-fallback.js +0 -76
  347. package/dist/src/runner/runner-fallback.js +0 -66
  348. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  349. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  350. /package/dist/src/cli/{brief-command.js → cli-brief-command.js} +0 -0
  351. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  352. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  353. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  354. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
@@ -1,4 +1,4 @@
1
- import { parseDurationMs } from "../../cli/cli-args.js";
1
+ import { durationLabel, parseDurationMs } from "../../cli/cli-args.js";
2
2
  import { PAUSE_FOR_MAX_MS } from "../../operator/operator-intent.js";
3
3
  import { cutSplitsSurrogatePair, gotSuffix } from "../../text/text.js";
4
4
  import { displayWidth } from "../../text/text-width.js";
@@ -10,6 +10,16 @@ function snapCursor(text, cursor) {
10
10
  const clamp = Math.max(0, Math.min(cursor, text.length));
11
11
  return cutSplitsSurrogatePair(text, clamp) ? clamp - 1 : clamp;
12
12
  }
13
+ /** Move the cursor one whole code point in `dir` (`-1` left, `+1` right), clamped to the
14
+ * text's ends — the one home of the surrogate-step rule applyKey's left and right arms
15
+ * shared, so neither direction can ever land between a surrogate pair's halves (a cursor
16
+ * there would let a later backspace/delete cut the pair in half). The step backs off one
17
+ * unit past the clamp when the landed index would split a pair, crossing the whole astral
18
+ * character instead. */
19
+ function stepCursor(text, cursor, dir) {
20
+ const n = dir === -1 ? Math.max(0, cursor - 1) : Math.min(text.length, cursor + 1);
21
+ return cutSplitsSurrogatePair(text, n) ? n + dir : n;
22
+ }
13
23
  /** The character immediately before index `i`, as its whole [start, i) span: a surrogate
14
24
  * pair is never split, so the backward word-scan killWordBefore runs on always steps a
15
25
  * whole character and its boundary lands where a cut is safe. Null at the text's start. */
@@ -19,6 +29,16 @@ function prevChar(text, i) {
19
29
  const start = cutSplitsSurrogatePair(text, i - 1) ? i - 2 : i - 1;
20
30
  return { start, ch: text.slice(start, i) };
21
31
  }
32
+ /** Delete the whole character (surrogate pair or single unit) that starts at code-unit index
33
+ * `start` and land the cursor on the deleted range's start — the shared shape of applyKey's
34
+ * backspace and delete arms, which differ only in where the removed character sits relative
35
+ * to the cursor. A surrogate pair is deleted as both units so no lone surrogate — which
36
+ * terminals render as a U+FFFD box — is ever left behind (the surrogate-safe rule snapCursor
37
+ * and prevChar enforce elsewhere). */
38
+ function removeCharAt(text, start) {
39
+ const width = cutSplitsSurrogatePair(text, start + 1) ? 2 : 1;
40
+ return { text: text.slice(0, start) + text.slice(start + width), cursor: start };
41
+ }
22
42
  /** Backward word kill (readline's unix-word-rubout, Alt+Backspace): whitespace immediately
23
43
  * before the cursor goes first, then the non-whitespace token that precedes it — on
24
44
  * "configure the loop" it kills " loop", leaving "configure the". The whitespace-first
@@ -33,6 +53,77 @@ function killWordBefore(text, c) {
33
53
  i = p.start;
34
54
  return { text: text.slice(0, i) + text.slice(c), cursor: i };
35
55
  }
56
+ /** The cursor-movement keys: left/right step one whole code point, home/end jump to the
57
+ * text's start/end. Returns undefined for any other key so the caller's chain falls
58
+ * through to the next helper. */
59
+ function moveKey(text, c, key) {
60
+ switch (key.name) {
61
+ case "left":
62
+ return { text, cursor: stepCursor(text, c, -1) };
63
+ case "right":
64
+ return { text, cursor: stepCursor(text, c, 1) };
65
+ case "home":
66
+ return { text, cursor: 0 };
67
+ case "end":
68
+ return { text, cursor: text.length };
69
+ default:
70
+ return undefined;
71
+ }
72
+ }
73
+ /** The character-deletion keys: backspace removes the character before the cursor (or, with
74
+ * Alt+Backspace, the whole word before it — readline's unix-word-rubout), delete removes the
75
+ * one at it. Each deletes a surrogate pair whole so no lone surrogate is left behind.
76
+ * Returns undefined for any other key so the caller's chain falls through. */
77
+ function deleteKey(text, c, key) {
78
+ switch (key.name) {
79
+ case "backspace":
80
+ if (key.meta)
81
+ return killWordBefore(text, c); // Alt+Backspace: kill the previous word
82
+ if (c === 0)
83
+ return { text, cursor: 0 };
84
+ // The character before the cursor is a surrogate pair [c-2, c-1]: delete both units.
85
+ return removeCharAt(text, cutSplitsSurrogatePair(text, c - 1) ? c - 2 : c - 1);
86
+ case "delete":
87
+ if (c >= text.length)
88
+ return { text, cursor: c };
89
+ // The character at the cursor is a surrogate pair [c, c+1]: delete both units.
90
+ return removeCharAt(text, c);
91
+ default:
92
+ return undefined;
93
+ }
94
+ }
95
+ /** The line-kill keys, both gated on Ctrl so a plain "u"/"k" never kills: Ctrl+U discards
96
+ * from the line start to the cursor, Ctrl+K from the cursor to the line end (readline's
97
+ * two directions of half-line discard). Returns undefined when not Ctrl or not one of these
98
+ * keys so the caller's chain falls through. */
99
+ function lineKillKey(text, c, key) {
100
+ if (!key.ctrl)
101
+ return undefined;
102
+ switch (key.name) {
103
+ case "u":
104
+ // Ctrl+U (readline's unix-line-discard) kills from the line start to the cursor; a
105
+ // plain "u" is not this key and reaches the printable insert in applyKey, unchanged.
106
+ return { text: text.slice(c), cursor: 0 };
107
+ case "k":
108
+ // Ctrl+K (readline's kill-line) kills from the cursor to the line end; a plain "k"
109
+ // is not this key and reaches the printable insert in applyKey, unchanged.
110
+ return { text: text.slice(0, c), cursor: c };
111
+ default:
112
+ return undefined;
113
+ }
114
+ }
115
+ /** Printable insertion: any plain printable string is inserted at the cursor (str can hold a
116
+ * whole composed string when IME input arrives as one keypress, advancing the cursor by its
117
+ * full length); every other — including control/meta and empty — key leaves text and the
118
+ * snapped cursor as they are. This is the chain's last member, so its return is total. */
119
+ function insertKey(text, c, str, key) {
120
+ if (str && !key.ctrl && !key.meta && str >= " ") {
121
+ // str can hold a whole composed string (IME input arrives as one keypress); the cursor
122
+ // must land after ALL of it, not one unit in.
123
+ return { text: text.slice(0, c) + str + text.slice(c), cursor: c + str.length };
124
+ }
125
+ return { text, cursor: c };
126
+ }
36
127
  /** Apply one keypress to the prompt text (pure, so it is unit-testable without a TTY).
37
128
  * Printable characters insert at the cursor — including multi-character strings readline
38
129
  * delivers for IME-composed input, which advance the cursor by their full length; backspace
@@ -54,57 +145,10 @@ function killWordBefore(text, c) {
54
145
  * the pair in half). */
55
146
  export function applyKey(text, cursor, str, key) {
56
147
  const c = snapCursor(text, cursor);
57
- switch (key.name) {
58
- case "left": {
59
- const n = Math.max(0, c - 1);
60
- // A cut at n would land inside a pair: step over the whole astral character instead.
61
- return { text, cursor: cutSplitsSurrogatePair(text, n) ? n - 1 : n };
62
- }
63
- case "right": {
64
- const n = Math.min(text.length, c + 1);
65
- return { text, cursor: cutSplitsSurrogatePair(text, n) ? n + 1 : n };
66
- }
67
- case "backspace":
68
- if (key.meta)
69
- return killWordBefore(text, c); // Alt+Backspace: kill the previous word
70
- if (c === 0)
71
- return { text, cursor: 0 };
72
- // The character before the cursor is a surrogate pair [c-2, c-1]: delete both units.
73
- if (cutSplitsSurrogatePair(text, c - 1)) {
74
- return { text: text.slice(0, c - 2) + text.slice(c), cursor: c - 2 };
75
- }
76
- return { text: text.slice(0, c - 1) + text.slice(c), cursor: c - 1 };
77
- case "home":
78
- return { text, cursor: 0 };
79
- case "end":
80
- return { text, cursor: text.length };
81
- case "u":
82
- // Ctrl+U (readline's unix-line-discard) kills from the line start to the cursor; a
83
- // plain "u" breaks here and reaches the printable insert branch below, unchanged.
84
- if (key.ctrl)
85
- return { text: text.slice(c), cursor: 0 };
86
- break;
87
- case "k":
88
- // Ctrl+K (readline's kill-line) kills from the cursor to the line end; a plain "k"
89
- // breaks here and reaches the printable insert branch below, unchanged.
90
- if (key.ctrl)
91
- return { text: text.slice(0, c), cursor: c };
92
- break;
93
- case "delete":
94
- if (c >= text.length)
95
- return { text, cursor: c };
96
- // The character at the cursor is a surrogate pair [c, c+1]: delete both units.
97
- if (cutSplitsSurrogatePair(text, c + 1)) {
98
- return { text: text.slice(0, c) + text.slice(c + 2), cursor: c };
99
- }
100
- return { text: text.slice(0, c) + text.slice(c + 1), cursor: c };
101
- }
102
- if (str && !key.ctrl && !key.meta && str >= " ") {
103
- // str can hold a whole composed string (IME input arrives as one keypress); the cursor
104
- // must land after ALL of it, not one unit in.
105
- return { text: text.slice(0, c) + str + text.slice(c), cursor: c + str.length };
106
- }
107
- return { text, cursor: c };
148
+ return (moveKey(text, c, key) ??
149
+ deleteKey(text, c, key) ??
150
+ lineKillKey(text, c, key) ??
151
+ insertKey(text, c, str, key));
108
152
  }
109
153
  /** Five parsers below signal an invalid value through the same `{ ok: false, error }` arm
110
154
  * of a `{ ok: true, … } | { ok: false, error: string }` result. `fail` is that arm's one
@@ -146,12 +190,14 @@ export function parseBudgetInput(text) {
146
190
  return fail(`budget must be a number of 0 or more, at most ${Number.MAX_SAFE_INTEGER}${gotSuffix(text)}`);
147
191
  return { ok: true, value: n };
148
192
  }
149
- /** The 90d cap refusal every duration editor shares — the same over-cap arm (`ms` past
150
- * PAUSE_FOR_MAX_MS, the limit `pause --for`/`wake --in`/`stop --in` all admit), spelled once
151
- * per verb so the wording cannot drift between the four parsers. Returns the error result
152
- * when `ms` exceeds the cap, else null so the caller keeps its ok result. */
193
+ /** The cap refusal every duration editor shares — the same over-cap arm (`ms` past
194
+ * PAUSE_FOR_MAX_MS, the limit `pause --for`/`wake --in`/`stop --in` all admit), labeled
195
+ * through durationLabel(PAUSE_FOR_MAX_MS) so the message can never advertise a bound the
196
+ * constant does not hold (the CLI surfaces already label their cap the same way), spelled
197
+ * once per verb so the wording cannot drift between the four parsers. Returns the error
198
+ * result when `ms` exceeds the cap, else null so the caller keeps its ok result. */
153
199
  function overDurationCap(ms, verb) {
154
- return ms > PAUSE_FOR_MAX_MS ? fail(`${verb} is capped at 90d`) : null;
200
+ return ms > PAUSE_FOR_MAX_MS ? fail(`${verb} is capped at ${durationLabel(PAUSE_FOR_MAX_MS)}`) : null;
155
201
  }
156
202
  /** Parse a duration with parseDurationMs and apply the shared 90d cap in one step, mapping
157
203
  * both a non-duration spelling (`bad`, caller-specific wording) and an over-cap value to an
@@ -260,6 +306,62 @@ export function renderInputView(text, cursor, width) {
260
306
  // showing the character being edited matters more than signalling clipped text.
261
307
  return displayWidth(withEllipsis) <= width - 2 ? withEllipsis : slice;
262
308
  }
309
+ /** The code-unit start of each character in `chars`: `starts[k]` is how many UTF-16
310
+ * code units precede character `k` (a surrogate pair is two). The cursor is always on a
311
+ * character boundary (applyKey snaps mid-pair cursors; BMP characters are one unit each),
312
+ * so it names a character. */
313
+ function characterStarts(chars) {
314
+ const starts = [];
315
+ let units = 0;
316
+ for (const ch of chars) {
317
+ starts.push(units);
318
+ units += ch.length;
319
+ }
320
+ return starts;
321
+ }
322
+ /** The display columns before each character: `prefix[k]` = columns before character `k`,
323
+ * where each character costs its displayWidth (a CJK character two, an astral pair never
324
+ * measured as halves). */
325
+ function displayPrefix(chars) {
326
+ const prefix = [0];
327
+ for (let k = 0; k < chars.length; k++)
328
+ prefix.push(prefix[k] + displayWidth(chars[k]));
329
+ return prefix;
330
+ }
331
+ /** The character the cursor (already snapped to a boundary, at code-unit `c`) sits on, or
332
+ * `chars.length` past the last as a virtual end marker. */
333
+ function cursorCharIndex(starts, chars, c) {
334
+ for (let k = 0; k < chars.length; k++) {
335
+ if (starts[k] <= c && c < starts[k] + chars[k].length)
336
+ return k;
337
+ }
338
+ return chars.length;
339
+ }
340
+ /** The left extent of the window: starts at the cursor's character and extends left while
341
+ * the column budget holds, so the cursor's character ends up at the right edge. */
342
+ function leftWindowStart(prefix, cursorEnd, room) {
343
+ let startCp = cursorEnd;
344
+ while (startCp > 0 && prefix[cursorEnd] - prefix[startCp - 1] <= room)
345
+ startCp--;
346
+ return startCp;
347
+ }
348
+ /** The right fill of the window: when the window is anchored at the text's start (cursor
349
+ * near the beginning), fill rightward from the cursor's character up to the budget. */
350
+ function rightWindowEnd(prefix, startCp, cursorEnd, n, room) {
351
+ let endCp = cursorEnd;
352
+ if (startCp === 0 && endCp < n) {
353
+ while (endCp < n && prefix[endCp + 1] - prefix[0] <= room)
354
+ endCp++;
355
+ }
356
+ return endCp;
357
+ }
358
+ /** The whole-character overflow fallback: the smallest window around the cursor when the
359
+ * character it sits on alone exceeds `room` — that whole character (two columns when
360
+ * wide), or the whole character before it at end of text. */
361
+ function wholeCharSpan(starts, chars, ci, n) {
362
+ const k = ci < n ? ci : n - 1;
363
+ return { start: starts[k], end: starts[k] + chars[k].length };
364
+ }
263
365
  /** The code-unit window `[start, end)` renderInputView shows for a prompt line wider than
264
366
  * its `room` display columns. The budget is spent in terminal display columns — each
265
367
  * character costs its displayWidth, so a CJK character costs two and astral pairs are never
@@ -275,50 +377,17 @@ export function renderInputView(text, cursor, width) {
275
377
  * longer both fit. Pure, so it is unit-testable without a TTY. */
276
378
  export function inputViewWindow(text, cursor, room) {
277
379
  const c = snapCursor(text, cursor);
278
- // Per-character decomposition: where each code point starts (in units) and how many
279
- // terminal columns it renders as. The cursor is always on a character boundary (applyKey
280
- // snaps mid-pair cursors; BMP characters are one unit each), so it names a character.
281
380
  const chars = Array.from(text);
282
381
  const n = chars.length;
283
- const starts = [];
284
- let units = 0;
285
- for (const ch of chars) {
286
- starts.push(units);
287
- units += ch.length;
288
- }
289
- const prefix = [0]; // prefix[k] = display columns before character k
290
- for (let k = 0; k < n; k++)
291
- prefix.push(prefix[k] + displayWidth(chars[k]));
292
- // The character the cursor sits on; past the last character it is a virtual end marker.
293
- let ci = n;
294
- for (let k = 0; k < n; k++) {
295
- if (starts[k] <= c && c < starts[k] + chars[k].length) {
296
- ci = k;
297
- break;
298
- }
299
- }
300
- // Window ending at the cursor's character, extended left while the column budget holds
301
- // (cursor at the right edge); anchored at the text's start instead when the cursor is
302
- // near the beginning, then filled rightward up to the budget.
382
+ const starts = characterStarts(chars);
383
+ const prefix = displayPrefix(chars);
384
+ const ci = cursorCharIndex(starts, chars, c);
303
385
  const cursorEnd = Math.min(n, ci + 1);
304
- let startCp = cursorEnd;
305
- while (startCp > 0 && prefix[cursorEnd] - prefix[startCp - 1] <= room)
306
- startCp--;
307
- let endCp = cursorEnd;
308
- if (startCp === 0 && endCp < n) {
309
- while (endCp < n && prefix[endCp + 1] - prefix[0] <= room)
310
- endCp++;
311
- }
312
- let start = startCp < n ? starts[startCp] : units;
313
- let end = endCp < n ? starts[endCp] : units;
314
- if (end <= start && n > 0) {
315
- // The character under the cursor alone exceeds the budget: show that whole character
316
- // (two columns when wide), or the whole character before it at end of text.
317
- const k = ci < n ? ci : n - 1;
318
- start = starts[k];
319
- end = starts[k] + chars[k].length;
320
- }
321
- return { start, end };
386
+ const startCp = leftWindowStart(prefix, cursorEnd, room);
387
+ const endCp = rightWindowEnd(prefix, startCp, cursorEnd, n, room);
388
+ const start = startCp < n ? starts[startCp] : text.length;
389
+ const end = endCp < n ? starts[endCp] : text.length;
390
+ return end <= start && n > 0 ? wholeCharSpan(starts, chars, ci, n) : { start, end };
322
391
  }
323
392
  /** The runTui start guard's error message: names the missing stream — a piped stdin and a
324
393
  * redirected stdout are distinct failures (the former cannot take typed prompts, the latter
@@ -3,6 +3,50 @@
3
3
  * tui-keys.ts's createTuiKeys consumes the readline (str, key) shape — this module mints
4
4
  * those names and classifies the vertical directions the dispatch branches steer by. Pure,
5
5
  * so every mapping is unit-testable without ink mounted. */
6
+ /** The flag-named keys in their chain order, each an ink flag and the readline name it maps
7
+ * to — the ordered table the inkKeyToReadline chain used to hand-roll as a flat if-chain.
8
+ * Named keys win first (ink sets `ctrl` alongside arrows too). */
9
+ const NAMED_KEYS = [
10
+ ["delete", "delete"],
11
+ ["tab", "tab"],
12
+ ["upArrow", "up"],
13
+ ["downArrow", "down"],
14
+ ["leftArrow", "left"],
15
+ ["rightArrow", "right"],
16
+ ["pageUp", "pageup"],
17
+ ["pageDown", "pagedown"],
18
+ ["home", "home"],
19
+ ["end", "end"],
20
+ ];
21
+ /** The name a flag-named key maps to — the backspace/backspace+meta pair first, then the
22
+ * NAMED_KEYS table scan (an arrow that also carries `ctrl` still returns the arrow name,
23
+ * because this runs before controlKey). undefined when no named-key flag matches. */
24
+ function namedKeyName(key) {
25
+ if (key.backspace) {
26
+ return key.meta ? { name: "backspace", meta: true } : { name: "backspace" };
27
+ }
28
+ for (const [flag, name] of NAMED_KEYS) {
29
+ if (key[flag])
30
+ return { name };
31
+ }
32
+ return undefined;
33
+ }
34
+ /** The modifier-chord shape a Ctrl/Alt key maps to, or undefined when neither matches — the
35
+ * Ctrl branch (readline `ctrl: true` plus the bare lowercased letter from `input`), then the
36
+ * Alt single-char branch (`meta: true` plus the lowercased `input`). */
37
+ function controlKey(input, key) {
38
+ if (key.ctrl)
39
+ return { ctrl: true, name: input.toLowerCase() };
40
+ if (key.meta && input.length === 1) {
41
+ return { meta: true, name: input.toLowerCase() };
42
+ }
43
+ return undefined;
44
+ }
45
+ /** The printable/empty fallback: text with no key flags flows through as `str`, and an empty
46
+ * non-key input (ink suppresses text for unrecognized sequences) is inert (str undefined). */
47
+ function printableKey(input) {
48
+ return { str: input || undefined, key: {} };
49
+ }
6
50
  /** Map one ink `useInput` (input, key) pair to the shape handleKey consumes — ink's parsed
7
51
  * flags and input text become the readline (str, key) pair the 2a dispatch was extracted
8
52
  * with. Pure, so the mapping is unit-testable without ink mounted. Named keys win first
@@ -14,35 +58,13 @@ export function inkKeyToReadline(input, key) {
14
58
  return { str: undefined, key: { name: "escape" } };
15
59
  if (key.return)
16
60
  return { str: undefined, key: { name: "return" } };
17
- if (key.backspace) {
18
- return { str: undefined, key: key.meta ? { name: "backspace", meta: true } : { name: "backspace" } };
19
- }
20
- if (key.delete)
21
- return { str: undefined, key: { name: "delete" } };
22
- if (key.tab)
23
- return { str: undefined, key: { name: "tab" } };
24
- if (key.upArrow)
25
- return { str: undefined, key: { name: "up" } };
26
- if (key.downArrow)
27
- return { str: undefined, key: { name: "down" } };
28
- if (key.leftArrow)
29
- return { str: undefined, key: { name: "left" } };
30
- if (key.rightArrow)
31
- return { str: undefined, key: { name: "right" } };
32
- if (key.pageUp)
33
- return { str: undefined, key: { name: "pageup" } };
34
- if (key.pageDown)
35
- return { str: undefined, key: { name: "pagedown" } };
36
- if (key.home)
37
- return { str: undefined, key: { name: "home" } };
38
- if (key.end)
39
- return { str: undefined, key: { name: "end" } };
40
- if (key.ctrl)
41
- return { str: undefined, key: { ctrl: true, name: input.toLowerCase() } };
42
- if (key.meta && input.length === 1) {
43
- return { str: undefined, key: { meta: true, name: input.toLowerCase() } };
44
- }
45
- return { str: input || undefined, key: {} };
61
+ const named = namedKeyName(key);
62
+ if (named)
63
+ return { str: undefined, key: named };
64
+ const chord = controlKey(input, key);
65
+ if (chord)
66
+ return { str: undefined, key: chord };
67
+ return printableKey(input);
46
68
  }
47
69
  /** The `"up" | "down"` direction an arrow key names — Up/Down only, so the branches that
48
70
  * reserve the arrows for one purpose (entry browse, history recall) reject the page keys —
@@ -9,7 +9,7 @@ import { loadConfigSafe } from "../../config/config.js";
9
9
  import { MERGE_LOCK_WAIT_MS } from "../../paths.js";
10
10
  import { commitOperatorBacklogEdit } from "../../backlog/backlog-commit.js";
11
11
  import { stampedSubject } from "../../git/commit-message.js";
12
- import { previewRevert, revertMergedCommit } from "../../operator/revert.js";
12
+ import { previewRevert, revertMergedCommit } from "../../operator/operator-revert.js";
13
13
  import { requestAbort } from "../../operator/operator-intent.js";
14
14
  import { errorMessage } from "../../text/text.js";
15
15
  import { DIRECTOR_ROLE } from "../../roles/roles.js";
@@ -2,10 +2,11 @@ import { activityEvents } from "./tui-pane.js";
2
2
  import { stepEntryScroll } from "./tui-backlog.js";
3
3
  import { pageDir } from "./tui-keymap.js";
4
4
  import { promptLineFree } from "./tui-pane-ctx.js";
5
+ import { distinctOrdered } from "../../collections.js";
5
6
  /** The Activity pane (view 0) of the TUI (src/ui/tui/tui.tsx): the session live-tail scope
6
- * (Ctrl+G), the free-text search (`/`), and the PgUp/PgDn tail-paging arm. Split out of the
7
- * pane-navigation monolith; it owns the pane's own scroll/scope/search state and the arms
8
- * that touch them. */
7
+ * (Ctrl+G), the event type/notable cull cycle (Ctrl+O), the free-text search (`/`), and the
8
+ * PgUp/PgDn tail-paging arm. Split out of the pane-navigation monolith; it owns the pane's
9
+ * own scroll/scope/search state and the arms that touch them. */
9
10
  export function createActivityPane(session) {
10
11
  // The Activity pane's body-window start into the loaded event tail, or null to follow the
11
12
  // live tail (the newest eventBudget events). PgUp pauses follow and pages back; PgDn returns
@@ -60,7 +61,7 @@ export function createActivityPane(session) {
60
61
  const activityTypeCycle = (ctx) => {
61
62
  if (!promptLineFree(ctx.es))
62
63
  return false;
63
- const present = [...new Set(activityEvents(session.root, activityRole, activityGrep, null).map((e) => e.type))].sort();
64
+ const present = distinctOrdered(activityEvents(session.root, activityRole, activityGrep, null).map((e) => e.type)).sort();
64
65
  const slots = [
65
66
  { notable: false, type: null },
66
67
  { notable: true, type: null },
@@ -8,6 +8,7 @@ import { requestWake } from "../../operator/operator-intent.js";
8
8
  import { isOpenBacklogLabel, moveEntrySelection, pageMarkdownBody, visibleBacklogIndexes } from "./tui-backlog.js";
9
9
  import { arrowDir, pageDir } from "./tui-keymap.js";
10
10
  import { promptLineFree } from "./tui-pane-ctx.js";
11
+ import { guardError } from "./tui-guard.js";
11
12
  /** Resolve the selected plan/bug from the flat backlog list, deduping the four entry-action
12
13
  * arms' common prologue (Ctrl+X cancel, Ctrl+V move-to-top, Ctrl+R release, Ctrl+E edit): the
13
14
  * flat-index clamp and the empty / read-only-done / question guards, whose refusal flash
@@ -38,10 +39,13 @@ export function selectedEditableEntry(session, selectedEntry, verb) {
38
39
  return { kind, position, picked };
39
40
  }
40
41
  /** The project-status pane (view roleIds.length + 1) of the TUI (src/ui/tui/tui.tsx): the
41
- * entry browser's selection/scroll state, the up/down and PgUp/PgDn entry arms, and the
42
- * three entry-action arms (Ctrl+X cancel, Ctrl+V move-to-top and Ctrl+R release) — each
43
- * two-step arming except the one-press move — plus the answer/title-edit selection clear the
44
- * editors call back.
42
+ * entry browser's selection/scroll state, and its six key arms — the Ctrl+O Ready-only
43
+ * toggle, the two-press Ctrl+X cancel, the one-press Ctrl+V move-to-top, the two-press
44
+ * Ctrl+R release, the arrow up/down entry selection and the PgUp/PgDn body page — each
45
+ * one inner named helper owning its whole guard, flash wording and repaint, dispatched in
46
+ * order by `handleKey`. The entry-action arms are each two-step except the one-press move;
47
+ * the answer/title-edit selection clear the editors call back through
48
+ * clearBacklogSelection.
45
49
  * Split out of the pane-navigation monolith; it owns the pane's selection/scroll state and
46
50
  * the arms that touch them. */
47
51
  export function createBacklogPane(session) {
@@ -76,7 +80,7 @@ export function createBacklogPane(session) {
76
80
  // cancel/release read the same as the dashboard drawer's non-exiting action.
77
81
  const cancelArmed = (target, armedTitle) => {
78
82
  const { kind, position } = target;
79
- try {
83
+ guardError(session, () => {
80
84
  const claims = allLiveClaims(session.root);
81
85
  const outcome = tryCancelEntry(session.root, kind, position, armedTitle);
82
86
  const claimNote = outcome.status === "cancelled" ? cancelledClaimNote(claims.get(entryKey(outcome.title)) ?? null) : "";
@@ -90,10 +94,7 @@ export function createBacklogPane(session) {
90
94
  session.flashMessage(outcome.status === "cancelled"
91
95
  ? `cancelled ${kind} #${position}: ${target.title}${note === "" ? "" : ` — ${note}`}`
92
96
  : `that ${kind} was already removed or moved — nothing cancelled`);
93
- }
94
- catch (err) {
95
- session.flashError(err);
96
- }
97
+ });
97
98
  };
98
99
  /** ` — still held [...]` for the entry now at a 1-based position, "" when it is eligible. */
99
100
  const heldTailAt = (kind, position) => {
@@ -102,7 +103,7 @@ export function createBacklogPane(session) {
102
103
  return hold === null ? "" : ` — ${stillHeldNote(hold)}`;
103
104
  };
104
105
  const moveEntryToTop = (kind, position, title) => {
105
- try {
106
+ guardError(session, () => {
106
107
  const outcome = tryMoveEntryToTop(session.root, kind, position, title);
107
108
  let heldTail = "";
108
109
  if (outcome.status === "moved_to_top") {
@@ -116,14 +117,11 @@ export function createBacklogPane(session) {
116
117
  : outcome.status === "already_first"
117
118
  ? `that ${kind} is already at the top`
118
119
  : `that ${kind} was already removed or moved — nothing moved`);
119
- }
120
- catch (err) {
121
- session.flashError(err);
122
- }
120
+ });
123
121
  };
124
122
  const releaseArmed = (target, armedTitle) => {
125
123
  const { kind, position } = target;
126
- try {
124
+ guardError(session, () => {
127
125
  const outcome = tryReleaseEntry(session.root, kind, position, armedTitle);
128
126
  let heldTail = "";
129
127
  if (outcome.status === "released") {
@@ -139,15 +137,15 @@ export function createBacklogPane(session) {
139
137
  : outcome.status === "not_refused"
140
138
  ? `that ${kind} carries no Refused note`
141
139
  : `that ${kind} was already removed or moved — nothing released`);
142
- }
143
- catch (err) {
144
- session.flashError(err);
145
- }
140
+ });
146
141
  };
147
- const handleKey = (key, ctx) => {
148
- if (key.ctrl && key.name === "o" &&
149
- promptLineFree(ctx.es) &&
150
- session.view === session.roleIds.length + 1) {
142
+ // The Ctrl+O arm: flips the Ready-only filter to keep only the open plans and bugs no hold
143
+ // keeps from being picked, the terminal twin of `tumwater backlog --ready` and the dashboard
144
+ // Backlog card's Ready only toggle. A second press restores the full list. Returns false
145
+ // when the prompt is busy or the Backlog view is not showing, so the key falls through the
146
+ // dispatch as today.
147
+ const backlogToggleReady = (ctx) => {
148
+ if (promptLineFree(ctx.es) && session.view === session.roleIds.length + 1) {
151
149
  // Ctrl+O flips the Ready-only filter: keep only the open plans and bugs no hold keeps
152
150
  // from being picked (questions and the answered/done/fixed rows stay), the terminal twin
153
151
  // of `tumwater backlog --ready` and the dashboard Backlog card's Ready only toggle. A
@@ -160,9 +158,13 @@ export function createBacklogPane(session) {
160
158
  session.requestRender();
161
159
  return true;
162
160
  }
163
- if (key.ctrl && key.name === "x" &&
164
- promptLineFree(ctx.es) &&
165
- session.view === session.roleIds.length + 1) {
161
+ return false;
162
+ };
163
+ // The two-press Ctrl+X arm: cancels the selected plan or bug through the non-exiting core
164
+ // the dashboard drawer uses. Returns false when the prompt is busy or the Backlog view is
165
+ // not showing, so the key falls through the dispatch as today.
166
+ const backlogCancel = (ctx) => {
167
+ if (promptLineFree(ctx.es) && session.view === session.roleIds.length + 1) {
166
168
  // Ctrl+X twice cancels the selected plan or bug through the non-exiting core the
167
169
  // dashboard drawer uses; the title check inside tryCancelEntry refuses a stale selection.
168
170
  const selected = selectedEditableEntry(session, selectedEntry, "cancelled");
@@ -183,9 +185,13 @@ export function createBacklogPane(session) {
183
185
  session.requestRender();
184
186
  return true;
185
187
  }
186
- if (key.ctrl && key.name === "v" &&
187
- promptLineFree(ctx.es) &&
188
- session.view === session.roleIds.length + 1) {
188
+ return false;
189
+ };
190
+ // The one-press Ctrl+V arm: moves the selected plan or bug to the head of its section
191
+ // through the non-exiting core the dashboard drawer uses. Returns false when the prompt is
192
+ // busy or the Backlog view is not showing, so the key falls through the dispatch as today.
193
+ const backlogMoveToTop = (ctx) => {
194
+ if (promptLineFree(ctx.es) && session.view === session.roleIds.length + 1) {
189
195
  // Ctrl+V moves the selected plan or bug to the head of its section through the
190
196
  // non-exiting core the dashboard drawer uses; the title check inside tryMoveEntryToTop
191
197
  // refuses a stale selection. It is the terminal twin of `tumwater plan|bug --top <n>`
@@ -199,9 +205,13 @@ export function createBacklogPane(session) {
199
205
  session.requestRender();
200
206
  return true;
201
207
  }
202
- if (key.ctrl && key.name === "r" &&
203
- promptLineFree(ctx.es) &&
204
- session.view === session.roleIds.length + 1) {
208
+ return false;
209
+ };
210
+ // The two-press Ctrl+R arm: releases the Refused note on the selected plan or bug through
211
+ // the non-exiting core the dashboard drawer uses. Returns false when the prompt is busy or
212
+ // the Backlog view is not showing, so the key falls through the dispatch as today.
213
+ const backlogRelease = (ctx) => {
214
+ if (promptLineFree(ctx.es) && session.view === session.roleIds.length + 1) {
205
215
  // Ctrl+R twice releases the Refused
206
216
  // note on the selected plan or bug through the non-exiting core the dashboard drawer
207
217
  // uses; the title check inside tryReleaseEntry refuses a stale selection. Releasing is the
@@ -228,9 +238,12 @@ export function createBacklogPane(session) {
228
238
  session.requestRender();
229
239
  return true;
230
240
  }
231
- const arrow = arrowDir(key.name);
232
- const page = pageDir(key.name);
233
- if (session.view === session.roleIds.length + 1 && arrow !== null) {
241
+ return false;
242
+ };
243
+ // The up/down arm: browses entries in full instead of editing the prompt, stepping only
244
+ // through the search/Ready-only kept entries when either filter is applied.
245
+ const backlogSelect = (ctx, arrow) => {
246
+ if (session.view === session.roleIds.length + 1) {
234
247
  // In the project-status pane, up/down browse entries in full instead of editing the
235
248
  // prompt; every other view keeps today's behavior (arrows are ignored by applyKey).
236
249
  // An open answer editor pins the selection (its position was taken from it).
@@ -253,7 +266,11 @@ export function createBacklogPane(session) {
253
266
  session.requestRender();
254
267
  return true;
255
268
  }
256
- if (session.view === session.roleIds.length + 1 && page !== null) {
269
+ return false;
270
+ };
271
+ // The PgUp/PgDn arm: scrolls the selected entry's body within the Backlog view's entry mode.
272
+ const backlogPage = (ctx, page) => {
273
+ if (session.view === session.roleIds.length + 1) {
257
274
  // Within-body scroll in project-status ENTRY mode: PgDn/PgUp page the selected entry's
258
275
  // body, clamped at both ends. No-op in list mode and for bodies that fit — every other
259
276
  // view ignores these keys as today (applyKey drops them).
@@ -268,6 +285,27 @@ export function createBacklogPane(session) {
268
285
  }
269
286
  return false;
270
287
  };
288
+ const handleKey = (key, ctx) => {
289
+ // The Backlog view's six key arms live in the named helpers above, each owning its whole
290
+ // guard, flash wording and repaint; this body is only the flat ordered dispatch in today's
291
+ // arm order (Ctrl+O, Ctrl+X, Ctrl+V, Ctrl+R, up/down, PgUp/PgDn), so each key's behavior
292
+ // is found by name.
293
+ if (key.ctrl && key.name === "o")
294
+ return backlogToggleReady(ctx);
295
+ if (key.ctrl && key.name === "x")
296
+ return backlogCancel(ctx);
297
+ if (key.ctrl && key.name === "v")
298
+ return backlogMoveToTop(ctx);
299
+ if (key.ctrl && key.name === "r")
300
+ return backlogRelease(ctx);
301
+ const arrow = arrowDir(key.name);
302
+ if (arrow !== null)
303
+ return backlogSelect(ctx, arrow);
304
+ const page = pageDir(key.name);
305
+ if (page !== null)
306
+ return backlogPage(ctx, page);
307
+ return false;
308
+ };
271
309
  return {
272
310
  handleKey,
273
311
  reset() {