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
package/src/plans/live.ts CHANGED
@@ -4,6 +4,8 @@ import { pathToFileURL } from 'node:url'
4
4
  import { terminalSafe } from '../plan-format/terminal-text'
5
5
  import { runHerdr } from '../terminal/herdr'
6
6
  import { openInBrowser } from '../terminal/open'
7
+ import { stripAnsi } from '../terminal/text'
8
+ import { closeWaiting } from '../waiting/command'
7
9
  import { appointmentKeys } from './appointment-rows'
8
10
  import { askCoordinator } from './ask-coordinator'
9
11
  import type { LaneMemory } from './current-phase'
@@ -21,16 +23,22 @@ import {
21
23
  import { fetchHeadPath } from './git'
22
24
  import { fetchPullRequests, noPullRequests, type PullRequestState } from './github'
23
25
  import { recallLanes, rememberLanes } from './herd-memory'
24
- import type { Screen } from './layout'
26
+ import { type Screen, settledSelection } from './layout'
27
+ import { nextKeys } from './next-rows'
25
28
  import { type KnownPage, pageAfterWrite, pageKey, pageNeedsWriting, planAnchorOn, withPlanChips } from './plan-chips'
26
29
  import { planPullRequests } from './plan-heads'
27
30
  import { herdLabel } from './plan-panes'
28
31
  import { pullRequestUrl } from './pull-requests'
29
32
  import { newReadCache, type ReadCache } from './read-cache'
30
33
  import { TAKEN_OVER_CODE, TAKEOVER_SIGNAL } from './restart-decision'
34
+ import { closeRow } from './row-actions'
35
+ import { blockRowByKey, bracketAt, hoveredKeyAt, lineIndexAt, withHovered } from './row-focus'
31
36
  import {
32
37
  inputOutcome,
38
+ isActivateKey,
39
+ isMotion,
33
40
  isSprintPageKey,
41
+ type MouseEvent,
34
42
  overviewSelection,
35
43
  parseMouse,
36
44
  settledBlockFolds,
@@ -39,7 +47,6 @@ import {
39
47
  syncLaneFolds,
40
48
  TOP_BLOCK,
41
49
  type TopBlockId,
42
- targetKeys,
43
50
  toggledBlockFold,
44
51
  toggledExpansion,
45
52
  type View,
@@ -48,6 +55,7 @@ import {
48
55
  import { BASE_REMOTE, baseRefOf, boardRepoPathOf, CLOCK_LOCALE } from './settings'
49
56
  import { singleFlight } from './single-flight'
50
57
  import { loadSnapshot, type Snapshot, type Source } from './snapshot'
58
+ import { sourceKeys } from './source-rows'
51
59
  import { exportSprintPage, writeSprintPageInSteps } from './sprint-export'
52
60
  import { boardTexts } from './texts'
53
61
  import {
@@ -61,7 +69,7 @@ import {
61
69
  TERMINAL_OFF,
62
70
  TERMINAL_ON,
63
71
  } from './view'
64
- import { type BlockClick, type BlockHit, blockClick, blockHeadClick, waitingKeys } from './waiting-click'
72
+ import { type BlockClick, type BlockHit, blockClick, blockHeadClick, mainClickOf, waitingKeys } from './waiting-click'
65
73
 
66
74
  /** The state of the running view; only the functions below `runLive` change it. */
67
75
  type Live = {
@@ -240,7 +248,9 @@ const readOnce = async (live: Live, onChange: () => void) => {
240
248
  )
241
249
  const settled = settledBlockFolds(folded, {
242
250
  waiting: waitingKeys(next.waiting ?? []),
251
+ next: nextKeys(next.next?.rows ?? []),
243
252
  appointments: appointmentKeys(next.appointments?.rows ?? []),
253
+ sources: sourceKeys(next.sourceBlock?.sources ?? []),
244
254
  })
245
255
  live.view = { ...live.view, selection: settled }
246
256
  live.snapshot = next
@@ -308,6 +318,15 @@ const applyBlockClick = (live: Live, outcome: BlockClick): void => {
308
318
  })
309
319
  return
310
320
  }
321
+ if ('done' in outcome) {
322
+ live.notice = closeRow(outcome.done, {
323
+ sprintFile: live.source.sprintFile,
324
+ language: live.source.settings.planLanguage,
325
+ now: new Date(),
326
+ closeRecords: (ids) => closeWaiting(live.source.root, ids, Date.now()),
327
+ })
328
+ return
329
+ }
311
330
  const reason = openInBrowser(outcome.open)
312
331
  if (reason) live.notice = boardTextsOf(live).notOpened(terminalSafe(reason))
313
332
  }
@@ -333,23 +352,72 @@ const clickBlock = (live: Live, block: TopBlockId, hit: BlockHit | null, input:
333
352
  return true
334
353
  }
335
354
 
355
+ /** The mouse moved: the row under it is drawn like the selected one, and the screen is redrawn only when that row changes. */
356
+ const hover = (live: Live, screen: Screen, mouse: MouseEvent): void => {
357
+ const frame = { headerRows: screen.header.length, bodyRows: bodyRows(screen), top: live.view.top }
358
+ const key = hoveredKeyAt(screen.focusHits, mouse, frame)
359
+ const line = lineIndexAt(mouse, frame)
360
+ const text = line === null ? '' : (screen.header[line] ?? screen.body.lines[line - frame.headerRows] ?? '')
361
+ const selection = withHovered(
362
+ live.view.selection,
363
+ key,
364
+ key === null ? null : bracketAt(stripAnsi(text), mouse.column - 1),
365
+ )
366
+ if (selection === live.view.selection) return
367
+ live.view = { ...live.view, selection }
368
+ redraw(live)
369
+ }
370
+
371
+ /**
372
+ * Enter or space on the selected row of a block on top does its main action (`mainClickOf`: ask, open, expand); true
373
+ * when the input was for such a row, whether or not it had anything to do.
374
+ */
375
+ const activateBlockRow = (live: Live, screen: Screen, input: string): boolean => {
376
+ if (!isActivateKey(input)) return false
377
+ const key = live.view.selection.selected
378
+ const row = key === null ? null : blockRowByKey(blocksOf(screen), key)
379
+ if (!row) return false
380
+ const click = mainClickOf(row)
381
+ if (click) applyBlockClick(live, click)
382
+ return true
383
+ }
384
+
385
+ const blocksOf = ({ waitingBlock, nextBlock, appointmentBlock, sourceBlock }: Screen): (BlockHit | null)[] => [
386
+ waitingBlock,
387
+ nextBlock,
388
+ appointmentBlock,
389
+ sourceBlock,
390
+ ]
391
+
336
392
  const handleInput = (live: Live, input: string) => {
337
393
  const { snapshot, screen } = live
338
394
  if (!snapshot || !screen) return
395
+ const mouse = parseMouse(input)
396
+ if (mouse && isMotion(mouse)) {
397
+ hover(live, screen, mouse)
398
+ return
399
+ }
339
400
  live.notice = null
401
+ live.view = { ...live.view, selection: settledSelection(live.view.selection, screen) }
402
+ if (activateBlockRow(live, screen, input)) {
403
+ redraw(live)
404
+ return
405
+ }
340
406
  if (isSprintPageKey(input)) {
341
407
  void writeSprintPage(live, snapshot)
342
408
  return
343
409
  }
344
410
  if (
345
411
  clickBlock(live, TOP_BLOCK.waiting, screen.waitingBlock, input) ||
346
- clickBlock(live, TOP_BLOCK.appointments, screen.appointmentBlock, input)
412
+ clickBlock(live, TOP_BLOCK.next, screen.nextBlock, input) ||
413
+ clickBlock(live, TOP_BLOCK.appointments, screen.appointmentBlock, input) ||
414
+ clickBlock(live, TOP_BLOCK.sources, screen.sourceBlock, input)
347
415
  ) {
348
416
  redraw(live)
349
417
  return
350
418
  }
351
419
  const context = {
352
- keys: targetKeys(screen.body.targets),
420
+ keys: screen.rowKeys,
353
421
  targets: screen.body.targets,
354
422
  headerRows: screen.header.length,
355
423
  bodyRows: bodyRows(screen),
@@ -0,0 +1,44 @@
1
+ import { sectionLines } from '../plan-format/markdown'
2
+ import { NEXT_VOCABULARY, type PlanLanguage } from '../vocabulary'
3
+ import { shortTitle } from './task-name'
4
+ import type { BlockItem } from './waiting-click'
5
+
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
13
+
14
+ const LIST_LINE = /^(?:\d+[.)]|[-*])\s+(.+)$/
15
+
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)
24
+ .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)
@@ -19,8 +19,15 @@ export type PlannedPlan = {
19
19
  phaseIds: string[]
20
20
  done: number
21
21
  total: number
22
+ /** Set when the plan file was last changed `STALE_PLAN_DAYS` or more days ago; absent otherwise or when git does not know. */
23
+ stale?: true
22
24
  }
23
25
 
26
+ /** Days without a change to its plan file from which a planned phase reads `stale?`. */
27
+ export const STALE_PLAN_DAYS = 14
28
+
29
+ const DAY_MS = 86_400_000
30
+
24
31
  /** A lane of the sprint and what its agent reports, null when it has none. */
25
32
  export type PlannedLane = { lane: SprintLane; agent: string | null }
26
33
 
@@ -76,9 +83,24 @@ export const plannedPlans = (input: PlannedInput): PlannedPlan[] => {
76
83
  })
77
84
  }
