jackherd 1.12.1 → 1.12.3

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 (51) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +16 -16
  3. package/dist/plan-format.d.ts +23 -11
  4. package/dist/plan-format.js +6 -3
  5. package/herdr-plugin/herdr-plugin.toml +1 -1
  6. package/install.sh +1 -1
  7. package/kit/hooks/hooks/merge-guard/decision.ts +35 -2
  8. package/kit/workflow/skills/orchestrate/SKILL.md +7 -1
  9. package/kit/workflow/skills/orchestrate/references/events.md +4 -2
  10. package/package.json +1 -1
  11. package/src/check/skill-budget-defaults.ts +6 -3
  12. package/src/context/session-log.ts +4 -6
  13. package/src/plan-format/phases.ts +22 -6
  14. package/src/plan-format/progress.ts +5 -8
  15. package/src/plans/command.ts +10 -3
  16. package/src/plans/current-task.ts +3 -7
  17. package/src/plans/focus-overlay.ts +80 -0
  18. package/src/plans/git.ts +6 -11
  19. package/src/plans/herd-state.ts +201 -0
  20. package/src/plans/lane-reason.ts +1 -1
  21. package/src/plans/lane-summary.ts +6 -2
  22. package/src/plans/layout-phase.ts +1 -3
  23. package/src/plans/layout-summary.ts +1 -1
  24. package/src/plans/layout.ts +32 -31
  25. package/src/plans/link-chips.ts +7 -16
  26. package/src/plans/live.ts +215 -267
  27. package/src/plans/markdown-text.ts +4 -0
  28. package/src/plans/page-sync.ts +39 -0
  29. package/src/plans/phase-source.ts +5 -2
  30. package/src/plans/reader.ts +217 -0
  31. package/src/plans/refresh-schedule.ts +53 -0
  32. package/src/plans/snapshot.ts +32 -31
  33. package/src/plans/status-pace.ts +7 -4
  34. package/src/plans/subagents-read.ts +28 -12
  35. package/src/plans/supervisor.ts +1 -1
  36. package/src/plans/view.ts +9 -101
  37. package/src/plans/waiting-click.ts +27 -17
  38. package/src/plans/worked-phases.ts +1 -1
  39. package/src/plans/worktree-stamp.ts +12 -9
  40. package/src/plans/your-turn-rows.ts +33 -32
  41. package/src/plans/your-turn.ts +6 -4
  42. package/src/shared/bounded-memo.ts +19 -0
  43. package/src/shared/stat-cache.ts +2 -1
  44. package/src/task-signals/command.ts +12 -3
  45. package/src/terminal/lifetime.ts +52 -0
  46. package/src/watch/command.ts +6 -0
  47. package/src/watch/herd-publisher.ts +84 -0
  48. package/src/watch/source-entries.ts +7 -0
  49. package/src/watch/source-statuses.ts +2 -2
  50. package/src/watch/sources.ts +5 -2
  51. package/src/plans/next-rows.ts +0 -12
