jackherd 1.8.1 → 1.10.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 (261) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/INSTALL.md +3 -1
  3. package/README.md +75 -64
  4. package/dist/{chunk-OU2KALXP.js → chunk-5MQBCUQ5.js} +24 -4
  5. package/dist/config.d.ts +45 -4
  6. package/dist/config.js +84 -27
  7. package/dist/plan-format.d.ts +63 -7
  8. package/dist/plan-format.js +66 -12
  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/approval-gate/index.ts +22 -4
  15. package/kit/hooks/hooks/approval-gate/run.ts +29 -6
  16. package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
  17. package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
  18. package/kit/hooks/hooks/ask-hint/index.ts +15 -19
  19. package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
  20. package/kit/hooks/hooks/git-guard/decision.ts +50 -102
  21. package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
  22. package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
  23. package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
  24. package/kit/hooks/hooks/merge-guard/decision.ts +127 -140
  25. package/kit/hooks/hooks/merge-guard/index.ts +6 -3
  26. package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
  27. package/kit/hooks/hooks/merge-guard/run.ts +59 -54
  28. package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
  29. package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
  30. package/kit/hooks/hooks/open-question/index.ts +26 -0
  31. package/kit/hooks/hooks/open-question/record.ts +135 -0
  32. package/kit/hooks/hooks/open-question/run.ts +52 -0
  33. package/kit/hooks/hooks/open-question/store.ts +39 -0
  34. package/kit/hooks/hooks/sent-file/index.ts +19 -0
  35. package/kit/hooks/hooks/sent-file/record.ts +128 -0
  36. package/kit/hooks/hooks/sent-file/run.ts +48 -0
  37. package/kit/hooks/hooks/sent-file/store.ts +9 -0
  38. package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
  39. package/kit/hooks/hooks/shared/config.ts +7 -0
  40. package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
  41. package/kit/hooks/hooks/shared/files.ts +7 -0
  42. package/kit/hooks/hooks/shared/lane.ts +1 -1
  43. package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
  44. package/kit/hooks/hooks/shared/private-directory.ts +56 -9
  45. package/kit/hooks/hooks/shared/program-position.ts +2 -75
  46. package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
  47. package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
  48. package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
  49. package/kit/hooks/hooks/task-hint/decision.ts +49 -0
  50. package/kit/hooks/hooks/task-hint/index.ts +79 -0
  51. package/kit/hooks/hooks/task-hint/run.ts +62 -0
  52. package/kit/hooks/hooks/task-hint/store.ts +51 -0
  53. package/kit/hooks/hooks.json +21 -4
  54. package/kit/hooks/statusline.sh +1 -1
  55. package/kit/review/agents/skill-review.md +34 -0
  56. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  57. package/kit/workflow/skills/follow/SKILL.md +1 -8
  58. package/kit/workflow/skills/implement/SKILL.md +10 -6
  59. package/kit/workflow/skills/orchestrate/SKILL.md +213 -412
  60. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  61. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  62. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  63. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  64. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  65. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  66. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  67. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  68. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  69. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  70. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  71. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  72. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  73. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  74. package/kit/workflow/skills/plan/SKILL.md +6 -5
  75. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  76. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  77. package/package.json +4 -1
  78. package/recipes/README.md +2 -1
  79. package/recipes/sources.md +1 -1
  80. package/schemas/jackherd.schema.json +85 -7
  81. package/src/check/agent-text-reviewers.ts +23 -0
  82. package/src/check/command.ts +2 -0
  83. package/src/check/plan-structure-check.ts +49 -2
  84. package/src/check/plan.ts +8 -1
  85. package/src/check/schema.ts +9 -0
  86. package/src/check/skill-budget-defaults.ts +55 -0
  87. package/src/check/skill-budgets.ts +185 -0
  88. package/src/cli.ts +4 -0
  89. package/src/config/auto-window.ts +8 -0
  90. package/src/config/link-kinds.ts +7 -1
  91. package/src/config/schema.ts +80 -26
  92. package/src/context/fill.ts +12 -0
  93. package/src/context/session-log.ts +43 -1
  94. package/src/lane/close-board.ts +106 -0
  95. package/src/lane/close-check.ts +183 -0
  96. package/src/lane/close-command-line.ts +11 -0
  97. package/src/lane/close-command.ts +217 -0
  98. package/src/lane/close-options.ts +43 -0
  99. package/src/lane/close-survey.ts +83 -0
  100. package/src/lane/close.ts +141 -0
  101. package/src/lane/command.ts +13 -0
  102. package/src/lane-prompt/build.ts +74 -0
  103. package/src/lane-prompt/command.ts +87 -0
  104. package/src/lane-prompt/input.ts +116 -0
  105. package/src/lane-prompt/options.ts +68 -0
  106. package/src/lane-prompt/texts.ts +101 -0
  107. package/src/plan-format/clock.ts +12 -0
  108. package/src/plan-format/dependencies.ts +9 -9
  109. package/src/plan-format/index.ts +1 -0
  110. package/src/plan-format/lanes.ts +30 -5
  111. package/src/plan-format/short-names.ts +71 -0
  112. package/src/plan-format/terminal-text.ts +4 -0
  113. package/src/plan-format/waiting.ts +53 -0
  114. package/src/plans/active-tasks.ts +60 -0
  115. package/src/plans/appointment-rows.ts +59 -0
  116. package/src/plans/ask-coordinator.ts +77 -0
  117. package/src/plans/ask-target.ts +25 -0
  118. package/src/plans/child-stop.ts +51 -0
  119. package/src/plans/closable-records.ts +34 -0
  120. package/src/plans/command.ts +3 -2
  121. package/src/plans/current-phase.ts +17 -7
  122. package/src/plans/current-task.ts +63 -5
  123. package/src/plans/fetch.ts +26 -3
  124. package/src/plans/git.ts +19 -5
  125. package/src/plans/herd-claim.ts +173 -0
  126. package/src/plans/herd-memory.ts +36 -25
  127. package/src/plans/lane-plans.ts +44 -13
  128. package/src/plans/lane-standing.ts +9 -4
  129. package/src/plans/lane-summary.ts +367 -92
  130. package/src/plans/lane-tickets.ts +15 -0
  131. package/src/plans/layout-phase.ts +80 -19
  132. package/src/plans/layout-summary.ts +163 -74
  133. package/src/plans/layout.ts +472 -114
  134. package/src/plans/link-chips.ts +1 -0
  135. package/src/plans/live.ts +119 -10
  136. package/src/plans/phase-ids.ts +8 -0
  137. package/src/plans/phase-models.ts +1 -1
  138. package/src/plans/phase-ranges.ts +45 -0
  139. package/src/plans/phase-source.ts +15 -0
  140. package/src/plans/plan-cache.ts +28 -0
  141. package/src/plans/plan-heads.ts +54 -0
  142. package/src/plans/pull-requests.ts +23 -2
  143. package/src/plans/question-records.ts +48 -0
  144. package/src/plans/questions-read.ts +13 -0
  145. package/src/plans/read-cache.ts +8 -0
  146. package/src/plans/record-id.ts +4 -0
  147. package/src/plans/restart-decision.ts +13 -4
  148. package/src/plans/selection.ts +117 -34
  149. package/src/plans/sent-files-read.ts +13 -0
  150. package/src/plans/sent-files.ts +114 -0
  151. package/src/plans/settings.ts +2 -1
  152. package/src/plans/signalled-phase.ts +42 -0
  153. package/src/plans/signals-read.ts +62 -0
  154. package/src/plans/snapshot.ts +279 -60
  155. package/src/plans/sprint-export.ts +11 -11
  156. package/src/plans/sprint-page.ts +5 -5
  157. package/src/plans/subagent-phases.ts +95 -0
  158. package/src/plans/subagent-tasks.ts +51 -0
  159. package/src/plans/subagents-read.ts +131 -0
  160. package/src/plans/subagents.ts +190 -0
  161. package/src/plans/supervisor.ts +14 -4
  162. package/src/plans/symbols.ts +3 -0
  163. package/src/plans/task-name.ts +34 -0
  164. package/src/plans/texts.ts +36 -14
  165. package/src/plans/unlisted-lanes.ts +10 -5
  166. package/src/plans/view.ts +20 -12
  167. package/src/plans/waiting-click.ts +231 -0
  168. package/src/plans/waiting-records.ts +39 -0
  169. package/src/plans/waiting-window.ts +8 -0
  170. package/src/preview/command.ts +105 -0
  171. package/src/preview/embed.ts +209 -0
  172. package/src/preview/images.ts +89 -0
  173. package/src/preview/open.ts +12 -0
  174. package/src/preview/options.ts +32 -0
  175. package/src/preview/page.ts +89 -0
  176. package/src/preview/render.ts +41 -0
  177. package/src/settled/board-row.ts +1 -2
  178. package/src/shared/is-record.ts +2 -0
  179. package/src/shared/private-location.ts +89 -0
  180. package/src/shared/private-records.ts +48 -0
  181. package/src/shared/stat-cache.ts +48 -0
  182. package/src/sync/kit-source.ts +29 -10
  183. package/src/sync/kit.ts +2 -0
  184. package/src/sync/manifest.ts +2 -0
  185. package/src/sync/plan-ask-rules.ts +94 -0
  186. package/src/sync/plan-hooks.ts +2 -0
  187. package/src/sync/render.ts +3 -3
  188. package/src/sync/settings-hooks.ts +4 -1
  189. package/src/sync/sync-plan.ts +1 -0
  190. package/src/sync/sync.ts +24 -3
  191. package/src/task-signals/agent.ts +15 -0
  192. package/src/task-signals/claude-read.ts +89 -0
  193. package/src/task-signals/claude-signals.ts +112 -0
  194. package/src/task-signals/command.ts +71 -0
  195. package/src/task-signals/fixtures/session.jsonl +7 -0
  196. package/src/task-signals/matcher.ts +96 -0
  197. package/src/task-signals/record-store.ts +40 -0
  198. package/src/task-signals/record.ts +68 -0
  199. package/src/task-signals/text.ts +13 -0
  200. package/src/task-signals/types.ts +14 -0
  201. package/src/terminal/open.ts +13 -6
  202. package/src/terminal/text.ts +16 -4
  203. package/src/terminal/width.ts +2 -1
  204. package/src/version/skill-diff.ts +46 -0
  205. package/src/vocabulary/index.ts +44 -6
  206. package/src/waiting/command.ts +50 -0
  207. package/src/watch/appointments.ts +2 -4
  208. package/src/watch/approvals.ts +2 -2
  209. package/src/watch/asking-lanes.ts +40 -0
  210. package/src/watch/base-tip.ts +11 -0
  211. package/src/watch/command.ts +401 -90
  212. package/src/watch/failed-tests.ts +56 -10
  213. package/src/watch/failure-read.ts +18 -6
  214. package/src/watch/failures.ts +29 -5
  215. package/src/watch/feedback.ts +1 -1
  216. package/src/watch/gh-process.ts +9 -3
  217. package/src/watch/heartbeat-progress.ts +6 -18
  218. package/src/watch/json-guards.ts +0 -3
  219. package/src/watch/kit-update.ts +28 -9
  220. package/src/watch/lane-fill.ts +1 -1
  221. package/src/watch/lane-model.ts +1 -1
  222. package/src/watch/lane-name.ts +6 -0
  223. package/src/watch/lane-parallel.ts +1 -1
  224. package/src/watch/lane-progress.ts +58 -14
  225. package/src/watch/lane-report-file.ts +20 -5
  226. package/src/watch/lane-report.ts +37 -5
  227. package/src/watch/lane-rest.ts +28 -0
  228. package/src/watch/lane-sources.ts +31 -3
  229. package/src/watch/main-checkout.ts +34 -0
  230. package/src/watch/plan-badges-read.ts +13 -7
  231. package/src/watch/plan-badges.ts +9 -2
  232. package/src/watch/previews.ts +1 -1
  233. package/src/watch/pull-requests.ts +8 -3
  234. package/src/watch/reference-paths.ts +87 -0
  235. package/src/watch/running-subagents.ts +43 -0
  236. package/src/watch/skill-change.ts +124 -32
  237. package/src/watch/source-change.ts +48 -0
  238. package/src/watch/source-path.ts +1 -1
  239. package/src/watch/sources.ts +7 -4
  240. package/src/watch/stream-output.ts +14 -8
  241. package/src/watch/task-parallel-auto.ts +208 -0
  242. package/src/watch/task-parallel-data-paths.ts +46 -0
  243. package/src/watch/task-parallel-input.ts +108 -0
  244. package/src/watch/task-parallel-read.ts +151 -0
  245. package/src/watch/task-parallel-touched.ts +39 -0
  246. package/src/watch/task-parallel.ts +235 -0
  247. package/src/watch/task-signal-missing.ts +69 -0
  248. package/src/watch/task-signal-read.ts +29 -0
  249. package/src/watch/texts.ts +10 -0
  250. package/src/watch/update-available.ts +6 -3
  251. package/src/watch/waiting-line.ts +19 -0
  252. package/src/watch/watch-events.ts +36 -1
  253. package/src/watch/watcher-lock.ts +25 -29
  254. package/src/watch/workflow-runs.ts +2 -1
  255. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  256. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  257. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  258. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  259. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  260. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  261. package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
