jackherd 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/INSTALL.md +4 -2
  3. package/{NOTICE → NOTICE.txt} +1 -1
  4. package/README.md +94 -1033
  5. package/assets/icons/bark-dark.svg +1 -1
  6. package/assets/icons/bark.svg +1 -1
  7. package/assets/icons/bowl-dark.svg +1 -1
  8. package/assets/icons/bowl.svg +1 -1
  9. package/assets/icons/decide-dark.svg +1 -1
  10. package/assets/icons/decide.svg +1 -1
  11. package/assets/icons/ear-dark.svg +1 -1
  12. package/assets/icons/ear.svg +1 -1
  13. package/assets/icons/lanes-dark.svg +1 -1
  14. package/assets/icons/lanes.svg +1 -1
  15. package/assets/icons/plan-dark.svg +1 -1
  16. package/assets/icons/plan.svg +1 -1
  17. package/dist/{chunk-OBUWTQ6Z.js → chunk-M6DDZEW4.js} +24 -10
  18. package/dist/config.d.ts +11 -5
  19. package/dist/config.js +53 -21
  20. package/dist/plan-format.d.ts +73 -7
  21. package/dist/plan-format.js +180 -100
  22. package/docs/workflow-dark.svg +1 -1
  23. package/docs/workflow.svg +1 -1
  24. package/herdr-plugin/herdr-plugin.toml +1 -1
  25. package/install.sh +1 -1
  26. package/kit/hooks/hooks/approval-gate/herdr-panes.ts +2 -12
  27. package/kit/hooks/hooks/approval-gate/index.ts +10 -8
  28. package/kit/hooks/hooks/approval-gate/run.ts +21 -12
  29. package/kit/hooks/hooks/ask-hint/decision.ts +3 -3
  30. package/kit/hooks/hooks/git-guard/decision.ts +189 -15
  31. package/kit/hooks/hooks/git-guard/index.ts +13 -3
  32. package/kit/hooks/hooks/git-guard/push-target.ts +85 -0
  33. package/kit/hooks/hooks/git-guard/repository.ts +63 -0
  34. package/kit/hooks/hooks/merge-guard/check-free.ts +1 -13
  35. package/kit/hooks/hooks/merge-guard/decision.ts +342 -102
  36. package/kit/hooks/hooks/merge-guard/index.ts +6 -4
  37. package/kit/hooks/hooks/merge-guard/plain-path.ts +15 -0
  38. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +23 -0
  39. package/kit/hooks/hooks/merge-guard/plan-approval-settings.ts +142 -0
  40. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +196 -0
  41. package/kit/hooks/hooks/merge-guard/plan-approval.ts +168 -0
  42. package/kit/hooks/hooks/merge-guard/plan-growth.ts +40 -0
  43. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +194 -0
  44. package/kit/hooks/hooks/merge-guard/pull-request.ts +67 -21
  45. package/kit/hooks/hooks/merge-guard/run.ts +118 -29
  46. package/kit/hooks/hooks/merge-guard/settings.ts +8 -8
  47. package/kit/hooks/hooks/merge-guard/worker.ts +3 -3
  48. package/kit/hooks/hooks/shared/base-branch.ts +23 -0
  49. package/kit/hooks/hooks/shared/board-lanes.ts +93 -0
  50. package/kit/hooks/hooks/shared/command-text-runners.ts +71 -0
  51. package/kit/hooks/hooks/shared/command.ts +14 -2
  52. package/kit/hooks/hooks/shared/commit.ts +2 -0
  53. package/kit/hooks/hooks/shared/dialog-answers.ts +4 -1
  54. package/kit/hooks/hooks/shared/json.ts +10 -0
  55. package/kit/hooks/hooks/shared/lane.ts +57 -0
  56. package/kit/hooks/hooks/shared/once.ts +8 -0
  57. package/kit/hooks/hooks/shared/own-pane.ts +31 -0
  58. package/kit/hooks/hooks/shared/pane-agent.ts +24 -0
  59. package/kit/hooks/hooks/shared/plan-open-phases.ts +114 -0
  60. package/kit/hooks/hooks/shared/program-position.ts +148 -5
  61. package/kit/hooks/hooks/shared/shell-aliases.ts +258 -0
  62. package/kit/hooks/hooks/shared/shell-snapshots.ts +68 -0
  63. package/kit/hooks/hooks/shared/shell.ts +167 -20
  64. package/kit/hooks/hooks.json +11 -2
  65. package/kit/hooks/statusline.sh +29 -16
  66. package/kit/review/skills/diagnose/SKILL.md +1 -1
  67. package/kit/review/skills/review/SKILL.md +2 -0
  68. package/kit/rules/rules/kit/testing.md +2 -5
  69. package/kit/rules/rules/kit/writing-for-agents.md +3 -1
  70. package/kit/workflow/agents/spec-review.md +1 -1
  71. package/kit/workflow/skills/follow/SKILL.md +3 -3
  72. package/kit/workflow/skills/grill/SKILL.md +1 -1
  73. package/kit/workflow/skills/implement/SKILL.md +10 -11
  74. package/kit/workflow/skills/orchestrate/SKILL.md +155 -117
  75. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +17 -0
  76. package/kit/workflow/skills/orchestrate/references/herd.md +27 -0
  77. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -0
  78. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +23 -0
  79. package/kit/workflow/skills/plan/SKILL.md +5 -2
  80. package/kit/workflow/skills/plan/references/template-English.md +6 -0
  81. package/kit/workflow/skills/plan/references/template-German.md +6 -0
  82. package/package.json +3 -2
  83. package/recipes/README.md +21 -0
  84. package/recipes/github-issues.md +23 -0
  85. package/recipes/github-projects.md +22 -0
  86. package/recipes/jira-cli.md +26 -0
  87. package/recipes/jira-rest.md +24 -0
  88. package/recipes/linear.md +25 -0
  89. package/recipes/sentry.md +18 -0
  90. package/recipes/sources/jira.ts +51 -0
  91. package/recipes/sources/linear.ts +19 -0
  92. package/recipes/sources/sentry.ts +12 -0
  93. package/recipes/sources.md +80 -0
  94. package/recipes/status-page.md +19 -0
  95. package/schemas/jackherd.schema.json +42 -18
  96. package/src/check/command.ts +2 -2
  97. package/src/check/parallel-report.ts +19 -9
  98. package/src/cli.ts +1 -0
  99. package/src/config/context-shares.ts +130 -0
  100. package/src/config/legacy-names.ts +8 -0
  101. package/src/config/read-config.ts +12 -4
  102. package/src/config/schema.ts +75 -20
  103. package/src/context/auto-compact.ts +68 -0
  104. package/src/context/command.ts +68 -17
  105. package/src/context/coordinator-name.ts +27 -9
  106. package/src/context/index.ts +0 -1
  107. package/src/context/level.ts +18 -11
  108. package/src/context/report.ts +6 -3
  109. package/src/plan-format/dependencies.ts +144 -32
  110. package/src/plan-format/index.ts +6 -0
  111. package/src/plan-format/lanes.ts +6 -0
  112. package/src/plan-format/progress.ts +19 -8
  113. package/src/plans/command.ts +14 -5
  114. package/src/plans/current-phase.ts +63 -7
  115. package/src/plans/current-task.ts +51 -0
  116. package/src/plans/ensure-panes.ts +25 -8
  117. package/src/plans/frame.ts +99 -0
  118. package/src/plans/git.ts +8 -0
  119. package/src/plans/herd-memory.ts +107 -0
  120. package/src/plans/lane-plans.ts +5 -3
  121. package/src/plans/lane-summary.ts +222 -0
  122. package/src/plans/layout-phase.ts +148 -0
  123. package/src/plans/layout-summary.ts +140 -0
  124. package/src/plans/layout.ts +164 -420
  125. package/src/plans/live.ts +11 -6
  126. package/src/plans/plan-panes.ts +15 -0
  127. package/src/plans/planned.ts +9 -42
  128. package/src/plans/selection.ts +2 -6
  129. package/src/plans/settings.ts +2 -7
  130. package/src/plans/snapshot.ts +38 -39
  131. package/src/plans/symbols.ts +2 -2
  132. package/src/plans/texts.ts +32 -31
  133. package/src/plans/view.ts +0 -1
  134. package/src/plans/worked-phases.ts +2 -2
  135. package/src/settled/board-row.ts +133 -0
  136. package/src/settled/check.ts +129 -0
  137. package/src/settled/command.ts +65 -0
  138. package/src/settled/evidence-version.ts +14 -0
  139. package/src/settled/options.ts +69 -0
  140. package/src/settled/send.ts +27 -0
  141. package/src/settled/settle.ts +87 -0
  142. package/src/settled/topics.ts +56 -0
  143. package/src/sync/command.ts +16 -1
  144. package/src/sync/render.ts +13 -4
  145. package/src/sync/sync.ts +25 -5
  146. package/src/terminal/herdr.ts +46 -10
  147. package/src/terminal/none.ts +1 -0
  148. package/src/terminal/types.ts +15 -2
  149. package/src/update/command.ts +4 -2
  150. package/src/update/update.ts +33 -9
  151. package/src/vocabulary/index.ts +47 -11
  152. package/src/watch/appointments.ts +205 -0
  153. package/src/watch/command.ts +62 -19
  154. package/src/watch/lane-report.ts +11 -3
  155. package/src/watch/texts.ts +35 -59
  156. package/src/watch/watch-events.ts +21 -28
  157. package/kit/hooks/hooks/approval-gate/lane.ts +0 -14
