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
@@ -0,0 +1,29 @@
1
+ import { fit, padVisible, paint, visibleLength } from '../terminal/text'
2
+
3
+ /** One line of the help: a mark or a key, then what it means. */
4
+ export type LegendEntry = { mark: string; meaning: string }
5
+
6
+ /** A section of the help under its title. */
7
+ export type LegendSection = { title: string; entries: LegendEntry[] }
8
+
9
+ /** The blanks before every entry and between its mark and its meaning. */
10
+ const ENTRY_INDENT = ' '
11
+ const COLUMN_GAP = ' '
12
+
13
+ const sectionLines = ({ title, entries }: LegendSection, width: number): string[] => {
14
+ const markWidth = Math.max(0, ...entries.map(({ mark }) => visibleLength(mark)))
15
+ return [
16
+ fit(paint.bold(title), width),
17
+ ...entries.map(({ mark, meaning }) =>
18
+ fit(`${ENTRY_INDENT}${padVisible(mark, markWidth)}${COLUMN_GAP}${meaning}`, width),
19
+ ),
20
+ ]
21
+ }
22
+
23
+ /**
24
+ * The help as lines: each section under its bold title, one entry per line in two columns (the marks of a section
25
+ * padded to its widest, so every meaning starts in one column), cut at the pane rather than wrapped; a blank line
26
+ * between the sections.
27
+ */
28
+ export const legendLines = (sections: readonly LegendSection[], width: number): string[] =>
29
+ sections.flatMap((section, index) => [...(index > 0 ? [''] : []), ...sectionLines(section, width)])
@@ -7,7 +7,7 @@ type Found = { url: string; text: string | null; before: string; line: string }
7
7
 
8
8
  const MARKDOWN_LINK = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g
9
9
  const AUTOLINK = /<(https?:\/\/[^>\s]+)>/g
