jackherd 0.0.1 → 1.0.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 (250) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +38 -0
  4. package/README.md +1121 -2
  5. package/assets/branding/README.md +62 -0
  6. package/assets/branding/jackherd-icon-256.png +0 -0
  7. package/assets/branding/jackherd-icon-512.png +0 -0
  8. package/assets/branding/jackherd-icon-64.png +0 -0
  9. package/assets/branding/jackherd-icon-small-32.png +0 -0
  10. package/assets/branding/jackherd-icon-small-64.png +0 -0
  11. package/assets/branding/jackherd-icon-small.svg +1 -0
  12. package/assets/branding/jackherd-icon.svg +1 -0
  13. package/assets/branding/jackherd-logo-1024.png +0 -0
  14. package/assets/branding/jackherd-logo-512.png +0 -0
  15. package/assets/branding/jackherd-logo-dark-1024.png +0 -0
  16. package/assets/branding/jackherd-logo-dark-512.png +0 -0
  17. package/assets/branding/jackherd-logo-dark.svg +1 -0
  18. package/assets/branding/jackherd-logo-mark-1024.png +0 -0
  19. package/assets/branding/jackherd-logo-mark-512.png +0 -0
  20. package/assets/branding/jackherd-logo-mark.svg +1 -0
  21. package/assets/branding/jackherd-logo.svg +1 -0
  22. package/dist/chunk-R6MGHCD7.js +93 -0
  23. package/dist/config.d.ts +167 -0
  24. package/dist/config.js +401 -0
  25. package/dist/plan-format.d.ts +325 -0
  26. package/dist/plan-format.js +636 -0
  27. package/docs/lanes.mmd +9 -0
  28. package/docs/lanes.svg +1 -0
  29. package/docs/skills.mmd +10 -0
  30. package/docs/skills.svg +1 -0
  31. package/docs/workflow.svg +172 -0
  32. package/herdr-plugin/herdr-plugin.toml +22 -0
  33. package/herdr-plugin/open-plans.ts +43 -0
  34. package/herdr-plugin/restore-plans.ts +107 -0
  35. package/install.sh +157 -0
  36. package/kit/hooks/hooks/git-guard/decision.ts +438 -0
  37. package/kit/hooks/hooks/git-guard/index.ts +38 -0
  38. package/kit/hooks/hooks/git-guard/shell.ts +407 -0
  39. package/kit/hooks/hooks/watch-guard/background.ts +36 -0
  40. package/kit/hooks/hooks/watch-guard/decision.ts +53 -0
  41. package/kit/hooks/hooks/watch-guard/index.ts +11 -0
  42. package/kit/hooks/hooks/watch-guard/shell-input.ts +32 -0
  43. package/kit/hooks/hooks/watch-guard/starts.ts +154 -0
  44. package/kit/hooks/hooks/watch-guard/text-fallback.ts +40 -0
  45. package/kit/hooks/hooks.json +30 -0
  46. package/kit/hooks/statusline.sh +72 -0
  47. package/kit/review/agents/devops.md +11 -0
  48. package/kit/review/agents/public-content.md +39 -0
  49. package/kit/review/agents/security-audit.md +11 -0
  50. package/kit/review/agents/uncle-bob.md +11 -0
  51. package/kit/review/skills/devops/SKILL.md +50 -0
  52. package/kit/review/skills/diagnose/SKILL.md +140 -0
  53. package/kit/review/skills/diagnose/references/feedback-loops.md +59 -0
  54. package/kit/review/skills/diagnose/scripts/hitl-loop.template.sh +71 -0
  55. package/kit/review/skills/pre-commit/SKILL.md +100 -0
  56. package/kit/review/skills/review/SKILL.md +43 -0
  57. package/kit/review/skills/security-audit/SKILL.md +65 -0
  58. package/kit/review/skills/uncle-bob/SKILL.md +43 -0
  59. package/kit/rules/rules/kit/arc42.md +89 -0
  60. package/kit/rules/rules/kit/architecture-files.md +87 -0
  61. package/kit/rules/rules/kit/code-style.md +159 -0
  62. package/kit/rules/rules/kit/pull-requests.md +59 -0
  63. package/kit/rules/rules/kit/testing.md +91 -0
  64. package/kit/rules/rules/kit/writing-for-agents.md +110 -0
  65. package/kit/workflow/agents/explore.md +25 -0
  66. package/kit/workflow/agents/implement.md +16 -0
  67. package/kit/workflow/agents/plan.md +13 -0
  68. package/kit/workflow/agents/spec-review.md +96 -0
  69. package/kit/workflow/skills/follow/SKILL.md +48 -0
  70. package/kit/workflow/skills/grill/SKILL.md +60 -0
  71. package/kit/workflow/skills/implement/SKILL.md +107 -0
  72. package/kit/workflow/skills/orchestrate/SKILL.md +565 -0
  73. package/kit/workflow/skills/orchestrate/handover/decision.ts +247 -0
  74. package/kit/workflow/skills/orchestrate/handover/index.ts +171 -0
  75. package/kit/workflow/skills/plan/SKILL.md +118 -0
  76. package/kit/workflow/skills/plan/references/template-English.md +92 -0
  77. package/kit/workflow/skills/plan/references/template-German.md +92 -0
  78. package/kit/workflow/skills/start/SKILL.md +47 -0
  79. package/package.json +63 -5
  80. package/schemas/jackherd.schema.json +601 -0
  81. package/src/check/changes.ts +45 -0
  82. package/src/check/command-line.ts +12 -0
  83. package/src/check/command.ts +136 -0
  84. package/src/check/deferral-arrows.ts +41 -0
  85. package/src/check/execute.ts +29 -0
  86. package/src/check/glob.ts +15 -0
  87. package/src/check/plan-structure-check.ts +135 -0
  88. package/src/check/plan.ts +106 -0
  89. package/src/check/public-content-check.ts +32 -0
  90. package/src/check/public-content-facts.ts +177 -0
  91. package/src/check/public-content.ts +216 -0
  92. package/src/check/report.ts +55 -0
  93. package/src/check/result.ts +42 -0
  94. package/src/check/roots.ts +36 -0
  95. package/src/check/routing.ts +22 -0
  96. package/src/check/schema.ts +83 -0
  97. package/src/check/settings.ts +44 -0
  98. package/src/claude/paths.ts +14 -0
  99. package/src/cli.ts +46 -0
  100. package/src/config/command.ts +45 -0
  101. package/src/config/detect.ts +196 -0
  102. package/src/config/index.ts +2 -0
  103. package/src/config/json-schema.ts +5 -0
  104. package/src/config/legacy-names.ts +56 -0
  105. package/src/config/link-kinds.ts +25 -0
  106. package/src/config/location.ts +46 -0
  107. package/src/config/read-config.ts +42 -0
  108. package/src/config/repository-facts.ts +19 -0
  109. package/src/config/repository-values.ts +46 -0
  110. package/src/config/schema.ts +302 -0
  111. package/src/context/command.ts +39 -0
  112. package/src/context/coordinator-name.ts +16 -0
  113. package/src/context/fill.ts +69 -0
  114. package/src/context/index.ts +10 -0
  115. package/src/context/level.ts +22 -0
  116. package/src/context/options.ts +29 -0
  117. package/src/context/report.ts +42 -0
  118. package/src/context/session-log.ts +91 -0
  119. package/src/plan-format/drift.ts +69 -0
  120. package/src/plan-format/header.ts +27 -0
  121. package/src/plan-format/index.ts +9 -0
  122. package/src/plan-format/lanes.ts +128 -0
  123. package/src/plan-format/markdown.ts +98 -0
  124. package/src/plan-format/phase-tally.ts +55 -0
  125. package/src/plan-format/phases.ts +224 -0
  126. package/src/plan-format/plan-structure.ts +150 -0
  127. package/src/plan-format/progress.ts +202 -0
  128. package/src/plan-format/sprint-document.ts +69 -0
  129. package/src/plan-format/terminal-text.ts +28 -0
  130. package/src/plans/board-choice.ts +60 -0
  131. package/src/plans/board-pick.ts +72 -0
  132. package/src/plans/board-rows.ts +62 -0
  133. package/src/plans/command.ts +60 -0
  134. package/src/plans/ensure-panes.ts +71 -0
  135. package/src/plans/error-text.ts +5 -0
  136. package/src/plans/fetch.ts +96 -0
  137. package/src/plans/git.ts +145 -0
  138. package/src/plans/github.ts +35 -0
  139. package/src/plans/lane-discovery.ts +34 -0
  140. package/src/plans/lane-plans.ts +80 -0
  141. package/src/plans/layout.ts +881 -0
  142. package/src/plans/link-chips.ts +74 -0
  143. package/src/plans/live.ts +336 -0
  144. package/src/plans/markdown-html.ts +140 -0
  145. package/src/plans/options.ts +15 -0
  146. package/src/plans/phase-source.ts +71 -0
  147. package/src/plans/plan-chips.ts +75 -0
  148. package/src/plans/plan-panes.ts +206 -0
  149. package/src/plans/planned.ts +117 -0
  150. package/src/plans/pull-requests.ts +107 -0
  151. package/src/plans/restart-decision.ts +42 -0
  152. package/src/plans/selection.ts +352 -0
  153. package/src/plans/settings.ts +68 -0
  154. package/src/plans/single-flight.ts +25 -0
  155. package/src/plans/snapshot.ts +300 -0
  156. package/src/plans/sprint-export.ts +103 -0
  157. package/src/plans/sprint-html.ts +301 -0
  158. package/src/plans/sprint-page.ts +287 -0
  159. package/src/plans/supervisor.ts +124 -0
  160. package/src/plans/symbols.ts +54 -0
  161. package/src/plans/texts.ts +103 -0
  162. package/src/plans/unlisted-lanes.ts +45 -0
  163. package/src/plans/view.ts +68 -0
  164. package/src/plans/worked-phases.ts +53 -0
  165. package/src/sync/checksum.ts +13 -0
  166. package/src/sync/choice.ts +14 -0
  167. package/src/sync/command-context.ts +113 -0
  168. package/src/sync/command.ts +94 -0
  169. package/src/sync/error-message.ts +1 -0
  170. package/src/sync/gitignore.ts +45 -0
  171. package/src/sync/global-kit.ts +176 -0
  172. package/src/sync/herdr-plugin.ts +175 -0
  173. package/src/sync/if-changed.ts +109 -0
  174. package/src/sync/init-command.ts +239 -0
  175. package/src/sync/init.ts +136 -0
  176. package/src/sync/kit-hooks.ts +18 -0
  177. package/src/sync/kit-path.ts +77 -0
  178. package/src/sync/kit-source.ts +169 -0
  179. package/src/sync/kit.ts +9 -0
  180. package/src/sync/legacy-files.ts +116 -0
  181. package/src/sync/manifest.ts +44 -0
  182. package/src/sync/next-steps.ts +42 -0
  183. package/src/sync/plan-file.ts +154 -0
  184. package/src/sync/plan-hooks.ts +165 -0
  185. package/src/sync/project-fs.ts +132 -0
  186. package/src/sync/render.ts +60 -0
  187. package/src/sync/report.ts +96 -0
  188. package/src/sync/settings-hooks.ts +90 -0
  189. package/src/sync/settings-migration.ts +94 -0
  190. package/src/sync/settings-offer.ts +168 -0
  191. package/src/sync/setup-questions.ts +70 -0
  192. package/src/sync/setup.ts +52 -0
  193. package/src/sync/sync-plan.ts +120 -0
  194. package/src/sync/sync-result.ts +9 -0
  195. package/src/sync/sync.ts +307 -0
  196. package/src/sync/tools.ts +52 -0
  197. package/src/terminal/herdr.ts +91 -0
  198. package/src/terminal/index.ts +13 -0
  199. package/src/terminal/none.ts +7 -0
  200. package/src/terminal/open.ts +39 -0
  201. package/src/terminal/text.ts +159 -0
  202. package/src/terminal/types.ts +30 -0
  203. package/src/terminal/width.ts +90 -0
  204. package/src/update/command.ts +65 -0
  205. package/src/update/package-manager.ts +56 -0
  206. package/src/update/update.ts +105 -0
  207. package/src/version/installed-version.ts +32 -0
  208. package/src/version/release.ts +22 -0
  209. package/src/version/skill-diff.ts +63 -0
  210. package/src/version/started-kit.ts +14 -0
  211. package/src/vocabulary/index.ts +132 -0
  212. package/src/watch/base-tip.ts +18 -0
  213. package/src/watch/command.ts +919 -0
  214. package/src/watch/coordinator-fill.ts +76 -0
  215. package/src/watch/feedback-read.ts +73 -0
  216. package/src/watch/feedback.ts +308 -0
  217. package/src/watch/gh.ts +22 -0
  218. package/src/watch/github-read.ts +175 -0
  219. package/src/watch/herdr.ts +11 -0
  220. package/src/watch/json-guards.ts +9 -0
  221. package/src/watch/kit-update.ts +18 -0
  222. package/src/watch/lane-fill.ts +36 -0
  223. package/src/watch/lane-message.ts +99 -0
  224. package/src/watch/lane-progress.ts +44 -0
  225. package/src/watch/lane-report-file.ts +53 -0
  226. package/src/watch/lane-report.ts +270 -0
  227. package/src/watch/lane-search.ts +41 -0
  228. package/src/watch/lane-sources.ts +46 -0
  229. package/src/watch/lane-watch.ts +121 -0
  230. package/src/watch/monitor-tool.ts +19 -0
  231. package/src/watch/options.ts +79 -0
  232. package/src/watch/plan-badges-read.ts +30 -0
  233. package/src/watch/plan-badges.ts +66 -0
  234. package/src/watch/plan-views.ts +59 -0
  235. package/src/watch/preview-read.ts +60 -0
  236. package/src/watch/previews.ts +234 -0
  237. package/src/watch/pull-requests.ts +117 -0
  238. package/src/watch/single-report.ts +15 -0
  239. package/src/watch/skill-change.ts +61 -0
  240. package/src/watch/source-dry-run-command.ts +35 -0
  241. package/src/watch/source-dry-run.ts +124 -0
  242. package/src/watch/source-fields.ts +9 -0
  243. package/src/watch/source-path.ts +40 -0
  244. package/src/watch/source-run.ts +90 -0
  245. package/src/watch/sources.ts +344 -0
  246. package/src/watch/stream-output.ts +14 -0
  247. package/src/watch/texts.ts +76 -0
  248. package/src/watch/watch-events.ts +399 -0
  249. package/src/watch/watcher-lock.ts +145 -0
  250. package/src/watch/workflow-runs.ts +96 -0
