jackherd 1.8.0 → 1.9.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 +39 -0
- package/INSTALL.md +2 -0
- package/README.md +41 -60
- package/assets/branding/README.md +5 -7
- package/assets/branding/jackherd-logo-outline.svg +1 -0
- package/assets/branding/outline.test.ts +30 -0
- package/assets/icons/bark.svg +1 -1
- package/assets/icons/bowl.svg +1 -1
- package/assets/icons/decide.svg +1 -1
- package/assets/icons/ear.svg +1 -1
- package/assets/icons/lanes.svg +1 -1
- package/assets/icons/plan.svg +1 -1
- package/dist/{chunk-M6DDZEW4.js → chunk-MCP4XJQH.js} +17 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +19 -3
- package/dist/plan-format.d.ts +22 -3
- package/dist/plan-format.js +21 -7
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- 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/merge-guard/decision.ts +70 -21
- package/kit/hooks/hooks/merge-guard/xargs-program.ts +182 -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 +18 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
- package/kit/hooks/hooks.json +9 -0
- package/kit/hooks/statusline.sh +1 -1
- package/kit/workflow/skills/implement/SKILL.md +2 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +31 -6
- package/package.json +3 -1
- package/schemas/jackherd.schema.json +26 -2
- package/src/cli.ts +3 -0
- package/src/config/read-config.ts +5 -1
- package/src/config/schema.ts +22 -2
- package/src/context/fill.ts +12 -0
- package/src/context/session-log.ts +43 -1
- package/src/plan-format/clock.ts +4 -0
- package/src/plan-format/dependencies.ts +2 -5
- package/src/plan-format/lanes.ts +30 -5
- package/src/plan-format/terminal-text.ts +4 -0
- package/src/plan-format/waiting.ts +48 -0
- package/src/plans/active-tasks.ts +60 -0
- package/src/plans/current-phase.ts +17 -7
- package/src/plans/current-task.ts +63 -5
- package/src/plans/lane-standing.ts +12 -6
- package/src/plans/lane-summary.ts +141 -74
- package/src/plans/lane-tickets.ts +15 -0
- package/src/plans/layout-phase.ts +57 -18
- package/src/plans/layout-summary.ts +85 -67
- package/src/plans/layout.ts +253 -89
- package/src/plans/live.ts +22 -0
- package/src/plans/phase-models.ts +1 -1
- package/src/plans/phase-ranges.ts +45 -0
- package/src/plans/pull-requests.ts +6 -0
- package/src/plans/selection.ts +19 -16
- package/src/plans/sent-files-read.ts +22 -0
- package/src/plans/sent-files.ts +121 -0
- package/src/plans/signals-read.ts +54 -0
- package/src/plans/snapshot.ts +251 -55
- package/src/plans/subagents-read.ts +82 -0
- package/src/plans/subagents.ts +132 -0
- package/src/plans/texts.ts +16 -11
- package/src/plans/unlisted-lanes.ts +10 -5
- package/src/plans/view.ts +3 -3
- package/src/plans/waiting-click.ts +76 -0
- package/src/preview/command.ts +83 -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-records.ts +48 -0
- package/src/task-signals/agent.ts +15 -0
- package/src/task-signals/claude-read.ts +72 -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 +8 -1
- package/src/terminal/width.ts +2 -1
- package/src/update/command.ts +2 -0
- package/src/update/config-shares.ts +30 -0
- package/src/update/update.ts +12 -1
- package/src/vocabulary/index.ts +33 -1
- package/src/waiting/command.ts +35 -0
- package/src/watch/appointments.ts +5 -5
- package/src/watch/approvals.ts +2 -2
- package/src/watch/command.ts +91 -5
- 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/json-guards.ts +0 -3
- package/src/watch/lane-fill.ts +1 -1
- package/src/watch/lane-model.ts +1 -1
- package/src/watch/lane-parallel.ts +1 -1
- package/src/watch/lane-progress.ts +19 -7
- package/src/watch/lane-report.ts +5 -1
- package/src/watch/lane-rest.ts +28 -0
- package/src/watch/plan-badges.ts +9 -2
- package/src/watch/previews.ts +1 -1
- package/src/watch/pull-requests.ts +2 -1
- package/src/watch/source-path.ts +1 -1
- package/src/watch/sources.ts +1 -1
- package/src/watch/stream-output.ts +12 -7
- package/src/watch/task-parallel-input.ts +118 -0
- package/src/watch/task-parallel-read.ts +149 -0
- package/src/watch/task-parallel-touched.ts +39 -0
- package/src/watch/task-parallel.ts +206 -0
- package/src/watch/waiting-line.ts +19 -0
- package/src/watch/watch-events.ts +13 -1
- package/src/watch/workflow-runs.ts +2 -1
- package/assets/icons/bark-dark.svg +0 -1
- package/assets/icons/bowl-dark.svg +0 -1
- package/assets/icons/decide-dark.svg +0 -1
- package/assets/icons/ear-dark.svg +0 -1
- package/assets/icons/lanes-dark.svg +0 -1
- package/assets/icons/plan-dark.svg +0 -1
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,16 +27,25 @@ import {
|
|
|
26
27
|
type WatchBadges,
|
|
27
28
|
type WatchHealth,
|
|
28
29
|
} from '../watch/plan-badges'
|
|
30
|
+
import type { UnplannedTask } from './active-tasks'
|
|
29
31
|
import { deeper, type Frame, joinFrames, nest, prefixed, rowFrame, TREE, textFrame } from './frame'
|
|
30
32
|
import { LANE_STANDING, type LaneStanding, laneStanding, waitingNote } from './lane-standing'
|
|
31
33
|
import { type LaneSummary, laneSummary } from './lane-summary'
|
|
32
|
-
import type
|
|
33
|
-
import {
|
|
34
|
+
import { type LaneTickets, withoutTicketKey } from './lane-tickets'
|
|
35
|
+
import {
|
|
36
|
+
COLUMN_SEPARATOR,
|
|
37
|
+
countCell,
|
|
38
|
+
countText,
|
|
39
|
+
type LaneContext,
|
|
40
|
+
MIN_TITLE_WIDTH,
|
|
41
|
+
nowLine,
|
|
42
|
+
plain,
|
|
43
|
+
} from './layout-phase'
|
|
34
44
|
import { summaryChildren } from './layout-summary'
|
|
35
45
|
import { chipKey, type LinkChip, planUrlOf } from './link-chips'
|
|
36
46
|
import type { PhaseModel } from './phase-models'
|
|
37
47
|
import { type PlannedPlan, phaseList, plannedPhaseCount } from './planned'
|
|
38
|
-
import { CHECKS, type ChecksState, type PullRequest, pullRequestKey } from './pull-requests'
|
|
48
|
+
import { CHECKS, type ChecksState, failedTestKey, type PullRequest, pullRequestKey } from './pull-requests'
|
|
39
49
|
import {
|
|
40
50
|
type ClickTarget,
|
|
41
51
|
isLaneOpen,
|
|
@@ -45,9 +55,11 @@ import {
|
|
|
45
55
|
selectedKey,
|
|
46
56
|
targetKeys,
|
|
47
57
|
} from './selection'
|
|
58
|
+
import type { SentChip } from './sent-files'
|
|
48
59
|
import { shownWithMore } from './shown-with-more'
|
|
49
|
-
import { chevronOf, NO_AGENT_SYMBOL,
|
|
60
|
+
import { chevronOf, NO_AGENT_SYMBOL, statusSymbol, statusWord } from './symbols'
|
|
50
61
|
import { BADGE_TEXTS, type BadgeTexts, checksText, laneMarkText, TEXTS } from './texts'
|
|
62
|
+
import { SHOWN_WAITING, WAITING_MARK, type WaitingRow, waitingRows } from './waiting-click'
|
|
51
63
|
|
|
52
64
|
/**
|
|
53
65
|
* Where a lane stands outside its plan. `agent` is the terminal adapter's status, null when no agent is listed;
|
|
@@ -78,6 +90,8 @@ export type LaneView = {
|
|
|
78
90
|
tickets?: LaneTickets
|
|
79
91
|
/** The model each phase's implementing subagent last ran on, by `phaseModelKey`; absent without a record. */
|
|
80
92
|
models?: Record<string, PhaseModel>
|
|
93
|
+
/** The files this lane's session sent to the user, shown as badges on the row of link chips. */
|
|
94
|
+
sentFiles?: SentChip[]
|
|
81
95
|
}
|
|
82
96
|
|
|
83
97
|
/** `watch` is the health of the watcher the badges come from, absent when it was not read. */
|
|
@@ -89,10 +103,15 @@ export type OverviewView = {
|
|
|
89
103
|
watch?: WatchHealth
|
|
90
104
|
/** True when the config marks a ticket source, so a missing watcher is said without a pull request too. */
|
|
91
105
|
watchesTickets?: boolean
|
|
106
|
+
/** The open items of the board's section of what waits on the user, shown under the title. */
|
|
107
|
+
awaiting?: WaitingItem[]
|
|
108
|
+
/** What agents report doing that no lane takes, one `now:` line each under the title. */
|
|
109
|
+
unplaced?: UnplannedTask[]
|
|
92
110
|
}
|
|
93
111
|
|
|
94
112
|
/** A fixed header and footer around a body that scrolls; body lines and targets count from the body's top. */
|
|
95
|
-
|
|
113
|
+
/** The drawn screen; `waitingLine` is the screen line (0-based) of the first for-you row, where a click is matched. */
|
|
114
|
+
export type Screen = { header: string[]; body: Frame; footer: string[]; waitingLine: number }
|
|
96
115
|
|
|
97
116
|
/**
|
|
98
117
|
* Widths shared by every lane line: the name column, the folded pull request badges, and bar with context. They are
|
|
@@ -103,6 +122,8 @@ export type Screen = { header: string[]; body: Frame; footer: string[] }
|
|
|
103
122
|
type LaneColumns = { name: number; marks: number; right: number; phases: boolean; tally: number }
|
|
104
123
|
|
|
105
124
|
const BRANCH_MARK = '⎇'
|
|
125
|
+
const WOOF = 'woof'
|
|
126
|
+
const SENT_MARK = '📎'
|
|
106
127
|
const SYMBOL_WIDTH = 2
|
|
107
128
|
const NAME_WIDTH_MAX = 24
|
|
108
129
|
const NON_BREAKING_SPACE = '\u00a0'
|
|
@@ -114,7 +135,6 @@ const CONTEXT_WIDTH = 5
|
|
|
114
135
|
/** The context cell at the right of the session line: symbol, share and a cell of space. */
|
|
115
136
|
const CONTEXT_CELL_WIDTH = CONTEXT_WIDTH + 3
|
|
116
137
|
const CONTEXT_SYMBOL = '🥣'
|
|
117
|
-
const PROGRESS_SYMBOL = '◐'
|
|
118
138
|
const HEADER_MARK = '🐾'
|
|
119
139
|
const COLUMN_RULE = '│'
|
|
120
140
|
/** The number badge of a three-digit pull request (`↗ #` and the digits, a cell of padding either side); a longer one widens it. */
|
|
@@ -127,17 +147,14 @@ const UNDER_STATUS = ' '
|
|
|
127
147
|
const CHECKS_WIDTH = 10
|
|
128
148
|
/** The most failed tests listed under a pull request; the rest is counted. */
|
|
129
149
|
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
|
|
150
|
+
const FAILED_MARK = '✗'
|
|
151
|
+
/** The tree under a check chip, one cell narrower than the lane's: the chip's children are short. */
|
|
152
|
+
const FAILED_TREE = { branch: '├ ', last: '└ ', pipe: '│ ', gap: ' ' } as const
|
|
153
|
+
/** An unfolded failed test's lines after its first hang under the name, past the mark. */
|
|
154
|
+
const FAILED_HANG = ' '
|
|
155
|
+
const TEST_PATH_LEVEL = ' > '
|
|
156
|
+
const TEST_PATH_SEPARATOR = ' › '
|
|
157
|
+
const PLACE_SEPARATOR = ' · '
|
|
141
158
|
|
|
142
159
|
/** 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
160
|
const STATUS_PAINT: Record<AgentStatus, (text: string) => string> = {
|
|
@@ -154,19 +171,9 @@ const CHECKS_PAINT: Record<ChecksState, (text: string) => string> = {
|
|
|
154
171
|
[CHECKS.none]: paint.dim,
|
|
155
172
|
}
|
|
156
173
|
|
|
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
|
-
}
|
|
174
|
+
/** Only a blocked agent is named in the lane line, since there the user must act; the symbol says the rest. */
|
|
175
|
+
const agentLabel = (agent: string | null): string =>
|
|
176
|
+
agent === AGENT_STATUS.blocked ? STATUS_PAINT[AGENT_STATUS.blocked](statusWord(agent)) : ''
|
|
170
177
|
|
|
171
178
|
/** The symbol of a status padded to `SYMBOL_WIDTH` cells, so the names after it start in one column. */
|
|
172
179
|
const symbolCell = (agent: string | null): string => {
|
|
@@ -179,7 +186,7 @@ const symbolCell = (agent: string | null): string => {
|
|
|
179
186
|
|
|
180
187
|
const aheadLabel = ({ branch, ahead }: LaneStatus): string => {
|
|
181
188
|
if (ahead === null) return branch ? paint.dim('↑?') : ''
|
|
182
|
-
return ahead > 0 ? paint.
|
|
189
|
+
return ahead > 0 ? paint.teal(`↑${ahead}`) : ''
|
|
183
190
|
}
|
|
184
191
|
|
|
185
192
|
const gitLabel = (status: LaneStatus): string =>
|
|
@@ -229,18 +236,26 @@ export const rowWithCount = (
|
|
|
229
236
|
const fitsBeside = (head: string, cells: BadgeCell[], width: number): boolean =>
|
|
230
237
|
visibleLength(head) + packedWidth(cells) + COLUMN_SEPARATOR.length + MIN_TITLE_WIDTH <= width
|
|
231
238
|
|
|
239
|
+
/** The number of a pull request as a badge linked to it, padded to its column. */
|
|
240
|
+
const numberCell = (pullRequest: PullRequest): string =>
|
|
241
|
+
padVisible(badge(pullRequest.url, `↗ #${pullRequest.number}`), PULL_REQUEST_BADGE_WIDTH)
|
|
242
|
+
|
|
243
|
+
/** The column the check chip of a pull request row starts in, behind its number. */
|
|
244
|
+
const checksColumnOf = (pullRequest: PullRequest): number =>
|
|
245
|
+
visibleLength(numberCell(pullRequest)) + COLUMN_SEPARATOR.length
|
|
246
|
+
|
|
232
247
|
/**
|
|
233
248
|
* `⟦↗ #<number>⟧ ✓ checks ⟦🌐 preview⟧ ⟦💬 3 new⟧ title`: the number as a badge and the check state in fixed
|
|
234
249
|
* columns, the watcher's badges after them and the title last, cut with `…` on its one line. When the badges leave no
|
|
235
250
|
* room for a title, they go on lines of their own below, starting in the column of the lane's status symbol (D96).
|
|
236
251
|
*/
|
|
237
252
|
const pullRequestRow = (pullRequest: PullRequest, context: LaneContext): Frame => {
|
|
238
|
-
const number =
|
|
253
|
+
const number = numberCell(pullRequest)
|
|
239
254
|
const checksWords = checksText(pullRequest)
|
|
240
255
|
const checksColumn =
|
|
241
256
|
textWidth(checksWords) < CHECKS_WIDTH ? CHECKS_WIDTH : textWidth(checksWords) + COLUMN_SEPARATOR.length
|
|
242
257
|
const checks = padVisible(CHECKS_PAINT[pullRequest.checks](checksWords), checksColumn)
|
|
243
|
-
const head = `${
|
|
258
|
+
const head = `${number}${COLUMN_SEPARATOR}${checks}`
|
|
244
259
|
const prefix = pullRequest.draft ? TEXTS.draft : ''
|
|
245
260
|
const dimPrefix = (line: string, index: number) =>
|
|
246
261
|
index === 0 && prefix ? `${paint.dim(prefix)}${line.slice(prefix.length)}` : line
|
|
@@ -248,8 +263,8 @@ const pullRequestRow = (pullRequest: PullRequest, context: LaneContext): Frame =
|
|
|
248
263
|
const key = pullRequestKey(pullRequest.number)
|
|
249
264
|
if (cells.length === 0 || !fitsBeside(head, cells, context.width))
|
|
250
265
|
return joinFrames([
|
|
251
|
-
rowFrame(key,
|
|
252
|
-
packedFrame(cells,
|
|
266
|
+
rowFrame(key, [fit(`${head}${dimPrefix(`${prefix}${pullRequest.title}`, 0)}`, context.width)], [], context),
|
|
267
|
+
packedFrame(cells, '', context.width),
|
|
253
268
|
])
|
|
254
269
|
const beside = `${head}${cells.map(({ cell }) => cell).join(CHIP_GAP)}${COLUMN_SEPARATOR}`
|
|
255
270
|
const row = rowFrame(
|
|
@@ -261,22 +276,75 @@ const pullRequestRow = (pullRequest: PullRequest, context: LaneContext): Frame =
|
|
|
261
276
|
return { ...row, targets: [...cellTargets(cells, 0, visibleLength(head), context.width), ...row.targets] }
|
|
262
277
|
}
|
|
263
278
|
|
|
264
|
-
/**
|
|
265
|
-
const
|
|
279
|
+
/** `✗ <place> · <path>` as plain text, each ` > ` of the test path as ` › `; without a place `✗ <path>`. */
|
|
280
|
+
const failedTestText = (name: string, place: string | null): string => {
|
|
281
|
+
const path = plain(name).replaceAll(TEST_PATH_LEVEL, TEST_PATH_SEPARATOR)
|
|
282
|
+
return place ? `${FAILED_MARK} ${plain(place)}${PLACE_SEPARATOR}${path}` : `${FAILED_MARK} ${path}`
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The first line of a failed test painted: mark and place red, separator and test path dim. */
|
|
286
|
+
const paintFailedTest = (line: string, place: string | null): string => {
|
|
287
|
+
const head = place ? `${FAILED_MARK} ${plain(place)}` : FAILED_MARK
|
|
288
|
+
if (!line.startsWith(head)) return paint.dim(line)
|
|
289
|
+
const lead = place ? `${paint.red(FAILED_MARK)} ${paint.red(plain(place))}` : paint.red(FAILED_MARK)
|
|
290
|
+
const rest = line.slice(head.length)
|
|
291
|
+
const separator = place && rest.startsWith(PLACE_SEPARATOR) ? PLACE_SEPARATOR : ''
|
|
292
|
+
return `${lead}${separator ? paint.dim(separator) : ''}${paint.dim(rest.slice(separator.length))}`
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** One child of the check chip: its tree branch (`├`, `└` for the last) and what carries on beside it. */
|
|
296
|
+
type FailedBranch = { first: string; rest: string }
|
|
297
|
+
|
|
298
|
+
const failedBranch = (last: boolean): FailedBranch =>
|
|
299
|
+
last ? { first: FAILED_TREE.last, rest: FAILED_TREE.gap } : { first: FAILED_TREE.branch, rest: FAILED_TREE.pipe }
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* A failed test under the check chip (`column`): cut to one line, which a click unfolds over several, the name hanging
|
|
303
|
+
* under its first word, and a second click folds; a name that fits is no click target.
|
|
304
|
+
*/
|
|
305
|
+
const failedTestFrame = (
|
|
306
|
+
test: { name: string; place: string | null; key: string },
|
|
307
|
+
branch: FailedBranch,
|
|
308
|
+
column: number,
|
|
309
|
+
context: LaneContext,
|
|
310
|
+
): Frame => {
|
|
311
|
+
const indent = ' '.repeat(column)
|
|
312
|
+
const room = Math.max(context.width - column - textWidth(branch.first), 1)
|
|
313
|
+
const text = failedTestText(test.name, test.place)
|
|
314
|
+
const cut = textWidth(text) > room
|
|
315
|
+
const [first = '', ...rest] =
|
|
316
|
+
cut && context.state.expanded.has(test.key) ? wrap(text, Math.max(room - FAILED_HANG.length, 1)) : [fit(text, room)]
|
|
317
|
+
const lines = [
|
|
318
|
+
`${indent}${paint.dim(branch.first)}${paintFailedTest(first, test.place)}`,
|
|
319
|
+
...rest.map((line) => `${indent}${paint.dim(branch.rest)}${FAILED_HANG}${paint.dim(line)}`),
|
|
320
|
+
]
|
|
321
|
+
return cut ? rowFrame(test.key, lines, [], context) : textFrame(lines)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* The failed tests of a red pull request as children of its check chip, at most `SHOWN_FAILED_TESTS`, then `+<n>`;
|
|
326
|
+
* the package of each first when the watcher read it from the log. None for a pull request that is not red.
|
|
327
|
+
*/
|
|
328
|
+
const failedTestsFrame = (pullRequest: PullRequest, context: LaneContext): Frame => {
|
|
329
|
+
const badges = context.badges[String(pullRequest.number)]
|
|
266
330
|
const tests = pullRequest.checks === CHECKS.failed ? (badges?.failedTests ?? []) : []
|
|
267
331
|
const { shown, more } = shownWithMore(tests, SHOWN_FAILED_TESTS)
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
332
|
+
const column = checksColumnOf(pullRequest)
|
|
333
|
+
const frames = shown.map((name, index) =>
|
|
334
|
+
failedTestFrame(
|
|
335
|
+
{ name, place: badges?.failedTestPlaces?.[name] ?? null, key: failedTestKey(pullRequest.number, index) },
|
|
336
|
+
failedBranch(more === 0 && index === shown.length - 1),
|
|
337
|
+
column,
|
|
338
|
+
context,
|
|
339
|
+
),
|
|
271
340
|
)
|
|
341
|
+
const moreLine = `${' '.repeat(column)}${paint.dim(`${FAILED_TREE.last}+${more}`)}`
|
|
342
|
+
return joinFrames([...frames, ...(more > 0 ? [textFrame([fit(moreLine, context.width)])] : [])])
|
|
272
343
|
}
|
|
273
344
|
|
|
274
345
|
/** The pull request row, then the tests its red checks failed, which only the watcher knows. */
|
|
275
346
|
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
|
-
])
|
|
347
|
+
joinFrames([pullRequestRow(pullRequest, context), failedTestsFrame(pullRequest, context)])
|
|
280
348
|
|
|
281
349
|
const CONTEXT_PAINT: Record<ContextLevel, (text: string) => string> = {
|
|
282
350
|
[CONTEXT_LEVEL.ok]: paint.dim,
|
|
@@ -288,17 +356,24 @@ const CONTEXT_PAINT: Record<ContextLevel, (text: string) => string> = {
|
|
|
288
356
|
const contextValue = (context: ContextReading | null | undefined): string =>
|
|
289
357
|
context ? `${CONTEXT_SYMBOL}${CONTEXT_PAINT[context.level](contextLabel(context.share).padStart(CONTEXT_WIDTH))}` : ''
|
|
290
358
|
|
|
291
|
-
/**
|
|
292
|
-
|
|
293
|
-
|
|
359
|
+
/**
|
|
360
|
+
* The context value with a rule before the bar that follows it on a folded lane line, from `warn` on; below, blanks
|
|
361
|
+
* as wide, so nothing moves when it shows. Empty without a reading.
|
|
362
|
+
*/
|
|
363
|
+
const contextCell = (context: ContextReading | null | undefined): string => {
|
|
364
|
+
if (!context) return ''
|
|
365
|
+
const cell = `${contextValue(context)} ${paint.dim(COLUMN_RULE)} `
|
|
366
|
+
return context.level === CONTEXT_LEVEL.ok ? ' '.repeat(visibleLength(cell)) : cell
|
|
367
|
+
}
|
|
294
368
|
|
|
295
369
|
/**
|
|
296
370
|
* 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
|
|
371
|
+
* the same column: `██░░ 50/213 · 4/14 ph`, folded or open. The phase count stands right-aligned in `tallyWidth`
|
|
372
|
+
* cells behind the `·`, the widest of the lanes on screen, so a shorter one moves neither bar nor `·`; at 0 it is
|
|
373
|
+
* left out.
|
|
299
374
|
*/
|
|
300
375
|
const progressColumn = ({ done, total, phasesDone, phases }: LaneSummary['tally'], tallyWidth: number): string => {
|
|
301
|
-
const counts = `${bar(done, total)} ${countCell(
|
|
376
|
+
const counts = `${bar(done, total)} ${countCell('', countText(done, total))}`
|
|
302
377
|
if (tallyWidth === 0) return counts
|
|
303
378
|
const tally = TEXTS.phaseTally(phasesDone, phases)
|
|
304
379
|
const padding = ' '.repeat(Math.max(tallyWidth - visibleLength(tally), 0))
|
|
@@ -377,18 +452,24 @@ const ticketCells = (tickets: LaneTickets | null): BadgeCell[] => {
|
|
|
377
452
|
return [...tickets.shown.map(ticketCell), ...more]
|
|
378
453
|
}
|
|
379
454
|
|
|
455
|
+
/** A file the lane's session sent to the user, `📎 name` linked by its `file:` address; the terminal opens it, no click of ours does. */
|
|
456
|
+
const sentCell = ({ url, label }: SentChip): BadgeCell => ({
|
|
457
|
+
cell: badge(url, `${SENT_MARK} ${plain(label)}`),
|
|
458
|
+
key: null,
|
|
459
|
+
})
|
|
460
|
+
|
|
380
461
|
const chipCellOf = (chip: LinkChip): BadgeCell => ({ cell: chipCell(chip), key: chipKey(chip.url) })
|
|
381
462
|
|
|
382
|
-
/** The chips of the lane's plans
|
|
383
|
-
const
|
|
463
|
+
/** The chips of the lane's plans, each once, the plan chips first, the tickets beside them, the sent files last. */
|
|
464
|
+
const chipCells = (chips: LinkChip[], tickets: LaneTickets | null, sent: readonly SentChip[] = []): BadgeCell[] => {
|
|
384
465
|
const isPlanChip = (chip: LinkChip) => planUrlOf(chipKey(chip.url)) !== null
|
|
385
466
|
const unique = chips.filter((chip, index) => chips.findIndex(({ url }) => url === chip.url) === index)
|
|
386
|
-
|
|
467
|
+
return [
|
|
387
468
|
...unique.filter(isPlanChip).map(chipCellOf),
|
|
388
469
|
...ticketCells(tickets),
|
|
389
470
|
...unique.filter((chip) => !isPlanChip(chip)).map(chipCellOf),
|
|
471
|
+
...sent.map(sentCell),
|
|
390
472
|
]
|
|
391
|
-
return packedFrame(cells, '', width)
|
|
392
473
|
}
|
|
393
474
|
|
|
394
475
|
/**
|
|
@@ -412,12 +493,9 @@ const badgeCells = (
|
|
|
412
493
|
]
|
|
413
494
|
}
|
|
414
495
|
|
|
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
496
|
/**
|
|
419
497
|
* 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
|
|
498
|
+
* ticked to the end, whose state the `✓` line says (`not merged`). Null when there is neither.
|
|
421
499
|
*/
|
|
422
500
|
const headPhase = (view: LaneView): PhaseProgress | null => {
|
|
423
501
|
const current = currentPhase(view.progress)
|
|
@@ -457,17 +535,28 @@ const ticketMark = (tickets: LaneTickets | undefined): string[] => {
|
|
|
457
535
|
const laneMarks = ({ pullRequests, badges, tickets }: LaneView): string =>
|
|
458
536
|
[...pullRequests.map((pullRequest) => pullRequestMark(pullRequest, badges ?? {})), ...ticketMark(tickets)].join(' ')
|
|
459
537
|
|
|
460
|
-
/**
|
|
461
|
-
|
|
538
|
+
/**
|
|
539
|
+
* `P3 Search API +1 · 12/20` for a folded lane: the phase it names, `+n` for the other active phases, the phase's
|
|
540
|
+
* ticked boxes. An open lane names it on a phase branch, so its own line does not.
|
|
541
|
+
*/
|
|
542
|
+
const foldedPhaseLabel = (view: LaneView): string => {
|
|
543
|
+
const phase = headPhase(view)
|
|
544
|
+
if (!phase) return ''
|
|
545
|
+
const more = summaryOf(view).active.filter((active) => active.phase.id !== phase.id).length
|
|
546
|
+
return `${nextPhaseLabel(view)}${more > 0 ? ` +${more}` : ''} · ${phase.done}/${phase.total}`
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** What follows the status on a lane line: the marker and, folded only, the phase it works on; the branch is on the session line. */
|
|
550
|
+
const laneDetail = (view: LaneView, open: boolean): string => {
|
|
462
551
|
const markText = laneMarkText(view)
|
|
463
552
|
const marker = markText ? paint.yellow(markText) : ''
|
|
464
|
-
return [marker,
|
|
553
|
+
return [agentLabel(view.status.agent), marker, open ? '' : foldedPhaseLabel(view)].filter(Boolean).join(' ')
|
|
465
554
|
}
|
|
466
555
|
|
|
467
556
|
/**
|
|
468
557
|
* The lane line, `head` and `right` (bar and count) at the edges. The detail (next phase, branch, pull
|
|
469
558
|
* 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` (
|
|
559
|
+
* it moves to a line of its own below, behind `indent` (`detailIndent`: up to the name column) and cut to the full width.
|
|
471
560
|
*/
|
|
472
561
|
export const withDetail = (
|
|
473
562
|
head: string,
|
|
@@ -484,10 +573,9 @@ export const withDetail = (
|
|
|
484
573
|
|
|
485
574
|
/** The summary the snapshot read for the lane, or the one its own steps give. */
|
|
486
575
|
const summaryOf = (view: LaneView): LaneSummary =>
|
|
487
|
-
view.summary ??
|
|
488
|
-
laneSummary({ progress: view.progress, phases: {}, texts: {}, activity: {}, language: PLAN_LANGUAGE.en }, 0)
|
|
576
|
+
view.summary ?? laneSummary({ progress: view.progress, phases: {}, activity: {} }, 0)
|
|
489
577
|
|
|
490
|
-
/** The bar over the lane's plans
|
|
578
|
+
/** The bar and checkbox count over the lane's plans; empty while they have no checkbox. */
|
|
491
579
|
const laneProgressColumn = (view: LaneView, tallyWidth: number): string => {
|
|
492
580
|
const { tally } = summaryOf(view)
|
|
493
581
|
return tally.total > 0 ? progressColumn(tally, tallyWidth) : ''
|
|
@@ -512,39 +600,80 @@ const markedRight = (view: LaneView, columns: LaneColumns): string => {
|
|
|
512
600
|
return `${padVisible(marks, columns.marks)}${COLUMN_SEPARATOR}${pad}${right}`
|
|
513
601
|
}
|
|
514
602
|
|
|
603
|
+
/**
|
|
604
|
+
* Before a lane's detail on a line of its own: blanks up to the column of the lane's name, so a folded lane's title
|
|
605
|
+
* starts where its name does (17:40), as the branches of an open lane do; an open lane keeps its trunk in front.
|
|
606
|
+
*/
|
|
607
|
+
const detailIndent = (open: boolean, nameColumn: number): string => {
|
|
608
|
+
if (!open) return ' '.repeat(nameColumn)
|
|
609
|
+
const trunk = `${UNDER_STATUS}${TREE.pipe}`
|
|
610
|
+
return `${paint.dim(trunk)}${' '.repeat(Math.max(nameColumn - visibleLength(trunk), 0))}`
|
|
611
|
+
}
|
|
612
|
+
|
|
515
613
|
const laneLines = (view: LaneView, open: boolean, width: number, columns: LaneColumns): string[] => {
|
|
516
614
|
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)
|
|
615
|
+
const cells = padVisible(paint.bold(fit(progress.lane.name, columns.name)), columns.name)
|
|
521
616
|
const head = `${paint.dim(chevronOf(open))} ${symbolCell(status.agent)} ${cells}`
|
|
522
617
|
const right = open ? laneProgressColumn(view, tallyWidthOf(columns)) : markedRight(view, columns)
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
)
|
|
618
|
+
const nameColumn = visibleLength(head) - visibleLength(cells)
|
|
619
|
+
return withDetail(head, laneDetail(view, open), right, width, detailIndent(open, nameColumn))
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** The parts of a session line: the session's title (the branch without one) at the left, the branch and the context value. */
|
|
623
|
+
const sessionParts = (view: LaneView): { left: string; git: string; context: string } => {
|
|
624
|
+
const git = gitLabel(view.status)
|
|
625
|
+
return { left: view.sessionTitle ? plain(view.sessionTitle) : git, git, context: contextValue(view.context) }
|
|
530
626
|
}
|
|
531
627
|
|
|
532
628
|
/**
|
|
533
|
-
* The
|
|
629
|
+
* 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
630
|
* context at the right. The title keeps its room: the branch gives way when both do not fit, and only then is the
|
|
535
631
|
* title cut. Without a title the branch stands at the left.
|
|
536
632
|
*/
|
|
537
633
|
const sessionLine = (view: LaneView, width: number): string => {
|
|
538
|
-
const git =
|
|
539
|
-
const context = contextValue(view.context)
|
|
634
|
+
const { left, git, context } = sessionParts(view)
|
|
540
635
|
if (!view.sessionTitle) return spread(fit(git, Math.max(width - CONTEXT_CELL_WIDTH, 1)), context, width)
|
|
541
|
-
const left = plain(view.sessionTitle)
|
|
542
636
|
const room = width - (context ? CONTEXT_CELL_WIDTH : 0)
|
|
543
637
|
const withGit = visibleLength(left) + COLUMN_SEPARATOR.length + visibleLength(git) <= room
|
|
544
638
|
const right = [withGit ? git : '', context].filter(Boolean).join(COLUMN_SEPARATOR)
|
|
545
639
|
return spread(fit(left, Math.max(room, 1)), right, width)
|
|
546
640
|
}
|
|
547
641
|
|
|
642
|
+
/**
|
|
643
|
+
* The session line with the lane's one link chip on it, so the lane needs no row of chips: title, chip, branch and
|
|
644
|
+
* context, the branch giving way first; without a title the branch at the left and the chip at the right. Null when
|
|
645
|
+
* the line has no room for the chip beside the whole title, and the chip keeps its row of chips.
|
|
646
|
+
*/
|
|
647
|
+
const sessionWithChip = (view: LaneView, chip: BadgeCell, width: number): Frame | null => {
|
|
648
|
+
const { left, git, context } = sessionParts(view)
|
|
649
|
+
const tails = view.sessionTitle
|
|
650
|
+
? [
|
|
651
|
+
[chip.cell, git, context],
|
|
652
|
+
[chip.cell, context],
|
|
653
|
+
]
|
|
654
|
+
: [[chip.cell, context]]
|
|
655
|
+
const right = tails
|
|
656
|
+
.map((tail) => tail.filter(Boolean).join(COLUMN_SEPARATOR))
|
|
657
|
+
.find((candidate) => visibleLength(left) + COLUMN_SEPARATOR.length + visibleLength(candidate) <= width)
|
|
658
|
+
if (right === undefined) return null
|
|
659
|
+
const from = width - visibleLength(right)
|
|
660
|
+
const targets = chip.key === null ? [] : [{ line: 0, from, to: from + visibleLength(chip.cell), key: chip.key }]
|
|
661
|
+
return { lines: [spread(left, right, width)], cursorLine: -1, targets }
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** Session and link chips of an open lane: a lone chip on the session line when it fits there, else a row of chips. */
|
|
665
|
+
const sessionAndChips = (
|
|
666
|
+
view: LaneView,
|
|
667
|
+
tickets: LaneTickets | null,
|
|
668
|
+
width: number,
|
|
669
|
+
): { session: Frame; chips: Frame } => {
|
|
670
|
+
const cells = chipCells(laneChips(view), tickets, view.sentFiles)
|
|
671
|
+
const [lone] = cells
|
|
672
|
+
const inline = cells.length === 1 && lone ? sessionWithChip(view, lone, width) : null
|
|
673
|
+
if (inline) return { session: inline, chips: textFrame([]) }
|
|
674
|
+
return { session: textFrame([sessionLine(view, width)]), chips: packedFrame(cells, '', width) }
|
|
675
|
+
}
|
|
676
|
+
|
|
548
677
|
const underStatus = (frame: Frame): Frame =>
|
|
549
678
|
prefixed(frame, { first: UNDER_STATUS, rest: UNDER_STATUS, style: (text) => text })
|
|
550
679
|
|
|
@@ -552,6 +681,16 @@ const underStatus = (frame: Frame): Frame =>
|
|
|
552
681
|
const laneChips = ({ progress, chips }: LaneView): LinkChip[] =>
|
|
553
682
|
progress.steps.flatMap(({ step }) => (step.planHref ? (chips[step.planHref] ?? []) : []))
|
|
554
683
|
|
|
684
|
+
/** The lane's pull requests one under the other, each without the lane ticket its title starts with. */
|
|
685
|
+
const pullRequestsFrame = (view: LaneView, context: LaneContext): Frame => {
|
|
686
|
+
const ids = (context.tickets?.shown ?? []).map(({ id }) => id)
|
|
687
|
+
return joinFrames(
|
|
688
|
+
view.pullRequests.map((pullRequest) =>
|
|
689
|
+
pullRequestFrame({ ...pullRequest, title: withoutTicketKey(pullRequest.title, ids) }, context),
|
|
690
|
+
),
|
|
691
|
+
)
|
|
692
|
+
}
|
|
693
|
+
|
|
555
694
|
/** The lane line is a row of its own; an open lane continues with its pull requests and plans. */
|
|
556
695
|
const laneBlock = (
|
|
557
696
|
view: LaneView,
|
|
@@ -574,17 +713,13 @@ const laneBlock = (
|
|
|
574
713
|
const row = rowFrame(laneKey(view.progress.lane.name), laneLines(view, open, width, columns), [], context)
|
|
575
714
|
if (!open) return row
|
|
576
715
|
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
|
-
}
|
|
716
|
+
const head = { ...sessionAndChips(view, inner.tickets, inner.width), pullRequests: pullRequestsFrame(view, inner) }
|
|
581
717
|
const [session, ...sections] = summaryChildren(inner, summaryOf(view), head)
|
|
582
718
|
const alone = sections.every((section) => section.lines.length === 0)
|
|
583
719
|
const sessionLead = alone ? { first: TREE.last, rest: TREE.gap } : { first: TREE.branch, rest: TREE.pipe }
|
|
584
720
|
return joinFrames([
|
|
585
721
|
row,
|
|
586
722
|
underStatus(prefixed(session, { ...sessionLead, style: paint.dim })),
|
|
587
|
-
...view.pullRequests.map((pullRequest) => pullRequestFrame(pullRequest, context)),
|
|
588
723
|
underStatus(nest(sections)),
|
|
589
724
|
])
|
|
590
725
|
}
|
|
@@ -647,6 +782,29 @@ const plannedRows = (planned: PlannedPlan[], width: number, indent: string): str
|
|
|
647
782
|
|
|
648
783
|
const rule = (width: number): string => paint.dim(RULE.repeat(width))
|
|
649
784
|
|
|
785
|
+
/** The rule under the for-you block, labelled woof (Jack barks) so the block reads as a section of its own. */
|
|
786
|
+
const barkingRule = (width: number): string => {
|
|
787
|
+
const lead = `${RULE.repeat(2)} ${WOOF} `
|
|
788
|
+
return paint.dim(`${lead}${RULE.repeat(Math.max(width - textWidth(lead), 0))}`)
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** One row as lines: a dim bar and the text in bold on each, linked when the row has an address. */
|
|
792
|
+
const waitingRowLines = ({ lines, url }: WaitingRow): string[] =>
|
|
793
|
+
lines.map((line) => `${paint.dim(WAITING_MARK)} ${paint.bold(url ? link(url, line) : line)}`)
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* What waits on the user's answer, on top of the herd until it is closed: `▌ 📎 name` for a sent file or an Artifact
|
|
797
|
+
* and `▌ ? question` for a question of the board, up to five and `▌ +<n>` for the rest. A row wider than the pane is
|
|
798
|
+
* cut to one line; once expanded it wraps over several.
|
|
799
|
+
*/
|
|
800
|
+
const waitingLines = (waiting: readonly WaitingItem[], expanded: ReadonlySet<string>, width: number): string[] => {
|
|
801
|
+
const more = Math.max(waiting.length - SHOWN_WAITING, 0)
|
|
802
|
+
return [
|
|
803
|
+
...waitingRows(waiting, expanded, width).flatMap(waitingRowLines),
|
|
804
|
+
...(more > 0 ? [`${paint.dim(WAITING_MARK)} +${more}`] : []),
|
|
805
|
+
]
|
|
806
|
+
}
|
|
807
|
+
|
|
650
808
|
/** A dimmed rule before every lane block but the first, folded or open: it shows where one lane ends. */
|
|
651
809
|
const separate = (blocks: Frame[], width: number): Frame[] =>
|
|
652
810
|
blocks.flatMap((frame, index) => (index === 0 ? [frame] : [textFrame([rule(width)]), frame]))
|
|
@@ -679,7 +837,7 @@ const laneColumnsOf = (lanes: LaneView[], width: number): LaneColumns => {
|
|
|
679
837
|
)
|
|
680
838
|
const rightOf = (tallyWidth: number) =>
|
|
681
839
|
Math.max(0, ...lanes.map((lane) => visibleLength(laneRight(lane, tallyWidth))))
|
|
682
|
-
const head = LANE_HEAD_CELLS + name + COLUMN_SEPARATOR.length
|
|
840
|
+
const head = LANE_HEAD_CELLS + name + COLUMN_SEPARATOR.length
|
|
683
841
|
const beside = MIN_BESIDE_WIDTH + COLUMN_SEPARATOR.length
|
|
684
842
|
const phases = head + beside + (marks > 0 ? marks + COLUMN_SEPARATOR.length : 0) + rightOf(tally) <= width
|
|
685
843
|
return { name, marks, right: rightOf(phases ? tally : 0), phases, tally }
|
|
@@ -702,7 +860,7 @@ const partitionLanes = (lanes: LaneView[]): Record<'active' | 'waiting' | 'noAge
|
|
|
702
860
|
* lanes with a worktree but no agent each share a closing block; finished lanes are not drawn.
|
|
703
861
|
*/
|
|
704
862
|
export const renderScreen = (
|
|
705
|
-
{ title, time, lanes, planned, watch, watchesTickets }: OverviewView,
|
|
863
|
+
{ title, time, lanes, planned, watch, watchesTickets, awaiting = [], unplaced = [] }: OverviewView,
|
|
706
864
|
state: Selection,
|
|
707
865
|
width: number,
|
|
708
866
|
): Screen => {
|
|
@@ -713,8 +871,14 @@ export const renderScreen = (
|
|
|
713
871
|
const blocks = active.map((lane) => laneBlock(lane, state, width, columns, badgeTexts))
|
|
714
872
|
const notice = watchNotice({ lanes: active, watch, watchesTickets }, badgeTexts)
|
|
715
873
|
const clock = notice ? `${paint.yellow(notice)}${COLUMN_SEPARATOR}${paint.dim(time)}` : paint.dim(time)
|
|
874
|
+
const top = [spread(`${HEADER_MARK} ${paint.bold(title)}`, clock, width), rule(width)]
|
|
716
875
|
return {
|
|
717
|
-
|
|
876
|
+
waitingLine: top.length + unplaced.length,
|
|
877
|
+
header: [
|
|
878
|
+
...top,
|
|
879
|
+
...unplaced.map((task) => nowLine(task, width)),
|
|
880
|
+
...(awaiting.length > 0 ? [...waitingLines(awaiting, state.expanded, width), barkingRule(width)] : []),
|
|
881
|
+
],
|
|
718
882
|
body: joinFrames([
|
|
719
883
|
...separate(blocks, width),
|
|
720
884
|
...(parked.length > 0 ? [textFrame([rule(width), ...parked])] : []),
|
package/src/plans/live.ts
CHANGED
|
@@ -25,9 +25,11 @@ import {
|
|
|
25
25
|
inputOutcome,
|
|
26
26
|
isSprintPageKey,
|
|
27
27
|
overviewSelection,
|
|
28
|
+
parseMouse,
|
|
28
29
|
splitKeys,
|
|
29
30
|
syncLaneFolds,
|
|
30
31
|
targetKeys,
|
|
32
|
+
toggledExpansion,
|
|
31
33
|
type View,
|
|
32
34
|
windowTop,
|
|
33
35
|
} from './selection'
|
|
@@ -37,6 +39,7 @@ import { loadSnapshot, type Snapshot, type Source } from './snapshot'
|
|
|
37
39
|
import { exportSprintPage, writeSprintPageInSteps } from './sprint-export'
|
|
38
40
|
import { boardTexts } from './texts'
|
|
39
41
|
import { bodyRows, clockOf, draw, paneWidth, renderSnapshot, startSelection, TERMINAL_OFF, TERMINAL_ON } from './view'
|
|
42
|
+
import { waitingClick, waitingRows } from './waiting-click'
|
|
40
43
|
|
|
41
44
|
/** The state of the running view; only the functions below `runLive` change it. */
|
|
42
45
|
type Live = {
|
|
@@ -243,6 +246,21 @@ const openPlan = (live: Live, snapshot: Snapshot, address: string) => {
|
|
|
243
246
|
live.notice = reason === null ? texts.pageOpened(live.page.path) : texts.pageNotOpenedReason(reason)
|
|
244
247
|
}
|
|
245
248
|
|
|
249
|
+
/** A plain click on the for-you block expands or folds a cut-off row, or opens its file or Artifact; the opener's failure shows in the footer. */
|
|
250
|
+
const clickWaiting = (live: Live, snapshot: Snapshot, screen: Screen, input: string): boolean => {
|
|
251
|
+
const mouse = parseMouse(input)
|
|
252
|
+
const rows = waitingRows(snapshot.waiting ?? [], live.view.selection.expanded, paneWidth())
|
|
253
|
+
const outcome = mouse ? waitingClick(rows, screen.waitingLine, mouse) : null
|
|
254
|
+
if (!outcome) return false
|
|
255
|
+
if ('toggle' in outcome) {
|
|
256
|
+
live.view = { ...live.view, selection: toggledExpansion(live.view.selection, outcome.toggle) }
|
|
257
|
+
return true
|
|
258
|
+
}
|
|
259
|
+
const reason = openInBrowser(outcome.open)
|
|
260
|
+
if (reason) live.notice = boardTextsOf(live).notOpened(terminalSafe(reason))
|
|
261
|
+
return true
|
|
262
|
+
}
|
|
263
|
+
|
|
246
264
|
const handleInput = (live: Live, input: string) => {
|
|
247
265
|
const { snapshot, screen } = live
|
|
248
266
|
if (!snapshot || !screen) return
|
|
@@ -251,6 +269,10 @@ const handleInput = (live: Live, input: string) => {
|
|
|
251
269
|
void writeSprintPage(live, snapshot)
|
|
252
270
|
return
|
|
253
271
|
}
|
|
272
|
+
if (clickWaiting(live, snapshot, screen, input)) {
|
|
273
|
+
redraw(live)
|
|
274
|
+
return
|
|
275
|
+
}
|
|
254
276
|
const context = {
|
|
255
277
|
keys: targetKeys(screen.body.targets),
|
|
256
278
|
targets: screen.body.targets,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type LaneProgress, resolvePlanPath } from '../plan-format'
|
|
2
|
-
import { isRecord } from '../
|
|
2
|
+
import { isRecord } from '../shared/is-record'
|
|
3
3
|
|
|
4
4
|
/** The model families a phase ran on and was recommended, as lower-case words; `fits` null without a recommendation. */
|
|
5
5
|
export type PhaseModel = { used: string; recommended: string | null; fits: boolean | null }
|