jackherd 1.11.2 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +17 -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
@@ -1,44 +1,12 @@
1
1
  import { sectionLines } from '../plan-format/markdown'
2
2
  import { NEXT_VOCABULARY, type PlanLanguage } from '../vocabulary'
3
- import { shortTitle } from './task-name'
4
- import type { BlockItem } from './waiting-click'
5
3
 
6
- /** A line of the board's next section as the herd shows it. */
7
- export type NextRow = { text: string }
8
-
9
- /** The next block: the section's lines without the ones the for-you block already shows. */
10
- export type NextBlock = { rows: NextRow[] }
11
-
12
- const MAX_NEXT_ROWS = 3
4
+ const MAX_NEXT_LINES = 3
13
5
 
14
6
  const LIST_LINE = /^(?:\d+[.)]|[-*])\s+(.+)$/
15
7
 
16
- const sameText = (left: string, right: string): boolean => left.trim().toLowerCase() === right.trim().toLowerCase()
17
-
18
- /**
19
- * The next section of the board, up to three list lines in the order written; the parser cuts after three, as the board
20
- * says. A line equal to one of `shown` (the texts of the for-you block) is left out: it stands above already.
21
- */
22
- export const nextBlock = (board: string, language: PlanLanguage, shown: readonly string[]): NextBlock => ({
23
- rows: sectionLines(board, NEXT_VOCABULARY[language].heading)
8
+ /** The board's next section, up to three list lines in the order written; the parser cuts after three, as the board says. */
9
+ export const nextLines = (board: string, language: PlanLanguage): string[] =>
10
+ sectionLines(board, NEXT_VOCABULARY[language].heading)
24
11
  .flatMap((line) => LIST_LINE.exec(line.trim())?.[1] ?? [])
25
- .slice(0, MAX_NEXT_ROWS)
26
- .filter((text) => !shown.some((known) => sameText(known, text)))
27
- .map((text) => ({ text })),
28
- })
29
-
30
- const nextKey = ({ text }: NextRow): string => `next>${text}`
31
-
32
- /** The rows as the block draws them, numbered; a click on one only expands it. */
33
- export const nextItems = (rows: readonly NextRow[]): BlockItem[] =>
34
- rows.map((row, index) => ({
35
- key: nextKey(row),
36
- mark: String(index + 1),
37
- text: row.text,
38
- title: shortTitle(row.text),
39
- url: null,
40
- ask: null,
41
- }))
42
-
43
- /** The keys of all rows: what the fold of the block remembers. */
44
- export const nextKeys = (rows: readonly NextRow[]): string[] => rows.map(nextKey)
12
+ .slice(0, MAX_NEXT_LINES)
@@ -50,9 +50,14 @@ export const newerPhases = (base: readonly PlanPhase[], copy: readonly PlanPhase
50
50
  return [...inBaseOrder, ...copy.filter((phase) => !baseIds.has(phase.id))]
51
51
  }
52
52
 
53
- /** A phase read from the worktree is touched when its ticks differ from the base's, or the base lacks it. */
54
- const markTouched = (phase: PlanPhase, basePhase: PlanPhase | undefined): PlanPhase =>
55
- basePhase && sameTicks(phase, basePhase) ? phase : { ...phase, touched: true }
53
+ /**
54
+ * A phase read from the worktree is touched when its ticks differ from the base's, or the base lacks it; a touched
55
+ * one is merged as well when the base already carries a tick of it.
56
+ */
57
+ const markTouched = (phase: PlanPhase, basePhase: PlanPhase | undefined): PlanPhase => {
58
+ if (basePhase && sameTicks(phase, basePhase)) return phase
59
+ return basePhase && tickCount(basePhase) > 0 ? { ...phase, touched: true, merged: true } : { ...phase, touched: true }
60
+ }
56
61
 
57
62
  /**
58
63
  * The phases of a plan as the lane sees them: the base ref's phases in their order, each replaced by the
@@ -51,10 +51,13 @@ export const planAnchorOn = (pagePath: string | null, address: string): string |
51
51
  /**
52
52
  * What decides whether the written page is out of date: the sprint text, the plan texts and the planned phases.
53
53
  * The status of the lanes is left out on purpose, it changes every few seconds and building the page costs
54
- * seconds; a page older than that says so in its header, and the key `s` writes a fresh one.
54
+ * seconds; a page older than that says so in its header, and the key `s` writes a fresh one. The texts, megabytes on
55
+ * a busy board and asked every refresh, are hashed as they are, not escaped into JSON first.
55
56
  */