@@ -0,0 +1,881 @@
1
+ import { CONTEXT_LEVEL, type ContextLevel, type ContextReading, contextLabel } from '../context'
2
+ import type { ChecklistItem, DeferredItem, LaneProgress, PhaseProgress, PlanStep, StepProgress } from '../plan-format'
3
+ import { activeItem, currentPhase, isTickedUnmerged, PHASE_ID_PREFIX } from '../plan-format'
4
+ import {
5
+ BADGE_TONE,
6
+ BAR_SEGMENT,
7
+ type BadgeTone,
8
+ badge,
9
+ bar,
10
+ fit,
11
+ INVERSE_ON,
12
+ packGroups,
13
+ padVisible,
14
+ paint,
15
+ RESET,
16
+ spread,
17
+ stripAnsi,
18
+ visibleLength,
19
+ wrap,
20
+ } from '../terminal/text'
21
+ import { AGENT_STATUS, type AgentStatus, isAgentStatus } from '../terminal/types'
22
+ import { textWidth } from '../terminal/width'
23
+ import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
24
+ import {
25
+ NO_BADGES,
26
+ type PullRequestBadges,
27
+ WATCH_HEALTH,
28
+ type WatchBadges,
29
+ type WatchHealth,
30
+ } from '../watch/plan-badges'
31
+ import { chipKey, type LinkChip } from './link-chips'
32
+ import { type PlannedPlan, phaseList, plannedPhaseCount } from './planned'
33
+ import { CHECKS, type ChecksState, type PullRequest, pullRequestKey } from './pull-requests'
34
+ import {
35
+ type ClickTarget,
36
+ followingKey,
37
+ isLaneOpen,
38
+ itemKey,
39
+ laneKey,
40
+ notesKey,
41
+ PLANNED_KEY,
42
+ phaseKey,
43
+ type Selection,
44
+ selectedKey,
45
+ targetKeys,
46
+ tickedKey,
47
+ } from './selection'
48
+ import {
49
+ chevronOf,
50
+ NO_AGENT_SYMBOL,
51
+ NO_AGENT_WORD,
52
+ PHASE_COUNT,
53
+ PHASE_STATE,
54
+ type PhaseState,
55
+ phaseStateOf,
56
+ statusSymbol,
57
+ statusWord,
58
+ } from './symbols'
59
+ import { BADGE_TEXTS, type BadgeTexts, laneMarkText, TEXTS } from './texts'
60
+
61
+ /**
62
+ * Where a lane stands outside its plan. `agent` is the terminal adapter's status, null when no agent is listed;
63
+ * `branch` is null when the lane has no worktree, and its plan is then read from the main checkout.
64
+ */
65
+ export type LaneStatus = { agent: string | null; branch: string | null; ahead: number | null; dirty: number }
66
+
67
+ /**
68
+ * A lane as drawn; `pullRequests` are the open pull requests of its branch, `chips` the links of each of its
69
+ * plans by the plan link as the sprint writes it.
70
+ */
71
+ export type LaneView = {
72
+ progress: LaneProgress
73
+ status: LaneStatus
74
+ pullRequests: PullRequest[]
75
+ chips: Record<string, LinkChip[]>
76
+ /** How full the agent's context is; null when the terminal gave no session or the session has no log. */
77
+ context?: ContextReading | null
78
+ /** The open phases of the lane's plans that its steps do not name; drawn as a folded row under the phases. */
79
+ following?: PlannedPlan[]
80
+ /** A running lane the sprint table has no row for; drawn with the mark `no row in lanes`. */
81
+ unlisted?: boolean
82
+ /** Preview and feedback of the lane's pull requests from the running watcher's state; none without a watcher. */
83
+ badges?: WatchBadges
84
+ }
85
+
86
+ /**
87
+ * `watch` is the health of the watcher the badges come from, absent when it was not read; `language` the project's
88
+ * response language for the badges' words, English when absent.
89
+ */
90
+ export type OverviewView = {
91
+ title: string
92
+ time: string
93
+ lanes: LaneView[]
94
+ planned: PlannedPlan[]
95
+ watch?: WatchHealth
96
+ language?: PlanLanguage
97
+ }
98
+
99
+ /**
100
+ * The lines of a frame, the line holding the selected row, a phase or a task (-1 when none), so the window can
101
+ * follow it, and the spans a click expands or closes.
102
+ */
103
+ export type Frame = { lines: string[]; cursorLine: number; targets: ClickTarget[] }
104
+
105
+ /** A fixed header and footer around a body that scrolls; body lines and targets count from the body's top. */
106
+ export type Screen = { header: string[]; body: Frame; footer: string[] }
107
+
108
+ /** What every block of one lane is drawn with. */
109
+ type LaneContext = {
110
+ progress: LaneProgress
111
+ state: Selection
112
+ width: number
113
+ chips: Record<string, LinkChip[]>
114
+ badges: WatchBadges
115
+ badgeTexts: BadgeTexts
116
+ }
117
+
118
+ /**
119
+ * Widths shared by every lane line: the name column, the folded pull request badges, and bar with context. They are
120
+ * measured over every lane on screen, open ones too, so opening or closing a lane moves no other lane's columns.
121
+ */
122
+ type LaneColumns = { name: number; marks: number; right: number }
123
+
124
+ type StepContext = LaneContext & { stepIndex: number }
125
+
126
+ type PhaseEntry = { stepIndex: number; phase: PhaseProgress }
127
+
128
+ const BRANCH_MARK = '⎇'
129
+ const ACTIVE_MARK = '▶'
130
+ const DEFERRED_MARK = '→'
131
+ const SYMBOL_WIDTH = 2
132
+ const COLUMN_SEPARATOR = ' '
133
+ const NAME_WIDTH_MAX = 24
134
+ const NON_BREAKING_SPACE = '\u00a0'
135
+ const ITEM_SEPARATOR = '·'
136
+ const RULE = '─'
137
+ const CONTENT_INDENT = ' '
138
+
139
+ /** The tree lines: a branch to a sibling that has more below it, the last branch, and what carries on beside them. */
140
+ const TREE = { branch: '├─ ', last: '└─ ', pipe: '│ ', gap: ' ', trunk: '│' } as const
141
+ const TREE_GUTTER = TREE.branch.length
142
+
143
+ /** Places on each side of the slash of a count: ` 9/9 `, ` 15/15 `, `123/456`. */
144
+ const COUNT_PLACES = 3
145
+ /** The cells before a count: `✅`, `✓`, `?`, `–` or nothing, padded so every slash on screen is in one column. */
146
+ const COUNT_MARK_WIDTH = 2
147
+ const BLANK_COUNT = ' '.repeat(2 * COUNT_PLACES + 1)
148
+ const MIN_TITLE_WIDTH = 8
149
+ const MIN_BESIDE_WIDTH = 24
150
+ const CONTEXT_WIDTH = 5
151
+ const CONTEXT_SYMBOL = '🧠'
152
+ const PROGRESS_SYMBOL = '✅'
153
+ const COLUMN_RULE = '│'
154
+ /** The number badge of a three-digit pull request (`↗ #` and the digits, a cell of padding either side); a longer one widens it. */
155
+ const PULL_REQUEST_BADGE_WIDTH = 8
156
+ /** Under the lane line's status symbol, where the pull request row and its badge line start (D96). */
157
+ const PULL_REQUEST_INDENT = ' '
158
+ const CHECKS_WIDTH = 10
159
+ const MARKDOWN_MARKS = /\*\*|`/g
160
+
161
+ /** Lower ranks need the coordinator sooner; a status an adapter adds later ranks after `working`. */
162
+ const URGENCY: Record<AgentStatus, number> = {
163
+ [AGENT_STATUS.blocked]: 0,
164
+ [AGENT_STATUS.done]: 1,
165
+ [AGENT_STATUS.idle]: 1,
166
+ [AGENT_STATUS.working]: 2,
167
+ }
168
+ const UNKNOWN_URGENCY = 3
169
+ const NO_AGENT_URGENCY = 4
170
+
171
+ const AGENT_PAINT: Record<AgentStatus, (text: string) => string> = {
172
+ [AGENT_STATUS.working]: paint.yellow,
173
+ [AGENT_STATUS.blocked]: paint.red,
174
+ [AGENT_STATUS.done]: paint.green,
175
+ [AGENT_STATUS.idle]: paint.dim,
176
+ }
177
+
178
+ const CHECKS_PAINT: Record<ChecksState, (text: string) => string> = {
179
+ [CHECKS.passed]: paint.green,
180
+ [CHECKS.failed]: paint.red,
181
+ [CHECKS.pending]: paint.yellow,
182
+ [CHECKS.none]: paint.dim,
183
+ }
184
+
185
+ const urgencyOf = ({ status }: LaneView): number => {
186
+ if (status.agent === null) return NO_AGENT_URGENCY
187
+ return isAgentStatus(status.agent) ? URGENCY[status.agent] : UNKNOWN_URGENCY
188
+ }
189
+
190
+ /** Lanes waiting for input first, then those with a report to read, then working ones; stable inside a group. */
191
+ export const sortByUrgency = (lanes: readonly LaneView[]): LaneView[] =>
192
+ [...lanes].sort((left, right) => urgencyOf(left) - urgencyOf(right))
193
+
194
+ const plain = (text: string): string => text.replace(MARKDOWN_MARKS, '')
195
+
196
+ const isCurrent = (progress: LaneProgress, stepIndex: number, phase: PhaseProgress): boolean =>
197
+ progress.current?.stepIndex === stepIndex && progress.current.phaseId === phase.id
198
+
199
+ const PHASE_PAINT: Record<PhaseState, (count: string) => string> = {
200
+ [PHASE_STATE.missing]: paint.dim,
201
+ [PHASE_STATE.empty]: paint.dim,
202
+ [PHASE_STATE.all]: paint.green,
203
+ [PHASE_STATE.open]: (count) => count,
204
+ }
205
+
206
+ const countText = (done: number, total: number): string =>
207
+ `${String(done).padStart(COUNT_PLACES)}/${String(total).padEnd(COUNT_PLACES)}`
208
+
209
+ /** Mark and count in one fixed-width cell at the right edge, the same for the lane, phase and planned rows. */
210
+ const countCell = (mark: string, count: string): string => `${padVisible(mark, COUNT_MARK_WIDTH)} ${count}`
211
+
212
+ const PHASE_MARK: Record<PhaseState, string> = {
213
+ [PHASE_STATE.missing]: PHASE_COUNT.missing,
214
+ [PHASE_STATE.empty]: PHASE_COUNT.empty,
215
+ [PHASE_STATE.all]: PHASE_COUNT.allTicked,
216
+ [PHASE_STATE.open]: '',
217
+ }
218
+
219
+ /** The count cell of a phase row at the right edge; the current phase is bold yellow. */
220
+ const phaseCountCell = (phase: PhaseProgress, current: boolean): string => {
221
+ const state = phaseStateOf(phase)
222
+ const hasCount = state === PHASE_STATE.open || state === PHASE_STATE.all
223
+ const cell = countCell(PHASE_MARK[state], hasCount ? countText(phase.done, phase.total) : BLANK_COUNT)
224
+ return current ? paint.bold(paint.yellow(cell)) : PHASE_PAINT[state](cell)
225
+ }
226
+
227
+ const agentLabel = (agent: string | null): string => {
228
+ if (agent === null) return paint.dim(statusWord(agent))
229
+ return isAgentStatus(agent) ? AGENT_PAINT[agent](statusWord(agent)) : statusWord(agent)
230
+ }
231
+
232
+ /** The symbol of a status padded to `SYMBOL_WIDTH` cells, so the names after it start in one column. */
233
+ const symbolCell = (agent: string | null): string => {
234
+ const symbol = statusSymbol(agent)
235
+ return padVisible(agent === null ? paint.dim(symbol) : symbol, SYMBOL_WIDTH)
236
+ }
237
+
238
+ const aheadLabel = ({ branch, ahead }: LaneStatus): string => {
239
+ if (ahead === null) return branch ? paint.dim('↑?') : ''
240
+ return ahead > 0 ? paint.cyan(`↑${ahead}`) : ''
241
+ }
242
+
243
+ const gitLabel = (status: LaneStatus): string =>
244
+ [
245
+ status.branch ? paint.dim(`${BRANCH_MARK} ${status.branch}`) : paint.dim(TEXTS.noWorktree),
246
+ aheadLabel(status),
247
+ status.dirty ? paint.magenta(`±${status.dirty}`) : '',
248
+ ]
249
+ .filter(Boolean)
250
+ .join(' ')
251
+
252
+ const textFrame = (lines: string[]): Frame => ({ lines, cursorLine: -1, targets: [] })
253
+
254
+ /** Stacks frames, moving each one's cursor line and click targets below the frames before it. */
255
+ const joinFrames = (frames: Frame[]): Frame =>
256
+ frames.reduce<Frame>((joined, frame) => {
257
+ const offset = joined.lines.length
258
+ return {
259
+ lines: [...joined.lines, ...frame.lines],
260
+ cursorLine: frame.cursorLine === -1 ? joined.cursorLine : offset + frame.cursorLine,
261
+ targets: [...joined.targets, ...frame.targets.map((target) => ({ ...target, line: offset + target.line }))],
262
+ }
263
+ }, textFrame([]))
264
+
265
+ /** The context of the children of a node: the pane minus the tree lines in front of them. */
266
+ const deeper = <Context extends Pick<LaneContext, 'width'>>(context: Context): Context => ({
267
+ ...context,
268
+ width: Math.max(context.width - TREE_GUTTER, 1),
269
+ })
270
+
271
+ /**
272
+ * A frame moved right by a prefix of `TREE_GUTTER` cells: a click target starting at the left edge keeps it and
273
+ * takes the prefix in.
274
+ */
275
+ const prefixed = (frame: Frame, first: string, rest: string): Frame => ({
276
+ lines: frame.lines.map((line, index) => {
277
+ const lead = index === 0 ? first : rest
278
+ return line === '' ? paint.dim(lead.trimEnd()) : `${paint.dim(lead)}${line}`
279
+ }),
280
+ cursorLine: frame.cursorLine,
281
+ targets: frame.targets.map((target) => ({
282
+ ...target,
283
+ from: target.from === 0 ? 0 : target.from + TREE_GUTTER,
284
+ to: target.to + TREE_GUTTER,
285
+ })),
286
+ })
287
+
288
+ const branches = (children: Frame[]): Frame[] => {
289
+ const present = children.filter((child) => child.lines.length > 0)
290
+ return present.map((child, index) => {
291
+ const last = index === present.length - 1
292
+ return prefixed(child, last ? TREE.last : TREE.branch, last ? TREE.gap : TREE.pipe)
293
+ })
294
+ }
295
+
296
+ /**
297
+ * The children of a node as a tree: each child's first line behind a branch, the last behind the closing one, the
298
+ * lines of a child after its first beside the trunk. The caller has drawn the children `TREE_GUTTER` narrower
299
+ * (`deeper`); a child without lines leaves no branch.
300
+ */
301
+ const nest = (children: Frame[]): Frame => joinFrames(branches(children))
302
+
303
+ /** `nest` with a bare trunk line before every child, to set the children of the lane apart. */
304
+ const nestSpaced = (children: Frame[]): Frame =>
305
+ joinFrames(branches(children).flatMap((child) => [textFrame([paint.dim(TREE.trunk)]), child]))
306
+
307
+ /**
308
+ * A row drawn inverse across the pane. A progress bar inside it is switched back to normal video: inverted,
309
+ * its filled and empty blocks would trade places with the background and the bar would be unreadable.
310
+ */
311
+ const highlight = (line: string, width: number): string => {
312
+ const bars = line.match(BAR_SEGMENT) ?? []
313
+ const texts = line.split(BAR_SEGMENT).map(stripAnsi)
314
+ const body = texts.map((text, index) => `${text}${bars[index] ? `${bars[index]}${INVERSE_ON}` : ''}`).join('')
315
+ return `${INVERSE_ON}${body}${' '.repeat(Math.max(width - visibleLength(line), 0))}${RESET}`
316
+ }
317
+
318
+ /** A selectable row: drawn inverse when selected, clickable across the pane on every line, `below` follows unselectable. */
319
+ const rowFrame = (
320
+ key: string,
321
+ rows: string[],
322
+ below: string[],
323
+ { state, width }: Pick<LaneContext, 'state' | 'width'>,
324
+ ): Frame => {
325
+ const selected = state.selected === key
326
+ return {
327
+ lines: [...(selected ? rows.map((row) => highlight(row, width)) : rows), ...below],
328
+ cursorLine: selected ? 0 : -1,
329
+ targets: rows.map((_, line) => ({ line, from: 0, to: width, key })),
330
+ }
331
+ }
332
+
333
+ /** `text` wrapped beside a one-cell `mark`, each line in `style`; follow-up lines indent under the text. */
334
+ const markedLines = (text: string, mark: string, style: (line: string) => string, width: number): string[] =>
335
+ wrap(plain(text), Math.max(width - 2, 1)).map((line, index) => `${index > 0 ? ' ' : mark} ${style(line)}`)
336
+
337
+ /** The mark and the text style of a task: a ticked one green and dim, the one being worked on yellow and bold. */
338
+ const taskLook = ({ done }: ChecklistItem, active: boolean): { mark: string; style: (line: string) => string } => {
339
+ if (active) return { mark: paint.yellow(ACTIVE_MARK), style: paint.bold }
340
+ return done ? { mark: paint.green('☑'), style: paint.dim } : { mark: '☐', style: (line) => line }
341
+ }
342
+
343
+ /** A closed task as one row, cut at the pane's edge with `…`. */
344
+ const closedTaskRow = (item: ChecklistItem, active: boolean, width: number): string[] => {
345
+ const { mark, style } = taskLook(item, active)
346
+ return [fit(`${mark} ${style(plain(item.text))}`, width)]
347
+ }
348
+
349
+ /** An open task as rows: its whole text wrapped. */
350
+ const openTaskRows = (item: ChecklistItem, active: boolean, width: number): string[] => {
351
+ const { mark, style } = taskLook(item, active)
352
+ return markedLines(item.text, mark, style, width)
353
+ }
354
+
355
+ /** A task as a selectable row under its `key`: clickable across every line it takes, the cursor on its first. */
356
+ const taskFrame = (key: string, rows: string[], context: Pick<LaneContext, 'state' | 'width'>): Frame =>
357
+ rowFrame(key, rows, [], context)
358
+
359
+ /** A checkbox moved to another phase, dim: `→ P9 <text>`. */
360
+ const deferredRow = ({ text, target }: DeferredItem, width: number): string =>
361
+ fit(paint.dim(`${DEFERRED_MARK} ${PHASE_ID_PREFIX}${target} ${plain(text)}`), width)
362
+
363
+ const tickedSummaryFrame = (count: number, phaseRowKey: string, width: number): Frame => ({
364
+ lines: [fit(paint.dim(`${chevronOf(false)} ${TEXTS.tickedSummary(count)}`), width)],
365
+ cursorLine: -1,
366
+ targets: [{ line: 0, from: 0, to: width, key: tickedKey(phaseRowKey) }],
367
+ })
368
+
369
+ /**
370
+ * The children of an open phase, one frame per task: open ones first, `active` marked among them, then the ones
371
+ * deferred to another phase, then the ticked ones listed or summed up.
372
+ */
373
+ const phaseChildren = (
374
+ context: StepContext,
375
+ phaseRowKey: string,
376
+ phase: PhaseProgress,
377
+ active: ChecklistItem | null,
378
+ ): Frame[] => {
379
+ const { state, width } = context
380
+ if (phase.items.length === 0 && !phase.deferred) return [textFrame([paint.dim(TEXTS.noCheckboxes)])]
381
+ const frameOf = (item: ChecklistItem, index: number): Frame => {
382
+ const key = itemKey(phaseRowKey, index)
383
+ const isActive = item === active
384
+ const rows = state.expanded.has(key) ? openTaskRows(item, isActive, width) : closedTaskRow(item, isActive, width)
385
+ return taskFrame(key, rows, context)
386
+ }
387
+ const framesWhere = (done: boolean): Frame[] =>
388
+ phase.items.flatMap((item, index) => (item.done === done ? [frameOf(item, index)] : []))
389
+ const tickedCount = phase.items.filter((item) => item.done).length
390
+ const summary = tickedCount > 0 ? [tickedSummaryFrame(tickedCount, phaseRowKey, width)] : []
391
+ const deferred = (phase.deferred ?? []).map((item) => textFrame([deferredRow(item, width)]))
392
+ return [...framesWhere(false), ...deferred, ...(state.showDone ? framesWhere(true) : summary)]
393
+ }
394
+
395
+ /** A head in fixed columns with `text` wrapped beside it; follow-up lines indent to where the text starts. */
396
+ const besideHead = (
397
+ head: string,
398
+ text: string,
399
+ width: number,
400
+ style: (line: string, index: number) => string = (line) => line,
401
+ ): string[] => {
402
+ const indent = ' '.repeat(visibleLength(head))
403
+ return wrap(text, Math.max(width - visibleLength(head), MIN_TITLE_WIDTH)).map((line, index) =>
404
+ fit(`${index === 0 ? head : indent}${style(line, index)}`, width),
405
+ )
406
+ }
407
+
408
+ /**
409
+ * `head`, then `text` wrapped beside it with `right` at the end of the first line; follow-up lines hang under
410
+ * the text. `style` is applied to the wrapped text lines.
411
+ */
412
+ export const rowWithCount = (
413
+ head: string,
414
+ text: string,
415
+ right: string,
416
+ width: number,
417
+ style: (line: string) => string = (line) => line,
418
+ ): string[] => {
419
+ const headWidth = visibleLength(head)
420
+ const rightWidth = visibleLength(right)
421
+ const lines = wrap(text, Math.max(width - headWidth - rightWidth - 1, MIN_TITLE_WIDTH))
422
+ return lines.map((line, index) =>
423
+ index === 0
424
+ ? fit(`${head}${right ? padVisible(style(line), width - headWidth - rightWidth) : style(line)}${right}`, width)
425
+ : fit(`${' '.repeat(headWidth)}${style(line)}`, width),
426
+ )
427
+ }
428
+
429
+ /** ` ticked, not merged` in yellow behind a phase the lane ticked to the end in its worktree only; empty otherwise. Only the current phase carries it, the others fold into the ticked line. */
430
+ const unmergedMark = (phase: PhaseProgress): string =>
431
+ isTickedUnmerged(phase) ? ` ${paint.yellow(TEXTS.tickedUnmerged)}` : ''
432
+
433
+ /** The phase title cut before the count, which stays at the right edge, or the note that the plan lacks the phase. */
434
+ const phaseRow = (phase: PhaseProgress, open: boolean, current: boolean, width: number): string => {
435
+ const title = phase.title === null ? paint.dim(TEXTS.notInPlan) : plain(phase.title)
436
+ const head = `${paint.dim(chevronOf(open))} P${phase.id} ${title}${current ? unmergedMark(phase) : ''}`
437
+ return spread(head, phaseCountCell(phase, current), width)
438
+ }
439
+
440
+ /** A closed phase shows only `active` under it, when given, closed whatever the task's fold. */
441
+ const activeTaskFrame = (
442
+ context: StepContext,
443
+ phaseRowKey: string,
444
+ phase: PhaseProgress,
445
+ active: ChecklistItem | null,
446
+ ): Frame => {
447
+ if (!active) return textFrame([])
448
+ const key = itemKey(phaseRowKey, phase.items.indexOf(active))
449
+ return taskFrame(key, closedTaskRow(active, true, context.width), context)
450
+ }
451
+
452
+ /** One phase as a node, with its tasks below it when expanded, or its active task when it is the current one. */
453
+ const phaseFrame = (context: StepContext, phase: PhaseProgress): Frame => {
454
+ const { progress, state, stepIndex, width } = context
455
+ const key = phaseKey(progress.lane.name, stepIndex, phase.id)
456
+ const open = state.expanded.has(key)
457
+ const current = isCurrent(progress, stepIndex, phase)
458
+ const inner = deeper(context)
459
+ const active = current ? activeItem(phase) : null
460
+ const children = open ? phaseChildren(inner, key, phase, active) : [activeTaskFrame(inner, key, phase, active)]
461
+ return joinFrames([rowFrame(key, [phaseRow(phase, open, current, width)], [], context), nest(children)])
462
+ }
463
+
464
+ /** True when number, checks and the badges leave room for a title beside them on one line. */
465
+ const fitsBeside = (head: string, cells: BadgeCell[], width: number): boolean =>
466
+ visibleLength(head) + packedWidth(cells) + COLUMN_SEPARATOR.length + MIN_TITLE_WIDTH <= width
467
+
468
+ /**
469
+ * `⟦↗ #<number>⟧ ✓ checks ⟦🌐 preview⟧ ⟦💬 3 new⟧ title`: the number as a badge and the check state in fixed
470
+ * columns, the watcher's badges after them and the title last, cut with `…` on its one line. When the badges leave no
471
+ * room for a title, they go on lines of their own below, starting in the column of the lane's status symbol (D96).
472
+ */
473
+ const pullRequestFrame = (pullRequest: PullRequest, context: LaneContext): Frame => {
474
+ const number = padVisible(badge(pullRequest.url, `↗ #${pullRequest.number}`), PULL_REQUEST_BADGE_WIDTH)
475
+ const checks = padVisible(CHECKS_PAINT[pullRequest.checks](TEXTS.checks[pullRequest.checks]), CHECKS_WIDTH)
476
+ const head = `${PULL_REQUEST_INDENT}${number}${COLUMN_SEPARATOR}${checks}`
477
+ const prefix = pullRequest.draft ? TEXTS.draft : ''
478
+ const dimPrefix = (line: string, index: number) =>
479
+ index === 0 && prefix ? `${paint.dim(prefix)}${line.slice(prefix.length)}` : line
480
+ const cells = badgeCells(pullRequest, context.badges[String(pullRequest.number)], context.badgeTexts)
481
+ const key = pullRequestKey(pullRequest.number)
482
+ if (cells.length === 0 || !fitsBeside(head, cells, context.width))
483
+ return joinFrames([
484
+ rowFrame(key, besideHead(head, `${prefix}${pullRequest.title}`, context.width, dimPrefix), [], context),
485
+ packedFrame(cells, PULL_REQUEST_INDENT, context.width),
486
+ ])
487
+ const beside = `${head}${cells.map(({ cell }) => cell).join(CHIP_GAP)}${COLUMN_SEPARATOR}`
488
+ const row = rowFrame(
489
+ key,
490
+ [fit(`${beside}${dimPrefix(`${prefix}${pullRequest.title}`, 0)}`, context.width)],
491
+ [],
492
+ context,
493
+ )
494
+ return { ...row, targets: [...cellTargets(cells, 0, visibleLength(head), context.width), ...row.targets] }
495
+ }
496
+
497
+ const CONTEXT_PAINT: Record<ContextLevel, (text: string) => string> = {
498
+ [CONTEXT_LEVEL.ok]: paint.dim,
499
+ [CONTEXT_LEVEL.warn]: paint.yellow,
500
+ [CONTEXT_LEVEL.alert]: paint.red,
501
+ }
502
+
503
+ /** `62 %` in the colour of its level, right-aligned in a fixed cell; empty when the lane has no reading. */
504
+ const contextCell = (context: ContextReading | null | undefined): string =>
505
+ context
506
+ ? `${CONTEXT_SYMBOL}${CONTEXT_PAINT[context.level](contextLabel(context.share).padStart(CONTEXT_WIDTH))} ${paint.dim(COLUMN_RULE)} `
507
+ : ''
508
+
509
+ /** Bar and count in a fixed-width column, so every bar on screen starts in the same column. */
510
+ const progressColumn = (done: number, total: number): string =>
511
+ `${bar(done, total)} ${countCell(PROGRESS_SYMBOL, countText(done, total))}`
512
+
513
+ /** Steps on the same plan share one group, wherever they stand in the lane, so a plan is headed once. */
514
+ const groupSteps = (steps: readonly StepProgress[]): number[][] =>
515
+ steps.reduce<number[][]>((groups, { step }, index) => {
516
+ const same =
517
+ step.planHref === null ? undefined : groups.find((group) => steps[group[0]].step.planHref === step.planHref)
518
+ if (same) same.push(index)
519
+ else groups.push([index])
520
+ return groups
521
+ }, [])
522
+
523
+ const isTicked = ({ phase }: PhaseEntry): boolean => phase.total > 0 && phase.done === phase.total
524
+
525
+ /** A ticked phase folds into the ticked line, unless it is the current one: that one stays a row of its own. */
526
+ const foldsAway = (progress: LaneProgress, entry: PhaseEntry): boolean =>
527
+ isTicked(entry) && !isCurrent(progress, entry.stepIndex, entry.phase)
528
+
529
+ const tickedLine = (entries: PhaseEntry[]): string[] =>
530
+ entries.length === 0
531
+ ? []
532
+ : [`${paint.green('✓')} ${paint.dim(`${entries.map(({ phase }) => `P${phase.id}`).join(' ')} ${TEXTS.ticked}`)}`]
533
+
534
+ const CHIP_GAP = ' '
535
+ const CHIP_MARK = '📎 '
536
+
537
+ /** `✦ label` as a badge linked to the chip's address, `private` faint after it for a link its plan says is private. */
538
+ const chipCell = ({ url, icon, label, private: hidden }: LinkChip): string =>
539
+ badge(url, `${icon} ${plain(label)}`, hidden ? TEXTS.privateLink : '')
540
+
541
+ /** A badge and the key a click on it opens. */
542
+ type BadgeCell = { cell: string; key: string }
543
+
544
+ const packedWidth = (cells: BadgeCell[]): number =>
545
+ cells.reduce((sum, { cell }, index) => sum + visibleLength(cell) + (index > 0 ? CHIP_GAP.length : 0), 0)
546
+
547
+ /** One click target per badge of a line, from `column` on, each over exactly the columns it covers up to `edge`. */
548
+ const cellTargets = (cells: BadgeCell[], line: number, column: number, edge: number): ClickTarget[] => {
549
+ let from = column
550
+ return cells.map(({ cell, key }) => {
551
+ const target = { line, from, to: Math.min(from + visibleLength(cell), edge), key }
552
+ from += visibleLength(cell) + CHIP_GAP.length
553
+ return target
554
+ })
555
+ }
556
+
557
+ /**
558
+ * Badges packed into lines behind `lead` on the first one and its width of blanks on the others; a badge wider than
559
+ * the pane is cut, never wrapped. Each badge is a click target over exactly the columns it covers.
560
+ */
561
+ const packedFrame = (cells: BadgeCell[], lead: string, width: number): Frame => {
562
+ const indent = visibleLength(lead)
563
+ const room = Math.max(width - indent, MIN_TITLE_WIDTH)
564
+ const groups = packGroups(
565
+ cells.map(({ cell }) => cell),
566
+ room,
567
+ CHIP_GAP,
568
+ )
569
+ const lineCells = groups.map((group, line) => {
570
+ const first = groups.slice(0, line).reduce((count, each) => count + each.length, 0)
571
+ return cells.slice(first, first + group.length)
572
+ })
573
+ const lines = groups.map(
574
+ (group, line) => `${line === 0 ? lead : ' '.repeat(indent)}${fit(group.join(CHIP_GAP), room)}`,
575
+ )
576
+ const targets = lineCells.flatMap((group, line) => cellTargets(group, line, indent, indent + room))
577
+ return { lines, cursorLine: -1, targets }
578
+ }
579
+
580
+ /** The chips of a plan behind a 📎, then a blank line before the phases. */
581
+ const chipFrame = (chips: LinkChip[], width: number): Frame => {
582
+ const frame = packedFrame(
583
+ chips.map((chip) => ({ cell: chipCell(chip), key: chipKey(chip.url) })),
584
+ CHIP_MARK,
585
+ width,
586
+ )
587
+ return frame.lines.length > 0 ? { ...frame, lines: [...frame.lines, ''] } : frame
588
+ }
589
+
590
+ /**
591
+ * `🌐 preview` opening the preview, `💬 <n> new` in amber opening the pull request, and `✋ changes requested` in dark
592
+ * red opening it too, each only when there is one (D96).
593
+ */
594
+ const badgeCells = (
595
+ pullRequest: PullRequest,
596
+ badges: PullRequestBadges | undefined,
597
+ texts: BadgeTexts,
598
+ ): BadgeCell[] => {
599
+ if (!badges) return []
600
+ const onPullRequest = (text: string, tone: BadgeTone) => ({
601
+ cell: badge(pullRequest.url, text, '', tone),
602
+ key: chipKey(pullRequest.url),
603
+ })
604
+ return [
605
+ ...(badges.preview ? [{ cell: badge(badges.preview, texts.preview), key: chipKey(badges.preview) }] : []),
606
+ ...(badges.feedback > 0 ? [onPullRequest(texts.feedback(badges.feedback), BADGE_TONE.amber)] : []),
607
+ ...(badges.changesRequested ? [onPullRequest(texts.changesRequested, BADGE_TONE.red)] : []),
608
+ ]
609
+ }
610
+
611
+ const planHeading = (title: string, width: number): string[] => wrap(plain(title), width).map(paint.bold)
612
+
613
+ /**
614
+ * A plan as a node: its title in bold, and below it the links, the open phases, and the ticked ones summed up in
615
+ * one line unless `showDone` lists them.
616
+ */
617
+ const planBlock = (context: LaneContext, stepIndexes: number[], entries: PhaseEntry[]): Frame => {
618
+ const { progress, state, width, chips } = context
619
+ const { planTitle, planHref } = progress.steps[stepIndexes[0]].step
620
+ const shown = state.showDone ? entries : entries.filter((entry) => !foldsAway(progress, entry))
621
+ const ticked = state.showDone ? [] : entries.filter((entry) => foldsAway(progress, entry))
622
+ const inner = deeper(context)
623
+ return joinFrames([
624
+ textFrame(planHeading(planTitle, width)),
625
+ nest([
626
+ chipFrame(planHref ? (chips[planHref] ?? []) : [], inner.width),
627
+ ...shown.map(({ stepIndex, phase }) => phaseFrame({ ...inner, stepIndex }, phase)),
628
+ textFrame(tickedLine(ticked)),
629
+ ]),
630
+ ])
631
+ }
632
+
633
+ /**
634
+ * The summary line of a lane's notes is a row of its own, clickable like the key `n`; shown, the notes follow it
635
+ * unselectable.
636
+ */
637
+ const noteFrame = (context: LaneContext, steps: PlanStep[]): Frame => {
638
+ if (steps.length === 0) return textFrame([])
639
+ const { progress, state, width } = context
640
+ const lane = progress.lane.name
641
+ const shown = state.notesShown.has(lane)
642
+ const summary = paint.dim(
643
+ `${chevronOf(shown)} ${shown ? TEXTS.notesShown(steps.length) : TEXTS.notesHidden(steps.length)}`,
644
+ )
645
+ const notes = shown
646
+ ? steps.flatMap((step) => [...planHeading(step.planTitle, width), ...wrap(plain(step.label), width).map(paint.dim)])
647
+ : []
648
+ return rowFrame(notesKey(lane), [fit(summary, width)], notes, context)
649
+ }
650
+
651
+ /** The columns lane lines share, so names, statuses and bars of all of them line up. */
652
+ const STATUS_WIDTH = Math.max(...[...Object.values(AGENT_STATUS), NO_AGENT_WORD].map(textWidth))
653
+
654
+ /** `P9 Freigabe 0.x` for the current phase of a lane, marked when ticked and not merged; empty when there is none. */
655
+ const nextPhaseLabel = ({ progress }: LaneView): string => {
656
+ const phase = currentPhase(progress)
657
+ return phase ? `P${phase.id}${phase.title ? ` ${plain(phase.title)}` : ''}${unmergedMark(phase)}` : ''
658
+ }
659
+
660
+ const markTone = ({ feedback, changesRequested }: PullRequestBadges): BadgeTone => {
661
+ if (changesRequested) return BADGE_TONE.red
662
+ return feedback > 0 ? BADGE_TONE.amber : BADGE_TONE.grey
663
+ }
664
+
665
+ /**
666
+ * `↗ #<n>` as a badge linked to the pull request, with `💬 <n>` while it has feedback since its last push and `✋`
667
+ * while changes are requested; dark red then, else amber with feedback, else grey.
668
+ */
669
+ const pullRequestMark = (pullRequest: PullRequest, badges: WatchBadges): string => {
670
+ const found = badges[String(pullRequest.number)] ?? NO_BADGES
671
+ const feedback = found.feedback > 0 ? ` 💬 ${found.feedback}` : ''
672
+ const text = `↗ #${pullRequest.number}${feedback}${found.changesRequested ? ' ✋' : ''}`
673
+ return badge(pullRequest.url, text, '', markTone(found))
674
+ }
675
+
676
+ const pullRequestMarks = ({ pullRequests, badges }: LaneView): string =>
677
+ pullRequests.map((pullRequest) => pullRequestMark(pullRequest, badges ?? {})).join(' ')
678
+
679
+ /** What follows the status on a lane line: git state while the lane is open, the next phase when folded. */
680
+ const laneDetail = (view: LaneView, open: boolean): string => {
681
+ const markText = laneMarkText(view)
682
+ const marker = markText ? paint.yellow(markText) : ''
683
+ return [marker, open ? gitLabel(view.status) : nextPhaseLabel(view)].filter(Boolean).join(' ')
684
+ }
685
+
686
+ /**
687
+ * The lane line, `head` and `right` (bar and count) at the edges. The detail (next phase, branch, pull
688
+ * requests) follows `head` when at least `MIN_BESIDE_WIDTH` characters are left for it; in a narrower pane
689
+ * it moves to a line of its own below, indented under the name and cut to the full width.
690
+ */
691
+ export const withDetail = (head: string, detail: string, right: string, width: number): string[] => {
692
+ if (!detail) return [spread(head, right, width)]
693
+ const room = width - visibleLength(head) - COLUMN_SEPARATOR.length - visibleLength(right) - 1
694
+ if (room >= MIN_BESIDE_WIDTH) return [spread(`${head}${COLUMN_SEPARATOR}${detail}`, right, width)]
695
+ return [spread(head, right, width), fit(`${CONTENT_INDENT}${detail}`, width)]
696
+ }
697
+
698
+ /** Context and bar at the right edge of a lane line, as wide as the lane has them. */
699
+ const laneRight = (view: LaneView): string => {
700
+ const counts = view.progress.total > 0 ? progressColumn(view.progress.done, view.progress.total) : ''
701
+ return counts ? `${contextCell(view.context)}${counts}` : contextCell(view.context).trimEnd()
702
+ }
703
+
704
+ /** A folded lane's pull request badges in the column of `columns.marks`, right before context and bar (D96). */
705
+ const markedRight = (view: LaneView, columns: LaneColumns): string => {
706
+ const right = laneRight(view)
707
+ const marks = pullRequestMarks(view)
708
+ if (!marks) return right
709
+ if (columns.right === 0) return padVisible(marks, columns.marks)
710
+ const pad = ' '.repeat(Math.max(columns.right - visibleLength(right), 0))
711
+ return `${padVisible(marks, columns.marks)}${COLUMN_SEPARATOR}${pad}${right}`
712
+ }
713
+
714
+ const laneLines = (view: LaneView, open: boolean, width: number, columns: LaneColumns): string[] => {
715
+ const { progress, status } = view
716
+ const cells = [
717
+ padVisible(paint.bold(fit(progress.lane.name, columns.name)), columns.name),
718
+ padVisible(agentLabel(status.agent), STATUS_WIDTH),
719
+ ].join(COLUMN_SEPARATOR)
720
+ const head = `${paint.dim(chevronOf(open))} ${symbolCell(status.agent)} ${cells}`
721
+ const right = open ? laneRight(view) : markedRight(view, columns)
722
+ return withDetail(head, laneDetail(view, open), right, width)
723
+ }
724
+
725
+ /** The lane line is a row of its own; an open lane continues with its pull requests and plans. */
726
+ const laneBlock = (
727
+ view: LaneView,
728
+ state: Selection,
729
+ width: number,
730
+ columns: LaneColumns,
731
+ badgeTexts: BadgeTexts,
732
+ ): Frame => {
733
+ const context = { progress: view.progress, state, width, chips: view.chips, badges: view.badges ?? {}, badgeTexts }
734
+ const open = isLaneOpen(state, view.progress.lane.name, view.status.agent)
735
+ const row = rowFrame(laneKey(view.progress.lane.name), laneLines(view, open, width, columns), [], context)
736
+ if (!open) return row
737
+ const groups = groupSteps(view.progress.steps).map((stepIndexes) => ({
738
+ stepIndexes,
739
+ entries: stepIndexes.flatMap((stepIndex) =>
740
+ view.progress.steps[stepIndex].phases.map((phase) => ({ stepIndex, phase })),
741
+ ),
742
+ }))
743
+ const planned = groups.filter(({ entries }) => entries.length > 0)
744
+ const notes = groups
745
+ .filter(({ entries }) => entries.length === 0)
746
+ .map(({ stepIndexes }) => view.progress.steps[stepIndexes[0]].step)
747
+ const inner = deeper(context)
748
+ const content = nestSpaced([
749
+ ...planned.map(({ stepIndexes, entries }) => planBlock(inner, stepIndexes, entries)),
750
+ noteFrame(inner, notes),
751
+ followingFrame(view, state, inner.width),
752
+ ])
753
+ return joinFrames([row, ...view.pullRequests.map((pullRequest) => pullRequestFrame(pullRequest, context)), content])
754
+ }
755
+
756
+ /** A closed lane, or one with no agent and no open pull request, has nothing to act on; it is listed in the closing block. */
757
+ const isParked = ({ progress, status, pullRequests }: LaneView): boolean =>
758
+ progress.lane.closed || (status.agent === null && pullRequests.length === 0)
759
+
760
+ const parkedItem = ({ progress }: LaneView): string => {
761
+ const { name, steps } = progress.lane
762
+ const hasPlan = steps.some((step) => step.planHref)
763
+ const label =
764
+ progress.total > 0 ? `${name} ${progress.done}/${progress.total}` : hasPlan ? name : `${name} (${TEXTS.noPlan})`
765
+ return label.replaceAll(' ', NON_BREAKING_SPACE)
766
+ }
767
+
768
+ /** `· no agent (6) name 3/9 · other 1/4`: one wrapping block for every parked lane, each with its progress. */
769
+ const parkedBlock = (lanes: LaneView[], width: number): string[] => {
770
+ if (lanes.length === 0) return []
771
+ const head = `${paint.dim(NO_AGENT_SYMBOL)} ${paint.dim(`${TEXTS.noAgent} (${lanes.length})`)} `
772
+ const items = lanes.map((lane, index) => {
773
+ const item = parkedItem(lane)
774
+ return index < lanes.length - 1 ? `${item}${NON_BREAKING_SPACE}${ITEM_SEPARATOR}` : item
775
+ })
776
+ return besideHead(head, items.join(' '), width, (line) => paint.dim(line.replaceAll(NON_BREAKING_SPACE, ' ')))
777
+ }
778
+
779
+ /**
780
+ * The closing row for the phases no lane works on, dimmed and closed unless its key is expanded. Opened, it
781
+ * lists each plan with its open phases and their checkboxes, and no checkbox itself.
782
+ */
783
+ const plannedFrame = (planned: PlannedPlan[], state: Selection, width: number): Frame => {
784
+ if (planned.length === 0) return textFrame([])
785
+ const phases = plannedPhaseCount(planned)
786
+ const open = state.expanded.has(PLANNED_KEY)
787
+ const head = [paint.dim(fit(`${chevronOf(open)} ${TEXTS.planned(phases, planned.length)}`, width))]
788
+ return rowFrame(PLANNED_KEY, head, open ? plannedRows(planned, width, CONTENT_INDENT).flat() : [], { state, width })
789
+ }
790
+
791
+ /** A dimmed row per plan, one list of lines each: its title and open phases at the left, its open checkbox count at the right. */
792
+ const plannedRows = (planned: PlannedPlan[], width: number, indent: string): string[][] =>
793
+ planned.map((plan) =>
794
+ rowWithCount(
795
+ indent,
796
+ `${plain(plan.title)} · ${phaseList(plan)}`,
797
+ countCell('', countText(plan.done, plan.total)),
798
+ width,
799
+ paint.dim,
800
+ ),
801
+ )
802
+
803
+ /** The plan title up to its subtitle: `Agent-Kit: Skills, Agenten und …` is `Agent-Kit`. */
804
+ const planName = (title: string): string => plain(title.split(':')[0]).trim()
805
+
806
+ /** The node under an open lane for the phases after its own: closed, `▸ also open: P7 P8`; opened, one child per plan. */
807
+ const followingFrame = (view: LaneView, state: Selection, width: number): Frame => {
808
+ const following = view.following ?? []
809
+ if (following.length === 0) return textFrame([])
810
+ const key = followingKey(view.progress.lane.name)
811
+ const open = state.expanded.has(key)
812
+ const label = TEXTS.following(following.map((plan) => `${planName(plan.title)}: ${phaseList(plan)}`).join(' · '))
813
+ const head = [paint.dim(fit(`${chevronOf(open)} ${label}`, width))]
814
+ const plans = open ? plannedRows(following, deeper({ width }).width, '').map(textFrame) : []
815
+ return joinFrames([rowFrame(key, head, [], { state, width }), nest(plans)])
816
+ }
817
+
818
+ const rule = (width: number): string => paint.dim(RULE.repeat(width))
819
+
820
+ /** A dimmed rule before every lane block but the first, folded or open: it shows where one lane ends. */
821
+ const separate = (blocks: Frame[], width: number): Frame[] =>
822
+ blocks.flatMap((frame, index) => (index === 0 ? [frame] : [textFrame([rule(width)]), frame]))
823
+
824
+ /**
825
+ * Why badges may be missing, said beside the clock: no watcher runs, or its state is stale. Nothing while it is live,
826
+ * or when no lane on screen has a pull request a badge could belong to.
827
+ */
828
+ const watchNotice = (lanes: LaneView[], watch: WatchHealth | undefined, texts: BadgeTexts): string => {
829
+ if (!watch || watch.kind === WATCH_HEALTH.live) return ''
830
+ if (!lanes.some(({ pullRequests }) => pullRequests.length > 0)) return ''
831
+ return watch.kind === WATCH_HEALTH.off ? texts.watcherOff : texts.stale(watch.minutes)
832
+ }
833
+
834
+ const laneColumnsOf = (lanes: LaneView[]): LaneColumns => ({
835
+ name: Math.min(Math.max(0, ...lanes.map(({ progress }) => textWidth(progress.lane.name))), NAME_WIDTH_MAX),
836
+ marks: Math.max(0, ...lanes.map((lane) => visibleLength(pullRequestMarks(lane)))),
837
+ right: Math.max(0, ...lanes.map((lane) => visibleLength(laneRight(lane)))),
838
+ })
839
+
840
+ /**
841
+ * Title and clock on top, the key hint at the bottom (with the legend after `?`), and in between one
842
+ * block per lane: the lane line with its total bar, and for an open lane the phase rows. Folded lanes are
843
+ * one line each, every lane block after the first is preceded by a dimmed rule, and lanes without an
844
+ * agent share the closing block.
845
+ */
846
+ export const renderScreen = (
847
+ { title, time, lanes, planned, watch, language = PLAN_LANGUAGE.en }: OverviewView,
848
+ state: Selection,
849
+ width: number,
850
+ ): Screen => {
851
+ const active = lanes.filter((lane) => !isParked(lane))
852
+ const parked = parkedBlock(lanes.filter(isParked), width)
853
+ const columns = laneColumnsOf(active)
854
+ const badgeTexts = BADGE_TEXTS[language]
855
+ const blocks = active.map((lane) => laneBlock(lane, state, width, columns, badgeTexts))
856
+ const notice = watchNotice(active, watch, badgeTexts)
857
+ const clock = notice ? `${paint.yellow(notice)}${COLUMN_SEPARATOR}${paint.dim(time)}` : paint.dim(time)
858
+ return {
859
+ header: [spread(paint.bold(title), clock, width), rule(width)],
860
+ body: joinFrames([
861
+ ...separate(blocks, width),
862
+ ...(parked.length > 0 ? [textFrame([rule(width), ...parked])] : []),
863
+ ...(planned.length > 0 ? [textFrame([rule(width)]), plannedFrame(planned, state, width)] : []),
864
+ ]),
865
+ footer: [
866
+ rule(width),
867
+ fit(paint.dim(TEXTS.hint), width),
868
+ ...(state.showLegend ? TEXTS.legend.flatMap((line) => wrap(line, width)).map(paint.dim) : []),
869
+ ],
870
+ }
871
+ }
872
+
873
+ /**
874
+ * `renderScreen` with the selection resolved against what is drawn: when the selected row is not on
875
+ * screen (its phase got ticked and hidden, or left the sprint), the first drawn row is selected.
876
+ */
877
+ export const renderSelected = (overview: OverviewView, selection: Selection, width: number): Screen => {
878
+ const first = renderScreen(overview, selection, width)
879
+ const selected = selectedKey(selection, targetKeys(first.body.targets))
880
+ return selected === selection.selected ? first : renderScreen(overview, { ...selection, selected }, width)
881
+ }