A live board of your approved plan: which steps are done, running, blocked or waiting on you.

<img src="docs/cover.jpg" alt="plan-tracker, a Claude Code plugin: Your plan on one live board inside Claude Code: every task, who's on it, and what waits on you. Below, the board of a demo plan, Add CSV export, in progress, 3 of 8 tasks done." width="100%">
<sub>The board on the cover is a demo plan: adding CSV export to a report CLI, 3 of 8 tasks done.</sub>
<h3 align="center"> <a href="#get-started">Get started</a> | <a href="#what-you-get">What you get</a> | <a href="#how-it-works">How it works</a> | <a href="#compared">Compared</a> | <a href="#try-it">Try it</a> | <a href="#limits">Limits</a> | <a href="#reference">Reference</a> </h3>
Claude Code mod that puts your plan on one live board: every task, who's on it, and what waits on you.
Install plan-tracker and plan-orchestrator, the separate plugin that writes your plan's tasks to PROGRESS.md:
/plugin marketplace add lijingwu2026/claude-code-plan-orchestrator
/plugin install plan-orchestrator@plan-orchestrator
/plugin marketplace add lijingwu2026/claude-code-plan-tracker
/plugin install plan-tracker@plan-tracker
| To | Do |
|---|---|
| See your plan | Approve a plan in plan mode. The band appears above your prompt, in every session of that project. |
| Open the board | Press the bar, or type /plan-tracker. |
| Pick a plan | /plan-tracker lists your open plans, newest first, with a search field. Typing also finds done and canceled plans. This needs a board command that lists several plans. A plain PROGRESS.md has one plan, so it opens its board. |
Use your own PROGRESS.md | Copy the table under Try it. plan-orchestrator is not needed. |
<img src="docs/band-window-2x.png" alt="A Claude Code terminal window: one question about the plan and Claude's answer, then the plan band, Add CSV export, In progress, a bar, 3/8, and the prompt below it." width="100%">
![]()
<img src="docs/band-done-2x.png" alt="The same band when the plan is finished: Add CSV export, Done, a rainbow bar, 8/8." width="100%"> <sub>Once the last open task goes Done, the bar turns to a rainbow until your next prompt.</sub>
In progress, Idle or Done), done out of total (3/8) and a bar.✅ <task> · N left and, in the terminal, a chime.<img src="docs/board-2x.png" alt="The board: Add CSV export, In progress 3/8, the recap and four lanes. Todo 2, In Progress 2 with 1 blocked, Verify 1 with a card marked needs you, Done 3. Cards are tagged Haiku, Opus, Sonnet or you." width="100%">
Opus, Sonnet, Haiku or you (in Verify, who checks it). A Done task's page says who did it and who checked it.<img src="docs/task-page-2x.png" alt="The page of task 5, Review the file name and header wording: Goal, Status Waiting for your check, Done by Opus main session, Latest note." width="100%">
◆ needs you; open its page for the goal and the latest note.The route skill in plan-orchestrator writes your plan's tasks to PROGRESS.md and updates them as they run. plan-tracker reads that file and never writes it.
<img src="docs/how-it-works.svg" alt="Sequence: 1 you approve a plan; 2 the route skill writes one row per task, each with its goal; 3 plan-tracker reads that file; 4 it draws the band and the board; 5 rows change as tasks run and new work gets its own row; 6 a task waits on your check; 7 its card says needs you and its page shows the goal and latest note; 8 you check it and reply; 9 the route skill marks it Done; 10 a pop-up and chime, the bar fills; 11 when Claude hands the plan back, the route skill writes the Recap; 12 in the next session the band and board draw from the same file: state, done/total and recap." width="100%">
Use plan-tracker alongside Claude Code's own views; each shows something different.
| Claude Code's | It shows | plan-tracker adds |
|---|---|---|
| Agent map (Desktop, VS Code) | This session's subagents, each with its status, time and tokens | The plan's tasks across sessions: who has each one and what waits on you |
Background tasks (/tasks) | Shells and subagents running in this session, cleared when Claude Code exits | Which tasks are running, blocked or done, kept in a file after the session ends |
Save this as PROGRESS.md in your project root. The band appears within a few seconds, the board shows a card in Done, In Progress and Verify, and task 3 says ◆ needs you.
# Add CSV export
| task | status | executor | check | evidence |
|---|---|---|---|---|
| 1. Write the serializer | Done | subagent · Sonnet | script | 12 tests pass |
| 2. Add the export command | In Progress | main session · Opus | script | |
| 3. Review the file name wording | Needs Verification | main session · Opus | you | draft ready |
Change a status in the file and the board follows.
PROGRESS.md.task and status columns, as one plan./plan-tracker: the board says what is wrong with the file. It needs a table with task and status columns./plan-tracker is not found. Run claude plugin list: plan-tracker@plan-tracker must be enabled. If you already have a /plan-tracker command, yours is kept; press the bar to open the board.claude plugin update ends with "Restart to apply changes".docs/reference.md: lanes, plan and card states, the board file and its columns, settings, the board command's JSON, and what plan-orchestrator fills.
MIT
hooks/register.tsx 1111 lines1/**
2 * The plan board for a terminal: a one-row band above the prompt (`● <plan> ▾ <bar> n/m Plan file ›`: "▾" opens the plan
3 * list above the prompt, the bar opens or closes the board, "Plan file ›" opens the board on the plan file) and a pane with
4 * the plan dropdown, the plan line and four lanes (Todo, In Progress, Verify, Done) of bordered cards (press one for its page).
5 * Picking a plan in the band or in the pane switches both.
6 *
7 * The data is the board file (PROGRESS.md, read again every 1.5 s, so the board follows the file) or, with the setting
8 * `board_command`, that command's JSON; read on session.start, on each prompt, after a Bash call that runs the command, and every
9 * 10 s while the pane is open; one run is in flight at a time, and none holds a prompt or a Bash
10 * result. With a `board_command`, `/plan-tracker` opens the pane at once on the list of the plans not Done or Canceled, latest updated first, under a
11 * search field that has the keys, with no text, whatever the surface says (a Desktop client may not have attached yet), and reads the list again
12 * behind it; `/plan-tracker <words>` puts the words in the field, and opens the plan when the answer is in if they are its name or anchor or leave
13 * one row. Typing filters every plan in the mod; Enter or a press on a row opens that plan, as do the band and the plan menus. A command that lists
14 * no `matches` but two or more `plans` has them listed; one that prints a single plan, and a plain board file, open that plan's board. When the
15 * board cannot be drawn a toast says why. A PLAN-DONE block in a Bash result becomes a toast, a chime
16 * and a notice in the conversation, and one with `0 left` makes the band's bar a rainbow until the person's next prompt. With no
17 * `board_command`, a row of the board file that goes Done does the same, with no block: the file is the only source.
18 */
19import { atom, read, update } from 'claude-code'
20import type { EngineInterface, Register, Timer } from 'claude-code'
21
22import type { Live, PlanBoard, PlanEntry, PlanStatus } from '../types'
23import {
24 BAND_MASCOT_SPACE,
25 MASCOT,
26 bandPlanOf,
27 bandPose,
28 bandPoseIndex,
29 detailOf,
30 directPick,
31 FRAME_MS,
32 doneBlocks,
33 doneHeader,
34 failureText,
35 finishedAnchor,
36 hasWorking,
37 isComplete,
38 isOpen,
39 isRainbow,
40 listOf,
41 listedMatches,
42 messageOf,
43 movement,
44 noBoardFile,
45 parseBoard,
46 parseProgress,
47 planOf,
48 planStateText,
49 truncate,
50 settings,
51 applyOptions,
52 usageArgv,
53 boardArgv,
54 commandMarkOf,
55} from './board'
56import type { LaneKey } from './board'
57import type { FileView } from './draw'
58import { drawBand, drawPane, drawPicker, holdsSomething, listedMatchOf, listedOf } from './draw'
59
60const COMMAND = 'plan-tracker'
61const PANE_ID = 'plan-tracker'
62const PANE_TITLE = 'Plans'
63const REFRESH_MS = 10_000
64const SCRIPT_TIMEOUT_MS = 30_000
65/** `--usage` reads session transcripts, which reach 50 MB. */
66const USAGE_TIMEOUT_MS = 120_000
67const DONE_SOUND = 'sounds/done.wav'
68const DONE_GAIN = 0.6
69const TOAST_COLUMNS = 70
70/**
71 * Opening the pane asks for the keyboard, so the arrows and PageUp/PageDown scroll it at once (a pane taller than its window
72 * scrolls only while focused; the wheel needs a fullscreen terminal), and for 60 rows, up to what the layout spares.
73 */
74const OPEN = { id: PANE_ID, title: PANE_TITLE, focus: true, rows: 60 } as const
75
76/** The tracker's last answer: a board, or the one line saying why there is none; null until the first answer. */
77const live = atom({ plugin: 'plan-tracker', key: 'live' } as const, null)
78/** The `--query` the refreshes pass: `*` while the pane lists the plans, else the plan opened from the list, the band or a plan menu, or the one that just finished. */
79const query = atom({ plugin: 'plan-tracker', key: 'query' } as const, '')
80/** The query that asks the board command for the list of every plan. */
81const ALL_PLANS = '*'
82/** Whether the pane shows the plan list: `/plan-tracker` turns it on, opening a plan turns it off. */
83const listing = atom({ plugin: 'plan-tracker', key: 'listing' } as const, false)
84/** The list's search field text; the list is filtered by it here, with no script run per key. */
85const search = atom({ plugin: 'plan-tracker', key: 'search' } as const, '')
86/** Every plan, from the last answer to the query `*`: the list is drawn from it at once while `/plan-tracker` reads a new one. */
87const allPlans = atom({ plugin: 'plan-tracker', key: 'allPlans' } as const, null)
88/** The anchor picked in the band or the pane; null until one is picked, when the tracker's own `selected` stands. */
89const selected = atom({ plugin: 'plan-tracker', key: 'selected' } as const, null)
90const card = atom({ plugin: 'plan-tracker', key: 'card' } as const, null)
91/** Whether the pane shows the plan file instead of the board. */
92const file = atom({ plugin: 'plan-tracker', key: 'file' } as const, false)
93/** The anchor of the plan a PLAN-DONE with 0 left finished; cleared by the person's next prompt. */
94const finished = atom({ plugin: 'plan-tracker', key: 'finished' } as const, null)
95/** The animation frame; advanced by the one timer, only while something on screen moves. */
96const frame = atom({ plugin: 'plan-tracker', key: 'frame' } as const, 0)
97/** Whether the pane's plan dropdown shows its list. */
98const menu = atom({ plugin: 'plan-tracker', key: 'menu' } as const, false)
99/** The lanes that show all their cards, as `<anchor>:<lane>`. */
100const expanded = atom({ plugin: 'plan-tracker', key: 'expanded' } as const, [])
101/** Whether the band's plan list shows. */
102const bandMenu = atom({ plugin: 'plan-tracker', key: 'bandMenu' } as const, false)
103
104/** The session's project root: where the board file and a relative path in a note are looked up. */
105async function projectRoot($: EngineInterface): Promise<string> {
106 if (settings.root === '') {
107 settings.root = (await $.session.root().catch(() => '')).replace(/\/+$/, '')
108 }
109
110 return settings.root
111}
112
113/** How a command runs: in the project root, with `CLAUDE_PROJECT_DIR` pointing at it. */
114async function commandInit($: EngineInterface, timeoutMs = SCRIPT_TIMEOUT_MS) {
115 const root = await projectRoot($)
116
117 return { cwd: root, env: { CLAUDE_PROJECT_DIR: root }, timeoutMs }
118}
119
120/** The board file's absolute path (`board_location`, relative to the project root). */
121const boardFile = (root: string): string => (settings.boardLocation.startsWith('/') ? settings.boardLocation : `${root}/${settings.boardLocation}`)
122
123// ---------------------------------------------------------------- the board: a command, or the board file
124
125/** The board file read as a board, or the one line saying why there is none. */
126async function readBoardFile($: EngineInterface): Promise<Live> {
127 if (settings.boardLocation.startsWith('command:')) {
128 return { error: 'plan-tracker: board_location is a "command:" board; set board_command to a command that prints the board as JSON' }
129 }
130
131 const file = boardFile(await projectRoot($))
132
133 if (!(await $.fs.exists(file).catch(() => false))) {
134 return noBoardFile(file)
135 }
136
137 try {
138 return parseProgress(await $.fs.read(file), file, file.split('/').at(-2) ?? 'Plan')
139 } catch (error) {
140 return { error: `plan-tracker: cannot read ${file}: ${messageOf(error)}` }
141 }
142}
143
144/** The board for `text`: `board_command`'s JSON when it is set, else the board file. */
145async function fetchBoard($: EngineInterface, text: string): Promise<Live> {
146 if (settings.boardCommand === '') {
147 return readBoardFile($)
148 }
149
150 try {
151 const { exitCode, stdout, stderr } = await $.process.run(boardArgv(await $.session.id(), text), await commandInit($))
152
153 return exitCode === 0 ? parseBoard(stdout) : { error: failureText(exitCode, stderr) }
154 } catch (error) {
155 return { error: `plan-tracker: could not run the board command: ${messageOf(error)}` }
156 }
157}
158
159/** The answer last written to `live`, as text; undefined until one is. */
160let shownJson: string | undefined
161
162/** The run of the script under way, and the one rerun waiting behind it. */
163let inFlight: Promise<void> | undefined
164let queued: Promise<void> | undefined
165
166/** Runs the script once for the current query and stores its answer (an answer to `*` also as the list); the state write redraws the band and the pane. */
167async function refreshOnce($: EngineInterface) {
168 const asked = await read($, query)
169 const answer = await fetchBoard($, asked)
170 const text = JSON.stringify(answer)
171
172 if (asked === ALL_PLANS && 'board' in answer) {
173 const list = listOf(answer.board)
174
175 // Written only when it changed, as `live` is below: a write redraws, and a redraw hands the desktop's buttons new handles.
176 if (JSON.stringify(list) !== JSON.stringify(await read($, allPlans))) {
177 await update($, allPlans, () => list)
178 }
179 }
180
181 // A write redraws what read the value, even one that changes nothing, and a redraw hands the desktop's buttons new handles:
182 // an answer like the one shown is not written.
183 if (text === shownJson) {
184 return
185 }
186
187 await update($, live, () => answer)
188 shownJson = text
189
190 // Only the board file has no PLAN-DONE to say that a row went Done; a board command prints its own.
191 if (settings.boardCommand === '') {
192 await announceFileDone($, answer)
193 }
194}
195
196/** The status of each row of the board file at its last read, by task; undefined until the first read, which is the baseline. */
197let lastStatuses: Map<string, PlanStatus> | undefined
198
199/**
200 * A read of the board file that moved rows to Done: each is a toast and a notice, `✅ <task> · N left` (N: rows neither Done nor
201 * Canceled), the read as a whole one chime, and a read that leaves every row Done or Canceled turns the band to a rainbow until
202 * the person's next prompt. The first read, a row first seen already Done, and an error answer (the baseline stays) give nothing.
203 */
204async function announceFileDone($: EngineInterface, answer: Live) {
205 if (!('board' in answer)) {
206 return
207 }
208
209 const plan = answer.board.plans[0]
210 const before = lastStatuses
211
212 lastStatuses = new Map((plan?.rows ?? []).map(row => [row.task, row.status]))
213
214 if (plan !== undefined && plan.anchor === (await read($, finished)) && plan.rows.some(isOpen)) {
215 await unmarkFinished($, plan.anchor)
216 }
217
218 if (before === undefined || plan === undefined) {
219 return
220 }
221
222 const went = plan.rows.filter(row => row.status === 'Done' && before.has(row.task) && before.get(row.task) !== 'Done')
223 const left = plan.rows.filter(isOpen).length
224
225 for (const [at, row] of went.entries()) {
226 await notify($, row.task, String(left + went.length - 1 - at))
227 }
228
229 if (went.length > 0) {
230 await chime($)
231
232 if (isComplete(plan)) {
233 await markFinished($, plan.anchor)
234 }
235 }
236}
237
238/**
239 * Reads the tracker again. One run is in flight at a time: a call that comes meanwhile (the 10 s timer, a prompt, a Bash
240 * call) shares the one rerun that starts when it ends, which reads the query as it is by then. The promise ends with the
241 * run that answers the call.
242 */
243function refreshBoard($: EngineInterface): Promise<void> {
244 if (inFlight === undefined) {
245 const run = refreshOnce($).finally(() => {
246 if (inFlight === run) {
247 inFlight = undefined
248 }
249 })
250
251 inFlight = run
252
253 return run
254 }
255
256 queued ??= inFlight
257 .catch(() => undefined)
258 .then(() => {
259 queued = undefined
260
261 return refreshBoard($)
262 })
263
264 return queued
265}
266
267/** `refreshBoard` for a place that must not fail because the script did. */
268const refreshQuietly = ($: EngineInterface) => refreshBoard($).catch(() => undefined)
269
270/** The rows whose usage run is under way, as `<anchor>:<index>`: a page opened twice meanwhile starts one run. */
271const usageRuns = new Set<string>()
272
273/**
274 * A task page opened for a row that started and has not finished: the tracker works out its usage (reading transcripts,
275 * which is slow, so never in the 10 s refresh) and the board is read again to show it. A failure leaves the page as it is.
276 */
277async function loadUsage($: EngineInterface, plan: PlanEntry, index: number) {
278 const row = plan.rows[index]
279 const key = `${plan.anchor}:${index}`
280
281 if (settings.usageCommand === '' || row === undefined || row.started === undefined || row.finished !== undefined || usageRuns.has(key)) {
282 return
283 }
284
285 usageRuns.add(key)
286
287 try {
288 const { exitCode, stderr } = await $.process.run(
289 usageArgv(plan.anchor, index, row.task, await $.session.id()),
290 await commandInit($, USAGE_TIMEOUT_MS),
291 )
292
293 if (exitCode !== 0) {
294 await log($, 'usage', `the usage command exited ${exitCode}: ${stderr.trim().split('\n').at(-1)?.slice(0, 200) ?? ''}`)
295
296 return
297 }
298
299 await refreshQuietly($)
300 } catch (error) {
301 await log($, 'usage', messageOf(error))
302 } finally {
303 usageRuns.delete(key)
304 }
305}
306
307const lastDrawn = new Map<string, { key: string; tree: unknown }>()
308
309/**
310 * The tree `build` makes for `data`, or the one made last time in `slot` when `data` is the same. Every Button and every link with a
311 * press gets a new handle when a render hook builds its tree, and the engine keeps only the latest tree's handles: a click that
312 * carries an earlier tree's handle is "ui_press not handled" and nothing happens. A render that draws what was drawn therefore hands
313 * back the tree it drew, whose handles are the ones the surface holds. `data` is every input of the drawing (JSON, closures left out).
314 */
315export function steadily<T>(slot: string, data: unknown, build: () => T): T {
316 const key = JSON.stringify(data)
317 const held = lastDrawn.get(slot)
318
319 if (held !== undefined && held.key === key) {
320 return held.tree as T
321 }
322
323 const tree = build()
324
325 lastDrawn.set(slot, { key, tree })
326
327 return tree
328}
329
330/** Starts a read of the tracker without waiting for it (a slow script must not hold a prompt or a Bash result), then redraws what moves. */
331function refreshInBackground($: EngineInterface) {
332 void refreshQuietly($)
333 .then(() => syncAnimation($))
334 .catch(() => undefined)
335}
336
337let refresh: Timer | undefined
338/** The poll of the board file: it runs for the whole session, so the band and the board follow the file without a command. */
339let watch: Timer | undefined
340
341/** How often the board file is read again (it is read whole and redrawn only when its content changed). */
342const WATCH_MS = 1_500
343
344function startWatch($: EngineInterface) {
345 if (settings.boardCommand === '') {
346 watch ??= $.clock.every(WATCH_MS, () => void refreshInBackground($))
347 }
348}
349
350function startRefresh($: EngineInterface) {
351 refresh ??= $.clock.every(REFRESH_MS, () => void refreshQuietly($))
352}
353
354function stopRefresh() {
355 refresh?.cancel()
356 refresh = undefined
357}
358
359// ---------------------------------------------------------------- what is shown
360
361/** The board the tracker last answered, or undefined while there is none (no answer yet, or an error line). */
362async function boardOf($: EngineInterface): Promise<PlanBoard | undefined> {
363 const answer = await read($, live)
364
365 return answer !== null && 'board' in answer ? answer.board : undefined
366}
367
368/** The plan picked (else the tracker's own choice). */
369async function shownPlan($: EngineInterface): Promise<PlanEntry | undefined> {
370 const board = await boardOf($)
371
372 return board === undefined ? undefined : planOf(board, await read($, selected))
373}
374
375/** The most characters of the plan file the pane draws (one drawing holds 100000 in all). */
376const FILE_CHARS = 90000
377
378/** The plan file's text for the pane, or why it cannot be read. */
379async function loadFile($: EngineInterface, plan: PlanEntry): Promise<FileView> {
380 try {
381 const text = await $.fs.read(plan.plan_file)
382
383 return { text: text.length > FILE_CHARS ? `${text.slice(0, FILE_CHARS)}\n\n… (cut here; open the file for the rest)` : text }
384 } catch (error) {
385 return { error: `cannot read ${plan.plan_file}: ${messageOf(error)}` }
386 }
387}
388
389// ---------------------------------------------------------------- the log, the toast and the chime
390
391/** One line of ~/.claude/mods-logs/plan-tracker.jsonl; `error` is null for a notice that was shown. `extra` adds fields after the four. */
392async function log($: EngineInterface, stage: string, error: string | null, extra: Record<string, unknown> = {}) {
393 try {
394 const home = await $.env.get('HOME')
395
396 if (home === undefined) {
397 return
398 }
399
400 const line = JSON.stringify({
401 ts: new Date().toISOString(),
402 session_id: await $.session.id().catch(() => ''),
403 stage,
404 error,
405 ...extra,
406 })
407
408 await $.process.run(['/bin/sh', '-c', 'mkdir -p "$1" && cat >> "$1/plan-tracker.jsonl"', 'sh', `${home}/.claude/mods-logs`], {
409 stdin: `${line}\n`,
410 })
411 } catch {
412 // The log is best effort: it must not break the Bash call it reports on.
413 }
414}
415
416/** Runs `work`; a throw is logged under `stage` and answers false. */
417async function attempt($: EngineInterface, stage: string, work: () => unknown): Promise<boolean> {
418 try {
419 await work()
420
421 return true
422 } catch (error) {
423 await log($, stage, messageOf(error))
424
425 return false
426 }
427}
428
429/** Shows one PLAN-DONE block as a toast and a notice in the conversation; whatever fails is logged, never thrown. */
430export async function announce($: EngineInterface, block: string) {
431 const done = doneHeader(block)
432
433 if (done === undefined) {
434 await log($, 'parse', `unreadable header: ${block.split('\n')[0] ?? ''}`)
435
436 return
437 }
438
439 await notify($, done.task, done.left, block)
440}
441
442/**
443 * The toast `✅ <task> · <left> left` (the task cut to the toast) and the notice in the conversation: `notice` as given, else the
444 * same line with the whole task. Whatever fails is logged, never thrown; a `sent` line says both were shown.
445 */
446async function notify($: EngineInterface, task: string, left: string, notice = `✅ ${task} · ${left} left`) {
447 const isToasted = await attempt($, 'toast', () => $.ui.toast(`✅ ${truncate(task, TOAST_COLUMNS)} · ${left} left`))
448 const isAppended = await attempt($, 'append', async () => {
449 const appended = await $.session.append({ message: { type: 'system', content: [{ type: 'text', text: notice }] } })
450
451 if (appended.deny !== undefined) {
452 throw new Error(`append refused: ${appended.deny}`)
453 }
454 })
455
456 if (isToasted && isAppended) {
457 await log($, 'sent', null)
458 }
459}
460
461/**
462 * The chime, only where a person can hear it: a session with a terminal. A session with none (a `claude -p` run at night
463 * loads mods too) stays silent, and a clip that cannot play never breaks anything.
464 */
465async function chime($: EngineInterface) {
466 try {
467 if ((await $.session.surfaces()).includes('terminal')) {
468 await $.audio.play({ asset: DONE_SOUND }, { gain: DONE_GAIN })
469 }
470 } catch {
471 // no screen to ask, or a clip that cannot play: the toast has been shown
472 }
473}
474
475// ---------------------------------------------------------------- the board, the band and what moves
476
477/**
478 * Opens the board when it is not up, closes it when it is. While the pane lists the plans, the press leaves the list instead: the pane
479 * shows the band's plan board, opened if it was closed, and the query becomes that plan's anchor.
480 */
481async function toggleBoard($: EngineInterface) {
482 const isUp = (await $.ui.panes()).some(pane => pane.id === PANE_ID && pane.isPlaced)
483
484 if (await read($, listing)) {
485 const plan = await shownPlan($)
486
487 await Promise.all([update($, listing, () => false), ...(plan === undefined ? [] : [update($, query, () => plan.anchor)])])
488 await $.ui.open(OPEN)
489 } else if (isUp) {
490 await $.ui.close({ id: PANE_ID })
491 } else {
492 await $.ui.open(OPEN)
493 }
494
495 await syncAnimation($)
496}
497
498/**
499 * Hands the keyboard to the pane if it is up. A click leaves the keys with the site it landed on (the band's Button holds
500 * them), so a handler that changes what the pane shows asks again, which the pane takes; with the pane closed it opens nothing.
501 * With `top`, the new view also starts at its first row (the window keeps its offset across views otherwise).
502 */
503async function refocus($: EngineInterface, top = false) {
504 try {
505 if ((await $.ui.panes()).some(pane => pane.id === PANE_ID && pane.isPlaced)) {
506 await $.ui.open(OPEN)
507
508 if (top) {
509 await $.ui.scroll({ to: 'start', in: PANE_ID })
510 }
511 }
512 } catch {
513 // a surface that cannot move the keys leaves them where they are
514 }
515}
516
517/** Opens the board (taking the keys) and shows the plan file in it. */
518async function openFile($: EngineInterface) {
519 await Promise.all([update($, file, () => true), update($, card, () => null), update($, menu, () => false), update($, bandMenu, () => false)])
520
521 await $.ui.open(OPEN)
522 await $.ui.scroll({ to: 'start', in: PANE_ID }).catch(() => undefined)
523 await syncAnimation($)
524}
525
526/** Goes back to the board's lanes. The keys go to the pane. */
527async function showBoard($: EngineInterface) {
528 await Promise.all([update($, card, () => null), update($, file, () => false)])
529 await syncAnimation($)
530 await refocus($, true)
531}
532
533/**
534 * Picks a plan for the band and the board together: both lists close and the board shows its lanes; the pane leaves the plan list, and
535 * the query becomes the anchor (else the refreshes would go on asking for every plan).
536 */
537async function pickPlan($: EngineInterface, anchor: string) {
538 await Promise.all([
539 update($, listing, () => false),
540 update($, query, () => anchor),
541 update($, selected, () => anchor),
542 update($, card, () => null),
543 update($, file, () => false),
544 update($, menu, () => false),
545 update($, bandMenu, () => false),
546 ])
547 await syncAnimation($)
548 await refocus($, true)
549}
550
551/**
552 * Picks a plan of the pane's list: the plan may not be among the session's, so the query becomes its anchor (the tracker answers
553 * with that plan's entry) as well as the plan shown. The pane leaves the list, lists close and the board shows its lanes, as in `pickPlan`.
554 */
555async function pickMatch($: EngineInterface, anchor: string) {
556 await Promise.all([
557 update($, listing, () => false),
558 update($, query, () => anchor),
559 update($, selected, () => anchor),
560 update($, card, () => null),
561 update($, file, () => false),
562 update($, menu, () => false),
563 update($, bandMenu, () => false),
564 ])
565 await refreshQuietly($)
566 await syncAnimation($)
567 await refocus($, true)
568}
569
570/**
571 * Whether anything on screen moves: the band (the rainbow of a complete plan, the mascot while the plan has a row In
572 * Progress) or the open pane (the mascot of an In Progress card).
573 */
574async function animates($: EngineInterface): Promise<boolean> {
575 const plan = await shownPlan($)
576
577 if (plan === undefined) {
578 return false
579 }
580
581 // Only a terminal moves by redraw: the desktop reads no frame (its sprite animates itself), so a timer would only write one.
582 const surfaces = await $.session.surfaces().catch(() => null)
583
584 if (JSON.stringify(surfaces) !== loggedSurfaces) {
585 loggedSurfaces = JSON.stringify(surfaces)
586 void log($, 'timer', null, { surfaces })
587 }
588
589 if (surfaces !== null && !surfaces.includes('terminal')) {
590 return false
591 }
592
593 const isPaneUp = (await $.ui.panes()).some(pane => pane.id === PANE_ID && pane.isPlaced && pane.isShown)
594 const isBoard = detailOf(plan, await read($, card)) === null && !(await read($, file))
595 const { band, pane } = movement(plan, { finished: await read($, finished), isPaneUp, isBoard })
596
597 return band || pane
598}
599
600/** The rows the pane last said it shows (`scroll.bodyRows`): logged with a lane press, for the Show less rule. */
601let paneRows: number | undefined
602
603/** The surfaces last logged by `animates`, as text. */
604let loggedSurfaces = ''
605
606/**
607 * What a press on the Button keyed `element` does: the same actions as the drawings' `onPress` closures,
608 * which call this too. It decides at once whether the key is the mod's (undefined if not) and returns the work, so a caller
609 * can answer a press without waiting: the `ui.press` hook runs the work in the background, because opening a pane from
610 * inside the press chain must not hold the chain.
611 */
612export function actionFor($: EngineInterface, element: string, surface: string): (() => Promise<unknown>) | undefined {
613 const lane = /^lane:(.+):(?:more|less)$/.exec(element)?.[1]
614
615 if (element === 'band:bar' || element === 'band:head') {
616 return () => toggleBoard($)
617 }
618
619 if (element === 'band:plans') {
620 return () => update($, bandMenu, isOpen => !isOpen)
621 }
622
623 if (element === 'band:file' || element === 'plan-file') {
624 return () => openFile($)
625 }
626
627 if (element === 'band:board' || element === 'back') {
628 return () => showBoard($)
629 }
630
631 if (element.startsWith('band:plan:')) {
632 return () => pickPlan($, element.slice('band:plan:'.length))
633 }
634
635 if (element === 'menu:name' || element === 'menu:arrow') {
636 return async () => {
637 await update($, menu, isOpen => !isOpen)
638 await refocus($)
639 }
640 }
641
642 if (element.startsWith('plan:')) {
643 return () => pickPlan($, element.slice('plan:'.length))
644 }
645
646 if (element.startsWith('match:')) {
647 return () => pickMatch($, element.slice('match:'.length))
648 }
649
650 if (lane !== undefined) {
651 return async () => {
652 const plan = await shownPlan($)
653
654 if (plan !== undefined) {
655 const key = `${plan.anchor}:${lane}`
656
657 // The desktop's rows may not be the terminal's: record what each surface says its pane shows.
658 void log($, 'lane', null, { surface, bodyRows: paneRows })
659
660 await update($, expanded, all => (all.includes(key) ? all.filter(one => one !== key) : [...all, key]))
661 await refocus($)
662 }
663 }
664 }
665
666 return undefined
667}
668
669/** Runs the press of `element` in the background (a failure is logged, never thrown); false for a key the mod does not draw. */
670function pressByKey($: EngineInterface, element: string, surface: string): boolean {
671 const action = actionFor($, element, surface)
672
673 if (action === undefined) {
674 return false
675 }
676
677 void action().catch((error: unknown) => log($, 'press', messageOf(error), { element, surface }))
678
679 return true
680}
681
682/** Enter in the search field: the text is kept and the first plan it leaves in the list opens; none left opens nothing. */
683async function submitSearch($: EngineInterface, text: string) {
684 await update($, search, () => text)
685
686 const top = listedMatches((await read($, allPlans)) ?? [], text)[0]
687
688 if (top !== undefined) {
689 await pickMatch($, top.anchor)
690 }
691}
692
693/**
694 * Runs what typing in the Input keyed `element` does, by its key as a press is (a desktop redraw makes the drawn closures' handles stale), in
695 * the background; false for a key the mod does not draw. A change keeps the text, which the list is filtered by when it is drawn.
696 */
697function typeByKey($: EngineInterface, element: string, kind: 'change' | 'submit', value: string, surface: string): boolean {
698 if (element !== 'search') {
699 return false
700 }
701
702 const work = kind === 'change' ? update($, search, () => value) : submitSearch($, value)
703
704 void work.catch((error: unknown) => log($, 'input', messageOf(error), { element, surface, kind }))
705
706 return true
707}
708
709/** Starts the frame timer when something moves and cancels it when nothing does. */
710async function syncAnimation($: EngineInterface): Promise<boolean> {
711 const moves = await animates($)
712
713 if (moves) {
714 animation ??= $.clock.every(FRAME_MS, () => void step($).catch(() => undefined))
715 } else {
716 animation?.cancel()
717 animation = undefined
718 }
719
720 return moves
721}
722
723/** One period of the frame timer: the next frame while something moves, else the timer ends. */
724async function step($: EngineInterface) {
725 if (await syncAnimation($)) {
726 await update($, frame, n => n + 1)
727 }
728}
729
730let animation: Timer | undefined
731
732/**
733 * A PLAN-DONE with 0 left: the band shows that plan, finished, from the tracker's answer for it, until the person's next prompt. The query
734 * is no longer `*`, so a pane listing the plans shows that plan's board instead (its list would be empty).
735 */
736async function markFinished($: EngineInterface, anchor: string) {
737 await Promise.all([update($, finished, () => anchor), update($, query, () => anchor), update($, selected, () => anchor), update($, listing, () => false)])
738 refreshInBackground($)
739 await syncAnimation($)
740}
741
742/** The finished plan has tasks left again (reopened, or a task added) before the next prompt: the band draws it as it is. */
743async function unmarkFinished($: EngineInterface, mark: string) {
744 await Promise.all([update($, finished, () => null), update($, query, asked => (asked === mark ? '' : asked))])
745 await syncAnimation($)
746}
747
748/**
749 * The plan `/plan-tracker` opens once the list is read: the one plan the board command printed with no `matches` (the board of a command
750 * that treats `*` as a word, and of a plain board file), else the plan the words are (`directPick`); undefined for none.
751 */
752async function planToOpen($: EngineInterface, text: string) {
753 const board = await boardOf($)
754
755 return board !== undefined && board.matches === undefined && board.plans.length === 1 ? board.plans[0] : directPick((await read($, allPlans)) ?? [], text)
756}
757
758/**
759 * `/plan-tracker` opens the pane at once on the list of every plan, its search field holding the words after the command and the keys
760 * (a focus move that does not take is logged), and answers no text. The list is the one the mod already holds (`Loading plans…` before
761 * the first); the tracker is read for `*` behind it, and the answer redraws the list. Then the plan it names opens (`planToOpen`), unless the
762 * person has typed or left the list meanwhile. A plain board file holds one plan: the list is skipped and its board is the pane. The pane
763 * opens whatever `$.session.surfaces()` says: a Desktop client joins that list only once it attaches, and `/plan-tracker` can be a session's
764 * first input. When the pane waits undrawn, a toast says why. One log line per run, once the answer is in: the surfaces seen, whether the pane
765 * was placed, and the milliseconds from the command to the open pane (`openMs`) and to the fresh list (`listMs`).
766 */
767async function runCommand($: EngineInterface, e: { args: string }) {
768 const startedAt = await $.clock.now()
769 const text = e.args.trim()
770 const hasList = settings.boardCommand !== ''
771
772 if (hasList) {
773 await Promise.all([update($, query, () => ALL_PLANS), update($, listing, () => true), update($, search, () => text)])
774 }
775
776 const surfaces = await $.session.surfaces().catch(() => null)
777 const opened = await $.ui.open(OPEN).catch((error: unknown) => ({ isPlaced: false as const, reason: messageOf(error) }))
778 const openMs = (await $.clock.now()) - startedAt
779
780 if (!opened.isPlaced) {
781 $.ui.toast(`Board waits: ${opened.reason}`)
782 } else if (hasList) {
783 const target = { requestId: PANE_ID, key: 'search' }
784
785 void $.ui
786 .focus(target)
787 .then(moved => (moved.deny === undefined ? undefined : log($, 'focus', moved.deny, target)))
788 .catch((error: unknown) => log($, 'focus', messageOf(error), target))
789 }
790
791 void (async () => {
792 await refreshQuietly($)
793
794 const listMs = (await $.clock.now()) - startedAt
795 const plan = hasList ? await planToOpen($, text) : undefined
796
797 if (plan !== undefined && (await read($, listing)) && (await read($, search)) === text) {
798 await pickMatch($, plan.anchor)
799 }
800
801 await syncAnimation($)
802 await log($, 'open', opened.isPlaced ? null : opened.reason, { surfaces, isPlaced: opened.isPlaced, openMs, listMs })
803 })().catch(() => undefined)
804
805 return {}
806}
807
808export const register: Register = (on, options) => {
809 applyOptions(options)
810
811 on('session.start', async ($, e, next) => {
812 try {
813 await $.command.register({
814 name: COMMAND,
815 description: 'Open the plan board; with a board command that lists several plans, pick one from the list, latest first; /plan-tracker <words> searches it',
816 argumentHint: '[words to search plans, with a board command]',
817 })
818 } catch (error) {
819 // The engine refuses a name another command already defines: the band and the board still work.
820 await log($, 'command', messageOf(error))
821 }
822
823 await projectRoot($)
824 refreshInBackground($)
825 startWatch($)
826
827 // A reload keeps the pane but drops the module's timers: start them again. A session with no screen (`claude -p`) has
828 // no panes to ask, and that must not stop the mod loading.
829 try {
830 if ((await $.ui.panes()).some(pane => pane.id === PANE_ID)) {
831 startRefresh($)
832 }
833 } catch {
834 // no panes to ask
835 }
836
837 await syncAnimation($).catch(() => undefined)
838
839 return next(e)
840 })
841
842 on('command.run', { command: COMMAND }, runCommand)
843
844 // Every way the pane opens starts its 10 s refresh; closing it ends it.
845 on('ui.open', { id: PANE_ID }, async ($, e, next) => {
846 const result = await next(e)
847 startRefresh($)
848
849 return result
850 })
851
852 on('ui.close', { id: PANE_ID }, async ($, e, next) => {
853 const result = await next(e)
854 stopRefresh()
855
856 return result
857 })
858
859 // The person's next prompt ends the rainbow; every prompt reads the tracker again. A failure here must not stop a prompt:
860 // `.catch` lets it through.
861 on('prompt.submit', async ($, e, next) => {
862 const isPerson = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
863 const mark = await read($, finished)
864
865 if (isPerson && mark !== null) {
866 await Promise.all([update($, finished, () => null), update($, query, asked => (asked === mark ? '' : asked))])
867 }
868
869 // The read does not hold the prompt; the rainbow has ended already, so the timer is told now and again when the answer lands.
870 refreshInBackground($)
871 await syncAnimation($)
872
873 return next(e)
874 }).catch(($, e, next) => next(e))
875
876 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
877 if (e.props.hasSurvey) {
878 return next(e)
879 }
880
881 const picked = await shownPlan($)
882 const below = await next(e)
883
884 // No answer, an error, or no plan for this session: the band draws nothing of ours.
885 if (picked === undefined) {
886 return below
887 }
888
889 const mark = await read($, finished)
890 const plan = bandPlanOf(picked, mark)
891 const isRainbowUp = isRainbow(picked, mark)
892 const moves = isRainbowUp || hasWorking(plan)
893 // The desktop reads no frame: a redraw issues new button handles, and its press arrives with the old one. Its sprite animates itself.
894 const isDesktop = e.surface === 'desktop'
895 const now = moves && !isDesktop ? await read($, frame) : 0
896 // "‹ Board" only while the pane is up and shows the plan file.
897 const isFile = (await read($, file)) && (await $.ui.panes().catch(() => [])).some(pane => pane.id === PANE_ID && pane.isPlaced)
898
899 const plans = (await boardOf($))?.plans ?? []
900 const at = Date.now()
901 // Everything the drawing is made of but its closures. It reads the session's other plans only through the plan list, the time only through
902 // the plan's state text, and what the hooks below drew only when that holds something (their press handles are part of it).
903 // `now` above is the animation frame; `at` is the time the state word's `since` is compared with.
904 const view = {
905 plan,
906 plans: plans.map(one => listedOf(one, at)),
907 stateText: planStateText(plan, at),
908 columns: e.props.bodyColumns - BAND_MASCOT_SPACE,
909 below: holdsSomething(below) ? below : null,
910 maxRows: e.props.maxRows,
911 isOpen: await read($, bandMenu),
912 isFile,
913 frame: isRainbowUp ? now : null,
914 pose: bandPose(plan, now),
915 spritePose: MASCOT[bandPoseIndex(plan, now)],
916 isProportional: isDesktop,
917 isMoving: isDesktop && hasWorking(plan),
918 }
919
920 // Only a plan that moves (a rainbow, or a row In Progress for the mascot) reads the frame, so only then is the band
921 // redrawn at each one. The row gets what the mascot and its space leave. The same data draws the same tree, as the pane does.
922 return steadily(`band:${e.surface}`, view, () =>
923 drawBand($.ui.resolve(e), {
924 ...view,
925 plans,
926 now: at,
927 below,
928 onBar: () => void pressByKey($, 'band:bar', e.surface),
929 onMenu: () => void pressByKey($, 'band:plans', e.surface),
930 onPick: anchor => void pressByKey($, `band:plan:${anchor}`, e.surface),
931 onFile: () => void pressByKey($, 'band:file', e.surface),
932 onBoard: () => void pressByKey($, 'band:board', e.surface),
933 }),
934 )
935 })
936
937 // A press on any button of the mod, run by its key with no closure: a press can arrive with a handle the host no longer holds.
938 on('ui.press', { plugin: 'plan-tracker' }, async ($, e, next) => {
939 // Answer at once: the work runs in the background, so nothing the press starts (opening the pane) can hold the chain.
940 const isHandled = actionFor($, e.element, e.surface) !== undefined
941
942 void log($, 'press', null, { element: e.element, surface: e.surface, handled: isHandled })
943
944 if (!isHandled) {
945 return next(e)
946 }
947
948 pressByKey($, e.element, e.surface)
949
950 return { element: e.element }
951 })
952
953 // Typing in the mod's search field, run by its key with no closure, as a press is. One log line per change or Enter.
954 on('ui.input', { plugin: 'plan-tracker' }, async ($, e, next) => {
955 const isHandled = typeByKey($, e.element, e.kind, e.value, e.surface)
956
957 void log($, 'input', null, { element: e.element, surface: e.surface, kind: e.kind, length: e.value.length, handled: isHandled })
958
959 return isHandled ? { element: e.element, value: e.value } : next(e)
960 })
961
962 // The desktop band's overlay regions post `{ band: 'toggle' }`: pressing anywhere on the band toggles the board.
963 on('ui.message', async ($, e, next) => {
964 if ((e.data as { band?: unknown } | null)?.band === 'toggle') {
965 void log($, 'press', null, { element: 'band:zone', surface: 'desktop' })
966 await toggleBoard($)
967 }
968
969 return next(e)
970 })
971
972 // A card drawn as a Client posts `{ open: <row index> }` when it is clicked (or Enter is pressed on it): open its page.
973 // The post is code's input, not a fact: anything but a row of the shown plan is ignored.
974 on('ui.message', { requestId: PANE_ID }, async ($, e, next) => {
975 const data = e.data as { open?: unknown } | null
976 const plan = await shownPlan($)
977
978 if (plan !== undefined && typeof data?.open === 'number' && plan.rows[data.open] !== undefined) {
979 const index = data.open
980
981 await Promise.all([update($, menu, () => false), update($, card, () => ({ anchor: plan.anchor, index }))])
982 void loadUsage($, plan, index)
983 await syncAnimation($)
984 await refocus($, true)
985 }
986
987 return next(e)
988 })
989
990 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
991 paneRows = e.props.scroll.bodyRows
992
993 const { Text } = $.ui.resolve(e)
994 const answer = await read($, live)
995
996 if (answer !== null && !('board' in answer)) {
997 return <Text wrap="wrap">{answer.error}</Text>
998 }
999
1000 // The plan list, while `/plan-tracker` has it on, whatever else the pane showed: the plans the search field's text leaves (with none, the
1001 // open ones), in the board command's order, from the last list read. The band and the plan dropdown still read the board's own `plans` and `selected`.
1002 if (await read($, listing)) {
1003 const all = await read($, allPlans)
1004
1005 if (all === null) {
1006 return <Text dimColor>Loading plans…</Text>
1007 }
1008
1009 const text = await read($, search)
1010 const now = Date.now()
1011 const rows = listedMatches(all, text).map(one => listedMatchOf(one, now))
1012 const view = { rows, search: text, empty: text.trim() === '' ? 'No open plans.' : `No plan matches "${text}"`, columns: e.props.bodyColumns }
1013
1014 return steadily(`pane:${e.surface}`, view, () =>
1015 drawPicker($.ui.resolve(e), {
1016 ...view,
1017 onPick: anchor => void pressByKey($, `match:${anchor}`, e.surface),
1018 onInput: value => void typeByKey($, 'search', 'change', value, e.surface),
1019 onSubmit: value => void typeByKey($, 'search', 'submit', value, e.surface),
1020 }),
1021 )
1022 }
1023
1024 if (answer === null) {
1025 return <Text dimColor>Reading the tracker…</Text>
1026 }
1027
1028 const { board } = answer
1029 const plan = await shownPlan($)
1030
1031 if (plan === undefined) {
1032 return <Text dimColor>{board.message ?? 'No plans in this session.'}</Text>
1033 }
1034
1035 const open = await read($, card)
1036 const isFile = await read($, file)
1037 const detail = detailOf(plan, open)
1038 const now = Date.now()
1039 // Everything the drawing is made of but its closures (which depend on the plan's anchor and the surface, both here).
1040 const view = {
1041 plans: board.plans,
1042 plan,
1043 columns: e.props.bodyColumns,
1044 bodyRows: e.props.scroll.bodyRows,
1045 message: board.message,
1046 detail: isFile ? null : detail,
1047 file: isFile ? await loadFile($, plan) : null,
1048 home: await $.env.get('HOME'),
1049 // Only a board with an In Progress card reads the frame, so only that is redrawn at each one.
1050 frame: e.surface !== 'desktop' && !isFile && detail === null && hasWorking(plan) ? await read($, frame) : 0,
1051 isOpen: await read($, menu),
1052 hasClient: e.surface === 'terminal' || e.surface === 'desktop',
1053 surface: e.surface,
1054 expanded: (await read($, expanded)).flatMap(key => (key.startsWith(`${plan.anchor}:`) ? [key.slice(plan.anchor.length + 1) as LaneKey] : [])),
1055 }
1056
1057 // The drawing reads the session's other plans only through the plan list, and the time only to the minute (a relative age, today or a date).
1058 const data = { ...view, plans: board.plans.map(one => listedOf(one, now)), minute: Math.floor(now / 60_000) }
1059
1060 return steadily(`pane:${e.surface}`, data, () => drawPane($.ui.resolve(e), {
1061 ...view,
1062 now,
1063 onLane: lane => void pressByKey($, `lane:${lane}:more`, e.surface),
1064 onMenu: () => void pressByKey($, 'menu:name', e.surface),
1065 onPick: anchor => void pressByKey($, `plan:${anchor}`, e.surface),
1066 onCard: index =>
1067 void Promise.all([update($, menu, () => false), update($, card, () => ({ anchor: plan.anchor, index }))])
1068 .then(() => void loadUsage($, plan, index))
1069 .then(() => syncAnimation($))
1070 .then(() => refocus($, true)),
1071 onBack: () => void pressByKey($, 'back', e.surface),
1072 onFile: () => void pressByKey($, 'plan-file', e.surface),
1073 }))
1074 })
1075
1076 // The script prints a PLAN-DONE block when a write makes a row Done; the Bash result is never changed. Each block is a toast
1077 // and a notice, once a chime, and one with `0 left` a rainbow; a Bash call that ran the script reads the tracker again.
1078 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
1079 const commandMark = commandMarkOf(boardArgv('', ''))
1080 const result = await next(e)
1081 const blocks = doneBlocks(result.text ?? '')
1082
1083 for (const block of blocks) {
1084 await announce($, block)
1085 }
1086
1087 try {
1088 if (blocks.length > 0) {
1089 await chime($)
1090 }
1091
1092 const anchor = blocks.flatMap(block => finishedAnchor(block) ?? []).pop()
1093
1094 const mark = await read($, finished)
1095
1096 if (anchor !== undefined) {
1097 await markFinished($, anchor)
1098 } else if (mark !== null && blocks.some(block => block.startsWith(`PLAN-DONE ${mark} · `))) {
1099 await unmarkFinished($, mark)
1100 refreshInBackground($)
1101 } else if (blocks.length > 0 || commandMark !== '' && e.command.includes(commandMark)) {
1102 refreshInBackground($)
1103 }
1104 } catch {
1105 // The board catches up at the next refresh; the Bash call is already answered.
1106 }
1107
1108 return result
1109 })
1110}
1111hooks/board.ts 1899 lines1/**
2 * The pure part of the mod: how the board command is called and what it prints (the
3 * PLAN-DONE block, the `--json` board), how a status is colored, how wide a string is on
4 * screen, and what the band and the board show. Nothing here draws or touches `$`.
5 * It shows only what the tracker records.
6 */
7import type { CardRef, PlanBoard, PlanEntry, PlanMatch, PlanRow, PlanStatus, PlanTotals, PlanUsage } from '../types'
8
9export const STATUSES: readonly PlanStatus[] = ['Todo', 'In Progress', 'Verifying', 'Needs Verification', 'Blocked', 'Done', 'Canceled']
10
11export type Style = { color: string; dimColor?: true }
12
13/**
14 * One color per status, as theme keys: every terminal theme defines them and
15 * they follow light and dark. The engine looks a bare name such as 'red' up in
16 * the theme too and finds nothing, so it would draw uncolored. No two statuses
17 * share a key (the v3 review found In Progress and Review resolving to one color).
18 */
19export const STYLE: Record<PlanStatus, Style> = {
20 Todo: { color: 'inactive' }, // gray
21 'In Progress': { color: 'suggestion' }, // blue
22 Verifying: { color: 'warning' }, // yellow, the Verify lane's colour
23 'Needs Verification': { color: 'warning' }, // yellow
24 Blocked: { color: 'error' }, // red
25 Done: { color: 'success' }, // green
26 Canceled: { color: 'inactive', dimColor: true }, // dim gray
27 Other: { color: 'permission' }, // a status the reader does not know
28}
29
30/** The color of a "needs you" marker: used by no status. */
31export const NEEDS_YOU_COLOR = 'merged'
32
33/**
34 * The color of a link, in the note of a task page: the theme's accent for suggestions, which reads on a dark and on a light
35 * theme (`remember` is a dark blue on a dark one).
36 */
37export const LINK_COLOR = 'suggestion'
38
39/** Most urgent first: what "worst" means for a plan. */
40const URGENCY: readonly PlanStatus[] = ['Blocked', 'Verifying', 'Needs Verification', 'In Progress', 'Other', 'Todo', 'Done', 'Canceled']
41
42export const isBlocked = (row: PlanRow): boolean => row.status === 'Blocked'
43
44/** Whether the row is still to do: not Done, not Canceled. */
45export const isOpen = (row: PlanRow): boolean => row.status !== 'Done' && row.status !== 'Canceled'
46
47export const messageOf = (error: unknown) => (error instanceof Error ? error.message : String(error))
48
49const isStatus = (value: unknown): value is PlanStatus => STATUSES.includes(value as PlanStatus)
50
51const asText = (value: unknown): string | undefined => (typeof value === 'string' && value !== '' ? value : undefined)
52
53const isNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value)
54
55/** The row's `usage` when it has the shape the tracker writes, else undefined (an old or damaged row shows "—", never a guess). */
56function usageOf(value: unknown): PlanUsage | undefined {
57 const raw = value as Partial<Record<keyof PlanUsage, unknown>> | null
58 const tokens = raw?.tokens as Record<string, unknown> | null | undefined
59
60 if (
61 typeof raw !== 'object' || raw === null ||
62 !isNumber(raw.working_ms) || !isNumber(raw.main_ms) || !isNumber(raw.subagent_ms) ||
63 typeof raw.effort !== 'object' || raw.effort === null ||
64 typeof tokens !== 'object' || tokens === null ||
65 !isNumber(tokens.input) || !isNumber(tokens.cache_read) || !isNumber(tokens.cache_write) || !isNumber(tokens.output)
66 ) {
67 return undefined
68 }
69
70 return {
71 working_ms: raw.working_ms,
72 main_ms: raw.main_ms,
73 subagent_ms: raw.subagent_ms,
74 effort: raw.effort as Record<string, number>,
75 models: Array.isArray(raw.models) ? raw.models.filter((one): one is string => typeof one === 'string') : [],
76 tokens: { input: tokens.input, cache_read: tokens.cache_read, cache_write: tokens.cache_write, output: tokens.output },
77 cost_usd: isNumber(raw.cost_usd) ? raw.cost_usd : null,
78 computed_at: asText(raw.computed_at) ?? '',
79 }
80}
81
82/** The plan's `usage_totals` when it has the shape the tracker writes, else undefined (the plan line then leaves the tokens and cost out). */
83function totalsOf(value: unknown): PlanTotals | undefined {
84 const raw = value as Record<string, unknown> | null
85
86 if (
87 typeof raw !== 'object' || raw === null ||
88 !isNumber(raw.working_ms) || !isNumber(raw.tokens_in) || !isNumber(raw.tokens_cached) || !isNumber(raw.tokens_out) ||
89 !isNumber(raw.measured) || !isNumber(raw.total)
90 ) {
91 return undefined
92 }
93
94 return {
95 working_ms: raw.working_ms,
96 tokens_in: raw.tokens_in,
97 tokens_cached: raw.tokens_cached,
98 tokens_out: raw.tokens_out,
99 cost: isNumber(raw.cost) ? raw.cost : null,
100 measured: raw.measured,
101 total: raw.total,
102 }
103}
104
105const MATCH_STATES: readonly PlanMatch['state'][] = ['In progress', 'Idle', 'Done', 'Canceled']
106
107/** The plans the board command lists (`matches`): an entry of another shape is dropped; none left is no list. */
108function matchesOf(value: unknown): PlanMatch[] | undefined {
109 const out = (Array.isArray(value) ? value : []).flatMap((one: Record<string, unknown> | null): PlanMatch[] => {
110 const anchor = asText(one?.anchor)
111 const name = asText(one?.name)
112 const state = MATCH_STATES.find(word => word === one?.state)
113 const done = one?.done
114 const total = one?.total
115 const updated = asText(one?.updated)
116
117 return anchor !== undefined && name !== undefined && state !== undefined && isNumber(done) && isNumber(total)
118 ? [{ anchor, name, state, done, total, ...(updated === undefined ? {} : { updated }) }]
119 : []
120 })
121
122 return out.length === 0 ? undefined : out
123}
124
125/** Lower case, with `- _ → : · /` as spaces, so `plan tracker` finds `plan-tracker`. */
126const normText = (text: string): string => text.toLowerCase().replace(/[-_→:·/]/g, ' ')
127
128/** The words of `text`, normalised. */
129const wordsOf = (text: string): string[] => normText(text).split(/\s+/).filter(word => word !== '')
130
131/** Whether a plan stays in the list for the search `text`: every word of it in the plan's name or anchor, in any order (no word: every plan). */
132export function isFound(plan: { anchor: string; name: string }, text: string): boolean {
133 const hay = `${normText(plan.name)} ${normText(plan.anchor)}`
134
135 return wordsOf(text).every(word => hay.includes(word))
136}
137
138/** The plans the list shows for the search `text`: with no words, every plan but the Done and Canceled ones; with words, every plan they find. */
139export function listedMatches<T extends { anchor: string; name: string; state: string }>(plans: readonly T[], text: string): T[] {
140 return wordsOf(text).length === 0 ? plans.filter(one => one.state !== 'Done' && one.state !== 'Canceled') : plans.filter(one => isFound(one, text))
141}
142
143/**
144 * The plan `/plan-tracker <words>` opens at once: the first whose name or anchor equals the words (normalised), else the only one
145 * the words leave in the list; undefined when they leave none or several, or are no words.
146 */
147export function directPick<T extends { anchor: string; name: string }>(plans: readonly T[], text: string): T | undefined {
148 const key = wordsOf(text).join(' ')
149
150 if (key === '') {
151 return undefined
152 }
153
154 const found = plans.filter(plan => isFound(plan, text))
155
156 return plans.find(plan => wordsOf(plan.name).join(' ') === key || wordsOf(plan.anchor).join(' ') === key) ?? (found.length === 1 ? found[0] : undefined)
157}
158
159/**
160 * The plans the list draws from the board command's answer to `*`: its `matches`; when it has none, its `plans` if there are two or more,
161 * latest `updated` first (a command that does not know `*` still lists the plans it prints); else none: one plan opens its board.
162 */
163export function listOf(board: PlanBoard): PlanMatch[] {
164 if (board.matches !== undefined) {
165 return board.matches
166 }
167
168 const at = (plan: { updated?: string }) => (Number.isNaN(Date.parse(plan.updated ?? '')) ? 0 : Date.parse(plan.updated ?? ''))
169
170 return board.plans.length < 2
171 ? []
172 : board.plans
173 .map((plan): PlanMatch => {
174 const state = planStateOf(plan)
175
176 return {
177 anchor: plan.anchor,
178 name: plan.name,
179 state: state === 'Stalled' ? 'In progress' : state,
180 ...countOf(plan),
181 ...(plan.updated === undefined ? {} : { updated: plan.updated }),
182 }
183 })
184 .sort((a, b) => at(b) - at(a))
185}
186
187/**
188 * The script's `--view --json` output as a board, or one line saying what is wrong with it. A field the tracker leaves null
189 * is dropped, so the drawing code reads an absent field, never a null.
190 */
191export function parseBoard(text: string): { board: PlanBoard } | { error: string } {
192 let data: unknown
193
194 try {
195 data = JSON.parse(text)
196 } catch (error) {
197 return { error: `plan-tracker: the board command did not print JSON: ${messageOf(error)}` }
198 }
199
200 const raw = data as { selected?: unknown; message?: unknown; plans?: unknown } | null
201 const isShaped =
202 Array.isArray(raw?.plans) &&
203 raw.plans.every(
204 (plan: { anchor?: unknown; rows?: unknown } | null) =>
205 typeof plan?.anchor === 'string' &&
206 Array.isArray(plan.rows) &&
207 plan.rows.every((row: { task?: unknown; status?: unknown } | null) => typeof row?.task === 'string' && isStatus(row.status)),
208 )
209
210 if (!isShaped) {
211 return { error: 'plan-tracker: the JSON of the board command has no plans[].rows[] with a task and a known status' }
212 }
213
214 const plans = (raw.plans as Record<string, unknown>[]).map((plan): PlanEntry => ({
215 anchor: plan.anchor as string,
216 name: asText(plan.name) ?? (plan.anchor as string),
217 goal: asText(plan.goal) ?? '',
218 plan_file: asText(plan.plan_file) ?? '',
219 ...(asText(plan.created) === undefined ? {} : { created: asText(plan.created) as string }),
220 ...(asText(plan.created_at) === undefined ? {} : { created_at: asText(plan.created_at) as string }),
221 ...(asText(plan.started) === undefined ? {} : { started: asText(plan.started) as string }),
222 ...(asText(plan.finished) === undefined ? {} : { finished: asText(plan.finished) as string }),
223 ...(asText(plan.updated) === undefined ? {} : { updated: asText(plan.updated) as string }),
224 ...(asText(plan.idle_since) === undefined ? {} : { idle_since: asText(plan.idle_since) as string }),
225 ...(totalsOf(plan.usage_totals) === undefined ? {} : { usage_totals: totalsOf(plan.usage_totals) as PlanTotals }),
226 ...(asText(plan.recap) === undefined ? {} : { recap: asText(plan.recap) as string }),
227 ...(asText(plan.recap) === undefined || asText(plan.recap_updated) === undefined ? {} : { recap_updated: asText(plan.recap_updated) as string }),
228 rows: (plan.rows as Record<string, unknown>[]).map((row): PlanRow => {
229 const note = asText(row.note)
230 const route = asText(row.route)
231 const who = asText(row.who)
232 const goal = asText(row.goal)
233 const progress = asText(row.progress)
234 const started = asText(row.started)
235 const finished = asText(row.finished)
236 const stalledAt = asText(row.stalled_since)
237 const stalledSince = stalledAt === undefined || Number.isNaN(Date.parse(stalledAt)) ? undefined : stalledAt
238 const usage = usageOf(row.usage)
239
240 return {
241 task: row.task as string,
242 status: row.status as PlanStatus,
243 updated: asText(row.updated) ?? null,
244 ...(note === undefined ? {} : { note }),
245 ...(route === undefined ? {} : { route }),
246 ...(who === undefined ? {} : { who }),
247 ...(goal === undefined ? {} : { goal }),
248 ...(progress === undefined ? {} : { progress }),
249 ...(started === undefined ? {} : { started }),
250 ...(finished === undefined ? {} : { finished }),
251 ...(stalledSince === undefined ? {} : { stalled_since: stalledSince }),
252 ...(usage === undefined ? {} : { usage }),
253 }
254 }),
255 }))
256
257 const message = asText(raw.message)
258 const matches = matchesOf((raw as { matches?: unknown }).matches)
259
260 return { board: { selected: asText(raw.selected) ?? null, message: message ?? null, plans, ...(matches === undefined ? {} : { matches }) } }
261}
262
263/** The plan the person picked, else the tracker's own choice, else the first. */
264export function planOf(board: PlanBoard, picked: string | null): PlanEntry | undefined {
265 return board.plans.find(plan => plan.anchor === picked) ?? board.plans.find(plan => plan.anchor === board.selected) ?? board.plans[0]
266}
267
268/** What decides the plan's color: its most urgent open row (Blocked, Verifying, Needs Verification, In Progress, Todo), green once none is left. */
269export function worstStatus(plan: PlanEntry): PlanStatus {
270 return URGENCY.find(status => status !== 'Done' && status !== 'Canceled' && plan.rows.some(row => row.status === status)) ?? 'Done'
271}
272
273/** Rows Done and rows to reach (every row but the Canceled): what `n/m` counts. A Canceled row is not shown anywhere. */
274export function countOf(plan: PlanEntry): { done: number; total: number } {
275 const canceled = plan.rows.filter(row => row.status === 'Canceled').length
276
277 return { done: plan.rows.filter(row => row.status === 'Done').length, total: plan.rows.length - canceled }
278}
279
280/** `5/12`. */
281export const countText = (plan: PlanEntry): string => `${countOf(plan).done}/${countOf(plan).total}`
282
283/**
284 * A plan's state word: `Done` once closed, `In progress` while a row runs, `Stalled` when every running row is stalled, else `Idle`.
285 * There is no "Todo" state: a plan nobody started is Idle. `Canceled` (an abandoned plan) comes only from a board command's list.
286 */
287export type PlanState = 'In progress' | 'Stalled' | 'Idle' | 'Done' | 'Canceled'
288
289/** The state words, widest first, for aligning a column of them. */
290export const STATE_COLUMNS = 'In progress'.length
291
292/** One color per state: the In Progress lane's, gray like the Todo lane, the Done lane's, dim gray like a Canceled row. */
293export const STATE_STYLE: Record<PlanState, Style> = {
294 'In progress': STYLE['In Progress'],
295 Stalled: STYLE.Todo,
296 Idle: STYLE.Todo,
297 Done: STYLE.Done,
298 Canceled: STYLE.Canceled,
299}
300
301/**
302 * Done = the plan is closed (the tracker gave `finished`, or every row is Done or Canceled with one Done). In progress = open,
303 * with a row In Progress or Verifying. Stalled = open, with running rows that are all stalled. Idle = open and nothing running (rows
304 * Todo, Needs Verification or Blocked, or no row started).
305 */
306export function planStateOf(plan: PlanEntry): PlanState {
307 if (plan.finished !== undefined || isComplete(plan)) {
308 return 'Done'
309 }
310
311 return hasInProgress(plan) ? (hasWorking(plan) ? 'In progress' : 'Stalled') : 'Idle'
312}
313
314/**
315 * The state word as the header, the band and the plan list write it, and the one place the board shows a plan's time: `Done at 21:40`
316 * (the plan's `finished`, else its `updated`), `Stalled since 19:23` (the newest `stalled_since` of its running rows), `Idle since 19:40`
317 * (the plan's `idle_since`); a time on another day reads `Oct 11 19:23`. Plain `Done`, `Stalled` or `Idle` when the time is not there or
318 * not a clock time (a `PROGRESS.md` has none); `In progress` is always the bare label. Colors follow the bare state (`planStateOf`); the
319 * room it takes is this text's.
320 */
321export function planStateText(plan: PlanEntry, now: number = Date.now()): string {
322 const state = planStateOf(plan)
323
324 if (state === 'Done') {
325 const at = [plan.finished, plan.updated].map(time => (time === undefined ? undefined : sinceText(time, now))).find(one => one !== undefined)
326
327 return at === undefined ? state : `Done at ${at}`
328 }
329
330 if (state === 'Stalled') {
331 const times = plan.rows.flatMap(row => (isStalled(row) ? [row.stalled_since as string] : []))
332 const newest = times.reduce((latest, one) => (Date.parse(one) > Date.parse(latest) ? one : latest))
333 const at = sinceText(newest, now)
334
335 return at === undefined ? state : `${state} since ${at}`
336 }
337
338 const at = state === 'Idle' && plan.idle_since !== undefined ? sinceText(plan.idle_since, now) : undefined
339
340 return at === undefined ? state : `${state} since ${at}`
341}
342
343// ---------------------------------------------------------------- width
344
345const WIDE: readonly (readonly [number, number])[] = [
346 [0x1100, 0x115f],
347 [0x231a, 0x231b],
348 [0x23e9, 0x23ec],
349 [0x2614, 0x2615],
350 [0x2705, 0x2705],
351 [0x270a, 0x270b],
352 [0x2728, 0x2728],
353 [0x274c, 0x274c],
354 [0x2753, 0x2755],
355 [0x2757, 0x2757],
356 [0x2795, 0x2797],
357 [0x2b1b, 0x2b1c],
358 [0x2b50, 0x2b50],
359 [0x2b55, 0x2b55],
360 [0x2e80, 0x303e],
361 [0x3041, 0x33ff],
362 [0x3400, 0x4dbf],
363 [0x4e00, 0x9fff],
364 [0xa000, 0xa4cf],
365 [0xac00, 0xd7a3],
366 [0xf900, 0xfaff],
367 [0xfe30, 0xfe6f],
368 [0xff00, 0xff60],
369 [0xffe0, 0xffe6],
370 [0x1f300, 0x1f64f],
371 [0x1f900, 0x1f9ff],
372 [0x20000, 0x3fffd],
373]
374
375const ZERO: readonly (readonly [number, number])[] = [
376 [0x0300, 0x036f],
377 [0x200b, 0x200f],
378 [0x20d0, 0x20ff],
379 [0xfe00, 0xfe0f],
380]
381
382const within = (ranges: readonly (readonly [number, number])[], cp: number) => ranges.some(([from, to]) => cp >= from && cp <= to)
383
384/** Columns one code point takes: 2 for CJK and emoji, 0 for marks and controls, else 1 (`■ ▸ · — ×` are 1). */
385export function charWidth(cp: number): 0 | 1 | 2 {
386 if (cp < 32 || (cp >= 0x7f && cp < 0xa0) || within(ZERO, cp)) {
387 return 0
388 }
389
390 return within(WIDE, cp) ? 2 : 1
391}
392
393export function displayWidth(text: string): number {
394 let width = 0
395
396 for (const char of text) {
397 width += charWidth(char.codePointAt(0) ?? 0)
398 }
399
400 return width
401}
402
403/** Characters a line does not start with (closing marks) and does not end with (opening marks). */
404const NO_START = ',。、;:!?)」』】》〉,.;:!?)]}'
405const NO_END = '(「『【《〈([{'
406
407/**
408 * Where `text` is broken when its first `cut` code units fit a line: the last space that fits, or the last boundary next
409 * to a wide (CJK) character, which has no spaces; else the cut itself. `skip` is the code units dropped at the break (the space).
410 */
411function breakOf(text: string, cut: number): { at: number; skip: number } {
412 let best = -1
413 let skip = 0
414 let prev = ''
415 let idx = 0
416
417 for (const char of text) {
418 if (idx > cut) {
419 break
420 }
421
422 if (char === ' ' && idx > 0) {
423 best = idx
424 skip = 1
425 } else if (idx > 0 && char !== ' ' && prev !== ' ' && prev !== '') {
426 const isWide = charWidth(char.codePointAt(0) ?? 0) === 2 || charWidth(prev.codePointAt(0) ?? 0) === 2
427
428 if (isWide && !NO_START.includes(char) && !NO_END.includes(prev)) {
429 best = idx
430 skip = 0
431 }
432 }
433
434 prev = char
435 idx += char.length
436 }
437
438 return best > 0 ? { at: best, skip } : { at: Math.max(cut, 1), skip: 0 }
439}
440
441/** `text` cut to at most `cols` columns; a cut ends in `…`. */
442export function truncate(text: string, cols: number): string {
443 if (cols <= 0) {
444 return ''
445 }
446
447 if (displayWidth(text) <= cols) {
448 return text
449 }
450
451 let kept = ''
452 let used = 0
453
454 for (const char of text) {
455 const width = charWidth(char.codePointAt(0) ?? 0)
456
457 if (used + width > cols - 1) {
458 break
459 }
460
461 kept += char
462 used += width
463 }
464
465 return `${kept}…`
466}
467
468/**
469 * `text` broken into lines of at most `cols` columns, at a space when the line has one and else wherever it fills (CJK
470 * has no spaces); a line never starts with a space. Fewer than 1 column leaves the text whole. With `maxLines`, the
471 * last line takes all that is left of the text, cut with a `…` when it does not fit.
472 */
473export function wrap(text: string, cols: number, maxLines = Infinity): string[] {
474 if (cols < 1) {
475 return [text]
476 }
477
478 const lines: string[] = []
479 let rest = text.trim()
480
481 while (displayWidth(rest) > cols) {
482 if (lines.length === maxLines - 1) {
483 return [...lines, truncate(rest, cols)]
484 }
485
486 let used = 0
487 let cut = 0
488
489 for (const char of rest) {
490 const width = charWidth(char.codePointAt(0) ?? 0)
491
492 if (used + width > cols) {
493 break
494 }
495
496 used += width
497 cut += char.length
498 }
499
500 const { at, skip } = breakOf(rest, cut)
501
502 lines.push(rest.slice(0, at))
503 rest = rest.slice(at + skip).trimStart()
504 }
505
506 return [...lines, rest]
507}
508
509// ---------------------------------------------------------------- band
510
511export const FILE_LABEL = 'Plan file ›'
512
513/** What the band's right end says while the board shows the plan file: the way back, which never scrolls away. */
514export const BOARD_LABEL = '‹ Board'
515
516/** Cells of the progress bar when the band has room. */
517export const BAR_COLUMNS = 12
518
519/** Columns the band's fixed parts take besides the name, the bar and the count: `● `, ` ▾`, two gaps, the space before the count, `Plan file ›`. */
520const BAND_FIXED = 2 + 2 + 2 + 1 + 2 + displayWidth(FILE_LABEL)
521
522/** Columns of name the band keeps before the bar starts to shrink. */
523const MIN_BAND_NAME = 8
524
525export type BandLine = { name: string; state: PlanState; /** The state word as written (`Stalled since 19:23`): the room it takes. */ stateText: string; filled: number; empty: number; count: string }
526
527/** Cells of a `width`-cell bar that are filled for `done` of `total`: at least 1 once started, all of them only when complete. */
528export function filledOf(done: number, total: number, width: number): number {
529 if (total <= 0 || done <= 0) {
530 return 0
531 }
532
533 return done >= total ? width : Math.min(width - 1, Math.max(1, Math.round((done / total) * width)))
534}
535
536/**
537 * The band's one row without the mascot, cut to `cols` columns: the name (cut first), the state word (never cut; its whole text, `Stalled
538 * since 19:23` and `Idle since 19:40` included, takes room from the name), the 12-cell bar (shrunk to 4 cells at the least, and only when
539 * the name would keep under 8 columns) and `done/total`. `now` decides whether a state time is today's.
540 */
541export function bandLine(plan: PlanEntry, cols: number, now: number = Date.now()): BandLine {
542 const { done, total } = countOf(plan)
543 const count = `${done}/${total}`
544 const state = planStateOf(plan)
545 const stateText = planStateText(plan, now)
546 // The state word is never cut: it takes a space and its width from the name's room.
547 const rest = cols - BAND_FIXED - displayWidth(count) - 1 - displayWidth(stateText)
548 // The bar keeps its 12 cells and the name is cut; only when less than 8 columns would be left for the name does the bar shrink.
549 const bar = Math.min(BAR_COLUMNS, Math.max(4, rest - MIN_BAND_NAME))
550 const filled = filledOf(done, total, bar)
551
552 return { name: truncate(plan.name, Math.max(4, rest - bar)), state, stateText, filled, empty: bar - filled, count }
553}
554
555/** What the terminal band draws in the room its column leaves (`bandFit`). */
556export type BandFit = {
557 name: string
558 state: PlanState
559 /** The state word as written (`Stalled since 19:23`). */
560 stateText: string
561 /** Whether the state word shows: it is dropped whole, never cut. */
562 hasState: boolean
563 filled: number
564 empty: number
565 count: string
566 /** Whether the right end ("Plan file ›") shows. */
567 hasFile: boolean
568 /** True when the row cannot hold even the name and the count: the progress takes a second row under it. */
569 isStacked: boolean
570}
571
572/** Columns of name the terminal band keeps before it drops anything else. */
573const MIN_FIT_NAME = 4
574
575/**
576 * The terminal band's parts for a row of `cols` columns after the mascot and its space. The name is cut first (to 4 columns at
577 * the least, the bar shrinking to 4 cells before the name goes under 8); then the state word (its whole text, `Stalled since 19:23`
578 * included) is dropped whole, then the right end, then the bar; when even the name and the count do not fit one row, the progress goes
579 * to a second row of `cols` plus the mascot's columns. Nothing is ever cut mid-word or left wider than `cols`. `now` decides whether
580 * a state time is today's.
581 */
582export function bandFit(plan: PlanEntry, cols: number, hasChoice: boolean, fileWidth: number, now: number = Date.now()): BandFit {
583 const { done, total } = countOf(plan)
584 const count = `${done}/${total}`
585 const state = planStateOf(plan)
586 const stateText = planStateText(plan, now)
587 const countCols = displayWidth(count)
588 const stateBit = 1 + displayWidth(stateText) + 2
589 // The columns of every part but the name and the bar's cells: the dot, the arrow, the state, the bar's trailing space, the count, the right end.
590 const fixed = (hasState: boolean, hasBar: boolean, hasFile: boolean) =>
591 2 + (hasChoice ? 2 : 0) + (hasState ? stateBit : 1) + (hasBar ? 1 : 0) + countCols + (hasFile ? 2 + fileWidth : 0)
592 const stages = [
593 { hasState: true, hasFile: true, hasBar: true },
594 { hasState: false, hasFile: true, hasBar: true },
595 { hasState: false, hasFile: false, hasBar: true },
596 { hasState: false, hasFile: false, hasBar: false },
597 ]
598
599 for (const stage of stages) {
600 const rest = cols - fixed(stage.hasState, stage.hasBar, stage.hasFile)
601
602 if (rest >= MIN_FIT_NAME + (stage.hasBar ? 4 : 0)) {
603 const bar = stage.hasBar ? Math.min(BAR_COLUMNS, Math.max(4, rest - MIN_BAND_NAME)) : 0
604 const filled = filledOf(done, total, bar)
605
606 return { name: truncate(plan.name, rest - bar), state, stateText, hasState: stage.hasState, filled, empty: bar - filled, count, hasFile: stage.hasFile, isStacked: false }
607 }
608 }
609
610 // Stacked: the first row keeps the dot, the name and the arrow; the second row (the whole frame width) the state, the bar and the count.
611 const wide = cols + BAND_MASCOT_SPACE
612 const second = (hasState: boolean) => wide - (hasState ? stateBit : 1) - 1 - countCols
613 const hasState = second(true) >= 4
614 const bar = Math.min(BAR_COLUMNS, second(hasState))
615 const barCells = bar >= 4 ? bar : 0
616 const filled = filledOf(done, total, barCells)
617 const name = truncate(plan.name, Math.max(1, cols - 2 - (hasChoice ? 2 : 0)))
618
619 return { name, state, stateText, hasState, filled, empty: barCells - filled, count, hasFile: false, isStacked: true }
620}
621
622/** A plan with nothing left to do: each row Done or Canceled, at least one of them Done (an all-Canceled plan was not completed). */
623export const isComplete = (plan: PlanEntry): boolean => plan.rows.every(row => !isOpen(row)) && plan.rows.some(row => row.status === 'Done')
624
625/** The plan as the band draws a plan just finished: every row Done, the Canceled ones as they were. */
626export const asFinished = (plan: PlanEntry): PlanEntry => ({
627 ...plan,
628 rows: plan.rows.map((row): PlanRow => (row.status === 'Canceled' ? row : { ...row, status: 'Done' })),
629})
630
631/** The plan the band draws: `plan` as it is, or as finished when `finished` names it (a PLAN-DONE with 0 left). */
632export const bandPlanOf = (plan: PlanEntry, finished: string | null): PlanEntry => (finished === plan.anchor ? asFinished(plan) : plan)
633
634/** Whether a row is running right now: In Progress, or being verified (a Blocked row is not: its mascot is frozen). */
635export const isRunning = (row: PlanRow): boolean => row.status === 'In Progress' || row.status === 'Verifying'
636
637/** Whether a row of the plan is running: In Progress or Verifying. */
638export const hasInProgress = (plan: PlanEntry): boolean => plan.rows.some(isRunning)
639
640/** Whether a running row is stalled: the board command says no session is working on it (`stalled_since`). */
641export const isStalled = (row: PlanRow): boolean => isRunning(row) && row.stalled_since !== undefined
642
643/** Whether a row of the plan is running and not stalled: what moves the band's mascot and the cards'. */
644export const hasWorking = (plan: PlanEntry): boolean => plan.rows.some(row => isRunning(row) && !isStalled(row))
645
646/** Rows of the plan still to do: not Done, not Canceled. */
647export const openCount = (plan: PlanEntry): number => plan.rows.filter(isOpen).length
648
649/** Rainbow hues, red to violet; raw hex, so they do not follow the theme. */
650export const RAINBOW: readonly string[] = ['#ff5555', '#ff9933', '#ffd633', '#55cc66', '#33b5e5', '#5577ff', '#aa66ee']
651
652/** The hue of band cell `cell` at animation frame `frame`: each frame moves the hues one cell along. */
653export const rainbowAt = (cell: number, frame: number): string => RAINBOW[(cell + frame) % RAINBOW.length] ?? '#ffffff'
654
655// ---------------------------------------------------------------- board
656
657/** A row on a lane; `index` is its place in the plan's `rows`, which names the card. */
658export type Card = { row: PlanRow; index: number }
659
660export type LaneKey = 'Todo' | 'In Progress' | 'Verify' | 'Done' | 'Other'
661
662/** `more` is how many cards the lane does not show; `moreLabel` the line that says so (`+2 more`, `+1 blocked · 12 running`). */
663export type Lane = { key: LaneKey; header: string; color: string; cards: Card[]; more: number; moreLabel: string }
664
665/** The overflow line of an expanded lane, which folds it back to its cap. */
666export const SHOW_LESS = 'Show less'
667
668/** The overflow button's label: the line, then ▴ to fold or ▾ to unfold (a Button draws the `[ ]` itself). */
669export const moreButton = (label: string): string => `${label} ${label === SHOW_LESS ? '▴' : '▾'}`
670
671/** Todo and Done show this many cards, then `+N more`. */
672export const CAPPED = 3
673
674/** In Progress shows this many cards, Blocked ones first, then a line counting the rest. */
675export const DOING_SHOWN = 5
676
677/** Blocked cards the In Progress lane shows at the most, so that running cards (the mascot) stay in view. */
678export const BLOCKED_SHOWN = 3
679
680/** Verify shows this many cards, then a line counting the rest and how many of them wait on her. */
681export const VERIFY_SHOWN = 5
682
683const timeOf = (row: PlanRow) => (row.updated === null ? 0 : Date.parse(row.updated) || 0)
684
685/**
686 * `cards` with the latest `updated` first; cards updated at the same moment keep the later row first. Cards none of which has a time
687 * (a plain PROGRESS.md) keep their file order, so a lane with no times is not reversed by the tie rule.
688 */
689const latestFirst = (cards: Card[]): Card[] =>
690 cards.some(card => timeOf(card.row) > 0) ? [...cards].sort((a, b) => timeOf(b.row) - timeOf(a.row) || b.index - a.index) : cards
691
692/**
693 * The four lanes of a plan, each listing its latest updated task first. Verifying and Needs Verification are Verify; Blocked stays on the
694 * In Progress lane (its card is red, first in the lane, 3 at the most while running cards fill the rest of 5); a Canceled row is no card
695 * and is counted nowhere. The groups of a lane keep their places and each is sorted inside: Blocked, then running in In Progress;
696 * verifying, then waiting on you, then other waiting in Verify. A lane whose cards have no times keeps file order.
697 * Todo and Done keep 3 cards (Done: the latest).
698 */
699export function lanesOf(plan: PlanEntry, expanded: readonly LaneKey[] = []): Lane[] {
700 const cards = plan.rows.map((row, index): Card => ({ row, index }))
701 const of = (...statuses: PlanStatus[]) => cards.filter(card => statuses.includes(card.row.status))
702 const todo = latestFirst(of('Todo'))
703 const blocked = latestFirst(of('Blocked'))
704 const running = latestFirst(of('In Progress'))
705 // Blocked cards first, but at most 3 unless there are too few running cards to fill the lane: a lane of red cards only
706 // reads "everything is stuck" when 12 of 18 are running.
707 const blockedShown = Math.min(blocked.length, Math.max(BLOCKED_SHOWN, DOING_SHOWN - running.length))
708 const runningShown = Math.min(running.length, DOING_SHOWN - blockedShown)
709 const isOpen = (key: LaneKey) => expanded.includes(key)
710 // An expanded lane shows every card in the same order: Blocked first in In Progress.
711 const doing = isOpen('In Progress') ? [...blocked, ...running] : [...blocked.slice(0, blockedShown), ...running.slice(0, runningShown)]
712 // What the collapsed lane hides; a lane that hides nothing has nothing to expand, whatever its state.
713 const hiddenBlocked = blocked.length - blockedShown
714 const hiddenRunning = running.length - runningShown
715 const isFoldable = (key: LaneKey, count: number) => isOpen(key) && count > 0
716 // Verify: rows being verified now first, then the ones waiting on her (◆ needs you), then the other waiting rows.
717 const verifying = latestFirst(of('Verifying'))
718 const waiting = latestFirst(of('Needs Verification'))
719 const verify = [...verifying, ...waiting.filter(card => needsYou(card.row)), ...waiting.filter(card => !needsYou(card.row))]
720 // What the collapsed Verify lane hides, and how many of those wait on her.
721 const hiddenVerify = verify.slice(VERIFY_SHOWN)
722 const hiddenAsks = hiddenVerify.filter(card => needsYou(card.row)).length
723 // Latest first; rows updated at the same moment keep the later row first.
724 const done = of('Done').sort((a, b) => timeOf(b.row) - timeOf(a.row) || b.index - a.index)
725
726 // `+3 blocked · 10 running`: the plus leads the line once; a part that is zero is left out.
727 const hidden = (...parts: [number, string][]) => {
728 const line = parts.filter(([count]) => count > 0).map(([count, word]) => `${count} ${word}`).join(' · ')
729
730 return line === '' ? '' : `+${line}`
731 }
732
733 // A status the markdown reader does not know has its own lane, after the four, only while a row has it; named for the status
734 // when the rows share one.
735 const other = of('Other')
736 const otherNames = [...new Set(other.map(card => card.row.statusText ?? 'Other'))]
737 const otherLane: Lane[] =
738 other.length === 0
739 ? []
740 : [
741 {
742 key: 'Other',
743 header: `${otherNames.length === 1 ? otherNames[0] : 'Other'} ${other.length}`,
744 color: STYLE.Other.color,
745 cards: isOpen('Other') ? other : other.slice(0, CAPPED),
746 more: isOpen('Other') ? 0 : Math.max(0, other.length - CAPPED),
747 moreLabel: isFoldable('Other', other.length - CAPPED) ? SHOW_LESS : isOpen('Other') ? '' : hidden([other.length - CAPPED, 'more']),
748 },
749 ]
750
751 return [
752 {
753 key: 'Todo',
754 header: `Todo ${todo.length}`,
755 color: STYLE.Todo.color,
756 cards: isOpen('Todo') ? todo : todo.slice(0, CAPPED),
757 more: isOpen('Todo') ? 0 : Math.max(0, todo.length - CAPPED),
758 moreLabel: isFoldable('Todo', todo.length - CAPPED) ? SHOW_LESS : isOpen('Todo') ? '' : hidden([todo.length - CAPPED, 'more']),
759 },
760 {
761 key: 'In Progress',
762 header: `In Progress ${blocked.length + running.length}${blocked.length > 0 ? ` · ${blocked.length} blocked` : ''}`,
763 color: STYLE['In Progress'].color,
764 cards: doing,
765 more: isOpen('In Progress') ? 0 : hiddenBlocked + hiddenRunning,
766 moreLabel: isFoldable('In Progress', hiddenBlocked + hiddenRunning)
767 ? SHOW_LESS
768 : isOpen('In Progress')
769 ? ''
770 : hidden([hiddenBlocked, 'blocked'], [hiddenRunning, 'running']),
771 },
772 {
773 key: 'Verify',
774 header: `Verify ${verify.length}${verifying.length > 0 ? ` · ${verifying.length} verifying` : ''}`,
775 color: STYLE['Needs Verification'].color,
776 cards: isOpen('Verify') ? verify : verify.slice(0, VERIFY_SHOWN),
777 more: isOpen('Verify') ? 0 : hiddenVerify.length,
778 moreLabel: isFoldable('Verify', hiddenVerify.length)
779 ? SHOW_LESS
780 : isOpen('Verify')
781 ? ''
782 : hidden([hiddenVerify.length, 'more'], [hiddenAsks, 'needs you']),
783 },
784 {
785 key: 'Done',
786 header: `Done ${done.length}`,
787 color: STYLE.Done.color,
788 cards: isOpen('Done') ? done : done.slice(0, CAPPED),
789 more: isOpen('Done') ? 0 : Math.max(0, done.length - CAPPED),
790 moreLabel: isFoldable('Done', done.length - CAPPED) ? SHOW_LESS : isOpen('Done') ? '' : hidden([done.length - CAPPED, 'more']),
791 },
792 ...otherLane,
793 ]
794}
795
796/** The pane lays the lanes side by side from this width, stacked below it. */
797export const SIDE_BY_SIDE_FROM = 100
798
799/** Columns between two lanes side by side: two, so a title that fills its lane does not read as one phrase with the next. */
800export const LANE_GAP = 2
801
802/** Columns one lane gets side by side: four lanes and the gaps between them. */
803export const laneWidth = (bodyColumns: number, count = 4) => Math.floor((bodyColumns - (count - 1) * LANE_GAP) / count)
804
805/** A card is a Box with a round border and one column of padding on each side: four columns in all. */
806export const BORDER_COLUMNS = 4
807
808/**
809 * Claude Code's mascot, the orange pixel creature, as a small sprite of block characters: 2 rows of `MASCOT_COLUMNS`
810 * columns, in two poses (the second has its arms up and its legs shifted).
811 */
812export type Pose = readonly [string, string]
813
814export const MASCOT: readonly [Pose, Pose] = [
815 ['▄█▜▛█▄', ' ▛▛▜▜ '],
816 ['▚█▜▛█▞', ' ▜▜▛▛ '],
817]
818
819/** The quadrants (upper left, upper right, lower left, lower right) each block character of the mascot fills. */
820const QUADRANTS: Readonly<Record<string, readonly [number, number, number, number]>> = {
821 '▄': [0, 0, 1, 1],
822 '█': [1, 1, 1, 1],
823 '▜': [1, 1, 0, 1],
824 '▛': [1, 1, 1, 0],
825 '▟': [0, 1, 1, 1],
826 '▙': [1, 0, 1, 1],
827 '▚': [1, 0, 0, 1],
828 '▞': [0, 1, 1, 0],
829}
830
831/** The colours of the mascot as raw hex, for a surface that draws it as a picture and cannot look up a theme key. */
832export const SPRITE_COLORS: Readonly<Record<string, string>> = { claude: '#D77757', error: '#E5484D' }
833
834/** The animation frame's period in ms: what the one timer ticks at, and so how long a pose lasts (`MASCOT_EVERY` frames). */
835export const FRAME_MS = 120
836
837/** The mascot's rows as an SVG pixel sprite: every block character is 2×2 sub-pixels, one `<rect>` for each one it fills. */
838export function spriteSvg(rows: readonly string[], color: string): { source: string; rects: number } {
839 const fill = SPRITE_COLORS[color] ?? color
840 const cols = Math.max(...rows.map(row => [...row].length))
841 const rects = spriteRects(rows)
842
843 const source = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${cols * 2} ${rows.length * 2}" preserveAspectRatio="none" shape-rendering="crispEdges"><g fill="${fill}">${rects.join('')}</g></svg>`
844
845 return { source, rects: rects.length }
846}
847
848/**
849 * Both poses in one SVG that switches between them by itself (SMIL `<animate>` on each group's opacity, discrete), a pose
850 * lasting what it lasts on the terminal: `MASCOT_EVERY` frames of `FRAME_MS`. The desktop animates this without a redraw, which
851 * would issue the buttons new handles. `rects` counts the rects of both poses.
852 */
853export function spriteSvgAnimated(poses: readonly [readonly string[], readonly string[]], color: string): { source: string; rects: number } {
854 const fill = SPRITE_COLORS[color] ?? color
855 const cols = Math.max(...poses[0].map(row => [...row].length))
856 const lists = poses.map(spriteRects)
857 const cycle = MASCOT_EVERY * FRAME_MS * 2
858
859 const group = (rects: string[], values: string) =>
860 `<g fill="${fill}" opacity="${values.split(';')[0]}">${rects.join('')}<animate attributeName="opacity" values="${values}" keyTimes="0;0.5" calcMode="discrete" dur="${cycle}ms" repeatCount="indefinite"/></g>`
861
862 const source = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${cols * 2} ${poses[0].length * 2}" preserveAspectRatio="none" shape-rendering="crispEdges">${group(lists[0] ?? [], '1;0')}${group(lists[1] ?? [], '0;1')}</svg>`
863
864 return { source, rects: lists.reduce((all, list) => all + list.length, 0) }
865}
866
867/** The `<rect>`s of a pose, one per filled sub-pixel. */
868function spriteRects(rows: readonly string[]): string[] {
869 const rects: string[] = []
870
871 rows.forEach((row, y) =>
872 [...row].forEach((char, x) =>
873 (QUADRANTS[char] ?? [0, 0, 0, 0]).forEach((isOn, at) => {
874 if (isOn === 1) {
875 rects.push(`<rect x="${x * 2 + (at % 2)}" y="${y * 2 + Math.floor(at / 2)}" width="1" height="1"/>`)
876 }
877 }),
878 ),
879 )
880
881 return rects
882}
883
884/** CSS pixels of the card's sprite (6 columns, 2 rows of text) and of the band's (5 columns, 1 row). */
885export const CARD_SPRITE_PX = { width: 36, height: 24 } as const
886export const BAND_SPRITE_PX = { width: 24, height: 16 } as const
887
888/** Cells the desktop gives the card's and the band's sprite beside it (a cell is about 8 px). */
889export const CARD_SPRITE_CELLS = 5
890export const BAND_SPRITE_CELLS = 4
891
892/** The desktop's solid bars: the Svg bar's size in CSS pixels, and the card's fixed width in cells (the Client region has no Svg). */
893export const BAR_PX = { width: 64, height: 6 } as const
894export const CARD_BAR_CELLS = 8
895
896/**
897 * A progress bar as an SVG: a rounded track and a rounded fill of `fraction` of it. With `colors` the fill is those colours in
898 * equal stripes (the band's rainbow), clipped to the same rounded shape.
899 */
900export function barSvg(fraction: number, color: string, colors?: readonly string[]): string {
901 const { width, height } = BAR_PX
902 const radius = height / 2
903 const part = Math.min(1, Math.max(0, fraction))
904 const fill = part === 0 ? 0 : Math.max(height, Math.round(width * part))
905 const stripe = colors === undefined ? 0 : fill / colors.length
906
907 const body =
908 fill === 0
909 ? ''
910 : colors === undefined
911 ? `<rect width="${fill}" height="${height}" rx="${radius}" fill="${color}"/>`
912 : `<clipPath id="f"><rect width="${fill}" height="${height}" rx="${radius}"/></clipPath><g clip-path="url(#f)">${colors.map((one, at) => `<rect x="${(at * stripe).toFixed(1)}" width="${(stripe + 0.5).toFixed(1)}" height="${height}" fill="${one}"/>`).join('')}</g>`
913
914 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="${width}" height="${height}" rx="${radius}" fill="${TRACK_COLOR}"/>${body}</svg>`
915}
916
917/** Theme colours as hex, for a picture (an Svg cannot look a theme key up): the statuses' colours as the light theme has them. */
918export const HEX: Readonly<Record<string, string>> = {
919 inactive: '#8A8A85',
920 suggestion: '#3B6FD4',
921 warning: '#B7791F',
922 error: '#E5484D',
923 success: '#3C9D5A',
924 claude: '#D77757',
925}
926
927/** The empty part of a solid progress bar on the desktop: a light grey. */
928export const TRACK_COLOR = '#D9D6CC'
929
930export const MASCOT_COLUMNS = 6
931export const MASCOT_ROWS = 2
932
933/** Animation frames (ticks of the one timer) each pose lasts: 3 ticks of 120 ms, about 360 ms. */
934export const MASCOT_EVERY = 3
935
936/** The card mascot's two rows at animation frame `frame`. */
937export const mascotAt = (frame: number): Pose => MASCOT[Math.floor(frame / MASCOT_EVERY) % MASCOT.length] ?? MASCOT[0]
938
939/**
940 * The band's mascot: Claude Code's creature again, one row of `BAND_MASCOT_COLUMNS` columns, in two poses (arms down,
941 * arms up). The two-row `MASCOT` stays on the In Progress cards.
942 */
943export const BAND_MASCOT: readonly [string, string] = ['▜▟█▙▛', '▞▟█▙▚']
944
945export const BAND_MASCOT_COLUMNS = 5
946
947/** Columns the band's mascot and the space after it take. */
948export const BAND_MASCOT_SPACE = BAND_MASCOT_COLUMNS + 1
949
950/** The band's mascot at animation frame `frame`: the pose changes every `MASCOT_EVERY` ticks, as the cards' does. */
951export const bandMascotAt = (frame: number): string => BAND_MASCOT[Math.floor(frame / MASCOT_EVERY) % BAND_MASCOT.length] ?? BAND_MASCOT[0]
952
953/**
954 * The band mascot's pose: arms up and still when the plan is complete (the rainbow), moving while a row of the plan is
955 * In Progress, else arms down and still.
956 */
957export const bandPose = (plan: PlanEntry, frame: number): string => BAND_MASCOT[bandPoseIndex(plan, frame)] ?? BAND_MASCOT[0]
958
959/** Which of the two poses the band's mascot is in: the same choice `bandPose` makes, as an index (the desktop draws the card sprite's pose of that index). */
960export const bandPoseIndex = (plan: PlanEntry, frame: number): 0 | 1 =>
961 isComplete(plan) ? 1 : hasWorking(plan) ? (Math.floor(frame / MASCOT_EVERY) % 2 === 0 ? 0 : 1) : 0
962
963/** Columns the mascot and the space after it take on a card. */
964export const MASCOT_SPACE = MASCOT_COLUMNS + 1
965
966/** Fewest columns of task name an In Progress card keeps beside the mascot; a narrower card has no mascot. */
967export const MIN_NAME_COLUMNS = 8
968
969/** Whether a card `cardWidth` wide has the mascot: its inside holds the mascot, a space and `MIN_NAME_COLUMNS`. */
970export const hasMascot = (cardWidth: number) => cardWidth - BORDER_COLUMNS >= MASCOT_SPACE + MIN_NAME_COLUMNS
971
972/** The index in `plan.rows` of the card whose page the pane shows, or null while it shows the board. */
973export const detailOf = (plan: PlanEntry, open: CardRef | null): number | null =>
974 open !== null && open.anchor === plan.anchor && plan.rows[open.index] !== undefined ? open.index : null
975
976/** Whether the band draws the rainbow: the plan is the one a PLAN-DONE with 0 left finished (until her next prompt) and has a Done row. */
977export const isRainbow = (plan: PlanEntry, finished: string | null): boolean => finished === plan.anchor && isComplete(bandPlanOf(plan, finished))
978
979/**
980 * What moves on screen: the band when it draws the rainbow of a finished plan or the plan has an In Progress row that is not stalled
981 * (its mascot), the pane's mascots while it is up with the board and such a card. A complete plan nothing finished stands still.
982 */
983export function movement(plan: PlanEntry, view: { finished: string | null; isPaneUp: boolean; isBoard: boolean }): { band: boolean; pane: boolean } {
984 const drawn = bandPlanOf(plan, view.finished)
985
986 return {
987 band: isRainbow(plan, view.finished) || hasWorking(drawn),
988 pane: view.isPaneUp && view.isBoard && hasWorking(plan),
989 }
990}
991
992// ---------------------------------------------------------------- settings and file links
993
994/** What the person set in the plugin's settings (`userConfig`); `applyOptions` fills it when the module loads. */
995export const settings = { boardLocation: 'PROGRESS.md', boardCommand: '', usageCommand: '', yourName: '', root: '' }
996
997/** Reads the four settings out of the options the engine hands the module (a missing or blank one keeps its default). */
998export function applyOptions(options: Readonly<Record<string, unknown>> | undefined): void {
999 const text = (key: string, fallback: string) => {
1000 const value = options?.[key]
1001
1002 return typeof value === 'string' && value.trim() !== '' ? value.trim() : fallback
1003 }
1004
1005 settings.boardLocation = text('board_location', 'PROGRESS.md')
1006 settings.boardCommand = text('board_command', '')
1007 settings.usageCommand = text('usage_command', '')
1008 settings.yourName = text('your_name', '')
1009}
1010
1011/** Whether `name` is the person: `you` in any case, or their own name (the setting `your_name`, when set). */
1012export const isYou = (name: string | undefined): boolean => {
1013 const who = name?.trim().toLowerCase()
1014
1015 return who !== undefined && (who === 'you' || (settings.yourName !== '' && who === settings.yourName.toLowerCase()))
1016}
1017
1018/** The `file://` link of an absolute path; each segment is percent-encoded (spaces, `#`, CJK). */
1019const linkOf = (absolute: string) => `file://${absolute.split('/').map(encodeURIComponent).join('/')}`
1020
1021/**
1022 * The `file://` link of a file path in a note: `~/…` is under `home`, a path that starts with `/` is as it is and any other is relative to
1023 * the project root. null for a `~` path when the home directory is not known.
1024 */
1025export function fileHref(path: string, home: string | undefined): string | null {
1026 const absolute = absolutePath(path, home)
1027
1028 return absolute === null ? null : linkOf(absolute)
1029}
1030
1031/** The absolute path of a file path in a note: `~/…` is under `home` (null when not known), `/…` is as it is, any other is relative to the project root (null while that is not known). */
1032export function absolutePath(path: string, home: string | undefined): string | null {
1033 if (path === '~' || path.startsWith('~/')) {
1034 return home === undefined || home === '' ? null : `${home.replace(/\/+$/, '')}${path.slice(1)}`
1035 }
1036
1037 return path.startsWith('/') ? path : settings.root === '' ? null : `${settings.root.replace(/\/+$/, '')}/${path}`
1038}
1039
1040// ---------------------------------------------------------------- usage on the task page
1041
1042/** `1.2M`, `48k`, `900`: a count in at most four characters of digits. */
1043export function compactCount(value: number): string {
1044 const trim = (n: number) => n.toFixed(1).replace(/\.0$/, '')
1045
1046 if (value >= 1_000_000) {
1047 return `${trim(value / 1_000_000)}M`
1048 }
1049
1050 return value >= 10_000 ? `${Math.round(value / 1000)}k` : value >= 1000 ? `${trim(value / 1000)}k` : String(Math.round(value))
1051}
1052
1053/** `42m`, `1h 5m`, `<1m` (nothing under a minute reads as zero unless it is zero). */
1054export function durationText(ms: number): string {
1055 const minutes = Math.round(ms / 60_000)
1056
1057 if (minutes < 1) {
1058 return ms > 0 ? '<1m' : '0m'
1059 }
1060
1061 return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`
1062}
1063
1064// ---------------------------------------------------------------- the plan line and the task page's lines
1065
1066/**
1067 * The dim label over a plan's recap: `Recap · updated 12 min ago`, from `recap_updated` (ISO, as the tracker wrote it).
1068 * Just `Recap` when there is no time, it is not an ISO time, or it lies ahead of `now`.
1069 */
1070export function recapLabel(plan: PlanEntry, now: number): string {
1071 const minutes = Math.floor((now - Date.parse(plan.recap_updated ?? '')) / 60_000)
1072
1073 if (!Number.isFinite(minutes) || minutes < 0) {
1074 return 'Recap'
1075 }
1076
1077 if (minutes < 1) {
1078 return 'Recap · updated just now'
1079 }
1080
1081 if (minutes < 60) {
1082 return `Recap · updated ${minutes} min ago`
1083 }
1084
1085 return minutes < 1440 ? `Recap · updated ${Math.floor(minutes / 60)} h ago` : `Recap · updated ${Math.floor(minutes / 1440)} d ago`
1086}
1087
1088const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
1089
1090const pad2 = (n: number): string => String(n).padStart(2, '0')
1091
1092/** `YYYY-MM-DDTHH:MM` of an instant on this machine's clock. */
1093function localMinute(ms: number): string {
1094 const at = new Date(ms)
1095
1096 return `${at.getFullYear()}-${pad2(at.getMonth() + 1)}-${pad2(at.getDate())}T${pad2(at.getHours())}:${pad2(at.getMinutes())}`
1097}
1098
1099/**
1100 * A tracker ISO time as the clock time when it is today (`19:23`) and as the day and the clock time on another day (`Oct 11 19:23`):
1101 * the one way the board writes a time (the plan's `updated`, the state word's `since`, a stalled task's tag and page). A time with an
1102 * offset reads as written, on its own clock, and "today" is `now` on that clock; a time ending in Z, or with no offset, reads on this
1103 * machine's clock. Undefined when the text is not an ISO time with a clock time.
1104 */
1105export function sinceText(iso: string, now: number): string | undefined {
1106 if (!/^\d{4}-\d\d-\d\dT\d\d:\d\d/.test(iso)) {
1107 return undefined
1108 }
1109
1110 const instant = /Z$/.test(iso) ? Date.parse(iso) : undefined
1111
1112 if (instant !== undefined && Number.isNaN(instant)) {
1113 return undefined
1114 }
1115
1116 const written = instant === undefined ? iso : localMinute(instant)
1117 const offset = /([+-])(\d\d):(\d\d)$/.exec(written)
1118 const today = offset === null ? localMinute(now).slice(0, 10) : new Date(now + (offset[1] === '-' ? -1 : 1) * (Number(offset[2]) * 60 + Number(offset[3])) * 60_000).toISOString().slice(0, 10)
1119 const time = written.slice(11, 16)
1120
1121 return written.slice(0, 10) === today ? time : `${MONTHS[Number(written.slice(5, 7)) - 1] ?? written.slice(5, 7)} ${Number(written.slice(8, 10))} ${time}`
1122}
1123
1124/**
1125 * One figure of the stats row: a big bold `value` over a small dim `label`. `drop` orders what a narrow pane gives up: the
1126 * lowest goes first.
1127 */
1128export type Stat = { value: string; label: string; drop: number }
1129
1130/** Columns between two stats of a row. */
1131export const STAT_GAP = 3
1132
1133/** What a stats row shows when the plan has not begun: no start or finish time, and no row has left Todo. */
1134export const NOT_STARTED = 'Not started yet'
1135
1136/**
1137 * Whether the plan has begun: it has a start or a finish time, or a row has left Todo (a Canceled row is ignored). The stats are drawn
1138 * for such a plan; a plan that has not begun reads `Not started yet` instead.
1139 */
1140const hasBegun = (plan: PlanEntry): boolean =>
1141 plan.started !== undefined || plan.finished !== undefined || plan.rows.some(row => row.status !== 'Todo' && row.status !== 'Canceled')
1142
1143/**
1144 * `Not started yet` for a plan that has not begun (no start or finish time, and every row Todo or Canceled), '' for any other plan,
1145 * whose header already says In progress, Idle or Done.
1146 */
1147export function notStartedText(plan: PlanEntry): string {
1148 return hasBegun(plan) ? '' : NOT_STARTED
1149}
1150
1151/** Columns a stat takes: the wider of its value and its label. */
1152const statCols = (stat: Stat): number => Math.max(displayWidth(stat.value), displayWidth(stat.label))
1153
1154/** `stats` as far as they fit `width` columns: the lowest `drop` goes first, the last one stays however narrow the pane. */
1155export function fitStats(stats: readonly Stat[], width: number): Stat[] {
1156 const kept = [...stats]
1157 const need = () => kept.reduce((all, stat) => all + statCols(stat), 0) + STAT_GAP * Math.max(0, kept.length - 1)
1158
1159 while (kept.length > 1 && need() > width) {
1160 const lowest = kept.reduce((low, stat) => (stat.drop < low.drop ? stat : low))
1161
1162 kept.splice(kept.indexOf(lowest), 1)
1163 }
1164
1165 return kept
1166}
1167
1168/** A stats row as two lines of text, each stat in a column as wide as the wider of its value and its label. */
1169export function statLines(stats: readonly Stat[]): { values: string; labels: string } {
1170 const cell = (text: string, stat: Stat) => `${text}${' '.repeat(statCols(stat) - displayWidth(text))}`
1171 const gap = ' '.repeat(STAT_GAP)
1172
1173 return { values: stats.map(stat => cell(stat.value, stat)).join(gap).trimEnd(), labels: stats.map(stat => cell(stat.label, stat)).join(gap).trimEnd() }
1174}
1175
1176/**
1177 * The stats under the plan name: the tokens (in + out, in already holds the cached) over `tokens` and the cost over `est. cost`; no time,
1178 * which the state word in the header carries. The tokens and the cost are left out when none is measured, the cost alone when it has no
1179 * price; with neither there is no details line. Narrower than `width`: the tokens go first; the cost stays. Empty for a plan that has not
1180 * begun (no start or finish time, every row Todo or Canceled): it reads `Not started yet`. A plan under way with no start time (older
1181 * tracker data) draws the figures it has.
1182 */
1183export function planStatsOf(plan: PlanEntry, width: number): Stat[] {
1184 if (!hasBegun(plan)) {
1185 return []
1186 }
1187
1188 const totals = plan.usage_totals
1189 const measured = totals !== undefined && totals.measured > 0
1190
1191 return fitStats(
1192 [
1193 ...(measured ? [{ value: compactCount(totals.tokens_in + totals.tokens_out), label: 'tokens', drop: 2 }] : []),
1194 ...(measured && totals.cost !== null ? [{ value: `~$${totals.cost.toFixed(2)}`, label: 'est. cost', drop: 3 }] : []),
1195 ],
1196 width,
1197 )
1198}
1199
1200/**hooks/draw.tsx 1042 lines1/** The two drawings of the mod, as trees: the band above the prompt and the pane. */
2import type { BoxProps, ButtonProps, ClientProps, ElementConstructor, InputProps, LinkProps, MarkdownProps, RenderElement, RenderSurface, SvgProps, TextProps } from 'claude-code'
3
4import type { PlanEntry, PlanMatch, PlanRow } from '../types'
5import {
6 BAND_MASCOT_SPACE,
7 BAND_SPRITE_CELLS,
8 BAND_SPRITE_PX,
9 BAR_PX,
10 CARD_BAR_CELLS,
11 CARD_SPRITE_CELLS,
12 HEX,
13 barSvg,
14 countOf,
15 filledOf,
16 BORDER_COLUMNS,
17 BOARD_LABEL,
18 CARD_SPRITE_PX,
19 TRACK_COLOR,
20 spriteSvg,
21 spriteSvgAnimated,
22 CHIPS,
23 chipOf,
24 planStateText,
25 FILE_LABEL,
26 LINK_COLOR,
27 MASCOT,
28 MASCOT_COLUMNS,
29 NEEDS_YOU_COLOR,
30 SIDE_BY_SIDE_FROM,
31 STYLE,
32 bandFit,
33 bandLine,
34 cardRows,
35 checkerOf,
36 cardView,
37 countText,
38 STATE_COLUMNS,
39 STATE_STYLE,
40 fileHref,
41 planStatsOf,
42 notStartedText,
43 recapLabel,
44 planStateOf,
45 progressOf,
46 statusWords,
47 usageStatsOf,
48 fitStats,
49 statLines,
50 STAT_GAP,
51 whoLineOf,
52 displayWidth,
53 noteLinesOf,
54 isBlocked,
55 executorOf,
56 isYou,
57 isRunning,
58 isStalled,
59 LANE_GAP,
60 laneWidth,
61 lanesOf,
62 SHOW_LESS,
63 mascotAt,
64 moreButton,
65 overflowLabel,
66 piecesOf,
67 rainbowAt,
68 shortPath,
69 sinceText,
70 truncate,
71 wrap,
72 worstStatus,
73} from './board'
74import type { CardRow } from './card'
75import type { Card, Chip, Lane, LaneKey, PlanState, Pose, Progress, Stat } from './board'
76
77/** The elements both drawings use; every surface's table has them. */
78export type Kit = {
79 Box: ElementConstructor<BoxProps>
80 Text: ElementConstructor<TextProps>
81 Button: ElementConstructor<ButtonProps>
82 Link: ElementConstructor<LinkProps>
83 Markdown: ElementConstructor<MarkdownProps>
84 /** Absent on a surface with no `Client` (vscode, mobile): a card is then Buttons. */
85 Client?: ElementConstructor<ClientProps>
86 /** Only the remote surfaces (desktop, vscode, mobile) draw it. */
87 Svg?: ElementConstructor<SvgProps>
88 /** The list's search field; absent on mobile, which draws no field. */
89 Input?: ElementConstructor<InputProps>
90}
91
92/** Whether what the hooks below drew is more than an empty Box or Text. */
93export function holdsSomething(drawn: RenderElement | null | undefined): boolean {
94 if (!drawn) {
95 return false
96 }
97
98 return (drawn.type !== 'Box' && drawn.type !== 'Text') || drawn.children === undefined || drawn.children.length > 0
99}
100
101/** The desktop's card bar: a fixed 8 cells whatever the total, filled in proportion to done/total. */
102const deskBar = (bar: Progress, color: string) => {
103 const filled = filledOf(bar.done, bar.total, CARD_BAR_CELLS)
104
105 return { filled, empty: CARD_BAR_CELLS - filled, color, track: TRACK_COLOR }
106}
107
108/** The fields of a card row's tag: its chip (the grey one of `stalled` and its time too), or none. */
109const tagFields = (word: string, pad: number): Pick<CardRow, 'tag' | 'tagPad' | 'tagBg' | 'tagFg' | 'tagBold'> => {
110 const chip: Chip | undefined = chipOf(word)
111
112 return word === '' || chip === undefined
113 ? { tag: '', tagPad: 0, tagBg: '', tagFg: '', tagBold: false }
114 : { tag: word, tagPad: pad, tagBg: chip.bg, tagFg: chip.fg, tagBold: chip.bold === true }
115}
116
117/** `text` padded with spaces on the right to `cols` columns. */
118const padTo = (text: string, cols: number) => `${text}${' '.repeat(Math.max(0, cols - displayWidth(text)))}`
119
120type BandArgs = {
121 /** The plan the band draws (as finished when a PLAN-DONE with 0 left named it). */
122 plan: PlanEntry
123 plans: readonly PlanEntry[]
124 /** Columns the row has after the mascot and its space. */
125 columns: number
126 /** What the hooks below drew, drawn under ours when it holds something. */
127 below: RenderElement | undefined
128 /** Whether the plan list shows under the row. */
129 isOpen: boolean
130 /** Whether the board is showing the plan file: the right end is then the way back to the board. */
131 isFile: boolean
132 /** With a frame the filled cells are the rainbow at that frame instead of the success color. */
133 frame: number | null
134 pose: string
135 /** The time a state word's `since` is compared with, to say today's time as a clock time and another day's with its day (`Date.now()`); a parameter so a test can pin it. */
136 now: number
137 /** The card sprite's pose the desktop draws instead of `pose`'s block characters. */
138 spritePose?: Pose
139 /** The desktop's sprite switches its poses by itself (a plan with a row In Progress), with no redraw. */
140 isMoving?: boolean
141 /** The desktop's proportional font: the mascot is drawn one Box per cell. */
142 isProportional?: boolean
143 /** Rows the engine lets the band show whole (`e.props.maxRows`); a terminal band that does not fit them with its frame is drawn without it. */
144 maxRows?: number
145 onBar: () => void
146 onMenu: () => void
147 onPick: (anchor: string) => void
148 onFile: () => void
149 onBoard: () => void
150}
151
152type PlanListArgs = {
153 plans: readonly PlanEntry[]
154 /** The open plan: its row is marked ▶ and drawn inverse. */
155 plan: PlanEntry
156 /** The columns the list may take, its one margin column included. */
157 columns: number
158 /** `Date.now()`, for the state words' times. */
159 now: number
160 /** The list Box's key. */
161 listKey: string
162 /** What a row's Button key starts with; the anchor follows. */
163 pickKey: string
164 onPick: (anchor: string) => void
165}
166
167/**
168 * One row of a plan list: a plan of the session, or a plan of the board command's list. `state` picks the color; `stateText` is the word drawn
169 * (`Idle since 19:40`). `time` is when a listed plan was last updated (`19:40` today, `Oct 9 20:08` another day); a plan of the session has none.
170 */
171export type ListItem = { anchor: string; name: string; state: PlanState; stateText: string; count: string; time?: string }
172
173/**
174 * What the plan list draws of a plan of the session: the only part of the session's other plans the pane reads. The render hook
175 * compares these (not the whole plans) to tell whether a new answer of the tracker changes what it draws.
176 */
177export const listedOf = (plan: PlanEntry, now: number): ListItem => ({ anchor: plan.anchor, name: plan.name, state: planStateOf(plan), stateText: planStateText(plan, now), count: countText(plan) })
178
179type RowsArgs = {
180 items: readonly ListItem[]
181 /** The anchor of the open plan: its row is marked ▶ and drawn inverse; null for a list with no open plan. */
182 activeAnchor: string | null
183 columns: number
184 listKey: string
185 pickKey: string
186 onPick: (anchor: string) => void
187}
188
189/**
190 * The rows of a plan list: one per plan (`▶` for the open one, the name, its state text in the state's style, n/m, and a listed plan's
191 * time, dim), the open row inverse and bold. The state column is as wide as the widest text (`Stalled since Oct 11 19:23` is wider than
192 * `In progress`); with times the count column is padded so they line up. The name is cut first (to 1 column at the least); if even then the
193 * state text and the time have no room, they are dropped whole.
194 */
195function drawListRows(kit: Kit, { items, activeAnchor, columns, listKey, pickKey, onPick }: RowsArgs): RenderElement {
196 const { Box, Text, Button } = kit
197 const room = columns - 1
198 const countCols = Math.max(0, ...items.map(one => displayWidth(one.count)))
199 const stateCols = Math.max(STATE_COLUMNS, ...items.map(one => displayWidth(one.stateText)))
200 const timeCols = Math.max(0, ...items.map(one => displayWidth(one.time ?? '')))
201 const nameRoom = (hasState: boolean) => room - 2 - 1 - countCols - (hasState ? stateCols + 2 + (timeCols > 0 ? timeCols + 2 : 0) : 0)
202 const hasState = nameRoom(true) >= 4
203 const names = items.map(one => truncate(one.name, Math.max(1, nameRoom(hasState))))
204 const nameCols = Math.max(0, ...names.map(displayWidth))
205
206 const option = (one: ListItem, at: number) => {
207 const isOn = one.anchor === activeAnchor
208 const text = `${isOn ? '▶' : ' '} ${padTo(names[at] ?? '', nameCols)}`
209
210 return (
211 <Box flexDirection="row">
212 <Button plain key={`${pickKey}${one.anchor}`} label={text} onPress={() => onPick(one.anchor)}>
213 <Text {...(isOn ? { bold: true, inverse: true } : {})}>{text}</Text>
214 </Button>
215 {hasState ? <Text {...STATE_STYLE[one.state]} {...(isOn ? { inverse: true } : {})}>{` ${padTo(one.stateText, stateCols)}`}</Text> : null}
216 <Text color="suggestion" {...(isOn ? { inverse: true } : {})}>{` ${timeCols > 0 ? padTo(one.count, countCols) : one.count}`}</Text>
217 {hasState && one.time !== undefined ? <Text dimColor>{` ${one.time}`}</Text> : null}
218 </Box>
219 )
220 }
221
222 return (
223 <Box key={listKey} flexDirection="column" marginLeft={1}>
224 {items.map(option)}
225 </Box>
226 )
227}
228
229/** The plan list both the board's dropdown and the band's open: every plan of the session, the open one marked. */
230function drawPlanList(kit: Kit, { plans, plan, columns, now, listKey, pickKey, onPick }: PlanListArgs): RenderElement {
231 return drawListRows(kit, {
232 items: plans.map(one => listedOf(one, now)),
233 activeAnchor: plan.anchor,
234 columns,
235 listKey,
236 pickKey,
237 onPick,
238 })
239}
240
241/** A plan of the board command's list as a row of it: the bare state word, `n/m` and when it was last updated (`sinceText`). */
242export const listedMatchOf = (one: PlanMatch, now: number): ListItem => {
243 const time = one.updated === undefined ? undefined : sinceText(one.updated, now)
244
245 return { anchor: one.anchor, name: one.name, state: one.state, stateText: one.state, count: `${one.done}/${one.total}`, ...(time === undefined ? {} : { time }) }
246}
247
248type PickerArgs = {
249 /** The rows the search leaves, in the board command's order (latest first). */
250 rows: readonly ListItem[]
251 /** The search field's text. */
252 search: string
253 /** The line drawn when no row is left. */
254 empty: string
255 /** `e.props.bodyColumns` */
256 columns: number
257 onPick: (anchor: string) => void
258 onInput: (value: string) => void
259 onSubmit: (value: string) => void
260}
261
262/**
263 * What the pane shows while it lists the plans: the search field (`Search plans`), then the rows the search leaves as the plan list draws
264 * them, with no plan open (a press picks one: keys `match:<anchor>`), or the `empty` line when none is left. A surface with no `Input`
265 * (mobile) has the rows alone.
266 */
267export function drawPicker(kit: Kit, { rows, search, empty, columns, onPick, onInput, onSubmit }: PickerArgs): RenderElement {
268 const { Box, Text, Input } = kit
269
270 return (
271 <Box key="matches" flexDirection="column">
272 {Input === undefined ? null : <Input key="search" placeholder="Search plans" value={search} autoFocus onInput={onInput} onSubmit={onSubmit} />}
273 {rows.length === 0
274 ? <Text dimColor>{empty}</Text>
275 : drawListRows(kit, { items: rows, activeAnchor: null, columns, listKey: 'matches:list', pickKey: 'match:', onPick })}
276 </Box>
277 )
278}
279
280/** Columns the band's frame takes from the row: two border columns and one padding column each side. */
281const FRAME_COLUMNS = 4
282
283/**
284 * The band: ONE row inside a dim round frame, `<mascot> ● <name> ▾ <bar> n/m ........ Plan file ›`, then the plan list when it is open, then
285 * whatever the hooks below drew. A Button takes no color, so the colored parts are Text inside a plain Button: "name ▾"
286 * opens the list, the bar and the count toggle the board, "Plan file ›" opens the board on the plan file (and is "‹ Board"
287 * while it shows it).
288 */
289export function drawBand(kit: Kit, args: BandArgs): RenderElement {
290 const { Box, Text, Button } = kit
291 const { plan, plans, columns, below, isOpen, isFile, frame, pose, now, spritePose, isMoving = false, isProportional = false, maxRows = Number.POSITIVE_INFINITY, onBar, onMenu, onPick, onFile, onBoard } = args
292 const line = bandLine(plan, columns, now)
293 const { color: stateColor } = STATE_STYLE[line.state]
294 const hasChoice = plans.length > 1
295 const { color: dotColor, ...dotDim } = STYLE[worstStatus(plan)]
296 const fileLabel = isFile ? BOARD_LABEL : FILE_LABEL
297 const fileButton = isFile ? (
298 <Button plain key="band:board" label={BOARD_LABEL} onPress={onBoard} />
299 ) : (
300 <Button plain key="band:file" label={FILE_LABEL} onPress={onFile} />
301 )
302
303 const filledCells = (count: number) =>
304 Array.from({ length: count }, (_, at) => <Text color={frame === null ? STYLE.Done.color : rainbowAt(at, frame)}>▰</Text>)
305
306 // The desktop: a Button holds only strings and Text, so a picture (the mascot, the bar) cannot be pressed. A region
307 // that posts `{ band: 'toggle' }` is laid over each such part (and the dot and the gap), so the whole band is a target. The
308 // name's and the plan file's buttons are drawn as before, and a region never covers them.
309 const Client = isProportional ? kit.Client : undefined
310
311 const zone = (key: string, child: RenderElement, grow = false) =>
312 Client === undefined ? (
313 child
314 ) : (
315 <Box key={`band:zone:${key}`} position="relative" flexShrink={grow ? 1 : 0} {...(grow ? { flexGrow: 1 } : {})}>
316 {child}
317 <Box position="absolute" top={0} left={0} width="100%" height="100%">
318 <Client key={`band:zone:${key}:region`} module="./band.tsx" width="100%" height="100%" />
319 </Box>
320 </Box>
321 )
322
323 if (isProportional) {
324 return (
325 <Box flexDirection="column">
326 <Box key="band:frame" flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
327 <Box key="bar" flexDirection="row">
328 {kit.Svg !== undefined
329 ? zone(
330 'mascot',
331 <Box width={BAND_SPRITE_CELLS} flexShrink={0} alignSelf="center">
332 <kit.Svg
333 source={(isMoving ? spriteSvgAnimated(MASCOT, 'claude') : spriteSvg(spritePose ?? MASCOT[0], 'claude')).source}
334 alt="Claude Code's mascot"
335 width={BAND_SPRITE_PX.width}
336 height={BAND_SPRITE_PX.height}
337 {...(isMoving ? { isInteractive: true } : {})}
338 />
339 </Box>,
340 )
341 : null}
342 {zone('dot', <Text color={dotColor} {...dotDim}>●{' '}</Text>)}
343 {hasChoice ? (
344 <Button plain key="band:plans" label={`${line.name} ▾`} onPress={onMenu}>
345 <Text bold>{line.name}</Text>
346 <Text> ▾</Text>
347 </Button>
348 ) : (
349 zone('name', <Text bold>{line.name}</Text>)
350 )}
351 {zone(
352 'gap',
353 <Box flexDirection="row">
354 <Text> </Text>
355 <Text color={stateColor}>{line.stateText}</Text>
356 <Text>{' '}</Text>
357 </Box>,
358 )}
359 {kit.Svg === undefined
360 ? null
361 : zone(
362 'bar',
363 // The glyphs draw as slanted dashes in a proportional font: a thin rounded bar of one fixed width.
364 <Box key="band:solid" flexShrink={0} alignSelf="center" marginRight={1}>
365 <kit.Svg
366 source={barSvg(
367 countOf(plan).total === 0 ? 0 : countOf(plan).done / countOf(plan).total,
368 HEX.success ?? '#3C9D5A',
369 frame === null ? undefined : Array.from({ length: Math.max(1, line.filled) }, (_, at) => rainbowAt(at, frame)),
370 )}
371 alt={`${line.count} done`}
372 width={BAR_PX.width}
373 height={BAR_PX.height}
374 />
375 </Box>,
376 )}
377 <Button plain key="band:bar" label={` ${line.count}`} onPress={onBar}>
378 <Text>{` ${line.count}`}</Text>
379 </Button>
380 {zone('rest', <Box minWidth={2} />, true)}
381 {fileButton}
382 </Box>
383 </Box>
384 {isOpen && hasChoice ? drawPlanList(kit, { plans, plan, columns: columns + BAND_MASCOT_SPACE, now, listKey: 'band:list', pickKey: 'band:plan:', onPick }) : null}
385 {holdsSomething(below) ? below : null}
386 </Box>
387 )
388 }
389
390 // The terminal: the mascot, the dot and (with one plan) the name are one Button; the bar, the count and the gap up to
391 // "Plan file ›" are another. Both toggle the board; the name's menu and the plan file keep their own buttons. The parts
392 // are fitted to the frame's inside (`bandFit`), so the row never runs past the column a docked pane leaves it.
393 // A pane open inline can leave the band two rows: then the frame (its two border rows) goes and the band is the one row it was before it.
394 const framedFit = bandFit(plan, columns - FRAME_COLUMNS, hasChoice, displayWidth(fileLabel), now)
395 const isFramed = maxRows >= (framedFit.isStacked ? 4 : 3)
396 const frameColumns = isFramed ? FRAME_COLUMNS : 0
397 const fit = isFramed ? framedFit : bandFit(plan, columns, hasChoice, displayWidth(fileLabel), now)
398 const headText = `${pose} ● ${hasChoice ? '' : fit.name}`
399 const nameWidth = hasChoice ? displayWidth(`${fit.name} ▾`) : 0
400 const bar = fit.filled + fit.empty
401 const barText = ` ${fit.hasState ? `${fit.stateText} ` : ''}${bar > 0 ? `${'▰'.repeat(fit.filled)}${'▱'.repeat(fit.empty)} ` : ''}${fit.count}`
402 const used = displayWidth(headText) + nameWidth + displayWidth(barText) + (fit.hasFile ? displayWidth(fileLabel) : 0)
403 const filler = ' '.repeat(Math.max(0, columns + BAND_MASCOT_SPACE - frameColumns - used - (fit.hasFile ? 2 : 0)))
404
405 const progress = (
406 <Button plain key="band:bar" label={`${barText}${fit.isStacked ? '' : filler}`} onPress={onBar}>
407 <Text>{' '}</Text>
408 {fit.hasState ? <Text color={stateColor}>{fit.stateText}</Text> : null}
409 {fit.hasState ? <Text>{' '}</Text> : null}
410 {bar > 0 ? filledCells(fit.filled) : null}
411 {bar > 0 ? <Text dimColor>{'▱'.repeat(fit.empty)}</Text> : null}
412 {bar > 0 ? <Text>{' '}</Text> : null}
413 <Text>{`${fit.count}${fit.isStacked ? '' : filler}`}</Text>
414 </Button>
415 )
416
417 return (
418 <Box flexDirection="column">
419 <Box key={isFramed ? 'band:frame' : 'band:plain'} flexDirection="column" {...(isFramed ? { borderStyle: 'round' as const, borderDimColor: true, paddingX: 1 } : {})}>
420 <Box key="bar" flexDirection="row">
421 <Button plain key="band:head" label={headText} onPress={onBar}>
422 <Text color="claude">{`${pose} `}</Text>
423 <Text color={dotColor} {...dotDim}>
424 ●{' '}
425 </Text>
426 {hasChoice ? null : <Text bold>{fit.name}</Text>}
427 </Button>
428 {hasChoice ? (
429 <Button plain key="band:plans" label={`${fit.name} ▾`} onPress={onMenu}>
430 <Text bold>{fit.name}</Text>
431 <Text> ▾</Text>
432 </Button>
433 ) : null}
434 {fit.isStacked ? <Box flexGrow={1} minWidth={0} /> : progress}
435 {fit.isStacked ? null : <Box flexGrow={1} minWidth={1} />}
436 {fit.hasFile ? fileButton : null}
437 </Box>
438 {fit.isStacked ? (
439 <Box key="bar:2" flexDirection="row">
440 {progress}
441 <Box flexGrow={1} minWidth={0} />
442 </Box>
443 ) : null}
444 </Box>
445 {isOpen && hasChoice ? drawPlanList(kit, { plans, plan, columns: columns + BAND_MASCOT_SPACE, now, listKey: 'band:list', pickKey: 'band:plan:', onPick }) : null}
446 {holdsSomething(below) ? below : null}
447 </Box>
448 )
449}
450
451type StatsArgs = {
452 stats: readonly Stat[]
453 isProportional: boolean
454 boxKey: string
455}
456
457/**
458 * A row of stats, each a bold value over a small dim label. The terminal has no font size: two lines of text, the values bold
459 * and the labels dim under them, each stat in a column as wide as the wider of the two. The desktop has no size prop either: a
460 * Box per stat, the value bold in the normal colour against its dim label.
461 */
462function drawStats(kit: Kit, { stats, isProportional, boxKey }: StatsArgs): RenderElement {
463 const { Box, Text } = kit
464
465 if (isProportional) {
466 return (
467 <Box key={boxKey} flexDirection="row" gap={STAT_GAP} marginTop={1}>
468 {stats.map(stat => (
469 <Box key={`${boxKey}:${stat.label}`} flexDirection="column" flexShrink={0}>
470 <Text bold wrap="truncate">
471 {stat.value}
472 </Text>
473 <Text dimColor wrap="truncate">
474 {stat.label}
475 </Text>
476 </Box>
477 ))}
478 </Box>
479 )
480 }
481
482 const { values, labels } = statLines(stats)
483
484 return (
485 <Box key={boxKey} flexDirection="column">
486 <Text bold wrap="truncate">
487 {values}
488 </Text>
489 <Text dimColor wrap="truncate">
490 {labels}
491 </Text>
492 </Box>
493 )
494}
495
496/** What the pane shows besides the board: the plan file's text, or why there is none. */
497export type FileView = { text: string } | { error: string }
498
499type PaneArgs = {
500 plans: readonly PlanEntry[]
501 plan: PlanEntry
502 /** `e.props.bodyColumns` */
503 columns: number
504 message: string | null
505 /** The index in `plan.rows` of the card whose page replaces the board; null shows the board. */
506 detail: number | null
507 /** The plan file view replaces the board (and the page): its text, or why it cannot be read. */
508 file: FileView | null
509 /** The time a shown time (`updated`, a state word's `since`, a stalled mark) is compared with, to say today's as a clock time and another day's with its day (`Date.now()`); a parameter so a test can pin it. */
510 now: number
511 /** Rows the pane shows (`scroll.bodyRows`); undefined when the surface does not say. */
512 bodyRows?: number
513 /** The person's home directory, which a `~/` path in a note is under; undefined when not known. */
514 home: string | undefined
515 /** The animation frame the mascot is drawn at. */
516 frame: number
517 /** Whether the plan dropdown shows its list. */
518 isOpen: boolean
519 /** The lanes of the plan that show all their cards. */
520 expanded: readonly LaneKey[]
521 /** Whether the surface draws a `Client` (terminal, desktop): a card is then one, else Buttons. */
522 hasClient: boolean
523 /** The surface drawing: the desktop app has a proportional font, so it is laid out with flex, not padded with spaces. */
524 surface: RenderSurface
525 onMenu: () => void
526 onPick: (anchor: string) => void
527 onCard: (index: number) => void
528 onBack: () => void
529 onFile: () => void
530 onLane: (key: LaneKey) => void
531}
532
533/** Columns of plan name the pane's header keeps beside "Plan file ›"; a narrower room puts that button on a row of its own. */
534const MIN_HEAD_NAME = 8
535
536/** The pane: the plan dropdown, "Plan file ›" and the plan line on top, then the four lanes (or one card's page, or the plan file). */
537export function drawPane(kit: Kit, args: PaneArgs): RenderElement {
538 const { Box, Text, Button, Markdown } = kit
539 // Only the terminal and the desktop draw a Client; another surface's table has the name but drops the element.
540 const Client = args.hasClient ? kit.Client : undefined
541 const { plans, plan, columns, message, detail, file, home, frame, isOpen, expanded, now } = args
542 const { onMenu, onPick, onCard, onBack, onFile, onLane, bodyRows } = args
543 const isProportional = args.surface === 'desktop'
544 // A Client's props: the terminal's stay as they were.
545 const proportional = isProportional ? ({ isProportional: true } as const) : {}
546 // The desktop leaves a row between cards, which a proportional layout has room for; the terminal's rows are dear.
547 const gapRows = args.surface === 'desktop' ? 1 : 0
548 const isSideBySide = columns >= SIDE_BY_SIDE_FROM
549 const width = isSideBySide ? laneWidth(columns, lanesOf(plan, expanded).length) : columns
550
551 const { color: dotColor, ...dotDim } = STYLE[worstStatus(plan)]
552 const hasChoice = plans.length > 1
553 const state = planStateOf(plan)
554 // The state text (`Idle since 19:40`) is drawn in the bare state's color; its width is what the header gives it.
555 const stateText = planStateText(plan, now)
556 const stats = planStatsOf(plan, columns)
557 const notStarted = notStartedText(plan)
558 // The state text and n/m are never cut: the name is shortened to leave them room. Beside "Plan file ›" the name has the old
559 // budget (two gaps and the ▾ besides the three; at least MIN_HEAD_NAME columns, else it would be cut to nothing). A name that
560 // does not fit beside it moves "Plan file ›" to a row of its own, and the first row gives the name what the other three leave.
561 const countWidth = displayWidth(countText(plan))
562 const beside = columns - 20 - countWidth - 2 - displayWidth(stateText)
563 const isFileBelow = beside < Math.min(MIN_HEAD_NAME, displayWidth(plan.name))
564 const headName = truncate(plan.name, isFileBelow ? Math.max(2, columns - 6 - countWidth - displayWidth(stateText) - (hasChoice ? 2 : 0) - 1) : beside)
565
566 const headRow = (
567 <Box key={isFileBelow ? 'menu:row' : 'menu'} flexDirection="row">
568 <Text color={dotColor} {...dotDim}>
569 ●{' '}
570 </Text>
571 {hasChoice ? (
572 <Button plain key="menu:name" label={headName} onPress={onMenu}>
573 <Text bold>{headName}</Text>
574 </Button>
575 ) : (
576 <Text bold>{headName}</Text>
577 )}
578 <Text>{' '}</Text>
579 <Text color={STATE_STYLE[state].color}>{stateText}</Text>
580 <Text>{' '}</Text>
581 <Text color="suggestion">{countText(plan)}</Text>
582 {hasChoice ? <Button plain key="menu:arrow" label={isOpen ? ' ▴' : ' ▾'} onPress={onMenu} /> : null}
583 {isFileBelow ? null : <Box flexGrow={1} minWidth={2} />}
584 {isFileBelow ? null : <Button plain key="plan-file" label={FILE_LABEL} onPress={onFile} />}
585 </Box>
586 )
587
588 const header = isFileBelow ? (
589 <Box key="menu" flexDirection="column">
590 {headRow}
591 <Box flexDirection="row">
592 <Box flexGrow={1} />
593 <Button plain key="plan-file" label={FILE_LABEL} onPress={onFile} />
594 </Box>
595 </Box>
596 ) : (
597 headRow
598 )
599
600 // A card is its own bordered Box (the border takes 2 columns): every line of it is a Button, so a press anywhere opens
601 // its page. An In Progress card has the mascot beside its two lines; a Blocked card is red all through (border, text,
602 // tag) with the mascot frozen on one pose; the tag is right-aligned on the last line.
603 // Only the Buttons are pressable, and a Button inverts under the pointer, so the mascot is plain Text beside the Button of
604 // its row and never sits under that bar. The card shows its task name whole, wrapped; there is no hover box.
605 const card = ({ row, index }: Card) => {
606 const view = cardView(row, width, now)
607 const isDoing = isRunning(row)
608 const isStuck = isBlocked(row)
609 const { color } = STYLE[row.status]
610 const press = () => onCard(index)
611 const art = isStuck || isStalled(row) ? MASCOT[0] : mascotAt(frame)
612 // A proportional font draws block glyphs at its own shapes: the desktop draws the mascot as a picture beside the card's region.
613 const Svg = isProportional ? kit.Svg : undefined
614 const useSprite = Svg !== undefined && view.mascot && Client !== undefined
615 const markerColor = isStuck ? 'error' : NEEDS_YOU_COLOR
616
617 const parsed: CardRow[] = view.lines.map((line, at) => {
618 const isBar = at === view.barAt
619 const marker = !isBar && at === 0 && view.marker !== '' && line.startsWith(view.marker) ? view.marker : ''
620
621 return {
622 art: view.mascot && !useSprite ? `${art[at] ?? ' '.repeat(MASCOT_COLUMNS)} ` : '',
623 artColor: isStuck ? 'error' : 'claude',
624 marker,
625 markerColor,
626 name: isBar ? ` ${view.bar?.count ?? ''}` : line.slice(marker.length),
627 isRed: isStuck,
628 isBold: isDoing && !isBar,
629 ...(isBar && view.bar !== undefined ? { bar: isProportional ? deskBar(view.bar, color) : { filled: view.bar.filled, empty: view.bar.empty, color, track: TRACK_COLOR } } : {}),
630 ...tagFields(at === view.tagAt ? view.tag : '', view.pad),
631 }
632 })
633
634 // With a Client the rows are drawn by `card.tsx` in one region that posts a click and inverts under no pointer.
635 const lineRows = Client
636 ? [
637 useSprite && Svg !== undefined ? (
638 <Box key={`card:${index}:sprite`} flexDirection="row" alignItems="center">
639 <Box width={CARD_SPRITE_CELLS} flexShrink={0}>
640 <Svg
641 source={(isDoing && !isStalled(row) ? spriteSvgAnimated(MASCOT, 'claude') : spriteSvg(art, isStuck ? 'error' : 'claude')).source}
642 alt="Claude Code's mascot"
643 width={CARD_SPRITE_PX.width}
644 height={CARD_SPRITE_PX.height}
645 {...(isDoing && !isStalled(row) ? { isInteractive: true } : {})}
646 />
647 </Box>
648 <Client
649 key={`card:${index}`}
650 module="./card.tsx"
651 width={Math.max(1, width - BORDER_COLUMNS - CARD_SPRITE_CELLS)}
652 height={parsed.length}
653 props={{ index, rows: parsed, ...proportional }}
654 />
655 </Box>
656 ) : (
657 <Client
658 key={`card:${index}`}
659 module="./card.tsx"
660 width={Math.max(1, width - BORDER_COLUMNS)}
661 height={parsed.length}
662 props={{ index, rows: parsed, ...proportional }}
663 />
664 ),
665 ]
666 : parsed.map((one, at) => {
667 const key = at === 0 ? `card:${index}` : `card:${index}:${at}`
668 const picture = view.mascot ? <Text color={one.artColor}>{one.art}</Text> : null
669 const button =
670 view.lines[at] === '' && one.tag === '' ? null : (
671 <Button plain key={key} label={`${view.lines[at] ?? ''}${one.tag === '' ? '' : ` ${one.tag} `}`} onPress={press}>
672 {one.marker === '' ? null : (
673 <Text bold color={one.markerColor}>
674 {one.marker}
675 </Text>
676 )}
677 {one.bar === undefined ? null : (
678 <Text color={one.bar.color}>{'▰'.repeat(one.bar.filled)}</Text>
679 )}
680 {one.bar === undefined ? null : <Text dimColor>{'▱'.repeat(one.bar.empty)}</Text>}
681 <Text {...(isStuck ? { color: 'error' } : {})} {...(isDoing && one.bar === undefined ? { bold: true } : {})}>
682 {one.name}
683 </Text>
684 {one.tag === '' ? null : (
685 <Text>
686 {' '.repeat(one.tagPad)}
687 <Text color={one.tagFg} backgroundColor={one.tagBg} {...(one.tagBold ? { bold: true } : {})}>{` ${one.tag} `}</Text>
688 </Text>
689 )}
690 </Button>
691 )
692
693 return view.mascot ? (
694 <Box key={`card:${index}:row${at}`} flexDirection="row">
695 {picture}
696 {button}
697 </Box>
698 ) : (
699 button
700 )
701 })
702
703 return (
704 <Box
705 key={`cardbox:${index}`}
706 {...(isProportional ? { hover: { borderStyle: 'bold', borderDimColor: false } } : {})}
707 width={width}
708 flexDirection="column"
709 borderStyle="round"
710 borderColor={color}
711 borderDimColor={!isDoing && !isStuck}
712 paddingX={1}
713 >
714 {lineRows}
715 </Box>
716 )
717 }
718
719 // The header row of an expanded lane has "Show less ▴" at its right end; a Button draws its own `[ ]`, two columns more.
720 const lessLabel = moreButton(SHOW_LESS)
721 const lessColumns = displayWidth(lessLabel) + 4
722
723 const lane = (one: Lane) => {
724 const isOpen = one.moreLabel === SHOW_LESS
725 const hasLess = isOpen && width >= lessColumns + 12
726 // The header's button folds the lane; the one at the bottom is kept only when the lane is taller than the pane shows
727 // (the header has then scrolled away), or when the pane does not say how many rows it shows.
728 const laneRows = 1 + one.cards.reduce((all, { row }) => all + cardRows(row, width, now) + gapRows, 0) + (one.cards.length === 0 ? 1 : 0) + 1
729 const hasBottom = one.moreLabel !== '' && !(hasLess && bodyRows !== undefined && laneRows <= bodyRows)
730
731 return (
732 <Box key={`lane:${one.key}`} flexDirection="column" width={width} {...(gapRows === 0 ? {} : { gap: gapRows })}>
733 {hasLess ? (
734 <Box flexDirection="row">
735 <Text bold color={one.color} wrap="truncate">
736 {truncate(one.header, width - lessColumns - 1)}
737 </Text>
738 <Box flexGrow={1} minWidth={1} />
739 <Button key={`lane:${one.key}:less`} label={lessLabel} onPress={() => onLane(one.key)} />
740 </Box>
741 ) : (
742 <Text bold color={one.color} wrap="truncate">
743 {truncate(one.header, width)}
744 </Text>
745 )}
746 {one.cards.length === 0 ? <Text dimColor>—</Text> : null}
747 {one.cards.map(card)}
748 {!hasBottom ? null : (
749 <Button key={`lane:${one.key}:more`} label={overflowLabel(one, width)} onPress={() => onLane(one.key)} />
750 )}
751 </Box>
752 )
753 }
754
755 const row = detail === null ? undefined : plan.rows[detail]
756
757 if (file !== null) {
758 return (
759 <Box flexDirection="column">
760 {message === null ? null : <Text dimColor>{message}</Text>}
761 <Box key="file" flexDirection="column">
762 <Button key="back" label="‹ Back" onPress={onBack} />
763 <Text dimColor wrap="truncate">
764 {shortPath(plan.plan_file, columns)}
765 </Text>
766 {'error' in file ? (
767 <Text color="error" wrap="wrap">
768 {file.error}
769 </Text>
770 ) : (
771 <Box marginTop={1}>
772 <Markdown text={file.text} />
773 </Box>
774 )}
775 </Box>
776 </Box>
777 )
778 }
779
780 return (
781 <Box flexDirection="column">
782 {message === null ? null : <Text dimColor>{message}</Text>}
783 {row === undefined ? (
784 <Box key="strip" flexDirection="column">
785 {header}
786 {isOpen && hasChoice ? drawPlanList(kit, { plans, plan, columns, now, listKey: 'menu:list', pickKey: 'plan:', onPick }) : null}
787 {stats.length > 0 ? (
788 drawStats(kit, { stats, isProportional, boxKey: 'plan-stats' })
789 ) : notStarted === '' ? null : (
790 <Box key="plan-stats">
791 <Text dimColor wrap="truncate">
792 {notStarted}
793 </Text>
794 </Box>
795 )}
796 {plan.recap === undefined ? null : (
797 <Box key="recap" flexDirection="column" marginTop={1}>
798 <Text dimColor wrap="truncate">
799 {recapLabel(plan, now)}
800 </Text>
801 <Text wrap="wrap">{plan.recap}</Text>
802 </Box>
803 )}
804 {/* A row of ─ is `columns` cells long, but a proportional font draws ─ wider than a cell and the Text is cut to "…": the desktop gets the Markdown rule, which the surface draws as wide as the pane. */}
805 <Box key="divider" marginTop={1} {...(isProportional ? { flexDirection: 'column' as const, width: '100%' } : {})}>
806 {isProportional ? (
807 <Markdown text="---" dimColor />
808 ) : (
809 <Text dimColor wrap="truncate">
810 {'─'.repeat(columns)}
811 </Text>
812 )}
813 </Box>
814 </Box>
815 ) : null}
816 {row === undefined ? (
817 <Box key="kanban" flexDirection={isSideBySide ? 'row' : 'column'} gap={isSideBySide ? LANE_GAP : 1} marginTop={1}>
818 {lanesOf(plan, expanded).map(lane)}
819 </Box>
820 ) : (
821 drawDetail(kit, { row, columns, home, onBack, isProportional, now })
822 )}
823 </Box>
824 )
825}
826
827type DetailArgs = Pick<PaneArgs, 'columns' | 'home' | 'onBack' | 'now'> & { row: PlanRow; isProportional: boolean }
828
829/** Columns of the table's label cell, at the most (the longest label is "Blocked because"). */
830const LABEL_COLUMNS = 15
831
832/** The narrowest value cell the table keeps before the label cell shrinks. */
833const MIN_VALUE_COLUMNS = 12
834
835/** A piece of a cell's line: text, its colour, and the file link it opens, if any. */
836type Part = { text: string; color?: string; bold?: boolean; dim?: true; href?: string | null; /** Drawn as this chip: the text padded one space each side. */ chip?: Chip; /** Drawn as a solid bar of Boxes (the desktop). */ bar?: { fraction: number; color: string } }
837
838/** One row of the table: the label and its lines, each a list of parts. */
839type TableRow = { key: string; label: string; isRed?: boolean; lines: Part[][] }
840
841/** Columns a desktop cell wraps at: none, since its Box wraps the text itself. */
842const UNWRAPPED_COLUMNS = 100_000
843
844const widthOf = (parts: readonly Part[]) => parts.reduce((all, part) => all + displayWidth(part.text) + (part.chip === undefined ? 0 : 2), 0)
845
846/** A cell of words with the chips `you`, `Opus`, `Sonnet` and `Haiku` kept: `main session · Sonnet`. */
847const chipParts = (words: string): Part[] =>
848 words.split(' · ').flatMap((word, at): Part[] => [...(at === 0 ? [] : [{ text: ' · ' }]), CHIPS[word] === undefined ? { text: word } : { text: word, chip: CHIPS[word] }])
849
850/**
851 * One task's page: the task behind a square in its status color; a row of stats (working time, tokens, est. cost, and the effort when the
852 * work ran at one: bold values over dim labels), left out for a row with no usage and for her own rows; then a bordered table of labelled
853 * rows, each left out when the tracker records nothing for it: Goal, Status (in words and its color), Done by (the tier a chip
854 * and the executor, or the you chip), Checked by (once the row is Done), Progress (a bar), "Latest note" (the reason, on a
855 * Blocked row; its date, then one bulleted point per line, its file paths links).
856 * The frame is box characters on the terminal: a label cell and a value cell, a rule between rows;
857 * values wrap inside their cell. On the desktop each row is a round-bordered Box, a label Box of fixed width and a value Box that
858 * wraps its text.
859 */
860function drawDetail(kit: Kit, { row, columns, home, onBack, isProportional, now }: DetailArgs): RenderElement {
861 const { Box, Text, Button } = kit
862 const { color, ...dim } = STYLE[row.status]
863 const stuck = isBlocked(row)
864 // `│ ` label ` │ ` value ` │`: seven columns of frame and padding.
865 const labelWidth = Math.max(4, Math.min(LABEL_COLUMNS, columns - 7 - MIN_VALUE_COLUMNS))
866 const valueWidth = isProportional ? UNWRAPPED_COLUMNS : Math.max(1, columns - 7 - labelWidth)
867 const who = whoLineOf(row)
868 const checker = checkerOf(row)
869 const noteLines = noteLinesOf(row.note ?? '', valueWidth)
870 const bar = progressOf(row)
871 // Nothing is shown before the row started, nor for a row written before the tracker kept usage: never a guessed window.
872 // Her own tasks show no usage: the numbers are Claude's session, which does not measure her work.
873 const usage = usageStatsOf(row.started === undefined || isYou(executorOf(row)) ? undefined : row.usage)
874 const plain = (text: string): Part[][] => wrap(text, valueWidth).map(line => [{ text: line }])
875 // The chips stay when the cell holds the line whole; a narrower cell wraps the plain words instead.
876 const chipped = (words: string): Part[][] => (widthOf(chipParts(words)) <= valueWidth ? [chipParts(words)] : plain(words))
877 const doneBy: Part[] =
878 who === undefined ? [] : who.executor === 'you' ? [{ text: 'you', chip: CHIPS.you }] : [...(who.tier === undefined || CHIPS[who.tier] === undefined ? [] : [{ text: who.tier, chip: CHIPS[who.tier] }]), { text: ` ${who.executor}` }]
879 const whoLines: Part[][] = widthOf(doneBy) <= valueWidth ? [doneBy] : plain(who?.executor === 'you' ? 'you' : `${who?.tier ?? ''} ${who?.executor ?? ''}`.trim())
880 const barParts: Part[] | undefined =
881 bar === undefined
882 ? undefined
883 : isProportional
884 ? [{ text: '', bar: { fraction: bar.done / bar.total, color: HEX[color] ?? HEX.inactive ?? '#8A8A85' } }, { text: ` ${bar.count}` }]
885 : [{ text: '▰'.repeat(bar.filled), color }, { text: '▱'.repeat(bar.empty), dim: true }, { text: ` ${bar.count}` }]
886
887 const rows: TableRow[] = [
888 ...(row.goal === undefined || row.goal === '' ? [] : [{ key: 'goal', label: 'Goal', lines: plain(row.goal) }]),
889 {
890 key: 'status',
891 label: 'Status',
892 lines: wrap(row.statusText ?? statusWords(row, now), valueWidth).map((text): Part[] => [{ text, color, bold: stuck, ...(dim.dimColor === true ? { dim: true as const } : {}) }]),
893 },
894 ...(who === undefined ? [] : [{ key: 'who', label: 'Done by', lines: whoLines }]),
895 ...(row.status === 'Done' && checker !== undefined ? [{ key: 'checker', label: 'Checked by', lines: chipped(checker) }] : []),
896 ...(barParts === undefined ? [] : [{ key: 'progress', label: 'Progress', lines: [barParts] }]),
897 ...(noteLines.length === 0
898 ? []
899 : [
900 {
901 key: 'note',
902 label: stuck ? 'Blocked because' : 'Latest note',
903 isRed: stuck,
904 lines: noteLines.map(line =>
905 piecesOf(line.text).map((piece): Part => ({
906 text: piece.text,
907 ...(line.dim === true ? { dim: true as const } : {}),
908 ...(line.needsYou === true ? { color: NEEDS_YOU_COLOR, bold: true } : {}),
909 ...(line.tag === true ? { bold: true, ...(stuck ? { color: 'error' } : {}) } : {}),
910 ...(piece.path === undefined ? {} : { color: LINK_COLOR, href: fileHref(piece.path, home) }),
911 })),
912 ),
913 },
914 ]),
915 ]
916
917 return (
918 <Box key="detail" flexDirection="column" marginTop={1}>
919 <Button key="back" label="‹ Back" onPress={onBack} />
920 <Box key="detail:title" flexDirection="row" marginTop={1}>
921 <Text color={color} {...dim}>
922 ■{' '}
923 </Text>
924 <Text bold wrap="wrap">
925 {row.task}
926 </Text>
927 </Box>
928 {usage === undefined ? null : drawStats(kit, { stats: fitStats(usage, columns), isProportional, boxKey: 'detail:usage' })}
929 {drawTable(kit, { rows, columns, isProportional })}
930 </Box>
931 )
932}
933
934type TableArgs = Pick<DetailArgs, 'columns' | 'isProportional'> & { rows: readonly TableRow[] }
935
936/** The bordered table of label and value rows: box characters on the terminal, one round-bordered Box per row on the desktop. */
937function drawTable(kit: Kit, { rows, columns, isProportional }: TableArgs): RenderElement {
938 const { Box, Text, Link } = kit
939 const labelWidth = Math.max(4, Math.min(LABEL_COLUMNS, columns - 7 - MIN_VALUE_COLUMNS))
940 const valueWidth = isProportional ? UNWRAPPED_COLUMNS : Math.max(1, columns - 7 - labelWidth)
941
942 const part = (one: Part) => {
943 if (one.bar !== undefined) {
944 return (
945 <Box flexShrink={0} alignSelf="center">
946 {kit.Svg === undefined ? null : <kit.Svg source={barSvg(one.bar.fraction, one.bar.color)} alt="progress" width={BAR_PX.width} height={BAR_PX.height} />}
947 </Box>
948 )
949 }
950
951 if (one.chip !== undefined) {
952 return (
953 <Text color={one.chip.fg} backgroundColor={one.chip.bg} {...(one.chip.bold === true ? { bold: true } : {})}>{` ${one.text} `}</Text>
954 )
955 }
956
957 return (
958 <Text {...(one.color === undefined ? {} : { color: one.color })} {...(one.bold === true ? { bold: true } : {})} {...(one.dim === true ? { dimColor: true } : {})}>
959 {one.href === undefined || one.href === null ? one.text : <Link href={one.href} label={one.text} />}
960 </Text>
961 )
962 }
963
964 const rule = (key: string, left: string, mid: string, right: string) => (
965 <Text key={key} dimColor wrap="truncate">
966 {`${left}${'─'.repeat(labelWidth + 2)}${mid}${'─'.repeat(valueWidth + 2)}${right}`}
967 </Text>
968 )
969
970 const tableRow = (one: TableRow) => {
971 const height = one.lines.length
972
973 return (
974 <Box key={`field:${one.key}`} flexDirection="column">
975 {Array.from({ length: height }, (_, at) => {
976 const parts = one.lines[at] ?? []
977
978 return (
979 <Box key={`field:${one.key}:${at}`} flexDirection="row">
980 <Text dimColor>│ </Text>
981 <Text bold {...(one.isRed === true ? { color: 'error' } : {})}>
982 {at === 0 ? padTo(truncate(one.label, labelWidth), labelWidth) : ' '.repeat(labelWidth)}
983 </Text>
984 <Text dimColor> │ </Text>
985 {parts.map(part)}
986 <Text>{' '.repeat(Math.max(0, valueWidth - widthOf(parts)))}</Text>
987 <Text dimColor> │</Text>
988 </Box>
989 )
990 })}
991 </Box>
992 )
993 }
994
995 // The desktop's row: a round Box, the label in a Box of fixed width, the value in a Box that wraps. A bullet is its own Text
996 // beside a Box that wraps the point, so a long point hangs under its text.
997 const labelBox = Math.max(4, Math.min(LABEL_COLUMNS, columns - 6 - MIN_VALUE_COLUMNS))
998
999 const valueLine = (parts: Part[], at: number) => {
1000 const isBullet = parts[0]?.text.startsWith('• ') === true
1001 const shown = isBullet ? parts.map((one, i) => (i === 0 ? { ...one, text: one.text.slice(2) } : one)).filter(one => one.text !== '') : parts
1002 // A solid bar is a Box, which a Text cannot hold: such a line is a row of its parts.
1003 const text = shown.some(one => one.bar !== undefined) ? <Box flexDirection="row">{shown.map(part)}</Box> : <Text wrap="wrap">{shown.map(part)}</Text>
1004
1005 return isBullet ? (
1006 <Box key={`value:${at}`} flexDirection="row">
1007 <Text>•</Text>
1008 <Box marginLeft={1} flexGrow={1} flexShrink={1}>
1009 {text}
1010 </Box>
1011 </Box>
1012 ) : (
1013 <Box key={`value:${at}`}>{text}</Box>
1014 )
1015 }
1016
1017 const field = (one: TableRow) => (
1018 <Box key={`field:${one.key}`} flexDirection="row" borderStyle="round" borderDimColor paddingX={1}>
1019 <Box width={labelBox} flexShrink={0}>
1020 <Text bold {...(one.isRed === true ? { color: 'error' } : {})}>
1021 {one.label}
1022 </Text>
1023 </Box>
1024 <Box flexGrow={1} flexShrink={1} flexDirection="column">
1025 {one.lines.map(valueLine)}
1026 </Box>
1027 </Box>
1028 )
1029
1030 return isProportional ? (
1031 <Box key="detail:fields" flexDirection="column" marginTop={1}>
1032 {rows.map(field)}
1033 </Box>
1034 ) : (
1035 <Box key="detail:fields" flexDirection="column" marginTop={1}>
1036 {rule('rule:top', '╭', '┬', '╮')}
1037 {rows.flatMap((one, at) => (at === 0 ? [tableRow(one)] : [rule(`rule:${at}`, '├', '┼', '┤'), tableRow(one)]))}
1038 {rule('rule:bottom', '╰', '┴', '╯')}
1039 </Box>
1040 )
1041}
1042hooks/card.tsx 127 lines1/**
2 * The surface module of a card: the card's text rows, drawn in a Client region. A Button inverts under the pointer; a
3 * Client does not, so the card is clickable (a left button down and up inside the region posts `{ open }` to the
4 * plugin) without a bar over its rows or its mascot. A click also gives the region the keyboard, and Enter posts the same.
5 */
6import type { ClientModule } from 'claude-code'
7
8/** One text row of a card, as plain data. */
9export type CardRow = {
10 /** The mascot's row of block characters and its space, or ''. */
11 art: string
12 artColor: string
13 /** The marker (`⚑ Blocked · `, `◆ needs you · `) and its colour. */
14 marker: string
15 markerColor: string
16 name: string
17 isRed: boolean
18 isBold: boolean
19 /** The progress bar that starts the row (`name` is then its count), or undefined: filled cells in `color`, the empty ones dim. */
20 bar?: { filled: number; empty: number; color: string; track: string }
21 /** The word of the tag's chip, or ''; `tagPad` spaces go before it (the terminal aligns it right; a proportional font does it with a Box). */
22 tag: string
23 tagPad: number
24 tagBg: string
25 tagFg: string
26 tagBold: boolean
27}
28
29/**
30 * `isProportional` is a surface with a proportional font (the desktop app): the tag cannot be aligned with spaces there, so a row is
31 * the name in a Box that grows, then the tag.
32 */
33export type CardProps = { index: number; rows: CardRow[]; isProportional?: boolean }
34
35/** The local state: whether the left button went down in the card and has not come up yet. */
36type State = { isDown: boolean }
37
38const Card: ClientModule<CardProps, State> = (props, surface) => {
39 const { Box, Text } = surface.elements
40 const open = () => surface.post({ open: props.index })
41 const colour = (row: CardRow) => (row.isRed ? { color: 'error' } : {})
42
43 surface.onPointer(event => {
44 if (event.type === 'down' && event.button === 'left') {
45 surface.setState({ isDown: true })
46 }
47
48 if (event.type === 'up') {
49 const wasDown = surface.state?.isDown === true
50 // A click is the left button coming up in the card where it went down: letting go outside cancels it. A region
51 // not yet laid out reports 0 cells, and then nothing is outside.
52 const isInside = surface.columns === 0 || surface.rows === 0 || (event.x >= 0 && event.y >= 0 && event.x < surface.columns && event.y < surface.rows)
53
54 surface.setState({ isDown: false })
55
56 if (wasDown && event.button === 'left' && isInside) {
57 open()
58 }
59 }
60 })
61 surface.onKey(event => {
62 if (event.key === 'return') {
63 open()
64 }
65 })
66
67 const chip = (row: CardRow) => (
68 <Text color={row.tagFg} backgroundColor={row.tagBg} {...(row.tagBold ? { bold: true } : {})}>{` ${row.tag} `}</Text>
69 )
70
71 const name = (row: CardRow) =>
72 row.bar === undefined
73 ? row.name
74 : [
75 <Text color={row.bar.color}>{'▰'.repeat(row.bar.filled)}</Text>,
76 <Text dimColor>{'▱'.repeat(row.bar.empty)}</Text>,
77 row.name,
78 ]
79
80 return (
81 <Box flexDirection="column">
82 {props.rows.map(row => (
83 <Box flexDirection="row">
84 {row.art === '' ? null : <Text color={row.artColor}>{row.art}</Text>}
85 {row.marker === '' ? null : (
86 <Text bold color={row.markerColor}>
87 {row.marker}
88 </Text>
89 )}
90 {props.isProportional === true ? (
91 <Box flexGrow={1} flexDirection="row">
92 {
93 // The glyphs of a bar draw as slanted dashes in a proportional font: a solid bar of two Boxes instead.
94 row.bar === undefined ? null : (
95 <Box flexShrink={0} flexDirection="row">
96 {row.bar.filled === 0 ? null : <Box width={row.bar.filled} height={1} backgroundColor={row.bar.color} />}
97 {row.bar.empty === 0 ? null : <Box width={row.bar.empty} height={1} backgroundColor={row.bar.track} />}
98 </Box>
99 )
100 }
101 <Text {...colour(row)} {...(row.isBold ? { bold: true } : {})}>
102 {row.name}
103 </Text>
104 </Box>
105 ) : (
106 <Text {...colour(row)} {...(row.isBold ? { bold: true } : {})}>
107 {name(row)}
108 </Text>
109 )}
110 {row.tag === '' ? null : props.isProportional === true ? (
111 <Box marginLeft={1} flexShrink={0}>
112 {chip(row)}
113 </Box>
114 ) : (
115 <Text>
116 {' '.repeat(row.tagPad)}
117 {chip(row)}
118 </Text>
119 )}
120 </Box>
121 ))}
122 </Box>
123 )
124}
125
126export default Card
127hooks/band.tsx 33 lines1/**
2 * The desktop band's pressable overlay: an empty region laid over a part of the band that no Button can hold (the mascot and
3 * the bar are pictures). A left button down and up inside it posts `{ band: 'toggle' }` to the plugin, which toggles the
4 * board, as a press on the band does on the terminal. The posts reach the plugin by the instance, not by a button handle.
5 */
6import type { ClientModule } from 'claude-code'
7
8type State = { isDown: boolean }
9
10const BandZone: ClientModule<Record<string, never>, State> = (_props, surface) => {
11 const { Box } = surface.elements
12
13 surface.onPointer(event => {
14 if (event.type === 'down' && event.button === 'left') {
15 surface.setState({ isDown: true })
16 }
17
18 if (event.type === 'up') {
19 const wasDown = surface.state?.isDown === true
20
21 surface.setState({ isDown: false })
22
23 if (wasDown && event.button === 'left') {
24 surface.post({ band: 'toggle' })
25 }
26 }
27 })
28
29 return <Box />
30}
31
32export default BandZone
33types/index.d.ts 161 lines1/**
2 * The statuses a row can have, spelled as the tracker writes them (Blocked is a status here, not a flag), and `Other` for a
3 * status the PROGRESS.md reader does not know (its own text is in `statusText`).
4 */
5export type PlanStatus = 'Todo' | 'In Progress' | 'Verifying' | 'Needs Verification' | 'Blocked' | 'Done' | 'Canceled' | 'Other'
6
7/**
8 * One task of a plan, with only what the tracker records: `route` is `<split> × <executor> · <tier> — <why>` (absent when
9 * unrouted), `note` the row's latest free text (the reason, on a Blocked row), `updated` when it was last written.
10 * Absent fields are the tracker's null, dropped when the `--json` output is read.
11 */
12export type PlanRow = {
13 task: string
14 status: PlanStatus
15 /** The status as written in the board file, kept for a row whose status is `Other`. */
16 statusText?: string
17 note?: string
18 /** When the row was last written, ISO text; each lane lists the latest first. Null for none (a `PROGRESS.md` has none): a lane with no times keeps file order. */
19 updated: string | null
20 route?: string
21 /** The route's `<split> × <executor> · <tier>` or `unrouted`, as the tracker's `--json` gives it; used when there is no `route`. */
22 who?: string
23 /** Who checks the work, from the board file's `check` column (`script (grep)`, `you`, `subagent · Opus`); absent when the file has none. */
24 check?: string
25 /** One sentence on what the task is for; absent when not given. */
26 goal?: string
27 /** `n/m` steps done of total, as the agent reported it; absent until it has. */
28 progress?: string
29 /** When the row first went In Progress, and when it was finished (Done or Canceled); ISO text. Absent on rows written before these fields. */
30 started?: string
31 finished?: string
32 /**
33 * When the row last had a session working on it, ISO text: given by a board command for an In Progress or Verifying row that no session is working
34 * on. The card then shows a grey `stalled` tag with the time (`stalled 14:02`, or the day, `stalled Oct 11`, when it was not today) and its mascot
35 * stops, and the task page says `stalled since 14:02` (`stalled since Oct 11 14:02`). A `PROGRESS.md` never has it.
36 */
37 stalled_since?: string
38 /** What the row's work cost, as the usage command wrote it; absent until computed. */
39 usage?: PlanUsage
40}
41
42/** Time, effort, tokens and cost of one row's work. `effort` holds shares that sum to 1; `cost_usd` is null when a model has no price. */
43export type PlanUsage = {
44 working_ms: number
45 main_ms: number
46 subagent_ms: number
47 effort: Record<string, number>
48 models: string[]
49 tokens: { input: number; cache_read: number; cache_write: number; output: number }
50 cost_usd: number | null
51 computed_at: string
52}
53
54/** What the tracker adds up over a plan's rows for the line under its name; `measured` of `total` tasks (not Canceled, not the person's own, by `your_name` or `you`) have usage. */
55export type PlanTotals = {
56 working_ms: number
57 /** Input + cache read + cache write. */
58 tokens_in: number
59 tokens_cached: number
60 tokens_out: number
61 /** Null when some measured task has no price. */
62 cost: number | null
63 measured: number
64 total: number
65}
66
67export type PlanEntry = {
68 anchor: string
69 name: string
70 goal: string
71 /** The absolute path of the plan file the "Plan file ›" view reads. */
72 plan_file: string
73 /** When the plan was registered: the date, and (plans made since 2026-10-11) the full ISO time. */
74 created?: string
75 created_at?: string
76 /** The earliest row `started`; the latest row `finished`, given only once the plan is closed (the header then reads `Done at 21:40`). */
77 started?: string
78 finished?: string
79 /**
80 * When the plan last changed, ISO text: the newest row `updated`. A closed plan with no `finished` reads `Done at 21:40` from it (`Done at Oct 11 21:40`
81 * on another day). A `PROGRESS.md` never has it.
82 */
83 updated?: string
84 /**
85 * Since when the plan has had no row In Progress or Verifying, ISO text: given by a board command for an idle plan. The state word then reads
86 * `Idle since 19:40` (`Idle since Oct 11 19:40`); without it, plain `Idle`.
87 */
88 idle_since?: string
89 /** Absent when the tracker is older than the plan line. */
90 usage_totals?: PlanTotals
91 /** Claude's 2–3 sentence summary of the plan (what it is for, where it is now, what is next or needed from the person), drawn under the plan line. Absent when none. */
92 recap?: string
93 /** When the recap was written, ISO text. Absent when the recap has no time (one read from PROGRESS.md has none). */
94 recap_updated?: string
95 rows: PlanRow[]
96}
97
98/**
99 * One plan of the list `/plan-tracker` draws, as the board command lists it for the query `*`: a light line with the plan's state word
100 * (`Canceled` for an abandoned plan), `done` of `total` tasks, and when it was last updated (absent when no row has a time).
101 */
102export type PlanMatch = {
103 anchor: string
104 name: string
105 state: 'In progress' | 'Idle' | 'Done' | 'Canceled'
106 done: number
107 total: number
108 updated?: string
109}
110
111/**
112 * What the board command prints as JSON (or the board file, read as one), read: the plans of the session and the one the band and
113 * the board show. `matches` is the list of every plan, latest updated first, for the query `*`; without it the list is drawn from
114 * `plans` when there are two or more.
115 */
116export type PlanBoard = {
117 selected: string | null
118 message: string | null
119 plans: PlanEntry[]
120 matches?: PlanMatch[]
121}
122
123/** The tracker's answer: a board, or one line saying why there is none. */
124export type Live = { board: PlanBoard } | { error: string }
125
126/** The card whose page the pane shows: a plan and the index of the row in its `rows`. */
127export type CardRef = { anchor: string; index: number }
128
129declare module 'claude-code' {
130 interface PluginState {
131 'plan-tracker': {
132 /** The tracker's last answer; null until the first one is in. */
133 live: Live | null
134 /** The `--query` the refreshes pass: `*` while the pane lists the plans, else the plan opened from the list, the band or the plan menu, or the one that just finished; '' for none. */
135 query: string
136 /** Whether the pane shows the plan list (set by `/plan-tracker`, cleared by opening a plan). */
137 listing: boolean
138 /** The text of the list's search field. */
139 search: string
140 /** Every plan, as the last answer to the query `*` listed them; null until one is in. The list is drawn from it while a new one is read. */
141 allPlans: PlanMatch[] | null
142 /** The anchor picked in the band or the pane; null until one is picked (the tracker's `selected` stands). */
143 selected: string | null
144 /** The card whose page replaces the board; null shows the board. */
145 card: CardRef | null
146 /** Whether the pane shows the plan file instead of the board. */
147 file: boolean
148 /** The anchor of the plan a PLAN-DONE with 0 left just finished, until the person's next prompt; null for none. */
149 finished: string | null
150 /** The animation frame, advanced by the one timer while something moves on screen. */
151 frame: number
152 /** Whether the pane's plan dropdown shows its list. */
153 menu: boolean
154 /** The lanes that show all their cards, as `<anchor>:<lane>`. */
155 expanded: string[]
156 /** Whether the band's plan list shows. */
157 bandMenu: boolean
158 }
159 }
160}
161