78
85
 
86
+ /** The plans with `stale` set on those whose file `touchedAt` says was last changed `STALE_PLAN_DAYS` or more days before `now`. */
87
+ export const markStale = (
88
+ plans: readonly PlannedPlan[],
89
+ touchedAt: (path: string) => number | null,
90
+ now: number,
91
+ ): PlannedPlan[] =>
92
+ plans.map((plan) => {
93
+ const touched = touchedAt(plan.path)
94
+ return touched !== null && now - touched >= STALE_PLAN_DAYS * DAY_MS ? { ...plan, stale: true } : plan
95
+ })
96
+
79
97
  /** `P3 P4 P5` */
80
98
  export const phaseList = (plan: Pick<PlannedPlan, 'phaseIds'>): string => phaseLabels(plan.phaseIds, ' ')
81
99
 
100
+ /** `P3`: the phase of the plan that comes next, the first open one in the order of the plan. */
101
+ export const plannedNextLabel = (plan: Pick<PlannedPlan, 'phaseIds' | 'stale'>): string =>
102
+ `${phaseLabels(plan.phaseIds.slice(0, 1), ' ')}${plan.stale ? ' stale?' : ''}`
103
+
82
104
  /** How many phases the plans list together. */
83
105
  export const plannedPhaseCount = (planned: readonly Pick<PlannedPlan, 'phaseIds'>[]): number =>
