jackherd 1.11.2 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +69 -51
  3. package/dist/{chunk-RMMXD4G5.js → chunk-V4HOLGFP.js} +2 -2
  4. package/dist/config.d.ts +10 -0
  5. package/dist/config.js +15 -5
  6. package/dist/plan-format.d.ts +11 -6
  7. package/dist/plan-format.js +8 -2
  8. package/docs/follow-dark.svg +2 -2
  9. package/docs/follow.svg +2 -2
  10. package/docs/workflow-dark.svg +56 -64
  11. package/docs/workflow.svg +56 -64
  12. package/herdr-plugin/herdr-plugin.toml +1 -1
  13. package/install.sh +1 -1
  14. package/kit/workflow/skills/implement/SKILL.md +1 -1
  15. package/kit/workflow/skills/orchestrate/SKILL.md +1 -1
  16. package/kit/workflow/skills/orchestrate/references/draft-review.md +4 -0
  17. package/kit/workflow/skills/orchestrate/references/events.md +8 -1
  18. package/kit/workflow/skills/plan/SKILL.md +2 -0
  19. package/package.json +2 -2
  20. package/schemas/jackherd.schema.json +18 -1
  21. package/src/config/link-kinds.ts +1 -1
  22. package/src/config/schema.ts +5 -2
  23. package/src/config/tones.ts +9 -2
  24. package/src/context/fill.ts +4 -10
  25. package/src/context/session-log.ts +108 -2
  26. package/src/plan-format/lanes.ts +10 -1
  27. package/src/plan-format/phases.ts +11 -3
  28. package/src/plan-format/progress.ts +4 -2
  29. package/src/plan-format/waiting.ts +12 -0
  30. package/src/plans/active-tasks.ts +31 -8
  31. package/src/plans/appointment-rows.ts +42 -53
  32. package/src/plans/board-rows.ts +14 -0
  33. package/src/plans/calendar-days.ts +29 -0
  34. package/src/plans/click-hint.ts +94 -0
  35. package/src/plans/command.ts +8 -9
  36. package/src/plans/current-phase.ts +22 -10
  37. package/src/plans/current-task.ts +18 -3
  38. package/src/plans/frame.ts +18 -26
  39. package/src/plans/git.ts +90 -31
  40. package/src/plans/groomed.ts +13 -7
  41. package/src/plans/lane-reason.ts +61 -0
  42. package/src/plans/lane-summary.ts +75 -24
  43. package/src/plans/layout-phase.ts +123 -76
  44. package/src/plans/layout-summary.ts +133 -83
  45. package/src/plans/layout.ts +451 -345
  46. package/src/plans/legend.ts +29 -0
  47. package/src/plans/link-chips.ts +26 -8
  48. package/src/plans/live.ts +238 -84
  49. package/src/plans/next-rows.ts +5 -37
  50. package/src/plans/phase-source.ts +8 -3
  51. package/src/plans/plan-chips.ts +6 -3
  52. package/src/plans/plan-heads.ts +1 -1
  53. package/src/plans/planned.ts +4 -1
  54. package/src/plans/process-slots.ts +36 -0
  55. package/src/plans/pull-requests.ts +9 -1
  56. package/src/plans/read-cache.ts +18 -3
  57. package/src/plans/refresh-pace.ts +13 -0
  58. package/src/plans/row-focus.ts +86 -15
  59. package/src/plans/selection.ts +124 -89
  60. package/src/plans/sent-files.ts +1 -1
  61. package/src/plans/settings.ts +5 -2
  62. package/src/plans/shimmer.ts +124 -0
  63. package/src/plans/signals-read.ts +38 -10
  64. package/src/plans/slice-gate.ts +51 -0
  65. package/src/plans/snapshot.ts +185 -84
  66. package/src/plans/source-asked.ts +1 -1
  67. package/src/plans/source-rows.ts +60 -18
  68. package/src/plans/sprint-html.ts +1 -1
  69. package/src/plans/status-pace.ts +45 -0
  70. package/src/plans/subagent-tasks.ts +1 -1
  71. package/src/plans/subagents-read.ts +61 -24
  72. package/src/plans/subagents.ts +2 -2
  73. package/src/plans/symbols.ts +4 -1
  74. package/src/plans/task-brief.ts +143 -0
  75. package/src/plans/texts.ts +181 -39
  76. package/src/plans/view.ts +198 -32
  77. package/src/plans/waiting-click.ts +54 -110
  78. package/src/plans/watch-burst.ts +25 -0
  79. package/src/plans/worktree-stamp.ts +35 -0
  80. package/src/plans/your-turn-rows.ts +102 -0
  81. package/src/plans/your-turn.ts +226 -0
  82. package/src/terminal/herdr.ts +25 -5
  83. package/src/terminal/restore.ts +44 -0
  84. package/src/terminal/text.ts +2 -2
  85. package/src/terminal/width.ts +9 -0
  86. package/src/vocabulary/index.ts +6 -5
  87. package/src/watch/command.ts +28 -2
  88. package/src/watch/lane-report.ts +2 -0
  89. package/src/watch/lane-rest.ts +7 -1
  90. package/src/watch/reference-paths.ts +1 -0
  91. package/src/watch/unlinked-artifacts-read.ts +43 -0
  92. package/src/watch/unlinked-artifacts.ts +76 -0
  93. package/src/watch/watch-events.ts +12 -0
  94. package/src/watch/watcher-lock.ts +47 -4
@@ -1,7 +1,6 @@
1
1
  import { currentPhase, type LaneProgress } from '../plan-format'
2
2
  import { AGENT_STATUS } from '../terminal/types'