56
- export const pageKey = ({ documents, planned }: Snapshot): string =>
57
- Bun.hash(JSON.stringify([documents, planned])).toString(36)
57
+ export const pageKey = ({ documents: { sprint, plans, ...rest }, planned }: Snapshot): string => {
58
+ const planHashes = Object.entries(plans).map(([href, text]) => [href, Bun.hash(text).toString(36)])
59
+ return Bun.hash(JSON.stringify([Bun.hash(sprint).toString(36), planHashes, rest, planned])).toString(36)
60
+ }
58
61
 
59
62
  /** The sprint page last written, with the key of the plans it was built from. */
60
63
  export type KnownPage = WrittenPage & { key: string }
@@ -8,7 +8,7 @@ export type PlanPullRequest = Pick<PullRequest, 'number' | 'branch' | 'files'>
8
8
  export type PlanHead = { number: number; files: readonly string[]; read: BaseReader }
9
9
 
10
10
  /** Where the herd keeps the fetched heads of open pull requests: its own namespace, apart from branches and remotes. */
11
- const PULL_HEAD_NAMESPACE = 'refs/jackherd/pr'
11
+ export const PULL_HEAD_NAMESPACE = 'refs/jackherd/pr'
12
12
 
13
13
  export const pullHeadRef = (number: number): string => `${PULL_HEAD_NAMESPACE}/${number}`
14
14
 
@@ -37,6 +37,8 @@ export type PlannedInput = {
37
37
  lanes: readonly PlannedLane[]
38
38
  sprintDirectory: string
39
39
  language: PlanLanguage
40
+ /** The phases of a plan's text, by its file; defaults to parsing it, a caller with a plan cache passes its own. */
41
+ parse?: (markdown: string, path: string) => readonly PlanPhase[]
40
42
  }
41
43
 
42
44
  /** A plan's file, its text and its phases, as one plan row reads them. */
@@ -75,11 +77,12 @@ const plannedPlanOf = ({ path, markdown, phases: all }: PlanRead, language: Plan
75
77
  export const plannedPlans = (input: PlannedInput): PlannedPlan[] => {
76
78
  const followed = followedPaths(input)
77
79
  const seen = new Set<string>()
80
+ const parse = input.parse ?? ((markdown: string) => parsePlanPhases(markdown, input.language))
78
81
  return Object.entries(input.plans).flatMap(([href, markdown]) => {
79
82
  const path = resolvePlanPath(input.sprintDirectory, href)
80
83
  if (seen.has(path) || followed.has(path)) return []
81
84
  seen.add(path)
82
- return plannedPlanOf({ path, markdown, phases: parsePlanPhases(markdown, input.language) }, input.language)
85
+ return plannedPlanOf({ path, markdown, phases: parse(markdown, path) }, input.language)
83
86
  })
84
87
  }
85
88
 
@@ -0,0 +1,36 @@
1
+ /** Runs tasks with at most a fixed number in flight; the others wait in the order they asked. */
2
+ export type ProcessSlots = { run: <T>(task: () => Promise<T>) => Promise<T> }
3
+
4
+ /**
5
+ * Slots for child processes. The event loop handles every process that ended in one turn, so dozens started side by
6
+ * side end as one long block; with `limit` in flight, their ends arrive spread over several turns.
7
+ */
8
+ export const createProcessSlots = (limit: number): ProcessSlots => {
9
+ const waiting: (() => void)[] = []
10
+ let inFlight = 0
11
+
12
+ const acquire = (): Promise<void> => {
13
+ if (inFlight < limit) {
14
+ inFlight += 1
15
+ return Promise.resolve()
16
+ }
17
+ return new Promise<void>((resolve) => waiting.push(resolve))
18
+ }
19
+
20
+ const release = () => {
21
+ const next = waiting.shift()
22
+ if (next) next()
23
+ else inFlight -= 1
24
+ }
25
+
26
+ const run = async <T>(task: () => Promise<T>): Promise<T> => {
27
+ await acquire()
28
+ try {
29
+ return await task()
30
+ } finally {
31
+ release()
32
+ }
33
+ }
34
+
35
+ return { run }
36
+ }
@@ -188,8 +188,16 @@ export const pullRequestsByBranch = (pullRequests: readonly PullRequest[]): Map<
188
188
  new Map<string, PullRequest[]>(),
189
189
  )
190
190
 
191
+ const PULL_REQUEST_KEY_PREFIX = 'pr:'
192
+
191
193
  /** The selection key of a pull request row; two parts, so it never equals a three-part phase key. */
