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/layout.ts
CHANGED
|
@@ -30,11 +30,19 @@ import {
|
|
|
30
30
|
type WatchHealth,
|
|
31
31
|
} from '../watch/plan-badges'
|
|
32
32
|
import type { UnplannedTask } from './active-tasks'
|
|
33
|
-
import { type
|
|
34
|
-
import {
|
|
33
|
+
import { type AgentOf, clickHint } from './click-hint'
|
|
34
|
+
import { type Frame, indented, joinFrames, narrower, rowFrame, spaced, textFrame, unpainted } from './frame'
|
|
35
35
|
import { groomedText } from './groomed'
|
|
36
|
+
import {
|
|
37
|
+
LANE_REASON,
|
|
38
|
+
type LaneReason,
|
|
39
|
+
laneReason,
|
|
40
|
+
lanesBalance,
|
|
41
|
+
type ReasonInput,
|
|
42
|
+
type UnblockingRow,
|
|
43
|
+
} from './lane-reason'
|
|
36
44
|
import { LANE_STANDING, type LaneStanding, laneStanding, waitingNote } from './lane-standing'
|
|
37
|
-
import { type LaneSummary, laneSummary } from './lane-summary'
|
|
45
|
+
import { type ActivePhase, type LaneSummary, laneSummary } from './lane-summary'
|
|
38
46
|
import { type LaneTickets, withoutTicketKey } from './lane-tickets'
|
|
39
47
|
import {
|
|
40
48
|
COLUMN_SEPARATOR,
|
|
@@ -44,14 +52,18 @@ import {
|
|
|
44
52
|
MIN_TITLE_WIDTH,
|
|
45
53
|
nowLine,
|
|
46
54
|
plain,
|
|
55
|
+
SHIMMERING_TASK,
|
|
56
|
+
STILL_TASK,
|
|
57
|
+
type TaskStyle,
|
|
47
58
|
} from './layout-phase'
|
|
48
|
-
import { foldedTaskLine,
|
|
59
|
+
import { foldedTaskLine, openLaneFrame } from './layout-summary'
|
|
60
|
+
import { legendLines } from './legend'
|
|
49
61
|
import { chipKey, type LinkChip, planUrlOf } from './link-chips'
|
|
50
|
-
import { type NextBlock, nextItems, nextKeys } from './next-rows'
|
|
51
62
|
import type { PhaseModel } from './phase-models'
|
|
52
63
|
import { type PlannedPlan, plannedNextLabel } from './planned'
|
|
53
64
|
import { CHECKS, type ChecksState, failedTestKey, type PullRequest, pullRequestKey } from './pull-requests'
|
|
54
65
|
import {
|
|
66
|
+
blockRowByKey,
|
|
55
67
|
boldBracketAt,
|
|
56
68
|
buttonsAllBold,
|
|
57
69
|
type FocusHit,
|
|
@@ -62,40 +74,39 @@ import {
|
|
|
62
74
|
LINKS_KEY_PREFIX,
|
|
63
75
|
} from './row-focus'
|
|
64
76
|
import {
|
|
77
|
+
BACKLOG_KEY,
|
|
65
78
|
type ClickTarget,
|
|
66
79
|
isBlockFolded,
|
|
80
|
+
isBlockHeadKey,
|
|
67
81
|
isLaneOpen,
|
|
82
|
+
isLanesOpen,
|
|
83
|
+
LANES_KEY,
|
|
68
84
|
laneKey,
|
|
69
|
-
PLANNED_KEY,
|
|
70
85
|
type Selection,
|
|
71
86
|
selectedKey,
|
|
72
87
|
TOP_BLOCK,
|
|
73
88
|
type TopBlockId,
|
|
74
89
|
targetKeys,
|
|
90
|
+
topBlockHeadKey,
|
|
91
|
+
WAITING_KEY,
|
|
75
92
|
} from './selection'
|
|
76
93
|
import type { SentChip } from './sent-files'
|
|
77
94
|
import { shownWithMore } from './shown-with-more'
|
|
78
95
|
import { askedCheckOf } from './source-asked'
|
|
79
|
-
import { type SourceBlock, sourceItems,
|
|
80
|
-
import { chevronOf, LESS_MARK, NO_AGENT_SYMBOL, statusSymbol, statusWord } from './symbols'
|
|
96
|
+
import { type SourceBlock, type SourceSummary, sourceItems, sourceSummaries } from './source-rows'
|
|
97
|
+
import { chevronOf, LESS_MARK, NO_AGENT_SYMBOL, statusSymbol, statusWord, TURN_MARK } from './symbols'
|
|
81
98
|
import { BADGE_TEXTS, type BadgeTexts, checksText, laneMarkText, TEXTS } from './texts'
|
|
82
99
|
import { paintOfRole, paintOfTone, toneOfRole } from './tones'
|
|
83
100
|
import {
|
|
84
|
-
APPOINTMENT_MORE_KEY,
|
|
85
101
|
allBlockRows,
|
|
86
102
|
type BlockHit,
|
|
87
103
|
type BlockRow,
|
|
88
|
-
blockRows,
|
|
89
104
|
buttonsText,
|
|
90
|
-
moreKeyOf,
|
|
91
|
-
NEXT_MORE_KEY,
|
|
92
105
|
SHOWN_BLOCK_ROWS,
|
|
93
106
|
SOURCE_MORE_KEY,
|
|
94
|
-
WAITING_MARK,
|
|
95
|
-
WAITING_MORE_KEY,
|
|
96
|
-
waitingKeys,
|
|
97
|
-
waitingRows,
|
|
98
107
|
} from './waiting-click'
|
|
108
|
+
import { type TurnBalance, turnBalance, turnPage, type YourTurnBlock } from './your-turn'
|
|
109
|
+
import { turnRows, YOUR_TURN_LATER_KEY, YOUR_TURN_MORE_KEY } from './your-turn-rows'
|
|
99
110
|
|
|
100
111
|
/**
|
|
101
112
|
* Where a lane stands outside its plan. `agent` is the terminal adapter's status, null when no agent is listed;
|
|
@@ -116,8 +127,6 @@ export type LaneView = {
|
|
|
116
127
|
context?: ContextReading | null
|
|
117
128
|
/** What the lane works on now, what comes next and after, and what is finished; derived from `progress` when absent. */
|
|
118
129
|
summary?: LaneSummary
|
|
119
|
-
/** The title of the agent's session as the terminal shows it; absent when it has none. */
|
|
120
|
-
sessionTitle?: string
|
|
121
130
|
/** A running lane the sprint table has no row for; drawn with the mark `no row in lanes`. */
|
|
122
131
|
unlisted?: boolean
|
|
123
132
|
/** Preview and feedback of the lane's pull requests from the running watcher's state; none without a watcher. */
|
|
@@ -135,38 +144,35 @@ export type LaneView = {
|
|
|
135
144
|
/** `watch` is the health of the watcher the badges come from, absent when it was not read. */
|
|
136
145
|
export type OverviewView = {
|
|
137
146
|
title: string
|
|
138
|
-
time: string
|
|
139
147
|
lanes: LaneView[]
|
|
140
148
|
planned: PlannedPlan[]
|
|
141
149
|
watch?: WatchHealth
|
|
142
150
|
/** True when the config marks a ticket source, so a missing watcher is said without a pull request too. */
|
|
143
151
|
watchesTickets?: boolean
|
|
144
|
-
/** The open items of the board's section of what waits on the user
|
|
152
|
+
/** The open items of the board's section of what waits on the user: which source entries "Your turn" holds. */
|
|
145
153
|
awaiting?: WaitingItem[]
|
|
146
|
-
/**
|
|
147
|
-
|
|
148
|
-
/** The
|
|
149
|
-
appointments?: AppointmentBlock
|
|
150
|
-
/** The sources the watcher runs that are not tickets, in a block of their own under the appointments. */
|
|
154
|
+
/** What is the user's turn, in a block under the title; absent for a herd without a board. */
|
|
155
|
+
yourTurn?: YourTurnBlock
|
|
156
|
+
/** The sources the watcher runs that are not tickets, in a block of their own under "Your turn". */
|
|
151
157
|
sourceBlock?: SourceBlock
|
|
152
158
|
/** Whole days since the board was groomed (`groomedDaysAgo`), beside the clock; null or absent when unknown. */
|
|
153
159
|
groomedDays?: number | null
|
|
154
160
|
/** What agents report doing that no lane takes, one `now:` line each under the title. */
|
|
155
161
|
unplaced?: UnplannedTask[]
|
|
162
|
+
/** True when a folded lane's running task shimmers (`shimmerAt` draws it) instead of carrying its `●`. */
|
|
163
|
+
shimmer?: boolean
|
|
156
164
|
}
|
|
157
165
|
|
|
158
166
|
/**
|
|
159
167
|
* The drawn screen: a fixed header and footer around a body that scrolls; body lines and targets count from the body's
|
|
160
|
-
* top. `
|
|
161
|
-
*
|
|
168
|
+
* top. `yourTurnBlock` and `sourceBlock` are what a click on these blocks is matched against, null while the block is
|
|
169
|
+
* not drawn.
|
|
162
170
|
*/
|
|
163
171
|
export type Screen = {
|
|
164
172
|
header: string[]
|
|
165
173
|
body: Frame
|
|
166
174
|
footer: string[]
|
|
167
|
-
|
|
168
|
-
nextBlock: BlockHit | null
|
|
169
|
-
appointmentBlock: BlockHit | null
|
|
175
|
+
yourTurnBlock: BlockHit | null
|
|
170
176
|
sourceBlock: BlockHit | null
|
|
171
177
|
/** 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
178
|
focusHits: FocusHit[]
|
|
@@ -189,30 +195,42 @@ type LaneColumns = { name: number; marks: number; right: number; phases: boolean
|
|
|
189
195
|
type BlockLines = { open: boolean; lines: string[]; hit: Omit<BlockHit, 'firstLine'> }
|
|
190
196
|
|
|
191
197
|
/**
|
|
192
|
-
* What a block of the top of the herd is drawn from: its name,
|
|
193
|
-
* a row, the key of its `+<n>` line
|
|
198
|
+
* What a block of the top of the herd is drawn from: its name, its count and balance in the head, the paint of the mark
|
|
199
|
+
* that starts a row, the key of its `+<n>` line and how many rows that line holds back, and its rows.
|
|
194
200
|
*/
|
|
195
201
|
type TopBlock = {
|
|
196
202
|
name: string
|
|
197
|
-
|
|
203
|
+
/** The count after the name; null for a block whose head shows none (the sources). */
|
|
204
|
+
count: number | null
|
|
205
|
+
/** What the head says after the count, painted, open and folded alike; empty for nothing. */
|
|
206
|
+
balance: string
|
|
198
207
|
markPaint: (text: string) => string
|
|
199
|
-
/** The paint of the buttons of a row: amber
|
|
208
|
+
/** The paint of the buttons of a row: amber for "Your turn", the normal foreground colour elsewhere. */
|
|
200
209
|
buttonPaint: (text: string) => string
|
|
201
|
-
/** Whether a row is the selected one or under the mouse: it gets the background, the mark, and
|
|
210
|
+
/** Whether a row is the selected one or under the mouse: it gets the background, the mark, and its buttons. */
|
|
202
211
|
focused: (key: string) => boolean
|
|
203
212
|
/** Whether all buttons of a row are bold: the arrows selected it and the mouse is not on it (the mouse bolds one). */
|
|
204
213
|
allBold: (key: string) => boolean
|
|
205
|
-
/** The paint of the count in the head while
|
|
214
|
+
/** The paint of the count in the head while it is more than 0: amber for what waits on the user. */
|
|
206
215
|
countPaint: (text: string) => string
|
|
207
|
-
moreKey: string
|
|
216
|
+
moreKey: string | null
|
|
217
|
+
hidden: number
|
|
208
218
|
expanded: boolean
|
|
209
|
-
keys: readonly string[]
|
|
210
219
|
rows: BlockRow[]
|
|
211
220
|
}
|
|
212
221
|
|
|
222
|
+
/** What the head of a block says: its name, its count, its balance and the paint of the count. */
|
|
223
|
+
type BlockHeadParts = Pick<TopBlock, 'name' | 'count' | 'balance' | 'countPaint'>
|
|
224
|
+
|
|
213
225
|
/** The blocks on top of the herd one under the other: their lines below the title, and the hit of each drawn block. */
|
|
214
226
|
type BlockStack = { lines: string[]; hits: (BlockHit | null)[] }
|
|
215
227
|
|
|
228
|
+
/** What every lane of the lanes block is drawn with. */
|
|
229
|
+
type LanesContext = { state: Selection; width: number; badgeTexts: BadgeTexts; taskStyle: TaskStyle }
|
|
230
|
+
|
|
231
|
+
/** In front of every line of the lanes block under its head. */
|
|
232
|
+
const LANE_INDENT = ' '
|
|
233
|
+
|
|
216
234
|
const BRANCH_MARK = '⎇'
|
|
217
235
|
const SENT_MARK = '📎'
|
|
218
236
|
const SYMBOL_WIDTH = 2
|
|
@@ -230,11 +248,6 @@ const HEADER_MARK = '🐾'
|
|
|
230
248
|
const COLUMN_RULE = '│'
|
|
231
249
|
/** The number badge of a three-digit pull request (`↗ #` and the digits, a cell of padding either side); a longer one widens it. */
|
|
232
250
|
const PULL_REQUEST_BADGE_WIDTH = 8
|
|
233
|
-
/**
|
|
234
|
-
* Under the lane line's status symbol: where the pull request row and its badge line start, and where the trunk
|
|
235
|
-
* of an open lane hangs, not under the fold arrow.
|
|
236
|
-
*/
|
|
237
|
-
const UNDER_STATUS = ' '
|
|
238
251
|
const CHECKS_WIDTH = 10
|
|
239
252
|
/** The most failed tests listed under a pull request; the rest is counted. */
|
|
240
253
|
const SHOWN_FAILED_TESTS = 3
|
|
@@ -479,9 +492,24 @@ const STATUS_SEPARATOR = ' · '
|
|
|
479
492
|
/** Between the checkbox count and the phase count of a lane. */
|
|
480
493
|
const TALLY_SEPARATOR = ' · '
|
|
481
494
|
|
|
482
|
-
/**
|
|
495
|
+
/** How many cut chip labels the render keeps; a label is often a whole line of a plan, measured anew every frame otherwise. */
|
|
496
|
+
const KEPT_LABELS = 2000
|
|
497
|
+
|
|
498
|
+
const cutLabels = new Map<string, string>()
|
|
499
|
+
|
|
500
|
+
/** The chip label cut to `CHIP_LABEL_WIDTH`, kept per label; the kept ones are dropped all at once when they are too many. */
|
|
501
|
+
const cutLabel = (label: string): string => {
|
|
502
|
+
const kept = cutLabels.get(label)
|
|
503
|
+
if (kept !== undefined) return kept
|
|
504
|
+
if (cutLabels.size >= KEPT_LABELS) cutLabels.clear()
|
|
505
|
+
const cut = fit(plain(label), CHIP_LABEL_WIDTH)
|
|
506
|
+
cutLabels.set(label, cut)
|
|
507
|
+
return cut
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** `[🔗 label]` linked to the chip's address, `private` faint inside it for a link its plan says is private. */
|
|
483
511
|
const chipCell = ({ url, icon, label, private: hidden }: LinkChip): string =>
|
|
484
|
-
bracket(url, `${icon} ${
|
|
512
|
+
bracket(url, `${icon} ${cutLabel(label)}`, hidden ? TEXTS.privateLink : '')
|
|
485
513
|
|
|
486
514
|
/** A badge and the key a click on it opens; null for a cell no click opens. */
|
|
487
515
|
type BadgeCell = { cell: string; key: string | null }
|
|
@@ -640,35 +668,39 @@ const foldedPhaseLabel = (view: LaneView): string => {
|
|
|
640
668
|
return `${nextPhaseLabel(view)}${more > 0 ? ` +${more}` : ''} · ${phase.done}/${phase.total}`
|
|
641
669
|
}
|
|
642
670
|
|
|
643
|
-
/** A lane whose agent rests (idle or done): the folded line says what it waits on
|
|
671
|
+
/** A lane whose agent rests (idle or done): the folded line says what it waits on, and its phase without the bar. */
|
|
644
672
|
const restsFolded = ({ status }: LaneView, open: boolean): boolean => !open && isRestingAgent(status.agent)
|
|
645
673
|
|
|
646
|
-
/**
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
674
|
+
/**
|
|
675
|
+
* Why a resting lane rests, the label dim and the short text bright; a question to you has its label in the tone of
|
|
676
|
+
* what waits. Empty for a lane without a reason, which works.
|
|
677
|
+
*/
|
|
678
|
+
const reasonText = (reason: LaneReason | null): string => {
|
|
679
|
+
if (!reason) return ''
|
|
680
|
+
if (!reason.label) return reason.text
|
|
681
|
+
const label = reason.kind === LANE_REASON.you ? paintOfRole(TONE_ROLE.waiting)(reason.label) : paint.dim(reason.label)
|
|
682
|
+
return `${label} ${reason.text}`
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** `P4 12/18`: the phase a resting lane names and its ticked boxes, without the title the reason takes the room of. */
|
|
686
|
+
const restingPhaseLabel = (view: LaneView): string => {
|
|
687
|
+
const phase = headPhase(view)
|
|
688
|
+
return phase ? paint.dim(`${phaseLabel(phase.id)} ${phase.done}/${phase.total}`) : ''
|
|
650
689
|
}
|
|
651
690
|
|
|
652
691
|
/**
|
|
653
|
-
*
|
|
654
|
-
*
|
|
692
|
+
* What follows the status on a lane line: the marker, the reason a resting lane rests, and, folded only, the phase it
|
|
693
|
+
* works on (a resting one by its label and count); the branch is on the session line. Wider than `room`, the reason
|
|
694
|
+
* is cut and the phase stays whole behind it.
|
|
655
695
|
*/
|
|
656
|
-
const
|
|
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
|
-
|
|
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. */
|
|
667
|
-
const laneDetail = (view: LaneView, open: boolean): string => {
|
|
696
|
+
const laneDetail = (view: LaneView, open: boolean, reason: LaneReason | null, room: number): string => {
|
|
668
697
|
const markText = laneMarkText(view)
|
|
669
698
|
const marker = markText ? paint.yellow(markText) : ''
|
|
670
|
-
const phase = restsFolded(view, open) ?
|
|
671
|
-
|
|
699
|
+
const phase = restsFolded(view, open) ? restingPhaseLabel(view) : open ? '' : foldedPhaseLabel(view)
|
|
700
|
+
const lead = [agentLabel(view.status.agent), marker, reasonText(reason)].filter(Boolean).join(' ')
|
|
701
|
+
if (!reason || !phase) return [lead, phase].filter(Boolean).join(' ')
|
|
702
|
+
const leadRoom = Math.max(room - COLUMN_SEPARATOR.length - visibleLength(phase), 1)
|
|
703
|
+
return `${visibleLength(lead) > leadRoom ? fit(lead, leadRoom) : lead}${COLUMN_SEPARATOR}${phase}`
|
|
672
704
|
}
|
|
673
705
|
|
|
674
706
|
/**
|
|
@@ -684,11 +716,26 @@ export const withDetail = (
|
|
|
684
716
|
indent = CONTENT_INDENT,
|
|
685
717
|
): string[] => {
|
|
686
718
|
if (!detail) return [spread(head, right, width)]
|
|
687
|
-
|
|
688
|
-
|
|
719
|
+
if (detailPlacement(head, right, width, indent).beside)
|
|
720
|
+
return [spread(`${head}${COLUMN_SEPARATOR}${detail}`, right, width)]
|
|
689
721
|
return [spread(head, right, width), fit(`${indent}${detail}`, width)]
|
|
690
722
|
}
|
|
691
723
|
|
|
724
|
+
/**
|
|
725
|
+
* Where `withDetail` puts a detail and how many cells it has there: beside `head` and `right` while at least
|
|
726
|
+
* `MIN_BESIDE_WIDTH` are left on that line, else on a line of its own behind `indent`.
|
|
727
|
+
*/
|
|
728
|
+
const detailPlacement = (
|
|
729
|
+
head: string,
|
|
730
|
+
right: string,
|
|
731
|
+
width: number,
|
|
732
|
+
indent: string,
|
|
733
|
+
): { beside: boolean; room: number } => {
|
|
734
|
+
const besideRoom = width - visibleLength(head) - COLUMN_SEPARATOR.length - visibleLength(right) - 1
|
|
735
|
+
const beside = besideRoom >= MIN_BESIDE_WIDTH
|
|
736
|
+
return { beside, room: beside ? besideRoom : width - visibleLength(indent) }
|
|
737
|
+
}
|
|
738
|
+
|
|
692
739
|
/** The summary the snapshot read for the lane, or the one its own steps give. */
|
|
693
740
|
const summaryOf = (view: LaneView): LaneSummary =>
|
|
694
741
|
view.summary ?? laneSummary({ progress: view.progress, phases: {}, activity: {} }, 0)
|
|
@@ -699,10 +746,14 @@ const laneProgressColumn = (view: LaneView, tallyWidth: number): string => {
|
|
|
699
746
|
return tally.total > 0 ? progressColumn(tally, tallyWidth) : ''
|
|
700
747
|
}
|
|
701
748
|
|
|
702
|
-
/**
|
|
749
|
+
/** The context value alone, from `warn` on, for a lane line that draws no bar after it; empty below or without a reading. */
|
|
750
|
+
const loneContextValue = (context: ContextReading | null | undefined): string =>
|
|
751
|
+
context && context.level !== CONTEXT_LEVEL.ok ? contextValue(context) : ''
|
|
752
|
+
|
|
753
|
+
/** Context and bar at the right edge of a folded lane line, as wide as the lane has them; no rule without a bar. */
|
|
703
754
|
const laneRight = (view: LaneView, tallyWidth: number): string => {
|
|
704
755
|
const counts = restsFolded(view, false) ? '' : laneProgressColumn(view, tallyWidth)
|
|
705
|
-
return counts ? `${contextCell(view.context)}${counts}` :
|
|
756
|
+
return counts ? `${contextCell(view.context)}${counts}` : loneContextValue(view.context)
|
|
706
757
|
}
|
|
707
758
|
|
|
708
759
|
/** The cells of the phase count column, 0 when the pane leaves it out. */
|
|
@@ -718,99 +769,88 @@ const markedRight = (view: LaneView, columns: LaneColumns): string => {
|
|
|
718
769
|
return `${padVisible(marks, columns.marks)}${COLUMN_SEPARATOR}${pad}${right}`
|
|
719
770
|
}
|
|
720
771
|
|
|
721
|
-
/**
|
|
722
|
-
|
|
723
|
-
* starts where its name does, as the branches of an open lane do; an open lane keeps its trunk in front.
|
|
724
|
-
*/
|
|
725
|
-
const detailIndent = (open: boolean, nameColumn: number): string => {
|
|
726
|
-
if (!open) return ' '.repeat(nameColumn)
|
|
727
|
-
const trunk = `${UNDER_STATUS}${TREE.pipe}`
|
|
728
|
-
return `${paint.dim(trunk)}${' '.repeat(Math.max(nameColumn - visibleLength(trunk), 0))}`
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
/** The task row below a folded lane's phase row, under the lane's name; none while the phase has no task on it. */
|
|
732
|
-
const foldedTaskRow = (view: LaneView, indent: string, width: number): string[] => {
|
|
772
|
+
/** The task row below a folded lane's phase row, under the lane's name; none while the lane reports no task. */
|
|
773
|
+
const foldedTaskRow = (view: LaneView, indent: string, width: number, style: TaskStyle): string[] => {
|
|
733
774
|
const phase = headPhase(view)
|
|
734
|
-
const
|
|
735
|
-
const active =
|
|
736
|
-
const line =
|
|
775
|
+
const summary = summaryOf(view)
|
|
776
|
+
const active = summary.active.find((each) => each.phase.id === phase?.id)
|
|
777
|
+
const line = foldedTaskLine(active, summary, TEXTS.herd, { indent, width, style })
|
|
737
778
|
return line ? [line] : []
|
|
738
779
|
}
|
|
739
780
|
|
|
740
|
-
|
|
781
|
+
/**
|
|
782
|
+
* The lines of a lane's own row: chevron, status symbol, name, detail and right column; a detail on a line of its own,
|
|
783
|
+
* and a folded lane's task row, start in the column of the name, as the children of an open lane do.
|
|
784
|
+
*/
|
|
785
|
+
const laneLines = (
|
|
786
|
+
view: LaneView,
|
|
787
|
+
open: boolean,
|
|
788
|
+
{ width, taskStyle }: Pick<LanesContext, 'width' | 'taskStyle'>,
|
|
789
|
+
columns: LaneColumns,
|
|
790
|
+
reason: LaneReason | null,
|
|
791
|
+
): string[] => {
|
|
741
792
|
const { progress, status } = view
|
|
742
793
|
const nameStyle = restsFolded(view, open) ? paint.dim : paint.bold
|
|
743
794
|
const cells = padVisible(nameStyle(fit(progress.lane.name, columns.name)), columns.name)
|
|
744
795
|
const head = `${paint.dim(chevronOf(open))} ${symbolCell(status.agent)} ${cells}`
|
|
745
796
|
const right = open ? laneProgressColumn(view, tallyWidthOf(columns)) : markedRight(view, columns)
|
|
746
|
-
const
|
|
747
|
-
const
|
|
748
|
-
|
|
797
|
+
const nameIndent = ' '.repeat(visibleLength(head) - visibleLength(cells))
|
|
798
|
+
const { room } = detailPlacement(head, right, width, nameIndent)
|
|
799
|
+
const lines = withDetail(head, laneDetail(view, open, reason, room), right, width, nameIndent)
|
|
800
|
+
return open ? lines : [...lines, ...foldedTaskRow(view, nameIndent, width, taskStyle)]
|
|
749
801
|
}
|
|
750
802
|
|
|
751
|
-
/** The
|
|
752
|
-
const
|
|
753
|
-
const
|
|
754
|
-
|
|
755
|
-
return { left: view.sessionTitle ? plain(view.sessionTitle) : git, git, branch, context: contextValue(view.context) }
|
|
803
|
+
/** The running phase the lane line names, with its step and age; undefined while it names none or that one does not run. */
|
|
804
|
+
const headActive = (view: LaneView): ActivePhase | undefined => {
|
|
805
|
+
const phase = headPhase(view)
|
|
806
|
+
return phase ? summaryOf(view).active.find((active) => active.phase.id === phase.id) : undefined
|
|
756
807
|
}
|
|
757
808
|
|
|
758
809
|
/**
|
|
759
|
-
*
|
|
760
|
-
*
|
|
761
|
-
* title cut. Without a title the branch stands at the left.
|
|
810
|
+
* `Opus`: the model cell of the board row the lane works on, the row of its running phase, else of its current one;
|
|
811
|
+
* empty when that cell is. Not the model a subagent ran on, which may differ from the lane's own session.
|
|
762
812
|
*/
|
|
763
|
-
const
|
|
764
|
-
const {
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
813
|
+
const modelLabel = (view: LaneView): string => {
|
|
814
|
+
const { lane, current } = view.progress
|
|
815
|
+
const stepIndex = headActive(view)?.stepIndex ?? current?.stepIndex
|
|
816
|
+
return stepIndex === undefined ? '' : plain(lane.steps[stepIndex]?.model ?? '').trim()
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/** `idle 4 h`: how long a resting lane's phase has not moved; empty while the lane works or the herd saw no move. */
|
|
820
|
+
const idleLabel = (view: LaneView): string => {
|
|
821
|
+
const minutes = isRestingAgent(view.status.agent) ? headActive(view)?.minutesAgo : null
|
|
822
|
+
return minutes === null || minutes === undefined ? '' : TEXTS.idleFor(minutes)
|
|
772
823
|
}
|
|
773
824
|
|
|
774
825
|
/**
|
|
775
|
-
* The session line
|
|
776
|
-
*
|
|
777
|
-
* without a title the branch stands at the left and the chip at the right. Null when even the cut title would be
|
|
778
|
-
* narrower than `MIN_TITLE_WIDTH`, and the chip keeps its row of chips.
|
|
826
|
+
* The session line of an open lane: `⎇ branch ↑2 ±1 · Sonnet · idle 4 h`, the branch with its counts, the model and the
|
|
827
|
+
* rest dim, cut before the context value at the right.
|
|
779
828
|
*/
|
|
780
|
-
const
|
|
781
|
-
const
|
|
782
|
-
const
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
[chip.cell, branch, context],
|
|
786
|
-
]
|
|
787
|
-
: [[chip.cell, context]]
|
|
788
|
-
const candidates = tails.map((tail) => tail.filter(Boolean).join(COLUMN_SEPARATOR))
|
|
789
|
-
const roomFor = (candidate: string) => width - COLUMN_SEPARATOR.length - visibleLength(candidate)
|
|
790
|
-
const narrowest = candidates[candidates.length - 1] ?? ''
|
|
791
|
-
const cuts = Boolean(view.sessionTitle) && roomFor(narrowest) >= MIN_TITLE_WIDTH
|
|
792
|
-
const right = candidates.find((candidate) => visibleLength(left) <= roomFor(candidate)) ?? (cuts ? narrowest : null)
|
|
793
|
-
if (right === null) return null
|
|
794
|
-
const from = width - visibleLength(right)
|
|
795
|
-
const targets = chip.key === null ? [] : [{ line: 0, from, to: from + visibleLength(chip.cell), key: chip.key }]
|
|
796
|
-
return { lines: [spread(left, right, width)], cursorLine: -1, targets }
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
/** Session and link chips of an open lane: a lone chip on the session line when it fits there, else a row of chips. */
|
|
800
|
-
const sessionAndChips = (
|
|
801
|
-
view: LaneView,
|
|
802
|
-
tickets: LaneTickets | null,
|
|
803
|
-
width: number,
|
|
804
|
-
): { session: Frame; chips: Frame } => {
|
|
805
|
-
const cells = chipCells(laneChips(view), tickets, view.sentFiles)
|
|
806
|
-
const [lone] = cells
|
|
807
|
-
const inline = cells.length === 1 && lone ? sessionWithChip(view, lone, width) : null
|
|
808
|
-
if (inline) return { session: inline, chips: textFrame([]) }
|
|
809
|
-
return { session: textFrame([sessionLine(view, width)]), chips: packedFrame(cells, '', width) }
|
|
829
|
+
const sessionLine = (view: LaneView, width: number): string => {
|
|
830
|
+
const words = [modelLabel(view), idleLabel(view)].filter(Boolean).map((word) => paint.dim(word))
|
|
831
|
+
const left = [gitLabel(view.status), ...words].join(paint.dim(PLACE_SEPARATOR))
|
|
832
|
+
const context = contextValue(view.context)
|
|
833
|
+
return spread(fit(left, Math.max(width - (context ? CONTEXT_CELL_WIDTH : 0), 1)), context, width)
|
|
810
834
|
}
|
|
811
835
|
|
|
812
|
-
|
|
813
|
-
|
|
836
|
+
/** How many of `cells` fit on one line of `width` cells with the `+<n>` for the rest; at least one, which is then cut. */
|
|
837
|
+
const fittingChips = (cells: BadgeCell[], width: number): number => {
|
|
838
|
+
const fits = (count: number) => {
|
|
839
|
+
const rest = cells.length - count
|
|
840
|
+
return packedWidth(cells.slice(0, count)) + (rest > 0 ? CHIP_GAP.length + `+${rest}`.length : 0) <= width
|
|
841
|
+
}
|
|
842
|
+
return Array.from({ length: cells.length }, (_, index) => cells.length - index).find(fits) ?? 1
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/** The link chips of an open lane on one line, `+<n>` dim for the ones past the pane's edge; each chip a click target over its columns. */
|
|
846
|
+
const chipLine = (cells: BadgeCell[], width: number): Frame => {
|
|
847
|
+
if (cells.length === 0) return textFrame([])
|
|
848
|
+
const count = fittingChips(cells, width)
|
|
849
|
+
const shown = cells.slice(0, count)
|
|
850
|
+
const rest = cells.length - count
|
|
851
|
+
const line = `${shown.map(({ cell }) => cell).join(CHIP_GAP)}${rest > 0 ? `${CHIP_GAP}${paint.dim(`+${rest}`)}` : ''}`
|
|
852
|
+
return { lines: [fit(line, width)], cursorLine: -1, targets: cellTargets(shown, 0, 0, width) }
|
|
853
|
+
}
|
|
814
854
|
|
|
815
855
|
/** The chips of the plans the lane's steps name, in the order of its steps. */
|
|
816
856
|
const laneChips = ({ progress, chips }: LaneView): LinkChip[] =>
|
|
@@ -826,13 +866,15 @@ const pullRequestsFrame = (view: LaneView, context: LaneContext): Frame => {
|
|
|
826
866
|
)
|
|
827
867
|
}
|
|
828
868
|
|
|
829
|
-
/**
|
|
869
|
+
/**
|
|
870
|
+
* The lane line is a row of its own; an open lane continues after a blank line with its session, links, pull requests
|
|
871
|
+
* and phases (`openLaneFrame`), indented to the column of the lane's name.
|
|
872
|
+
*/
|
|
830
873
|
const laneBlock = (
|
|
831
874
|
view: LaneView,
|
|
832
|
-
state:
|
|
833
|
-
width: number,
|
|
875
|
+
{ state, width, badgeTexts, taskStyle }: LanesContext,
|
|
834
876
|
columns: LaneColumns,
|
|
835
|
-
|
|
877
|
+
reason: LaneReason | null,
|
|
836
878
|
): Frame => {
|
|
837
879
|
const context: LaneContext = {
|
|
838
880
|
progress: view.progress,
|
|
@@ -843,20 +885,20 @@ const laneBlock = (
|
|
|
843
885
|
badgeTexts,
|
|
844
886
|
tickets: view.tickets ?? null,
|
|
845
887
|
models: view.models ?? {},
|
|
888
|
+
taskStyle,
|
|
846
889
|
}
|
|
847
890
|
const open = isLaneOpen(state, view.progress.lane.name, view.status.agent)
|
|
848
|
-
const
|
|
891
|
+
const lines = laneLines(view, open, { width, taskStyle }, columns, reason)
|
|
892
|
+
const row = rowFrame(laneKey(view.progress.lane.name), lines, [], context)
|
|
849
893
|
if (!open) return row
|
|
850
|
-
const inner =
|
|
851
|
-
const head = {
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
underStatus(nest(sections)),
|
|
859
|
-
])
|
|
894
|
+
const inner = narrower(context, LANE_HEAD_CELLS)
|
|
895
|
+
const head = {
|
|
896
|
+
session: textFrame([sessionLine(view, inner.width)]),
|
|
897
|
+
chips: chipLine(chipCells(laneChips(view), inner.tickets, view.sentFiles), inner.width),
|
|
898
|
+
pullRequests: pullRequestsFrame(view, inner),
|
|
899
|
+
}
|
|
900
|
+
const children = openLaneFrame(inner, summaryOf(view), head)
|
|
901
|
+
return joinFrames([row, textFrame(['']), indented(children, ' '.repeat(LANE_HEAD_CELLS))])
|
|
860
902
|
}
|
|
861
903
|
|
|
862
904
|
const laneTally = ({ progress }: Pick<LaneView, 'progress'>): string =>
|
|
@@ -868,16 +910,49 @@ const parkedItem = (view: Pick<LaneView, 'progress'>): string => {
|
|
|
868
910
|
return label.replaceAll(' ', NON_BREAKING_SPACE)
|
|
869
911
|
}
|
|
870
912
|
|
|
871
|
-
/**
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
913
|
+
/**
|
|
914
|
+
* A block of the body: its head a row under `key` (`▸ <name> · <n> <balance>` folded, the rule after it open), and
|
|
915
|
+
* the lines of `content` under it while open.
|
|
916
|
+
*/
|
|
917
|
+
const bodyBlock = (
|
|
918
|
+
key: string,
|
|
919
|
+
head: BlockHeadParts,
|
|
920
|
+
open: boolean,
|
|
921
|
+
content: () => Frame,
|
|
922
|
+
{ state, width }: { state: Selection; width: number },
|
|
923
|
+
): Frame =>
|
|
924
|
+
joinFrames([rowFrame(key, [blockHead(head, open, width)], [], { state, width }), ...(open ? [content()] : [])])
|
|
925
|
+
|
|
926
|
+
/** The names of the lanes, dim and joined, the ones past the pane's edge left out. */
|
|
927
|
+
const laneNames = (lanes: readonly LaneView[], width: number): string =>
|
|
928
|
+
paint.dim(
|
|
929
|
+
visibleStart(
|
|
930
|
+
lanes.map(({ progress }) => progress.lane.name),
|
|
931
|
+
', ',
|
|
932
|
+
width,
|
|
933
|
+
),
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* The lanes that wait for their start, folded until the user opens them (`▸ Waiting · 2 name, other`); open,
|
|
938
|
+
* ` name 2/5 after P1 gemergt` per lane, dimmed and cut at the pane's edge.
|
|
939
|
+
*/
|
|
940
|
+
const waitingFrame = (lanes: LaneView[], state: Selection, width: number): Frame => {
|
|
941
|
+
const rows = () =>
|
|
942
|
+
textFrame(
|
|
943
|
+
lanes.map((lane) => {
|
|
944
|
+
const note = waitingNote(lane.progress.lane)
|
|
945
|
+
const item = laneTally(lane)
|
|
946
|
+
return paint.dim(fit(note === null ? ` ${item}` : ` ${item} ${TEXTS.startsAfter(plain(note))}`, width))
|
|
947
|
+
}),
|
|
948
|
+
)
|
|
949
|
+
const head = {
|
|
950
|
+
name: TEXTS.waitingHeading,
|
|
951
|
+
count: lanes.length,
|
|
952
|
+
balance: laneNames(lanes, width),
|
|
953
|
+
countPaint: unpainted,
|
|
954
|
+
}
|
|
955
|
+
return bodyBlock(WAITING_KEY, head, state.expanded.has(WAITING_KEY), rows, { state, width })
|
|
881
956
|
}
|
|
882
957
|
|
|
883
958
|
/** `· no agent (6) name 3/9 · other 1/4`: one wrapping block for every parked lane, each with its progress. */
|
|
@@ -891,16 +966,36 @@ const parkedBlock = (lanes: LaneView[], width: number): string[] => {
|
|
|
891
966
|
return besideHead(head, items.join(' '), width, (line) => paint.dim(line.replaceAll(NON_BREAKING_SPACE, ' ')))
|
|
892
967
|
}
|
|
893
968
|
|
|
969
|
+
/** The texts joined by `separator`, the ones that start past `width` cells left out: what follows is cut anyway. */
|
|
970
|
+
const visibleStart = (texts: readonly string[], separator: string, width: number): string => {
|
|
971
|
+
const shown: string[] = []
|
|
972
|
+
let used = 0
|
|
973
|
+
for (const text of texts) {
|
|
974
|
+
if (used > width) break
|
|
975
|
+
shown.push(text)
|
|
976
|
+
used += visibleLength(text) + separator.length
|
|
977
|
+
}
|
|
978
|
+
return shown.join(separator)
|
|
979
|
+
}
|
|
980
|
+
|
|
894
981
|
/**
|
|
895
|
-
* The
|
|
896
|
-
* lists each plan with its
|
|
982
|
+
* The backlog, the phases no lane works on, folded until the user opens it: `▸ Backlog · <plans> next: Plan P3, …`.
|
|
983
|
+
* Opened, it lists each plan with its next phase and its checkboxes, and no checkbox itself.
|
|
897
984
|
*/
|
|
898
|
-
const
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
985
|
+
const backlogFrame = (planned: PlannedPlan[], state: Selection, width: number): Frame => {
|
|
986
|
+
const next = visibleStart(
|
|
987
|
+
planned.map((plan) => `${plain(plan.title)} ${plannedNextLabel(plan)}`),
|
|
988
|
+
', ',
|
|
989
|
+
width,
|
|
990
|
+
)
|
|
991
|
+
const head = {
|
|
992
|
+
name: TEXTS.backlogHeading,
|
|
993
|
+
count: planned.length,
|
|
994
|
+
balance: paint.dim(`${TEXTS.backlogNext} ${next}`),
|
|
995
|
+
countPaint: unpainted,
|
|
996
|
+
}
|
|
997
|
+
const rows = () => textFrame(plannedRows(planned, width, CONTENT_INDENT).flat())
|
|
998
|
+
return bodyBlock(BACKLOG_KEY, head, state.expanded.has(BACKLOG_KEY), rows, { state, width })
|
|
904
999
|
}
|
|
905
1000
|
|
|
906
1001
|
/** A dimmed row per plan, one list of lines each: its title and open phases at the left, its open checkbox count at the right. */
|
|
@@ -917,20 +1012,20 @@ const plannedRows = (planned: PlannedPlan[], width: number, indent: string): str
|
|
|
917
1012
|
|
|
918
1013
|
const rule = (width: number): string => paint.dim(RULE.repeat(width))
|
|
919
1014
|
|
|
920
|
-
/** The
|
|
921
|
-
const
|
|
922
|
-
|
|
923
|
-
/** The grey bar of the appointment block: it waits on nobody's answer. */
|
|
924
|
-
const appointmentBar = (): string => paint.dim(WAITING_MARK)
|
|
1015
|
+
/** The blank that starts every line of a block on top: no bar, the mark of a row says what it is. */
|
|
1016
|
+
const blockBar = (): string => ' '
|
|
925
1017
|
|
|
926
1018
|
/**
|
|
927
|
-
* The head of
|
|
928
|
-
*
|
|
1019
|
+
* The head of a block, `▾ <name> · <n> <balance> ──` while open, filled with the rule to the width, so it is the rule
|
|
1020
|
+
* above the block; `▸ <name> · <n> <balance>` while folded. The count (in `countPaint` while it is more than 0) is
|
|
1021
|
+
* left out for a block without one; the balance comes painted.
|
|
929
1022
|
*/
|
|
930
|
-
const blockHead = (name
|
|
931
|
-
const
|
|
932
|
-
const
|
|
933
|
-
|
|
1023
|
+
const blockHead = ({ name, count, balance, countPaint }: BlockHeadParts, open: boolean, width: number): string => {
|
|
1024
|
+
const counted = count === null ? '' : ` ${ITEM_SEPARATOR} ${count > 0 ? countPaint(String(count)) : count}`
|
|
1025
|
+
const lead = `${paint.dim(chevronOf(open))} ${name}${counted}${balance ? ` ${balance}` : ''}`
|
|
1026
|
+
if (!open) return fit(lead, width)
|
|
1027
|
+
const cut = fit(lead, Math.max(width - 2, 1))
|
|
1028
|
+
return `${cut} ${paint.dim(RULE.repeat(Math.max(width - visibleLength(cut) - 1, 0)))}`
|
|
934
1029
|
}
|
|
935
1030
|
|
|
936
1031
|
const markedLine = (line: string, row: BlockRow, markPaint: (text: string) => string): string => {
|
|
@@ -946,133 +1041,120 @@ const dimmedRest = (text: string, from: number): string => {
|
|
|
946
1041
|
|
|
947
1042
|
/**
|
|
948
1043
|
* One row as lines: the bar on each, the mark painted on the first line only, the short title in the normal colour
|
|
949
|
-
* and the rest of an expanded row dim, linked when the row has an address
|
|
950
|
-
*
|
|
1044
|
+
* and the rest of an expanded row dim, linked when the row has an address. The buttons at the end of the first line
|
|
1045
|
+
* are drawn only on the focused row, in the block's button paint; they keep their columns on every row.
|
|
951
1046
|
*/
|
|
952
1047
|
const blockRowLines = (
|
|
953
|
-
{
|
|
1048
|
+
{ markPaint, buttonPaint, focused, allBold }: TopBlock,
|
|
954
1049
|
row: BlockRow,
|
|
955
1050
|
width: number,
|
|
956
1051
|
): string[] => {
|
|
957
1052
|
const hot = focused(row.key)
|
|
958
1053
|
const paintButton = (text: string) => (allBold(row.key) ? paint.bold(buttonPaint(text)) : buttonPaint(text))
|
|
959
1054
|
return row.lines.map((line, index) => {
|
|
960
|
-
const withButton = index === 0 && row.buttons.length > 0
|
|
1055
|
+
const withButton = hot && index === 0 && row.buttons.length > 0
|
|
961
1056
|
const styled = dimmedRest(line, row.dimFrom[index] ?? line.length)
|
|
962
1057
|
const marked = index === 0 ? markedLine(styled, row, markPaint) : styled
|
|
963
1058
|
const shown = row.url ? link(row.url, marked) : marked
|
|
964
1059
|
const buttons = withButton ? buttonsText(row.buttons, row.dim ? paint.dim : paintButton) : ''
|
|
965
|
-
const drawn = `${
|
|
1060
|
+
const drawn = `${blockBar()} ${shown}${buttons}`
|
|
966
1061
|
return hot ? focusLine(drawn, width, index === 0) : drawn
|
|
967
1062
|
})
|
|
968
1063
|
}
|
|
969
1064
|
|
|
970
|
-
/** A folded block:
|
|
971
|
-
const foldedBlockLines = (
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1065
|
+
/** A folded block: its head alone, which keeps the count and the balance; a click matches no row and no `+<n>` line. */
|
|
1066
|
+
const foldedBlockLines = (block: TopBlock, headKey: string, width: number): BlockLines => ({
|
|
1067
|
+
open: false,
|
|
1068
|
+
lines: [focusedHead(block, headKey, false, width)],
|
|
1069
|
+
hit: { rows: [], moreKey: null, headKey },
|
|
1070
|
+
})
|
|
1071
|
+
|
|
1072
|
+
/** The head of a block on top, drawn like a focused row while the arrows or the mouse are on it. */
|
|
1073
|
+
const focusedHead = (block: TopBlock, headKey: string, open: boolean, width: number): string => {
|
|
1074
|
+
const head = blockHead(block, open, width)
|
|
1075
|
+
return block.focused(headKey) ? focusLine(head, width, true) : head
|
|
979
1076
|
}
|
|
980
1077
|
|
|
981
1078
|
/**
|
|
982
|
-
* An open block on top of the herd: the head
|
|
983
|
-
*
|
|
984
|
-
*
|
|
1079
|
+
* An open block on top of the herd: the head, one line per row and `+<n>` for the rows `hidden` holds back, which turns
|
|
1080
|
+
* into `▴ <n>` once all show and folds them again. A row wider than the pane is cut to one line; once expanded it
|
|
1081
|
+
* wraps over several. The rule below is the stack's, not the block's.
|
|
985
1082
|
*/
|
|
986
|
-
const openBlockLines = (block: TopBlock, width: number): BlockLines => {
|
|
987
|
-
const
|
|
988
|
-
const
|
|
989
|
-
const moreLine = block.expanded ? `${LESS_MARK} ${more}` : `+${more}`
|
|
1083
|
+
const openBlockLines = (block: TopBlock, headKey: string, width: number): BlockLines => {
|
|
1084
|
+
const moreLine = block.expanded ? `${LESS_MARK} ${block.hidden}` : `+${block.hidden}`
|
|
1085
|
+
const withMore = block.moreKey !== null && block.hidden > 0
|
|
990
1086
|
return {
|
|
991
1087
|
open: true,
|
|
992
1088
|
lines: [
|
|
993
|
-
|
|
1089
|
+
focusedHead(block, headKey, true, width),
|
|
994
1090
|
...block.rows.flatMap((row) => blockRowLines(block, row, width)),
|
|
995
|
-
...(
|
|
1091
|
+
...(withMore ? [`${blockBar()} ${moreLine}`] : []),
|
|
996
1092
|
],
|
|
997
|
-
hit: { rows: block.rows, moreKey:
|
|
1093
|
+
hit: { rows: block.rows, moreKey: withMore ? block.moreKey : null, headKey },
|
|
998
1094
|
}
|
|
999
1095
|
}
|
|
1000
1096
|
|
|
1001
|
-
const blockLinesOf = (block: TopBlock, state: Selection, id: TopBlockId, width: number): BlockLines =>
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
/**
|
|
1005
|
-
* What waits on the user's answer, on top of the herd until it is closed: `▌ 📎 name` for a sent file or an
|
|
1006
|
-
* Artifact, `▌ ? question` for a question of the board or of a session's dialog, in the amber of a lane that waits.
|
|
1007
|
-
* One line while the user folded it.
|
|
1008
|
-
*/
|
|
1009
|
-
const waitingLines = (waiting: readonly WaitingItem[], state: Selection, width: number): BlockLines => {
|
|
1010
|
-
const keys = waitingKeys(waiting)
|
|
1011
|
-
const block: TopBlock = {
|
|
1012
|
-
name: TEXTS.waitingHeading,
|
|
1013
|
-
bar: waitingBar,
|
|
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),
|
|
1019
|
-
moreKey: WAITING_MORE_KEY,
|
|
1020
|
-
expanded: state.expanded.has(WAITING_MORE_KEY),
|
|
1021
|
-
keys,
|
|
1022
|
-
rows: waitingRows(waiting, state.expanded, width),
|
|
1023
|
-
}
|
|
1024
|
-
return blockLinesOf(block, state, TOP_BLOCK.waiting, width)
|
|
1097
|
+
const blockLinesOf = (block: TopBlock, state: Selection, id: TopBlockId, width: number): BlockLines => {
|
|
1098
|
+
const headKey = topBlockHeadKey(id)
|
|
1099
|
+
return isBlockFolded(state, id) ? foldedBlockLines(block, headKey, width) : openBlockLines(block, headKey, width)
|
|
1025
1100
|
}
|
|
1026
1101
|
|
|
1027
|
-
/**
|
|
1028
|
-
const
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
bar: appointmentBar,
|
|
1033
|
-
markPaint: paint.dim,
|
|
1034
|
-
buttonPaint: (text) => text,
|
|
1035
|
-
focused: (key) => isFocused(state, key),
|
|
1036
|
-
allBold: (key) => buttonsAllBold(state, key),
|
|
1037
|
-
countPaint: (text) => text,
|
|
1038
|
-
moreKey: APPOINTMENT_MORE_KEY,
|
|
1039
|
-
expanded: state.expanded.has(APPOINTMENT_MORE_KEY),
|
|
1040
|
-
keys,
|
|
1041
|
-
rows: blockRows(appointmentItems(rows), state.expanded, width, APPOINTMENT_MORE_KEY),
|
|
1042
|
-
}
|
|
1043
|
-
return blockLinesOf(block, state, TOP_BLOCK.appointments, width)
|
|
1044
|
-
}
|
|
1102
|
+
/** `4 block lanes · oldest 12 d`: what blocks a lane in the tone of what waits, the age dim. */
|
|
1103
|
+
const balanceText = ({ blocking, oldest }: TurnBalance): string =>
|
|
1104
|
+
[blocking === null ? '' : paintOfRole(TONE_ROLE.waiting)(blocking), oldest === null ? '' : paint.dim(oldest)]
|
|
1105
|
+
.filter(Boolean)
|
|
1106
|
+
.join(paint.dim(` ${ITEM_SEPARATOR} `))
|
|
1045
1107
|
|
|
1046
|
-
/** The
|
|
1047
|
-
const
|
|
1108
|
+
/** The mark of a row of "Your turn": `?` and `📎` in the tone of what waits, `◷` in the tone of what is planned, chevrons dim. */
|
|
1109
|
+
const turnMarkPaint = (mark: string): string => {
|
|
1110
|
+
if (mark === TURN_MARK.planned) return paintOfRole(TONE_ROLE.planned)(mark)
|
|
1111
|
+
return mark === chevronOf(true) || mark === chevronOf(false) ? paint.dim(mark) : paintOfRole(TONE_ROLE.waiting)(mark)
|
|
1112
|
+
}
|
|
1048
1113
|
|
|
1049
|
-
/**
|
|
1050
|
-
const
|
|
1114
|
+
/** "Your turn" without a point: one line that cannot be opened, its name in the column of the other block names. */
|
|
1115
|
+
const emptyTurnLines = (): BlockLines => ({
|
|
1051
1116
|
open: false,
|
|
1052
|
-
lines: [
|
|
1053
|
-
hit: { rows: [], moreKey: null
|
|
1117
|
+
lines: [`${blockBar()} ${TEXTS.yourTurnHeading} ${paint.dim(`${ITEM_SEPARATOR} ${TEXTS.yourTurnEmpty}`)}`],
|
|
1118
|
+
hit: { rows: [], moreKey: null },
|
|
1054
1119
|
})
|
|
1055
1120
|
|
|
1056
|
-
/**
|
|
1057
|
-
|
|
1058
|
-
|
|
1121
|
+
/**
|
|
1122
|
+
* What is the user's turn, on top of the herd: the head `Your turn · <n>` with its balance, folded until the user opens
|
|
1123
|
+
* it; open, the now rows and the planned rows (`turnRows`), told apart by their mark alone.
|
|
1124
|
+
*/
|
|
1125
|
+
const yourTurnLines = ({ turn, now }: YourTurnBlock, state: Selection, width: number): BlockLines => {
|
|
1126
|
+
const count = turn.now.length + turn.planned.length
|
|
1127
|
+
if (count === 0) return emptyTurnLines()
|
|
1128
|
+
const opened = { allNow: state.expanded.has(YOUR_TURN_MORE_KEY), allPlanned: state.expanded.has(YOUR_TURN_LATER_KEY) }
|
|
1129
|
+
const clock = new Date(now)
|
|
1059
1130
|
const block: TopBlock = {
|
|
1060
|
-
name: TEXTS.
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1131
|
+
name: TEXTS.yourTurnHeading,
|
|
1132
|
+
count,
|
|
1133
|
+
balance: balanceText(turnBalance(turn, clock)),
|
|
1134
|
+
markPaint: turnMarkPaint,
|
|
1135
|
+
buttonPaint: paintOfRole(TONE_ROLE.waiting),
|
|
1064
1136
|
focused: (key) => isFocused(state, key),
|
|
1065
1137
|
allBold: (key) => buttonsAllBold(state, key),
|
|
1066
|
-
countPaint: (text) => text,
|
|
1067
|
-
moreKey:
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
rows:
|
|
1138
|
+
countPaint: turn.now.length > 0 ? paintOfRole(TONE_ROLE.waiting) : (text) => text,
|
|
1139
|
+
moreKey: null,
|
|
1140
|
+
hidden: 0,
|
|
1141
|
+
expanded: false,
|
|
1142
|
+
rows: turnRows(turnPage(turn, opened, clock), width),
|
|
1071
1143
|
}
|
|
1072
|
-
return blockLinesOf(block, state, TOP_BLOCK.
|
|
1144
|
+
return blockLinesOf(block, state, TOP_BLOCK.yourTurn, width)
|
|
1073
1145
|
}
|
|
1074
1146
|
|
|
1075
|
-
/**
|
|
1147
|
+
/** `grafana 5 entries, 1 new · seobility 7 h ago`, each source in its tone, dim without one. */
|
|
1148
|
+
const sourceSummaryText = (summaries: readonly SourceSummary[]): string =>
|
|
1149
|
+
summaries
|
|
1150
|
+
.map(({ text, tone }) => (tone ? paintOfTone(tone)(text) : paint.dim(text)))
|
|
1151
|
+
.join(paint.dim(` ${ITEM_SEPARATOR} `))
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* The sources the watcher runs, as one line until the user opens it (`▸ Sources grafana 5 entries, 1 new`); open,
|
|
1155
|
+
* `● errors ran 12 min ago · <newest report>` per source, the mark amber while the report is new, red while the source
|
|
1156
|
+
* fails, and a source's entries under it once opened.
|
|
1157
|
+
*/
|
|
1076
1158
|
const sourceLines = (
|
|
1077
1159
|
{ sources, now }: SourceBlock,
|
|
1078
1160
|
awaiting: readonly WaitingItem[],
|
|
@@ -1080,19 +1162,21 @@ const sourceLines = (
|
|
|
1080
1162
|
width: number,
|
|
1081
1163
|
): BlockLines => {
|
|
1082
1164
|
const shown = state.expanded.has(SOURCE_MORE_KEY) ? sources : sources.slice(0, SHOWN_BLOCK_ROWS)
|
|
1083
|
-
const
|
|
1165
|
+
const asked = askedCheckOf(awaiting)
|
|
1166
|
+
const newTone = toneOfRole(TONE_ROLE.new)
|
|
1084
1167
|
const block: TopBlock = {
|
|
1085
1168
|
name: TEXTS.sourceHeading,
|
|
1086
|
-
|
|
1169
|
+
count: null,
|
|
1170
|
+
balance: sourceSummaryText(sourceSummaries(sources, now, newTone, asked)),
|
|
1087
1171
|
markPaint: paint.dim,
|
|
1088
1172
|
buttonPaint: (text) => text,
|
|
1089
1173
|
focused: (key) => isFocused(state, key),
|
|
1090
1174
|
allBold: (key) => buttonsAllBold(state, key),
|
|
1091
1175
|
countPaint: (text) => text,
|
|
1092
1176
|
moreKey: SOURCE_MORE_KEY,
|
|
1177
|
+
hidden: Math.max(sources.length - SHOWN_BLOCK_ROWS, 0),
|
|
1093
1178
|
expanded: state.expanded.has(SOURCE_MORE_KEY),
|
|
1094
|
-
|
|
1095
|
-
rows: allBlockRows(sourceItems(shown, now, toneOfRole(TONE_ROLE.new), view), state.expanded, width),
|
|
1179
|
+
rows: allBlockRows(sourceItems(shown, now, newTone, { expanded: state.expanded, asked }), state.expanded, width),
|
|
1096
1180
|
}
|
|
1097
1181
|
return blockLinesOf(block, state, TOP_BLOCK.sources, width)
|
|
1098
1182
|
}
|
|
@@ -1105,47 +1189,22 @@ const sourceLinesOf = (
|
|
|
1105
1189
|
): BlockLines | null =>
|
|
1106
1190
|
sourceBlock && sourceBlock.sources.length > 0 ? sourceLines(sourceBlock, awaiting, state, width) : null
|
|
1107
1191
|
|
|
1108
|
-
const appointmentLinesOf = (
|
|
1109
|
-
appointments: AppointmentBlock | undefined,
|
|
1110
|
-
state: Selection,
|
|
1111
|
-
width: number,
|
|
1112
|
-
): BlockLines | null =>
|
|
1113
|
-
appointments && appointments.rows.length > 0 ? appointmentLines(appointments, state, width) : null
|
|
1114
|
-
|
|
1115
1192
|
/**
|
|
1116
|
-
* The blocks one under the other, `offset` lines below the top of the screen,
|
|
1117
|
-
*
|
|
1118
|
-
* above (folded blocks share the rule below them), and a plain rule closes the stack. An
|
|
1119
|
-
* absent block (null) takes no line and has no hit.
|
|
1193
|
+
* The blocks one under the other, `offset` lines below the top of the screen, a blank line after each, so one stands
|
|
1194
|
+
* between neighbours and one between the last and the lanes. An absent block (null) takes no line and has no hit.
|
|
1120
1195
|
*/
|
|
1121
|
-
const stackBlocks = (slots: readonly (BlockLines | null)[], offset: number
|
|
1196
|
+
const stackBlocks = (slots: readonly (BlockLines | null)[], offset: number): BlockStack => {
|
|
1122
1197
|
const lines: string[] = []
|
|
1123
1198
|
const hits: (BlockHit | null)[] = []
|
|
1124
|
-
let previousFolded = false
|
|
1125
1199
|
for (const slot of slots) {
|
|
1126
|
-
if (slot && !slot.open && !previousFolded) lines.push(rule(width))
|
|
1127
1200
|
hits.push(slot && { ...slot.hit, firstLine: offset + lines.length + 1 })
|
|
1128
|
-
if (slot)
|
|
1129
|
-
lines.push(...slot.lines)
|
|
1130
|
-
previousFolded = !slot.open
|
|
1131
|
-
}
|
|
1201
|
+
if (slot) lines.push(...slot.lines, '')
|
|
1132
1202
|
}
|
|
1133
|
-
return { lines
|
|
1203
|
+
return { lines, hits }
|
|
1134
1204
|
}
|
|
1135
1205
|
|
|
1136
1206
|
/**
|
|
1137
|
-
*
|
|
1138
|
-
* one-line lane blocks (the first `laneCount` sections), so folded lanes stand together as a list.
|
|
1139
|
-
*/
|
|
1140
|
-
const separate = (sections: Frame[], width: number, laneCount: number): Frame[] =>
|
|
1141
|
-
sections.flatMap((frame, index) => {
|
|
1142
|
-
if (index === 0) return [frame]
|
|
1143
|
-
const listed = index < laneCount && frame.lines.length === 1 && sections[index - 1]?.lines.length === 1
|
|
1144
|
-
return listed ? [frame] : [textFrame([rule(width)]), frame]
|
|
1145
|
-
})
|
|
1146
|
-
|
|
1147
|
-
/**
|
|
1148
|
-
* Why badges may be missing, said beside the clock: no watcher runs, or its state is stale. Nothing while it is live,
|
|
1207
|
+
* Why badges may be missing, said at the right end of the title line: no watcher runs, or its state is stale. Nothing while it is live,
|
|
1149
1208
|
* or when no lane on screen has a pull request a badge could belong to and no source is marked `ticket`.
|
|
1150
1209
|
*/
|
|
1151
1210
|
const watchNotice = (
|
|
@@ -1157,7 +1216,7 @@ const watchNotice = (
|
|
|
1157
1216
|
return watch.kind === WATCH_HEALTH.off ? texts.watcherOff : texts.stale(watch.minutes)
|
|
1158
1217
|
}
|
|
1159
1218
|
|
|
1160
|
-
/** `groomed 3d ago`
|
|
1219
|
+
/** `groomed 3d ago` at the right end of the title line, dim, amber once the board is overdue; empty while it is unknown. */
|
|
1161
1220
|
const groomedCell = (days: number | null | undefined): string => {
|
|
1162
1221
|
if (days === null || days === undefined) return ''
|
|
1163
1222
|
const { text, stale } = groomedText(days)
|
|
@@ -1185,6 +1244,57 @@ const laneColumnsOf = (lanes: LaneView[], width: number): LaneColumns => {
|
|
|
1185
1244
|
return { name, marks, right: rightOf(phases ? tally : 0), phases, tally }
|
|
1186
1245
|
}
|
|
1187
1246
|
|
|
1247
|
+
const reasonInputOf = ({ progress, status, restNote }: LaneView): ReasonInput => ({
|
|
1248
|
+
lane: progress.lane,
|
|
1249
|
+
agent: status.agent,
|
|
1250
|
+
restNote,
|
|
1251
|
+
})
|
|
1252
|
+
|
|
1253
|
+
/** `1 waits on you · 3 idle`: what waits on you in the tone of what waits, the idle count dim; a part at 0 left out. */
|
|
1254
|
+
const lanesBalanceText = (lanes: readonly LaneView[]): string => {
|
|
1255
|
+
const { waitsOnYou, idle } = lanesBalance(lanes.map(reasonInputOf))
|
|
1256
|
+
return [
|
|
1257
|
+
waitsOnYou > 0 ? paintOfRole(TONE_ROLE.waiting)(TEXTS.lanesWaitOnYou(waitsOnYou)) : '',
|
|
1258
|
+
idle > 0 ? paint.dim(TEXTS.lanesIdle(idle)) : '',
|
|
1259
|
+
]
|
|
1260
|
+
.filter(Boolean)
|
|
1261
|
+
.join(paint.dim(` ${ITEM_SEPARATOR} `))
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* The lanes at work under the head `▾ Lanes · <n> <balance>`, open at the start; each lane indented under it, a
|
|
1266
|
+
* blank line after an open lane, none above it, so its row keeps its screen line when it opens and folds under the
|
|
1267
|
+
* mouse; none between folded lanes, so they stand together as a list, a folded lane's task row included. `rows` are
|
|
1268
|
+
* the rows of "Your turn" a resting lane may wait on.
|
|
1269
|
+
*/
|
|
1270
|
+
const lanesFrame = (lanes: LaneView[], rows: readonly UnblockingRow[], context: LanesContext): Frame => {
|
|
1271
|
+
const { state, width } = context
|
|
1272
|
+
const inner = Math.max(width - LANE_INDENT.length, 1)
|
|
1273
|
+
const columns = laneColumnsOf(lanes, inner)
|
|
1274
|
+
const isOpen = ({ progress, status }: LaneView) => isLaneOpen(state, progress.lane.name, status.agent)
|
|
1275
|
+
const content = () => {
|
|
1276
|
+
const laneList = lanes.flatMap((lane, index) => {
|
|
1277
|
+
const frame = laneBlock(lane, { ...context, width: inner }, columns, laneReason(reasonInputOf(lane), rows))
|
|
1278
|
+
const previous = lanes[index - 1]
|
|
1279
|
+
return previous && isOpen(previous) ? [textFrame(['']), frame] : [frame]
|
|
1280
|
+
})
|
|
1281
|
+
return indented(joinFrames(laneList), LANE_INDENT)
|
|
1282
|
+
}
|
|
1283
|
+
const head = {
|
|
1284
|
+
name: TEXTS.lanesHeading,
|
|
1285
|
+
count: lanes.length,
|
|
1286
|
+
balance: lanesBalanceText(lanes),
|
|
1287
|
+
countPaint: unpainted,
|
|
1288
|
+
}
|
|
1289
|
+
return bodyBlock(LANES_KEY, head, isLanesOpen(state), content, { state, width })
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/** The status each lane's agent reports, by the lane's name, for the footer's click hint. */
|
|
1293
|
+
const agentOfLanes =
|
|
1294
|
+
(lanes: readonly LaneView[]): AgentOf =>
|
|
1295
|
+
(name) =>
|
|
1296
|
+
lanes.find(({ progress }) => progress.lane.name === name)?.status.agent ?? null
|
|
1297
|
+
|
|
1188
1298
|
/** The lanes by what the view does with them; closed lanes are in none of the three groups. */
|
|
1189
1299
|
const partitionLanes = (lanes: LaneView[]): Record<'active' | 'waiting' | 'noAgent', LaneView[]> => {
|
|
1190
1300
|
const standing = (wanted: LaneStanding) => lanes.filter((lane) => laneStanding(lane) === wanted)
|
|
@@ -1216,72 +1326,65 @@ const emphasisedAt = (
|
|
|
1216
1326
|
}
|
|
1217
1327
|
|
|
1218
1328
|
/**
|
|
1219
|
-
*
|
|
1220
|
-
*
|
|
1221
|
-
*
|
|
1222
|
-
*
|
|
1329
|
+
* The title on top, no clock (it would change the frame every minute and reset the refresh pace), then the blocks "Your turn" and "Sources"; the body below them scrolls: "Lanes" with one
|
|
1330
|
+
* line per lane at work and an open lane's children, "Waiting" for the lanes not started, the lanes with a worktree
|
|
1331
|
+
* but no agent, and the "Backlog"; a blank line between neighbouring blocks, none inside one. At the bottom what a
|
|
1332
|
+
* click does on the focused row, else the key hint, and the legend after `?`. Finished lanes are not drawn.
|
|
1223
1333
|
*/
|
|
1224
1334
|
export const renderScreen = (
|
|
1225
1335
|
{
|
|
1226
1336
|
title,
|
|
1227
|
-
time,
|
|
1228
1337
|
lanes,
|
|
1229
1338
|
planned,
|
|
1230
1339
|
watch,
|
|
1231
1340
|
watchesTickets,
|
|
1232
1341
|
awaiting = [],
|
|
1233
|
-
|
|
1234
|
-
appointments,
|
|
1342
|
+
yourTurn,
|
|
1235
1343
|
sourceBlock,
|
|
1236
1344
|
groomedDays,
|
|
1237
1345
|
unplaced = [],
|
|
1346
|
+
shimmer,
|
|
1238
1347
|
}: OverviewView,
|
|
1239
1348
|
state: Selection,
|
|
1240
1349
|
width: number,
|
|
1241
1350
|
): Screen => {
|
|
1351
|
+
const taskStyle = shimmer ? SHIMMERING_TASK : STILL_TASK
|
|
1242
1352
|
const { active, waiting, noAgent } = partitionLanes(lanes)
|
|
1243
|
-
const parked = [...waitingBlock(waiting, width), ...parkedBlock(noAgent, width)]
|
|
1244
|
-
const columns = laneColumnsOf(active, width)
|
|
1245
1353
|
const badgeTexts = BADGE_TEXTS
|
|
1246
|
-
const
|
|
1354
|
+
const turnRowsOf = yourTurn ? [...yourTurn.turn.now, ...yourTurn.turn.planned] : []
|
|
1247
1355
|
const notice = watchNotice({ lanes: active, watch, watchesTickets }, badgeTexts)
|
|
1248
|
-
const
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
const
|
|
1252
|
-
const forYou = awaiting.length > 0 ? waitingLines(awaiting, state, width) : null
|
|
1253
|
-
const queued = next ? nextLines(next, state, width) : null
|
|
1254
|
-
const dated = appointmentLinesOf(appointments, state, width)
|
|
1255
|
-
const nowLines = unplaced.map((task) => nowLine(task, width))
|
|
1356
|
+
const status = [notice ? paint.yellow(notice) : '', groomedCell(groomedDays)].filter(Boolean).join(COLUMN_SEPARATOR)
|
|
1357
|
+
const titleLine = spread(`${HEADER_MARK} ${paint.bold(title)}`, status, width)
|
|
1358
|
+
const turn = yourTurn ? yourTurnLines(yourTurn, state, width) : null
|
|
1359
|
+
const nowLines = unplaced.map((task) => nowLine(task, width, taskStyle))
|
|
1256
1360
|
const above = nowLines.length > 0 ? [titleLine, rule(width), ...nowLines] : [titleLine]
|
|
1257
1361
|
const listed = sourceLinesOf(sourceBlock, awaiting, state, width)
|
|
1258
|
-
const stack = stackBlocks([
|
|
1259
|
-
const endsWithRule = stack.lines.length > 0 || nowLines.length === 0
|
|
1362
|
+
const stack = stackBlocks([turn, listed], above.length)
|
|
1260
1363
|
const sections = [
|
|
1261
|
-
...
|
|
1262
|
-
...(
|
|
1263
|
-
|
|
1364
|
+
...(active.length > 0 ? [lanesFrame(active, turnRowsOf, { state, width, badgeTexts, taskStyle })] : []),
|
|
1365
|
+
...(waiting.length > 0 ? [waitingFrame(waiting, state, width)] : []),
|
|
1366
|
+
textFrame(parkedBlock(noAgent, width)),
|
|
1367
|
+
...(planned.length > 0 ? [backlogFrame(planned, state, width)] : []),
|
|
1264
1368
|
]
|
|
1265
|
-
const
|
|
1266
|
-
const
|
|
1267
|
-
const plainBody = joinFrames([...lead, ...separate(sections, width, blocks.length)])
|
|
1369
|
+
const plainHeader = [...above, ...(stack.lines.length > 0 ? stack.lines : [''])]
|
|
1370
|
+
const plainBody = spaced(sections)
|
|
1268
1371
|
const blockHits = stack.hits.flatMap(focusHitsOfBlock)
|
|
1269
1372
|
const focusHits = [...blockHits, ...focusHitsOfTargets(plainBody.targets, plainHeader.length)]
|
|
1270
1373
|
const header = emphasisedAt(plainHeader, 0, focusHits, state, width)
|
|
1271
1374
|
const body = { ...plainBody, lines: emphasisedAt(plainBody.lines, plainHeader.length, focusHits, state, width) }
|
|
1272
1375
|
return {
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
sourceBlock: stack.hits[3],
|
|
1277
|
-
focusHits,
|
|
1376
|
+
yourTurnBlock: stack.hits[0],
|
|
1377
|
+
sourceBlock: stack.hits[1],
|
|
1378
|
+
focusHits: state.showLegend ? focusHits.filter(({ line }) => line < plainHeader.length) : focusHits,
|
|
1278
1379
|
rowKeys: [...blockHits.map(({ key }) => key), ...targetKeys(body.targets)],
|
|
1279
1380
|
header,
|
|
1280
|
-
body,
|
|
1381
|
+
body: state.showLegend ? textFrame(legendLines(TEXTS.legend, width)) : body,
|
|
1281
1382
|
footer: [
|
|
1282
1383
|
rule(width),
|
|
1283
|
-
fit(
|
|
1284
|
-
|
|
1384
|
+
fit(
|
|
1385
|
+
paint.dim(clickHint(state, (key) => blockRowByKey(stack.hits, key), agentOfLanes(active)) ?? TEXTS.hint),
|
|
1386
|
+
width,
|
|
1387
|
+
),
|
|
1285
1388
|
],
|
|
1286
1389
|
}
|
|
1287
1390
|
}
|
|
@@ -1297,11 +1400,14 @@ export const renderSelected = (overview: OverviewView, selection: Selection, wid
|
|
|
1297
1400
|
}
|
|
1298
1401
|
|
|
1299
1402
|
/**
|
|
1300
|
-
* The selection with its row resolved against `screen`: a row the screen draws stays, a block row included;
|
|
1301
|
-
* first row of the body is selected,
|
|
1403
|
+
* The selection with its row resolved against `screen`: a row the screen draws stays, a block row or head included;
|
|
1404
|
+
* else the first row of the body that is no block head is selected (the first lane), else the first head, none when
|
|
1405
|
+
* the body has no row.
|
|
1302
1406
|
*/
|
|
1303
1407
|
export const settledSelection = (selection: Selection, screen: Screen): Selection => {
|
|
1304
1408
|
const kept = selection.selected !== null && screen.rowKeys.includes(selection.selected)
|
|
1305
|
-
const
|
|
1409
|
+
const keys = targetKeys(screen.body.targets)
|
|
1410
|
+
const rows = keys.filter((key) => !isBlockHeadKey(key))
|
|
1411
|
+
const selected = kept ? selection.selected : selectedKey(selection, rows.length > 0 ? rows : keys)
|
|
1306
1412
|
return selected === selection.selected ? selection : { ...selection, selected }
|
|
1307
1413
|
}
|