jackherd 1.8.1 → 1.10.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 +60 -0
- package/INSTALL.md +3 -1
- package/README.md +75 -64
- package/dist/{chunk-OU2KALXP.js → chunk-5MQBCUQ5.js} +24 -4
- package/dist/config.d.ts +45 -4
- package/dist/config.js +84 -27
- package/dist/plan-format.d.ts +63 -7
- package/dist/plan-format.js +66 -12
- 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/approval-gate/index.ts +22 -4
- package/kit/hooks/hooks/approval-gate/run.ts +29 -6
- package/kit/hooks/hooks/approval-gate/subagent.ts +18 -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 -140
- 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 +52 -0
- package/kit/hooks/hooks/open-question/store.ts +39 -0
- package/kit/hooks/hooks/sent-file/index.ts +19 -0
- package/kit/hooks/hooks/sent-file/record.ts +128 -0
- package/kit/hooks/hooks/sent-file/run.ts +48 -0
- package/kit/hooks/hooks/sent-file/store.ts +9 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
- 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 +21 -4
- package/kit/hooks/statusline.sh +1 -1
- package/kit/review/agents/skill-review.md +34 -0
- package/kit/review/skills/pre-commit/SKILL.md +1 -1
- package/kit/workflow/skills/follow/SKILL.md +1 -8
- package/kit/workflow/skills/implement/SKILL.md +10 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +213 -412
- 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 +4 -1
- package/recipes/README.md +2 -1
- package/recipes/sources.md +1 -1
- package/schemas/jackherd.schema.json +85 -7
- 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 +4 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +80 -26
- package/src/context/fill.ts +12 -0
- package/src/context/session-log.ts +43 -1
- 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/build.ts +74 -0
- package/src/lane-prompt/command.ts +87 -0
- package/src/lane-prompt/input.ts +116 -0
- package/src/lane-prompt/options.ts +68 -0
- package/src/lane-prompt/texts.ts +101 -0
- package/src/plan-format/clock.ts +12 -0
- package/src/plan-format/dependencies.ts +9 -9
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/lanes.ts +30 -5
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/terminal-text.ts +4 -0
- package/src/plan-format/waiting.ts +53 -0
- package/src/plans/active-tasks.ts +60 -0
- package/src/plans/appointment-rows.ts +59 -0
- package/src/plans/ask-coordinator.ts +77 -0
- package/src/plans/ask-target.ts +25 -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/current-phase.ts +17 -7
- package/src/plans/current-task.ts +63 -5
- package/src/plans/fetch.ts +26 -3
- package/src/plans/git.ts +19 -5
- 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-standing.ts +9 -4
- package/src/plans/lane-summary.ts +367 -92
- package/src/plans/lane-tickets.ts +15 -0
- package/src/plans/layout-phase.ts +80 -19
- package/src/plans/layout-summary.ts +163 -74
- package/src/plans/layout.ts +472 -114
- package/src/plans/link-chips.ts +1 -0
- package/src/plans/live.ts +119 -10
- package/src/plans/phase-ids.ts +8 -0
- package/src/plans/phase-models.ts +1 -1
- package/src/plans/phase-ranges.ts +45 -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/pull-requests.ts +23 -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/selection.ts +117 -34
- package/src/plans/sent-files-read.ts +13 -0
- package/src/plans/sent-files.ts +114 -0
- package/src/plans/settings.ts +2 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +62 -0
- package/src/plans/snapshot.ts +279 -60
- 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 +131 -0
- package/src/plans/subagents.ts +190 -0
- 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 +36 -14
- package/src/plans/unlisted-lanes.ts +10 -5
- package/src/plans/view.ts +20 -12
- package/src/plans/waiting-click.ts +231 -0
- package/src/plans/waiting-records.ts +39 -0
- package/src/plans/waiting-window.ts +8 -0
- package/src/preview/command.ts +105 -0
- package/src/preview/embed.ts +209 -0
- package/src/preview/images.ts +89 -0
- package/src/preview/open.ts +12 -0
- package/src/preview/options.ts +32 -0
- package/src/preview/page.ts +89 -0
- package/src/preview/render.ts +41 -0
- package/src/settled/board-row.ts +1 -2
- package/src/shared/is-record.ts +2 -0
- package/src/shared/private-location.ts +89 -0
- package/src/shared/private-records.ts +48 -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/agent.ts +15 -0
- package/src/task-signals/claude-read.ts +89 -0
- package/src/task-signals/claude-signals.ts +112 -0
- package/src/task-signals/command.ts +71 -0
- package/src/task-signals/fixtures/session.jsonl +7 -0
- package/src/task-signals/matcher.ts +96 -0
- package/src/task-signals/record-store.ts +40 -0
- package/src/task-signals/record.ts +68 -0
- package/src/task-signals/text.ts +13 -0
- package/src/task-signals/types.ts +14 -0
- package/src/terminal/open.ts +13 -6
- package/src/terminal/text.ts +16 -4
- package/src/terminal/width.ts +2 -1
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +44 -6
- package/src/waiting/command.ts +50 -0
- package/src/watch/appointments.ts +2 -4
- package/src/watch/approvals.ts +2 -2
- package/src/watch/asking-lanes.ts +40 -0
- package/src/watch/base-tip.ts +11 -0
- package/src/watch/command.ts +401 -90
- package/src/watch/failed-tests.ts +56 -10
- package/src/watch/failure-read.ts +18 -6
- package/src/watch/failures.ts +29 -5
- package/src/watch/feedback.ts +1 -1
- package/src/watch/gh-process.ts +9 -3
- package/src/watch/heartbeat-progress.ts +6 -18
- package/src/watch/json-guards.ts +0 -3
- package/src/watch/kit-update.ts +28 -9
- package/src/watch/lane-fill.ts +1 -1
- package/src/watch/lane-model.ts +1 -1
- package/src/watch/lane-name.ts +6 -0
- package/src/watch/lane-parallel.ts +1 -1
- package/src/watch/lane-progress.ts +58 -14
- package/src/watch/lane-report-file.ts +20 -5
- package/src/watch/lane-report.ts +37 -5
- package/src/watch/lane-rest.ts +28 -0
- package/src/watch/lane-sources.ts +31 -3
- package/src/watch/main-checkout.ts +34 -0
- package/src/watch/plan-badges-read.ts +13 -7
- package/src/watch/plan-badges.ts +9 -2
- package/src/watch/previews.ts +1 -1
- package/src/watch/pull-requests.ts +8 -3
- package/src/watch/reference-paths.ts +87 -0
- 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-path.ts +1 -1
- package/src/watch/sources.ts +7 -4
- package/src/watch/stream-output.ts +14 -8
- 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 +108 -0
- package/src/watch/task-parallel-read.ts +151 -0
- package/src/watch/task-parallel-touched.ts +39 -0
- package/src/watch/task-parallel.ts +235 -0
- 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/waiting-line.ts +19 -0
- package/src/watch/watch-events.ts +36 -1
- package/src/watch/watcher-lock.ts +25 -29
- package/src/watch/workflow-runs.ts +2 -1
- 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,6 +1,7 @@
|
|
|
1
1
|
import { CONTEXT_LEVEL, type ContextLevel, type ContextReading, contextLabel } from '../context'
|
|
2
2
|
import type { LaneProgress, PhaseProgress } from '../plan-format'
|
|
3
3
|
import { currentPhase, isPhaseOpen, phaseLabel } from '../plan-format'
|
|
4
|
+
import type { WaitingItem } from '../plan-format/waiting'
|
|
4
5
|
import {
|
|
5
6
|
BADGE_TONE,
|
|
6
7
|
type BadgeTone,
|
|
@@ -8,6 +9,7 @@ import {
|
|
|
8
9
|
bar,
|
|
9
10
|
box,
|
|
10
11
|
fit,
|
|
12
|
+
link,
|
|
11
13
|
packGroups,
|
|
12
14
|
padVisible,
|
|
13
15
|
paint,
|
|
@@ -17,7 +19,6 @@ import {
|
|
|
17
19
|
} from '../terminal/text'
|
|
18
20
|
import { AGENT_STATUS, type AgentStatus, isAgentStatus } from '../terminal/types'
|
|
19
21
|
import { textWidth } from '../terminal/width'
|
|
20
|
-
import { PLAN_LANGUAGE } from '../vocabulary'
|
|
21
22
|
import {
|
|
22
23
|
NO_BADGES,
|
|
23
24
|
type PullRequestBadges,
|
|
@@ -26,28 +27,55 @@ import {
|
|
|
26
27
|
type WatchBadges,
|
|
27
28
|
type WatchHealth,
|
|
28
29
|
} from '../watch/plan-badges'
|
|
30
|
+
import type { UnplannedTask } from './active-tasks'
|
|
31
|
+
import { type AppointmentBlock, appointmentItems, appointmentKeys } from './appointment-rows'
|
|
29
32
|
import { deeper, type Frame, joinFrames, nest, prefixed, rowFrame, TREE, textFrame } from './frame'
|
|
30
33
|
import { LANE_STANDING, type LaneStanding, laneStanding, waitingNote } from './lane-standing'
|
|
31
34
|
import { type LaneSummary, laneSummary } from './lane-summary'
|
|
32
|
-
import type
|
|
33
|
-
import {
|
|
34
|
-
|
|
35
|
+
import { type LaneTickets, withoutTicketKey } from './lane-tickets'
|
|
36
|
+
import {
|
|
37
|
+
COLUMN_SEPARATOR,
|
|
38
|
+
countCell,
|
|
39
|
+
countText,
|
|
40
|
+
type LaneContext,
|
|
41
|
+
MIN_TITLE_WIDTH,
|
|
42
|
+
nowLine,
|
|
43
|
+
plain,
|
|
44
|
+
} from './layout-phase'
|
|
45
|
+
import { foldedTaskLine, summaryChildren } from './layout-summary'
|
|
35
46
|
import { chipKey, type LinkChip, planUrlOf } from './link-chips'
|
|
36
47
|
import type { PhaseModel } from './phase-models'
|
|
37
48
|
import { type PlannedPlan, phaseList, plannedPhaseCount } from './planned'
|
|
38
|
-
import { CHECKS, type ChecksState, type PullRequest, pullRequestKey } from './pull-requests'
|
|
49
|
+
import { CHECKS, type ChecksState, failedTestKey, type PullRequest, pullRequestKey } from './pull-requests'
|
|
39
50
|
import {
|
|
40
51
|
type ClickTarget,
|
|
52
|
+
isBlockFolded,
|
|
41
53
|
isLaneOpen,
|
|
42
54
|
laneKey,
|
|
43
55
|
PLANNED_KEY,
|
|
44
56
|
type Selection,
|
|
45
57
|
selectedKey,
|
|
58
|
+
TOP_BLOCK,
|
|
59
|
+
type TopBlockId,
|
|
46
60
|
targetKeys,
|
|
47
61
|
} from './selection'
|
|
62
|
+
import type { SentChip } from './sent-files'
|
|
48
63
|
import { shownWithMore } from './shown-with-more'
|
|
49
|
-
import { chevronOf,
|
|
64
|
+
import { chevronOf, LESS_MARK, NO_AGENT_SYMBOL, statusSymbol, statusWord } from './symbols'
|
|
50
65
|
import { BADGE_TEXTS, type BadgeTexts, checksText, laneMarkText, TEXTS } from './texts'
|
|
66
|
+
import {
|
|
67
|
+
APPOINTMENT_MORE_KEY,
|
|
68
|
+
ASK_BUTTON,
|
|
69
|
+
type BlockHit,
|
|
70
|
+
type BlockRow,
|
|
71
|
+
blockRows,
|
|
72
|
+
moreKeyOf,
|
|
73
|
+
SHOWN_BLOCK_ROWS,
|
|
74
|
+
WAITING_MARK,
|
|
75
|
+
WAITING_MORE_KEY,
|
|
76
|
+
waitingKeys,
|
|
77
|
+
waitingRows,
|
|
78
|
+
} from './waiting-click'
|
|
51
79
|
|
|
52
80
|
/**
|
|
53
81
|
* Where a lane stands outside its plan. `agent` is the terminal adapter's status, null when no agent is listed;
|
|
@@ -78,6 +106,8 @@ export type LaneView = {
|
|
|
78
106
|
tickets?: LaneTickets
|
|
79
107
|
/** The model each phase's implementing subagent last ran on, by `phaseModelKey`; absent without a record. */
|
|
80
108
|
models?: Record<string, PhaseModel>
|
|
109
|
+
/** The files this lane's session sent to the user, shown as badges on the row of link chips. */
|
|
110
|
+
sentFiles?: SentChip[]
|
|
81
111
|
}
|
|
82
112
|
|
|
83
113
|
/** `watch` is the health of the watcher the badges come from, absent when it was not read. */
|
|
@@ -89,10 +119,26 @@ export type OverviewView = {
|
|
|
89
119
|
watch?: WatchHealth
|
|
90
120
|
/** True when the config marks a ticket source, so a missing watcher is said without a pull request too. */
|
|
91
121
|
watchesTickets?: boolean
|
|
122
|
+
/** The open items of the board's section of what waits on the user, shown under the title. */
|
|
123
|
+
awaiting?: WaitingItem[]
|
|
124
|
+
/** The open appointments of the board, in a block of their own under the for-you block. */
|
|
125
|
+
appointments?: AppointmentBlock
|
|
126
|
+
/** What agents report doing that no lane takes, one `now:` line each under the title. */
|
|
127
|
+
unplaced?: UnplannedTask[]
|
|
92
128
|
}
|
|
93
129
|
|
|
94
|
-
/**
|
|
95
|
-
|
|
130
|
+
/**
|
|
131
|
+
* The drawn screen: a fixed header and footer around a body that scrolls; body lines and targets count from the body's
|
|
132
|
+
* top. `waitingBlock` and `appointmentBlock` are what a click on the for-you and the appointment block is matched
|
|
133
|
+
* against, null while the block is not drawn.
|
|
134
|
+
*/
|
|
135
|
+
export type Screen = {
|
|
136
|
+
header: string[]
|
|
137
|
+
body: Frame
|
|
138
|
+
footer: string[]
|
|
139
|
+
waitingBlock: BlockHit | null
|
|
140
|
+
appointmentBlock: BlockHit | null
|
|
141
|
+
}
|
|
96
142
|
|
|
97
143
|
/**
|
|
98
144
|
* Widths shared by every lane line: the name column, the folded pull request badges, and bar with context. They are
|
|
@@ -102,7 +148,33 @@ export type Screen = { header: string[]; body: Frame; footer: string[] }
|
|
|
102
148
|
*/
|
|
103
149
|
type LaneColumns = { name: number; marks: number; right: number; phases: boolean; tally: number }
|
|
104
150
|
|
|
151
|
+
/**
|
|
152
|
+
* 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.
|
|
153
|
+
* `hit` is what a click on it is matched against, without the screen line the stack gives it.
|
|
154
|
+
*/
|
|
155
|
+
type BlockLines = { open: boolean; lines: string[]; hit: Omit<BlockHit, 'firstLine'> }
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 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
|
|
159
|
+
* a row, the key of its `+<n>` line, its points and their rows.
|
|
160
|
+
*/
|
|
161
|
+
type TopBlock = {
|
|
162
|
+
name: string
|
|
163
|
+
bar: () => string
|
|
164
|
+
markPaint: (text: string) => string
|
|
165
|
+
/** The paint of the count in the head while the block has points: amber for what waits on the user. */
|
|
166
|
+
countPaint: (text: string) => string
|
|
167
|
+
moreKey: string
|
|
168
|
+
expanded: boolean
|
|
169
|
+
keys: readonly string[]
|
|
170
|
+
rows: BlockRow[]
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The blocks on top of the herd one under the other: their lines below the title, and the hit of each drawn block. */
|
|
174
|
+
type BlockStack = { lines: string[]; hits: (BlockHit | null)[] }
|
|
175
|
+
|
|
105
176
|
const BRANCH_MARK = '⎇'
|
|
177
|
+
const SENT_MARK = '📎'
|
|
106
178
|
const SYMBOL_WIDTH = 2
|
|
107
179
|
const NAME_WIDTH_MAX = 24
|
|
108
180
|
const NON_BREAKING_SPACE = '\u00a0'
|
|
@@ -114,30 +186,26 @@ const CONTEXT_WIDTH = 5
|
|
|
114
186
|
/** The context cell at the right of the session line: symbol, share and a cell of space. */
|
|
115
187
|
const CONTEXT_CELL_WIDTH = CONTEXT_WIDTH + 3
|
|
116
188
|
const CONTEXT_SYMBOL = '🥣'
|
|
117
|
-
const PROGRESS_SYMBOL = '◐'
|
|
118
189
|
const HEADER_MARK = '🐾'
|
|
119
190
|
const COLUMN_RULE = '│'
|
|
120
191
|
/** The number badge of a three-digit pull request (`↗ #` and the digits, a cell of padding either side); a longer one widens it. */
|
|
121
192
|
const PULL_REQUEST_BADGE_WIDTH = 8
|
|
122
193
|
/**
|
|
123
|
-
* Under the lane line's status symbol: where the pull request row and its badge line start
|
|
194
|
+
* Under the lane line's status symbol: where the pull request row and its badge line start, and where the trunk
|
|
124
195
|
* of an open lane hangs, not under the fold arrow.
|
|
125
196
|
*/
|
|
126
197
|
const UNDER_STATUS = ' '
|
|
127
198
|
const CHECKS_WIDTH = 10
|
|
128
199
|
/** The most failed tests listed under a pull request; the rest is counted. */
|
|
129
200
|
const SHOWN_FAILED_TESTS = 3
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
const UNKNOWN_URGENCY = 3
|
|
140
|
-
const NO_AGENT_URGENCY = 4
|
|
201
|
+
const FAILED_MARK = '✗'
|
|
202
|
+
/** The tree under a check chip, one cell narrower than the lane's: the chip's children are short. */
|
|
203
|
+
const FAILED_TREE = { branch: '├ ', last: '└ ', pipe: '│ ', gap: ' ' } as const
|
|
204
|
+
/** An unfolded failed test's lines after its first hang under the name, past the mark. */
|
|
205
|
+
const FAILED_HANG = ' '
|
|
206
|
+
const TEST_PATH_LEVEL = ' > '
|
|
207
|
+
const TEST_PATH_SEPARATOR = ' › '
|
|
208
|
+
const PLACE_SEPARATOR = ' · '
|
|
141
209
|
|
|
142
210
|
/** The colour of a lane state, word and symbol alike: teal while working or done, bold amber when it waits for you, grey when idle. */
|
|
143
211
|
const STATUS_PAINT: Record<AgentStatus, (text: string) => string> = {
|
|
@@ -154,19 +222,9 @@ const CHECKS_PAINT: Record<ChecksState, (text: string) => string> = {
|
|
|
154
222
|
[CHECKS.none]: paint.dim,
|
|
155
223
|
}
|
|
156
224
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/** Lanes waiting for input first, then those with a report to read, then working ones; stable inside a group. */
|
|
163
|
-
export const sortByUrgency = (lanes: readonly LaneView[]): LaneView[] =>
|
|
164
|
-
[...lanes].sort((left, right) => urgencyOf(left) - urgencyOf(right))
|
|
165
|
-
|
|
166
|
-
const agentLabel = (agent: string | null): string => {
|
|
167
|
-
if (agent === null) return paint.dim(statusWord(agent))
|
|
168
|
-
return isAgentStatus(agent) ? STATUS_PAINT[agent](statusWord(agent)) : statusWord(agent)
|
|
169
|
-
}
|
|
225
|
+
/** Only a blocked agent is named in the lane line, since there the user must act; the symbol says the rest. */
|
|
226
|
+
const agentLabel = (agent: string | null): string =>
|
|
227
|
+
agent === AGENT_STATUS.blocked ? STATUS_PAINT[AGENT_STATUS.blocked](statusWord(agent)) : ''
|
|
170
228
|
|
|
171
229
|
/** The symbol of a status padded to `SYMBOL_WIDTH` cells, so the names after it start in one column. */
|
|
172
230
|
const symbolCell = (agent: string | null): string => {
|
|
@@ -179,15 +237,14 @@ const symbolCell = (agent: string | null): string => {
|
|
|
179
237
|
|
|
180
238
|
const aheadLabel = ({ branch, ahead }: LaneStatus): string => {
|
|
181
239
|
if (ahead === null) return branch ? paint.dim('↑?') : ''
|
|
182
|
-
return ahead > 0 ? paint.
|
|
240
|
+
return ahead > 0 ? paint.teal(`↑${ahead}`) : ''
|
|
183
241
|
}
|
|
184
242
|
|
|
243
|
+
const branchLabel = (status: LaneStatus): string =>
|
|
244
|
+
status.branch ? paint.dim(`${BRANCH_MARK} ${status.branch}`) : paint.dim(TEXTS.noWorktree)
|
|
245
|
+
|
|
185
246
|
const gitLabel = (status: LaneStatus): string =>
|
|
186
|
-
[
|
|
187
|
-
status.branch ? paint.dim(`${BRANCH_MARK} ${status.branch}`) : paint.dim(TEXTS.noWorktree),
|
|
188
|
-
aheadLabel(status),
|
|
189
|
-
status.dirty ? paint.magenta(`±${status.dirty}`) : '',
|
|
190
|
-
]
|
|
247
|
+
[branchLabel(status), aheadLabel(status), status.dirty ? paint.magenta(`±${status.dirty}`) : '']
|
|
191
248
|
.filter(Boolean)
|
|
192
249
|
.join(' ')
|
|
193
250
|
|
|
@@ -229,18 +286,26 @@ export const rowWithCount = (
|
|
|
229
286
|
const fitsBeside = (head: string, cells: BadgeCell[], width: number): boolean =>
|
|
230
287
|
visibleLength(head) + packedWidth(cells) + COLUMN_SEPARATOR.length + MIN_TITLE_WIDTH <= width
|
|
231
288
|
|
|
289
|
+
/** The number of a pull request as a badge linked to it, padded to its column. */
|
|
290
|
+
const numberCell = (pullRequest: PullRequest): string =>
|
|
291
|
+
padVisible(badge(pullRequest.url, `↗ #${pullRequest.number}`), PULL_REQUEST_BADGE_WIDTH)
|
|
292
|
+
|
|
293
|
+
/** The column the check chip of a pull request row starts in, behind its number. */
|
|
294
|
+
const checksColumnOf = (pullRequest: PullRequest): number =>
|
|
295
|
+
visibleLength(numberCell(pullRequest)) + COLUMN_SEPARATOR.length
|
|
296
|
+
|
|
232
297
|
/**
|
|
233
298
|
* `⟦↗ #<number>⟧ ✓ checks ⟦🌐 preview⟧ ⟦💬 3 new⟧ title`: the number as a badge and the check state in fixed
|
|
234
299
|
* columns, the watcher's badges after them and the title last, cut with `…` on its one line. When the badges leave no
|
|
235
|
-
* room for a title, they go on lines of their own below, starting in the column of the lane's status symbol
|
|
300
|
+
* room for a title, they go on lines of their own below, starting in the column of the lane's status symbol.
|
|
236
301
|
*/
|
|
237
302
|
const pullRequestRow = (pullRequest: PullRequest, context: LaneContext): Frame => {
|
|
238
|
-
const number =
|
|
303
|
+
const number = numberCell(pullRequest)
|
|
239
304
|
const checksWords = checksText(pullRequest)
|
|
240
305
|
const checksColumn =
|
|
241
306
|
textWidth(checksWords) < CHECKS_WIDTH ? CHECKS_WIDTH : textWidth(checksWords) + COLUMN_SEPARATOR.length
|
|
242
307
|
const checks = padVisible(CHECKS_PAINT[pullRequest.checks](checksWords), checksColumn)
|
|
243
|
-
const head = `${
|
|
308
|
+
const head = `${number}${COLUMN_SEPARATOR}${checks}`
|
|
244
309
|
const prefix = pullRequest.draft ? TEXTS.draft : ''
|
|
245
310
|
const dimPrefix = (line: string, index: number) =>
|
|
246
311
|
index === 0 && prefix ? `${paint.dim(prefix)}${line.slice(prefix.length)}` : line
|
|
@@ -248,8 +313,8 @@ const pullRequestRow = (pullRequest: PullRequest, context: LaneContext): Frame =
|
|
|
248
313
|
const key = pullRequestKey(pullRequest.number)
|
|
249
314
|
if (cells.length === 0 || !fitsBeside(head, cells, context.width))
|
|
250
315
|
return joinFrames([
|
|
251
|
-
rowFrame(key,
|
|
252
|
-
packedFrame(cells,
|
|
316
|
+
rowFrame(key, [fit(`${head}${dimPrefix(`${prefix}${pullRequest.title}`, 0)}`, context.width)], [], context),
|
|
317
|
+
packedFrame(cells, '', context.width),
|
|
253
318
|
])
|
|
254
319
|
const beside = `${head}${cells.map(({ cell }) => cell).join(CHIP_GAP)}${COLUMN_SEPARATOR}`
|
|
255
320
|
const row = rowFrame(
|
|
@@ -261,22 +326,75 @@ const pullRequestRow = (pullRequest: PullRequest, context: LaneContext): Frame =
|
|
|
261
326
|
return { ...row, targets: [...cellTargets(cells, 0, visibleLength(head), context.width), ...row.targets] }
|
|
262
327
|
}
|
|
263
328
|
|
|
264
|
-
/**
|
|
265
|
-
const
|
|
329
|
+
/** `✗ <place> · <path>` as plain text, each ` > ` of the test path as ` › `; without a place `✗ <path>`. */
|
|
330
|
+
const failedTestText = (name: string, place: string | null): string => {
|
|
331
|
+
const path = plain(name).replaceAll(TEST_PATH_LEVEL, TEST_PATH_SEPARATOR)
|
|
332
|
+
return place ? `${FAILED_MARK} ${plain(place)}${PLACE_SEPARATOR}${path}` : `${FAILED_MARK} ${path}`
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** The first line of a failed test painted: mark and place red, separator and test path dim. */
|
|
336
|
+
const paintFailedTest = (line: string, place: string | null): string => {
|
|
337
|
+
const head = place ? `${FAILED_MARK} ${plain(place)}` : FAILED_MARK
|
|
338
|
+
if (!line.startsWith(head)) return paint.dim(line)
|
|
339
|
+
const lead = place ? `${paint.red(FAILED_MARK)} ${paint.red(plain(place))}` : paint.red(FAILED_MARK)
|
|
340
|
+
const rest = line.slice(head.length)
|
|
341
|
+
const separator = place && rest.startsWith(PLACE_SEPARATOR) ? PLACE_SEPARATOR : ''
|
|
342
|
+
return `${lead}${separator ? paint.dim(separator) : ''}${paint.dim(rest.slice(separator.length))}`
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** One child of the check chip: its tree branch (`├`, `└` for the last) and what carries on beside it. */
|
|
346
|
+
type FailedBranch = { first: string; rest: string }
|
|
347
|
+
|
|
348
|
+
const failedBranch = (last: boolean): FailedBranch =>
|
|
349
|
+
last ? { first: FAILED_TREE.last, rest: FAILED_TREE.gap } : { first: FAILED_TREE.branch, rest: FAILED_TREE.pipe }
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* A failed test under the check chip (`column`): cut to one line, which a click unfolds over several, the name hanging
|
|
353
|
+
* under its first word, and a second click folds; a name that fits is no click target.
|
|
354
|
+
*/
|
|
355
|
+
const failedTestFrame = (
|
|
356
|
+
test: { name: string; place: string | null; key: string },
|
|
357
|
+
branch: FailedBranch,
|
|
358
|
+
column: number,
|
|
359
|
+
context: LaneContext,
|
|
360
|
+
): Frame => {
|
|
361
|
+
const indent = ' '.repeat(column)
|
|
362
|
+
const room = Math.max(context.width - column - textWidth(branch.first), 1)
|
|
363
|
+
const text = failedTestText(test.name, test.place)
|
|
364
|
+
const cut = textWidth(text) > room
|
|
365
|
+
const [first = '', ...rest] =
|
|
366
|
+
cut && context.state.expanded.has(test.key) ? wrap(text, Math.max(room - FAILED_HANG.length, 1)) : [fit(text, room)]
|
|
367
|
+
const lines = [
|
|
368
|
+
`${indent}${paint.dim(branch.first)}${paintFailedTest(first, test.place)}`,
|
|
369
|
+
...rest.map((line) => `${indent}${paint.dim(branch.rest)}${FAILED_HANG}${paint.dim(line)}`),
|
|
370
|
+
]
|
|
371
|
+
return cut ? rowFrame(test.key, lines, [], context) : textFrame(lines)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* The failed tests of a red pull request as children of its check chip, at most `SHOWN_FAILED_TESTS`, then `+<n>`;
|
|
376
|
+
* the package of each first when the watcher read it from the log. None for a pull request that is not red.
|
|
377
|
+
*/
|
|
378
|
+
const failedTestsFrame = (pullRequest: PullRequest, context: LaneContext): Frame => {
|
|
379
|
+
const badges = context.badges[String(pullRequest.number)]
|
|
266
380
|
const tests = pullRequest.checks === CHECKS.failed ? (badges?.failedTests ?? []) : []
|
|
267
381
|
const { shown, more } = shownWithMore(tests, SHOWN_FAILED_TESTS)
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
382
|
+
const column = checksColumnOf(pullRequest)
|
|
383
|
+
const frames = shown.map((name, index) =>
|
|
384
|
+
failedTestFrame(
|
|
385
|
+
{ name, place: badges?.failedTestPlaces?.[name] ?? null, key: failedTestKey(pullRequest.number, index) },
|
|
386
|
+
failedBranch(more === 0 && index === shown.length - 1),
|
|
387
|
+
column,
|
|
388
|
+
context,
|
|
389
|
+
),
|
|
271
390
|
)
|
|
391
|
+
const moreLine = `${' '.repeat(column)}${paint.dim(`${FAILED_TREE.last}+${more}`)}`
|
|
392
|
+
return joinFrames([...frames, ...(more > 0 ? [textFrame([fit(moreLine, context.width)])] : [])])
|
|
272
393
|
}
|
|
273
394
|
|
|
274
395
|
/** The pull request row, then the tests its red checks failed, which only the watcher knows. */
|
|
275
396
|
const pullRequestFrame = (pullRequest: PullRequest, context: LaneContext): Frame =>
|
|
276
|
-
joinFrames([
|
|
277
|
-
pullRequestRow(pullRequest, context),
|
|
278
|
-
textFrame(failedTestLines(pullRequest, context.badges[String(pullRequest.number)], context.width)),
|
|
279
|
-
])
|
|
397
|
+
joinFrames([pullRequestRow(pullRequest, context), failedTestsFrame(pullRequest, context)])
|
|
280
398
|
|
|
281
399
|
const CONTEXT_PAINT: Record<ContextLevel, (text: string) => string> = {
|
|
282
400
|
[CONTEXT_LEVEL.ok]: paint.dim,
|
|
@@ -288,17 +406,24 @@ const CONTEXT_PAINT: Record<ContextLevel, (text: string) => string> = {
|
|
|
288
406
|
const contextValue = (context: ContextReading | null | undefined): string =>
|
|
289
407
|
context ? `${CONTEXT_SYMBOL}${CONTEXT_PAINT[context.level](contextLabel(context.share).padStart(CONTEXT_WIDTH))}` : ''
|
|
290
408
|
|
|
291
|
-
/**
|
|
292
|
-
|
|
293
|
-
|
|
409
|
+
/**
|
|
410
|
+
* The context value with a rule before the bar that follows it on a folded lane line, from `warn` on; below, blanks
|
|
411
|
+
* as wide, so nothing moves when it shows. Empty without a reading.
|
|
412
|
+
*/
|
|
413
|
+
const contextCell = (context: ContextReading | null | undefined): string => {
|
|
414
|
+
if (!context) return ''
|
|
415
|
+
const cell = `${contextValue(context)} ${paint.dim(COLUMN_RULE)} `
|
|
416
|
+
return context.level === CONTEXT_LEVEL.ok ? ' '.repeat(visibleLength(cell)) : cell
|
|
417
|
+
}
|
|
294
418
|
|
|
295
419
|
/**
|
|
296
420
|
* Bar, checkbox count and phase count over the lane's plans in a fixed-width column, so every bar on screen starts in
|
|
297
|
-
* the same column: `██░░
|
|
298
|
-
* `·`, the widest of the lanes on screen, so a shorter one moves neither bar nor `·`; at 0 it is
|
|
421
|
+
* the same column: `██░░ 50/213 · 4/14 ph`, folded or open. The phase count stands right-aligned in `tallyWidth`
|
|
422
|
+
* cells behind the `·`, the widest of the lanes on screen, so a shorter one moves neither bar nor `·`; at 0 it is
|
|
423
|
+
* left out.
|
|
299
424
|
*/
|
|
300
425
|
const progressColumn = ({ done, total, phasesDone, phases }: LaneSummary['tally'], tallyWidth: number): string => {
|
|
301
|
-
const counts = `${bar(done, total)} ${countCell(
|
|
426
|
+
const counts = `${bar(done, total)} ${countCell('', countText(done, total))}`
|
|
302
427
|
if (tallyWidth === 0) return counts
|
|
303
428
|
const tally = TEXTS.phaseTally(phasesDone, phases)
|
|
304
429
|
const padding = ' '.repeat(Math.max(tallyWidth - visibleLength(tally), 0))
|
|
@@ -377,23 +502,29 @@ const ticketCells = (tickets: LaneTickets | null): BadgeCell[] => {
|
|
|
377
502
|
return [...tickets.shown.map(ticketCell), ...more]
|
|
378
503
|
}
|
|
379
504
|
|
|
505
|
+
/** A file the lane's session sent to the user, `📎 name` linked by its `file:` address; the terminal opens it, no click of ours does. */
|
|
506
|
+
const sentCell = ({ url, label }: SentChip): BadgeCell => ({
|
|
507
|
+
cell: badge(url, `${SENT_MARK} ${plain(label)}`),
|
|
508
|
+
key: null,
|
|
509
|
+
})
|
|
510
|
+
|
|
380
511
|
const chipCellOf = (chip: LinkChip): BadgeCell => ({ cell: chipCell(chip), key: chipKey(chip.url) })
|
|
381
512
|
|
|
382
|
-
/** The chips of the lane's plans
|
|
383
|
-
const
|
|
513
|
+
/** The chips of the lane's plans, each once, the plan chips first, the tickets beside them, the sent files last. */
|
|
514
|
+
const chipCells = (chips: LinkChip[], tickets: LaneTickets | null, sent: readonly SentChip[] = []): BadgeCell[] => {
|
|
384
515
|
const isPlanChip = (chip: LinkChip) => planUrlOf(chipKey(chip.url)) !== null
|
|
385
516
|
const unique = chips.filter((chip, index) => chips.findIndex(({ url }) => url === chip.url) === index)
|
|
386
|
-
|
|
517
|
+
return [
|
|
387
518
|
...unique.filter(isPlanChip).map(chipCellOf),
|
|
388
519
|
...ticketCells(tickets),
|
|
389
520
|
...unique.filter((chip) => !isPlanChip(chip)).map(chipCellOf),
|
|
521
|
+
...sent.map(sentCell),
|
|
390
522
|
]
|
|
391
|
-
return packedFrame(cells, '', width)
|
|
392
523
|
}
|
|
393
524
|
|
|
394
525
|
/**
|
|
395
526
|
* `🌐 preview` opening the preview, `💬 <n> new` in amber opening the pull request, and `✋ changes requested` in dark
|
|
396
|
-
* red opening it too, each only when there is one
|
|
527
|
+
* red opening it too, each only when there is one.
|
|
397
528
|
*/
|
|
398
529
|
const badgeCells = (
|
|
399
530
|
pullRequest: PullRequest,
|
|
@@ -412,19 +543,18 @@ const badgeCells = (
|
|
|
412
543
|
]
|
|
413
544
|
}
|
|
414
545
|
|
|
415
|
-
/** The columns lane lines share, so names, statuses and bars of all of them line up. */
|
|
416
|
-
const STATUS_WIDTH = Math.max(...[...Object.values(AGENT_STATUS), NO_AGENT_WORD].map(textWidth))
|
|
417
|
-
|
|
418
546
|
/**
|
|
419
|
-
* The phase the lane line names: the
|
|
420
|
-
* ticked to the end, whose state
|
|
547
|
+
* The phase the lane line names: the one its report or branch signals, else the open phase moved last, else the
|
|
548
|
+
* lane's current phase while it is open; never one ticked to the end, whose state the `✓` line says. Null when there
|
|
549
|
+
* is none.
|
|
421
550
|
*/
|
|
422
551
|
const headPhase = (view: LaneView): PhaseProgress | null => {
|
|
552
|
+
const { signalled, latest } = summaryOf(view)
|
|
423
553
|
const current = currentPhase(view.progress)
|
|
424
|
-
return
|
|
554
|
+
return signalled ?? latest ?? (current && isPhaseOpen(current) ? current : null)
|
|
425
555
|
}
|
|
426
556
|
|
|
427
|
-
/** `P9
|
|
557
|
+
/** `P9 Release 1.0` for the phase the lane line names; empty when there is none. */
|
|
428
558
|
const nextPhaseLabel = (view: LaneView): string => {
|
|
429
559
|
const phase = headPhase(view)
|
|
430
560
|
return phase ? `${phaseLabel(phase.id)}${phase.title ? ` ${plain(phase.title)}` : ''}` : ''
|
|
@@ -457,17 +587,28 @@ const ticketMark = (tickets: LaneTickets | undefined): string[] => {
|
|
|
457
587
|
const laneMarks = ({ pullRequests, badges, tickets }: LaneView): string =>
|
|
458
588
|
[...pullRequests.map((pullRequest) => pullRequestMark(pullRequest, badges ?? {})), ...ticketMark(tickets)].join(' ')
|
|
459
589
|
|
|
460
|
-
/**
|
|
461
|
-
|
|
590
|
+
/**
|
|
591
|
+
* `P3 Search API +1 · 12/20` for a folded lane: the phase it names, `+n` for the other active phases, the phase's
|
|
592
|
+
* ticked boxes. An open lane names it on a phase branch, so its own line does not.
|
|
593
|
+
*/
|
|
594
|
+
const foldedPhaseLabel = (view: LaneView): string => {
|
|
595
|
+
const phase = headPhase(view)
|
|
596
|
+
if (!phase) return ''
|
|
597
|
+
const more = summaryOf(view).active.filter((active) => active.phase.id !== phase.id).length
|
|
598
|
+
return `${nextPhaseLabel(view)}${more > 0 ? ` +${more}` : ''} · ${phase.done}/${phase.total}`
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** What follows the status on a lane line: the marker and, folded only, the phase it works on; the branch is on the session line. */
|
|
602
|
+
const laneDetail = (view: LaneView, open: boolean): string => {
|
|
462
603
|
const markText = laneMarkText(view)
|
|
463
604
|
const marker = markText ? paint.yellow(markText) : ''
|
|
464
|
-
return [marker,
|
|
605
|
+
return [agentLabel(view.status.agent), marker, open ? '' : foldedPhaseLabel(view)].filter(Boolean).join(' ')
|
|
465
606
|
}
|
|
466
607
|
|
|
467
608
|
/**
|
|
468
609
|
* The lane line, `head` and `right` (bar and count) at the edges. The detail (next phase, branch, pull
|
|
469
610
|
* requests) follows `head` when at least `MIN_BESIDE_WIDTH` characters are left for it; in a narrower pane
|
|
470
|
-
* it moves to a line of its own below, behind `indent` (
|
|
611
|
+
* it moves to a line of its own below, behind `indent` (`detailIndent`: up to the name column) and cut to the full width.
|
|
471
612
|
*/
|
|
472
613
|
export const withDetail = (
|
|
473
614
|
head: string,
|
|
@@ -484,10 +625,9 @@ export const withDetail = (
|
|
|
484
625
|
|
|
485
626
|
/** The summary the snapshot read for the lane, or the one its own steps give. */
|
|
486
627
|
const summaryOf = (view: LaneView): LaneSummary =>
|
|
487
|
-
view.summary ??
|
|
488
|
-
laneSummary({ progress: view.progress, phases: {}, texts: {}, activity: {}, language: PLAN_LANGUAGE.en }, 0)
|
|
628
|
+
view.summary ?? laneSummary({ progress: view.progress, phases: {}, activity: {} }, 0)
|
|
489
629
|
|
|
490
|
-
/** The bar over the lane's plans
|
|
630
|
+
/** The bar and checkbox count over the lane's plans; empty while they have no checkbox. */
|
|
491
631
|
const laneProgressColumn = (view: LaneView, tallyWidth: number): string => {
|
|
492
632
|
const { tally } = summaryOf(view)
|
|
493
633
|
return tally.total > 0 ? progressColumn(tally, tallyWidth) : ''
|
|
@@ -502,7 +642,7 @@ const laneRight = (view: LaneView, tallyWidth: number): string => {
|
|
|
502
642
|
/** The cells of the phase count column, 0 when the pane leaves it out. */
|
|
503
643
|
const tallyWidthOf = ({ phases, tally }: LaneColumns): number => (phases ? tally : 0)
|
|
504
644
|
|
|
505
|
-
/** A folded lane's marks in the column of `columns.marks`, right before context and bar
|
|
645
|
+
/** A folded lane's marks in the column of `columns.marks`, right before context and bar. */
|
|
506
646
|
const markedRight = (view: LaneView, columns: LaneColumns): string => {
|
|
507
647
|
const right = laneRight(view, tallyWidthOf(columns))
|
|
508
648
|
const marks = laneMarks(view)
|
|
@@ -512,39 +652,96 @@ const markedRight = (view: LaneView, columns: LaneColumns): string => {
|
|
|
512
652
|
return `${padVisible(marks, columns.marks)}${COLUMN_SEPARATOR}${pad}${right}`
|
|
513
653
|
}
|
|
514
654
|
|
|
655
|
+
/**
|
|
656
|
+
* 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
|
|
657
|
+
* starts where its name does, as the branches of an open lane do; an open lane keeps its trunk in front.
|
|
658
|
+
*/
|
|
659
|
+
const detailIndent = (open: boolean, nameColumn: number): string => {
|
|
660
|
+
if (!open) return ' '.repeat(nameColumn)
|
|
661
|
+
const trunk = `${UNDER_STATUS}${TREE.pipe}`
|
|
662
|
+
return `${paint.dim(trunk)}${' '.repeat(Math.max(nameColumn - visibleLength(trunk), 0))}`
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** The task row below a folded lane's phase row, under the lane's name; none while the phase has no task on it. */
|
|
666
|
+
const foldedTaskRow = (view: LaneView, indent: string, width: number): string[] => {
|
|
667
|
+
const phase = headPhase(view)
|
|
668
|
+
const { active: running, subagentTasks } = summaryOf(view)
|
|
669
|
+
const active = running.find((each) => each.phase.id === phase?.id)
|
|
670
|
+
const line = active && foldedTaskLine(active, subagentTasks, TEXTS.herd, indent, width)
|
|
671
|
+
return line ? [line] : []
|
|
672
|
+
}
|
|
673
|
+
|
|
515
674
|
const laneLines = (view: LaneView, open: boolean, width: number, columns: LaneColumns): string[] => {
|
|
516
675
|
const { progress, status } = view
|
|
517
|
-
const cells =
|
|
518
|
-
padVisible(paint.bold(fit(progress.lane.name, columns.name)), columns.name),
|
|
519
|
-
padVisible(agentLabel(status.agent), STATUS_WIDTH),
|
|
520
|
-
].join(COLUMN_SEPARATOR)
|
|
676
|
+
const cells = padVisible(paint.bold(fit(progress.lane.name, columns.name)), columns.name)
|
|
521
677
|
const head = `${paint.dim(chevronOf(open))} ${symbolCell(status.agent)} ${cells}`
|
|
522
678
|
const right = open ? laneProgressColumn(view, tallyWidthOf(columns)) : markedRight(view, columns)
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
679
|
+
const nameColumn = visibleLength(head) - visibleLength(cells)
|
|
680
|
+
const lines = withDetail(head, laneDetail(view, open), right, width, detailIndent(open, nameColumn))
|
|
681
|
+
return open ? lines : [...lines, ...foldedTaskRow(view, ' '.repeat(nameColumn), width)]
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** The parts of a session line: the session's title (the branch without one) at the left, the branch and the context value. */
|
|
685
|
+
const sessionParts = (view: LaneView): { left: string; git: string; branch: string; context: string } => {
|
|
686
|
+
const git = gitLabel(view.status)
|
|
687
|
+
const branch = branchLabel(view.status)
|
|
688
|
+
return { left: view.sessionTitle ? plain(view.sessionTitle) : git, git, branch, context: contextValue(view.context) }
|
|
530
689
|
}
|
|
531
690
|
|
|
532
691
|
/**
|
|
533
|
-
* The
|
|
692
|
+
* The session branch of an open lane: the session's title at the left; the branch at the end of the line, before the
|
|
534
693
|
* context at the right. The title keeps its room: the branch gives way when both do not fit, and only then is the
|
|
535
694
|
* title cut. Without a title the branch stands at the left.
|
|
536
695
|
*/
|
|
537
696
|
const sessionLine = (view: LaneView, width: number): string => {
|
|
538
|
-
const git =
|
|
539
|
-
const context = contextValue(view.context)
|
|
697
|
+
const { left, git, branch, context } = sessionParts(view)
|
|
540
698
|
if (!view.sessionTitle) return spread(fit(git, Math.max(width - CONTEXT_CELL_WIDTH, 1)), context, width)
|
|
541
|
-
const left = plain(view.sessionTitle)
|
|
542
699
|
const room = width - (context ? CONTEXT_CELL_WIDTH : 0)
|
|
543
|
-
const
|
|
544
|
-
|
|
700
|
+
const fitting = [git, branch].find(
|
|
701
|
+
(label) => visibleLength(left) + COLUMN_SEPARATOR.length + visibleLength(label) <= room,
|
|
702
|
+
)
|
|
703
|
+
const right = [fitting ?? '', context].filter(Boolean).join(COLUMN_SEPARATOR)
|
|
545
704
|
return spread(fit(left, Math.max(room, 1)), right, width)
|
|
546
705
|
}
|
|
547
706
|
|
|
707
|
+
/**
|
|
708
|
+
* The session line with the lane's one link chip on it, so the lane needs no row of chips: title, chip, branch and
|
|
709
|
+
* context. When the line is short the ahead and dirty counts of the branch go first, then the title is cut with `…`;
|
|
710
|
+
* without a title the branch stands at the left and the chip at the right. Null when even the cut title would be
|
|
711
|
+
* narrower than `MIN_TITLE_WIDTH`, and the chip keeps its row of chips.
|
|
712
|
+
*/
|
|
713
|
+
const sessionWithChip = (view: LaneView, chip: BadgeCell, width: number): Frame | null => {
|
|
714
|
+
const { left, git, branch, context } = sessionParts(view)
|
|
715
|
+
const tails = view.sessionTitle
|
|
716
|
+
? [
|
|
717
|
+
[chip.cell, git, context],
|
|
718
|
+
[chip.cell, branch, context],
|
|
719
|
+
]
|
|
720
|
+
: [[chip.cell, context]]
|
|
721
|
+
const candidates = tails.map((tail) => tail.filter(Boolean).join(COLUMN_SEPARATOR))
|
|
722
|
+
const roomFor = (candidate: string) => width - COLUMN_SEPARATOR.length - visibleLength(candidate)
|
|
723
|
+
const narrowest = candidates[candidates.length - 1] ?? ''
|
|
724
|
+
const cuts = Boolean(view.sessionTitle) && roomFor(narrowest) >= MIN_TITLE_WIDTH
|
|
725
|
+
const right = candidates.find((candidate) => visibleLength(left) <= roomFor(candidate)) ?? (cuts ? narrowest : null)
|
|
726
|
+
if (right === null) return null
|
|
727
|
+
const from = width - visibleLength(right)
|
|
728
|
+
const targets = chip.key === null ? [] : [{ line: 0, from, to: from + visibleLength(chip.cell), key: chip.key }]
|
|
729
|
+
return { lines: [spread(left, right, width)], cursorLine: -1, targets }
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** Session and link chips of an open lane: a lone chip on the session line when it fits there, else a row of chips. */
|
|
733
|
+
const sessionAndChips = (
|
|
734
|
+
view: LaneView,
|
|
735
|
+
tickets: LaneTickets | null,
|
|
736
|
+
width: number,
|
|
737
|
+
): { session: Frame; chips: Frame } => {
|
|
738
|
+
const cells = chipCells(laneChips(view), tickets, view.sentFiles)
|
|
739
|
+
const [lone] = cells
|
|
740
|
+
const inline = cells.length === 1 && lone ? sessionWithChip(view, lone, width) : null
|
|
741
|
+
if (inline) return { session: inline, chips: textFrame([]) }
|
|
742
|
+
return { session: textFrame([sessionLine(view, width)]), chips: packedFrame(cells, '', width) }
|
|
743
|
+
}
|
|
744
|
+
|
|
548
745
|
const underStatus = (frame: Frame): Frame =>
|
|
549
746
|
prefixed(frame, { first: UNDER_STATUS, rest: UNDER_STATUS, style: (text) => text })
|
|
550
747
|
|
|
@@ -552,6 +749,16 @@ const underStatus = (frame: Frame): Frame =>
|
|
|
552
749
|
const laneChips = ({ progress, chips }: LaneView): LinkChip[] =>
|
|
553
750
|
progress.steps.flatMap(({ step }) => (step.planHref ? (chips[step.planHref] ?? []) : []))
|
|
554
751
|
|
|
752
|
+
/** The lane's pull requests one under the other, each without the lane ticket its title starts with. */
|
|
753
|
+
const pullRequestsFrame = (view: LaneView, context: LaneContext): Frame => {
|
|
754
|
+
const ids = (context.tickets?.shown ?? []).map(({ id }) => id)
|
|
755
|
+
return joinFrames(
|
|
756
|
+
view.pullRequests.map((pullRequest) =>
|
|
757
|
+
pullRequestFrame({ ...pullRequest, title: withoutTicketKey(pullRequest.title, ids) }, context),
|
|
758
|
+
),
|
|
759
|
+
)
|
|
760
|
+
}
|
|
761
|
+
|
|
555
762
|
/** The lane line is a row of its own; an open lane continues with its pull requests and plans. */
|
|
556
763
|
const laneBlock = (
|
|
557
764
|
view: LaneView,
|
|
@@ -574,17 +781,13 @@ const laneBlock = (
|
|
|
574
781
|
const row = rowFrame(laneKey(view.progress.lane.name), laneLines(view, open, width, columns), [], context)
|
|
575
782
|
if (!open) return row
|
|
576
783
|
const inner = deeper({ ...context, width: Math.max(width - UNDER_STATUS.length, 1) })
|
|
577
|
-
const head = {
|
|
578
|
-
session: (room: number) => sessionLine(view, room),
|
|
579
|
-
chips: (room: number) => chipFrame(laneChips(view), inner.tickets, room),
|
|
580
|
-
}
|
|
784
|
+
const head = { ...sessionAndChips(view, inner.tickets, inner.width), pullRequests: pullRequestsFrame(view, inner) }
|
|
581
785
|
const [session, ...sections] = summaryChildren(inner, summaryOf(view), head)
|
|
582
786
|
const alone = sections.every((section) => section.lines.length === 0)
|
|
583
787
|
const sessionLead = alone ? { first: TREE.last, rest: TREE.gap } : { first: TREE.branch, rest: TREE.pipe }
|
|
584
788
|
return joinFrames([
|
|
585
789
|
row,
|
|
586
790
|
underStatus(prefixed(session, { ...sessionLead, style: paint.dim })),
|
|
587
|
-
...view.pullRequests.map((pullRequest) => pullRequestFrame(pullRequest, context)),
|
|
588
791
|
underStatus(nest(sections)),
|
|
589
792
|
])
|
|
590
793
|
}
|
|
@@ -647,9 +850,153 @@ const plannedRows = (planned: PlannedPlan[], width: number, indent: string): str
|
|
|
647
850
|
|
|
648
851
|
const rule = (width: number): string => paint.dim(RULE.repeat(width))
|
|
649
852
|
|
|
650
|
-
/**
|
|
651
|
-
const
|
|
652
|
-
|
|
853
|
+
/** The amber bar that starts every line of the for-you block: the one signal that this waits on the user. */
|
|
854
|
+
const waitingBar = (): string => STATUS_PAINT[AGENT_STATUS.blocked](WAITING_MARK)
|
|
855
|
+
|
|
856
|
+
/** The grey bar of the appointment block: it waits on nobody's answer. */
|
|
857
|
+
const appointmentBar = (): string => paint.dim(WAITING_MARK)
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* The head of an open block, `▾ <name> · <n> ──`, filled with the rule to the width, so it is the rule above the block.
|
|
861
|
+
* The label and the count wear the normal colour (the count in `countPaint`), only the rule is dim.
|
|
862
|
+
*/
|
|
863
|
+
const blockHead = (name: string, count: number, width: number, countPaint: (text: string) => string): string => {
|
|
864
|
+
const lead = `${chevronOf(true)} ${name} ${ITEM_SEPARATOR} `
|
|
865
|
+
const used = textWidth(`${lead}${count} `)
|
|
866
|
+
return `${lead}${countPaint(String(count))} ${paint.dim(RULE.repeat(Math.max(width - used, 0)))}`
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const markedLine = (line: string, row: BlockRow, markPaint: (text: string) => string): string =>
|
|
870
|
+
line.startsWith(row.mark) ? `${markPaint(row.mark)}${line.slice(row.mark.length)}` : line
|
|
871
|
+
|
|
872
|
+
/** The text from `from` on dimmed, without the blanks that pad it to the right edge. */
|
|
873
|
+
const dimmedRest = (text: string, from: number): string => {
|
|
874
|
+
const end = text.trimEnd().length
|
|
875
|
+
return from >= end ? text : `${text.slice(0, from)}${paint.dim(text.slice(from, end))}${text.slice(end)}`
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* One row as lines: the bar on each, the mark painted on the first line only, the short title in the normal colour
|
|
880
|
+
* and the rest of an expanded row dim, linked when the row has an address; the `[ask]` button at the end of a
|
|
881
|
+
* question's first line wears the mark's paint.
|
|
882
|
+
*/
|
|
883
|
+
const blockRowLines = ({ bar, markPaint }: TopBlock, row: BlockRow): string[] =>
|
|
884
|
+
row.lines.map((line, index) => {
|
|
885
|
+
const withButton = index === 0 && row.askColumn !== null
|
|
886
|
+
const styled = dimmedRest(line, row.dimFrom[index] ?? line.length)
|
|
887
|
+
const marked = index === 0 ? markedLine(styled, row, markPaint) : styled
|
|
888
|
+
const text = withButton ? `${marked}${markPaint(ASK_BUTTON)}` : marked
|
|
889
|
+
return `${bar()} ${row.url ? link(row.url, text) : text}`
|
|
890
|
+
})
|
|
891
|
+
|
|
892
|
+
/** A folded block: the one line `▸ <name> · <n>` and the first title as a preview; a click matches no row and no `+<n>` line. */
|
|
893
|
+
const foldedBlockLines = ({ name, keys, rows }: TopBlock, width: number): BlockLines => {
|
|
894
|
+
const preview = rows[0]?.preview
|
|
895
|
+
const head = `${chevronOf(false)} ${name} ${ITEM_SEPARATOR} ${keys.length}`
|
|
896
|
+
return {
|
|
897
|
+
open: false,
|
|
898
|
+
lines: [paint.dim(fit(preview === undefined ? head : `${head} ${preview}`, width))],
|
|
899
|
+
hit: { rows: [], moreKey: null, keys },
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* An open block on top of the herd: the head `▾ <name> · <n> ──`, one barred line per row (the first five, all while the
|
|
905
|
+
* 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
|
|
906
|
+
* one line; once expanded it wraps over several. The rule below is the stack's, not the block's.
|
|
907
|
+
*/
|
|
908
|
+
const openBlockLines = (block: TopBlock, width: number): BlockLines => {
|
|
909
|
+
const count = block.keys.length
|
|
910
|
+
const more = Math.max(count - SHOWN_BLOCK_ROWS, 0)
|
|
911
|
+
const moreLine = block.expanded ? `${LESS_MARK} ${more}` : `+${more}`
|
|
912
|
+
return {
|
|
913
|
+
open: true,
|
|
914
|
+
lines: [
|
|
915
|
+
blockHead(block.name, count, width, count > 0 ? block.countPaint : (text) => text),
|
|
916
|
+
...block.rows.flatMap((row) => blockRowLines(block, row)),
|
|
917
|
+
...(more > 0 ? [`${block.bar()} ${moreLine}`] : []),
|
|
918
|
+
],
|
|
919
|
+
hit: { rows: block.rows, moreKey: moreKeyOf(count, block.moreKey), keys: block.keys },
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const blockLinesOf = (block: TopBlock, state: Selection, id: TopBlockId, width: number): BlockLines =>
|
|
924
|
+
isBlockFolded(state, id, block.keys) ? foldedBlockLines(block, width) : openBlockLines(block, width)
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* What waits on the user's answer, on top of the herd until it is closed: `▌ 📎 name` for a sent file or an
|
|
928
|
+
* Artifact, `▌ ? question` for a question of the board or of a session's dialog, in the amber of a lane that waits.
|
|
929
|
+
* One line while the user folded it.
|
|
930
|
+
*/
|
|
931
|
+
const waitingLines = (waiting: readonly WaitingItem[], state: Selection, width: number): BlockLines => {
|
|
932
|
+
const keys = waitingKeys(waiting)
|
|
933
|
+
const block: TopBlock = {
|
|
934
|
+
name: TEXTS.waitingHeading,
|
|
935
|
+
bar: waitingBar,
|
|
936
|
+
markPaint: STATUS_PAINT[AGENT_STATUS.blocked],
|
|
937
|
+
countPaint: STATUS_PAINT[AGENT_STATUS.blocked],
|
|
938
|
+
moreKey: WAITING_MORE_KEY,
|
|
939
|
+
expanded: state.expanded.has(WAITING_MORE_KEY),
|
|
940
|
+
keys,
|
|
941
|
+
rows: waitingRows(waiting, state.expanded, width),
|
|
942
|
+
}
|
|
943
|
+
return blockLinesOf(block, state, TOP_BLOCK.waiting, width)
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/** 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. */
|
|
947
|
+
const appointmentLines = ({ rows }: AppointmentBlock, state: Selection, width: number): BlockLines => {
|
|
948
|
+
const keys = appointmentKeys(rows)
|
|
949
|
+
const block: TopBlock = {
|
|
950
|
+
name: TEXTS.appointmentHeading,
|
|
951
|
+
bar: appointmentBar,
|
|
952
|
+
markPaint: paint.dim,
|
|
953
|
+
countPaint: (text) => text,
|
|
954
|
+
moreKey: APPOINTMENT_MORE_KEY,
|
|
955
|
+
expanded: state.expanded.has(APPOINTMENT_MORE_KEY),
|
|
956
|
+
keys,
|
|
957
|
+
rows: blockRows(appointmentItems(rows), state.expanded, width, APPOINTMENT_MORE_KEY),
|
|
958
|
+
}
|
|
959
|
+
return blockLinesOf(block, state, TOP_BLOCK.appointments, width)
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const appointmentLinesOf = (
|
|
963
|
+
appointments: AppointmentBlock | undefined,
|
|
964
|
+
state: Selection,
|
|
965
|
+
width: number,
|
|
966
|
+
): BlockLines | null =>
|
|
967
|
+
appointments && appointments.rows.length > 0 ? appointmentLines(appointments, state, width) : null
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* The blocks one under the other, `offset` lines below the top of the screen, with one rule between neighbours: the
|
|
971
|
+
* head of an open block is that rule, a folded block gets a plain one above it unless a folded block stands right
|
|
972
|
+
* above (folded blocks share the rule below them), and a plain rule closes the stack. An
|
|
973
|
+
* absent block (null) takes no line and has no hit.
|
|
974
|
+
*/
|
|
975
|
+
const stackBlocks = (slots: readonly (BlockLines | null)[], offset: number, width: number): BlockStack => {
|
|
976
|
+
const lines: string[] = []
|
|
977
|
+
const hits: (BlockHit | null)[] = []
|
|
978
|
+
let previousFolded = false
|
|
979
|
+
for (const slot of slots) {
|
|
980
|
+
if (slot && !slot.open && !previousFolded) lines.push(rule(width))
|
|
981
|
+
hits.push(slot && { ...slot.hit, firstLine: offset + lines.length + 1 })
|
|
982
|
+
if (slot) {
|
|
983
|
+
lines.push(...slot.lines)
|
|
984
|
+
previousFolded = !slot.open
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
return { lines: lines.length > 0 ? [...lines, rule(width)] : lines, hits }
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* A dimmed rule before every section of the body but the first, which shows where one ends; none between two
|
|
992
|
+
* one-line lane blocks (the first `laneCount` sections), so folded lanes stand together as a list.
|
|
993
|
+
*/
|
|
994
|
+
const separate = (sections: Frame[], width: number, laneCount: number): Frame[] =>
|
|
995
|
+
sections.flatMap((frame, index) => {
|
|
996
|
+
if (index === 0) return [frame]
|
|
997
|
+
const listed = index < laneCount && frame.lines.length === 1 && sections[index - 1]?.lines.length === 1
|
|
998
|
+
return listed ? [frame] : [textFrame([rule(width)]), frame]
|
|
999
|
+
})
|
|
653
1000
|
|
|
654
1001
|
/**
|
|
655
1002
|
* Why badges may be missing, said beside the clock: no watcher runs, or its state is stale. Nothing while it is live,
|
|
@@ -679,7 +1026,7 @@ const laneColumnsOf = (lanes: LaneView[], width: number): LaneColumns => {
|
|
|
679
1026
|
)
|
|
680
1027
|
const rightOf = (tallyWidth: number) =>
|
|
681
1028
|
Math.max(0, ...lanes.map((lane) => visibleLength(laneRight(lane, tallyWidth))))
|
|
682
|
-
const head = LANE_HEAD_CELLS + name + COLUMN_SEPARATOR.length
|
|
1029
|
+
const head = LANE_HEAD_CELLS + name + COLUMN_SEPARATOR.length
|
|
683
1030
|
const beside = MIN_BESIDE_WIDTH + COLUMN_SEPARATOR.length
|
|
684
1031
|
const phases = head + beside + (marks > 0 ? marks + COLUMN_SEPARATOR.length : 0) + rightOf(tally) <= width
|
|
685
1032
|
return { name, marks, right: rightOf(phases ? tally : 0), phases, tally }
|
|
@@ -698,11 +1045,11 @@ const partitionLanes = (lanes: LaneView[]): Record<'active' | 'waiting' | 'noAge
|
|
|
698
1045
|
/**
|
|
699
1046
|
* Title and clock on top, the key hint at the bottom (with the legend after `?`), and in between one
|
|
700
1047
|
* block per lane: the lane line with its total bar, and for an open lane the phase rows. Folded lanes are
|
|
701
|
-
* one line each and
|
|
1048
|
+
* one line each, and a dimmed rule stands between lane blocks unless both are one line. Lanes that wait and
|
|
702
1049
|
* lanes with a worktree but no agent each share a closing block; finished lanes are not drawn.
|
|
703
1050
|
*/
|
|
704
1051
|
export const renderScreen = (
|
|
705
|
-
{ title, time, lanes, planned, watch, watchesTickets }: OverviewView,
|
|
1052
|
+
{ title, time, lanes, planned, watch, watchesTickets, awaiting = [], appointments, unplaced = [] }: OverviewView,
|
|
706
1053
|
state: Selection,
|
|
707
1054
|
width: number,
|
|
708
1055
|
): Screen => {
|
|
@@ -713,13 +1060,24 @@ export const renderScreen = (
|
|
|
713
1060
|
const blocks = active.map((lane) => laneBlock(lane, state, width, columns, badgeTexts))
|
|
714
1061
|
const notice = watchNotice({ lanes: active, watch, watchesTickets }, badgeTexts)
|
|
715
1062
|
const clock = notice ? `${paint.yellow(notice)}${COLUMN_SEPARATOR}${paint.dim(time)}` : paint.dim(time)
|
|
1063
|
+
const titleLine = spread(`${HEADER_MARK} ${paint.bold(title)}`, clock, width)
|
|
1064
|
+
const forYou = awaiting.length > 0 ? waitingLines(awaiting, state, width) : null
|
|
1065
|
+
const dated = appointmentLinesOf(appointments, state, width)
|
|
1066
|
+
const nowLines = unplaced.map((task) => nowLine(task, width))
|
|
1067
|
+
const above = nowLines.length > 0 ? [titleLine, rule(width), ...nowLines] : [titleLine]
|
|
1068
|
+
const stack = stackBlocks([forYou, dated], above.length, width)
|
|
1069
|
+
const endsWithRule = stack.lines.length > 0 || nowLines.length === 0
|
|
1070
|
+
const sections = [
|
|
1071
|
+
...blocks,
|
|
1072
|
+
...(parked.length > 0 ? [textFrame(parked)] : []),
|
|
1073
|
+
...(planned.length > 0 ? [plannedFrame(planned, state, width)] : []),
|
|
1074
|
+
]
|
|
1075
|
+
const lead = !endsWithRule && blocks.length === 0 && sections.length > 0 ? [textFrame([rule(width)])] : []
|
|
716
1076
|
return {
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
...(planned.length > 0 ? [textFrame([rule(width)]), plannedFrame(planned, state, width)] : []),
|
|
722
|
-
]),
|
|
1077
|
+
waitingBlock: stack.hits[0],
|
|
1078
|
+
appointmentBlock: stack.hits[1],
|
|
1079
|
+
header: [...above, ...(stack.lines.length > 0 || nowLines.length > 0 ? stack.lines : [rule(width)])],
|
|
1080
|
+
body: joinFrames([...lead, ...separate(sections, width, blocks.length)]),
|
|
723
1081
|
footer: [
|
|
724
1082
|
rule(width),
|
|
725
1083
|
fit(paint.dim(TEXTS.hint), width),
|