192
- export const pullRequestKey = (number: number): string => `pr:${number}`
194
+ export const pullRequestKey = (number: number): string => `${PULL_REQUEST_KEY_PREFIX}${number}`
195
+
196
+ /** The number a pull request key names, null for any other key. */
197
+ export const pullRequestNumberOfKey = (key: string): number | null => {
198
+ const digits = key.startsWith(PULL_REQUEST_KEY_PREFIX) ? key.slice(PULL_REQUEST_KEY_PREFIX.length) : ''
199
+ return /^\d+$/.test(digits) ? Number(digits) : null
200
+ }
193
201
 
194
202
  /**
195
203
  * The key of a failed test's line under a pull request, by its place in the list: a click unfolds a cut name and folds
@@ -1,8 +1,23 @@
1
1
  import type { StatCache } from '../shared/stat-cache'
2
2
  import type { TaskSignal } from '../task-signals/types'
3
+ import { type BoardRowsReader, boardRowsReader } from './board-rows'
3
4
  import type { PlanCache } from './plan-cache'
5
+ import type { KeptStatus } from './status-pace'
4
6
 
5
- /** What the herd keeps between its reads, so session logs and plans that did not change are not parsed again. */
6
- export type ReadCache = { signals: StatCache<TaskSignal[]>; plans: PlanCache }
7
+ /**
8
+ * What the herd keeps between its reads, so session logs, plans and a board that did not change are not parsed
9
+ * again, and a worktree where nothing moved is not asked `git status` again (`pacedStatus`).
10
+ */
11
+ export type ReadCache = {
12
+ signals: StatCache<TaskSignal[]>
13
+ plans: PlanCache
14
+ boardRows: BoardRowsReader
15
+ statuses: Map<string, KeptStatus>
16
+ }
7
17
 
8
- export const newReadCache = (): ReadCache => ({ signals: new Map(), plans: new Map() })
18
+ export const newReadCache = (): ReadCache => ({
19
+ signals: new Map(),
20
+ plans: new Map(),
21
+ boardRows: boardRowsReader(),
22
+ statuses: new Map(),
23
+ })
@@ -0,0 +1,13 @@
1
+ /**
2
+ * How often the live view reads everything again: at `baseMs` while the screen changes, slower while it does not, up
3
+ * to `maxMs`. A watched plan file still refreshes at once; the pace only decides how soon an unwatched change (a session
4
+ * log, an agent's status, git) shows after a quiet spell.
5
+ */
6
+ export const REFRESH_PACE = { baseMs: 5_000, maxMs: 20_000 } as const
7
+
8
+ /** The wait before the next refresh after `quietRounds` refreshes in a row wrote nothing: doubled per quiet round, up to `maxMs`. */
9
+ export const refreshDelay = (quietRounds: number): number =>
10
+ Math.min(REFRESH_PACE.baseMs * 2 ** quietRounds, REFRESH_PACE.maxMs)
11
+
12
+ /** The quiet rounds after a refresh: one more when it wrote nothing, none when it changed the screen. */
13
+ export const quietRoundsAfter = (quietRounds: number, changed: boolean): number => (changed ? 0 : quietRounds + 1)
@@ -1,7 +1,10 @@
1
- import { ESC, RESET, stripAnsi, visibleLength } from '../terminal/text'
1
+ import { TONE_ROLE } from '../config/tones'
2
+ import { ESC, FAINT_OFF, FAINT_ON, RESET, stripAnsi, visibleLength } from '../terminal/text'
2
3
  import { textWidth } from '../terminal/width'
3
4
  import { isChipKey } from './link-chips'
4
5
  import type { ClickTarget, MouseEvent, Selection } from './selection'
6
+ import { CHEVRON } from './symbols'
7
+ import { paintOfRole } from './tones'
5
8
  import type { BlockHit, BlockRow } from './waiting-click'
6
9
 
7
10
  /** A clickable row on screen: its key, the screen line (0-based, header lines first) it starts on and how many lines it takes. */
@@ -12,18 +15,34 @@ export const FOCUS_ON = `${ESC}[48;5;238m`
12
15
 
13
16
  const BACKGROUND_LOST = new RegExp(`${ESC}\\[(?:0|49)?m`, 'g')
14
17
  const LEADING_STYLES = new RegExp(`^((?:${ESC}\\[[0-9;]*m)*)(.)`, 'u')
