jackherd 1.11.2 → 1.12.1

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 (94) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +69 -51
  3. package/dist/{chunk-RMMXD4G5.js → chunk-V4HOLGFP.js} +2 -2
  4. package/dist/config.d.ts +10 -0
  5. package/dist/config.js +15 -5
  6. package/dist/plan-format.d.ts +11 -6
  7. package/dist/plan-format.js +8 -2
  8. package/docs/follow-dark.svg +2 -2
  9. package/docs/follow.svg +2 -2
  10. package/docs/workflow-dark.svg +56 -64
  11. package/docs/workflow.svg +56 -64
  12. package/herdr-plugin/herdr-plugin.toml +1 -1
  13. package/install.sh +1 -1
  14. package/kit/workflow/skills/implement/SKILL.md +1 -1
  15. package/kit/workflow/skills/orchestrate/SKILL.md +1 -1
  16. package/kit/workflow/skills/orchestrate/references/draft-review.md +4 -0
  17. package/kit/workflow/skills/orchestrate/references/events.md +8 -1
  18. package/kit/workflow/skills/plan/SKILL.md +2 -0
  19. package/package.json +2 -2
  20. package/schemas/jackherd.schema.json +18 -1
  21. package/src/config/link-kinds.ts +1 -1
  22. package/src/config/schema.ts +5 -2
  23. package/src/config/tones.ts +9 -2
  24. package/src/context/fill.ts +4 -10
  25. package/src/context/session-log.ts +108 -2
  26. package/src/plan-format/lanes.ts +10 -1
  27. package/src/plan-format/phases.ts +11 -3
  28. package/src/plan-format/progress.ts +4 -2
  29. package/src/plan-format/waiting.ts +12 -0
  30. package/src/plans/active-tasks.ts +31 -8
  31. package/src/plans/appointment-rows.ts +42 -53
  32. package/src/plans/board-rows.ts +14 -0
  33. package/src/plans/calendar-days.ts +29 -0
  34. package/src/plans/click-hint.ts +94 -0
  35. package/src/plans/command.ts +8 -9
  36. package/src/plans/current-phase.ts +22 -10
  37. package/src/plans/current-task.ts +18 -3
  38. package/src/plans/frame.ts +18 -26
  39. package/src/plans/git.ts +90 -31
  40. package/src/plans/groomed.ts +13 -7
  41. package/src/plans/lane-reason.ts +61 -0
  42. package/src/plans/lane-summary.ts +75 -24
  43. package/src/plans/layout-phase.ts +123 -76
  44. package/src/plans/layout-summary.ts +133 -83
  45. package/src/plans/layout.ts +451 -345
  46. package/src/plans/legend.ts +29 -0
  47. package/src/plans/link-chips.ts +26 -8
  48. package/src/plans/live.ts +238 -84
  49. package/src/plans/next-rows.ts +5 -37
  50. package/src/plans/phase-source.ts +8 -3
  51. package/src/plans/plan-chips.ts +6 -3
  52. package/src/plans/plan-heads.ts +1 -1
  53. package/src/plans/planned.ts +4 -1
  54. package/src/plans/process-slots.ts +36 -0
  55. package/src/plans/pull-requests.ts +9 -1
  56. package/src/plans/read-cache.ts +18 -3
  57. package/src/plans/refresh-pace.ts +13 -0
  58. package/src/plans/row-focus.ts +86 -15
  59. package/src/plans/selection.ts +124 -89
  60. package/src/plans/sent-files.ts +1 -1
  61. package/src/plans/settings.ts +5 -2
  62. package/src/plans/shimmer.ts +124 -0
  63. package/src/plans/signals-read.ts +38 -10
  64. package/src/plans/slice-gate.ts +51 -0
  65. package/src/plans/snapshot.ts +185 -84
  66. package/src/plans/source-asked.ts +1 -1
  67. package/src/plans/source-rows.ts +60 -18
  68. package/src/plans/sprint-html.ts +1 -1
  69. package/src/plans/status-pace.ts +45 -0
  70. package/src/plans/subagent-tasks.ts +1 -1
  71. package/src/plans/subagents-read.ts +61 -24
  72. package/src/plans/subagents.ts +2 -2
  73. package/src/plans/symbols.ts +4 -1
  74. package/src/plans/task-brief.ts +143 -0
  75. package/src/plans/texts.ts +181 -39
  76. package/src/plans/view.ts +198 -32
  77. package/src/plans/waiting-click.ts +54 -110
  78. package/src/plans/watch-burst.ts +25 -0
  79. package/src/plans/worktree-stamp.ts +35 -0
  80. package/src/plans/your-turn-rows.ts +102 -0
  81. package/src/plans/your-turn.ts +226 -0
  82. package/src/terminal/herdr.ts +25 -5
  83. package/src/terminal/restore.ts +44 -0
  84. package/src/terminal/text.ts +2 -2
  85. package/src/terminal/width.ts +9 -0
  86. package/src/vocabulary/index.ts +6 -5
  87. package/src/watch/command.ts +28 -2
  88. package/src/watch/lane-report.ts +2 -0
  89. package/src/watch/lane-rest.ts +7 -1
  90. package/src/watch/reference-paths.ts +1 -0
  91. package/src/watch/unlinked-artifacts-read.ts +43 -0
  92. package/src/watch/unlinked-artifacts.ts +76 -0
  93. package/src/watch/watch-events.ts +12 -0
  94. package/src/watch/watcher-lock.ts +47 -4
@@ -30,11 +30,19 @@ import {
30
30
  type WatchHealth,
31
31
  } from '../watch/plan-badges'
32
32
  import type { UnplannedTask } from './active-tasks'
33
- import { type AppointmentBlock, appointmentItems, appointmentKeys } from './appointment-rows'
34
- import { deeper, type Frame, joinFrames, nest, prefixed, rowFrame, TREE, textFrame } from './frame'
33
+ import { type AgentOf, clickHint } from './click-hint'
34
+ import { type Frame, indented, joinFrames, narrower, rowFrame, spaced, textFrame, unpainted } from './frame'
35
35
  import { groomedText } from './groomed'
36
+ import {
37
+ LANE_REASON,
38
+ type LaneReason,
39
+ laneReason,
40
+ lanesBalance,
41
+ type ReasonInput,
42
+ type UnblockingRow,
43
+ } from './lane-reason'
36
44
  import { LANE_STANDING, type LaneStanding, laneStanding, waitingNote } from './lane-standing'
37
- import { type LaneSummary, laneSummary } from './lane-summary'
45
+ import { type ActivePhase, type LaneSummary, laneSummary } from './lane-summary'
38
46
  import { type LaneTickets, withoutTicketKey } from './lane-tickets'
