@defrex/autobuild 0.8.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 (251) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +192 -0
  3. package/SPEC.md +2269 -0
  4. package/bin/ab-build-runner.ts +47 -0
  5. package/bin/ab-dev.ts +27 -0
  6. package/bin/ab-dispatch-kernel.ts +92 -0
  7. package/bin/ab-harvest-runner.ts +29 -0
  8. package/bin/ab-init-probe.ts +19 -0
  9. package/bin/ab-process-group-reaper.ts +32 -0
  10. package/bin/ab.ts +6 -0
  11. package/bin/agent/ab +3 -0
  12. package/docs/answering-blocked-builds.md +89 -0
  13. package/docs/architecture.md +726 -0
  14. package/docs/assets/headline-wide.png +0 -0
  15. package/docs/configuration.md +1623 -0
  16. package/docs/hosted-dispatcher-cutover-validation.md +178 -0
  17. package/docs/hosted-dispatcher.md +204 -0
  18. package/docs/mcp.md +119 -0
  19. package/docs/operator-api.md +186 -0
  20. package/docs/remote-store-protocol.md +972 -0
  21. package/docs/remote-ticket-protocol.md +79 -0
  22. package/docs/setup.md +289 -0
  23. package/docs/spec-standard.md +63 -0
  24. package/docs/ticket-dependencies.md +59 -0
  25. package/docs/vercel-sandbox-rollout-probe.md +7 -0
  26. package/package.json +135 -0
  27. package/packages/core/src/cli/args.ts +74 -0
  28. package/packages/core/src/cli/artifact.ts +182 -0
  29. package/packages/core/src/cli/binary.ts +198 -0
  30. package/packages/core/src/cli/build-control.ts +789 -0
  31. package/packages/core/src/cli/build-progress.ts +55 -0
  32. package/packages/core/src/cli/bulk-control.ts +385 -0
  33. package/packages/core/src/cli/context.ts +470 -0
  34. package/packages/core/src/cli/dashboard/actions.ts +59 -0
  35. package/packages/core/src/cli/dashboard/cells.ts +92 -0
  36. package/packages/core/src/cli/dashboard/composer.ts +230 -0
  37. package/packages/core/src/cli/dashboard/detail.ts +100 -0
  38. package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
  39. package/packages/core/src/cli/dashboard/live.ts +152 -0
  40. package/packages/core/src/cli/dashboard/model.ts +1173 -0
  41. package/packages/core/src/cli/dashboard/poll.ts +196 -0
  42. package/packages/core/src/cli/dashboard/render.ts +1568 -0
  43. package/packages/core/src/cli/dashboard/selection.ts +66 -0
  44. package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
  45. package/packages/core/src/cli/dashboard/session-view.ts +506 -0
  46. package/packages/core/src/cli/dashboard/transcript.ts +108 -0
  47. package/packages/core/src/cli/dev.ts +46 -0
  48. package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
  49. package/packages/core/src/cli/dispatch-process.ts +259 -0
  50. package/packages/core/src/cli/dispatch.ts +3692 -0
  51. package/packages/core/src/cli/dotenv.ts +48 -0
  52. package/packages/core/src/cli/env.ts +206 -0
  53. package/packages/core/src/cli/harvest.ts +698 -0
  54. package/packages/core/src/cli/help.ts +713 -0
  55. package/packages/core/src/cli/init-agent.ts +106 -0
  56. package/packages/core/src/cli/init-validation.ts +583 -0
  57. package/packages/core/src/cli/init.ts +756 -0
  58. package/packages/core/src/cli/installation.ts +333 -0
  59. package/packages/core/src/cli/keyboard.ts +261 -0
  60. package/packages/core/src/cli/main.ts +1399 -0
  61. package/packages/core/src/cli/mcp.ts +279 -0
  62. package/packages/core/src/cli/models.ts +65 -0
  63. package/packages/core/src/cli/observe.ts +55 -0
  64. package/packages/core/src/cli/plugin.ts +227 -0
  65. package/packages/core/src/cli/pr-attachments.ts +138 -0
  66. package/packages/core/src/cli/pr-summary.ts +124 -0
  67. package/packages/core/src/cli/publication-settlement.ts +188 -0
  68. package/packages/core/src/cli/repo-state.ts +213 -0
  69. package/packages/core/src/cli/repository-status.ts +80 -0
  70. package/packages/core/src/cli/self-update.ts +443 -0
  71. package/packages/core/src/cli/status.ts +895 -0
  72. package/packages/core/src/cli/store-opening.contract.ts +289 -0
  73. package/packages/core/src/cli/store-opening.ts +167 -0
  74. package/packages/core/src/cli/store-ref.ts +33 -0
  75. package/packages/core/src/cli/terminal-restore.ts +174 -0
  76. package/packages/core/src/cli/terminal.ts +361 -0
  77. package/packages/core/src/cli/terminals.ts +995 -0
  78. package/packages/core/src/cli/testkit.ts +194 -0
  79. package/packages/core/src/cli/ticket.ts +484 -0
  80. package/packages/core/src/cli/upgrade-agent.ts +180 -0
  81. package/packages/core/src/cli/upgrade-commit.ts +468 -0
  82. package/packages/core/src/cli/upgrade-notice.ts +91 -0
  83. package/packages/core/src/cli/upgrade-progress.ts +110 -0
  84. package/packages/core/src/cli/upgrade.ts +888 -0
  85. package/packages/core/src/cli/wait.ts +587 -0
  86. package/packages/core/src/cli/watch.ts +932 -0
  87. package/packages/core/src/config/live.ts +255 -0
  88. package/packages/core/src/config/load.ts +98 -0
  89. package/packages/core/src/config/roles.ts +438 -0
  90. package/packages/core/src/config/schema.ts +774 -0
  91. package/packages/core/src/distribution.ts +24 -0
  92. package/packages/core/src/events/catalog.ts +201 -0
  93. package/packages/core/src/events/envelope.ts +59 -0
  94. package/packages/core/src/events/payloads.ts +618 -0
  95. package/packages/core/src/events/repository.ts +559 -0
  96. package/packages/core/src/events/sessions.ts +180 -0
  97. package/packages/core/src/harvest/schema.ts +147 -0
  98. package/packages/core/src/ids.ts +45 -0
  99. package/packages/core/src/integration/harness.ts +681 -0
  100. package/packages/core/src/kernel/auto-merge.ts +259 -0
  101. package/packages/core/src/kernel/converge.ts +117 -0
  102. package/packages/core/src/kernel/dispatch-settings.ts +60 -0
  103. package/packages/core/src/kernel/dispatch-status.ts +141 -0
  104. package/packages/core/src/kernel/engine.ts +1047 -0
  105. package/packages/core/src/kernel/harvest.ts +823 -0
  106. package/packages/core/src/kernel/origin.ts +69 -0
  107. package/packages/core/src/kernel/phases.ts +196 -0
  108. package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
  109. package/packages/core/src/kernel/pr-attachments.ts +82 -0
  110. package/packages/core/src/kernel/reducer.ts +752 -0
  111. package/packages/core/src/kernel/stall.ts +155 -0
  112. package/packages/core/src/kernel/verify-applicability.ts +52 -0
  113. package/packages/core/src/markdown.ts +67 -0
  114. package/packages/core/src/ontology.ts +288 -0
  115. package/packages/core/src/open-map.ts +203 -0
  116. package/packages/core/src/operator/annotations.ts +187 -0
  117. package/packages/core/src/operator/client.ts +311 -0
  118. package/packages/core/src/operator/control.ts +117 -0
  119. package/packages/core/src/operator/index.ts +62 -0
  120. package/packages/core/src/operator/presentation.ts +22 -0
  121. package/packages/core/src/operator/protocol.ts +98 -0
  122. package/packages/core/src/operator/query.ts +201 -0
  123. package/packages/core/src/operator/registry.ts +1043 -0
  124. package/packages/core/src/operator/requests.ts +138 -0
  125. package/packages/core/src/operator/sandbox.ts +458 -0
  126. package/packages/core/src/operator/server.ts +616 -0
  127. package/packages/core/src/operator/sessions.ts +223 -0
  128. package/packages/core/src/operator/tickets.ts +177 -0
  129. package/packages/core/src/plugin-sdk/index.ts +174 -0
  130. package/packages/core/src/plugins/contract-entry.ts +74 -0
  131. package/packages/core/src/plugins/contract-env.ts +3 -0
  132. package/packages/core/src/plugins/load.ts +259 -0
  133. package/packages/core/src/plugins/manifest.ts +236 -0
  134. package/packages/core/src/plugins/registry.ts +274 -0
  135. package/packages/core/src/plugins/runtimes.ts +75 -0
  136. package/packages/core/src/ports/forge/contract.ts +258 -0
  137. package/packages/core/src/ports/forge/create.ts +74 -0
  138. package/packages/core/src/ports/forge/fake.ts +488 -0
  139. package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
  140. package/packages/core/src/ports/forge/github-transport.ts +352 -0
  141. package/packages/core/src/ports/forge/github.ts +1162 -0
  142. package/packages/core/src/ports/forge/local-git.ts +568 -0
  143. package/packages/core/src/ports/runner/claude.ts +873 -0
  144. package/packages/core/src/ports/runner/cli-stream.ts +90 -0
  145. package/packages/core/src/ports/runner/codex.ts +873 -0
  146. package/packages/core/src/ports/runner/contract.ts +492 -0
  147. package/packages/core/src/ports/runner/fake.ts +213 -0
  148. package/packages/core/src/ports/runner/json-record.ts +2 -0
  149. package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
  150. package/packages/core/src/ports/runner/one-shot.ts +29 -0
  151. package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
  152. package/packages/core/src/ports/runner/pi-bridge.js +6 -0
  153. package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
  154. package/packages/core/src/ports/runner/pi.ts +617 -0
  155. package/packages/core/src/ports/runner/production.ts +74 -0
  156. package/packages/core/src/ports/runner/provider-error.ts +121 -0
  157. package/packages/core/src/ports/runner/routing.ts +234 -0
  158. package/packages/core/src/ports/runner/runtime.ts +214 -0
  159. package/packages/core/src/ports/runner/session-env.ts +34 -0
  160. package/packages/core/src/ports/runner/stream-parts.ts +103 -0
  161. package/packages/core/src/ports/tickets/contract.ts +570 -0
  162. package/packages/core/src/ports/tickets/create.ts +135 -0
  163. package/packages/core/src/ports/tickets/fake.ts +215 -0
  164. package/packages/core/src/ports/tickets/file.ts +559 -0
  165. package/packages/core/src/ports/tickets/linear.ts +811 -0
  166. package/packages/core/src/ports/tickets/operations.ts +161 -0
  167. package/packages/core/src/ports/tickets/remote-index.ts +13 -0
  168. package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
  169. package/packages/core/src/ports/tickets/remote-server.ts +185 -0
  170. package/packages/core/src/ports/tickets/remote.ts +130 -0
  171. package/packages/core/src/ports/tickets/update.ts +51 -0
  172. package/packages/core/src/ports/types.ts +528 -0
  173. package/packages/core/src/ports/workspace/build-execution.ts +123 -0
  174. package/packages/core/src/ports/workspace/contract.ts +136 -0
  175. package/packages/core/src/ports/workspace/create.ts +132 -0
  176. package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
  177. package/packages/core/src/ports/workspace/fake.ts +432 -0
  178. package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
  179. package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
  180. package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
  181. package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
  182. package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
  183. package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
  184. package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
  185. package/packages/core/src/processes/build-child-exit.ts +78 -0
  186. package/packages/core/src/processes/build-child.ts +119 -0
  187. package/packages/core/src/processes/build-execution-state.ts +95 -0
  188. package/packages/core/src/processes/build-parent-watch.ts +35 -0
  189. package/packages/core/src/processes/build-runner.ts +2614 -0
  190. package/packages/core/src/processes/dispatcher.ts +2825 -0
  191. package/packages/core/src/processes/execution-settlement.ts +171 -0
  192. package/packages/core/src/processes/harvest-child.ts +115 -0
  193. package/packages/core/src/processes/harvest-execution-state.ts +121 -0
  194. package/packages/core/src/processes/harvest-runner.ts +1656 -0
  195. package/packages/core/src/processes/harvest.ts +595 -0
  196. package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
  197. package/packages/core/src/processes/phase-failure-budget.ts +52 -0
  198. package/packages/core/src/processes/process-group-reaper.ts +35 -0
  199. package/packages/core/src/processes/process-group.ts +65 -0
  200. package/packages/core/src/processes/publication-loss.ts +62 -0
  201. package/packages/core/src/processes/publication-state.ts +93 -0
  202. package/packages/core/src/processes/sandbox-state.ts +101 -0
  203. package/packages/core/src/registry.ts +24 -0
  204. package/packages/core/src/sessions/reducer.ts +185 -0
  205. package/packages/core/src/skills.ts +7 -0
  206. package/packages/core/src/spec-standard.ts +47 -0
  207. package/packages/core/src/store/adapter.ts +80 -0
  208. package/packages/core/src/store/build-scope.ts +272 -0
  209. package/packages/core/src/store/contract.ts +2581 -0
  210. package/packages/core/src/store/local/blobs.ts +38 -0
  211. package/packages/core/src/store/local/schema.ts +180 -0
  212. package/packages/core/src/store/local/store.ts +1713 -0
  213. package/packages/core/src/store/memory.ts +1138 -0
  214. package/packages/core/src/store/phase-session.ts +425 -0
  215. package/packages/core/src/store/remote/client.ts +780 -0
  216. package/packages/core/src/store/remote/index.ts +12 -0
  217. package/packages/core/src/store/remote/protocol.ts +348 -0
  218. package/packages/core/src/store/remote/server.ts +923 -0
  219. package/packages/core/src/store/remote/token.ts +137 -0
  220. package/packages/core/src/store/remote/version.ts +10 -0
  221. package/packages/core/src/store/retention.ts +91 -0
  222. package/packages/core/src/store/session-handle.ts +265 -0
  223. package/packages/core/src/store/streams/assemble.ts +79 -0
  224. package/packages/core/src/store/streams/lock.ts +42 -0
  225. package/packages/core/src/store/streams/session-writer.ts +306 -0
  226. package/packages/core/src/store/streams/types.ts +171 -0
  227. package/packages/core/src/store/streams/wait.ts +127 -0
  228. package/packages/core/src/store/subscribe.ts +105 -0
  229. package/packages/core/src/store/types.ts +466 -0
  230. package/packages/core/src/testing/fixed.ts +44 -0
  231. package/packages/core/src/testing/store-failures.ts +32 -0
  232. package/packages/core/src/zod-issues.ts +91 -0
  233. package/skills/code-review/SKILL.md +100 -0
  234. package/skills/finalize/SKILL.md +44 -0
  235. package/skills/guide/SKILL.md +1781 -0
  236. package/skills/guide/references/operator-api.md +164 -0
  237. package/skills/guide/references/plugin-authoring.md +354 -0
  238. package/skills/guide/references/remote-store-protocol.md +972 -0
  239. package/skills/guide/references/remote-ticket-protocol.md +79 -0
  240. package/skills/guide/references/setup.md +289 -0
  241. package/skills/guide/references/spec-standard.md +63 -0
  242. package/skills/guide/references/ticket-dependencies.md +59 -0
  243. package/skills/harvest/SKILL.md +66 -0
  244. package/skills/harvest-review/SKILL.md +63 -0
  245. package/skills/implement/SKILL.md +82 -0
  246. package/skills/plan/SKILL.md +123 -0
  247. package/skills/plan-review/SKILL.md +121 -0
  248. package/skills/reconcile/SKILL.md +65 -0
  249. package/skills/spec/SKILL.md +132 -0
  250. package/skills/tickets/SKILL.md +139 -0
  251. package/templates/autobuild.toml +36 -0
