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
package/src/plans/texts.ts
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ARTIFACT_LINK_KIND } from '../config/link-kinds'
|
|
2
|
+
import { AGENT_STATUS } from '../terminal/types'
|
|
3
|
+
import type { LegendSection } from './legend'
|
|
2
4
|
import { STALE_PLAN_DAYS } from './planned'
|
|
3
5
|
import { CHECK_NAME_LENGTH, CHECKS, type ChecksState, type PullRequest } from './pull-requests'
|
|
4
6
|
import { shownWithMore } from './shown-with-more'
|
|
5
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
CHEVRON,
|
|
9
|
+
MODEL_MARK,
|
|
10
|
+
NO_AGENT_SYMBOL,
|
|
11
|
+
NO_AGENT_WORD,
|
|
12
|
+
PHASE_COUNT,
|
|
13
|
+
STATUS_SYMBOL,
|
|
14
|
+
TURN_MARK,
|
|
15
|
+
UNKNOWN_SYMBOL,
|
|
16
|
+
} from './symbols'
|
|
6
17
|
|
|
7
18
|
/** The words of an open lane: the age of a running phase, the words before the open phases that wait and the finished ones, and the pointer of a phase with only its review gate open. */
|
|
8
19
|
export type HerdTexts = {
|
|
@@ -19,21 +30,48 @@ export type HerdTexts = {
|
|
|
19
30
|
*/
|
|
20
31
|
export type PlanTexts = {
|
|
21
32
|
noAgent: string
|
|
22
|
-
waiting: (lanes: number) => string
|
|
23
33
|
startsAfter: (wait: string) => string
|
|
24
34
|
noPlan: string
|
|
25
35
|
noWorktree: string
|
|
26
36
|
noRow: string
|
|
27
37
|
/** The heads of the blocks on top of the herd. */
|
|
28
|
-
|
|
29
|
-
appointmentHeading: string
|
|
38
|
+
yourTurnHeading: string
|
|
30
39
|
sourceHeading: string
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
/** The heads of the blocks of the body: the lanes at work, the lanes that wait for their start, the unstarted plans. */
|
|
41
|
+
lanesHeading: string
|
|
42
|
+
waitingHeading: string
|
|
43
|
+
backlogHeading: string
|
|
44
|
+
/** The balance of the lanes head: how many lanes wait on you, how many idle otherwise. */
|
|
45
|
+
lanesWaitOnYou: (lanes: number) => string
|
|
46
|
+
lanesIdle: (lanes: number) => string
|
|
47
|
+
/** On a resting lane's line: the labels before a question to you, a row of "Your turn" and its starts-after cell, and the line for a lane without any reason. */
|
|
48
|
+
restWaitsOnYou: string
|
|
49
|
+
/** After `restWaitsOnYou`: how many questions of its report wait, their text being rows of "Your turn". */
|
|
50
|
+
restQuestions: (count: number) => string
|
|
51
|
+
restWaitsOn: string
|
|
33
52
|
restAfter: string
|
|
34
53
|
restNoReason: string
|
|
35
|
-
/**
|
|
36
|
-
|
|
54
|
+
/** On an open lane's session line: how long its agent has rested. */
|
|
55
|
+
idleFor: (minutes: number) => string
|
|
56
|
+
/** The row under the tasks a phase shows of its open ones: how many more are open and how many are done. */
|
|
57
|
+
moreTasks: (open: number, done: number) => string
|
|
58
|
+
/** The row under an opened task's brief: how many lines its rest takes. */
|
|
59
|
+
moreLines: (count: number) => string
|
|
60
|
+
/** The footer while a row has the focus: what a click or a key does on it. */
|
|
61
|
+
clickHint: (action: string) => string
|
|
62
|
+
hintOpens: (name: string) => string
|
|
63
|
+
hintFolds: (name: string) => string
|
|
64
|
+
hintListsTasks: (phase: string) => string
|
|
65
|
+
hintFoldsTasks: (phase: string) => string
|
|
66
|
+
hintBlockAsks: string
|
|
67
|
+
hintBlockOpens: string
|
|
68
|
+
/** What the click hint calls a task, the rest of an opened task, the phases `next` lists and the ones `done` lists. */
|
|
69
|
+
hintTask: string
|
|
70
|
+
hintTaskRest: string
|
|
71
|
+
hintNextPhases: string
|
|
72
|
+
hintDonePhases: string
|
|
73
|
+
/** After the head of "Your turn" when nothing waits for the user and nothing is planned. */
|
|
74
|
+
yourTurnEmpty: string
|
|
37
75
|
notInPlan: string
|
|
38
76
|
/** Before a task an agent reports that no task of the plan names. */
|
|
39
77
|
now: string
|
|
@@ -41,21 +79,18 @@ export type PlanTexts = {
|
|
|
41
79
|
subagent: string
|
|
42
80
|
/** In place of the count of a phase whose only open box is the documentation box of its review gate. */
|
|
43
81
|
allButDocs: string
|
|
44
|
-
tickedSummary: (count: number) => string
|
|
45
|
-
/** The line above the listed ticked tasks of a phase; a click or `a` folds them again. */
|
|
46
|
-
tickedFold: (count: number) => string
|
|
47
82
|
draft: string
|
|
48
83
|
privateLink: string
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
84
|
+
/** The head of the backlog on the HTML page: `Backlog (3 phases in 2 plans)`. */
|
|
85
|
+
backlog: (phases: number, plans: number) => string
|
|
86
|
+
/** After the backlog head: `next: <plan> <phase>, …`. */
|
|
87
|
+
backlogNext: string
|
|
52
88
|
phaseTally: (done: number, total: number) => string
|
|
53
89
|
herd: HerdTexts
|
|
54
90
|
checks: Record<ChecksState, string>
|
|
55
91
|
hint: string
|
|
56
|
-
/** The
|
|
57
|
-
|
|
58
|
-
legend: string[]
|
|
92
|
+
/** The help behind `?`: marks and keys with their meaning; the rules behind them stand in the README. */
|
|
93
|
+
legend: LegendSection[]
|
|
59
94
|
/** The footer line when the coordinator could not be asked to put a question, with the reason. */
|
|
60
95
|
notAsked: (reason: string) => string
|
|
61
96
|
/** The footer line when the coordinator `name` was asked to put `subject`, `question <id>` or `board row <key>`. */
|
|
@@ -99,24 +134,42 @@ const ALL_BUT_DOCS = 'docs open'
|
|
|
99
134
|
|
|
100
135
|
export const TEXTS: PlanTexts = {
|
|
101
136
|
noAgent: NO_AGENT_WORD,
|
|
102
|
-
waiting: (lanes) => `waiting (${lanes})`,
|
|
103
137
|
startsAfter: (wait) => `after ${wait}`,
|
|
104
138
|
noPlan: 'no plan',
|
|
105
139
|
noWorktree: 'no worktree',
|
|
106
140
|
noRow: 'no row in lanes',
|
|
107
|
-
|
|
108
|
-
appointmentHeading: 'Appointments',
|
|
141
|
+
yourTurnHeading: 'Your turn',
|
|
109
142
|
sourceHeading: 'Sources',
|
|
110
|
-
|
|
111
|
-
|
|
143
|
+
lanesHeading: 'Lanes',
|
|
144
|
+
waitingHeading: 'Waiting',
|
|
145
|
+
backlogHeading: 'Backlog',
|
|
146
|
+
lanesWaitOnYou: (lanes) => `${lanes} ${lanes === 1 ? 'waits' : 'wait'} on you`,
|
|
147
|
+
lanesIdle: (lanes) => `${lanes} idle`,
|
|
148
|
+
restWaitsOnYou: 'waits on you',
|
|
149
|
+
restQuestions: (count) => `· ${count} ${count === 1 ? 'question' : 'questions'}`,
|
|
150
|
+
restWaitsOn: 'waits on',
|
|
151
|
+
restAfter: 'after',
|
|
112
152
|
restNoReason: 'idle · no reason',
|
|
113
|
-
|
|
153
|
+
idleFor: (minutes) => `idle ${minutes < 60 ? `${minutes} min` : `${Math.floor(minutes / 60)} h`}`,
|
|
154
|
+
moreTasks: (open, done) =>
|
|
155
|
+
[...(open > 0 ? [`${open} more open`] : []), ...(done > 0 ? [`${done} done`] : [])].join(' · '),
|
|
156
|
+
moreLines: (count) => `${count} more ${count === 1 ? 'line' : 'lines'}`,
|
|
157
|
+
clickHint: (action) => `${action} · ? help`,
|
|
158
|
+
hintOpens: (name) => `click or → opens ${name}`,
|
|
159
|
+
hintFolds: (name) => `click or ← folds ${name}`,
|
|
160
|
+
hintListsTasks: (phase) => `click or → lists the tasks of ${phase}`,
|
|
161
|
+
hintFoldsTasks: (phase) => `click or ← folds the tasks of ${phase}`,
|
|
162
|
+
hintBlockAsks: 'click or enter asks the coordinator',
|
|
163
|
+
hintBlockOpens: 'click or enter opens it',
|
|
164
|
+
hintTask: 'the task',
|
|
165
|
+
hintTaskRest: 'the rest of the task',
|
|
166
|
+
hintNextPhases: 'the next phases',
|
|
167
|
+
hintDonePhases: 'the done phases',
|
|
168
|
+
yourTurnEmpty: 'nothing waits for you',
|
|
114
169
|
notInPlan: 'not in the plan',
|
|
115
170
|
now: 'now:',
|
|
116
171
|
subagent: 'Subagent:',
|
|
117
172
|
allButDocs: ALL_BUT_DOCS,
|
|
118
|
-
tickedSummary: (count) => `${count} ticked · click or a lists them`,
|
|
119
|
-
tickedFold: (count) => `${count} ticked · click or a folds them`,
|
|
120
173
|
draft: 'draft · ',
|
|
121
174
|
privateLink: 'private',
|
|
122
175
|
phaseTally: (done, total) => (total > 0 ? `${done}/${total} ph` : ''),
|
|
@@ -127,26 +180,115 @@ export const TEXTS: PlanTexts = {
|
|
|
127
180
|
reviewGate: 'Review gate',
|
|
128
181
|
gate: '· gate',
|
|
129
182
|
},
|
|
130
|
-
|
|
131
|
-
`
|
|
132
|
-
|
|
183
|
+
backlog: (phases, plans) =>
|
|
184
|
+
`Backlog (${phases} ${phases === 1 ? 'phase' : 'phases'} in ${plans} ${plans === 1 ? 'plan' : 'plans'})`,
|
|
185
|
+
backlogNext: 'next:',
|
|
133
186
|
checks: {
|
|
134
187
|
[CHECKS.passed]: '✓ checks',
|
|
135
188
|
[CHECKS.failed]: '✗ checks',
|
|
136
189
|
[CHECKS.pending]: '… checks',
|
|
137
190
|
[CHECKS.none]: 'no checks',
|
|
138
191
|
},
|
|
139
|
-
hint: '↑↓ select →← open
|
|
140
|
-
showDoneNeedsPhase: 'a lists the ticked tasks inside an open phase: open one with → or a click',
|
|
192
|
+
hint: '↑↓ select →← open s html q quit ? help',
|
|
141
193
|
legend: [
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
194
|
+
{
|
|
195
|
+
title: 'Keys',
|
|
196
|
+
entries: [
|
|
197
|
+
{ mark: '↑↓ j k', meaning: 'select a row' },
|
|
198
|
+
{ mark: '→ ← enter', meaning: 'open, fold' },
|
|
199
|
+
{ mark: 'h l space', meaning: 'like ← → and enter' },
|
|
200
|
+
{ mark: 's', meaning: 'every lane and plan as one HTML page' },
|
|
201
|
+
{ mark: 'wheel PgUp PgDn', meaning: 'scroll' },
|
|
202
|
+
{ mark: 'click', meaning: 'what the footer says' },
|
|
203
|
+
{ mark: 'ctrl+click', meaning: 'open a link chip' },
|
|
204
|
+
{ mark: '? esc', meaning: 'close help' },
|
|
205
|
+
{ mark: 'q', meaning: 'quit' },
|
|
206
|
+
],
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
title: 'Lanes',
|
|
210
|
+
entries: [
|
|
211
|
+
{ mark: STATUS_SYMBOL[AGENT_STATUS.working], meaning: 'agent working' },
|
|
212
|
+
{ mark: STATUS_SYMBOL[AGENT_STATUS.blocked], meaning: 'blocked, waits for you' },
|
|
213
|
+
{ mark: STATUS_SYMBOL[AGENT_STATUS.done], meaning: 'done: the agent finished its answer' },
|
|
214
|
+
{ mark: STATUS_SYMBOL[AGENT_STATUS.idle], meaning: 'idle' },
|
|
215
|
+
{ mark: `(${NO_AGENT_SYMBOL}) ${NO_AGENT_WORD}`, meaning: 'a worktree nobody works in' },
|
|
216
|
+
{ mark: UNKNOWN_SYMBOL, meaning: 'state unknown' },
|
|
217
|
+
{ mark: 'waits on you', meaning: '· n questions: open questions of its report' },
|
|
218
|
+
{ mark: '🥣 36 %', meaning: 'context used' },
|
|
219
|
+
{ mark: '⎇ ↑ ±', meaning: 'branch, commits ahead, uncommitted' },
|
|
220
|
+
{ mark: 'n/m ph', meaning: 'phases finished' },
|
|
221
|
+
{ mark: 'stale?', meaning: `plan unchanged for ${STALE_PLAN_DAYS} days` },
|
|
222
|
+
],
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
title: 'Tasks',
|
|
226
|
+
entries: [
|
|
227
|
+
{ mark: '● teal', meaning: 'running (reported), its text shimmers' },
|
|
228
|
+
{ mark: '◌ dim', meaning: 'running (guessed)' },
|
|
229
|
+
{ mark: `${CHEVRON.closed} ${CHEVRON.open}`, meaning: 'opens / folds' },
|
|
230
|
+
{ mark: '☐ ☑', meaning: 'open, done' },
|
|
231
|
+
{ mark: '→ P9', meaning: 'moved to another phase' },
|
|
232
|
+
{ mark: 'now:', meaning: 'reported, no plan task names it' },
|
|
233
|
+
{ mark: 'Subagent:', meaning: 'a subagent on no phase' },
|
|
234
|
+
{ mark: '· gate', meaning: 'only the review gate is open' },
|
|
235
|
+
{ mark: ALL_BUT_DOCS, meaning: 'only the documentation box is open' },
|
|
236
|
+
{ mark: 'n/m', meaning: 'n of m checkboxes ticked' },
|
|
237
|
+
{ mark: PHASE_COUNT.empty, meaning: 'no checkboxes' },
|
|
238
|
+
{ mark: PHASE_COUNT.missing, meaning: 'not in the plan' },
|
|
239
|
+
{ mark: `Sonnet ${MODEL_MARK.differs} Opus`, meaning: "another model than the plan's" },
|
|
240
|
+
],
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
title: 'Your turn',
|
|
244
|
+
entries: [
|
|
245
|
+
{ mark: TURN_MARK.now, meaning: 'question, amber' },
|
|
246
|
+
{ mark: TURN_MARK.file, meaning: 'file or artifact sent' },
|
|
247
|
+
{ mark: TURN_MARK.planned, meaning: 'appointment, teal' },
|
|
248
|
+
{ mark: 'unblocks …', meaning: 'the lanes it frees' },
|
|
249
|
+
{ mark: '[ask]', meaning: 'ask the coordinator to put it' },
|
|
250
|
+
{ mark: '[done]', meaning: 'close the row' },
|
|
251
|
+
{ mark: '[run]', meaning: 'run the appointment now' },
|
|
252
|
+
{ mark: '[move]', meaning: 'ask for a new time' },
|
|
253
|
+
],
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
title: 'Sources',
|
|
257
|
+
entries: [
|
|
258
|
+
{ mark: 'amber', meaning: 'a new entry, not in Your turn' },
|
|
259
|
+
{ mark: 'red', meaning: 'the source fails' },
|
|
260
|
+
{ mark: '↑ in your turn', meaning: 'a row of Your turn asks about it' },
|
|
261
|
+
],
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
title: 'Links',
|
|
265
|
+
entries: [
|
|
266
|
+
{ mark: ARTIFACT_LINK_KIND.icon, meaning: 'artifact' },
|
|
267
|
+
{ mark: '⑂', meaning: 'pull request or issue' },
|
|
268
|
+
{ mark: '◈', meaning: 'Figma' },
|
|
269
|
+
{ mark: '↗', meaning: 'pull request' },
|
|
270
|
+
{ mark: '💬', meaning: 'new feedback' },
|
|
271
|
+
{ mark: '✋', meaning: 'changes requested' },
|
|
272
|
+
{ mark: '🌐', meaning: 'preview' },
|
|
273
|
+
{ mark: '📡 T-1', meaning: 'ticket' },
|
|
274
|
+
{ mark: '+n', meaning: 'more chips' },
|
|
275
|
+
{ mark: 'private', meaning: 'the plan says so' },
|
|
276
|
+
],
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
title: 'Colours',
|
|
280
|
+
entries: [
|
|
281
|
+
{ mark: 'bold', meaning: 'a heading: lane name, phase title' },
|
|
282
|
+
{ mark: 'plain', meaning: 'an open task' },
|
|
283
|
+
{ mark: 'dim', meaning: 'an addition: time, ▸ n done, a path' },
|
|
284
|
+
{ mark: '❯ light', meaning: 'the row under the mouse or the arrows' },
|
|
285
|
+
{ mark: 'teal', meaning: 'runs: a reported task and its ●, or planned' },
|
|
286
|
+
{ mark: 'amber', meaning: 'waits on you' },
|
|
287
|
+
{ mark: 'red', meaning: 'fails' },
|
|
288
|
+
{ mark: 'green', meaning: 'all ticked' },
|
|
289
|
+
{ mark: '[xx] bold', meaning: 'the button under the mouse' },
|
|
290
|
+
],
|
|
291
|
+
},
|
|
150
292
|
],
|
|
151
293
|
notAsked: (reason) => `no coordinator to ask: ${reason}`,
|
|
152
294
|
asked: (name, subject) => `asked ${name} for ${subject}`,
|
package/src/plans/view.ts
CHANGED
|
@@ -1,16 +1,29 @@
|
|
|
1
1
|
import { ARTIFACT_LINK_KIND } from '../config/link-kinds'
|
|
2
2
|
import { DEFAULT_TONES } from '../config/tones'
|
|
3
|
-
import { fit, plainText } from '../terminal/text'
|
|
4
|
-
import {
|
|
3
|
+
import { fit, paint, plainText, visibleLength } from '../terminal/text'
|
|
4
|
+
import { type AgentOf, clickHint } from './click-hint'
|
|
5
|
+
import { type OverviewView, renderScreen, renderSelected, type Screen } from './layout'
|
|
5
6
|
import type { Options } from './options'
|
|
7
|
+
import { blockRowByKey, boldBracketAt, type FocusHit, focusLine, isFocused, LINKS_KEY_PREFIX } from './row-focus'
|
|
6
8
|
import { laneSelection, overviewSelection, type Selection } from './selection'
|
|
9
|
+
import { longestShimmer, type ShimmerRow, shimmerAt, shimmerRows } from './shimmer'
|
|
7
10
|
import type { Snapshot } from './snapshot'
|
|
11
|
+
import { TEXTS } from './texts'
|
|
8
12
|
import { setTones } from './tones'
|
|
9
13
|
|
|
10
14
|
const DEFAULT_WIDTH = 100
|
|
11
15
|
const CURSOR_HOME = '\x1b[H'
|
|
12
16
|
const CLEAR_LINE_END = '\x1b[K'
|
|
13
17
|
const CLEAR_BELOW = '\x1b[J'
|
|
18
|
+
const SYNC_BEGIN = '\x1b[?2026h'
|
|
19
|
+
const SYNC_END = '\x1b[?2026l'
|
|
20
|
+
|
|
21
|
+
/** One frame at 60 Hz: the herd draws at most once in this time. */
|
|
22
|
+
export const FRAME_MS = 16
|
|
23
|
+
|
|
24
|
+
/** How long to wait before the next drawing, so there is at most one per `FRAME_MS`; null `lastFrameAt` before the first. */
|
|
25
|
+
export const nextFrameDelay = (lastFrameAt: number | null, now: number): number =>
|
|
26
|
+
lastFrameAt === null ? 0 : Math.max(FRAME_MS - (now - lastFrameAt), 0)
|
|
14
27
|
|
|
15
28
|
export const TERMINAL_ON = '\x1b[?1049h\x1b[?25l\x1b[?1000h\x1b[?1003h\x1b[?1006h'
|
|
16
29
|
export const TERMINAL_OFF = '\x1b[?1006l\x1b[?1003l\x1b[?1000l\x1b[?25h\x1b[?1049l'
|
|
@@ -23,52 +36,205 @@ const paneRows = (): number => process.stdout.rows ?? Number.MAX_SAFE_INTEGER
|
|
|
23
36
|
export const bodyRows = (screen: Screen): number =>
|
|
24
37
|
Math.max(paneRows() - screen.header.length - screen.footer.length, 1)
|
|
25
38
|
|
|
26
|
-
/** The time as `HH:MM`,
|
|
39
|
+
/** The time as `HH:MM`, for the refresh error; the header carries no clock, so a quiet herd draws nothing new. */
|
|
27
40
|
export const clockOf = (locale: string, now: Date = new Date()): string =>
|
|
28
41
|
now.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })
|
|
29
42
|
|
|
43
|
+
const overviewOf = (snapshot: Snapshot, shimmer: boolean): OverviewView => {
|
|
44
|
+
setTones(snapshot.tones ?? DEFAULT_TONES)
|
|
45
|
+
return {
|
|
46
|
+
title: snapshot.title,
|
|
47
|
+
lanes: snapshot.views,
|
|
48
|
+
planned: snapshot.planned,
|
|
49
|
+
watch: snapshot.watch,
|
|
50
|
+
watchesTickets: snapshot.watchesTickets,
|
|
51
|
+
awaiting: snapshot.waiting,
|
|
52
|
+
groomedDays: snapshot.groomedDays,
|
|
53
|
+
yourTurn: snapshot.yourTurn,
|
|
54
|
+
sourceBlock: snapshot.sourceBlock,
|
|
55
|
+
unplaced: snapshot.unplaced,
|
|
56
|
+
shimmer,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const withErrors = (screen: Screen, errors: string[], width: number): Screen => ({
|
|
61
|
+
...screen,
|
|
62
|
+
footer: [...errors.map((error) => fit(error, width)), ...screen.footer],
|
|
63
|
+
})
|
|
64
|
+
|
|
30
65
|
export const renderSnapshot = (
|
|
31
66
|
snapshot: Snapshot,
|
|
32
67
|
selection: Selection,
|
|
33
68
|
errors: string[],
|
|
34
69
|
width: number,
|
|
35
|
-
|
|
36
|
-
): Screen =>
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
},
|
|
53
|
-
selection,
|
|
54
|
-
width,
|
|
55
|
-
)
|
|
56
|
-
return { ...screen, footer: [...errors.map((error) => fit(error, width)), ...screen.footer] }
|
|
57
|
-
}
|
|
70
|
+
shimmer = false,
|
|
71
|
+
): Screen => withErrors(renderSelected(overviewOf(snapshot, shimmer), selection, width), errors, width)
|
|
72
|
+
|
|
73
|
+
/** The snapshot drawn with no row focused (`unfocused`), the base a hover lays its focus on (`focusedScreen`). */
|
|
74
|
+
export const renderUnfocused = (
|
|
75
|
+
snapshot: Snapshot,
|
|
76
|
+
selection: Selection,
|
|
77
|
+
errors: string[],
|
|
78
|
+
width: number,
|
|
79
|
+
shimmer = false,
|
|
80
|
+
): Screen => withErrors(renderScreen(overviewOf(snapshot, shimmer), unfocused(selection), width), errors, width)
|
|
81
|
+
|
|
82
|
+
/** The status of a lane's agent by the lane's name, for the footer's click hint. */
|
|
83
|
+
export const agentOfSnapshot =
|
|
84
|
+
({ views }: Snapshot): AgentOf =>
|
|
85
|
+
(name) =>
|
|
86
|
+
views.find(({ progress }) => progress.lane.name === name)?.status.agent ?? null
|
|
58
87
|
|
|
59
88
|
export const startSelection = (snapshot: Snapshot, options: Options): Selection =>
|
|
60
89
|
options.lane ? laneSelection(snapshot.views[0]?.progress) : overviewSelection()
|
|
61
90
|
|
|
62
|
-
/** Header and footer stay put; the body shows `
|
|
63
|
-
export const
|
|
91
|
+
/** Header and footer stay put; the body shows `rows` lines from `top`, padded so the footer sits at the bottom. */
|
|
92
|
+
export const frameLinesOf = (screen: Screen, top: number, rows: number): string[] => {
|
|
64
93
|
const body = screen.body.lines.slice(top, top + rows)
|
|
65
94
|
const padding = Array.from({ length: Math.max(rows - body.length, 0) }, () => '')
|
|
66
|
-
|
|
67
|
-
|
|
95
|
+
return [...screen.header, ...body, ...padding, ...screen.footer]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** One line as written, a shimmering text with its stripe at `now`, cut to the pane. */
|
|
99
|
+
const drawnLine = (line: string, width: number, now: number, longest: number): string =>
|
|
100
|
+
`${fit(shimmerAt(line, now, longest), width)}${CLEAR_LINE_END}`
|
|
101
|
+
|
|
102
|
+
/** `lines` written whole from the top, between the marks of synchronized output. */
|
|
103
|
+
const wholeFrame = (lines: readonly string[], width: number, now: number): string => {
|
|
104
|
+
const longest = longestShimmer(lines)
|
|
105
|
+
const drawn = lines.map((line) => drawnLine(line, width, now, longest)).join('\r\n')
|
|
106
|
+
return `${SYNC_BEGIN}${CURSOR_HOME}${drawn}${CLEAR_BELOW}${SYNC_END}`
|
|
68
107
|
}
|
|
69
108
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
109
|
+
/**
|
|
110
|
+
* The frame of `frameLinesOf` as written, a shimmering text with its stripe at `now` (epoch milliseconds). It stands
|
|
111
|
+
* between the marks of synchronized output, so a terminal that knows them never shows half of it.
|
|
112
|
+
*/
|
|
113
|
+
export const frameOutput = (screen: Screen, top: number, width: number, rows: number, now: number): string =>
|
|
114
|
+
wholeFrame(frameLinesOf(screen, top, rows), width, now)
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* What to write to turn the frame `previous` on the terminal into `lines`: the frame whole when the terminal content
|
|
118
|
+
* is unknown (null) or its height changed, else each changed line at its cursor address, nothing when none changed.
|
|
119
|
+
* Lines are compared with their shimmer marks, so a stripe that moved on is no change (the shimmer clock draws that).
|
|
120
|
+
*/
|
|
121
|
+
export const changedLinesOutput = (
|
|
122
|
+
previous: readonly string[] | null,
|
|
123
|
+
lines: readonly string[],
|
|
124
|
+
width: number,
|
|
125
|
+
now: number,
|
|
126
|
+
): string => {
|
|
127
|
+
if (previous === null || previous.length !== lines.length) return wholeFrame(lines, width, now)
|
|
128
|
+
const longest = longestShimmer(lines)
|
|
129
|
+
const changed = lines.flatMap((line, index) =>
|
|
130
|
+
line === previous[index] ? [] : [`\x1b[${index + 1};1H${drawnLine(line, width, now, longest)}`],
|
|
131
|
+
)
|
|
132
|
+
return changed.length === 0 ? '' : `${SYNC_BEGIN}${changed.join('')}${SYNC_END}`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The lines of the frame `frameOutput` writes that shimmer, by their row on the terminal. */
|
|
136
|
+
export const frameShimmerRows = (screen: Screen, top: number, rows: number): ShimmerRow[] =>
|
|
137
|
+
shimmerRows(frameLinesOf(screen, top, rows))
|
|
138
|
+
|
|
139
|
+
/** The rows that shimmer, prepared once per frame: the pass they share, and whether a row is wider than the pane. */
|
|
140
|
+
export type PreparedShimmer = {
|
|
141
|
+
rows: readonly (ShimmerRow & { wide: boolean })[]
|
|
142
|
+
longest: number
|
|
143
|
+
width: number
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export const preparedShimmer = (rows: readonly ShimmerRow[], width: number): PreparedShimmer => ({
|
|
147
|
+
rows: rows.map((row) => ({ ...row, wide: visibleLength(row.line) > width })),
|
|
148
|
+
longest: longestShimmer(rows.map(({ line }) => line)),
|
|
149
|
+
width,
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The shimmer of the prepared rows at `now`: each row rewritten at its cursor address, nothing else of the pane, in
|
|
154
|
+
* one text within the marks of synchronized output; empty without rows. Only a row wider than the pane is cut.
|
|
155
|
+
*/
|
|
156
|
+
export const shimmerOutput = ({ rows, longest, width }: PreparedShimmer, now: number): string => {
|
|
157
|
+
if (rows.length === 0) return ''
|
|
158
|
+
const drawn = rows.map(({ row, line, wide }) => {
|
|
159
|
+
const striped = shimmerAt(line, now, longest)
|
|
160
|
+
return `\x1b[${row};1H${wide ? fit(striped, width) : striped}${CLEAR_LINE_END}`
|
|
161
|
+
})
|
|
162
|
+
return `${SYNC_BEGIN}${drawn.join('')}${SYNC_END}`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A selection that focuses no row: the screen drawn with it is the base `focusedScreen` lays the focus on. */
|
|
166
|
+
export const unfocused = (selection: Selection): Selection => ({
|
|
167
|
+
...selection,
|
|
168
|
+
selected: NO_ROW,
|
|
169
|
+
hovered: null,
|
|
170
|
+
hoveredFrom: null,
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
const NO_ROW = '\0'
|
|
174
|
+
const LEAD = /^ */
|
|
175
|
+
|
|
176
|
+
/** The blanks a block puts before a row it holds (`indented`): the focus lies right of them, as narrow as the row. */
|
|
177
|
+
const leadOf = (line: string): number => LEAD.exec(line)?.[0].length ?? 0
|
|
178
|
+
|
|
179
|
+
/** `lines` with the lines of `hit` (counted from `offset`) changed by `change`, the first line told by its index. */
|
|
180
|
+
const changedHit = (
|
|
181
|
+
lines: readonly string[],
|
|
182
|
+
offset: number,
|
|
183
|
+
hit: FocusHit | undefined,
|
|
184
|
+
change: (line: string, index: number) => string,
|
|
185
|
+
): string[] =>
|
|
186
|
+
lines.map((line, at) => {
|
|
187
|
+
const index = hit ? at + offset - hit.line : -1
|
|
188
|
+
return hit && index >= 0 && index < hit.lines ? change(line, index) : line
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* `screen` with the focus `state` gives laid on one region of `lines`: the focused row on the focus background and
|
|
193
|
+
* mark, a line of links under the mouse marked alone, and the square-bracket button under the mouse bold.
|
|
194
|
+
*/
|
|
195
|
+
const focusedLines = (lines: readonly string[], offset: number, screen: Screen, state: Selection, width: number) => {
|
|
196
|
+
const hitOf = (key: string | null | undefined) => (key ? screen.focusHits.find((hit) => hit.key === key) : undefined)
|
|
197
|
+
const hovered = hitOf(state.hovered)
|
|
198
|
+
const focusedKey = state.selected !== null && isFocused(state, state.selected) ? state.selected : null
|
|
199
|
+
const focusedHit = hitOf(focusedKey)
|
|
200
|
+
const gutter = focusedHit ? leadOf(lines[focusedHit.line - offset] ?? '') : 0
|
|
201
|
+
const focused = changedHit(
|
|
202
|
+
lines,
|
|
203
|
+
offset,
|
|
204
|
+
focusedHit,
|
|
205
|
+
(line, index) => `${line.slice(0, gutter)}${focusLine(line.slice(gutter), width - gutter, index === 0)}`,
|
|
206
|
+
)
|
|
207
|
+
const links = state.hovered?.startsWith(LINKS_KEY_PREFIX)
|
|
208
|
+
? changedHit(focused, offset, hovered, (line, index) => (index === 0 ? focusLine(line, width, true) : line))
|
|
209
|
+
: focused
|
|
210
|
+
const from = state.hoveredFrom
|
|
211
|
+
return from == null
|
|
212
|
+
? links
|
|
213
|
+
: changedHit(links, offset, hovered, (line, index) => (index === 0 ? boldBracketAt(line, from) : line))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The screen a full drawing with `state` gives, laid on `plain` (drawn with `unfocused`) without a new layout: a hover
|
|
218
|
+
* only moves the focus. Null where it needs the layout: with the help open, or for a row of the blocks on top, whose
|
|
219
|
+
* buttons stand only at the selection. `agentOf` says a lane's agent, for the footer's click hint.
|
|
220
|
+
*/
|
|
221
|
+
export const focusedScreen = (plain: Screen, state: Selection, width: number, agentOf: AgentOf): Screen | null => {
|
|
222
|
+
const blocks = [plain.yourTurnBlock, plain.sourceBlock]
|
|
223
|
+
const isBlockRow = (key: string | null | undefined) => !!key && blockRowByKey(blocks, key) !== null
|
|
224
|
+
if (state.showLegend || isBlockRow(state.selected) || isBlockRow(state.hovered)) return null
|
|
225
|
+
const header = focusedLines(plain.header, 0, plain, state, width)
|
|
226
|
+
const lines = focusedLines(plain.body.lines, plain.header.length, plain, state, width)
|
|
227
|
+
const selectedHit = plain.focusHits.find(({ key }) => key === state.selected)
|
|
228
|
+
const cursorLine =
|
|
229
|
+
selectedHit && selectedHit.line >= plain.header.length ? selectedHit.line - plain.header.length : -1
|
|
230
|
+
const hint = clickHint(state, (key) => blockRowByKey(blocks, key), agentOf) ?? TEXTS.hint
|
|
231
|
+
return {
|
|
232
|
+
...plain,
|
|
233
|
+
header,
|
|
234
|
+
body: { ...plain.body, lines, cursorLine },
|
|
235
|
+
footer: [...plain.footer.slice(0, -1), fit(paint.dim(hint), width)],
|
|
236
|
+
}
|
|
237
|
+
}
|
|
72
238
|
|
|
73
239
|
const ARTIFACT_ADDRESS = new RegExp(ARTIFACT_LINK_KIND.pattern)
|
|
74
240
|
|