10
- const BARE_LINK = /(?<![(<\w"])(https?:\/\/[^\s)>\]]+)/g
10
+ const BARE_LINK = /(?<![<\w"])(https?:\/\/[^\s)>\]]+)/g
11
11
  const LABEL_BEFORE = /\*\*([^*]+?):?\*\*:?\s*(?:[-–>]\s*)?$/
12
12
  const PRIVATE = /\bprivat/i
13
13
  const MARKS = /\*\*|`/g
@@ -29,7 +29,7 @@ const linksOf = (line: string): Found[] => {
29
29
 
30
30
  const LIST_LEAD = /^\s*(?:[-*>]\s*)*(?:\[[ xX]\]\s*)?/
31
31
  const LEFTOVER_LINK = /\bhttps?:\/\/\S+/g
32
- const TITLE_END = /[\s:,;.\-–>]+$/
32
+ const TITLE_END = /[\s:,;.\-–>(]+$/
33
33
 
34
34
  /** What a line says besides its links, without list marks, checkbox and emphasis: the title of a bare artifact link. */
35
35
  const lineTitleOf = ({ line }: Found): string =>
@@ -50,12 +50,13 @@ const labelOf = (found: Found, kind: LinkKind): string => {
50
50
  return (kind.pattern === ARTIFACT_LINK_KIND.pattern && lineTitleOf(found)) || kind.label
51
51
  }
52
52
 
53
- /**
54
- * The links of a plan's text that match one of the kinds, once each and in the order they appear. The chip's
55
- * text is the link text, else the bold label written before the link (`**Mockup:** <url>`), else the kind's
56
- * label; a link on a line that says it is private carries that. Nothing is fetched.
57
- */
58
- export const extractLinkChips = (markdown: string, kinds: readonly LinkKind[]): LinkChip[] => {
53
+ /** How many texts the chips are kept for, per list of kinds; the kept ones are dropped all at once when they are too many. */
54
+ const KEPT_TEXTS = 200
55
+
56
+ const keptChips = new WeakMap<readonly LinkKind[], Map<string, LinkChip[]>>()
57
+
58
+ /** `extractLinkChips` without its cache, for a reader that goes through more texts than the cache keeps. */
59
+ export const readLinkChips = (markdown: string, kinds: readonly LinkKind[]): LinkChip[] => {
59
60
  const compiled = kinds.map((kind) => ({ kind, pattern: new RegExp(kind.pattern) }))
60
61
  const chips = new Map<string, LinkChip>()
61
62
  for (const line of markdown.split('\n')) {
@@ -73,6 +74,23 @@ export const extractLinkChips = (markdown: string, kinds: readonly LinkKind[]):
73
74
  return [...chips.values()]
74
75
  }
75
76
 
77
+ /**
78
+ * The links of a plan's text that match one of the kinds, once each and in the order they appear. The chip's
79
+ * text is the link text, else the bold label written before the link (`**Mockup:** <url>`), else the kind's
80
+ * label; a link on a line that says it is private carries that. Nothing is fetched. The chips are kept per text
81
+ * and list of kinds, so a refresh and lanes sharing a plan get the same array back: callers must not change it.
82
+ */
83
+ export const extractLinkChips = (markdown: string, kinds: readonly LinkKind[]): LinkChip[] => {
84
+ const byText = keptChips.get(kinds) ?? new Map<string, LinkChip[]>()
85
+ keptChips.set(kinds, byText)
86
+ const kept = byText.get(markdown)
87
+ if (kept) return kept
88
+ if (byText.size >= KEPT_TEXTS) byText.clear()
89
+ const chips = readLinkChips(markdown, kinds)
90
+ byText.set(markdown, chips)
91
+ return chips
92
+ }
93
+
76
94
  const CHIP_KEY = /^chip>(https?:\/\/.+)$/
77
95
  const PLAN_KEY = /^plan>(file:\/\/\/.+)$/
78
96
 
package/src/plans/live.ts CHANGED
@@ -1,12 +1,11 @@
1
- import { existsSync, type FSWatcher, watch } from 'node:fs'
1
+ import { existsSync, type FSWatcher, statSync, watch, writeSync } from 'node:fs'
2
2
  import { basename, dirname } from 'node:path'
3
3
  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'
7
+ import { crashEnd, onceRestore } from '../terminal/restore'
8
8
  import { closeWaiting } from '../waiting/command'
9
- import { appointmentKeys } from './appointment-rows'
10
9
  import { askCoordinator } from './ask-coordinator'
11
10
  import type { LaneMemory } from './current-phase'
12
11
  import { errorText } from './error-text'
@@ -24,16 +23,17 @@ import { fetchHeadPath } from './git'
24
23
  import { fetchPullRequests, noPullRequests, type PullRequestState } from './github'
25
24
  import { recallLanes, rememberLanes } from './herd-memory'
26
25
  import { type Screen, settledSelection } from './layout'
27
- import { nextKeys } from './next-rows'
28
26
  import { type KnownPage, pageAfterWrite, pageKey, pageNeedsWriting, planAnchorOn, withPlanChips } from './plan-chips'
29
27
  import { planPullRequests } from './plan-heads'
30
28
  import { herdLabel } from './plan-panes'
31
29
  import { pullRequestUrl } from './pull-requests'
32
30
  import { newReadCache, type ReadCache } from './read-cache'
31
+ import { quietRoundsAfter, REFRESH_PACE, refreshDelay } from './refresh-pace'
33
32
  import { TAKEN_OVER_CODE, TAKEOVER_SIGNAL } from './restart-decision'
34
33
  import { closeRow } from './row-actions'
35
- import { blockRowByKey, bracketAt, hoveredKeyAt, lineIndexAt, withHovered } from './row-focus'
34
+ import { blockRowByKey, type HoverScreen, hoveredSelection, rehovered, withoutHover } from './row-focus'
36
35
  import {
36
+ applyClick,
37
37
  inputOutcome,
38
38
  isActivateKey,
39
39
  isMotion,
@@ -41,35 +41,42 @@ import {
41
41
  type MouseEvent,
42
42
  overviewSelection,
43
43
  parseMouse,
44
- settledBlockFolds,
45
- showDoneNotice,
46
44
  splitKeys,
47
45
  syncLaneFolds,
48
46
  TOP_BLOCK,
49
47
  type TopBlockId,
50
- toggledBlockFold,
51
48
  toggledExpansion,
49
+ topBlockHeadKey,
52
50
  type View,
53
51
  windowTop,
52
+ withoutStaleMotions,
54
53
  } from './selection'
55
54
  import { BASE_REMOTE, baseRefOf, boardRepoPathOf, CLOCK_LOCALE } from './settings'
55
+ import { SHIMMER, shimmerEnabled, shimmerRows } from './shimmer'
56
56
  import { singleFlight } from './single-flight'
57
57
  import { loadSnapshot, type Snapshot, type Source } from './snapshot'
58
- import { sourceKeys } from './source-rows'
59
58
  import { exportSprintPage, writeSprintPageInSteps } from './sprint-export'
60
59
  import { boardTexts } from './texts'
61
60
  import {
61
+ agentOfSnapshot,
62
62
  bodyRows,
63
+ changedLinesOutput,
63
64
  clockOf,
64
- frameOutput,
65
- frameToWrite,
65
+ focusedScreen,
66
+ frameLinesOf,
67
+ nextFrameDelay,
68
+ type PreparedShimmer,
66
69
  paneWidth,
70
+ preparedShimmer,
67
71
  renderSnapshot,
72
+ renderUnfocused,
73
+ shimmerOutput,
68
74
  startSelection,
69
75
  TERMINAL_OFF,
70
76
  TERMINAL_ON,
71
77
  } from './view'
72
- import { type BlockClick, type BlockHit, blockClick, blockHeadClick, mainClickOf, waitingKeys } from './waiting-click'
78
+ import { type BlockClick, type BlockHit, blockClick, blockHeadClick, mainClickOf } from './waiting-click'
79
+ import { isOwnFetchWrite, watchRefreshDelay } from './watch-burst'
73
80
 
74
81
  /** The state of the running view; only the functions below `runLive` change it. */
75
82
  type Live = {
@@ -97,16 +104,40 @@ type Live = {
97
104
  page: KnownPage | null
98
105
  pageWriting: boolean
99
106
  flight: (() => Promise<void>) | null
107
+ /** The refreshes in a row that wrote nothing to the terminal; the next one waits `refreshDelay` of it. */
108
+ quietRounds: number
109
+ /** When the last refresh started (epoch milliseconds), null before the first; a refresh outside the pace waits `watchRefreshDelay` of it. */
110
+ lastRefreshAt: number | null
100
111
  pageError: string | null
101
- /** The frame last written to the terminal; null while its content is unknown, such as after a resize. */
102
- written: string | null
112
+ /**
113
+ * The lines last written to the terminal, with their shimmer marks, so a stripe that moved on is no change (the
114
+ * shimmer clock draws that); null while the terminal content is unknown, such as after a resize.
115
+ */
116
+ written: string[] | null
117
+ /** The current state drawn with no row focused, which a hover lays its focus on; null until a hover needs it. */
118
+ plain: Screen | null
119
+ /** The last mouse motion, so a new frame finds the row under a mouse that did not move; null before the first. */
120
+ mouse: MouseEvent | null
121
+ /** When the last frame was drawn (epoch milliseconds), null before the first; a hover draws at most once per frame time. */
122
+ lastFrameAt: number | null
123
+ /** The drawing a hover asked for, while it waits for the frame time. */
124
+ frame: Timer | null
125
+ /** The rows of the frame on the terminal that shimmer, prepared, and the clock that redraws them; null while none does. */
126
+ shimmer: { prepared: PreparedShimmer; clock: Timer | null }
103
127
  }
104
128
 
105
- const REFRESH_MS = 5_000
106
129
  const PULL_REQUEST_REFRESH_MS = 60_000
107
- const DEBOUNCE_MS = 150
108
130
  const QUIT_KEYS = new Set(['\x03', 'q'])
109
131
 
132
+ /** When `file` was last written (epoch milliseconds); 0 when it cannot be stat-ed, which no fetch of the herd claims. */
133
+ const modifiedAt = (file: string): number => {
134
+ try {
135
+ return statSync(file).mtimeMs
136
+ } catch {
137
+ return 0
138
+ }
139
+ }
140
+
110
141
  /** The sidebar titles that differ from the ones last reported, in the order of `titles`; null clears one. */
111
142
  export const changedTitles = (
112
143
  titles: ReadonlyMap<string, string | null>,
@@ -130,7 +161,7 @@ const clearTitles = (source: Source, reported: Map<string, string | null>) => {
130
161
  }
131
162
  }
132
163
 
133
- const syncWatchers = (watchers: Map<string, FSWatcher>, files: string[], onChange: () => void) => {
164
+ const syncWatchers = (watchers: Map<string, FSWatcher>, files: string[], onChange: (file: string) => void) => {
134
165
  for (const [file, watcher] of watchers) {
135
166
  if (files.includes(file)) continue
136
167
  watcher.close()
@@ -143,44 +174,156 @@ const syncWatchers = (watchers: Map<string, FSWatcher>, files: string[], onChang
143
174
  watcher.close()
144
175
  watchers.delete(file)
145
176
  }
146
- onChange()
177
+ onChange(file)
147
178
  })
148
179
  watchers.set(file, watcher)
149
180
  } catch {
150
- // A file that cannot be watched right now is retried on the next refresh; the 5 s refresh covers it meanwhile.
181
+ // A file that cannot be watched right now is retried on the next refresh, which also covers it meanwhile.
151
182
  }
152
183
  }
153
184
  }
154
185
 
155
- /** Alternate screen, hidden cursor, mouse reports and raw keys; `quit` restores the terminal. */
156
- const enterTerminal = (onInput: (input: string) => void, quit: () => void, onResize: () => void) => {
186
+ const STDOUT_FD = 1
187
+ const STDERR_FD = 2
188
+
189
+ /**
190
+ * The terminal given back on every end, written synchronously because `exit` waits for nothing: mouse reports and
191
+ * alternate screen off, cursor shown, raw mode released, once (`onceRestore`).
192
+ */
193
+ const terminalRestore = (): (() => void) =>
194
+ onceRestore(TERMINAL_OFF, {
195
+ write: (text) => writeSync(STDOUT_FD, text),
196
+ releaseRaw: () => {
197
+ if (process.stdin.isTTY) process.stdin.setRawMode(false)
198
+ },
199
+ })
200
+
201
+ /** How the view ends: `quit` and `crash` leave, `restore` gives the terminal back and runs on `exit` as well. */
202
+ type TerminalEnds = { quit: () => void; crash: (error: unknown) => void; restore: () => void }
203
+
204
+ /**
205
+ * Alternate screen, hidden cursor, mouse reports and raw keys. A signal quits, an error nobody caught crashes (a
206
+ * closed terminal's `EIO` on stdin among them), and `exit` restores the terminal whatever ended the process.
207
+ */
208
+ const enterTerminal = (onInput: (input: string) => void, ends: TerminalEnds, onResize: () => void) => {
157
209
  process.stdout.write(TERMINAL_ON)
158
- process.on('SIGINT', quit)
159
- process.on('SIGTERM', quit)
210
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) process.on(signal, ends.quit)
211
+ process.on('exit', ends.restore)
212
+ process.on('uncaughtException', ends.crash)
213
+ process.on('unhandledRejection', ends.crash)
160
214
  process.stdout.on('resize', onResize)
161
215
  if (!process.stdin.isTTY) return
162
216
  process.stdin.setRawMode(true)
217
+ process.stdin.on('error', ends.crash)
163
218
  process.stdin.on('data', (chunk) => {
164
- for (const input of splitKeys(chunk.toString())) onInput(input)
219
+ for (const input of withoutStaleMotions(splitKeys(chunk.toString()))) onInput(input)
165
220
  })
166
221
  }
167
222
 
168
- const redraw = (live: Live) => {
169
- if (!live.snapshot) return
170
- const screen = renderSnapshot(
171
- live.snapshot,
172
- live.view.selection,
173
- [...live.errors, ...(live.pageError ? [live.pageError] : []), ...(live.notice ? [live.notice] : [])],
174
- paneWidth(),
175
- CLOCK_LOCALE,
176
- )
177
- const top = windowTop(live.view, screen.body.cursorLine, screen.body.lines.length, bodyRows(screen))
223
+ /** The terminal restored first, so the report lands in the shell and not on the alternate screen; null reports nothing. */
224
+ const reportCrash = (report: string | null, restore: () => void) => {
225
+ restore()
226
+ if (report === null) return
227
+ try {
228
+ writeSync(STDERR_FD, `${report}\n`)
229
+ } catch {
230
+ // stderr went away with the terminal; the exit code still tells.
231
+ }
232
+ }
233
+
234
+ const hoverScreenOf = (screen: Screen, top: number): HoverScreen => ({
235
+ header: screen.header,
236
+ bodyLines: screen.body.lines,
237
+ focusHits: screen.focusHits,
238
+ frame: { headerRows: screen.header.length, bodyRows: bodyRows(screen), top },
239
+ })
240
+
241
+ /** The lines over the footer: refresh errors, a failed page, the notice of the last action. */
242
+ const liveErrors = (live: Live): string[] => [
243
+ ...live.errors,
244
+ ...(live.pageError ? [live.pageError] : []),
245
+ ...(live.notice ? [live.notice] : []),
246
+ ]
247
+
248
+ const shimmerOn = (live: Live): boolean => shimmerEnabled(live.source.settings.shimmer, process.env)
249
+
250
+ /** The screen for the current state, with the window scrolled to keep the cursor in view. */
251
+ const renderLive = (live: Live, snapshot: Snapshot): { screen: Screen; top: number } => {
252
+ const screen = renderSnapshot(snapshot, live.view.selection, liveErrors(live), paneWidth(), shimmerOn(live))
253
+ return { screen, top: windowTop(live.view, screen.body.cursorLine, screen.body.lines.length, bodyRows(screen)) }
254
+ }
255
+
256
+ /**
257
+ * Draws the current state. When the new layout put another row under a mouse that has the focus and did not move,
258
+ * that row takes the focus and the screen is rendered once more before it is written. True when it wrote anything.
259
+ */
260
+ const redraw = (live: Live): boolean => {
261
+ if (!live.snapshot) return false
262
+ if (live.frame) clearTimeout(live.frame)
263
+ live.frame = null
264
+ let { screen, top } = renderLive(live, live.snapshot)
265
+ const selection = rehovered(live.view.selection, hoverScreenOf(screen, top), live.mouse)
266
+ if (selection !== live.view.selection) {
267
+ live.view = { ...live.view, selection }
268
+ ;({ screen, top } = renderLive(live, live.snapshot))
269
+ }
270
+ live.plain = null
271
+ return write(live, screen, top)
272
+ }
273
+
274
+ /**
275
+ * Writes `screen` scrolled to `top`: the frame built once, only its changed lines, its shimmer rows prepared. True when
276
+ * a line changed.
277
+ */
278
+ const write = (live: Live, screen: Screen, top: number): boolean => {
178
279
  live.screen = screen
179
280
  live.view = { ...live.view, top }
180
- const output = frameToWrite(live.written, frameOutput(screen, top, paneWidth(), bodyRows(screen)))
181
- if (output === null) return
182
- live.written = output
183
- process.stdout.write(output)
281
+ live.lastFrameAt = Date.now()
282
+ const width = paneWidth()
283
+ const lines = frameLinesOf(screen, top, bodyRows(screen))
284
+ followShimmer(live, preparedShimmer(shimmerRows(lines), width))
285
+ const output = changedLinesOutput(live.written, lines, width, live.lastFrameAt)
286
+ live.written = lines
287
+ if (output) process.stdout.write(output)
288
+ return output !== ''
289
+ }
290
+
291
+ /**
292
+ * Keeps the shimmer clock running while a row on the terminal shimmers, and only then: `SHIMMER.framesPerSecond` times a
293
+ * second it rewrites those rows at their cursor address (`shimmerOutput`), never the rest of the pane; it pauses while a
294
+ * hover's drawing waits, so the two never write over each other.
295
+ */
296
+ const followShimmer = (live: Live, prepared: PreparedShimmer) => {
297
+ live.shimmer.prepared = prepared
298
+ if (prepared.rows.length === 0 && live.shimmer.clock) {
299
+ clearInterval(live.shimmer.clock)
300
+ live.shimmer.clock = null
301
+ }
302
+ if (prepared.rows.length > 0 && !live.shimmer.clock) {
303
+ const draw = () => {
304
+ if (!live.frame) process.stdout.write(shimmerOutput(live.shimmer.prepared, Date.now()))
305
+ }
306
+ live.shimmer.clock = setInterval(draw, 1000 / SHIMMER.framesPerSecond)
307
+ }
308
+ }
309
+
310
+ /**
311
+ * A hover's drawing: the focus laid on the screen without one (`focusedScreen`), no new layout; a row of the blocks on
312
+ * top, or the help, is drawn whole. The screen without a focus is drawn once, on the first hover after a drawing.
313
+ */
314
+ const drawHover = (live: Live) => {
315
+ live.frame = null
316
+ if (!live.snapshot) return
317
+ live.plain ??= renderUnfocused(live.snapshot, live.view.selection, liveErrors(live), paneWidth(), shimmerOn(live))
318
+ const laid = focusedScreen(live.plain, live.view.selection, paneWidth(), agentOfSnapshot(live.snapshot))
319
+ if (laid === null) redraw(live)
320
+ else write(live, laid, live.view.top)
321
+ }
322
+
323
+ /** A drawing for a hover: at most one per frame time, the ones asked for meanwhile drawn together. */
324
+ const requestFrame = (live: Live) => {
325
+ if (live.frame) return
326
+ live.frame = setTimeout(() => drawHover(live), nextFrameDelay(live.lastFrameAt, Date.now()))
184
327
  }
185
328
 
186
329
  /** The whole sprint, also while the view shows one lane: the snapshot is read again without the lane filter. */
@@ -223,12 +366,13 @@ const syncSprintPage = async (live: Live, read: Snapshot) => {
223
366
  }
224
367
 
225
368
  /** A re-read never overlaps another: one asked for meanwhile runs once after the running one, so the screen cannot go back to an older read. */
226
- const refresh = (live: Live, onChange: () => void): Promise<void> => {
369
+ const refresh = (live: Live, onChange: (file: string) => void): Promise<void> => {
227
370
  live.flight ??= singleFlight(() => readOnce(live, onChange))
228
371
  return live.flight()
229
372
  }
230
373
 
231
- const readOnce = async (live: Live, onChange: () => void) => {
374
+ const readOnce = async (live: Live, onChange: (file: string) => void) => {
375
+ live.lastRefreshAt = Date.now()
232
376
  try {
233
377
  const read = await loadSnapshot(live.source, live.pullRequests, live.memories, live.cache)
234
378
  rememberLanes(live.source.root, live.memories, read.documents.laneOrder)
@@ -246,13 +390,7 @@ const readOnce = async (live: Live, onChange: () => void) => {
246
390
  status: status.agent,
247
391
  })),
248
392
  )
249
- const settled = settledBlockFolds(folded, {
250
- waiting: waitingKeys(next.waiting ?? []),
251
- next: nextKeys(next.next?.rows ?? []),
252
- appointments: appointmentKeys(next.appointments?.rows ?? []),
253
- sources: sourceKeys(next.sourceBlock?.sources ?? []),
254
- })
255
- live.view = { ...live.view, selection: settled }
393
+ live.view = { ...live.view, selection: folded }
256
394
  live.snapshot = next
257
395
  const fetchLine = fetchNotice(live.fetch, baseRefOf(live.source.settings), CLOCK_LOCALE)
258
396
  live.errors = [
@@ -265,7 +403,7 @@ const readOnce = async (live: Live, onChange: () => void) => {
265
403
  const at = clockOf(CLOCK_LOCALE)
266
404
  live.errors = [`refresh failed at ${at}, showing the last good state: ${errorText(error)}`]
267
405
  }
268
- redraw(live)
406
+ live.quietRounds = quietRoundsAfter(live.quietRounds, redraw(live))
269
407
  const { read } = live
270
408
  if (read) setTimeout(() => void syncSprintPage(live, read), 0)
271
409
  }
@@ -333,8 +471,8 @@ const applyBlockClick = (live: Live, outcome: BlockClick): void => {
333
471
 
334
472
  /**
335
473
  * A plain click on a block on top of the herd, matched against `hit` as it was drawn: the head folds or opens it, a
336
- * cut-off row expands, a file or Artifact opens, a question or a board row asks the coordinator; the failure or the result
337
- * shows in the footer. An appointment row has nothing to open or ask.
474
+ * cut-off row expands, a file or Artifact opens, a button asks the coordinator or closes the row; the failure or the
475
+ * result shows in the footer.
338
476
  */
339
477
  const clickBlock = (live: Live, block: TopBlockId, hit: BlockHit | null, input: string): boolean => {
340
478
  const mouse = parseMouse(input)
@@ -342,7 +480,7 @@ const clickBlock = (live: Live, block: TopBlockId, hit: BlockHit | null, input:
342
480
  if (blockHeadClick(hit.firstLine - 1, mouse)) {
343
481
  live.view = {
344
482
  ...live.view,
345
- selection: toggledBlockFold(live.view.selection, block, hit.keys),
483
+ selection: applyClick(live.view.selection, topBlockHeadKey(block)),
346
484
  }
347
485
  return true
348
486
  }
@@ -352,20 +490,16 @@ const clickBlock = (live: Live, block: TopBlockId, hit: BlockHit | null, input:
352
490
  return true
353
491
  }
354
492
 
355
- /** The mouse moved: the row under it is drawn like the selected one, and the screen is redrawn only when that row changes. */
493
+ /**
494
+ * The mouse moved: the row under it takes the focus, and the screen is drawn again, at most once per frame time,
495
+ * only when that row or the button under the mouse changes.
496
+ */
356
497
  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
- )
498
+ live.mouse = mouse
499
+ const selection = hoveredSelection(live.view.selection, hoverScreenOf(screen, live.view.top), mouse)
366
500
  if (selection === live.view.selection) return
367
501
  live.view = { ...live.view, selection }
368
- redraw(live)
502
+ requestFrame(live)
369
503
  }
370
504
 
371
505
  /**
@@ -382,12 +516,7 @@ const activateBlockRow = (live: Live, screen: Screen, input: string): boolean =>
382
516
  return true
383
517
  }
384
518
 
385
- const blocksOf = ({ waitingBlock, nextBlock, appointmentBlock, sourceBlock }: Screen): (BlockHit | null)[] => [
386
- waitingBlock,
387
- nextBlock,
388
- appointmentBlock,
389
- sourceBlock,
390
- ]
519
+ const blocksOf = ({ yourTurnBlock, sourceBlock }: Screen): (BlockHit | null)[] => [yourTurnBlock, sourceBlock]
391
520
 
392
521
  const handleInput = (live: Live, input: string) => {
393
522
  const { snapshot, screen } = live
@@ -398,7 +527,8 @@ const handleInput = (live: Live, input: string) => {
398
527
  return
399
528
  }
400
529
  live.notice = null
401
- live.view = { ...live.view, selection: settledSelection(live.view.selection, screen) }
530
+ const settled = settledSelection(live.view.selection, screen)
531
+ live.view = { ...live.view, selection: mouse ? settled : withoutHover(settled) }
402
532
  if (activateBlockRow(live, screen, input)) {
403
533
  redraw(live)
404
534
  return
@@ -408,9 +538,7 @@ const handleInput = (live: Live, input: string) => {
408
538
  return
409
539
  }
410
540
  if (
411
- clickBlock(live, TOP_BLOCK.waiting, screen.waitingBlock, input) ||
412
- clickBlock(live, TOP_BLOCK.next, screen.nextBlock, input) ||
413
- clickBlock(live, TOP_BLOCK.appointments, screen.appointmentBlock, input) ||
541
+ clickBlock(live, TOP_BLOCK.yourTurn, screen.yourTurnBlock, input) ||
414
542
  clickBlock(live, TOP_BLOCK.sources, screen.sourceBlock, input)
415
543
  ) {
416
544
  redraw(live)
@@ -422,7 +550,6 @@ const handleInput = (live: Live, input: string) => {
422
550
  headerRows: screen.header.length,
423
551
  bodyRows: bodyRows(screen),
424
552
  }
425
- live.notice = showDoneNotice(live.view.selection, input, context.keys)
426
553
  const pullRequests = snapshot.views.flatMap((view) => view.pullRequests)
427
554
  const outcome = inputOutcome(live.view, input, context, (key) => pullRequestUrl(pullRequests, key))
428
555
  live.view = outcome.view
@@ -456,12 +583,31 @@ export const runLive = (source: Source): void => {
456
583
  page: null,
457
584
  pageWriting: false,
458
585
  flight: null,
586
+ quietRounds: 0,
587
+ lastRefreshAt: null,
459
588
  pageError: null,
460
589
  written: null,
590
+ plain: null,
591
+ mouse: null,
592
+ lastFrameAt: null,
593
+ frame: null,
594
+ shimmer: { prepared: preparedShimmer([], 0), clock: null },
461
595
  }
596
+ /** A refresh asked for outside the pace, bundled by `watchRefreshDelay`: one already pending covers the ask. */
462
597
  const schedule = () => {
463
- if (live.pending) clearTimeout(live.pending)
464
- live.pending = setTimeout(() => refresh(live, schedule), DEBOUNCE_MS)
598
+ if (live.pending) return
599
+ live.pending = setTimeout(
600
+ () => {
601
+ live.pending = null
602
+ void refresh(live, onFileChange)
603
+ },
604
+ watchRefreshDelay(live.lastRefreshAt, Date.now()),
605
+ )
606
+ }
607
+ /** A watched file changed; `FETCH_HEAD` written by the herd's own fetch asks nothing, its end refreshes once. */
608
+ const onFileChange = (file: string) => {
609
+ if (file === live.fetchHead && isOwnFetchWrite(modifiedAt(file), live.fetch)) return
610
+ schedule()
465
611
  }
466
612
  const fetchBaseBranch = async () => {
467
613
  live.fetch = { ...live.fetch, lastAttempt: Date.now(), running: true }
@@ -476,7 +622,8 @@ export const runLive = (source: Source): void => {
476
622
  }
477
623
  schedule()
478
624
  }
479
- const fetchWhenDue = (pullRequestGone: boolean) => {
625
+ /** Starts a fetch when one is due; true when it did, whose end then refreshes. */
626
+ const fetchWhenDue = (pullRequestGone: boolean): boolean => {
480
627
  const due = shouldFetch({
481
628
  now: Date.now(),
482
629
  state: live.fetch,
@@ -484,6 +631,7 @@ export const runLive = (source: Source): void => {
484
631
  pullRequestGone,
485
632
  })
486
633
  if (due) void fetchBaseBranch()
634
+ return due
487
635
  }
488
636
  const refreshPullRequests = async () => {
489
637
  const previous = live.pullRequests
@@ -491,32 +639,38 @@ export const runLive = (source: Source): void => {
491
639
  const readable = previous.errors.length === 0 && live.pullRequests.errors.length === 0
492
640
  const laneBranches = new Set(live.snapshot?.views.flatMap(({ status }) => (status.branch ? [status.branch] : [])))
493
641
  const gone = goneLanePullRequests(previous.byBranch, live.pullRequests.byBranch, laneBranches)
494
- fetchWhenDue(readable && gone.length > 0)
495
- schedule()
642
+ if (!fetchWhenDue(readable && gone.length > 0)) schedule()
496
643
  }
644
+ const restore = terminalRestore()
497
645
  const leave = (code: number) => {
646
+ restore()
498
647
  clearTitles(source, live.reported)
499
648
  source.terminal.labelOwnPane(null)
500
649
  for (const watcher of live.watchers.values()) watcher.close()
501
- process.stdout.write(TERMINAL_OFF)
502
650
  process.exit(code)
503
651
  }
504
652
  const quit = () => leave(0)
653
+ const crash = (error: unknown) => {
654
+ const { code, report } = crashEnd(error)
655
+ reportCrash(report, restore)
656
+ leave(code)
657
+ }
505
658
  const takenOver = () => leave(TAKEN_OVER_CODE)
506
659
  const onInput = (input: string) => (QUIT_KEYS.has(input) ? quit() : handleInput(live, input))
507
660
 
508
661
  source.terminal.labelOwnPane(herdLabel(source.options.lane))
509
662
  process.removeAllListeners(TAKEOVER_SIGNAL)
510
663
  process.on(TAKEOVER_SIGNAL, takenOver)
511
- enterTerminal(onInput, quit, () => {
664
+ enterTerminal(onInput, { quit, crash, restore }, () => {
512
665
  live.written = null
513
666
  redraw(live)
514
667
  })
515
- setInterval(() => {
668
+ const poll = () => {
516
669
  fetchWhenDue(false)
517
- void refresh(live, schedule)
518
- }, REFRESH_MS)
670
+ void refresh(live, onFileChange).finally(() => setTimeout(poll, refreshDelay(live.quietRounds)))
671
+ }
672
+ setTimeout(poll, REFRESH_PACE.baseMs)
519
673
  setInterval(refreshPullRequests, PULL_REQUEST_REFRESH_MS)
520
- void refresh(live, schedule)
674
+ void refresh(live, onFileChange)
521
675
  void refreshPullRequests()
522
676
  }