hooks/register.tsx 460 lines
1// The roadmap pane: `/ready` opens the ready set the CLI computes, in
2// leverage order, with the claims in play and the milestones' progress, and
3// runs again to close it. A digit picks a row; `c` claims it after asking
4// who (an assignee is never inferred), `r` refreshes. The CLI is the
5// plugin's own scripts/roadmap.py, the same file the skills run, so the pane
6// needs python3 and nothing else. Nothing here edits roadmaps.json except
7// through `claim`.
8//
9// Colours are the engine's theme keys, never raw values: the pane follows
10// whichever theme the person runs. A release tier takes one hue everywhere it
11// appears (the tier row, its milestone chips, the task ids drawn from it).
12
13import { atom, read, update } from 'claude-code'
14import type { EngineInterface, Register } from 'claude-code'
15
16import type { Candidate, Claimed, Milestone, Problem, Snapshot } from '../types'
17
18const PANE = 'ready'
19const ROWS = 9
20const BAR_CELLS = 5
21const TIER_LABEL_WIDTH = 11
22
23const snapshot = atom({ plugin: 'roadmap', key: 'snapshot' } as const, null as Snapshot | null)
24const selected = atom({ plugin: 'roadmap', key: 'selected' } as const, null as string | null)
25const asking = atom({ plugin: 'roadmap', key: 'asking' } as const, null as string | null)
26
27// Module-level because the validator holds `$` to top-level functions; only
28// the CLI path lives here, which no drawing reads.
29let cli = ''
30
31type Ui = ReturnType<EngineInterface['ui']['resolve']>
32
33const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
34const str = (v: unknown): string => (typeof v === 'string' ? v : typeof v === 'number' ? String(v) : '')
35const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
36
37// No cwd: the default is the session's working directory read per call, so a
38// move into a worktree takes the pane with it and the CLI finds that branch's
39// roadmaps.json by walking up from there.
40const run = ($: EngineInterface, args: readonly string[]) => $.process.run(['python3', cli, ...args], { timeoutMs: 15_000 })
41
42const firstLine = (text: string): string => text.trim().split('\n')[0] ?? ''
43
44const lastLine = (text: string): string => text.trim().split('\n').pop() ?? ''
45
46/** What the CLI said when it refused: its own `✗` line goes to stdout; a traceback's last line is the error. */
47export const reasonOf = (ran: { stdout: string; stderr: string }, fallback: string): string => {
48 if (/Traceback \(most recent call last\)/.test(ran.stderr)) return lastLine(ran.stderr) || fallback
49 return firstLine(ran.stdout) || firstLine(ran.stderr) || fallback
50}
51
52const isPaneOpen = async ($: EngineInterface): Promise<boolean> => {
53 try {
54 return (await $.ui.panes()).some(p => p.id === PANE)
55 } catch {
56 return false
57 }
58}
59
60const TIER_NAMES = ['Core', 'Secondary', 'Tertiary', 'Quaternary', 'Quinary']
61const TIER_HUES = ['claude', 'suggestion', 'remember', 'permission'] as const
62
63/** One hue per release tier, cycling past the fourth. */
64export const tierHue = (tier: number): string => TIER_HUES[Math.max(0, tier) % TIER_HUES.length] ?? 'subtle'
65
66/** A fixed-width bar: `███░░` for 60% in five cells. */
67export const bar = (pct: number, cells: number): string => {
68 const filled = Math.round((Math.max(0, Math.min(100, pct)) / 100) * cells)
69 return '█'.repeat(filled) + '░'.repeat(cells - filled)
70}
71
72/** A labelled rule that fills `columns`: `── Ready now ─────────`. */
73export const rule = (label: string, columns: number): string => {
74 const head = `── ${label} `
75 return head + '─'.repeat(Math.max(2, columns - head.length))
76}
77
78/** Milestones grouped by release tier, lowest tier first, each group in roadmap order. */
79export const groupByTier = (milestones: readonly Milestone[]): { tier: number; label: string; milestones: Milestone[] }[] => {
80 const tiers = [...new Set(milestones.map(m => m.tier))].sort((a, b) => a - b)
81 return tiers.map(tier => {
82 const members = milestones.filter(m => m.tier === tier)
83 return { tier, label: members[0]?.tierLabel || TIER_NAMES[tier] || `Tier ${tier + 1}`, milestones: members }
84 })
85}
86
87/** Turn the CLI's two JSON documents into what the pane draws. */
88export const parseSnapshot = (readyText: string, statsText: string, at: number): Snapshot => {
89 const ready: unknown = JSON.parse(readyText)
90 const stats: unknown = JSON.parse(statsText)
91 if (!isRecord(ready) || !isRecord(stats)) throw new Error('not an object')
92 const candidates: Candidate[] = (Array.isArray(ready.candidates) ? ready.candidates : []).filter(isRecord).map(c => ({
93 id: str(c.id),
94 description: str(c.description),
95 milestone: str(c.milestone),
96 milestoneName: str(c.milestoneName),
97 milestoneDonePct: num(c.milestoneDonePct),
98 tier: num(c.tier),
99 tierLabel: str(c.tierLabel),
100 transitiveUnblocks: num(c.transitiveUnblocks),
101 isMilestoneSink: c.isMilestoneSink === true,
102 assignee: str(c.assignee),
103 }))
104 const claimed: Claimed[] = (Array.isArray(ready.claimed) ? ready.claimed : []).filter(isRecord).map(c => ({
105 id: str(c.id),
106 description: str(c.description),
107 assignee: str(c.assignee),
108 started: str(c.started),
109 }))
110 const milestones: Milestone[] = (Array.isArray(stats.milestones) ? stats.milestones : []).filter(isRecord).map(m => ({
111 id: str(m.id),
112 name: str(m.name),
113 donePct: num(m.donePct),
114 tier: num(m.tier),
115 tierLabel: str(m.tierLabel) || TIER_NAMES[num(m.tier)] || '',
116 state: str(m.state),
117 }))
118 const byStatus = isRecord(stats.byStatus) ? stats.byStatus : {}
119 return {
120 ok: true,
121 problem: 'none',
122 reason: '',
123 phase: str(ready.phase) || str(stats.phase),
124 donePct: num(stats.donePct),
125 doneCount: num(byStatus.done),
126 inScope: num(stats.inScope),
127 milestones,
128 candidates,
129 claimed,
130 at,
131 }
132}
133
134const refresh = async ($: EngineInterface): Promise<void> => {
135 const at = await $.clock.now()
136 const failed = (problem: Problem, reason: string): Snapshot => ({
137 ok: false,
138 problem,
139 reason,
140 phase: '',
141 donePct: 0,
142 doneCount: 0,
143 inScope: 0,
144 milestones: [],
145 candidates: [],
146 claimed: [],
147 at,
148 })
149 try {
150 const detect = await run($, ['detect'])
151 if (detect.exitCode === 3) return void (await update($, snapshot, () => failed('legacy', 'old single-file roadmap')))
152 if (detect.exitCode === 2) return void (await update($, snapshot, () => failed('missing', reasonOf(detect, 'no roadmap above this directory'))))
153 if (detect.exitCode !== 0) return void (await update($, snapshot, () => failed('broken', reasonOf(detect, 'the CLI refused'))))
154 const [ready, stats] = await Promise.all([run($, ['ready', '--json']), run($, ['stats', '--json'])])
155 if (ready.exitCode !== 0 || stats.exitCode !== 0) {
156 return void (await update($, snapshot, () => failed('broken', reasonOf(ready.exitCode !== 0 ? ready : stats, 'the CLI refused'))))
157 }
158 const next = parseSnapshot(ready.stdout, stats.stdout, at)
159 await update($, snapshot, () => next)
160 } catch (error) {
161 await update($, snapshot, () => failed('broken', error instanceof Error && /ENOENT|not found/i.test(error.message) ? 'python3 is not on PATH' : 'the CLI could not run'))
162 }
163}
164
165const claim = async ($: EngineInterface, id: string, assignee: string): Promise<string> => {
166 const who = assignee.trim()
167 if (!who) return 'a claim needs a name.'
168 try {
169 const ran = await run($, ['claim', id, `--assignee=${who}`])
170 if (ran.exitCode !== 0) return reasonOf(ran, `claim ${id} refused`)
171 return `claimed ${id} for ${who}. commit roadmaps.json when you are ready.`
172 } catch {
173 return 'the CLI could not run'
174 }
175}
176
177/** One step of the empty state: a numbered line, what it does, the command that does it. */
178const step = (ui: Ui, n: number, what: string, pointer: string) => {
179 const { Box, Text } = ui
180 const [command = '', alt = ''] = pointer.split(' or ')
181 return (
182 <Box key={`step-${n}`} flexDirection="row" columnGap={1} flexWrap="wrap">
183 <Text color="suggestion" bold>
184 {n}
185 </Text>
186 <Text>{what.padEnd(12)}</Text>
187 <Text color="claude" bold>
188 {command}
189 </Text>
190 {alt && <Text dimColor>or {alt}</Text>}
191 </Box>
192 )
193}
194
195/** No roadmap to draw: a framed card that says why and what to do about it. */
196const emptyState = ($: EngineInterface, ui: Ui, snap: Snapshot, width: number) => {
197 const { Box, Text, Button } = ui
198 const frame = snap.problem === 'broken' ? 'error' : 'subtle'
199 const content =
200 snap.problem === 'legacy'
201 ? {
202 glyph: '◈',
203 title: 'Old roadmap format',
204 lead: 'This roadmap is a single file. The pane reads the phase-array format.',
205 steps: [step(ui, 1, 'Convert it', '/roadmap:migrate'), step(ui, 2, 'Reopen', '/ready or press r')],
206 }
207 : snap.problem === 'broken'
208 ? {
209 glyph: '✗',
210 title: 'The roadmap would not load',
211 lead: snap.reason,
212 steps: [step(ui, 1, 'Check it', '/roadmap:maintain'), step(ui, 2, 'Then', 'r or refresh')],
213 }
214 : {
215 glyph: '◇',
216 title: 'No roadmap in this project',
217 lead: 'Nothing at .claude/roadmaps.json above this directory.',
218 steps: [step(ui, 1, 'Start one', '/roadmap:create'), step(ui, 2, 'Reopen', '/ready or press r')],
219 }
220 return (
221 <Box flexDirection="column" width={width} rowGap={1}>
222 <Box flexDirection="column" borderStyle="round" borderColor={frame} paddingX={2} paddingY={1} rowGap={1}>
223 <Box flexDirection="row" columnGap={1}>
224 <Text color={frame === 'error' ? 'error' : 'claude'} bold>
225 {content.glyph}
226 </Text>
227 <Text bold>{content.title}</Text>
228 </Box>
229 <Text color={snap.problem === 'broken' ? 'error' : undefined} dimColor={snap.problem !== 'broken'}>
230 {content.lead}
231 </Text>
232 {snap.problem === 'missing' && <Text dimColor>A roadmap holds your tasks as a dependency graph. This pane then shows what is unblocked, what it unlocks and who has claimed what.</Text>}
233 <Box flexDirection="column">{content.steps}</Box>
234 </Box>
235 <Box flexDirection="row" columnGap={2}>
236 <Button key="refresh" label="refresh" hotkey="r" onPress={() => refresh($)} />
237 <Text dimColor>esc or /ready closes</Text>
238 </Box>
239 </Box>
240 )
241}
242
243/** The phase line and one row per release tier, each milestone a chip of its own progress. */
244const header = (ui: Ui, snap: Snapshot, inner: number) => {
245 const { Box, Text } = ui
246 const tiers = groupByTier(snap.milestones)
247 const barCells = Math.max(10, Math.min(24, inner - 40))
248 return (
249 <Box flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1} width={inner + 4}>
250 <Box flexDirection="row" columnGap={2} flexWrap="wrap">
251 <Text bold>{snap.phase || 'roadmap'}</Text>
252 <Text color={snap.donePct >= 100 ? 'success' : 'claude'}>{bar(snap.donePct, barCells)}</Text>
253 <Text bold>{snap.donePct}%</Text>
254 {snap.inScope > 0 && (
255 <Text dimColor>
256 {snap.doneCount}/{snap.inScope} tasks
257 </Text>
258 )}
259 </Box>
260 {tiers.length > 0 && <Text dimColor>{'─'.repeat(Math.max(2, inner))}</Text>}
261 {tiers.map(group => {
262 const hue = tierHue(group.tier)
263 const deferred = group.milestones.every(m => m.state === 'deferred')
264 return (
265 <Box key={`tier-${group.tier}`} flexDirection="row" columnGap={1}>
266 <Box width={TIER_LABEL_WIDTH}>
267 <Text color={hue} bold dimColor={deferred}>
268 {`${group.tier === 0 ? '●' : '○'} ${group.label}`}
269 </Text>
270 </Box>
271 <Box flexDirection="row" columnGap={2} flexWrap="wrap" flexGrow={1}>
272 {group.milestones.map(m => (
273 <Box key={`ms-${m.id}`} flexDirection="row" columnGap={1}>
274 <Text color={hue} dimColor={m.state === 'deferred'} bold={m.donePct < 100 && m.donePct > 0}>
275 {m.id}
276 </Text>
277 {m.donePct >= 100 ? (
278 <Text color="success">✓</Text>
279 ) : (
280 <Text color={m.donePct > 0 ? hue : 'subtle'} dimColor={m.donePct === 0}>
281 {bar(m.donePct, BAR_CELLS)} {m.donePct}%
282 </Text>
283 )}
284 </Box>
285 ))}
286 </Box>
287 </Box>
288 )
289 })}
290 </Box>
291 )
292}
293
294/** One ready task: hotkey and id on the first line with the description, the leverage signals beneath. */
295const taskRow = ($: EngineInterface, ui: Ui, c: Candidate, i: number, picked: string | null, inner: number) => {
296 const { Box, Text, Button } = ui
297 const hue = tierHue(c.tier)
298 const isPicked = picked === c.id
299 const lead = 3 + 3 + c.id.length + 1
300 return (
301 <Box key={`task-${c.id}`} flexDirection="column">
302 <Box flexDirection="row" columnGap={1}>
303 <Text color={hue} bold>
304 {isPicked ? '▶' : ' '}
305 </Text>
306 <Button
307 key={`row-${c.id}`}
308 plain
309 hotkey={String(i + 1)}
310 dimColor={picked !== null && !isPicked}
311 label={c.id}
312 onPress={async () => {
313 await update($, selected, () => c.id)
314 await update($, asking, () => null)
315 }}
316 />
317 <Box width={Math.max(10, inner - lead)}>
318 <Text bold={isPicked} dimColor={picked !== null && !isPicked} wrap={isPicked ? 'wrap' : 'truncate-end'}>
319 {c.description}
320 </Text>
321 </Box>
322 </Box>
323 <Box flexDirection="row" flexWrap="wrap" paddingLeft={5} columnGap={1}>
324 <Text dimColor>↳</Text>
325 <Text color={hue}>unblocks {c.transitiveUnblocks}</Text>
326 <Text dimColor>·</Text>
327 <Text dimColor>
328 {c.milestone} {c.milestoneName ? `${c.milestoneName} ` : ''}
329 {c.milestoneDonePct}%
330 </Text>
331 {c.isMilestoneSink && <Text color="success">completes it</Text>}
332 {c.assignee && <Text color="warning">● {c.assignee}</Text>}
333 </Box>
334 </Box>
335 )
336}
337
338/** Claims in play: who holds which task, and since when. */
339const claimedRows = (ui: Ui, claimed: readonly Claimed[], inner: number) => {
340 const { Box, Text } = ui
341 return claimed.map(c => (
342 <Box key={`claimed-${c.id}`} flexDirection="row" columnGap={1}>
343 <Text color="warning">●</Text>
344 <Text bold>{c.id}</Text>
345 <Text color="warning">{c.assignee || 'someone'}</Text>
346 <Text dimColor>since {c.started || '?'}</Text>
347 <Box width={Math.max(8, inner - c.id.length - (c.assignee || 'someone').length - (c.started || '?').length - 14)}>
348 <Text dimColor wrap="truncate-end">
349 {c.description}
350 </Text>
351 </Box>
352 </Box>
353 ))
354}
355
356export const register: Register = on => {
357 on('session.start', async ($, e, next) => {
358 cli = `${$.plugin.root}/scripts/roadmap.py`
359 try {
360 await $.command.register({
361 name: 'ready',
362 description: 'The roadmap ready set in a pane, or closes it: a digit picks, c claims, r refreshes',
363 immediate: true,
364 })
365 } catch {
366 // the name is taken; nothing else to do
367 }
368 // Only while the pane is actually placed: an unplaced or closed pane spawns nothing.
369 $.clock.every(120_000, () => {
370 void isPaneOpen($).then(open => {
371 if (open) void refresh($)
372 })
373 })
374 return next(e)
375 })
376
377 // Open or close: the pane is a tab beside any other, /diff included, and
378 // the same command takes it down again.
379 on('command.run', { command: 'ready' }, async $ => {
380 if (await isPaneOpen($)) {
381 await $.ui.close({ id: PANE })
382 return {}
383 }
384 await refresh($)
385 await $.ui.open({ id: PANE, title: 'ready', focus: true, closeOnEscape: true, rows: 16 })
386 return {}
387 })
388
389 // A change to the roadmap by any route redraws the pane while it is open.
390 on('tool.call', async ($, e, next) => {
391 const ran = await next(e)
392 if (e.agentId) return ran
393 const touched =
394 ((e.tool === 'Edit' || e.tool === 'Write') && /roadmaps\.json$/.test(e.file_path)) ||
395 (e.tool === 'Bash' && /roadmap\.py|roadmaps\.json/.test(e.command))
396 if (touched && (await isPaneOpen($))) await refresh($)
397 return ran
398 })
399
400 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
401 if (e.surface !== 'terminal') return next(e)
402 const ui = $.ui.resolve(e)
403 const { Box, Text, Button, Input } = ui
404 const [snap, picked, ask] = await Promise.all([read($, snapshot), read($, selected), read($, asking)])
405 if (!snap) return <Text dimColor>reading the roadmap…</Text>
406 const width = Math.max(40, e.props.bodyColumns)
407 if (!snap.ok) return emptyState($, ui, snap, width - 1)
408 // the header's frame and padding take four columns; every line inside it shares the measure
409 const inner = width - 5
410 const rows = snap.candidates.slice(0, ROWS)
411 const current = rows.find(c => c.id === picked) ?? null
412 return (
413 <Box flexDirection="column" width={width} rowGap={1}>
414 {header(ui, snap, inner)}
415 <Box flexDirection="column" paddingX={1}>
416 <Text color="subtle">{rule(`Ready now · ${snap.candidates.length}`, inner)}</Text>
417 {rows.length === 0 && <Text dimColor>nothing is ready. a blocker or a gate holds everything; /roadmap:review names it.</Text>}
418 <Box flexDirection="column" rowGap={1}>
419 {rows.map((c, i) => taskRow($, ui, c, i, picked, inner))}
420 </Box>
421 {snap.candidates.length > ROWS && <Text dimColor>+{snap.candidates.length - ROWS} more below the fold</Text>}
422 </Box>
423 {snap.claimed.length > 0 && (
424 <Box flexDirection="column" paddingX={1}>
425 <Text color="subtle">{rule(`In play · ${snap.claimed.length}`, inner)}</Text>
426 {claimedRows(ui, snap.claimed, inner)}
427 </Box>
428 )}
429 <Box flexDirection="column" paddingX={1}>
430 {ask === null && (
431 <Box flexDirection="row" columnGap={2}>
432 {current && <Button key="claim" label="claim" hotkey="c" variant="primary" onPress={() => update($, asking, () => current.id)} />}
433 <Button key="refresh" label="refresh" hotkey="r" onPress={() => refresh($)} />
434 </Box>
435 )}
436 {current && ask === current.id && (
437 <Input
438 key="assignee"
439 label={`who is doing ${current.id}`}
440 placeholder="a name; never inferred, never pre-filled"
441 submitLabel="claim"
442 autoFocus
443 onSubmit={async value => {
444 if (!value.trim()) {
445 $.ui.toast('a claim needs a name.')
446 return
447 }
448 $.ui.toast(await claim($, current.id, value))
449 await update($, asking, () => null)
450 await refresh($)
451 }}
452 />
453 )}
454 <Text dimColor>1-9 pick · c claim · r refresh · esc or /ready closes</Text>
455 </Box>
456 </Box>
457 )
458 })
459}
460