39
47
  import {
40
48
  COLUMN_SEPARATOR,
@@ -44,14 +52,18 @@ import {
44
52
  MIN_TITLE_WIDTH,
45
53
  nowLine,
46
54
  plain,
55
+ SHIMMERING_TASK,
56
+ STILL_TASK,
57
+ type TaskStyle,
47
58
  } from './layout-phase'
48
- import { foldedTaskLine, summaryChildren } from './layout-summary'
59
+ import { foldedTaskLine, openLaneFrame } from './layout-summary'
60
+ import { legendLines } from './legend'
49
61
  import { chipKey, type LinkChip, planUrlOf } from './link-chips'
50
- import { type NextBlock, nextItems, nextKeys } from './next-rows'
51
62
  import type { PhaseModel } from './phase-models'
52
63
  import { type PlannedPlan, plannedNextLabel } from './planned'
53
64
  import { CHECKS, type ChecksState, failedTestKey, type PullRequest, pullRequestKey } from './pull-requests'
54
65
  import {
66
+ blockRowByKey,
55
67
  boldBracketAt,
56
68
  buttonsAllBold,
57
69
  type FocusHit,
@@ -62,40 +74,39 @@ import {
62
74
  LINKS_KEY_PREFIX,
63
75
  } from './row-focus'
64
76
  import {
77
+ BACKLOG_KEY,
65
78
  type ClickTarget,
66
79
  isBlockFolded,
80
+ isBlockHeadKey,
67
81
  isLaneOpen,
82
+ isLanesOpen,
83
+ LANES_KEY,
68
84
  laneKey,
69
- PLANNED_KEY,
70
85
  type Selection,
71
86
  selectedKey,
72
87
  TOP_BLOCK,
73
88
  type TopBlockId,
74
89
  targetKeys,
90
+ topBlockHeadKey,
91
+ WAITING_KEY,
75
92
  } from './selection'
76
93
  import type { SentChip } from './sent-files'
77
94
  import { shownWithMore } from './shown-with-more'
78
95
  import { askedCheckOf } from './source-asked'
79
- import { type SourceBlock, sourceItems, sourceKeys } from './source-rows'
80
- import { chevronOf, LESS_MARK, NO_AGENT_SYMBOL, statusSymbol, statusWord } from './symbols'
96
+ import { type SourceBlock, type SourceSummary, sourceItems, sourceSummaries } from './source-rows'
97
+ import { chevronOf, LESS_MARK, NO_AGENT_SYMBOL, statusSymbol, statusWord, TURN_MARK } from './symbols'
81
98
  import { BADGE_TEXTS, type BadgeTexts, checksText, laneMarkText, TEXTS } from './texts'
82
99
  import { paintOfRole, paintOfTone, toneOfRole } from './tones'
83
100
  import {
84
- APPOINTMENT_MORE_KEY,
85
101
  allBlockRows,
86
102
  type BlockHit,
87
103
  type BlockRow,
88
- blockRows,
89
104
  buttonsText,
90
- moreKeyOf,
91
- NEXT_MORE_KEY,
92
105
  SHOWN_BLOCK_ROWS,
93
106
  SOURCE_MORE_KEY,
94
- WAITING_MARK,
95
- WAITING_MORE_KEY,
96
- waitingKeys,
97
- waitingRows,
98
107
  } from './waiting-click'
108
+ import { type TurnBalance, turnBalance, turnPage, type YourTurnBlock } from './your-turn'
109
+ import { turnRows, YOUR_TURN_LATER_KEY, YOUR_TURN_MORE_KEY } from './your-turn-rows'
99
110
 
100
111
  /**
101
112
  * Where a lane stands outside its plan. `agent` is the terminal adapter's status, null when no agent is listed;
@@ -116,8 +127,6 @@ export type LaneView = {
116
127
  context?: ContextReading | null
117
128
  /** What the lane works on now, what comes next and after, and what is finished; derived from `progress` when absent. */
118
129
  summary?: LaneSummary
119
- /** The title of the agent's session as the terminal shows it; absent when it has none. */
120
- sessionTitle?: string
121
130
  /** A running lane the sprint table has no row for; drawn with the mark `no row in lanes`. */
122
131
  unlisted?: boolean
123
132
  /** Preview and feedback of the lane's pull requests from the running watcher's state; none without a watcher. */
@@ -135,38 +144,35 @@ export type LaneView = {
135
144
  /** `watch` is the health of the watcher the badges come from, absent when it was not read. */
136
145
  export type OverviewView = {
137
146
  title: string
138
- time: string
139
147
  lanes: LaneView[]
140
148
  planned: PlannedPlan[]
141
149
  watch?: WatchHealth
142
150
  /** True when the config marks a ticket source, so a missing watcher is said without a pull request too. */
143
151
  watchesTickets?: boolean
144
- /** The open items of the board's section of what waits on the user, shown under the title. */
152
+ /** The open items of the board's section of what waits on the user: which source entries "Your turn" holds. */
145
153
  awaiting?: WaitingItem[]
146
- /** The board's next section, in a block of its own under the for-you block; absent for a herd without a board. */
147
- next?: NextBlock
148
- /** The open appointments of the board, in a block of their own under the next block. */
149
- appointments?: AppointmentBlock
150
- /** The sources the watcher runs that are not tickets, in a block of their own under the appointments. */
154
+ /** What is the user's turn, in a block under the title; absent for a herd without a board. */
155
+ yourTurn?: YourTurnBlock
156
+ /** The sources the watcher runs that are not tickets, in a block of their own under "Your turn". */
151
157
  sourceBlock?: SourceBlock
152
158
  /** Whole days since the board was groomed (`groomedDaysAgo`), beside the clock; null or absent when unknown. */
153
159
  groomedDays?: number | null
154
160
  /** What agents report doing that no lane takes, one `now:` line each under the title. */
155
161
  unplaced?: UnplannedTask[]
162
+ /** True when a folded lane's running task shimmers (`shimmerAt` draws it) instead of carrying its `●`. */
163
+ shimmer?: boolean
156
164
  }
157
165
 
158
166
  /**
159
167
  * The drawn screen: a fixed header and footer around a body that scrolls; body lines and targets count from the body's
160
- * top. `waitingBlock` and `appointmentBlock` are what a click on the for-you and the appointment block is matched
161
- * against, null while the block is not drawn.
168
+ * top. `yourTurnBlock` and `sourceBlock` are what a click on these blocks is matched against, null while the block is
169
+ * not drawn.
162
170
  */
163
171
  export type Screen = {
164
172
  header: string[]
165
173
  body: Frame
166
174
  footer: string[]
167
- waitingBlock: BlockHit | null
168
- nextBlock: BlockHit | null
169
- appointmentBlock: BlockHit | null
175
+ yourTurnBlock: BlockHit | null
170
176
  sourceBlock: BlockHit | null
171
177
  /** Every clickable row by the screen line it starts on (header lines first, then the body unscrolled): what the mouse and the keys land on. */
172
178
  focusHits: FocusHit[]
@@ -189,30 +195,42 @@ type LaneColumns = { name: number; marks: number; right: number; phases: boolean
189
195
  type BlockLines = { open: boolean; lines: string[]; hit: Omit<BlockHit, 'firstLine'> }
190
196
 
191
197
  /**
192
- * What a block of the top of the herd is drawn from: its name, the bar of its lines, the paint of the mark that starts
193
- * a row, the key of its `+<n>` line, its points and their rows.
198
+ * What a block of the top of the herd is drawn from: its name, its count and balance in the head, the paint of the mark
199
+ * that starts a row, the key of its `+<n>` line and how many rows that line holds back, and its rows.
194
200
  */
195
201
  type TopBlock = {
196
202
  name: string
197
- bar: () => string
203
+ /** The count after the name; null for a block whose head shows none (the sources). */
204
+ count: number | null
205
+ /** What the head says after the count, painted, open and folded alike; empty for nothing. */
206
+ balance: string
198
207
  markPaint: (text: string) => string
199
- /** The paint of the buttons of a row: amber and bold in "Waiting for you", the normal foreground colour elsewhere. */
208
+ /** The paint of the buttons of a row: amber for "Your turn", the normal foreground colour elsewhere. */
200
209
  buttonPaint: (text: string) => string
201
- /** Whether a row is the selected one or under the mouse: it gets the background, the mark, and bold buttons. */
210
+ /** Whether a row is the selected one or under the mouse: it gets the background, the mark, and its buttons. */
202
211
  focused: (key: string) => boolean
203
212
  /** Whether all buttons of a row are bold: the arrows selected it and the mouse is not on it (the mouse bolds one). */
204
213
  allBold: (key: string) => boolean
205
- /** The paint of the count in the head while the block has points: amber for what waits on the user. */
214
+ /** The paint of the count in the head while it is more than 0: amber for what waits on the user. */
206
215
  countPaint: (text: string) => string
207
- moreKey: string
216
+ moreKey: string | null
217
+ hidden: number
208
218
  expanded: boolean
209
- keys: readonly string[]
210
219
  rows: BlockRow[]
211
220
  }
212
221
 
222
+ /** What the head of a block says: its name, its count, its balance and the paint of the count. */
223
+ type BlockHeadParts = Pick<TopBlock, 'name' | 'count' | 'balance' | 'countPaint'>
224
+
213
225
  /** The blocks on top of the herd one under the other: their lines below the title, and the hit of each drawn block. */
214
226
  type BlockStack = { lines: string[]; hits: (BlockHit | null)[] }
215
227
 
228
+ /** What every lane of the lanes block is drawn with. */
229
+ type LanesContext = { state: Selection; width: number; badgeTexts: BadgeTexts; taskStyle: TaskStyle }
230
+
231
+ /** In front of every line of the lanes block under its head. */
232
+ const LANE_INDENT = ' '
233
+
216
234
  const BRANCH_MARK = '⎇'
217
235
  const SENT_MARK = '📎'
218
236
  const SYMBOL_WIDTH = 2
@@ -230,11 +248,6 @@ const HEADER_MARK = '🐾'
230
248
  const COLUMN_RULE = '│'
231
249
  /** The number badge of a three-digit pull request (`↗ #` and the digits, a cell of padding either side); a longer one widens it. */
232
250
  const PULL_REQUEST_BADGE_WIDTH = 8
233
- /**
234
- * Under the lane line's status symbol: where the pull request row and its badge line start, and where the trunk
235
- * of an open lane hangs, not under the fold arrow.
236
- */
237
- const UNDER_STATUS = ' '
238
251
  const CHECKS_WIDTH = 10
239
252
  /** The most failed tests listed under a pull request; the rest is counted. */
240
253
  const SHOWN_FAILED_TESTS = 3
@@ -479,9 +492,24 @@ const STATUS_SEPARATOR = ' · '
479
492
  /** Between the checkbox count and the phase count of a lane. */
480
493
  const TALLY_SEPARATOR = ' · '
481
494
 
482
- /** `[✦ label]` linked to the chip's address, `private` faint inside it for a link its plan says is private. */
495
+ /** How many cut chip labels the render keeps; a label is often a whole line of a plan, measured anew every frame otherwise. */
496
+ const KEPT_LABELS = 2000
497
+
498
+ const cutLabels = new Map<string, string>()
499
+
500
+ /** The chip label cut to `CHIP_LABEL_WIDTH`, kept per label; the kept ones are dropped all at once when they are too many. */
501
+ const cutLabel = (label: string): string => {
502
+ const kept = cutLabels.get(label)
503
+ if (kept !== undefined) return kept
504
+ if (cutLabels.size >= KEPT_LABELS) cutLabels.clear()
505
+ const cut = fit(plain(label), CHIP_LABEL_WIDTH)
506
+ cutLabels.set(label, cut)
507
+ return cut
508
+ }
509
+
510
+ /** `[🔗 label]` linked to the chip's address, `private` faint inside it for a link its plan says is private. */
483
511
  const chipCell = ({ url, icon, label, private: hidden }: LinkChip): string =>
484
- bracket(url, `${icon} ${fit(plain(label), CHIP_LABEL_WIDTH)}`, hidden ? TEXTS.privateLink : '')
512
+ bracket(url, `${icon} ${cutLabel(label)}`, hidden ? TEXTS.privateLink : '')
485
513
 
486
514
  /** A badge and the key a click on it opens; null for a cell no click opens. */
487
515
  type BadgeCell = { cell: string; key: string | null }
@@ -640,35 +668,39 @@ const foldedPhaseLabel = (view: LaneView): string => {
640
668
  return `${nextPhaseLabel(view)}${more > 0 ? ` +${more}` : ''} · ${phase.done}/${phase.total}`
641
669
  }
642
670
 
643
- /** A lane whose agent rests (idle or done): the folded line says what it waits on instead of the phase and the bar. */
671
+ /** A lane whose agent rests (idle or done): the folded line says what it waits on, and its phase without the bar. */
644
672
  const restsFolded = ({ status }: LaneView, open: boolean): boolean => !open && isRestingAgent(status.agent)
645
673
 
646
- /** The report's `waits on: <question>` split at its label, so the label can sit dim and the reason bright. */
647
- const noteParts = (note: string): { label: string; reason: string } => {
648
- const split = note.indexOf(': ')
649
- return split === -1 ? { label: '', reason: note } : { label: note.slice(0, split + 1), reason: note.slice(split + 2) }
674
+ /**
675
+ * Why a resting lane rests, the label dim and the short text bright; a question to you has its label in the tone of
676
+ * what waits. Empty for a lane without a reason, which works.
677
+ */
678
+ const reasonText = (reason: LaneReason | null): string => {
679
+ if (!reason) return ''
680
+ if (!reason.label) return reason.text
681
+ const label = reason.kind === LANE_REASON.you ? paintOfRole(TONE_ROLE.waiting)(reason.label) : paint.dim(reason.label)
682
+ return `${label} ${reason.text}`
683
+ }
684
+
685
+ /** `P4 12/18`: the phase a resting lane names and its ticked boxes, without the title the reason takes the room of. */
686
+ const restingPhaseLabel = (view: LaneView): string => {
687
+ const phase = headPhase(view)
688
+ return phase ? paint.dim(`${phaseLabel(phase.id)} ${phase.done}/${phase.total}`) : ''
650
689
  }
651
690
 
652
691
  /**
653
- * Why a resting lane rests: the open question of its report (the watcher's `waits on:` line), else the starts-after
654
- * cell of the board (`after: …`), else the plain fact that nothing is known, which is meant to stand out.
692
+ * What follows the status on a lane line: the marker, the reason a resting lane rests, and, folded only, the phase it
693
+ * works on (a resting one by its label and count); the branch is on the session line. Wider than `room`, the reason
694
+ * is cut and the phase stays whole behind it.
655
695
  */
656
- const restDetail = (view: LaneView): string => {
657
- const wait = waitingNote(view.progress.lane)
658
- const { label, reason } = view.restNote
659
- ? noteParts(view.restNote)
660
- : wait !== null
661
- ? { label: TEXTS.restAfter, reason: plain(wait) }
662
- : { label: '', reason: TEXTS.restNoReason }
663
- return label ? `${paint.dim(label)} ${reason}` : reason
664
- }
665
-
666
- /** What follows the status on a lane line: the marker and, folded only, the phase it works on; the branch is on the session line. */
667
- const laneDetail = (view: LaneView, open: boolean): string => {
696
+ const laneDetail = (view: LaneView, open: boolean, reason: LaneReason | null, room: number): string => {
668
697
  const markText = laneMarkText(view)
669
698
  const marker = markText ? paint.yellow(markText) : ''
670
- const phase = restsFolded(view, open) ? restDetail(view) : open ? '' : foldedPhaseLabel(view)
671
- return [agentLabel(view.status.agent), marker, phase].filter(Boolean).join(' ')
699
+ const phase = restsFolded(view, open) ? restingPhaseLabel(view) : open ? '' : foldedPhaseLabel(view)
700
+ const lead = [agentLabel(view.status.agent), marker, reasonText(reason)].filter(Boolean).join(' ')
701
+ if (!reason || !phase) return [lead, phase].filter(Boolean).join(' ')
702
+ const leadRoom = Math.max(room - COLUMN_SEPARATOR.length - visibleLength(phase), 1)
703
+ return `${visibleLength(lead) > leadRoom ? fit(lead, leadRoom) : lead}${COLUMN_SEPARATOR}${phase}`
672
704
  }
673
705
 
674
706
  /**
@@ -684,11 +716,26 @@ export const withDetail = (
684
716
  indent = CONTENT_INDENT,
685
717
  ): string[] => {
686
718
  if (!detail) return [spread(head, right, width)]
687
- const room = width - visibleLength(head) - COLUMN_SEPARATOR.length - visibleLength(right) - 1
688
- if (room >= MIN_BESIDE_WIDTH) return [spread(`${head}${COLUMN_SEPARATOR}${detail}`, right, width)]
719
+ if (detailPlacement(head, right, width, indent).beside)
720
+ return [spread(`${head}${COLUMN_SEPARATOR}${detail}`, right, width)]
689
721
  return [spread(head, right, width), fit(`${indent}${detail}`, width)]
690
722
  }
691
723
 
724
+ /**
725
+ * Where `withDetail` puts a detail and how many cells it has there: beside `head` and `right` while at least
726
+ * `MIN_BESIDE_WIDTH` are left on that line, else on a line of its own behind `indent`.
727
+ */
728
+ const detailPlacement = (
729
+ head: string,
730
+ right: string,
731
+ width: number,
732
+ indent: string,
733
+ ): { beside: boolean; room: number } => {
734
+ const besideRoom = width - visibleLength(head) - COLUMN_SEPARATOR.length - visibleLength(right) - 1
735
+ const beside = besideRoom >= MIN_BESIDE_WIDTH
736
+ return { beside, room: beside ? besideRoom : width - visibleLength(indent) }
737
+ }
738
+
692
739
  /** The summary the snapshot read for the lane, or the one its own steps give. */
693
740
  const summaryOf = (view: LaneView): LaneSummary =>
694
741
  view.summary ?? laneSummary({ progress: view.progress, phases: {}, activity: {} }, 0)
@@ -699,10 +746,14 @@ const laneProgressColumn = (view: LaneView, tallyWidth: number): string => {
699
746
  return tally.total > 0 ? progressColumn(tally, tallyWidth) : ''
700
747
  }
701
748
 
702
- /** Context and bar at the right edge of a folded lane line, as wide as the lane has them. */
749
+ /** The context value alone, from `warn` on, for a lane line that draws no bar after it; empty below or without a reading. */
750
+ const loneContextValue = (context: ContextReading | null | undefined): string =>
751
+ context && context.level !== CONTEXT_LEVEL.ok ? contextValue(context) : ''
752
+
753
+ /** Context and bar at the right edge of a folded lane line, as wide as the lane has them; no rule without a bar. */
703
754
  const laneRight = (view: LaneView, tallyWidth: number): string => {
704
755
  const counts = restsFolded(view, false) ? '' : laneProgressColumn(view, tallyWidth)
705
- return counts ? `${contextCell(view.context)}${counts}` : contextCell(view.context).trimEnd()
756
+ return counts ? `${contextCell(view.context)}${counts}` : loneContextValue(view.context)
706
757
  }
707
758
 
708
759
  /** The cells of the phase count column, 0 when the pane leaves it out. */
@@ -718,99 +769,88 @@ const markedRight = (view: LaneView, columns: LaneColumns): string => {
718
769
  return `${padVisible(marks, columns.marks)}${COLUMN_SEPARATOR}${pad}${right}`
719
770
  }
720
771
 
721
- /**
722
- * Before a lane's detail on a line of its own: blanks up to the column of the lane's name, so a folded lane's title
723
- * starts where its name does, as the branches of an open lane do; an open lane keeps its trunk in front.
724
- */
725
- const detailIndent = (open: boolean, nameColumn: number): string => {
726
- if (!open) return ' '.repeat(nameColumn)
727
- const trunk = `${UNDER_STATUS}${TREE.pipe}`
728
- return `${paint.dim(trunk)}${' '.repeat(Math.max(nameColumn - visibleLength(trunk), 0))}`
729
- }
730
-
731
- /** The task row below a folded lane's phase row, under the lane's name; none while the phase has no task on it. */
732
- const foldedTaskRow = (view: LaneView, indent: string, width: number): string[] => {
772
+ /** The task row below a folded lane's phase row, under the lane's name; none while the lane reports no task. */
773
+ const foldedTaskRow = (view: LaneView, indent: string, width: number, style: TaskStyle): string[] => {
733
774
  const phase = headPhase(view)
734
- const { active: running, subagentTasks } = summaryOf(view)
735
- const active = running.find((each) => each.phase.id === phase?.id)
736
- const line = active && foldedTaskLine(active, subagentTasks, TEXTS.herd, indent, width)
775
+ const summary = summaryOf(view)
776
+ const active = summary.active.find((each) => each.phase.id === phase?.id)
777
+ const line = foldedTaskLine(active, summary, TEXTS.herd, { indent, width, style })
737
778
  return line ? [line] : []
738
779
  }
739
780
 
740
- const laneLines = (view: LaneView, open: boolean, width: number, columns: LaneColumns): string[] => {
781
+ /**
782
+ * The lines of a lane's own row: chevron, status symbol, name, detail and right column; a detail on a line of its own,
783
+ * and a folded lane's task row, start in the column of the name, as the children of an open lane do.
784
+ */
785
+ const laneLines = (
786
+ view: LaneView,
787
+ open: boolean,
788
+ { width, taskStyle }: Pick<LanesContext, 'width' | 'taskStyle'>,
789
+ columns: LaneColumns,
790
+ reason: LaneReason | null,
791
+ ): string[] => {
741
792
  const { progress, status } = view
742
793
  const nameStyle = restsFolded(view, open) ? paint.dim : paint.bold
743
794
  const cells = padVisible(nameStyle(fit(progress.lane.name, columns.name)), columns.name)
744
795
  const head = `${paint.dim(chevronOf(open))} ${symbolCell(status.agent)} ${cells}`
745
796
  const right = open ? laneProgressColumn(view, tallyWidthOf(columns)) : markedRight(view, columns)
746
- const nameColumn = visibleLength(head) - visibleLength(cells)
747
- const lines = withDetail(head, laneDetail(view, open), right, width, detailIndent(open, nameColumn))
748
- return open ? lines : [...lines, ...foldedTaskRow(view, ' '.repeat(nameColumn), width)]
797
+ const nameIndent = ' '.repeat(visibleLength(head) - visibleLength(cells))
798
+ const { room } = detailPlacement(head, right, width, nameIndent)
799
+ const lines = withDetail(head, laneDetail(view, open, reason, room), right, width, nameIndent)
800
+ return open ? lines : [...lines, ...foldedTaskRow(view, nameIndent, width, taskStyle)]
749
801
  }
750
802
 
751
- /** The parts of a session line: the session's title (the branch without one) at the left, the branch and the context value. */
752
- const sessionParts = (view: LaneView): { left: string; git: string; branch: string; context: string } => {
753
- const git = gitLabel(view.status)
754
- const branch = branchLabel(view.status)
755
- return { left: view.sessionTitle ? plain(view.sessionTitle) : git, git, branch, context: contextValue(view.context) }
803
+ /** The running phase the lane line names, with its step and age; undefined while it names none or that one does not run. */
804
+ const headActive = (view: LaneView): ActivePhase | undefined => {
805
+ const phase = headPhase(view)
806
+ return phase ? summaryOf(view).active.find((active) => active.phase.id === phase.id) : undefined
756
807
  }
757
808
 
758
809
  /**
759
- * The session branch of an open lane: the session's title at the left; the branch at the end of the line, before the
760
- * context at the right. The title keeps its room: the branch gives way when both do not fit, and only then is the
761
- * title cut. Without a title the branch stands at the left.
810
+ * `Opus`: the model cell of the board row the lane works on, the row of its running phase, else of its current one;
811
+ * empty when that cell is. Not the model a subagent ran on, which may differ from the lane's own session.
762
812
  */
763
- const sessionLine = (view: LaneView, width: number): string => {
764
- const { left, git, branch, context } = sessionParts(view)
765
- if (!view.sessionTitle) return spread(fit(git, Math.max(width - CONTEXT_CELL_WIDTH, 1)), context, width)
766
- const room = width - (context ? CONTEXT_CELL_WIDTH : 0)
767
- const fitting = [git, branch].find(
768
- (label) => visibleLength(left) + COLUMN_SEPARATOR.length + visibleLength(label) <= room,
769
- )
770
- const right = [fitting ?? '', context].filter(Boolean).join(COLUMN_SEPARATOR)
771
- return spread(fit(left, Math.max(room, 1)), right, width)
813
+ const modelLabel = (view: LaneView): string => {
814
+ const { lane, current } = view.progress
815
+ const stepIndex = headActive(view)?.stepIndex ?? current?.stepIndex
816
+ return stepIndex === undefined ? '' : plain(lane.steps[stepIndex]?.model ?? '').trim()
817
+ }
818
+
819
+ /** `idle 4 h`: how long a resting lane's phase has not moved; empty while the lane works or the herd saw no move. */
820
+ const idleLabel = (view: LaneView): string => {
821
+ const minutes = isRestingAgent(view.status.agent) ? headActive(view)?.minutesAgo : null
822
+ return minutes === null || minutes === undefined ? '' : TEXTS.idleFor(minutes)
772
823
  }
773
824
 
774
825
  /**
775
- * The session line with the lane's one link chip on it, so the lane needs no row of chips: title, chip, branch and
776
- * context. When the line is short the ahead and dirty counts of the branch go first, then the title is cut with `…`;
777
- * without a title the branch stands at the left and the chip at the right. Null when even the cut title would be
778
- * narrower than `MIN_TITLE_WIDTH`, and the chip keeps its row of chips.
826
+ * The session line of an open lane: `⎇ branch ↑2 ±1 · Sonnet · idle 4 h`, the branch with its counts, the model and the
827
+ * rest dim, cut before the context value at the right.
779
828
  */
780
- const sessionWithChip = (view: LaneView, chip: BadgeCell, width: number): Frame | null => {
781
- const { left, git, branch, context } = sessionParts(view)
782
- const tails = view.sessionTitle
783
- ? [
784
- [chip.cell, git, context],
785
- [chip.cell, branch, context],
786
- ]
787
- : [[chip.cell, context]]
788
- const candidates = tails.map((tail) => tail.filter(Boolean).join(COLUMN_SEPARATOR))
789
- const roomFor = (candidate: string) => width - COLUMN_SEPARATOR.length - visibleLength(candidate)
790
- const narrowest = candidates[candidates.length - 1] ?? ''
791
- const cuts = Boolean(view.sessionTitle) && roomFor(narrowest) >= MIN_TITLE_WIDTH
792
- const right = candidates.find((candidate) => visibleLength(left) <= roomFor(candidate)) ?? (cuts ? narrowest : null)
793
- if (right === null) return null
794
- const from = width - visibleLength(right)
795
- const targets = chip.key === null ? [] : [{ line: 0, from, to: from + visibleLength(chip.cell), key: chip.key }]
796
- return { lines: [spread(left, right, width)], cursorLine: -1, targets }
797
- }
798
-
799
- /** Session and link chips of an open lane: a lone chip on the session line when it fits there, else a row of chips. */
800
- const sessionAndChips = (
801
- view: LaneView,
802
- tickets: LaneTickets | null,
803
- width: number,
804
- ): { session: Frame; chips: Frame } => {
805
- const cells = chipCells(laneChips(view), tickets, view.sentFiles)
806
- const [lone] = cells
807
- const inline = cells.length === 1 && lone ? sessionWithChip(view, lone, width) : null
808
- if (inline) return { session: inline, chips: textFrame([]) }
809
- return { session: textFrame([sessionLine(view, width)]), chips: packedFrame(cells, '', width) }
829
+ const sessionLine = (view: LaneView, width: number): string => {
830
+ const words = [modelLabel(view), idleLabel(view)].filter(Boolean).map((word) => paint.dim(word))
831
+ const left = [gitLabel(view.status), ...words].join(paint.dim(PLACE_SEPARATOR))
832
+ const context = contextValue(view.context)
833
+ return spread(fit(left, Math.max(width - (context ? CONTEXT_CELL_WIDTH : 0), 1)), context, width)
810
834
  }
811
835
 
812
- const underStatus = (frame: Frame): Frame =>
813
- prefixed(frame, { first: UNDER_STATUS, rest: UNDER_STATUS, style: (text) => text })
836
+ /** How many of `cells` fit on one line of `width` cells with the `+<n>` for the rest; at least one, which is then cut. */
837
+ const fittingChips = (cells: BadgeCell[], width: number): number => {
838
+ const fits = (count: number) => {
839
+ const rest = cells.length - count
840
+ return packedWidth(cells.slice(0, count)) + (rest > 0 ? CHIP_GAP.length + `+${rest}`.length : 0) <= width
841
+ }
842
+ return Array.from({ length: cells.length }, (_, index) => cells.length - index).find(fits) ?? 1
843
+ }
844
+
845
+ /** The link chips of an open lane on one line, `+<n>` dim for the ones past the pane's edge; each chip a click target over its columns. */
846
+ const chipLine = (cells: BadgeCell[], width: number): Frame => {
847
+ if (cells.length === 0) return textFrame([])
848
+ const count = fittingChips(cells, width)
849
+ const shown = cells.slice(0, count)
850
+ const rest = cells.length - count
851
+ const line = `${shown.map(({ cell }) => cell).join(CHIP_GAP)}${rest > 0 ? `${CHIP_GAP}${paint.dim(`+${rest}`)}` : ''}`
852
+ return { lines: [fit(line, width)], cursorLine: -1, targets: cellTargets(shown, 0, 0, width) }
853
+ }
814
854
 
815
855
  /** The chips of the plans the lane's steps name, in the order of its steps. */
816
856
  const laneChips = ({ progress, chips }: LaneView): LinkChip[] =>
@@ -826,13 +866,15 @@ const pullRequestsFrame = (view: LaneView, context: LaneContext): Frame => {
826
866
  )
827
867
  }
828
868
 
829
- /** The lane line is a row of its own; an open lane continues with its pull requests and plans. */
869
+ /**
870
+ * The lane line is a row of its own; an open lane continues after a blank line with its session, links, pull requests
871
+ * and phases (`openLaneFrame`), indented to the column of the lane's name.
872
+ */
830
873
  const laneBlock = (
831
874
  view: LaneView,
832
- state: Selection,
833
- width: number,
875
+ { state, width, badgeTexts, taskStyle }: LanesContext,
834
876
  columns: LaneColumns,
835
- badgeTexts: BadgeTexts,
877
+ reason: LaneReason | null,
836
878
  ): Frame => {
837
879
  const context: LaneContext = {
838
880
  progress: view.progress,
@@ -843,20 +885,20 @@ const laneBlock = (
843
885
  badgeTexts,
844
886
  tickets: view.tickets ?? null,
845
887
  models: view.models ?? {},
888
+ taskStyle,
846
889
  }
847
890
  const open = isLaneOpen(state, view.progress.lane.name, view.status.agent)
848
- const row = rowFrame(laneKey(view.progress.lane.name), laneLines(view, open, width, columns), [], context)
891
+ const lines = laneLines(view, open, { width, taskStyle }, columns, reason)
892
+ const row = rowFrame(laneKey(view.progress.lane.name), lines, [], context)
849
893
  if (!open) return row
850
- const inner = deeper({ ...context, width: Math.max(width - UNDER_STATUS.length, 1) })
851
- const head = { ...sessionAndChips(view, inner.tickets, inner.width), pullRequests: pullRequestsFrame(view, inner) }
852
- const [session, ...sections] = summaryChildren(inner, summaryOf(view), head)
853
- const alone = sections.every((section) => section.lines.length === 0)
854
- const sessionLead = alone ? { first: TREE.last, rest: TREE.gap } : { first: TREE.branch, rest: TREE.pipe }
855
- return joinFrames([
856
- row,
857
- underStatus(prefixed(session, { ...sessionLead, style: paint.dim })),
858
- underStatus(nest(sections)),
859
- ])
894
+ const inner = narrower(context, LANE_HEAD_CELLS)
895
+ const head = {
896
+ session: textFrame([sessionLine(view, inner.width)]),
897
+ chips: chipLine(chipCells(laneChips(view), inner.tickets, view.sentFiles), inner.width),
898
+ pullRequests: pullRequestsFrame(view, inner),
899
+ }
900
+ const children = openLaneFrame(inner, summaryOf(view), head)
901
+ return joinFrames([row, textFrame(['']), indented(children, ' '.repeat(LANE_HEAD_CELLS))])
860
902
  }
861
903
 
862
904
  const laneTally = ({ progress }: Pick<LaneView, 'progress'>): string =>
@@ -868,16 +910,49 @@ const parkedItem = (view: Pick<LaneView, 'progress'>): string => {
868
910
  return label.replaceAll(' ', NON_BREAKING_SPACE)
869
911
  }
870
912
 
871
- /** ` name 2/5 after P1 gemergt`: one dimmed row per lane that waits, its wait cut at the pane's edge. */
872
- const waitingBlock = (lanes: LaneView[], width: number): string[] => {
873
- if (lanes.length === 0) return []
874
- const head = paint.dim(`${NO_AGENT_SYMBOL} ${TEXTS.waiting(lanes.length)}`)
875
- const rows = lanes.map((lane) => {
876
- const note = waitingNote(lane.progress.lane)
877
- const item = laneTally(lane)
878
- return paint.dim(fit(note === null ? ` ${item}` : ` ${item} ${TEXTS.startsAfter(plain(note))}`, width))
879
- })
880
- return [head, ...rows]
913
+ /**
914
+ * A block of the body: its head a row under `key` (`▸ <name> · <n> <balance>` folded, the rule after it open), and
915
+ * the lines of `content` under it while open.
916
+ */
917
+ const bodyBlock = (
918
+ key: string,
919
+ head: BlockHeadParts,
920
+ open: boolean,
921
+ content: () => Frame,
922
+ { state, width }: { state: Selection; width: number },
923
+ ): Frame =>
924
+ joinFrames([rowFrame(key, [blockHead(head, open, width)], [], { state, width }), ...(open ? [content()] : [])])
925
+
926
+ /** The names of the lanes, dim and joined, the ones past the pane's edge left out. */
927
+ const laneNames = (lanes: readonly LaneView[], width: number): string =>
928
+ paint.dim(
929
+ visibleStart(
930
+ lanes.map(({ progress }) => progress.lane.name),
931
+ ', ',
932
+ width,
933
+ ),
934
+ )
935
+
936
+ /**
937
+ * The lanes that wait for their start, folded until the user opens them (`▸ Waiting · 2 name, other`); open,
938
+ * ` name 2/5 after P1 gemergt` per lane, dimmed and cut at the pane's edge.
939
+ */
940
+ const waitingFrame = (lanes: LaneView[], state: Selection, width: number): Frame => {
941
+ const rows = () =>
942
+ textFrame(
943
+ lanes.map((lane) => {
944
+ const note = waitingNote(lane.progress.lane)
945
+ const item = laneTally(lane)
946
+ return paint.dim(fit(note === null ? ` ${item}` : ` ${item} ${TEXTS.startsAfter(plain(note))}`, width))
947
+ }),
948
+ )
949
+ const head = {
950
+ name: TEXTS.waitingHeading,
951
+ count: lanes.length,
952
+ balance: laneNames(lanes, width),
953
+ countPaint: unpainted,
954
+ }
955
+ return bodyBlock(WAITING_KEY, head, state.expanded.has(WAITING_KEY), rows, { state, width })
881
956
  }
882
957
 
883
958
  /** `· no agent (6) name 3/9 · other 1/4`: one wrapping block for every parked lane, each with its progress. */
@@ -891,16 +966,36 @@ const parkedBlock = (lanes: LaneView[], width: number): string[] => {
891
966
  return besideHead(head, items.join(' '), width, (line) => paint.dim(line.replaceAll(NON_BREAKING_SPACE, ' ')))
892
967
  }
893
968
 
969
+ /** The texts joined by `separator`, the ones that start past `width` cells left out: what follows is cut anyway. */
970
+ const visibleStart = (texts: readonly string[], separator: string, width: number): string => {
971
+ const shown: string[] = []
972
+ let used = 0
973
+ for (const text of texts) {
974
+ if (used > width) break
975
+ shown.push(text)
976
+ used += visibleLength(text) + separator.length
977
+ }
978
+ return shown.join(separator)
979
+ }
980
+
894
981
  /**
895
- * The closing row for the phases no lane works on, dimmed and closed unless its key is expanded. Opened, it
896
- * lists each plan with its open phases and their checkboxes, and no checkbox itself.
982
+ * The backlog, the phases no lane works on, folded until the user opens it: `▸ Backlog · <plans> next: Plan P3, …`.
983
+ * Opened, it lists each plan with its next phase and its checkboxes, and no checkbox itself.
897
984
  */
898
- const plannedFrame = (planned: PlannedPlan[], state: Selection, width: number): Frame => {
899
- if (planned.length === 0) return textFrame([])
900
- const open = state.expanded.has(PLANNED_KEY)
901
- const next = planned.map((plan) => `${plain(plan.title)} ${plannedNextLabel(plan)}`).join(', ')
902
- const head = [paint.dim(fit(`${chevronOf(open)} ${TEXTS.plannedHead(planned.length)} ${next}`, width))]
903
- return rowFrame(PLANNED_KEY, head, open ? plannedRows(planned, width, CONTENT_INDENT).flat() : [], { state, width })
985
+ const backlogFrame = (planned: PlannedPlan[], state: Selection, width: number): Frame => {
986
+ const next = visibleStart(
987
+ planned.map((plan) => `${plain(plan.title)} ${plannedNextLabel(plan)}`),
988
+ ', ',
989
+ width,
990
+ )
991
+ const head = {
992
+ name: TEXTS.backlogHeading,
993
+ count: planned.length,
994
+ balance: paint.dim(`${TEXTS.backlogNext} ${next}`),
995
+ countPaint: unpainted,
996
+ }
997
+ const rows = () => textFrame(plannedRows(planned, width, CONTENT_INDENT).flat())
998
+ return bodyBlock(BACKLOG_KEY, head, state.expanded.has(BACKLOG_KEY), rows, { state, width })
904
999
  }
905
1000
 
906
1001
  /** A dimmed row per plan, one list of lines each: its title and open phases at the left, its open checkbox count at the right. */
@@ -917,20 +1012,20 @@ const plannedRows = (planned: PlannedPlan[], width: number, indent: string): str
917
1012
 
918
1013
  const rule = (width: number): string => paint.dim(RULE.repeat(width))
919
1014
 
920
- /** The amber bar that starts every line of the for-you block: the one signal that this waits on the user. */
921
- const waitingBar = (): string => statusPaint(AGENT_STATUS.blocked)(WAITING_MARK)
922
-
923
- /** The grey bar of the appointment block: it waits on nobody's answer. */
924
- const appointmentBar = (): string => paint.dim(WAITING_MARK)
1015
+ /** The blank that starts every line of a block on top: no bar, the mark of a row says what it is. */
1016
+ const blockBar = (): string => ' '
925
1017
 
926
1018
  /**
927
- * The head of an open block, `▾ <name> · <n> ──`, filled with the rule to the width, so it is the rule above the block.
928
- * The label and the count wear the normal colour (the count in `countPaint`), only the rule is dim.
1019
+ * The head of a block, `▾ <name> · <n> <balance> ──` while open, filled with the rule to the width, so it is the rule
1020
+ * above the block; `▸ <name> · <n> <balance>` while folded. The count (in `countPaint` while it is more than 0) is
1021
+ * left out for a block without one; the balance comes painted.
929
1022
  */
930
- const blockHead = (name: string, count: number, width: number, countPaint: (text: string) => string): string => {
931
- const lead = `${chevronOf(true)} ${name} ${ITEM_SEPARATOR} `
932
- const used = textWidth(`${lead}${count} `)
933
- return `${lead}${countPaint(String(count))} ${paint.dim(RULE.repeat(Math.max(width - used, 0)))}`
1023
+ const blockHead = ({ name, count, balance, countPaint }: BlockHeadParts, open: boolean, width: number): string => {
1024
+ const counted = count === null ? '' : ` ${ITEM_SEPARATOR} ${count > 0 ? countPaint(String(count)) : count}`
1025
+ const lead = `${paint.dim(chevronOf(open))} ${name}${counted}${balance ? ` ${balance}` : ''}`
1026
+ if (!open) return fit(lead, width)
1027
+ const cut = fit(lead, Math.max(width - 2, 1))
1028
+ return `${cut} ${paint.dim(RULE.repeat(Math.max(width - visibleLength(cut) - 1, 0)))}`
934
1029
  }
935
1030
 
936
1031
  const markedLine = (line: string, row: BlockRow, markPaint: (text: string) => string): string => {
@@ -946,133 +1041,120 @@ const dimmedRest = (text: string, from: number): string => {
946
1041
 
947
1042
  /**
948
1043
  * One row as lines: the bar on each, the mark painted on the first line only, the short title in the normal colour
949
- * and the rest of an expanded row dim, linked when the row has an address; the `[ask]` button at the end of a
950
- * question's first line wears the mark's paint.
1044
+ * and the rest of an expanded row dim, linked when the row has an address. The buttons at the end of the first line
1045
+ * are drawn only on the focused row, in the block's button paint; they keep their columns on every row.
951
1046
  */
952
1047
  const blockRowLines = (
953
- { bar, markPaint, buttonPaint, focused, allBold }: TopBlock,
1048
+ { markPaint, buttonPaint, focused, allBold }: TopBlock,
954
1049
  row: BlockRow,
955
1050
  width: number,
956
1051
  ): string[] => {
957
1052
  const hot = focused(row.key)
958
1053
  const paintButton = (text: string) => (allBold(row.key) ? paint.bold(buttonPaint(text)) : buttonPaint(text))
959
1054
  return row.lines.map((line, index) => {
960
- const withButton = index === 0 && row.buttons.length > 0
1055
+ const withButton = hot && index === 0 && row.buttons.length > 0
961
1056
  const styled = dimmedRest(line, row.dimFrom[index] ?? line.length)
962
1057
  const marked = index === 0 ? markedLine(styled, row, markPaint) : styled
963
1058
  const shown = row.url ? link(row.url, marked) : marked
964
1059
  const buttons = withButton ? buttonsText(row.buttons, row.dim ? paint.dim : paintButton) : ''
965
- const drawn = `${bar()} ${shown}${buttons}`
1060
+ const drawn = `${blockBar()} ${shown}${buttons}`
966
1061
  return hot ? focusLine(drawn, width, index === 0) : drawn
967
1062
  })
968
1063
  }
969
1064
 
970
- /** A folded block: the one line `▸ <name> · <n>` and the first title as a preview; a click matches no row and no `+<n>` line. */
971
- const foldedBlockLines = ({ name, keys, rows }: TopBlock, width: number): BlockLines => {
972
- const preview = rows[0]?.preview
973
- const head = `${chevronOf(false)} ${name} ${ITEM_SEPARATOR} ${keys.length}`
974
- return {
975
- open: false,
976
- lines: [paint.dim(fit(preview === undefined ? head : `${head} ${preview}`, width))],
977
- hit: { rows: [], moreKey: null, keys },
978
- }
1065
+ /** A folded block: its head alone, which keeps the count and the balance; a click matches no row and no `+<n>` line. */
1066
+ const foldedBlockLines = (block: TopBlock, headKey: string, width: number): BlockLines => ({
1067
+ open: false,
1068
+ lines: [focusedHead(block, headKey, false, width)],
1069
+ hit: { rows: [], moreKey: null, headKey },
1070
+ })
1071
+
1072
+ /** The head of a block on top, drawn like a focused row while the arrows or the mouse are on it. */
1073
+ const focusedHead = (block: TopBlock, headKey: string, open: boolean, width: number): string => {
1074
+ const head = blockHead(block, open, width)
1075
+ return block.focused(headKey) ? focusLine(head, width, true) : head
979
1076
  }
980
1077
 
981
1078
  /**
982
- * An open block on top of the herd: the head `▾ <name> · <n> ──`, one barred line per row (the first five, all while the
983
- * block is expanded) and `▌ +<n>` for the rest, which turns into `▌ ▴ <n>` once all show and folds them again. A row wider than the pane is cut to
984
- * one line; once expanded it wraps over several. The rule below is the stack's, not the block's.
1079
+ * An open block on top of the herd: the head, one line per row and `+<n>` for the rows `hidden` holds back, which turns
1080
+ * into `▴ <n>` once all show and folds them again. A row wider than the pane is cut to one line; once expanded it
1081
+ * wraps over several. The rule below is the stack's, not the block's.
985
1082
  */
986
- const openBlockLines = (block: TopBlock, width: number): BlockLines => {
987
- const count = block.keys.length
988
- const more = Math.max(count - SHOWN_BLOCK_ROWS, 0)
989
- const moreLine = block.expanded ? `${LESS_MARK} ${more}` : `+${more}`
1083
+ const openBlockLines = (block: TopBlock, headKey: string, width: number): BlockLines => {
1084
+ const moreLine = block.expanded ? `${LESS_MARK} ${block.hidden}` : `+${block.hidden}`
1085
+ const withMore = block.moreKey !== null && block.hidden > 0
990
1086
  return {
991
1087
  open: true,
992
1088
  lines: [
993
- blockHead(block.name, count, width, count > 0 ? block.countPaint : (text) => text),
1089
+ focusedHead(block, headKey, true, width),
994
1090
  ...block.rows.flatMap((row) => blockRowLines(block, row, width)),
995
- ...(more > 0 ? [`${block.bar()} ${moreLine}`] : []),
1091
+ ...(withMore ? [`${blockBar()} ${moreLine}`] : []),
996
1092
  ],
997
- hit: { rows: block.rows, moreKey: moreKeyOf(count, block.moreKey), keys: block.keys },
1093
+ hit: { rows: block.rows, moreKey: withMore ? block.moreKey : null, headKey },
998
1094
  }
999
1095
  }
1000
1096
 
1001
- const blockLinesOf = (block: TopBlock, state: Selection, id: TopBlockId, width: number): BlockLines =>
1002
- isBlockFolded(state, id, block.keys) ? foldedBlockLines(block, width) : openBlockLines(block, width)
1003
-
1004
- /**
1005
- * What waits on the user's answer, on top of the herd until it is closed: `▌ 📎 name` for a sent file or an
1006
- * Artifact, `▌ ? question` for a question of the board or of a session's dialog, in the amber of a lane that waits.
1007
- * One line while the user folded it.
1008
- */
1009
- const waitingLines = (waiting: readonly WaitingItem[], state: Selection, width: number): BlockLines => {
1010
- const keys = waitingKeys(waiting)
1011
- const block: TopBlock = {
1012
- name: TEXTS.waitingHeading,
1013
- bar: waitingBar,
1014
- markPaint: statusPaint(AGENT_STATUS.blocked),
1015
- buttonPaint: statusPaint(AGENT_STATUS.blocked),
1016
- focused: (key) => isFocused(state, key),
1017
- allBold: (key) => buttonsAllBold(state, key),
1018
- countPaint: statusPaint(AGENT_STATUS.blocked),
1019
- moreKey: WAITING_MORE_KEY,
1020
- expanded: state.expanded.has(WAITING_MORE_KEY),
1021
- keys,
1022
- rows: waitingRows(waiting, state.expanded, width),
1023
- }
1024
- return blockLinesOf(block, state, TOP_BLOCK.waiting, width)
1097
+ const blockLinesOf = (block: TopBlock, state: Selection, id: TopBlockId, width: number): BlockLines => {
1098
+ const headKey = topBlockHeadKey(id)
1099
+ return isBlockFolded(state, id) ? foldedBlockLines(block, headKey, width) : openBlockLines(block, headKey, width)
1025
1100
  }
1026
1101
 
1027
- /** The open appointments of the board under the for-you block: `▌ ◷ Mo 09:03 Release 2.0 rollout`, in a grey bar and with a grey mark. */
1028
- const appointmentLines = ({ rows }: AppointmentBlock, state: Selection, width: number): BlockLines => {
1029
- const keys = appointmentKeys(rows)
1030
- const block: TopBlock = {
1031
- name: TEXTS.appointmentHeading,
1032
- bar: appointmentBar,
1033
- markPaint: paint.dim,
1034
- buttonPaint: (text) => text,
1035
- focused: (key) => isFocused(state, key),
1036
- allBold: (key) => buttonsAllBold(state, key),
1037
- countPaint: (text) => text,
1038
- moreKey: APPOINTMENT_MORE_KEY,
1039
- expanded: state.expanded.has(APPOINTMENT_MORE_KEY),
1040
- keys,
1041
- rows: blockRows(appointmentItems(rows), state.expanded, width, APPOINTMENT_MORE_KEY),
1042
- }
1043
- return blockLinesOf(block, state, TOP_BLOCK.appointments, width)
1044
- }
1102
+ /** `4 block lanes · oldest 12 d`: what blocks a lane in the tone of what waits, the age dim. */
1103
+ const balanceText = ({ blocking, oldest }: TurnBalance): string =>
1104
+ [blocking === null ? '' : paintOfRole(TONE_ROLE.waiting)(blocking), oldest === null ? '' : paint.dim(oldest)]
1105
+ .filter(Boolean)
1106
+ .join(paint.dim(` ${ITEM_SEPARATOR} `))
1045
1107
 
1046
- /** The bar of the next block: a blank, since it asks nothing of the user and only its numbers lead the rows. */
1047
- const nextBar = (): string => ' '
1108
+ /** The mark of a row of "Your turn": `?` and `📎` in the tone of what waits, `◷` in the tone of what is planned, chevrons dim. */
1109
+ const turnMarkPaint = (mark: string): string => {
1110
+ if (mark === TURN_MARK.planned) return paintOfRole(TONE_ROLE.planned)(mark)
1111
+ return mark === chevronOf(true) || mark === chevronOf(false) ? paint.dim(mark) : paintOfRole(TONE_ROLE.waiting)(mark)
1112
+ }
1048
1113
 
1049
- /** The next block names nothing queued in one dim line, which has no rows to click. */
1050
- const emptyNextLines = (): BlockLines => ({
1114
+ /** "Your turn" without a point: one line that cannot be opened, its name in the column of the other block names. */
1115
+ const emptyTurnLines = (): BlockLines => ({
1051
1116
  open: false,
1052
- lines: [paint.dim(`${TEXTS.nextHeading} ${ITEM_SEPARATOR} ${TEXTS.nextEmpty}`)],
1053
- hit: { rows: [], moreKey: null, keys: [] },
1117
+ lines: [`${blockBar()} ${TEXTS.yourTurnHeading} ${paint.dim(`${ITEM_SEPARATOR} ${TEXTS.yourTurnEmpty}`)}`],
1118
+ hit: { rows: [], moreKey: null },
1054
1119
  })
1055
1120
 
1056
- /** The board's next section under the for-you block: ` 1 Answer the phone tests`, numbered, without the amber bar. */
1057
- const nextLines = ({ rows }: NextBlock, state: Selection, width: number): BlockLines => {
1058
- if (rows.length === 0) return emptyNextLines()
1121
+ /**
1122
+ * What is the user's turn, on top of the herd: the head `Your turn · <n>` with its balance, folded until the user opens
1123
+ * it; open, the now rows and the planned rows (`turnRows`), told apart by their mark alone.
1124
+ */
1125
+ const yourTurnLines = ({ turn, now }: YourTurnBlock, state: Selection, width: number): BlockLines => {
1126
+ const count = turn.now.length + turn.planned.length
1127
+ if (count === 0) return emptyTurnLines()
1128
+ const opened = { allNow: state.expanded.has(YOUR_TURN_MORE_KEY), allPlanned: state.expanded.has(YOUR_TURN_LATER_KEY) }
1129
+ const clock = new Date(now)
1059
1130
  const block: TopBlock = {
1060
- name: TEXTS.nextHeading,
1061
- bar: nextBar,
1062
- markPaint: paint.dim,
1063
- buttonPaint: (text) => text,
1131
+ name: TEXTS.yourTurnHeading,
1132
+ count,
1133
+ balance: balanceText(turnBalance(turn, clock)),
1134
+ markPaint: turnMarkPaint,
1135
+ buttonPaint: paintOfRole(TONE_ROLE.waiting),
1064
1136
  focused: (key) => isFocused(state, key),
1065
1137
  allBold: (key) => buttonsAllBold(state, key),
1066
- countPaint: (text) => text,
1067
- moreKey: NEXT_MORE_KEY,
1068
- expanded: state.expanded.has(NEXT_MORE_KEY),
1069
- keys: nextKeys(rows),
1070
- rows: blockRows(nextItems(rows), state.expanded, width, NEXT_MORE_KEY),
1138
+ countPaint: turn.now.length > 0 ? paintOfRole(TONE_ROLE.waiting) : (text) => text,
1139
+ moreKey: null,
1140
+ hidden: 0,
1141
+ expanded: false,
1142
+ rows: turnRows(turnPage(turn, opened, clock), width),
1071
1143
  }
1072
- return blockLinesOf(block, state, TOP_BLOCK.next, width)
1144
+ return blockLinesOf(block, state, TOP_BLOCK.yourTurn, width)
1073
1145
  }
1074
1146
 
1075
- /** The sources the watcher runs: ` ● errors ran 12 min ago · <newest report>`, the mark amber while the report is new, red while the source fails. */
1147
+ /** `grafana 5 entries, 1 new · seobility 7 h ago`, each source in its tone, dim without one. */
1148
+ const sourceSummaryText = (summaries: readonly SourceSummary[]): string =>
1149
+ summaries
1150
+ .map(({ text, tone }) => (tone ? paintOfTone(tone)(text) : paint.dim(text)))
1151
+ .join(paint.dim(` ${ITEM_SEPARATOR} `))
1152
+
1153
+ /**
1154
+ * The sources the watcher runs, as one line until the user opens it (`▸ Sources grafana 5 entries, 1 new`); open,
1155
+ * `● errors ran 12 min ago · <newest report>` per source, the mark amber while the report is new, red while the source
1156
+ * fails, and a source's entries under it once opened.
1157
+ */
1076
1158
  const sourceLines = (
1077
1159
  { sources, now }: SourceBlock,
1078
1160
  awaiting: readonly WaitingItem[],
@@ -1080,19 +1162,21 @@ const sourceLines = (
1080
1162
  width: number,
1081
1163
  ): BlockLines => {
1082
1164
  const shown = state.expanded.has(SOURCE_MORE_KEY) ? sources : sources.slice(0, SHOWN_BLOCK_ROWS)
1083
- const view = { expanded: state.expanded, asked: askedCheckOf(awaiting) }
1165
+ const asked = askedCheckOf(awaiting)
1166
+ const newTone = toneOfRole(TONE_ROLE.new)
1084
1167
  const block: TopBlock = {
1085
1168
  name: TEXTS.sourceHeading,
1086
- bar: nextBar,
1169
+ count: null,
1170
+ balance: sourceSummaryText(sourceSummaries(sources, now, newTone, asked)),
1087
1171
  markPaint: paint.dim,
1088
1172
  buttonPaint: (text) => text,
1089
1173
  focused: (key) => isFocused(state, key),
1090
1174
  allBold: (key) => buttonsAllBold(state, key),
1091
1175
  countPaint: (text) => text,
1092
1176
  moreKey: SOURCE_MORE_KEY,
1177
+ hidden: Math.max(sources.length - SHOWN_BLOCK_ROWS, 0),
1093
1178
  expanded: state.expanded.has(SOURCE_MORE_KEY),
1094
- keys: sourceKeys(sources),
1095
- rows: allBlockRows(sourceItems(shown, now, toneOfRole(TONE_ROLE.new), view), state.expanded, width),
1179
+ rows: allBlockRows(sourceItems(shown, now, newTone, { expanded: state.expanded, asked }), state.expanded, width),
1096
1180
  }
1097
1181
  return blockLinesOf(block, state, TOP_BLOCK.sources, width)
1098
1182
  }
@@ -1105,47 +1189,22 @@ const sourceLinesOf = (
1105
1189
  ): BlockLines | null =>
1106
1190
  sourceBlock && sourceBlock.sources.length > 0 ? sourceLines(sourceBlock, awaiting, state, width) : null
1107
1191
 
1108
- const appointmentLinesOf = (
1109
- appointments: AppointmentBlock | undefined,
1110
- state: Selection,
1111
- width: number,
1112
- ): BlockLines | null =>
1113
- appointments && appointments.rows.length > 0 ? appointmentLines(appointments, state, width) : null
1114
-
1115
1192
  /**
1116
- * The blocks one under the other, `offset` lines below the top of the screen, with one rule between neighbours: the
1117
- * head of an open block is that rule, a folded block gets a plain one above it unless a folded block stands right
1118
- * above (folded blocks share the rule below them), and a plain rule closes the stack. An
1119
- * absent block (null) takes no line and has no hit.
1193
+ * The blocks one under the other, `offset` lines below the top of the screen, a blank line after each, so one stands
1194
+ * between neighbours and one between the last and the lanes. An absent block (null) takes no line and has no hit.
1120
1195
  */
1121
- const stackBlocks = (slots: readonly (BlockLines | null)[], offset: number, width: number): BlockStack => {
1196
+ const stackBlocks = (slots: readonly (BlockLines | null)[], offset: number): BlockStack => {
1122
1197
  const lines: string[] = []
1123
1198
  const hits: (BlockHit | null)[] = []
1124
- let previousFolded = false
1125
1199
  for (const slot of slots) {
1126
- if (slot && !slot.open && !previousFolded) lines.push(rule(width))
1127
1200
  hits.push(slot && { ...slot.hit, firstLine: offset + lines.length + 1 })
1128
- if (slot) {
1129
- lines.push(...slot.lines)
1130
- previousFolded = !slot.open
1131
- }
1201
+ if (slot) lines.push(...slot.lines, '')
1132
1202
  }
1133
- return { lines: lines.length > 0 ? [...lines, rule(width)] : lines, hits }
1203
+ return { lines, hits }
1134
1204
  }
1135
1205
 
1136
1206
  /**
1137
- * A dimmed rule before every section of the body but the first, which shows where one ends; none between two
1138
- * one-line lane blocks (the first `laneCount` sections), so folded lanes stand together as a list.
1139
- */
1140
- const separate = (sections: Frame[], width: number, laneCount: number): Frame[] =>
1141
- sections.flatMap((frame, index) => {
1142
- if (index === 0) return [frame]
1143
- const listed = index < laneCount && frame.lines.length === 1 && sections[index - 1]?.lines.length === 1
1144
- return listed ? [frame] : [textFrame([rule(width)]), frame]
1145
- })
1146
-
1147
- /**
1148
- * Why badges may be missing, said beside the clock: no watcher runs, or its state is stale. Nothing while it is live,
1207
+ * Why badges may be missing, said at the right end of the title line: no watcher runs, or its state is stale. Nothing while it is live,
1149
1208
  * or when no lane on screen has a pull request a badge could belong to and no source is marked `ticket`.
1150
1209
  */
1151
1210
  const watchNotice = (
@@ -1157,7 +1216,7 @@ const watchNotice = (
1157
1216
  return watch.kind === WATCH_HEALTH.off ? texts.watcherOff : texts.stale(watch.minutes)
1158
1217
  }
1159
1218
 
1160
- /** `groomed 3d ago` beside the clock, dim, amber once the board is overdue; empty while it is unknown. */
1219
+ /** `groomed 3d ago` at the right end of the title line, dim, amber once the board is overdue; empty while it is unknown. */
1161
1220
  const groomedCell = (days: number | null | undefined): string => {
1162
1221
  if (days === null || days === undefined) return ''
1163
1222
  const { text, stale } = groomedText(days)
@@ -1185,6 +1244,57 @@ const laneColumnsOf = (lanes: LaneView[], width: number): LaneColumns => {
1185
1244
  return { name, marks, right: rightOf(phases ? tally : 0), phases, tally }
1186
1245
  }
1187
1246
 
1247
+ const reasonInputOf = ({ progress, status, restNote }: LaneView): ReasonInput => ({
1248
+ lane: progress.lane,
1249
+ agent: status.agent,
1250
+ restNote,
1251
+ })
1252
+
1253
+ /** `1 waits on you · 3 idle`: what waits on you in the tone of what waits, the idle count dim; a part at 0 left out. */
1254
+ const lanesBalanceText = (lanes: readonly LaneView[]): string => {
1255
+ const { waitsOnYou, idle } = lanesBalance(lanes.map(reasonInputOf))
1256
+ return [
1257
+ waitsOnYou > 0 ? paintOfRole(TONE_ROLE.waiting)(TEXTS.lanesWaitOnYou(waitsOnYou)) : '',
1258
+ idle > 0 ? paint.dim(TEXTS.lanesIdle(idle)) : '',
1259
+ ]
1260
+ .filter(Boolean)
1261
+ .join(paint.dim(` ${ITEM_SEPARATOR} `))
1262
+ }
1263
+
1264
+ /**
1265
+ * The lanes at work under the head `▾ Lanes · <n> <balance>`, open at the start; each lane indented under it, a
1266
+ * blank line after an open lane, none above it, so its row keeps its screen line when it opens and folds under the
1267
+ * mouse; none between folded lanes, so they stand together as a list, a folded lane's task row included. `rows` are
1268
+ * the rows of "Your turn" a resting lane may wait on.
1269
+ */
1270
+ const lanesFrame = (lanes: LaneView[], rows: readonly UnblockingRow[], context: LanesContext): Frame => {
1271
+ const { state, width } = context
1272
+ const inner = Math.max(width - LANE_INDENT.length, 1)
1273
+ const columns = laneColumnsOf(lanes, inner)
1274
+ const isOpen = ({ progress, status }: LaneView) => isLaneOpen(state, progress.lane.name, status.agent)
1275
+ const content = () => {
1276
+ const laneList = lanes.flatMap((lane, index) => {
1277
+ const frame = laneBlock(lane, { ...context, width: inner }, columns, laneReason(reasonInputOf(lane), rows))
1278
+ const previous = lanes[index - 1]
1279
+ return previous && isOpen(previous) ? [textFrame(['']), frame] : [frame]
1280
+ })
1281
+ return indented(joinFrames(laneList), LANE_INDENT)
1282
+ }
1283
+ const head = {
1284
+ name: TEXTS.lanesHeading,
1285
+ count: lanes.length,
1286
+ balance: lanesBalanceText(lanes),
1287
+ countPaint: unpainted,
1288
+ }
1289
+ return bodyBlock(LANES_KEY, head, isLanesOpen(state), content, { state, width })
1290
+ }
1291
+
1292
+ /** The status each lane's agent reports, by the lane's name, for the footer's click hint. */
1293
+ const agentOfLanes =
1294
+ (lanes: readonly LaneView[]): AgentOf =>
1295
+ (name) =>
1296
+ lanes.find(({ progress }) => progress.lane.name === name)?.status.agent ?? null
1297
+
1188
1298
  /** The lanes by what the view does with them; closed lanes are in none of the three groups. */
1189
1299
  const partitionLanes = (lanes: LaneView[]): Record<'active' | 'waiting' | 'noAgent', LaneView[]> => {
1190
1300
  const standing = (wanted: LaneStanding) => lanes.filter((lane) => laneStanding(lane) === wanted)
@@ -1216,72 +1326,65 @@ const emphasisedAt = (
1216
1326
  }
1217
1327
 
1218
1328
  /**
1219
- * Title and clock on top, the key hint at the bottom (with the legend after `?`), and in between one
1220
- * block per lane: the lane line with its total bar, and for an open lane the phase rows. Folded lanes are
1221
- * one line each, and a dimmed rule stands between lane blocks unless both are one line. Lanes that wait and
1222
- * lanes with a worktree but no agent each share a closing block; finished lanes are not drawn.
1329
+ * The title on top, no clock (it would change the frame every minute and reset the refresh pace), then the blocks "Your turn" and "Sources"; the body below them scrolls: "Lanes" with one
1330
+ * line per lane at work and an open lane's children, "Waiting" for the lanes not started, the lanes with a worktree
1331
+ * but no agent, and the "Backlog"; a blank line between neighbouring blocks, none inside one. At the bottom what a
1332
+ * click does on the focused row, else the key hint, and the legend after `?`. Finished lanes are not drawn.
1223
1333
  */
1224
1334
  export const renderScreen = (
1225
1335
  {
1226
1336
  title,
1227
- time,
1228
1337
  lanes,
1229
1338
  planned,
1230
1339
  watch,
1231
1340
  watchesTickets,
1232
1341
  awaiting = [],
1233
- next,
1234
- appointments,
1342
+ yourTurn,
1235
1343
  sourceBlock,
1236
1344
  groomedDays,
1237
1345
  unplaced = [],
1346
+ shimmer,
1238
1347
  }: OverviewView,
1239
1348
  state: Selection,
1240
1349
  width: number,
1241
1350
  ): Screen => {
1351
+ const taskStyle = shimmer ? SHIMMERING_TASK : STILL_TASK
1242
1352
  const { active, waiting, noAgent } = partitionLanes(lanes)
1243
- const parked = [...waitingBlock(waiting, width), ...parkedBlock(noAgent, width)]
1244
- const columns = laneColumnsOf(active, width)
1245
1353
  const badgeTexts = BADGE_TEXTS
1246
- const blocks = active.map((lane) => laneBlock(lane, state, width, columns, badgeTexts))
1354
+ const turnRowsOf = yourTurn ? [...yourTurn.turn.now, ...yourTurn.turn.planned] : []
1247
1355
  const notice = watchNotice({ lanes: active, watch, watchesTickets }, badgeTexts)
1248
- const clock = [notice ? paint.yellow(notice) : '', groomedCell(groomedDays), paint.dim(time)]
1249
- .filter(Boolean)
1250
- .join(COLUMN_SEPARATOR)
1251
- const titleLine = spread(`${HEADER_MARK} ${paint.bold(title)}`, clock, width)
1252
- const forYou = awaiting.length > 0 ? waitingLines(awaiting, state, width) : null
1253
- const queued = next ? nextLines(next, state, width) : null
1254
- const dated = appointmentLinesOf(appointments, state, width)
1255
- const nowLines = unplaced.map((task) => nowLine(task, width))
1356
+ const status = [notice ? paint.yellow(notice) : '', groomedCell(groomedDays)].filter(Boolean).join(COLUMN_SEPARATOR)
1357
+ const titleLine = spread(`${HEADER_MARK} ${paint.bold(title)}`, status, width)
1358
+ const turn = yourTurn ? yourTurnLines(yourTurn, state, width) : null
1359
+ const nowLines = unplaced.map((task) => nowLine(task, width, taskStyle))
1256
1360
  const above = nowLines.length > 0 ? [titleLine, rule(width), ...nowLines] : [titleLine]
1257
1361
  const listed = sourceLinesOf(sourceBlock, awaiting, state, width)
1258
- const stack = stackBlocks([forYou, queued, dated, listed], above.length, width)
1259
- const endsWithRule = stack.lines.length > 0 || nowLines.length === 0
1362
+ const stack = stackBlocks([turn, listed], above.length)
1260
1363
  const sections = [
1261
- ...blocks,
1262
- ...(parked.length > 0 ? [textFrame(parked)] : []),
1263
- ...(planned.length > 0 ? [plannedFrame(planned, state, width)] : []),
1364
+ ...(active.length > 0 ? [lanesFrame(active, turnRowsOf, { state, width, badgeTexts, taskStyle })] : []),
1365
+ ...(waiting.length > 0 ? [waitingFrame(waiting, state, width)] : []),
1366
+ textFrame(parkedBlock(noAgent, width)),
1367
+ ...(planned.length > 0 ? [backlogFrame(planned, state, width)] : []),
1264
1368
  ]
1265
- const lead = !endsWithRule && blocks.length === 0 && sections.length > 0 ? [textFrame([rule(width)])] : []
1266
- const plainHeader = [...above, ...(stack.lines.length > 0 || nowLines.length > 0 ? stack.lines : [rule(width)])]
1267
- const plainBody = joinFrames([...lead, ...separate(sections, width, blocks.length)])
1369
+ const plainHeader = [...above, ...(stack.lines.length > 0 ? stack.lines : [''])]
1370
+ const plainBody = spaced(sections)
1268
1371
  const blockHits = stack.hits.flatMap(focusHitsOfBlock)
1269
1372
  const focusHits = [...blockHits, ...focusHitsOfTargets(plainBody.targets, plainHeader.length)]
1270
1373
  const header = emphasisedAt(plainHeader, 0, focusHits, state, width)
1271
1374
  const body = { ...plainBody, lines: emphasisedAt(plainBody.lines, plainHeader.length, focusHits, state, width) }
1272
1375
  return {
1273
- waitingBlock: stack.hits[0],
1274
- nextBlock: stack.hits[1],
1275
- appointmentBlock: stack.hits[2],
1276
- sourceBlock: stack.hits[3],
1277
- focusHits,
1376
+ yourTurnBlock: stack.hits[0],
1377
+ sourceBlock: stack.hits[1],
1378
+ focusHits: state.showLegend ? focusHits.filter(({ line }) => line < plainHeader.length) : focusHits,
1278
1379
  rowKeys: [...blockHits.map(({ key }) => key), ...targetKeys(body.targets)],
1279
1380
  header,
1280
- body,
1381
+ body: state.showLegend ? textFrame(legendLines(TEXTS.legend, width)) : body,
1281
1382
  footer: [
1282
1383
  rule(width),
1283
- fit(paint.dim(TEXTS.hint), width),
1284
- ...(state.showLegend ? TEXTS.legend.flatMap((line) => wrap(line, width)).map(paint.dim) : []),
1384
+ fit(
1385
+ paint.dim(clickHint(state, (key) => blockRowByKey(stack.hits, key), agentOfLanes(active)) ?? TEXTS.hint),
1386
+ width,
1387
+ ),
1285
1388
  ],
1286
1389
  }
1287
1390
  }
@@ -1297,11 +1400,14 @@ export const renderSelected = (overview: OverviewView, selection: Selection, wid
1297
1400
  }
1298
1401
 
1299
1402
  /**
1300
- * The selection with its row resolved against `screen`: a row the screen draws stays, a block row included; else the
1301
- * first row of the body is selected, none when the body has none.
1403
+ * The selection with its row resolved against `screen`: a row the screen draws stays, a block row or head included;
1404
+ * else the first row of the body that is no block head is selected (the first lane), else the first head, none when
1405
+ * the body has no row.
1302
1406
  */
1303
1407
  export const settledSelection = (selection: Selection, screen: Screen): Selection => {
1304
1408
  const kept = selection.selected !== null && screen.rowKeys.includes(selection.selected)
1305
- const selected = kept ? selection.selected : selectedKey(selection, targetKeys(screen.body.targets))
1409
+ const keys = targetKeys(screen.body.targets)
1410
+ const rows = keys.filter((key) => !isBlockHeadKey(key))
1411
+ const selected = kept ? selection.selected : selectedKey(selection, rows.length > 0 ? rows : keys)
1306
1412
  return selected === selection.selected ? selection : { ...selection, selected }
1307
1413
  }