jackherd 1.15.2 → 1.16.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.
@@ -98,7 +98,17 @@ import {
98
98
  import type { SentChip } from './sent-files'
99
99
  import { shownWithMore } from './shown-with-more'
100
100
  import { askedCheckOf } from './source-asked'
101
- import { type SourceBlock, type SourceSummary, sourceItems, sourceSummaries } from './source-rows'
101
+ import {
102
+ entryBlockItems,
103
+ entryBlockSources,
104
+ type HerdSource,
105
+ type SourceBlock,
106
+ type SourceSummary,
107
+ sourceItems,
108
+ sourceMoreKey,
109
+ sourceSummaries,
110
+ statusOnly,
111
+ } from './source-rows'
102
112
  import { chevronOf, LESS_MARK, NO_AGENT_SYMBOL, statusSymbol, statusWord, TURN_MARK } from './symbols'
103
113
  import { BADGE_TEXTS, type BadgeTexts, checksText, foldedChecksText, laneMarkText, TEXTS } from './texts'
104
114
  import { paintOfRole, paintOfTone, toneOfRole } from './tones'
@@ -177,14 +187,16 @@ export type OverviewView = {
177
187
 
178
188
  /**
179
189
  * The drawn screen: a fixed header and footer around a body that scrolls; body lines and targets count from the body's
180
- * top. `yourTurnBlock` and `sourceBlock` are what a click on these blocks is matched against, null while the block is
181
- * not drawn.
190
+ * top. `yourTurnBlock`, `entryBlocks` and `sourceBlock` are what a click on these blocks is matched against, null (or
191
+ * no entry block) while the block is not drawn.
182
192
  */
183
193
  export type Screen = {
184
194
  header: string[]
185
195
  body: Frame
186
196
  footer: string[]
187
197
  yourTurnBlock: BlockHit | null
198
+ /** The blocks of the sources that show their entries, in their order under "Your turn". */
199
+ entryBlocks: BlockHit[]
188
200
  sourceBlock: BlockHit | null
189
201
  /** Every clickable row by the screen line it starts on (header lines first, then the body unscrolled): what the mouse and the keys land on. */
190
202
  focusHits: FocusHit[]
@@ -227,10 +239,12 @@ type TopBlock = {
227
239
  hidden: number
228
240
  expanded: boolean
229
241
  rows: BlockRow[]
242
+ /** The mark in front of the name in place of the chevron, for a block that does not fold; absent for the chevron. */
243
+ headMark?: string
230
244
  }
231
245
 
232
246
  /** What the head of a block says: its name, its count, its balance and the paint of the count. */
233
- type BlockHeadParts = Pick<TopBlock, 'name' | 'count' | 'balance' | 'countPaint'>
247
+ type BlockHeadParts = Pick<TopBlock, 'name' | 'count' | 'balance' | 'countPaint' | 'headMark'>
234
248
 
235
249
  /** The blocks on top of the herd one under the other: their lines below the title, and the hit of each drawn block. */
236
250
  type BlockStack = { lines: string[]; hits: (BlockHit | null)[] }
@@ -903,24 +917,57 @@ const SUBAGENT_ACTIVITY_INDENT = ' '
903
917
  /** How long a subagent's name may grow before its action, so a narrow pane cuts the name and keeps the action. */
904
918
  const SUBAGENT_NAME_CELLS = 28
905
919
 
906
- /** One dim line of activity behind `indent`, cut to `width`; the cut is made before the paint, on plain text. */
907
- const activityLine = (indent: string, text: string, width: number): string =>
908
- paint.dim(fit(`${indent}${ACTIVITY_MARK} ${text}`, width))
920
+ /** The key of a lane's activity line, flipped in `expanded`. */
921
+ const laneActivityKey = (lane: string): string => `activity>${lane}`
922
+
923
+ /**
924
+ * The key of a subagent's activity line, by its label: a subagent that moves up when another ends keeps its own state,
925
+ * and the key is no child of the lane's, so folding one leaves the other.
926
+ */
927
+ const subagentActivityKey = (lane: string, label: string): string => `subagent-activity>${lane}>${label}`
928
+
929
+ /**
930
+ * One dim line of activity behind `indent`, cut to the pane on plain text before the paint. A cut line is a row: a
931
+ * click unfolds it over several lines, the text hanging under its first word, and a second click folds it; a line
932
+ * that fits is no click target.
933
+ */
934
+ const activityFrame = (
935
+ key: string,
936
+ indent: string,
937
+ text: string,
938
+ { state, width }: { state: Selection; width: number },
939
+ ): Frame => {
940
+ const head = `${indent}${ACTIVITY_MARK} `
941
+ const cut = textWidth(`${head}${text}`) > width
942
+ const lines =
943
+ cut && state.expanded.has(key)
944
+ ? wrap(text, Math.max(width - textWidth(head), 1)).map((line, index) =>
945
+ index === 0 ? `${head}${line}` : `${' '.repeat(textWidth(head))}${line}`,
946
+ )
947
+ : [fit(`${head}${text}`, width)]
948
+ const dimmed = lines.map((line) => paint.dim(line))
949
+ return cut ? rowFrame(key, dimmed, [], { state, width }) : textFrame(dimmed)
950
+ }
909
951
 
910
952
  /**
911
953
  * The last action of a working lane's agent and of each of its running subagents, one dim line each, under the lane
912
954
  * line in the column of its children. None while the lane does not work, or without a source for it.
913
955
  */
914
- const activityLines = ({ activity, status }: LaneView, width: number): string[] => {
956
+ const activityFrames = (
957
+ { activity, status, progress }: LaneView,
958
+ context: { state: Selection; width: number },
959
+ ): Frame[] => {
915
960
  if (!activity || status.agent !== AGENT_STATUS.working) return []
961
+ const lane = progress.lane.name
916
962
  const indent = ' '.repeat(LANE_HEAD_CELLS)
917
963
  return [
918
- ...(activity.lane === null ? [] : [activityLine(indent, activity.lane, width)]),
964
+ ...(activity.lane === null ? [] : [activityFrame(laneActivityKey(lane), indent, activity.lane, context)]),
919
965
  ...activity.subagents.map(({ label, description, line }) =>
920
- activityLine(
966
+ activityFrame(
967
+ subagentActivityKey(lane, label),
921
968
  `${indent}${SUBAGENT_ACTIVITY_INDENT}`,
922
969
  `${ellipsize(description ?? label, SUBAGENT_NAME_CELLS)}: ${line}`,
923
- width,
970
+ context,
924
971
  ),
925
972
  ),
926
973
  ]
@@ -950,8 +997,7 @@ const laneBlock = (
950
997
  const open = isLaneOpen(state, view.progress.lane.name, view.status.agent)
951
998
  const lines = laneLines(view, open, { width, taskStyle }, columns, reason)
952
999
  const lane = rowFrame(laneKey(view.progress.lane.name), lines, [], context)
953
- const activity = activityLines(view, width)
954
- const row = activity.length > 0 ? joinFrames([lane, textFrame(activity)]) : lane
1000
+ const row = joinFrames([lane, ...activityFrames(view, { state, width })])
955
1001
  if (!open) return row
956
1002
  const inner = narrower(context, LANE_HEAD_CELLS)
957
1003
  const head = {
@@ -1085,9 +1131,13 @@ const blockBar = (): string => ' '
1085
1131
  * above the block; `▸ <name> · <n> <balance>` while folded. The count (in `countPaint` while it is more than 0) is
1086
1132
  * left out for a block without one; the balance comes painted.
1087
1133
  */
1088
- const blockHead = ({ name, count, balance, countPaint }: BlockHeadParts, open: boolean, width: number): string => {
1134
+ const blockHead = (
1135
+ { name, count, balance, countPaint, headMark }: BlockHeadParts,
1136
+ open: boolean,
1137
+ width: number,
1138
+ ): string => {
1089
1139
  const counted = count === null ? '' : ` ${ITEM_SEPARATOR} ${count > 0 ? countPaint(String(count)) : count}`
1090
- const lead = `${paint.dim(chevronOf(open))} ${name}${counted}${balance ? ` ${balance}` : ''}`
1140
+ const lead = `${paint.dim(headMark ?? chevronOf(open))} ${name}${counted}${balance ? ` ${balance}` : ''}`
1091
1141
  if (!open) return fit(lead, width)
1092
1142
  const cut = fit(lead, Math.max(width - 2, 1))
1093
1143
  return `${cut} ${paint.dim(RULE.repeat(Math.max(width - visibleLength(cut) - 1, 0)))}`
@@ -1226,11 +1276,12 @@ const sourceSummaryText = (summaries: readonly SourceSummary[]): string =>
1226
1276
  * fails, and a source's entries under it once opened.
1227
1277
  */
1228
1278
  const sourceLines = (
1229
- { sources, now }: SourceBlock,
1279
+ { sources: listed, now }: SourceBlock,
1230
1280
  awaiting: readonly WaitingItem[],
1231
1281
  state: Selection,
1232
1282
  width: number,
1233
1283
  ): BlockLines => {
1284
+ const sources = listed.map(statusOnly)
1234
1285
  const shown = state.expanded.has(SOURCE_MORE_KEY) ? sources : sources.slice(0, SHOWN_BLOCK_ROWS)
1235
1286
  const asked = askedCheckOf(awaiting)
1236
1287
  const newTone = toneOfRole(TONE_ROLE.new)
@@ -1258,6 +1309,69 @@ const sourceLinesOf = (
1258
1309
  ): BlockLines | null =>
1259
1310
  sourceBlock && sourceBlock.sources.length > 0 ? sourceLines(sourceBlock, awaiting, state, width) : null
1260
1311
 
1312
+ /**
1313
+ * How many entry rows all blocks of entries show together while folded: one calendar keeps the five of the other
1314
+ * blocks, a second one takes from the same five, so the head of the herd, which does not scroll, leaves the lanes room.
1315
+ */
1316
+ const ENTRY_BLOCK_ROW_BUDGET = SHOWN_BLOCK_ROWS
1317
+
1318
+ /**
1319
+ * The rows each block of entries shows while folded, in the order of the sources: the shared budget, with one row kept
1320
+ * for each block after it, and at least one row per block, so a block past the budget still shows its first entry; a
1321
+ * block with fewer entries than its share leaves the rest to the blocks after it. More blocks than the budget take one
1322
+ * row each, past the budget.
1323
+ */
1324
+ const entryRowShares = (counts: readonly number[], budget: number): number[] => {
1325
+ let left = budget
1326
+ return counts.map((count, index) => {
1327
+ const share = Math.min(count, Math.max(left - (counts.length - index - 1), 1))
1328
+ left -= share
1329
+ return share
1330
+ })
1331
+ }
1332
+
1333
+ /** A source with a block of its own, the clock its times are counted against and the rows it shows while folded. */
1334
+ type EntryBlock = { source: HerdSource; now: number; share: number }
1335
+
1336
+ /**
1337
+ * The block of a source that shows its entries: always open, headed by its label (else its name) without a chevron,
1338
+ * its `share` of entries and `+<n>` for the rest, which opens all of them and folds them again as in the other blocks.
1339
+ * The head is no click target; an entry with an address opens it.
1340
+ */
1341
+ const entryBlockLines = ({ source, now, share }: EntryBlock, state: Selection, width: number): BlockLines => {
1342
+ const moreKey = sourceMoreKey(source.name)
1343
+ const expanded = state.expanded.has(moreKey)
1344
+ const hidden = source.entries.length - share
1345
+ const block: TopBlock = {
1346
+ name: source.label ?? source.name,
1347
+ count: null,
1348
+ balance: '',
1349
+ markPaint: paint.dim,
1350
+ focused: (key) => isFocused(state, key),
1351
+ allBold: (key) => buttonsAllBold(state, key),
1352
+ countPaint: (text) => text,
1353
+ moreKey: hidden > 0 ? moreKey : null,
1354
+ hidden,
1355
+ expanded,
1356
+ rows: allBlockRows(entryBlockItems(source, now, expanded ? source.entries.length : share), new Set(), width),
1357
+ headMark: ' ',
1358
+ }
1359
+ const { lines, hit } = openBlockLines(block, '', width)
1360
+ return { open: true, lines, hit: { rows: hit.rows, moreKey: hit.moreKey } }
1361
+ }
1362
+
1363
+ const entryBlocksOf = (sourceBlock: SourceBlock | undefined, state: Selection, width: number): BlockLines[] => {
1364
+ if (!sourceBlock) return []
1365
+ const sources = entryBlockSources(sourceBlock.sources)
1366
+ const shares = entryRowShares(
1367
+ sources.map(({ entries }) => entries.length),
1368
+ ENTRY_BLOCK_ROW_BUDGET,
1369
+ )
1370
+ return sources.map((source, index) =>
1371
+ entryBlockLines({ source, now: sourceBlock.now, share: shares[index] ?? 0 }, state, width),
1372
+ )
1373
+ }
1374
+
1261
1375
  /**
1262
1376
  * The blocks one under the other, `offset` lines below the top of the screen, a blank line after each, so one stands
1263
1377
  * between neighbours and one between the last and the lanes. An absent block (null) takes no line and has no hit.
@@ -1429,7 +1543,9 @@ export const renderScreen = (
1429
1543
  const nowLines = unplaced.map((task) => nowLine(task, width, taskStyle))
1430
1544
  const above = nowLines.length > 0 ? [titleLine, rule(width), ...nowLines] : [titleLine]
1431
1545
  const listed = sourceLinesOf(sourceBlock, awaiting, state, width)
1432
- const stack = stackBlocks([turn, listed], above.length)
1546
+ const entryBlocks = entryBlocksOf(sourceBlock, state, width)
1547
+ const stack = stackBlocks([turn, ...entryBlocks, listed], above.length)
1548
+ const sourcesSlot = 1 + entryBlocks.length
1433
1549
  const sections = [
1434
1550
  ...(active.length > 0 ? [lanesFrame(active, turnRowsOf, { state, width, badgeTexts, taskStyle })] : []),
1435
1551
  ...(waiting.length > 0 ? [waitingFrame(waiting, state, width)] : []),
@@ -1448,7 +1564,8 @@ export const renderScreen = (
1448
1564
  const body = { ...plainBody, lines: emphasisedAt(plainBody.lines, plainHeader.length, focusHits, state, width) }
1449
1565
  return {
1450
1566
  yourTurnBlock: stack.hits[0],
1451
- sourceBlock: stack.hits[1],
1567
+ entryBlocks: stack.hits.slice(1, sourcesSlot).filter((hit) => hit !== null),
1568
+ sourceBlock: stack.hits[sourcesSlot],
1452
1569
  focusHits: state.showLegend ? focusHits.filter(({ line }) => line < plainHeader.length) : focusHits,
1453
1570
  rowKeys: [...blockHits.map(({ key }) => key), ...targetKeys(body.targets)],
1454
1571
  header,
package/src/plans/live.ts CHANGED
@@ -35,6 +35,7 @@ import { REFRESH_PACE } from './refresh-pace'
35
35
  import { TAKEN_OVER_CODE, TAKEOVER_SIGNAL } from './restart-decision'
36
36
  import { closeRow } from './row-actions'
37
37
  import { blockRowByKey, type HoverScreen, hoveredSelection, rehovered, withoutHover } from './row-focus'
38
+ import { DONE_KIND, type DoneTarget } from './row-targets'
38
39
  import {
39
40
  applyClick,
40
41
  inputOutcome,
@@ -76,6 +77,7 @@ import {
76
77
  startSelection,
77
78
  } from './view'
78
79
  import { type BlockClick, type BlockHit, blockClick, blockHeadClick, mainClickOf } from './waiting-click'
80
+ import type { TurnRow, YourTurn } from './your-turn'
79
81
 
80
82
  /**
81
83
  * Where the herd's reads come from: its own reader, or the read the watcher publishes for every herd of the board
@@ -97,6 +99,8 @@ type Live = {
97
99
  errors: string[]
98
100
  /** One footer line about the last action of the person, such as where the sprint page was written; cleared by the next key. */
99
101
  notice: string | null
102
+ /** What a `[done]` closed, left out of "Your turn" from the click until a read no longer holds its row. */
103
+ closing: DoneTarget[]
100
104
  reported: Map<string, string | null>
101
105
  /** The snapshot as read, without the plan chips; `snapshot` is this plus the chips of the page. */
102
106
  read: Snapshot | null
@@ -123,6 +127,39 @@ type Live = {
123
127
 
124
128
  const QUIT_KEYS = new Set(['\x03', 'q'])
125
129
 
130
+ /** Whether `row` of "Your turn" is what `target` closes: a record by its id, a board row or an appointment by its cells. */
131
+ const isClosedBy = (target: DoneTarget, { waiting, appointment }: TurnRow): boolean => {
132
+ switch (target.kind) {
133
+ case DONE_KIND.record:
134
+ return waiting?.id === target.id
135
+ case DONE_KIND.boardRow:
136
+ return waiting?.id === undefined && waiting?.since === target.since && waiting.item === target.item
137
+ case DONE_KIND.appointment:
138
+ return appointment?.when === target.when && appointment.action === target.action
139
+ }
140
+ }
141
+
142
+ const isClosing = (closing: readonly DoneTarget[], row: TurnRow): boolean =>
143
+ closing.some((target) => isClosedBy(target, row))
144
+
145
+ /** "Your turn" without the rows `closing` names, so a closed row leaves at the click, not at the next read. */
146
+ export const withoutClosing = (turn: YourTurn, closing: readonly DoneTarget[]): YourTurn => {
147
+ if (closing.length === 0) return turn
148
+ const open = (rows: readonly TurnRow[]) => rows.filter((row) => !isClosing(closing, row))
149
+ return { now: open(turn.now), planned: open(turn.planned) }
150
+ }
151
+
152
+ /** The closings a read still holds a row of; one whose row the read no longer holds is closed on the board as well. */
153
+ export const pendingClosings = (turn: YourTurn | undefined, closing: readonly DoneTarget[]): DoneTarget[] => {
154
+ const rows = turn ? [...turn.now, ...turn.planned] : []
155
+ return closing.filter((target) => rows.some((row) => isClosedBy(target, row)))
156
+ }
157
+
158
+ const withClosingLeftOut = (snapshot: Snapshot, closing: readonly DoneTarget[]): Snapshot =>
159
+ snapshot.yourTurn && closing.length > 0
160
+ ? { ...snapshot, yourTurn: { ...snapshot.yourTurn, turn: withoutClosing(snapshot.yourTurn.turn, closing) } }
161
+ : snapshot
162
+
126
163
  /** The sidebar titles that differ from the ones last reported, in the order of `titles`; null clears one. */
127
164
  export const changedTitles = (
128
165
  titles: ReadonlyMap<string, string | null>,
@@ -171,7 +208,7 @@ const renderContextOf = (live: Live): RenderContext => ({
171
208
 
172
209
  /** The screen for the current state, with the window scrolled to keep the cursor in view. */
173
210
  const renderLive = (live: Live, snapshot: Snapshot): { screen: Screen; top: number } => {
174
- const screen = renderSnapshot(snapshot, renderContextOf(live))
211
+ const screen = renderSnapshot(withClosingLeftOut(snapshot, live.closing), renderContextOf(live))
175
212
  return { screen, top: windowTop(live.view, screen.body.cursorLine, screen.body.lines.length, bodyRows(screen)) }
176
213
  }
177
214
 
@@ -236,7 +273,7 @@ const followShimmer = (live: Live, prepared: PreparedShimmer) => {
236
273
  const drawHover = (live: Live) => {
237
274
  live.frame = null
238
275
  if (!live.snapshot) return
239
- live.plain ??= renderUnfocused(live.snapshot, renderContextOf(live))
276
+ live.plain ??= renderUnfocused(withClosingLeftOut(live.snapshot, live.closing), renderContextOf(live))
240
277
  const laid = focusedScreen(live.plain, live.view.selection, paneWidth(), agentOfSnapshot(live.snapshot))
241
278
  if (laid === null) redraw(live)
242
279
  else write(live, laid, live.view.top)
@@ -311,6 +348,7 @@ const takeRead = (live: Live, outcome: ReadOutcome, notes: readonly string[]): b
311
348
  )
312
349
  live.view = { ...live.view, selection: folded }
313
350
  live.snapshot = next
351
+ live.closing = pendingClosings(next.yourTurn?.turn, live.closing)
314
352
  live.errors = [...next.errors, ...notes, ...reportTitles(live.source, next.titles, live.reported)]
315
353
  return redraw(live)
316
354
  }
@@ -474,12 +512,14 @@ const applyBlockClick = (live: Live, outcome: BlockClick): void => {
474
512
  return
475
513
  }
476
514
  if ('done' in outcome) {
477
- live.notice = closeRow(outcome.done, {
515
+ const { closed, notice } = closeRow(outcome.done, {
478
516
  sprintFile: live.source.sprintFile,
479
517
  language: live.source.settings.planLanguage,
480
518
  now: new Date(),
481
519
  closeRecords: (ids) => closeWaiting(live.source.root, ids, Date.now()),
482
520
  })
521
+ if (closed) live.closing = [...live.closing, outcome.done]
522
+ live.notice = notice
483
523
  return
484
524
  }
485
525
  const reason = openInBrowser(outcome.open)
@@ -487,14 +527,15 @@ const applyBlockClick = (live: Live, outcome: BlockClick): void => {
487
527
  }
488
528
 
489
529
  /**
490
- * A plain click on a block on top of the herd, matched against `hit` as it was drawn: the head folds or opens it, a
530
+ * A plain click on a block on top of the herd, matched against `hit` as it was drawn: the head of `block` folds or
531
+ * opens it (a block of entries, `block` null, has no such head), a
491
532
  * cut-off row expands, a file or Artifact opens, a button asks the coordinator or closes the row; the failure or the
492
533
  * result shows in the footer.
493
534
  */
494
- const clickBlock = (live: Live, block: TopBlockId, hit: BlockHit | null, input: string): boolean => {
535
+ const clickBlock = (live: Live, block: TopBlockId | null, hit: BlockHit | null, input: string): boolean => {
495
536
  const mouse = parseMouse(input)
496
537
  if (!mouse || !hit) return false
497
- if (blockHeadClick(hit.firstLine - 1, mouse)) {
538
+ if (block !== null && blockHeadClick(hit.firstLine - 1, mouse)) {
498
539
  live.view = {
499
540
  ...live.view,
500
541
  selection: applyClick(live.view.selection, topBlockHeadKey(block)),
@@ -533,7 +574,11 @@ const activateBlockRow = (live: Live, screen: Screen, input: string): boolean =>
533
574
  return true
534
575
  }
535
576
 
536
- const blocksOf = ({ yourTurnBlock, sourceBlock }: Screen): (BlockHit | null)[] => [yourTurnBlock, sourceBlock]
577
+ const blocksOf = ({ yourTurnBlock, entryBlocks, sourceBlock }: Screen): (BlockHit | null)[] => [
578
+ yourTurnBlock,
579
+ ...entryBlocks,
580
+ sourceBlock,
581
+ ]
537
582
 
538
583
  const handleInput = (live: Live, input: string) => {
539
584
  const { snapshot, screen } = live
@@ -556,6 +601,7 @@ const handleInput = (live: Live, input: string) => {
556
601
  }
557
602
  if (
558
603
  clickBlock(live, TOP_BLOCK.yourTurn, screen.yourTurnBlock, input) ||
604
+ screen.entryBlocks.some((hit) => clickBlock(live, null, hit, input)) ||
559
605
  clickBlock(live, TOP_BLOCK.sources, screen.sourceBlock, input)
560
606
  ) {
561
607
  redraw(live)
@@ -594,6 +640,7 @@ export const runLive = (source: Source, key: string | null = null): void => {
594
640
  view: { selection: overviewSelection(), top: 0, follow: true },
595
641
  errors: [],
596
642
  notice: null,
643
+ closing: [],
597
644
  reported: new Map(),
598
645
  read: null,
599
646
  whole: null,
@@ -16,9 +16,16 @@ export type CloseDependencies = {
16
16
  closeRecords: (ids: readonly string[]) => number
17
17
  }
18
18
 
19
- const closeRecord = (id: string, { closeRecords }: CloseDependencies): string => {
20
- if (!isQuestionId(id)) return TEXTS.notClosed('not a record id')
21
- return closeRecords([id]) > 0 ? TEXTS.closed(`record ${id}`) : TEXTS.notClosed('record not found or closed already')
19
+ /** Whether the row was closed, and the footer line that says so or why not. */
20
+ export type CloseResult = { closed: boolean; notice: string }
21
+
22
+ const closedAs = (subject: string): CloseResult => ({ closed: true, notice: TEXTS.closed(subject) })
23
+
24
+ const notClosed = (reason: string): CloseResult => ({ closed: false, notice: TEXTS.notClosed(reason) })
25
+
26
+ const closeRecord = (id: string, { closeRecords }: CloseDependencies): CloseResult => {
27
+ if (!isQuestionId(id)) return notClosed('not a record id')
28
+ return closeRecords([id]) > 0 ? closedAs(`record ${id}`) : notClosed('record not found or closed already')
22
29
  }
23
30
 
24
31
  const closedBoard = (target: DoneTarget, board: string, { language, now }: CloseDependencies): string | null => {
@@ -31,22 +38,22 @@ const subjectOfRow = (target: DoneTarget): string => {
31
38
  return target.kind === DONE_KIND.appointment ? `appointment ${boardRowKey(target.when, target.action)}` : ''
32
39
  }
33
40
 
34
- const closeBoardRow = (target: DoneTarget, dependencies: CloseDependencies): string => {
41
+ const closeBoardRow = (target: DoneTarget, dependencies: CloseDependencies): CloseResult => {
35
42
  try {
36
43
  const board = readFileSync(dependencies.sprintFile, 'utf8')
37
44
  const closed = closedBoard(target, board, dependencies)
38
- if (closed === null) return TEXTS.notClosed('row not found in the board')
45
+ if (closed === null) return notClosed('row not found in the board')
39
46
  writeFileSync(dependencies.sprintFile, closed)
40
- return TEXTS.closed(subjectOfRow(target))
47
+ return closedAs(subjectOfRow(target))
41
48
  } catch (error) {
42
- return TEXTS.notClosed(errorText(error))
49
+ return notClosed(errorText(error))
43
50
  }
44
51
  }
45
52
 
46
53
  /**
47
54
  * Closes what a `[done]` button names: a log record by its id, or a row of the board's file by writing the stamp into its
48
- * status cell. Returns the footer line. The board is read again just before it is written, so a row the coordinator
49
- * changed since the herd drew it is found by its text or not at all.
55
+ * status cell. Returns whether it closed and the footer line. The board is read again just before it is written, so a
56
+ * row the coordinator changed since the herd drew it is found by its text or not at all.
50
57
  */
51
- export const closeRow = (target: DoneTarget, dependencies: CloseDependencies): string =>
58
+ export const closeRow = (target: DoneTarget, dependencies: CloseDependencies): CloseResult =>
52
59
  target.kind === DONE_KIND.record ? closeRecord(target.id, dependencies) : closeBoardRow(target, dependencies)
@@ -635,15 +635,20 @@ const decoratedViews = (
635
635
  ),
636
636
  )
637
637
 
638
- /** Each source with the tone and the label its config gives it. */
638
+ /** Each source with the tone, the label and the `show` its config gives it. */
639
639
  const withSourceStyle = (
640
640
  statuses: readonly SourceStatus[],
641
641
  { herdSources, sourceLabels }: HerdSourceSettings,
642
642
  ): HerdSource[] =>
643
643
  statuses.map((status) => {
644
644
  const label = sourceLabels[status.name]
645
- const tone = herdSources.find(({ name }) => name === status.name)?.tone ?? null
646
- return { ...status, tone, ...(label === undefined ? {} : { label }) }
645
+ const config = herdSources.find(({ name }) => name === status.name)
646
+ return {
647
+ ...status,
648
+ tone: config?.tone ?? null,
649
+ ...(label === undefined ? {} : { label }),
650
+ ...(config?.show === undefined ? {} : { show: config.show }),
651
+ }
647
652
  })
648
653
 
649
654
  /**
@@ -2,6 +2,7 @@ import { TONE, type ToneName } from '../config/tones'
2
2
  import { padVisible } from '../terminal/text'
3
3
  import { textWidth } from '../terminal/width'
4
4
  import type { EntryStatus, SourceStatus } from '../watch/source-statuses'
5
+ import { SOURCE_SHOW, type SourceShow } from '../watch/sources'
5
6
  import { ASK_KIND } from './ask-target'
6
7
  import type { AskedCheck } from './source-asked'
7
8
  import { chevronOf } from './symbols'
@@ -9,9 +10,10 @@ import type { BlockItem } from './waiting-click'
9
10
 
10
11
  /**
11
12
  * A source's status with the tone its config names for the mark while its report is new (null where it names none) and
12
- * the label its config gives it, absent where it gives none.
13
+ * the label its config gives it, absent where it gives none, and `show` where it asks for its entries in a block of
14
+ * their own.
13
15
  */
14
- export type HerdSource = SourceStatus & { tone?: ToneName | null; label?: string }
16
+ export type HerdSource = SourceStatus & { tone?: ToneName | null; label?: string; show?: SourceShow }
15
17
 
16
18
  /** One source in the line of the folded sources block: what it says and the tone it wears, none for dim. */
17
19
  export type SourceSummary = { text: string; tone?: ToneName }
@@ -179,6 +181,37 @@ const entryItems = (source: HerdSource, view: SourceView, now: number, newTone:
179
181
  ]
180
182
  }
181
183
 
184
+ /** Whether the source asks for its entries in a block of their own. */
185
+ export const showsEntries = (source: HerdSource): boolean => source.show === SOURCE_SHOW.entries
186
+
187
+ /** The sources that have a block of their own: those that ask for it and have entries; an empty run draws none. */
188
+ export const entryBlockSources = (sources: readonly HerdSource[]): HerdSource[] =>
189
+ sources.filter((source) => showsEntries(source) && source.entries.length > 0)
190
+
191
+ /** The source as the `Sources` block draws it: a source with a block of its own keeps its status, not its entries. */
192
+ export const statusOnly = (source: HerdSource): HerdSource =>
193
+ showsEntries(source) ? { ...source, entries: [], latest: null } : source
194
+
195
+ /**
196
+ * The rows of the block of a source that shows its entries: count, time and title as in the opened source, in the
197
+ * order the state holds them, in the tone of the source, none of them asking or expanding; a row with an address
198
+ * is linked. Only the first `limit` entries become rows, the columns measured over those.
199
+ */
200
+ export const entryBlockItems = (source: HerdSource, now: number, limit: number): BlockItem[] => {
201
+ const shown = source.entries.slice(0, limit)
202
+ const columns = columnsOf(shown, now)
203
+ return shown.map((entry) => ({
204
+ key: entryKey(source.name, entry),
205
+ mark: ENTRY_MARK,
206
+ lead: entryLeadOf(entry, now, columns),
207
+ text: entry.title,
208
+ url: entry.url,
209
+ ask: null,
210
+ expands: false,
211
+ ...(source.tone && { tone: source.tone }),
212
+ }))
213
+ }
214
+
182
215
  const DEFAULT_VIEW: SourceView = { expanded: new Set(), asked: () => false }
183
216
 
184
217
  /**
@@ -409,7 +409,8 @@ const readyPhaseText = ({ id, after }: ReadyPhase): string =>
409
409
 
410
410
  /**
411
411
  * What a resting lane (`done` or `idle`) with ready phases asks of the coordinator (`orchestrate` § 2): one line per such
412
- * lane, naming every ready phase, to start it now; a pull request waiting for approval holds only the commit.
412
+ * lane, naming every ready phase, to start it now; a pull request of the lane waiting for approval or merge
413
+ * holds only the commit.
413
414
  */
414
415
  export const readyPhaseDutyLines = (
415
416
  stopped: readonly string[],
@@ -35,7 +35,7 @@ const RAW_LINE_LENGTH = 200
35
35
  const INDENT = ' '
36
36
  const BASELINE = 'first run of this source: baseline, nothing reported'
37
37
  const NOT_CHECKED = '0 entries: map not checked'
38
- const FOOTER = 'watcher state not read or changed; the watcher takes a new source over at its next start'
38
+ const FOOTER = 'watcher state not read or changed; a running watcher takes a new source over at its next cycle'
39
39
 
40
40
  export const dryRunTarget = (value: string): DryRunTarget => {
41
41
  if (value === STDIN) return { kind: 'stdin' }
@@ -79,7 +79,7 @@ export const watchTexts = (dialogLanguage: PlanLanguage): WatchTexts => ({
79
79
  laneModelDuty:
80
80
  'switch it while idle: /exit, then claude --resume <session> --model <id> in the same pane (§ 2 step 6)',
81
81
  readyPhaseDuty: (count) =>
82
- `start ${count === 1 ? 'it' : 'them'} now (§ 2); a waiting PR approval holds only the commit`,
82
+ `start ${count === 1 ? 'it' : 'them'} now (§ 2), on a branch over the open PR of the lane if it waits for approval or merge: that holds only the commit`,
83
83
  mainCheckoutBehind: (remoteBase, count) =>
84
84
  `main checkout behind ${remoteBase} by ${count} (git merge --ff-only ${remoteBase})`,
85
85
  mainCheckoutOffBase: (branch, baseBranch) => `main checkout on ${branch ?? 'a detached head'}, not ${baseBranch}`,