A VNX mod: shows the progress of the VNX project the session is in. A pane and a status line with the builders at work, what waits on a decision, and per track…

A VNX mod for Claude Code. It shows the progress of the one VNX project the session is in: the builders at work, what waits on a decision, the tracks with how many of their deliverables are done, what is being worked on and what comes next, and the open PRs with the review seats still owed. It needs VNX: the project stores under ~/.vnx-data and the vnx CLI. It ships with a VNX release and lives in this repository under packages/vnx-mods/.
It reads metadata only: ids, counts, statuses, PR numbers, the titles of deliverables, and per builder one title line. It never reads a transcript, a report, a receipt or the body of an open item, so it is safe to load in a session that holds client work.
The labels in the pane are Dutch (ververs, onbeschikbaar, geen store, verouderd).
One project per session: the one whose .vnx-project-id lies above the session's working directory. A fleet view over all projects is not part of this mod. It is a separate, aggregated product that comes later.
The mod walks up from the session's working directory to the first directory that holds .vnx-project-id. The first line of that file, with all whitespace removed, is the project id; it must match ^[a-z][a-z0-9-]{1,31}$ (the rule bin/vnx follows), else the mod says geen geldig project-id in <path>, and the store is <data_root>/<id>. The nearest file decides. It never falls back to another project.
Without such a file the pane says:
geen VNX-project in deze map (geen .vnx-project-id boven /pad/naar/map)
and the status line is empty.
/voortgang refreshes and opens the pane Voortgang. The pane never opens by itself. The unit of progress is the deliverable: a track row counts deliverables, and names the ones in progress and the ones that come next. PRs are not a unit of a track; an open PR is shown where the work is. The sections of the now view come in this order:
project-a · index 2 min
Nu bezig
opus · 6 min · <first # line of the instruction> · <track> · PR #2150
PR #2150 · ci loopt · stoel open: codex_gate
<title of a deliverable>
PR #2151 · klaar voor merge
Wacht op een besluit
1 te promoveren (now) · 2 open uitkomsten
backlog: 12 voorgesteld op next/later
Tracks
P1 track-a ▓▓▓░░░░░ 3/12
bezig: K2a-3b
volgt: K2a-3c · K2b · K3 (+6)
P1 track-b ▓▓▓▓▓▓▓▓ 8/8
P2 track-c geen leveringen gepland
opruimen: 10 stale rijen
verouderd stands once, on the header, when the index or the state is older than 60 minutes or has no timestamp. In the plan view · plan follows.live_work (the live and the starting entries), one row each: model of its dispatch-spec.json, its minutes (age_seconds), the first line of its instruction.md that starts with # , and the track_id and pr_id of the spec when it sets them. A spec without model shows model ?; its track and PR still link (the spec's model is optional). A long title gives way to the track and the PR behind it. Nu bezig: niets when there is none and no PR is open.pr_id, or whose dispatch opened it (live_work.open_prs). A PR no builder owns keeps a row of its own only when its dispatch id is a deliverable that bezig: names on any track the pane shows, whatever its horizon, under the title of that deliverable. Every other open PR is folded into one row at the end of the section: N open PR's zonder bouwer · M klaar voor merge · K wacht op stoel · J ci rood, each count left out when it is 0 (klaar voor merge in ok, wacht op stoel in warn, ci rood in error, the rest dim). The plan view does not show it. A PR row says PR #N · ci loopt|rood|groen · stoel open: <seats> (the seats of blocked_on), or PR #N · klaar voor merge when nothing is owed and CI is green. A PR the index lists and the readable PR queue misses counts in N only; with an unreadable PR queue the folded row ends in onbeschikbaar: <reason>, and when no PR is known at all (the normal shape when gh fails) Nu bezig carries one row onbeschikbaar: <reason> of its own, never both. A builder list that cannot be read is one onbeschikbaar: <reason> row; the PRs of a readable queue are still drawn after it, all folded because no builder is known.N te promoveren (now) counts the proposed deliverables on tracks with horizon now; with open uitkomsten after it. Below it one dim row backlog: N voorgesteld op next/later, only when N is above 0. A proposed deliverable on a track whose phase is done (an orphan), on a track without horizon, or on a track the track list does not know, is not counted and not shown. When the tracks or the deliverables cannot be read the row says te promoveren onbeschikbaar: <reason>: without the tracks a deliverable cannot be placed on a horizon.now horizon, plus every track with activity: a builder whose spec names it, an open PR that its pr_ref lists or that the dispatch spec of the PR names, or a deliverable of it with last_activity in the last 7 days. They are ordered by what is going on, in four tiers: a builder whose spec names the track (the track with the most builders first), then an open PR linked to the track, then a deliverable with last_activity in the last 7 days, then no activity (a now track shown only for its horizon). Within a tier by priority (P1, P2, P3, other), then by id.▓ for done, ░ for the rest, rounded down) and done/total: the total counts every deliverable of the track, done those whose derived_status is done.bezig: names the deliverables that are ready or in_progress, or that a running builder carries (the builder's dispatch id is the deliverable's id, the part of its deliverable_ref after the colon), and the builders on the track whose dispatch is no deliverable of it (by the builder's title line). A deliverable is named by its title up to the first : or the first 40 cells, whichever is shorter.volgt: names the proposed deliverables in the order vnx deliverable list gives them, as many as fit the width, then (+N) for the rest. The count is never cut: the first title is shortened (with …) to make room for it, and where not even one title fits only (+N) stays. The row is left out when there are none, and so is bezig:.geen leveringen gepland, dim, on its first row. That is a signal: the progress of that track is not measurable. When the deliverables themselves could not be read the second row says leveringen onbeschikbaar: <reason>.vnx deliverable list fails, a track whose only activity was a recent deliverable cannot be recognised. The section then starts with one row leveringen onleesbaar: <reason>, tracks met alleen recente leveringen ontbreken.stale and unmeasured counts of live_work, only when one of them is above 0. They appear nowhere else.Colour. A row is a list of segments and each segment has a tone. ok (the theme's success colour) is the filled part of a bar, a track whose deliverables are all done, ci groen and klaar voor merge. warn is what waits on the operator: the te promoveren (now) count, a proposed deliverable in volgt:, a seat still owed. error is ci rood and every onbeschikbaar. dim is the empty part of a bar, the backlog row, (+N), the count of tracks without horizon. Every row reads correctly without colour; the text carries the meaning and the tone adds to it. Width is measured on the text.
/voortgang plan opens the same pane as a plan: the tracks with horizon next, then those with later (each under its own word), each with its bar and its volgt: row, ordered by priority and id. A track whose phase is done is left out. The tracks without a horizon are one dim count, N tracks zonder horizon. /voortgang without an argument opens the now view again; any other argument is answered with the two forms and opens nothing.
Room. The whole pane is for this one project. Sections take rows in the order above. A section that does not fit whole shows the items that fit plus one row +N niet getoond, and never a +N row without at least one item above it. A section of which not even one item fits is not drawn. Every row is cut to the pane's width in display cells: a wide character takes two cells and a combining mark none; ▓ and ░ take one.
The pane draws with Box, Text and Button only, the elements every surface has, so it draws the same in the terminal, the desktop app, VS Code and the mobile app through Remote Control. The ververs button (hotkey r) refreshes.
After every refresh the mod pins one line under the prompt. It answers the question of the pane at a glance: how far is this project, and does something run.
project-a · track-a ▓▓▓▓▓░░░ 5/8 · track-b ▓▓░░░░░░ 2/8 · 1 bouwer
project-a · track-a: geen plan · track-b ▓▓▓▓▓▓░░ 6/8 · 2 bouwers
project-a · geen tracks in beweging
X/Y (done of all). A track without deliverables shows : geen plan, one whose deliverables could not be read shows : ?.….1 bouwer or N bouwers stands only when at least one builder runs (live_work.counts.live + live_work.counts.starting; stale and unmeasured entries are not builders). It is left out at 0 and when the live work could not be read.verouderd is added when the index or the state is older than 60 minutes. onleesbaar is added when a part could not be read.geen tracks in beweging. When the tracks could not be read it says tracks onbekend, never that nothing moves.te promoveren and uitkomsten are not on the line; they stay in the pane.Where no surface places the pane, as in claude --print "/voortgang", the command answers with the status line, then one line per track the pane would show:
project-a · track-a: geen plan · track-b ▓▓▓▓▓▓░░ 6/8 · 1 bouwer
P1 track-a · geen leveringen gepland
P1 track-b · 6 van 8 klaar
A read that failed is never shown as zero or as an empty list.
| Situation | What the pane shows |
|---|---|
No .vnx-project-id above the working directory | the message above, an empty status line |
| The file holds no usable project id | .vnx-project-id in <dir> bevat geen geldige project-id |
| The store directory is missing | <id> · geen store on the header, nothing else |
The id is not one directory name (letters, digits, ., _ and -, starting with a letter or digit) | ongeldige project-id, and nothing is read for it |
| A file is missing, unreadable or not JSON | that part: onbeschikbaar: <reason> |
A track or deliverable entry that vnx lists is not an object | that part: onbeschikbaar: vnx gaf geen object op plaats <n> |
The store has no runtime_coordination.db | tracks and deliverables: onbeschikbaar: runtime_coordination.db ontbreekt; vnx is not run |
| A refresh rejects | the status line and the first pane row: verversen mislukt: <reason>; the last good numbers stay below it |
A block says available is anything but true | that part: onbeschikbaar: <the block's own reason>, else available: <value> |
vnx exits non-zero, times out, cannot start or prints no JSON | tracks and deliverables: onbeschikbaar: vnx exit 1 (or vnx time-out, vnx startfout: ..., vnx gaf ongeldige JSON) |
| A field of a builder cannot be read | onbeschikbaar: <reason> for that field of that builder, and onleesbaar in the status line |
The age always shows (index 3 min). A stale index can say there are no builders while one runs, which is why it is marked.
The mod is part of a VNX release: a release copies packages/ into ~/.vnx-system/versions/<version>/, and ~/.vnx-system/current points at the installed one. Install it from there on each Claude account you use:
claude plugin marketplace add ~/.vnx-system/current/packages/vnx-mods
claude plugin install voortgang@vnx-mods
For a second account, the same with its config directory in front:
CLAUDE_CONFIG_DIR=~/.claude-salesminds claude plugin marketplace add ~/.vnx-system/current/packages/vnx-mods
CLAUDE_CONFIG_DIR=~/.claude-salesminds claude plugin install voortgang@vnx-mods
The marketplace is a folder, so an update of VNX that rewrites current reaches the mod after /reload-plugins.
For development from a checkout of this repository:
claude --plugin-dir packages/vnx-mods/voortgang
It needs the vnx binary (VNX 1.7.2 or newer) and the project stores under the data root.
Set them in /config, where each one is a row, or in settings under pluginConfigs.voortgang.options.
| Option | Default | What it does |
|---|---|---|
data_root | ~/.vnx-data | The directory that holds one store per project. A leading ~ is the HOME of the session. |
vnx_bin | ~/.vnx-system/current/bin/vnx | The vnx executable that lists tracks and deliverables. |
refresh_seconds | 120 | How often the status line and the pane refresh. 15 seconds at least. |
The option exclude of 0.2 is gone: there is no fleet to leave projects out of. When HOME is unknown and a path starts with ~, the mod reads no store and says HOME onbekend, ~ niet op te lossen.
The session's working directory comes from the start of the session. Then:
/, whether <dir>/.vnx-project-id exists; the first one found is read, and only its first line is kept.With the project p, the store S = <data_root>/<p>/state and the data directory D = <data_root>/<p>:
S exists.S/t0_index.json: timestamp, live_work (available, reason, counts, live[].dispatch_id, live[].age_seconds, open_prs[].number, open_prs[].dispatch_id) and open_outcomes (available, reason, total).S/t0_state.json: generated_at and pr_queue (available, reason, and per open PR number, ci_status, gates_passed, blocked_on).S/runtime_coordination.db exists. vnx creates that database when a store has none, so the two commands below are run only for a store that has it.vnx objective list --json --project-id p --state-dir S, run in S with a 10 second timeout: track_id, priority, horizon, phase, pr_ref.vnx deliverable list --json --project-id p --state-dir S, run the same way: track, derived_status, last_activity, and per deliverable its deliverable_ref and its title.d in live_work.live or live_work.starting whose id is one directory name: D/dispatches/pending/d/dispatch-spec.json, fields model, track_id and pr_id only.D/dispatches/pending/d/instruction.md: the first line that starts with # , without the # , control characters turned into spaces. The engine's read returns a whole file, so the mod drops everything but that line at once and keeps none of it. It looks in the first 4096 characters only: a file whose first # line lies beyond them, or is cut by them, shows onbeschikbaar: geen titelregel in de eerste 4096 tekens. A file without such a line shows onbeschikbaar: geen titelregel.live_work.open_prs whose dispatch id is one directory name: the track_id of D/dispatches/pending/d/dispatch-spec.json. A dispatch that has left pending/ has no spec; the PR then belongs to the tracks whose pr_ref lists it, and nothing is shown as missing.The age_seconds of each builder comes from its own live_work entry. stale and unmeasured entries are not builders: their spec and instruction are never read.
--state-dir is required: without it, run from a directory outside a VNX repository, objective list fails with sqlite3.OperationalError: unable to open database file.
instruction.md than its first # line.gh, no git, no shell, never build_t0_state.py or anything else that writes state.$.store. For a store without runtime_coordination.db it does not even run vnx, which would create that database. It keeps its model in the session's $.state and pins its status line, nothing more.gh call: when that fails, the pane shows the reason.pr_ref and from the spec of the PR's dispatch while it is in pending/. A PR whose dispatch has left pending/ and that no pr_ref lists links to no track.claude plugin test. The pane was also rendered through the test kit against a real store; it was not checked on a live screen.claude plugin validate packages/vnx-mods
claude plugin validate packages/vnx-mods/voortgang
claude plugin test packages/vnx-mods/voortgang
bash packages/vnx-mods/voortgang/tests/no-operator-names.sh
hooks/register.tsx: the hooks module (session.start, command.run, ui.render, the timer).hooks/collect.ts: the collector. Pure functions over parsed JSON, and one function that reads a project through a narrow Io that $ provides in a session and an object in memory provides in a test.hooks/view.ts: the pane sections and rows, cut to the pane's size.types/index.d.ts: the PluginState contract for the five $.state values (model, isRefreshing, failure, cwd, view).tests/: the engine-driven pane, command, timer and boundary tests, and the collector's tests. tests/no-operator-names.sh greps the package for the operator's own project names, because the engine's test runner cannot read files from disk. Fixtures and examples use project-a.Type-check it with the tsconfig.json from the header of the claude-code.d.ts the engine writes, kept outside this folder, with include naming that file and this folder's hooks, types and tests. Without a loaded mod, load the plugin-authoring skill (it names the types/claude-code.d.ts of the running build) and write a tsconfig.json outside this folder: strict and noUncheckedIndexedAccess on, noEmit, skipLibCheck, types: [], module: esnext, moduleResolution: bundler, lib and target es2023, jsx: react with jsxFactory: h and jsxFragmentFactory: Fragment, and include naming that file plus this folder's hooks, types and tests. Run npx --yes -p typescript@5.6.3 tsc -p tsconfig.json from the folder holding that file; exit 0 is the bar. The engine writes .claude-plugin/types/ when it hot-reloads the folder; that folder carries its own .gitignore and is never committed.
hooks/register.tsx 178 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Model, Tone } from '../types'
5
6import { collectModel, commandText, configOf, messageOf, statusLine } from './collect'
7import type { Config, Io } from './collect'
8import { paneRows } from './view'
9
10const PANE = 'voortgang'
11const BUTTON_COLUMNS = 13
12
13const model = atom({ plugin: 'voortgang', key: 'model' } as const, null)
14const isRefreshing = atom({ plugin: 'voortgang', key: 'isRefreshing' } as const, false)
15const failure = atom({ plugin: 'voortgang', key: 'failure' } as const, null)
16// The working directory of the session, learned at its start: the project is the one above it.
17const cwd = atom({ plugin: 'voortgang', key: 'cwd' } as const, null)
18// The view the pane draws: what is going on now, or the plan. `/voortgang plan` sets it, `/voortgang` sets it back.
19const paneView = atom({ plugin: 'voortgang', key: 'view' } as const, 'now')
20
21// Joins a refresh asked while one runs (the timer, the button, the command). Never drawn from:
22// a reload starts it over at null, which only means the next ask starts a fresh refresh.
23let running: Promise<Model> | null = null
24
25function ioOf($: EngineInterface): Io {
26 return {
27 list: async path => (await $.fs.list(path)).map(entry => entry.name),
28 exists: path => $.fs.exists(path),
29 read: path => $.fs.read(path),
30 run: (argv, init) => $.process.run(argv, init),
31 }
32}
33
34async function collectInto($: EngineInterface, config: Config): Promise<Model> {
35 await update($, isRefreshing, () => true)
36
37 try {
38 const home = await $.env.get('HOME')
39 const now = await $.clock.now()
40 const next = await collectModel(ioOf($), config, home, await read($, cwd), now)
41 await update($, model, () => next)
42 await update($, failure, () => null)
43 $.ui.status(statusLine(next))
44
45 return next
46 } finally {
47 await update($, isRefreshing, () => false)
48 running = null
49 }
50}
51
52function refresh($: EngineInterface, config: Config): Promise<Model> {
53 if (running === null) {
54 running = collectInto($, config)
55 }
56
57 return running
58}
59
60/** A refresh that rejected is shown in the status line and in the pane; the model of the last good refresh stays. */
61async function showFailure($: EngineInterface, error: unknown): Promise<void> {
62 const reason = messageOf(error)
63
64 await update($, failure, () => reason)
65 $.ui.status(`voortgang · verversen mislukt: ${reason}`)
66}
67
68/** A refresh nobody awaits: the timer, the button and the start of a session. */
69async function refreshShown($: EngineInterface, config: Config): Promise<void> {
70 try {
71 await refresh($, config)
72 } catch (error) {
73 await showFailure($, error)
74 }
75}
76
77const toneOf = (tone: Tone) => {
78 if (tone === 'head') {
79 return { bold: true }
80 }
81
82 if (tone === 'dim') {
83 return { dimColor: true }
84 }
85
86 if (tone === 'ok') {
87 return { color: 'success' }
88 }
89
90 if (tone === 'warn') {
91 return { color: 'warning' }
92 }
93
94 if (tone === 'error') {
95 return { color: 'error' }
96 }
97
98 return {}
99}
100
101export const register: Register = (on, options) => {
102 const config = configOf(options)
103
104 on('session.start', async ($, e, next) => {
105 await update($, cwd, () => e.cwd)
106 await $.command.register({
107 name: 'voortgang',
108 description: 'Toont de voortgang van het VNX-project van deze sessie; met het argument plan het plan van next en later',
109 })
110 void refreshShown($, config)
111 $.clock.every(config.refreshSeconds * 1000, () => {
112 void refreshShown($, config)
113 })
114
115 return next(e)
116 })
117
118 on('command.run', { command: 'voortgang' }, async ($, e) => {
119 const argument = e.args.trim()
120
121 if (argument !== '' && argument !== 'plan') {
122 return { text: `Voortgang: onbekend argument '${argument}'. Gebruik /voortgang of /voortgang plan.` }
123 }
124
125 await update($, paneView, () => (argument === 'plan' ? 'plan' : 'now'))
126
127 let latest: Model
128
129 try {
130 latest = await refresh($, config)
131 } catch (error) {
132 await showFailure($, error)
133
134 return { text: `Voortgang: verversen mislukt (${messageOf(error)}), het paneel is niet geopend.` }
135 }
136
137 const opened = await $.ui.open({ id: PANE, title: 'Voortgang' })
138
139 return { text: commandText(latest, opened.isPlaced ? null : opened.reason) }
140 })
141
142 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
143 const { Box, Button, Text } = $.ui.resolve(e)
144 const rows = e.props.scroll.bodyRows > 0 ? e.props.scroll.bodyRows : (e.viewport?.rows ?? 24)
145 const view = paneRows(await read($, model), {
146 columns: e.props.bodyColumns,
147 headerColumns: Math.max(1, e.props.bodyColumns - BUTTON_COLUMNS),
148 rows,
149 isRefreshing: await read($, isRefreshing),
150 failure: await read($, failure),
151 view: await read($, paneView),
152 })
153
154 return (
155 <Box flexDirection="column">
156 <Box flexDirection="row" justifyContent="space-between">
157 <Text bold {...toneOf(view.headerTone)}>{view.header}</Text>
158 <Button
159 key="ververs"
160 label="ververs"
161 hotkey="r"
162 onPress={() => {
163 void refreshShown($, config)
164 }}
165 />
166 </Box>
167 {view.lines.map(line => (
168 <Text>
169 {line.segments.filter(part => part.text !== '').map(part => (
170 <Text {...toneOf(part.tone)}>{part.text}</Text>
171 ))}
172 </Text>
173 ))}
174 </Box>
175 )
176 })
177}
178hooks/collect.ts 1052 lines1import type { PluginOptions, ProcessRunInit, ProcessRunResult } from 'claude-code'
2
3import type {
4 Builder,
5 DeliverableItem,
6 DeliverableView,
7 IndexView,
8 LiveWork,
9 Model,
10 Outcomes,
11 Part,
12 Pr,
13 PrDispatch,
14 ProjectModel,
15 Running,
16 StateView,
17 TrackDeliverables,
18 TrackInfo,
19 TrackView,
20} from '../types'
21
22import { fit } from './width'
23
24/** The four things the collector needs from the engine; `$` in a session, an object in memory in a test. */
25export type Io = {
26 /** The names of the entries of a directory. */
27 list: (path: string) => Promise<string[]>
28 exists: (path: string) => Promise<boolean>
29 read: (path: string) => Promise<string>
30 run: (argv: readonly string[], init: ProcessRunInit) => Promise<ProcessRunResult>
31}
32
33export type Config = { dataRoot: string; vnxBin: string; refreshSeconds: number }
34
35export const STALE_MINUTES = 60
36export const RUN_TIMEOUT_MS = 10_000
37export const MIN_REFRESH_SECONDS = 15
38export const INDEX_FILE = 't0_index.json'
39export const STATE_FILE = 't0_state.json'
40export const DB_FILE = 'runtime_coordination.db'
41export const SPEC_FILE = 'dispatch-spec.json'
42export const INSTRUCTION_FILE = 'instruction.md'
43export const PROJECT_ID_FILE = '.vnx-project-id'
44/** A deliverable counts as recent activity of its track when its `last_activity` is at most this old. */
45export const RECENT_DAYS = 7
46/** The cells of a progress bar. */
47export const BAR_CELLS = 8
48/** How much of `instruction.md` is searched for its first `# ` line, in characters. */
49export const INSTRUCTION_BUDGET = 4096
50
51const DEFAULT_DATA_ROOT = '~/.vnx-data'
52const DEFAULT_VNX_BIN = '~/.vnx-system/current/bin/vnx'
53const DEFAULT_REFRESH_SECONDS = 120
54const HOME_UNKNOWN = 'HOME onbekend, ~ niet op te lossen'
55const PROJECT_ID = /^[a-z][a-z0-9-]{1,31}$/
56const DISPATCH_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
57const MESSAGE_CHARS = 80
58
59type Json = Record<string, unknown>
60
61const ok = <T>(value: T): Part<T> => ({ isOk: true, value })
62const fail = <T>(reason: string): Part<T> => ({ isOk: false, reason })
63const andThen = <A, B>(part: Part<A>, next: (value: A) => Part<B>): Part<B> => (part.isOk ? next(part.value) : fail(part.reason))
64const isRecord = (value: unknown): value is Json => typeof value === 'object' && value !== null && !Array.isArray(value)
65const isCount = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value)
66const textOr = (value: unknown, fallback: string): string => (typeof value === 'string' && value !== '' ? value : fallback)
67const stringsOf = (value: unknown): string[] | null =>
68 Array.isArray(value) ? value.filter((one): one is string => typeof one === 'string') : null
69/** A dictionary whose keys come from data: no prototype, so `__proto__` or `constructor` is a key like any other. */
70const dictionaryOf = <T>(entries: Iterable<readonly [string, T]> = []): Record<string, T> => {
71 const dictionary = Object.create(null) as Record<string, T>
72
73 for (const [key, value] of entries) {
74 dictionary[key] = value
75 }
76
77 return dictionary
78}
79
80/** The reason a rejected `exists`, `read`, `list` or `run` is shown with. */
81const unavailableOf = (error: unknown): `onbeschikbaar: ${string}` => `onbeschikbaar: ${messageOf(error)}`
82
83export function messageOf(error: unknown): string {
84 const text = error instanceof Error ? error.message : String(error)
85 const line = text.split('\n')[0] ?? ''
86
87 return line.length > MESSAGE_CHARS ? `${line.slice(0, MESSAGE_CHARS - 1)}…` : line
88}
89
90export function configOf(options: PluginOptions): Config {
91 const pathOf = (value: unknown, fallback: string) => (typeof value === 'string' && value.trim() !== '' ? value.trim() : fallback)
92 const seconds = options.refresh_seconds
93
94 return {
95 dataRoot: pathOf(options.data_root, DEFAULT_DATA_ROOT),
96 vnxBin: pathOf(options.vnx_bin, DEFAULT_VNX_BIN),
97 refreshSeconds: isCount(seconds) ? Math.max(MIN_REFRESH_SECONDS, seconds) : DEFAULT_REFRESH_SECONDS,
98 }
99}
100
101/** The path with a leading `~` resolved from HOME; null when it needs HOME and there is none. */
102export function expandHome(path: string, home: string | undefined): string | null {
103 if (path !== '~' && !path.startsWith('~/')) {
104 return path
105 }
106
107 return home === undefined ? null : `${home.replace(/\/+$/, '')}${path.slice(1)}`
108}
109
110/** A project id names one directory under the data root: no separator, no `.` or `..`. */
111export function isProjectId(id: string): boolean {
112 return PROJECT_ID.test(id)
113}
114
115/** A dispatch id names one directory under `dispatches/pending`: no separator, no `.` or `..`. */
116export function isDispatchId(id: string): boolean {
117 return DISPATCH_ID.test(id)
118}
119
120export function storeOf(root: string, id: string): string {
121 return `${root.replace(/\/+$/, '')}/${id}/state`
122}
123
124export function objectiveArgv(bin: string, id: string, store: string): string[] {
125 return [bin, 'objective', 'list', '--json', '--project-id', id, '--state-dir', store]
126}
127
128export function deliverableArgv(bin: string, id: string, store: string): string[] {
129 return [bin, 'deliverable', 'list', '--json', '--project-id', id, '--state-dir', store]
130}
131
132export function parseJson(text: string): Part<unknown> {
133 try {
134 return ok(JSON.parse(text) as unknown)
135 } catch {
136 return fail('ongeldige JSON')
137 }
138}
139
140/** Whole minutes from an ISO timestamp to `now`; null when there is no readable timestamp. */
141export function ageMinutesOf(stamp: unknown, now: number): number | null {
142 if (typeof stamp !== 'string') {
143 return null
144 }
145
146 // build_t0_state writes microseconds; a date parser need not take more than milliseconds.
147 const at = Date.parse(stamp.replace(/(\.\d{3})\d+/, '$1'))
148
149 return Number.isNaN(at) ? null : Math.max(0, Math.floor((now - at) / 60_000))
150}
151
152/** An index of unknown age cannot show it is fresh, so it counts as stale. */
153export function isStale(ageMinutes: number | null): boolean {
154 return ageMinutes === null || ageMinutes > STALE_MINUTES
155}
156
157export function formatAge(ageMinutes: number | null): string {
158 if (ageMinutes === null) {
159 return 'leeftijd onbekend'
160 }
161
162 if (ageMinutes < 60) {
163 return `${ageMinutes} min`
164 }
165
166 if (ageMinutes < 48 * 60) {
167 return `${Math.floor(ageMinutes / 60)} u`
168 }
169
170 return `${Math.floor(ageMinutes / (24 * 60))} d`
171}
172
173/** A block of the index or the state that says `available: true`, or why not. */
174function blockOf(value: unknown, name: string): Part<Json> {
175 if (!isRecord(value)) {
176 return fail(`blok ${name} ontbreekt`)
177 }
178
179 if (value.available !== true) {
180 return fail(textOr(value.reason, `available: ${String(value.available)}`))
181 }
182
183 return ok(value)
184}
185
186/** The most entries the index producer keeps in `live` (`_INDEX_LIVE_WORK_LIVE_CAP` in `scripts/build_t0_state.py`). */
187export const INDEX_LIVE_CAP = 8
188
189/**
190 * The builders in the index. The producer writes `live` with the live and the starting entries together, capped,
191 * and no `starting` list; a `starting` list is read too when it is there. A list that is absent stands only for a
192 * count of zero.
193 */
194function runningOf(block: Json, counts: Json): Part<Running[]> {
195 const running: Running[] = []
196
197 for (const name of ['live', 'starting'] as const) {
198 const list = block[name]
199
200 if (list === undefined) {
201 if (name === 'live' && (counts.live as number) + (counts.starting as number) > 0) {
202 return fail('live ontbreekt')
203 }
204
205 continue
206 }
207
208 if (!Array.isArray(list)) {
209 return fail(`${name} ontbreekt`)
210 }
211
212 for (const entry of list) {
213 if (!isRecord(entry) || typeof entry.dispatch_id !== 'string' || entry.dispatch_id === '') {
214 return fail(`${name}: bouwer zonder dispatch_id`)
215 }
216
217 running.push({ dispatchId: entry.dispatch_id, ageSeconds: isCount(entry.age_seconds) ? entry.age_seconds : null })
218 }
219 }
220
221 return ok(running)
222}
223
224/**
225 * `live_work.open_prs`: which dispatch opened which PR. An entry without a number says nothing and is left out;
226 * one without a dispatch id (a branch that is not `dispatch/<id>`) still says the PR is open and links no track.
227 */
228function livePrsOf(value: unknown): PrDispatch[] {
229 if (!Array.isArray(value)) {
230 return []
231 }
232
233 return value.flatMap(one =>
234 isRecord(one) && isCount(one.number) ? [{ number: one.number, dispatchId: typeof one.dispatch_id === 'string' && one.dispatch_id !== '' ? one.dispatch_id : null }] : [],
235 )
236}
237
238function liveWorkOf(value: unknown): Part<LiveWork> {
239 const block = blockOf(value, 'live_work')
240
241 if (!block.isOk) {
242 return fail(block.reason)
243 }
244
245 const counts = block.value.counts
246
247 if (!isRecord(counts) || ![counts.live, counts.starting, counts.stale, counts.unmeasured].every(isCount)) {
248 return fail('counts onvolledig')
249 }
250
251 return ok({
252 live: counts.live as number,
253 starting: counts.starting as number,
254 stale: counts.stale as number,
255 unmeasured: counts.unmeasured as number,
256 openPrs: livePrsOf(block.value.open_prs),
257 running: runningOf(block.value, counts),
258 })
259}
260
261function outcomesOf(value: unknown): Part<Outcomes> {
262 const block = blockOf(value, 'open_outcomes')
263
264 if (!block.isOk) {
265 return fail(block.reason)
266 }
267
268 const { total } = block.value
269
270 if (!isCount(total)) {
271 return fail('total ontbreekt')
272 }
273
274 return ok({ total })
275}
276
277export function indexViewOf(json: unknown, now: number): Part<IndexView> {
278 if (!isRecord(json)) {
279 return fail('geen JSON-object')
280 }
281
282 return ok({
283 ageMinutes: ageMinutesOf(json.timestamp, now),
284 liveWork: liveWorkOf(json.live_work),
285 outcomes: outcomesOf(json.open_outcomes),
286 })
287}
288
289function prsOf(value: unknown): Part<Pr[]> {
290 const block = blockOf(value, 'pr_queue')
291
292 if (!block.isOk) {
293 return fail(block.reason)
294 }
295
296 const open = block.value.open_prs
297
298 if (!Array.isArray(open)) {
299 return fail('open_prs ontbreekt')
300 }
301
302 const records = open.filter(isRecord)
303
304 if (records.length !== open.length || !records.every(pr => isCount(pr.number))) {
305 return fail('PR zonder nummer')
306 }
307
308 return ok(
309 records.map(pr => ({
310 number: pr.number as number,
311 title: textOr(pr.title, ''),
312 ci: textOr(pr.ci_status, '?'),
313 merge: textOr(pr.merge_state, '?'),
314 passed: stringsOf(pr.gates_passed),
315 owed: stringsOf(pr.blocked_on),
316 })),
317 )
318}
319
320export function stateViewOf(json: unknown, now: number): Part<StateView> {
321 if (!isRecord(json)) {
322 return fail('geen JSON-object')
323 }
324
325 return ok({ ageMinutes: ageMinutesOf(json.generated_at, now), prs: prsOf(json.pr_queue) })
326}
327
328/** `pr_ref` of a track is one string of PR numbers (`#2139,#2142`); the numbers, in order. */
329export function prRefsOf(value: unknown): number[] {
330 return typeof value === 'string' ? [...value.matchAll(/\d+/g)].map(one => Number(one[0])) : []
331}
332
333export function trackViewOf(json: unknown): Part<TrackView> {
334 if (!Array.isArray(json)) {
335 return fail('vnx gaf geen lijst')
336 }
337
338 const malformed = json.findIndex(one => !isRecord(one))
339
340 if (malformed >= 0) {
341 return fail(`vnx gaf geen object op plaats ${malformed + 1}`)
342 }
343
344 const tracks: TrackInfo[] = (json as Json[]).map(track => ({
345 id: textOr(track.track_id, '?'),
346 priority: textOr(track.priority, '-'),
347 horizon: textOr(track.horizon, '-'),
348 phase: textOr(track.phase, '-'),
349 prRefs: prRefsOf(track.pr_ref),
350 }))
351
352 return ok({ tracks })
353}
354
355/** `pr:dlv-1db7c550e9dc` -> `dlv-1db7c550e9dc`: the dispatch id behind a `deliverable_ref`; the whole ref when it has no prefix. */
356export function deliverableIdOf(ref: unknown): string {
357 const text = typeof ref === 'string' ? ref : ''
358
359 return text.slice(text.indexOf(':') + 1)
360}
361
362/** A deliverable title on one line, control characters turned into spaces; empty when it has none. */
363export const cleanTitleOf = (value: unknown): string => (typeof value === 'string' ? value.replace(/[\u0000-\u001f\u007f]/g, ' ').trim() : '')
364
365export function deliverableViewOf(json: unknown, now: number): Part<DeliverableView> {
366 if (!Array.isArray(json)) {
367 return fail('vnx gaf geen lijst')
368 }
369
370 const view: DeliverableView = { byTrack: dictionaryOf() }
371 const malformed = json.findIndex(one => !isRecord(one))
372
373 if (malformed >= 0) {
374 return fail(`vnx gaf geen object op plaats ${malformed + 1}`)
375 }
376
377 for (const deliverable of json as Json[]) {
378 const track = textOr(deliverable.track, '?')
379 const status = textOr(deliverable.derived_status, 'onbekend')
380 const age = ageMinutesOf(deliverable.last_activity, now)
381 const counts: TrackDeliverables = view.byTrack[track] ?? { items: [], total: 0, done: 0, isRecent: false }
382
383 counts.items.push({ id: deliverableIdOf(deliverable.deliverable_ref), title: cleanTitleOf(deliverable.title), status })
384 counts.total += 1
385 counts.done += status === 'done' ? 1 : 0
386 counts.isRecent = counts.isRecent || (age !== null && age <= RECENT_DAYS * 24 * 60)
387 view.byTrack[track] = counts
388 }
389
390 return ok(view)
391}
392
393async function readText(io: Io, path: string, name: string): Promise<Part<string>> {
394 try {
395 if (!(await io.exists(path))) {
396 return fail(`${name} ontbreekt`)
397 }
398 } catch (error) {
399 return fail(unavailableOf(error))
400 }
401
402 try {
403 return ok(await io.read(path))
404 } catch (error) {
405 return fail(`${name} ${unavailableOf(error)}`)
406 }
407}
408
409const readJson = async (io: Io, path: string, name: string): Promise<Part<unknown>> => andThen(await readText(io, path, name), parseJson)
410
411/** Whether a file exists, or why that could not be told. */
412async function existsPart(io: Io, path: string): Promise<Part<boolean>> {
413 try {
414 return ok(await io.exists(path))
415 } catch (error) {
416 return fail(unavailableOf(error))
417 }
418}
419
420/**
421 * The first line that starts with `# ` in the first `INSTRUCTION_BUDGET` characters,
422 * without the `# `. The engine reads a file whole, so the rest of the text is dropped
423 * here and nothing of it is kept: no other line is looked at or shown.
424 */
425export function titleOf(text: string): Part<string> {
426 const isCut = text.length > INSTRUCTION_BUDGET
427 const lines = (isCut ? text.slice(0, INSTRUCTION_BUDGET) : text).split('\n')
428
429 if (isCut) {
430 // The last line ends at the budget, not at its own end: it may be cut in half.
431 lines.pop()
432 }
433
434 for (const line of lines) {
435 if (line.startsWith('# ')) {
436 const title = line.slice(2).replace(/[\u0000-\u001f\u007f]/g, ' ').trim()
437
438 return title === '' ? fail('lege titelregel') : ok(title)
439 }
440 }
441
442 return fail(isCut ? `geen titelregel in de eerste ${INSTRUCTION_BUDGET} tekens` : 'geen titelregel')
443}
444
445/** A spec field that is a non-empty string, or null when the spec sets none. */
446const optionalTextOf = (value: unknown): string | null => (typeof value === 'string' && value.trim() !== '' ? value.trim() : null)
447
448/** `model`, `track_id` and `pr_id` of a dispatch spec, each read on its own; null when the spec sets none (`DispatchSpec.model` is optional). */
449export function whoOf(json: unknown): Part<{ model: string | null; trackId: string | null; prId: string | null }> {
450 if (!isRecord(json)) {
451 return fail('spec is geen JSON-object')
452 }
453
454 const { model, track_id: trackId, pr_id: prId } = json
455
456 return ok({ model: optionalTextOf(model), trackId: optionalTextOf(trackId), prId: isCount(prId) ? String(prId) : optionalTextOf(prId) })
457}
458
459/** The minutes of an `age_seconds`, whole. */
460export function minutesOf(ageSeconds: number | null): Part<number> {
461 return ageSeconds === null ? fail('age_seconds ontbreekt') : ok(Math.floor(Math.max(0, ageSeconds) / 60))
462}
463
464/** One builder: its spec, the first title line of its instruction, and its age; each part on its own. */
465async function builderOf(io: Io, dir: string, entry: Running): Promise<Builder> {
466 const minutes = minutesOf(entry.ageSeconds)
467
468 if (!isDispatchId(entry.dispatchId)) {
469 return { dispatchId: entry.dispatchId, who: fail('ongeldige dispatch-id'), task: fail('ongeldige dispatch-id'), minutes }
470 }
471
472 const base = `${dir}/${entry.dispatchId}`
473 const who = andThen(await readJson(io, `${base}/${SPEC_FILE}`, SPEC_FILE), whoOf)
474 const task = andThen(await readText(io, `${base}/${INSTRUCTION_FILE}`, INSTRUCTION_FILE), titleOf)
475
476 return { dispatchId: entry.dispatchId, who, task, minutes }
477}
478
479const dispatchesOf = (root: string, id: string) => `${root.replace(/\/+$/, '')}/${id}/dispatches/pending`
480
481/** The builders of a project, in the order of the index: `live` first, then `starting`. */
482async function buildersOf(io: Io, root: string, id: string, index: Part<IndexView>): Promise<Part<Builder[]>> {
483 if (!index.isOk) {
484 return fail(index.reason)
485 }
486
487 const { liveWork } = index.value
488
489 if (!liveWork.isOk) {
490 return fail(liveWork.reason)
491 }
492
493 const { running } = liveWork.value
494
495 if (!running.isOk) {
496 return fail(running.reason)
497 }
498
499 const dir = dispatchesOf(root, id)
500
501 return ok(await Promise.all(running.value.map(entry => builderOf(io, dir, entry))))
502}
503
504/**
505 * The track that the dispatch of each open PR names in its spec. A spec that is gone (the dispatch left `pending/`)
506 * or has no `track_id` links nothing (the model is not needed to link it): the PR still shows under the tracks whose `pr_ref` lists it.
507 */
508async function prTracksOf(io: Io, root: string, id: string, index: Part<IndexView>): Promise<Record<number, string>> {
509 if (!index.isOk || !index.value.liveWork.isOk) {
510 return {}
511 }
512
513 const dir = dispatchesOf(root, id)
514 const linked: Record<number, string> = {}
515
516 await Promise.all(
517 index.value.liveWork.value.openPrs.map(async ({ number, dispatchId }) => {
518 if (dispatchId === null || !isDispatchId(dispatchId)) {
519 return
520 }
521
522 const who = andThen(await readJson(io, `${dir}/${dispatchId}/${SPEC_FILE}`, SPEC_FILE), whoOf)
523
524 if (who.isOk && who.value.trackId !== null) {
525 linked[number] = who.value.trackId
526 }
527 }),
528 )
529
530 return linked
531}
532
533/** Builders the counts name that the index lists no entry for: its list is capped, the counts are not. */
534function omittedOf(index: Part<IndexView>, builders: Part<Builder[]>): number {
535 if (!index.isOk || !index.value.liveWork.isOk || !builders.isOk) {
536 return 0
537 }
538
539 const { live, starting } = index.value.liveWork.value
540
541 return Math.max(0, live + starting - builders.value.length)
542}
543
544async function runJson(io: Io, argv: string[], store: string): Promise<Part<unknown>> {
545 let result: ProcessRunResult
546
547 try {
548 result = await io.run(argv, { cwd: store, timeoutMs: RUN_TIMEOUT_MS })
549 } catch (error) {
550 const message = messageOf(error)
551
552 return fail(/timed? ?-?out|ETIMEDOUT/i.test(message) ? 'vnx time-out' : `vnx startfout: ${message}`)
553 }
554
555 if (result.exitCode !== 0) {
556 return fail(`vnx exit ${result.exitCode}`)
557 }
558
559 const parsed = parseJson(result.stdout)
560
561 return parsed.isOk ? parsed : fail('vnx gaf ongeldige JSON')
562}
563
564/**
565 * The project: the store directory, its two files and the two vnx lists, each
566 * part read on its own so one that fails leaves the others standing.
567 */
568export async function collectProject(io: Io, id: string, root: string, bin: string, now: number): Promise<ProjectModel> {
569 if (!isProjectId(id)) {
570 return { id, hasStore: false, reason: 'ongeldige project-id' }
571 }
572
573 const store = storeOf(root, id)
574
575 try {
576 if (!(await io.exists(store))) {
577 return { id, hasStore: false, reason: 'geen store' }
578 }
579 } catch (error) {
580 return { id, hasStore: false, reason: unavailableOf(error) }
581 }
582
583 const index = andThen(await readJson(io, `${store}/${INDEX_FILE}`, INDEX_FILE), json => indexViewOf(json, now))
584 const state = andThen(await readJson(io, `${store}/${STATE_FILE}`, STATE_FILE), json => stateViewOf(json, now))
585 const builders = await buildersOf(io, root, id, index)
586 const buildersOmitted = omittedOf(index, builders)
587 const prTracks = await prTracksOf(io, root, id, index)
588
589 // `vnx objective list` and `vnx deliverable list` create the database when the store has none; the mod writes nothing.
590 const hasDb = await existsPart(io, `${store}/${DB_FILE}`)
591
592 if (!hasDb.isOk || !hasDb.value) {
593 const reason = hasDb.isOk ? `${DB_FILE} ontbreekt` : hasDb.reason
594
595 return { id, hasStore: true, index, state, tracks: fail(reason), deliverables: fail(reason), builders, buildersOmitted, prTracks }
596 }
597
598 const tracks = andThen(await runJson(io, objectiveArgv(bin, id, store), store), trackViewOf)
599 const deliverables = andThen(await runJson(io, deliverableArgv(bin, id, store), store), json => deliverableViewOf(json, now))
600
601 return { id, hasStore: true, index, state, tracks, deliverables, builders, buildersOmitted, prTracks }
602}
603
604/** The directories from `cwd` up to `/`, nearest first. */
605export function ancestorsOf(cwd: string): string[] {
606 const parts = cwd.split('/').filter(part => part !== '')
607 const dirs: string[] = []
608
609 for (let depth = parts.length; depth > 0; depth -= 1) {
610 dirs.push(`/${parts.slice(0, depth).join('/')}`)
611 }
612
613 return [...dirs, '/']
614}
615
616/**
617 * The project id of a session: walk up from `cwd` to the first directory that holds `.vnx-project-id`; its first
618 * line, with all whitespace removed, is the id (the rule `bin/vnx` follows). The nearest file decides, as there. Never another project.
619 */
620export async function resolveProject(io: Io, cwd: string): Promise<Part<string>> {
621 for (const dir of ancestorsOf(cwd)) {
622 const file = `${dir === '/' ? '' : dir}/${PROJECT_ID_FILE}`
623 const found = await existsPart(io, file)
624
625 if (!found.isOk) {
626 return fail(found.reason)
627 }
628
629 if (!found.value) {
630 continue
631 }
632
633 const text = await readText(io, file, PROJECT_ID_FILE)
634
635 if (!text.isOk) {
636 return fail(text.reason)
637 }
638
639 const id = (text.value.split('\n')[0] ?? '').replace(/\s+/g, '')
640
641 return isProjectId(id) ? ok(id) : fail(`geen geldig project-id in ${file}`)
642 }
643
644 return fail(`geen VNX-project in deze map (geen ${PROJECT_ID_FILE} boven ${cwd})`)
645}
646
647export async function collectModel(io: Io, config: Config, home: string | undefined, cwd: string | null, now: number): Promise<Model> {
648 if (cwd === null) {
649 return { collectedAt: now, noProject: 'geen VNX-project in deze map (werkmap onbekend)', project: null }
650 }
651
652 const id = await resolveProject(io, cwd)
653
654 if (!id.isOk) {
655 return { collectedAt: now, noProject: id.reason, project: null }
656 }
657
658 const root = expandHome(config.dataRoot, home)
659 const bin = expandHome(config.vnxBin, home)
660
661 if (root === null || bin === null) {
662 return { collectedAt: now, noProject: HOME_UNKNOWN, project: null }
663 }
664
665 return { collectedAt: now, noProject: null, project: await collectProject(io, id.value, root, bin, now) }
666}
667
668export function isUnreadable(project: ProjectModel): boolean {
669 if (!project.hasStore) {
670 return true
671 }
672
673 const { index, state, tracks, deliverables } = project
674
675 return (
676 !index.isOk ||
677 !index.value.liveWork.isOk ||
678 !index.value.outcomes.isOk ||
679 !state.isOk ||
680 !state.value.prs.isOk ||
681 !tracks.isOk ||
682 !deliverables.isOk ||
683 !project.builders.isOk ||
684 project.builders.value.some(builder => !builder.who.isOk || !builder.task.isOk || !builder.minutes.isOk)
685 )
686}
687
688export function isOutdated(project: ProjectModel): boolean {
689 if (!project.hasStore) {
690 return false
691 }
692
693 const { index, state } = project
694
695 return (index.isOk && isStale(index.value.ageMinutes)) || (state.isOk && isStale(state.value.ageMinutes))
696}
697
698/** The number of builders: the counts of the live work, not the capped list. Null when it could not be read. */
699export function builderCountOf(project: ProjectModel): number | null {
700 return project.hasStore && project.builders.isOk ? project.builders.value.length + project.buildersOmitted : null
701}
702
703/**
704 * What is known of the open PRs. `numbers` holds every PR a readable source lists: the PR queue of the state, and
705 * `live_work.open_prs` of the index. The index list is capped and is empty when `gh` fails, so it only ever proves a
706 * PR open. `isComplete` is true when the PR queue could be read: only then does a PR missing from `numbers` count as closed.
707 * `reason` says why the PR queue could not be read; empty when it could.
708 */
709export function openNumbersOf(project: ProjectModel): { numbers: Set<number>; isComplete: boolean; reason: string } {
710 const numbers = new Set<number>()
711
712 if (!project.hasStore) {
713 return { numbers, isComplete: false, reason: project.reason }
714 }
715
716 const { index, state } = project
717
718 if (index.isOk && index.value.liveWork.isOk) {
719 index.value.liveWork.value.openPrs.forEach(pr => numbers.add(pr.number))
720 }
721
722 if (!state.isOk) {
723 return { numbers, isComplete: false, reason: state.reason }
724 }
725
726 if (!state.value.prs.isOk) {
727 return { numbers, isComplete: false, reason: state.value.prs.reason }
728 }
729
730 state.value.prs.value.forEach(pr => numbers.add(pr.number))
731
732 return { numbers, isComplete: true, reason: '' }
733}
734
735/** One track the pane shows: the track, its measured progress, and what is going on around it. */
736export type ShownTrack = {
737 track: TrackInfo
738 /** Null when the track has no deliverables, or when the deliverables could not be read (`unreadable`). */
739 progress: TrackDeliverables | null
740 /** Why the deliverables could not be read; null when they could. */
741 unreadable: string | null
742 builders: number
743 /** The PRs of `pr_ref`: `isOpen` when a source lists them, false when the PR queue was read and does not, else null. Used for the order and for showing the track, never drawn. */
744 prs: { number: number; isOpen: boolean | null }[]
745 hasActivity: boolean
746 /** Short titles of what is being worked on: `ready` and `in_progress` deliverables, deliverables with a live dispatch, builders of the track. */
747 busy: string[]
748 /** Short titles of the `proposed` deliverables, in the order the list gives them. */
749 upcoming: string[]
750}
751
752/** The cells a short title keeps at most. */
753export const SHORT_TITLE_CELLS = 40
754
755/** The title up to the first `:` or the first 40 cells, whichever is shorter. */
756export function shortTitleOf(title: string): string {
757 const colon = title.indexOf(':')
758 const head = (colon > 0 ? title.slice(0, colon) : title).trim()
759
760 return fit(head, SHORT_TITLE_CELLS)
761}
762
763const PRIORITY_ORDER = ['P1', 'P2', 'P3']
764
765const rankOf = (priority: string): number => {
766 const at = PRIORITY_ORDER.indexOf(priority)
767
768 return at < 0 ? PRIORITY_ORDER.length : at
769}
770
771const byPriorityThenId = (a: TrackInfo, b: TrackInfo): number =>
772 rankOf(a.priority) !== rankOf(b.priority) ? rankOf(a.priority) - rankOf(b.priority) : a.id < b.id ? -1 : a.id > b.id ? 1 : 0
773
774/** How alive a track is: 0 a builder works on it, 1 an open PR is linked, 2 a deliverable moved in the last 7 days, 3 nothing. */
775const tierOf = (one: ShownTrack): number => (one.builders > 0 ? 0 : one.prs.some(pr => pr.isOpen === true) ? 1 : one.progress?.isRecent ? 2 : 3)
776
777const BUSY_STATUSES = ['ready', 'in_progress']
778
779const isBusyItem = (item: DeliverableItem, running: ReadonlyMap<string, Builder>): boolean => BUSY_STATUSES.includes(item.status) || (item.status !== 'done' && running.has(item.id))
780
781/** The ids of the deliverables that `bezig:` names, on every track the pane shows: `ready`, `in_progress`, or carried by a running builder. */
782export function busyDeliverableIdsOf(project: ProjectModel): Set<string> {
783 const ids = new Set<string>()
784
785 if (!project.hasStore || !project.deliverables.isOk) {
786 return ids
787 }
788
789 const running = new Map<string, Builder>((project.builders.isOk ? project.builders.value : []).map(builder => [builder.dispatchId, builder]))
790
791 for (const one of shownTracksOf(project) ?? []) {
792 one.progress?.items.filter(item => isBusyItem(item, running)).forEach(item => ids.add(item.id))
793 }
794
795 return ids
796}
797
798/** What is being worked on in a track, and what is proposed next, as short titles. */
799function workOf(project: Extract<ProjectModel, { hasStore: true }>, track: TrackInfo, progress: TrackDeliverables | null): { busy: string[]; upcoming: string[] } {
800 const running = new Map<string, Builder>()
801 const trackBuilders: Builder[] = []
802
803 if (project.builders.isOk) {
804 for (const builder of project.builders.value) {
805 running.set(builder.dispatchId, builder)
806
807 if (builder.who.isOk && builder.who.value.trackId === track.id) {
808 trackBuilders.push(builder)
809 }
810 }
811 }
812
813 const items = progress?.items ?? []
814 const titleOf = (item: DeliverableItem) => shortTitleOf(item.title === '' ? cleanTitleOf(item.id) : item.title)
815 const isBusy = (item: DeliverableItem) => isBusyItem(item, running)
816 const busy = items.filter(isBusy).map(titleOf)
817 const covered = new Set(items.map(item => item.id))
818 const builderTitle = (builder: Builder) => shortTitleOf(builder.task.isOk ? builder.task.value : builder.dispatchId)
819
820 // A builder on the track whose dispatch is no deliverable of it still counts as work in progress.
821 busy.push(...trackBuilders.filter(builder => !covered.has(builder.dispatchId)).map(builderTitle))
822
823 return { busy, upcoming: items.filter(item => item.status === 'proposed' && !isBusy(item)).map(titleOf) }
824}
825
826function shownOf(project: Extract<ProjectModel, { hasStore: true }>, track: TrackInfo, open: ReturnType<typeof openNumbersOf>, builderTracks: string[]): ShownTrack {
827 const progress = project.deliverables.isOk ? (project.deliverables.value.byTrack[track.id] ?? null) : null
828 const builders = builderTracks.filter(id => id === track.id).length
829 // An open PR whose dispatch spec names the track belongs to it even when `pr_ref` does not list it yet.
830 const named = Object.entries(project.prTracks).flatMap(([number, id]) => (id === track.id && open.numbers.has(Number(number)) ? [Number(number)] : []))
831 const numbers = [...new Set([...track.prRefs, ...named])]
832 const prs = numbers.map(number => ({ number, isOpen: open.numbers.has(number) ? true : open.isComplete ? false : null }))
833 const hasActivity = builders > 0 || prs.some(pr => pr.isOpen === true) || (progress?.isRecent ?? false)
834
835 return { track, progress, unreadable: project.deliverables.isOk ? null : project.deliverables.reason, builders, prs, hasActivity, ...workOf(project, track, progress) }
836}
837
838const builderTracksOf = (project: Extract<ProjectModel, { hasStore: true }>): string[] =>
839 project.builders.isOk ? project.builders.value.flatMap(builder => (builder.who.isOk && builder.who.value.trackId !== null ? [builder.who.value.trackId] : [])) : []
840
841/**
842 * The tracks the pane shows: every `now` track, and every track with activity (a builder whose spec names it, an
843 * open PR that its `pr_ref` lists or whose dispatch spec names it, a deliverable with recent `last_activity`).
844 * Ordered by activity tier (a builder, an open PR, a recent deliverable, nothing), within the builder tier by builder
845 * count, then by priority, then by id. Null when the tracks could not be read.
846 */
847export function shownTracksOf(project: ProjectModel): ShownTrack[] | null {
848 if (!project.hasStore || !project.tracks.isOk) {
849 return null
850 }
851
852 const open = openNumbersOf(project)
853 const builderTracks = builderTracksOf(project)
854 const shown: ShownTrack[] = []
855
856 for (const track of project.tracks.value.tracks) {
857 const one = shownOf(project, track, open, builderTracks)
858 // A failed read never drops a track: while a PR of it is neither listed nor known closed, the track stays.
859 const isUnknown = one.prs.some(pr => pr.isOpen === null)
860
861 if (track.horizon === 'now' || one.hasActivity || isUnknown) {
862 shown.push(one)
863 }
864 }
865
866 return shown.sort((a, b) => {
867 if (tierOf(a) !== tierOf(b)) {
868 return tierOf(a) - tierOf(b)
869 }
870
871 // Within the builder tier the track with the most builders comes first.
872 if (a.builders !== b.builders) {
873 return b.builders - a.builders
874 }
875
876 return byPriorityThenId(a.track, b.track)
877 })
878}
879
880/** The plan: the tracks on `next` and on `later` whose phase is not `done`, by priority then id, and how many tracks have no horizon. Null when the tracks could not be read. */
881export function planOf(project: ProjectModel): { next: ShownTrack[]; later: ShownTrack[]; withoutHorizon: number } | null {
882 if (!project.hasStore || !project.tracks.isOk) {
883 return null
884 }
885
886 const open = openNumbersOf(project)
887 const builderTracks = builderTracksOf(project)
888 const all = project.tracks.value.tracks
889 const listOf = (horizon: string) =>
890 all
891 .filter(track => track.horizon === horizon && track.phase !== 'done')
892 .sort(byPriorityThenId)
893 .map(track => shownOf(project, track, open, builderTracks))
894
895 return { next: listOf('next'), later: listOf('later'), withoutHorizon: all.filter(track => track.phase !== 'done' && track.horizon !== 'now' && track.horizon !== 'next' && track.horizon !== 'later').length }
896}
897
898/**
899 * What waits on the operator's promote: the `proposed` deliverables on tracks with horizon `now`, and the backlog of
900 * those on `next` and `later`. A `proposed` deliverable on a track whose phase is `done` is an orphan and is not counted,
901 * nor is one on a track the list does not know. Null with the reason when the tracks or the deliverables could not be read.
902 */
903export function promotionOf(project: ProjectModel): Part<{ now: number; backlog: number }> {
904 if (!project.hasStore) {
905 return fail(project.reason)
906 }
907
908 if (!project.deliverables.isOk) {
909 return fail(project.deliverables.reason)
910 }
911
912 if (!project.tracks.isOk) {
913 return fail(project.tracks.reason)
914 }
915
916 let now = 0
917 let backlog = 0
918
919 for (const track of project.tracks.value.tracks) {
920 const proposed = (project.deliverables.value.byTrack[track.id]?.items ?? []).filter(item => item.status === 'proposed').length
921
922 if (track.phase === 'done') {
923 continue
924 }
925
926 if (track.horizon === 'now') {
927 now += proposed
928 } else if (track.horizon === 'next' || track.horizon === 'later') {
929 backlog += proposed
930 }
931 }
932
933 return ok({ now, backlog })
934}
935
936/** `X van Y klaar` for a track with deliverables, `geen leveringen gepland` for one without. */
937export function progressText(one: ShownTrack): string {
938 if (one.unreadable !== null) {
939 return `leveringen onbeschikbaar: ${one.unreadable}`
940 }
941
942 return one.progress === null ? 'geen leveringen gepland' : `${one.progress.done} van ${one.progress.total} klaar`
943}
944
945/** The filled and the empty cells of the bar: the filled count rounds down. */
946export function barCellsOf(progress: TrackDeliverables): { filled: number; empty: number } {
947 const filled = Math.floor((BAR_CELLS * progress.done) / progress.total)
948
949 return { filled, empty: BAR_CELLS - filled }
950}
951
952/** The bar of `BAR_CELLS` cells: `▓` for done, `░` for the rest, rounded down. */
953export function barOf(progress: TrackDeliverables): string {
954 const { filled, empty } = barCellsOf(progress)
955
956 return '▓'.repeat(filled) + '░'.repeat(empty)
957}
958
959/** The tracks the status line names, at most. */
960export const STATUS_TRACKS = 2
961/** The longest track id the status line shows, in display cells. */
962export const STATUS_TRACK_CELLS = 28
963
964/** One track in the status line: its bar and `X/Y`, `: geen plan` without deliverables, `: ?` when they could not be read. */
965function statusTrackOf(one: ShownTrack): string {
966 const id = fit(one.track.id, STATUS_TRACK_CELLS)
967
968 if (one.unreadable !== null) {
969 return `${id}: ?`
970 }
971
972 return one.progress === null ? `${id}: geen plan` : `${id} ${barOf(one.progress)} ${one.progress.done}/${one.progress.total}`
973}
974
975/**
976 * The one line under the prompt: how far the first two tracks of the pane are, and whether builders run. Empty when
977 * the session is in no VNX project.
978 */
979export function statusLine(model: Model): string {
980 const { project } = model
981
982 if (project === null) {
983 return ''
984 }
985
986 const tracks = shownTracksOf(project)
987 const builders = builderCountOf(project)
988 const parts: string[] = [project.id]
989
990 if (tracks === null) {
991 parts.push('tracks onbekend')
992 } else if (tracks.length === 0) {
993 parts.push('geen tracks in beweging')
994 } else {
995 parts.push(...tracks.slice(0, STATUS_TRACKS).map(statusTrackOf))
996 }
997
998 if (builders !== null && builders > 0) {
999 parts.push(`${builders} ${builders === 1 ? 'bouwer' : 'bouwers'}`)
1000 }
1001
1002 if (isOutdated(project)) {
1003 parts.push('verouderd')
1004 }
1005
1006 if (isUnreadable(project)) {
1007 parts.push('onleesbaar')
1008 }
1009
1010 return parts.join(' · ')
1011}
1012
1013/**
1014 * The note under `Tracks` when `vnx deliverable list` failed: a track whose only activity is a recent deliverable
1015 * cannot be recognised then. Null when the deliverables could be read.
1016 */
1017export function deliverablesNotice(project: ProjectModel): string | null {
1018 return project.hasStore && !project.deliverables.isOk ? `leveringen onleesbaar: ${project.deliverables.reason}, tracks met alleen recente leveringen ontbreken` : null
1019}
1020
1021/** The summary of `/voortgang` where no surface draws the pane: the status line, then one line per shown track. */
1022export function summaryLines(model: Model): string[] {
1023 if (model.project === null) {
1024 return [model.noProject ?? '']
1025 }
1026
1027 const tracks = shownTracksOf(model.project)
1028
1029 if (tracks === null) {
1030 const reason = model.project.hasStore && !model.project.tracks.isOk ? model.project.tracks.reason : model.project.hasStore ? '' : model.project.reason
1031
1032 return [statusLine(model), `tracks onbeschikbaar: ${reason}`]
1033 }
1034
1035 const notice = deliverablesNotice(model.project)
1036
1037 return [statusLine(model), ...(notice === null ? [] : [notice]), ...tracks.map(one => `${one.track.priority} ${one.track.id} · ${progressText(one)}`)]
1038}
1039
1040/**
1041 * What `/voortgang` answers: one short line when the pane is placed; where it
1042 * is not (no surface draws it, as in a headless run) the summary lines, so the
1043 * person still sees the progress.
1044 */
1045export function commandText(model: Model, unplacedReason: string | null): string {
1046 if (unplacedReason === null) {
1047 return model.project === null ? `Voortgang: ${model.noProject ?? ''}` : `Voortgang geopend: ${statusLine(model)}`
1048 }
1049
1050 return summaryLines(model).join('\n')
1051}
1052hooks/view.ts 415 lines1import type { Builder, Model, PaneView, Pr, ProjectModel, Row, Segment, Tone } from '../types'
2
3import { barCellsOf, busyDeliverableIdsOf, cleanTitleOf, deliverablesNotice, formatAge, INDEX_LIVE_CAP, isOutdated, planOf, promotionOf, shownTracksOf } from './collect'
4import type { ShownTrack } from './collect'
5import { cellsOf, fit } from './width'
6
7export { cellsOf, fit }
8
9export type PaneSize = { columns: number; headerColumns: number; rows: number; isRefreshing: boolean; failure?: string | null; view?: PaneView }
10
11type Stored = Extract<ProjectModel, { hasStore: true }>
12
13/** The column under which the second row of every track starts: after ` P1 `, whatever the priority is. */
14const TRACK_INDENT = 5
15/** The columns a title keeps at least, before it is cut for the track and the PR behind it. */
16const MIN_TITLE_COLUMNS = 8
17
18const seg = (text: string, tone: Tone = 'normal'): Segment => ({ text, tone })
19const rowOf = (...segments: Segment[]): Row => ({ segments })
20const row = (text: string, tone: Tone = 'normal'): Row => rowOf(seg(text, tone))
21
22/** The text of a row, without its tones. */
23export const textOf = (line: Row): string => line.segments.map(one => one.text).join('')
24
25/** The row cut to `columns` display cells with an ellipsis; width is measured on the text, whatever the tones. */
26export function fitRow(line: Row, columns: number): Row {
27 const full = textOf(line)
28 const fitted = fit(full, columns)
29
30 if (fitted === full) {
31 return line
32 }
33
34 let left = [...fitted].length - 1
35
36 if (left <= 0) {
37 return rowOf(seg('…', line.segments[0]?.tone))
38 }
39
40 const segments: Segment[] = []
41
42 for (const one of line.segments) {
43 const chars = [...one.text]
44
45 if (chars.length >= left) {
46 segments.push(seg(`${chars.slice(0, left).join('')}…`, one.tone))
47 break
48 }
49
50 segments.push(one)
51 left -= chars.length
52 }
53
54 return rowOf(...segments)
55}
56
57const seatsOf = (seats: string[] | null) => (seats === null ? 'onbekend' : seats.length === 0 ? 'geen' : seats.join(', '))
58const pluralOf = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`
59
60/** A section of the pane: a title row, then items of one or more rows each. */
61type Section = {
62 title: string
63 /** What the title row says after a colon when the section has no item. */
64 empty: string
65 items: Row[][]
66 /** Items the data names that could not be drawn at all (the index lists at most `INDEX_LIVE_CAP` builders). */
67 omitted: number
68}
69
70const sectionOf = (title: string, empty: string, items: Row[][], omitted = 0): Section => ({ title, empty, items, omitted })
71const failedSection = (title: string, reason: string): Section => sectionOf(title, '', [[row(` onbeschikbaar: ${reason}`, 'error')]])
72
73/** `PR #2150` from the `pr_id` of a spec, which is a number or a string such as `#2150`. */
74const prLabelOf = (prId: string): string => (/^#?\d+$/.test(prId) ? `PR #${prId.replace('#', '')}` : `PR ${prId}`)
75
76function builderRow(builder: Builder, columns: number): Row {
77 const failures = [builder.who, builder.task, builder.minutes].some(part => !part.isOk)
78 const unavailableOf = (reason: string) => `onbeschikbaar: ${reason}`
79 const model = builder.who.isOk ? (builder.who.value.model ?? 'model ?') : unavailableOf(builder.who.reason)
80 const minutes = builder.minutes.isOk ? `${builder.minutes.value} min` : unavailableOf(builder.minutes.reason)
81 const links = builder.who.isOk
82 ? [builder.who.value.trackId, builder.who.value.prId === null ? null : prLabelOf(builder.who.value.prId)].filter((one): one is string => one !== null)
83 : []
84 const before = ` ${model} · ${minutes} · `
85 const after = links.length === 0 ? '' : ` · ${links.join(' · ')}`
86 const task = builder.task.isOk ? builder.task.value : unavailableOf(builder.task.reason)
87 // The title gives way to the track and the PR behind it: those say what the builder belongs to.
88 const room = columns - cellsOf(before) - cellsOf(after)
89 const title = room >= MIN_TITLE_COLUMNS ? fit(task, room) : task
90
91 return row(`${before}${title}${after}`, failures ? 'error' : 'normal')
92}
93
94/** An open PR: its number, what the PR queue knows of it (null when only the index lists it), and the dispatch behind it. */
95type OpenPr = { number: number; detail: Pr | null; dispatchId: string | null }
96
97/** Every open PR a source lists: the PR queue, then the ones only the index lists. */
98function openPrsToShow(project: Stored): { prs: OpenPr[]; reason: string | null } {
99 const { index, state } = project
100 const fromIndex = index.isOk && index.value.liveWork.isOk ? index.value.liveWork.value.openPrs : []
101 const dispatchOf = (number: number) => fromIndex.find(pr => pr.number === number)?.dispatchId ?? null
102 const queue = state.isOk && state.value.prs.isOk ? state.value.prs.value : []
103 const reason = !state.isOk ? state.reason : !state.value.prs.isOk ? state.value.prs.reason : null
104 const prs: OpenPr[] = queue.map(detail => ({ number: detail.number, detail, dispatchId: dispatchOf(detail.number) }))
105
106 for (const pr of fromIndex) {
107 if (!prs.some(one => one.number === pr.number)) {
108 prs.push({ number: pr.number, detail: null, dispatchId: pr.dispatchId })
109 }
110 }
111
112 return { prs, reason }
113}
114
115const ciTone = (ci: string): Tone => (ci === 'pass' ? 'ok' : ci === 'fail' ? 'error' : 'normal')
116const ciOf = (ci: string): string => (ci === 'pass' ? 'groen' : ci === 'fail' ? 'rood' : ci === 'pending' ? 'loopt' : ci)
117
118/** `PR #N · ci loopt|rood|groen · stoel open: <seats>`, or `PR #N · klaar voor merge` when nothing is owed and CI is green. */
119function prRow(pr: OpenPr, indent: string, reason: string | null): Row {
120 const label = seg(`${indent}PR #${pr.number}`)
121 const dot = seg(' · ', 'dim')
122
123 if (pr.detail === null) {
124 return reason === null ? rowOf(label, dot, seg('niet in de PR-wachtrij', 'warn')) : rowOf(label, dot, seg(`onbeschikbaar: ${reason}`, 'error'))
125 }
126
127 const { ci, owed } = pr.detail
128
129 if (owed !== null && owed.length === 0 && ci === 'pass') {
130 return rowOf(label, dot, seg('klaar voor merge', 'ok'))
131 }
132
133 const checks = [label, dot, seg(`ci ${ciOf(ci)}`, ciTone(ci))]
134
135 return owed !== null && owed.length === 0 ? rowOf(...checks) : rowOf(...checks, dot, seg(`stoel open: ${seatsOf(owed)}`, 'warn'))
136}
137
138const isReady = (pr: OpenPr): boolean => pr.detail !== null && pr.detail.owed !== null && pr.detail.owed.length === 0 && pr.detail.ci === 'pass'
139const isWaiting = (pr: OpenPr): boolean => pr.detail !== null && (pr.detail.owed === null || pr.detail.owed.length > 0)
140const isRed = (pr: OpenPr): boolean => pr.detail !== null && pr.detail.ci === 'fail'
141
142/** `N open PR's zonder bouwer · M klaar voor merge · K wacht op stoel · J ci rood`, each count left out at 0, then the reason when the PR queue cannot be read. */
143function foldedRow(prs: OpenPr[], reason: string | null): Row {
144 const counts: [number, string, Tone][] = [
145 [prs.filter(isReady).length, 'klaar voor merge', 'ok'],
146 [prs.filter(isWaiting).length, 'wacht op stoel', 'warn'],
147 [prs.filter(isRed).length, 'ci rood', 'error'],
148 ]
149 const dot = seg(' · ', 'dim')
150 const parts = counts.filter(([count]) => count > 0).flatMap(([count, text, tone]) => [dot, seg(`${count} ${text}`, tone)])
151
152 const unavailable = reason === null ? [] : [dot, seg(`onbeschikbaar: ${reason}`, 'error')]
153
154 return rowOf(seg(` ${prs.length} open PR's zonder bouwer`, 'dim'), ...parts, ...unavailable)
155}
156
157/** The builders at work, each with the PR it belongs to; then the open PRs that belong to a deliverable `bezig:` names. The rest fold into one row. */
158function nowSectionOf(project: Stored, columns: number): Section {
159 const { prs, reason } = openPrsToShow(project)
160 const owned = new Set<number>()
161 // A builder list that cannot be read is its own row; the PRs of a readable queue are still drawn, all folded as no builder is known.
162 const items: Row[][] = project.builders.isOk ? [] : [[row(` onbeschikbaar: ${project.builders.reason}`, 'error')]]
163 const builders = project.builders.isOk ? project.builders.value : []
164
165 items.push(...builders.map(builder => {
166 const prId = builder.who.isOk && builder.who.value.prId !== null ? (/\d+/.exec(builder.who.value.prId)?.[0] ?? null) : null
167 const mine = prs.filter(pr => (prId !== null && pr.number === Number(prId)) || pr.dispatchId === builder.dispatchId)
168
169 mine.forEach(pr => owned.add(pr.number))
170
171 return [builderRow(builder, columns), ...mine.map(pr => prRow(pr, ' ', reason))]
172 }))
173 const deliverables = project.deliverables.isOk ? Object.values(project.deliverables.value.byTrack).flatMap(one => one.items) : []
174
175 const busyIds = project.builders.isOk ? busyDeliverableIdsOf(project) : new Set<string>()
176 const folded: OpenPr[] = []
177
178 for (const pr of prs.filter(one => !owned.has(one.number))) {
179 const deliverable = pr.dispatchId === null || !busyIds.has(pr.dispatchId) ? undefined : deliverables.find(one => one.id === pr.dispatchId)
180
181 if (deliverable === undefined) {
182 folded.push(pr)
183 continue
184 }
185
186 const label = deliverable.title !== '' ? fit(deliverable.title, MIN_TITLE_COLUMNS * 4) : cleanTitleOf(deliverable.id)
187
188 items.push([row(` ${label}`), prRow(pr, ' ', reason)])
189 }
190
191 if (folded.length > 0) {
192 items.push([foldedRow(folded, reason)])
193 }
194
195 // With no PR known at all no row can carry the reason of an unreadable PR queue, so it gets its own.
196 if (reason !== null && prs.length === 0) {
197 items.push([row(` onbeschikbaar: ${reason}`, 'error')])
198 }
199
200 return sectionOf('Nu bezig', 'niets', items, project.buildersOmitted)
201}
202
203function waitSectionOf(project: Stored): Section {
204 const { index } = project
205 const outcomes = index.isOk ? index.value.outcomes : index
206 const promotion = promotionOf(project)
207 const parts: Segment[] = []
208
209 if (promotion.isOk) {
210 parts.push(seg(`${promotion.value.now} te promoveren (now)`, promotion.value.now > 0 ? 'warn' : 'normal'))
211 } else {
212 parts.push(seg(`te promoveren onbeschikbaar: ${promotion.reason}`, 'error'))
213 }
214
215 if (outcomes.isOk) {
216 parts.push(seg(`${pluralOf(outcomes.value.total, 'open uitkomst', 'open uitkomsten')}`))
217 } else {
218 parts.push(seg(`uitkomsten onbeschikbaar: ${outcomes.reason}`, 'error'))
219 }
220
221 const backlog = promotion.isOk ? promotion.value.backlog : 0
222 const isNothing = promotion.isOk && outcomes.isOk && promotion.value.now === 0 && outcomes.value.total === 0 && backlog === 0
223 const [first = seg(''), ...others] = parts
224 const main = rowOf(seg(' '), first, ...others.flatMap(other => [seg(' · '), other]))
225 const lines = [main, ...(backlog > 0 ? [row(` backlog: ${backlog} voorgesteld op next/later`, 'dim')] : [])]
226
227 return sectionOf('Wacht op een besluit', 'niets', isNothing ? [] : [lines])
228}
229
230/** The bar in two tones: the filled cells `ok`, the empty cells `dim`. */
231const barSegments = (one: NonNullable<ShownTrack['progress']>): Segment[] => {
232 const { filled, empty } = barCellsOf(one)
233
234 return [seg('▓'.repeat(filled), 'ok'), seg('░'.repeat(empty), 'dim')]
235}
236
237const BUSY_LABEL = 'bezig: '
238const NEXT_LABEL = 'volgt: '
239
240/** `volgt:` titles as many as fit the width, then `(+N)` for the rest. The count is never cut: the first title gives way to it. */
241function upcomingRow(titles: string[], columns: number): Row {
242 const prefix = ' '.repeat(TRACK_INDENT) + NEXT_LABEL
243 const room = columns - cellsOf(prefix)
244 const countOf = (rest: number) => (rest > 0 ? ` (+${rest})` : '')
245 let shown = titles.length
246
247 while (shown > 1 && cellsOf(titles.slice(0, shown).join(' · ')) + cellsOf(countOf(titles.length - shown)) > room) {
248 shown -= 1
249 }
250
251 const rest = titles.length - shown
252 const count = countOf(rest)
253 const shownTitles = titles.slice(0, shown)
254 const titleRoom = room - cellsOf(count)
255
256 if (shown === 1) {
257 const only = titleRoom >= 1 ? fit(titles[0] ?? '', titleRoom) : ''
258 const dim = only === '' ? count.trim() : count
259
260 return rowOf(seg(prefix), ...(only === '' ? [] : [seg(only, 'warn')]), ...(rest > 0 ? [seg(dim, 'dim')] : []))
261 }
262
263 const list = shownTitles.flatMap((title, at) => (at === 0 ? [seg(title, 'warn')] : [seg(' · ', 'dim'), seg(title, 'warn')]))
264
265 return rowOf(seg(prefix), ...list, ...(rest > 0 ? [seg(count, 'dim')] : []))
266}
267
268function trackItem(one: ShownTrack, columns: number, isNow: boolean): Row[] {
269 const head = ` ${one.track.priority} ${one.track.id}`
270 const indent = ' '.repeat(TRACK_INDENT)
271 const busy = isNow && one.busy.length > 0 ? [rowOf(seg(`${indent}${BUSY_LABEL}`), seg(one.busy.join(' · ')))] : []
272 const upcoming = one.upcoming.length > 0 ? [upcomingRow(one.upcoming, columns)] : []
273
274 if (one.unreadable !== null) {
275 return [row(head), row(`${indent}leveringen onbeschikbaar: ${one.unreadable}`, 'error'), ...busy]
276 }
277
278 if (one.progress === null) {
279 return [rowOf(seg(head), seg(' geen leveringen gepland', 'dim')), ...busy]
280 }
281
282 const isAllDone = one.progress.done === one.progress.total
283 const tone: Tone = isAllDone ? 'ok' : 'normal'
284
285 return [rowOf(seg(head, tone), seg(' '), ...barSegments(one.progress), seg(` ${one.progress.done}/${one.progress.total}`, tone)), ...busy, ...upcoming]
286}
287
288function tracksSectionOf(project: Stored, columns: number): Section {
289 const tracks = shownTracksOf(project)
290
291 if (tracks === null) {
292 return failedSection('Tracks', project.tracks.isOk ? '' : project.tracks.reason)
293 }
294
295 const notice = deliverablesNotice(project)
296
297 return sectionOf('Tracks', 'geen', [...(notice === null ? [] : [[row(` ${notice}`, 'error')]]), ...tracks.map(one => trackItem(one, columns, true))])
298}
299
300/** The plan sections: the tracks on `next`, the tracks on `later`, and one dim count of the tracks without a horizon. */
301function planSectionsOf(project: Stored, columns: number): { sections: Section[]; footer: Row | null } {
302 const plan = planOf(project)
303
304 if (plan === null) {
305 return { sections: [failedSection('Plan', project.tracks.isOk ? '' : project.tracks.reason)], footer: null }
306 }
307
308 const notice = deliverablesNotice(project)
309 const noticeItems = notice === null ? [] : [[row(` ${notice}`, 'error')]]
310 const footer = plan.withoutHorizon > 0 ? row(`${pluralOf(plan.withoutHorizon, 'track', 'tracks')} zonder horizon`, 'dim') : null
311
312 return {
313 sections: [
314 sectionOf('next', 'geen', [...noticeItems, ...plan.next.map(one => trackItem(one, columns, false))]),
315 sectionOf('later', 'geen', plan.later.map(one => trackItem(one, columns, false))),
316 ],
317 footer,
318 }
319}
320
321/** The rows of a section in `free` rows: whole, or as many whole items as fit plus one row for the rest. Nothing when not even one item fits. */
322function placeSection(section: Section, free: number): Row[] {
323 const { title, items } = section
324
325 if (items.length === 0) {
326 const text = section.omitted > 0 ? `${section.omitted} niet getoond (index noemt ze niet)` : section.empty
327
328 return free >= 1 ? [row(`${title}: ${text}`, 'head')] : []
329 }
330
331 const cost = (list: Row[][]) => list.reduce((sum, item) => sum + item.length, 0)
332 const rest = (hidden: number) => row(` +${hidden} niet getoond${hidden === section.omitted ? ` (index toont er max. ${INDEX_LIVE_CAP})` : ''}`, 'dim')
333
334 if (1 + cost(items) + (section.omitted > 0 ? 1 : 0) <= free) {
335 return [row(title, 'head'), ...items.flat(), ...(section.omitted > 0 ? [rest(section.omitted)] : [])]
336 }
337
338 let shown = items.length
339
340 while (shown > 0 && 1 + cost(items.slice(0, shown)) + 1 > free) {
341 shown -= 1
342 }
343
344 return shown === 0 ? [] : [row(title, 'head'), ...items.slice(0, shown).flat(), rest(items.length - shown + section.omitted)]
345}
346
347/** The one dim row at the bottom: what the live work counts as `stale` or `unmeasured`. Nothing when both are zero. */
348function cleanupRow(project: Stored): Row | null {
349 if (!project.index.isOk || !project.index.value.liveWork.isOk) {
350 return null
351 }
352
353 const { stale, unmeasured } = project.index.value.liveWork.value
354 const parts = [stale > 0 ? pluralOf(stale, 'stale rij', 'stale rijen') : null, unmeasured > 0 ? `${unmeasured} ongemeten` : null].filter((part): part is string => part !== null)
355
356 return parts.length === 0 ? null : row(`opruimen: ${parts.join(' · ')}`, 'dim')
357}
358
359/** The header of the pane: the project and the age of its index; `verouderd` once, when the index or the state is old. */
360function headerOf(project: ProjectModel): { text: string; tone: Tone } {
361 if (!project.hasStore) {
362 return { text: `${project.id} · ${project.reason}`, tone: 'error' }
363 }
364
365 const age = project.index.isOk ? formatAge(project.index.value.ageMinutes) : 'onbeschikbaar'
366 const isOld = isOutdated(project)
367
368 return { text: `${project.id} · index ${age}${isOld ? ' · verouderd' : ''}`, tone: isOld ? 'warn' : 'head' }
369}
370
371/**
372 * The sections in the order that takes the room. The now view: Nu bezig, Wacht op een besluit, Tracks, opruimen.
373 * The plan view: next, later, the count without horizon. Each section gets what is left of `budget` rows.
374 */
375function projectRows(project: Stored, budget: number, columns: number, view: PaneView): Row[] {
376 const plan = view === 'plan' ? planSectionsOf(project, columns) : null
377 const sections = plan === null ? [nowSectionOf(project, columns), waitSectionOf(project), tracksSectionOf(project, columns)] : plan.sections
378 const rows: Row[] = []
379
380 for (const section of sections) {
381 rows.push(...placeSection(section, budget - rows.length))
382 }
383
384 const last = plan === null ? cleanupRow(project) : plan.footer
385
386 return last !== null && budget - rows.length >= 1 ? [...rows, last] : rows
387}
388
389/**
390 * The pane, drawn from the model alone: the header (project and index age), then the sections of that one
391 * project, every row cut to the pane's width and every section cut to the rows that are left.
392 */
393export function paneRows(model: Model | null, size: PaneSize): { header: string; headerTone: Tone; lines: Row[] } {
394 const busy = size.isRefreshing ? ' · verversen…' : ''
395 const failed = size.failure === undefined || size.failure === null ? [] : [row(`verversen mislukt: ${size.failure}`, 'error')]
396 const cut = (lines: Row[]) => lines.map(line => fitRow(line, size.columns))
397
398 if (model === null) {
399 return { header: fit(`Voortgang${busy}`, size.headerColumns), headerTone: 'head', lines: cut([...failed, row('nog niet ververst', 'dim')]) }
400 }
401
402 const { project } = model
403
404 if (project === null) {
405 return { header: fit(`Voortgang${busy}`, size.headerColumns), headerTone: 'head', lines: cut([...failed, row(model.noProject ?? '', 'dim')]) }
406 }
407
408 const header = headerOf(project)
409 // The header row and the failure row come out of the pane's rows before the sections share what is left.
410 const budget = Math.max(0, size.rows - 1 - failed.length)
411 const lines = project.hasStore ? projectRows(project, budget, size.columns, size.view ?? 'now') : []
412
413 return { header: fit(`${header.text}${busy}${size.view === 'plan' ? ' · plan' : ''}`, size.headerColumns), headerTone: header.tone, lines: cut([...failed, ...lines]) }
414}
415hooks/width.ts 113 lines1// Display width of text in terminal cells; shared by the pane and the status line.
2
3/** Code point ranges a terminal draws two cells wide: East Asian wide and fullwidth forms, and emoji. */
4const WIDE: readonly (readonly [number, number])[] = [
5 [0x1100, 0x115f],
6 [0x231a, 0x231b],
7 [0x23e9, 0x23ec],
8 [0x25fd, 0x25fe],
9 [0x2614, 0x2615],
10 [0x2648, 0x2653],
11 [0x2693, 0x2693],
12 [0x26a1, 0x26a1],
13 [0x26bd, 0x26be],
14 [0x26c4, 0x26c5],
15 [0x26d4, 0x26d4],
16 [0x26ea, 0x26ea],
17 [0x26f2, 0x26f3],
18 [0x26f5, 0x26f5],
19 [0x26fa, 0x26fa],
20 [0x26fd, 0x26fd],
21 [0x2705, 0x2705],
22 [0x270a, 0x270b],
23 [0x2728, 0x2728],
24 [0x274c, 0x274c],
25 [0x2753, 0x2755],
26 [0x2795, 0x2797],
27 [0x2b1b, 0x2b1c],
28 [0x2b50, 0x2b50],
29 [0x2b55, 0x2b55],
30 [0x2e80, 0x303e],
31 [0x3041, 0x33ff],
32 [0x3400, 0x4dbf],
33 [0x4e00, 0x9fff],
34 [0xa000, 0xa4cf],
35 [0xac00, 0xd7a3],
36 [0xf900, 0xfaff],
37 [0xfe30, 0xfe4f],
38 [0xff00, 0xff60],
39 [0xffe0, 0xffe6],
40 [0x1f300, 0x1f64f],
41 [0x1f680, 0x1f6ff],
42 [0x1f900, 0x1faff],
43 [0x20000, 0x3fffd],
44]
45const MARK = /^\p{M}$/u
46const ZERO_WIDTH_JOINER = 0x200d
47const VARIATION_SELECTOR_EMOJI = 0xfe0f
48
49const isExtender = (char: string, code: number): boolean =>
50 MARK.test(char) || (code >= 0xfe00 && code <= 0xfe0f) || (code >= 0x1f3fb && code <= 0x1f3ff) || code === ZERO_WIDTH_JOINER
51
52function cellsOfCode(char: string, code: number): number {
53 if (code < 0x20 || (code >= 0x7f && code < 0xa0) || (code >= 0x200b && code <= 0x200f) || code === 0x2060 || code === 0xfeff || MARK.test(char)) {
54 return 0
55 }
56
57 return WIDE.some(([from, to]) => code >= from && code <= to) ? 2 : 1
58}
59
60type Cluster = { text: string; cells: number }
61
62/** The text as what a terminal draws in one place: a character with the marks, selectors and joined characters after it. */
63function clustersOf(text: string): Cluster[] {
64 const clusters: Cluster[] = []
65 let isJoined = false
66
67 for (const char of text) {
68 const code = char.codePointAt(0) ?? 0
69 const last = clusters[clusters.length - 1]
70
71 if (last !== undefined && (isJoined || isExtender(char, code))) {
72 last.text += char
73 last.cells = code === VARIATION_SELECTOR_EMOJI ? Math.max(last.cells, 2) : last.cells
74 isJoined = code === ZERO_WIDTH_JOINER
75 } else {
76 clusters.push({ text: char, cells: cellsOfCode(char, code) })
77 isJoined = false
78 }
79 }
80
81 return clusters
82}
83
84/** The width of the text in display cells: wide characters take two, combining marks none. */
85export function cellsOf(text: string): number {
86 return clustersOf(text).reduce((sum, cluster) => sum + cluster.cells, 0)
87}
88
89/** The text cut to `columns` display cells with an ellipsis, so no character is split and none sticks out of the pane. */
90export function fit(text: string, columns: number): string {
91 if (cellsOf(text) <= columns) {
92 return text
93 }
94
95 if (columns <= 1) {
96 return '…'
97 }
98
99 let cells = 0
100 let kept = ''
101
102 for (const cluster of clustersOf(text)) {
103 if (cells + cluster.cells > columns - 1) {
104 break
105 }
106
107 cells += cluster.cells
108 kept += cluster.text
109 }
110
111 return `${kept}…`
112}
113types/index.d.ts 153 lines1/**
2 * One part of a project's progress: read and parsed, or unavailable with the
3 * reason. An unavailable part is never shown as zero or as an empty list.
4 */
5export type Part<T> = { isOk: true; value: T } | { isOk: false; reason: string }
6
7/** One entry of `live_work.open_prs`: an open PR and the dispatch that opened it; null when the branch is not `dispatch/<id>`. */
8export type PrDispatch = { number: number; dispatchId: string | null }
9
10/** One entry of `live_work.live` or `live_work.starting`: a dispatch that runs, and for how long. */
11export type Running = {
12 dispatchId: string
13 /** `age_seconds`; null when the entry carries none. */
14 ageSeconds: number | null
15}
16
17/** `live_work` of `t0_index.json`: the counts, and the dispatch ids that run. */
18export type LiveWork = {
19 live: number
20 starting: number
21 stale: number
22 unmeasured: number
23 /** `live_work.open_prs`: which dispatch belongs to which open PR. Positive evidence only: the producer caps the list and keeps it empty when `gh` fails. */
24 openPrs: PrDispatch[]
25 /** The `live` and `starting` entries, the builders; `stale` and `unmeasured` entries are not builders. */
26 running: Part<Running[]>
27}
28
29/** One builder: a dispatch in `live` or `starting`, as three parts that each stand or fall on their own. */
30export type Builder = {
31 dispatchId: string
32 /** `model`, `track_id` and `pr_id` of the dispatch's `dispatch-spec.json`; each is null when the spec sets none. */
33 who: Part<{ model: string | null; trackId: string | null; prId: string | null }>
34 /** The first `# ` line of the dispatch's `instruction.md`. */
35 task: Part<string>
36 /** Whole minutes of the entry's `age_seconds`. */
37 minutes: Part<number>
38}
39
40/** `open_outcomes` of `t0_index.json`: what still waits on a decision. */
41export type Outcomes = {
42 total: number
43}
44
45/** A pane row: its text, already cut to the pane's width, and how it is drawn. */
46export type Tone = 'normal' | 'head' | 'dim' | 'ok' | 'warn' | 'error'
47
48/** A stretch of a pane row with its own tone. The text carries the meaning; the tone only adds to it. */
49export type Segment = { text: string; tone: Tone }
50
51/** A pane row: segments, already cut to the pane's width. */
52export type Row = { segments: Segment[] }
53
54/** Which view the pane draws: what is going on now, or the plan (the tracks on `next` and `later`). */
55export type PaneView = 'now' | 'plan'
56
57/** `t0_index.json`: its age, and its two blocks the mod shows. */
58export type IndexView = {
59 /** Minutes between the index's `timestamp` and the refresh; null when it has none. */
60 ageMinutes: number | null
61 liveWork: Part<LiveWork>
62 outcomes: Part<Outcomes>
63}
64
65/** One open PR of `pr_queue`. A list that was missing in the file is null, never empty. */
66export type Pr = {
67 number: number
68 title: string
69 ci: string
70 merge: string
71 /** `gates_passed`: the seats that passed. */
72 passed: string[] | null
73 /** `blocked_on`: the seats still owed. */
74 owed: string[] | null
75}
76
77/** `t0_state.json`: its age, and the PR queue. */
78export type StateView = {
79 /** Minutes between the state's `generated_at` and the refresh; null when it has none. */
80 ageMinutes: number | null
81 prs: Part<Pr[]>
82}
83
84/** One track of `vnx objective list`, on any horizon. */
85export type TrackInfo = {
86 id: string
87 /** `P1`, `P2`, `P3`, or `-` when the track has none or another scale. */
88 priority: string
89 horizon: string
90 /** `phase`; `done` marks a finished track whose leftovers are orphans. */
91 phase: string
92 /** The PR numbers of `pr_ref`, in the order it lists them. */
93 prRefs: number[]
94}
95
96/** `vnx objective list`: every track; the pane picks the ones to show. */
97export type TrackView = { tracks: TrackInfo[] }
98
99/** One deliverable of `vnx deliverable list`: the id behind its `deliverable_ref`, its title and its derived status. */
100export type DeliverableItem = { id: string; title: string; status: string }
101
102/** The deliverables of one track. */
103export type TrackDeliverables = {
104 /** In the order the list gives them. */
105 items: DeliverableItem[]
106 total: number
107 /** Those with `derived_status` `done`. */
108 done: number
109 /** Whether one has a `last_activity` in the last seven days. */
110 isRecent: boolean
111}
112
113/** `vnx deliverable list`: per track the deliverables, counted and listed. */
114export type DeliverableView = {
115 byTrack: Record<string, TrackDeliverables>
116}
117
118/**
119 * The session's own project, as the last refresh read it. Without a store nothing of it was
120 * read: its store directory is missing (`geen store`), the check for it rejected
121 * (`onbeschikbaar: <reason>`), or its id would name a path outside the data root (`ongeldige project-id`).
122 */
123export type ProjectModel =
124 | { id: string; hasStore: false; reason: 'geen store' | 'ongeldige project-id' | `onbeschikbaar: ${string}` }
125 | {
126 id: string
127 hasStore: true
128 index: Part<IndexView>
129 state: Part<StateView>
130 tracks: Part<TrackView>
131 deliverables: Part<DeliverableView>
132 builders: Part<Builder[]>
133 /** `counts.live + counts.starting` minus the entries the index lists: its list is capped, the counts are not. */
134 buildersOmitted: number
135 /** The `track_id` that the dispatch of an open PR names in its spec, per PR number; only the ones that could be read. */
136 prTracks: Record<number, string>
137 }
138
139/** What the pane and the status line draw from. */
140export type Model = {
141 /** Milliseconds since the epoch at which the refresh started. */
142 collectedAt: number
143 /** Why no project was read: no `.vnx-project-id` above the cwd, or a path that could not be resolved. Null when there is one. */
144 noProject: string | null
145 project: ProjectModel | null
146}
147
148declare module 'claude-code' {
149 interface PluginState {
150 voortgang: { model: Model | null; isRefreshing: boolean; failure: string | null; cwd: string | null; view: PaneView }
151 }
152}
153