jackherd 1.7.0 → 1.8.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 +43 -0
- package/INSTALL.md +4 -2
- package/{NOTICE → NOTICE.txt} +1 -1
- package/README.md +94 -1033
- package/assets/icons/bark-dark.svg +1 -1
- package/assets/icons/bark.svg +1 -1
- package/assets/icons/bowl-dark.svg +1 -1
- package/assets/icons/bowl.svg +1 -1
- package/assets/icons/decide-dark.svg +1 -1
- package/assets/icons/decide.svg +1 -1
- package/assets/icons/ear-dark.svg +1 -1
- package/assets/icons/ear.svg +1 -1
- package/assets/icons/lanes-dark.svg +1 -1
- package/assets/icons/lanes.svg +1 -1
- package/assets/icons/plan-dark.svg +1 -1
- package/assets/icons/plan.svg +1 -1
- package/dist/{chunk-OBUWTQ6Z.js → chunk-M6DDZEW4.js} +24 -10
- package/dist/config.d.ts +11 -5
- package/dist/config.js +53 -21
- package/dist/plan-format.d.ts +73 -7
- package/dist/plan-format.js +180 -100
- package/docs/workflow-dark.svg +1 -1
- package/docs/workflow.svg +1 -1
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/herdr-panes.ts +2 -12
- package/kit/hooks/hooks/approval-gate/index.ts +10 -8
- package/kit/hooks/hooks/approval-gate/run.ts +21 -12
- package/kit/hooks/hooks/ask-hint/decision.ts +3 -3
- package/kit/hooks/hooks/git-guard/decision.ts +189 -15
- package/kit/hooks/hooks/git-guard/index.ts +13 -3
- package/kit/hooks/hooks/git-guard/push-target.ts +85 -0
- package/kit/hooks/hooks/git-guard/repository.ts +63 -0
- package/kit/hooks/hooks/merge-guard/check-free.ts +1 -13
- package/kit/hooks/hooks/merge-guard/decision.ts +342 -102
- package/kit/hooks/hooks/merge-guard/index.ts +6 -4
- package/kit/hooks/hooks/merge-guard/plain-path.ts +15 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +23 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-settings.ts +142 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +196 -0
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +168 -0
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +40 -0
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +194 -0
- package/kit/hooks/hooks/merge-guard/pull-request.ts +67 -21
- package/kit/hooks/hooks/merge-guard/run.ts +118 -29
- package/kit/hooks/hooks/merge-guard/settings.ts +8 -8
- package/kit/hooks/hooks/merge-guard/worker.ts +3 -3
- package/kit/hooks/hooks/shared/base-branch.ts +23 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +93 -0
- package/kit/hooks/hooks/shared/command-text-runners.ts +71 -0
- package/kit/hooks/hooks/shared/command.ts +14 -2
- package/kit/hooks/hooks/shared/commit.ts +2 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +4 -1
- package/kit/hooks/hooks/shared/json.ts +10 -0
- package/kit/hooks/hooks/shared/lane.ts +57 -0
- package/kit/hooks/hooks/shared/once.ts +8 -0
- package/kit/hooks/hooks/shared/own-pane.ts +31 -0
- package/kit/hooks/hooks/shared/pane-agent.ts +24 -0
- package/kit/hooks/hooks/shared/plan-open-phases.ts +114 -0
- package/kit/hooks/hooks/shared/program-position.ts +148 -5
- package/kit/hooks/hooks/shared/shell-aliases.ts +258 -0
- package/kit/hooks/hooks/shared/shell-snapshots.ts +68 -0
- package/kit/hooks/hooks/shared/shell.ts +167 -20
- package/kit/hooks/hooks.json +11 -2
- package/kit/hooks/statusline.sh +29 -16
- package/kit/review/skills/diagnose/SKILL.md +1 -1
- package/kit/review/skills/review/SKILL.md +2 -0
- package/kit/rules/rules/kit/testing.md +2 -5
- package/kit/rules/rules/kit/writing-for-agents.md +3 -1
- package/kit/workflow/agents/spec-review.md +1 -1
- package/kit/workflow/skills/follow/SKILL.md +3 -3
- package/kit/workflow/skills/grill/SKILL.md +1 -1
- package/kit/workflow/skills/implement/SKILL.md +10 -11
- package/kit/workflow/skills/orchestrate/SKILL.md +155 -117
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +17 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +27 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -0
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +23 -0
- package/kit/workflow/skills/plan/SKILL.md +5 -2
- package/kit/workflow/skills/plan/references/template-English.md +6 -0
- package/kit/workflow/skills/plan/references/template-German.md +6 -0
- package/package.json +3 -2
- package/recipes/README.md +21 -0
- package/recipes/github-issues.md +23 -0
- package/recipes/github-projects.md +22 -0
- package/recipes/jira-cli.md +26 -0
- package/recipes/jira-rest.md +24 -0
- package/recipes/linear.md +25 -0
- package/recipes/sentry.md +18 -0
- package/recipes/sources/jira.ts +51 -0
- package/recipes/sources/linear.ts +19 -0
- package/recipes/sources/sentry.ts +12 -0
- package/recipes/sources.md +80 -0
- package/recipes/status-page.md +19 -0
- package/schemas/jackherd.schema.json +42 -18
- package/src/check/command.ts +2 -2
- package/src/check/parallel-report.ts +19 -9
- package/src/cli.ts +1 -0
- package/src/config/context-shares.ts +130 -0
- package/src/config/legacy-names.ts +8 -0
- package/src/config/read-config.ts +12 -4
- package/src/config/schema.ts +75 -20
- package/src/context/auto-compact.ts +68 -0
- package/src/context/command.ts +68 -17
- package/src/context/coordinator-name.ts +27 -9
- package/src/context/index.ts +0 -1
- package/src/context/level.ts +18 -11
- package/src/context/report.ts +6 -3
- package/src/plan-format/dependencies.ts +144 -32
- package/src/plan-format/index.ts +6 -0
- package/src/plan-format/lanes.ts +6 -0
- package/src/plan-format/progress.ts +19 -8
- package/src/plans/command.ts +14 -5
- package/src/plans/current-phase.ts +63 -7
- package/src/plans/current-task.ts +51 -0
- package/src/plans/ensure-panes.ts +25 -8
- package/src/plans/frame.ts +99 -0
- package/src/plans/git.ts +8 -0
- package/src/plans/herd-memory.ts +107 -0
- package/src/plans/lane-plans.ts +5 -3
- package/src/plans/lane-summary.ts +222 -0
- package/src/plans/layout-phase.ts +148 -0
- package/src/plans/layout-summary.ts +140 -0
- package/src/plans/layout.ts +164 -420
- package/src/plans/live.ts +11 -6
- package/src/plans/plan-panes.ts +15 -0
- package/src/plans/planned.ts +9 -42
- package/src/plans/selection.ts +2 -6
- package/src/plans/settings.ts +2 -7
- package/src/plans/snapshot.ts +38 -39
- package/src/plans/symbols.ts +2 -2
- package/src/plans/texts.ts +32 -31
- package/src/plans/view.ts +0 -1
- package/src/plans/worked-phases.ts +2 -2
- package/src/settled/board-row.ts +133 -0
- package/src/settled/check.ts +129 -0
- package/src/settled/command.ts +65 -0
- package/src/settled/evidence-version.ts +14 -0
- package/src/settled/options.ts +69 -0
- package/src/settled/send.ts +27 -0
- package/src/settled/settle.ts +87 -0
- package/src/settled/topics.ts +56 -0
- package/src/sync/command.ts +16 -1
- package/src/sync/render.ts +13 -4
- package/src/sync/sync.ts +25 -5
- package/src/terminal/herdr.ts +46 -10
- package/src/terminal/none.ts +1 -0
- package/src/terminal/types.ts +15 -2
- package/src/update/command.ts +4 -2
- package/src/update/update.ts +33 -9
- package/src/vocabulary/index.ts +47 -11
- package/src/watch/appointments.ts +205 -0
- package/src/watch/command.ts +62 -19
- package/src/watch/lane-report.ts +11 -3
- package/src/watch/texts.ts +35 -59
- package/src/watch/watch-events.ts +21 -28
- package/kit/hooks/hooks/approval-gate/lane.ts +0 -14
package/src/plans/layout.ts
CHANGED
|
@@ -1,27 +1,23 @@
|
|
|
1
1
|
import { CONTEXT_LEVEL, type ContextLevel, type ContextReading, contextLabel } from '../context'
|
|
2
|
-
import type {
|
|
3
|
-
import {
|
|
2
|
+
import type { LaneProgress, PhaseProgress } from '../plan-format'
|
|
3
|
+
import { currentPhase, isPhaseOpen, phaseLabel } from '../plan-format'
|
|
4
4
|
import {
|
|
5
5
|
BADGE_TONE,
|
|
6
|
-
BAR_SEGMENT,
|
|
7
6
|
type BadgeTone,
|
|
8
7
|
badge,
|
|
9
8
|
bar,
|
|
10
9
|
box,
|
|
11
10
|
fit,
|
|
12
|
-
INVERSE_ON,
|
|
13
11
|
packGroups,
|
|
14
12
|
padVisible,
|
|
15
13
|
paint,
|
|
16
|
-
RESET,
|
|
17
14
|
spread,
|
|
18
|
-
stripAnsi,
|
|
19
15
|
visibleLength,
|
|
20
16
|
wrap,
|
|
21
17
|
} from '../terminal/text'
|
|
22
18
|
import { AGENT_STATUS, type AgentStatus, isAgentStatus } from '../terminal/types'
|
|
23
19
|
import { textWidth } from '../terminal/width'
|
|
24
|
-
import { PLAN_LANGUAGE
|
|
20
|
+
import { PLAN_LANGUAGE } from '../vocabulary'
|
|
25
21
|
import {
|
|
26
22
|
NO_BADGES,
|
|
27
23
|
type PullRequestBadges,
|
|
@@ -30,38 +26,27 @@ import {
|
|
|
30
26
|
type WatchBadges,
|
|
31
27
|
type WatchHealth,
|
|
32
28
|
} from '../watch/plan-badges'
|
|
29
|
+
import { deeper, type Frame, joinFrames, nest, prefixed, rowFrame, TREE, textFrame } from './frame'
|
|
33
30
|
import { LANE_STANDING, type LaneStanding, laneStanding, waitingNote } from './lane-standing'
|
|
31
|
+
import { type LaneSummary, laneSummary } from './lane-summary'
|
|
34
32
|
import type { LaneTickets } from './lane-tickets'
|
|
33
|
+
import { COLUMN_SEPARATOR, countCell, countText, type LaneContext, MIN_TITLE_WIDTH, plain } from './layout-phase'
|
|
34
|
+
import { summaryChildren } from './layout-summary'
|
|
35
35
|
import { chipKey, type LinkChip, planUrlOf } from './link-chips'
|
|
36
|
-
import {
|
|
36
|
+
import type { PhaseModel } from './phase-models'
|
|
37
37
|
import { type PlannedPlan, phaseList, plannedPhaseCount } from './planned'
|
|
38
38
|
import { CHECKS, type ChecksState, type PullRequest, pullRequestKey } from './pull-requests'
|
|
39
39
|
import {
|
|
40
40
|
type ClickTarget,
|
|
41
|
-
followingKey,
|
|
42
41
|
isLaneOpen,
|
|
43
|
-
itemKey,
|
|
44
42
|
laneKey,
|
|
45
43
|
PLANNED_KEY,
|
|
46
|
-
phaseKey,
|
|
47
44
|
type Selection,
|
|
48
45
|
selectedKey,
|
|
49
46
|
targetKeys,
|
|
50
|
-
tickedKey,
|
|
51
47
|
} from './selection'
|
|
52
48
|
import { shownWithMore } from './shown-with-more'
|
|
53
|
-
import {
|
|
54
|
-
chevronOf,
|
|
55
|
-
MODEL_MARK,
|
|
56
|
-
NO_AGENT_SYMBOL,
|
|
57
|
-
NO_AGENT_WORD,
|
|
58
|
-
PHASE_COUNT,
|
|
59
|
-
PHASE_STATE,
|
|
60
|
-
type PhaseState,
|
|
61
|
-
phaseStateOf,
|
|
62
|
-
statusSymbol,
|
|
63
|
-
statusWord,
|
|
64
|
-
} from './symbols'
|
|
49
|
+
import { chevronOf, NO_AGENT_SYMBOL, NO_AGENT_WORD, statusSymbol, statusWord } from './symbols'
|
|
65
50
|
import { BADGE_TEXTS, type BadgeTexts, checksText, laneMarkText, TEXTS } from './texts'
|
|
66
51
|
|
|
67
52
|
/**
|
|
@@ -81,8 +66,10 @@ export type LaneView = {
|
|
|
81
66
|
chips: Record<string, LinkChip[]>
|
|
82
67
|
/** How full the agent's context is; null when the terminal gave no session or the session has no log. */
|
|
83
68
|
context?: ContextReading | null
|
|
84
|
-
/**
|
|
85
|
-
|
|
69
|
+
/** What the lane works on now, what comes next and after, and what is finished; derived from `progress` when absent. */
|
|
70
|
+
summary?: LaneSummary
|
|
71
|
+
/** The title of the agent's session as the terminal shows it; absent when it has none. */
|
|
72
|
+
sessionTitle?: string
|
|
86
73
|
/** A running lane the sprint table has no row for; drawn with the mark `no row in lanes`. */
|
|
87
74
|
unlisted?: boolean
|
|
88
75
|
/** Preview and feedback of the lane's pull requests from the running watcher's state; none without a watcher. */
|
|
@@ -93,10 +80,7 @@ export type LaneView = {
|
|
|
93
80
|
models?: Record<string, PhaseModel>
|
|
94
81
|
}
|
|
95
82
|
|
|
96
|
-
/**
|
|
97
|
-
* `watch` is the health of the watcher the badges come from, absent when it was not read; `language` the project's
|
|
98
|
-
* response language for the badges' words, English when absent.
|
|
99
|
-
*/
|
|
83
|
+
/** `watch` is the health of the watcher the badges come from, absent when it was not read. */
|
|
100
84
|
export type OverviewView = {
|
|
101
85
|
title: string
|
|
102
86
|
time: string
|
|
@@ -105,78 +89,45 @@ export type OverviewView = {
|
|
|
105
89
|
watch?: WatchHealth
|
|
106
90
|
/** True when the config marks a ticket source, so a missing watcher is said without a pull request too. */
|
|
107
91
|
watchesTickets?: boolean
|
|
108
|
-
language?: PlanLanguage
|
|
109
92
|
}
|
|
110
93
|
|
|
111
|
-
/**
|
|
112
|
-
* The lines of a frame, the line holding the selected row, a phase or a task (-1 when none), so the window can
|
|
113
|
-
* follow it, and the spans a click expands or closes.
|
|
114
|
-
*/
|
|
115
|
-
export type Frame = { lines: string[]; cursorLine: number; targets: ClickTarget[] }
|
|
116
|
-
|
|
117
94
|
/** A fixed header and footer around a body that scrolls; body lines and targets count from the body's top. */
|
|
118
95
|
export type Screen = { header: string[]; body: Frame; footer: string[] }
|
|
119
96
|
|
|
120
|
-
/** What every block of one lane is drawn with. */
|
|
121
|
-
type LaneContext = {
|
|
122
|
-
progress: LaneProgress
|
|
123
|
-
state: Selection
|
|
124
|
-
width: number
|
|
125
|
-
chips: Record<string, LinkChip[]>
|
|
126
|
-
badges: WatchBadges
|
|
127
|
-
badgeTexts: BadgeTexts
|
|
128
|
-
/** The lane's tickets for the plan block that shows them, the first; null in every other block. */
|
|
129
|
-
tickets: LaneTickets | null
|
|
130
|
-
/** The model each phase ran on, by `phaseModelKey`. */
|
|
131
|
-
models: Record<string, PhaseModel>
|
|
132
|
-
}
|
|
133
|
-
|
|
134
97
|
/**
|
|
135
98
|
* Widths shared by every lane line: the name column, the folded pull request badges, and bar with context. They are
|
|
136
99
|
* measured over every lane on screen, open ones too, so opening or closing a lane moves no other lane's columns.
|
|
100
|
+
* `phases` says whether the phase count follows the bar: it gives way on every line when it would push a lane's
|
|
101
|
+
* detail onto a line of its own or cut its status.
|
|
137
102
|
*/
|
|
138
|
-
type LaneColumns = { name: number; marks: number; right: number }
|
|
139
|
-
|
|
140
|
-
type StepContext = LaneContext & { stepIndex: number }
|
|
141
|
-
|
|
142
|
-
type PhaseEntry = { stepIndex: number; phase: PhaseProgress }
|
|
103
|
+
type LaneColumns = { name: number; marks: number; right: number; phases: boolean; tally: number }
|
|
143
104
|
|
|
144
105
|
const BRANCH_MARK = '⎇'
|
|
145
|
-
const ACTIVE_MARK = '▶'
|
|
146
|
-
const DEFERRED_MARK = '→'
|
|
147
106
|
const SYMBOL_WIDTH = 2
|
|
148
|
-
const COLUMN_SEPARATOR = ' '
|
|
149
107
|
const NAME_WIDTH_MAX = 24
|
|
150
108
|
const NON_BREAKING_SPACE = '\u00a0'
|
|
151
109
|
const ITEM_SEPARATOR = '·'
|
|
152
110
|
const RULE = '─'
|
|
153
111
|
const CONTENT_INDENT = ' '
|
|
154
|
-
|
|
155
|
-
/** The tree lines: a branch to a sibling that has more below it, the last branch, and what carries on beside them. */
|
|
156
|
-
const TREE = { branch: '├─ ', last: '└─ ', pipe: '│ ', gap: ' ', trunk: '│' } as const
|
|
157
|
-
const TREE_GUTTER = TREE.branch.length
|
|
158
|
-
|
|
159
|
-
/** Places on each side of the slash of a count: ` 9/9 `, ` 15/15 `, `123/456`. */
|
|
160
|
-
const COUNT_PLACES = 3
|
|
161
|
-
/** The cells before a count: `◐`, `✓`, `?`, `–` or nothing, padded so every slash on screen is in one column. */
|
|
162
|
-
const COUNT_MARK_WIDTH = 2
|
|
163
|
-
const BLANK_COUNT = ' '.repeat(2 * COUNT_PLACES + 1)
|
|
164
|
-
const MIN_TITLE_WIDTH = 8
|
|
165
112
|
const MIN_BESIDE_WIDTH = 24
|
|
166
113
|
const CONTEXT_WIDTH = 5
|
|
114
|
+
/** The context cell at the right of the session line: symbol, share and a cell of space. */
|
|
115
|
+
const CONTEXT_CELL_WIDTH = CONTEXT_WIDTH + 3
|
|
167
116
|
const CONTEXT_SYMBOL = '🥣'
|
|
168
117
|
const PROGRESS_SYMBOL = '◐'
|
|
169
118
|
const HEADER_MARK = '🐾'
|
|
170
119
|
const COLUMN_RULE = '│'
|
|
171
120
|
/** The number badge of a three-digit pull request (`↗ #` and the digits, a cell of padding either side); a longer one widens it. */
|
|
172
121
|
const PULL_REQUEST_BADGE_WIDTH = 8
|
|
173
|
-
/**
|
|
174
|
-
|
|
122
|
+
/**
|
|
123
|
+
* Under the lane line's status symbol: where the pull request row and its badge line start (D96), and where the trunk
|
|
124
|
+
* of an open lane hangs, not under the fold arrow.
|
|
125
|
+
*/
|
|
126
|
+
const UNDER_STATUS = ' '
|
|
175
127
|
const CHECKS_WIDTH = 10
|
|
176
128
|
/** The most failed tests listed under a pull request; the rest is counted. */
|
|
177
129
|
const SHOWN_FAILED_TESTS = 3
|
|
178
130
|
const FAILED_TEST_INDENT = ' '
|
|
179
|
-
const MARKDOWN_MARKS = /\*\*|`/g
|
|
180
131
|
|
|
181
132
|
/** Lower ranks need the coordinator sooner; a status an adapter adds later ranks after `working`. */
|
|
182
133
|
const URGENCY: Record<AgentStatus, number> = {
|
|
@@ -212,48 +163,6 @@ const urgencyOf = ({ status }: LaneView): number => {
|
|
|
212
163
|
export const sortByUrgency = (lanes: readonly LaneView[]): LaneView[] =>
|
|
213
164
|
[...lanes].sort((left, right) => urgencyOf(left) - urgencyOf(right))
|
|
214
165
|
|
|
215
|
-
const plain = (text: string): string => text.replace(MARKDOWN_MARKS, '')
|
|
216
|
-
|
|
217
|
-
const isCurrent = (progress: LaneProgress, stepIndex: number, phase: PhaseProgress): boolean =>
|
|
218
|
-
progress.current?.stepIndex === stepIndex && progress.current.phaseId === phase.id
|
|
219
|
-
|
|
220
|
-
const PHASE_PAINT: Record<PhaseState, (count: string) => string> = {
|
|
221
|
-
[PHASE_STATE.missing]: paint.dim,
|
|
222
|
-
[PHASE_STATE.empty]: paint.dim,
|
|
223
|
-
[PHASE_STATE.all]: paint.green,
|
|
224
|
-
[PHASE_STATE.open]: (count) => count,
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const countText = (done: number, total: number): string =>
|
|
228
|
-
`${String(done).padStart(COUNT_PLACES)}/${String(total).padEnd(COUNT_PLACES)}`
|
|
229
|
-
|
|
230
|
-
const COUNT_CELL_WIDTH = COUNT_MARK_WIDTH + 1 + BLANK_COUNT.length
|
|
231
|
-
|
|
232
|
-
/** Mark and count in one fixed-width cell at the right edge, the same for the lane, phase and planned rows. */
|
|
233
|
-
const countCell = (mark: string, count: string): string => `${padVisible(mark, COUNT_MARK_WIDTH)} ${count}`
|
|
234
|
-
|
|
235
|
-
/** A word in place of a count, right-aligned in the width of the count cell like `n/m`. */
|
|
236
|
-
const wordCell = (word: string): string => `${' '.repeat(Math.max(COUNT_CELL_WIDTH - textWidth(word), 0))}${word}`
|
|
237
|
-
|
|
238
|
-
const PHASE_MARK: Record<PhaseState, string> = {
|
|
239
|
-
[PHASE_STATE.missing]: PHASE_COUNT.missing,
|
|
240
|
-
[PHASE_STATE.empty]: PHASE_COUNT.empty,
|
|
241
|
-
[PHASE_STATE.all]: '',
|
|
242
|
-
[PHASE_STATE.open]: '',
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/** The count cell of a phase row at the right edge; the current phase is bold yellow. A phase lacking only the documentation box says so instead of a count. */
|
|
246
|
-
const phaseCountCell = (phase: PhaseProgress, current: boolean): string => {
|
|
247
|
-
const state = phaseStateOf(phase)
|
|
248
|
-
if (state === PHASE_STATE.open && onlyDocumentationOpen(phase)) {
|
|
249
|
-
const cell = wordCell(TEXTS.allButDocs)
|
|
250
|
-
return current ? paint.bold(paint.yellow(cell)) : paint.green(cell)
|
|
251
|
-
}
|
|
252
|
-
const hasCount = state === PHASE_STATE.open || state === PHASE_STATE.all
|
|
253
|
-
const cell = countCell(PHASE_MARK[state], hasCount ? countText(phase.done, phase.total) : BLANK_COUNT)
|
|
254
|
-
return current ? paint.bold(paint.yellow(cell)) : PHASE_PAINT[state](cell)
|
|
255
|
-
}
|
|
256
|
-
|
|
257
166
|
const agentLabel = (agent: string | null): string => {
|
|
258
167
|
if (agent === null) return paint.dim(statusWord(agent))
|
|
259
168
|
return isAgentStatus(agent) ? STATUS_PAINT[agent](statusWord(agent)) : statusWord(agent)
|
|
@@ -282,149 +191,6 @@ const gitLabel = (status: LaneStatus): string =>
|
|
|
282
191
|
.filter(Boolean)
|
|
283
192
|
.join(' ')
|
|
284
193
|
|
|
285
|
-
const textFrame = (lines: string[]): Frame => ({ lines, cursorLine: -1, targets: [] })
|
|
286
|
-
|
|
287
|
-
/** Stacks frames, moving each one's cursor line and click targets below the frames before it. */
|
|
288
|
-
const joinFrames = (frames: Frame[]): Frame =>
|
|
289
|
-
frames.reduce<Frame>((joined, frame) => {
|
|
290
|
-
const offset = joined.lines.length
|
|
291
|
-
return {
|
|
292
|
-
lines: [...joined.lines, ...frame.lines],
|
|
293
|
-
cursorLine: frame.cursorLine === -1 ? joined.cursorLine : offset + frame.cursorLine,
|
|
294
|
-
targets: [...joined.targets, ...frame.targets.map((target) => ({ ...target, line: offset + target.line }))],
|
|
295
|
-
}
|
|
296
|
-
}, textFrame([]))
|
|
297
|
-
|
|
298
|
-
/** The context of the children of a node: the pane minus the tree lines in front of them. */
|
|
299
|
-
const deeper = <Context extends Pick<LaneContext, 'width'>>(context: Context): Context => ({
|
|
300
|
-
...context,
|
|
301
|
-
width: Math.max(context.width - TREE_GUTTER, 1),
|
|
302
|
-
})
|
|
303
|
-
|
|
304
|
-
/**
|
|
305
|
-
* A frame moved right by a prefix of `TREE_GUTTER` cells: a click target starting at the left edge keeps it and
|
|
306
|
-
* takes the prefix in.
|
|
307
|
-
*/
|
|
308
|
-
const prefixed = (frame: Frame, first: string, rest: string): Frame => ({
|
|
309
|
-
lines: frame.lines.map((line, index) => {
|
|
310
|
-
const lead = index === 0 ? first : rest
|
|
311
|
-
return line === '' ? paint.dim(lead.trimEnd()) : `${paint.dim(lead)}${line}`
|
|
312
|
-
}),
|
|
313
|
-
cursorLine: frame.cursorLine,
|
|
314
|
-
targets: frame.targets.map((target) => ({
|
|
315
|
-
...target,
|
|
316
|
-
from: target.from === 0 ? 0 : target.from + TREE_GUTTER,
|
|
317
|
-
to: target.to + TREE_GUTTER,
|
|
318
|
-
})),
|
|
319
|
-
})
|
|
320
|
-
|
|
321
|
-
const branches = (children: Frame[]): Frame[] => {
|
|
322
|
-
const present = children.filter((child) => child.lines.length > 0)
|
|
323
|
-
return present.map((child, index) => {
|
|
324
|
-
const last = index === present.length - 1
|
|
325
|
-
return prefixed(child, last ? TREE.last : TREE.branch, last ? TREE.gap : TREE.pipe)
|
|
326
|
-
})
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
/**
|
|
330
|
-
* The children of a node as a tree: each child's first line behind a branch, the last behind the closing one, the
|
|
331
|
-
* lines of a child after its first beside the trunk. The caller has drawn the children `TREE_GUTTER` narrower
|
|
332
|
-
* (`deeper`); a child without lines leaves no branch.
|
|
333
|
-
*/
|
|
334
|
-
const nest = (children: Frame[]): Frame => joinFrames(branches(children))
|
|
335
|
-
|
|
336
|
-
/** `nest` with a bare trunk line before every child, to set the children of the lane apart. */
|
|
337
|
-
const nestSpaced = (children: Frame[]): Frame =>
|
|
338
|
-
joinFrames(branches(children).flatMap((child) => [textFrame([paint.dim(TREE.trunk)]), child]))
|
|
339
|
-
|
|
340
|
-
/**
|
|
341
|
-
* A row drawn inverse across the pane. A progress bar inside it is switched back to normal video: inverted,
|
|
342
|
-
* its filled and empty blocks would trade places with the background and the bar would be unreadable.
|
|
343
|
-
*/
|
|
344
|
-
const highlight = (line: string, width: number): string => {
|
|
345
|
-
const bars = line.match(BAR_SEGMENT) ?? []
|
|
346
|
-
const texts = line.split(BAR_SEGMENT).map(stripAnsi)
|
|
347
|
-
const body = texts.map((text, index) => `${text}${bars[index] ? `${bars[index]}${INVERSE_ON}` : ''}`).join('')
|
|
348
|
-
return `${INVERSE_ON}${body}${' '.repeat(Math.max(width - visibleLength(line), 0))}${RESET}`
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/** A selectable row: drawn inverse when selected, clickable across the pane on every line, `below` follows unselectable. */
|
|
352
|
-
const rowFrame = (
|
|
353
|
-
key: string,
|
|
354
|
-
rows: string[],
|
|
355
|
-
below: string[],
|
|
356
|
-
{ state, width }: Pick<LaneContext, 'state' | 'width'>,
|
|
357
|
-
): Frame => {
|
|
358
|
-
const selected = state.selected === key
|
|
359
|
-
return {
|
|
360
|
-
lines: [...(selected ? rows.map((row) => highlight(row, width)) : rows), ...below],
|
|
361
|
-
cursorLine: selected ? 0 : -1,
|
|
362
|
-
targets: rows.map((_, line) => ({ line, from: 0, to: width, key })),
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/** `text` wrapped beside a one-cell `mark`, each line in `style`; follow-up lines indent under the text. */
|
|
367
|
-
const markedLines = (text: string, mark: string, style: (line: string) => string, width: number): string[] =>
|
|
368
|
-
wrap(plain(text), Math.max(width - 2, 1)).map((line, index) => `${index > 0 ? ' ' : mark} ${style(line)}`)
|
|
369
|
-
|
|
370
|
-
/** The mark and the text style of a task: a ticked one green and dim, the one being worked on yellow and bold. */
|
|
371
|
-
const taskLook = ({ done }: ChecklistItem, active: boolean): { mark: string; style: (line: string) => string } => {
|
|
372
|
-
if (active) return { mark: paint.yellow(ACTIVE_MARK), style: paint.bold }
|
|
373
|
-
return done ? { mark: paint.green('☑'), style: paint.dim } : { mark: '☐', style: (line) => line }
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
/** A closed task as one row, cut at the pane's edge with `…`. */
|
|
377
|
-
const closedTaskRow = (item: ChecklistItem, active: boolean, width: number): string[] => {
|
|
378
|
-
const { mark, style } = taskLook(item, active)
|
|
379
|
-
return [fit(`${mark} ${style(plain(item.text))}`, width)]
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
/** An open task as rows: its whole text wrapped. */
|
|
383
|
-
const openTaskRows = (item: ChecklistItem, active: boolean, width: number): string[] => {
|
|
384
|
-
const { mark, style } = taskLook(item, active)
|
|
385
|
-
return markedLines(item.text, mark, style, width)
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/** A task as a selectable row under its `key`: clickable across every line it takes, the cursor on its first. */
|
|
389
|
-
const taskFrame = (key: string, rows: string[], context: Pick<LaneContext, 'state' | 'width'>): Frame =>
|
|
390
|
-
rowFrame(key, rows, [], context)
|
|
391
|
-
|
|
392
|
-
/** A checkbox moved to another phase, dim: `→ P9 <text>`. */
|
|
393
|
-
const deferredRow = ({ text, target }: DeferredItem, width: number): string =>
|
|
394
|
-
fit(paint.dim(`${DEFERRED_MARK} ${PHASE_ID_PREFIX}${target} ${plain(text)}`), width)
|
|
395
|
-
|
|
396
|
-
const tickedSummaryFrame = (count: number, phaseRowKey: string, width: number): Frame => ({
|
|
397
|
-
lines: [fit(paint.dim(`${chevronOf(false)} ${TEXTS.tickedSummary(count)}`), width)],
|
|
398
|
-
cursorLine: -1,
|
|
399
|
-
targets: [{ line: 0, from: 0, to: width, key: tickedKey(phaseRowKey) }],
|
|
400
|
-
})
|
|
401
|
-
|
|
402
|
-
/**
|
|
403
|
-
* The children of an open phase, one frame per task: open ones first, `active` marked among them, then the ones
|
|
404
|
-
* deferred to another phase, then the ticked ones listed or summed up.
|
|
405
|
-
*/
|
|
406
|
-
const phaseChildren = (
|
|
407
|
-
context: StepContext,
|
|
408
|
-
phaseRowKey: string,
|
|
409
|
-
phase: PhaseProgress,
|
|
410
|
-
active: ChecklistItem | null,
|
|
411
|
-
): Frame[] => {
|
|
412
|
-
const { state, width } = context
|
|
413
|
-
if (phase.items.length === 0 && !phase.deferred) return [textFrame([paint.dim(TEXTS.noCheckboxes)])]
|
|
414
|
-
const frameOf = (item: ChecklistItem, index: number): Frame => {
|
|
415
|
-
const key = itemKey(phaseRowKey, index)
|
|
416
|
-
const isActive = item === active
|
|
417
|
-
const rows = state.expanded.has(key) ? openTaskRows(item, isActive, width) : closedTaskRow(item, isActive, width)
|
|
418
|
-
return taskFrame(key, rows, context)
|
|
419
|
-
}
|
|
420
|
-
const framesWhere = (done: boolean): Frame[] =>
|
|
421
|
-
phase.items.flatMap((item, index) => (item.done === done ? [frameOf(item, index)] : []))
|
|
422
|
-
const tickedCount = phase.items.filter((item) => item.done).length
|
|
423
|
-
const summary = tickedCount > 0 ? [tickedSummaryFrame(tickedCount, phaseRowKey, width)] : []
|
|
424
|
-
const deferred = (phase.deferred ?? []).map((item) => textFrame([deferredRow(item, width)]))
|
|
425
|
-
return [...framesWhere(false), ...deferred, ...(state.showDone ? framesWhere(true) : summary)]
|
|
426
|
-
}
|
|
427
|
-
|
|
428
194
|
/** A head in fixed columns with `text` wrapped beside it; follow-up lines indent to where the text starts. */
|
|
429
195
|
const besideHead = (
|
|
430
196
|
head: string,
|
|
@@ -459,70 +225,6 @@ export const rowWithCount = (
|
|
|
459
225
|
)
|
|
460
226
|
}
|
|
461
227
|
|
|
462
|
-
/** ` ticked, not merged` in yellow behind a phase the lane ticked to the end in its worktree only; empty otherwise. Only the current phase carries it, the others fold into the ticked line. */
|
|
463
|
-
const unmergedMark = (phase: PhaseProgress): string =>
|
|
464
|
-
isTickedUnmerged(phase) ? ` ${paint.yellow(TEXTS.tickedUnmerged)}` : ''
|
|
465
|
-
|
|
466
|
-
/** The chevron of a phase row; a finished phase that is closed shows a green ✓ in its place. */
|
|
467
|
-
const phaseMark = (phase: PhaseProgress, open: boolean): string =>
|
|
468
|
-
!open && phaseStateOf(phase) === PHASE_STATE.all ? paint.green(PHASE_COUNT.allTicked) : paint.dim(chevronOf(open))
|
|
469
|
-
|
|
470
|
-
const familyLabel = (family: string): string => `${family.charAt(0).toUpperCase()}${family.slice(1)}`
|
|
471
|
-
|
|
472
|
-
/**
|
|
473
|
-
* The model the phase's subagent last ran on: `Opus ✓` when it fits the plan's recommendation, `Sonnet ≠ Opus` in
|
|
474
|
-
* amber when it does not, the family alone, dim, when the plan recommends none; empty without a record.
|
|
475
|
-
*/
|
|
476
|
-
const modelMark = (model: PhaseModel | undefined): string => {
|
|
477
|
-
if (!model) return ''
|
|
478
|
-
const used = familyLabel(model.used)
|
|
479
|
-
if (model.recommended === null) return paint.dim(used)
|
|
480
|
-
return model.fits
|
|
481
|
-
? `${paint.dim(used)} ${paint.green(MODEL_MARK.fits)}`
|
|
482
|
-
: paint.yellow(`${used} ${MODEL_MARK.differs} ${familyLabel(model.recommended)}`)
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
/**
|
|
486
|
-
* The phase title cut before the model and the count, which stay at the right edge, or the note that the plan lacks
|
|
487
|
-
* the phase. The model gives way in a pane too narrow to leave the title `MIN_TITLE_WIDTH` cells.
|
|
488
|
-
*/
|
|
489
|
-
const phaseRow = (phase: PhaseProgress, open: boolean, current: boolean, width: number, model?: PhaseModel): string => {
|
|
490
|
-
const title = phase.title === null ? paint.dim(TEXTS.notInPlan) : plain(phase.title)
|
|
491
|
-
const label = `${phaseMark(phase, open)} P${phase.id} `
|
|
492
|
-
const head = `${label}${title}${current ? unmergedMark(phase) : ''}`
|
|
493
|
-
const count = phaseCountCell(phase, current)
|
|
494
|
-
const withModel = `${modelMark(model)}${COLUMN_SEPARATOR}${count}`
|
|
495
|
-
const titleRoom = width - visibleLength(label) - visibleLength(withModel) - 1
|
|
496
|
-
const fitsModel =
|
|
497
|
-
model !== undefined && (titleRoom >= MIN_TITLE_WIDTH || titleRoom >= visibleLength(head) - visibleLength(label))
|
|
498
|
-
return spread(head, fitsModel ? withModel : count, width)
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
/** A closed phase shows only `active` under it, when given, closed whatever the task's fold. */
|
|
502
|
-
const activeTaskFrame = (
|
|
503
|
-
context: StepContext,
|
|
504
|
-
phaseRowKey: string,
|
|
505
|
-
phase: PhaseProgress,
|
|
506
|
-
active: ChecklistItem | null,
|
|
507
|
-
): Frame => {
|
|
508
|
-
if (!active) return textFrame([])
|
|
509
|
-
const key = itemKey(phaseRowKey, phase.items.indexOf(active))
|
|
510
|
-
return taskFrame(key, closedTaskRow(active, true, context.width), context)
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
/** One phase as a node, with its tasks below it when expanded, or its active task when it is the current one. */
|
|
514
|
-
const phaseFrame = (context: StepContext, phase: PhaseProgress): Frame => {
|
|
515
|
-
const { progress, state, stepIndex, width } = context
|
|
516
|
-
const key = phaseKey(progress.lane.name, stepIndex, phase.id)
|
|
517
|
-
const open = state.expanded.has(key)
|
|
518
|
-
const current = isCurrent(progress, stepIndex, phase)
|
|
519
|
-
const inner = deeper(context)
|
|
520
|
-
const active = current ? activeItem(phase) : null
|
|
521
|
-
const children = open ? phaseChildren(inner, key, phase, active) : [activeTaskFrame(inner, key, phase, active)]
|
|
522
|
-
const model = context.models[phaseModelKey(stepIndex, phase.id)]
|
|
523
|
-
return joinFrames([rowFrame(key, [phaseRow(phase, open, current, width, model)], [], context), nest(children)])
|
|
524
|
-
}
|
|
525
|
-
|
|
526
228
|
/** True when number, checks and the badges leave room for a title beside them on one line. */
|
|
527
229
|
const fitsBeside = (head: string, cells: BadgeCell[], width: number): boolean =>
|
|
528
230
|
visibleLength(head) + packedWidth(cells) + COLUMN_SEPARATOR.length + MIN_TITLE_WIDTH <= width
|
|
@@ -538,7 +240,7 @@ const pullRequestRow = (pullRequest: PullRequest, context: LaneContext): Frame =
|
|
|
538
240
|
const checksColumn =
|
|
539
241
|
textWidth(checksWords) < CHECKS_WIDTH ? CHECKS_WIDTH : textWidth(checksWords) + COLUMN_SEPARATOR.length
|
|
540
242
|
const checks = padVisible(CHECKS_PAINT[pullRequest.checks](checksWords), checksColumn)
|
|
541
|
-
const head = `${
|
|
243
|
+
const head = `${UNDER_STATUS}${number}${COLUMN_SEPARATOR}${checks}`
|
|
542
244
|
const prefix = pullRequest.draft ? TEXTS.draft : ''
|
|
543
245
|
const dimPrefix = (line: string, index: number) =>
|
|
544
246
|
index === 0 && prefix ? `${paint.dim(prefix)}${line.slice(prefix.length)}` : line
|
|
@@ -547,7 +249,7 @@ const pullRequestRow = (pullRequest: PullRequest, context: LaneContext): Frame =
|
|
|
547
249
|
if (cells.length === 0 || !fitsBeside(head, cells, context.width))
|
|
548
250
|
return joinFrames([
|
|
549
251
|
rowFrame(key, besideHead(head, `${prefix}${pullRequest.title}`, context.width, dimPrefix), [], context),
|
|
550
|
-
packedFrame(cells,
|
|
252
|
+
packedFrame(cells, UNDER_STATUS, context.width),
|
|
551
253
|
])
|
|
552
254
|
const beside = `${head}${cells.map(({ cell }) => cell).join(CHIP_GAP)}${COLUMN_SEPARATOR}`
|
|
553
255
|
const row = rowFrame(
|
|
@@ -582,30 +284,35 @@ const CONTEXT_PAINT: Record<ContextLevel, (text: string) => string> = {
|
|
|
582
284
|
[CONTEXT_LEVEL.alert]: paint.red,
|
|
583
285
|
}
|
|
584
286
|
|
|
585
|
-
/**
|
|
287
|
+
/** `🥣 62 %`, the share in the colour of its level and right-aligned in a fixed cell; empty without a reading. */
|
|
288
|
+
const contextValue = (context: ContextReading | null | undefined): string =>
|
|
289
|
+
context ? `${CONTEXT_SYMBOL}${CONTEXT_PAINT[context.level](contextLabel(context.share).padStart(CONTEXT_WIDTH))}` : ''
|
|
290
|
+
|
|
291
|
+
/** The context value with a rule before the bar that follows it on a folded lane line; empty without a reading. */
|
|
586
292
|
const contextCell = (context: ContextReading | null | undefined): string =>
|
|
587
|
-
context
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
}, [])
|
|
293
|
+
context ? `${contextValue(context)} ${paint.dim(COLUMN_RULE)} ` : ''
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 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: `██░░ ◐ 50/213 · 4/14 ph`. The phase count stands right-aligned in `tallyWidth` cells behind the
|
|
298
|
+
* `·`, the widest of the lanes on screen, so a shorter one moves neither bar nor `·`; at 0 it is left out.
|
|
299
|
+
*/
|
|
300
|
+
const progressColumn = ({ done, total, phasesDone, phases }: LaneSummary['tally'], tallyWidth: number): string => {
|
|
301
|
+
const counts = `${bar(done, total)} ${countCell(PROGRESS_SYMBOL, countText(done, total))}`
|
|
302
|
+
if (tallyWidth === 0) return counts
|
|
303
|
+
const tally = TEXTS.phaseTally(phasesDone, phases)
|
|
304
|
+
const padding = ' '.repeat(Math.max(tallyWidth - visibleLength(tally), 0))
|
|
305
|
+
// A lane without phases keeps the column blank, so its bar starts where the others do.
|
|
306
|
+
const phaseCount = tally ? `${TALLY_SEPARATOR}${padding}${tally}` : ' '.repeat(TALLY_SEPARATOR.length + tallyWidth)
|
|
307
|
+
return `${counts}${paint.dim(phaseCount)}`
|
|
308
|
+
}
|
|
604
309
|
|
|
605
310
|
const CHIP_GAP = ' '
|
|
606
|
-
|
|
311
|
+
|
|
607
312
|
const TICKET_MARK = '📡'
|
|
608
313
|
const STATUS_SEPARATOR = ' · '
|
|
314
|
+
/** Between the checkbox count and the phase count of a lane. */
|
|
315
|
+
const TALLY_SEPARATOR = ' · '
|
|
609
316
|
|
|
610
317
|
/** `✦ label` as a badge linked to the chip's address, `private` faint after it for a link its plan says is private. */
|
|
611
318
|
const chipCell = ({ url, icon, label, private: hidden }: LinkChip): string =>
|
|
@@ -672,16 +379,16 @@ const ticketCells = (tickets: LaneTickets | null): BadgeCell[] => {
|
|
|
672
379
|
|
|
673
380
|
const chipCellOf = (chip: LinkChip): BadgeCell => ({ cell: chipCell(chip), key: chipKey(chip.url) })
|
|
674
381
|
|
|
675
|
-
/** The chips of
|
|
382
|
+
/** The chips of the lane's plans behind a 📎, each once, the plan chips first and the tickets beside them. */
|
|
676
383
|
const chipFrame = (chips: LinkChip[], tickets: LaneTickets | null, width: number): Frame => {
|
|
677
384
|
const isPlanChip = (chip: LinkChip) => planUrlOf(chipKey(chip.url)) !== null
|
|
385
|
+
const unique = chips.filter((chip, index) => chips.findIndex(({ url }) => url === chip.url) === index)
|
|
678
386
|
const cells = [
|
|
679
|
-
...
|
|
387
|
+
...unique.filter(isPlanChip).map(chipCellOf),
|
|
680
388
|
...ticketCells(tickets),
|
|
681
|
-
...
|
|
389
|
+
...unique.filter((chip) => !isPlanChip(chip)).map(chipCellOf),
|
|
682
390
|
]
|
|
683
|
-
|
|
684
|
-
return frame.lines.length > 0 ? { ...frame, lines: [...frame.lines, ''] } : frame
|
|
391
|
+
return packedFrame(cells, '', width)
|
|
685
392
|
}
|
|
686
393
|
|
|
687
394
|
/**
|
|
@@ -705,32 +412,22 @@ const badgeCells = (
|
|
|
705
412
|
]
|
|
706
413
|
}
|
|
707
414
|
|
|
708
|
-
|
|
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))
|
|
709
417
|
|
|
710
418
|
/**
|
|
711
|
-
*
|
|
712
|
-
*
|
|
419
|
+
* The phase the lane line names: the open phase moved last, else the lane's current phase while it is open; never one
|
|
420
|
+
* ticked to the end, whose state DONE says (`not merged`). Null when there is neither.
|
|
713
421
|
*/
|
|
714
|
-
const
|
|
715
|
-
const
|
|
716
|
-
|
|
717
|
-
const inner = deeper(context)
|
|
718
|
-
return joinFrames([
|
|
719
|
-
textFrame(planHeading(planTitle, width)),
|
|
720
|
-
nest([
|
|
721
|
-
chipFrame(planHref ? (chips[planHref] ?? []) : [], tickets, inner.width),
|
|
722
|
-
...entries.map(({ stepIndex, phase }) => phaseFrame({ ...inner, stepIndex }, phase)),
|
|
723
|
-
]),
|
|
724
|
-
])
|
|
422
|
+
const headPhase = (view: LaneView): PhaseProgress | null => {
|
|
423
|
+
const current = currentPhase(view.progress)
|
|
424
|
+
return summaryOf(view).latest ?? (current && isPhaseOpen(current) ? current : null)
|
|
725
425
|
}
|
|
726
426
|
|
|
727
|
-
/**
|
|
728
|
-
const
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
const nextPhaseLabel = ({ progress }: LaneView): string => {
|
|
732
|
-
const phase = currentPhase(progress)
|
|
733
|
-
return phase ? `P${phase.id}${phase.title ? ` ${plain(phase.title)}` : ''}${unmergedMark(phase)}` : ''
|
|
427
|
+
/** `P9 Freigabe 0.x` for the phase the lane line names; empty when there is none. */
|
|
428
|
+
const nextPhaseLabel = (view: LaneView): string => {
|
|
429
|
+
const phase = headPhase(view)
|
|
430
|
+
return phase ? `${phaseLabel(phase.id)}${phase.title ? ` ${plain(phase.title)}` : ''}` : ''
|
|
734
431
|
}
|
|
735
432
|
|
|
736
433
|
const markTone = ({ feedback, changesRequested }: PullRequestBadges): BadgeTone => {
|
|
@@ -760,34 +457,54 @@ const ticketMark = (tickets: LaneTickets | undefined): string[] => {
|
|
|
760
457
|
const laneMarks = ({ pullRequests, badges, tickets }: LaneView): string =>
|
|
761
458
|
[...pullRequests.map((pullRequest) => pullRequestMark(pullRequest, badges ?? {})), ...ticketMark(tickets)].join(' ')
|
|
762
459
|
|
|
763
|
-
/** What follows the status on a lane line:
|
|
764
|
-
const laneDetail = (view: LaneView
|
|
460
|
+
/** What follows the status on a lane line, open or folded: the phase it works on; the branch is on the session line. */
|
|
461
|
+
const laneDetail = (view: LaneView): string => {
|
|
765
462
|
const markText = laneMarkText(view)
|
|
766
463
|
const marker = markText ? paint.yellow(markText) : ''
|
|
767
|
-
return [marker,
|
|
464
|
+
return [marker, nextPhaseLabel(view)].filter(Boolean).join(' ')
|
|
768
465
|
}
|
|
769
466
|
|
|
770
467
|
/**
|
|
771
468
|
* The lane line, `head` and `right` (bar and count) at the edges. The detail (next phase, branch, pull
|
|
772
469
|
* requests) follows `head` when at least `MIN_BESIDE_WIDTH` characters are left for it; in a narrower pane
|
|
773
|
-
* it moves to a line of its own below,
|
|
470
|
+
* it moves to a line of its own below, behind `indent` (three cells, the trunk of an open lane) and cut to the full width.
|
|
774
471
|
*/
|
|
775
|
-
export const withDetail = (
|
|
472
|
+
export const withDetail = (
|
|
473
|
+
head: string,
|
|
474
|
+
detail: string,
|
|
475
|
+
right: string,
|
|
476
|
+
width: number,
|
|
477
|
+
indent = CONTENT_INDENT,
|
|
478
|
+
): string[] => {
|
|
776
479
|
if (!detail) return [spread(head, right, width)]
|
|
777
480
|
const room = width - visibleLength(head) - COLUMN_SEPARATOR.length - visibleLength(right) - 1
|
|
778
481
|
if (room >= MIN_BESIDE_WIDTH) return [spread(`${head}${COLUMN_SEPARATOR}${detail}`, right, width)]
|
|
779
|
-
return [spread(head, right, width), fit(`${
|
|
482
|
+
return [spread(head, right, width), fit(`${indent}${detail}`, width)]
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** The summary the snapshot read for the lane, or the one its own steps give. */
|
|
486
|
+
const summaryOf = (view: LaneView): LaneSummary =>
|
|
487
|
+
view.summary ??
|
|
488
|
+
laneSummary({ progress: view.progress, phases: {}, texts: {}, activity: {}, language: PLAN_LANGUAGE.en }, 0)
|
|
489
|
+
|
|
490
|
+
/** The bar over the lane's plans, empty while they have no checkbox. */
|
|
491
|
+
const laneProgressColumn = (view: LaneView, tallyWidth: number): string => {
|
|
492
|
+
const { tally } = summaryOf(view)
|
|
493
|
+
return tally.total > 0 ? progressColumn(tally, tallyWidth) : ''
|
|
780
494
|
}
|
|
781
495
|
|
|
782
|
-
/** Context and bar at the right edge of a lane line, as wide as the lane has them. */
|
|
783
|
-
const laneRight = (view: LaneView): string => {
|
|
784
|
-
const counts =
|
|
496
|
+
/** Context and bar at the right edge of a folded lane line, as wide as the lane has them. */
|
|
497
|
+
const laneRight = (view: LaneView, tallyWidth: number): string => {
|
|
498
|
+
const counts = laneProgressColumn(view, tallyWidth)
|
|
785
499
|
return counts ? `${contextCell(view.context)}${counts}` : contextCell(view.context).trimEnd()
|
|
786
500
|
}
|
|
787
501
|
|
|
502
|
+
/** The cells of the phase count column, 0 when the pane leaves it out. */
|
|
503
|
+
const tallyWidthOf = ({ phases, tally }: LaneColumns): number => (phases ? tally : 0)
|
|
504
|
+
|
|
788
505
|
/** A folded lane's marks in the column of `columns.marks`, right before context and bar (D96). */
|
|
789
506
|
const markedRight = (view: LaneView, columns: LaneColumns): string => {
|
|
790
|
-
const right = laneRight(view)
|
|
507
|
+
const right = laneRight(view, tallyWidthOf(columns))
|
|
791
508
|
const marks = laneMarks(view)
|
|
792
509
|
if (!marks) return right
|
|
793
510
|
if (columns.right === 0) return padVisible(marks, columns.marks)
|
|
@@ -802,10 +519,39 @@ const laneLines = (view: LaneView, open: boolean, width: number, columns: LaneCo
|
|
|
802
519
|
padVisible(agentLabel(status.agent), STATUS_WIDTH),
|
|
803
520
|
].join(COLUMN_SEPARATOR)
|
|
804
521
|
const head = `${paint.dim(chevronOf(open))} ${symbolCell(status.agent)} ${cells}`
|
|
805
|
-
const right = open ?
|
|
806
|
-
return withDetail(
|
|
522
|
+
const right = open ? laneProgressColumn(view, tallyWidthOf(columns)) : markedRight(view, columns)
|
|
523
|
+
return withDetail(
|
|
524
|
+
head,
|
|
525
|
+
laneDetail(view),
|
|
526
|
+
right,
|
|
527
|
+
width,
|
|
528
|
+
open ? `${UNDER_STATUS}${paint.dim(TREE.pipe)}` : CONTENT_INDENT,
|
|
529
|
+
)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* 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
|
+
* context at the right. The title keeps its room: the branch gives way when both do not fit, and only then is the
|
|
535
|
+
* title cut. Without a title the branch stands at the left.
|
|
536
|
+
*/
|
|
537
|
+
const sessionLine = (view: LaneView, width: number): string => {
|
|
538
|
+
const git = gitLabel(view.status)
|
|
539
|
+
const context = contextValue(view.context)
|
|
540
|
+
if (!view.sessionTitle) return spread(fit(git, Math.max(width - CONTEXT_CELL_WIDTH, 1)), context, width)
|
|
541
|
+
const left = plain(view.sessionTitle)
|
|
542
|
+
const room = width - (context ? CONTEXT_CELL_WIDTH : 0)
|
|
543
|
+
const withGit = visibleLength(left) + COLUMN_SEPARATOR.length + visibleLength(git) <= room
|
|
544
|
+
const right = [withGit ? git : '', context].filter(Boolean).join(COLUMN_SEPARATOR)
|
|
545
|
+
return spread(fit(left, Math.max(room, 1)), right, width)
|
|
807
546
|
}
|
|
808
547
|
|
|
548
|
+
const underStatus = (frame: Frame): Frame =>
|
|
549
|
+
prefixed(frame, { first: UNDER_STATUS, rest: UNDER_STATUS, style: (text) => text })
|
|
550
|
+
|
|
551
|
+
/** The chips of the plans the lane's steps name, in the order of its steps. */
|
|
552
|
+
const laneChips = ({ progress, chips }: LaneView): LinkChip[] =>
|
|
553
|
+
progress.steps.flatMap(({ step }) => (step.planHref ? (chips[step.planHref] ?? []) : []))
|
|
554
|
+
|
|
809
555
|
/** The lane line is a row of its own; an open lane continues with its pull requests and plans. */
|
|
810
556
|
const laneBlock = (
|
|
811
557
|
view: LaneView,
|
|
@@ -814,7 +560,7 @@ const laneBlock = (
|
|
|
814
560
|
columns: LaneColumns,
|
|
815
561
|
badgeTexts: BadgeTexts,
|
|
816
562
|
): Frame => {
|
|
817
|
-
const context = {
|
|
563
|
+
const context: LaneContext = {
|
|
818
564
|
progress: view.progress,
|
|
819
565
|
state,
|
|
820
566
|
width,
|
|
@@ -827,22 +573,20 @@ const laneBlock = (
|
|
|
827
573
|
const open = isLaneOpen(state, view.progress.lane.name, view.status.agent)
|
|
828
574
|
const row = rowFrame(laneKey(view.progress.lane.name), laneLines(view, open, width, columns), [], context)
|
|
829
575
|
if (!open) return row
|
|
830
|
-
const
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
const
|
|
837
|
-
const
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
),
|
|
843
|
-
followingFrame(view, state, inner.width),
|
|
576
|
+
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
|
+
}
|
|
581
|
+
const [session, ...sections] = summaryChildren(inner, summaryOf(view), head)
|
|
582
|
+
const alone = sections.every((section) => section.lines.length === 0)
|
|
583
|
+
const sessionLead = alone ? { first: TREE.last, rest: TREE.gap } : { first: TREE.branch, rest: TREE.pipe }
|
|
584
|
+
return joinFrames([
|
|
585
|
+
row,
|
|
586
|
+
underStatus(prefixed(session, { ...sessionLead, style: paint.dim })),
|
|
587
|
+
...view.pullRequests.map((pullRequest) => pullRequestFrame(pullRequest, context)),
|
|
588
|
+
underStatus(nest(sections)),
|
|
844
589
|
])
|
|
845
|
-
return joinFrames([row, ...view.pullRequests.map((pullRequest) => pullRequestFrame(pullRequest, context)), content])
|
|
846
590
|
}
|
|
847
591
|
|
|
848
592
|
const laneTally = ({ progress }: Pick<LaneView, 'progress'>): string =>
|
|
@@ -901,21 +645,6 @@ const plannedRows = (planned: PlannedPlan[], width: number, indent: string): str
|
|
|
901
645
|
),
|
|
902
646
|
)
|
|
903
647
|
|
|
904
|
-
/** The plan title up to its subtitle: `Agent-Kit: Skills, Agenten und …` is `Agent-Kit`. */
|
|
905
|
-
const planName = (title: string): string => plain(title.split(':')[0]).trim()
|
|
906
|
-
|
|
907
|
-
/** The node under an open lane for the phases after its own: closed, `▸ also open: P7 P8`; opened, one child per plan. */
|
|
908
|
-
const followingFrame = (view: LaneView, state: Selection, width: number): Frame => {
|
|
909
|
-
const following = view.following ?? []
|
|
910
|
-
if (following.length === 0) return textFrame([])
|
|
911
|
-
const key = followingKey(view.progress.lane.name)
|
|
912
|
-
const open = state.expanded.has(key)
|
|
913
|
-
const label = TEXTS.following(following.map((plan) => `${planName(plan.title)}: ${phaseList(plan)}`).join(' · '))
|
|
914
|
-
const head = [paint.dim(fit(`${chevronOf(open)} ${label}`, width))]
|
|
915
|
-
const plans = open ? plannedRows(following, deeper({ width }).width, '').map(textFrame) : []
|
|
916
|
-
return joinFrames([rowFrame(key, head, [], { state, width }), nest(plans)])
|
|
917
|
-
}
|
|
918
|
-
|
|
919
648
|
const rule = (width: number): string => paint.dim(RULE.repeat(width))
|
|
920
649
|
|
|
921
650
|
/** A dimmed rule before every lane block but the first, folded or open: it shows where one lane ends. */
|
|
@@ -935,11 +664,26 @@ const watchNotice = (
|
|
|
935
664
|
return watch.kind === WATCH_HEALTH.off ? texts.watcherOff : texts.stale(watch.minutes)
|
|
936
665
|
}
|
|
937
666
|
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
})
|
|
667
|
+
/** Chevron, symbol and the gaps around them before the name column of a lane line. */
|
|
668
|
+
const LANE_HEAD_CELLS = 2 + SYMBOL_WIDTH + 1
|
|
669
|
+
|
|
670
|
+
const laneColumnsOf = (lanes: LaneView[], width: number): LaneColumns => {
|
|
671
|
+
const name = Math.min(Math.max(0, ...lanes.map(({ progress }) => textWidth(progress.lane.name))), NAME_WIDTH_MAX)
|
|
672
|
+
const marks = Math.max(0, ...lanes.map((lane) => visibleLength(laneMarks(lane))))
|
|
673
|
+
const tally = Math.max(
|
|
674
|
+
0,
|
|
675
|
+
...lanes.map((lane) => {
|
|
676
|
+
const { phasesDone, phases } = summaryOf(lane).tally
|
|
677
|
+
return visibleLength(TEXTS.phaseTally(phasesDone, phases))
|
|
678
|
+
}),
|
|
679
|
+
)
|
|
680
|
+
const rightOf = (tallyWidth: number) =>
|
|
681
|
+
Math.max(0, ...lanes.map((lane) => visibleLength(laneRight(lane, tallyWidth))))
|
|
682
|
+
const head = LANE_HEAD_CELLS + name + COLUMN_SEPARATOR.length + STATUS_WIDTH + COLUMN_SEPARATOR.length
|
|
683
|
+
const beside = MIN_BESIDE_WIDTH + COLUMN_SEPARATOR.length
|
|
684
|
+
const phases = head + beside + (marks > 0 ? marks + COLUMN_SEPARATOR.length : 0) + rightOf(tally) <= width
|
|
685
|
+
return { name, marks, right: rightOf(phases ? tally : 0), phases, tally }
|
|
686
|
+
}
|
|
943
687
|
|
|
944
688
|
/** The lanes by what the view does with them; closed lanes are in none of the three groups. */
|
|
945
689
|
const partitionLanes = (lanes: LaneView[]): Record<'active' | 'waiting' | 'noAgent', LaneView[]> => {
|
|
@@ -958,14 +702,14 @@ const partitionLanes = (lanes: LaneView[]): Record<'active' | 'waiting' | 'noAge
|
|
|
958
702
|
* lanes with a worktree but no agent each share a closing block; finished lanes are not drawn.
|
|
959
703
|
*/
|
|
960
704
|
export const renderScreen = (
|
|
961
|
-
{ title, time, lanes, planned, watch, watchesTickets
|
|
705
|
+
{ title, time, lanes, planned, watch, watchesTickets }: OverviewView,
|
|
962
706
|
state: Selection,
|
|
963
707
|
width: number,
|
|
964
708
|
): Screen => {
|
|
965
709
|
const { active, waiting, noAgent } = partitionLanes(lanes)
|
|
966
710
|
const parked = [...waitingBlock(waiting, width), ...parkedBlock(noAgent, width)]
|
|
967
|
-
const columns = laneColumnsOf(active)
|
|
968
|
-
const badgeTexts = BADGE_TEXTS
|
|
711
|
+
const columns = laneColumnsOf(active, width)
|
|
712
|
+
const badgeTexts = BADGE_TEXTS
|
|
969
713
|
const blocks = active.map((lane) => laneBlock(lane, state, width, columns, badgeTexts))
|
|
970
714
|
const notice = watchNotice({ lanes: active, watch, watchesTickets }, badgeTexts)
|
|
971
715
|
const clock = notice ? `${paint.yellow(notice)}${COLUMN_SEPARATOR}${paint.dim(time)}` : paint.dim(time)
|