@@ -0,0 +1,80 @@
1
+ import { fit, paint } from '../terminal/text'
2
+ import { type AgentOf, clickHint } from './click-hint'
3
+ import type { Screen } from './layout'
4
+ import { blockRowByKey, boldBracketAt, type FocusHit, focusLine, isFocused, LINKS_KEY_PREFIX } from './row-focus'
5
+ import type { Selection } from './selection'
6
+ import { TEXTS } from './texts'
7
+
8
+ const NO_ROW = '\0'
9
+ const LEAD = /^ */
10
+
11
+ /** A selection that focuses no row: the screen drawn with it is the base `focusedScreen` lays the focus on. */
12
+ export const unfocused = (selection: Selection): Selection => ({
13
+ ...selection,
14
+ selected: NO_ROW,
15
+ hovered: null,
16
+ hoveredFrom: null,
17
+ })
18
+
19
+ /** The blanks a block puts before a row it holds (`indented`): the focus lies right of them, as narrow as the row. */
20
+ const leadOf = (line: string): number => LEAD.exec(line)?.[0].length ?? 0
21
+
22
+ /** `lines` with the lines of `hit` (counted from `offset`) changed by `change`, the first line told by its index. */
23
+ const changedHit = (
24
+ lines: readonly string[],
25
+ offset: number,
26
+ hit: FocusHit | undefined,
27
+ change: (line: string, index: number) => string,
28
+ ): string[] =>
29
+ lines.map((line, at) => {
30
+ const index = hit ? at + offset - hit.line : -1
31
+ return hit && index >= 0 && index < hit.lines ? change(line, index) : line
32
+ })
33
+
34
+ /**
35
+ * `screen` with the focus `state` gives laid on one region of `lines`: the focused row on the focus background and
36
+ * mark, a line of links under the mouse marked alone, and the square-bracket button under the mouse bold.
37
+ */
38
+ const focusedLines = (lines: readonly string[], offset: number, screen: Screen, state: Selection, width: number) => {
39
+ const hitOf = (key: string | null | undefined) => (key ? screen.focusHits.find((hit) => hit.key === key) : undefined)
40
+ const hovered = hitOf(state.hovered)
41
+ const focusedKey = state.selected !== null && isFocused(state, state.selected) ? state.selected : null
42
+ const focusedHit = hitOf(focusedKey)
43
+ const gutter = focusedHit ? leadOf(lines[focusedHit.line - offset] ?? '') : 0
44
+ const focused = changedHit(
45
+ lines,
46
+ offset,
47
+ focusedHit,
48
+ (line, index) => `${line.slice(0, gutter)}${focusLine(line.slice(gutter), width - gutter, index === 0)}`,
49
+ )
50
+ const links = state.hovered?.startsWith(LINKS_KEY_PREFIX)
51
+ ? changedHit(focused, offset, hovered, (line, index) => (index === 0 ? focusLine(line, width, true) : line))
52
+ : focused
53
+ const from = state.hoveredFrom
54
+ return from == null
55
+ ? links
56
+ : changedHit(links, offset, hovered, (line, index) => (index === 0 ? boldBracketAt(line, from) : line))
57
+ }
58
+
59
+ /**
60
+ * The screen a full drawing with `state` gives, laid on `plain` (drawn with `unfocused`) without a new layout: a hover
61
+ * only moves the focus. Null where it needs the layout: with the help open, or for a row of the blocks on top, whose
62
+ * buttons stand only at the selection. `agentOf` says a lane's agent, for the footer's click hint.
63
+ */
64
+ export const focusedScreen = (plain: Screen, state: Selection, width: number, agentOf: AgentOf): Screen | null => {
65
+ const blocks = [plain.yourTurnBlock, plain.sourceBlock]
66
+ const isBlockRow = (key: string | null | undefined) => !!key && blockRowByKey(blocks, key) !== null
67
+ if (state.showLegend || isBlockRow(state.selected) || isBlockRow(state.hovered)) return null
68
+ const header = focusedLines(plain.header, 0, plain, state, width)
69
+ const lines = focusedLines(plain.body.lines, plain.header.length, plain, state, width)
70
+ const selectedHit = plain.focusHits.find(({ key }) => key === state.selected)
71
+ const cursorLine =
72
+ selectedHit && selectedHit.line >= plain.header.length ? selectedHit.line - plain.header.length : -1
73
+ const hint = clickHint(state, (key) => blockRowByKey(blocks, key), agentOf) ?? TEXTS.hint
74
+ return {
75
+ ...plain,
76
+ header,
77
+ body: { ...plain.body, lines, cursorLine },
78
+ footer: [...plain.footer.slice(0, -1), fit(paint.dim(hint), width)],
79
+ }
80
+ }
package/src/plans/git.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  import { existsSync, readdirSync, statSync } from 'node:fs'
2
2
  import { dirname } from 'node:path'
3
- import type { LaneStatus } from './layout'
4
3
  import { createProcessSlots } from './process-slots'
5
4
  import type { BaseRefInfo } from './sprint-page'
6
- import type { BranchStatus } from './status-pace'
5
+ import type { BranchStatus, WorktreeStatus } from './status-pace'
7
6
 
8
7
  /** How many git processes the herd's reads keep in flight; more end together and hold the event loop as one block. */
9
8
  const GIT_PROCESSES = 6
@@ -176,33 +175,29 @@ export const branchStatusOf = (output: string | null): BranchStatus => {
176
175
  }
177
176
  }
178
177
 
179
- /** Reads branch and changed files of a worktree; its git process starts after `pass`. */
180
- export type BranchStatusReader = (worktree: string, pass: () => Promise<void>) => Promise<BranchStatus>
181
-
182
178
  /**
183
179
  * Branch and changed files of a worktree from `git status`, the dearest of the herd's git calls in a large checkout.
184
180
  * Without optional locks: the herd never rewrites the index, which would take the lock an agent's git needs and move
185
181
  * the index the worktree's stamp watches (`worktreeStamp`).
186
182
  */
187
- export const branchStatusAt: BranchStatusReader = async (worktree, pass) =>
183
+ const branchStatusAt = async (worktree: string, pass: () => Promise<void>): Promise<BranchStatus> =>
188
184
  branchStatusOf(await gitAsync(worktree, ['--no-optional-locks', 'status', '--porcelain=v2', '--branch'], pass))
189
185
 
190
186
  /**
191
187
  * Branch, commits ahead of `baseRef` and changed files of a worktree; null and 0 when it does not exist. The two
192
188
  * git calls run side by side and without holding the event loop, so a key pressed meanwhile is answered at once;
193
- * each starts after `pass` (a `SliceGate`), so the starts of many worktrees' calls do not add up to one block.
194
- * `readBranchStatus` may answer from what it kept instead of asking git (`pacedStatus`).
189
+ * each starts after `pass` (a `SliceGate`), so the starts of many worktrees' calls do not add up to one block. The
190
+ * herd keeps the answer while nothing moved (`pacedStatus`).
195
191
  */