3
3
  import { chipUrlOf, isChipKey, planUrlOf } from './link-chips'
4
- import { TEXTS } from './texts'
5
4
 
6
5
  /**
7
6
  * Whether a lane is drawn open. It is decided once, when the lane first appears (`autoOpen`, by its status then), and
@@ -10,13 +9,12 @@ import { TEXTS } from './texts'
10
9
  export type LaneFold = { open: boolean }
11
10
 
12
11
  /** The blocks on top of the herd, by the name their fold is remembered under. */
13
- export const TOP_BLOCK = { waiting: 'waiting', next: 'next', appointments: 'appointments', sources: 'sources' } as const
12
+ export const TOP_BLOCK = { yourTurn: 'yourTurn', sources: 'sources' } as const
14
13
  export type TopBlockId = (typeof TOP_BLOCK)[keyof typeof TOP_BLOCK]
15
14
 
16
15
  /**
17
- * The row under the cursor and the expanded phases, both as keys, whether ticked phases and items are
18
- * listed one by one or summed up in one line, whether the footer shows the symbol legend, and the
19
- * folds of the lanes by lane name.
16
+ * The row under the cursor and the expanded phases, both as keys, whether the footer shows the symbol legend, and
17
+ * the folds of the lanes by lane name.
20
18
  */