84
106
  planned.reduce((count, plan) => count + plan.phaseIds.length, 0)
@@ -0,0 +1,52 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs'
2
+ import type { PlanLanguage } from '../vocabulary'
3
+ import { boardRowKey } from './ask-target'
4
+ import { closeAppointmentRow, closeWaitingRow } from './board-close'
5
+ import { errorText } from './error-text'
6
+ import { isQuestionId } from './question-records'
7
+ import { DONE_KIND, type DoneTarget } from './row-targets'
8
+ import { TEXTS } from './texts'
9
+
10
+ /** What `closeRow` needs from the outside: the board file, its language, the clock, and the closing of log records. */
11
+ export type CloseDependencies = {
12
+ sprintFile: string
13
+ language: PlanLanguage
14
+ now: Date
15
+ /** Closes the records of the files and questions log by id; how many it closed. */
16
+ closeRecords: (ids: readonly string[]) => number
17
+ }
18
+
19
+ const closeRecord = (id: string, { closeRecords }: CloseDependencies): string => {
20
+ if (!isQuestionId(id)) return TEXTS.notClosed('not a record id')
21
+ return closeRecords([id]) > 0 ? TEXTS.closed(`record ${id}`) : TEXTS.notClosed('record not found or closed already')
22
+ }
23
+
24
+ const closedBoard = (target: DoneTarget, board: string, { language, now }: CloseDependencies): string | null => {
25
+ if (target.kind === DONE_KIND.boardRow) return closeWaitingRow(board, language, target, now)
26
+ return target.kind === DONE_KIND.appointment ? closeAppointmentRow(board, language, target, now) : null
27
+ }
28
+
29
+ const subjectOfRow = (target: DoneTarget): string => {
30
+ if (target.kind === DONE_KIND.boardRow) return `board row ${boardRowKey(target.since, target.item)}`
31
+ return target.kind === DONE_KIND.appointment ? `appointment ${boardRowKey(target.when, target.action)}` : ''
32
+ }
33
+
34
+ const closeBoardRow = (target: DoneTarget, dependencies: CloseDependencies): string => {
35
+ try {
36
+ const board = readFileSync(dependencies.sprintFile, 'utf8')
37
+ const closed = closedBoard(target, board, dependencies)
38
+ if (closed === null) return TEXTS.notClosed('row not found in the board')
39
+ writeFileSync(dependencies.sprintFile, closed)
40
+ return TEXTS.closed(subjectOfRow(target))
41
+ } catch (error) {
42
+ return TEXTS.notClosed(errorText(error))
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Closes what a `[done]` button names: a log record by its id, or a row of the board's file by writing the stamp into its
48
+ * status cell. Returns the footer line. The board is read again just before it is written, so a row the coordinator
49
+ * changed since the herd drew it is found by its text or not at all.
50
+ */
51
+ export const closeRow = (target: DoneTarget, dependencies: CloseDependencies): string =>
52
+ target.kind === DONE_KIND.record ? closeRecord(target.id, dependencies) : closeBoardRow(target, dependencies)
@@ -0,0 +1,148 @@
1
+ import { ESC, RESET, stripAnsi, visibleLength } from '../terminal/text'
2
+ import { textWidth } from '../terminal/width'
3
+ import { isChipKey } from './link-chips'
4
+ import type { ClickTarget, MouseEvent, Selection } from './selection'
5
+ import type { BlockHit, BlockRow } from './waiting-click'
6
+
7
+ /** A clickable row on screen: its key, the screen line (0-based, header lines first) it starts on and how many lines it takes. */
8
+ export type FocusHit = { key: string; line: number; lines: number }
9
+
10
+ export const FOCUS_MARK = '❯'
11
+ export const FOCUS_ON = `${ESC}[48;5;238m`
12
+
13
+ const BACKGROUND_LOST = new RegExp(`${ESC}\\[(?:0|49)?m`, 'g')
14
+ 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([' ', '▌', '▸'])
17
+
18
+ const markedLead = (line: string): string =>
19
+ line.replace(LEADING_STYLES, (whole, styles: string, lead: string) =>
20
+ MARKABLE_LEADS.has(lead) ? `${styles}${FOCUS_MARK}` : whole,
21
+ )
22
+
23
+ /**
24
+ * 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.
27
+ */
28
+ export const focusLine = (line: string, width: number, marked: boolean): string => {
29
+ const shown = marked ? markedLead(line) : line
30
+ const padded = `${shown}${' '.repeat(Math.max(width - visibleLength(shown), 0))}`
31
+ return `${FOCUS_ON}${padded.replace(BACKGROUND_LOST, (reset) => `${reset}${FOCUS_ON}`)}${RESET}`
32
+ }
33
+
34
+ /** The row on screen line `line`, null where none is. */
35
+ export const focusHitAt = (hits: readonly FocusHit[], line: number): FocusHit | null =>
36
+ hits.find((hit) => line >= hit.line && line < hit.line + hit.lines) ?? null
37
+
38
+ /** The screen a motion report is looked up on: header lines, the body's rows and how far it is scrolled. */
39
+ export type HoverFrame = { headerRows: number; bodyRows: number; top: number }
40
+
41
+ /** The screen line (0-based, header first, the body unscrolled) under a motion report; null in the footer. */
42
+ export const lineIndexAt = ({ row }: MouseEvent, { headerRows, bodyRows, top }: HoverFrame): number | null => {
43
+ const line = row - 1
44
+ if (line < headerRows) return line
45
+ const bodyLine = line - headerRows
46
+ return bodyLine < bodyRows ? headerRows + top + bodyLine : null
47
+ }
48
+
49
+ /** The key of the row under the mouse, null over none. */
50
+ export const hoveredKeyAt = (hits: readonly FocusHit[], mouse: MouseEvent, frame: HoverFrame): string | null => {
51
+ const line = lineIndexAt(mouse, frame)
52
+ return line === null ? null : (focusHitAt(hits, line)?.key ?? null)
53
+ }
54
+
55
+ const BRACKET_BUTTON = /\[[^[\]]+\]/g
56
+
57
+ /** The columns `[from, to)` of the square-bracket buttons in the visible text of a line. */
58
+ export const bracketSpans = (visible: string): { from: number; to: number }[] =>
59
+ [...visible.matchAll(BRACKET_BUTTON)].map((match) => {
60
+ const from = textWidth(visible.slice(0, match.index))
61
+ return { from, to: from + textWidth(match[0]) }
62
+ })
63
+
64
+ /** The column a square-bracket button under `column` starts in, null where the mouse is over none. */
65
+ export const bracketAt = (visible: string, column: number): number | null =>
66
+ bracketSpans(visible).find((span) => column >= span.from && column < span.to)?.from ?? null
67
+
68
+ const STYLE_OR_LINK = new RegExp(`(${ESC}\\[[0-9;]*m|${ESC}\\]8;[^;${ESC}\\x07]*;[^${ESC}\\x07]*(?:${ESC}\\\\|\\x07))`)
69
+ const BOLD_ON = `${ESC}[1m`
70
+ const BOLD_OFF = `${ESC}[22m`
71
+
72
+ /** The line with the cells `[from, to)` bold, counted by what shows; styles and links in the line stay where they are. */
73
+ export const boldCells = (line: string, from: number, to: number): string => {
74
+ let column = 0
75
+ return line
76
+ .split(STYLE_OR_LINK)
77
+ .map((token, index) => {
78
+ if (index % 2 === 1) return token
79
+ return [...token]
80
+ .map((character) => {
81
+ const start = column
82
+ column += textWidth(character)
83
+ const opens = start === from ? BOLD_ON : ''
84
+ const closes = column >= to && start < to ? BOLD_OFF : ''
85
+ return `${opens}${character}${closes}`
86
+ })
87
+ .join('')
88
+ })
89
+ .join('')
90
+ }
91
+
92
+ /** The line with the square-bracket button that starts in column `from` bold; the line itself where none does. */
93
+ export const boldBracketAt = (line: string, from: number): string => {
94
+ const span = bracketSpans(stripAnsi(line)).find((each) => each.from === from)
95
+ return span ? boldCells(line, span.from, span.to) : line
96
+ }
97
+
98
+ /** 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
+ export const LINKS_KEY_PREFIX = 'links@'
100
+
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
104
+ ? selection
105
+ : { ...selection, hovered: key, hoveredFrom: from }
106
+
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
109
+
110
+ /** Whether the buttons of a row are all bold: it is the row the arrows selected, and the mouse is not on it. */
111
+ export const buttonsAllBold = ({ selected, hovered }: Selection, key: string): boolean =>
112
+ key === selected && key !== hovered
113
+
114
+ /** The row of a drawn block that has `key`, null for a key of the body. */
115
+ export const blockRowByKey = (blocks: readonly (BlockHit | null)[], key: string): BlockRow | null =>
116
+ blocks.flatMap((block) => block?.rows ?? []).find((row) => row.key === key) ?? null
117
+
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. */
119
+ export const focusHitsOfBlock = (block: BlockHit | null): FocusHit[] => {
120
+ let line = block?.firstLine ?? 0
121
+ return (block?.rows ?? []).map((row) => {
122
+ const hit = { key: row.key, line, lines: row.lines.length }
123
+ line += row.lines.length
124
+ return hit
125
+ })
126
+ }
127
+
128
+ /**
129
+ * The rows of a body from the lines its whole-line targets span, `offset` screen lines below the top; a chip is part of
130
+ * its row. A line with chips and no row of its own (the links under a lane) is a hit under a key made of its line.
131
+ */
132
+ export const focusHitsOfTargets = (targets: readonly ClickTarget[], offset: number): FocusHit[] => {
133
+ const hits = new Map<string, FocusHit>()
134
+ for (const { key, line, wholeLine } of targets) {
135
+ if (!wholeLine || isChipKey(key)) continue
136
+ const known = hits.get(key)
137
+ hits.set(
138
+ key,
139
+ known ? { ...known, lines: line + 1 - (known.line - offset) } : { key, line: offset + line, lines: 1 },
140
+ )
141
+ }
142
+ const rowLines = new Set(targets.filter(({ wholeLine }) => wholeLine).map(({ line }) => line))
143
+ const linkLines = [...new Set(targets.filter(({ key }) => isChipKey(key)).map(({ line }) => line))]
144
+ const links = linkLines
145
+ .filter((line) => !rowLines.has(line))
146
+ .map((line) => ({ key: `${LINKS_KEY_PREFIX}${offset + line}`, line: offset + line, lines: 1 }))
147
+ return [...hits.values(), ...links]
148
+ }
@@ -0,0 +1,7 @@
1
+ /** What a `[done]` button closes: a record of the files and questions log, a row of the waiting section, an appointment row. */
2
+ export const DONE_KIND = { record: 'record', boardRow: 'boardRow', appointment: 'appointment' } as const
3
+
4
+ export type DoneTarget =
5
+ | { kind: typeof DONE_KIND.record; id: string }
6
+ | { kind: typeof DONE_KIND.boardRow; since: string; item: string }
7
+ | { kind: typeof DONE_KIND.appointment; when: string; action: string }
@@ -10,7 +10,7 @@ import { TEXTS } from './texts'
10
10
  export type LaneFold = { open: boolean }
11
11
 
12
12
  /** The blocks on top of the herd, by the name their fold is remembered under. */
13
- export const TOP_BLOCK = { waiting: 'waiting', appointments: 'appointments' } as const
13
+ export const TOP_BLOCK = { waiting: 'waiting', next: 'next', appointments: 'appointments', sources: 'sources' } as const
14
14
  export type TopBlockId = (typeof TOP_BLOCK)[keyof typeof TOP_BLOCK]
15
15
 
16
16
  /**
@@ -20,6 +20,10 @@ export type TopBlockId = (typeof TOP_BLOCK)[keyof typeof TOP_BLOCK]
20
20
  */
21
21
  export type Selection = {
22
22
  selected: string | null
23
+ /** The row under the mouse, null or absent while it is over none; drawn like the selected one. */
24
+ hovered?: string | null
25
+ /** The column the square-bracket button under the mouse starts in; null or absent while the mouse is over none. */
26
+ hoveredFrom?: number | null
23
27
  expanded: ReadonlySet<string>
24
28
  showDone: boolean
25
29
  showLegend: boolean
@@ -313,6 +317,9 @@ export const applyKey = (selection: Selection, input: string, keys: readonly str
313
317
  return selection
314
318
  }
315
319
 
320
+ /** Whether an input is enter or space, which acts on the selected row. */
321
+ export const isActivateKey = (input: string): boolean => isInput(INPUT.toggle, input)
322
+
316
323
  /** The key that writes the whole sprint as an HTML page and opens it. */
317
324
  export const isSprintPageKey = (input: string): boolean => isInput(INPUT.sprintPage, input)
318
325
 
@@ -322,6 +329,12 @@ export const parseMouse = (key: string): MouseEvent | null => {
322
329
  return { button: Number(match[1]), column: Number(match[2]), row: Number(match[3]), pressed: match[4] === 'M' }
323
330
  }
324
331
 
332
+ const MOTION_BIT = 32
333
+
334
+ /** Whether a report is the mouse moving (with or without a button held), not a click, a release or the wheel. */
335
+ export const isMotion = ({ button, pressed }: MouseEvent): boolean =>
336
+ pressed && button >= MOTION_BIT && button < MOUSE_BUTTON.wheelUp
337
+
325
338
  export const targetAt = (targets: readonly ClickTarget[], line: number, column: number): string | null =>
326
339
  targets.find((target) => target.line === line && column >= target.from && column < target.to)?.key ?? null
327
340
 
@@ -3,6 +3,7 @@ import { join, relative } from 'node:path'
3
3
  import { type JackherdConfig, parseJackherdConfig } from '../config'
4
4
  import { PROJECT_CONFIG_PATH } from '../config/location'
5
5
  import { readConfig } from '../config/read-config'
6
+ import type { ToneName } from '../config/tones'
6
7
  import type { TicketSource } from '../watch/plan-badges'
7
8
 
8
9
  export const CONFIG_PATH = PROJECT_CONFIG_PATH
@@ -17,6 +18,12 @@ export type PlansSettings = Pick<
17
18
  context: JackherdConfig['plans']['context']
18
19
  /** The `watch.sources` marked `ticket`, whose entries the view shows as badges. */
19
20
  ticketSources: TicketSource[]
21
+ /** The other `watch.sources` by name, with the tone each names for a new report; the herd lists them in a block of their own. */
22
+ herdSources: { name: string; tone: ToneName | null }[]
23
+ /** By source name: what the herd puts before a row of "Waiting for you" that came from the source, its `label` or else its name. */
24
+ sourceLabels: Record<string, string>
25
+ /** The tone of each role in the herd. */
26
+ tones: JackherdConfig['plans']['tones']
20
27
  }
21
28
 
22
29
  /** The locale of the herd's clock: the herd is English, with a 24-hour clock. */
@@ -38,6 +45,9 @@ export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
38
45
  ticketSources: config.watch.sources.flatMap(({ name, lane, ticket }) =>
39
46
  ticket ? [{ name, lane: lane ?? null }] : [],
40
47
  ),
48
+ herdSources: config.watch.sources.flatMap(({ name, ticket, tone }) => (ticket ? [] : [{ name, tone: tone ?? null }])),
49
+ sourceLabels: Object.fromEntries(config.watch.sources.map(({ name, label }) => [name, label ?? name])),
50
+ tones: config.plans.tones,
41
51
  })
42
52
 
43
53
  /** The herd's slice of `.jackherd/config.json`, validated by the shared schema. */