15
- /** The cells a row's lead may give up for the mark: a blank, the bar of a block, the closed chevron; an open chevron or a tree branch says more. */
16
- const MARKABLE_LEADS = new Set([' ', '▌', '▸'])
18
+ /** The cells a row's lead may give up for the mark: a blank or the bar of a block; a chevron or a tree branch says more. */
19
+ const MARKABLE_LEADS = new Set([' ', '▌'])
20
+ /** A chevron that leads a row, behind nothing but styles and blanks. */
21
+ const LEADING_CHEVRON = new RegExp(`^((?:${ESC}\\[[0-9;]*m| )*)([${CHEVRON.closed}${CHEVRON.open}])`, 'u')
22
+
23
+ /** Whether the styles before a point leave the dim on: it was set and neither unset nor reset since. */
24
+ const dimIsOn = (styles: string): boolean =>
25
+ styles.lastIndexOf(FAINT_ON) > Math.max(styles.lastIndexOf(FAINT_OFF), styles.lastIndexOf(RESET))
26
+
27
+ /** The chevron of a row in the tone of what runs, the dim around it lifted for it alone. */
28
+ const paintedChevron = (styles: string, chevron: string): string => {
29
+ const dimmed = dimIsOn(styles)
30
+ return `${styles}${dimmed ? FAINT_OFF : ''}${paintOfRole(TONE_ROLE.running)(chevron)}${dimmed ? FAINT_ON : ''}`
31
+ }
17
32
 
