jackherd 1.11.2 → 1.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +69 -51
- package/dist/{chunk-RMMXD4G5.js → chunk-V4HOLGFP.js} +2 -2
- package/dist/config.d.ts +10 -0
- package/dist/config.js +15 -5
- package/dist/plan-format.d.ts +11 -6
- package/dist/plan-format.js +8 -2
- package/docs/follow-dark.svg +2 -2
- package/docs/follow.svg +2 -2
- package/docs/workflow-dark.svg +56 -64
- package/docs/workflow.svg +56 -64
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/workflow/skills/implement/SKILL.md +1 -1
- package/kit/workflow/skills/orchestrate/SKILL.md +1 -1
- package/kit/workflow/skills/orchestrate/references/draft-review.md +4 -0
- package/kit/workflow/skills/orchestrate/references/events.md +8 -1
- package/kit/workflow/skills/plan/SKILL.md +2 -0
- package/package.json +2 -2
- package/schemas/jackherd.schema.json +18 -1
- package/src/config/link-kinds.ts +1 -1
- package/src/config/schema.ts +5 -2
- package/src/config/tones.ts +9 -2
- package/src/context/fill.ts +4 -10
- package/src/context/session-log.ts +108 -2
- package/src/plan-format/lanes.ts +10 -1
- package/src/plan-format/phases.ts +11 -3
- package/src/plan-format/progress.ts +4 -2
- package/src/plan-format/waiting.ts +12 -0
- package/src/plans/active-tasks.ts +31 -8
- package/src/plans/appointment-rows.ts +42 -53
- package/src/plans/board-rows.ts +14 -0
- package/src/plans/calendar-days.ts +29 -0
- package/src/plans/click-hint.ts +94 -0
- package/src/plans/command.ts +8 -9
- package/src/plans/current-phase.ts +22 -10
- package/src/plans/current-task.ts +18 -3
- package/src/plans/frame.ts +18 -26
- package/src/plans/git.ts +90 -31
- package/src/plans/groomed.ts +13 -7
- package/src/plans/lane-reason.ts +61 -0
- package/src/plans/lane-summary.ts +75 -24
- package/src/plans/layout-phase.ts +123 -76
- package/src/plans/layout-summary.ts +133 -83
- package/src/plans/layout.ts +451 -345
- package/src/plans/legend.ts +29 -0
- package/src/plans/link-chips.ts +26 -8
- package/src/plans/live.ts +238 -84
- package/src/plans/next-rows.ts +5 -37
- package/src/plans/phase-source.ts +8 -3
- package/src/plans/plan-chips.ts +6 -3
- package/src/plans/plan-heads.ts +1 -1
- package/src/plans/planned.ts +4 -1
- package/src/plans/process-slots.ts +36 -0
- package/src/plans/pull-requests.ts +9 -1
- package/src/plans/read-cache.ts +18 -3
- package/src/plans/refresh-pace.ts +13 -0
- package/src/plans/row-focus.ts +86 -15
- package/src/plans/selection.ts +124 -89
- package/src/plans/sent-files.ts +1 -1
- package/src/plans/settings.ts +5 -2
- package/src/plans/shimmer.ts +124 -0
- package/src/plans/signals-read.ts +38 -10
- package/src/plans/slice-gate.ts +51 -0
- package/src/plans/snapshot.ts +185 -84
- package/src/plans/source-asked.ts +1 -1
- package/src/plans/source-rows.ts +60 -18
- package/src/plans/sprint-html.ts +1 -1
- package/src/plans/status-pace.ts +45 -0
- package/src/plans/subagent-tasks.ts +1 -1
- package/src/plans/subagents-read.ts +61 -24
- package/src/plans/subagents.ts +2 -2
- package/src/plans/symbols.ts +4 -1
- package/src/plans/task-brief.ts +143 -0
- package/src/plans/texts.ts +181 -39
- package/src/plans/view.ts +198 -32
- package/src/plans/waiting-click.ts +54 -110
- package/src/plans/watch-burst.ts +25 -0
- package/src/plans/worktree-stamp.ts +35 -0
- package/src/plans/your-turn-rows.ts +102 -0
- package/src/plans/your-turn.ts +226 -0
- package/src/terminal/herdr.ts +25 -5
- package/src/terminal/restore.ts +44 -0
- package/src/terminal/text.ts +2 -2
- package/src/terminal/width.ts +9 -0
- package/src/vocabulary/index.ts +6 -5
- package/src/watch/command.ts +28 -2
- package/src/watch/lane-report.ts +2 -0
- package/src/watch/lane-rest.ts +7 -1
- package/src/watch/reference-paths.ts +1 -0
- package/src/watch/unlinked-artifacts-read.ts +43 -0
- package/src/watch/unlinked-artifacts.ts +76 -0
- package/src/watch/watch-events.ts +12 -0
- 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
|
|
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
|
-
|
|
75
|
+
headKey?: string
|
|
84
76
|
}
|
|
85
77
|
|
|
86
|
-
/** How many rows
|
|
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
|
|
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
|
-
|
|
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
|
|
116
|
-
export const
|
|
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
|
-
/**
|
|
123
|
-
const
|
|
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
|
-
|
|
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
|
|
132
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
157
|
-
const
|
|
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
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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,
|
|
209
|
-
const { room, placed } = buttonLayout(
|
|
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 =
|
|
180
|
+
const cut = expands !== false && textWidth(`${start}${text}`) > room
|
|
213
181
|
const expanded = cut && expandedKeys.has(key)
|
|
214
|
-
const
|
|
215
|
-
const lines =
|
|
216
|
-
|
|
217
|
-
const dimFrom =
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
+
}
|