@@ -0,0 +1,1568 @@
1
+ /**
2
+ * The dashboard's other pure half: model → `string[]`. No terminal, no state,
3
+ * no I/O — `live.ts` owns the writer, this owns the pixels.
4
+ *
5
+ * Two constraints shape every choice here:
6
+ *
7
+ * **Never color-only for pipeline and row state.** Color is additive emphasis:
8
+ * every step also carries a glyph (`[x]` `[>]` `[~]` `[ ]`) and every status
9
+ * its literal word (`RUNNING` / `PAUSED` / `BLOCKED`). Auto-merge is the one
10
+ * deliberate presentation exception: token presence carries on/off intent,
11
+ * while color distinguishes requested, enabled, and cancelling. Strip the
12
+ * escapes — as `--plain` and every pipe do — and the actionable intent remains.
13
+ *
14
+ * **Cell-honest Unicode.** Printable text is segmented into whole grapheme
15
+ * clusters and measured in terminal display cells. Control bytes remain
16
+ * visible escapes, so dynamic text cannot inject terminal traffic.
17
+ *
18
+ * The ~40 lines of ANSI are hand-rolled for the same reason: this repo has
19
+ * four runtime deps and none is terminal-related; `chalk`/`ink` would be a
20
+ * posture change to win a color map.
21
+ */
22
+ import { basename } from 'node:path'
23
+ import type {
24
+ DashboardBuild,
25
+ DashboardHarvest,
26
+ DashboardModel,
27
+ DashboardSelection,
28
+ PipelineStep,
29
+ } from './model'
30
+ import { dashboardBuildControl } from './actions'
31
+ import type { DashboardSession } from './detail'
32
+ import type { TranscriptPresentation, TranscriptTurn } from './transcript'
33
+ import { projectSessionDocument, projectSessionParts } from './session-view'
34
+ import type { SessionDashboardView } from './model'
35
+ import { dashboardSelections, sameSelection } from './selection'
36
+ import { displayText, layoutComposer, packAtomic, wrapDisplay } from './composer'
37
+ import { cellWidth, graphemes, padEndCells } from './cells'
38
+
39
+ export interface RenderOpts {
40
+ /** ANSI on. False ⇒ not a single `\x1b` in the output (the `--plain` AC). */
41
+ color: boolean
42
+ /** Hard cap per line; one rendered line must be one physical row. */
43
+ width: number
44
+ /**
45
+ * The render-time clock (epoch ms). A running step's elapsed is
46
+ * `timing.accumulatedMs + (now - timing.runningSince)`, so painting the same
47
+ * cached model against a moving `now` is what makes the elapsed tick without
48
+ * recomputing the model (AC 8). Steps with no open interval ignore it.
49
+ */
50
+ now: number
51
+ /**
52
+ * Hard cap on the NUMBER of lines — the screen's paintable rows.
53
+ *
54
+ * The live region clears its alternate display and anchors this frame at
55
+ * terminal row 1 on every effective paint. The cap still matters: every
56
+ * rendered line has a trailing newline, so a frame as tall as the screen
57
+ * scrolls its own top away even on a freshly cleared display.
58
+ *
59
+ * This is a cap on LINES, and it is NOT the screen's row count: the region
60
+ * needs a row for the cursor to rest on, so a caller painting a terminal
61
+ * must pass `paintableRows(rows)`, not `rows`. This module cannot enforce
62
+ * that — it counts lines and knows nothing of the region's trailing newline.
63
+ *
64
+ * Absent ⇒ unbounded, for callers that are not painting a screen.
65
+ */
66
+ height?: number
67
+ }
68
+
69
+ /** A dashboard presentation implementation. Kept as a named dependency so the
70
+ * repo-local dev entry can replace presentation without replacing the live
71
+ * dispatch loop that owns runners, input, timers, and leases. */
72
+ export type DashboardRenderer = (model: DashboardModel, opts: RenderOpts) => string[]
73
+
74
+ /** Resolve at paint time rather than capturing one renderer at loop startup. */
75
+ export type DashboardRendererResolver = () => DashboardRenderer
76
+
77
+ // ── ANSI ─────────────────────────────────────────────────────────────────────
78
+
79
+ const ESC = '\x1b['
80
+ const RESET = `${ESC}0m`
81
+ /** Closes an OSC 8 hyperlink. `RESET` does NOT: it is an SGR reset, and the
82
+ * two families are independent — only this ends the link. */
83
+ const LINK_OFF = '\x1b]8;;\x07'
84
+ const CODES = {
85
+ dim: 2,
86
+ red: 31,
87
+ green: 32,
88
+ yellow: 33,
89
+ blue: 34,
90
+ cyan: 36,
91
+ bold: 1,
92
+ } as const
93
+ type ColorName = keyof typeof CODES
94
+
95
+ function paint(text: string, color: ColorName, on: boolean): string {
96
+ return on ? `${ESC}${CODES[color]}m${text}${RESET}` : text
97
+ }
98
+
99
+ /** OSC 8: the escape common terminals turn into a real hyperlink. In plain
100
+ * mode we emit the bare URL, which those same terminals linkify on sight —
101
+ * so "PR URLs are recognized as a link" holds on both paths. */
102
+ function link(url: string, text: string, on: boolean): string {
103
+ if (!/^https?:\/\//.test(url)) return text === url ? url : `${text} ${url}`
104
+ return on ? `\x1b]8;;${url}\x07${text}${LINK_OFF}` : url
105
+ }
106
+
107
+ /** Visible length: what the operator's eye counts, not what `.length` does. */
108
+ function visibleLength(text: string): number {
109
+ return cellWidth(stripAnsi(text))
110
+ }
111
+
112
+ export function stripAnsi(text: string): string {
113
+ // CSI (colors) and OSC 8 (hyperlinks) — the only two families we emit.
114
+ return text.replace(/\x1b\][^\x07]*\x07/g, '').replace(/\x1b\[[0-9;]*m/g, '')
115
+ }
116
+
117
+ /**
118
+ * Truncate to `width` VISIBLE columns without ever splitting an escape, and
119
+ * without leaving any mode it cut through switched ON.
120
+ *
121
+ * Walking the string escape-aware (rather than slicing and hoping) is the
122
+ * whole point: a cut mid-sequence leaks raw escape bytes onto the screen, and
123
+ * a cut that drops a closer leaves the mode running down the rest of the
124
+ * frame. Escapes are copied through and cost zero columns.
125
+ *
126
+ * BOTH families this file emits are stateful, and both must be closed — the
127
+ * SGR colors with `RESET`, and an OSC 8 hyperlink with `LINK_OFF`. `RESET` does
128
+ * not close a hyperlink. Getting this wrong is not cosmetic: `renderBuild` puts
129
+ * the PR link LAST, so it is the first thing truncation eats, and an unclosed
130
+ * OSC 8 makes every line painted afterwards — the progress row, the blockers,
131
+ * every later build — clickable to that one PR. `finish()` deliberately leaves
132
+ * the final frame up, so the state would outlive the process and land on the
133
+ * operator's shell prompt.
134
+ */
135
+ function truncate(text: string, width: number): string {
136
+ if (width <= 0) return ''
137
+ if (visibleLength(text) <= width) return text
138
+ let out = ''
139
+ let visible = 0
140
+ let sawSgr = false
141
+ let linkOpen = false
142
+ const budget = Math.max(0, width - 1)
143
+
144
+ for (let i = 0; i < text.length; ) {
145
+ if (text[i] === '\x1b') {
146
+ const osc = text[i + 1] === ']'
147
+ const end = osc ? text.indexOf('\x07', i) : text.slice(i).search(/[A-Za-z]/) + i
148
+ const stop = end === -1 || end < i ? text.length - 1 : end
149
+ const seq = text.slice(i, stop + 1)
150
+ out += seq
151
+ i = stop + 1
152
+ if (osc) linkOpen = seq !== LINK_OFF
153
+ else sawSgr = true
154
+ continue
155
+ }
156
+
157
+ const nextEscape = text.indexOf('\x1b', i)
158
+ const stop = nextEscape === -1 ? text.length : nextEscape
159
+ let cut = false
160
+ for (const cluster of graphemes(text.slice(i, stop))) {
161
+ if (cluster.width > budget || visible + cluster.width > budget) {
162
+ cut = true
163
+ break
164
+ }
165
+ out += cluster.text
166
+ visible += cluster.width
167
+ }
168
+ if (cut) break
169
+ i = stop
170
+ }
171
+ out += '~'
172
+ // Close what we cut through — the link first, so the `~` stays inside it.
173
+ if (linkOpen) out += LINK_OFF
174
+ return sawSgr ? `${out}${RESET}` : out
175
+ }
176
+
177
+ /**
178
+ * Greedily pack pre-rendered tokens into `indent`-prefixed lines of at most
179
+ * `width` VISIBLE columns.
180
+ *
181
+ * Why wrap rather than truncate: truncation is mandatory for redraw
182
+ * correctness (one rendered line must be one physical row, or the painted-line
183
+ * count under-counts and the redraw leaves fragments) — but a truncated
184
+ * progress row loses `finalize` and `merge waiting` off the right edge, i.e.
185
+ * exactly the steps the ACs require and the ones the operator is waiting on.
186
+ * Both hold at once because WE do the wrapping: every line we emit is within
187
+ * the width, so the row count stays honest AND nothing is dropped.
188
+ *
189
+ * A single token wider than the line is truncated — it cannot be helped, and
190
+ * it keeps the width guarantee absolute.
191
+ */
192
+ function packLines(tokens: string[], width: number, indent: string): string[] {
193
+ const lines: string[] = []
194
+ let line = ''
195
+ for (const token of tokens) {
196
+ const candidate = line === '' ? `${indent}${token}` : `${line} ${token}`
197
+ if (line !== '' && visibleLength(candidate) > width) {
198
+ lines.push(line)
199
+ line = `${indent}${token}`
200
+ } else {
201
+ line = candidate
202
+ }
203
+ }
204
+ if (line !== '') lines.push(line)
205
+ return lines.map((l) => truncate(l, width))
206
+ }
207
+
208
+ // ── Steps ────────────────────────────────────────────────────────────────────
209
+
210
+ const GLYPH: Record<PipelineStep['state'], string> = {
211
+ done: '[x]',
212
+ current: '[>]',
213
+ provisional: '[~]',
214
+ pending: '[ ]',
215
+ }
216
+
217
+ const STEP_COLOR: Record<PipelineStep['state'], ColorName> = {
218
+ done: 'green',
219
+ current: 'cyan',
220
+ provisional: 'yellow',
221
+ pending: 'dim',
222
+ }
223
+
224
+ /**
225
+ * Elapsed duration, ASCII only: `Xs` under a minute, `MmSSs` under an hour,
226
+ * `HhMMm` above — seconds/minutes zero-padded when a larger unit precedes, so
227
+ * the field width is stable as it ticks (`38s`, `4m12s`, `1h04m`).
228
+ */
229
+ export function formatDuration(ms: number): string {
230
+ const totalSec = Math.max(0, Math.floor(ms / 1000))
231
+ if (totalSec < 60) return `${totalSec}s`
232
+ const totalMin = Math.floor(totalSec / 60)
233
+ if (totalMin < 60) return `${totalMin}m${String(totalSec % 60).padStart(2, '0')}s`
234
+ return `${Math.floor(totalMin / 60)}h${String(totalMin % 60).padStart(2, '0')}m`
235
+ }
236
+
237
+ function renderStep(step: PipelineStep, color: boolean, now: number): string {
238
+ const parts: string[] = []
239
+ if (step.qualifier !== undefined) parts.push(step.qualifier)
240
+ // The elapsed segment can only be composed here — it depends on the
241
+ // render-time clock. A running step's open interval grows with `now`; a
242
+ // frozen or done step has none, so its elapsed is stable across repaints.
243
+ // `count` rides the time as `/n` (AC 7); with no time in scope it is not
244
+ // shown (a fresh/restarted step should not carry a stale round).
245
+ if (step.timing !== undefined) {
246
+ const { accumulatedMs, runningSince } = step.timing
247
+ const elapsedMs =
248
+ accumulatedMs + (runningSince !== undefined ? Math.max(0, now - runningSince) : 0)
249
+ const count = step.count !== undefined && step.count > 1 ? `/${step.count}` : ''
250
+ parts.push(`${formatDuration(elapsedMs)}${count}`)
251
+ }
252
+ const note = parts.length > 0 ? `(${parts.join(', ')})` : ''
253
+ const text = `${GLYPH[step.state]} ${step.label}${note}`
254
+ const painted = paint(text, STEP_COLOR[step.state], color)
255
+ return step.state === 'current' ? paint(painted, 'bold', color) : painted
256
+ }
257
+
258
+ // ── Builds ───────────────────────────────────────────────────────────────────
259
+
260
+ const STATUS_COLOR: Record<DashboardBuild['status'] | DashboardHarvest['status'], ColorName> = {
261
+ queued: 'cyan',
262
+ running: 'green',
263
+ pausing: 'yellow',
264
+ paused: 'yellow',
265
+ resuming: 'cyan',
266
+ blocked: 'red',
267
+ aborting: 'red',
268
+ cleaning: 'yellow',
269
+ escalated: 'yellow',
270
+ failed: 'red',
271
+ }
272
+
273
+ /** The marker is one shared visual grammar for every selectable row. */
274
+ function selectionMarker(selected: boolean, selecting: boolean, color: boolean): string {
275
+ // The marker owns a fixed two-column lane even when keyboard selection is
276
+ // unavailable. No identity or detail text may drift into those columns.
277
+ return selecting && selected ? paint('> ', 'cyan', color) : ' '
278
+ }
279
+
280
+ /** Pin a right cluster to the frame edge while keeping exactly one flexible,
281
+ * truncatable segment on the left. */
282
+ function rightPinnedLine(prefix: string, flexible: string, right: string, width: number): string {
283
+ const budget = width - visibleLength(prefix) - visibleLength(right) - 2
284
+ const left = truncate(flexible, Math.max(0, budget))
285
+ const used = visibleLength(prefix) + visibleLength(left)
286
+ const gap = ' '.repeat(Math.max(1, width - used - visibleLength(right)))
287
+ return truncate(`${prefix}${left}${gap}${right}`, width)
288
+ }
289
+
290
+ function reviewRoundCeilingText(build: DashboardBuild): string | undefined {
291
+ if (build.reviewRoundCeilings === undefined) return undefined
292
+ const values = [
293
+ ...(build.reviewRoundCeilings.plan !== undefined
294
+ ? [`plan ${build.reviewRoundCeilings.plan}`]
295
+ : []),
296
+ ...(build.reviewRoundCeilings.code !== undefined
297
+ ? [`code ${build.reviewRoundCeilings.code}`]
298
+ : []),
299
+ ]
300
+ return values.length === 0 ? undefined : `review round ceiling: ${values.join(', ')}`
301
+ }
302
+
303
+ function renderBuild(
304
+ build: DashboardBuild,
305
+ repositoryPaused: boolean,
306
+ opts: RenderOpts,
307
+ widths: Widths,
308
+ selected: boolean,
309
+ selecting: boolean,
310
+ ): string[] {
311
+ const { color, width, now } = opts
312
+
313
+ // ── The slug line: TICKET-ID slug … <right cluster> ──────────────────────
314
+ //
315
+ // Explicit left / flexible / right layout, NOT "join then truncate the whole
316
+ // line" — the latter eats the rightmost token (the status, the PR link) first,
317
+ // exactly what this change moves away from. Ticket id is the left column,
318
+ // the slug is the flexible/truncatable identity segment, and the right
319
+ // cluster hugs `width` so status ends at the same column on every row.
320
+
321
+ // Left column: ticket id, padded frame-wide so slugs align even for a build
322
+ // with no ticket (AC 2). No column at all when the frame has zero ticket ids.
323
+ const marker = selectionMarker(selected, selecting, color)
324
+ const ticketCol =
325
+ widths.ticket > 0 ? paint(padEndCells(build.ticketId ?? '', widths.ticket), 'blue', color) : ''
326
+ const leftPrefix = `${marker}${ticketCol === '' ? '' : `${ticketCol} `}`
327
+
328
+ // Right cluster, rightmost last: [PR] [(paused)] STATUS. `padStart`
329
+ // right-justifies the status word so it is never truncated and always ends at
330
+ // the frame's right edge (AC 3, AC 5).
331
+ const rightTokens: string[] = []
332
+ if (build.autoMerge !== 'off') {
333
+ const autoColor: ColorName =
334
+ build.autoMerge === 'enabled' ? 'green' : build.autoMerge === 'requested' ? 'cyan' : 'yellow'
335
+ rightTokens.push(paint('auto merge', autoColor, color))
336
+ }
337
+ if (build.pr !== undefined) rightTokens.push(link(build.pr.url, `PR ${build.pr.state}`, color))
338
+ // A repository hold changes no build lifecycle state. Keep QUEUED literal
339
+ // and annotate only the rows the hold prevents from launching.
340
+ if (repositoryPaused && build.status === 'queued') {
341
+ rightTokens.push(paint('(held)', 'yellow', color))
342
+ }
343
+ // Blocked overrides paused visually, but the pause is still a fact the
344
+ // operator needs — so it rides along rather than being overwritten.
345
+ if (build.alsoPaused) rightTokens.push(paint('(paused)', 'yellow', color))
346
+ rightTokens.push(
347
+ paint(build.status.toUpperCase().padStart(widths.status), STATUS_COLOR[build.status], color),
348
+ )
349
+ const rightStr = rightTokens.join(' ')
350
+
351
+ // The slug is the sole flexible segment; narrow frames truncate it before
352
+ // the right-pinned lifecycle status.
353
+ const lines = [rightPinnedLine(leftPrefix, paint(build.slug, 'bold', color), rightStr, width)]
354
+ const ceiling = reviewRoundCeilingText(build)
355
+ if (ceiling !== undefined) lines.push(truncate(` ${ceiling}`, width))
356
+
357
+ if (build.abortProgress !== undefined) {
358
+ for (const line of wrappedText(build.abortProgress, width - 4, '')) {
359
+ lines.push(truncate(paint(` ! ${line}`, STATUS_COLOR[build.status], color), width))
360
+ }
361
+ } else if (build.dispatch !== undefined) {
362
+ for (const line of wrappedText(build.dispatch, width - 4, '')) {
363
+ lines.push(truncate(paint(` ! ${line}`, STATUS_COLOR[build.status], color), width))
364
+ }
365
+ } else {
366
+ // The progress row wraps rather than truncating: the tail is `finalize` and
367
+ // `merge waiting`, which the ACs require and the operator is waiting on.
368
+ lines.push(
369
+ ...packLines(
370
+ build.steps.map((s) => renderStep(s, color, now)),
371
+ width,
372
+ ' ',
373
+ ),
374
+ )
375
+ }
376
+ // Setup failures and blocker questions are dense-list previews. Their model
377
+ // strings remain untouched; newline-aware wrapping and capping happen only
378
+ // at this presentation seam.
379
+ if (build.setupError !== undefined) {
380
+ lines.push(...prefixedMessagePreview(build.setupError, width, 'red', color, true))
381
+ }
382
+ for (const blocker of build.blockers) {
383
+ lines.push(...prefixedMessagePreview(blocker, width, 'red', color, true))
384
+ }
385
+ return lines
386
+ }
387
+
388
+ function renderHarvest(
389
+ harvest: DashboardHarvest,
390
+ opts: RenderOpts,
391
+ widths: Widths,
392
+ selected: boolean,
393
+ selecting: boolean,
394
+ ): string[] {
395
+ const { color, width, now } = opts
396
+ const marker = selectionMarker(selected, selecting, color)
397
+ // Harvest has no ticket id, so its title takes the ticket column itself —
398
+ // aligned with the ids, not the slugs — and the observation count sits in
399
+ // the flexible slug slot, keeping one row grammar across mixed frames.
400
+ const title = widths.ticket > 0 ? padEndCells(HARVEST_TITLE, widths.ticket) : HARVEST_TITLE
401
+ const leftPrefix = `${marker}${paint(title, 'bold', color)} `
402
+ const identity = paint(`${harvest.observations} observations`, 'dim', color)
403
+ const statusColor = STATUS_COLOR[harvest.status]
404
+ const status = paint(harvest.status.toUpperCase().padStart(widths.status), statusColor, color)
405
+ const lines = [rightPinnedLine(leftPrefix, identity, status, width)]
406
+ lines.push(
407
+ ...packLines(
408
+ harvest.steps.map((item) => renderStep(item, color, now)),
409
+ width,
410
+ ' ',
411
+ ),
412
+ )
413
+ if (harvest.detail !== undefined) {
414
+ lines.push(...prefixedMessagePreview(harvest.detail, width, statusColor, color, false))
415
+ }
416
+ return lines
417
+ }
418
+
419
+ interface Widths {
420
+ ticket: number
421
+ status: number
422
+ }
423
+
424
+ const HARVEST_TITLE = 'Harvest'
425
+
426
+ /** Pad ticket id and status to the widest in the FRAME, so columns line up down
427
+ * the whole dashboard rather than per-row. `ticket` is 0 when no build in the
428
+ * frame has a ticket id — then there is no ticket column at all. The Harvest
429
+ * title lives in the ticket column, so when that column exists it must also fit
430
+ * the title; Harvest joins the status-width calculation even in a mixed frame. */
431
+ function frameWidths(builds: DashboardBuild[], harvest: DashboardHarvest | undefined): Widths {
432
+ const ticketIds = builds.map((b) => cellWidth(b.ticketId ?? ''))
433
+ const hasTicketColumn = ticketIds.some((length) => length > 0)
434
+ return {
435
+ ticket: Math.max(
436
+ 0,
437
+ ...ticketIds,
438
+ ...(harvest !== undefined && hasTicketColumn ? [cellWidth(HARVEST_TITLE)] : []),
439
+ ),
440
+ status: Math.max(
441
+ 0,
442
+ ...builds.map((b) => cellWidth(b.status)),
443
+ ...(harvest !== undefined ? [cellWidth(harvest.status)] : []),
444
+ ),
445
+ }
446
+ }
447
+
448
+ // ── The frame ────────────────────────────────────────────────────────────────
449
+
450
+ export const DASHBOARD_GLOBAL_LEGEND =
451
+ 'Keys: Up/Down select h harvest m auto-merge i intake p pause all r resume all Ctrl-C quit'
452
+ export const DASHBOARD_HARVEST_LEGEND = 'Keys: Up/Down select Ctrl-C quit'
453
+ export const DASHBOARD_HARVEST_RESUME_LEGEND = 'Keys: Up/Down select p resume Ctrl-C quit'
454
+ export const DASHBOARD_HARVEST_ACKNOWLEDGE_LEGEND =
455
+ 'Keys: Up/Down select p acknowledge Ctrl-C quit'
456
+ export const DASHBOARD_BUILD_LEGEND =
457
+ 'Keys: Up/Down select Enter details m auto-merge p pause a abort Ctrl-C quit'
458
+ export const DASHBOARD_QUEUED_BUILD_LEGEND =
459
+ 'Keys: Up/Down select Enter details d discard a abort Ctrl-C quit'
460
+ /** The session view offers ONLY scrolling, back, and quit — it is read-only,
461
+ * and the legend says so by naming nothing else. */
462
+ export const DASHBOARD_SESSION_LIVE_LEGEND =
463
+ 'Keys: Up/Down scroll (following tail) Esc back Ctrl-C quit'
464
+ export const DASHBOARD_SESSION_PAUSED_LEGEND =
465
+ 'Keys: Up/Down scroll (paused) Esc back Ctrl-C quit'
466
+
467
+ function buildLegend(build: DashboardBuild, detail: boolean): string {
468
+ if (build.status === 'aborting' || build.status === 'cleaning') {
469
+ return detail
470
+ ? 'Keys: Up/Down select session Enter transcript Esc back Ctrl-C quit'
471
+ : 'Keys: Up/Down select Enter details Ctrl-C quit'
472
+ }
473
+ if (build.status === 'queued') {
474
+ return detail
475
+ ? 'Keys: d discard a abort Esc back Ctrl-C quit'
476
+ : DASHBOARD_QUEUED_BUILD_LEGEND
477
+ }
478
+ const control = dashboardBuildControl(build.status)
479
+ const action = control === undefined ? '' : ` ${control.key} ${control.label}`
480
+ return detail
481
+ ? `Keys: Up/Down scroll Left/Right select session Enter transcript m auto-merge${action} a abort Esc back Ctrl-C quit`
482
+ : `Keys: Up/Down select Enter details m auto-merge${action} a abort Ctrl-C quit`
483
+ }
484
+
485
+ const MESSAGE_PREVIEW_ROWS = 3
486
+
487
+ /** Preserve authored line structure while limiting display-only blank runs to
488
+ * one row. Two newline separators make one paragraph row; longer runs do not
489
+ * consume more dense-list space. */
490
+ function messageDisplayRows(value: string, width: number, collapseBlankRuns: boolean): string[] {
491
+ if (width <= 0) return []
492
+ const logical = value.split(/\r?\n/)
493
+ const normalized: string[] = []
494
+ let previousBlank = false
495
+ for (const line of logical) {
496
+ const blank = line.trim().length === 0
497
+ if (!collapseBlankRuns || !blank || !previousBlank) normalized.push(line)
498
+ previousBlank = blank
499
+ }
500
+ return normalized.flatMap((line) => wrapDisplay(line, width))
501
+ }
502
+
503
+ function previewMessageRows(value: string, width: number, expandable: boolean): string[] {
504
+ const rows = messageDisplayRows(value, width, true)
505
+ if (rows.length <= MESSAGE_PREVIEW_ROWS) return rows
506
+ const remaining = rows.length - MESSAGE_PREVIEW_ROWS
507
+ const noun = remaining === 1 ? 'row' : 'rows'
508
+ const hint = expandable ? ' - Enter details' : ''
509
+ return [
510
+ ...rows.slice(0, MESSAGE_PREVIEW_ROWS),
511
+ ...wrapDisplay(`... ${remaining} more ${noun}${hint}`, width),
512
+ ]
513
+ }
514
+
515
+ function prefixedMessagePreview(
516
+ value: string,
517
+ width: number,
518
+ colorName: ColorName,
519
+ color: boolean,
520
+ expandable: boolean,
521
+ ): string[] {
522
+ const contentWidth = Math.max(0, width - 4)
523
+ return previewMessageRows(value, contentWidth, expandable).map((line, index) =>
524
+ truncate(paint(`${index === 0 ? ' ! ' : ' '}${line}`, colorName, color), width),
525
+ )
526
+ }
527
+
528
+ /** Each process warning is independently capped. Unlike build-owned messages,
529
+ * the warning slot has no selectable row, so its notice promises no expansion
530
+ * action. */
531
+ function warningRows(lines: readonly string[], width: number): string[] {
532
+ const contentWidth = Math.max(0, width - 2)
533
+ return lines.flatMap((line) =>
534
+ previewMessageRows(line, contentWidth, false).map((row) => truncate(` ${row}`, width)),
535
+ )
536
+ }
537
+
538
+ /**
539
+ * Wrap prose for the legacy frame elements — build rows, harvest detail,
540
+ * detail view, transcripts.
541
+ *
542
+ * Deliberately unchanged, and deliberately NOT the composer's `wrapDisplay`:
543
+ * this truncates a token wider than the line with a `~`, which every existing
544
+ * frame's byte-stable output depends on. Operator-facing prose that must
545
+ * survive in full — the resume panel's questions and hint — goes through
546
+ * `wrapDisplay` instead, and short labels that must stay whole go through
547
+ * `packAtomic`.
548
+ */
549
+ function wrappedText(value: string, width: number, indent = ''): string[] {
550
+ if (width <= 0) return []
551
+ const paragraphs = value.split(/\r?\n/)
552
+ const lines: string[] = []
553
+ for (const paragraph of paragraphs) {
554
+ const safe = displayText(paragraph)
555
+ if (safe.length === 0) {
556
+ lines.push(indent)
557
+ continue
558
+ }
559
+ const wrapped = packLines(safe.split(/\s+/), width, indent)
560
+ lines.push(...(wrapped.length > 0 ? wrapped : [indent]))
561
+ }
562
+ return lines
563
+ }
564
+
565
+ function detailStep(step: PipelineStep, opts: RenderOpts): string {
566
+ const { color, now } = opts
567
+ const notes: string[] = []
568
+ if (step.qualifier !== undefined) notes.push(step.qualifier)
569
+ if (step.count !== undefined) {
570
+ const kind =
571
+ step.label.startsWith('verify:') || step.label === 'reconcile' ? 'attempt' : 'round'
572
+ notes.push(`${kind} ${step.count}`)
573
+ }
574
+ if (step.timing !== undefined) {
575
+ const elapsed =
576
+ step.timing.accumulatedMs +
577
+ (step.timing.runningSince !== undefined ? Math.max(0, now - step.timing.runningSince) : 0)
578
+ notes.push(formatDuration(elapsed))
579
+ }
580
+ const note = notes.length > 0 ? ` (${notes.join(', ')})` : ''
581
+ return paint(`${GLYPH[step.state]} ${step.label}${note}`, STEP_COLOR[step.state], color)
582
+ }
583
+
584
+ function sessionLines(session: DashboardSession, selected: boolean, opts: RenderOpts): string[] {
585
+ const usage = session.usage
586
+ const tokens = [
587
+ session.role,
588
+ `phase ${session.phase}`,
589
+ ...(session.round !== undefined ? [`round ${session.round}`] : []),
590
+ `runtime ${session.runtime}`,
591
+ ...(session.model !== undefined ? [`model ${session.model}`] : []),
592
+ session.status,
593
+ ...(session.stream !== undefined ? [`stream ${session.stream} (${session.streamStatus})`] : []),
594
+ ...(session.reclaimedBy !== undefined
595
+ ? [
596
+ `by ${session.reclaimedBy.instance} at resume boundary ${session.reclaimedBy.resumedFromSeq}`,
597
+ 'transcript unavailable',
598
+ ]
599
+ : []),
600
+ ...(usage !== undefined
601
+ ? [`tokens ${usage.inputTokens} in/${usage.outputTokens} out, ${usage.turns} turns`]
602
+ : []),
603
+ ]
604
+ const wrapped = packLines(tokens.map(displayText), Math.max(1, opts.width - 4), '')
605
+ return wrapped.map((line, index) =>
606
+ truncate(
607
+ `${index === 0 ? selectionMarker(selected, true, opts.color) : ' '} ${line}`,
608
+ opts.width,
609
+ ),
610
+ )
611
+ }
612
+
613
+ function viewport(lines: string[], capacity: number, focus: number, scroll = 0): string[] {
614
+ if (capacity <= 0) return []
615
+ const maxStart = Math.max(0, lines.length - capacity)
616
+ const wanted = scroll > 0 ? scroll : Math.max(0, focus - Math.floor(capacity / 2))
617
+ const start = Math.min(maxStart, wanted)
618
+ return lines.slice(start, start + capacity)
619
+ }
620
+
621
+ interface DetailBodyLayout {
622
+ body: string[]
623
+ sessionFocus: number | undefined
624
+ messageFocus: number | undefined
625
+ }
626
+
627
+ export type DetailFocusTarget = 'session' | 'message'
628
+
629
+ function detailBody(
630
+ build: DashboardBuild,
631
+ view: Extract<NonNullable<DashboardModel['view']>, { kind: 'detail' }>,
632
+ opts: RenderOpts,
633
+ ): DetailBodyLayout {
634
+ const { color, width } = opts
635
+ const body: string[] = []
636
+ if (build.ticketId !== undefined) body.push(` ticket ${displayText(build.ticketId)}`)
637
+ body.push(` auto merge ${build.autoMerge}`)
638
+ const ceiling = reviewRoundCeilingText(build)
639
+ if (ceiling !== undefined) body.push(` ${ceiling}`)
640
+ if (build.pr !== undefined)
641
+ body.push(` PR ${build.pr.state} ${link(build.pr.url, build.pr.url, color)}`)
642
+ if (build.abortProgress !== undefined) {
643
+ body.push('', paint('Abort progress', 'bold', color))
644
+ body.push(...wrappedText(build.abortProgress, width, ' '))
645
+ } else if (build.dispatch !== undefined) {
646
+ body.push('', paint('Dispatch', 'bold', color), ...wrappedText(build.dispatch, width, ' '))
647
+ } else {
648
+ body.push('', paint('Pipeline', 'bold', color))
649
+ body.push(...build.steps.map((item) => ` ${detailStep(item, opts)}`))
650
+ }
651
+ if (build.setupError !== undefined) {
652
+ body.push('', paint('Setup failure', 'bold', color))
653
+ body.push(
654
+ ...wrapDisplay(build.setupError, width, ' ').map((line) => paint(line, 'red', color)),
655
+ )
656
+ }
657
+ if (build.blockers.length > 0) {
658
+ body.push('', paint('Unresolved blockers', 'bold', color))
659
+ for (const blocker of build.blockers) {
660
+ body.push(
661
+ ...wrapDisplay(`! ${blocker}`, width, ' ').map((line) => paint(line, 'red', color)),
662
+ )
663
+ }
664
+ for (const note of [
665
+ 'The r field records guidance, or a bare retry when empty.',
666
+ `CLI only: ab answer ${build.slug} --dismiss`,
667
+ `CLI only: ab answer ${build.slug} --revise-spec <file>`,
668
+ ]) {
669
+ body.push(...wrappedText(note, width, ' ').map((line) => paint(line, 'dim', color)))
670
+ }
671
+ }
672
+ body.push('', paint('Sessions', 'bold', color))
673
+ const sessions = build.sessions ?? []
674
+ let sessionFocus: number | undefined
675
+ if (sessions.length === 0) body.push(' no agent sessions recorded')
676
+ for (const session of sessions) {
677
+ if (session.id === view.sessionId) sessionFocus = body.length
678
+ body.push(...sessionLines(session, session.id === view.sessionId, opts))
679
+ }
680
+ let messageFocus: number | undefined
681
+ if (view.message !== undefined) {
682
+ const messageLines = wrappedText(view.message, width, ' ').map((line) =>
683
+ paint(line, 'yellow', color),
684
+ )
685
+ body.push('')
686
+ if (messageLines.length > 0) messageFocus = body.length
687
+ body.push(...messageLines)
688
+ }
689
+ return { body, sessionFocus, messageFocus }
690
+ }
691
+
692
+ interface DetailGeometry extends DetailBodyLayout {
693
+ capacity: number
694
+ limit: number
695
+ }
696
+
697
+ function upgradeNoticeLine(
698
+ model: DashboardModel,
699
+ color: boolean,
700
+ width: number,
701
+ ): string | undefined {
702
+ return model.availableUpgrade === undefined
703
+ ? undefined
704
+ : truncate(
705
+ paint(
706
+ ` Autobuild v${displayText(model.availableUpgrade)} is available — run ab upgrade`,
707
+ 'cyan',
708
+ color,
709
+ ),
710
+ width,
711
+ )
712
+ }
713
+
714
+ function detailGeometry(
715
+ model: DashboardModel,
716
+ width: number,
717
+ height: number,
718
+ ): DetailGeometry | undefined {
719
+ const view = model.view
720
+ if (view?.kind !== 'detail') return undefined
721
+ const build = model.builds.find((candidate) => candidate.slug === view.slug)
722
+ if (build === undefined) return undefined
723
+ const layout = detailBody(build, view, { color: false, width, height, now: 0 })
724
+ const topLength = 2 + (model.availableUpgrade === undefined ? 0 : 1)
725
+ const controls =
726
+ model.resumeInput !== undefined || model.abortConfirmation !== undefined
727
+ ? dashboardControls(model, false, width, controlsCapacity(height, topLength))
728
+ : [buildLegend(build, true)]
729
+ const capacity = Math.max(0, height - topLength - 2 - controls.length)
730
+ return { ...layout, capacity, limit: Math.max(0, layout.body.length - capacity) }
731
+ }
732
+
733
+ export function detailScrollLimit(model: DashboardModel, width: number, height: number): number {
734
+ return detailGeometry(model, width, height)?.limit ?? 0
735
+ }
736
+
737
+ export function moveDetailScroll(
738
+ model: DashboardModel,
739
+ width: number,
740
+ height: number,
741
+ current: number,
742
+ delta: number,
743
+ ): number {
744
+ const limit = detailScrollLimit(model, width, height)
745
+ return Math.max(0, Math.min(limit, Math.min(current, limit) + delta))
746
+ }
747
+
748
+ /** Reveal one action-selected detail target without making retained display
749
+ * content a standing focus. Incoming and resulting offsets are always clamped. */
750
+ export function revealDetailFocus(
751
+ model: DashboardModel,
752
+ width: number,
753
+ height: number,
754
+ target: DetailFocusTarget,
755
+ current: number,
756
+ ): number {
757
+ const geometry = detailGeometry(model, width, height)
758
+ if (geometry === undefined) return 0
759
+ const scroll = Math.max(0, Math.min(geometry.limit, current))
760
+ const focus = target === 'session' ? geometry.sessionFocus : geometry.messageFocus
761
+ if (focus === undefined || geometry.capacity <= 0) return scroll
762
+ if (focus < scroll) return focus
763
+ if (focus >= scroll + geometry.capacity) {
764
+ return Math.min(geometry.limit, focus - Math.max(0, geometry.capacity - 1))
765
+ }
766
+ return scroll
767
+ }
768
+
769
+ function renderDetail(model: DashboardModel, opts: RenderOpts): string[] {
770
+ const view = model.view
771
+ if (view?.kind !== 'detail') return []
772
+ const build = model.builds.find((candidate) => candidate.slug === view.slug)
773
+ if (build === undefined) return []
774
+ const { color, width, height } = opts
775
+ const paused = build.alsoPaused ? ' (also paused)' : ''
776
+ const notice = upgradeNoticeLine(model, color, width)
777
+ const top = [
778
+ truncate(`${paint('Build', 'bold', color)} ${displayText(build.slug)}`, width),
779
+ truncate(
780
+ ` status ${paint(build.status.toUpperCase(), STATUS_COLOR[build.status], color)}${paused}`,
781
+ width,
782
+ ),
783
+ ...(notice === undefined ? [] : [notice]),
784
+ ]
785
+ const { body } = detailBody(build, view, opts)
786
+ const controls =
787
+ model.resumeInput !== undefined || model.abortConfirmation !== undefined
788
+ ? dashboardControls(model, color, width, controlsCapacity(height, top.length))
789
+ : [truncate(paint(buildLegend(build, true), 'dim', color), width)]
790
+ if (height !== undefined && height <= 0) return []
791
+ if (height !== undefined && height <= top.length) return top.slice(0, height)
792
+ if (height !== undefined && height === top.length + 1) return [...top, ...controls]
793
+ if (height !== undefined && height === top.length + 2) return [...top, '', ...controls]
794
+ const capacity =
795
+ height === undefined ? body.length : Math.max(0, height - top.length - 2 - controls.length)
796
+ const limit = Math.max(0, body.length - capacity)
797
+ const start = Math.max(0, Math.min(limit, view.scroll))
798
+ return [
799
+ ...top,
800
+ '',
801
+ ...body.slice(start, start + capacity).map((line) => truncate(line, width)),
802
+ '',
803
+ ...controls,
804
+ ].slice(0, height === undefined ? Number.POSITIVE_INFINITY : height)
805
+ }
806
+
807
+ function transcriptContent(presentation: TranscriptPresentation, width: number): string[] {
808
+ if (presentation.kind === 'raw') {
809
+ return [
810
+ 'Unrecognized transcript format - raw content:',
811
+ '',
812
+ ...presentation.text.split(/\r?\n/).flatMap((line) => wrappedText(line, width, ' ')),
813
+ ]
814
+ }
815
+ const lines: string[] = []
816
+ if (presentation.kind === 'producer-boundary') {
817
+ lines.push(...wrappedText(presentation.notice, width, ''), '')
818
+ }
819
+ for (const [index, turn] of presentation.turns.entries()) {
820
+ lines.push(`Turn ${index + 1}`)
821
+ lines.push(...turnContent(turn, width), '')
822
+ }
823
+ return lines
824
+ }
825
+
826
+ /** Convert a terminal's width to the dashboard's inset composition width. */
827
+ export function dashboardContentWidth(terminalWidth: number): number {
828
+ return Math.max(0, Math.max(0, terminalWidth) - 2)
829
+ }
830
+
831
+ /** Maximum process-local transcript offset for the current wrapped viewport. */
832
+ export function transcriptScrollLimit(
833
+ presentation: TranscriptPresentation,
834
+ terminalWidth: number,
835
+ height: number,
836
+ hasUpgradeNotice = false,
837
+ ): number {
838
+ const contentLines = transcriptContent(presentation, dashboardContentWidth(terminalWidth)).length
839
+ // Transcript chrome is two header rows, the optional upgrade row, two
840
+ // separators, and controls. Keep this in lockstep with renderTranscript.
841
+ const capacity = Math.max(0, height - 5 - (hasUpgradeNotice ? 1 : 0))
842
+ return Math.max(0, contentLines - capacity)
843
+ }
844
+
845
+ export function moveTranscriptScroll(
846
+ presentation: TranscriptPresentation,
847
+ terminalWidth: number,
848
+ height: number,
849
+ current: number,
850
+ delta: number,
851
+ hasUpgradeNotice = false,
852
+ ): number {
853
+ const limit = transcriptScrollLimit(presentation, terminalWidth, height, hasUpgradeNotice)
854
+ // Clamp current first because a resize, or state from an older controller,
855
+ // may leave it beyond the freshly wrapped viewport's end.
856
+ return Math.max(0, Math.min(limit, Math.min(current, limit) + delta))
857
+ }
858
+
859
+ function turnContent(turn: TranscriptTurn, width: number): string[] {
860
+ const lines = [
861
+ ...wrappedText(`Prompt: ${turn.prompt}`, width, ' '),
862
+ ...wrappedText(`Agent: ${turn.text}`, width, ' '),
863
+ ]
864
+ if (turn.usage !== undefined) {
865
+ lines.push(
866
+ ` Usage: ${turn.usage.inputTokens} input, ${turn.usage.outputTokens} output${
867
+ turn.usage.turns !== undefined ? `, ${turn.usage.turns} turns` : ''
868
+ }`,
869
+ )
870
+ }
871
+ if (turn.failure !== undefined)
872
+ lines.push(...wrappedText(`Failure: ${turn.failure}`, width, ' '))
873
+ return lines
874
+ }
875
+
876
+ function renderTranscript(model: DashboardModel, opts: RenderOpts): string[] {
877
+ const view = model.view
878
+ if (view?.kind !== 'transcript') return []
879
+ const { color, width, height } = opts
880
+ const notice = upgradeNoticeLine(model, color, width)
881
+ const top = [
882
+ truncate(`${paint('Transcript', 'bold', color)} ${displayText(view.slug)}`, width),
883
+ truncate(` session ${displayText(view.sessionId)}`, width),
884
+ ...(notice === undefined ? [] : [notice]),
885
+ ]
886
+ const content = transcriptContent(view.transcript, width).map((line) => truncate(line, width))
887
+ const controls = truncate(
888
+ paint('Keys: Up/Down scroll Esc back Ctrl-C quit', 'dim', color),
889
+ width,
890
+ )
891
+ if (height !== undefined && height <= 0) return []
892
+ if (height !== undefined && height <= top.length) return top.slice(0, height)
893
+ const capacity = height === undefined ? content.length : Math.max(0, height - top.length - 3)
894
+ return [...top, '', ...viewport(content, capacity, 0, view.scroll), '', controls].slice(
895
+ 0,
896
+ height === undefined ? Number.POSITIVE_INFINITY : height,
897
+ )
898
+ }
899
+
900
+ /** The session view's full scrollable content: the shared projection's lines,
901
+ * or the error message in place of them, plus the closed stream's outcome
902
+ * line. ONE shared derivation for the renderer and the scroll limit so the
903
+ * tail math cannot drift from the paint. */
904
+ export function sessionContentLines(view: SessionDashboardView, width: number): string[] {
905
+ if (view.error !== undefined) return wrappedText(view.error, width, '')
906
+ const lines =
907
+ view.source.kind === 'parts'
908
+ ? projectSessionParts(view.source.parts, width)
909
+ : projectSessionDocument(view.source.document, width)
910
+ if (view.status === 'closed') {
911
+ lines.push(`Stream closed: ${view.outcome ?? 'completed'}`)
912
+ }
913
+ return lines
914
+ }
915
+
916
+ /** Maximum process-local session-view offset for the current wrapped viewport.
917
+ * Mirrors the transcript pair; the session view's chrome is identical. */
918
+ export function sessionScrollLimit(
919
+ view: SessionDashboardView,
920
+ terminalWidth: number,
921
+ height: number,
922
+ hasUpgradeNotice = false,
923
+ ): number {
924
+ const contentLines = sessionContentLines(view, dashboardContentWidth(terminalWidth)).length
925
+ // Session chrome is two header rows, the optional upgrade row, two
926
+ // separators, and controls — the transcript's exact arithmetic.
927
+ const capacity = Math.max(0, height - 5 - (hasUpgradeNotice ? 1 : 0))
928
+ return Math.max(0, contentLines - capacity)
929
+ }
930
+
931
+ export function moveSessionScroll(
932
+ view: SessionDashboardView,
933
+ terminalWidth: number,
934
+ height: number,
935
+ current: number,
936
+ delta: number,
937
+ hasUpgradeNotice = false,
938
+ ): number {
939
+ const limit = sessionScrollLimit(view, terminalWidth, height, hasUpgradeNotice)
940
+ // Clamp current first: a resize, a feed apply, or state from an older
941
+ // controller may leave it beyond the freshly wrapped viewport's end.
942
+ return Math.max(0, Math.min(limit, Math.min(current, limit) + delta))
943
+ }
944
+
945
+ function renderSessionView(model: DashboardModel, opts: RenderOpts): string[] {
946
+ const view = model.view
947
+ if (view?.kind !== 'session') return []
948
+ const { color, width, height } = opts
949
+ const notice = upgradeNoticeLine(model, color, width)
950
+ const top = [
951
+ truncate(`${paint('Session', 'bold', color)} ${displayText(view.slug)}`, width),
952
+ truncate(
953
+ ` session ${displayText(view.sessionId)} · ${displayText(view.stream)} · ${
954
+ view.status === 'open' ? 'live' : 'closed'
955
+ }`,
956
+ width,
957
+ ),
958
+ ...(notice === undefined ? [] : [notice]),
959
+ ]
960
+ const content = sessionContentLines(view, width).map((line) => truncate(line, width))
961
+ const controls = truncate(
962
+ paint(
963
+ view.follow ? DASHBOARD_SESSION_LIVE_LEGEND : DASHBOARD_SESSION_PAUSED_LEGEND,
964
+ 'dim',
965
+ color,
966
+ ),
967
+ width,
968
+ )
969
+ if (height !== undefined && height <= 0) return []
970
+ if (height !== undefined && height <= top.length) return top.slice(0, height)
971
+ const capacity = height === undefined ? content.length : Math.max(0, height - top.length - 3)
972
+ // While following, the view pins to the limit so the tail is tracked as new
973
+ // content lands; otherwise the stored process-local offset rules.
974
+ const limit = Math.max(0, content.length - capacity)
975
+ const scroll = view.follow ? limit : Math.max(0, Math.min(limit, view.scroll))
976
+ return [...top, '', ...viewport(content, capacity, 0, scroll), '', controls].slice(
977
+ 0,
978
+ height === undefined ? Number.POSITIVE_INFINITY : height,
979
+ )
980
+ }
981
+
982
+ // ── The blocked-build resume panel ───────────────────────────────────────────
983
+ //
984
+ // Answering a blocked build is the ONLY thing that can happen while the prompt
985
+ // is open, so the controls region has nothing else to say and the panel takes
986
+ // it over whole. It is a block of rows, not a line: a title, a hint, the
987
+ // blocker being answered, a real multi-line field with a caret, and its own key
988
+ // bindings.
989
+ //
990
+ // The panel is bounded from OUTSIDE (a capacity the frame grants) and from
991
+ // INSIDE (its own per-block row caps), which is what makes graceful degradation
992
+ // a property of the allocation rather than a special case per element.
993
+
994
+ const FIELD_MAX_ROWS = 6
995
+ const KEYS_MAX_ROWS = 6
996
+ const HINT_MAX_ROWS = 4
997
+ const QUESTION_MAX_ROWS = 4
998
+ /** The caret is an inserted ASCII glyph, not reverse video: `frame-image.ts`
999
+ * rejects any SGR outside the dashboard's palette, and this file's opening rule
1000
+ * is that stripping escapes must leave the actionable state intact — which a
1001
+ * colour-only cursor does not. */
1002
+ const RESUME_CARET = '|'
1003
+
1004
+ interface ResumeBinding {
1005
+ key: string
1006
+ /** A shorter key form, used only when it saves a ROW. Dropping the alias
1007
+ * costs a convenience; dropping an action word would cost the panel its
1008
+ * meaning. */
1009
+ keyShort?: string
1010
+ action: string
1011
+ }
1012
+
1013
+ /**
1014
+ * The panel's key bindings, held as data rather than baked into format strings
1015
+ * so the invariant is visible at the definition site:
1016
+ *
1017
+ * >>> A rendered binding ALWAYS reads `<key> <action>`, action spelled out.
1018
+ *
1019
+ * Narrow widths are paid for in ROWS — by wrapping a binding across two — never
1020
+ * in words. `Ctrl-J nl` fits where `Ctrl-J newline` does not, and stops naming
1021
+ * the action, which was the entire point of the row.
1022
+ */
1023
+ const RESUME_BINDINGS: readonly ResumeBinding[] = [
1024
+ { key: 'Enter', action: 'submit' },
1025
+ { key: 'Ctrl-J or Shift+Enter', keyShort: 'Ctrl-J', action: 'newline' },
1026
+ { key: 'Esc', action: 'cancel' },
1027
+ ]
1028
+
1029
+ /** Lay one set of key forms out at `width`, or `undefined` when some binding
1030
+ * cannot be named at all — its key or its action alone overflows the row. */
1031
+ function layoutBindings(
1032
+ bindings: ReadonlyArray<{ key: string; action: string }>,
1033
+ width: number,
1034
+ ): string[] | undefined {
1035
+ const blocks: string[][] = []
1036
+ let allSingleRow = true
1037
+ for (const binding of bindings) {
1038
+ const single = `${binding.key} ${binding.action}`
1039
+ if (single.length <= width) {
1040
+ blocks.push([single])
1041
+ continue
1042
+ }
1043
+ if (binding.key.length > width || binding.action.length > width) return undefined
1044
+ allSingleRow = false
1045
+ // The continuation indent is the first thing to go; the action word is not.
1046
+ const indent = 2 + binding.action.length <= width ? ' ' : ''
1047
+ blocks.push([binding.key, `${indent}${binding.action}`])
1048
+ }
1049
+ if (!allSingleRow) return blocks.flat()
1050
+ const tokens = blocks.map((block) => block[0]!)
1051
+ // The `Keys: ` prefix earns its columns only when all three still share one
1052
+ // row with it.
1053
+ const prefixed = `Keys: ${tokens.join(' ')}`
1054
+ if (prefixed.length <= width) return [prefixed]
1055
+ return packAtomic(tokens, width)
1056
+ }
1057
+
1058
+ /**
1059
+ * The keys block: 0…`KEYS_MAX_ROWS` rows naming submit, newline, and cancel.
1060
+ *
1061
+ * Below the width where `newline` fits a row at all the block is dropped
1062
+ * WHOLE. Rendering the other two while silently omitting newline would
1063
+ * misrepresent what the prompt accepts, which is worse than saying nothing.
1064
+ */
1065
+ export function resumeKeysRows(width: number): string[] {
1066
+ if (width <= 0) return []
1067
+ const long = layoutBindings(
1068
+ RESUME_BINDINGS.map((b) => ({ key: b.key, action: b.action })),
1069
+ width,
1070
+ )
1071
+ const short = layoutBindings(
1072
+ RESUME_BINDINGS.map((b) => ({ key: b.keyShort ?? b.key, action: b.action })),
1073
+ width,
1074
+ )
1075
+ // Fewer rows wins; a tie goes to the long form, so `Shift+Enter` is named
1076
+ // whenever it is free.
1077
+ const chosen =
1078
+ long === undefined ? short : short !== undefined && short.length < long.length ? short : long
1079
+ return (chosen ?? []).slice(0, KEYS_MAX_ROWS)
1080
+ }
1081
+
1082
+ /**
1083
+ * The hint's rungs, widest first.
1084
+ *
1085
+ * >>> EVERY rung states both facts: guidance is optional, and submitting an
1086
+ * >>> empty field resumes the build without it.
1087
+ *
1088
+ * The rungs shorten the connective tissue, never the content. A future edit
1089
+ * that shortens one past both facts is visible right here.
1090
+ */
1091
+ const RESUME_HINTS: readonly string[] = [
1092
+ 'Guidance is optional -- Enter on an empty field resumes without it',
1093
+ 'Guidance is optional -- Enter alone resumes without it',
1094
+ 'Guidance optional; Enter alone resumes without it',
1095
+ 'Guidance optional; Enter alone resumes',
1096
+ ]
1097
+
1098
+ /** The widest rung that fits one row, else the narrowest rung WRAPPED — because
1099
+ * wrapping costs rows while shortening cost meaning. Past the row cap the block
1100
+ * is dropped whole: half a sentence states neither fact. */
1101
+ export function resumeHintRows(width: number): string[] {
1102
+ if (width <= 0) return []
1103
+ for (const rung of RESUME_HINTS) if (rung.length <= width) return [rung]
1104
+ const wrapped = wrapDisplay(RESUME_HINTS.at(-1)!, width)
1105
+ return wrapped.length <= HINT_MAX_ROWS ? wrapped : []
1106
+ }
1107
+
1108
+ /**
1109
+ * Rows the controls region may claim.
1110
+ *
1111
+ * Exactly `1` at every height where today's frame has room for only the
1112
+ * legend — so a one-line legend spends exactly what it always did and every
1113
+ * non-prompt frame stays byte-identical — and otherwise at least one body row
1114
+ * is reserved for the build list.
1115
+ */
1116
+ export function controlsCapacity(
1117
+ height: number | undefined,
1118
+ topLength: number,
1119
+ separatorCount = 2,
1120
+ ): number {
1121
+ if (height === undefined) return Number.POSITIVE_INFINITY
1122
+ const below = height - topLength - separatorCount
1123
+ return Math.max(1, Math.min(below - 1, Math.max(4, Math.floor(below / 2))))
1124
+ }
1125
+
1126
+ /** One field row with the caret inserted at `caret`, painted but never wider
1127
+ * than `width`. The composer reserves a column for the glyph, so the clip is a
1128
+ * no-op for every `width >= 2`; at width 1 it keeps the caret visible. */
1129
+ function renderFieldRow(
1130
+ row: string,
1131
+ caret: { offset: number; column: number } | undefined,
1132
+ width: number,
1133
+ color: boolean,
1134
+ ): string {
1135
+ if (caret === undefined) return truncate(row, width)
1136
+ if (width <= 1) return paint(RESUME_CARET, 'cyan', color)
1137
+ const before = row.slice(0, caret.offset)
1138
+ const after = row.slice(caret.offset)
1139
+ // layoutComposer reserves the caret's one cell. Keep insertion in UTF-16
1140
+ // space and geometry in cell space so a wide cluster is never sliced at a
1141
+ // display column.
1142
+ return truncate(`${before}${paint(RESUME_CARET, 'cyan', color)}${after}`, width)
1143
+ }
1144
+
1145
+ /**
1146
+ * The panel, assembled and allocated.
1147
+ *
1148
+ * Against a granted capacity, rows go out in this flat order while capacity
1149
+ * remains — the field and its bindings are the LAST things to go and the
1150
+ * blocker text is the first:
1151
+ *
1152
+ * 1. the first field row,
1153
+ * 2. every keys row, in order,
1154
+ * 3. the title row,
1155
+ * 4. every hint row, in order,
1156
+ * 5. question rows, up to `min(desired, QUESTION_MAX_ROWS, ceil(rest / 2))`,
1157
+ * 6. further field rows, up to `FIELD_MAX_ROWS`,
1158
+ * 7. any capacity still unspent, back to questions.
1159
+ *
1160
+ * >>> That order is a PREFIX under WIDTH exactly as it is under capacity. <<<
1161
+ *
1162
+ * A block the granted width cannot represent at all — the keys below content
1163
+ * width 7, the hint below 11 — HALTS the order rather than yielding its rows to
1164
+ * whatever comes next. Without that, a six-column panel printed a truncated
1165
+ * title and a wrapped blocker while naming none of its keys, which is the
1166
+ * degradation order run exactly backwards. Only the field is exempt, because it
1167
+ * outranks every block that can halt: leftover rows still make it taller.
1168
+ */
1169
+ export function resumePanel(
1170
+ model: DashboardModel,
1171
+ color: boolean,
1172
+ width: number,
1173
+ capacity: number,
1174
+ ): string[] {
1175
+ const prompt = model.resumeInput!
1176
+ // A frame that granted NO content columns can render nothing at all. This is
1177
+ // not the same as running out of capacity: `renderDashboard` reserves its
1178
+ // one-column gutter BEFORE calling in, so a one-column terminal arrives here
1179
+ // as width 0, and a field that clamped itself up to one column would emit a
1180
+ // physical row two columns wide. The panel never manufactures a column the
1181
+ // frame did not grant — which is what makes every `fieldWidth` below at
1182
+ // least 1 without a clamp.
1183
+ if (width <= 0) return []
1184
+ const questions = model.builds.find((candidate) => candidate.slug === prompt.slug)?.blockers ?? []
1185
+
1186
+ const title = truncate(
1187
+ paint(paint(`Resume ${displayText(prompt.slug)}`, 'cyan', color), 'bold', color),
1188
+ width,
1189
+ )
1190
+ const hintRows = resumeHintRows(width)
1191
+ const keysRows = resumeKeysRows(width)
1192
+ // Questions go through the NON-truncating wrap: an 80-character URL in a
1193
+ // blocker occupies three rows rather than losing its tail to a `~`. Content
1194
+ // leaves the panel only in whole-row units, which is what the omission
1195
+ // notice accounts for.
1196
+ const questionRows: string[] = []
1197
+ for (const question of questions) {
1198
+ const wrapped = wrapDisplay(question, width - 4)
1199
+ for (const [index, line] of wrapped.entries()) {
1200
+ questionRows.push(
1201
+ truncate(paint(`${index === 0 ? ' ! ' : ' '}${line}`, 'red', color), width),
1202
+ )
1203
+ }
1204
+ }
1205
+ // The indent is dropped before it could eat the field's last column, so this
1206
+ // is at least 1 for every width the early return above lets through.
1207
+ const fieldIndent = width >= 6 ? ' ' : ''
1208
+ const fieldWidth = width - fieldIndent.length
1209
+ const field = layoutComposer(prompt.value, prompt.cursor, fieldWidth)
1210
+
1211
+ let rest = capacity
1212
+ const spend = (want: number): number => {
1213
+ const granted = Math.max(0, Math.min(want, rest))
1214
+ rest -= granted
1215
+ return granted
1216
+ }
1217
+ // Both blocks are built from constants, so an empty one means the width
1218
+ // cannot represent it — never that there was nothing to say. Each halts every
1219
+ // block BELOW it in the priority order.
1220
+ const keysDropped = keysRows.length === 0
1221
+ const hintDropped = keysDropped || hintRows.length === 0
1222
+
1223
+ let fieldGrant = spend(1)
1224
+ const keysGrant = spend(keysRows.length)
1225
+ const titleGrant = keysDropped ? 0 : spend(1)
1226
+ const hintGrant = spend(hintRows.length)
1227
+ let questionGrant = hintDropped
1228
+ ? 0
1229
+ : spend(Math.min(questionRows.length, QUESTION_MAX_ROWS, Math.ceil(Math.max(0, rest) / 2)))
1230
+ // Ungated: the field outranks every block that can halt the order, so rows a
1231
+ // halt freed up are exactly what a taller field is for.
1232
+ fieldGrant += spend(Math.min(field.rows.length, FIELD_MAX_ROWS) - fieldGrant)
1233
+ questionGrant += hintDropped
1234
+ ? 0
1235
+ : spend(Math.min(questionRows.length, QUESTION_MAX_ROWS) - questionGrant)
1236
+
1237
+ const rows: string[] = []
1238
+ if (titleGrant > 0) rows.push(title)
1239
+ for (const line of hintRows.slice(0, hintGrant)) {
1240
+ rows.push(truncate(paint(line, 'dim', color), width))
1241
+ }
1242
+ if (questionGrant > 0) {
1243
+ const shown = questionRows.slice(0, questionGrant)
1244
+ const omitted = questionRows.length - questionGrant
1245
+ // Whole-row loss is accounted for explicitly rather than cut silently. At
1246
+ // zero granted rows the blocker is absent entirely — AC 7's drop order,
1247
+ // with no row left to carry a notice.
1248
+ if (omitted > 0) {
1249
+ shown[shown.length - 1] = overflowNotice(`and ${omitted + 1} more lines`, color, width)
1250
+ }
1251
+ rows.push(...shown)
1252
+ }
1253
+ // The visible window is the granted slice containing the cursor row, biased
1254
+ // to keep that row last while typing.
1255
+ const start = Math.max(
1256
+ 0,
1257
+ Math.min(field.cursorRow - fieldGrant + 1, Math.max(0, field.rows.length - fieldGrant)),
1258
+ )
1259
+ for (const [offset, row] of field.rows.slice(start, start + fieldGrant).entries()) {
1260
+ const index = start + offset
1261
+ rows.push(
1262
+ `${fieldIndent}${renderFieldRow(
1263
+ row,
1264
+ index === field.cursorRow
1265
+ ? { offset: field.cursorOffset, column: field.cursorColumn }
1266
+ : undefined,
1267
+ fieldWidth,
1268
+ color,
1269
+ )}`,
1270
+ )
1271
+ }
1272
+ for (const line of keysRows.slice(0, keysGrant)) {
1273
+ rows.push(truncate(paint(line, 'dim', color), width))
1274
+ }
1275
+ return rows
1276
+ }
1277
+
1278
+ function dashboardControls(
1279
+ model: DashboardModel,
1280
+ color: boolean,
1281
+ width: number,
1282
+ capacity: number,
1283
+ ): string[] {
1284
+ if (model.abortConfirmation !== undefined) {
1285
+ return [
1286
+ truncate(
1287
+ paint(
1288
+ `Abort ${displayText(model.abortConfirmation.slug)} and delete its workspace, PR, and branches? Enter confirm Esc cancel`,
1289
+ 'red',
1290
+ color,
1291
+ ),
1292
+ width,
1293
+ ),
1294
+ ]
1295
+ }
1296
+ if (model.resumeInput === undefined) {
1297
+ const legend =
1298
+ model.selection?.kind === 'build'
1299
+ ? (() => {
1300
+ const build = model.builds.find(
1301
+ (candidate) =>
1302
+ candidate.slug === (model.selection?.kind === 'build' ? model.selection.slug : ''),
1303
+ )
1304
+ return build === undefined ? DASHBOARD_BUILD_LEGEND : buildLegend(build, false)
1305
+ })()
1306
+ : model.selection?.kind === 'harvest'
1307
+ ? model.harvest?.action === 'resume'
1308
+ ? DASHBOARD_HARVEST_RESUME_LEGEND
1309
+ : model.harvest?.action === 'acknowledge'
1310
+ ? DASHBOARD_HARVEST_ACKNOWLEDGE_LEGEND
1311
+ : DASHBOARD_HARVEST_LEGEND
1312
+ : DASHBOARD_GLOBAL_LEGEND
1313
+ return [truncate(paint(legend, 'dim', color), width)]
1314
+ }
1315
+ return resumePanel(model, color, width, capacity)
1316
+ }
1317
+
1318
+ function overflowNotice(text: string, color: boolean, width: number): string {
1319
+ return truncate(paint(` ... ${text}`, 'dim', color), width)
1320
+ }
1321
+
1322
+ interface RenderedDashboardRow {
1323
+ selection: DashboardSelection
1324
+ lines: string[]
1325
+ }
1326
+
1327
+ function flattenRows(rows: readonly RenderedDashboardRow[]): string[] {
1328
+ const lines: string[] = []
1329
+ for (const [index, row] of rows.entries()) {
1330
+ if (index > 0) lines.push('')
1331
+ lines.push(...row.lines)
1332
+ }
1333
+ return lines
1334
+ }
1335
+
1336
+ function renderDashboardContent(model: DashboardModel, opts: RenderOpts): string[] {
1337
+ if (model.view?.kind === 'detail') return renderDetail(model, opts)
1338
+ if (model.view?.kind === 'transcript') return renderTranscript(model, opts)
1339
+ if (model.view?.kind === 'session') return renderSessionView(model, opts)
1340
+
1341
+ const { color, width, height } = opts
1342
+ const selecting = model.selection !== undefined
1343
+ const globalSelection = { kind: 'global' } as const
1344
+ const marker = selectionMarker(sameSelection(globalSelection, model.selection), selecting, color)
1345
+ const intake = model.drained
1346
+ ? paint('intake OFF', 'yellow', color)
1347
+ : paint('intake ON', 'green', color)
1348
+ // "default" is implicit at the top level — the header is repo-wide state,
1349
+ // while a build row's own `auto merge` token is that build's setting.
1350
+ const autoMergeDefault = model.defaultAutoMerge
1351
+ ? paint('auto merge ON', 'green', color)
1352
+ : paint('auto merge OFF', 'yellow', color)
1353
+ const harvestGate = model.harvestPaused
1354
+ ? paint('harvest OFF', 'yellow', color)
1355
+ : paint('harvest ON', 'green', color)
1356
+ const repositoryPause = model.repositoryPaused
1357
+ ? paint('repository PAUSED', 'yellow', color)
1358
+ : undefined
1359
+ const summary = truncate(
1360
+ `${marker}${[
1361
+ paint('Autobuild', 'bold', color),
1362
+ displayText(basename(model.repo)),
1363
+ paint(
1364
+ `queue ${model.queued} | active ${model.active.current}/${model.active.limit} | ` +
1365
+ `observations ${model.observations.current}/${model.observations.limit}`,
1366
+ 'dim',
1367
+ color,
1368
+ ),
1369
+ ].join(' ')}`,
1370
+ width,
1371
+ )
1372
+ // The global controls live on their own mandatory line. Its fixed blank
1373
+ // marker prefix aligns the first toggle with the title while keeping the
1374
+ // selection lane empty.
1375
+ const toggles = truncate(
1376
+ ` ${[intake, autoMergeDefault, harvestGate, repositoryPause]
1377
+ .filter((token): token is string => token !== undefined)
1378
+ .join(' ')}`,
1379
+ width,
1380
+ )
1381
+ const upgradeNotice = upgradeNoticeLine(model, color, width)
1382
+ // Warnings are conditional chrome, not a reserved log slot. Each notice is
1383
+ // capped to the configured three-row content preview, followed by the
1384
+ // overflow/count notice when content remains; notices are not dropped by
1385
+ // count. The resulting rows join `top`, so `top.slice` limits what short
1386
+ // frames paint and `controlsCapacity` budgets proportionally fewer controls
1387
+ // rows as warnings increase `top.length`.
1388
+ const warnings = warningRows(model.warningLines ?? [], width)
1389
+ const top = [
1390
+ summary,
1391
+ toggles,
1392
+ ...(upgradeNotice === undefined ? [] : [upgradeNotice]),
1393
+ ...warnings,
1394
+ ]
1395
+ // Both structural separators are fixed frame chrome: one between the
1396
+ // completed top section and body, and one between body and controls.
1397
+ const separatorCount = 2
1398
+ const controls = dashboardControls(
1399
+ model,
1400
+ color,
1401
+ width,
1402
+ controlsCapacity(height, top.length, separatorCount),
1403
+ )
1404
+
1405
+ // No paintable height at all (a 1-row screen — see `paintableRows`): paint
1406
+ // nothing. Its trailing newline would scroll a single line off even on the
1407
+ // alternate display, which is worse than an empty region. The two mandatory
1408
+ // header lines outrank warning/body/controls as height disappears.
1409
+ if (height !== undefined && height <= 0) return []
1410
+ if (height !== undefined && height <= top.length) return top.slice(0, height)
1411
+ // Once the complete top section fits, retain controls before spending rows
1412
+ // on body content. Chrome-only remainder is spent on the two structural
1413
+ // separators; a visible body is not admitted until both gaps fit.
1414
+ if (height !== undefined && height <= top.length + separatorCount + controls.length) {
1415
+ const available = height - top.length
1416
+ if (available <= controls.length) return [...top, ...controls.slice(0, available)]
1417
+ return [...top, ...Array.from({ length: available - controls.length }, () => ''), ...controls]
1418
+ }
1419
+
1420
+ const widths = frameWidths(model.builds, model.harvest)
1421
+ const rows: RenderedDashboardRow[] = dashboardSelections(model).flatMap(
1422
+ (selection): RenderedDashboardRow[] => {
1423
+ // The global selection is fixed frame chrome above the body. It still
1424
+ // participates in navigation/reconciliation, but never enters viewport
1425
+ // clamping because its header is always visible.
1426
+ if (selection.kind === 'global') return []
1427
+ if (selection.kind === 'harvest') {
1428
+ return [
1429
+ {
1430
+ selection,
1431
+ lines: renderHarvest(
1432
+ model.harvest!,
1433
+ opts,
1434
+ widths,
1435
+ sameSelection(selection, model.selection),
1436
+ selecting,
1437
+ ),
1438
+ },
1439
+ ]
1440
+ }
1441
+ const build = model.builds.find((candidate) => candidate.slug === selection.slug)!
1442
+ return [
1443
+ {
1444
+ selection,
1445
+ lines: renderBuild(
1446
+ build,
1447
+ model.repositoryPaused,
1448
+ opts,
1449
+ widths,
1450
+ sameSelection(selection, model.selection),
1451
+ selecting,
1452
+ ),
1453
+ },
1454
+ ]
1455
+ },
1456
+ )
1457
+
1458
+ // Both frame composers derive their body budget from `controls.length`, so a
1459
+ // one-line legend reduces to exactly today's arithmetic and a prompt panel
1460
+ // grows into rows the body gives up.
1461
+ const bodyBudget =
1462
+ height === undefined
1463
+ ? Number.POSITIVE_INFINITY
1464
+ : Math.max(0, height - top.length - separatorCount - controls.length)
1465
+ let body: string[]
1466
+ if (rows.length === 0) {
1467
+ body = bodyBudget >= 1 ? [truncate(paint(' no active builds', 'dim', color), width)] : []
1468
+ } else {
1469
+ const allRows = flattenRows(rows)
1470
+ if (allRows.length <= bodyBudget) {
1471
+ body = allRows
1472
+ } else if (bodyBudget <= 0) {
1473
+ body = []
1474
+ } else {
1475
+ const selectedIndex = Math.max(
1476
+ 0,
1477
+ rows.findIndex((row) => sameSelection(row.selection, model.selection)),
1478
+ )
1479
+ const prefix = [0]
1480
+ for (const row of rows) prefix.push(prefix.at(-1)! + row.lines.length)
1481
+
1482
+ // Brute force is intentional and tiny (dashboard row counts are bounded
1483
+ // by operator workload): choose the largest contiguous whole-row window
1484
+ // containing selection, with explicit omission notices. Harvest and
1485
+ // builds participate identically.
1486
+ let best: { start: number; end: number; count: number; used: number } | undefined
1487
+ for (let start = 0; start <= selectedIndex; start += 1) {
1488
+ for (let end = selectedIndex; end < rows.length; end += 1) {
1489
+ const rowLines = prefix[end + 1]! - prefix[start]! + (end - start)
1490
+ const notices = (start > 0 ? 1 : 0) + (end < rows.length - 1 ? 1 : 0)
1491
+ const used = rowLines + notices
1492
+ if (used > bodyBudget) continue
1493
+ const count = end - start + 1
1494
+ if (
1495
+ best === undefined ||
1496
+ count > best.count ||
1497
+ (count === best.count && used > best.used)
1498
+ ) {
1499
+ best = { start, end, count, used }
1500
+ }
1501
+ }
1502
+ }
1503
+
1504
+ if (best !== undefined) {
1505
+ body = []
1506
+ if (best.start > 0) {
1507
+ body.push(overflowNotice(`${best.start} more above`, color, width))
1508
+ }
1509
+ body.push(...flattenRows(rows.slice(best.start, best.end + 1)))
1510
+ const below = rows.length - best.end - 1
1511
+ if (below > 0) {
1512
+ body.push(overflowNotice(`and ${below} more below`, color, width))
1513
+ }
1514
+ } else {
1515
+ // A detailed selected row itself cannot fit. Keep its selectable
1516
+ // identity visible, then spend remaining rows on detail and omission.
1517
+ const selectedLines = rows[selectedIndex]!.lines
1518
+ const above = selectedIndex
1519
+ const below = rows.length - selectedIndex - 1
1520
+ let noticeLines = 0
1521
+ if (bodyBudget > 1 && (above > 0 || below > 0)) {
1522
+ noticeLines = bodyBudget > 2 && above > 0 && below > 0 ? 2 : 1
1523
+ }
1524
+ const detailCapacity = Math.max(1, bodyBudget - noticeLines)
1525
+ body = []
1526
+ if (noticeLines === 2 && above > 0) {
1527
+ body.push(overflowNotice(`${above} more above`, color, width))
1528
+ }
1529
+ body.push(...selectedLines.slice(0, detailCapacity))
1530
+ if (noticeLines === 2 && below > 0) {
1531
+ body.push(overflowNotice(`and ${below} more below`, color, width))
1532
+ } else if (noticeLines === 1) {
1533
+ body.push(
1534
+ overflowNotice(
1535
+ above > 0 && below > 0
1536
+ ? `${above} above, ${below} below`
1537
+ : above > 0
1538
+ ? `${above} more above`
1539
+ : `and ${below} more below`,
1540
+ color,
1541
+ width,
1542
+ ),
1543
+ )
1544
+ }
1545
+ body = body.slice(0, bodyBudget)
1546
+ }
1547
+ }
1548
+ }
1549
+
1550
+ // Both blanks are structural frame chrome, independent of warning/upgrade
1551
+ // content, and are charged exactly once in the body budget above.
1552
+ return [...top, '', ...body, '', ...controls]
1553
+ }
1554
+
1555
+ /** Render the dashboard inside a fixed one-column horizontal gutter.
1556
+ *
1557
+ * All composition happens against the inset content width so wrapping,
1558
+ * truncation, and right-pinning share one geometry. Prefixing only after the
1559
+ * frame is composed preserves its logical line count (including truly blank
1560
+ * separators) and therefore leaves vertical budgeting unchanged. */
1561
+ export function renderDashboard(model: DashboardModel, opts: RenderOpts): string[] {
1562
+ const terminalWidth = Math.max(0, opts.width)
1563
+ const contentWidth = dashboardContentWidth(terminalWidth)
1564
+ const lines = renderDashboardContent(model, { ...opts, width: contentWidth })
1565
+ const leftGutter = terminalWidth > 0 ? ' ' : ''
1566
+
1567
+ return lines.map((line) => (line === '' ? '' : `${leftGutter}${line}`))
1568
+ }