jackherd 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/INSTALL.md +1 -1
- package/README.md +65 -32
- package/dist/{chunk-MCP4XJQH.js → chunk-RMMXD4G5.js} +18 -6
- package/dist/config.d.ts +99 -4
- package/dist/config.js +105 -29
- package/dist/plan-format.d.ts +51 -6
- package/dist/plan-format.js +49 -7
- package/docs/workflow-dark.svg +67 -63
- package/docs/workflow.svg +67 -63
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +15 -0
- package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
- package/kit/hooks/hooks/ask-hint/index.ts +15 -19
- package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
- package/kit/hooks/hooks/git-guard/decision.ts +50 -102
- package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
- package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
- package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
- package/kit/hooks/hooks/merge-guard/index.ts +6 -3
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
- package/kit/hooks/hooks/merge-guard/run.ts +59 -54
- package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
- package/kit/hooks/hooks/open-question/index.ts +26 -0
- package/kit/hooks/hooks/open-question/record.ts +135 -0
- package/kit/hooks/hooks/open-question/run.ts +57 -0
- package/kit/hooks/hooks/open-question/store.ts +39 -0
- package/kit/hooks/hooks/sent-file/store.ts +3 -12
- package/kit/hooks/hooks/shared/config.ts +7 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
- package/kit/hooks/hooks/shared/files.ts +7 -0
- package/kit/hooks/hooks/shared/lane.ts +1 -1
- package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
- package/kit/hooks/hooks/shared/private-directory.ts +56 -9
- package/kit/hooks/hooks/shared/program-position.ts +2 -75
- package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
- package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
- package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
- package/kit/hooks/hooks/task-hint/decision.ts +49 -0
- package/kit/hooks/hooks/task-hint/index.ts +79 -0
- package/kit/hooks/hooks/task-hint/run.ts +62 -0
- package/kit/hooks/hooks/task-hint/store.ts +51 -0
- package/kit/hooks/hooks.json +13 -5
- package/kit/review/agents/skill-review.md +34 -0
- package/kit/review/skills/pre-commit/SKILL.md +1 -1
- package/kit/rules/rules/kit/pull-requests.md +2 -0
- package/kit/workflow/skills/follow/SKILL.md +3 -10
- package/kit/workflow/skills/implement/SKILL.md +8 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +218 -433
- package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
- package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
- package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
- package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
- package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
- package/kit/workflow/skills/orchestrate/references/events.md +86 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
- package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
- package/kit/workflow/skills/plan/SKILL.md +6 -5
- package/kit/workflow/skills/plan/references/template-English.md +4 -4
- package/kit/workflow/skills/plan/references/template-German.md +4 -4
- package/package.json +3 -1
- package/recipes/README.md +2 -1
- package/recipes/sources.md +5 -1
- package/schemas/jackherd.schema.json +139 -5
- package/src/check/agent-text-reviewers.ts +23 -0
- package/src/check/command.ts +2 -0
- package/src/check/plan-structure-check.ts +49 -2
- package/src/check/plan.ts +8 -1
- package/src/check/schema.ts +9 -0
- package/src/check/skill-budget-defaults.ts +55 -0
- package/src/check/skill-budgets.ts +185 -0
- package/src/cli.ts +2 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +82 -25
- package/src/config/tones.ts +27 -0
- package/src/lane/close-board.ts +106 -0
- package/src/lane/close-check.ts +183 -0
- package/src/lane/close-command-line.ts +11 -0
- package/src/lane/close-command.ts +217 -0
- package/src/lane/close-options.ts +43 -0
- package/src/lane/close-survey.ts +83 -0
- package/src/lane/close.ts +141 -0
- package/src/lane/command.ts +13 -0
- package/src/lane-prompt/additions.ts +14 -0
- package/src/lane-prompt/build.ts +74 -0
- package/src/lane-prompt/command.ts +93 -0
- package/src/lane-prompt/input.ts +157 -0
- package/src/lane-prompt/options.ts +70 -0
- package/src/lane-prompt/texts.ts +101 -0
- package/src/mermaid/command.ts +36 -0
- package/src/mermaid/problems.ts +48 -0
- package/src/plan-format/clock.ts +8 -0
- package/src/plan-format/dependencies.ts +9 -6
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/waiting.ts +22 -4
- package/src/plans/appointment-rows.ts +83 -0
- package/src/plans/ask-coordinator.ts +111 -0
- package/src/plans/ask-target.ts +36 -0
- package/src/plans/board-close.ts +85 -0
- package/src/plans/child-stop.ts +51 -0
- package/src/plans/closable-records.ts +34 -0
- package/src/plans/command.ts +3 -2
- package/src/plans/fetch.ts +26 -3
- package/src/plans/frame.ts +5 -12
- package/src/plans/git.ts +54 -5
- package/src/plans/groomed.ts +27 -0
- package/src/plans/herd-claim.ts +173 -0
- package/src/plans/herd-memory.ts +36 -25
- package/src/plans/lane-plans.ts +44 -13
- package/src/plans/lane-summary.ts +253 -45
- package/src/plans/layout-phase.ts +36 -11
- package/src/plans/layout-summary.ts +89 -18
- package/src/plans/layout.ts +502 -98
- package/src/plans/link-chips.ts +23 -4
- package/src/plans/live.ts +178 -23
- package/src/plans/next-rows.ts +44 -0
- package/src/plans/phase-ids.ts +8 -0
- package/src/plans/phase-source.ts +15 -0
- package/src/plans/plan-cache.ts +28 -0
- package/src/plans/plan-heads.ts +54 -0
- package/src/plans/planned.ts +22 -0
- package/src/plans/pull-requests.ts +17 -2
- package/src/plans/question-records.ts +48 -0
- package/src/plans/questions-read.ts +13 -0
- package/src/plans/read-cache.ts +8 -0
- package/src/plans/record-id.ts +4 -0
- package/src/plans/restart-decision.ts +13 -4
- 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 +111 -18
- package/src/plans/sent-files-read.ts +6 -15
- package/src/plans/sent-files.ts +7 -14
- package/src/plans/settings.ts +12 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +12 -4
- package/src/plans/snapshot.ts +162 -81
- 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/sprint-export.ts +11 -11
- package/src/plans/sprint-page.ts +5 -5
- package/src/plans/subagent-phases.ts +95 -0
- package/src/plans/subagent-tasks.ts +51 -0
- package/src/plans/subagents-read.ts +66 -17
- package/src/plans/subagents.ts +64 -6
- package/src/plans/supervisor.ts +14 -4
- package/src/plans/symbols.ts +3 -0
- package/src/plans/task-name.ts +34 -0
- package/src/plans/texts.ts +48 -6
- package/src/plans/tones.ts +30 -0
- package/src/plans/view.ts +25 -11
- package/src/plans/waiting-click.ts +295 -35
- package/src/plans/waiting-records.ts +39 -0
- package/src/plans/waiting-window.ts +8 -0
- package/src/preview/command.ts +25 -3
- package/src/shared/private-location.ts +89 -0
- package/src/shared/stat-cache.ts +48 -0
- package/src/sync/kit-source.ts +29 -10
- package/src/sync/kit.ts +2 -0
- package/src/sync/manifest.ts +2 -0
- package/src/sync/plan-ask-rules.ts +94 -0
- package/src/sync/plan-hooks.ts +2 -0
- package/src/sync/render.ts +3 -3
- package/src/sync/settings-hooks.ts +4 -1
- package/src/sync/sync-plan.ts +1 -0
- package/src/sync/sync.ts +24 -3
- package/src/task-signals/claude-read.ts +27 -10
- package/src/terminal/text.ts +16 -3
- package/src/terminal/types.ts +4 -0
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +26 -6
- package/src/waiting/command.ts +22 -7
- package/src/watch/appointments.ts +1 -2
- package/src/watch/asking-lanes.ts +40 -0
- package/src/watch/base-tip.ts +11 -0
- package/src/watch/command.ts +330 -105
- package/src/watch/heartbeat-progress.ts +6 -18
- package/src/watch/kit-update.ts +28 -9
- package/src/watch/lane-name.ts +6 -0
- package/src/watch/lane-progress.ts +40 -8
- package/src/watch/lane-report-file.ts +20 -5
- package/src/watch/lane-report.ts +32 -4
- package/src/watch/lane-sources.ts +31 -3
- package/src/watch/main-checkout.ts +34 -0
- package/src/watch/plan-badges-read.ts +22 -9
- package/src/watch/plan-badges.ts +10 -2
- package/src/watch/pull-requests.ts +6 -2
- package/src/watch/reference-paths.ts +87 -0
- package/src/watch/report-questions.ts +4 -3
- package/src/watch/running-subagents.ts +43 -0
- package/src/watch/skill-change.ts +124 -32
- package/src/watch/source-change.ts +48 -0
- 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 +47 -4
- package/src/watch/stream-output.ts +4 -3
- package/src/watch/task-parallel-auto.ts +208 -0
- package/src/watch/task-parallel-data-paths.ts +46 -0
- package/src/watch/task-parallel-input.ts +5 -15
- package/src/watch/task-parallel-read.ts +3 -1
- package/src/watch/task-parallel.ts +36 -7
- package/src/watch/task-signal-missing.ts +69 -0
- package/src/watch/task-signal-read.ts +29 -0
- package/src/watch/texts.ts +10 -0
- package/src/watch/update-available.ts +6 -3
- package/src/watch/watch-events.ts +23 -0
- package/src/watch/watcher-lock.ts +25 -29
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
- package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
- package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
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,
|
|
@@ -28,7 +30,9 @@ import {
|
|
|
28
30
|
type WatchHealth,
|
|
29
31
|
} from '../watch/plan-badges'
|
|
30
32
|
import type { UnplannedTask } from './active-tasks'
|
|
33
|
+
import { type AppointmentBlock, appointmentItems, appointmentKeys } from './appointment-rows'
|
|
31
34
|
import { deeper, type Frame, joinFrames, nest, prefixed, rowFrame, TREE, textFrame } from './frame'
|
|
35
|
+
import { groomedText } from './groomed'
|
|
32
36
|
import { LANE_STANDING, type LaneStanding, laneStanding, waitingNote } from './lane-standing'
|
|
33
37
|
import { type LaneSummary, laneSummary } from './lane-summary'
|
|
34
38
|
import { type LaneTickets, withoutTicketKey } from './lane-tickets'
|
|
@@ -41,25 +45,57 @@ import {
|
|
|
41
45
|
nowLine,
|
|
42
46
|
plain,
|
|
43
47
|
} from './layout-phase'
|
|
44
|
-
import { summaryChildren } from './layout-summary'
|
|
48
|
+
import { foldedTaskLine, summaryChildren } from './layout-summary'
|
|
45
49
|
import { chipKey, type LinkChip, planUrlOf } from './link-chips'
|
|
50
|
+
import { type NextBlock, nextItems, nextKeys } from './next-rows'
|
|
46
51
|
import type { PhaseModel } from './phase-models'
|
|
47
|
-
import { type PlannedPlan,
|
|
52
|
+
import { type PlannedPlan, plannedNextLabel } from './planned'
|
|
48
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'
|
|
49
64
|
import {
|
|
50
65
|
type ClickTarget,
|
|
66
|
+
isBlockFolded,
|
|
51
67
|
isLaneOpen,
|
|
52
68
|
laneKey,
|
|
53
69
|
PLANNED_KEY,
|
|
54
70
|
type Selection,
|
|
55
71
|
selectedKey,
|
|
72
|
+
TOP_BLOCK,
|
|
73
|
+
type TopBlockId,
|
|
56
74
|
targetKeys,
|
|
57
75
|
} from './selection'
|
|
58
76
|
import type { SentChip } from './sent-files'
|
|
59
77
|
import { shownWithMore } from './shown-with-more'
|
|
60
|
-
import {
|
|
78
|
+
import { askedCheckOf } from './source-asked'
|
|
79
|
+
import { type SourceBlock, sourceItems, sourceKeys } from './source-rows'
|
|
80
|
+
import { chevronOf, LESS_MARK, NO_AGENT_SYMBOL, statusSymbol, statusWord } from './symbols'
|
|
61
81
|
import { BADGE_TEXTS, type BadgeTexts, checksText, laneMarkText, TEXTS } from './texts'
|
|
62
|
-
import {
|
|
82
|
+
import { paintOfRole, paintOfTone, toneOfRole } from './tones'
|
|
83
|
+
import {
|
|
84
|
+
APPOINTMENT_MORE_KEY,
|
|
85
|
+
allBlockRows,
|
|
86
|
+
type BlockHit,
|
|
87
|
+
type BlockRow,
|
|
88
|
+
blockRows,
|
|
89
|
+
buttonsText,
|
|
90
|
+
moreKeyOf,
|
|
91
|
+
NEXT_MORE_KEY,
|
|
92
|
+
SHOWN_BLOCK_ROWS,
|
|
93
|
+
SOURCE_MORE_KEY,
|
|
94
|
+
WAITING_MARK,
|
|
95
|
+
WAITING_MORE_KEY,
|
|
96
|
+
waitingKeys,
|
|
97
|
+
waitingRows,
|
|
98
|
+
} from './waiting-click'
|
|
63
99
|
|
|
64
100
|
/**
|
|
65
101
|
* Where a lane stands outside its plan. `agent` is the terminal adapter's status, null when no agent is listed;
|
|
@@ -92,6 +128,8 @@ export type LaneView = {
|
|
|
92
128
|
models?: Record<string, PhaseModel>
|
|
93
129
|
/** The files this lane's session sent to the user, shown as badges on the row of link chips. */
|
|
94
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
|
|
95
133
|
}
|
|
96
134
|
|
|
97
135
|
/** `watch` is the health of the watcher the badges come from, absent when it was not read. */
|
|
@@ -105,13 +143,36 @@ export type OverviewView = {
|
|
|
105
143
|
watchesTickets?: boolean
|
|
106
144
|
/** The open items of the board's section of what waits on the user, shown under the title. */
|
|
107
145
|
awaiting?: WaitingItem[]
|
|
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. */
|
|
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
|
|
108
154
|
/** What agents report doing that no lane takes, one `now:` line each under the title. */
|
|
109
155
|
unplaced?: UnplannedTask[]
|
|
110
156
|
}
|
|
111
157
|
|
|
112
|
-
/**
|
|
113
|
-
|
|
114
|
-
|
|
158
|
+
/**
|
|
159
|
+
* The drawn screen: a fixed header and footer around a body that scrolls; body lines and targets count from the body's
|
|
160
|
+
* top. `waitingBlock` and `appointmentBlock` are what a click on the for-you and the appointment block is matched
|
|
161
|
+
* against, null while the block is not drawn.
|
|
162
|
+
*/
|
|
163
|
+
export type Screen = {
|
|
164
|
+
header: string[]
|
|
165
|
+
body: Frame
|
|
166
|
+
footer: string[]
|
|
167
|
+
waitingBlock: BlockHit | null
|
|
168
|
+
nextBlock: BlockHit | null
|
|
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[]
|
|
175
|
+
}
|
|
115
176
|
|
|
116
177
|
/**
|
|
117
178
|
* Widths shared by every lane line: the name column, the folded pull request badges, and bar with context. They are
|
|
@@ -121,8 +182,38 @@ export type Screen = { header: string[]; body: Frame; footer: string[]; waitingL
|
|
|
121
182
|
*/
|
|
122
183
|
type LaneColumns = { name: number; marks: number; right: number; phases: boolean; tally: number }
|
|
123
184
|
|
|
185
|
+
/**
|
|
186
|
+
* The lines of a block on top of the herd; the head of an open one is a rule of its own, a folded one is a plain line.
|
|
187
|
+
* `hit` is what a click on it is matched against, without the screen line the stack gives it.
|
|
188
|
+
*/
|
|
189
|
+
type BlockLines = { open: boolean; lines: string[]; hit: Omit<BlockHit, 'firstLine'> }
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* What a block of the top of the herd is drawn from: its name, the bar of its lines, the paint of the mark that starts
|
|
193
|
+
* a row, the key of its `+<n>` line, its points and their rows.
|
|
194
|
+
*/
|
|
195
|
+
type TopBlock = {
|
|
196
|
+
name: string
|
|
197
|
+
bar: () => string
|
|
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
|
|
205
|
+
/** The paint of the count in the head while the block has points: amber for what waits on the user. */
|
|
206
|
+
countPaint: (text: string) => string
|
|
207
|
+
moreKey: string
|
|
208
|
+
expanded: boolean
|
|
209
|
+
keys: readonly string[]
|
|
210
|
+
rows: BlockRow[]
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The blocks on top of the herd one under the other: their lines below the title, and the hit of each drawn block. */
|
|
214
|
+
type BlockStack = { lines: string[]; hits: (BlockHit | null)[] }
|
|
215
|
+
|
|
124
216
|
const BRANCH_MARK = '⎇'
|
|
125
|
-
const WOOF = 'woof'
|
|
126
217
|
const SENT_MARK = '📎'
|
|
127
218
|
const SYMBOL_WIDTH = 2
|
|
128
219
|
const NAME_WIDTH_MAX = 24
|
|
@@ -140,7 +231,7 @@ const COLUMN_RULE = '│'
|
|
|
140
231
|
/** The number badge of a three-digit pull request (`↗ #` and the digits, a cell of padding either side); a longer one widens it. */
|
|
141
232
|
const PULL_REQUEST_BADGE_WIDTH = 8
|
|
142
233
|
/**
|
|
143
|
-
* Under the lane line's status symbol: where the pull request row and its badge line start
|
|
234
|
+
* Under the lane line's status symbol: where the pull request row and its badge line start, and where the trunk
|
|
144
235
|
* of an open lane hangs, not under the fold arrow.
|
|
145
236
|
*/
|
|
146
237
|
const UNDER_STATUS = ' '
|
|
@@ -156,12 +247,10 @@ const TEST_PATH_LEVEL = ' > '
|
|
|
156
247
|
const TEST_PATH_SEPARATOR = ' › '
|
|
157
248
|
const PLACE_SEPARATOR = ' · '
|
|
158
249
|
|
|
159
|
-
/** The colour of a lane state, word and symbol alike:
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
[AGENT_STATUS.done]: paint.teal,
|
|
164
|
-
[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)
|
|
165
254
|
}
|
|
166
255
|
|
|
167
256
|
const CHECKS_PAINT: Record<ChecksState, (text: string) => string> = {
|
|
@@ -173,13 +262,13 @@ const CHECKS_PAINT: Record<ChecksState, (text: string) => string> = {
|
|
|
173
262
|
|
|
174
263
|
/** Only a blocked agent is named in the lane line, since there the user must act; the symbol says the rest. */
|
|
175
264
|
const agentLabel = (agent: string | null): string =>
|
|
176
|
-
agent === AGENT_STATUS.blocked ?
|
|
265
|
+
agent === AGENT_STATUS.blocked ? statusPaint(AGENT_STATUS.blocked)(statusWord(agent)) : ''
|
|
177
266
|
|
|
178
267
|
/** The symbol of a status padded to `SYMBOL_WIDTH` cells, so the names after it start in one column. */
|
|
179
268
|
const symbolCell = (agent: string | null): string => {
|
|
180
269
|
const symbol = statusSymbol(agent)
|
|
181
270
|
return padVisible(
|
|
182
|
-
agent !== null && isAgentStatus(agent) ?
|
|
271
|
+
agent !== null && isAgentStatus(agent) ? statusPaint(agent)(symbol) : paint.dim(symbol),
|
|
183
272
|
SYMBOL_WIDTH,
|
|
184
273
|
)
|
|
185
274
|
}
|
|
@@ -189,12 +278,11 @@ const aheadLabel = ({ branch, ahead }: LaneStatus): string => {
|
|
|
189
278
|
return ahead > 0 ? paint.teal(`↑${ahead}`) : ''
|
|
190
279
|
}
|
|
191
280
|
|
|
281
|
+
const branchLabel = (status: LaneStatus): string =>
|
|
282
|
+
status.branch ? paint.dim(`${BRANCH_MARK} ${status.branch}`) : paint.dim(TEXTS.noWorktree)
|
|
283
|
+
|
|
192
284
|
const gitLabel = (status: LaneStatus): string =>
|
|
193
|
-
[
|
|
194
|
-
status.branch ? paint.dim(`${BRANCH_MARK} ${status.branch}`) : paint.dim(TEXTS.noWorktree),
|
|
195
|
-
aheadLabel(status),
|
|
196
|
-
status.dirty ? paint.magenta(`±${status.dirty}`) : '',
|
|
197
|
-
]
|
|
285
|
+
[branchLabel(status), aheadLabel(status), status.dirty ? paint.magenta(`±${status.dirty}`) : '']
|
|
198
286
|
.filter(Boolean)
|
|
199
287
|
.join(' ')
|
|
200
288
|
|
|
@@ -236,9 +324,9 @@ export const rowWithCount = (
|
|
|
236
324
|
const fitsBeside = (head: string, cells: BadgeCell[], width: number): boolean =>
|
|
237
325
|
visibleLength(head) + packedWidth(cells) + COLUMN_SEPARATOR.length + MIN_TITLE_WIDTH <= width
|
|
238
326
|
|
|
239
|
-
/** 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. */
|
|
240
328
|
const numberCell = (pullRequest: PullRequest): string =>
|
|
241
|
-
padVisible(
|
|
329
|
+
padVisible(bracket(pullRequest.url, `↗ #${pullRequest.number}`), PULL_REQUEST_BADGE_WIDTH)
|
|
242
330
|
|
|
243
331
|
/** The column the check chip of a pull request row starts in, behind its number. */
|
|
244
332
|
const checksColumnOf = (pullRequest: PullRequest): number =>
|
|
@@ -247,7 +335,7 @@ const checksColumnOf = (pullRequest: PullRequest): number =>
|
|
|
247
335
|
/**
|
|
248
336
|
* `⟦↗ #<number>⟧ ✓ checks ⟦🌐 preview⟧ ⟦💬 3 new⟧ title`: the number as a badge and the check state in fixed
|
|
249
337
|
* columns, the watcher's badges after them and the title last, cut with `…` on its one line. When the badges leave no
|
|
250
|
-
* room for a title, they go on lines of their own below, starting in the column of the lane's status symbol
|
|
338
|
+
* room for a title, they go on lines of their own below, starting in the column of the lane's status symbol.
|
|
251
339
|
*/
|
|
252
340
|
const pullRequestRow = (pullRequest: PullRequest, context: LaneContext): Frame => {
|
|
253
341
|
const number = numberCell(pullRequest)
|
|
@@ -382,16 +470,18 @@ const progressColumn = ({ done, total, phasesDone, phases }: LaneSummary['tally'
|
|
|
382
470
|
return `${counts}${paint.dim(phaseCount)}`
|
|
383
471
|
}
|
|
384
472
|
|
|
385
|
-
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
|
|
386
476
|
|
|
387
477
|
const TICKET_MARK = '📡'
|
|
388
478
|
const STATUS_SEPARATOR = ' · '
|
|
389
479
|
/** Between the checkbox count and the phase count of a lane. */
|
|
390
480
|
const TALLY_SEPARATOR = ' · '
|
|
391
481
|
|
|
392
|
-
/**
|
|
482
|
+
/** `[✦ label]` linked to the chip's address, `private` faint inside it for a link its plan says is private. */
|
|
393
483
|
const chipCell = ({ url, icon, label, private: hidden }: LinkChip): string =>
|
|
394
|
-
|
|
484
|
+
bracket(url, `${icon} ${fit(plain(label), CHIP_LABEL_WIDTH)}`, hidden ? TEXTS.privateLink : '')
|
|
395
485
|
|
|
396
486
|
/** A badge and the key a click on it opens; null for a cell no click opens. */
|
|
397
487
|
type BadgeCell = { cell: string; key: string | null }
|
|
@@ -435,9 +525,11 @@ const packedFrame = (cells: BadgeCell[], lead: string, width: number): Frame =>
|
|
|
435
525
|
|
|
436
526
|
const ticketTone = (isNew: boolean): BadgeTone => (isNew ? BADGE_TONE.amber : BADGE_TONE.grey)
|
|
437
527
|
|
|
438
|
-
/** A
|
|
439
|
-
const linkedBox = (url: string | null, text: string, tone: BadgeTone): string =>
|
|
440
|
-
|
|
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
|
+
}
|
|
441
533
|
|
|
442
534
|
/** `📡 T-1 · In Progress` linked to the ticket, amber while new; a ticket without URL is a box no click opens. */
|
|
443
535
|
const ticketCell = ({ id, url, status, isNew }: TicketBadge): BadgeCell => {
|
|
@@ -454,7 +546,7 @@ const ticketCells = (tickets: LaneTickets | null): BadgeCell[] => {
|
|
|
454
546
|
|
|
455
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. */
|
|
456
548
|
const sentCell = ({ url, label }: SentChip): BadgeCell => ({
|
|
457
|
-
cell:
|
|
549
|
+
cell: bracket(url, `${SENT_MARK} ${plain(label)}`),
|
|
458
550
|
key: null,
|
|
459
551
|
})
|
|
460
552
|
|
|
@@ -474,7 +566,7 @@ const chipCells = (chips: LinkChip[], tickets: LaneTickets | null, sent: readonl
|
|
|
474
566
|
|
|
475
567
|
/**
|
|
476
568
|
* `🌐 preview` opening the preview, `💬 <n> new` in amber opening the pull request, and `✋ changes requested` in dark
|
|
477
|
-
* red opening it too, each only when there is one
|
|
569
|
+
* red opening it too, each only when there is one.
|
|
478
570
|
*/
|
|
479
571
|
const badgeCells = (
|
|
480
572
|
pullRequest: PullRequest,
|
|
@@ -487,22 +579,24 @@ const badgeCells = (
|
|
|
487
579
|
key: chipKey(pullRequest.url),
|
|
488
580
|
})
|
|
489
581
|
return [
|
|
490
|
-
...(badges.preview ? [{ cell:
|
|
582
|
+
...(badges.preview ? [{ cell: bracket(badges.preview, texts.preview), key: chipKey(badges.preview) }] : []),
|
|
491
583
|
...(badges.feedback > 0 ? [onPullRequest(texts.feedback(badges.feedback), BADGE_TONE.amber)] : []),
|
|
492
584
|
...(badges.changesRequested ? [onPullRequest(texts.changesRequested, BADGE_TONE.red)] : []),
|
|
493
585
|
]
|
|
494
586
|
}
|
|
495
587
|
|
|
496
588
|
/**
|
|
497
|
-
* The phase the lane line names: the
|
|
498
|
-
* ticked to the end, whose state the `✓` line says
|
|
589
|
+
* The phase the lane line names: the one its report or branch signals, else the open phase moved last, else the
|
|
590
|
+
* lane's current phase while it is open; never one ticked to the end, whose state the `✓` line says. Null when there
|
|
591
|
+
* is none.
|
|
499
592
|
*/
|
|
500
593
|
const headPhase = (view: LaneView): PhaseProgress | null => {
|
|
594
|
+
const { signalled, latest } = summaryOf(view)
|
|
501
595
|
const current = currentPhase(view.progress)
|
|
502
|
-
return
|
|
596
|
+
return signalled ?? latest ?? (current && isPhaseOpen(current) ? current : null)
|
|
503
597
|
}
|
|
504
598
|
|
|
505
|
-
/** `P9
|
|
599
|
+
/** `P9 Release 1.0` for the phase the lane line names; empty when there is none. */
|
|
506
600
|
const nextPhaseLabel = (view: LaneView): string => {
|
|
507
601
|
const phase = headPhase(view)
|
|
508
602
|
return phase ? `${phaseLabel(phase.id)}${phase.title ? ` ${plain(phase.title)}` : ''}` : ''
|
|
@@ -521,7 +615,7 @@ const pullRequestMark = (pullRequest: PullRequest, badges: WatchBadges): string
|
|
|
521
615
|
const found = badges[String(pullRequest.number)] ?? NO_BADGES
|
|
522
616
|
const feedback = found.feedback > 0 ? ` 💬 ${found.feedback}` : ''
|
|
523
617
|
const text = `↗ #${pullRequest.number}${feedback}${found.changesRequested ? ' ✋' : ''}`
|
|
524
|
-
return
|
|
618
|
+
return linkedBox(pullRequest.url, text, markTone(found))
|
|
525
619
|
}
|
|
526
620
|
|
|
527
621
|
/** `📡 <n>` in amber while tickets of the lane are new, linked to the first of them; nothing otherwise. */
|
|
@@ -546,11 +640,35 @@ const foldedPhaseLabel = (view: LaneView): string => {
|
|
|
546
640
|
return `${nextPhaseLabel(view)}${more > 0 ? ` +${more}` : ''} · ${phase.done}/${phase.total}`
|
|
547
641
|
}
|
|
548
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
|
+
|
|
549
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. */
|
|
550
667
|
const laneDetail = (view: LaneView, open: boolean): string => {
|
|
551
668
|
const markText = laneMarkText(view)
|
|
552
669
|
const marker = markText ? paint.yellow(markText) : ''
|
|
553
|
-
|
|
670
|
+
const phase = restsFolded(view, open) ? restDetail(view) : open ? '' : foldedPhaseLabel(view)
|
|
671
|
+
return [agentLabel(view.status.agent), marker, phase].filter(Boolean).join(' ')
|
|
554
672
|
}
|
|
555
673
|
|
|
556
674
|
/**
|
|
@@ -583,14 +701,14 @@ const laneProgressColumn = (view: LaneView, tallyWidth: number): string => {
|
|
|
583
701
|
|
|
584
702
|
/** Context and bar at the right edge of a folded lane line, as wide as the lane has them. */
|
|
585
703
|
const laneRight = (view: LaneView, tallyWidth: number): string => {
|
|
586
|
-
const counts = laneProgressColumn(view, tallyWidth)
|
|
704
|
+
const counts = restsFolded(view, false) ? '' : laneProgressColumn(view, tallyWidth)
|
|
587
705
|
return counts ? `${contextCell(view.context)}${counts}` : contextCell(view.context).trimEnd()
|
|
588
706
|
}
|
|
589
707
|
|
|
590
708
|
/** The cells of the phase count column, 0 when the pane leaves it out. */
|
|
591
709
|
const tallyWidthOf = ({ phases, tally }: LaneColumns): number => (phases ? tally : 0)
|
|
592
710
|
|
|
593
|
-
/** A folded lane's marks in the column of `columns.marks`, right before context and bar
|
|
711
|
+
/** A folded lane's marks in the column of `columns.marks`, right before context and bar. */
|
|
594
712
|
const markedRight = (view: LaneView, columns: LaneColumns): string => {
|
|
595
713
|
const right = laneRight(view, tallyWidthOf(columns))
|
|
596
714
|
const marks = laneMarks(view)
|
|
@@ -602,7 +720,7 @@ const markedRight = (view: LaneView, columns: LaneColumns): string => {
|
|
|
602
720
|
|
|
603
721
|
/**
|
|
604
722
|
* Before a lane's detail on a line of its own: blanks up to the column of the lane's name, so a folded lane's title
|
|
605
|
-
* starts where its name does
|
|
723
|
+
* starts where its name does, as the branches of an open lane do; an open lane keeps its trunk in front.
|
|
606
724
|
*/
|
|
607
725
|
const detailIndent = (open: boolean, nameColumn: number): string => {
|
|
608
726
|
if (!open) return ' '.repeat(nameColumn)
|
|
@@ -610,19 +728,31 @@ const detailIndent = (open: boolean, nameColumn: number): string => {
|
|
|
610
728
|
return `${paint.dim(trunk)}${' '.repeat(Math.max(nameColumn - visibleLength(trunk), 0))}`
|
|
611
729
|
}
|
|
612
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[] => {
|
|
733
|
+
const phase = headPhase(view)
|
|
734
|
+
const { active: running, subagentTasks } = summaryOf(view)
|
|
735
|
+
const active = running.find((each) => each.phase.id === phase?.id)
|
|
736
|
+
const line = active && foldedTaskLine(active, subagentTasks, TEXTS.herd, indent, width)
|
|
737
|
+
return line ? [line] : []
|
|
738
|
+
}
|
|
739
|
+
|
|
613
740
|
const laneLines = (view: LaneView, open: boolean, width: number, columns: LaneColumns): string[] => {
|
|
614
741
|
const { progress, status } = view
|
|
615
|
-
const
|
|
742
|
+
const nameStyle = restsFolded(view, open) ? paint.dim : paint.bold
|
|
743
|
+
const cells = padVisible(nameStyle(fit(progress.lane.name, columns.name)), columns.name)
|
|
616
744
|
const head = `${paint.dim(chevronOf(open))} ${symbolCell(status.agent)} ${cells}`
|
|
617
745
|
const right = open ? laneProgressColumn(view, tallyWidthOf(columns)) : markedRight(view, columns)
|
|
618
746
|
const nameColumn = visibleLength(head) - visibleLength(cells)
|
|
619
|
-
|
|
747
|
+
const lines = withDetail(head, laneDetail(view, open), right, width, detailIndent(open, nameColumn))
|
|
748
|
+
return open ? lines : [...lines, ...foldedTaskRow(view, ' '.repeat(nameColumn), width)]
|
|
620
749
|
}
|
|
621
750
|
|
|
622
751
|
/** The parts of a session line: the session's title (the branch without one) at the left, the branch and the context value. */
|
|
623
|
-
const sessionParts = (view: LaneView): { left: string; git: string; context: string } => {
|
|
752
|
+
const sessionParts = (view: LaneView): { left: string; git: string; branch: string; context: string } => {
|
|
624
753
|
const git = gitLabel(view.status)
|
|
625
|
-
|
|
754
|
+
const branch = branchLabel(view.status)
|
|
755
|
+
return { left: view.sessionTitle ? plain(view.sessionTitle) : git, git, branch, context: contextValue(view.context) }
|
|
626
756
|
}
|
|
627
757
|
|
|
628
758
|
/**
|
|
@@ -631,31 +761,36 @@ const sessionParts = (view: LaneView): { left: string; git: string; context: str
|
|
|
631
761
|
* title cut. Without a title the branch stands at the left.
|
|
632
762
|
*/
|
|
633
763
|
const sessionLine = (view: LaneView, width: number): string => {
|
|
634
|
-
const { left, git, context } = sessionParts(view)
|
|
764
|
+
const { left, git, branch, context } = sessionParts(view)
|
|
635
765
|
if (!view.sessionTitle) return spread(fit(git, Math.max(width - CONTEXT_CELL_WIDTH, 1)), context, width)
|
|
636
766
|
const room = width - (context ? CONTEXT_CELL_WIDTH : 0)
|
|
637
|
-
const
|
|
638
|
-
|
|
767
|
+
const fitting = [git, branch].find(
|
|
768
|
+
(label) => visibleLength(left) + COLUMN_SEPARATOR.length + visibleLength(label) <= room,
|
|
769
|
+
)
|
|
770
|
+
const right = [fitting ?? '', context].filter(Boolean).join(COLUMN_SEPARATOR)
|
|
639
771
|
return spread(fit(left, Math.max(room, 1)), right, width)
|
|
640
772
|
}
|
|
641
773
|
|
|
642
774
|
/**
|
|
643
775
|
* The session line with the lane's one link chip on it, so the lane needs no row of chips: title, chip, branch and
|
|
644
|
-
* context
|
|
645
|
-
* the
|
|
776
|
+
* context. When the line is short the ahead and dirty counts of the branch go first, then the title is cut with `…`;
|
|
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.
|
|
646
779
|
*/
|
|
647
780
|
const sessionWithChip = (view: LaneView, chip: BadgeCell, width: number): Frame | null => {
|
|
648
|
-
const { left, git, context } = sessionParts(view)
|
|
781
|
+
const { left, git, branch, context } = sessionParts(view)
|
|
649
782
|
const tails = view.sessionTitle
|
|
650
783
|
? [
|
|
651
784
|
[chip.cell, git, context],
|
|
652
|
-
[chip.cell, context],
|
|
785
|
+
[chip.cell, branch, context],
|
|
653
786
|
]
|
|
654
787
|
: [[chip.cell, context]]
|
|
655
|
-
const
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
|
659
794
|
const from = width - visibleLength(right)
|
|
660
795
|
const targets = chip.key === null ? [] : [{ line: 0, from, to: from + visibleLength(chip.cell), key: chip.key }]
|
|
661
796
|
return { lines: [spread(left, right, width)], cursorLine: -1, targets }
|
|
@@ -762,9 +897,9 @@ const parkedBlock = (lanes: LaneView[], width: number): string[] => {
|
|
|
762
897
|
*/
|
|
763
898
|
const plannedFrame = (planned: PlannedPlan[], state: Selection, width: number): Frame => {
|
|
764
899
|
if (planned.length === 0) return textFrame([])
|
|
765
|
-
const phases = plannedPhaseCount(planned)
|
|
766
900
|
const open = state.expanded.has(PLANNED_KEY)
|
|
767
|
-
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))]
|
|
768
903
|
return rowFrame(PLANNED_KEY, head, open ? plannedRows(planned, width, CONTENT_INDENT).flat() : [], { state, width })
|
|
769
904
|
}
|
|
770
905
|
|
|
@@ -773,7 +908,7 @@ const plannedRows = (planned: PlannedPlan[], width: number, indent: string): str
|
|
|
773
908
|
planned.map((plan) =>
|
|
774
909
|
rowWithCount(
|
|
775
910
|
indent,
|
|
776
|
-
`${plain(plan.title)} · ${
|
|
911
|
+
`${plain(plan.title)} · ${plannedNextLabel(plan)}`,
|
|
777
912
|
countCell('', countText(plan.done, plan.total)),
|
|
778
913
|
width,
|
|
779
914
|
paint.dim,
|
|
@@ -782,32 +917,232 @@ const plannedRows = (planned: PlannedPlan[], width: number, indent: string): str
|
|
|
782
917
|
|
|
783
918
|
const rule = (width: number): string => paint.dim(RULE.repeat(width))
|
|
784
919
|
|
|
785
|
-
/** The
|
|
786
|
-
const
|
|
787
|
-
|
|
788
|
-
|
|
920
|
+
/** The amber bar that starts every line of the for-you block: the one signal that this waits on the user. */
|
|
921
|
+
const waitingBar = (): string => statusPaint(AGENT_STATUS.blocked)(WAITING_MARK)
|
|
922
|
+
|
|
923
|
+
/** The grey bar of the appointment block: it waits on nobody's answer. */
|
|
924
|
+
const appointmentBar = (): string => paint.dim(WAITING_MARK)
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* The head of an open block, `▾ <name> · <n> ──`, filled with the rule to the width, so it is the rule above the block.
|
|
928
|
+
* The label and the count wear the normal colour (the count in `countPaint`), only the rule is dim.
|
|
929
|
+
*/
|
|
930
|
+
const blockHead = (name: string, count: number, width: number, countPaint: (text: string) => string): string => {
|
|
931
|
+
const lead = `${chevronOf(true)} ${name} ${ITEM_SEPARATOR} `
|
|
932
|
+
const used = textWidth(`${lead}${count} `)
|
|
933
|
+
return `${lead}${countPaint(String(count))} ${paint.dim(RULE.repeat(Math.max(width - used, 0)))}`
|
|
789
934
|
}
|
|
790
935
|
|
|
791
|
-
|
|
792
|
-
const
|
|
793
|
-
|
|
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
|
+
}
|
|
940
|
+
|
|
941
|
+
/** The text from `from` on dimmed, without the blanks that pad it to the right edge. */
|
|
942
|
+
const dimmedRest = (text: string, from: number): string => {
|
|
943
|
+
const end = text.trimEnd().length
|
|
944
|
+
return from >= end ? text : `${text.slice(0, from)}${paint.dim(text.slice(from, end))}${text.slice(end)}`
|
|
945
|
+
}
|
|
794
946
|
|
|
795
947
|
/**
|
|
796
|
-
*
|
|
797
|
-
* and
|
|
798
|
-
*
|
|
948
|
+
* 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; the `[ask]` button at the end of a
|
|
950
|
+
* question's first line wears the mark's paint.
|
|
799
951
|
*/
|
|
800
|
-
const
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
|
961
|
+
const styled = dimmedRest(line, row.dimFrom[index] ?? line.length)
|
|
962
|
+
const marked = index === 0 ? markedLine(styled, row, markPaint) : styled
|
|
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
|
|
967
|
+
})
|
|
968
|
+
}
|
|
969
|
+
|
|
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. */
|
|
971
|
+
const foldedBlockLines = ({ name, keys, rows }: TopBlock, width: number): BlockLines => {
|
|
972
|
+
const preview = rows[0]?.preview
|
|
973
|
+
const head = `${chevronOf(false)} ${name} ${ITEM_SEPARATOR} ${keys.length}`
|
|
974
|
+
return {
|
|
975
|
+
open: false,
|
|
976
|
+
lines: [paint.dim(fit(preview === undefined ? head : `${head} ${preview}`, width))],
|
|
977
|
+
hit: { rows: [], moreKey: null, keys },
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* An open block on top of the herd: the head `▾ <name> · <n> ──`, one barred line per row (the first five, all while the
|
|
983
|
+
* block is expanded) and `▌ +<n>` for the rest, which turns into `▌ ▴ <n>` once all show and folds them again. A row wider than the pane is cut to
|
|
984
|
+
* one line; once expanded it wraps over several. The rule below is the stack's, not the block's.
|
|
985
|
+
*/
|
|
986
|
+
const openBlockLines = (block: TopBlock, width: number): BlockLines => {
|
|
987
|
+
const count = block.keys.length
|
|
988
|
+
const more = Math.max(count - SHOWN_BLOCK_ROWS, 0)
|
|
989
|
+
const moreLine = block.expanded ? `${LESS_MARK} ${more}` : `+${more}`
|
|
990
|
+
return {
|
|
991
|
+
open: true,
|
|
992
|
+
lines: [
|
|
993
|
+
blockHead(block.name, count, width, count > 0 ? block.countPaint : (text) => text),
|
|
994
|
+
...block.rows.flatMap((row) => blockRowLines(block, row, width)),
|
|
995
|
+
...(more > 0 ? [`${block.bar()} ${moreLine}`] : []),
|
|
996
|
+
],
|
|
997
|
+
hit: { rows: block.rows, moreKey: moreKeyOf(count, block.moreKey), keys: block.keys },
|
|
998
|
+
}
|
|
806
999
|
}
|
|
807
1000
|
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
1001
|
+
const blockLinesOf = (block: TopBlock, state: Selection, id: TopBlockId, width: number): BlockLines =>
|
|
1002
|
+
isBlockFolded(state, id, block.keys) ? foldedBlockLines(block, width) : openBlockLines(block, width)
|
|
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)
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** The open appointments of the board under the for-you block: `▌ ◷ Mo 09:03 Release 2.0 rollout`, in a grey bar and with a grey mark. */
|
|
1028
|
+
const appointmentLines = ({ rows }: AppointmentBlock, state: Selection, width: number): BlockLines => {
|
|
1029
|
+
const keys = appointmentKeys(rows)
|
|
1030
|
+
const block: TopBlock = {
|
|
1031
|
+
name: TEXTS.appointmentHeading,
|
|
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
|
+
}
|
|
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
|
+
|
|
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
|
+
/**
|
|
1116
|
+
* The blocks one under the other, `offset` lines below the top of the screen, with one rule between neighbours: the
|
|
1117
|
+
* head of an open block is that rule, a folded block gets a plain one above it unless a folded block stands right
|
|
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.
|
|
1120
|
+
*/
|
|
1121
|
+
const stackBlocks = (slots: readonly (BlockLines | null)[], offset: number, width: number): BlockStack => {
|
|
1122
|
+
const lines: string[] = []
|
|
1123
|
+
const hits: (BlockHit | null)[] = []
|
|
1124
|
+
let previousFolded = false
|
|
1125
|
+
for (const slot of slots) {
|
|
1126
|
+
if (slot && !slot.open && !previousFolded) lines.push(rule(width))
|
|
1127
|
+
hits.push(slot && { ...slot.hit, firstLine: offset + lines.length + 1 })
|
|
1128
|
+
if (slot) {
|
|
1129
|
+
lines.push(...slot.lines)
|
|
1130
|
+
previousFolded = !slot.open
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
return { lines: lines.length > 0 ? [...lines, rule(width)] : lines, hits }
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* A dimmed rule before every section of the body but the first, which shows where one ends; none between two
|
|
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
|
+
})
|
|
811
1146
|
|
|
812
1147
|
/**
|
|
813
1148
|
* Why badges may be missing, said beside the clock: no watcher runs, or its state is stale. Nothing while it is live,
|
|
@@ -822,6 +1157,13 @@ const watchNotice = (
|
|
|
822
1157
|
return watch.kind === WATCH_HEALTH.off ? texts.watcherOff : texts.stale(watch.minutes)
|
|
823
1158
|
}
|
|
824
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
|
+
|
|
825
1167
|
/** Chevron, symbol and the gaps around them before the name column of a lane line. */
|
|
826
1168
|
const LANE_HEAD_CELLS = 2 + SYMBOL_WIDTH + 1
|
|
827
1169
|
|
|
@@ -853,14 +1195,47 @@ const partitionLanes = (lanes: LaneView[]): Record<'active' | 'waiting' | 'noAge
|
|
|
853
1195
|
}
|
|
854
1196
|
}
|
|
855
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
|
+
|
|
856
1218
|
/**
|
|
857
1219
|
* Title and clock on top, the key hint at the bottom (with the legend after `?`), and in between one
|
|
858
1220
|
* block per lane: the lane line with its total bar, and for an open lane the phase rows. Folded lanes are
|
|
859
|
-
* one line each and
|
|
1221
|
+
* one line each, and a dimmed rule stands between lane blocks unless both are one line. Lanes that wait and
|
|
860
1222
|
* lanes with a worktree but no agent each share a closing block; finished lanes are not drawn.
|
|
861
1223
|
*/
|
|
862
1224
|
export const renderScreen = (
|
|
863
|
-
{
|
|
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,
|
|
864
1239
|
state: Selection,
|
|
865
1240
|
width: number,
|
|
866
1241
|
): Screen => {
|
|
@@ -870,20 +1245,39 @@ export const renderScreen = (
|
|
|
870
1245
|
const badgeTexts = BADGE_TEXTS
|
|
871
1246
|
const blocks = active.map((lane) => laneBlock(lane, state, width, columns, badgeTexts))
|
|
872
1247
|
const notice = watchNotice({ lanes: active, watch, watchesTickets }, badgeTexts)
|
|
873
|
-
const clock = notice ?
|
|
874
|
-
|
|
1248
|
+
const clock = [notice ? paint.yellow(notice) : '', groomedCell(groomedDays), paint.dim(time)]
|
|
1249
|
+
.filter(Boolean)
|
|
1250
|
+
.join(COLUMN_SEPARATOR)
|
|
1251
|
+
const titleLine = spread(`${HEADER_MARK} ${paint.bold(title)}`, clock, width)
|
|
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))
|
|
1256
|
+
const above = nowLines.length > 0 ? [titleLine, rule(width), ...nowLines] : [titleLine]
|
|
1257
|
+
const listed = sourceLinesOf(sourceBlock, awaiting, state, width)
|
|
1258
|
+
const stack = stackBlocks([forYou, queued, dated, listed], above.length, width)
|
|
1259
|
+
const endsWithRule = stack.lines.length > 0 || nowLines.length === 0
|
|
1260
|
+
const sections = [
|
|
1261
|
+
...blocks,
|
|
1262
|
+
...(parked.length > 0 ? [textFrame(parked)] : []),
|
|
1263
|
+
...(planned.length > 0 ? [plannedFrame(planned, state, width)] : []),
|
|
1264
|
+
]
|
|
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) }
|
|
875
1272
|
return {
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
],
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
...(parked.length > 0 ? [textFrame([rule(width), ...parked])] : []),
|
|
885
|
-
...(planned.length > 0 ? [textFrame([rule(width)]), plannedFrame(planned, state, width)] : []),
|
|
886
|
-
]),
|
|
1273
|
+
waitingBlock: stack.hits[0],
|
|
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,
|
|
887
1281
|
footer: [
|
|
888
1282
|
rule(width),
|
|
889
1283
|
fit(paint.dim(TEXTS.hint), width),
|
|
@@ -898,6 +1292,16 @@ export const renderScreen = (
|
|
|
898
1292
|
*/
|
|
899
1293
|
export const renderSelected = (overview: OverviewView, selection: Selection, width: number): Screen => {
|
|
900
1294
|
const first = renderScreen(overview, selection, width)
|
|
901
|
-
const
|
|
902
|
-
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 }
|
|
903
1307
|
}
|