21
19
  export type Selection = {
22
20
  selected: string | null
@@ -25,11 +23,12 @@ export type Selection = {
25
23
  /** The column the square-bracket button under the mouse starts in; null or absent while the mouse is over none. */
26
24
  hoveredFrom?: number | null
27
25
  expanded: ReadonlySet<string>
28
- showDone: boolean
29
26
  showLegend: boolean
30
27
  laneFolds: ReadonlyMap<string, LaneFold>
31
- /** Per folded top block, the keys of its points seen when it was folded; a block that is open has no entry. */
32
- blockFolds?: Readonly<Partial<Record<TopBlockId, ReadonlySet<string>>>>
28
+ /** The top blocks the user opened; every other one is folded, at the start of the herd all of them. */
29
+ openBlocks?: ReadonlySet<TopBlockId>
30
+ /** True once the user folded the lanes block, which is open at the start; absent or false while it is open. */
31
+ lanesFolded?: boolean
33
32
  }
34
33
 
35
34
  /** A lane as the folds need it: its name and the status its agent reports, null without an agent. */
@@ -58,8 +57,8 @@ export const INPUT = {
58
57
  toggle: ['\r', ' '],
59
58
  expand: ['\x1b[C', '\x1bOC', 'l'],
60
59
  collapse: ['\x1b[D', '\x1bOD', 'h'],
61
- showDone: ['a'],
62
60
  legend: ['?'],
61
+ closeLegend: ['\x1b'],
63
62
  sprintPage: ['s'],
64
63
  pageUp: ['\x1b[5~'],
65
64
  pageDown: ['\x1b[6~'],
@@ -76,8 +75,46 @@ export const MOUSE_BUTTON = { left: 0, wheelUp: 64, wheelDown: 65 } as const
76
75
  /** One terminal read can carry several key presses; this splits it into keys, escape sequences kept whole. */
77
76
  export const splitKeys = (chunk: string): string[] => chunk.match(ESCAPE_SEQUENCE) ?? []
78
77
 
79
- /** The key of the closing row that lists the planned phases; no lane key starts without `@`, no phase key lacks a colon. */
80
- export const PLANNED_KEY = 'planned'
78
+ const isMotionReport = (key: string): boolean => {
79
+ const mouse = parseMouse(key)
80
+ return mouse !== null && isMotion(mouse)
81
+ }
82
+
83
+ /**
84
+ * The keys of one read with every mouse motion but the last dropped: only where the mouse ended up counts, so a burst
85
+ * of motions is one hover, not one drawing each. Clicks, releases, the wheel and keys stay in their order.
86
+ */
87
+ export const withoutStaleMotions = (keys: readonly string[]): string[] => {
88
+ const last = keys.reduce((found, key, index) => (isMotionReport(key) ? index : found), -1)
89
+ return keys.filter((key, index) => index === last || !isMotionReport(key))
90
+ }
91
+
92
+ /**
93
+ * The keys of the heads of the blocks of the body: the lanes at work, the lanes that wait for their start, and the
94
+ * backlog of phases no lane works on. No lane key starts without `@`, no phase key lacks a colon.
95
+ */
96
+ const BLOCK_KEY_PREFIX = 'block>'
97
+ export const LANES_KEY = `${BLOCK_KEY_PREFIX}lanes`
98
+ export const WAITING_KEY = `${BLOCK_KEY_PREFIX}waiting`
99
+ export const BACKLOG_KEY = `${BLOCK_KEY_PREFIX}backlog`
100
+
101
+ /** Whether a key is the head of a block of the body. */
102
+ export const isBlockHeadKey = (key: string): boolean => key.startsWith(BLOCK_KEY_PREFIX)
103
+
104
+ const TOP_HEAD_KEY_PREFIX = 'head>'
105
+
106
+ /** The key of the head of a block on top, which the arrows reach like every other clickable row. */
107
+ export const topBlockHeadKey = (block: TopBlockId): string => `${TOP_HEAD_KEY_PREFIX}${block}`
108
+
109
+ /** The block on top whose head a key names, null for any other key. */
110
+ export const topBlockOfKey = (key: string): TopBlockId | null =>
111
+ Object.values(TOP_BLOCK).find((block) => topBlockHeadKey(block) === key) ?? null
112
+
113
+ /** Whether the lanes block is drawn open: it is at the start of the herd, until the user folds it. */
114
+ export const isLanesOpen = ({ lanesFolded }: Selection): boolean => !lanesFolded
115
+
116
+ const withLanesOpen = (selection: Selection, open: boolean): Selection =>
117
+ isLanesOpen(selection) === open ? selection : { ...selection, lanesFolded: !open }
81
118
 
82
119
  export const phaseKey = (lane: string, stepIndex: number, phaseId: string): string => `${lane}:${stepIndex}:${phaseId}`
83
120
 
@@ -88,17 +125,16 @@ const PHASE_KEY = new RegExp(`^${PHASE_KEY_SHAPE}$`)
88
125
  /** Whether a key names a phase: not a lane, row or task key, which carry `@`, `>` or `#`. */
89
126
  const isPhaseKey = (key: string): boolean => PHASE_KEY.test(key)
90
127
 
128
+ /** The phase id a phase key names (`4` of `lane:0:4`), null for any other key. */
129
+ export const phaseIdOfKey = (key: string): string | null =>
130
+ isPhaseKey(key) ? key.slice(key.lastIndexOf(':') + 1) : null
131
+
91
132
  /** The key of a lane's `next` row, which opens to the phases that wait. */
92
133
  export const nextKey = (lane: string): string => `next>${lane}`
93
134
 
94
135
  /** The key of a lane's `✓` row, which opens to the finished phases. */
95
136
  export const finishedKey = (lane: string): string => `done>${lane}`
96
137
 
97
- const TICKED_KEY_PREFIX = 'ticked>'
98
-
99
- /** The key of the "N ticked" line of an open phase; it is clicked only, and a click lands on the phase. */
100
- export const tickedKey = (phase: string): string => `${TICKED_KEY_PREFIX}${phase}`
101
-
102
138
  const ITEM_KEY = new RegExp(`^(${PHASE_KEY_SHAPE})#(\\d+)$`)
103
139
 
104
140
  /**
@@ -108,18 +144,23 @@ const ITEM_KEY = new RegExp(`^(${PHASE_KEY_SHAPE})#(\\d+)$`)
108
144
  */
109
145
  export const itemKey = (phase: string, index: number): string => `${phase}#${index}`
110
146
 
147
+ const TASK_REST_PREFIX = 'rest>'
148
+
149
+ /** The key of the `▸ n more lines` row under an opened task, which opens the rest of its text. */
150
+ export const taskRestKey = (task: string): string => `${TASK_REST_PREFIX}${task}`
151
+
152
+ /** Whether a key is the rest row of an opened task. */
153
+ export const isTaskRestKey = (key: string): boolean => key.startsWith(TASK_REST_PREFIX)
154
+
111
155
  /** The phase a task key belongs to, null for any other key. */
112
156
  export const itemPhaseOfKey = (key: string): string | null => key.match(ITEM_KEY)?.[1] ?? null
113
157
 
114
- const tickedPhaseOfKey = (key: string): string | null =>
115
- key.startsWith(TICKED_KEY_PREFIX) ? key.slice(TICKED_KEY_PREFIX.length) : null
116
-
117
158
  const LANE_KEY_PREFIX = '@'
118
159
 
119
160
  /** The key of a lane's own row; it cannot collide with a phase key, which starts with a lane name and a colon. */
120
161
  export const laneKey = (lane: string): string => `${LANE_KEY_PREFIX}${lane}`
121
162
 
122
- const laneOfKey = (key: string): string | null => (key.startsWith(LANE_KEY_PREFIX) ? key.slice(1) : null)
163
+ export const laneOfKey = (key: string): string | null => (key.startsWith(LANE_KEY_PREFIX) ? key.slice(1) : null)
123
164
 
124
165
  /** Working and blocked lanes show their phases; idle and done ones, and lanes without an agent, fold into one line. */
125
166
  export const autoOpen = (status: string | null): boolean =>
@@ -148,7 +189,6 @@ export const syncLaneFolds = (selection: Selection, lanes: readonly LaneStatusEn
148
189
  export const overviewSelection = (): Selection => ({
149
190
  selected: null,
150
191
  expanded: new Set(),
151
- showDone: false,
152
192
  showLegend: false,
153
193
  laneFolds: new Map(),
154
194
  })
@@ -164,9 +204,9 @@ export const laneSelection = (progress: LaneProgress | undefined): Selection =>
164
204
  return { ...opened, selected: key, expanded: new Set([key]) }
165
205
  }
166
206
 
167
- /** The selectable rows in screen order, once each: only what is drawn can be selected; a chip or a "N ticked" line is clicked, not selected. */
207
+ /** The selectable rows in screen order, once each: only what is drawn can be selected; a chip is clicked, not selected. */
168
208
  export const targetKeys = (targets: readonly ClickTarget[]): string[] => [
169
- ...new Set(targets.map((target) => target.key).filter((key) => !isChipKey(key) && tickedPhaseOfKey(key) === null)),
209
+ ...new Set(targets.map((target) => target.key).filter((key) => !isChipKey(key))),
170
210
  ]
171
211
 
172
212
  /** The selected key, or the first one while nothing is selected or the selected phase left the sprint. */
@@ -183,7 +223,8 @@ const move = (selection: Selection, keys: readonly string[], step: number): Sele
183
223
  }
184
224
 
185
225
  const toggleIn = (keys: Set<string>, key: string): void => {
186
- if (!keys.delete(key)) keys.add(key)
226
+ if (keys.has(key)) removeKey(keys, key)
227
+ else keys.add(key)
187
228
  }
188
229
 
189
230
  const opened = () => true
@@ -198,18 +239,15 @@ const foldLane = (selection: Selection, lane: string, next: (open: boolean) => b
198
239
  }
199
240
 
200
241
  /**
201
- * Selects the row under a click. A "N ticked" line flips `showDone` and selects its phase, a lane line flips the
202
- * fold of its lane, any other row opens when closed and closes when open: a phase shows its items.
242
+ * Selects the row under a click. A lane line flips the fold of its lane, any other row opens when closed and closes
243
+ * when open: a phase shows its items, a task its whole text. A task never opens its phase: the tasks shown under a
244
+ * folded phase stay under a folded phase.
203
245
  */
204
246
  export const applyClick = (selection: Selection, key: string): Selection => {
205
- const phase = tickedPhaseOfKey(key)
206
- if (phase !== null) return { ...selection, showDone: !selection.showDone, selected: phase }
207
- const lane = laneOfKey(key)
208
- if (lane !== null) return { ...foldLane(selection, lane, flipped), selected: key }
247
+ const head = foldedHead(selection, key, flipped)
248
+ if (head) return head
209
249
  const expanded = new Set(selection.expanded)
210
- const itemPhase = itemPhaseOfKey(key)
211
- if (itemPhase !== null && !expanded.has(itemPhase)) addWithPhase(expanded, key)
212
- else toggleIn(expanded, key)
250
+ toggleIn(expanded, key)
213
251
  return { ...selection, selected: key, expanded }
214
252
  }
215
253
 
@@ -218,18 +256,33 @@ const toggle = (selection: Selection, keys: readonly string[]): Selection => {
218
256
  return current ? applyClick(selection, current) : selection
219
257
  }
220
258
 
221
- /** Adds a key to the expanded ones, with the phase of a task. */
222
- const addWithPhase = (expanded: Set<string>, key: string): void => {
259
+ const addKey = (expanded: Set<string>, key: string): void => {
223
260
  expanded.add(key)
224
- const itemPhase = itemPhaseOfKey(key)
225
- if (itemPhase !== null) expanded.add(itemPhase)
226
261
  }
227
262
 
263
+ /** Folds `key`, and the rest of its text when it is a task, so the next opening shows the brief again. */
228
264
  const removeKey = (expanded: Set<string>, key: string): void => {
229
265
  expanded.delete(key)
266
+ expanded.delete(taskRestKey(key))
230
267
  }
231
268
 
232
- /** Applies a change to the selected row: `nextFold` to the fold of a lane, `change` to the expanded keys for any other row. */
269
+ /**
270
+ * The selection with the fold of `key` changed by `next` and `key` selected, when it is a row with a fold of its own:
271
+ * a lane, the lanes block or a block on top. Null for any other row, which opens through the expanded keys.
272
+ */
273
+ const foldedHead = (selection: Selection, key: string, next: (open: boolean) => boolean): Selection | null => {
274
+ const lane = laneOfKey(key)
275
+ if (lane !== null) return { ...foldLane(selection, lane, next), selected: key }
276
+ if (key === LANES_KEY) return { ...withLanesOpen(selection, next(isLanesOpen(selection))), selected: key }
277
+ const block = topBlockOfKey(key)
278
+ if (block === null) return null
279
+ return { ...withBlockOpen(selection, block, next(!isBlockFolded(selection, block))), selected: key }
280
+ }
281
+
282
+ /**
283
+ * Applies a change to the selected row: `nextFold` to the fold of a lane, the lanes block or a block on top, `change`
284
+ * to the expanded keys for any other row.
285
+ */
233
286
  const changeSelected = (
234
287
  selection: Selection,
235
288
  keys: readonly string[],
@@ -238,82 +291,61 @@ const changeSelected = (
238
291
  ): Selection => {
239
292
  const current = selectedKey(selection, keys)
240
293
  if (!current) return selection
241
- const lane = laneOfKey(current)
242
- if (lane !== null) return { ...foldLane(selection, lane, nextFold), selected: current }
294
+ const head = foldedHead(selection, current, nextFold)
295
+ if (head) return head
243
296
  const expanded = new Set(selection.expanded)
244
297
  change(expanded, current)
245
298
  return { ...selection, selected: current, expanded }
246
299
  }
247
300
 
248
- /** The selection with `key` expanded when it was folded and folded when it was expanded; nothing else changes. */
249
- export const toggledExpansion = (selection: Selection, key: string): Selection => {
250
- const expanded = new Set(selection.expanded)
251
- if (!expanded.delete(key)) expanded.add(key)
252
- return { ...selection, expanded }
253
- }
301
+ /**
302
+ * Whether `candidate` is a point under `key`: one of its rows (`<key>><id>`), its `+<n>` line (`more><key>`) or the
303
+ * rest of its text when it is a task.
304
+ */
305
+ const isChildKey = (key: string, candidate: string): boolean =>
306
+ candidate.startsWith(`${key}>`) || candidate === `more>${key}` || candidate === taskRestKey(key)
254
307
 
255
308
  /**
256
- * Whether `block` is drawn folded: it was folded and every point now open was there then. A point that arrived since
257
- * opens it again, so folding never hides something new. Each block remembers its own points.
309
+ * The selection with `key` expanded when it was folded, and folded with every point under it when it was expanded, so
310
+ * nothing stays open inside a folded row and comes back on the next opening; nothing else changes.
258
311
  */
259
- export const isBlockFolded = ({ blockFolds }: Selection, block: TopBlockId, keys: readonly string[]): boolean => {
260
- const folded = blockFolds?.[block]
261
- return folded !== undefined && keys.every((key) => folded.has(key))
312
+ export const toggledExpansion = (selection: Selection, key: string): Selection => {
313
+ if (!selection.expanded.has(key)) return { ...selection, expanded: new Set(selection.expanded).add(key) }
314
+ const expanded = new Set([...selection.expanded].filter((open) => open !== key && !isChildKey(key, open)))
315
+ return { ...selection, expanded }
262
316
  }
263
317
 
264
318
  /**
265
- * The selection with the fold of each block forgotten that a new point opened, given the points each block holds now.
266
- * Without it a block that opened for a new point would fold again by itself once that point is gone. The selection
267
- * itself when no fold is forgotten.
319
+ * Whether `block` is drawn folded: every block on top starts folded, its head saying what it holds, and stays open from
320
+ * the user's opening to their folding, over every refresh. Nothing new opens it by itself.
268
321
  */
269
- export const settledBlockFolds = (
270
- selection: Selection,
271
- keysOf: Readonly<Record<TopBlockId, readonly string[]>>,
272
- ): Selection => {
273
- const folds = selection.blockFolds ?? {}
274
- const opened = Object.values(TOP_BLOCK).filter(
275
- (block) => folds[block] !== undefined && !isBlockFolded(selection, block, keysOf[block]),
276
- )
277
- if (opened.length === 0) return selection
278
- const kept = Object.fromEntries(Object.entries(folds).filter(([block]) => !opened.includes(block as TopBlockId)))
279
- const { blockFolds: _forgotten, ...rest } = selection
280
- return Object.keys(kept).length > 0 ? { ...rest, blockFolds: kept } : rest
322
+ export const isBlockFolded = ({ openBlocks }: Selection, block: TopBlockId): boolean => !openBlocks?.has(block)
323
+
324
+ /** The selection with `block` opened when it was folded, and folded when it was open. */
325
+ export const toggledBlockFold = (selection: Selection, block: TopBlockId): Selection => {
326
+ const open = new Set(selection.openBlocks)
327
+ if (open.has(block)) open.delete(block)
328
+ else open.add(block)
329
+ return { ...selection, openBlocks: open }
281
330
  }
282
331
 
283
- /** The selection with `block` folded over the points `keys` when it was open, and open when it was folded. */
284
- export const toggledBlockFold = (selection: Selection, block: TopBlockId, keys: readonly string[]): Selection => {
285
- if (!isBlockFolded(selection, block, keys)) {
286
- return { ...selection, blockFolds: { ...selection.blockFolds, [block]: new Set(keys) } }
287
- }
288
- const { blockFolds, ...rest } = selection
289
- const { [block]: _folded, ...otherFolds } = blockFolds ?? {}
290
- return Object.keys(otherFolds).length > 0 ? { ...rest, blockFolds: otherFolds } : rest
291
- }
332
+ const withBlockOpen = (selection: Selection, block: TopBlockId, open: boolean): Selection =>
333
+ isBlockFolded(selection, block) === !open ? selection : toggledBlockFold(selection, block)
292
334
 
293
335
  const expand = (selection: Selection, keys: readonly string[]): Selection =>
294
- changeSelected(selection, keys, opened, addWithPhase)
336
+ changeSelected(selection, keys, opened, addKey)
295
337
 
296
338
  const collapse = (selection: Selection, keys: readonly string[]): Selection =>
297
339
  changeSelected(selection, keys, closed, removeKey)
298
340
 
299
- /**
300
- * The line for a press of `a` that changes nothing on screen, null for any other input. The key lists the ticked
301
- * tasks inside an expanded phase, so it needs one among the `keys` drawn.
302
- */
303
- export const showDoneNotice = (selection: Selection, input: string, keys: readonly string[]): string | null => {
304
- if (!isInput(INPUT.showDone, input)) return null
305
- const phaseOpen = keys.some((key) => isPhaseKey(key) && selection.expanded.has(key))
306
- return phaseOpen ? null : TEXTS.showDoneNeedsPhase
307
- }
308
-
309
341
  export const applyKey = (selection: Selection, input: string, keys: readonly string[]): Selection => {
310
342
  if (isInput(INPUT.up, input)) return move(selection, keys, -1)
311
343
  if (isInput(INPUT.down, input)) return move(selection, keys, 1)
312
344
  if (isInput(INPUT.toggle, input)) return toggle(selection, keys)
313
345
  if (isInput(INPUT.expand, input)) return expand(selection, keys)
314
346
  if (isInput(INPUT.collapse, input)) return collapse(selection, keys)
315
- if (isInput(INPUT.showDone, input)) return { ...selection, showDone: !selection.showDone }
316
347
  if (isInput(INPUT.legend, input)) return { ...selection, showLegend: !selection.showLegend }
348
+ if (isInput(INPUT.closeLegend, input) && selection.showLegend) return { ...selection, showLegend: false }
317
349
  return selection
318
350
  }
319
351
 
@@ -353,7 +385,8 @@ const clicked = (mouse: MouseEvent, view: View, { targets, headerRows }: InputCo
353
385
 
354
386
  /**
355
387
  * Applies one key or mouse report. Wheel and page keys move the window and stop it following the
356
- * cursor; everything that changes the selection makes it follow again.
388
+ * cursor; everything that changes the selection makes it follow again, except opening the help, which
389
+ * starts at its top.
357
390
  */
358
391
  export const applyInput = (view: View, input: string, context: InputContext): View => {
359
392
  const mouse = parseMouse(input)
@@ -363,7 +396,9 @@ export const applyInput = (view: View, input: string, context: InputContext): Vi
363
396
  if (mouse)
364
397
  return key && !isChipKey(key) ? { ...view, selection: applyClick(view.selection, key), follow: true } : view
365
398
  const selection = applyKey(view.selection, input, context.keys)
366
- return selection === view.selection ? view : { ...view, selection, follow: true }
399
+ if (selection === view.selection) return view
400
+ if (selection.showLegend && !view.selection.showLegend) return { ...view, selection, top: 0, follow: false }
401
+ return { ...view, selection, follow: true }
367
402
  }
368
403
 
369
404
  /** The row an input acts on: enter, space or right on the selected row, or a left click; null for anything else. */
@@ -31,7 +31,7 @@ const isAddress = (file: string): boolean => file.startsWith('https://')
31
31
  const addressOf = (file: string): string =>
32
32
  isAddress(file) ? file : `file://${file.split('/').map(encodeURIComponent).join('/')}`
33
33
 
34
- /** What a badge or a for-you row shows of a file, without the control characters a hand-written record may carry. */
34
+ /** What a badge or a row of "Your turn" shows of a file, without the control characters a hand-written record may carry. */
35
35
  const labelOf = (file: string, caption: string | null): string =>
36
36
  terminalSafe(isAddress(file) ? (caption ?? 'artifact') : basename(file))
37
37
 
@@ -14,19 +14,21 @@ export type PlansSettings = Pick<
14
14
  > & {
15
15
  adapter: JackherdConfig['terminal']['adapter']
16
16
  fetchIntervalMinutes: number
17
+ /** Whether the running task of a folded lane shimmers; `NO_COLOR` keeps it still whatever this says. */
18
+ shimmer: boolean
17
19
  linkKinds: JackherdConfig['plans']['linkKinds']
18
20
  context: JackherdConfig['plans']['context']
19
21
  /** The `watch.sources` marked `ticket`, whose entries the view shows as badges. */
20
22
  ticketSources: TicketSource[]
21
23
  /** 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
24
  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. */
25
+ /** By source name: what the herd puts before a row of "Your turn" that came from the source, its `label` or else its name. */
24
26
  sourceLabels: Record<string, string>
25
27
  /** The tone of each role in the herd. */
26
28
  tones: JackherdConfig['plans']['tones']
27
29
  }
28
30
 
29
- /** The locale of the herd's clock: the herd is English, with a 24-hour clock. */
31
+ /** The locale of the times the herd writes (the refresh error, the last fetch): English, with a 24-hour clock. */
30
32
  export const CLOCK_LOCALE = 'en-GB'
31
33
 
32
34
  /** The herd's slice of a validated config. */
@@ -40,6 +42,7 @@ export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
40
42
  skillPrefix: config.skillPrefix,
41
43
  adapter: config.terminal.adapter,
42
44
  fetchIntervalMinutes: config.plans.fetchIntervalMinutes,
45
+ shimmer: config.plans.shimmer,
43
46
  linkKinds: config.plans.linkKinds,
44
47
  context: config.plans.context,
45
48
  ticketSources: config.watch.sources.flatMap(({ name, lane, ticket }) =>
@@ -0,0 +1,124 @@
1
+ import { ESC, stripAnsi } from '../terminal/text'
2
+
3
+ /** A line of the frame that shimmers, by its row on the terminal (1-based) and its text with the marks in it. */
4
+ export type ShimmerRow = { row: number; line: string }
5
+
6
+ /**
7
+ * The marks around a text that shimmers: SGR 5 and 25 (blink on, off), which nothing else in the herd writes.
8
+ * `shimmerAt` turns them into the stripe of the moment, so no line reaches the terminal with them.
9
+ */
10
+ const MARK = { open: `${ESC}[5m`, close: `${ESC}[25m` } as const
11
+ const MARKED = new RegExp(`${ESC}\\[5m([^${ESC}]*)${ESC}\\[25m`, 'g')
12
+ const LONE_MARK = new RegExp(`${ESC}\\[2?5m`, 'g')
13
+ const DEFAULT_FOREGROUND = `${ESC}[39m`
14
+
15
+ type Rgb = readonly [number, number, number]
16
+
17
+ /**
18
+ * The stripe is drawn `framesPerSecond` times a second; its middle glides `cellsPerSecond`, and it lifts the cells
19
+ * within `reachCells` of it in a soft bell from the teal `base` the whole text stands in to the light teal `peak`.
20
+ * After the stripe left the longest text on screen every row rests `pauseMs`, then all start again together.
21
+ */
22
+ export const SHIMMER = {
23
+ framesPerSecond: 30,
24
+ cellsPerSecond: 20,
25
+ pauseMs: 600,
26
+ reachCells: 4.5,
27
+ base: [80, 180, 170] as Rgb,
28
+ peak: [175, 240, 232] as Rgb,
29
+ } as const
30
+
31
+ /** The width of the bell: at `reachCells` from its middle a cell keeps 5 % of the lift. */
32
+ const BELL_WIDTH = SHIMMER.reachCells / Math.sqrt(3)
33
+ /** The six levels of each channel in the colour cube of the 256 colours, which starts at index 16. */
34
+ const CUBE_LEVELS: readonly number[] = [0, 95, 135, 175, 215, 255]
35
+ const CUBE_FIRST = 16
36
+
37
+ const nearestLevel = (value: number): number => {
38
+ const distances = CUBE_LEVELS.map((level) => Math.abs(level - value))
39
+ return distances.indexOf(Math.min(...distances))
40
+ }
41
+
42
+ /** The colour of the 256 nearest to `rgb`, channel by channel in the colour cube. */
43
+ const cubeIndex = ([red, green, blue]: Rgb): number =>
44
+ CUBE_FIRST + 36 * nearestLevel(red) + 6 * nearestLevel(green) + nearestLevel(blue)
45
+
46
+ const colourCode = (rgb: Rgb, trueColour: boolean): string =>
47
+ trueColour ? `38;2;${rgb[0]};${rgb[1]};${rgb[2]}` : `38;5;${cubeIndex(rgb)}`
48
+
49
+ export const isTrueColour = (env: Record<string, string | undefined>): boolean =>
50
+ env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit'
51
+
52
+ const TRUE_COLOUR = isTrueColour(process.env)
53
+
54
+ /** `text` marked to shimmer, as plain text: styling of its own would stop the marks from pairing. */
55
+ export const shimmering = (text: string): string => `${MARK.open}${stripAnsi(text)}${MARK.close}`
56
+
57
+ /**
58
+ * `text` as plain text in the teal the shimmer rests in, the default foreground after it: a running task while the
59
+ * herd does not shimmer (`plans.shimmer: false`, `NO_COLOR`, `--once`). Only the foreground, so a focus background stays.
60
+ */
61
+ export const stillTeal = (text: string, trueColour = TRUE_COLOUR): string =>
62
+ `${ESC}[${colourCode(SHIMMER.base, trueColour)}m${stripAnsi(text)}${DEFAULT_FOREGROUND}`
63
+
64
+ export const hasShimmer = (line: string): boolean => line.includes(MARK.open)
65
+
66
+ /** The characters of the longest marked text in `lines`, 0 without one: it sets the pass of every row. */
67
+ export const longestShimmer = (lines: readonly string[]): number =>
68
+ Math.max(0, ...lines.flatMap((line) => [...line.matchAll(MARKED)].map((match) => [...(match[1] ?? '')].length)))
69
+
70
+ /** One pass of the stripe over a longest text of `longest` characters, in and out of it, and the rest after it. */
71
+ export const shimmerCycleMs = (longest: number): number =>
72
+ ((longest + 2 * SHIMMER.reachCells) / SHIMMER.cellsPerSecond) * 1000 + SHIMMER.pauseMs
73
+
74
+ /** The middle of the stripe at `now` (epoch milliseconds) as a fraction of cells, negative before the text. */
75
+ const stripeMiddle = (now: number, longest: number): number =>
76
+ ((now % shimmerCycleMs(longest)) * SHIMMER.cellsPerSecond) / 1000 - SHIMMER.reachCells
77
+
78
+ /** The foreground of a cell `distance` cells from the middle: the teal lifted along the bell towards the peak. */
79
+ const shadeCode = (distance: number, trueColour: boolean): string => {
80
+ const weight = Math.exp(-((distance / BELL_WIDTH) ** 2))
81
+ const channel = (index: 0 | 1 | 2) =>
82
+ Math.round(SHIMMER.base[index] + (SHIMMER.peak[index] - SHIMMER.base[index]) * weight)
83
+ return colourCode([channel(0), channel(1), channel(2)], trueColour)
84
+ }
85
+
86
+ /**
87
+ * `text` in teal, each character within reach of `middle` in the shade its distance gives, the default foreground
88
+ * after it; the characters out of reach are written in one run, so a long text costs no more than a short one.
89
+ */
90
+ const striped = (text: string, middle: number, trueColour: boolean): string => {
91
+ const characters = [...text]
92
+ const base = colourCode(SHIMMER.base, trueColour)
93
+ const from = Math.max(Math.floor(middle - SHIMMER.reachCells), 0)
94
+ const to = Math.min(Math.ceil(middle + SHIMMER.reachCells), characters.length)
95
+ if (to <= from) return `${ESC}[${base}m${text}${DEFAULT_FOREGROUND}`
96
+ let stripe = ''
97
+ let current = base
98
+ for (let index = from; index < to; index++) {
99
+ const code = shadeCode(index + 0.5 - middle, trueColour)
100
+ if (code !== current) stripe += `${ESC}[${code}m`
101
+ current = code
102
+ stripe += characters[index]
103
+ }
104
+ const rest = characters.slice(to).join('')
105
+ const after = rest && current !== base ? `${ESC}[${base}m${rest}` : rest
106
+ return `${ESC}[${base}m${characters.slice(0, from).join('')}${stripe}${after}${DEFAULT_FOREGROUND}`
107
+ }
108
+
109
+ /**
110
+ * `line` as drawn at `now`: each marked text with the stripe of the moment, the marks gone, a mark left alone by a cut
111
+ * too. `longest` is `longestShimmer` over the frame, so every row starts and rests together.
112
+ */
113
+ export const shimmerAt = (line: string, now: number, longest: number, trueColour = TRUE_COLOUR): string =>
114
+ line
115
+ .replace(MARKED, (_marked, text: string) => striped(text, stripeMiddle(now, longest), trueColour))
116
+ .replace(LONE_MARK, '')
117
+
118
+ /** The lines of a frame that shimmer, by their row on the terminal; `lines` start at the top row. */
119
+ export const shimmerRows = (lines: readonly string[]): ShimmerRow[] =>
120
+ lines.flatMap((line, index) => (hasShimmer(line) ? [{ row: index + 1, line }] : []))
121
+
122
+ /** Whether running tasks shimmer: while the config's `plans.shimmer` allows it, not with `NO_COLOR` set to a value (no-color.org). */
123
+ export const shimmerEnabled = (configured: boolean, env: Record<string, string | undefined>): boolean =>
124
+ configured && !env.NO_COLOR
@@ -39,6 +39,22 @@ const recentSessionLogs = (project: string, notOlderThan: number): string[] => {
39
39
  }
40
40
  }
41
41
 
42
+ /** One read of the signals: the recent logs, how one is read, and what ends the read (the cache cleaned, the records added). */
43
+ const signalRound = ({ projects, root, worktrees }: SignalSource, now: number, cache: StatCache<TaskSignal[]>) => {
44
+ const notOlderThan = now - STALE_ACTIVITY_MINUTES * MS_PER_MINUTE
45
+ const round: SignalCache = { entries: cache, used: new Set<string>() }
46
+ return {
47
+ paths: [root, ...subdirectories(worktrees)].flatMap((directory) =>
48
+ recentSessionLogs(join(projects, projectDirectoryOf(directory)), notOlderThan),
49
+ ),
50
+ read: (path: string) => readClaudeSignals(path, { notOlderThan, cache: round }),
51
+ finish: (claude: TaskSignal[]): TaskSignal[] => {
52
+ dropUnused(round.entries, round.used)
53
+ return [...claude, ...recordSignals(readTaskRecords(root))]
54
+ },
55
+ }
56
+ }
57
+
42
58
  /**
43
59
  * What agents report doing, as `TaskSignal`s: the tasks in progress in the recent Claude Code sessions of the main
44
60
  * checkout (the coordinator and its subagents) and of every worktree, and the open `jh task` records. Which lane a
@@ -46,17 +62,29 @@ const recentSessionLogs = (project: string, notOlderThan: number): string[] => {
46
62
  * log whose modification time and size did not change; logs this call did not look at are dropped from it.
47
63
  */
48
64
  export const readAllSignals = (
49
- { projects, root, worktrees }: SignalSource,
65
+ source: SignalSource,
50
66
  now: number,
51
67
  cache: StatCache<TaskSignal[]> = new Map(),
52
68
  ): TaskSignal[] => {
53
- const notOlderThan = now - STALE_ACTIVITY_MINUTES * MS_PER_MINUTE
54
- const round: SignalCache = { entries: cache, used: new Set<string>() }
55
- const claude = [root, ...subdirectories(worktrees)].flatMap((directory) =>
56
- recentSessionLogs(join(projects, projectDirectoryOf(directory)), notOlderThan).flatMap((path) =>
57
- readClaudeSignals(path, { notOlderThan, cache: round }),
58
- ),
59
- )
60
- dropUnused(round.entries, round.used)
61
- return [...claude, ...recordSignals(readTaskRecords(root))]
69
+ const round = signalRound(source, now, cache)
70
+ return round.finish(round.paths.flatMap(round.read))
71
+ }
72
+
73
+ /**
74
+ * `readAllSignals` with `pass` awaited before each log is parsed, so a gate (`createSliceGate`) can hand the event
75
+ * loop back between them; the result and the cache are the same.
76
+ */
77
+ export const readAllSignalsSliced = async (
78
+ source: SignalSource,
79
+ now: number,
80
+ cache: StatCache<TaskSignal[]>,
81
+ pass: () => Promise<void>,
82
+ ): Promise<TaskSignal[]> => {
83
+ const round = signalRound(source, now, cache)
84
+ const claude: TaskSignal[] = []
85
+ for (const path of round.paths) {
86
+ await pass()
87
+ claude.push(...round.read(path))
88
+ }
89
+ return round.finish(claude)
62
90
  }
@@ -0,0 +1,51 @@
1
+ /** Where the gate hands the event loop back and what it times the slices with; `setImmediate` and `performance.now`. */
2
+ export type SliceLoop = { schedule: (turn: () => void) => void; clock: () => number }
3
+
4
+ /** A gate that cuts long synchronous work into slices of the event loop. */
5
+ export type SliceGate = { pass: () => Promise<void> }
6
+
7
+ const SYSTEM_LOOP: SliceLoop = { schedule: (turn) => setImmediate(turn), clock: () => performance.now() }
8
+
9
+ /**
10
+ * A gate the steps of a read pass before each piece of synchronous work. While the current slice is shorter than
11
+ * `budgetMs` a step passes at once; after that the gate hands the loop back (keys, mouse and drawing come first) and
12
+ * lets the waiting steps through in the order they asked, one at a time, each after the one before it reached its
13
+ * next `await`, as long as the new slice has time. Steps that run side by side (`Promise.all` over the lanes) thus
14
+ * take turns instead of running their synchronous parts back to back in one block.
15
+ */
16
+ export const createSliceGate = (budgetMs: number, loop: SliceLoop = SYSTEM_LOOP): SliceGate => {
17
+ const waiting: (() => void)[] = []
18
+ let sliceStart = loop.clock()
19
+ let scheduled = false
20
+ const hasTime = () => loop.clock() - sliceStart < budgetMs
21
+
22
+ const scheduleTurn = () => {
23
+ if (scheduled) return
24
+ scheduled = true
25
+ loop.schedule(() => {
26
+ scheduled = false
27
+ sliceStart = loop.clock()
28
+ releaseNext()
29
+ })
30
+ }
31
+
32
+ const releaseNext = () => {
33
+ const next = waiting.shift()
34
+ if (!next) return
35
+ next()
36
+ void Promise.resolve().then(() => {
37
+ if (waiting.length === 0) return
38
+ if (hasTime()) releaseNext()
39
+ else scheduleTurn()
40
+ })
41
+ }
42
+
43
+ const pass = (): Promise<void> => {
44
+ if (waiting.length === 0 && !scheduled && hasTime()) return Promise.resolve()
45
+ const passed = new Promise<void>((resolve) => waiting.push(resolve))
46
+ scheduleTurn()
47
+ return passed
48
+ }
49
+
50
+ return { pass }
51
+ }