jackherd 1.11.1 → 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 +22 -0
  2. package/README.md +76 -58
  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,8 +1,19 @@
1
- import { STALE_ACTIVITY_MINUTES } from './lane-summary'
1
+ import { ARTIFACT_LINK_KIND } from '../config/link-kinds'
2
+ import { AGENT_STATUS } from '../terminal/types'
3
+ import type { LegendSection } from './legend'
2
4
  import { STALE_PLAN_DAYS } from './planned'
3
5
  import { CHECK_NAME_LENGTH, CHECKS, type ChecksState, type PullRequest } from './pull-requests'
4
6
  import { shownWithMore } from './shown-with-more'
5
- import { MODEL_MARK, NO_AGENT_WORD, PHASE_COUNT } from './symbols'
7
+ import {
8
+ CHEVRON,
9
+ MODEL_MARK,
10
+ NO_AGENT_SYMBOL,
11
+ NO_AGENT_WORD,
12
+ PHASE_COUNT,
13
+ STATUS_SYMBOL,
14
+ TURN_MARK,
15
+ UNKNOWN_SYMBOL,
16
+ } from './symbols'
6
17
 
7
18
  /** The words of an open lane: the age of a running phase, the words before the open phases that wait and the finished ones, and the pointer of a phase with only its review gate open. */