196
192
  export const gitStatusOf = async (
197
193
  worktree: string,
198
194
  baseRef: string,
199
195
  pass: () => Promise<void> = passAtOnce,
200
- readBranchStatus: BranchStatusReader = branchStatusAt,
201
- ): Promise<Pick<LaneStatus, 'branch' | 'ahead' | 'dirty'>> => {
196
+ ): Promise<WorktreeStatus> => {
202
197
  if (!existsSync(worktree)) return { branch: null, ahead: null, dirty: 0 }
203
198
  const [count, status] = await Promise.all([
204
199
  gitAsync(worktree, ['rev-list', '--count', `${baseRef}..HEAD`], pass),
205
- readBranchStatus(worktree, pass),
200
+ branchStatusAt(worktree, pass),
206
201
  ])
207
202
  const ahead = Number.parseInt(count ?? '', 10)
208
203
  return { ...status, ahead: Number.isNaN(ahead) ? null : ahead }
@@ -0,0 +1,201 @@
1
+ import { join } from 'node:path'
2
+ import { writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
3
+ import { readPrivateText } from '../shared/private-location'
4
+ import type { StartedKit } from '../version/started-kit'
5
+ import { WATCHER_FILES, watcherDirectoryOf } from '../watch/watcher-lock'
6
+ import type { KnownPage } from './plan-chips'
7
+ import { REFRESH_PACE } from './refresh-pace'
8
+ import type { PlansSettings } from './settings'
9
+ import type { Snapshot, SnapshotDocuments } from './snapshot'
10
+
11
+ /** A read the watcher published, as the herd takes it: the snapshot, the sprint page written from it, the fetch line. */
12
+ export type PublishedRead = { snapshot: Snapshot; page: KnownPage | null; fetchLine: string | null }
13
+
14
+ /** The snapshot as the file holds it: the pane titles as pairs, the plan texts in a file of their own (`HerdPlans`). */
15
+ type StoredSnapshot = Omit<Snapshot, 'titles' | 'documents'> & {
16
+ titles: [string, string | null][]
17
+ documents: Omit<SnapshotDocuments, 'plans'>
18
+ }
19
+
20
+ type StoredPage = Omit<KnownPage, 'anchors'> & { anchors: [string, string][] }
21
+
22
+ /** `herd-read.json`: the published read, for the herds of the board, settings and kit `key` names. */
23
+ export type HerdRead = {
24
+ key: string
25
+ at: number
26
+ plansKey: string
27
+ snapshot: StoredSnapshot
28
+ page: StoredPage | null
29
+ fetchLine: string | null
30
+ }
31
+
32
+ /** `herd-plans.json`: the plan texts of the read, written only when they changed. */
33
+ export type HerdPlans = { plansKey: string; plans: Record<string, string> }
34
+
35
+ /**
36
+ * `herd-beat.json`: when the watcher last read with success, for which `key`; a herd trusts the read only while this
37
+ * is young and names its own key, so a watcher whose reads fail lets the herds read for themselves and show why.
38
+ */
39
+ export type HerdBeat = { key: string; at: number }
40
+
41
+ /** What the herd checks before it takes the watcher's read: the beat, its own key, and whether the watcher still runs. */
42
+ export type Liveness = { beat: HerdBeat | null; key: string | null; watcherRunning: boolean; now: number }
43
+
44
+ export const HERD_FILES = { read: 'herd-read.json', plans: 'herd-plans.json', beat: 'herd-beat.json' } as const
45
+
46
+ /**
47
+ * How old the watcher's last read may be before a herd reads for itself again: three of the slowest refreshes, so one
48
+ * slow read or a busy watcher does not flip the herd back and forth.
49
+ */
50
+ export const PUBLISHED_MAX_AGE_MS = 3 * REFRESH_PACE.maxMs
51
+
52
+ /** Raised when the file layout changes, so a herd never takes a read of another layout. */
53
+ const FORMAT = 1
54
+
55
+ const hashOf = (value: unknown): string => Bun.hash(JSON.stringify(value)).toString(36)
56
+
57
+ /**
58
+ * What a herd and the watcher must share for the herd to take the watcher's read: the checkout, the board, the
59
+ * settings and the kit that read it (its version and where it lies, so a herd running other code reads for itself).
60
+ */
61
+ export const publishedKey = (
62
+ context: { root: string; sprintFile: string; settings: PlansSettings },
63
+ kit: StartedKit,
64
+ ): string => hashOf([FORMAT, kit.version, kit.packageRoot, context.root, context.sprintFile, context.settings])
65
+
66
+ /** The plan texts' key, so a herd reads `herd-plans.json` again only when they changed. */
67
+ export const plansKeyOf = (plans: Record<string, string>): string =>
68
+ hashOf(Object.entries(plans).map(([href, text]) => [href, Bun.hash(text).toString(36)]))
69
+
70
+ /** The read as the file holds it. */
71
+ export const storedRead = (read: PublishedRead, key: string, at: number): HerdRead => {
72
+ const { titles, documents, ...rest } = read.snapshot
73
+ const { plans, ...otherDocuments } = documents
74
+ return {
75
+ key,
76
+ at,
77
+ plansKey: plansKeyOf(plans),
78
+ snapshot: { ...rest, titles: [...titles], documents: otherDocuments },
79
+ page: read.page && { ...read.page, anchors: [...read.page.anchors] },
80
+ fetchLine: read.fetchLine,
81
+ }
82
+ }
83
+
84
+ /**
85
+ * The snapshot with its clocks set to `now`: the sources' ages and your turn's, which the reader draws at its own
86
+ * time with every read.
87
+ */
88
+ const clockedAt = <T extends Pick<Snapshot, 'sourceBlock' | 'yourTurn'>>(snapshot: T, now: number): T => {
89
+ const { sourceBlock, yourTurn, ...rest } = snapshot
90
+ return {
91
+ ...rest,
92
+ ...(sourceBlock ? { sourceBlock: { ...sourceBlock, now } } : {}),
93
+ ...(yourTurn ? { yourTurn: { ...yourTurn, now } } : {}),
94
+ } as T
95
+ }
96
+
97
+ /** The read from the files, the plan texts from `plans`; `now` is the time the herd draws the ages at. */
98
+ export const publishedRead = (stored: HerdRead, plans: Record<string, string>, now: number): PublishedRead => {
99
+ const { titles, documents, ...rest } = clockedAt(stored.snapshot, now)
100
+ return {
101
+ snapshot: {
102
+ ...rest,
103
+ titles: new Map(titles),
104
+ documents: { ...documents, plans },
105
+ },
106
+ page: stored.page && { ...stored.page, anchors: new Map(stored.page.anchors) },
107
+ fetchLine: stored.fetchLine,
108
+ }
109
+ }
110
+
111
+ /**
112
+ * What decides whether the read changed: everything but its time and the clocks of the sources and your turn, which
113
+ * move with every read while the herd draws the ages at its own time.
114
+ */
115
+ export const readContent = ({ at: _at, snapshot, ...rest }: HerdRead): string =>
116
+ JSON.stringify({ ...rest, snapshot: clockedAt(snapshot, 0) })
117
+
118
+ /** What the publisher wrote last: the read's content (`readContent`) and the plan texts' key; null before the first. */
119
+ export type Written = { content: string | null; plansKey: string | null }
120
+
121
+ /**
122
+ * Which files a new read rewrites: the plan texts only when their key moved, the read only when its content did. The
123
+ * beat is written after every read that worked, whatever this says. `content` is the read's `readContent`, kept for
124
+ * the next comparison, so the board is serialized once per read.
125
+ */
126
+ export const publishDecision = (
127
+ written: Written,
128
+ stored: HerdRead,
129
+ ): { writePlans: boolean; writeRead: boolean; content: string } => {
130
+ const content = readContent(stored)
131
+ return { writePlans: stored.plansKey !== written.plansKey, writeRead: content !== written.content, content }
132
+ }
133
+
134
+ /**
135
+ * The whole board's read as the herd of one lane would have read it: that lane's view and pane title only, nothing
136
+ * planned or unplaced, and the board's notice when the lane is not on it.
137
+ */
138
+ export const laneRead = (snapshot: Snapshot, lane: string, laneNotListed: (lane: string) => string): Snapshot => {
139
+ const views = snapshot.views.filter((view) => view.progress.lane.name === lane)
140
+ const pane = snapshot.panes?.[lane]
141
+ return {
142
+ ...snapshot,
143
+ title: views.length === 0 ? laneNotListed(lane) : snapshot.title,
144
+ views,
145
+ documents: { ...snapshot.documents, laneOrder: snapshot.documents.laneOrder.filter((name) => name === lane) },
146
+ planned: [],
147
+ titles: new Map([...snapshot.titles].filter(([titled]) => titled === pane)),
148
+ panes: pane ? { [lane]: pane } : {},
149
+ unplaced: [],
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Whether a herd may take the watcher's read: the watcher runs, read for the herd's key within
155
+ * `PUBLISHED_MAX_AGE_MS`. A herd without a key (its kit is no release) never does.
156
+ */
157
+ export const isPublishedLive = ({ beat, key, watcherRunning, now }: Liveness): boolean =>
158
+ watcherRunning && key !== null && beat !== null && beat.key === key && now - beat.at <= PUBLISHED_MAX_AGE_MS
159
+
160
+ const parsed = <T>(text: string | null, isShape: (value: Record<string, unknown>) => boolean): T | null => {
161
+ if (text === null) return null
162
+ try {
163
+ const value: unknown = JSON.parse(text)
164
+ return value && typeof value === 'object' && isShape(value as Record<string, unknown>) ? (value as T) : null
165
+ } catch {
166
+ return null
167
+ }
168
+ }
169
+
170
+ const isRead = (value: Record<string, unknown>) =>
171
+ typeof value.key === 'string' && typeof value.plansKey === 'string' && typeof value.snapshot === 'object'
172
+
173
+ const isPlans = (value: Record<string, unknown>) =>
174
+ typeof value.plansKey === 'string' && typeof value.plans === 'object'
175
+
176
+ const isBeat = (value: Record<string, unknown>) => typeof value.key === 'string' && typeof value.at === 'number'
177
+
178
+ /**
179
+ * Where the herd files of the checkout at `root` lie: the watcher's directory, which only its user may read, beside the
180
+ * watcher's lock, which tells whether it still runs.
181
+ */
182
+ export const herdFilesOf = (root: string): Record<keyof typeof HERD_FILES | 'lock', string> => {
183
+ const directory = watcherDirectoryOf(root)
184
+ return {
185
+ read: join(directory, HERD_FILES.read),
186
+ plans: join(directory, HERD_FILES.plans),
187
+ beat: join(directory, HERD_FILES.beat),
188
+ lock: join(directory, WATCHER_FILES.lock),
189
+ }
190
+ }
191
+
192
+ /** The published read; null for a missing, damaged or foreign file. */
193
+ export const readHerdRead = (path: string): HerdRead | null => parsed<HerdRead>(readPrivateText(path), isRead)
194
+
195
+ export const readHerdPlans = (path: string): HerdPlans | null => parsed<HerdPlans>(readPrivateText(path), isPlans)
196
+
197
+ export const readHerdBeat = (path: string): HerdBeat | null => parsed<HerdBeat>(readPrivateText(path), isBeat)
198
+
199
+ /** Writes a file through a temporary one, so a herd woken by the change never reads half of it. */
200
+ export const writeHerdFile = (path: string, value: HerdRead | HerdPlans | HerdBeat): void =>
201
+ writePrivateFile(path, JSON.stringify(value))
@@ -2,7 +2,7 @@ import type { SprintLane } from '../plan-format'
2
2
  import { AGENT_STATUS, isRestingAgent } from '../terminal/types'
3
3
  import { REST_QUESTION_COUNT } from '../watch/lane-rest'
4
4
  import { waitingNote } from './lane-standing'
5
- import { plain } from './layout-phase'
5
+ import { plain } from './markdown-text'
6
6
  import { shortTitle } from './task-name'
7
7
  import { TEXTS } from './texts'
8
8
 
@@ -2,6 +2,7 @@ import {
2
2
  isPhaseFinished,
3
3
  isPhaseOpen,
4
4
  type LaneProgress,
5
+ PHASE_WORK,
5
6
  type PhaseProgress,
6
7
  type PlanPhase,
7
8
  type PlansByHref,
@@ -130,6 +131,9 @@ const namedIds = ({ steps }: LaneProgress, href: string): Set<string> =>
130
131
  steps.flatMap(({ step }) => (step.planHref === href ? [...step.phaseIds, ...(step.namedPhaseIds ?? [])] : [])),
131
132
  )
132
133
 
134
+ /** The base branch carries the phase's ticks: the worktree left them as they stand, or a run of it was merged. */
135
+ const baseCarriesTicks = ({ work }: PhaseProgress): boolean => work === undefined || work === PHASE_WORK.merged
136
+
133
137
  /**
134
138
  * Whether a phase is a merged leftover: an older board row named it, the newest row does not, and the base branch
135
139
  * carries its ticks as they stand (not `touched`), so its open boxes were left open on purpose, or carries ticks of
@@ -140,7 +144,7 @@ const mergedLeftoverOf = ({ steps }: LaneProgress): IsLeftover => {
140
144
  const row = [...steps].reverse().find(({ step }) => !step.withoutRow)?.step
141
145
  if (!row) return () => false
142
146
  const rowIds = new Set(row.namedPhaseIds ?? row.phaseIds)
143
- return (phase) => (phase.touched !== true || phase.merged === true) && !isNamedBy(phase.id, rowIds)
147
+ return (phase) => baseCarriesTicks(phase) && !isNamedBy(phase.id, rowIds)
144
148
  }
145
149
 
146
150
  /**
@@ -173,7 +177,7 @@ type IsLeftover = (phase: PhaseProgress) => boolean
173
177
  * its movement nor the latest movement makes it run. A leftover without that evidence still runs once it moves.
174
178
  */
175
179
  const isMergedRun = (phase: PhaseProgress, isLeftover: IsLeftover): boolean =>
176
- phase.merged === true && isLeftover(phase)
180
+ phase.work === PHASE_WORK.merged && isLeftover(phase)
177
181
 
178
182
  /** What decides which open phases wait and in which order: the ids that run, and the merged leftovers. */
179
183
  type WaitingContext = { running: ReadonlySet<string>; isLeftover: IsLeftover }
@@ -8,6 +8,7 @@ import type { ActiveTask, UnplannedTask } from './active-tasks'
8
8
  import { type Frame, joinFrames, rowFrame, textFrame } from './frame'
9
9
  import type { LaneTickets } from './lane-tickets'
10
10
  import type { LinkChip } from './link-chips'
11
+ import { plain } from './markdown-text'
11
12
  import type { PhaseModel } from './phase-models'
12
13
  import { itemKey, type Selection, taskRestKey } from './selection'
13
14
  import { shimmering, stillTeal } from './shimmer'
@@ -61,7 +62,6 @@ const COUNT_PLACES = 3
61
62
  const COUNT_MARK_WIDTH = 2
62
63
  const BLANK_COUNT = ' '.repeat(2 * COUNT_PLACES + 1)
63
64
  const COUNT_CELL_WIDTH = COUNT_MARK_WIDTH + 1 + BLANK_COUNT.length
64
- const MARKDOWN_MARKS = /\*\*|`/g
65
65
  const CODE_SPAN = /`([^`]+)`/g
66
66
 
67
67
  const PHASE_PAINT: Record<PhaseState, (count: string) => string> = {
@@ -78,8 +78,6 @@ const PHASE_MARK: Record<PhaseState, string> = {
78
78
  [PHASE_STATE.open]: '',
79
79
  }
80
80
 
81
- export const plain = (text: string): string => text.replace(MARKDOWN_MARKS, '')
82
-
83
81
  /** What a task reads as on one row: its short name where it carries one (`taskNameOf`), else its whole text. */
84
82
  export const taskTitle = (text: string): string => plain(taskNameOf(text) ?? text)
85
83
 
@@ -11,13 +11,13 @@ import {
11
11
  nowLine,
12
12
  phaseChildren,
13
13
  phaseCountCell,
14
- plain,
15
14
  subagentLine,
16
15
  TASK_INDENT,
17
16
  type TaskStyle,
18
17
  taskRowFrame,
19
18
  taskTitle,
20
19
  } from './layout-phase'
20
+ import { plain } from './markdown-text'
21
21
  import { type PhaseModel, phaseModelKey } from './phase-models'
22
22
  import { phaseRanges } from './phase-ranges'
23
23
  import { finishedKey, nextKey, phaseKey } from './selection'
@@ -1,8 +1,9 @@
1
- import { TONE_ROLE } from '../config/tones'
1
+ import { TONE, TONE_ROLE } from '../config/tones'
2
2
  import { CONTEXT_LEVEL, type ContextLevel, type ContextReading, contextLabel } from '../context'
3
3
  import type { LaneProgress, PhaseProgress } from '../plan-format'
4
4
  import { currentPhase, isPhaseOpen, phaseLabel } from '../plan-format'
5
5
  import type { WaitingItem } from '../plan-format/waiting'
6
+ import { boundedMemo } from '../shared/bounded-memo'
6
7
  import {
7
8
  BADGE_TONE,
8
9
  type BadgeTone,
@@ -10,6 +11,7 @@ import {
10
11
  bar,
11
12
  box,
12
13
  bracket,
14
+ ESC,
13
15
  fit,
14
16
  link,
15
17
  packGroups,
@@ -51,7 +53,6 @@ import {
51
53
  type LaneContext,
52
54
  MIN_TITLE_WIDTH,
53
55
  nowLine,
54
- plain,
55
56
  SHIMMERING_TASK,
56
57
  STILL_TASK,
57
58
  type TaskStyle,
@@ -59,6 +60,7 @@ import {
59
60
  import { foldedTaskLine, openLaneFrame } from './layout-summary'
60
61
  import { legendLines } from './legend'
61
62
  import { chipKey, type LinkChip, planUrlOf } from './link-chips'
63
+ import { plain } from './markdown-text'
62
64
  import type { PhaseModel } from './phase-models'
63
65
  import { type PlannedPlan, plannedNextLabel } from './planned'
64
66
  import { CHECKS, type ChecksState, failedTestKey, type PullRequest, pullRequestKey } from './pull-requests'
@@ -205,8 +207,6 @@ type TopBlock = {
205
207
  /** What the head says after the count, painted, open and folded alike; empty for nothing. */
206
208
  balance: string
207
209
  markPaint: (text: string) => string
208
- /** The paint of the buttons of a row: amber for "Your turn", the normal foreground colour elsewhere. */
209
- buttonPaint: (text: string) => string
210
210
  /** Whether a row is the selected one or under the mouse: it gets the background, the mark, and its buttons. */
211
211
  focused: (key: string) => boolean
212
212
  /** Whether all buttons of a row are bold: the arrows selected it and the mouse is not on it (the mouse bolds one). */
@@ -495,17 +495,11 @@ const TALLY_SEPARATOR = ' · '
495
495
  /** How many cut chip labels the render keeps; a label is often a whole line of a plan, measured anew every frame otherwise. */
496
496
  const KEPT_LABELS = 2000
497
497
 
498
- const cutLabels = new Map<string, string>()
498
+ const cutLabels = boundedMemo<string, string>(KEPT_LABELS)
499
499
 
500
500
  /** The chip label cut to `CHIP_LABEL_WIDTH`, kept per label; the kept ones are dropped all at once when they are too many. */
501
- const cutLabel = (label: string): string => {
502
- const kept = cutLabels.get(label)
503
- if (kept !== undefined) return kept
504
- if (cutLabels.size >= KEPT_LABELS) cutLabels.clear()
505
- const cut = fit(plain(label), CHIP_LABEL_WIDTH)
506
- cutLabels.set(label, cut)
507
- return cut
508
- }
501
+ const cutLabel = (label: string): string =>
502
+ cutLabels.get(label) ?? cutLabels.set(label, fit(plain(label), CHIP_LABEL_WIDTH))
509
503
 
510
504
  /** `[🔗 label]` linked to the chip's address, `private` faint inside it for a link its plan says is private. */
511
505
  const chipCell = ({ url, icon, label, private: hidden }: LinkChip): string =>
@@ -1012,6 +1006,9 @@ const plannedRows = (planned: PlannedPlan[], width: number, indent: string): str
1012
1006
 
1013
1007
  const rule = (width: number): string => paint.dim(RULE.repeat(width))
1014
1008
 
1009
+ /** The one colour of the buttons of "Your turn" and "Sources" (`[ask]`, `[done]`, `[run]`, `[move]`). */
1010
+ const ACTION_PAINT = paintOfTone(TONE.teal)
1011
+
1015
1012
  /** The blank that starts every line of a block on top: no bar, the mark of a row says what it is. */
1016
1013
  const blockBar = (): string => ' '
1017
1014
 
@@ -1033,35 +1030,41 @@ const markedLine = (line: string, row: BlockRow, markPaint: (text: string) => st
1033
1030
  return line.startsWith(row.mark) ? `${paintMark(row.mark)}${line.slice(row.mark.length)}` : line
1034
1031
  }
1035
1032
 
1036
- /** The text from `from` on dimmed, without the blanks that pad it to the right edge. */
1033
+ /**
1034
+ * The text from `from` on dimmed, without the blanks that pad it to the right edge. `from` counts in the line the row
1035
+ * measured it on; a line cut shorter for the buttons (`row.hot`) ends in a colour code, and a `from` past its start
1036
+ * would tear that code, so such a line stays undimmed from there.
1037
+ */
1037
1038
  const dimmedRest = (text: string, from: number): string => {
1038
- const end = text.trimEnd().length
1039
+ const code = text.indexOf(ESC)
1040
+ const end = code === -1 || from < code ? text.trimEnd().length : from
1039
1041
  return from >= end ? text : `${text.slice(0, from)}${paint.dim(text.slice(from, end))}${text.slice(end)}`
1040
1042
  }
1041
1043
 
1042
1044
  /**
1043
1045
  * One row as lines: the bar on each, the mark painted on the first line only, the short title in the normal colour
1044
- * and the rest of an expanded row dim, linked when the row has an address. The buttons at the end of the first line
1045
- * are drawn only on the focused row, in the block's button paint; they keep their columns on every row.
1046
+ * and the rest of an expanded row dim, linked when the row has an address. The text takes the whole pane; only the
1047
+ * row under the selection or the mouse draws its buttons, in the action colour (`ACTION_PAINT`), flush right at the
1048
+ * edge of its first line, over the end of its text (`row.hot`) and, in "Your turn", its time.
1046
1049
  */
1047
- const blockRowLines = (
1048
- { markPaint, buttonPaint, focused, allBold }: TopBlock,
1049
- row: BlockRow,
1050
- width: number,
1051
- ): string[] => {
1050
+ const blockRowLines = ({ markPaint, focused, allBold }: TopBlock, row: BlockRow, width: number): string[] => {
1052
1051
  const hot = focused(row.key)
1053
- const paintButton = (text: string) => (allBold(row.key) ? paint.bold(buttonPaint(text)) : buttonPaint(text))
1052
+ const paintButton = (text: string) => (allBold(row.key) ? paint.bold(ACTION_PAINT(text)) : ACTION_PAINT(text))
1054
1053
  return row.lines.map((line, index) => {
1055
- const withButton = hot && index === 0 && row.buttons.length > 0
1056
- const styled = dimmedRest(line, row.dimFrom[index] ?? line.length)
1057
- const marked = index === 0 ? markedLine(styled, row, markPaint) : styled
1058
- const shown = row.url ? link(row.url, marked) : marked
1059
- const buttons = withButton ? buttonsText(row.buttons, row.dim ? paint.dim : paintButton) : ''
1054
+ const covered = hot && index === 0 ? row.hot : undefined
1055
+ const shown = rowText(row, covered ?? line, index, markPaint)
1056
+ const buttons = covered === undefined ? '' : ` ${buttonsText(row.buttons, row.dim ? paint.dim : paintButton)}`
1060
1057
  const drawn = `${blockBar()} ${shown}${buttons}`
1061
1058
  return hot ? focusLine(drawn, width, index === 0) : drawn
1062
1059
  })
1063
1060
  }
1064
1061
 
1062
+ /** The text of a row's line `index`: dim from its `dimFrom` on, the mark painted on the first line, linked to its address. */
1063
+ const rowText = (row: BlockRow, text: string, index: number, markPaint: (text: string) => string): string => {
1064
+ const styled = dimmedRest(text, row.dimFrom[index] ?? text.length)
1065
+ const marked = index === 0 ? markedLine(styled, row, markPaint) : styled
1066
+ return row.url ? link(row.url, marked) : marked
1067
+ }
1065
1068
  /** A folded block: its head alone, which keeps the count and the balance; a click matches no row and no `+<n>` line. */
1066
1069
  const foldedBlockLines = (block: TopBlock, headKey: string, width: number): BlockLines => ({
1067
1070
  open: false,
@@ -1132,14 +1135,13 @@ const yourTurnLines = ({ turn, now }: YourTurnBlock, state: Selection, width: nu
1132
1135
  count,
1133
1136
  balance: balanceText(turnBalance(turn, clock)),
1134
1137
  markPaint: turnMarkPaint,
1135
- buttonPaint: paintOfRole(TONE_ROLE.waiting),
1136
1138
  focused: (key) => isFocused(state, key),
1137
1139
  allBold: (key) => buttonsAllBold(state, key),
1138
1140
  countPaint: turn.now.length > 0 ? paintOfRole(TONE_ROLE.waiting) : (text) => text,
1139
1141
  moreKey: null,
1140
1142
  hidden: 0,
1141
1143
  expanded: false,
1142
- rows: turnRows(turnPage(turn, opened, clock), width),
1144
+ rows: turnRows(turnPage(turn, opened, clock), width, state.expanded),
1143
1145
  }
1144
1146
  return blockLinesOf(block, state, TOP_BLOCK.yourTurn, width)
1145
1147
  }
@@ -1169,7 +1171,6 @@ const sourceLines = (
1169
1171
  count: null,
1170
1172
  balance: sourceSummaryText(sourceSummaries(sources, now, newTone, asked)),
1171
1173
  markPaint: paint.dim,
1172
- buttonPaint: (text) => text,
1173
1174
  focused: (key) => isFocused(state, key),
1174
1175
  allBold: (key) => buttonsAllBold(state, key),
1175
1176
  countPaint: (text) => text,
@@ -1,4 +1,6 @@
1
1
  import { ARTIFACT_LINK_KIND, type LinkKind } from '../config/link-kinds'
2
+ import { type BoundedMemo, boundedMemo } from '../shared/bounded-memo'
3
+ import { plain } from './markdown-text'
2
4
 
3
5
  /** A link of a plan that matches a link kind, as the sprint page shows it. */
4
6
  export type LinkChip = { url: string; label: string; icon: string; private: boolean }
@@ -10,7 +12,6 @@ const AUTOLINK = /<(https?:\/\/[^>\s]+)>/g
10
12
  const BARE_LINK = /(?<![<\w"])(https?:\/\/[^\s)>\]]+)/g
11
13
  const LABEL_BEFORE = /\*\*([^*]+?):?\*\*:?\s*(?:[-–>]\s*)?$/
12
14
  const PRIVATE = /\bprivat/i
13
- const MARKS = /\*\*|`/g
14
15
  const CONTROL = /\p{Cc}/gu
15
16
 
16
17
  const linksOf = (line: string): Found[] => {
@@ -33,19 +34,14 @@ const TITLE_END = /[\s:,;.\-–>(]+$/
33
34
 
34
35
  /** What a line says besides its links, without list marks, checkbox and emphasis: the title of a bare artifact link. */
35
36
  const lineTitleOf = ({ line }: Found): string =>
36
- line
37
- .replace(MARKDOWN_LINK, '$1')
38
- .replace(AUTOLINK, '')
39
- .replace(LEFTOVER_LINK, '')
40
- .replace(LIST_LEAD, '')
41
- .replace(MARKS, '')
37
+ plain(line.replace(MARKDOWN_LINK, '$1').replace(AUTOLINK, '').replace(LEFTOVER_LINK, '').replace(LIST_LEAD, ''))
42
38
  .replace(CONTROL, '')
43
39
  .replace(TITLE_END, '')
44
40
  .trim()
45
41
 
46
42
  const labelOf = (found: Found, kind: LinkKind): string => {
47
43
  const written = found.text ?? LABEL_BEFORE.exec(found.before)?.[1]
48
- const own = written?.replace(MARKS, '').replace(CONTROL, '').trim()
44
+ const own = written && plain(written).replace(CONTROL, '').trim()
49
45
  if (own) return own
50
46
  return (kind.pattern === ARTIFACT_LINK_KIND.pattern && lineTitleOf(found)) || kind.label
51
47
  }
@@ -53,7 +49,7 @@ const labelOf = (found: Found, kind: LinkKind): string => {
53
49
  /** How many texts the chips are kept for, per list of kinds; the kept ones are dropped all at once when they are too many. */
54
50
  const KEPT_TEXTS = 200
55
51
 
56
- const keptChips = new WeakMap<readonly LinkKind[], Map<string, LinkChip[]>>()
52
+ const keptChips = new WeakMap<readonly LinkKind[], BoundedMemo<string, LinkChip[]>>()
57
53
 
58
54
  /** `extractLinkChips` without its cache, for a reader that goes through more texts than the cache keeps. */
59
55
  export const readLinkChips = (markdown: string, kinds: readonly LinkKind[]): LinkChip[] => {
@@ -81,14 +77,9 @@ export const readLinkChips = (markdown: string, kinds: readonly LinkKind[]): Lin
81
77
  * and list of kinds, so a refresh and lanes sharing a plan get the same array back: callers must not change it.
82
78
  */
83
79
  export const extractLinkChips = (markdown: string, kinds: readonly LinkKind[]): LinkChip[] => {
84
- const byText = keptChips.get(kinds) ?? new Map<string, LinkChip[]>()
80
+ const byText = keptChips.get(kinds) ?? boundedMemo<string, LinkChip[]>(KEPT_TEXTS)
85
81
  keptChips.set(kinds, byText)
86
- const kept = byText.get(markdown)
87
- if (kept) return kept
88
- if (byText.size >= KEPT_TEXTS) byText.clear()
89
- const chips = readLinkChips(markdown, kinds)
90
- byText.set(markdown, chips)
91
- return chips
82
+ return byText.get(markdown) ?? byText.set(markdown, readLinkChips(markdown, kinds))
92
83
  }
93
84
 
94
85
  const CHIP_KEY = /^chip>(https?:\/\/.+)$/