@@ -1,27 +1,23 @@
1
1
  import { CONTEXT_LEVEL, type ContextLevel, type ContextReading, contextLabel } from '../context'
2
- import type { ChecklistItem, DeferredItem, LaneProgress, PhaseProgress, StepProgress } from '../plan-format'
3
- import { activeItem, currentPhase, isTickedUnmerged, onlyDocumentationOpen, PHASE_ID_PREFIX } from '../plan-format'
2
+ import type { LaneProgress, PhaseProgress } from '../plan-format'
3
+ import { currentPhase, isPhaseOpen, phaseLabel } from '../plan-format'
4
4
  import {
5
5
  BADGE_TONE,
6
- BAR_SEGMENT,
7
6
  type BadgeTone,
8
7
  badge,
9
8
  bar,
10
9
  box,
11
10
  fit,
12
- INVERSE_ON,
13
11
  packGroups,
14
12
  padVisible,
15
13
  paint,
16
- RESET,
17
14
  spread,
18
- stripAnsi,
19
15
  visibleLength,
20
16
  wrap,
21
17
  } from '../terminal/text'
22
18
  import { AGENT_STATUS, type AgentStatus, isAgentStatus } from '../terminal/types'
23
19
  import { textWidth } from '../terminal/width'
24
- import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
20
+ import { PLAN_LANGUAGE } from '../vocabulary'
25
21
  import {
26
22
  NO_BADGES,
27
23
  type PullRequestBadges,
@@ -30,38 +26,27 @@ import {
30
26
  type WatchBadges,
31
27
  type WatchHealth,
32
28
  } from '../watch/plan-badges'
29
+ import { deeper, type Frame, joinFrames, nest, prefixed, rowFrame, TREE, textFrame } from './frame'
33
30
  import { LANE_STANDING, type LaneStanding, laneStanding, waitingNote } from './lane-standing'
31
+ import { type LaneSummary, laneSummary } from './lane-summary'
34
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
35
  import { chipKey, type LinkChip, planUrlOf } from './link-chips'
36
- import { type PhaseModel, phaseModelKey } from './phase-models'
36
+ import type { PhaseModel } from './phase-models'
37
37
  import { type PlannedPlan, phaseList, plannedPhaseCount } from './planned'
38
38
  import { CHECKS, type ChecksState, type PullRequest, pullRequestKey } from './pull-requests'
39
39
  import {
40
40
  type ClickTarget,
41
- followingKey,
42
41
  isLaneOpen,
43
- itemKey,
44
42
  laneKey,
45
43
  PLANNED_KEY,
46
- phaseKey,
47
44
  type Selection,
48
45
  selectedKey,
49
46
  targetKeys,
50
- tickedKey,
51
47
  } from './selection'
52
48
  import { shownWithMore } from './shown-with-more'
53
- import {
54
- chevronOf,
55
- MODEL_MARK,
56
- NO_AGENT_SYMBOL,
57
- NO_AGENT_WORD,
58
- PHASE_COUNT,
59
- PHASE_STATE,
60
- type PhaseState,
61
- phaseStateOf,
62
- statusSymbol,
63
- statusWord,
64
- } from './symbols'
49
+ import { chevronOf, NO_AGENT_SYMBOL, NO_AGENT_WORD, statusSymbol, statusWord } from './symbols'
65
50
  import { BADGE_TEXTS, type BadgeTexts, checksText, laneMarkText, TEXTS } from './texts'
66
51
 
67
52
  /**
@@ -81,8 +66,10 @@ export type LaneView = {
81
66
  chips: Record<string, LinkChip[]>
82
67
  /** How full the agent's context is; null when the terminal gave no session or the session has no log. */
83
68
  context?: ContextReading | null
84
- /** The open phases of the lane's plans that its steps do not name; drawn as a folded row under the phases. */
85
- following?: PlannedPlan[]
69
+ /** What the lane works on now, what comes next and after, and what is finished; derived from `progress` when absent. */
70
+ summary?: LaneSummary
71
+ /** The title of the agent's session as the terminal shows it; absent when it has none. */
72
+ sessionTitle?: string
86
73
  /** A running lane the sprint table has no row for; drawn with the mark `no row in lanes`. */
87
74
  unlisted?: boolean
88
75
  /** Preview and feedback of the lane's pull requests from the running watcher's state; none without a watcher. */
@@ -93,10 +80,7 @@ export type LaneView = {
93
80
  models?: Record<string, PhaseModel>
94
81
  }
95
82
 
96
- /**
97
- * `watch` is the health of the watcher the badges come from, absent when it was not read; `language` the project's
98
- * response language for the badges' words, English when absent.
99
- */
83
+ /** `watch` is the health of the watcher the badges come from, absent when it was not read. */
100
84
  export type OverviewView = {
101
85
  title: string
102
86
  time: string
@@ -105,78 +89,45 @@ export type OverviewView = {
105
89
  watch?: WatchHealth
106
90
  /** True when the config marks a ticket source, so a missing watcher is said without a pull request too. */
107
91
  watchesTickets?: boolean
108
- language?: PlanLanguage
109
92
  }
110
93
 
111
- /**
112
- * The lines of a frame, the line holding the selected row, a phase or a task (-1 when none), so the window can
113
- * follow it, and the spans a click expands or closes.
114
- */
115
- export type Frame = { lines: string[]; cursorLine: number; targets: ClickTarget[] }
116
-
117
94
  /** A fixed header and footer around a body that scrolls; body lines and targets count from the body's top. */
118
95
  export type Screen = { header: string[]; body: Frame; footer: string[] }
119
96
 
120
- /** What every block of one lane is drawn with. */
121
- type LaneContext = {
122
- progress: LaneProgress
123
- state: Selection
124
- width: number
125
- chips: Record<string, LinkChip[]>
126
- badges: WatchBadges
127
- badgeTexts: BadgeTexts
128
- /** The lane's tickets for the plan block that shows them, the first; null in every other block. */
129
- tickets: LaneTickets | null
130
- /** The model each phase ran on, by `phaseModelKey`. */
131
- models: Record<string, PhaseModel>
132
- }
133
-
134
97
  /**
135
98
  * Widths shared by every lane line: the name column, the folded pull request badges, and bar with context. They are
136
99
  * measured over every lane on screen, open ones too, so opening or closing a lane moves no other lane's columns.
100
+ * `phases` says whether the phase count follows the bar: it gives way on every line when it would push a lane's
101
+ * detail onto a line of its own or cut its status.
137
102
  */
138
- type LaneColumns = { name: number; marks: number; right: number }
139
-
140
- type StepContext = LaneContext & { stepIndex: number }
141
-
142
- type PhaseEntry = { stepIndex: number; phase: PhaseProgress }
103
+ type LaneColumns = { name: number; marks: number; right: number; phases: boolean; tally: number }
143
104
 
144
105
  const BRANCH_MARK = '⎇'
145
- const ACTIVE_MARK = '▶'
146
- const DEFERRED_MARK = '→'
147
106
  const SYMBOL_WIDTH = 2
148
- const COLUMN_SEPARATOR = ' '
149
107
  const NAME_WIDTH_MAX = 24
150
108
  const NON_BREAKING_SPACE = '\u00a0'
151
109
  const ITEM_SEPARATOR = '·'
152
110
  const RULE = '─'
153
111
  const CONTENT_INDENT = ' '
154
-
155
- /** The tree lines: a branch to a sibling that has more below it, the last branch, and what carries on beside them. */
156
- const TREE = { branch: '├─ ', last: '└─ ', pipe: '│ ', gap: ' ', trunk: '│' } as const
157
- const TREE_GUTTER = TREE.branch.length
158
-
159
- /** Places on each side of the slash of a count: ` 9/9 `, ` 15/15 `, `123/456`. */
160
- const COUNT_PLACES = 3
161
- /** The cells before a count: `◐`, `✓`, `?`, `–` or nothing, padded so every slash on screen is in one column. */
162
- const COUNT_MARK_WIDTH = 2
163
- const BLANK_COUNT = ' '.repeat(2 * COUNT_PLACES + 1)
164
- const MIN_TITLE_WIDTH = 8
165
112
  const MIN_BESIDE_WIDTH = 24
166
113
  const CONTEXT_WIDTH = 5
114
+ /** The context cell at the right of the session line: symbol, share and a cell of space. */
115
+ const CONTEXT_CELL_WIDTH = CONTEXT_WIDTH + 3
167
116
  const CONTEXT_SYMBOL = '🥣'
168
117
  const PROGRESS_SYMBOL = '◐'
169
118
  const HEADER_MARK = '🐾'
170
119
  const COLUMN_RULE = '│'
171
120
  /** The number badge of a three-digit pull request (`↗ #` and the digits, a cell of padding either side); a longer one widens it. */
172
121
  const PULL_REQUEST_BADGE_WIDTH = 8
173
- /** Under the lane line's status symbol, where the pull request row and its badge line start (D96). */
174
- const PULL_REQUEST_INDENT = ' '
122
+ /**
123
+ * Under the lane line's status symbol: where the pull request row and its badge line start (D96), and where the trunk
124
+ * of an open lane hangs, not under the fold arrow.
125
+ */
126
+ const UNDER_STATUS = ' '
175
127
  const CHECKS_WIDTH = 10
176
128
  /** The most failed tests listed under a pull request; the rest is counted. */
177
129
  const SHOWN_FAILED_TESTS = 3
178
130
  const FAILED_TEST_INDENT = ' '
179
- const MARKDOWN_MARKS = /\*\*|`/g
180
131
 
181
132
  /** Lower ranks need the coordinator sooner; a status an adapter adds later ranks after `working`. */
182
133
  const URGENCY: Record<AgentStatus, number> = {
@@ -212,48 +163,6 @@ const urgencyOf = ({ status }: LaneView): number => {
212
163
  export const sortByUrgency = (lanes: readonly LaneView[]): LaneView[] =>
213
164
  [...lanes].sort((left, right) => urgencyOf(left) - urgencyOf(right))
214
165
 
215
- const plain = (text: string): string => text.replace(MARKDOWN_MARKS, '')
216
-
217
- const isCurrent = (progress: LaneProgress, stepIndex: number, phase: PhaseProgress): boolean =>
218
- progress.current?.stepIndex === stepIndex && progress.current.phaseId === phase.id
219
-
220
- const PHASE_PAINT: Record<PhaseState, (count: string) => string> = {
221
- [PHASE_STATE.missing]: paint.dim,
222
- [PHASE_STATE.empty]: paint.dim,
223
- [PHASE_STATE.all]: paint.green,
224
- [PHASE_STATE.open]: (count) => count,
225
- }
226
-
227
- const countText = (done: number, total: number): string =>
228
- `${String(done).padStart(COUNT_PLACES)}/${String(total).padEnd(COUNT_PLACES)}`
229
-
230
- const COUNT_CELL_WIDTH = COUNT_MARK_WIDTH + 1 + BLANK_COUNT.length
231
-
232
- /** Mark and count in one fixed-width cell at the right edge, the same for the lane, phase and planned rows. */
233
- const countCell = (mark: string, count: string): string => `${padVisible(mark, COUNT_MARK_WIDTH)} ${count}`
234
-
235
- /** A word in place of a count, right-aligned in the width of the count cell like `n/m`. */
236
- const wordCell = (word: string): string => `${' '.repeat(Math.max(COUNT_CELL_WIDTH - textWidth(word), 0))}${word}`
237
-
238
- const PHASE_MARK: Record<PhaseState, string> = {
239
- [PHASE_STATE.missing]: PHASE_COUNT.missing,
240
- [PHASE_STATE.empty]: PHASE_COUNT.empty,
241
- [PHASE_STATE.all]: '',
242
- [PHASE_STATE.open]: '',
243
- }
244
-
245
- /** The count cell of a phase row at the right edge; the current phase is bold yellow. A phase lacking only the documentation box says so instead of a count. */
246
- const phaseCountCell = (phase: PhaseProgress, current: boolean): string => {
247
- const state = phaseStateOf(phase)
248
- if (state === PHASE_STATE.open && onlyDocumentationOpen(phase)) {
249
- const cell = wordCell(TEXTS.allButDocs)
250
- return current ? paint.bold(paint.yellow(cell)) : paint.green(cell)
251
- }
252
- const hasCount = state === PHASE_STATE.open || state === PHASE_STATE.all
253
- const cell = countCell(PHASE_MARK[state], hasCount ? countText(phase.done, phase.total) : BLANK_COUNT)
254
- return current ? paint.bold(paint.yellow(cell)) : PHASE_PAINT[state](cell)
255
- }
256
-
257
166
  const agentLabel = (agent: string | null): string => {
258
167
  if (agent === null) return paint.dim(statusWord(agent))
259
168
  return isAgentStatus(agent) ? STATUS_PAINT[agent](statusWord(agent)) : statusWord(agent)
@@ -282,149 +191,6 @@ const gitLabel = (status: LaneStatus): string =>
282
191
  .filter(Boolean)
283
192
  .join(' ')
284
193
 
285
- const textFrame = (lines: string[]): Frame => ({ lines, cursorLine: -1, targets: [] })
286
-
287
- /** Stacks frames, moving each one's cursor line and click targets below the frames before it. */
288
- const joinFrames = (frames: Frame[]): Frame =>
289
- frames.reduce<Frame>((joined, frame) => {
290
- const offset = joined.lines.length
291
- return {
292
- lines: [...joined.lines, ...frame.lines],
293
- cursorLine: frame.cursorLine === -1 ? joined.cursorLine : offset + frame.cursorLine,
294
- targets: [...joined.targets, ...frame.targets.map((target) => ({ ...target, line: offset + target.line }))],
295
- }
296
- }, textFrame([]))
297
-
298
- /** The context of the children of a node: the pane minus the tree lines in front of them. */
299
- const deeper = <Context extends Pick<LaneContext, 'width'>>(context: Context): Context => ({
300
- ...context,
301
- width: Math.max(context.width - TREE_GUTTER, 1),
302
- })
303
-
304
- /**
305
- * A frame moved right by a prefix of `TREE_GUTTER` cells: a click target starting at the left edge keeps it and
306
- * takes the prefix in.
307
- */
308
- const prefixed = (frame: Frame, first: string, rest: string): Frame => ({
309
- lines: frame.lines.map((line, index) => {
310
- const lead = index === 0 ? first : rest
311
- return line === '' ? paint.dim(lead.trimEnd()) : `${paint.dim(lead)}${line}`
312
- }),
313
- cursorLine: frame.cursorLine,
314
- targets: frame.targets.map((target) => ({
315
- ...target,
316
- from: target.from === 0 ? 0 : target.from + TREE_GUTTER,
317
- to: target.to + TREE_GUTTER,
318
- })),
319
- })
320
-
321
- const branches = (children: Frame[]): Frame[] => {
322
- const present = children.filter((child) => child.lines.length > 0)
323
- return present.map((child, index) => {
324
- const last = index === present.length - 1
325
- return prefixed(child, last ? TREE.last : TREE.branch, last ? TREE.gap : TREE.pipe)
326
- })
327
- }
328
-
329
- /**
330
- * The children of a node as a tree: each child's first line behind a branch, the last behind the closing one, the
331
- * lines of a child after its first beside the trunk. The caller has drawn the children `TREE_GUTTER` narrower
332
- * (`deeper`); a child without lines leaves no branch.
333
- */
334
- const nest = (children: Frame[]): Frame => joinFrames(branches(children))
335
-
336
- /** `nest` with a bare trunk line before every child, to set the children of the lane apart. */
337
- const nestSpaced = (children: Frame[]): Frame =>
338
- joinFrames(branches(children).flatMap((child) => [textFrame([paint.dim(TREE.trunk)]), child]))
339
-
340
- /**
341
- * A row drawn inverse across the pane. A progress bar inside it is switched back to normal video: inverted,
342
- * its filled and empty blocks would trade places with the background and the bar would be unreadable.
343
- */
344
- const highlight = (line: string, width: number): string => {
345
- const bars = line.match(BAR_SEGMENT) ?? []
346
- const texts = line.split(BAR_SEGMENT).map(stripAnsi)
347
- const body = texts.map((text, index) => `${text}${bars[index] ? `${bars[index]}${INVERSE_ON}` : ''}`).join('')
348
- return `${INVERSE_ON}${body}${' '.repeat(Math.max(width - visibleLength(line), 0))}${RESET}`
349
- }
350
-
351
- /** A selectable row: drawn inverse when selected, clickable across the pane on every line, `below` follows unselectable. */
352
- const rowFrame = (
353
- key: string,
354
- rows: string[],
355
- below: string[],
356
- { state, width }: Pick<LaneContext, 'state' | 'width'>,
357
- ): Frame => {
358
- const selected = state.selected === key
359
- return {
360
- lines: [...(selected ? rows.map((row) => highlight(row, width)) : rows), ...below],
361
- cursorLine: selected ? 0 : -1,
362
- targets: rows.map((_, line) => ({ line, from: 0, to: width, key })),
363
- }
364
- }
365
-
366
- /** `text` wrapped beside a one-cell `mark`, each line in `style`; follow-up lines indent under the text. */
367
- const markedLines = (text: string, mark: string, style: (line: string) => string, width: number): string[] =>
368
- wrap(plain(text), Math.max(width - 2, 1)).map((line, index) => `${index > 0 ? ' ' : mark} ${style(line)}`)
369
-
370
- /** The mark and the text style of a task: a ticked one green and dim, the one being worked on yellow and bold. */
371
- const taskLook = ({ done }: ChecklistItem, active: boolean): { mark: string; style: (line: string) => string } => {
372
- if (active) return { mark: paint.yellow(ACTIVE_MARK), style: paint.bold }
373
- return done ? { mark: paint.green('☑'), style: paint.dim } : { mark: '☐', style: (line) => line }
374
- }
375
-
376
- /** A closed task as one row, cut at the pane's edge with `…`. */
377
- const closedTaskRow = (item: ChecklistItem, active: boolean, width: number): string[] => {
378
- const { mark, style } = taskLook(item, active)
379
- return [fit(`${mark} ${style(plain(item.text))}`, width)]
380
- }
381
-
382
- /** An open task as rows: its whole text wrapped. */
383
- const openTaskRows = (item: ChecklistItem, active: boolean, width: number): string[] => {
384
- const { mark, style } = taskLook(item, active)
385
- return markedLines(item.text, mark, style, width)
386
- }
387
-
388
- /** A task as a selectable row under its `key`: clickable across every line it takes, the cursor on its first. */
389
- const taskFrame = (key: string, rows: string[], context: Pick<LaneContext, 'state' | 'width'>): Frame =>
390
- rowFrame(key, rows, [], context)
391
-
392
- /** A checkbox moved to another phase, dim: `→ P9 <text>`. */
393
- const deferredRow = ({ text, target }: DeferredItem, width: number): string =>
394
- fit(paint.dim(`${DEFERRED_MARK} ${PHASE_ID_PREFIX}${target} ${plain(text)}`), width)
395
-
396
- const tickedSummaryFrame = (count: number, phaseRowKey: string, width: number): Frame => ({
397
- lines: [fit(paint.dim(`${chevronOf(false)} ${TEXTS.tickedSummary(count)}`), width)],
398
- cursorLine: -1,
399
- targets: [{ line: 0, from: 0, to: width, key: tickedKey(phaseRowKey) }],
400
- })
401
-
402
- /**
403
- * The children of an open phase, one frame per task: open ones first, `active` marked among them, then the ones
404
- * deferred to another phase, then the ticked ones listed or summed up.
405
- */
406
- const phaseChildren = (
407
- context: StepContext,
408
- phaseRowKey: string,
409
- phase: PhaseProgress,
410
- active: ChecklistItem | null,
411
- ): Frame[] => {
412
- const { state, width } = context
413
- if (phase.items.length === 0 && !phase.deferred) return [textFrame([paint.dim(TEXTS.noCheckboxes)])]
414
- const frameOf = (item: ChecklistItem, index: number): Frame => {
415
- const key = itemKey(phaseRowKey, index)
416
- const isActive = item === active
417
- const rows = state.expanded.has(key) ? openTaskRows(item, isActive, width) : closedTaskRow(item, isActive, width)
418
- return taskFrame(key, rows, context)
419
- }
420
- const framesWhere = (done: boolean): Frame[] =>
421
- phase.items.flatMap((item, index) => (item.done === done ? [frameOf(item, index)] : []))
422
- const tickedCount = phase.items.filter((item) => item.done).length
423
- const summary = tickedCount > 0 ? [tickedSummaryFrame(tickedCount, phaseRowKey, width)] : []
424
- const deferred = (phase.deferred ?? []).map((item) => textFrame([deferredRow(item, width)]))
425
- return [...framesWhere(false), ...deferred, ...(state.showDone ? framesWhere(true) : summary)]
426
- }
427
-
428
194
  /** A head in fixed columns with `text` wrapped beside it; follow-up lines indent to where the text starts. */
429
195
  const besideHead = (
430
196
  head: string,
@@ -459,70 +225,6 @@ export const rowWithCount = (
459
225
  )
460
226
  }
461
227
 
462
- /** ` ticked, not merged` in yellow behind a phase the lane ticked to the end in its worktree only; empty otherwise. Only the current phase carries it, the others fold into the ticked line. */
463
- const unmergedMark = (phase: PhaseProgress): string =>
464
- isTickedUnmerged(phase) ? ` ${paint.yellow(TEXTS.tickedUnmerged)}` : ''
465
-
466
- /** The chevron of a phase row; a finished phase that is closed shows a green ✓ in its place. */
467
- const phaseMark = (phase: PhaseProgress, open: boolean): string =>
468
- !open && phaseStateOf(phase) === PHASE_STATE.all ? paint.green(PHASE_COUNT.allTicked) : paint.dim(chevronOf(open))
469
-
470
- const familyLabel = (family: string): string => `${family.charAt(0).toUpperCase()}${family.slice(1)}`
471
-
472
- /**
473
- * The model the phase's subagent last ran on: `Opus ✓` when it fits the plan's recommendation, `Sonnet ≠ Opus` in
474
- * amber when it does not, the family alone, dim, when the plan recommends none; empty without a record.
475
- */
476
- const modelMark = (model: PhaseModel | undefined): string => {
477
- if (!model) return ''
478
- const used = familyLabel(model.used)
479
- if (model.recommended === null) return paint.dim(used)
480
- return model.fits
481
- ? `${paint.dim(used)} ${paint.green(MODEL_MARK.fits)}`
482
- : paint.yellow(`${used} ${MODEL_MARK.differs} ${familyLabel(model.recommended)}`)
483
- }
484
-
485
- /**
486
- * The phase title cut before the model and the count, which stay at the right edge, or the note that the plan lacks
487
- * the phase. The model gives way in a pane too narrow to leave the title `MIN_TITLE_WIDTH` cells.
488
- */
489
- const phaseRow = (phase: PhaseProgress, open: boolean, current: boolean, width: number, model?: PhaseModel): string => {
490
- const title = phase.title === null ? paint.dim(TEXTS.notInPlan) : plain(phase.title)
491
- const label = `${phaseMark(phase, open)} P${phase.id} `
492
- const head = `${label}${title}${current ? unmergedMark(phase) : ''}`
493
- const count = phaseCountCell(phase, current)
494
- const withModel = `${modelMark(model)}${COLUMN_SEPARATOR}${count}`
495
- const titleRoom = width - visibleLength(label) - visibleLength(withModel) - 1
496
- const fitsModel =
497
- model !== undefined && (titleRoom >= MIN_TITLE_WIDTH || titleRoom >= visibleLength(head) - visibleLength(label))
498
- return spread(head, fitsModel ? withModel : count, width)
499
- }
500
-
501
- /** A closed phase shows only `active` under it, when given, closed whatever the task's fold. */
502
- const activeTaskFrame = (
503
- context: StepContext,
504
- phaseRowKey: string,
505
- phase: PhaseProgress,
506
- active: ChecklistItem | null,
507
- ): Frame => {
508
- if (!active) return textFrame([])
509
- const key = itemKey(phaseRowKey, phase.items.indexOf(active))
510
- return taskFrame(key, closedTaskRow(active, true, context.width), context)
511
- }
512
-
513
- /** One phase as a node, with its tasks below it when expanded, or its active task when it is the current one. */
514
- const phaseFrame = (context: StepContext, phase: PhaseProgress): Frame => {
515
- const { progress, state, stepIndex, width } = context
516
- const key = phaseKey(progress.lane.name, stepIndex, phase.id)
517
- const open = state.expanded.has(key)
518
- const current = isCurrent(progress, stepIndex, phase)
519
- const inner = deeper(context)
520
- const active = current ? activeItem(phase) : null
521
- const children = open ? phaseChildren(inner, key, phase, active) : [activeTaskFrame(inner, key, phase, active)]
522
- const model = context.models[phaseModelKey(stepIndex, phase.id)]
523
- return joinFrames([rowFrame(key, [phaseRow(phase, open, current, width, model)], [], context), nest(children)])
524
- }
525
-
526
228
  /** True when number, checks and the badges leave room for a title beside them on one line. */
527
229
  const fitsBeside = (head: string, cells: BadgeCell[], width: number): boolean =>
528
230
  visibleLength(head) + packedWidth(cells) + COLUMN_SEPARATOR.length + MIN_TITLE_WIDTH <= width
@@ -538,7 +240,7 @@ const pullRequestRow = (pullRequest: PullRequest, context: LaneContext): Frame =
538
240
  const checksColumn =
539
241
  textWidth(checksWords) < CHECKS_WIDTH ? CHECKS_WIDTH : textWidth(checksWords) + COLUMN_SEPARATOR.length
540
242
  const checks = padVisible(CHECKS_PAINT[pullRequest.checks](checksWords), checksColumn)
541
- const head = `${PULL_REQUEST_INDENT}${number}${COLUMN_SEPARATOR}${checks}`
243
+ const head = `${UNDER_STATUS}${number}${COLUMN_SEPARATOR}${checks}`
542
244
  const prefix = pullRequest.draft ? TEXTS.draft : ''
543
245
  const dimPrefix = (line: string, index: number) =>
544
246
  index === 0 && prefix ? `${paint.dim(prefix)}${line.slice(prefix.length)}` : line
@@ -547,7 +249,7 @@ const pullRequestRow = (pullRequest: PullRequest, context: LaneContext): Frame =
547
249
  if (cells.length === 0 || !fitsBeside(head, cells, context.width))
548
250
  return joinFrames([
549
251
  rowFrame(key, besideHead(head, `${prefix}${pullRequest.title}`, context.width, dimPrefix), [], context),
550
- packedFrame(cells, PULL_REQUEST_INDENT, context.width),
252
+ packedFrame(cells, UNDER_STATUS, context.width),
551
253
  ])
552
254
  const beside = `${head}${cells.map(({ cell }) => cell).join(CHIP_GAP)}${COLUMN_SEPARATOR}`
553
255
  const row = rowFrame(
@@ -582,30 +284,35 @@ const CONTEXT_PAINT: Record<ContextLevel, (text: string) => string> = {
582
284
  [CONTEXT_LEVEL.alert]: paint.red,
583
285
  }
584
286
 
585
- /** `62 %` in the colour of its level, right-aligned in a fixed cell; empty when the lane has no reading. */
287
+ /** `🥣 62 %`, the share in the colour of its level and right-aligned in a fixed cell; empty without a reading. */
288
+ const contextValue = (context: ContextReading | null | undefined): string =>
289
+ context ? `${CONTEXT_SYMBOL}${CONTEXT_PAINT[context.level](contextLabel(context.share).padStart(CONTEXT_WIDTH))}` : ''
290
+
291
+ /** The context value with a rule before the bar that follows it on a folded lane line; empty without a reading. */
586
292
  const contextCell = (context: ContextReading | null | undefined): string =>
587
- context
588
- ? `${CONTEXT_SYMBOL}${CONTEXT_PAINT[context.level](contextLabel(context.share).padStart(CONTEXT_WIDTH))} ${paint.dim(COLUMN_RULE)} `
589
- : ''
590
-
591
- /** Bar and count in a fixed-width column, so every bar on screen starts in the same column. */
592
- const progressColumn = (done: number, total: number): string =>
593
- `${bar(done, total)} ${countCell(PROGRESS_SYMBOL, countText(done, total))}`
594
-
595
- /** Steps on the same plan share one group, wherever they stand in the lane, so a plan is headed once. */
596
- const groupSteps = (steps: readonly StepProgress[]): number[][] =>
597
- steps.reduce<number[][]>((groups, { step }, index) => {
598
- const same =
599
- step.planHref === null ? undefined : groups.find((group) => steps[group[0]].step.planHref === step.planHref)
600
- if (same) same.push(index)
601
- else groups.push([index])
602
- return groups
603
- }, [])
293
+ context ? `${contextValue(context)} ${paint.dim(COLUMN_RULE)} ` : ''
294
+
295
+ /**
296
+ * 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.
299
+ */
300
+ const progressColumn = ({ done, total, phasesDone, phases }: LaneSummary['tally'], tallyWidth: number): string => {
301
+ const counts = `${bar(done, total)} ${countCell(PROGRESS_SYMBOL, countText(done, total))}`
302
+ if (tallyWidth === 0) return counts
303
+ const tally = TEXTS.phaseTally(phasesDone, phases)
304
+ const padding = ' '.repeat(Math.max(tallyWidth - visibleLength(tally), 0))
305
+ // A lane without phases keeps the column blank, so its bar starts where the others do.
306
+ const phaseCount = tally ? `${TALLY_SEPARATOR}${padding}${tally}` : ' '.repeat(TALLY_SEPARATOR.length + tallyWidth)
307
+ return `${counts}${paint.dim(phaseCount)}`
308
+ }
604
309
 
605
310
  const CHIP_GAP = ' '
606
- const CHIP_MARK = '📎 '
311
+
607
312
  const TICKET_MARK = '📡'
608
313
  const STATUS_SEPARATOR = ' · '
314
+ /** Between the checkbox count and the phase count of a lane. */
315
+ const TALLY_SEPARATOR = ' · '
609
316
 
610
317
  /** `✦ label` as a badge linked to the chip's address, `private` faint after it for a link its plan says is private. */
611
318
  const chipCell = ({ url, icon, label, private: hidden }: LinkChip): string =>
@@ -672,16 +379,16 @@ const ticketCells = (tickets: LaneTickets | null): BadgeCell[] => {
672
379
 
673
380
  const chipCellOf = (chip: LinkChip): BadgeCell => ({ cell: chipCell(chip), key: chipKey(chip.url) })
674
381
 
675
- /** The chips of a plan behind a 📎, the plan chip first and the tickets beside it, then a blank line before the phases. */
382
+ /** The chips of the lane's plans behind a 📎, each once, the plan chips first and the tickets beside them. */
676
383
  const chipFrame = (chips: LinkChip[], tickets: LaneTickets | null, width: number): Frame => {
677
384
  const isPlanChip = (chip: LinkChip) => planUrlOf(chipKey(chip.url)) !== null
385
+ const unique = chips.filter((chip, index) => chips.findIndex(({ url }) => url === chip.url) === index)
678
386
  const cells = [
679
- ...chips.filter(isPlanChip).map(chipCellOf),
387
+ ...unique.filter(isPlanChip).map(chipCellOf),
680
388
  ...ticketCells(tickets),
681
- ...chips.filter((chip) => !isPlanChip(chip)).map(chipCellOf),
389
+ ...unique.filter((chip) => !isPlanChip(chip)).map(chipCellOf),
682
390
  ]
683
- const frame = packedFrame(cells, CHIP_MARK, width)
684
- return frame.lines.length > 0 ? { ...frame, lines: [...frame.lines, ''] } : frame
391
+ return packedFrame(cells, '', width)
685
392
  }
686
393
 
687
394
  /**
@@ -705,32 +412,22 @@ const badgeCells = (
705
412
  ]
706
413
  }
707
414
 
708
- const planHeading = (title: string, width: number): string[] => wrap(plain(title), width).map(paint.bold)
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))
709
417
 
710
418
  /**
711
- * A plan as a node: its title in bold, and below it the links and the phases in plan order, each ticked one as a
712
- * collapsed row of its own.
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.
713
421
  */
714
- const planBlock = (context: LaneContext, stepIndexes: number[], entries: PhaseEntry[]): Frame => {
715
- const { progress, width, chips, tickets } = context
716
- const { planTitle, planHref } = progress.steps[stepIndexes[0]].step
717
- const inner = deeper(context)
718
- return joinFrames([
719
- textFrame(planHeading(planTitle, width)),
720
- nest([
721
- chipFrame(planHref ? (chips[planHref] ?? []) : [], tickets, inner.width),
722
- ...entries.map(({ stepIndex, phase }) => phaseFrame({ ...inner, stepIndex }, phase)),
723
- ]),
724
- ])
422
+ const headPhase = (view: LaneView): PhaseProgress | null => {
423
+ const current = currentPhase(view.progress)
424
+ return summaryOf(view).latest ?? (current && isPhaseOpen(current) ? current : null)
725
425
  }
726
426
 
727
- /** The columns lane lines share, so names, statuses and bars of all of them line up. */
728
- const STATUS_WIDTH = Math.max(...[...Object.values(AGENT_STATUS), NO_AGENT_WORD].map(textWidth))
729
-
730
- /** `P9 Freigabe 0.x` for the current phase of a lane, marked when ticked and not merged; empty when there is none. */
731
- const nextPhaseLabel = ({ progress }: LaneView): string => {
732
- const phase = currentPhase(progress)
733
- return phase ? `P${phase.id}${phase.title ? ` ${plain(phase.title)}` : ''}${unmergedMark(phase)}` : ''
427
+ /** `P9 Freigabe 0.x` for the phase the lane line names; empty when there is none. */
428
+ const nextPhaseLabel = (view: LaneView): string => {
429
+ const phase = headPhase(view)
430
+ return phase ? `${phaseLabel(phase.id)}${phase.title ? ` ${plain(phase.title)}` : ''}` : ''
734
431
  }
735
432
 
736
433
  const markTone = ({ feedback, changesRequested }: PullRequestBadges): BadgeTone => {
@@ -760,34 +457,54 @@ const ticketMark = (tickets: LaneTickets | undefined): string[] => {
760
457
  const laneMarks = ({ pullRequests, badges, tickets }: LaneView): string =>
761
458
  [...pullRequests.map((pullRequest) => pullRequestMark(pullRequest, badges ?? {})), ...ticketMark(tickets)].join(' ')
762
459
 
763
- /** What follows the status on a lane line: git state while the lane is open, the next phase when folded. */
764
- const laneDetail = (view: LaneView, open: boolean): string => {
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 => {
765
462
  const markText = laneMarkText(view)
766
463
  const marker = markText ? paint.yellow(markText) : ''
767
- return [marker, open ? gitLabel(view.status) : nextPhaseLabel(view)].filter(Boolean).join(' ')
464
+ return [marker, nextPhaseLabel(view)].filter(Boolean).join(' ')
768
465
  }
769
466
 
770
467
  /**
771
468
  * The lane line, `head` and `right` (bar and count) at the edges. The detail (next phase, branch, pull
772
469
  * requests) follows `head` when at least `MIN_BESIDE_WIDTH` characters are left for it; in a narrower pane
773
- * it moves to a line of its own below, indented under the name and cut to the full width.
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.
774
471
  */
775
- export const withDetail = (head: string, detail: string, right: string, width: number): string[] => {
472
+ export const withDetail = (
473
+ head: string,
474
+ detail: string,
475
+ right: string,
476
+ width: number,
477
+ indent = CONTENT_INDENT,
478
+ ): string[] => {
776
479
  if (!detail) return [spread(head, right, width)]
777
480
  const room = width - visibleLength(head) - COLUMN_SEPARATOR.length - visibleLength(right) - 1
778
481
  if (room >= MIN_BESIDE_WIDTH) return [spread(`${head}${COLUMN_SEPARATOR}${detail}`, right, width)]
779
- return [spread(head, right, width), fit(`${CONTENT_INDENT}${detail}`, width)]
482
+ return [spread(head, right, width), fit(`${indent}${detail}`, width)]
483
+ }
484
+
485
+ /** The summary the snapshot read for the lane, or the one its own steps give. */
486
+ const summaryOf = (view: LaneView): LaneSummary =>
487
+ view.summary ??
488
+ laneSummary({ progress: view.progress, phases: {}, texts: {}, activity: {}, language: PLAN_LANGUAGE.en }, 0)
489
+
490
+ /** The bar over the lane's plans, empty while they have no checkbox. */
491
+ const laneProgressColumn = (view: LaneView, tallyWidth: number): string => {
492
+ const { tally } = summaryOf(view)
493
+ return tally.total > 0 ? progressColumn(tally, tallyWidth) : ''
780
494
  }
781
495
 
782
- /** Context and bar at the right edge of a lane line, as wide as the lane has them. */
783
- const laneRight = (view: LaneView): string => {
784
- const counts = view.progress.total > 0 ? progressColumn(view.progress.done, view.progress.total) : ''
496
+ /** Context and bar at the right edge of a folded lane line, as wide as the lane has them. */
497
+ const laneRight = (view: LaneView, tallyWidth: number): string => {
498
+ const counts = laneProgressColumn(view, tallyWidth)
785
499
  return counts ? `${contextCell(view.context)}${counts}` : contextCell(view.context).trimEnd()
786
500
  }
787
501
 
502
+ /** The cells of the phase count column, 0 when the pane leaves it out. */
503
+ const tallyWidthOf = ({ phases, tally }: LaneColumns): number => (phases ? tally : 0)
504
+
788
505
  /** A folded lane's marks in the column of `columns.marks`, right before context and bar (D96). */
789
506
  const markedRight = (view: LaneView, columns: LaneColumns): string => {
790
- const right = laneRight(view)
507
+ const right = laneRight(view, tallyWidthOf(columns))
791
508
  const marks = laneMarks(view)
792
509
  if (!marks) return right
793
510
  if (columns.right === 0) return padVisible(marks, columns.marks)
@@ -802,10 +519,39 @@ const laneLines = (view: LaneView, open: boolean, width: number, columns: LaneCo
802
519
  padVisible(agentLabel(status.agent), STATUS_WIDTH),
803
520
  ].join(COLUMN_SEPARATOR)
804
521
  const head = `${paint.dim(chevronOf(open))} ${symbolCell(status.agent)} ${cells}`
805
- const right = open ? laneRight(view) : markedRight(view, columns)
806
- return withDetail(head, laneDetail(view, open), right, width)
522
+ 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
+ )
530
+ }
531
+
532
+ /**
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
534
+ * context at the right. The title keeps its room: the branch gives way when both do not fit, and only then is the
535
+ * title cut. Without a title the branch stands at the left.
536
+ */
537
+ const sessionLine = (view: LaneView, width: number): string => {
538
+ const git = gitLabel(view.status)
539
+ const context = contextValue(view.context)
540
+ if (!view.sessionTitle) return spread(fit(git, Math.max(width - CONTEXT_CELL_WIDTH, 1)), context, width)
541
+ const left = plain(view.sessionTitle)
542
+ 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)
545
+ return spread(fit(left, Math.max(room, 1)), right, width)
807
546
  }
808
547
 
548
+ const underStatus = (frame: Frame): Frame =>
549
+ prefixed(frame, { first: UNDER_STATUS, rest: UNDER_STATUS, style: (text) => text })
550
+
551
+ /** The chips of the plans the lane's steps name, in the order of its steps. */
552
+ const laneChips = ({ progress, chips }: LaneView): LinkChip[] =>
553
+ progress.steps.flatMap(({ step }) => (step.planHref ? (chips[step.planHref] ?? []) : []))
554
+
809
555
  /** The lane line is a row of its own; an open lane continues with its pull requests and plans. */
810
556
  const laneBlock = (
811
557
  view: LaneView,
@@ -814,7 +560,7 @@ const laneBlock = (
814
560
  columns: LaneColumns,
815
561
  badgeTexts: BadgeTexts,
816
562
  ): Frame => {
817
- const context = {
563
+ const context: LaneContext = {
818
564
  progress: view.progress,
819
565
  state,
820
566
  width,
@@ -827,22 +573,20 @@ const laneBlock = (
827
573
  const open = isLaneOpen(state, view.progress.lane.name, view.status.agent)
828
574
  const row = rowFrame(laneKey(view.progress.lane.name), laneLines(view, open, width, columns), [], context)
829
575
  if (!open) return row
830
- const groups = groupSteps(view.progress.steps).map((stepIndexes) => ({
831
- stepIndexes,
832
- entries: stepIndexes.flatMap((stepIndex) =>
833
- view.progress.steps[stepIndex].phases.map((phase) => ({ stepIndex, phase })),
834
- ),
835
- }))
836
- const planned = groups.filter(({ entries }) => entries.length > 0)
837
- const inner = deeper(context)
838
- const content = nestSpaced([
839
- ...(planned.length === 0 ? [chipFrame([], inner.tickets, inner.width)] : []),
840
- ...planned.map(({ stepIndexes, entries }, index) =>
841
- planBlock(index === 0 ? inner : { ...inner, tickets: null }, stepIndexes, entries),
842
- ),
843
- followingFrame(view, state, inner.width),
576
+ 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
+ }
581
+ const [session, ...sections] = summaryChildren(inner, summaryOf(view), head)
582
+ const alone = sections.every((section) => section.lines.length === 0)
583
+ const sessionLead = alone ? { first: TREE.last, rest: TREE.gap } : { first: TREE.branch, rest: TREE.pipe }
584
+ return joinFrames([
585
+ row,
586
+ underStatus(prefixed(session, { ...sessionLead, style: paint.dim })),
587
+ ...view.pullRequests.map((pullRequest) => pullRequestFrame(pullRequest, context)),
588
+ underStatus(nest(sections)),
844
589
  ])
845
- return joinFrames([row, ...view.pullRequests.map((pullRequest) => pullRequestFrame(pullRequest, context)), content])
846
590
  }
847
591
 
848
592
  const laneTally = ({ progress }: Pick<LaneView, 'progress'>): string =>
@@ -901,21 +645,6 @@ const plannedRows = (planned: PlannedPlan[], width: number, indent: string): str
901
645
  ),
902
646
  )
903
647
 
904
- /** The plan title up to its subtitle: `Agent-Kit: Skills, Agenten und …` is `Agent-Kit`. */
905
- const planName = (title: string): string => plain(title.split(':')[0]).trim()
906
-
907
- /** The node under an open lane for the phases after its own: closed, `▸ also open: P7 P8`; opened, one child per plan. */
908
- const followingFrame = (view: LaneView, state: Selection, width: number): Frame => {
909
- const following = view.following ?? []
910
- if (following.length === 0) return textFrame([])
911
- const key = followingKey(view.progress.lane.name)
912
- const open = state.expanded.has(key)
913
- const label = TEXTS.following(following.map((plan) => `${planName(plan.title)}: ${phaseList(plan)}`).join(' · '))
914
- const head = [paint.dim(fit(`${chevronOf(open)} ${label}`, width))]
915
- const plans = open ? plannedRows(following, deeper({ width }).width, '').map(textFrame) : []
916
- return joinFrames([rowFrame(key, head, [], { state, width }), nest(plans)])
917
- }
918
-
919
648
  const rule = (width: number): string => paint.dim(RULE.repeat(width))
920
649
 
921
650
  /** A dimmed rule before every lane block but the first, folded or open: it shows where one lane ends. */
@@ -935,11 +664,26 @@ const watchNotice = (
935
664
  return watch.kind === WATCH_HEALTH.off ? texts.watcherOff : texts.stale(watch.minutes)
936
665
  }
937
666
 
938
- const laneColumnsOf = (lanes: LaneView[]): LaneColumns => ({
939
- name: Math.min(Math.max(0, ...lanes.map(({ progress }) => textWidth(progress.lane.name))), NAME_WIDTH_MAX),
940
- marks: Math.max(0, ...lanes.map((lane) => visibleLength(laneMarks(lane)))),
941
- right: Math.max(0, ...lanes.map((lane) => visibleLength(laneRight(lane)))),
942
- })
667
+ /** Chevron, symbol and the gaps around them before the name column of a lane line. */
668
+ const LANE_HEAD_CELLS = 2 + SYMBOL_WIDTH + 1
669
+
670
+ const laneColumnsOf = (lanes: LaneView[], width: number): LaneColumns => {
671
+ const name = Math.min(Math.max(0, ...lanes.map(({ progress }) => textWidth(progress.lane.name))), NAME_WIDTH_MAX)
672
+ const marks = Math.max(0, ...lanes.map((lane) => visibleLength(laneMarks(lane))))
673
+ const tally = Math.max(
674
+ 0,
675
+ ...lanes.map((lane) => {
676
+ const { phasesDone, phases } = summaryOf(lane).tally
677
+ return visibleLength(TEXTS.phaseTally(phasesDone, phases))
678
+ }),
679
+ )
680
+ const rightOf = (tallyWidth: number) =>
681
+ 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
683
+ const beside = MIN_BESIDE_WIDTH + COLUMN_SEPARATOR.length
684
+ const phases = head + beside + (marks > 0 ? marks + COLUMN_SEPARATOR.length : 0) + rightOf(tally) <= width
685
+ return { name, marks, right: rightOf(phases ? tally : 0), phases, tally }
686
+ }
943
687
 
944
688
  /** The lanes by what the view does with them; closed lanes are in none of the three groups. */
945
689
  const partitionLanes = (lanes: LaneView[]): Record<'active' | 'waiting' | 'noAgent', LaneView[]> => {
@@ -958,14 +702,14 @@ const partitionLanes = (lanes: LaneView[]): Record<'active' | 'waiting' | 'noAge
958
702
  * lanes with a worktree but no agent each share a closing block; finished lanes are not drawn.
959
703
  */
960
704
  export const renderScreen = (
961
- { title, time, lanes, planned, watch, watchesTickets, language = PLAN_LANGUAGE.en }: OverviewView,
705
+ { title, time, lanes, planned, watch, watchesTickets }: OverviewView,
962
706
  state: Selection,
963
707
  width: number,
964
708
  ): Screen => {
965
709
  const { active, waiting, noAgent } = partitionLanes(lanes)
966
710
  const parked = [...waitingBlock(waiting, width), ...parkedBlock(noAgent, width)]
967
- const columns = laneColumnsOf(active)
968
- const badgeTexts = BADGE_TEXTS[language]
711
+ const columns = laneColumnsOf(active, width)
712
+ const badgeTexts = BADGE_TEXTS
969
713
  const blocks = active.map((lane) => laneBlock(lane, state, width, columns, badgeTexts))
970
714
  const notice = watchNotice({ lanes: active, watch, watchesTickets }, badgeTexts)
971
715
  const clock = notice ? `${paint.yellow(notice)}${COLUMN_SEPARATOR}${paint.dim(time)}` : paint.dim(time)