jackherd 1.10.0 → 1.11.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 (66) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +24 -16
  3. package/dist/{chunk-5MQBCUQ5.js → chunk-RMMXD4G5.js} +9 -2
  4. package/dist/config.d.ts +60 -0
  5. package/dist/config.js +39 -4
  6. package/dist/plan-format.d.ts +7 -1
  7. package/dist/plan-format.js +4 -2
  8. package/docs/follow-dark.svg +48 -0
  9. package/docs/follow.svg +48 -0
  10. package/herdr-plugin/herdr-plugin.toml +1 -1
  11. package/install.sh +1 -1
  12. package/kit/hooks/hooks/open-question/run.ts +8 -3
  13. package/kit/rules/rules/kit/pull-requests.md +2 -0
  14. package/kit/workflow/skills/follow/SKILL.md +3 -3
  15. package/kit/workflow/skills/orchestrate/SKILL.md +11 -3
  16. package/package.json +2 -2
  17. package/recipes/sources.md +4 -0
  18. package/schemas/jackherd.schema.json +80 -0
  19. package/src/cli.ts +1 -0
  20. package/src/config/schema.ts +24 -1
  21. package/src/config/tones.ts +27 -0
  22. package/src/lane-prompt/additions.ts +14 -0
  23. package/src/lane-prompt/command.ts +8 -2
  24. package/src/lane-prompt/input.ts +44 -3
  25. package/src/lane-prompt/options.ts +2 -0
  26. package/src/mermaid/command.ts +36 -0
  27. package/src/mermaid/problems.ts +48 -0
  28. package/src/plan-format/dependencies.ts +2 -2
  29. package/src/plan-format/waiting.ts +15 -2
  30. package/src/plans/appointment-rows.ts +34 -10
  31. package/src/plans/ask-coordinator.ts +46 -12
  32. package/src/plans/ask-target.ts +13 -2
  33. package/src/plans/board-close.ts +85 -0
  34. package/src/plans/frame.ts +5 -12
  35. package/src/plans/git.ts +35 -0
  36. package/src/plans/groomed.ts +27 -0
  37. package/src/plans/layout-phase.ts +7 -4
  38. package/src/plans/layout.ts +258 -48
  39. package/src/plans/link-chips.ts +22 -4
  40. package/src/plans/live.ts +73 -5
  41. package/src/plans/next-rows.ts +44 -0
  42. package/src/plans/planned.ts +22 -0
  43. package/src/plans/row-actions.ts +52 -0
  44. package/src/plans/row-focus.ts +148 -0
  45. package/src/plans/row-targets.ts +7 -0
  46. package/src/plans/selection.ts +14 -1
  47. package/src/plans/settings.ts +10 -0
  48. package/src/plans/snapshot.ts +79 -21
  49. package/src/plans/source-asked.ts +19 -0
  50. package/src/plans/source-prefix.ts +17 -0
  51. package/src/plans/source-rows.ts +188 -0
  52. package/src/plans/texts.ts +28 -3
  53. package/src/plans/tones.ts +30 -0
  54. package/src/plans/view.ts +8 -2
  55. package/src/plans/waiting-click.ts +129 -24
  56. package/src/terminal/text.ts +8 -0
  57. package/src/terminal/types.ts +4 -0
  58. package/src/vocabulary/index.ts +10 -0
  59. package/src/watch/plan-badges-read.ts +9 -2
  60. package/src/watch/plan-badges.ts +10 -2
  61. package/src/watch/report-questions.ts +4 -3
  62. package/src/watch/source-entries.ts +55 -0
  63. package/src/watch/source-fields.ts +1 -0
  64. package/src/watch/source-statuses.ts +60 -0
  65. package/src/watch/source-tickets.ts +21 -1
  66. package/src/watch/sources.ts +41 -1
@@ -1,3 +1,4 @@
1
+ import { TONE_ROLE } from '../config/tones'
1
2
  import { CONTEXT_LEVEL, type ContextLevel, type ContextReading, contextLabel } from '../context'
2
3
  import type { LaneProgress, PhaseProgress } from '../plan-format'
3
4
  import { currentPhase, isPhaseOpen, phaseLabel } from '../plan-format'
