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
@@ -3,14 +3,12 @@ import { WAITING_KIND, type WaitingItem } from '../plan-format/waiting'
3
3
  import { fit, padVisible, wrap } from '../terminal/text'
4
4
  import { textWidth } from '../terminal/width'
5
5
  import { ASK_KIND, type AskTarget } from './ask-target'
6
- import { plain } from './layout-phase'
7
6
  import { DONE_KIND, type DoneTarget } from './row-targets'
8
7
  import { MOUSE_BUTTON, type MouseEvent } from './selection'
9
- import { shortTitle } from './task-name'
10
8
 
11
9
  /**
12
10
  * One row of a block of the herd as drawn: its plain text lines (more than one while it is expanded; the first starts
13
- * with `mark` and a blank and, for a row with `[ask]`, is padded to leave the button its cells), whether its text is wider than the pane (`cut`), whether it is expanded, and the address a
11
+ * with `mark` and a blank and, for a row with `[ask]`, is padded to leave the button its cells), whether its text is wider than the pane and the row may expand (`cut`), whether it is expanded, and the address a
14
12
  * click opens.
15
13
  */
16
14
  export type BlockRow = {
@@ -20,12 +18,8 @@ export type BlockRow = {
20
18
  cut: boolean
21
19
  expanded: boolean
22
20
  url: string | null
23
- /** What the coordinator can be asked to put: a dialog question or a row of the board; null for files and appointments. */
24
- ask: AskTarget | null
25
21
  /** The buttons at the right edge of the first line, `[ask]` first, each with the screen column (1-based) it starts in. */
26
22
  buttons: PlacedButton[]
27
- /** What the folded block shows of the row: the time column, if any, and the short title, without the mark. */
28
- preview: string
29
23
  /** Per line, the index in it where the part behind the short title starts; the line's length where none of it is. */
30
24
  dimFrom: number[]
31
25
  /** The tone of the mark when it differs from the block's; absent for most rows. */
@@ -58,43 +52,33 @@ export type BlockItem = {
58
52
  /** A column before the text, padded by the caller to the widest of the block (the time of an appointment). */
59
53
  lead?: string
60
54
  text: string
61
- /** What the row shows while folded; the whole `text` when absent. */
62
- title?: string
63
55
  url: string | null
64
56
  ask: AskTarget | null
65
- /** The buttons after `[ask]`, in the order they are drawn. */
66
- buttons?: readonly BlockButton[]
67
57
  tone?: ToneName
68
58
  /** The key in the expanded set that a click on the row flips, whatever its text or address. */
69
59
  toggle?: string
70
60
  /** The row is dim from its text on (an entry already asked about). */
71
61
  dim?: boolean
62
+ /** The row never expands: everything it says fits its one line, so a click opens its address (a source's entry). */
63
+ expands?: false
72
64
  }
73
65
 
74
66
  /**
75
67
  * What a click on a block is matched against, built from the block as it was drawn: `firstLine` is the screen line
76
68
  * (0-based) of the first row, the line above it is the block's head; `rows` and `moreKey` are empty and null while the
77
- * block is folded; `keys` are all its points, what a fold of the block remembers.
69
+ * block is folded. `headKey` is the key the arrows select the head under, absent for a head that cannot open.
78
70
  */
79
71
  export type BlockHit = {
80
72
  firstLine: number
81
73
  rows: readonly BlockRow[]
82
74
  moreKey: string | null
83
- keys: readonly string[]
75
+ headKey?: string
84
76
  }
85
77
 
86
- /** How many rows a block of the herd (for-you, appointments) draws; the rest is one `+<n>` line. */
78
+ /** How many rows the sources block draws; the rest is one `+<n>` line. */
87
79
  export const SHOWN_BLOCK_ROWS = 5
88
- /** The key in the herd's expanded set that shows every point of the for-you block instead of the first five. */
89
- export const WAITING_MORE_KEY = 'more>waiting'
90
- /** The same for the appointment block. */
91
- export const APPOINTMENT_MORE_KEY = 'more>appointment'
92
- /** The same for the next block. */
93
- export const NEXT_MORE_KEY = 'more>next'
94
- /** The same for the sources block. */
80
+ /** The key in the herd's expanded set that shows every source instead of the first five. */
95
81
  export const SOURCE_MORE_KEY = 'more>source'
96
- /** The bar and the blank that start every line of a for-you row. */
97
- export const WAITING_MARK = '▌'
98
82
 
99
83
  /** The button at the right edge of a question or a board row; a click on it, and only on it, asks the coordinator. */
100
84
  export const ASK_BUTTON = '[ask]'
@@ -103,42 +87,44 @@ export const DONE_BUTTON = '[done]'
103
87
  /** The appointment buttons that ask the coordinator to run the row now, and to ask the user for a new time. */
104
88
  export const RUN_BUTTON = '[run]'
105
89
  export const MOVE_BUTTON = '[move]'
106
- const ASK_GAP = 1
90
+ /** The blank between a row's text and its first button. */
91
+ export const ASK_GAP = 1
107
92
  const BUTTON_GAP = 1
108
- const FILE_MARK = '📎'
109
- const QUESTION_MARK = '?'
110
93
  const OPENABLE = /^(https?|file):\/\//
111
94
 
112
95
  /** The key of a row in the herd's expanded set: the record's id, else its text. */
113
96
  export const waitingKey = ({ id, item }: WaitingItem): string => `waiting>${id ?? item}`
114
97
 
115
- /** The keys of all open points, the shown ones and the `+<n>` rest alike: what the fold of the block remembers. */
116
- export const waitingKeys = (items: readonly WaitingItem[]): string[] => items.map(waitingKey)
117
-
118
- const BAR_WIDTH = textWidth(`${WAITING_MARK} `)
98
+ /** The cells the two blanks take that start every line of a block row (`blockBar` and the gap after it). */
99
+ export const BAR_WIDTH = 2
119
100
 
120
101
  const roomOf = (width: number): number => Math.max(width - BAR_WIDTH, 1)
121
102
 
122
- /** The buttons of an item, `[ask]` first when the item can be asked. */
123
- const buttonsOf = ({ ask, buttons = [] }: Pick<BlockItem, 'ask' | 'buttons'>): BlockButton[] => [
124
- ...(ask ? [{ label: ASK_BUTTON, click: { ask } }] : []),
125
- ...buttons,
126
- ]
103
+ /** `[ask]` for what the coordinator can put, none for what it cannot. */
104
+ const askButtons = (ask: AskTarget | null): BlockButton[] => (ask ? [{ label: ASK_BUTTON, click: { ask } }] : [])
127
105
 
128
- const buttonsWidth = (buttons: readonly BlockButton[]): number =>
106
+ /** The cells a row's buttons take, one blank apart. */
107
+ export const buttonsWidth = (buttons: readonly BlockButton[]): number =>
129
108
  buttons.reduce((sum, { label }) => sum + textWidth(label), 0) + Math.max(buttons.length - 1, 0) * BUTTON_GAP
130
109
 
131
- /** The cells a row's text may take and the buttons with the screen columns they start in; none for a row without one. */
132
- const buttonLayout = (items: readonly BlockButton[], width: number): { room: number; placed: PlacedButton[] } => {
133
- if (items.length === 0) return { room: roomOf(width), placed: [] }
134
- const room = Math.max(roomOf(width) - (buttonsWidth(items) + ASK_GAP), 1)
110
+ /** The buttons with the screen columns (1-based) they start in, the first one after `room` cells of text and a blank. */
111
+ export const placedButtons = (buttons: readonly BlockButton[], room: number): PlacedButton[] => {
135
112
  let column = BAR_WIDTH + room + ASK_GAP + 1
136
- const placed = items.map((button) => {
113
+ return buttons.map((button) => {
137
114
  const at = column
138
115
  column += textWidth(button.label) + BUTTON_GAP
139
116
  return { ...button, column: at }
140
117
  })
141
- return { room, placed }
118
+ }
119
+
120
+ /** The cells a row's text may take beside `buttonCells` of buttons (0 for none) and the blank before them. */
121
+ export const textRoom = (width: number, buttonCells: number): number =>
122
+ buttonCells === 0 ? roomOf(width) : Math.max(roomOf(width) - (buttonCells + ASK_GAP), 1)
123
+
124
+ /** The cells a row's text may take and the buttons with the screen columns they start in; none for a row without one. */
125
+ const buttonLayout = (items: readonly BlockButton[], width: number): { room: number; placed: PlacedButton[] } => {
126
+ const room = textRoom(width, items.length === 0 ? 0 : buttonsWidth(items))
127
+ return { room, placed: placedButtons(items, room) }
142
128
  }
143
129
 
144
130
  /** The labels of a row's buttons as drawn after its text, one blank apart, each painted with `paintLabel`. */
@@ -153,32 +139,19 @@ const askOf = (item: WaitingItem): AskTarget | null => {
153
139
  return { kind: ASK_KIND.boardRow, since: item.since, item: item.item }
154
140
  }
155
141
 
156
- /** The text wrapped to `room` cells behind `start` (the mark and the time column), the other lines indented to the column of the text. */
157
- const hangingLines = (start: string, text: string, room: number): string[] => {
158
- const hang = textWidth(start)
159
- const [first, ...rest] = wrap(text, Math.max(room - hang, 1))
160
- return [`${start}${first}`, ...rest.map((line) => `${' '.repeat(hang)}${line}`)]
161
- }
142
+ /** The fewest cells a wrapped line behind the lead may take; with less room the row is cut to one line instead. */
143
+ const MIN_WRAP_WIDTH = 12
162
144
 
163
145
  /**
164
- * Per line, the index where the part behind the short title starts (the line's length while none of it is on the
165
- * line): the title may take several lines, and the rest starts on the line where it ends. `titleLength` is 0 for a
166
- * row without a short title.
146
+ * The text wrapped to `room` cells behind `start` (the mark and the time column), the other lines indented to the
147
+ * column of the text; one line cut at `room` when the lead leaves fewer than `MIN_WRAP_WIDTH` cells, so a wide lead never
148
+ * wraps the text one cell per line.
167
149
  */
168
- const dimStarts = (lines: readonly string[], start: string, titleLength: number): number[] => {
169
- let remaining = titleLength
170
- return lines.map((line, index) => {
171
- if (titleLength === 0) return line.length
172
- const from = index === 0 ? start.length : textWidth(start)
173
- const content = line.length - from
174
- if (remaining > content) {
175
- remaining -= content + 1
176
- return line.length
177
- }
178
- const dimFrom = from + Math.max(remaining, 0)
179
- remaining = 0
180
- return dimFrom
181
- })
150
+ const hangingLines = (start: string, text: string, room: number): string[] => {
151
+ const hang = textWidth(start)
152
+ if (room - hang < MIN_WRAP_WIDTH) return [fit(`${start}${text}`, room)]
153
+ const [first, ...rest] = wrap(text, room - hang)
154
+ return [`${start}${first}`, ...rest.map((line) => `${' '.repeat(hang)}${line}`)]
182
155
  }
183
156
 
184
157
  /** What `[done]` closes: the record of a file or a question, else the row of the board; none for a record-less question. */
@@ -192,32 +165,27 @@ const doneButtons = (item: WaitingItem): BlockButton[] => {
192
165
  return done ? [{ label: DONE_BUTTON, click: { done } }] : []
193
166
  }
194
167
 
195
- const withPrefix = (prefix: string | undefined, text: string): string => (prefix ? `${prefix}: ${text}` : text)
168
+ /** The buttons of what waits on the user, `[ask]` where the coordinator can put it, then `[done]` where it can be closed. */
169
+ export const waitingButtons = (item: WaitingItem): BlockButton[] => [...askButtons(askOf(item)), ...doneButtons(item)]
196
170
 
197
- const itemOf = (item: WaitingItem): BlockItem => ({
198
- buttons: doneButtons(item),
199
- key: waitingKey(item),
200
- mark: item.kind === WAITING_KIND.file ? FILE_MARK : QUESTION_MARK,
201
- text: withPrefix(item.prefix, plain(item.item)),
202
- title: withPrefix(item.prefix, shortTitle(plain(item.item))),
203
- url: item.kind === WAITING_KIND.file && item.url && OPENABLE.test(item.url) ? item.url : null,
204
- ask: askOf(item),
205
- })
171
+ /** The address a click on what waits opens: a sent file's, when it is one the herd can open. */
172
+ export const waitingUrl = (item: WaitingItem): string | null =>
173
+ item.kind === WAITING_KIND.file && item.url && OPENABLE.test(item.url) ? item.url : null
206
174
 
207
175
  const rowOf = (item: BlockItem, expandedKeys: ReadonlySet<string>, width: number): BlockRow => {
208
- const { key, mark, lead, text, title = text, url, ask, tone, toggle, dim } = item
209
- const { room, placed } = buttonLayout(buttonsOf(item), width)
176
+ const { key, mark, lead, text, url, ask, tone, toggle, dim, expands } = item
177
+ const { room, placed } = buttonLayout(askButtons(ask), width)
210
178
  const leadText = lead === undefined ? '' : `${lead} `
211
179
  const start = `${mark} ${leadText}`
212
- const cut = title !== text || textWidth(`${start}${text}`) > room
180
+ const cut = expands !== false && textWidth(`${start}${text}`) > room
213
181
  const expanded = cut && expandedKeys.has(key)
214
- const [first, ...rest] = expanded ? hangingLines(start, text, room) : [fit(`${start}${title}`, room)]
215
- const lines = placed.length > 0 ? [padVisible(first, room + ASK_GAP), ...rest] : [first, ...rest]
216
- const lineParts = [first, ...rest]
217
- const dimFrom = dim
218
- ? lineParts.map((_, index) => (index === 0 ? mark.length + 1 : 0))
219
- : dimStarts(lineParts, start, expanded && title !== text ? title.length : 0)
220
- const preview = `${leadText}${title}`
182
+ const parts = expanded ? hangingLines(start, text, room) : [fit(`${start}${text}`, room)]
183
+ const lines =
184
+ placed.length > 0 ? parts.map((line, index) => (index === 0 ? padVisible(line, room + ASK_GAP) : line)) : parts
185
+ const dimFrom = parts.map((line, index) => {
186
+ if (!dim) return line.length
187
+ return index === 0 ? mark.length + 1 : 0
188
+ })
221
189
  return {
222
190
  key,
223
191
  mark,
@@ -225,9 +193,7 @@ const rowOf = (item: BlockItem, expandedKeys: ReadonlySet<string>, width: number
225
193
  cut,
226
194
  expanded,
227
195
  url,
228
- ask,
229
196
  buttons: placed,
230
- preview,
231
197
  dimFrom,
232
198
  ...(tone && { tone }),
233
199
  ...(toggle && { toggle }),
@@ -235,35 +201,13 @@ const rowOf = (item: BlockItem, expandedKeys: ReadonlySet<string>, width: number
235
201
  }
236
202
  }
237
203
 
238
- /**
239
- * The rows a block of the herd draws: the first five, or all of them while `moreKey` is in the expanded set; each
240
- * folded to one line unless expanded.
241
- */
242
- export const blockRows = (
243
- items: readonly BlockItem[],
244
- expandedKeys: ReadonlySet<string>,
245
- width: number,
246
- moreKey: string,
247
- ): BlockRow[] =>
248
- (expandedKeys.has(moreKey) ? items : items.slice(0, SHOWN_BLOCK_ROWS)).map((item) => rowOf(item, expandedKeys, width))
249
-
250
- /** Every item as a row, for a block that limits its rows itself (the sources block, five entries per source). */
204
+ /** Every item as a row, each folded to one line unless expanded; the block limits its rows itself (five per source). */
251
205
  export const allBlockRows = (
252
206
  items: readonly BlockItem[],
253
207
  expandedKeys: ReadonlySet<string>,
254
208
  width: number,
255
209
  ): BlockRow[] => items.map((item) => rowOf(item, expandedKeys, width))
256
210
 
257
- /** The rows the herd draws for what waits on the user. */
258
- export const waitingRows = (
259
- items: readonly WaitingItem[],
260
- expandedKeys: ReadonlySet<string>,
261
- width: number,
262
- ): BlockRow[] => blockRows(items.map(itemOf), expandedKeys, width, WAITING_MORE_KEY)
263
-
264
- /** The key of the `+<n>` line of a block with `count` points, null while the block has no more than it shows. */
265
- export const moreKeyOf = (count: number, key: string): string | null => (count > SHOWN_BLOCK_ROWS ? key : null)
266
-
267
211
  const rowAt = (
268
212
  rows: readonly BlockRow[],
269
213
  firstLine: number,
@@ -0,0 +1,25 @@
1
+ import type { FetchState } from './fetch'
2
+
3
+ /**
4
+ * How a refresh asked for outside the pace turns into one: a watched file (a plan written, `FETCH_HEAD` moved), the end
5
+ * of the herd's fetch, a new read of the pull requests. The first after a quiet spell waits only `debounceMs`, which
6
+ * bundles the several events of one write; any ask within `windowMs` after a refresh waits for the end of that window,
7
+ * so a burst of edits or fetches costs one refresh per window, not one per event.
8
+ */
9
+ export const WATCH_BURST = { debounceMs: 150, windowMs: 5_000 } as const
10
+
11
+ /** The wait before the refresh an ask at `now` gets, after the last refresh started at `lastRefreshAt`. */
12
+ export const watchRefreshDelay = (lastRefreshAt: number | null, now: number): number =>
13
+ lastRefreshAt === null
14
+ ? WATCH_BURST.debounceMs
15
+ : Math.max(WATCH_BURST.debounceMs, lastRefreshAt + WATCH_BURST.windowMs - now)
16
+
17
+ /**
18
+ * Whether a write to `FETCH_HEAD` at `modifiedAt` (epoch milliseconds) came from the herd's own fetch: one running, or
19
+ * the last that succeeded, between its start and its end. The herd refreshes once when its fetch ends, so that write
20
+ * asks for no refresh of its own; a fetch by anyone else still does.
21
+ */
22
+ export const isOwnFetchWrite = (modifiedAt: number, { lastAttempt, lastSuccess, running }: FetchState): boolean => {
23
+ if (lastAttempt === null || modifiedAt < lastAttempt) return false
24
+ return running || (lastSuccess !== null && modifiedAt <= lastSuccess.getTime())
25
+ }
@@ -0,0 +1,35 @@
1
+ import { readFileSync, statSync } from 'node:fs'
2
+ import { isAbsolute, join, resolve } from 'node:path'
3
+
4
+ const GIT_DIRECTORY_LINE = /^gitdir:\s*(.+)$/m
5
+
6
+ /** The git directory of `worktree`: `.git` itself in a main checkout, the directory its `.git` file names in a linked one. */
7
+ const gitDirectoryOf = (worktree: string): string => {
8
+ const dotGit = join(worktree, '.git')
9
+ try {
10
+ if (statSync(dotGit).isDirectory()) return dotGit
11
+ const named = GIT_DIRECTORY_LINE.exec(readFileSync(dotGit, 'utf8'))?.[1]?.trim()
12
+ if (!named) return dotGit
13
+ return isAbsolute(named) ? named : resolve(worktree, named)
14
+ } catch {
15
+ return dotGit
16
+ }
17
+ }
18
+
19
+ const fileMark = (path: string): string => {
20
+ try {
21
+ const { mtimeMs, size } = statSync(path)
22
+ return `${mtimeMs}:${size}`
23
+ } catch {
24
+ return '-'
25
+ }
26
+ }
27
+
28
+ /**
29
+ * What tells whether a worktree's `git status` may have changed without asking git: its index and HEAD as stat-ed,
30
+ * and `activity` (`logMark` of its agent, its subagents' work). Stable for a worktree that does not exist.
31
+ */
32
+ export const worktreeStamp = (worktree: string, activity: string): string => {
33
+ const gitDirectory = gitDirectoryOf(worktree)
34
+ return [fileMark(join(gitDirectory, 'index')), fileMark(join(gitDirectory, 'HEAD')), activity].join('|')
35
+ }
@@ -0,0 +1,102 @@
1
+ import { WAITING_KIND } from '../plan-format/waiting'
2
+ import { fit, padVisible, spread } from '../terminal/text'
3
+ import { textWidth } from '../terminal/width'
4
+ import { appointmentButtons } from './appointment-rows'
5
+ import { chevronOf, TURN_MARK } from './symbols'
6
+ import {
7
+ ASK_GAP,
8
+ type BlockButton,
9
+ type BlockRow,
10
+ buttonsWidth,
11
+ placedButtons,
12
+ textRoom,
13
+ waitingButtons,
14
+ waitingUrl,
15
+ } from './waiting-click'
16
+ import { OLD_DAYS, TURN_KIND, type TurnPage, type TurnRow } from './your-turn'
17
+
18
+ /** The key of the line under the now rows that shows them all, and of the one under the planned rows. */
19
+ export const YOUR_TURN_MORE_KEY = 'more>yourTurn>now'
20
+ export const YOUR_TURN_LATER_KEY = 'more>yourTurn>planned'
21
+
22
+ /** The blanks between a row's text and its time column. */
23
+ const TIME_GAP = 2
24
+ const UNBLOCKS_INDENT = ' '
25
+
26
+ const markOf = (row: TurnRow): string => {
27
+ if (row.kind === TURN_KIND.planned) return TURN_MARK.planned
28
+ return row.waiting?.kind === WAITING_KIND.file ? TURN_MARK.file : TURN_MARK.now
29
+ }
30
+
31
+ const buttonsOf = (row: TurnRow): BlockButton[] => {
32
+ if (row.appointment) return appointmentButtons(row.appointment)
33
+ return row.waiting ? waitingButtons(row.waiting) : []
34
+ }
35
+
36
+ /** The text cut to leave the time its column, the time flush right at `room`. */
37
+ const firstLineOf = (mark: string, row: TurnRow, room: number): string => {
38
+ const textCells = Math.max(room - textWidth(row.time) - TIME_GAP, 1)
39
+ return spread(fit(`${mark} ${row.text}`, textCells), row.time, room)
40
+ }
41
+
42
+ /**
43
+ * A topic as a block row: the mark, the text and the time flush right at the edge of the text room, which every row
44
+ * of the block shares (`room`), so the times end in one column before the buttons; under a row that frees lanes the
45
+ * dim line `unblocks …`. The buttons keep their columns whether drawn or not; the block draws them at the focused row.
46
+ */
47
+ const rowOf = (row: TurnRow, room: number): BlockRow => {
48
+ const mark = markOf(row)
49
+ const first = firstLineOf(mark, row, room)
50
+ const unblocks = row.unblocks.length > 0 ? [fit(`${UNBLOCKS_INDENT}unblocks ${row.unblocks.join(', ')}`, room)] : []
51
+ const buttons = buttonsOf(row)
52
+ return {
53
+ key: row.key,
54
+ mark,
55
+ lines: [buttons.length > 0 ? padVisible(first, room + ASK_GAP) : first, ...unblocks],
56
+ cut: false,
57
+ expanded: false,
58
+ url: row.waiting ? waitingUrl(row.waiting) : null,
59
+ buttons: placedButtons(buttons, room),
60
+ dimFrom: [first.length - row.time.length, ...unblocks.map(() => 0)],
61
+ }
62
+ }
63
+
64
+ /** `▸ 23 more · 9 older than 7 days`, `▸ 2 later`: a dim line that a click opens and folds. */
65
+ const toggleRow = (key: string, text: string, open: boolean, room: number): BlockRow => {
66
+ const line = fit(`${chevronOf(open)} ${text}`, room)
67
+ return {
68
+ key,
69
+ mark: chevronOf(open),
70
+ lines: [line],
71
+ cut: false,
72
+ expanded: false,
73
+ url: null,
74
+ buttons: [],
75
+ dimFrom: [0],
76
+ toggle: key,
77
+ }
78
+ }
79
+
80
+ const moreText = ({ count, older }: { count: number; older: number }): string =>
81
+ older > 0 ? `${count} more · ${older} older than ${OLD_DAYS} days` : `${count} more`
82
+
83
+ /** The cells of text every row of the block may take: what the widest set of buttons leaves of the pane. */
84
+ const sharedRoom = (rows: readonly TurnRow[], width: number): number =>
85
+ textRoom(width, Math.max(0, ...rows.map((row) => buttonsWidth(buttonsOf(row)))))
86
+
87
+ /**
88
+ * The rows of the open "Your turn" block: the now rows, the line for the rest of them, the planned rows and the line
89
+ * for the later ones; an opened line points down at the rest of its rows, which stand below it.
90
+ */
91
+ export const turnRows = (page: TurnPage, width: number): BlockRow[] => {
92
+ const { opened } = page
93
+ const room = sharedRoom([...page.now, ...page.nowRest, ...page.planned, ...page.plannedRest], width)
94
+ return [
95
+ ...page.now.map((row) => rowOf(row, room)),
96
+ ...(page.nowMore ? [toggleRow(YOUR_TURN_MORE_KEY, moreText(page.nowMore), opened.allNow, room)] : []),
97
+ ...page.nowRest.map((row) => rowOf(row, room)),
98
+ ...page.planned.map((row) => rowOf(row, room)),
99
+ ...(page.later > 0 ? [toggleRow(YOUR_TURN_LATER_KEY, `${page.later} later`, opened.allPlanned, room)] : []),
100
+ ...page.plannedRest.map((row) => rowOf(row, room)),
101
+ ]
102
+ }