8
19
  export type HerdTexts = {
@@ -19,21 +30,48 @@ export type HerdTexts = {
19
30
  */
20
31
  export type PlanTexts = {
21
32
  noAgent: string
22
- waiting: (lanes: number) => string
23
33
  startsAfter: (wait: string) => string
24
34
  noPlan: string
25
35
  noWorktree: string
26
36
  noRow: string
27
37
  /** The heads of the blocks on top of the herd. */
28
- waitingHeading: string
29
- appointmentHeading: string
38
+ yourTurnHeading: string
30
39
  sourceHeading: string
31
- nextHeading: string
32
- /** On a resting lane's line: the label before its starts-after cell, and the line for a lane without any reason. */
40
+ /** The heads of the blocks of the body: the lanes at work, the lanes that wait for their start, the unstarted plans. */
41
+ lanesHeading: string
42
+ waitingHeading: string
43
+ backlogHeading: string
44
+ /** The balance of the lanes head: how many lanes wait on you, how many idle otherwise. */
45
+ lanesWaitOnYou: (lanes: number) => string
46
+ lanesIdle: (lanes: number) => string
47
+ /** On a resting lane's line: the labels before a question to you, a row of "Your turn" and its starts-after cell, and the line for a lane without any reason. */
48
+ restWaitsOnYou: string
49
+ /** After `restWaitsOnYou`: how many questions of its report wait, their text being rows of "Your turn". */
50
+ restQuestions: (count: number) => string
51
+ restWaitsOn: string
33
52
  restAfter: string
34
53
  restNoReason: string
35
- /** After the head of the next block when the board's section holds no line. */
36
- nextEmpty: string
54
+ /** On an open lane's session line: how long its agent has rested. */
55
+ idleFor: (minutes: number) => string
56
+ /** The row under the tasks a phase shows of its open ones: how many more are open and how many are done. */
57
+ moreTasks: (open: number, done: number) => string
58
+ /** The row under an opened task's brief: how many lines its rest takes. */
59
+ moreLines: (count: number) => string
60
+ /** The footer while a row has the focus: what a click or a key does on it. */
61
+ clickHint: (action: string) => string
62
+ hintOpens: (name: string) => string
63
+ hintFolds: (name: string) => string
64
+ hintListsTasks: (phase: string) => string
65
+ hintFoldsTasks: (phase: string) => string
66
+ hintBlockAsks: string
67
+ hintBlockOpens: string
68
+ /** What the click hint calls a task, the rest of an opened task, the phases `next` lists and the ones `done` lists. */
69
+ hintTask: string
70
+ hintTaskRest: string
71
+ hintNextPhases: string
72
+ hintDonePhases: string
73
+ /** After the head of "Your turn" when nothing waits for the user and nothing is planned. */
74
+ yourTurnEmpty: string
37
75
  notInPlan: string
38
76
  /** Before a task an agent reports that no task of the plan names. */
39
77
  now: string
@@ -41,21 +79,18 @@ export type PlanTexts = {
41
79
  subagent: string
42
80
  /** In place of the count of a phase whose only open box is the documentation box of its review gate. */
43
81
  allButDocs: string
44
- tickedSummary: (count: number) => string
45
- /** The line above the listed ticked tasks of a phase; a click or `a` folds them again. */
46
- tickedFold: (count: number) => string
47
82
  draft: string
48
83
  privateLink: string
49
- planned: (phases: number, plans: number) => string
50
- /** The head of the planned section in the herd: `planned · 3 plans, next:`. */
51
- plannedHead: (plans: number) => string
84
+ /** The head of the backlog on the HTML page: `Backlog (3 phases in 2 plans)`. */
85
+ backlog: (phases: number, plans: number) => string
86
+ /** After the backlog head: `next: <plan> <phase>, …`. */
87
+ backlogNext: string
52
88
  phaseTally: (done: number, total: number) => string
53
89
  herd: HerdTexts
54
90
  checks: Record<ChecksState, string>
55
91
  hint: string
56
- /** The notice when `a` is pressed with no phase open, where the key changes nothing on screen. */
57
- showDoneNeedsPhase: string
58
- legend: string[]
92
+ /** The help behind `?`: marks and keys with their meaning; the rules behind them stand in the README. */
93
+ legend: LegendSection[]
59
94
  /** The footer line when the coordinator could not be asked to put a question, with the reason. */
60
95
  notAsked: (reason: string) => string
61
96
  /** The footer line when the coordinator `name` was asked to put `subject`, `question <id>` or `board row <key>`. */
@@ -99,24 +134,42 @@ const ALL_BUT_DOCS = 'docs open'
99
134
 
100
135
  export const TEXTS: PlanTexts = {
101
136
  noAgent: NO_AGENT_WORD,
102
- waiting: (lanes) => `waiting (${lanes})`,
103
137
  startsAfter: (wait) => `after ${wait}`,
104
138
  noPlan: 'no plan',
105
139
  noWorktree: 'no worktree',
106
140
  noRow: 'no row in lanes',
107
- waitingHeading: 'Waiting for you',
108
- appointmentHeading: 'Appointments',
141
+ yourTurnHeading: 'Your turn',
109
142
  sourceHeading: 'Sources',
110
- nextHeading: 'Next',
111
- restAfter: 'after:',
143
+ lanesHeading: 'Lanes',
144
+ waitingHeading: 'Waiting',
145
+ backlogHeading: 'Backlog',
146
+ lanesWaitOnYou: (lanes) => `${lanes} ${lanes === 1 ? 'waits' : 'wait'} on you`,
147
+ lanesIdle: (lanes) => `${lanes} idle`,
148
+ restWaitsOnYou: 'waits on you',
149
+ restQuestions: (count) => `· ${count} ${count === 1 ? 'question' : 'questions'}`,
150
+ restWaitsOn: 'waits on',
151
+ restAfter: 'after',
112
152
  restNoReason: 'idle · no reason',
113
- nextEmpty: 'nothing queued, groom the board',
153
+ idleFor: (minutes) => `idle ${minutes < 60 ? `${minutes} min` : `${Math.floor(minutes / 60)} h`}`,
154
+ moreTasks: (open, done) =>
155
+ [...(open > 0 ? [`${open} more open`] : []), ...(done > 0 ? [`${done} done`] : [])].join(' · '),
156
+ moreLines: (count) => `${count} more ${count === 1 ? 'line' : 'lines'}`,
157
+ clickHint: (action) => `${action} · ? help`,
158
+ hintOpens: (name) => `click or → opens ${name}`,
159
+ hintFolds: (name) => `click or ← folds ${name}`,
160
+ hintListsTasks: (phase) => `click or → lists the tasks of ${phase}`,
161
+ hintFoldsTasks: (phase) => `click or ← folds the tasks of ${phase}`,
162
+ hintBlockAsks: 'click or enter asks the coordinator',
163
+ hintBlockOpens: 'click or enter opens it',
164
+ hintTask: 'the task',
165
+ hintTaskRest: 'the rest of the task',
166
+ hintNextPhases: 'the next phases',
167
+ hintDonePhases: 'the done phases',
168
+ yourTurnEmpty: 'nothing waits for you',
114
169
  notInPlan: 'not in the plan',
115
170
  now: 'now:',
116
171
  subagent: 'Subagent:',
117
172
  allButDocs: ALL_BUT_DOCS,
118
- tickedSummary: (count) => `${count} ticked · click or a lists them`,
119
- tickedFold: (count) => `${count} ticked · click or a folds them`,
120
173
  draft: 'draft · ',
121
174
  privateLink: 'private',
122
175
  phaseTally: (done, total) => (total > 0 ? `${done}/${total} ph` : ''),
@@ -127,26 +180,115 @@ export const TEXTS: PlanTexts = {
127
180
  reviewGate: 'Review gate',
128
181
  gate: '· gate',
129
182
  },
130
- planned: (phases, plans) =>
131
- `planned (${phases} ${phases === 1 ? 'phase' : 'phases'} in ${plans} ${plans === 1 ? 'plan' : 'plans'})`,
132
- plannedHead: (plans) => `planned · ${plans} ${plans === 1 ? 'plan' : 'plans'}, next:`,
183
+ backlog: (phases, plans) =>
184
+ `Backlog (${phases} ${phases === 1 ? 'phase' : 'phases'} in ${plans} ${plans === 1 ? 'plan' : 'plans'})`,
185
+ backlogNext: 'next:',
133
186
  checks: {
134
187
  [CHECKS.passed]: '✓ checks',
135
188
  [CHECKS.failed]: '✗ checks',
136
189
  [CHECKS.pending]: '… checks',
137
190
  [CHECKS.none]: 'no checks',
138
191
  },
139
- hint: '↑↓ select →← open a/click ticked tasks s html q quit ? help',
140
- showDoneNeedsPhase: 'a lists the ticked tasks inside an open phase: open one with → or a click',
192
+ hint: '↑↓ select →← open s html q quit ? help',
141
193
  legend: [
142
- 'enter/space or click toggles a lane or a phase, click or enter on a task opens its whole text, and opens a ↗ pull request · wheel PgUp PgDn scroll · j k h l like the arrows · click or ctrl+click on a link chip opens its link · ✦ artifact · ⑂ pull request or issue · ◈ Figma · private = the plan says so',
143
- '● working · ◆ blocked, waits for you · ◉ done = the agent finished its answer, not the lane · ○ idle · (·) no agent = a worktree nobody works in · • state unknown · only blocked wears its word in the lane line, the symbol says the rest · waiting = not started: no worktree yet, something left to do, and what it starts after; finished lanes (cleaned up, or every named phase ticked) are not shown · a working or blocked lane opens by itself only when it first appears, after that → ← and clicks are yours alone',
144
- `▸ ▾ open and close · an open lane's branches, top down: the agent's title and ⎇ its branch, ↗ its pull requests, its link chips, the phases it works on, next, done · a lane shows only the phases its board rows name · a phase row = some of its tasks are ticked, one of its boxes was ticked in the last ${STALE_ACTIVITY_MINUTES} min, or an agent reports working on it, by number · n min ago since the last tick or report, · gate = only the review gate is open · ▶ teal = a task an agent reported (its task list or jh task; its name dim at the right edge when several agents work in the lane), ▷ dim = only guessed from the words of its log · now: <text> = a reported task no plan task names · a running review sits on the gate box that names it, in the phase it names or else the lane's current one · Subagent: <text> = a running subagent that names no phase and sits on none · next = the other open phases, as ranges · done = the finished ones · ${ALL_BUT_DOCS} = only the documentation box is open · a folded lane line names the phase moved last, +n more active ones, and its count · ${PHASE_COUNT.empty} no checkboxes · ${PHASE_COUNT.missing} not in the plan · a or a click on its ▸ n ticked line lists the ticked checkboxes of an open phase one by one, a or a click on its ▾ line folds them · s opens every lane and plan as one HTML page`,
145
- `the bar = progress over the lane's plans · n/m = m checkboxes, n ticked · n/m ph = n of m phases finished · n/n in green = all ticked · 🥣 share of the context window used, on a folded lane only once it warns · Sonnet ${MODEL_MARK.differs} Opus in amber = the phase's subagent ran on another model than the plan's`,
146
- '↗ pull request · 💬 new feedback · ✋ changes requested · 🌐 preview · 📡 ticket: chip 📡 T-1 · status among the link chips, 📡 <n> in amber on a folded lane when tickets are new',
147
- `▌ on top = waits for your answer: 📎 a file or an Artifact that was sent (click: a cut-off row expands, then it opens), ? a question of the board or one a session left in its dialog (a click on its text only expands and folds it; [ask] at the right edge of a question asks the coordinator to put it; [done] closes the row: a board row gets its status, a sent file its record) · it goes when the board row is closed or \`jh waiting close <id>\` · \`Waiting for you\` heads the block (click: fold; click on +n: show all, on ▴ n: five) · ◷ the appointments of the board in a grey bar below it (click: fold; [done] closes the row, [run] asks the coordinator to run it now, [move] to ask you for a new time) · 1 2 3 = Next, the board lines to do next · ● Sources = the watcher’s sources that are no tickets: when each ran and its newest report, the mark amber while that report is under a day old and red while the source fails (click on a row: opens the entry) · planned = the open phases no lane works on, the next one per plan; stale? = its plan file was not changed on the base branch for ${STALE_PLAN_DAYS} days`,
148
- '⎇ branch · ↑ commits ahead · ± uncommitted · no worktree: plan read from main',
149
- '❯ and a light background = the row under the mouse or the arrows, on every clickable row · enter on a block row asks, opens or expands it and never presses [done] · [xx] in square brackets = a click does something, bold under the mouse',
194
+ {
195
+ title: 'Keys',
196
+ entries: [
197
+ { mark: '↑↓ j k', meaning: 'select a row' },
198
+ { mark: '→ ← enter', meaning: 'open, fold' },
199
+ { mark: 'h l space', meaning: 'like ← → and enter' },
200
+ { mark: 's', meaning: 'every lane and plan as one HTML page' },
201
+ { mark: 'wheel PgUp PgDn', meaning: 'scroll' },
202
+ { mark: 'click', meaning: 'what the footer says' },
203
+ { mark: 'ctrl+click', meaning: 'open a link chip' },
204
+ { mark: '? esc', meaning: 'close help' },
205
+ { mark: 'q', meaning: 'quit' },
206
+ ],
207
+ },
208
+ {
209
+ title: 'Lanes',
210
+ entries: [
211
+ { mark: STATUS_SYMBOL[AGENT_STATUS.working], meaning: 'agent working' },
212
+ { mark: STATUS_SYMBOL[AGENT_STATUS.blocked], meaning: 'blocked, waits for you' },
213
+ { mark: STATUS_SYMBOL[AGENT_STATUS.done], meaning: 'done: the agent finished its answer' },
214
+ { mark: STATUS_SYMBOL[AGENT_STATUS.idle], meaning: 'idle' },
215
+ { mark: `(${NO_AGENT_SYMBOL}) ${NO_AGENT_WORD}`, meaning: 'a worktree nobody works in' },
216
+ { mark: UNKNOWN_SYMBOL, meaning: 'state unknown' },
217
+ { mark: 'waits on you', meaning: '· n questions: open questions of its report' },
218
+ { mark: '🥣 36 %', meaning: 'context used' },
219
+ { mark: '⎇ ↑ ±', meaning: 'branch, commits ahead, uncommitted' },
220
+ { mark: 'n/m ph', meaning: 'phases finished' },
221
+ { mark: 'stale?', meaning: `plan unchanged for ${STALE_PLAN_DAYS} days` },
222
+ ],
223
+ },
224
+ {
225
+ title: 'Tasks',
226
+ entries: [
227
+ { mark: '● teal', meaning: 'running (reported), its text shimmers' },
228
+ { mark: '◌ dim', meaning: 'running (guessed)' },
229
+ { mark: `${CHEVRON.closed} ${CHEVRON.open}`, meaning: 'opens / folds' },
230
+ { mark: '☐ ☑', meaning: 'open, done' },
231
+ { mark: '→ P9', meaning: 'moved to another phase' },
232
+ { mark: 'now:', meaning: 'reported, no plan task names it' },
233
+ { mark: 'Subagent:', meaning: 'a subagent on no phase' },
234
+ { mark: '· gate', meaning: 'only the review gate is open' },
235
+ { mark: ALL_BUT_DOCS, meaning: 'only the documentation box is open' },
236
+ { mark: 'n/m', meaning: 'n of m checkboxes ticked' },
237
+ { mark: PHASE_COUNT.empty, meaning: 'no checkboxes' },
238
+ { mark: PHASE_COUNT.missing, meaning: 'not in the plan' },
239
+ { mark: `Sonnet ${MODEL_MARK.differs} Opus`, meaning: "another model than the plan's" },
240
+ ],
241
+ },
242
+ {
243
+ title: 'Your turn',
244
+ entries: [
245
+ { mark: TURN_MARK.now, meaning: 'question, amber' },
246
+ { mark: TURN_MARK.file, meaning: 'file or artifact sent' },
247
+ { mark: TURN_MARK.planned, meaning: 'appointment, teal' },
248
+ { mark: 'unblocks …', meaning: 'the lanes it frees' },
249
+ { mark: '[ask]', meaning: 'ask the coordinator to put it' },
250
+ { mark: '[done]', meaning: 'close the row' },
251
+ { mark: '[run]', meaning: 'run the appointment now' },
252
+ { mark: '[move]', meaning: 'ask for a new time' },
253
+ ],
254
+ },
255
+ {
256
+ title: 'Sources',
257
+ entries: [
258
+ { mark: 'amber', meaning: 'a new entry, not in Your turn' },
259
+ { mark: 'red', meaning: 'the source fails' },
260
+ { mark: '↑ in your turn', meaning: 'a row of Your turn asks about it' },
261
+ ],
262
+ },
263
+ {
264
+ title: 'Links',
265
+ entries: [
266
+ { mark: ARTIFACT_LINK_KIND.icon, meaning: 'artifact' },
267
+ { mark: '⑂', meaning: 'pull request or issue' },
268
+ { mark: '◈', meaning: 'Figma' },
269
+ { mark: '↗', meaning: 'pull request' },
270
+ { mark: '💬', meaning: 'new feedback' },
271
+ { mark: '✋', meaning: 'changes requested' },
272
+ { mark: '🌐', meaning: 'preview' },
273
+ { mark: '📡 T-1', meaning: 'ticket' },
274
+ { mark: '+n', meaning: 'more chips' },
275
+ { mark: 'private', meaning: 'the plan says so' },
276
+ ],
277
+ },
278
+ {
279
+ title: 'Colours',
280
+ entries: [
281
+ { mark: 'bold', meaning: 'a heading: lane name, phase title' },
282
+ { mark: 'plain', meaning: 'an open task' },
283
+ { mark: 'dim', meaning: 'an addition: time, ▸ n done, a path' },
284
+ { mark: '❯ light', meaning: 'the row under the mouse or the arrows' },
285
+ { mark: 'teal', meaning: 'runs: a reported task and its ●, or planned' },
286
+ { mark: 'amber', meaning: 'waits on you' },
287
+ { mark: 'red', meaning: 'fails' },
288
+ { mark: 'green', meaning: 'all ticked' },
289
+ { mark: '[xx] bold', meaning: 'the button under the mouse' },
290
+ ],
291
+ },
150
292
  ],
151
293
  notAsked: (reason) => `no coordinator to ask: ${reason}`,
152
294
  asked: (name, subject) => `asked ${name} for ${subject}`,
package/src/plans/view.ts CHANGED
@@ -1,16 +1,29 @@
1
1
  import { ARTIFACT_LINK_KIND } from '../config/link-kinds'
2
2
  import { DEFAULT_TONES } from '../config/tones'
3
- import { fit, plainText } from '../terminal/text'
4
- import { renderSelected, type Screen } from './layout'
3
+ import { fit, paint, plainText, visibleLength } from '../terminal/text'
4
+ import { type AgentOf, clickHint } from './click-hint'
5
+ import { type OverviewView, renderScreen, renderSelected, type Screen } from './layout'
5
6
  import type { Options } from './options'
7
+ import { blockRowByKey, boldBracketAt, type FocusHit, focusLine, isFocused, LINKS_KEY_PREFIX } from './row-focus'
6
8
  import { laneSelection, overviewSelection, type Selection } from './selection'
9
+ import { longestShimmer, type ShimmerRow, shimmerAt, shimmerRows } from './shimmer'
7
10
  import type { Snapshot } from './snapshot'
11
+ import { TEXTS } from './texts'
8
12
  import { setTones } from './tones'
9
13
 
10
14
  const DEFAULT_WIDTH = 100
11
15
  const CURSOR_HOME = '\x1b[H'
12
16
  const CLEAR_LINE_END = '\x1b[K'
13
17
  const CLEAR_BELOW = '\x1b[J'
18
+ const SYNC_BEGIN = '\x1b[?2026h'
19
+ const SYNC_END = '\x1b[?2026l'
20
+
21
+ /** One frame at 60 Hz: the herd draws at most once in this time. */
22
+ export const FRAME_MS = 16
23
+
24
+ /** How long to wait before the next drawing, so there is at most one per `FRAME_MS`; null `lastFrameAt` before the first. */
25
+ export const nextFrameDelay = (lastFrameAt: number | null, now: number): number =>
26
+ lastFrameAt === null ? 0 : Math.max(FRAME_MS - (now - lastFrameAt), 0)
14
27
 
15
28
  export const TERMINAL_ON = '\x1b[?1049h\x1b[?25l\x1b[?1000h\x1b[?1003h\x1b[?1006h'
16
29
  export const TERMINAL_OFF = '\x1b[?1006l\x1b[?1003l\x1b[?1000l\x1b[?25h\x1b[?1049l'
@@ -23,52 +36,205 @@ const paneRows = (): number => process.stdout.rows ?? Number.MAX_SAFE_INTEGER
23
36
  export const bodyRows = (screen: Screen): number =>
24
37
  Math.max(paneRows() - screen.header.length - screen.footer.length, 1)
25
38
 
26
- /** The time as `HH:MM`, in the header and in the refresh error alike: a frame differs only once a minute. */
39
+ /** The time as `HH:MM`, for the refresh error; the header carries no clock, so a quiet herd draws nothing new. */
27
40
  export const clockOf = (locale: string, now: Date = new Date()): string =>
28
41
  now.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })
29
42
 
43
+ const overviewOf = (snapshot: Snapshot, shimmer: boolean): OverviewView => {
44
+ setTones(snapshot.tones ?? DEFAULT_TONES)
45
+ return {
46
+ title: snapshot.title,
47
+ lanes: snapshot.views,
48
+ planned: snapshot.planned,
49
+ watch: snapshot.watch,
50
+ watchesTickets: snapshot.watchesTickets,
51
+ awaiting: snapshot.waiting,
52
+ groomedDays: snapshot.groomedDays,
53
+ yourTurn: snapshot.yourTurn,
54
+ sourceBlock: snapshot.sourceBlock,
55
+ unplaced: snapshot.unplaced,
56
+ shimmer,
57
+ }
58
+ }
59
+
60
+ const withErrors = (screen: Screen, errors: string[], width: number): Screen => ({
61
+ ...screen,
62
+ footer: [...errors.map((error) => fit(error, width)), ...screen.footer],
63
+ })
64
+
30
65
  export const renderSnapshot = (
31
66
  snapshot: Snapshot,
32
67
  selection: Selection,
33
68
  errors: string[],
34
69
  width: number,
35
- locale: string,
36
- ): Screen => {
37
- setTones(snapshot.tones ?? DEFAULT_TONES)
38
- const screen = renderSelected(
39
- {
40
- title: snapshot.title,
41
- time: clockOf(locale),
42
- lanes: snapshot.views,
43
- planned: snapshot.planned,
44
- watch: snapshot.watch,
45
- watchesTickets: snapshot.watchesTickets,
46
- awaiting: snapshot.waiting,
47
- groomedDays: snapshot.groomedDays,
48
- next: snapshot.next,
49
- appointments: snapshot.appointments,
50
- sourceBlock: snapshot.sourceBlock,
51
- unplaced: snapshot.unplaced,
52
- },
53
- selection,
54
- width,
55
- )
56
- return { ...screen, footer: [...errors.map((error) => fit(error, width)), ...screen.footer] }
57
- }
70
+ shimmer = false,
71
+ ): Screen => withErrors(renderSelected(overviewOf(snapshot, shimmer), selection, width), errors, width)
72
+
73
+ /** The snapshot drawn with no row focused (`unfocused`), the base a hover lays its focus on (`focusedScreen`). */
74
+ export const renderUnfocused = (
75
+ snapshot: Snapshot,
76
+ selection: Selection,
77
+ errors: string[],
78
+ width: number,
79
+ shimmer = false,
80
+ ): Screen => withErrors(renderScreen(overviewOf(snapshot, shimmer), unfocused(selection), width), errors, width)
81
+
82
+ /** The status of a lane's agent by the lane's name, for the footer's click hint. */
83
+ export const agentOfSnapshot =
84
+ ({ views }: Snapshot): AgentOf =>
85
+ (name) =>
86
+ views.find(({ progress }) => progress.lane.name === name)?.status.agent ?? null
58
87
 
59
88
  export const startSelection = (snapshot: Snapshot, options: Options): Selection =>
60
89
  options.lane ? laneSelection(snapshot.views[0]?.progress) : overviewSelection()
61
90
 
62
- /** Header and footer stay put; the body shows `bodyRows` lines from `top`, padded so the footer sits at the bottom. */
63
- export const frameOutput = (screen: Screen, top: number, width: number, rows: number): string => {
91
+ /** Header and footer stay put; the body shows `rows` lines from `top`, padded so the footer sits at the bottom. */
92
+ export const frameLinesOf = (screen: Screen, top: number, rows: number): string[] => {
64
93
  const body = screen.body.lines.slice(top, top + rows)
65
94
  const padding = Array.from({ length: Math.max(rows - body.length, 0) }, () => '')
66
- const lines = [...screen.header, ...body, ...padding, ...screen.footer]
67
- return `${CURSOR_HOME}${lines.map((line) => `${fit(line, width)}${CLEAR_LINE_END}`).join('\r\n')}${CLEAR_BELOW}`
95
+ return [...screen.header, ...body, ...padding, ...screen.footer]
96
+ }
97
+
98
+ /** One line as written, a shimmering text with its stripe at `now`, cut to the pane. */
99
+ const drawnLine = (line: string, width: number, now: number, longest: number): string =>
100
+ `${fit(shimmerAt(line, now, longest), width)}${CLEAR_LINE_END}`
101
+
102
+ /** `lines` written whole from the top, between the marks of synchronized output. */
103
+ const wholeFrame = (lines: readonly string[], width: number, now: number): string => {
104
+ const longest = longestShimmer(lines)
105
+ const drawn = lines.map((line) => drawnLine(line, width, now, longest)).join('\r\n')
106
+ return `${SYNC_BEGIN}${CURSOR_HOME}${drawn}${CLEAR_BELOW}${SYNC_END}`
68
107
  }
69
108
 
70
- /** The frame to write, or null when the terminal already shows it; a null `previous` means the terminal content is unknown. */
71
- export const frameToWrite = (previous: string | null, next: string): string | null => (previous === next ? null : next)
109
+ /**
110
+ * The frame of `frameLinesOf` as written, a shimmering text with its stripe at `now` (epoch milliseconds). It stands
111
+ * between the marks of synchronized output, so a terminal that knows them never shows half of it.
112
+ */
113
+ export const frameOutput = (screen: Screen, top: number, width: number, rows: number, now: number): string =>
114
+ wholeFrame(frameLinesOf(screen, top, rows), width, now)
115
+
116
+ /**
117
+ * What to write to turn the frame `previous` on the terminal into `lines`: the frame whole when the terminal content
118
+ * is unknown (null) or its height changed, else each changed line at its cursor address, nothing when none changed.
119
+ * Lines are compared with their shimmer marks, so a stripe that moved on is no change (the shimmer clock draws that).
120
+ */
121
+ export const changedLinesOutput = (
122
+ previous: readonly string[] | null,
123
+ lines: readonly string[],
124
+ width: number,
125
+ now: number,
126
+ ): string => {
127
+ if (previous === null || previous.length !== lines.length) return wholeFrame(lines, width, now)
128
+ const longest = longestShimmer(lines)
129
+ const changed = lines.flatMap((line, index) =>
130
+ line === previous[index] ? [] : [`\x1b[${index + 1};1H${drawnLine(line, width, now, longest)}`],
131
+ )
132
+ return changed.length === 0 ? '' : `${SYNC_BEGIN}${changed.join('')}${SYNC_END}`
133
+ }
134
+
135
+ /** The lines of the frame `frameOutput` writes that shimmer, by their row on the terminal. */
136
+ export const frameShimmerRows = (screen: Screen, top: number, rows: number): ShimmerRow[] =>
137
+ shimmerRows(frameLinesOf(screen, top, rows))
138
+
139
+ /** The rows that shimmer, prepared once per frame: the pass they share, and whether a row is wider than the pane. */
140
+ export type PreparedShimmer = {
141
+ rows: readonly (ShimmerRow & { wide: boolean })[]
142
+ longest: number
143
+ width: number
144
+ }
145
+
146
+ export const preparedShimmer = (rows: readonly ShimmerRow[], width: number): PreparedShimmer => ({
147
+ rows: rows.map((row) => ({ ...row, wide: visibleLength(row.line) > width })),
148
+ longest: longestShimmer(rows.map(({ line }) => line)),
149
+ width,
150
+ })
151
+
152
+ /**
153
+ * The shimmer of the prepared rows at `now`: each row rewritten at its cursor address, nothing else of the pane, in
154
+ * one text within the marks of synchronized output; empty without rows. Only a row wider than the pane is cut.
155
+ */
156
+ export const shimmerOutput = ({ rows, longest, width }: PreparedShimmer, now: number): string => {
157
+ if (rows.length === 0) return ''
158
+ const drawn = rows.map(({ row, line, wide }) => {
159
+ const striped = shimmerAt(line, now, longest)
160
+ return `\x1b[${row};1H${wide ? fit(striped, width) : striped}${CLEAR_LINE_END}`
161
+ })
162
+ return `${SYNC_BEGIN}${drawn.join('')}${SYNC_END}`
163
+ }
164
+
165
+ /** A selection that focuses no row: the screen drawn with it is the base `focusedScreen` lays the focus on. */
166
+ export const unfocused = (selection: Selection): Selection => ({
167
+ ...selection,
168
+ selected: NO_ROW,
169
+ hovered: null,
170
+ hoveredFrom: null,
171
+ })
172
+
173
+ const NO_ROW = '\0'
174
+ const LEAD = /^ */
175
+
176
+ /** The blanks a block puts before a row it holds (`indented`): the focus lies right of them, as narrow as the row. */
177
+ const leadOf = (line: string): number => LEAD.exec(line)?.[0].length ?? 0
178
+
179
+ /** `lines` with the lines of `hit` (counted from `offset`) changed by `change`, the first line told by its index. */
180
+ const changedHit = (
181
+ lines: readonly string[],
182
+ offset: number,
183
+ hit: FocusHit | undefined,
184
+ change: (line: string, index: number) => string,
185
+ ): string[] =>
186
+ lines.map((line, at) => {
187
+ const index = hit ? at + offset - hit.line : -1
188
+ return hit && index >= 0 && index < hit.lines ? change(line, index) : line
189
+ })
190
+
191
+ /**
192
+ * `screen` with the focus `state` gives laid on one region of `lines`: the focused row on the focus background and
193
+ * mark, a line of links under the mouse marked alone, and the square-bracket button under the mouse bold.
194
+ */
195
+ const focusedLines = (lines: readonly string[], offset: number, screen: Screen, state: Selection, width: number) => {
196
+ const hitOf = (key: string | null | undefined) => (key ? screen.focusHits.find((hit) => hit.key === key) : undefined)
197
+ const hovered = hitOf(state.hovered)
198
+ const focusedKey = state.selected !== null && isFocused(state, state.selected) ? state.selected : null
199
+ const focusedHit = hitOf(focusedKey)
200
+ const gutter = focusedHit ? leadOf(lines[focusedHit.line - offset] ?? '') : 0
201
+ const focused = changedHit(
202
+ lines,
203
+ offset,
204
+ focusedHit,
205
+ (line, index) => `${line.slice(0, gutter)}${focusLine(line.slice(gutter), width - gutter, index === 0)}`,
206
+ )
207
+ const links = state.hovered?.startsWith(LINKS_KEY_PREFIX)
208
+ ? changedHit(focused, offset, hovered, (line, index) => (index === 0 ? focusLine(line, width, true) : line))
209
+ : focused
210
+ const from = state.hoveredFrom
211
+ return from == null
212
+ ? links
213
+ : changedHit(links, offset, hovered, (line, index) => (index === 0 ? boldBracketAt(line, from) : line))
214
+ }
215
+
216
+ /**
217
+ * The screen a full drawing with `state` gives, laid on `plain` (drawn with `unfocused`) without a new layout: a hover
218
+ * only moves the focus. Null where it needs the layout: with the help open, or for a row of the blocks on top, whose
219
+ * buttons stand only at the selection. `agentOf` says a lane's agent, for the footer's click hint.
220
+ */
221
+ export const focusedScreen = (plain: Screen, state: Selection, width: number, agentOf: AgentOf): Screen | null => {
222
+ const blocks = [plain.yourTurnBlock, plain.sourceBlock]
223
+ const isBlockRow = (key: string | null | undefined) => !!key && blockRowByKey(blocks, key) !== null
224
+ if (state.showLegend || isBlockRow(state.selected) || isBlockRow(state.hovered)) return null
225
+ const header = focusedLines(plain.header, 0, plain, state, width)
226
+ const lines = focusedLines(plain.body.lines, plain.header.length, plain, state, width)
227
+ const selectedHit = plain.focusHits.find(({ key }) => key === state.selected)
228
+ const cursorLine =
229
+ selectedHit && selectedHit.line >= plain.header.length ? selectedHit.line - plain.header.length : -1
230
+ const hint = clickHint(state, (key) => blockRowByKey(blocks, key), agentOf) ?? TEXTS.hint
231
+ return {
232
+ ...plain,
233
+ header,
234
+ body: { ...plain.body, lines, cursorLine },
235
+ footer: [...plain.footer.slice(0, -1), fit(paint.dim(hint), width)],
236
+ }
237
+ }
72
238
 
73
239
  const ARTIFACT_ADDRESS = new RegExp(ARTIFACT_LINK_KIND.pattern)
74
240