18
- const markedLead = (line: string): string =>
19
- line.replace(LEADING_STYLES, (whole, styles: string, lead: string) =>
33
+ /** The row's chevron painted, which says the row opens; else the `❯` on the blank or bar that leads it. */
34
+ const markedLead = (line: string): string => {
35
+ if (LEADING_CHEVRON.test(line))
36
+ return line.replace(LEADING_CHEVRON, (_whole, styles: string, chevron: string) => paintedChevron(styles, chevron))
37
+ return line.replace(LEADING_STYLES, (whole, styles: string, lead: string) =>
20
38
  MARKABLE_LEADS.has(lead) ? `${styles}${FOCUS_MARK}` : whole,
21
39
  )
40
+ }
22
41
 
23
42
  /**
24
43
  * A row under the mouse or the selection: a light background across `width` cells, and, on the first line of the row
25
- * (`marked`), the `❯` in place of the bar, blank or closed chevron that leads it. The background is laid again after
26
- * every reset inside the line, so styled text and buttons keep it.
44
+ * (`marked`), its leading chevron in the tone of what runs, or the `❯` in place of the bar or blank that leads it. The
45
+ * background is laid again after every reset inside the line, so styled text and buttons keep it.
27
46
  */
28
47
  export const focusLine = (line: string, width: number, marked: boolean): string => {
29
48
  const shown = marked ? markedLead(line) : line
@@ -38,6 +57,14 @@ export const focusHitAt = (hits: readonly FocusHit[], line: number): FocusHit |
38
57
  /** The screen a motion report is looked up on: header lines, the body's rows and how far it is scrolled. */
39
58
  export type HoverFrame = { headerRows: number; bodyRows: number; top: number }
40
59
 
60
+ /** A drawn screen as the mouse sees it: its lines, the rows on it and how the body is scrolled. */
61
+ export type HoverScreen = {
62
+ header: readonly string[]
63
+ bodyLines: readonly string[]
64
+ focusHits: readonly FocusHit[]
65
+ frame: HoverFrame
66
+ }
67
+
41
68
  /** The screen line (0-based, header first, the body unscrolled) under a motion report; null in the footer. */
42
69
  export const lineIndexAt = ({ row }: MouseEvent, { headerRows, bodyRows, top }: HoverFrame): number | null => {
43
70
  const line = row - 1
@@ -98,14 +125,53 @@ export const boldBracketAt = (line: string, from: number): string => {
98
125
  /** The key of the hit of a line that holds links but is no row of its own: it is marked by the mouse only. */
99
126
  export const LINKS_KEY_PREFIX = 'links@'
100
127
 
101
- /** The selection with the row under the mouse and the button in it set; the selection itself while both are the same. */
102
- export const withHovered = (selection: Selection, key: string | null, from: number | null = null): Selection =>
103
- (selection.hovered ?? null) === key && (selection.hoveredFrom ?? null) === from
128
+ const isLinksKey = (key: string | null | undefined): boolean => key?.startsWith(LINKS_KEY_PREFIX) === true
129
+
130
+ /** Whether the mouse moving onto `key` takes the selection: a row does, a line of links (no row of its own) and none do not. */
131
+ const takesSelection = (key: string | null): key is string => key !== null && !isLinksKey(key)
132
+
133
+ /** Whether the selected row is the one the mouse chose, not one the arrows moved to. */
134
+ const isMouseChosen = ({ selected, hovered }: Selection): boolean => (hovered ?? null) !== null && hovered === selected
135
+
136
+ /**
137
+ * The selection with the row under the mouse and the button in it set; the row also becomes the selected one, so
138
+ * the mouse and the arrows share one focus. When the mouse leaves for a line without a row (a blank line, a head, a
139
+ * text, a line of links), the row it chose is no longer selected; a row the arrows selected stays. The selection
140
+ * itself while nothing changes.
141
+ */
142
+ export const withHovered = (selection: Selection, key: string | null, from: number | null = null): Selection => {
143
+ const kept = isMouseChosen(selection) ? null : selection.selected
144
+ const selected = takesSelection(key) ? key : kept
145
+ const same =
146
+ (selection.hovered ?? null) === key && (selection.hoveredFrom ?? null) === from && selection.selected === selected
147
+ return same ? selection : { ...selection, selected, hovered: key, hoveredFrom: from }
148
+ }
149
+
150
+ /** The selection without the row under the mouse: a key press hands the focus back to the arrows. */
151
+ export const withoutHover = (selection: Selection): Selection =>
152
+ (selection.hovered ?? null) === null && (selection.hoveredFrom ?? null) === null
104
153
  ? selection
105
- : { ...selection, hovered: key, hoveredFrom: from }
154
+ : { ...selection, hovered: null, hoveredFrom: null }
155
+
156
+ /** The selection with the row and the square-bracket button under the mouse, as `withHovered` sets them. */
157
+ export const hoveredSelection = (selection: Selection, screen: HoverScreen, mouse: MouseEvent): Selection => {
158
+ const key = hoveredKeyAt(screen.focusHits, mouse, screen.frame)
159
+ const line = lineIndexAt(mouse, screen.frame)
160
+ const text = line === null ? '' : (screen.header[line] ?? screen.bodyLines[line - screen.frame.headerRows] ?? '')
161
+ return withHovered(selection, key, key === null ? null : bracketAt(stripAnsi(text), mouse.column - 1))
162
+ }
163
+
164
+ /**
165
+ * The selection after a new frame under a mouse that did not move (`mouse` is its last report, null before the first):
166
+ * a layout that moved another row under it gives that row the focus. Only while the mouse has the focus; after a key
167
+ * press the arrows keep it. The selection itself when nothing changes.
168
+ */
169
+ export const rehovered = (selection: Selection, screen: HoverScreen, mouse: MouseEvent | null): Selection =>
170
+ mouse === null || (selection.hovered ?? null) === null ? selection : hoveredSelection(selection, screen, mouse)
106
171
 
107
- /** Whether a row is the selected one or the one under the mouse. */
108
- export const isFocused = ({ selected, hovered }: Selection, key: string): boolean => key === selected || key === hovered
172
+ /** Whether a row has the focus: the selected one, unless the mouse is on a line of links, which then has it alone. */
173
+ export const isFocused = ({ selected, hovered }: Selection, key: string): boolean =>
174
+ key === selected && !isLinksKey(hovered)
109
175
 
110
176
  /** Whether the buttons of a row are all bold: it is the row the arrows selected, and the mouse is not on it. */
111
177
  export const buttonsAllBold = ({ selected, hovered }: Selection, key: string): boolean =>
@@ -115,14 +181,19 @@ export const buttonsAllBold = ({ selected, hovered }: Selection, key: string): b
115
181
  export const blockRowByKey = (blocks: readonly (BlockHit | null)[], key: string): BlockRow | null =>
116
182
  blocks.flatMap((block) => block?.rows ?? []).find((row) => row.key === key) ?? null
117
183
 
118
- /** The rows of a block on top of the herd, from the screen line of its first row; none while the block is not drawn or folded. */
184
+ /**
185
+ * The head of a block on top of the herd, when it can open, then its rows from the screen line of its first row; no
186
+ * rows while the block is folded, nothing while it is not drawn.
187
+ */
119
188
  export const focusHitsOfBlock = (block: BlockHit | null): FocusHit[] => {
120
189
  let line = block?.firstLine ?? 0
121
- return (block?.rows ?? []).map((row) => {
190
+ const head = block?.headKey ? [{ key: block.headKey, line: line - 1, lines: 1 }] : []
191
+ const rows = (block?.rows ?? []).map((row) => {
122
192
  const hit = { key: row.key, line, lines: row.lines.length }
123
193
  line += row.lines.length
124
194
  return hit
125
195
  })
196
+ return [...head, ...rows]
126
197
  }
127
198
 
128
199
  /**