jackherd 1.9.0 → 1.11.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 (228) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +65 -32
  4. package/dist/{chunk-MCP4XJQH.js → chunk-RMMXD4G5.js} +18 -6
  5. package/dist/config.d.ts +99 -4
  6. package/dist/config.js +105 -29
  7. package/dist/plan-format.d.ts +51 -6
  8. package/dist/plan-format.js +49 -7
  9. package/docs/workflow-dark.svg +67 -63
  10. package/docs/workflow.svg +67 -63
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/install.sh +1 -1
  13. package/kit/hooks/ask-rules.json +15 -0
  14. package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
  15. package/kit/hooks/hooks/ask-hint/index.ts +15 -19
  16. package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
  17. package/kit/hooks/hooks/git-guard/decision.ts +50 -102
  18. package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
  19. package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
  20. package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
  21. package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
  22. package/kit/hooks/hooks/merge-guard/index.ts +6 -3
  23. package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
  24. package/kit/hooks/hooks/merge-guard/run.ts +59 -54
  25. package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
  26. package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
  27. package/kit/hooks/hooks/open-question/index.ts +26 -0
  28. package/kit/hooks/hooks/open-question/record.ts +135 -0
  29. package/kit/hooks/hooks/open-question/run.ts +57 -0
  30. package/kit/hooks/hooks/open-question/store.ts +39 -0
  31. package/kit/hooks/hooks/sent-file/store.ts +3 -12
  32. package/kit/hooks/hooks/shared/config.ts +7 -0
  33. package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
  34. package/kit/hooks/hooks/shared/files.ts +7 -0
  35. package/kit/hooks/hooks/shared/lane.ts +1 -1
  36. package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
  37. package/kit/hooks/hooks/shared/private-directory.ts +56 -9
  38. package/kit/hooks/hooks/shared/program-position.ts +2 -75
  39. package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
  40. package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
  41. package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
  42. package/kit/hooks/hooks/task-hint/decision.ts +49 -0
  43. package/kit/hooks/hooks/task-hint/index.ts +79 -0
  44. package/kit/hooks/hooks/task-hint/run.ts +62 -0
  45. package/kit/hooks/hooks/task-hint/store.ts +51 -0
  46. package/kit/hooks/hooks.json +13 -5
  47. package/kit/review/agents/skill-review.md +34 -0
  48. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  49. package/kit/rules/rules/kit/pull-requests.md +2 -0
  50. package/kit/workflow/skills/follow/SKILL.md +3 -10
  51. package/kit/workflow/skills/implement/SKILL.md +8 -6
  52. package/kit/workflow/skills/orchestrate/SKILL.md +218 -433
  53. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  54. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  55. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  56. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  57. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  58. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  59. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  60. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  61. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  62. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  63. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  64. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  65. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  66. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  67. package/kit/workflow/skills/plan/SKILL.md +6 -5
  68. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  69. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  70. package/package.json +3 -1
  71. package/recipes/README.md +2 -1
  72. package/recipes/sources.md +5 -1
  73. package/schemas/jackherd.schema.json +139 -5
  74. package/src/check/agent-text-reviewers.ts +23 -0
  75. package/src/check/command.ts +2 -0
  76. package/src/check/plan-structure-check.ts +49 -2
  77. package/src/check/plan.ts +8 -1
  78. package/src/check/schema.ts +9 -0
  79. package/src/check/skill-budget-defaults.ts +55 -0
  80. package/src/check/skill-budgets.ts +185 -0
  81. package/src/cli.ts +2 -0
  82. package/src/config/auto-window.ts +8 -0
  83. package/src/config/link-kinds.ts +7 -1
  84. package/src/config/schema.ts +82 -25
  85. package/src/config/tones.ts +27 -0
  86. package/src/lane/close-board.ts +106 -0
  87. package/src/lane/close-check.ts +183 -0
  88. package/src/lane/close-command-line.ts +11 -0
  89. package/src/lane/close-command.ts +217 -0
  90. package/src/lane/close-options.ts +43 -0
  91. package/src/lane/close-survey.ts +83 -0
  92. package/src/lane/close.ts +141 -0
  93. package/src/lane/command.ts +13 -0
  94. package/src/lane-prompt/additions.ts +14 -0
  95. package/src/lane-prompt/build.ts +74 -0
  96. package/src/lane-prompt/command.ts +93 -0
  97. package/src/lane-prompt/input.ts +157 -0
  98. package/src/lane-prompt/options.ts +70 -0
  99. package/src/lane-prompt/texts.ts +101 -0
  100. package/src/mermaid/command.ts +36 -0
  101. package/src/mermaid/problems.ts +48 -0
  102. package/src/plan-format/clock.ts +8 -0
  103. package/src/plan-format/dependencies.ts +9 -6
  104. package/src/plan-format/index.ts +1 -0
  105. package/src/plan-format/short-names.ts +71 -0
  106. package/src/plan-format/waiting.ts +22 -4
  107. package/src/plans/appointment-rows.ts +83 -0
  108. package/src/plans/ask-coordinator.ts +111 -0
  109. package/src/plans/ask-target.ts +36 -0
  110. package/src/plans/board-close.ts +85 -0
  111. package/src/plans/child-stop.ts +51 -0
  112. package/src/plans/closable-records.ts +34 -0
  113. package/src/plans/command.ts +3 -2
  114. package/src/plans/fetch.ts +26 -3
  115. package/src/plans/frame.ts +5 -12
  116. package/src/plans/git.ts +54 -5
  117. package/src/plans/groomed.ts +27 -0
  118. package/src/plans/herd-claim.ts +173 -0
  119. package/src/plans/herd-memory.ts +36 -25
  120. package/src/plans/lane-plans.ts +44 -13
  121. package/src/plans/lane-summary.ts +253 -45
  122. package/src/plans/layout-phase.ts +36 -11
  123. package/src/plans/layout-summary.ts +89 -18
  124. package/src/plans/layout.ts +502 -98
  125. package/src/plans/link-chips.ts +23 -4
  126. package/src/plans/live.ts +178 -23
  127. package/src/plans/next-rows.ts +44 -0
  128. package/src/plans/phase-ids.ts +8 -0
  129. package/src/plans/phase-source.ts +15 -0
  130. package/src/plans/plan-cache.ts +28 -0
  131. package/src/plans/plan-heads.ts +54 -0
  132. package/src/plans/planned.ts +22 -0
  133. package/src/plans/pull-requests.ts +17 -2
  134. package/src/plans/question-records.ts +48 -0
  135. package/src/plans/questions-read.ts +13 -0
  136. package/src/plans/read-cache.ts +8 -0
  137. package/src/plans/record-id.ts +4 -0
  138. package/src/plans/restart-decision.ts +13 -4
  139. package/src/plans/row-actions.ts +52 -0
  140. package/src/plans/row-focus.ts +148 -0
  141. package/src/plans/row-targets.ts +7 -0
  142. package/src/plans/selection.ts +111 -18
  143. package/src/plans/sent-files-read.ts +6 -15
  144. package/src/plans/sent-files.ts +7 -14
  145. package/src/plans/settings.ts +12 -1
  146. package/src/plans/signalled-phase.ts +42 -0
  147. package/src/plans/signals-read.ts +12 -4
  148. package/src/plans/snapshot.ts +162 -81
  149. package/src/plans/source-asked.ts +19 -0
  150. package/src/plans/source-prefix.ts +17 -0
  151. package/src/plans/source-rows.ts +188 -0
  152. package/src/plans/sprint-export.ts +11 -11
  153. package/src/plans/sprint-page.ts +5 -5
  154. package/src/plans/subagent-phases.ts +95 -0
  155. package/src/plans/subagent-tasks.ts +51 -0
  156. package/src/plans/subagents-read.ts +66 -17
  157. package/src/plans/subagents.ts +64 -6
  158. package/src/plans/supervisor.ts +14 -4
  159. package/src/plans/symbols.ts +3 -0
  160. package/src/plans/task-name.ts +34 -0
  161. package/src/plans/texts.ts +48 -6
  162. package/src/plans/tones.ts +30 -0
  163. package/src/plans/view.ts +25 -11
  164. package/src/plans/waiting-click.ts +295 -35
  165. package/src/plans/waiting-records.ts +39 -0
  166. package/src/plans/waiting-window.ts +8 -0
  167. package/src/preview/command.ts +25 -3
  168. package/src/shared/private-location.ts +89 -0
  169. package/src/shared/stat-cache.ts +48 -0
  170. package/src/sync/kit-source.ts +29 -10
  171. package/src/sync/kit.ts +2 -0
  172. package/src/sync/manifest.ts +2 -0
  173. package/src/sync/plan-ask-rules.ts +94 -0
  174. package/src/sync/plan-hooks.ts +2 -0
  175. package/src/sync/render.ts +3 -3
  176. package/src/sync/settings-hooks.ts +4 -1
  177. package/src/sync/sync-plan.ts +1 -0
  178. package/src/sync/sync.ts +24 -3
  179. package/src/task-signals/claude-read.ts +27 -10
  180. package/src/terminal/text.ts +16 -3
  181. package/src/terminal/types.ts +4 -0
  182. package/src/version/skill-diff.ts +46 -0
  183. package/src/vocabulary/index.ts +26 -6
  184. package/src/waiting/command.ts +22 -7
  185. package/src/watch/appointments.ts +1 -2
  186. package/src/watch/asking-lanes.ts +40 -0
  187. package/src/watch/base-tip.ts +11 -0
  188. package/src/watch/command.ts +330 -105
  189. package/src/watch/heartbeat-progress.ts +6 -18
  190. package/src/watch/kit-update.ts +28 -9
  191. package/src/watch/lane-name.ts +6 -0
  192. package/src/watch/lane-progress.ts +40 -8
  193. package/src/watch/lane-report-file.ts +20 -5
  194. package/src/watch/lane-report.ts +32 -4
  195. package/src/watch/lane-sources.ts +31 -3
  196. package/src/watch/main-checkout.ts +34 -0
  197. package/src/watch/plan-badges-read.ts +22 -9
  198. package/src/watch/plan-badges.ts +10 -2
  199. package/src/watch/pull-requests.ts +6 -2
  200. package/src/watch/reference-paths.ts +87 -0
  201. package/src/watch/report-questions.ts +4 -3
  202. package/src/watch/running-subagents.ts +43 -0
  203. package/src/watch/skill-change.ts +124 -32
  204. package/src/watch/source-change.ts +48 -0
  205. package/src/watch/source-entries.ts +55 -0
  206. package/src/watch/source-fields.ts +1 -0
  207. package/src/watch/source-statuses.ts +60 -0
  208. package/src/watch/source-tickets.ts +21 -1
  209. package/src/watch/sources.ts +47 -4
  210. package/src/watch/stream-output.ts +4 -3
  211. package/src/watch/task-parallel-auto.ts +208 -0
  212. package/src/watch/task-parallel-data-paths.ts +46 -0
  213. package/src/watch/task-parallel-input.ts +5 -15
  214. package/src/watch/task-parallel-read.ts +3 -1
  215. package/src/watch/task-parallel.ts +36 -7
  216. package/src/watch/task-signal-missing.ts +69 -0
  217. package/src/watch/task-signal-read.ts +29 -0
  218. package/src/watch/texts.ts +10 -0
  219. package/src/watch/update-available.ts +6 -3
  220. package/src/watch/watch-events.ts +23 -0
  221. package/src/watch/watcher-lock.ts +25 -29
  222. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  223. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  224. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  225. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  226. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  227. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  228. package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