@@ -1,6 +1,7 @@
1
1
  import { CONTEXT_LEVEL, type ContextLevel, type ContextReading, contextLabel } from '../context'
2
2
  import type { LaneProgress, PhaseProgress } from '../plan-format'
3
3
  import { currentPhase, isPhaseOpen, phaseLabel } from '../plan-format'
4
+ import type { WaitingItem } from '../plan-format/waiting'
4
5
  import {
5
6
  BADGE_TONE,
6
7
  type BadgeTone,
@@ -8,6 +9,7 @@ import {
8
9
  bar,
9
10
  box,
10
11
  fit,
12
+ link,
11
13
  packGroups,
12
14
  padVisible,
13
15
  paint,
@@ -17,7 +19,6 @@ import {
17
19
  } from '../terminal/text'
18
20
  import { AGENT_STATUS, type AgentStatus, isAgentStatus } from '../terminal/types'
19
21
  import { textWidth } from '../terminal/width'
20
- import { PLAN_LANGUAGE } from '../vocabulary'
21
22
  import {
22
23
  NO_BADGES,
23
24
  type PullRequestBadges,
@@ -26,28 +27,55 @@ import {
26
27
  type WatchBadges,
27
28
  type WatchHealth,
28
29
  } from '../watch/plan-badges'
30
+ import type { UnplannedTask } from './active-tasks'
31
+ import { type AppointmentBlock, appointmentItems, appointmentKeys } from './appointment-rows'
29
32
  import { deeper, type Frame, joinFrames, nest, prefixed, rowFrame, TREE, textFrame } from './frame'
30
33
  import { LANE_STANDING, type LaneStanding, laneStanding, waitingNote } from './lane-standing'
31
34
  import { type LaneSummary, laneSummary } from './lane-summary'
32
- import type { LaneTickets } from './lane-tickets'
33
- import { COLUMN_SEPARATOR, countCell, countText, type LaneContext, MIN_TITLE_WIDTH, plain } from './layout-phase'
34
- import { summaryChildren } from './layout-summary'
35
+ import { type LaneTickets, withoutTicketKey } from './lane-tickets'
36
+ import {
37
+ COLUMN_SEPARATOR,
38
+ countCell,
39
+ countText,
40
+ type LaneContext,
41
+ MIN_TITLE_WIDTH,
42
+ nowLine,
43
+ plain,
44
+ } from './layout-phase'
45
+ import { foldedTaskLine, summaryChildren } from './layout-summary'
35
46
  import { chipKey, type LinkChip, planUrlOf } from './link-chips'
36
47
  import type { PhaseModel } from './phase-models'
37
48
  import { type PlannedPlan, phaseList, plannedPhaseCount } from './planned'
38
- import { CHECKS, type ChecksState, type PullRequest, pullRequestKey } from './pull-requests'
49
+ import { CHECKS, type ChecksState, failedTestKey, type PullRequest, pullRequestKey } from './pull-requests'
39
50
  import {
40
51
  type ClickTarget,
52
+ isBlockFolded,
41
53
  isLaneOpen,
42
54
  laneKey,
43
55
  PLANNED_KEY,
44
56
  type Selection,
45
57
  selectedKey,
58
+ TOP_BLOCK,
59
+ type TopBlockId,
46
60
  targetKeys,
47
61
  } from './selection'
62
+ import type { SentChip } from './sent-files'
48
63
  import { shownWithMore } from './shown-with-more'
49
- import { chevronOf, NO_AGENT_SYMBOL, NO_AGENT_WORD, statusSymbol, statusWord } from './symbols'
64
+ import { chevronOf, LESS_MARK, NO_AGENT_SYMBOL, statusSymbol, statusWord } from './symbols'
50
65
  import { BADGE_TEXTS, type BadgeTexts, checksText, laneMarkText, TEXTS } from './texts'
66
+ import {
67
+ APPOINTMENT_MORE_KEY,
68
+ ASK_BUTTON,
69
+ type BlockHit,
70
+ type BlockRow,
71
+ blockRows,
72
+ moreKeyOf,
73
+ SHOWN_BLOCK_ROWS,
74
+ WAITING_MARK,
75
+ WAITING_MORE_KEY,
76
+ waitingKeys,
77
+ waitingRows,
78
+ } from './waiting-click'
51
79
 
52
80
  /**
53
81
  * Where a lane stands outside its plan. `agent` is the terminal adapter's status, null when no agent is listed;
@@ -78,6 +106,8 @@ export type LaneView = {
78
106
  tickets?: LaneTickets
79
107
  /** The model each phase's implementing subagent last ran on, by `phaseModelKey`; absent without a record. */
80
108
  models?: Record<string, PhaseModel>
109
+ /** The files this lane's session sent to the user, shown as badges on the row of link chips. */
110
+ sentFiles?: SentChip[]
81
111
  }
82
112
 
83
113
  /** `watch` is the health of the watcher the badges come from, absent when it was not read. */
@@ -89,10 +119,26 @@ export type OverviewView = {
89
119
  watch?: WatchHealth
90
120
  /** True when the config marks a ticket source, so a missing watcher is said without a pull request too. */
91
121
  watchesTickets?: boolean
122
+ /** The open items of the board's section of what waits on the user, shown under the title. */
123
+ awaiting?: WaitingItem[]
124
+ /** The open appointments of the board, in a block of their own under the for-you block. */
125
+ appointments?: AppointmentBlock
126
+ /** What agents report doing that no lane takes, one `now:` line each under the title. */
127
+ unplaced?: UnplannedTask[]
92
128
  }
93
129
 
94
- /** A fixed header and footer around a body that scrolls; body lines and targets count from the body's top. */
95
- export type Screen = { header: string[]; body: Frame; footer: string[] }
130
+ /**
131
+ * The drawn screen: a fixed header and footer around a body that scrolls; body lines and targets count from the body's
132
+ * top. `waitingBlock` and `appointmentBlock` are what a click on the for-you and the appointment block is matched
133
+ * against, null while the block is not drawn.
134
+ */
135
+ export type Screen = {
136
+ header: string[]
137
+ body: Frame
138
+ footer: string[]
139
+ waitingBlock: BlockHit | null
140
+ appointmentBlock: BlockHit | null
141
+ }
96
142
 
97
143
  /**
98
144
  * Widths shared by every lane line: the name column, the folded pull request badges, and bar with context. They are
@@ -102,7 +148,33 @@ export type Screen = { header: string[]; body: Frame; footer: string[] }
102
148
  */
103
149
  type LaneColumns = { name: number; marks: number; right: number; phases: boolean; tally: number }
104
150
 
151
+ /**
152
+ * 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.
153
+ * `hit` is what a click on it is matched against, without the screen line the stack gives it.
154
+ */
155
+ type BlockLines = { open: boolean; lines: string[]; hit: Omit<BlockHit, 'firstLine'> }
156
+
157
+ /**
158
+ * 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
159
+ * a row, the key of its `+<n>` line, its points and their rows.
160
+ */
161
+ type TopBlock = {
162
+ name: string
163
+ bar: () => string
164
+ markPaint: (text: string) => string
165
+ /** The paint of the count in the head while the block has points: amber for what waits on the user. */
166
+ countPaint: (text: string) => string
167
+ moreKey: string
168
+ expanded: boolean
169
+ keys: readonly string[]
170
+ rows: BlockRow[]
171
+ }
172
+
173
+ /** The blocks on top of the herd one under the other: their lines below the title, and the hit of each drawn block. */
174
+ type BlockStack = { lines: string[]; hits: (BlockHit | null)[] }
175
+
105
176
  const BRANCH_MARK = '⎇'
177
+ const SENT_MARK = '📎'
106
178
  const SYMBOL_WIDTH = 2
107
179
  const NAME_WIDTH_MAX = 24
108
180
  const NON_BREAKING_SPACE = '\u00a0'
@@ -114,30 +186,26 @@ const CONTEXT_WIDTH = 5
114
186
  /** The context cell at the right of the session line: symbol, share and a cell of space. */
115
187
  const CONTEXT_CELL_WIDTH = CONTEXT_WIDTH + 3
116
188
  const CONTEXT_SYMBOL = '🥣'
117
- const PROGRESS_SYMBOL = '◐'
118
189
  const HEADER_MARK = '🐾'
119
190
  const COLUMN_RULE = '│'
120
191
  /** The number badge of a three-digit pull request (`↗ #` and the digits, a cell of padding either side); a longer one widens it. */
121
192
  const PULL_REQUEST_BADGE_WIDTH = 8
122
193
  /**
123
- * Under the lane line's status symbol: where the pull request row and its badge line start (D96), and where the trunk
194
+ * Under the lane line's status symbol: where the pull request row and its badge line start, and where the trunk
124
195
  * of an open lane hangs, not under the fold arrow.
125
196
  */
126
197
  const UNDER_STATUS = ' '
127
198
  const CHECKS_WIDTH = 10
128
199
  /** The most failed tests listed under a pull request; the rest is counted. */
129
200
  const SHOWN_FAILED_TESTS = 3
130
- const FAILED_TEST_INDENT = ' '
131
-
132
- /** Lower ranks need the coordinator sooner; a status an adapter adds later ranks after `working`. */
133
- const URGENCY: Record<AgentStatus, number> = {
134
- [AGENT_STATUS.blocked]: 0,
135
- [AGENT_STATUS.done]: 1,
136
- [AGENT_STATUS.idle]: 1,
137
- [AGENT_STATUS.working]: 2,
138
- }
139
- const UNKNOWN_URGENCY = 3
140
- const NO_AGENT_URGENCY = 4
201
+ const FAILED_MARK = '✗'
202
+ /** The tree under a check chip, one cell narrower than the lane's: the chip's children are short. */
203
+ const FAILED_TREE = { branch: '├ ', last: '└ ', pipe: '│ ', gap: ' ' } as const
204
+ /** An unfolded failed test's lines after its first hang under the name, past the mark. */
205
+ const FAILED_HANG = ' '
206
+ const TEST_PATH_LEVEL = ' > '
207
+ const TEST_PATH_SEPARATOR = ' › '
208
+ const PLACE_SEPARATOR = ' · '
141
209
 
142
210
  /** 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. */
143
211
  const STATUS_PAINT: Record<AgentStatus, (text: string) => string> = {
@@ -154,19 +222,9 @@ const CHECKS_PAINT: Record<ChecksState, (text: string) => string> = {
154
222
  [CHECKS.none]: paint.dim,
155
223
  }
156
224
 
157
- const urgencyOf = ({ status }: LaneView): number => {
158
- if (status.agent === null) return NO_AGENT_URGENCY
159
- return isAgentStatus(status.agent) ? URGENCY[status.agent] : UNKNOWN_URGENCY
160
- }
161
-
162
- /** Lanes waiting for input first, then those with a report to read, then working ones; stable inside a group. */
163
- export const sortByUrgency = (lanes: readonly LaneView[]): LaneView[] =>
164
- [...lanes].sort((left, right) => urgencyOf(left) - urgencyOf(right))
165
-
166
- const agentLabel = (agent: string | null): string => {
167
- if (agent === null) return paint.dim(statusWord(agent))
168
- return isAgentStatus(agent) ? STATUS_PAINT[agent](statusWord(agent)) : statusWord(agent)
169
- }
225
+ /** Only a blocked agent is named in the lane line, since there the user must act; the symbol says the rest. */
226
+ const agentLabel = (agent: string | null): string =>
227
+ agent === AGENT_STATUS.blocked ? STATUS_PAINT[AGENT_STATUS.blocked](statusWord(agent)) : ''
170
228
 
171
229
  /** The symbol of a status padded to `SYMBOL_WIDTH` cells, so the names after it start in one column. */
172
230
  const symbolCell = (agent: string | null): string => {
@@ -179,15 +237,14 @@ const symbolCell = (agent: string | null): string => {
179
237
 
180
238
  const aheadLabel = ({ branch, ahead }: LaneStatus): string => {
181
239
  if (ahead === null) return branch ? paint.dim('↑?') : ''
182
- return ahead > 0 ? paint.cyan(`↑${ahead}`) : ''
240
+ return ahead > 0 ? paint.teal(`↑${ahead}`) : ''
183
241
  }
184
242
 
243
+ const branchLabel = (status: LaneStatus): string =>
244
+ status.branch ? paint.dim(`${BRANCH_MARK} ${status.branch}`) : paint.dim(TEXTS.noWorktree)
245
+
185
246
  const gitLabel = (status: LaneStatus): string =>
186
- [
187
- status.branch ? paint.dim(`${BRANCH_MARK} ${status.branch}`) : paint.dim(TEXTS.noWorktree),
188
- aheadLabel(status),
189
- status.dirty ? paint.magenta(`±${status.dirty}`) : '',
190
- ]
247
+ [branchLabel(status), aheadLabel(status), status.dirty ? paint.magenta(`±${status.dirty}`) : '']
191
248
  .filter(Boolean)
192
249
  .join(' ')
193
250
 
@@ -229,18 +286,26 @@ export const rowWithCount = (
229
286
  const fitsBeside = (head: string, cells: BadgeCell[], width: number): boolean =>
230
287
  visibleLength(head) + packedWidth(cells) + COLUMN_SEPARATOR.length + MIN_TITLE_WIDTH <= width
231
288
 
289
+ /** The number of a pull request as a badge linked to it, padded to its column. */
290
+ const numberCell = (pullRequest: PullRequest): string =>
291
+ padVisible(badge(pullRequest.url, `↗ #${pullRequest.number}`), PULL_REQUEST_BADGE_WIDTH)
292
+
293
+ /** The column the check chip of a pull request row starts in, behind its number. */
294
+ const checksColumnOf = (pullRequest: PullRequest): number =>
295
+ visibleLength(numberCell(pullRequest)) + COLUMN_SEPARATOR.length
296
+
232
297
  /**
233
298
  * `⟦↗ #<number>⟧ ✓ checks ⟦🌐 preview⟧ ⟦💬 3 new⟧ title`: the number as a badge and the check state in fixed
234
299
  * columns, the watcher's badges after them and the title last, cut with `…` on its one line. When the badges leave no
235
- * room for a title, they go on lines of their own below, starting in the column of the lane's status symbol (D96).
300
+ * room for a title, they go on lines of their own below, starting in the column of the lane's status symbol.
236
301
  */
237
302
  const pullRequestRow = (pullRequest: PullRequest, context: LaneContext): Frame => {
238
- const number = padVisible(badge(pullRequest.url, `↗ #${pullRequest.number}`), PULL_REQUEST_BADGE_WIDTH)
303
+ const number = numberCell(pullRequest)
239
304
  const checksWords = checksText(pullRequest)
240
305
  const checksColumn =
241
306
  textWidth(checksWords) < CHECKS_WIDTH ? CHECKS_WIDTH : textWidth(checksWords) + COLUMN_SEPARATOR.length
242
307
  const checks = padVisible(CHECKS_PAINT[pullRequest.checks](checksWords), checksColumn)
243
- const head = `${UNDER_STATUS}${number}${COLUMN_SEPARATOR}${checks}`
308
+ const head = `${number}${COLUMN_SEPARATOR}${checks}`
244
309
  const prefix = pullRequest.draft ? TEXTS.draft : ''
245
310
  const dimPrefix = (line: string, index: number) =>
246
311
  index === 0 && prefix ? `${paint.dim(prefix)}${line.slice(prefix.length)}` : line
@@ -248,8 +313,8 @@ const pullRequestRow = (pullRequest: PullRequest, context: LaneContext): Frame =
248
313
  const key = pullRequestKey(pullRequest.number)
249
314
  if (cells.length === 0 || !fitsBeside(head, cells, context.width))
250
315
  return joinFrames([
251
- rowFrame(key, besideHead(head, `${prefix}${pullRequest.title}`, context.width, dimPrefix), [], context),
252
- packedFrame(cells, UNDER_STATUS, context.width),
316
+ rowFrame(key, [fit(`${head}${dimPrefix(`${prefix}${pullRequest.title}`, 0)}`, context.width)], [], context),
317
+ packedFrame(cells, '', context.width),
253
318
  ])
254
319
  const beside = `${head}${cells.map(({ cell }) => cell).join(CHIP_GAP)}${COLUMN_SEPARATOR}`
255
320
  const row = rowFrame(
@@ -261,22 +326,75 @@ const pullRequestRow = (pullRequest: PullRequest, context: LaneContext): Frame =
261
326
  return { ...row, targets: [...cellTargets(cells, 0, visibleLength(head), context.width), ...row.targets] }
262
327
  }
263
328
 
264
- /** The failed tests of a red pull request, at most `SHOWN_FAILED_TESTS` of them one per line, then `+<n>`; none otherwise. */
265
- const failedTestLines = (pullRequest: PullRequest, badges: PullRequestBadges | undefined, width: number): string[] => {
329
+ /** `✗ <place> · <path>` as plain text, each ` > ` of the test path as ` › `; without a place `✗ <path>`. */
330
+ const failedTestText = (name: string, place: string | null): string => {
331
+ const path = plain(name).replaceAll(TEST_PATH_LEVEL, TEST_PATH_SEPARATOR)
332
+ return place ? `${FAILED_MARK} ${plain(place)}${PLACE_SEPARATOR}${path}` : `${FAILED_MARK} ${path}`
333
+ }
334
+
335
+ /** The first line of a failed test painted: mark and place red, separator and test path dim. */
336
+ const paintFailedTest = (line: string, place: string | null): string => {
337
+ const head = place ? `${FAILED_MARK} ${plain(place)}` : FAILED_MARK
338
+ if (!line.startsWith(head)) return paint.dim(line)
339
+ const lead = place ? `${paint.red(FAILED_MARK)} ${paint.red(plain(place))}` : paint.red(FAILED_MARK)
340
+ const rest = line.slice(head.length)
341
+ const separator = place && rest.startsWith(PLACE_SEPARATOR) ? PLACE_SEPARATOR : ''
342
+ return `${lead}${separator ? paint.dim(separator) : ''}${paint.dim(rest.slice(separator.length))}`
343
+ }
344
+
345
+ /** One child of the check chip: its tree branch (`├`, `└` for the last) and what carries on beside it. */
346
+ type FailedBranch = { first: string; rest: string }
347
+
348
+ const failedBranch = (last: boolean): FailedBranch =>
349
+ last ? { first: FAILED_TREE.last, rest: FAILED_TREE.gap } : { first: FAILED_TREE.branch, rest: FAILED_TREE.pipe }
350
+
351
+ /**
352
+ * A failed test under the check chip (`column`): cut to one line, which a click unfolds over several, the name hanging
353
+ * under its first word, and a second click folds; a name that fits is no click target.
354
+ */
355
+ const failedTestFrame = (
356
+ test: { name: string; place: string | null; key: string },
357
+ branch: FailedBranch,
358
+ column: number,
359
+ context: LaneContext,
360
+ ): Frame => {
361
+ const indent = ' '.repeat(column)
362
+ const room = Math.max(context.width - column - textWidth(branch.first), 1)
363
+ const text = failedTestText(test.name, test.place)
364
+ const cut = textWidth(text) > room
365
+ const [first = '', ...rest] =
366
+ cut && context.state.expanded.has(test.key) ? wrap(text, Math.max(room - FAILED_HANG.length, 1)) : [fit(text, room)]
367
+ const lines = [
368
+ `${indent}${paint.dim(branch.first)}${paintFailedTest(first, test.place)}`,
369
+ ...rest.map((line) => `${indent}${paint.dim(branch.rest)}${FAILED_HANG}${paint.dim(line)}`),
370
+ ]
371
+ return cut ? rowFrame(test.key, lines, [], context) : textFrame(lines)
372
+ }
373
+
374
+ /**
375
+ * The failed tests of a red pull request as children of its check chip, at most `SHOWN_FAILED_TESTS`, then `+<n>`;
376
+ * the package of each first when the watcher read it from the log. None for a pull request that is not red.
377
+ */
378
+ const failedTestsFrame = (pullRequest: PullRequest, context: LaneContext): Frame => {
379
+ const badges = context.badges[String(pullRequest.number)]
266
380
  const tests = pullRequest.checks === CHECKS.failed ? (badges?.failedTests ?? []) : []
267
381
  const { shown, more } = shownWithMore(tests, SHOWN_FAILED_TESTS)
268
- const lines = shown.map((name) => `${FAILED_TEST_INDENT}${paint.red(`✗ ${plain(name)}`)}`)
269
- return [...lines, ...(more > 0 ? [`${FAILED_TEST_INDENT}${paint.dim(`+${more}`)}`] : [])].map((line) =>
270
- fit(line, width),
382
+ const column = checksColumnOf(pullRequest)
383
+ const frames = shown.map((name, index) =>
384
+ failedTestFrame(
385
+ { name, place: badges?.failedTestPlaces?.[name] ?? null, key: failedTestKey(pullRequest.number, index) },
386
+ failedBranch(more === 0 && index === shown.length - 1),
387
+ column,
388
+ context,
389
+ ),
271
390
  )
391
+ const moreLine = `${' '.repeat(column)}${paint.dim(`${FAILED_TREE.last}+${more}`)}`
392
+ return joinFrames([...frames, ...(more > 0 ? [textFrame([fit(moreLine, context.width)])] : [])])
272
393
  }
273
394
 
274
395
  /** The pull request row, then the tests its red checks failed, which only the watcher knows. */
275
396
  const pullRequestFrame = (pullRequest: PullRequest, context: LaneContext): Frame =>
276
- joinFrames([
277
- pullRequestRow(pullRequest, context),
278
- textFrame(failedTestLines(pullRequest, context.badges[String(pullRequest.number)], context.width)),
279
- ])
397
+ joinFrames([pullRequestRow(pullRequest, context), failedTestsFrame(pullRequest, context)])
280
398
 
281
399
  const CONTEXT_PAINT: Record<ContextLevel, (text: string) => string> = {
282
400
  [CONTEXT_LEVEL.ok]: paint.dim,
@@ -288,17 +406,24 @@ const CONTEXT_PAINT: Record<ContextLevel, (text: string) => string> = {
288
406
  const contextValue = (context: ContextReading | null | undefined): string =>
289
407
  context ? `${CONTEXT_SYMBOL}${CONTEXT_PAINT[context.level](contextLabel(context.share).padStart(CONTEXT_WIDTH))}` : ''
290
408
 
291
- /** The context value with a rule before the bar that follows it on a folded lane line; empty without a reading. */
292
- const contextCell = (context: ContextReading | null | undefined): string =>
293
- context ? `${contextValue(context)} ${paint.dim(COLUMN_RULE)} ` : ''
409
+ /**
410
+ * The context value with a rule before the bar that follows it on a folded lane line, from `warn` on; below, blanks
411
+ * as wide, so nothing moves when it shows. Empty without a reading.
412
+ */
413
+ const contextCell = (context: ContextReading | null | undefined): string => {
414
+ if (!context) return ''
415
+ const cell = `${contextValue(context)} ${paint.dim(COLUMN_RULE)} `
416
+ return context.level === CONTEXT_LEVEL.ok ? ' '.repeat(visibleLength(cell)) : cell
417
+ }
294
418
 
295
419
  /**
296
420
  * Bar, checkbox count and phase count over the lane's plans in a fixed-width column, so every bar on screen starts in
297
- * the same column: `██░░ ◐ 50/213 · 4/14 ph`. The phase count stands right-aligned in `tallyWidth` cells behind the
298
- * `·`, the widest of the lanes on screen, so a shorter one moves neither bar nor `·`; at 0 it is left out.
421
+ * the same column: `██░░ 50/213 · 4/14 ph`, folded or open. The phase count stands right-aligned in `tallyWidth`
422
+ * cells behind the `·`, the widest of the lanes on screen, so a shorter one moves neither bar nor `·`; at 0 it is
423
+ * left out.
299
424
  */
300
425
  const progressColumn = ({ done, total, phasesDone, phases }: LaneSummary['tally'], tallyWidth: number): string => {
301
- const counts = `${bar(done, total)} ${countCell(PROGRESS_SYMBOL, countText(done, total))}`
426
+ const counts = `${bar(done, total)} ${countCell('', countText(done, total))}`
302
427
  if (tallyWidth === 0) return counts
303
428
  const tally = TEXTS.phaseTally(phasesDone, phases)
304
429
  const padding = ' '.repeat(Math.max(tallyWidth - visibleLength(tally), 0))
@@ -377,23 +502,29 @@ const ticketCells = (tickets: LaneTickets | null): BadgeCell[] => {
377
502
  return [...tickets.shown.map(ticketCell), ...more]
378
503
  }
379
504
 
505
+ /** 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. */
506
+ const sentCell = ({ url, label }: SentChip): BadgeCell => ({
507
+ cell: badge(url, `${SENT_MARK} ${plain(label)}`),
508
+ key: null,
509
+ })
510
+
380
511
  const chipCellOf = (chip: LinkChip): BadgeCell => ({ cell: chipCell(chip), key: chipKey(chip.url) })
381
512
 
382
- /** The chips of the lane's plans behind a 📎, each once, the plan chips first and the tickets beside them. */
383
- const chipFrame = (chips: LinkChip[], tickets: LaneTickets | null, width: number): Frame => {
513
+ /** The chips of the lane's plans, each once, the plan chips first, the tickets beside them, the sent files last. */
514
+ const chipCells = (chips: LinkChip[], tickets: LaneTickets | null, sent: readonly SentChip[] = []): BadgeCell[] => {
384
515
  const isPlanChip = (chip: LinkChip) => planUrlOf(chipKey(chip.url)) !== null
385
516
  const unique = chips.filter((chip, index) => chips.findIndex(({ url }) => url === chip.url) === index)
386
- const cells = [
517
+ return [
387
518
  ...unique.filter(isPlanChip).map(chipCellOf),
388
519
  ...ticketCells(tickets),
389
520
  ...unique.filter((chip) => !isPlanChip(chip)).map(chipCellOf),
521
+ ...sent.map(sentCell),
390
522
  ]
391
- return packedFrame(cells, '', width)
392
523
  }
393
524
 
394
525
  /**
395
526
  * `🌐 preview` opening the preview, `💬 <n> new` in amber opening the pull request, and `✋ changes requested` in dark
396
- * red opening it too, each only when there is one (D96).
527
+ * red opening it too, each only when there is one.
397
528
  */
398
529
  const badgeCells = (
399
530
  pullRequest: PullRequest,
@@ -412,19 +543,18 @@ const badgeCells = (
412
543
  ]
413
544
  }
414
545
 
415
- /** The columns lane lines share, so names, statuses and bars of all of them line up. */
416
- const STATUS_WIDTH = Math.max(...[...Object.values(AGENT_STATUS), NO_AGENT_WORD].map(textWidth))
417
-
418
546
  /**
419
- * The phase the lane line names: the open phase moved last, else the lane's current phase while it is open; never one
420
- * ticked to the end, whose state DONE says (`not merged`). Null when there is neither.
547
+ * The phase the lane line names: the one its report or branch signals, else the open phase moved last, else the
548
+ * lane's current phase while it is open; never one ticked to the end, whose state the `✓` line says. Null when there
549
+ * is none.
421
550
  */
422
551
  const headPhase = (view: LaneView): PhaseProgress | null => {
552
+ const { signalled, latest } = summaryOf(view)
423
553
  const current = currentPhase(view.progress)
424
- return summaryOf(view).latest ?? (current && isPhaseOpen(current) ? current : null)
554
+ return signalled ?? latest ?? (current && isPhaseOpen(current) ? current : null)
425
555
  }
426
556
 
427
- /** `P9 Freigabe 0.x` for the phase the lane line names; empty when there is none. */
557
+ /** `P9 Release 1.0` for the phase the lane line names; empty when there is none. */
428
558
  const nextPhaseLabel = (view: LaneView): string => {
429
559
  const phase = headPhase(view)
430
560
  return phase ? `${phaseLabel(phase.id)}${phase.title ? ` ${plain(phase.title)}` : ''}` : ''
@@ -457,17 +587,28 @@ const ticketMark = (tickets: LaneTickets | undefined): string[] => {
457
587
  const laneMarks = ({ pullRequests, badges, tickets }: LaneView): string =>
458
588
  [...pullRequests.map((pullRequest) => pullRequestMark(pullRequest, badges ?? {})), ...ticketMark(tickets)].join(' ')
459
589
 
460
- /** What follows the status on a lane line, open or folded: the phase it works on; the branch is on the session line. */
461
- const laneDetail = (view: LaneView): string => {
590
+ /**
591
+ * `P3 Search API +1 · 12/20` for a folded lane: the phase it names, `+n` for the other active phases, the phase's
592
+ * ticked boxes. An open lane names it on a phase branch, so its own line does not.
593
+ */
594
+ const foldedPhaseLabel = (view: LaneView): string => {
595
+ const phase = headPhase(view)
596
+ if (!phase) return ''
597
+ const more = summaryOf(view).active.filter((active) => active.phase.id !== phase.id).length
598
+ return `${nextPhaseLabel(view)}${more > 0 ? ` +${more}` : ''} · ${phase.done}/${phase.total}`
599
+ }
600
+
601
+ /** 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. */
602
+ const laneDetail = (view: LaneView, open: boolean): string => {
462
603
  const markText = laneMarkText(view)
463
604
  const marker = markText ? paint.yellow(markText) : ''
464
- return [marker, nextPhaseLabel(view)].filter(Boolean).join(' ')
605
+ return [agentLabel(view.status.agent), marker, open ? '' : foldedPhaseLabel(view)].filter(Boolean).join(' ')
465
606
  }
466
607
 
467
608
  /**
468
609
  * The lane line, `head` and `right` (bar and count) at the edges. The detail (next phase, branch, pull
469
610
  * requests) follows `head` when at least `MIN_BESIDE_WIDTH` characters are left for it; in a narrower pane
470
- * it moves to a line of its own below, behind `indent` (three cells, the trunk of an open lane) and cut to the full width.
611
+ * it moves to a line of its own below, behind `indent` (`detailIndent`: up to the name column) and cut to the full width.
471
612
  */
472
613
  export const withDetail = (
473
614
  head: string,
@@ -484,10 +625,9 @@ export const withDetail = (
484
625
 
485
626
  /** The summary the snapshot read for the lane, or the one its own steps give. */
486
627
  const summaryOf = (view: LaneView): LaneSummary =>
487
- view.summary ??
488
- laneSummary({ progress: view.progress, phases: {}, texts: {}, activity: {}, language: PLAN_LANGUAGE.en }, 0)
628
+ view.summary ?? laneSummary({ progress: view.progress, phases: {}, activity: {} }, 0)
489
629
 
490
- /** The bar over the lane's plans, empty while they have no checkbox. */
630
+ /** The bar and checkbox count over the lane's plans; empty while they have no checkbox. */
491
631
  const laneProgressColumn = (view: LaneView, tallyWidth: number): string => {
492
632
  const { tally } = summaryOf(view)
493
633
  return tally.total > 0 ? progressColumn(tally, tallyWidth) : ''
@@ -502,7 +642,7 @@ const laneRight = (view: LaneView, tallyWidth: number): string => {
502
642
  /** The cells of the phase count column, 0 when the pane leaves it out. */
503
643
  const tallyWidthOf = ({ phases, tally }: LaneColumns): number => (phases ? tally : 0)
504
644
 
505
- /** A folded lane's marks in the column of `columns.marks`, right before context and bar (D96). */
645
+ /** A folded lane's marks in the column of `columns.marks`, right before context and bar. */
506
646
  const markedRight = (view: LaneView, columns: LaneColumns): string => {
507
647
  const right = laneRight(view, tallyWidthOf(columns))
508
648
  const marks = laneMarks(view)
@@ -512,39 +652,96 @@ const markedRight = (view: LaneView, columns: LaneColumns): string => {
512
652
  return `${padVisible(marks, columns.marks)}${COLUMN_SEPARATOR}${pad}${right}`
513
653
  }
514
654
 
655
+ /**
656
+ * 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
657
+ * starts where its name does, as the branches of an open lane do; an open lane keeps its trunk in front.
658
+ */
659
+ const detailIndent = (open: boolean, nameColumn: number): string => {
660
+ if (!open) return ' '.repeat(nameColumn)
661
+ const trunk = `${UNDER_STATUS}${TREE.pipe}`
662
+ return `${paint.dim(trunk)}${' '.repeat(Math.max(nameColumn - visibleLength(trunk), 0))}`
663
+ }
664
+
665
+ /** The task row below a folded lane's phase row, under the lane's name; none while the phase has no task on it. */
666
+ const foldedTaskRow = (view: LaneView, indent: string, width: number): string[] => {
667
+ const phase = headPhase(view)
668
+ const { active: running, subagentTasks } = summaryOf(view)
669
+ const active = running.find((each) => each.phase.id === phase?.id)
670
+ const line = active && foldedTaskLine(active, subagentTasks, TEXTS.herd, indent, width)
671
+ return line ? [line] : []
672
+ }
673
+
515
674
  const laneLines = (view: LaneView, open: boolean, width: number, columns: LaneColumns): string[] => {
516
675
  const { progress, status } = view
517
- const cells = [
518
- padVisible(paint.bold(fit(progress.lane.name, columns.name)), columns.name),
519
- padVisible(agentLabel(status.agent), STATUS_WIDTH),
520
- ].join(COLUMN_SEPARATOR)
676
+ const cells = padVisible(paint.bold(fit(progress.lane.name, columns.name)), columns.name)
521
677
  const head = `${paint.dim(chevronOf(open))} ${symbolCell(status.agent)} ${cells}`
522
678
  const right = open ? laneProgressColumn(view, tallyWidthOf(columns)) : markedRight(view, columns)
523
- return withDetail(
524
- head,
525
- laneDetail(view),
526
- right,
527
- width,
528
- open ? `${UNDER_STATUS}${paint.dim(TREE.pipe)}` : CONTENT_INDENT,
529
- )
679
+ const nameColumn = visibleLength(head) - visibleLength(cells)
680
+ const lines = withDetail(head, laneDetail(view, open), right, width, detailIndent(open, nameColumn))
681
+ return open ? lines : [...lines, ...foldedTaskRow(view, ' '.repeat(nameColumn), width)]
682
+ }
683
+
684
+ /** The parts of a session line: the session's title (the branch without one) at the left, the branch and the context value. */
685
+ const sessionParts = (view: LaneView): { left: string; git: string; branch: string; context: string } => {
686
+ const git = gitLabel(view.status)
687
+ const branch = branchLabel(view.status)
688
+ return { left: view.sessionTitle ? plain(view.sessionTitle) : git, git, branch, context: contextValue(view.context) }
530
689
  }
531
690
 
532
691
  /**
533
- * The SESSION branch of an open lane: the session's title at the left; the branch at the end of the line, before the
692
+ * The session branch of an open lane: the session's title at the left; the branch at the end of the line, before the
534
693
  * context at the right. The title keeps its room: the branch gives way when both do not fit, and only then is the
535
694
  * title cut. Without a title the branch stands at the left.
536
695
  */
537
696
  const sessionLine = (view: LaneView, width: number): string => {
538
- const git = gitLabel(view.status)
539
- const context = contextValue(view.context)
697
+ const { left, git, branch, context } = sessionParts(view)
540
698
  if (!view.sessionTitle) return spread(fit(git, Math.max(width - CONTEXT_CELL_WIDTH, 1)), context, width)
541
- const left = plain(view.sessionTitle)
542
699
  const room = width - (context ? CONTEXT_CELL_WIDTH : 0)
543
- const withGit = visibleLength(left) + COLUMN_SEPARATOR.length + visibleLength(git) <= room
544
- const right = [withGit ? git : '', context].filter(Boolean).join(COLUMN_SEPARATOR)
700
+ const fitting = [git, branch].find(
701
+ (label) => visibleLength(left) + COLUMN_SEPARATOR.length + visibleLength(label) <= room,
702
+ )
703
+ const right = [fitting ?? '', context].filter(Boolean).join(COLUMN_SEPARATOR)
545
704
  return spread(fit(left, Math.max(room, 1)), right, width)
546
705
  }
547
706
 
707
+ /**
708
+ * The session line with the lane's one link chip on it, so the lane needs no row of chips: title, chip, branch and
709
+ * context. When the line is short the ahead and dirty counts of the branch go first, then the title is cut with `…`;
710
+ * without a title the branch stands at the left and the chip at the right. Null when even the cut title would be
711
+ * narrower than `MIN_TITLE_WIDTH`, and the chip keeps its row of chips.
712
+ */
713
+ const sessionWithChip = (view: LaneView, chip: BadgeCell, width: number): Frame | null => {
714
+ const { left, git, branch, context } = sessionParts(view)
715
+ const tails = view.sessionTitle
716
+ ? [
717
+ [chip.cell, git, context],
718
+ [chip.cell, branch, context],
719
+ ]
720
+ : [[chip.cell, context]]
721
+ const candidates = tails.map((tail) => tail.filter(Boolean).join(COLUMN_SEPARATOR))
722
+ const roomFor = (candidate: string) => width - COLUMN_SEPARATOR.length - visibleLength(candidate)
723
+ const narrowest = candidates[candidates.length - 1] ?? ''
724
+ const cuts = Boolean(view.sessionTitle) && roomFor(narrowest) >= MIN_TITLE_WIDTH
725
+ const right = candidates.find((candidate) => visibleLength(left) <= roomFor(candidate)) ?? (cuts ? narrowest : null)
726
+ if (right === null) return null
727
+ const from = width - visibleLength(right)
728
+ const targets = chip.key === null ? [] : [{ line: 0, from, to: from + visibleLength(chip.cell), key: chip.key }]
729
+ return { lines: [spread(left, right, width)], cursorLine: -1, targets }
730
+ }
731
+
732
+ /** Session and link chips of an open lane: a lone chip on the session line when it fits there, else a row of chips. */
733
+ const sessionAndChips = (
734
+ view: LaneView,
735
+ tickets: LaneTickets | null,
736
+ width: number,
737
+ ): { session: Frame; chips: Frame } => {
738
+ const cells = chipCells(laneChips(view), tickets, view.sentFiles)
739
+ const [lone] = cells
740
+ const inline = cells.length === 1 && lone ? sessionWithChip(view, lone, width) : null
741
+ if (inline) return { session: inline, chips: textFrame([]) }
742
+ return { session: textFrame([sessionLine(view, width)]), chips: packedFrame(cells, '', width) }
743
+ }
744
+
548
745
  const underStatus = (frame: Frame): Frame =>
549
746
  prefixed(frame, { first: UNDER_STATUS, rest: UNDER_STATUS, style: (text) => text })
550
747
 
@@ -552,6 +749,16 @@ const underStatus = (frame: Frame): Frame =>
552
749
  const laneChips = ({ progress, chips }: LaneView): LinkChip[] =>
553
750
  progress.steps.flatMap(({ step }) => (step.planHref ? (chips[step.planHref] ?? []) : []))
554
751
 
752
+ /** The lane's pull requests one under the other, each without the lane ticket its title starts with. */
753
+ const pullRequestsFrame = (view: LaneView, context: LaneContext): Frame => {
754
+ const ids = (context.tickets?.shown ?? []).map(({ id }) => id)
755
+ return joinFrames(
756
+ view.pullRequests.map((pullRequest) =>
757
+ pullRequestFrame({ ...pullRequest, title: withoutTicketKey(pullRequest.title, ids) }, context),
758
+ ),
759
+ )
760
+ }
761
+
555
762
  /** The lane line is a row of its own; an open lane continues with its pull requests and plans. */
556
763
  const laneBlock = (
557
764
  view: LaneView,
@@ -574,17 +781,13 @@ const laneBlock = (
574
781
  const row = rowFrame(laneKey(view.progress.lane.name), laneLines(view, open, width, columns), [], context)
575
782
  if (!open) return row
576
783
  const inner = deeper({ ...context, width: Math.max(width - UNDER_STATUS.length, 1) })
577
- const head = {
578
- session: (room: number) => sessionLine(view, room),
579
- chips: (room: number) => chipFrame(laneChips(view), inner.tickets, room),
580
- }
784
+ const head = { ...sessionAndChips(view, inner.tickets, inner.width), pullRequests: pullRequestsFrame(view, inner) }
581
785
  const [session, ...sections] = summaryChildren(inner, summaryOf(view), head)
582
786
  const alone = sections.every((section) => section.lines.length === 0)
583
787
  const sessionLead = alone ? { first: TREE.last, rest: TREE.gap } : { first: TREE.branch, rest: TREE.pipe }
584
788
  return joinFrames([
585
789
  row,
586
790
  underStatus(prefixed(session, { ...sessionLead, style: paint.dim })),
587
- ...view.pullRequests.map((pullRequest) => pullRequestFrame(pullRequest, context)),
588
791
  underStatus(nest(sections)),
589
792
  ])
590
793
  }
@@ -647,9 +850,153 @@ const plannedRows = (planned: PlannedPlan[], width: number, indent: string): str
647
850
 
648
851
  const rule = (width: number): string => paint.dim(RULE.repeat(width))
649
852
 
650
- /** A dimmed rule before every lane block but the first, folded or open: it shows where one lane ends. */
651
- const separate = (blocks: Frame[], width: number): Frame[] =>
652
- blocks.flatMap((frame, index) => (index === 0 ? [frame] : [textFrame([rule(width)]), frame]))
853
+ /** The amber bar that starts every line of the for-you block: the one signal that this waits on the user. */
854
+ const waitingBar = (): string => STATUS_PAINT[AGENT_STATUS.blocked](WAITING_MARK)
855
+
856
+ /** The grey bar of the appointment block: it waits on nobody's answer. */
857
+ const appointmentBar = (): string => paint.dim(WAITING_MARK)
858
+
859
+ /**
860
+ * The head of an open block, `▾ <name> · <n> ──`, filled with the rule to the width, so it is the rule above the block.
861
+ * The label and the count wear the normal colour (the count in `countPaint`), only the rule is dim.
862
+ */
863
+ const blockHead = (name: string, count: number, width: number, countPaint: (text: string) => string): string => {
864
+ const lead = `${chevronOf(true)} ${name} ${ITEM_SEPARATOR} `
865
+ const used = textWidth(`${lead}${count} `)
866
+ return `${lead}${countPaint(String(count))} ${paint.dim(RULE.repeat(Math.max(width - used, 0)))}`
867
+ }
868
+
869
+ const markedLine = (line: string, row: BlockRow, markPaint: (text: string) => string): string =>
870
+ line.startsWith(row.mark) ? `${markPaint(row.mark)}${line.slice(row.mark.length)}` : line
871
+
872
+ /** The text from `from` on dimmed, without the blanks that pad it to the right edge. */
873
+ const dimmedRest = (text: string, from: number): string => {
874
+ const end = text.trimEnd().length
875
+ return from >= end ? text : `${text.slice(0, from)}${paint.dim(text.slice(from, end))}${text.slice(end)}`
876
+ }
877
+
878
+ /**
879
+ * One row as lines: the bar on each, the mark painted on the first line only, the short title in the normal colour
880
+ * and the rest of an expanded row dim, linked when the row has an address; the `[ask]` button at the end of a
881
+ * question's first line wears the mark's paint.
882
+ */
883
+ const blockRowLines = ({ bar, markPaint }: TopBlock, row: BlockRow): string[] =>
884
+ row.lines.map((line, index) => {
885
+ const withButton = index === 0 && row.askColumn !== null
886
+ const styled = dimmedRest(line, row.dimFrom[index] ?? line.length)
887
+ const marked = index === 0 ? markedLine(styled, row, markPaint) : styled
888
+ const text = withButton ? `${marked}${markPaint(ASK_BUTTON)}` : marked
889
+ return `${bar()} ${row.url ? link(row.url, text) : text}`
890
+ })
891
+
892
+ /** A folded block: the one line `▸ <name> · <n>` and the first title as a preview; a click matches no row and no `+<n>` line. */
893
+ const foldedBlockLines = ({ name, keys, rows }: TopBlock, width: number): BlockLines => {
894
+ const preview = rows[0]?.preview
895
+ const head = `${chevronOf(false)} ${name} ${ITEM_SEPARATOR} ${keys.length}`
896
+ return {
897
+ open: false,
898
+ lines: [paint.dim(fit(preview === undefined ? head : `${head} ${preview}`, width))],
899
+ hit: { rows: [], moreKey: null, keys },
900
+ }
901
+ }
902
+
903
+ /**
904
+ * An open block on top of the herd: the head `▾ <name> · <n> ──`, one barred line per row (the first five, all while the
905
+ * 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
906
+ * one line; once expanded it wraps over several. The rule below is the stack's, not the block's.
907
+ */
908
+ const openBlockLines = (block: TopBlock, width: number): BlockLines => {
909
+ const count = block.keys.length
910
+ const more = Math.max(count - SHOWN_BLOCK_ROWS, 0)
911
+ const moreLine = block.expanded ? `${LESS_MARK} ${more}` : `+${more}`
912
+ return {
913
+ open: true,
914
+ lines: [
915
+ blockHead(block.name, count, width, count > 0 ? block.countPaint : (text) => text),
916
+ ...block.rows.flatMap((row) => blockRowLines(block, row)),
917
+ ...(more > 0 ? [`${block.bar()} ${moreLine}`] : []),
918
+ ],
919
+ hit: { rows: block.rows, moreKey: moreKeyOf(count, block.moreKey), keys: block.keys },
920
+ }
921
+ }
922
+
923
+ const blockLinesOf = (block: TopBlock, state: Selection, id: TopBlockId, width: number): BlockLines =>
924
+ isBlockFolded(state, id, block.keys) ? foldedBlockLines(block, width) : openBlockLines(block, width)
925
+
926
+ /**
927
+ * What waits on the user's answer, on top of the herd until it is closed: `▌ 📎 name` for a sent file or an
928
+ * Artifact, `▌ ? question` for a question of the board or of a session's dialog, in the amber of a lane that waits.
929
+ * One line while the user folded it.
930
+ */
931
+ const waitingLines = (waiting: readonly WaitingItem[], state: Selection, width: number): BlockLines => {
932
+ const keys = waitingKeys(waiting)
933
+ const block: TopBlock = {
934
+ name: TEXTS.waitingHeading,
935
+ bar: waitingBar,
936
+ markPaint: STATUS_PAINT[AGENT_STATUS.blocked],
937
+ countPaint: STATUS_PAINT[AGENT_STATUS.blocked],
938
+ moreKey: WAITING_MORE_KEY,
939
+ expanded: state.expanded.has(WAITING_MORE_KEY),
940
+ keys,
941
+ rows: waitingRows(waiting, state.expanded, width),
942
+ }
943
+ return blockLinesOf(block, state, TOP_BLOCK.waiting, width)
944
+ }
945
+
946
+ /** 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. */
947
+ const appointmentLines = ({ rows }: AppointmentBlock, state: Selection, width: number): BlockLines => {
948
+ const keys = appointmentKeys(rows)
949
+ const block: TopBlock = {
950
+ name: TEXTS.appointmentHeading,
951
+ bar: appointmentBar,
952
+ markPaint: paint.dim,
953
+ countPaint: (text) => text,
954
+ moreKey: APPOINTMENT_MORE_KEY,
955
+ expanded: state.expanded.has(APPOINTMENT_MORE_KEY),
956
+ keys,
957
+ rows: blockRows(appointmentItems(rows), state.expanded, width, APPOINTMENT_MORE_KEY),
958
+ }
959
+ return blockLinesOf(block, state, TOP_BLOCK.appointments, width)
960
+ }
961
+
962
+ const appointmentLinesOf = (
963
+ appointments: AppointmentBlock | undefined,
964
+ state: Selection,
965
+ width: number,
966
+ ): BlockLines | null =>
967
+ appointments && appointments.rows.length > 0 ? appointmentLines(appointments, state, width) : null
968
+
969
+ /**
970
+ * The blocks one under the other, `offset` lines below the top of the screen, with one rule between neighbours: the
971
+ * head of an open block is that rule, a folded block gets a plain one above it unless a folded block stands right
972
+ * above (folded blocks share the rule below them), and a plain rule closes the stack. An
973
+ * absent block (null) takes no line and has no hit.
974
+ */
975
+ const stackBlocks = (slots: readonly (BlockLines | null)[], offset: number, width: number): BlockStack => {
976
+ const lines: string[] = []
977
+ const hits: (BlockHit | null)[] = []
978
+ let previousFolded = false
979
+ for (const slot of slots) {
980
+ if (slot && !slot.open && !previousFolded) lines.push(rule(width))
981
+ hits.push(slot && { ...slot.hit, firstLine: offset + lines.length + 1 })
982
+ if (slot) {
983
+ lines.push(...slot.lines)
984
+ previousFolded = !slot.open
985
+ }
986
+ }
987
+ return { lines: lines.length > 0 ? [...lines, rule(width)] : lines, hits }
988
+ }
989
+
990
+ /**
991
+ * A dimmed rule before every section of the body but the first, which shows where one ends; none between two
992
+ * one-line lane blocks (the first `laneCount` sections), so folded lanes stand together as a list.
993
+ */
994
+ const separate = (sections: Frame[], width: number, laneCount: number): Frame[] =>
995
+ sections.flatMap((frame, index) => {
996
+ if (index === 0) return [frame]
997
+ const listed = index < laneCount && frame.lines.length === 1 && sections[index - 1]?.lines.length === 1
998
+ return listed ? [frame] : [textFrame([rule(width)]), frame]
999
+ })
653
1000
 
654
1001
  /**
655
1002
  * Why badges may be missing, said beside the clock: no watcher runs, or its state is stale. Nothing while it is live,
@@ -679,7 +1026,7 @@ const laneColumnsOf = (lanes: LaneView[], width: number): LaneColumns => {
679
1026
  )
680
1027
  const rightOf = (tallyWidth: number) =>
681
1028
  Math.max(0, ...lanes.map((lane) => visibleLength(laneRight(lane, tallyWidth))))
682
- const head = LANE_HEAD_CELLS + name + COLUMN_SEPARATOR.length + STATUS_WIDTH + COLUMN_SEPARATOR.length
1029
+ const head = LANE_HEAD_CELLS + name + COLUMN_SEPARATOR.length
683
1030
  const beside = MIN_BESIDE_WIDTH + COLUMN_SEPARATOR.length
684
1031
  const phases = head + beside + (marks > 0 ? marks + COLUMN_SEPARATOR.length : 0) + rightOf(tally) <= width
685
1032
  return { name, marks, right: rightOf(phases ? tally : 0), phases, tally }
@@ -698,11 +1045,11 @@ const partitionLanes = (lanes: LaneView[]): Record<'active' | 'waiting' | 'noAge
698
1045
  /**
699
1046
  * Title and clock on top, the key hint at the bottom (with the legend after `?`), and in between one
700
1047
  * block per lane: the lane line with its total bar, and for an open lane the phase rows. Folded lanes are
701
- * one line each and every lane block after the first is preceded by a dimmed rule. Lanes that wait and
1048
+ * one line each, and a dimmed rule stands between lane blocks unless both are one line. Lanes that wait and
702
1049
  * lanes with a worktree but no agent each share a closing block; finished lanes are not drawn.
703
1050
  */
704
1051
  export const renderScreen = (
705
- { title, time, lanes, planned, watch, watchesTickets }: OverviewView,
1052
+ { title, time, lanes, planned, watch, watchesTickets, awaiting = [], appointments, unplaced = [] }: OverviewView,
706
1053
  state: Selection,
707
1054
  width: number,
708
1055
  ): Screen => {
@@ -713,13 +1060,24 @@ export const renderScreen = (
713
1060
  const blocks = active.map((lane) => laneBlock(lane, state, width, columns, badgeTexts))
714
1061
  const notice = watchNotice({ lanes: active, watch, watchesTickets }, badgeTexts)
715
1062
  const clock = notice ? `${paint.yellow(notice)}${COLUMN_SEPARATOR}${paint.dim(time)}` : paint.dim(time)
1063
+ const titleLine = spread(`${HEADER_MARK} ${paint.bold(title)}`, clock, width)
1064
+ const forYou = awaiting.length > 0 ? waitingLines(awaiting, state, width) : null
1065
+ const dated = appointmentLinesOf(appointments, state, width)
1066
+ const nowLines = unplaced.map((task) => nowLine(task, width))
1067
+ const above = nowLines.length > 0 ? [titleLine, rule(width), ...nowLines] : [titleLine]
1068
+ const stack = stackBlocks([forYou, dated], above.length, width)
1069
+ const endsWithRule = stack.lines.length > 0 || nowLines.length === 0
1070
+ const sections = [
1071
+ ...blocks,
1072
+ ...(parked.length > 0 ? [textFrame(parked)] : []),
1073
+ ...(planned.length > 0 ? [plannedFrame(planned, state, width)] : []),
1074
+ ]
1075
+ const lead = !endsWithRule && blocks.length === 0 && sections.length > 0 ? [textFrame([rule(width)])] : []
716
1076
  return {
717
- header: [spread(`${HEADER_MARK} ${paint.bold(title)}`, clock, width), rule(width)],
718
- body: joinFrames([
719
- ...separate(blocks, width),
720
- ...(parked.length > 0 ? [textFrame([rule(width), ...parked])] : []),
721
- ...(planned.length > 0 ? [textFrame([rule(width)]), plannedFrame(planned, state, width)] : []),
722
- ]),
1077
+ waitingBlock: stack.hits[0],
1078
+ appointmentBlock: stack.hits[1],
1079
+ header: [...above, ...(stack.lines.length > 0 || nowLines.length > 0 ? stack.lines : [rule(width)])],
1080
+ body: joinFrames([...lead, ...separate(sections, width, blocks.length)]),
723
1081
  footer: [
724
1082
  rule(width),
725
1083
  fit(paint.dim(TEXTS.hint), width),