jackherd 1.8.1 → 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.
Files changed (111) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/INSTALL.md +2 -0
  3. package/README.md +37 -44
  4. package/dist/{chunk-OU2KALXP.js → chunk-MCP4XJQH.js} +15 -0
  5. package/dist/config.d.ts +6 -0
  6. package/dist/config.js +19 -3
  7. package/dist/plan-format.d.ts +19 -2
  8. package/dist/plan-format.js +21 -7
  9. package/herdr-plugin/herdr-plugin.toml +1 -1
  10. package/install.sh +1 -1
  11. package/kit/hooks/hooks/approval-gate/index.ts +22 -4
  12. package/kit/hooks/hooks/approval-gate/run.ts +29 -6
  13. package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
  14. package/kit/hooks/hooks/merge-guard/decision.ts +67 -20
  15. package/kit/hooks/hooks/sent-file/index.ts +19 -0
  16. package/kit/hooks/hooks/sent-file/record.ts +128 -0
  17. package/kit/hooks/hooks/sent-file/run.ts +48 -0
  18. package/kit/hooks/hooks/sent-file/store.ts +18 -0
  19. package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
  20. package/kit/hooks/hooks.json +9 -0
  21. package/kit/hooks/statusline.sh +1 -1
  22. package/kit/workflow/skills/implement/SKILL.md +2 -0
  23. package/kit/workflow/skills/orchestrate/SKILL.md +30 -6
  24. package/package.json +2 -1
  25. package/schemas/jackherd.schema.json +26 -2
  26. package/src/cli.ts +3 -0
  27. package/src/config/schema.ts +22 -2
  28. package/src/context/fill.ts +12 -0
  29. package/src/context/session-log.ts +43 -1
  30. package/src/plan-format/clock.ts +4 -0
  31. package/src/plan-format/dependencies.ts +2 -5
  32. package/src/plan-format/lanes.ts +30 -5
  33. package/src/plan-format/terminal-text.ts +4 -0
  34. package/src/plan-format/waiting.ts +48 -0
  35. package/src/plans/active-tasks.ts +60 -0
  36. package/src/plans/current-phase.ts +17 -7
  37. package/src/plans/current-task.ts +63 -5
  38. package/src/plans/lane-standing.ts +9 -4
  39. package/src/plans/lane-summary.ts +141 -74
  40. package/src/plans/lane-tickets.ts +15 -0
  41. package/src/plans/layout-phase.ts +57 -18
  42. package/src/plans/layout-summary.ts +85 -67
  43. package/src/plans/layout.ts +253 -89
  44. package/src/plans/live.ts +22 -0
  45. package/src/plans/phase-models.ts +1 -1
  46. package/src/plans/phase-ranges.ts +45 -0
  47. package/src/plans/pull-requests.ts +6 -0
  48. package/src/plans/selection.ts +19 -16
  49. package/src/plans/sent-files-read.ts +22 -0
  50. package/src/plans/sent-files.ts +121 -0
  51. package/src/plans/signals-read.ts +54 -0
  52. package/src/plans/snapshot.ts +251 -55
  53. package/src/plans/subagents-read.ts +82 -0
  54. package/src/plans/subagents.ts +132 -0
  55. package/src/plans/texts.ts +16 -11
  56. package/src/plans/unlisted-lanes.ts +10 -5
  57. package/src/plans/view.ts +3 -3
  58. package/src/plans/waiting-click.ts +76 -0
  59. package/src/preview/command.ts +83 -0
  60. package/src/preview/embed.ts +209 -0
  61. package/src/preview/images.ts +89 -0
  62. package/src/preview/open.ts +12 -0
  63. package/src/preview/options.ts +32 -0
  64. package/src/preview/page.ts +89 -0
  65. package/src/preview/render.ts +41 -0
  66. package/src/settled/board-row.ts +1 -2
  67. package/src/shared/is-record.ts +2 -0
  68. package/src/shared/private-records.ts +48 -0
  69. package/src/task-signals/agent.ts +15 -0
  70. package/src/task-signals/claude-read.ts +72 -0
  71. package/src/task-signals/claude-signals.ts +112 -0
  72. package/src/task-signals/command.ts +71 -0
  73. package/src/task-signals/fixtures/session.jsonl +7 -0
  74. package/src/task-signals/matcher.ts +96 -0
  75. package/src/task-signals/record-store.ts +40 -0
  76. package/src/task-signals/record.ts +68 -0
  77. package/src/task-signals/text.ts +13 -0
  78. package/src/task-signals/types.ts +14 -0
  79. package/src/terminal/open.ts +13 -6
  80. package/src/terminal/text.ts +8 -1
  81. package/src/terminal/width.ts +2 -1
  82. package/src/vocabulary/index.ts +28 -0
  83. package/src/waiting/command.ts +35 -0
  84. package/src/watch/appointments.ts +2 -3
  85. package/src/watch/approvals.ts +2 -2
  86. package/src/watch/command.ts +91 -5
  87. package/src/watch/failed-tests.ts +56 -10
  88. package/src/watch/failure-read.ts +18 -6
  89. package/src/watch/failures.ts +29 -5
  90. package/src/watch/feedback.ts +1 -1
  91. package/src/watch/gh-process.ts +9 -3
  92. package/src/watch/json-guards.ts +0 -3
  93. package/src/watch/lane-fill.ts +1 -1
  94. package/src/watch/lane-model.ts +1 -1
  95. package/src/watch/lane-parallel.ts +1 -1
  96. package/src/watch/lane-progress.ts +19 -7
  97. package/src/watch/lane-report.ts +5 -1
  98. package/src/watch/lane-rest.ts +28 -0
  99. package/src/watch/plan-badges.ts +9 -2
  100. package/src/watch/previews.ts +1 -1
  101. package/src/watch/pull-requests.ts +2 -1
  102. package/src/watch/source-path.ts +1 -1
  103. package/src/watch/sources.ts +1 -1
  104. package/src/watch/stream-output.ts +12 -7
  105. package/src/watch/task-parallel-input.ts +118 -0
  106. package/src/watch/task-parallel-read.ts +149 -0
  107. package/src/watch/task-parallel-touched.ts +39 -0
  108. package/src/watch/task-parallel.ts +206 -0
  109. package/src/watch/waiting-line.ts +19 -0
  110. package/src/watch/watch-events.ts +13 -1
  111. package/src/watch/workflow-runs.ts +2 -1