@@ -1,3 +1,4 @@
1
+ import { TONE_ROLE } from '../config/tones'
1
2
  import { CONTEXT_LEVEL, type ContextLevel, type ContextReading, contextLabel } from '../context'
2
3
  import type { LaneProgress, PhaseProgress } from '../plan-format'
3
4
  import { currentPhase, isPhaseOpen, phaseLabel } from '../plan-format'
@@ -8,6 +9,7 @@ import {
8
9
  badge,
9
10
  bar,
10
11
  box,
12
+ bracket,
11
13
  fit,
12
14
  link,
13
15
  packGroups,
@@ -17,7 +19,7 @@ import {
17
19
  visibleLength,
18
20
  wrap,
19
21
  } from '../terminal/text'
20
- import { AGENT_STATUS, type AgentStatus, isAgentStatus } from '../terminal/types'
22
+ import { AGENT_STATUS, type AgentStatus, isAgentStatus, isRestingAgent } from '../terminal/types'
21
23
  import { textWidth } from '../terminal/width'
22
24
  import {
23
25
  NO_BADGES,
@@ -28,7 +30,9 @@ import {
28
30
  type WatchHealth,
29
31
  } from '../watch/plan-badges'
30
32
  import type { UnplannedTask } from './active-tasks'
33
+ import { type AppointmentBlock, appointmentItems, appointmentKeys } from './appointment-rows'
31
34
  import { deeper, type Frame, joinFrames, nest, prefixed, rowFrame, TREE, textFrame } from './frame'
35
+ import { groomedText } from './groomed'
32
36
  import { LANE_STANDING, type LaneStanding, laneStanding, waitingNote } from './lane-standing'
33
37
  import { type LaneSummary, laneSummary } from './lane-summary'
34
38
  import { type LaneTickets, withoutTicketKey } from './lane-tickets'
@@ -41,25 +45,57 @@ import {
41
45
  nowLine,
42
46
  plain,
43
47
  } from './layout-phase'
44
- import { summaryChildren } from './layout-summary'
48
+ import { foldedTaskLine, summaryChildren } from './layout-summary'
45
49
  import { chipKey, type LinkChip, planUrlOf } from './link-chips'
50
+ import { type NextBlock, nextItems, nextKeys } from './next-rows'
46
51
  import type { PhaseModel } from './phase-models'
47
- import { type PlannedPlan, phaseList, plannedPhaseCount } from './planned'
52
+ import { type PlannedPlan, plannedNextLabel } from './planned'
48
53
  import { CHECKS, type ChecksState, failedTestKey, type PullRequest, pullRequestKey } from './pull-requests'
54
+ import {
55
+ boldBracketAt,
56
+ buttonsAllBold,
57
+ type FocusHit,
58
+ focusHitsOfBlock,
59
+ focusHitsOfTargets,
60
+ focusLine,
61
+ isFocused,
62
+ LINKS_KEY_PREFIX,
63
+ } from './row-focus'
49
64
  import {
50
65
  type ClickTarget,
66
+ isBlockFolded,
51
67
  isLaneOpen,
52
68
  laneKey,
53
69
  PLANNED_KEY,
54
70
  type Selection,
55
71
  selectedKey,
72
+ TOP_BLOCK,
73
+ type TopBlockId,
56
74
  targetKeys,
57
75
  } from './selection'
58
76
  import type { SentChip } from './sent-files'
59
77
  import { shownWithMore } from './shown-with-more'
60
- import { chevronOf, NO_AGENT_SYMBOL, statusSymbol, statusWord } from './symbols'
78
+ import { askedCheckOf } from './source-asked'
79
+ import { type SourceBlock, sourceItems, sourceKeys } from './source-rows'
80
+ import { chevronOf, LESS_MARK, NO_AGENT_SYMBOL, statusSymbol, statusWord } from './symbols'
61
81
  import { BADGE_TEXTS, type BadgeTexts, checksText, laneMarkText, TEXTS } from './texts'
62
- import { SHOWN_WAITING, WAITING_MARK, type WaitingRow, waitingRows } from './waiting-click'
82
+ import { paintOfRole, paintOfTone, toneOfRole } from './tones'
83
+ import {
84
+ APPOINTMENT_MORE_KEY,
85
+ allBlockRows,
86
+ type BlockHit,
87
+ type BlockRow,
88
+ blockRows,
89
+ buttonsText,
90
+ moreKeyOf,
91
+ NEXT_MORE_KEY,
92
+ SHOWN_BLOCK_ROWS,
93
+ SOURCE_MORE_KEY,
94
+ WAITING_MARK,
95
+ WAITING_MORE_KEY,
96
+ waitingKeys,
97
+ waitingRows,
98
+ } from './waiting-click'
63
99
 
64
100
  /**
65
101
  * Where a lane stands outside its plan. `agent` is the terminal adapter's status, null when no agent is listed;
@@ -92,6 +128,8 @@ export type LaneView = {
92
128
  models?: Record<string, PhaseModel>
93
129
  /** The files this lane's session sent to the user, shown as badges on the row of link chips. */
94
130
  sentFiles?: SentChip[]
131
+ /** What a resting lane waits on, from its report (`restNote`); absent or null while it works or asks nothing. */
132
+ restNote?: string | null
95
133
  }
96
134
 
97
135
  /** `watch` is the health of the watcher the badges come from, absent when it was not read. */
@@ -105,13 +143,36 @@ export type OverviewView = {
105
143
  watchesTickets?: boolean
106
144
  /** The open items of the board's section of what waits on the user, shown under the title. */
107
145
  awaiting?: WaitingItem[]
146
+ /** The board's next section, in a block of its own under the for-you block; absent for a herd without a board. */
147
+ next?: NextBlock
148
+ /** The open appointments of the board, in a block of their own under the next block. */
149
+ appointments?: AppointmentBlock
150
+ /** The sources the watcher runs that are not tickets, in a block of their own under the appointments. */
151
+ sourceBlock?: SourceBlock
152
+ /** Whole days since the board was groomed (`groomedDaysAgo`), beside the clock; null or absent when unknown. */
153
+ groomedDays?: number | null
108
154
  /** What agents report doing that no lane takes, one `now:` line each under the title. */
109
155
  unplaced?: UnplannedTask[]
110
156
  }
111
157
 
112
- /** A fixed header and footer around a body that scrolls; body lines and targets count from the body's top. */
113
- /** The drawn screen; `waitingLine` is the screen line (0-based) of the first for-you row, where a click is matched. */
114
- export type Screen = { header: string[]; body: Frame; footer: string[]; waitingLine: number }
158
+ /**
159
+ * The drawn screen: a fixed header and footer around a body that scrolls; body lines and targets count from the body's
160
+ * top. `waitingBlock` and `appointmentBlock` are what a click on the for-you and the appointment block is matched
161
+ * against, null while the block is not drawn.
162
+ */
163
+ export type Screen = {
164
+ header: string[]
165
+ body: Frame
166
+ footer: string[]
167
+ waitingBlock: BlockHit | null
168
+ nextBlock: BlockHit | null
169
+ appointmentBlock: BlockHit | null
170
+ sourceBlock: BlockHit | null
171
+ /** Every clickable row by the screen line it starts on (header lines first, then the body unscrolled): what the mouse and the keys land on. */
172
+ focusHits: FocusHit[]
173
+ /** The keys of the rows the arrows walk, in screen order: the rows of the blocks on top, then those of the body. */
174
+ rowKeys: string[]
175
+ }
115
176
 
116
177
  /**
117
178
  * Widths shared by every lane line: the name column, the folded pull request badges, and bar with context. They are
@@ -121,8 +182,38 @@ export type Screen = { header: string[]; body: Frame; footer: string[]; waitingL
121
182
  */
122
183
  type LaneColumns = { name: number; marks: number; right: number; phases: boolean; tally: number }
123
184
 
185
+ /**
186
+ * The lines of a block on top of the herd; the head of an open one is a rule of its own, a folded one is a plain line.
187
+ * `hit` is what a click on it is matched against, without the screen line the stack gives it.
188
+ */
189
+ type BlockLines = { open: boolean; lines: string[]; hit: Omit<BlockHit, 'firstLine'> }
190
+
191
+ /**
192
+ * What a block of the top of the herd is drawn from: its name, the bar of its lines, the paint of the mark that starts
193
+ * a row, the key of its `+<n>` line, its points and their rows.
194
+ */
195
+ type TopBlock = {
196
+ name: string
197
+ bar: () => string
198
+ markPaint: (text: string) => string
199
+ /** The paint of the buttons of a row: amber and bold in "Waiting for you", the normal foreground colour elsewhere. */
200
+ buttonPaint: (text: string) => string
201
+ /** Whether a row is the selected one or under the mouse: it gets the background, the mark, and bold buttons. */
202
+ focused: (key: string) => boolean
203
+ /** Whether all buttons of a row are bold: the arrows selected it and the mouse is not on it (the mouse bolds one). */
204
+ allBold: (key: string) => boolean
205
+ /** The paint of the count in the head while the block has points: amber for what waits on the user. */
206
+ countPaint: (text: string) => string
207
+ moreKey: string
208
+ expanded: boolean
209
+ keys: readonly string[]
210
+ rows: BlockRow[]
211
+ }
212
+
213
+ /** The blocks on top of the herd one under the other: their lines below the title, and the hit of each drawn block. */
214
+ type BlockStack = { lines: string[]; hits: (BlockHit | null)[] }
215
+
124
216
  const BRANCH_MARK = '⎇'
125
- const WOOF = 'woof'
126
217
  const SENT_MARK = '📎'
127
218
  const SYMBOL_WIDTH = 2
128
219
  const NAME_WIDTH_MAX = 24
@@ -140,7 +231,7 @@ const COLUMN_RULE = '│'
140
231
  /** The number badge of a three-digit pull request (`↗ #` and the digits, a cell of padding either side); a longer one widens it. */
141
232
  const PULL_REQUEST_BADGE_WIDTH = 8
142
233
  /**
143
- * Under the lane line's status symbol: where the pull request row and its badge line start (D96), and where the trunk
234
+ * Under the lane line's status symbol: where the pull request row and its badge line start, and where the trunk
144
235
  * of an open lane hangs, not under the fold arrow.
145
236
  */
146
237
  const UNDER_STATUS = ' '
@@ -156,12 +247,10 @@ const TEST_PATH_LEVEL = ' > '
156
247
  const TEST_PATH_SEPARATOR = ' › '
157
248
  const PLACE_SEPARATOR = ' · '
158
249
 
159
- /** The colour of a lane state, word and symbol alike: teal while working or done, bold amber when it waits for you, grey when idle. */
160
- const STATUS_PAINT: Record<AgentStatus, (text: string) => string> = {
161
- [AGENT_STATUS.working]: paint.teal,
162
- [AGENT_STATUS.blocked]: (text) => paint.bold(paint.yellow(text)),
163
- [AGENT_STATUS.done]: paint.teal,
164
- [AGENT_STATUS.idle]: paint.dim,
250
+ /** The colour of a lane state, word and symbol alike: the tone of `running` while working or done, bold `waiting` when it waits for you, `resting` when idle. */
251
+ const statusPaint = (status: AgentStatus): ((text: string) => string) => {
252
+ if (status === AGENT_STATUS.blocked) return (text) => paint.bold(paintOfRole(TONE_ROLE.waiting)(text))
253
+ return status === AGENT_STATUS.idle ? paintOfRole(TONE_ROLE.resting) : paintOfRole(TONE_ROLE.running)
165
254
  }
166
255
 
167
256
  const CHECKS_PAINT: Record<ChecksState, (text: string) => string> = {
@@ -173,13 +262,13 @@ const CHECKS_PAINT: Record<ChecksState, (text: string) => string> = {
173
262
 
174
263
  /** Only a blocked agent is named in the lane line, since there the user must act; the symbol says the rest. */
175
264
  const agentLabel = (agent: string | null): string =>
176
- agent === AGENT_STATUS.blocked ? STATUS_PAINT[AGENT_STATUS.blocked](statusWord(agent)) : ''
265
+ agent === AGENT_STATUS.blocked ? statusPaint(AGENT_STATUS.blocked)(statusWord(agent)) : ''
177
266
 
178
267
  /** The symbol of a status padded to `SYMBOL_WIDTH` cells, so the names after it start in one column. */
179
268
  const symbolCell = (agent: string | null): string => {
180
269
  const symbol = statusSymbol(agent)
181
270
  return padVisible(
182
- agent !== null && isAgentStatus(agent) ? STATUS_PAINT[agent](symbol) : paint.dim(symbol),
271
+ agent !== null && isAgentStatus(agent) ? statusPaint(agent)(symbol) : paint.dim(symbol),
183
272
  SYMBOL_WIDTH,
184
273
  )
185
274
  }
@@ -189,12 +278,11 @@ const aheadLabel = ({ branch, ahead }: LaneStatus): string => {
189
278
  return ahead > 0 ? paint.teal(`↑${ahead}`) : ''
190
279
  }
191
280
 
281
+ const branchLabel = (status: LaneStatus): string =>
282
+ status.branch ? paint.dim(`${BRANCH_MARK} ${status.branch}`) : paint.dim(TEXTS.noWorktree)
283
+
192
284
  const gitLabel = (status: LaneStatus): string =>
193
- [
194
- status.branch ? paint.dim(`${BRANCH_MARK} ${status.branch}`) : paint.dim(TEXTS.noWorktree),
195
- aheadLabel(status),
196
- status.dirty ? paint.magenta(`±${status.dirty}`) : '',
197
- ]
285
+ [branchLabel(status), aheadLabel(status), status.dirty ? paint.magenta(`±${status.dirty}`) : '']
198
286
  .filter(Boolean)
199
287
  .join(' ')
200
288
 
@@ -236,9 +324,9 @@ export const rowWithCount = (
236
324
  const fitsBeside = (head: string, cells: BadgeCell[], width: number): boolean =>
237
325
  visibleLength(head) + packedWidth(cells) + COLUMN_SEPARATOR.length + MIN_TITLE_WIDTH <= width
238
326
 
239
- /** The number of a pull request as a badge linked to it, padded to its column. */
327
+ /** The number of a pull request as a bracket button linked to it, padded to its column. */
240
328
  const numberCell = (pullRequest: PullRequest): string =>
241
- padVisible(badge(pullRequest.url, `↗ #${pullRequest.number}`), PULL_REQUEST_BADGE_WIDTH)
329
+ padVisible(bracket(pullRequest.url, `↗ #${pullRequest.number}`), PULL_REQUEST_BADGE_WIDTH)
242
330
 
243
331
  /** The column the check chip of a pull request row starts in, behind its number. */
244
332
  const checksColumnOf = (pullRequest: PullRequest): number =>
@@ -247,7 +335,7 @@ const checksColumnOf = (pullRequest: PullRequest): number =>
247
335
  /**
248
336
  * `⟦↗ #<number>⟧ ✓ checks ⟦🌐 preview⟧ ⟦💬 3 new⟧ title`: the number as a badge and the check state in fixed
249
337
  * columns, the watcher's badges after them and the title last, cut with `…` on its one line. When the badges leave no
250
- * room for a title, they go on lines of their own below, starting in the column of the lane's status symbol (D96).
338
+ * room for a title, they go on lines of their own below, starting in the column of the lane's status symbol.
251
339
  */
252
340
  const pullRequestRow = (pullRequest: PullRequest, context: LaneContext): Frame => {
253
341
  const number = numberCell(pullRequest)
@@ -382,16 +470,18 @@ const progressColumn = ({ done, total, phasesDone, phases }: LaneSummary['tally'
382
470
  return `${counts}${paint.dim(phaseCount)}`
383
471
  }
384
472
 
385
- const CHIP_GAP = ' '
473
+ const CHIP_GAP = ' '
474
+ /** The most cells the label of a link chip takes in its bracket; a longer title is cut with an ellipsis. */
475
+ const CHIP_LABEL_WIDTH = 24
386
476
 
387
477
  const TICKET_MARK = '📡'
388
478
  const STATUS_SEPARATOR = ' · '
389
479
  /** Between the checkbox count and the phase count of a lane. */
390
480
  const TALLY_SEPARATOR = ' · '
391
481
 
392
- /** `✦ label` as a badge linked to the chip's address, `private` faint after it for a link its plan says is private. */
482
+ /** `[✦ label]` linked to the chip's address, `private` faint inside it for a link its plan says is private. */
393
483
  const chipCell = ({ url, icon, label, private: hidden }: LinkChip): string =>
394
- badge(url, `${icon} ${plain(label)}`, hidden ? TEXTS.privateLink : '')
484
+ bracket(url, `${icon} ${fit(plain(label), CHIP_LABEL_WIDTH)}`, hidden ? TEXTS.privateLink : '')
395
485
 
396
486
  /** A badge and the key a click on it opens; null for a cell no click opens. */
397
487
  type BadgeCell = { cell: string; key: string | null }
@@ -435,9 +525,11 @@ const packedFrame = (cells: BadgeCell[], lead: string, width: number): Frame =>
435
525
 
436
526
  const ticketTone = (isNew: boolean): BadgeTone => (isNew ? BADGE_TONE.amber : BADGE_TONE.grey)
437
527
 
438
- /** A badge linked to `url`, or a box no click opens when there is none. */
439
- const linkedBox = (url: string | null, text: string, tone: BadgeTone): string =>
440
- url ? badge(url, text, '', tone) : box(text, tone)
528
+ /** A bracket button linked to `url` where nothing is new, a badge in the tone of what is, a box no click opens without `url`. */
529
+ const linkedBox = (url: string | null, text: string, tone: BadgeTone): string => {
530
+ if (!url) return box(text, tone)
531
+ return tone === BADGE_TONE.grey ? bracket(url, text) : badge(url, text, '', tone)
532
+ }
441
533
 
442
534
  /** `📡 T-1 · In Progress` linked to the ticket, amber while new; a ticket without URL is a box no click opens. */
443
535
  const ticketCell = ({ id, url, status, isNew }: TicketBadge): BadgeCell => {
@@ -454,7 +546,7 @@ const ticketCells = (tickets: LaneTickets | null): BadgeCell[] => {
454
546
 
455
547
  /** A file the lane's session sent to the user, `📎 name` linked by its `file:` address; the terminal opens it, no click of ours does. */
456
548
  const sentCell = ({ url, label }: SentChip): BadgeCell => ({
457
- cell: badge(url, `${SENT_MARK} ${plain(label)}`),
549
+ cell: bracket(url, `${SENT_MARK} ${plain(label)}`),
458
550
  key: null,
459
551
  })
460
552
 
@@ -474,7 +566,7 @@ const chipCells = (chips: LinkChip[], tickets: LaneTickets | null, sent: readonl
474
566
 
475
567
  /**
476
568
  * `🌐 preview` opening the preview, `💬 <n> new` in amber opening the pull request, and `✋ changes requested` in dark
477
- * red opening it too, each only when there is one (D96).
569
+ * red opening it too, each only when there is one.
478
570
  */
479
571
  const badgeCells = (
480
572
  pullRequest: PullRequest,
@@ -487,22 +579,24 @@ const badgeCells = (
487
579
  key: chipKey(pullRequest.url),
488
580
  })
489
581
  return [
490
- ...(badges.preview ? [{ cell: badge(badges.preview, texts.preview), key: chipKey(badges.preview) }] : []),
582
+ ...(badges.preview ? [{ cell: bracket(badges.preview, texts.preview), key: chipKey(badges.preview) }] : []),
491
583
  ...(badges.feedback > 0 ? [onPullRequest(texts.feedback(badges.feedback), BADGE_TONE.amber)] : []),
492
584
  ...(badges.changesRequested ? [onPullRequest(texts.changesRequested, BADGE_TONE.red)] : []),
493
585
  ]
494
586
  }
495
587
 
496
588
  /**
497
- * The phase the lane line names: the open phase moved last, else the lane's current phase while it is open; never one
498
- * ticked to the end, whose state the `✓` line says (`not merged`). Null when there is neither.
589
+ * The phase the lane line names: the one its report or branch signals, else the open phase moved last, else the
590
+ * lane's current phase while it is open; never one ticked to the end, whose state the `✓` line says. Null when there
591
+ * is none.
499
592
  */
500
593
  const headPhase = (view: LaneView): PhaseProgress | null => {
594
+ const { signalled, latest } = summaryOf(view)
501
595
  const current = currentPhase(view.progress)
502
- return summaryOf(view).latest ?? (current && isPhaseOpen(current) ? current : null)
596
+ return signalled ?? latest ?? (current && isPhaseOpen(current) ? current : null)
503
597
  }
504
598
 
505
- /** `P9 Freigabe 0.x` for the phase the lane line names; empty when there is none. */
599
+ /** `P9 Release 1.0` for the phase the lane line names; empty when there is none. */
506
600
  const nextPhaseLabel = (view: LaneView): string => {
507
601
  const phase = headPhase(view)
508
602
  return phase ? `${phaseLabel(phase.id)}${phase.title ? ` ${plain(phase.title)}` : ''}` : ''
@@ -521,7 +615,7 @@ const pullRequestMark = (pullRequest: PullRequest, badges: WatchBadges): string
521
615
  const found = badges[String(pullRequest.number)] ?? NO_BADGES
522
616
  const feedback = found.feedback > 0 ? ` 💬 ${found.feedback}` : ''
523
617
  const text = `↗ #${pullRequest.number}${feedback}${found.changesRequested ? ' ✋' : ''}`
524
- return badge(pullRequest.url, text, '', markTone(found))
618
+ return linkedBox(pullRequest.url, text, markTone(found))
525
619
  }
526
620
 
527
621
  /** `📡 <n>` in amber while tickets of the lane are new, linked to the first of them; nothing otherwise. */
@@ -546,11 +640,35 @@ const foldedPhaseLabel = (view: LaneView): string => {
546
640
  return `${nextPhaseLabel(view)}${more > 0 ? ` +${more}` : ''} · ${phase.done}/${phase.total}`
547
641
  }
548
642
 
643
+ /** A lane whose agent rests (idle or done): the folded line says what it waits on instead of the phase and the bar. */
644
+ const restsFolded = ({ status }: LaneView, open: boolean): boolean => !open && isRestingAgent(status.agent)
645
+
646
+ /** The report's `waits on: <question>` split at its label, so the label can sit dim and the reason bright. */
647
+ const noteParts = (note: string): { label: string; reason: string } => {
648
+ const split = note.indexOf(': ')
649
+ return split === -1 ? { label: '', reason: note } : { label: note.slice(0, split + 1), reason: note.slice(split + 2) }
650
+ }
651
+
652
+ /**
653
+ * Why a resting lane rests: the open question of its report (the watcher's `waits on:` line), else the starts-after
654
+ * cell of the board (`after: …`), else the plain fact that nothing is known, which is meant to stand out.
655
+ */
656
+ const restDetail = (view: LaneView): string => {
657
+ const wait = waitingNote(view.progress.lane)
658
+ const { label, reason } = view.restNote
659
+ ? noteParts(view.restNote)
660
+ : wait !== null
661
+ ? { label: TEXTS.restAfter, reason: plain(wait) }
662
+ : { label: '', reason: TEXTS.restNoReason }
663
+ return label ? `${paint.dim(label)} ${reason}` : reason
664
+ }
665
+
549
666
  /** What follows the status on a lane line: the marker and, folded only, the phase it works on; the branch is on the session line. */
550
667
  const laneDetail = (view: LaneView, open: boolean): string => {
551
668
  const markText = laneMarkText(view)
552
669
  const marker = markText ? paint.yellow(markText) : ''
553
- return [agentLabel(view.status.agent), marker, open ? '' : foldedPhaseLabel(view)].filter(Boolean).join(' ')
670
+ const phase = restsFolded(view, open) ? restDetail(view) : open ? '' : foldedPhaseLabel(view)
671
+ return [agentLabel(view.status.agent), marker, phase].filter(Boolean).join(' ')
554
672
  }
555
673
 
556
674
  /**
@@ -583,14 +701,14 @@ const laneProgressColumn = (view: LaneView, tallyWidth: number): string => {
583
701
 
584
702
  /** Context and bar at the right edge of a folded lane line, as wide as the lane has them. */
585
703
  const laneRight = (view: LaneView, tallyWidth: number): string => {
586
- const counts = laneProgressColumn(view, tallyWidth)
704
+ const counts = restsFolded(view, false) ? '' : laneProgressColumn(view, tallyWidth)
587
705
  return counts ? `${contextCell(view.context)}${counts}` : contextCell(view.context).trimEnd()
588
706
  }
589
707
 
590
708
  /** The cells of the phase count column, 0 when the pane leaves it out. */
591
709
  const tallyWidthOf = ({ phases, tally }: LaneColumns): number => (phases ? tally : 0)
592
710
 
593
- /** A folded lane's marks in the column of `columns.marks`, right before context and bar (D96). */
711
+ /** A folded lane's marks in the column of `columns.marks`, right before context and bar. */
594
712
  const markedRight = (view: LaneView, columns: LaneColumns): string => {
595
713
  const right = laneRight(view, tallyWidthOf(columns))
596
714
  const marks = laneMarks(view)
@@ -602,7 +720,7 @@ const markedRight = (view: LaneView, columns: LaneColumns): string => {
602
720
 
603
721
  /**
604
722
  * Before a lane's detail on a line of its own: blanks up to the column of the lane's name, so a folded lane's title
605
- * starts where its name does (17:40), as the branches of an open lane do; an open lane keeps its trunk in front.
723
+ * starts where its name does, as the branches of an open lane do; an open lane keeps its trunk in front.
606
724
  */
607
725
  const detailIndent = (open: boolean, nameColumn: number): string => {
608
726
  if (!open) return ' '.repeat(nameColumn)
@@ -610,19 +728,31 @@ const detailIndent = (open: boolean, nameColumn: number): string => {
610
728
  return `${paint.dim(trunk)}${' '.repeat(Math.max(nameColumn - visibleLength(trunk), 0))}`
611
729
  }
612
730
 
731
+ /** The task row below a folded lane's phase row, under the lane's name; none while the phase has no task on it. */
732
+ const foldedTaskRow = (view: LaneView, indent: string, width: number): string[] => {
733
+ const phase = headPhase(view)
734
+ const { active: running, subagentTasks } = summaryOf(view)
735
+ const active = running.find((each) => each.phase.id === phase?.id)
736
+ const line = active && foldedTaskLine(active, subagentTasks, TEXTS.herd, indent, width)
737
+ return line ? [line] : []
738
+ }
739
+
613
740
  const laneLines = (view: LaneView, open: boolean, width: number, columns: LaneColumns): string[] => {
614
741
  const { progress, status } = view
615
- const cells = padVisible(paint.bold(fit(progress.lane.name, columns.name)), columns.name)
742
+ const nameStyle = restsFolded(view, open) ? paint.dim : paint.bold
743
+ const cells = padVisible(nameStyle(fit(progress.lane.name, columns.name)), columns.name)
616
744
  const head = `${paint.dim(chevronOf(open))} ${symbolCell(status.agent)} ${cells}`
617
745
  const right = open ? laneProgressColumn(view, tallyWidthOf(columns)) : markedRight(view, columns)
618
746
  const nameColumn = visibleLength(head) - visibleLength(cells)
619
- return withDetail(head, laneDetail(view, open), right, width, detailIndent(open, nameColumn))
747
+ const lines = withDetail(head, laneDetail(view, open), right, width, detailIndent(open, nameColumn))
748
+ return open ? lines : [...lines, ...foldedTaskRow(view, ' '.repeat(nameColumn), width)]
620
749
  }
621
750
 
622
751
  /** The parts of a session line: the session's title (the branch without one) at the left, the branch and the context value. */
623
- const sessionParts = (view: LaneView): { left: string; git: string; context: string } => {
752
+ const sessionParts = (view: LaneView): { left: string; git: string; branch: string; context: string } => {
624
753
  const git = gitLabel(view.status)
625
- return { left: view.sessionTitle ? plain(view.sessionTitle) : git, git, context: contextValue(view.context) }
754
+ const branch = branchLabel(view.status)
755
+ return { left: view.sessionTitle ? plain(view.sessionTitle) : git, git, branch, context: contextValue(view.context) }
626
756
  }
627
757
 
628
758
  /**
@@ -631,31 +761,36 @@ const sessionParts = (view: LaneView): { left: string; git: string; context: str
631
761
  * title cut. Without a title the branch stands at the left.
632
762
  */
633
763
  const sessionLine = (view: LaneView, width: number): string => {
634
- const { left, git, context } = sessionParts(view)
764
+ const { left, git, branch, context } = sessionParts(view)
635
765
  if (!view.sessionTitle) return spread(fit(git, Math.max(width - CONTEXT_CELL_WIDTH, 1)), context, width)
636
766
  const room = width - (context ? CONTEXT_CELL_WIDTH : 0)
637
- const withGit = visibleLength(left) + COLUMN_SEPARATOR.length + visibleLength(git) <= room
638
- const right = [withGit ? git : '', context].filter(Boolean).join(COLUMN_SEPARATOR)
767
+ const fitting = [git, branch].find(
768
+ (label) => visibleLength(left) + COLUMN_SEPARATOR.length + visibleLength(label) <= room,
769
+ )
770
+ const right = [fitting ?? '', context].filter(Boolean).join(COLUMN_SEPARATOR)
639
771
  return spread(fit(left, Math.max(room, 1)), right, width)
640
772
  }
641
773
 
642
774
  /**
643
775
  * The session line with the lane's one link chip on it, so the lane needs no row of chips: title, chip, branch and
644
- * context, the branch giving way first; without a title the branch at the left and the chip at the right. Null when
645
- * the line has no room for the chip beside the whole title, and the chip keeps its row of chips.
776
+ * context. When the line is short the ahead and dirty counts of the branch go first, then the title is cut with `…`;
777
+ * without a title the branch stands at the left and the chip at the right. Null when even the cut title would be
778
+ * narrower than `MIN_TITLE_WIDTH`, and the chip keeps its row of chips.
646
779
  */
647
780
  const sessionWithChip = (view: LaneView, chip: BadgeCell, width: number): Frame | null => {
648
- const { left, git, context } = sessionParts(view)
781
+ const { left, git, branch, context } = sessionParts(view)
649
782
  const tails = view.sessionTitle
650
783
  ? [
651
784
  [chip.cell, git, context],
652
- [chip.cell, context],
785
+ [chip.cell, branch, context],
653
786
  ]
654
787
  : [[chip.cell, context]]
655
- const right = tails
656
- .map((tail) => tail.filter(Boolean).join(COLUMN_SEPARATOR))
657
- .find((candidate) => visibleLength(left) + COLUMN_SEPARATOR.length + visibleLength(candidate) <= width)
658
- if (right === undefined) return null
788
+ const candidates = tails.map((tail) => tail.filter(Boolean).join(COLUMN_SEPARATOR))
789
+ const roomFor = (candidate: string) => width - COLUMN_SEPARATOR.length - visibleLength(candidate)
790
+ const narrowest = candidates[candidates.length - 1] ?? ''
791
+ const cuts = Boolean(view.sessionTitle) && roomFor(narrowest) >= MIN_TITLE_WIDTH
792
+ const right = candidates.find((candidate) => visibleLength(left) <= roomFor(candidate)) ?? (cuts ? narrowest : null)
793
+ if (right === null) return null
659
794
  const from = width - visibleLength(right)
660
795
  const targets = chip.key === null ? [] : [{ line: 0, from, to: from + visibleLength(chip.cell), key: chip.key }]
661
796
  return { lines: [spread(left, right, width)], cursorLine: -1, targets }
@@ -762,9 +897,9 @@ const parkedBlock = (lanes: LaneView[], width: number): string[] => {
762
897
  */
763
898
  const plannedFrame = (planned: PlannedPlan[], state: Selection, width: number): Frame => {
764
899
  if (planned.length === 0) return textFrame([])
765
- const phases = plannedPhaseCount(planned)
766
900
  const open = state.expanded.has(PLANNED_KEY)
767
- const head = [paint.dim(fit(`${chevronOf(open)} ${TEXTS.planned(phases, planned.length)}`, width))]
901
+ const next = planned.map((plan) => `${plain(plan.title)} ${plannedNextLabel(plan)}`).join(', ')
902
+ const head = [paint.dim(fit(`${chevronOf(open)} ${TEXTS.plannedHead(planned.length)} ${next}`, width))]
768
903
  return rowFrame(PLANNED_KEY, head, open ? plannedRows(planned, width, CONTENT_INDENT).flat() : [], { state, width })
769
904
  }
770
905
 
@@ -773,7 +908,7 @@ const plannedRows = (planned: PlannedPlan[], width: number, indent: string): str
773
908
  planned.map((plan) =>
774
909
  rowWithCount(
775
910
  indent,
776
- `${plain(plan.title)} · ${phaseList(plan)}`,
911
+ `${plain(plan.title)} · ${plannedNextLabel(plan)}`,
777
912
  countCell('', countText(plan.done, plan.total)),
778
913
  width,
779
914
  paint.dim,
@@ -782,32 +917,232 @@ const plannedRows = (planned: PlannedPlan[], width: number, indent: string): str
782
917
 
783
918
  const rule = (width: number): string => paint.dim(RULE.repeat(width))
784
919
 
785
- /** The rule under the for-you block, labelled woof (Jack barks) so the block reads as a section of its own. */
786
- const barkingRule = (width: number): string => {
787
- const lead = `${RULE.repeat(2)} ${WOOF} `
788
- return paint.dim(`${lead}${RULE.repeat(Math.max(width - textWidth(lead), 0))}`)
920
+ /** The amber bar that starts every line of the for-you block: the one signal that this waits on the user. */
921
+ const waitingBar = (): string => statusPaint(AGENT_STATUS.blocked)(WAITING_MARK)
922
+
923
+ /** The grey bar of the appointment block: it waits on nobody's answer. */
924
+ const appointmentBar = (): string => paint.dim(WAITING_MARK)
925
+
926
+ /**
927
+ * The head of an open block, `▾ <name> · <n> ──`, filled with the rule to the width, so it is the rule above the block.
928
+ * The label and the count wear the normal colour (the count in `countPaint`), only the rule is dim.
929
+ */
930
+ const blockHead = (name: string, count: number, width: number, countPaint: (text: string) => string): string => {
931
+ const lead = `${chevronOf(true)} ${name} ${ITEM_SEPARATOR} `
932
+ const used = textWidth(`${lead}${count} `)
933
+ return `${lead}${countPaint(String(count))} ${paint.dim(RULE.repeat(Math.max(width - used, 0)))}`
789
934
  }
790
935
 
791
- /** One row as lines: a dim bar and the text in bold on each, linked when the row has an address. */
792
- const waitingRowLines = ({ lines, url }: WaitingRow): string[] =>
793
- lines.map((line) => `${paint.dim(WAITING_MARK)} ${paint.bold(url ? link(url, line) : line)}`)
936
+ const markedLine = (line: string, row: BlockRow, markPaint: (text: string) => string): string => {
937
+ const paintMark = row.tone ? paintOfTone(row.tone) : markPaint
938
+ return line.startsWith(row.mark) ? `${paintMark(row.mark)}${line.slice(row.mark.length)}` : line
939
+ }
940
+
941
+ /** The text from `from` on dimmed, without the blanks that pad it to the right edge. */
942
+ const dimmedRest = (text: string, from: number): string => {
943
+ const end = text.trimEnd().length
944
+ return from >= end ? text : `${text.slice(0, from)}${paint.dim(text.slice(from, end))}${text.slice(end)}`
945
+ }
794
946
 
795
947
  /**
796
- * What waits on the user's answer, on top of the herd until it is closed: `▌ 📎 name` for a sent file or an Artifact
797
- * and `▌ ? question` for a question of the board, up to five and `▌ +<n>` for the rest. A row wider than the pane is
798
- * cut to one line; once expanded it wraps over several.
948
+ * One row as lines: the bar on each, the mark painted on the first line only, the short title in the normal colour
949
+ * and the rest of an expanded row dim, linked when the row has an address; the `[ask]` button at the end of a
950
+ * question's first line wears the mark's paint.
799
951
  */
800
- const waitingLines = (waiting: readonly WaitingItem[], expanded: ReadonlySet<string>, width: number): string[] => {
801
- const more = Math.max(waiting.length - SHOWN_WAITING, 0)
802
- return [
803
- ...waitingRows(waiting, expanded, width).flatMap(waitingRowLines),
804
- ...(more > 0 ? [`${paint.dim(WAITING_MARK)} +${more}`] : []),
805
- ]
952
+ const blockRowLines = (
953
+ { bar, markPaint, buttonPaint, focused, allBold }: TopBlock,
954
+ row: BlockRow,
955
+ width: number,
956
+ ): string[] => {
957
+ const hot = focused(row.key)
958
+ const paintButton = (text: string) => (allBold(row.key) ? paint.bold(buttonPaint(text)) : buttonPaint(text))
959
+ return row.lines.map((line, index) => {
960
+ const withButton = index === 0 && row.buttons.length > 0
961
+ const styled = dimmedRest(line, row.dimFrom[index] ?? line.length)
962
+ const marked = index === 0 ? markedLine(styled, row, markPaint) : styled
963
+ const shown = row.url ? link(row.url, marked) : marked
964
+ const buttons = withButton ? buttonsText(row.buttons, row.dim ? paint.dim : paintButton) : ''
965
+ const drawn = `${bar()} ${shown}${buttons}`
966
+ return hot ? focusLine(drawn, width, index === 0) : drawn
967
+ })
968
+ }
969
+
970
+ /** A folded block: the one line `▸ <name> · <n>` and the first title as a preview; a click matches no row and no `+<n>` line. */
971
+ const foldedBlockLines = ({ name, keys, rows }: TopBlock, width: number): BlockLines => {
972
+ const preview = rows[0]?.preview
973
+ const head = `${chevronOf(false)} ${name} ${ITEM_SEPARATOR} ${keys.length}`
974
+ return {
975
+ open: false,
976
+ lines: [paint.dim(fit(preview === undefined ? head : `${head} ${preview}`, width))],
977
+ hit: { rows: [], moreKey: null, keys },
978
+ }
979
+ }
980
+
981
+ /**
982
+ * An open block on top of the herd: the head `▾ <name> · <n> ──`, one barred line per row (the first five, all while the
983
+ * block is expanded) and `▌ +<n>` for the rest, which turns into `▌ ▴ <n>` once all show and folds them again. A row wider than the pane is cut to
984
+ * one line; once expanded it wraps over several. The rule below is the stack's, not the block's.
985
+ */
986
+ const openBlockLines = (block: TopBlock, width: number): BlockLines => {
987
+ const count = block.keys.length
988
+ const more = Math.max(count - SHOWN_BLOCK_ROWS, 0)
989
+ const moreLine = block.expanded ? `${LESS_MARK} ${more}` : `+${more}`
990
+ return {
991
+ open: true,
992
+ lines: [
993
+ blockHead(block.name, count, width, count > 0 ? block.countPaint : (text) => text),
994
+ ...block.rows.flatMap((row) => blockRowLines(block, row, width)),
995
+ ...(more > 0 ? [`${block.bar()} ${moreLine}`] : []),
996
+ ],
997
+ hit: { rows: block.rows, moreKey: moreKeyOf(count, block.moreKey), keys: block.keys },
998
+ }
806
999
  }
807
1000
 
808
- /** A dimmed rule before every lane block but the first, folded or open: it shows where one lane ends. */
809
- const separate = (blocks: Frame[], width: number): Frame[] =>
810
- blocks.flatMap((frame, index) => (index === 0 ? [frame] : [textFrame([rule(width)]), frame]))
1001
+ const blockLinesOf = (block: TopBlock, state: Selection, id: TopBlockId, width: number): BlockLines =>
1002
+ isBlockFolded(state, id, block.keys) ? foldedBlockLines(block, width) : openBlockLines(block, width)
1003
+
1004
+ /**
1005
+ * What waits on the user's answer, on top of the herd until it is closed: `▌ 📎 name` for a sent file or an
1006
+ * Artifact, `▌ ? question` for a question of the board or of a session's dialog, in the amber of a lane that waits.
1007
+ * One line while the user folded it.
1008
+ */
1009
+ const waitingLines = (waiting: readonly WaitingItem[], state: Selection, width: number): BlockLines => {
1010
+ const keys = waitingKeys(waiting)
1011
+ const block: TopBlock = {
1012
+ name: TEXTS.waitingHeading,
1013
+ bar: waitingBar,
1014
+ markPaint: statusPaint(AGENT_STATUS.blocked),
1015
+ buttonPaint: statusPaint(AGENT_STATUS.blocked),
1016
+ focused: (key) => isFocused(state, key),
1017
+ allBold: (key) => buttonsAllBold(state, key),
1018
+ countPaint: statusPaint(AGENT_STATUS.blocked),
1019
+ moreKey: WAITING_MORE_KEY,
1020
+ expanded: state.expanded.has(WAITING_MORE_KEY),
1021
+ keys,
1022
+ rows: waitingRows(waiting, state.expanded, width),
1023
+ }
1024
+ return blockLinesOf(block, state, TOP_BLOCK.waiting, width)
1025
+ }
1026
+
1027
+ /** The open appointments of the board under the for-you block: `▌ ◷ Mo 09:03 Release 2.0 rollout`, in a grey bar and with a grey mark. */
1028
+ const appointmentLines = ({ rows }: AppointmentBlock, state: Selection, width: number): BlockLines => {
1029
+ const keys = appointmentKeys(rows)
1030
+ const block: TopBlock = {
1031
+ name: TEXTS.appointmentHeading,
1032
+ bar: appointmentBar,
1033
+ markPaint: paint.dim,
1034
+ buttonPaint: (text) => text,
1035
+ focused: (key) => isFocused(state, key),
1036
+ allBold: (key) => buttonsAllBold(state, key),
1037
+ countPaint: (text) => text,
1038
+ moreKey: APPOINTMENT_MORE_KEY,
1039
+ expanded: state.expanded.has(APPOINTMENT_MORE_KEY),
1040
+ keys,
1041
+ rows: blockRows(appointmentItems(rows), state.expanded, width, APPOINTMENT_MORE_KEY),
1042
+ }
1043
+ return blockLinesOf(block, state, TOP_BLOCK.appointments, width)
1044
+ }
1045
+
1046
+ /** The bar of the next block: a blank, since it asks nothing of the user and only its numbers lead the rows. */
1047
+ const nextBar = (): string => ' '
1048
+
1049
+ /** The next block names nothing queued in one dim line, which has no rows to click. */
1050
+ const emptyNextLines = (): BlockLines => ({
1051
+ open: false,
1052
+ lines: [paint.dim(`${TEXTS.nextHeading} ${ITEM_SEPARATOR} ${TEXTS.nextEmpty}`)],
1053
+ hit: { rows: [], moreKey: null, keys: [] },
1054
+ })
1055
+
1056
+ /** The board's next section under the for-you block: ` 1 Answer the phone tests`, numbered, without the amber bar. */
1057
+ const nextLines = ({ rows }: NextBlock, state: Selection, width: number): BlockLines => {
1058
+ if (rows.length === 0) return emptyNextLines()
1059
+ const block: TopBlock = {
1060
+ name: TEXTS.nextHeading,
1061
+ bar: nextBar,
1062
+ markPaint: paint.dim,
1063
+ buttonPaint: (text) => text,
1064
+ focused: (key) => isFocused(state, key),
1065
+ allBold: (key) => buttonsAllBold(state, key),
1066
+ countPaint: (text) => text,
1067
+ moreKey: NEXT_MORE_KEY,
1068
+ expanded: state.expanded.has(NEXT_MORE_KEY),
1069
+ keys: nextKeys(rows),
1070
+ rows: blockRows(nextItems(rows), state.expanded, width, NEXT_MORE_KEY),
1071
+ }
1072
+ return blockLinesOf(block, state, TOP_BLOCK.next, width)
1073
+ }
1074
+
1075
+ /** The sources the watcher runs: ` ● errors ran 12 min ago · <newest report>`, the mark amber while the report is new, red while the source fails. */
1076
+ const sourceLines = (
1077
+ { sources, now }: SourceBlock,
1078
+ awaiting: readonly WaitingItem[],
1079
+ state: Selection,
1080
+ width: number,
1081
+ ): BlockLines => {
1082
+ const shown = state.expanded.has(SOURCE_MORE_KEY) ? sources : sources.slice(0, SHOWN_BLOCK_ROWS)
1083
+ const view = { expanded: state.expanded, asked: askedCheckOf(awaiting) }
1084
+ const block: TopBlock = {
1085
+ name: TEXTS.sourceHeading,
1086
+ bar: nextBar,
1087
+ markPaint: paint.dim,
1088
+ buttonPaint: (text) => text,
1089
+ focused: (key) => isFocused(state, key),
1090
+ allBold: (key) => buttonsAllBold(state, key),
1091
+ countPaint: (text) => text,
1092
+ moreKey: SOURCE_MORE_KEY,
1093
+ expanded: state.expanded.has(SOURCE_MORE_KEY),
1094
+ keys: sourceKeys(sources),
1095
+ rows: allBlockRows(sourceItems(shown, now, toneOfRole(TONE_ROLE.new), view), state.expanded, width),
1096
+ }
1097
+ return blockLinesOf(block, state, TOP_BLOCK.sources, width)
1098
+ }
1099
+
1100
+ const sourceLinesOf = (
1101
+ sourceBlock: SourceBlock | undefined,
1102
+ awaiting: readonly WaitingItem[],
1103
+ state: Selection,
1104
+ width: number,
1105
+ ): BlockLines | null =>
1106
+ sourceBlock && sourceBlock.sources.length > 0 ? sourceLines(sourceBlock, awaiting, state, width) : null
1107
+
1108
+ const appointmentLinesOf = (
1109
+ appointments: AppointmentBlock | undefined,
1110
+ state: Selection,
1111
+ width: number,
1112
+ ): BlockLines | null =>
1113
+ appointments && appointments.rows.length > 0 ? appointmentLines(appointments, state, width) : null
1114
+
1115
+ /**
1116
+ * The blocks one under the other, `offset` lines below the top of the screen, with one rule between neighbours: the
1117
+ * head of an open block is that rule, a folded block gets a plain one above it unless a folded block stands right
1118
+ * above (folded blocks share the rule below them), and a plain rule closes the stack. An
1119
+ * absent block (null) takes no line and has no hit.
1120
+ */
1121
+ const stackBlocks = (slots: readonly (BlockLines | null)[], offset: number, width: number): BlockStack => {
1122
+ const lines: string[] = []
1123
+ const hits: (BlockHit | null)[] = []
1124
+ let previousFolded = false
1125
+ for (const slot of slots) {
1126
+ if (slot && !slot.open && !previousFolded) lines.push(rule(width))
1127
+ hits.push(slot && { ...slot.hit, firstLine: offset + lines.length + 1 })
1128
+ if (slot) {
1129
+ lines.push(...slot.lines)
1130
+ previousFolded = !slot.open
1131
+ }
1132
+ }
1133
+ return { lines: lines.length > 0 ? [...lines, rule(width)] : lines, hits }
1134
+ }
1135
+
1136
+ /**
1137
+ * A dimmed rule before every section of the body but the first, which shows where one ends; none between two
1138
+ * one-line lane blocks (the first `laneCount` sections), so folded lanes stand together as a list.
1139
+ */
1140
+ const separate = (sections: Frame[], width: number, laneCount: number): Frame[] =>
1141
+ sections.flatMap((frame, index) => {
1142
+ if (index === 0) return [frame]
1143
+ const listed = index < laneCount && frame.lines.length === 1 && sections[index - 1]?.lines.length === 1
1144
+ return listed ? [frame] : [textFrame([rule(width)]), frame]
1145
+ })
811
1146
 
812
1147
  /**
813
1148
  * Why badges may be missing, said beside the clock: no watcher runs, or its state is stale. Nothing while it is live,
@@ -822,6 +1157,13 @@ const watchNotice = (
822
1157
  return watch.kind === WATCH_HEALTH.off ? texts.watcherOff : texts.stale(watch.minutes)
823
1158
  }
824
1159
 
1160
+ /** `groomed 3d ago` beside the clock, dim, amber once the board is overdue; empty while it is unknown. */
1161
+ const groomedCell = (days: number | null | undefined): string => {
1162
+ if (days === null || days === undefined) return ''
1163
+ const { text, stale } = groomedText(days)
1164
+ return stale ? paint.yellow(text) : paint.dim(text)
1165
+ }
1166
+
825
1167
  /** Chevron, symbol and the gaps around them before the name column of a lane line. */
826
1168
  const LANE_HEAD_CELLS = 2 + SYMBOL_WIDTH + 1
827
1169
 
@@ -853,14 +1195,47 @@ const partitionLanes = (lanes: LaneView[]): Record<'active' | 'waiting' | 'noAge
853
1195
  }
854
1196
  }
855
1197
 
1198
+ /**
1199
+ * The line under the mouse as the mouse wants it: a line of links, which is no row of its own, gets the focus
1200
+ * background and mark, and the square-bracket button the mouse is on is bold. `offset` is the screen line `lines` starts at.
1201
+ */
1202
+ const emphasisedAt = (
1203
+ lines: string[],
1204
+ offset: number,
1205
+ hits: readonly FocusHit[],
1206
+ { hovered, hoveredFrom }: Selection,
1207
+ width: number,
1208
+ ): string[] => {
1209
+ const hit = hovered ? hits.find(({ key }) => key === hovered) : undefined
1210
+ const index = hit ? hit.line - offset : -1
1211
+ const line = lines[index]
1212
+ if (line === undefined || !hit) return lines
1213
+ const marked = hit.key.startsWith(LINKS_KEY_PREFIX) ? focusLine(line, width, true) : line
1214
+ const emphasised = hoveredFrom == null ? marked : boldBracketAt(marked, hoveredFrom)
1215
+ return lines.map((each, at) => (at === index ? emphasised : each))
1216
+ }
1217
+
856
1218
  /**
857
1219
  * Title and clock on top, the key hint at the bottom (with the legend after `?`), and in between one
858
1220
  * block per lane: the lane line with its total bar, and for an open lane the phase rows. Folded lanes are
859
- * one line each and every lane block after the first is preceded by a dimmed rule. Lanes that wait and
1221
+ * one line each, and a dimmed rule stands between lane blocks unless both are one line. Lanes that wait and
860
1222
  * lanes with a worktree but no agent each share a closing block; finished lanes are not drawn.
861
1223
  */
862
1224
  export const renderScreen = (
863
- { title, time, lanes, planned, watch, watchesTickets, awaiting = [], unplaced = [] }: OverviewView,
1225
+ {
1226
+ title,
1227
+ time,
1228
+ lanes,
1229
+ planned,
1230
+ watch,
1231
+ watchesTickets,
1232
+ awaiting = [],
1233
+ next,
1234
+ appointments,
1235
+ sourceBlock,
1236
+ groomedDays,
1237
+ unplaced = [],
1238
+ }: OverviewView,
864
1239
  state: Selection,
865
1240
  width: number,
866
1241
  ): Screen => {
@@ -870,20 +1245,39 @@ export const renderScreen = (
870
1245
  const badgeTexts = BADGE_TEXTS
871
1246
  const blocks = active.map((lane) => laneBlock(lane, state, width, columns, badgeTexts))
872
1247
  const notice = watchNotice({ lanes: active, watch, watchesTickets }, badgeTexts)
873
- const clock = notice ? `${paint.yellow(notice)}${COLUMN_SEPARATOR}${paint.dim(time)}` : paint.dim(time)
874
- const top = [spread(`${HEADER_MARK} ${paint.bold(title)}`, clock, width), rule(width)]
1248
+ const clock = [notice ? paint.yellow(notice) : '', groomedCell(groomedDays), paint.dim(time)]
1249
+ .filter(Boolean)
1250
+ .join(COLUMN_SEPARATOR)
1251
+ const titleLine = spread(`${HEADER_MARK} ${paint.bold(title)}`, clock, width)
1252
+ const forYou = awaiting.length > 0 ? waitingLines(awaiting, state, width) : null
1253
+ const queued = next ? nextLines(next, state, width) : null
1254
+ const dated = appointmentLinesOf(appointments, state, width)
1255
+ const nowLines = unplaced.map((task) => nowLine(task, width))
1256
+ const above = nowLines.length > 0 ? [titleLine, rule(width), ...nowLines] : [titleLine]
1257
+ const listed = sourceLinesOf(sourceBlock, awaiting, state, width)
1258
+ const stack = stackBlocks([forYou, queued, dated, listed], above.length, width)
1259
+ const endsWithRule = stack.lines.length > 0 || nowLines.length === 0
1260
+ const sections = [
1261
+ ...blocks,
1262
+ ...(parked.length > 0 ? [textFrame(parked)] : []),
1263
+ ...(planned.length > 0 ? [plannedFrame(planned, state, width)] : []),
1264
+ ]
1265
+ const lead = !endsWithRule && blocks.length === 0 && sections.length > 0 ? [textFrame([rule(width)])] : []
1266
+ const plainHeader = [...above, ...(stack.lines.length > 0 || nowLines.length > 0 ? stack.lines : [rule(width)])]
1267
+ const plainBody = joinFrames([...lead, ...separate(sections, width, blocks.length)])
1268
+ const blockHits = stack.hits.flatMap(focusHitsOfBlock)
1269
+ const focusHits = [...blockHits, ...focusHitsOfTargets(plainBody.targets, plainHeader.length)]
1270
+ const header = emphasisedAt(plainHeader, 0, focusHits, state, width)
1271
+ const body = { ...plainBody, lines: emphasisedAt(plainBody.lines, plainHeader.length, focusHits, state, width) }
875
1272
  return {
876
- waitingLine: top.length + unplaced.length,
877
- header: [
878
- ...top,
879
- ...unplaced.map((task) => nowLine(task, width)),
880
- ...(awaiting.length > 0 ? [...waitingLines(awaiting, state.expanded, width), barkingRule(width)] : []),
881
- ],
882
- body: joinFrames([
883
- ...separate(blocks, width),
884
- ...(parked.length > 0 ? [textFrame([rule(width), ...parked])] : []),
885
- ...(planned.length > 0 ? [textFrame([rule(width)]), plannedFrame(planned, state, width)] : []),
886
- ]),
1273
+ waitingBlock: stack.hits[0],
1274
+ nextBlock: stack.hits[1],
1275
+ appointmentBlock: stack.hits[2],
1276
+ sourceBlock: stack.hits[3],
1277
+ focusHits,
1278
+ rowKeys: [...blockHits.map(({ key }) => key), ...targetKeys(body.targets)],
1279
+ header,
1280
+ body,
887
1281
  footer: [
888
1282
  rule(width),
889
1283
  fit(paint.dim(TEXTS.hint), width),
@@ -898,6 +1292,16 @@ export const renderScreen = (
898
1292
  */
899
1293
  export const renderSelected = (overview: OverviewView, selection: Selection, width: number): Screen => {
900
1294
  const first = renderScreen(overview, selection, width)
901
- const selected = selectedKey(selection, targetKeys(first.body.targets))
902
- return selected === selection.selected ? first : renderScreen(overview, { ...selection, selected }, width)
1295
+ const settled = settledSelection(selection, first)
1296
+ return settled === selection ? first : renderScreen(overview, settled, width)
1297
+ }
1298
+
1299
+ /**
1300
+ * The selection with its row resolved against `screen`: a row the screen draws stays, a block row included; else the
1301
+ * first row of the body is selected, none when the body has none.
1302
+ */
1303
+ export const settledSelection = (selection: Selection, screen: Screen): Selection => {
1304
+ const kept = selection.selected !== null && screen.rowKeys.includes(selection.selected)
1305
+ const selected = kept ? selection.selected : selectedKey(selection, targetKeys(screen.body.targets))
1306
+ return selected === selection.selected ? selection : { ...selection, selected }
903
1307
  }