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