SLOPSHOPPER

plan-tracker

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

newpanebandguardcommandtoast
v0.7.2MITupdated 2026-10-16lijingwu2026/claude-code-plan-tracker
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · plan-tracker
│ ┃ Plans ✕ › fix the failing auth test and add an audit log call │ ┃ No PROGRESS.md yet: the route skill writes │ ┃ it when an approved plan starts. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /plan-tracker │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Plans
No PROGRESS.md yet: the route skill writes it when an approved plan starts.
README

<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>

plan-tracker

Claude Code mod that puts your plan on one live board: every task, who's on it, and what waits on you.

Get started

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
ToDo
See your planApprove a plan in plan mode. The band appears above your prompt, in every session of that project.
Open the boardPress 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.mdCopy the table under Try it. plan-orchestrator is not needed.

What you get

Plan band

<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%">

A task goes Done

<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>

  • Above your prompt: the state word (In progress, Idle or Done), done out of total (3/8) and a bar.
  • A task is done → a pop-up ✅ <task> · N left and, in the terminal, a chime.
  • You come back, or start a new session → the band shows the file's state; the board has Claude's last recap.

Board

<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%">

  • Claude adds a task → a card for it appears.
  • A task is blocked → its card sits red at the top of In Progress; open its page and the latest note says why.
  • You want to know who runs what → each open card is tagged with who has it: Opus, Sonnet, Haiku or you (in Verify, who checks it). A Done task's page says who did it and who checked it.

Task page

<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%">

  • A task needs your review → its card says ◆ needs you; open its page for the goal and the latest note.

How it works

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%">

Compared

Use plan-tracker alongside Claude Code's own views; each shows something different.

Claude Code'sIt showsplan-tracker adds
Agent map (Desktop, VS Code)This session's subagents, each with its status, time and tokensThe 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 exitsWhich tasks are running, blocked or done, kept in a file after the session ends

Try it

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.

Limits

  • Read-only for your board: it never writes PROGRESS.md.
  • Polls the file about every 1.5 s; it does not use file-system events.
  • Shows the first table in the file with task and status columns, as one plan.
  • Every open session on the same project shows the pop-up and plays the chime when a task goes Done.
  • Tested on Claude Code 2.1.299 on macOS, in the terminal. The Claude Desktop app is not tested with this plugin; an earlier private build of the same code ran there.
  • Claude Code mods are an early-access feature.

Troubleshooting

  • No band. Run /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.
  • Nothing changed after install. Restart Claude Code. claude plugin update ends with "Restart to apply changes".

Reference

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.

License

MIT

Source 6 files
hooks/register.tsx 1111 lines
1/**
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}
1111
hooks/board.ts 1899 lines
1/**
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 lines
1/** 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}
1042
hooks/card.tsx 127 lines
1/**
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
127
hooks/band.tsx 33 lines
1/**
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
33
types/index.d.ts 161 lines
1/**
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