@@ -8,6 +9,7 @@ import {
8
9
  badge,
9
10
  bar,
10
11
  box,
12
+ bracket,
11
13
  fit,
12
14
  link,
13
15
  packGroups,
@@ -17,7 +19,7 @@ import {
17
19
  visibleLength,
18
20
  wrap,
19
21
  } from '../terminal/text'
20
- import { AGENT_STATUS, type AgentStatus, isAgentStatus } from '../terminal/types'
22
+ import { AGENT_STATUS, type AgentStatus, isAgentStatus, isRestingAgent } from '../terminal/types'
21
23
  import { textWidth } from '../terminal/width'
22
24
  import {
23
25
  NO_BADGES,
@@ -30,6 +32,7 @@ import {
30
32
  import type { UnplannedTask } from './active-tasks'
31
33
  import { type AppointmentBlock, appointmentItems, appointmentKeys } from './appointment-rows'
32
34
  import { deeper, type Frame, joinFrames, nest, prefixed, rowFrame, TREE, textFrame } from './frame'
35
+ import { groomedText } from './groomed'
33
36
  import { LANE_STANDING, type LaneStanding, laneStanding, waitingNote } from './lane-standing'
34
37
  import { type LaneSummary, laneSummary } from './lane-summary'
35
38
  import { type LaneTickets, withoutTicketKey } from './lane-tickets'
@@ -44,9 +47,20 @@ import {
44
47
  } from './layout-phase'
45
48
  import { foldedTaskLine, summaryChildren } from './layout-summary'
46
49
  import { chipKey, type LinkChip, planUrlOf } from './link-chips'
50
+ import { type NextBlock, nextItems, nextKeys } from './next-rows'
47
51
  import type { PhaseModel } from './phase-models'
48
- import { type PlannedPlan, phaseList, plannedPhaseCount } from './planned'
52
+ import { type PlannedPlan, plannedNextLabel } from './planned'
49
53
  import { CHECKS, type ChecksState, failedTestKey, type PullRequest, pullRequestKey } from './pull-requests'
54
+ import {
55
+ boldBracketAt,
56
+ buttonsAllBold,
57
+ type FocusHit,
58
+ focusHitsOfBlock,
59
+ focusHitsOfTargets,
60
+ focusLine,
61
+ isFocused,
62
+ LINKS_KEY_PREFIX,
63
+ } from './row-focus'
50
64
  import {
51
65
  type ClickTarget,
52
66
  isBlockFolded,
@@ -61,16 +75,22 @@ import {
61
75
  } from './selection'
62
76
  import type { SentChip } from './sent-files'
63
77
  import { shownWithMore } from './shown-with-more'
78
+ import { askedCheckOf } from './source-asked'
79
+ import { type SourceBlock, sourceItems, sourceKeys } from './source-rows'
64
80
  import { chevronOf, LESS_MARK, NO_AGENT_SYMBOL, statusSymbol, statusWord } from './symbols'
65
81
  import { BADGE_TEXTS, type BadgeTexts, checksText, laneMarkText, TEXTS } from './texts'
82
+ import { paintOfRole, paintOfTone, toneOfRole } from './tones'
66
83
  import {
67
84
  APPOINTMENT_MORE_KEY,
68
- ASK_BUTTON,
85
+ allBlockRows,
69
86
  type BlockHit,
70
87
  type BlockRow,
71
88
  blockRows,
89
+ buttonsText,
72
90
  moreKeyOf,
91
+ NEXT_MORE_KEY,
73
92
  SHOWN_BLOCK_ROWS,
93
+ SOURCE_MORE_KEY,
74
94
  WAITING_MARK,
75
95
  WAITING_MORE_KEY,
76
96
  waitingKeys,
@@ -108,6 +128,8 @@ export type LaneView = {
108
128
  models?: Record<string, PhaseModel>
109
129
  /** The files this lane's session sent to the user, shown as badges on the row of link chips. */
110
130
  sentFiles?: SentChip[]
131
+ /** What a resting lane waits on, from its report (`restNote`); absent or null while it works or asks nothing. */
132
+ restNote?: string | null
111
133
  }
112
134
 
113
135
  /** `watch` is the health of the watcher the badges come from, absent when it was not read. */
@@ -121,8 +143,14 @@ export type OverviewView = {
121
143
  watchesTickets?: boolean
122
144
  /** The open items of the board's section of what waits on the user, shown under the title. */
123
145
  awaiting?: WaitingItem[]
124
- /** The open appointments of the board, in a block of their own under the for-you block. */
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. */
125
149
  appointments?: AppointmentBlock
150
+ /** The sources the watcher runs that are not tickets, in a block of their own under the appointments. */
151
+ sourceBlock?: SourceBlock
152
+ /** Whole days since the board was groomed (`groomedDaysAgo`), beside the clock; null or absent when unknown. */
153
+ groomedDays?: number | null
126
154
  /** What agents report doing that no lane takes, one `now:` line each under the title. */
127
155
  unplaced?: UnplannedTask[]
128
156
  }
@@ -137,7 +165,13 @@ export type Screen = {
137
165
  body: Frame
138
166
  footer: string[]
139
167
  waitingBlock: BlockHit | null
168
+ nextBlock: BlockHit | null
140
169
  appointmentBlock: BlockHit | null
170
+ sourceBlock: BlockHit | null
171
+ /** Every clickable row by the screen line it starts on (header lines first, then the body unscrolled): what the mouse and the keys land on. */
172
+ focusHits: FocusHit[]
173
+ /** The keys of the rows the arrows walk, in screen order: the rows of the blocks on top, then those of the body. */
174
+ rowKeys: string[]
141
175
  }
142
176
 
143
177
  /**
@@ -162,6 +196,12 @@ type TopBlock = {
162
196
  name: string
163
197
  bar: () => string
164
198
  markPaint: (text: string) => string
199
+ /** The paint of the buttons of a row: amber and bold in "Waiting for you", the normal foreground colour elsewhere. */
200
+ buttonPaint: (text: string) => string
201
+ /** Whether a row is the selected one or under the mouse: it gets the background, the mark, and bold buttons. */
202
+ focused: (key: string) => boolean
203
+ /** Whether all buttons of a row are bold: the arrows selected it and the mouse is not on it (the mouse bolds one). */
204
+ allBold: (key: string) => boolean
165
205
  /** The paint of the count in the head while the block has points: amber for what waits on the user. */
166
206
  countPaint: (text: string) => string
167
207
  moreKey: string
@@ -207,12 +247,10 @@ const TEST_PATH_LEVEL = ' > '
207
247
  const TEST_PATH_SEPARATOR = ' › '
208
248
  const PLACE_SEPARATOR = ' · '
209
249
 
210
- /** The colour of a lane state, word and symbol alike: teal while working or done, bold amber when it waits for you, grey when idle. */
211
- const STATUS_PAINT: Record<AgentStatus, (text: string) => string> = {
212
- [AGENT_STATUS.working]: paint.teal,
213
- [AGENT_STATUS.blocked]: (text) => paint.bold(paint.yellow(text)),
214
- [AGENT_STATUS.done]: paint.teal,
215
- [AGENT_STATUS.idle]: paint.dim,
250
+ /** The colour of a lane state, word and symbol alike: the tone of `running` while working or done, bold `waiting` when it waits for you, `resting` when idle. */
251
+ const statusPaint = (status: AgentStatus): ((text: string) => string) => {
252
+ if (status === AGENT_STATUS.blocked) return (text) => paint.bold(paintOfRole(TONE_ROLE.waiting)(text))
253
+ return status === AGENT_STATUS.idle ? paintOfRole(TONE_ROLE.resting) : paintOfRole(TONE_ROLE.running)
216
254
  }
217
255
 
218
256
  const CHECKS_PAINT: Record<ChecksState, (text: string) => string> = {
@@ -224,13 +262,13 @@ const CHECKS_PAINT: Record<ChecksState, (text: string) => string> = {
224
262
 
225
263
  /** Only a blocked agent is named in the lane line, since there the user must act; the symbol says the rest. */
226
264
  const agentLabel = (agent: string | null): string =>
227
- agent === AGENT_STATUS.blocked ? STATUS_PAINT[AGENT_STATUS.blocked](statusWord(agent)) : ''
265
+ agent === AGENT_STATUS.blocked ? statusPaint(AGENT_STATUS.blocked)(statusWord(agent)) : ''
228
266
 
229
267
  /** The symbol of a status padded to `SYMBOL_WIDTH` cells, so the names after it start in one column. */
230
268
  const symbolCell = (agent: string | null): string => {
231
269
  const symbol = statusSymbol(agent)
232
270
  return padVisible(
233
- agent !== null && isAgentStatus(agent) ? STATUS_PAINT[agent](symbol) : paint.dim(symbol),
271
+ agent !== null && isAgentStatus(agent) ? statusPaint(agent)(symbol) : paint.dim(symbol),
234
272
  SYMBOL_WIDTH,
235
273
  )
236
274
  }
@@ -286,9 +324,9 @@ export const rowWithCount = (
286
324
  const fitsBeside = (head: string, cells: BadgeCell[], width: number): boolean =>
287
325
  visibleLength(head) + packedWidth(cells) + COLUMN_SEPARATOR.length + MIN_TITLE_WIDTH <= width
288
326
 
289
- /** The number of a pull request as a badge linked to it, padded to its column. */
327
+ /** The number of a pull request as a bracket button linked to it, padded to its column. */
290
328
  const numberCell = (pullRequest: PullRequest): string =>
291
- padVisible(badge(pullRequest.url, `↗ #${pullRequest.number}`), PULL_REQUEST_BADGE_WIDTH)
329
+ padVisible(bracket(pullRequest.url, `↗ #${pullRequest.number}`), PULL_REQUEST_BADGE_WIDTH)
292
330
 
293
331
  /** The column the check chip of a pull request row starts in, behind its number. */
294
332
  const checksColumnOf = (pullRequest: PullRequest): number =>
@@ -432,16 +470,18 @@ const progressColumn = ({ done, total, phasesDone, phases }: LaneSummary['tally'
432
470
  return `${counts}${paint.dim(phaseCount)}`
433
471
  }
434
472
 
435
- const CHIP_GAP = ' '
473
+ const CHIP_GAP = ' '
474
+ /** The most cells the label of a link chip takes in its bracket; a longer title is cut with an ellipsis. */
475
+ const CHIP_LABEL_WIDTH = 24
436
476
 
437
477
  const TICKET_MARK = '📡'
438
478
  const STATUS_SEPARATOR = ' · '
439
479
  /** Between the checkbox count and the phase count of a lane. */
440
480
  const TALLY_SEPARATOR = ' · '
441
481
 
442
- /** `✦ label` as a badge linked to the chip's address, `private` faint after it for a link its plan says is private. */
482
+ /** `[✦ label]` linked to the chip's address, `private` faint inside it for a link its plan says is private. */
443
483
  const chipCell = ({ url, icon, label, private: hidden }: LinkChip): string =>
444
- badge(url, `${icon} ${plain(label)}`, hidden ? TEXTS.privateLink : '')
484
+ bracket(url, `${icon} ${fit(plain(label), CHIP_LABEL_WIDTH)}`, hidden ? TEXTS.privateLink : '')
445
485
 
446
486
  /** A badge and the key a click on it opens; null for a cell no click opens. */
447
487
  type BadgeCell = { cell: string; key: string | null }
@@ -485,9 +525,11 @@ const packedFrame = (cells: BadgeCell[], lead: string, width: number): Frame =>
485
525
 
486
526
  const ticketTone = (isNew: boolean): BadgeTone => (isNew ? BADGE_TONE.amber : BADGE_TONE.grey)
487
527
 
488
- /** A badge linked to `url`, or a box no click opens when there is none. */
489
- const linkedBox = (url: string | null, text: string, tone: BadgeTone): string =>
490
- url ? badge(url, text, '', tone) : box(text, tone)
528
+ /** A bracket button linked to `url` where nothing is new, a badge in the tone of what is, a box no click opens without `url`. */
529
+ const linkedBox = (url: string | null, text: string, tone: BadgeTone): string => {
530
+ if (!url) return box(text, tone)
531
+ return tone === BADGE_TONE.grey ? bracket(url, text) : badge(url, text, '', tone)
532
+ }
491
533
 
492
534
  /** `📡 T-1 · In Progress` linked to the ticket, amber while new; a ticket without URL is a box no click opens. */
493
535
  const ticketCell = ({ id, url, status, isNew }: TicketBadge): BadgeCell => {
@@ -504,7 +546,7 @@ const ticketCells = (tickets: LaneTickets | null): BadgeCell[] => {
504
546
 
505
547
  /** A file the lane's session sent to the user, `📎 name` linked by its `file:` address; the terminal opens it, no click of ours does. */
506
548
  const sentCell = ({ url, label }: SentChip): BadgeCell => ({
507
- cell: badge(url, `${SENT_MARK} ${plain(label)}`),
549
+ cell: bracket(url, `${SENT_MARK} ${plain(label)}`),
508
550
  key: null,
509
551
  })
510
552
 
@@ -537,7 +579,7 @@ const badgeCells = (
537
579
  key: chipKey(pullRequest.url),
538
580
  })
539
581
  return [
540
- ...(badges.preview ? [{ cell: badge(badges.preview, texts.preview), key: chipKey(badges.preview) }] : []),
582
+ ...(badges.preview ? [{ cell: bracket(badges.preview, texts.preview), key: chipKey(badges.preview) }] : []),
541
583
  ...(badges.feedback > 0 ? [onPullRequest(texts.feedback(badges.feedback), BADGE_TONE.amber)] : []),
542
584
  ...(badges.changesRequested ? [onPullRequest(texts.changesRequested, BADGE_TONE.red)] : []),
543
585
  ]
@@ -573,7 +615,7 @@ const pullRequestMark = (pullRequest: PullRequest, badges: WatchBadges): string
573
615
  const found = badges[String(pullRequest.number)] ?? NO_BADGES
574
616
  const feedback = found.feedback > 0 ? ` 💬 ${found.feedback}` : ''
575
617
  const text = `↗ #${pullRequest.number}${feedback}${found.changesRequested ? ' ✋' : ''}`
576
- return badge(pullRequest.url, text, '', markTone(found))
618
+ return linkedBox(pullRequest.url, text, markTone(found))
577
619
  }
578
620
 
579
621
  /** `📡 <n>` in amber while tickets of the lane are new, linked to the first of them; nothing otherwise. */
@@ -598,11 +640,35 @@ const foldedPhaseLabel = (view: LaneView): string => {
598
640
  return `${nextPhaseLabel(view)}${more > 0 ? ` +${more}` : ''} · ${phase.done}/${phase.total}`
599
641
  }
600
642
 
643
+ /** A lane whose agent rests (idle or done): the folded line says what it waits on instead of the phase and the bar. */
644
+ const restsFolded = ({ status }: LaneView, open: boolean): boolean => !open && isRestingAgent(status.agent)
645
+
646
+ /** The report's `waits on: <question>` split at its label, so the label can sit dim and the reason bright. */
647
+ const noteParts = (note: string): { label: string; reason: string } => {
648
+ const split = note.indexOf(': ')
649
+ return split === -1 ? { label: '', reason: note } : { label: note.slice(0, split + 1), reason: note.slice(split + 2) }
650
+ }
651
+
652
+ /**
653
+ * Why a resting lane rests: the open question of its report (the watcher's `waits on:` line), else the starts-after
654
+ * cell of the board (`after: …`), else the plain fact that nothing is known, which is meant to stand out.
655
+ */
656
+ const restDetail = (view: LaneView): string => {
657
+ const wait = waitingNote(view.progress.lane)
658
+ const { label, reason } = view.restNote
659
+ ? noteParts(view.restNote)
660
+ : wait !== null
661
+ ? { label: TEXTS.restAfter, reason: plain(wait) }
662
+ : { label: '', reason: TEXTS.restNoReason }
663
+ return label ? `${paint.dim(label)} ${reason}` : reason
664
+ }
665
+
601
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. */
602
667
  const laneDetail = (view: LaneView, open: boolean): string => {
603
668
  const markText = laneMarkText(view)
604
669
  const marker = markText ? paint.yellow(markText) : ''
605
- return [agentLabel(view.status.agent), marker, open ? '' : foldedPhaseLabel(view)].filter(Boolean).join(' ')
670
+ const phase = restsFolded(view, open) ? restDetail(view) : open ? '' : foldedPhaseLabel(view)
671
+ return [agentLabel(view.status.agent), marker, phase].filter(Boolean).join(' ')
606
672
  }
607
673
 
608
674
  /**
@@ -635,7 +701,7 @@ const laneProgressColumn = (view: LaneView, tallyWidth: number): string => {
635
701
 
636
702
  /** Context and bar at the right edge of a folded lane line, as wide as the lane has them. */
637
703
  const laneRight = (view: LaneView, tallyWidth: number): string => {
638
- const counts = laneProgressColumn(view, tallyWidth)
704
+ const counts = restsFolded(view, false) ? '' : laneProgressColumn(view, tallyWidth)
639
705
  return counts ? `${contextCell(view.context)}${counts}` : contextCell(view.context).trimEnd()
640
706
  }
641
707
 
@@ -673,7 +739,8 @@ const foldedTaskRow = (view: LaneView, indent: string, width: number): string[]
673
739
 
674
740
  const laneLines = (view: LaneView, open: boolean, width: number, columns: LaneColumns): string[] => {
675
741
  const { progress, status } = view
676
- const cells = padVisible(paint.bold(fit(progress.lane.name, columns.name)), columns.name)
742
+ const nameStyle = restsFolded(view, open) ? paint.dim : paint.bold
743
+ const cells = padVisible(nameStyle(fit(progress.lane.name, columns.name)), columns.name)
677
744
  const head = `${paint.dim(chevronOf(open))} ${symbolCell(status.agent)} ${cells}`
678
745
  const right = open ? laneProgressColumn(view, tallyWidthOf(columns)) : markedRight(view, columns)
679
746
  const nameColumn = visibleLength(head) - visibleLength(cells)
@@ -830,9 +897,9 @@ const parkedBlock = (lanes: LaneView[], width: number): string[] => {
830
897
  */
831
898
  const plannedFrame = (planned: PlannedPlan[], state: Selection, width: number): Frame => {
832
899
  if (planned.length === 0) return textFrame([])
833
- const phases = plannedPhaseCount(planned)
834
900
  const open = state.expanded.has(PLANNED_KEY)
835
- const head = [paint.dim(fit(`${chevronOf(open)} ${TEXTS.planned(phases, planned.length)}`, width))]
901
+ const next = planned.map((plan) => `${plain(plan.title)} ${plannedNextLabel(plan)}`).join(', ')
902
+ const head = [paint.dim(fit(`${chevronOf(open)} ${TEXTS.plannedHead(planned.length)} ${next}`, width))]
836
903
  return rowFrame(PLANNED_KEY, head, open ? plannedRows(planned, width, CONTENT_INDENT).flat() : [], { state, width })
837
904
  }
838
905
 
@@ -841,7 +908,7 @@ const plannedRows = (planned: PlannedPlan[], width: number, indent: string): str
841
908
  planned.map((plan) =>
842
909
  rowWithCount(
843
910
  indent,
844
- `${plain(plan.title)} · ${phaseList(plan)}`,
911
+ `${plain(plan.title)} · ${plannedNextLabel(plan)}`,
845
912
  countCell('', countText(plan.done, plan.total)),
846
913
  width,
847
914
  paint.dim,
@@ -851,7 +918,7 @@ const plannedRows = (planned: PlannedPlan[], width: number, indent: string): str
851
918
  const rule = (width: number): string => paint.dim(RULE.repeat(width))
852
919
 
853
920
  /** The amber bar that starts every line of the for-you block: the one signal that this waits on the user. */
854
- const waitingBar = (): string => STATUS_PAINT[AGENT_STATUS.blocked](WAITING_MARK)
921
+ const waitingBar = (): string => statusPaint(AGENT_STATUS.blocked)(WAITING_MARK)
855
922
 
856
923
  /** The grey bar of the appointment block: it waits on nobody's answer. */
857
924
  const appointmentBar = (): string => paint.dim(WAITING_MARK)
@@ -866,8 +933,10 @@ const blockHead = (name: string, count: number, width: number, countPaint: (text
866
933
  return `${lead}${countPaint(String(count))} ${paint.dim(RULE.repeat(Math.max(width - used, 0)))}`
867
934
  }
868
935
 
869
- const markedLine = (line: string, row: BlockRow, markPaint: (text: string) => string): string =>
870
- line.startsWith(row.mark) ? `${markPaint(row.mark)}${line.slice(row.mark.length)}` : line
936
+ const markedLine = (line: string, row: BlockRow, markPaint: (text: string) => string): string => {
937
+ const paintMark = row.tone ? paintOfTone(row.tone) : markPaint
938
+ return line.startsWith(row.mark) ? `${paintMark(row.mark)}${line.slice(row.mark.length)}` : line
939
+ }
871
940
 
872
941
  /** The text from `from` on dimmed, without the blanks that pad it to the right edge. */
873
942
  const dimmedRest = (text: string, from: number): string => {
@@ -880,14 +949,23 @@ const dimmedRest = (text: string, from: number): string => {
880
949
  * and the rest of an expanded row dim, linked when the row has an address; the `[ask]` button at the end of a
881
950
  * question's first line wears the mark's paint.
882
951
  */
883
- const blockRowLines = ({ bar, markPaint }: TopBlock, row: BlockRow): string[] =>
884
- row.lines.map((line, index) => {
885
- const withButton = index === 0 && row.askColumn !== null
952
+ const blockRowLines = (
953
+ { bar, markPaint, buttonPaint, focused, allBold }: TopBlock,
954
+ row: BlockRow,
955
+ width: number,
956
+ ): string[] => {
957
+ const hot = focused(row.key)
958
+ const paintButton = (text: string) => (allBold(row.key) ? paint.bold(buttonPaint(text)) : buttonPaint(text))
959
+ return row.lines.map((line, index) => {
960
+ const withButton = index === 0 && row.buttons.length > 0
886
961
  const styled = dimmedRest(line, row.dimFrom[index] ?? line.length)
887
962
  const marked = index === 0 ? markedLine(styled, row, markPaint) : styled
888
- const text = withButton ? `${marked}${markPaint(ASK_BUTTON)}` : marked
889
- return `${bar()} ${row.url ? link(row.url, text) : text}`
963
+ const shown = row.url ? link(row.url, marked) : marked
964
+ const buttons = withButton ? buttonsText(row.buttons, row.dim ? paint.dim : paintButton) : ''
965
+ const drawn = `${bar()} ${shown}${buttons}`
966
+ return hot ? focusLine(drawn, width, index === 0) : drawn
890
967
  })
968
+ }
891
969
 
892
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. */
893
971
  const foldedBlockLines = ({ name, keys, rows }: TopBlock, width: number): BlockLines => {
@@ -913,7 +991,7 @@ const openBlockLines = (block: TopBlock, width: number): BlockLines => {
913
991
  open: true,
914
992
  lines: [
915
993
  blockHead(block.name, count, width, count > 0 ? block.countPaint : (text) => text),
916
- ...block.rows.flatMap((row) => blockRowLines(block, row)),
994
+ ...block.rows.flatMap((row) => blockRowLines(block, row, width)),
917
995
  ...(more > 0 ? [`${block.bar()} ${moreLine}`] : []),
918
996
  ],
919
997
  hit: { rows: block.rows, moreKey: moreKeyOf(count, block.moreKey), keys: block.keys },
@@ -933,8 +1011,11 @@ const waitingLines = (waiting: readonly WaitingItem[], state: Selection, width:
933
1011
  const block: TopBlock = {
934
1012
  name: TEXTS.waitingHeading,
935
1013
  bar: waitingBar,
936
- markPaint: STATUS_PAINT[AGENT_STATUS.blocked],
937
- countPaint: STATUS_PAINT[AGENT_STATUS.blocked],
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),
938
1019
  moreKey: WAITING_MORE_KEY,
939
1020
  expanded: state.expanded.has(WAITING_MORE_KEY),
940
1021
  keys,
@@ -950,6 +1031,9 @@ const appointmentLines = ({ rows }: AppointmentBlock, state: Selection, width: n
950
1031
  name: TEXTS.appointmentHeading,
951
1032
  bar: appointmentBar,
952
1033
  markPaint: paint.dim,
1034
+ buttonPaint: (text) => text,
1035
+ focused: (key) => isFocused(state, key),
1036
+ allBold: (key) => buttonsAllBold(state, key),
953
1037
  countPaint: (text) => text,
954
1038
  moreKey: APPOINTMENT_MORE_KEY,
955
1039
  expanded: state.expanded.has(APPOINTMENT_MORE_KEY),
@@ -959,6 +1043,68 @@ const appointmentLines = ({ rows }: AppointmentBlock, state: Selection, width: n
959
1043
  return blockLinesOf(block, state, TOP_BLOCK.appointments, width)
960
1044
  }
961
1045
 
1046
+ /** The bar of the next block: a blank, since it asks nothing of the user and only its numbers lead the rows. */
1047
+ const nextBar = (): string => ' '
1048
+
1049
+ /** The next block names nothing queued in one dim line, which has no rows to click. */
1050
+ const emptyNextLines = (): BlockLines => ({
1051
+ open: false,
1052
+ lines: [paint.dim(`${TEXTS.nextHeading} ${ITEM_SEPARATOR} ${TEXTS.nextEmpty}`)],
1053
+ hit: { rows: [], moreKey: null, keys: [] },
1054
+ })
1055
+
1056
+ /** The board's next section under the for-you block: ` 1 Answer the phone tests`, numbered, without the amber bar. */
1057
+ const nextLines = ({ rows }: NextBlock, state: Selection, width: number): BlockLines => {
1058
+ if (rows.length === 0) return emptyNextLines()
1059
+ const block: TopBlock = {
1060
+ name: TEXTS.nextHeading,
1061
+ bar: nextBar,
1062
+ markPaint: paint.dim,
1063
+ buttonPaint: (text) => text,
1064
+ focused: (key) => isFocused(state, key),
1065
+ allBold: (key) => buttonsAllBold(state, key),
1066
+ countPaint: (text) => text,
1067
+ moreKey: NEXT_MORE_KEY,
1068
+ expanded: state.expanded.has(NEXT_MORE_KEY),
1069
+ keys: nextKeys(rows),
1070
+ rows: blockRows(nextItems(rows), state.expanded, width, NEXT_MORE_KEY),
1071
+ }
1072
+ return blockLinesOf(block, state, TOP_BLOCK.next, width)
1073
+ }
1074
+
1075
+ /** The sources the watcher runs: ` ● errors ran 12 min ago · <newest report>`, the mark amber while the report is new, red while the source fails. */
1076
+ const sourceLines = (
1077
+ { sources, now }: SourceBlock,
1078
+ awaiting: readonly WaitingItem[],
1079
+ state: Selection,
1080
+ width: number,
1081
+ ): BlockLines => {
1082
+ const shown = state.expanded.has(SOURCE_MORE_KEY) ? sources : sources.slice(0, SHOWN_BLOCK_ROWS)
1083
+ const view = { expanded: state.expanded, asked: askedCheckOf(awaiting) }
1084
+ const block: TopBlock = {
1085
+ name: TEXTS.sourceHeading,
1086
+ bar: nextBar,
1087
+ markPaint: paint.dim,
1088
+ buttonPaint: (text) => text,
1089
+ focused: (key) => isFocused(state, key),
1090
+ allBold: (key) => buttonsAllBold(state, key),
1091
+ countPaint: (text) => text,
1092
+ moreKey: SOURCE_MORE_KEY,
1093
+ expanded: state.expanded.has(SOURCE_MORE_KEY),
1094
+ keys: sourceKeys(sources),
1095
+ rows: allBlockRows(sourceItems(shown, now, toneOfRole(TONE_ROLE.new), view), state.expanded, width),
1096
+ }
1097
+ return blockLinesOf(block, state, TOP_BLOCK.sources, width)
1098
+ }
1099
+
1100
+ const sourceLinesOf = (
1101
+ sourceBlock: SourceBlock | undefined,
1102
+ awaiting: readonly WaitingItem[],
1103
+ state: Selection,
1104
+ width: number,
1105
+ ): BlockLines | null =>
1106
+ sourceBlock && sourceBlock.sources.length > 0 ? sourceLines(sourceBlock, awaiting, state, width) : null
1107
+
962
1108
  const appointmentLinesOf = (
963
1109
  appointments: AppointmentBlock | undefined,
964
1110
  state: Selection,
@@ -1011,6 +1157,13 @@ const watchNotice = (
1011
1157
  return watch.kind === WATCH_HEALTH.off ? texts.watcherOff : texts.stale(watch.minutes)
1012
1158
  }
1013
1159
 
1160
+ /** `groomed 3d ago` beside the clock, dim, amber once the board is overdue; empty while it is unknown. */
1161
+ const groomedCell = (days: number | null | undefined): string => {
1162
+ if (days === null || days === undefined) return ''
1163
+ const { text, stale } = groomedText(days)
1164
+ return stale ? paint.yellow(text) : paint.dim(text)
1165
+ }
1166
+
1014
1167
  /** Chevron, symbol and the gaps around them before the name column of a lane line. */
1015
1168
  const LANE_HEAD_CELLS = 2 + SYMBOL_WIDTH + 1
1016
1169
 
@@ -1042,6 +1195,26 @@ const partitionLanes = (lanes: LaneView[]): Record<'active' | 'waiting' | 'noAge
1042
1195
  }
1043
1196
  }
1044
1197
 
1198
+ /**
1199
+ * The line under the mouse as the mouse wants it: a line of links, which is no row of its own, gets the focus
1200
+ * background and mark, and the square-bracket button the mouse is on is bold. `offset` is the screen line `lines` starts at.
1201
+ */
1202
+ const emphasisedAt = (
1203
+ lines: string[],
1204
+ offset: number,
1205
+ hits: readonly FocusHit[],
1206
+ { hovered, hoveredFrom }: Selection,
1207
+ width: number,
1208
+ ): string[] => {
1209
+ const hit = hovered ? hits.find(({ key }) => key === hovered) : undefined
1210
+ const index = hit ? hit.line - offset : -1
1211
+ const line = lines[index]
1212
+ if (line === undefined || !hit) return lines
1213
+ const marked = hit.key.startsWith(LINKS_KEY_PREFIX) ? focusLine(line, width, true) : line
1214
+ const emphasised = hoveredFrom == null ? marked : boldBracketAt(marked, hoveredFrom)
1215
+ return lines.map((each, at) => (at === index ? emphasised : each))
1216
+ }
1217
+
1045
1218
  /**
1046
1219
  * Title and clock on top, the key hint at the bottom (with the legend after `?`), and in between one
1047
1220
  * block per lane: the lane line with its total bar, and for an open lane the phase rows. Folded lanes are
@@ -1049,7 +1222,20 @@ const partitionLanes = (lanes: LaneView[]): Record<'active' | 'waiting' | 'noAge
1049
1222
  * lanes with a worktree but no agent each share a closing block; finished lanes are not drawn.
1050
1223
  */
1051
1224
  export const renderScreen = (
1052
- { title, time, lanes, planned, watch, watchesTickets, awaiting = [], appointments, unplaced = [] }: OverviewView,
1225
+ {
1226
+ title,
1227
+ time,
1228
+ lanes,
1229
+ planned,
1230
+ watch,
1231
+ watchesTickets,
1232
+ awaiting = [],
1233
+ next,
1234
+ appointments,
1235
+ sourceBlock,
1236
+ groomedDays,
1237
+ unplaced = [],
1238
+ }: OverviewView,
1053
1239
  state: Selection,
1054
1240
  width: number,
1055
1241
  ): Screen => {
@@ -1059,13 +1245,17 @@ export const renderScreen = (
1059
1245
  const badgeTexts = BADGE_TEXTS
1060
1246
  const blocks = active.map((lane) => laneBlock(lane, state, width, columns, badgeTexts))
1061
1247
  const notice = watchNotice({ lanes: active, watch, watchesTickets }, badgeTexts)
1062
- const clock = notice ? `${paint.yellow(notice)}${COLUMN_SEPARATOR}${paint.dim(time)}` : paint.dim(time)
1248
+ const clock = [notice ? paint.yellow(notice) : '', groomedCell(groomedDays), paint.dim(time)]
1249
+ .filter(Boolean)
1250
+ .join(COLUMN_SEPARATOR)
1063
1251
  const titleLine = spread(`${HEADER_MARK} ${paint.bold(title)}`, clock, width)
1064
1252
  const forYou = awaiting.length > 0 ? waitingLines(awaiting, state, width) : null
1253
+ const queued = next ? nextLines(next, state, width) : null
1065
1254
  const dated = appointmentLinesOf(appointments, state, width)
1066
1255
  const nowLines = unplaced.map((task) => nowLine(task, width))
1067
1256
  const above = nowLines.length > 0 ? [titleLine, rule(width), ...nowLines] : [titleLine]
1068
- const stack = stackBlocks([forYou, dated], above.length, width)
1257
+ const listed = sourceLinesOf(sourceBlock, awaiting, state, width)
1258
+ const stack = stackBlocks([forYou, queued, dated, listed], above.length, width)
1069
1259
  const endsWithRule = stack.lines.length > 0 || nowLines.length === 0
1070
1260
  const sections = [
1071
1261
  ...blocks,
@@ -1073,11 +1263,21 @@ export const renderScreen = (
1073
1263
  ...(planned.length > 0 ? [plannedFrame(planned, state, width)] : []),
1074
1264
  ]
1075
1265
  const lead = !endsWithRule && blocks.length === 0 && sections.length > 0 ? [textFrame([rule(width)])] : []
1266
+ const plainHeader = [...above, ...(stack.lines.length > 0 || nowLines.length > 0 ? stack.lines : [rule(width)])]
1267
+ const plainBody = joinFrames([...lead, ...separate(sections, width, blocks.length)])
1268
+ const blockHits = stack.hits.flatMap(focusHitsOfBlock)
1269
+ const focusHits = [...blockHits, ...focusHitsOfTargets(plainBody.targets, plainHeader.length)]
1270
+ const header = emphasisedAt(plainHeader, 0, focusHits, state, width)
1271
+ const body = { ...plainBody, lines: emphasisedAt(plainBody.lines, plainHeader.length, focusHits, state, width) }
1076
1272
  return {
1077
1273
  waitingBlock: stack.hits[0],
1078
- appointmentBlock: stack.hits[1],
1079
- header: [...above, ...(stack.lines.length > 0 || nowLines.length > 0 ? stack.lines : [rule(width)])],
1080
- body: joinFrames([...lead, ...separate(sections, width, blocks.length)]),
1274
+ nextBlock: stack.hits[1],
1275
+ appointmentBlock: stack.hits[2],
1276
+ sourceBlock: stack.hits[3],
1277
+ focusHits,
1278
+ rowKeys: [...blockHits.map(({ key }) => key), ...targetKeys(body.targets)],
1279
+ header,
1280
+ body,
1081
1281
  footer: [
1082
1282
  rule(width),
1083
1283
  fit(paint.dim(TEXTS.hint), width),
@@ -1092,6 +1292,16 @@ export const renderScreen = (
1092
1292
  */
1093
1293
  export const renderSelected = (overview: OverviewView, selection: Selection, width: number): Screen => {
1094
1294
  const first = renderScreen(overview, selection, width)
1095
- const selected = selectedKey(selection, targetKeys(first.body.targets))
1096
- return selected === selection.selected ? first : renderScreen(overview, { ...selection, selected }, width)
1295
+ const settled = settledSelection(selection, first)
1296
+ return settled === selection ? first : renderScreen(overview, settled, width)
1297
+ }
1298
+
1299
+ /**
1300
+ * The selection with its row resolved against `screen`: a row the screen draws stays, a block row included; else the
1301
+ * first row of the body is selected, none when the body has none.
1302
+ */
1303
+ export const settledSelection = (selection: Selection, screen: Screen): Selection => {
1304
+ const kept = selection.selected !== null && screen.rowKeys.includes(selection.selected)
1305
+ const selected = kept ? selection.selected : selectedKey(selection, targetKeys(screen.body.targets))
1306
+ return selected === selection.selected ? selection : { ...selection, selected }
1097
1307
  }
@@ -1,4 +1,4 @@
1
- import type { LinkKind } from '../config/link-kinds'
1
+ import { ARTIFACT_LINK_KIND, type LinkKind } from '../config/link-kinds'
2
2
 
3
3
  /** A link of a plan that matches a link kind, as the sprint page shows it. */
4
4
  export type LinkChip = { url: string; label: string; icon: string; private: boolean }
@@ -27,9 +27,27 @@ const linksOf = (line: string): Found[] => {
27
27
  return found.sort((left, right) => line.indexOf(left.url) - line.indexOf(right.url))
28
28
  }
29
29
 
30
- const labelOf = ({ text, before }: Found, kind: LinkKind): string => {
31
- const written = text ?? LABEL_BEFORE.exec(before)?.[1]
32
- return written ? written.replace(MARKS, '').replace(CONTROL, '').trim() || kind.label : kind.label
30
+ const LIST_LEAD = /^\s*(?:[-*>]\s*)*(?:\[[ xX]\]\s*)?/
31
+ const LEFTOVER_LINK = /\bhttps?:\/\/\S+/g
32
+ const TITLE_END = /[\s:,;.\-–>]+$/
33
+
34
+ /** What a line says besides its links, without list marks, checkbox and emphasis: the title of a bare artifact link. */
35
+ const lineTitleOf = ({ line }: Found): string =>
36
+ line
37
+ .replace(MARKDOWN_LINK, '$1')
38
+ .replace(AUTOLINK, '')
39
+ .replace(LEFTOVER_LINK, '')
40
+ .replace(LIST_LEAD, '')
41
+ .replace(MARKS, '')
42
+ .replace(CONTROL, '')
43
+ .replace(TITLE_END, '')
44
+ .trim()
45
+
46
+ const labelOf = (found: Found, kind: LinkKind): string => {
47
+ const written = found.text ?? LABEL_BEFORE.exec(found.before)?.[1]
48
+ const own = written?.replace(MARKS, '').replace(CONTROL, '').trim()
49
+ if (own) return own
50
+ return (kind.pattern === ARTIFACT_LINK_KIND.pattern && lineTitleOf(found)) || kind.label
33
51
  }
34
52
 
35
53
  /**