@@ -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 { LaneTickets } from './lane-tickets'
33
- import { COLUMN_SEPARATOR, countCell, countText, type LaneContext, MIN_TITLE_WIDTH, plain } from './layout-phase'
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, NO_AGENT_WORD, statusSymbol, statusWord } from './symbols'
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
- export type Screen = { header: string[]; body: Frame; footer: string[] }
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 FAILED_TEST_INDENT = ' '
131
-
132
- /** Lower ranks need the coordinator sooner; a status an adapter adds later ranks after `working`. */
133
- const URGENCY: Record<AgentStatus, number> = {
134
- [AGENT_STATUS.blocked]: 0,
135
- [AGENT_STATUS.done]: 1,
136
- [AGENT_STATUS.idle]: 1,
137
- [AGENT_STATUS.working]: 2,
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
- const urgencyOf = ({ status }: LaneView): number => {
158
- if (status.agent === null) return NO_AGENT_URGENCY
159
- return isAgentStatus(status.agent) ? URGENCY[status.agent] : UNKNOWN_URGENCY
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.cyan(`↑${ahead}`) : ''
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 = padVisible(badge(pullRequest.url, `↗ #${pullRequest.number}`), PULL_REQUEST_BADGE_WIDTH)
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 = `${UNDER_STATUS}${number}${COLUMN_SEPARATOR}${checks}`
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, besideHead(head, `${prefix}${pullRequest.title}`, context.width, dimPrefix), [], context),
252
- packedFrame(cells, UNDER_STATUS, context.width),
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
- /** The failed tests of a red pull request, at most `SHOWN_FAILED_TESTS` of them one per line, then `+<n>`; none otherwise. */
265
- const failedTestLines = (pullRequest: PullRequest, badges: PullRequestBadges | undefined, width: number): string[] => {
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 lines = shown.map((name) => `${FAILED_TEST_INDENT}${paint.red(`✗ ${plain(name)}`)}`)
269
- return [...lines, ...(more > 0 ? [`${FAILED_TEST_INDENT}${paint.dim(`+${more}`)}`] : [])].map((line) =>
270
- fit(line, width),
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
- /** The context value with a rule before the bar that follows it on a folded lane line; empty without a reading. */
292
- const contextCell = (context: ContextReading | null | undefined): string =>
293
- context ? `${contextValue(context)} ${paint.dim(COLUMN_RULE)} ` : ''
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: `██░░ ◐ 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.
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(PROGRESS_SYMBOL, countText(done, total))}`
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 behind a 📎, each once, the plan chips first and the tickets beside them. */
383
- const chipFrame = (chips: LinkChip[], tickets: LaneTickets | null, width: number): Frame => {
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
- const cells = [
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 DONE says (`not merged`). Null when there is neither.
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
- /** 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 => {
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, nextPhaseLabel(view)].filter(Boolean).join(' ')
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` (three cells, the trunk of an open lane) and cut to the full width.
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, empty while they have no checkbox. */
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
- return withDetail(
524
- head,
525
- laneDetail(view),
526
- right,
527
- width,
528
- open ? `${UNDER_STATUS}${paint.dim(TREE.pipe)}` : CONTENT_INDENT,
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 SESSION branch of an open lane: the session's title at the left; the branch at the end of the line, before 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 = gitLabel(view.status)
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 + STATUS_WIDTH + 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
- header: [spread(`${HEADER_MARK} ${paint.bold(title)}`, clock, width), rule(width)],
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 '../watch/json-guards'
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 }