jackherd 1.10.0 → 1.11.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 +26 -0
- package/README.md +24 -16
- package/dist/{chunk-5MQBCUQ5.js → chunk-RMMXD4G5.js} +9 -2
- package/dist/config.d.ts +60 -0
- package/dist/config.js +39 -4
- package/dist/plan-format.d.ts +7 -1
- package/dist/plan-format.js +4 -2
- package/docs/follow-dark.svg +48 -0
- package/docs/follow.svg +48 -0
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/open-question/run.ts +8 -3
- package/kit/rules/rules/kit/pull-requests.md +2 -0
- package/kit/workflow/skills/follow/SKILL.md +3 -3
- package/kit/workflow/skills/orchestrate/SKILL.md +11 -3
- package/package.json +2 -2
- package/recipes/sources.md +4 -0
- package/schemas/jackherd.schema.json +80 -0
- package/src/cli.ts +1 -0
- package/src/config/schema.ts +24 -1
- package/src/config/tones.ts +27 -0
- package/src/lane-prompt/additions.ts +14 -0
- package/src/lane-prompt/command.ts +8 -2
- package/src/lane-prompt/input.ts +44 -3
- package/src/lane-prompt/options.ts +2 -0
- package/src/mermaid/command.ts +36 -0
- package/src/mermaid/problems.ts +48 -0
- package/src/plan-format/dependencies.ts +2 -2
- package/src/plan-format/waiting.ts +15 -2
- package/src/plans/appointment-rows.ts +34 -10
- package/src/plans/ask-coordinator.ts +46 -12
- package/src/plans/ask-target.ts +13 -2
- package/src/plans/board-close.ts +85 -0
- package/src/plans/frame.ts +5 -12
- package/src/plans/git.ts +35 -0
- package/src/plans/groomed.ts +27 -0
- package/src/plans/layout-phase.ts +7 -4
- package/src/plans/layout.ts +258 -48
- package/src/plans/link-chips.ts +22 -4
- package/src/plans/live.ts +73 -5
- package/src/plans/next-rows.ts +44 -0
- package/src/plans/planned.ts +22 -0
- package/src/plans/row-actions.ts +52 -0
- package/src/plans/row-focus.ts +148 -0
- package/src/plans/row-targets.ts +7 -0
- package/src/plans/selection.ts +14 -1
- package/src/plans/settings.ts +10 -0
- package/src/plans/snapshot.ts +79 -21
- package/src/plans/source-asked.ts +19 -0
- package/src/plans/source-prefix.ts +17 -0
- package/src/plans/source-rows.ts +188 -0
- package/src/plans/texts.ts +28 -3
- package/src/plans/tones.ts +30 -0
- package/src/plans/view.ts +8 -2
- package/src/plans/waiting-click.ts +129 -24
- package/src/terminal/text.ts +8 -0
- package/src/terminal/types.ts +4 -0
- package/src/vocabulary/index.ts +10 -0
- package/src/watch/plan-badges-read.ts +9 -2
- package/src/watch/plan-badges.ts +10 -2
- package/src/watch/report-questions.ts +4 -3
- package/src/watch/source-entries.ts +55 -0
- package/src/watch/source-fields.ts +1 -0
- package/src/watch/source-statuses.ts +60 -0
- package/src/watch/source-tickets.ts +21 -1
- package/src/watch/sources.ts +41 -1
package/src/plans/layout.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { TONE_ROLE } from '../config/tones'
|
|
1
2
|
import { CONTEXT_LEVEL, type ContextLevel, type ContextReading, contextLabel } from '../context'
|
|
2
3
|
import type { LaneProgress, PhaseProgress } from '../plan-format'
|
|
3
4
|
import { currentPhase, isPhaseOpen, phaseLabel } from '../plan-format'
|
|
@@ -8,6 +9,7 @@ import {
|
|
|
8
9
|
badge,
|
|
9
10
|
bar,
|
|
10
11
|
box,
|
|
12
|
+
bracket,
|
|
11
13
|
fit,
|
|
12
14
|
link,
|
|
13
15
|
packGroups,
|
|
@@ -17,7 +19,7 @@ import {
|
|
|
17
19
|
visibleLength,
|
|
18
20
|
wrap,
|
|
19
21
|
} from '../terminal/text'
|
|
20
|
-
import { AGENT_STATUS, type AgentStatus, isAgentStatus } from '../terminal/types'
|
|
22
|
+
import { AGENT_STATUS, type AgentStatus, isAgentStatus, isRestingAgent } from '../terminal/types'
|
|
21
23
|
import { textWidth } from '../terminal/width'
|
|
22
24
|
import {
|
|
23
25
|
NO_BADGES,
|
|
@@ -30,6 +32,7 @@ import {
|
|
|
30
32
|
import type { UnplannedTask } from './active-tasks'
|
|
31
33
|
import { type AppointmentBlock, appointmentItems, appointmentKeys } from './appointment-rows'
|
|
32
34
|
import { deeper, type Frame, joinFrames, nest, prefixed, rowFrame, TREE, textFrame } from './frame'
|
|
35
|
+
import { groomedText } from './groomed'
|
|
33
36
|
import { LANE_STANDING, type LaneStanding, laneStanding, waitingNote } from './lane-standing'
|
|
34
37
|
import { type LaneSummary, laneSummary } from './lane-summary'
|
|
35
38
|
import { type LaneTickets, withoutTicketKey } from './lane-tickets'
|
|
@@ -44,9 +47,20 @@ import {
|
|
|
44
47
|
} from './layout-phase'
|
|
45
48
|
import { foldedTaskLine, summaryChildren } from './layout-summary'
|
|
46
49
|
import { chipKey, type LinkChip, planUrlOf } from './link-chips'
|
|
50
|
+
import { type NextBlock, nextItems, nextKeys } from './next-rows'
|
|
47
51
|
import type { PhaseModel } from './phase-models'
|
|
48
|
-
import { type PlannedPlan,
|
|
52
|
+
import { type PlannedPlan, plannedNextLabel } from './planned'
|
|
49
53
|
import { CHECKS, type ChecksState, failedTestKey, type PullRequest, pullRequestKey } from './pull-requests'
|
|
54
|
+
import {
|
|
55
|
+
boldBracketAt,
|
|
56
|
+
buttonsAllBold,
|
|
57
|
+
type FocusHit,
|
|
58
|
+
focusHitsOfBlock,
|
|
59
|
+
focusHitsOfTargets,
|
|
60
|
+
focusLine,
|
|
61
|
+
isFocused,
|
|
62
|
+
LINKS_KEY_PREFIX,
|
|
63
|
+
} from './row-focus'
|
|
50
64
|
import {
|
|
51
65
|
type ClickTarget,
|
|
52
66
|
isBlockFolded,
|
|
@@ -61,16 +75,22 @@ import {
|
|
|
61
75
|
} from './selection'
|
|
62
76
|
import type { SentChip } from './sent-files'
|
|
63
77
|
import { shownWithMore } from './shown-with-more'
|
|
78
|
+
import { askedCheckOf } from './source-asked'
|
|
79
|
+
import { type SourceBlock, sourceItems, sourceKeys } from './source-rows'
|
|
64
80
|
import { chevronOf, LESS_MARK, NO_AGENT_SYMBOL, statusSymbol, statusWord } from './symbols'
|
|
65
81
|
import { BADGE_TEXTS, type BadgeTexts, checksText, laneMarkText, TEXTS } from './texts'
|
|
82
|
+
import { paintOfRole, paintOfTone, toneOfRole } from './tones'
|
|
66
83
|
import {
|
|
67
84
|
APPOINTMENT_MORE_KEY,
|
|
68
|
-
|
|
85
|
+
allBlockRows,
|
|
69
86
|
type BlockHit,
|
|
70
87
|
type BlockRow,
|
|
71
88
|
blockRows,
|
|
89
|
+
buttonsText,
|
|
72
90
|
moreKeyOf,
|
|
91
|
+
NEXT_MORE_KEY,
|
|
73
92
|
SHOWN_BLOCK_ROWS,
|
|
93
|
+
SOURCE_MORE_KEY,
|
|
74
94
|
WAITING_MARK,
|
|
75
95
|
WAITING_MORE_KEY,
|
|
76
96
|
waitingKeys,
|
|
@@ -108,6 +128,8 @@ export type LaneView = {
|
|
|
108
128
|
models?: Record<string, PhaseModel>
|
|
109
129
|
/** The files this lane's session sent to the user, shown as badges on the row of link chips. */
|
|
110
130
|
sentFiles?: SentChip[]
|
|
131
|
+
/** What a resting lane waits on, from its report (`restNote`); absent or null while it works or asks nothing. */
|
|
132
|
+
restNote?: string | null
|
|
111
133
|
}
|
|
112
134
|
|
|
113
135
|
/** `watch` is the health of the watcher the badges come from, absent when it was not read. */
|
|
@@ -121,8 +143,14 @@ export type OverviewView = {
|
|
|
121
143
|
watchesTickets?: boolean
|
|
122
144
|
/** The open items of the board's section of what waits on the user, shown under the title. */
|
|
123
145
|
awaiting?: WaitingItem[]
|
|
124
|
-
/** The
|
|
146
|
+
/** The board's next section, in a block of its own under the for-you block; absent for a herd without a board. */
|
|
147
|
+
next?: NextBlock
|
|
148
|
+
/** The open appointments of the board, in a block of their own under the next block. */
|
|
125
149
|
appointments?: AppointmentBlock
|
|
150
|
+
/** The sources the watcher runs that are not tickets, in a block of their own under the appointments. */
|
|
151
|
+
sourceBlock?: SourceBlock
|
|
152
|
+
/** Whole days since the board was groomed (`groomedDaysAgo`), beside the clock; null or absent when unknown. */
|
|
153
|
+
groomedDays?: number | null
|
|
126
154
|
/** What agents report doing that no lane takes, one `now:` line each under the title. */
|
|
127
155
|
unplaced?: UnplannedTask[]
|
|
128
156
|
}
|
|
@@ -137,7 +165,13 @@ export type Screen = {
|
|
|
137
165
|
body: Frame
|
|
138
166
|
footer: string[]
|
|
139
167
|
waitingBlock: BlockHit | null
|
|
168
|
+
nextBlock: BlockHit | null
|
|
140
169
|
appointmentBlock: BlockHit | null
|
|
170
|
+
sourceBlock: BlockHit | null
|
|
171
|
+
/** Every clickable row by the screen line it starts on (header lines first, then the body unscrolled): what the mouse and the keys land on. */
|
|
172
|
+
focusHits: FocusHit[]
|
|
173
|
+
/** The keys of the rows the arrows walk, in screen order: the rows of the blocks on top, then those of the body. */
|
|
174
|
+
rowKeys: string[]
|
|
141
175
|
}
|
|
142
176
|
|
|
143
177
|
/**
|
|
@@ -162,6 +196,12 @@ type TopBlock = {
|
|
|
162
196
|
name: string
|
|
163
197
|
bar: () => string
|
|
164
198
|
markPaint: (text: string) => string
|
|
199
|
+
/** The paint of the buttons of a row: amber and bold in "Waiting for you", the normal foreground colour elsewhere. */
|
|
200
|
+
buttonPaint: (text: string) => string
|
|
201
|
+
/** Whether a row is the selected one or under the mouse: it gets the background, the mark, and bold buttons. */
|
|
202
|
+
focused: (key: string) => boolean
|
|
203
|
+
/** Whether all buttons of a row are bold: the arrows selected it and the mouse is not on it (the mouse bolds one). */
|
|
204
|
+
allBold: (key: string) => boolean
|
|
165
205
|
/** The paint of the count in the head while the block has points: amber for what waits on the user. */
|
|
166
206
|
countPaint: (text: string) => string
|
|
167
207
|
moreKey: string
|
|
@@ -207,12 +247,10 @@ const TEST_PATH_LEVEL = ' > '
|
|
|
207
247
|
const TEST_PATH_SEPARATOR = ' › '
|
|
208
248
|
const PLACE_SEPARATOR = ' · '
|
|
209
249
|
|
|
210
|
-
/** The colour of a lane state, word and symbol alike:
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
[AGENT_STATUS.done]: paint.teal,
|
|
215
|
-
[AGENT_STATUS.idle]: paint.dim,
|
|
250
|
+
/** The colour of a lane state, word and symbol alike: the tone of `running` while working or done, bold `waiting` when it waits for you, `resting` when idle. */
|
|
251
|
+
const statusPaint = (status: AgentStatus): ((text: string) => string) => {
|
|
252
|
+
if (status === AGENT_STATUS.blocked) return (text) => paint.bold(paintOfRole(TONE_ROLE.waiting)(text))
|
|
253
|
+
return status === AGENT_STATUS.idle ? paintOfRole(TONE_ROLE.resting) : paintOfRole(TONE_ROLE.running)
|
|
216
254
|
}
|
|
217
255
|
|
|
218
256
|
const CHECKS_PAINT: Record<ChecksState, (text: string) => string> = {
|
|
@@ -224,13 +262,13 @@ const CHECKS_PAINT: Record<ChecksState, (text: string) => string> = {
|
|
|
224
262
|
|
|
225
263
|
/** Only a blocked agent is named in the lane line, since there the user must act; the symbol says the rest. */
|
|
226
264
|
const agentLabel = (agent: string | null): string =>
|
|
227
|
-
agent === AGENT_STATUS.blocked ?
|
|
265
|
+
agent === AGENT_STATUS.blocked ? statusPaint(AGENT_STATUS.blocked)(statusWord(agent)) : ''
|
|
228
266
|
|
|
229
267
|
/** The symbol of a status padded to `SYMBOL_WIDTH` cells, so the names after it start in one column. */
|
|
230
268
|
const symbolCell = (agent: string | null): string => {
|
|
231
269
|
const symbol = statusSymbol(agent)
|
|
232
270
|
return padVisible(
|
|
233
|
-
agent !== null && isAgentStatus(agent) ?
|
|
271
|
+
agent !== null && isAgentStatus(agent) ? statusPaint(agent)(symbol) : paint.dim(symbol),
|
|
234
272
|
SYMBOL_WIDTH,
|
|
235
273
|
)
|
|
236
274
|
}
|
|
@@ -286,9 +324,9 @@ export const rowWithCount = (
|
|
|
286
324
|
const fitsBeside = (head: string, cells: BadgeCell[], width: number): boolean =>
|
|
287
325
|
visibleLength(head) + packedWidth(cells) + COLUMN_SEPARATOR.length + MIN_TITLE_WIDTH <= width
|
|
288
326
|
|
|
289
|
-
/** The number of a pull request as a
|
|
327
|
+
/** The number of a pull request as a bracket button linked to it, padded to its column. */
|
|
290
328
|
const numberCell = (pullRequest: PullRequest): string =>
|
|
291
|
-
padVisible(
|
|
329
|
+
padVisible(bracket(pullRequest.url, `↗ #${pullRequest.number}`), PULL_REQUEST_BADGE_WIDTH)
|
|
292
330
|
|
|
293
331
|
/** The column the check chip of a pull request row starts in, behind its number. */
|
|
294
332
|
const checksColumnOf = (pullRequest: PullRequest): number =>
|
|
@@ -432,16 +470,18 @@ const progressColumn = ({ done, total, phasesDone, phases }: LaneSummary['tally'
|
|
|
432
470
|
return `${counts}${paint.dim(phaseCount)}`
|
|
433
471
|
}
|
|
434
472
|
|
|
435
|
-
const CHIP_GAP = '
|
|
473
|
+
const CHIP_GAP = ' '
|
|
474
|
+
/** The most cells the label of a link chip takes in its bracket; a longer title is cut with an ellipsis. */
|
|
475
|
+
const CHIP_LABEL_WIDTH = 24
|
|
436
476
|
|
|
437
477
|
const TICKET_MARK = '📡'
|
|
438
478
|
const STATUS_SEPARATOR = ' · '
|
|
439
479
|
/** Between the checkbox count and the phase count of a lane. */
|
|
440
480
|
const TALLY_SEPARATOR = ' · '
|
|
441
481
|
|
|
442
|
-
/**
|
|
482
|
+
/** `[✦ label]` linked to the chip's address, `private` faint inside it for a link its plan says is private. */
|
|
443
483
|
const chipCell = ({ url, icon, label, private: hidden }: LinkChip): string =>
|
|
444
|
-
|
|
484
|
+
bracket(url, `${icon} ${fit(plain(label), CHIP_LABEL_WIDTH)}`, hidden ? TEXTS.privateLink : '')
|
|
445
485
|
|
|
446
486
|
/** A badge and the key a click on it opens; null for a cell no click opens. */
|
|
447
487
|
type BadgeCell = { cell: string; key: string | null }
|
|
@@ -485,9 +525,11 @@ const packedFrame = (cells: BadgeCell[], lead: string, width: number): Frame =>
|
|
|
485
525
|
|
|
486
526
|
const ticketTone = (isNew: boolean): BadgeTone => (isNew ? BADGE_TONE.amber : BADGE_TONE.grey)
|
|
487
527
|
|
|
488
|
-
/** A
|
|
489
|
-
const linkedBox = (url: string | null, text: string, tone: BadgeTone): string =>
|
|
490
|
-
|
|
528
|
+
/** A bracket button linked to `url` where nothing is new, a badge in the tone of what is, a box no click opens without `url`. */
|
|
529
|
+
const linkedBox = (url: string | null, text: string, tone: BadgeTone): string => {
|
|
530
|
+
if (!url) return box(text, tone)
|
|
531
|
+
return tone === BADGE_TONE.grey ? bracket(url, text) : badge(url, text, '', tone)
|
|
532
|
+
}
|
|
491
533
|
|
|
492
534
|
/** `📡 T-1 · In Progress` linked to the ticket, amber while new; a ticket without URL is a box no click opens. */
|
|
493
535
|
const ticketCell = ({ id, url, status, isNew }: TicketBadge): BadgeCell => {
|
|
@@ -504,7 +546,7 @@ const ticketCells = (tickets: LaneTickets | null): BadgeCell[] => {
|
|
|
504
546
|
|
|
505
547
|
/** A file the lane's session sent to the user, `📎 name` linked by its `file:` address; the terminal opens it, no click of ours does. */
|
|
506
548
|
const sentCell = ({ url, label }: SentChip): BadgeCell => ({
|
|
507
|
-
cell:
|
|
549
|
+
cell: bracket(url, `${SENT_MARK} ${plain(label)}`),
|
|
508
550
|
key: null,
|
|
509
551
|
})
|
|
510
552
|
|
|
@@ -537,7 +579,7 @@ const badgeCells = (
|
|
|
537
579
|
key: chipKey(pullRequest.url),
|
|
538
580
|
})
|
|
539
581
|
return [
|
|
540
|
-
...(badges.preview ? [{ cell:
|
|
582
|
+
...(badges.preview ? [{ cell: bracket(badges.preview, texts.preview), key: chipKey(badges.preview) }] : []),
|
|
541
583
|
...(badges.feedback > 0 ? [onPullRequest(texts.feedback(badges.feedback), BADGE_TONE.amber)] : []),
|
|
542
584
|
...(badges.changesRequested ? [onPullRequest(texts.changesRequested, BADGE_TONE.red)] : []),
|
|
543
585
|
]
|
|
@@ -573,7 +615,7 @@ const pullRequestMark = (pullRequest: PullRequest, badges: WatchBadges): string
|
|
|
573
615
|
const found = badges[String(pullRequest.number)] ?? NO_BADGES
|
|
574
616
|
const feedback = found.feedback > 0 ? ` 💬 ${found.feedback}` : ''
|
|
575
617
|
const text = `↗ #${pullRequest.number}${feedback}${found.changesRequested ? ' ✋' : ''}`
|
|
576
|
-
return
|
|
618
|
+
return linkedBox(pullRequest.url, text, markTone(found))
|
|
577
619
|
}
|
|
578
620
|
|
|
579
621
|
/** `📡 <n>` in amber while tickets of the lane are new, linked to the first of them; nothing otherwise. */
|
|
@@ -598,11 +640,35 @@ const foldedPhaseLabel = (view: LaneView): string => {
|
|
|
598
640
|
return `${nextPhaseLabel(view)}${more > 0 ? ` +${more}` : ''} · ${phase.done}/${phase.total}`
|
|
599
641
|
}
|
|
600
642
|
|
|
643
|
+
/** A lane whose agent rests (idle or done): the folded line says what it waits on instead of the phase and the bar. */
|
|
644
|
+
const restsFolded = ({ status }: LaneView, open: boolean): boolean => !open && isRestingAgent(status.agent)
|
|
645
|
+
|
|
646
|
+
/** The report's `waits on: <question>` split at its label, so the label can sit dim and the reason bright. */
|
|
647
|
+
const noteParts = (note: string): { label: string; reason: string } => {
|
|
648
|
+
const split = note.indexOf(': ')
|
|
649
|
+
return split === -1 ? { label: '', reason: note } : { label: note.slice(0, split + 1), reason: note.slice(split + 2) }
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Why a resting lane rests: the open question of its report (the watcher's `waits on:` line), else the starts-after
|
|
654
|
+
* cell of the board (`after: …`), else the plain fact that nothing is known, which is meant to stand out.
|
|
655
|
+
*/
|
|
656
|
+
const restDetail = (view: LaneView): string => {
|
|
657
|
+
const wait = waitingNote(view.progress.lane)
|
|
658
|
+
const { label, reason } = view.restNote
|
|
659
|
+
? noteParts(view.restNote)
|
|
660
|
+
: wait !== null
|
|
661
|
+
? { label: TEXTS.restAfter, reason: plain(wait) }
|
|
662
|
+
: { label: '', reason: TEXTS.restNoReason }
|
|
663
|
+
return label ? `${paint.dim(label)} ${reason}` : reason
|
|
664
|
+
}
|
|
665
|
+
|
|
601
666
|
/** What follows the status on a lane line: the marker and, folded only, the phase it works on; the branch is on the session line. */
|
|
602
667
|
const laneDetail = (view: LaneView, open: boolean): string => {
|
|
603
668
|
const markText = laneMarkText(view)
|
|
604
669
|
const marker = markText ? paint.yellow(markText) : ''
|
|
605
|
-
|
|
670
|
+
const phase = restsFolded(view, open) ? restDetail(view) : open ? '' : foldedPhaseLabel(view)
|
|
671
|
+
return [agentLabel(view.status.agent), marker, phase].filter(Boolean).join(' ')
|
|
606
672
|
}
|
|
607
673
|
|
|
608
674
|
/**
|
|
@@ -635,7 +701,7 @@ const laneProgressColumn = (view: LaneView, tallyWidth: number): string => {
|
|
|
635
701
|
|
|
636
702
|
/** Context and bar at the right edge of a folded lane line, as wide as the lane has them. */
|
|
637
703
|
const laneRight = (view: LaneView, tallyWidth: number): string => {
|
|
638
|
-
const counts = laneProgressColumn(view, tallyWidth)
|
|
704
|
+
const counts = restsFolded(view, false) ? '' : laneProgressColumn(view, tallyWidth)
|
|
639
705
|
return counts ? `${contextCell(view.context)}${counts}` : contextCell(view.context).trimEnd()
|
|
640
706
|
}
|
|
641
707
|
|
|
@@ -673,7 +739,8 @@ const foldedTaskRow = (view: LaneView, indent: string, width: number): string[]
|
|
|
673
739
|
|
|
674
740
|
const laneLines = (view: LaneView, open: boolean, width: number, columns: LaneColumns): string[] => {
|
|
675
741
|
const { progress, status } = view
|
|
676
|
-
const
|
|
742
|
+
const nameStyle = restsFolded(view, open) ? paint.dim : paint.bold
|
|
743
|
+
const cells = padVisible(nameStyle(fit(progress.lane.name, columns.name)), columns.name)
|
|
677
744
|
const head = `${paint.dim(chevronOf(open))} ${symbolCell(status.agent)} ${cells}`
|
|
678
745
|
const right = open ? laneProgressColumn(view, tallyWidthOf(columns)) : markedRight(view, columns)
|
|
679
746
|
const nameColumn = visibleLength(head) - visibleLength(cells)
|
|
@@ -830,9 +897,9 @@ const parkedBlock = (lanes: LaneView[], width: number): string[] => {
|
|
|
830
897
|
*/
|
|
831
898
|
const plannedFrame = (planned: PlannedPlan[], state: Selection, width: number): Frame => {
|
|
832
899
|
if (planned.length === 0) return textFrame([])
|
|
833
|
-
const phases = plannedPhaseCount(planned)
|
|
834
900
|
const open = state.expanded.has(PLANNED_KEY)
|
|
835
|
-
const
|
|
901
|
+
const next = planned.map((plan) => `${plain(plan.title)} ${plannedNextLabel(plan)}`).join(', ')
|
|
902
|
+
const head = [paint.dim(fit(`${chevronOf(open)} ${TEXTS.plannedHead(planned.length)} ${next}`, width))]
|
|
836
903
|
return rowFrame(PLANNED_KEY, head, open ? plannedRows(planned, width, CONTENT_INDENT).flat() : [], { state, width })
|
|
837
904
|
}
|
|
838
905
|
|
|
@@ -841,7 +908,7 @@ const plannedRows = (planned: PlannedPlan[], width: number, indent: string): str
|
|
|
841
908
|
planned.map((plan) =>
|
|
842
909
|
rowWithCount(
|
|
843
910
|
indent,
|
|
844
|
-
`${plain(plan.title)} · ${
|
|
911
|
+
`${plain(plan.title)} · ${plannedNextLabel(plan)}`,
|
|
845
912
|
countCell('', countText(plan.done, plan.total)),
|
|
846
913
|
width,
|
|
847
914
|
paint.dim,
|
|
@@ -851,7 +918,7 @@ const plannedRows = (planned: PlannedPlan[], width: number, indent: string): str
|
|
|
851
918
|
const rule = (width: number): string => paint.dim(RULE.repeat(width))
|
|
852
919
|
|
|
853
920
|
/** The amber bar that starts every line of the for-you block: the one signal that this waits on the user. */
|
|
854
|
-
const waitingBar = (): string =>
|
|
921
|
+
const waitingBar = (): string => statusPaint(AGENT_STATUS.blocked)(WAITING_MARK)
|
|
855
922
|
|
|
856
923
|
/** The grey bar of the appointment block: it waits on nobody's answer. */
|
|
857
924
|
const appointmentBar = (): string => paint.dim(WAITING_MARK)
|
|
@@ -866,8 +933,10 @@ const blockHead = (name: string, count: number, width: number, countPaint: (text
|
|
|
866
933
|
return `${lead}${countPaint(String(count))} ${paint.dim(RULE.repeat(Math.max(width - used, 0)))}`
|
|
867
934
|
}
|
|
868
935
|
|
|
869
|
-
const markedLine = (line: string, row: BlockRow, markPaint: (text: string) => string): string =>
|
|
870
|
-
|
|
936
|
+
const markedLine = (line: string, row: BlockRow, markPaint: (text: string) => string): string => {
|
|
937
|
+
const paintMark = row.tone ? paintOfTone(row.tone) : markPaint
|
|
938
|
+
return line.startsWith(row.mark) ? `${paintMark(row.mark)}${line.slice(row.mark.length)}` : line
|
|
939
|
+
}
|
|
871
940
|
|
|
872
941
|
/** The text from `from` on dimmed, without the blanks that pad it to the right edge. */
|
|
873
942
|
const dimmedRest = (text: string, from: number): string => {
|
|
@@ -880,14 +949,23 @@ const dimmedRest = (text: string, from: number): string => {
|
|
|
880
949
|
* and the rest of an expanded row dim, linked when the row has an address; the `[ask]` button at the end of a
|
|
881
950
|
* question's first line wears the mark's paint.
|
|
882
951
|
*/
|
|
883
|
-
const blockRowLines = (
|
|
884
|
-
|
|
885
|
-
|
|
952
|
+
const blockRowLines = (
|
|
953
|
+
{ bar, markPaint, buttonPaint, focused, allBold }: TopBlock,
|
|
954
|
+
row: BlockRow,
|
|
955
|
+
width: number,
|
|
956
|
+
): string[] => {
|
|
957
|
+
const hot = focused(row.key)
|
|
958
|
+
const paintButton = (text: string) => (allBold(row.key) ? paint.bold(buttonPaint(text)) : buttonPaint(text))
|
|
959
|
+
return row.lines.map((line, index) => {
|
|
960
|
+
const withButton = index === 0 && row.buttons.length > 0
|
|
886
961
|
const styled = dimmedRest(line, row.dimFrom[index] ?? line.length)
|
|
887
962
|
const marked = index === 0 ? markedLine(styled, row, markPaint) : styled
|
|
888
|
-
const
|
|
889
|
-
|
|
963
|
+
const shown = row.url ? link(row.url, marked) : marked
|
|
964
|
+
const buttons = withButton ? buttonsText(row.buttons, row.dim ? paint.dim : paintButton) : ''
|
|
965
|
+
const drawn = `${bar()} ${shown}${buttons}`
|
|
966
|
+
return hot ? focusLine(drawn, width, index === 0) : drawn
|
|
890
967
|
})
|
|
968
|
+
}
|
|
891
969
|
|
|
892
970
|
/** A folded block: the one line `▸ <name> · <n>` and the first title as a preview; a click matches no row and no `+<n>` line. */
|
|
893
971
|
const foldedBlockLines = ({ name, keys, rows }: TopBlock, width: number): BlockLines => {
|
|
@@ -913,7 +991,7 @@ const openBlockLines = (block: TopBlock, width: number): BlockLines => {
|
|
|
913
991
|
open: true,
|
|
914
992
|
lines: [
|
|
915
993
|
blockHead(block.name, count, width, count > 0 ? block.countPaint : (text) => text),
|
|
916
|
-
...block.rows.flatMap((row) => blockRowLines(block, row)),
|
|
994
|
+
...block.rows.flatMap((row) => blockRowLines(block, row, width)),
|
|
917
995
|
...(more > 0 ? [`${block.bar()} ${moreLine}`] : []),
|
|
918
996
|
],
|
|
919
997
|
hit: { rows: block.rows, moreKey: moreKeyOf(count, block.moreKey), keys: block.keys },
|
|
@@ -933,8 +1011,11 @@ const waitingLines = (waiting: readonly WaitingItem[], state: Selection, width:
|
|
|
933
1011
|
const block: TopBlock = {
|
|
934
1012
|
name: TEXTS.waitingHeading,
|
|
935
1013
|
bar: waitingBar,
|
|
936
|
-
markPaint:
|
|
937
|
-
|
|
1014
|
+
markPaint: statusPaint(AGENT_STATUS.blocked),
|
|
1015
|
+
buttonPaint: statusPaint(AGENT_STATUS.blocked),
|
|
1016
|
+
focused: (key) => isFocused(state, key),
|
|
1017
|
+
allBold: (key) => buttonsAllBold(state, key),
|
|
1018
|
+
countPaint: statusPaint(AGENT_STATUS.blocked),
|
|
938
1019
|
moreKey: WAITING_MORE_KEY,
|
|
939
1020
|
expanded: state.expanded.has(WAITING_MORE_KEY),
|
|
940
1021
|
keys,
|
|
@@ -950,6 +1031,9 @@ const appointmentLines = ({ rows }: AppointmentBlock, state: Selection, width: n
|
|
|
950
1031
|
name: TEXTS.appointmentHeading,
|
|
951
1032
|
bar: appointmentBar,
|
|
952
1033
|
markPaint: paint.dim,
|
|
1034
|
+
buttonPaint: (text) => text,
|
|
1035
|
+
focused: (key) => isFocused(state, key),
|
|
1036
|
+
allBold: (key) => buttonsAllBold(state, key),
|
|
953
1037
|
countPaint: (text) => text,
|
|
954
1038
|
moreKey: APPOINTMENT_MORE_KEY,
|
|
955
1039
|
expanded: state.expanded.has(APPOINTMENT_MORE_KEY),
|
|
@@ -959,6 +1043,68 @@ const appointmentLines = ({ rows }: AppointmentBlock, state: Selection, width: n
|
|
|
959
1043
|
return blockLinesOf(block, state, TOP_BLOCK.appointments, width)
|
|
960
1044
|
}
|
|
961
1045
|
|
|
1046
|
+
/** The bar of the next block: a blank, since it asks nothing of the user and only its numbers lead the rows. */
|
|
1047
|
+
const nextBar = (): string => ' '
|
|
1048
|
+
|
|
1049
|
+
/** The next block names nothing queued in one dim line, which has no rows to click. */
|
|
1050
|
+
const emptyNextLines = (): BlockLines => ({
|
|
1051
|
+
open: false,
|
|
1052
|
+
lines: [paint.dim(`${TEXTS.nextHeading} ${ITEM_SEPARATOR} ${TEXTS.nextEmpty}`)],
|
|
1053
|
+
hit: { rows: [], moreKey: null, keys: [] },
|
|
1054
|
+
})
|
|
1055
|
+
|
|
1056
|
+
/** The board's next section under the for-you block: ` 1 Answer the phone tests`, numbered, without the amber bar. */
|
|
1057
|
+
const nextLines = ({ rows }: NextBlock, state: Selection, width: number): BlockLines => {
|
|
1058
|
+
if (rows.length === 0) return emptyNextLines()
|
|
1059
|
+
const block: TopBlock = {
|
|
1060
|
+
name: TEXTS.nextHeading,
|
|
1061
|
+
bar: nextBar,
|
|
1062
|
+
markPaint: paint.dim,
|
|
1063
|
+
buttonPaint: (text) => text,
|
|
1064
|
+
focused: (key) => isFocused(state, key),
|
|
1065
|
+
allBold: (key) => buttonsAllBold(state, key),
|
|
1066
|
+
countPaint: (text) => text,
|
|
1067
|
+
moreKey: NEXT_MORE_KEY,
|
|
1068
|
+
expanded: state.expanded.has(NEXT_MORE_KEY),
|
|
1069
|
+
keys: nextKeys(rows),
|
|
1070
|
+
rows: blockRows(nextItems(rows), state.expanded, width, NEXT_MORE_KEY),
|
|
1071
|
+
}
|
|
1072
|
+
return blockLinesOf(block, state, TOP_BLOCK.next, width)
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/** The sources the watcher runs: ` ● errors ran 12 min ago · <newest report>`, the mark amber while the report is new, red while the source fails. */
|
|
1076
|
+
const sourceLines = (
|
|
1077
|
+
{ sources, now }: SourceBlock,
|
|
1078
|
+
awaiting: readonly WaitingItem[],
|
|
1079
|
+
state: Selection,
|
|
1080
|
+
width: number,
|
|
1081
|
+
): BlockLines => {
|
|
1082
|
+
const shown = state.expanded.has(SOURCE_MORE_KEY) ? sources : sources.slice(0, SHOWN_BLOCK_ROWS)
|
|
1083
|
+
const view = { expanded: state.expanded, asked: askedCheckOf(awaiting) }
|
|
1084
|
+
const block: TopBlock = {
|
|
1085
|
+
name: TEXTS.sourceHeading,
|
|
1086
|
+
bar: nextBar,
|
|
1087
|
+
markPaint: paint.dim,
|
|
1088
|
+
buttonPaint: (text) => text,
|
|
1089
|
+
focused: (key) => isFocused(state, key),
|
|
1090
|
+
allBold: (key) => buttonsAllBold(state, key),
|
|
1091
|
+
countPaint: (text) => text,
|
|
1092
|
+
moreKey: SOURCE_MORE_KEY,
|
|
1093
|
+
expanded: state.expanded.has(SOURCE_MORE_KEY),
|
|
1094
|
+
keys: sourceKeys(sources),
|
|
1095
|
+
rows: allBlockRows(sourceItems(shown, now, toneOfRole(TONE_ROLE.new), view), state.expanded, width),
|
|
1096
|
+
}
|
|
1097
|
+
return blockLinesOf(block, state, TOP_BLOCK.sources, width)
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
const sourceLinesOf = (
|
|
1101
|
+
sourceBlock: SourceBlock | undefined,
|
|
1102
|
+
awaiting: readonly WaitingItem[],
|
|
1103
|
+
state: Selection,
|
|
1104
|
+
width: number,
|
|
1105
|
+
): BlockLines | null =>
|
|
1106
|
+
sourceBlock && sourceBlock.sources.length > 0 ? sourceLines(sourceBlock, awaiting, state, width) : null
|
|
1107
|
+
|
|
962
1108
|
const appointmentLinesOf = (
|
|
963
1109
|
appointments: AppointmentBlock | undefined,
|
|
964
1110
|
state: Selection,
|
|
@@ -1011,6 +1157,13 @@ const watchNotice = (
|
|
|
1011
1157
|
return watch.kind === WATCH_HEALTH.off ? texts.watcherOff : texts.stale(watch.minutes)
|
|
1012
1158
|
}
|
|
1013
1159
|
|
|
1160
|
+
/** `groomed 3d ago` beside the clock, dim, amber once the board is overdue; empty while it is unknown. */
|
|
1161
|
+
const groomedCell = (days: number | null | undefined): string => {
|
|
1162
|
+
if (days === null || days === undefined) return ''
|
|
1163
|
+
const { text, stale } = groomedText(days)
|
|
1164
|
+
return stale ? paint.yellow(text) : paint.dim(text)
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1014
1167
|
/** Chevron, symbol and the gaps around them before the name column of a lane line. */
|
|
1015
1168
|
const LANE_HEAD_CELLS = 2 + SYMBOL_WIDTH + 1
|
|
1016
1169
|
|
|
@@ -1042,6 +1195,26 @@ const partitionLanes = (lanes: LaneView[]): Record<'active' | 'waiting' | 'noAge
|
|
|
1042
1195
|
}
|
|
1043
1196
|
}
|
|
1044
1197
|
|
|
1198
|
+
/**
|
|
1199
|
+
* The line under the mouse as the mouse wants it: a line of links, which is no row of its own, gets the focus
|
|
1200
|
+
* background and mark, and the square-bracket button the mouse is on is bold. `offset` is the screen line `lines` starts at.
|
|
1201
|
+
*/
|
|
1202
|
+
const emphasisedAt = (
|
|
1203
|
+
lines: string[],
|
|
1204
|
+
offset: number,
|
|
1205
|
+
hits: readonly FocusHit[],
|
|
1206
|
+
{ hovered, hoveredFrom }: Selection,
|
|
1207
|
+
width: number,
|
|
1208
|
+
): string[] => {
|
|
1209
|
+
const hit = hovered ? hits.find(({ key }) => key === hovered) : undefined
|
|
1210
|
+
const index = hit ? hit.line - offset : -1
|
|
1211
|
+
const line = lines[index]
|
|
1212
|
+
if (line === undefined || !hit) return lines
|
|
1213
|
+
const marked = hit.key.startsWith(LINKS_KEY_PREFIX) ? focusLine(line, width, true) : line
|
|
1214
|
+
const emphasised = hoveredFrom == null ? marked : boldBracketAt(marked, hoveredFrom)
|
|
1215
|
+
return lines.map((each, at) => (at === index ? emphasised : each))
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1045
1218
|
/**
|
|
1046
1219
|
* Title and clock on top, the key hint at the bottom (with the legend after `?`), and in between one
|
|
1047
1220
|
* block per lane: the lane line with its total bar, and for an open lane the phase rows. Folded lanes are
|
|
@@ -1049,7 +1222,20 @@ const partitionLanes = (lanes: LaneView[]): Record<'active' | 'waiting' | 'noAge
|
|
|
1049
1222
|
* lanes with a worktree but no agent each share a closing block; finished lanes are not drawn.
|
|
1050
1223
|
*/
|
|
1051
1224
|
export const renderScreen = (
|
|
1052
|
-
{
|
|
1225
|
+
{
|
|
1226
|
+
title,
|
|
1227
|
+
time,
|
|
1228
|
+
lanes,
|
|
1229
|
+
planned,
|
|
1230
|
+
watch,
|
|
1231
|
+
watchesTickets,
|
|
1232
|
+
awaiting = [],
|
|
1233
|
+
next,
|
|
1234
|
+
appointments,
|
|
1235
|
+
sourceBlock,
|
|
1236
|
+
groomedDays,
|
|
1237
|
+
unplaced = [],
|
|
1238
|
+
}: OverviewView,
|
|
1053
1239
|
state: Selection,
|
|
1054
1240
|
width: number,
|
|
1055
1241
|
): Screen => {
|
|
@@ -1059,13 +1245,17 @@ export const renderScreen = (
|
|
|
1059
1245
|
const badgeTexts = BADGE_TEXTS
|
|
1060
1246
|
const blocks = active.map((lane) => laneBlock(lane, state, width, columns, badgeTexts))
|
|
1061
1247
|
const notice = watchNotice({ lanes: active, watch, watchesTickets }, badgeTexts)
|
|
1062
|
-
const clock = notice ?
|
|
1248
|
+
const clock = [notice ? paint.yellow(notice) : '', groomedCell(groomedDays), paint.dim(time)]
|
|
1249
|
+
.filter(Boolean)
|
|
1250
|
+
.join(COLUMN_SEPARATOR)
|
|
1063
1251
|
const titleLine = spread(`${HEADER_MARK} ${paint.bold(title)}`, clock, width)
|
|
1064
1252
|
const forYou = awaiting.length > 0 ? waitingLines(awaiting, state, width) : null
|
|
1253
|
+
const queued = next ? nextLines(next, state, width) : null
|
|
1065
1254
|
const dated = appointmentLinesOf(appointments, state, width)
|
|
1066
1255
|
const nowLines = unplaced.map((task) => nowLine(task, width))
|
|
1067
1256
|
const above = nowLines.length > 0 ? [titleLine, rule(width), ...nowLines] : [titleLine]
|
|
1068
|
-
const
|
|
1257
|
+
const listed = sourceLinesOf(sourceBlock, awaiting, state, width)
|
|
1258
|
+
const stack = stackBlocks([forYou, queued, dated, listed], above.length, width)
|
|
1069
1259
|
const endsWithRule = stack.lines.length > 0 || nowLines.length === 0
|
|
1070
1260
|
const sections = [
|
|
1071
1261
|
...blocks,
|
|
@@ -1073,11 +1263,21 @@ export const renderScreen = (
|
|
|
1073
1263
|
...(planned.length > 0 ? [plannedFrame(planned, state, width)] : []),
|
|
1074
1264
|
]
|
|
1075
1265
|
const lead = !endsWithRule && blocks.length === 0 && sections.length > 0 ? [textFrame([rule(width)])] : []
|
|
1266
|
+
const plainHeader = [...above, ...(stack.lines.length > 0 || nowLines.length > 0 ? stack.lines : [rule(width)])]
|
|
1267
|
+
const plainBody = joinFrames([...lead, ...separate(sections, width, blocks.length)])
|
|
1268
|
+
const blockHits = stack.hits.flatMap(focusHitsOfBlock)
|
|
1269
|
+
const focusHits = [...blockHits, ...focusHitsOfTargets(plainBody.targets, plainHeader.length)]
|
|
1270
|
+
const header = emphasisedAt(plainHeader, 0, focusHits, state, width)
|
|
1271
|
+
const body = { ...plainBody, lines: emphasisedAt(plainBody.lines, plainHeader.length, focusHits, state, width) }
|
|
1076
1272
|
return {
|
|
1077
1273
|
waitingBlock: stack.hits[0],
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1274
|
+
nextBlock: stack.hits[1],
|
|
1275
|
+
appointmentBlock: stack.hits[2],
|
|
1276
|
+
sourceBlock: stack.hits[3],
|
|
1277
|
+
focusHits,
|
|
1278
|
+
rowKeys: [...blockHits.map(({ key }) => key), ...targetKeys(body.targets)],
|
|
1279
|
+
header,
|
|
1280
|
+
body,
|
|
1081
1281
|
footer: [
|
|
1082
1282
|
rule(width),
|
|
1083
1283
|
fit(paint.dim(TEXTS.hint), width),
|
|
@@ -1092,6 +1292,16 @@ export const renderScreen = (
|
|
|
1092
1292
|
*/
|
|
1093
1293
|
export const renderSelected = (overview: OverviewView, selection: Selection, width: number): Screen => {
|
|
1094
1294
|
const first = renderScreen(overview, selection, width)
|
|
1095
|
-
const
|
|
1096
|
-
return
|
|
1295
|
+
const settled = settledSelection(selection, first)
|
|
1296
|
+
return settled === selection ? first : renderScreen(overview, settled, width)
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/**
|
|
1300
|
+
* The selection with its row resolved against `screen`: a row the screen draws stays, a block row included; else the
|
|
1301
|
+
* first row of the body is selected, none when the body has none.
|
|
1302
|
+
*/
|
|
1303
|
+
export const settledSelection = (selection: Selection, screen: Screen): Selection => {
|
|
1304
|
+
const kept = selection.selected !== null && screen.rowKeys.includes(selection.selected)
|
|
1305
|
+
const selected = kept ? selection.selected : selectedKey(selection, targetKeys(screen.body.targets))
|
|
1306
|
+
return selected === selection.selected ? selection : { ...selection, selected }
|
|
1097
1307
|
}
|
package/src/plans/link-chips.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { ARTIFACT_LINK_KIND, type LinkKind } from '../config/link-kinds'
|
|
2
2
|
|
|
3
3
|
/** A link of a plan that matches a link kind, as the sprint page shows it. */
|
|
4
4
|
export type LinkChip = { url: string; label: string; icon: string; private: boolean }
|
|
@@ -27,9 +27,27 @@ const linksOf = (line: string): Found[] => {
|
|
|
27
27
|
return found.sort((left, right) => line.indexOf(left.url) - line.indexOf(right.url))
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
const LIST_LEAD = /^\s*(?:[-*>]\s*)*(?:\[[ xX]\]\s*)?/
|
|
31
|
+
const LEFTOVER_LINK = /\bhttps?:\/\/\S+/g
|
|
32
|
+
const TITLE_END = /[\s:,;.\-–>]+$/
|
|
33
|
+
|
|
34
|
+
/** What a line says besides its links, without list marks, checkbox and emphasis: the title of a bare artifact link. */
|
|
35
|
+
const lineTitleOf = ({ line }: Found): string =>
|
|
36
|
+
line
|
|
37
|
+
.replace(MARKDOWN_LINK, '$1')
|
|
38
|
+
.replace(AUTOLINK, '')
|
|
39
|
+
.replace(LEFTOVER_LINK, '')
|
|
40
|
+
.replace(LIST_LEAD, '')
|
|
41
|
+
.replace(MARKS, '')
|
|
42
|
+
.replace(CONTROL, '')
|
|
43
|
+
.replace(TITLE_END, '')
|
|
44
|
+
.trim()
|
|
45
|
+
|
|
46
|
+
const labelOf = (found: Found, kind: LinkKind): string => {
|
|
47
|
+
const written = found.text ?? LABEL_BEFORE.exec(found.before)?.[1]
|
|
48
|
+
const own = written?.replace(MARKS, '').replace(CONTROL, '').trim()
|
|
49
|
+
if (own) return own
|
|
50
|
+
return (kind.pattern === ARTIFACT_LINK_KIND.pattern && lineTitleOf(found)) || kind.label
|
|
33
51
|
}
|
|
34
52
|
|
|
35
53
|
/**
|