A live pane that draws the project's ROADMAP.md as a timeline: overall progress, one row per phase, and tasks you can tick off. It opens by itself when the…

A Claude Code mod that shows your project's ROADMAP.md as a live timeline in a side pane: overall progress, one row per phase, and tasks you can tick off without leaving the session.


At the prompt of a terminal claude session:
/plugin install roadmap-pane --marketplace LeoBessin/claude-roadmap-mod
Answer y to add the marketplace, then pick a scope (user scope loads it in every session). Once installed it also loads in Claude Code sessions started by the desktop app.
Or add the marketplace first, then install from it:
/plugin marketplace add LeoBessin/claude-roadmap-mod
/plugin install roadmap-pane@claude-roadmap-mod
ROADMAP.md: the pane opens by itself./roadmap opens the pane; /roadmap path/to/file.md points it at another file.The pane follows the file as it changes, including edits Claude makes, within a couple of seconds.
ROADMAP.md, Roadmap.md, roadmap.md, docs/ROADMAP.md or .github/ROADMAP.md in the session's working directory, or uses the file you named with /roadmap. Every 1.5 seconds while the session runs it checks whether those paths exist and reads the found file's modification time, rereading the file only when that changed.[ ]/[x]. Create roadmap writes a starter ROADMAP.md when the project has none./roadmap reopens it.# Project roadmap
One line about where the project is heading.
## Phase 1 — Foundations (in progress)
Short note about the phase.
### Platform
- [x] Set up the repository
- [ ] Ship the first milestone
- [ ] A nested task
## Later
- Ideas without checkboxes are shown but not counted
# is the title, ## headings are phases, ### headings are groups.- [ ] and - [x] are tasks; plain bullets are notes.(done), (in progress).Without a roadmap, the pane offers to create one.
On the desktop app the header, bar and icons are SVG in Claude Code's colors, following the system's light or dark scheme. In the terminal the same layout is drawn with text.
claude plugin validate .
claude plugin test .
To run a working copy instead of the installed one, name its folder in CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json.
MIT
hooks/register.tsx 405 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type { RoadmapFilter, RoadmapItem, RoadmapPhase } from '../types'
4import { TEMPLATE, focusPhase, parseRoadmap, toggleLine } from './parse'
5import { checkboxSvg, headerSvg, railSvg, statusSvg } from './svg'
6
7const PANE = 'roadmap'
8const TITLE = 'Roadmap'
9const CANDIDATES = ['ROADMAP.md', 'Roadmap.md', 'roadmap.md', 'docs/ROADMAP.md', '.github/ROADMAP.md']
10const POLL_MS = 1500
11
12const DOC = { plugin: 'roadmap-pane', key: 'doc' } as const
13const PATH = { plugin: 'roadmap-pane', key: 'path' } as const
14const ERROR = { plugin: 'roadmap-pane', key: 'error' } as const
15
16const FILTER = { plugin: 'roadmap-pane', key: 'filter' } as const
17const COLLAPSED = { plugin: 'roadmap-pane', key: 'collapsed' } as const
18
19async function locate($: EngineInterface): Promise<string | null> {
20 const { value: chosen = null } = await $.state.get(PATH)
21 if (chosen !== null) return chosen
22 for (const name of CANDIDATES) {
23 if (await $.fs.exists(name).catch(() => false)) return name
24 }
25
26 return null
27}
28
29/** Re-reads the file when its mtime moved (or always, with `isForced`). */
30async function refresh($: EngineInterface, isForced = false): Promise<void> {
31 const file = await locate($)
32 const { value: known = null } = await $.state.get(DOC)
33 if (file === null) {
34 if (known !== null) await $.state.set(DOC, null)
35 return
36 }
37 try {
38 const stat = await $.fs.stat(file)
39 if (!isForced && known?.path === file && known.mtimeMs === stat.mtimeMs) return
40 const source = await $.fs.read(file)
41 await $.state.set(DOC, parseRoadmap(source, file, stat.mtimeMs))
42 const { value: problem = null } = await $.state.get(ERROR)
43 if (problem !== null) await $.state.set(ERROR, null)
44 } catch (cause) {
45 const message = cause instanceof Error ? cause.message : String(cause)
46 await $.state.set(ERROR, `Could not read ${file}: ${message}`)
47 }
48}
49
50async function toggle($: EngineInterface, item: RoadmapItem): Promise<void> {
51 const { value: known = null } = await $.state.get(DOC)
52 if (known === null) return
53 const source = await $.fs.read(known.path)
54 const next = toggleLine(source, item)
55 if (next === null) {
56 $.ui.toast('ROADMAP.md changed underneath; reloaded it.')
57 } else {
58 await $.fs.write(known.path, next)
59 }
60 await refresh($, true)
61}
62
63function percent(done: number, total: number): number {
64 return total === 0 ? 0 : Math.round((done / total) * 100)
65}
66
67// Claude Code's own theme keys, so the pane follows the person's theme.
68const STATUS_COLOR = { done: 'success', active: 'claude', planned: 'inactive' } as const
69const STATUS_GLYPH = { done: '●', active: '◐', planned: '○' } as const
70const STATUS_LABEL = { done: 'Done', active: 'In progress', planned: 'Planned' } as const
71const TRACK = 'subtle'
72
73/** Splits `width` cells between phases by task count, never past `width` in all. */
74function segmentWidths(phases: RoadmapPhase[], width: number): number[] {
75 const counted = phases.filter(phase => phase.total > 0)
76 const room = Math.max(counted.length, width - (counted.length - 1))
77 const total = counted.reduce((sum, phase) => sum + phase.total, 0)
78
79 return counted.map(phase => Math.max(1, Math.floor((phase.total / total) * room)))
80}
81
82export const register: Register = on => {
83 on('session.start', async ($, e, next) => {
84 await $.command.register({
85 name: 'roadmap',
86 description: "Show the project's ROADMAP.md as widgets in a pane",
87 argumentHint: '[path to roadmap file]',
88 })
89 await refresh($, true)
90 $.clock.every(POLL_MS, () => void refresh($))
91 if ((await $.state.get(DOC)).value != null) {
92 void $.ui.open({ id: PANE, title: TITLE, columns: 56 })
93 }
94
95 return next(e)
96 })
97
98 on('command.run', { command: 'roadmap' }, async ($, e) => {
99 const asked = e.args.trim()
100 if (asked !== '') {
101 if (!(await $.fs.exists(asked).catch(() => false))) {
102 return { text: `No file at ${asked}.` }
103 }
104 await $.state.set(PATH, asked)
105 }
106 await refresh($, true)
107 await $.ui.open({ id: PANE, title: TITLE, columns: 56 })
108 const { value: known = null } = await $.state.get(DOC)
109
110 return {
111 text:
112 known === null
113 ? 'Roadmap pane opened; no ROADMAP.md found yet.'
114 : `Roadmap pane opened on ${known.path}: ${known.done}/${known.total} tasks done.`,
115 }
116 })
117
118 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
119 const table = $.ui.resolve(e)
120 const { Box, Text, Button } = table
121 const Svg = 'Svg' in table ? table.Svg : undefined
122 const Markdown = 'Markdown' in table ? table.Markdown : undefined
123 const columns = Math.max(20, e.props.bodyColumns - 6)
124
125 const { value: roadmap = null } = await $.state.get(DOC)
126 const { value: problem = null } = await $.state.get(ERROR)
127 const { value: shown = 'all' } = await $.state.get(FILTER)
128 const { value: folded = {} } = await $.state.get(COLLAPSED)
129
130 const heading = (text: string) =>
131 Markdown ? <Markdown key="heading" text={`### ${text}`} /> : <Text bold>{text}</Text>
132
133 if (roadmap === null) {
134 return (
135 <Box flexDirection="column" paddingX={2} paddingY={1} gap={1}>
136 {heading('Start your roadmap')}
137 <Text>
138 Add a ROADMAP.md with ## phases and - [ ] tasks, and it shows up here as you work. You can also point at
139 another file with /roadmap path/to/file.md.
140 </Text>
141 {problem === null ? null : <Text color="error">{problem}</Text>}
142 <Box>
143 <Button
144 key="create"
145 variant="primary"
146 label="Create roadmap"
147 onPress={async () => {
148 await $.fs.write('ROADMAP.md', TEMPLATE)
149 await $.state.set(PATH, null)
150 await refresh($, true)
151 }}
152 />
153 </Box>
154 </Box>
155 )
156 }
157
158 const focus = focusPhase(roadmap)
159 const phaseIndex = focus === undefined ? roadmap.phases.length : roadmap.phases.indexOf(focus) + 1
160 const isOpen = (phase: RoadmapPhase) => folded[phase.id] ?? phase.id === focus?.id
161
162 const keep = (item: RoadmapItem) => {
163 if (shown === 'all') return true
164 if (item.isDone === null) return false
165
166 return shown === 'done' ? item.isDone : !item.isDone
167 }
168
169 // Desktop: the mockup's drawing as Svg, with plain Buttons where a press lands.
170 if (Svg) {
171 const chip = (value: RoadmapFilter, label: string) => (
172 <Button key={`filter-${value}`} plain dimColor={shown !== value} onPress={() => $.state.set(FILTER, value)}>
173 <Text bold={shown === value}>{label}</Text>
174 </Button>
175 )
176
177 const task = (item: RoadmapItem) =>
178 item.isDone === null ? (
179 <Box key={item.id} paddingLeft={item.depth * 2}>
180 <Text dimColor>· {item.text}</Text>
181 </Box>
182 ) : (
183 <Box key={item.id} gap={1} alignItems="center" paddingLeft={item.depth * 2}>
184 <Svg source={checkboxSvg(item.isDone)} alt={item.isDone ? 'Done' : 'Open'} width={16} height={16} />
185 <Button key={`task-${item.id}`} plain dimColor={item.isDone} onPress={() => toggle($, item)}>
186 <Text strikethrough={item.isDone}>{item.text}</Text>
187 </Button>
188 </Box>
189 )
190
191 // Each phase is a block with its rail behind it: an absolute Box from under the
192 // icon to the block's bottom, clipping one tall line, so no row height matters.
193 const phaseBlock = (phase: RoadmapPhase, index: number) => {
194 const isLast = index === roadmap.phases.length - 1
195 const expanded = isOpen(phase)
196 const groups = phase.groups
197 .map(group => ({ ...group, items: group.items.filter(keep) }))
198 .filter(group => group.items.length > 0)
199 const flip = () => $.state.set(COLLAPSED, { ...folded, [phase.id]: !expanded })
200
201 return (
202 <Box key={`phase-${phase.id}`} flexDirection="column" position="relative" paddingBottom={1}>
203 {isLast ? null : (
204 <Box position="absolute" left={0} top={2} bottom={0} width={2} overflow="hidden">
205 <Svg source={railSvg()} alt="Timeline" width={16} height={4000} />
206 </Box>
207 )}
208 <Box gap={1} alignItems="center" justifyContent="space-between">
209 <Box gap={1} alignItems="center" flexShrink={1}>
210 <Svg source={statusSvg(phase.status)} alt={STATUS_LABEL[phase.status]} width={16} height={16} />
211 <Button key={`title-${phase.id}`} plain dimColor={phase.status === 'done'} onPress={flip}>
212 <Text bold={phase.id === focus?.id}>{phase.title}</Text>
213 </Button>
214 </Box>
215 <Box gap={1} alignItems="center">
216 <Text dimColor>{phase.total === 0 ? '' : `${phase.done}/${phase.total}`}</Text>
217 <Button key={`chevron-${phase.id}`} plain dimColor label={expanded ? '⌄' : '›'} onPress={flip} />
218 </Box>
219 </Box>
220 {expanded ? (
221 <Box flexDirection="column" paddingLeft={3}>
222 {phase.note === null ? null : (
223 <Text dimColor italic>
224 {phase.note}
225 </Text>
226 )}
227 {groups.length === 0 ? (
228 <Text dimColor>{shown === 'all' ? 'Nothing listed' : `No ${shown} tasks here`}</Text>
229 ) : null}
230 {groups.map((group, groupIndex) => (
231 <Box key={`${phase.id}-g${groupIndex}`} flexDirection="column" marginTop={1}>
232 {group.title === null ? null : <Text dimColor>{group.title}</Text>}
233 {group.items.map(task)}
234 </Box>
235 ))}
236 </Box>
237 ) : null}
238 </Box>
239 )
240 }
241
242 return (
243 <Box flexDirection="column" paddingX={1} paddingY={1}>
244 <Svg
245 source={headerSvg(roadmap)}
246 alt={`${roadmap.title}: ${roadmap.done} of ${roadmap.total} tasks done, ${percent(roadmap.done, roadmap.total)}%`}
247 />
248 <Box justifyContent="space-between" marginY={1}>
249 <Box gap={1}>
250 {chip('all', 'All')}
251 {chip('open', 'Open')}
252 {chip('done', 'Done')}
253 </Box>
254 <Button key="reload" plain dimColor label="↻" onPress={() => refresh($, true)} />
255 </Box>
256 {problem === null ? null : <Text color="error">{problem}</Text>}
257 <Box flexDirection="column">{roadmap.phases.map(phaseBlock)}</Box>
258 <Text dimColor wrap="truncate-start">
259 {roadmap.path}
260 </Text>
261 </Box>
262 )
263 }
264
265 const bar = () => {
266 const tasked = roadmap.phases.filter(phase => phase.total > 0)
267 const widths = segmentWidths(roadmap.phases, columns)
268
269 return (
270 <Text wrap="truncate-end">
271 {tasked.map((phase, index) => {
272 const width = widths[index] ?? 1
273 const filled = Math.round((phase.done / phase.total) * width)
274
275 return (
276 <Text key={`seg-${phase.id}`}>
277 <Text color={phase.status === 'done' ? 'success' : 'claude'}>{'━'.repeat(filled)}</Text>
278 <Text color={TRACK}>{'━'.repeat(width - filled)}</Text>
279 {index < tasked.length - 1 ? ' ' : ''}
280 </Text>
281 )
282 })}
283 </Text>
284 )
285 }
286
287 const stat = (key: string, value: string, label: string, color?: string) => (
288 <Box key={key} flexDirection="column" flexGrow={1} borderStyle="round" borderColor={TRACK} paddingX={1}>
289 <Text bold color={color}>
290 {value}
291 </Text>
292 <Text dimColor>{label}</Text>
293 </Box>
294 )
295
296 const chip = (value: RoadmapFilter, label: string) => (
297 <Button key={`filter-${value}`} plain dimColor={shown !== value} onPress={() => $.state.set(FILTER, value)}>
298 <Text bold={shown === value} color={shown === value ? 'claude' : undefined} underline={shown === value}>
299 {label}
300 </Text>
301 </Button>
302 )
303
304 const taskRow = (item: RoadmapItem) => {
305 const indent = ' '.repeat(item.depth)
306 if (item.isDone === null) {
307 return (
308 <Text key={item.id} dimColor>
309 {indent}· {item.text}
310 </Text>
311 )
312 }
313
314 return (
315 <Button key={item.id} plain onPress={() => toggle($, item)}>
316 {indent}
317 <Text color={item.isDone ? 'success' : 'inactive'}>{item.isDone ? '☑' : '☐'}</Text>{' '}
318 <Text dimColor={item.isDone} strikethrough={item.isDone}>
319 {item.text}
320 </Text>
321 </Button>
322 )
323 }
324
325 const phaseBlock = (phase: RoadmapPhase) => {
326 const expanded = isOpen(phase)
327 const groups = phase.groups
328 .map(group => ({ ...group, items: group.items.filter(keep) }))
329 .filter(group => group.items.length > 0)
330
331 return (
332 <Box key={`phase-${phase.id}`} flexDirection="column" marginBottom={1}>
333 <Button
334 key={`toggle-${phase.id}`}
335 plain
336 onPress={() => $.state.set(COLLAPSED, { ...folded, [phase.id]: !expanded })}
337 >
338 <Text color={STATUS_COLOR[phase.status]}>{STATUS_GLYPH[phase.status]}</Text>{' '}
339 <Text bold>{phase.title}</Text> <Text dimColor>{expanded ? '▾' : '▸'}</Text>
340 </Button>
341 <Box paddingLeft={2}>
342 <Text color={STATUS_COLOR[phase.status]}>{STATUS_LABEL[phase.status]}</Text>
343 <Text dimColor>
344 {phase.total === 0
345 ? ' · ideas'
346 : ` · ${phase.done} of ${phase.total} · ${percent(phase.done, phase.total)}%`}
347 </Text>
348 </Box>
349 {expanded ? (
350 <Box flexDirection="column" paddingLeft={2} marginTop={1}>
351 {phase.note === null ? null : (
352 <Text dimColor italic>
353 {phase.note}
354 </Text>
355 )}
356 {groups.length === 0 ? (
357 <Text dimColor>{shown === 'all' ? 'Nothing listed' : `No ${shown} tasks here`}</Text>
358 ) : null}
359 {groups.map((group, index) => (
360 <Box key={`${phase.id}-g${index}`} flexDirection="column" marginTop={1}>
361 {group.title === null ? null : <Text bold>{group.title}</Text>}
362 {group.items.map(taskRow)}
363 </Box>
364 ))}
365 </Box>
366 ) : null}
367 </Box>
368 )
369 }
370
371 return (
372 <Box flexDirection="column" paddingX={2} paddingY={1}>
373 {heading(roadmap.title)}
374 {roadmap.summary === null ? null : <Text dimColor>{roadmap.summary}</Text>}
375
376 <Box marginTop={1} gap={1}>
377 {stat('pct', `${percent(roadmap.done, roadmap.total)}%`, 'complete', roadmap.done === roadmap.total ? 'success' : 'claude')}
378 {stat('tasks', `${roadmap.done}/${roadmap.total}`, 'tasks done')}
379 {stat('phase', `${phaseIndex}/${roadmap.phases.length}`, 'current phase')}
380 </Box>
381
382 <Box marginTop={1}>{bar()}</Box>
383
384 <Box justifyContent="space-between" marginY={1}>
385 <Box gap={2}>
386 {chip('all', 'All')}
387 {chip('open', 'Open')}
388 {chip('done', 'Done')}
389 </Box>
390 <Button key="reload" plain dimColor onPress={() => refresh($, true)}>
391 ↻ Reload
392 </Button>
393 </Box>
394
395 {problem === null ? null : <Text color="error">{problem}</Text>}
396 {roadmap.phases.map(phaseBlock)}
397
398 <Text dimColor wrap="truncate-start">
399 {roadmap.path}
400 </Text>
401 </Box>
402 )
403 })
404}
405hooks/parse.ts 185 lines1import type {
2 PhaseStatus,
3 Roadmap,
4 RoadmapGroup,
5 RoadmapItem,
6 RoadmapPhase,
7} from '../types'
8
9const DONE_MARK = /(✅|✔️?|☑️?|\b(done|complete[d]?|shipped|released)\b)/i
10const ACTIVE_MARK = /(🚧|🏗️?|⏳|🔄|\b(in[ -]progress|wip|current|ongoing|now)\b)/i
11const ITEM = /^(\s*)[-*+]\s+(?:\[( |x|X)\]\s+)?(.*)$/
12const FENCE = /^\s*(```|~~~)/
13
14/** Drops markdown decoration a widget row should not show. */
15export function plain(text: string): string {
16 return text
17 .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
18 .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
19 .replace(/(\*\*|__)(.+?)\1/g, '$2')
20 .replace(/(\*|_)(.+?)\1/g, '$2')
21 .replace(/~~(.+?)~~/g, '$1')
22 .replace(/`([^`]+)`/g, '$1')
23 .replace(/<[^>]+>/g, '')
24 .trim()
25}
26
27/** A heading with its status markers ("✅", "(in progress)", "[done]") taken out. */
28function cleanTitle(raw: string): string {
29 return plain(raw)
30 .replace(/[([](done|complete[d]?|shipped|released|in[ -]progress|wip|current|ongoing|now|planned|next|later)[)\]]/gi, '')
31 .replace(/(✅|✔️?|☑️?|🚧|🏗️?|⏳|🔄|📋|🗓️?|🔜)/g, '')
32 .replace(/\s+[-–—:]\s*$/, '')
33 .replace(/\s{2,}/g, ' ')
34 .trim()
35}
36
37function slug(text: string, index: number): string {
38 const base = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
39 return `${index}-${base || 'phase'}`.slice(0, 64)
40}
41
42function statusOf(heading: string, done: number, total: number): PhaseStatus {
43 if (DONE_MARK.test(heading)) return 'done'
44 if (ACTIVE_MARK.test(heading)) return 'active'
45 if (total > 0 && done === total) return 'done'
46 if (done > 0) return 'active'
47 return 'planned'
48}
49
50/** Reads a ROADMAP.md: `#` title, `##` phases, `###` groups, `- [ ]` tasks. */
51export function parseRoadmap(source: string, path: string, mtimeMs: number): Roadmap {
52 const lines = source.split(/\r?\n/)
53 let title = ''
54 let summary: string | null = null
55 const drafts: Array<{ heading: string; note: string | null; groups: RoadmapGroup[] }> = []
56 let isFenced = false
57
58 const current = () => {
59 if (drafts.length === 0) {
60 drafts.push({ heading: 'Roadmap', note: null, groups: [] })
61 }
62 const phase = drafts[drafts.length - 1]!
63 if (phase.groups.length === 0) phase.groups.push({ title: null, items: [] })
64 return phase
65 }
66
67 lines.forEach((line, index) => {
68 if (FENCE.test(line)) {
69 isFenced = !isFenced
70 return
71 }
72 if (isFenced) return
73
74 const heading = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line)
75 if (heading) {
76 const level = heading[1]!.length
77 const text = heading[2]!
78 if (level === 1 && title === '') {
79 title = cleanTitle(text)
80 } else if (level <= 2) {
81 drafts.push({ heading: text, note: null, groups: [] })
82 } else {
83 current().groups.push({ title: cleanTitle(text), items: [] })
84 }
85 return
86 }
87
88 const item = ITEM.exec(line)
89 if (item) {
90 const phase = current()
91 const group = phase.groups[phase.groups.length - 1]!
92 const mark = item[2]
93 const text = plain(item[3]!)
94 if (text === '') return
95 const entry: RoadmapItem = {
96 id: `L${index}`,
97 text,
98 isDone: mark === undefined ? null : mark !== ' ',
99 line: index,
100 depth: Math.floor(item[1]!.replace(/\t/g, ' ').length / 2),
101 }
102 group.items.push(entry)
103 return
104 }
105
106 const prose = plain(line.replace(/^>\s?/, ''))
107 if (prose === '' || /^[-=|*_]{3,}/.test(line.trim())) return
108 if (drafts.length === 0) {
109 if (summary === null && title !== '') summary = prose
110 } else {
111 const phase = drafts[drafts.length - 1]!
112 const isBeforeItems = phase.groups.every(group => group.items.length === 0)
113 if (phase.note === null && isBeforeItems) phase.note = prose
114 }
115 })
116
117 const phases: RoadmapPhase[] = drafts
118 .map((draft, index) => {
119 const groups = draft.groups.filter(group => group.items.length > 0 || group.title !== null)
120 const tasks = groups.flatMap(group => group.items).filter(item => item.isDone !== null)
121 const done = tasks.filter(item => item.isDone === true).length
122 const phaseTitle = cleanTitle(draft.heading) || `Phase ${index + 1}`
123
124 return {
125 id: slug(phaseTitle, index),
126 title: phaseTitle,
127 status: statusOf(draft.heading, done, tasks.length),
128 note: draft.note,
129 groups,
130 done,
131 total: tasks.length,
132 }
133 })
134 .filter(phase => phase.groups.length > 0 || phase.note !== null)
135
136 return {
137 path,
138 title: title || 'Roadmap',
139 summary,
140 phases,
141 done: phases.reduce((sum, phase) => sum + phase.done, 0),
142 total: phases.reduce((sum, phase) => sum + phase.total, 0),
143 mtimeMs,
144 }
145}
146
147/** The phase to put up front: the first in progress, else the first not done. */
148export function focusPhase(doc: Roadmap): RoadmapPhase | undefined {
149 return (
150 doc.phases.find(phase => phase.status === 'active') ??
151 doc.phases.find(phase => phase.status === 'planned')
152 )
153}
154
155/** Flips the checkbox on `line`, if that line is still the task it was. */
156export function toggleLine(source: string, item: RoadmapItem): string | null {
157 const eol = source.includes('\r\n') ? '\r\n' : '\n'
158 const lines = source.split(/\r?\n/)
159 const line = lines[item.line]
160 if (line === undefined) return null
161 const match = ITEM.exec(line)
162 if (!match || match[2] === undefined || plain(match[3]!) !== item.text) return null
163 lines[item.line] = line.replace(/\[( |x|X)\]/, match[2] === ' ' ? '[x]' : '[ ]')
164
165 return lines.join(eol)
166}
167
168export const TEMPLATE = `# Roadmap
169
170What this project is heading towards, phase by phase.
171
172## Phase 1: Foundations (in progress)
173
174- [x] Set up the repository
175- [ ] Write the first milestone
176
177## Phase 2: Next
178
179- [ ] Plan the next milestone
180
181## Later
182
183- Ideas worth keeping
184`
185hooks/svg.ts 107 lines1import type { PhaseStatus, Roadmap } from '../types'
2
3// Claude Code's theme colors. An Svg cannot name a theme key, so each color
4// carries a light and a dark value, picked by the system's color scheme.
5const STYLE = `<style>
6.p{fill:#1F1F1E}.s{fill:#6B6A66}.m{fill:#8E8D88}.k{fill:#E3E2DD}
7.f-ok{fill:#2C7A39}.f-go{fill:#D77757}.f-no{fill:#8E8D88}
8.l-ok{stroke:#2C7A39}.l-go{stroke:#D77757}.l-no{stroke:#8E8D88}.l-rail{stroke:#C9C7C0}
9@media (prefers-color-scheme:dark){.p{fill:#ECEBE6}.s{fill:#A3A29C}.m{fill:#7A7974}.k{fill:#3A3936}
10.f-ok{fill:#4EBA65}.l-ok{stroke:#4EBA65}.l-rail{stroke:#5E5D58}.f-no{fill:#7A7974}.l-no{stroke:#7A7974}}
11</style>`
12const FONT = `font-family="system-ui,-apple-system,'Segoe UI',sans-serif"`
13
14const STATUS_CLASS = { done: 'ok', active: 'go', planned: 'no' } as const
15
16function esc(text: string): string {
17 return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
18}
19
20/** Text width in px, roughly, for laying out a row inside the drawing. */
21function measure(text: string, size: number): number {
22 return text.length * size * 0.5
23}
24
25/** The header: title, count, one segmented bar, and the status legend. */
26export function headerSvg(doc: Roadmap, width = 400): string {
27 const pct = doc.total === 0 ? 0 : Math.round((doc.done / doc.total) * 100)
28 const tasked = doc.phases.filter(phase => phase.total > 0)
29 const gap = 3
30 const room = width - gap * Math.max(0, tasked.length - 1)
31 let x = 0
32 const bar = tasked
33 .map(phase => {
34 const w = (phase.total / doc.total) * room
35 const filled = (phase.done / phase.total) * w
36 const fillClass = phase.status === 'done' ? 'ok' : 'go'
37 const out =
38 `<rect class="k" x="${x.toFixed(1)}" y="30" width="${w.toFixed(1)}" height="6" rx="3" fill="#8E8D8840"/>` +
39 (filled > 0
40 ? `<rect class="f-${fillClass}" x="${x.toFixed(1)}" y="30" width="${Math.max(filled, 6).toFixed(1)}" height="6" rx="3" fill="#D77757"/>`
41 : '')
42 x += w + gap
43
44 return out
45 })
46 .join('')
47
48 const counts: Array<[PhaseStatus, string]> = [
49 ['done', 'Done'],
50 ['active', 'In progress'],
51 ['planned', 'Planned'],
52 ]
53 let lx = 0
54 const legend = counts
55 .map(([status, label]) => {
56 const text = `${label} ${doc.phases.filter(phase => phase.status === status).length}`
57 const out =
58 `<circle class="f-${STATUS_CLASS[status]}" cx="${lx + 4}" cy="54" r="3.5" fill="#8E8D88"/>` +
59 `<text class="m" x="${lx + 12}" y="58" font-size="11" fill="#8E8D88">${esc(text)}</text>`
60 lx += 12 + measure(text, 11) + 14
61
62 return out
63 })
64 .join('')
65
66 const right = `${doc.done} of ${doc.total} · ${pct}%`
67 const titleRoom = Math.floor((width - measure(right, 13) - 16) / (15 * 0.56))
68 const title = doc.title.length > titleRoom ? `${doc.title.slice(0, Math.max(1, titleRoom - 1))}…` : doc.title
69
70 return (
71 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="64" viewBox="0 0 ${width} 64" ${FONT}>${STYLE}` +
72 `<text class="p" x="0" y="17" font-size="15" font-weight="500" fill="#8E8D88">${esc(title)}</text>` +
73 `<text class="s" x="${width}" y="17" font-size="13" text-anchor="end" fill="#8E8D88">${esc(right)}</text>` +
74 bar +
75 legend +
76 `</svg>`
77 )
78}
79
80/** A phase's status icon, as the mockup draws it: check, half-filled, dashed. */
81export function statusSvg(status: PhaseStatus): string {
82 const c = STATUS_CLASS[status]
83 const ring = `<circle class="l-${c}" cx="8" cy="8" r="6.25" fill="none" stroke="#8E8D88" stroke-width="1.5"${status === 'planned' ? ' stroke-dasharray="2.4 2.2"' : ''}/>`
84 const mark =
85 status === 'done'
86 ? `<path class="l-${c}" d="M5.4 8.2l1.8 1.8 3.4-3.6" fill="none" stroke="#8E8D88" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
87 : status === 'active'
88 ? `<path class="f-${c}" d="M8 3.5a4.5 4.5 0 0 1 0 9z" fill="#D77757"/>`
89 : ''
90
91 return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">${STYLE}${ring}${mark}</svg>`
92}
93
94/** A task's checkbox: a rounded square, ticked in green once done. */
95export function checkboxSvg(isDone: boolean): string {
96 const body = isDone
97 ? `<rect class="l-ok" x="1.75" y="1.75" width="12.5" height="12.5" rx="3" fill="none" stroke="#4EBA65" stroke-width="1.5"/><path class="l-ok" d="M5 8.1l2 2 4-4.2" fill="none" stroke="#4EBA65" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
98 : `<rect class="l-no" x="1.75" y="1.75" width="12.5" height="12.5" rx="3" fill="none" stroke="#8E8D88" stroke-width="1.5"/>`
99
100 return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">${STYLE}${body}</svg>`
101}
102
103/** A tall rail line; the Box it sits in clips it to the phase's height. */
104export function railSvg(height = 4000): string {
105 return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="${height}" viewBox="0 0 16 ${height}">${STYLE}<line class="l-rail" x1="8" y1="0" x2="8" y2="${height}" stroke="#8E8D88" stroke-width="1.5"/></svg>`
106}
107types/index.d.ts 48 lines1export type RoadmapItem = {
2 id: string
3 text: string
4 /** `true`/`false` for a `- [x]`/`- [ ]` task, `null` for a plain bullet. */
5 isDone: boolean | null
6 /** Zero-based line in the file, so a toggle rewrites the right one. */
7 line: number
8 depth: number
9}
10
11export type RoadmapGroup = { title: string | null; items: RoadmapItem[] }
12
13export type PhaseStatus = 'done' | 'active' | 'planned'
14
15export type RoadmapPhase = {
16 id: string
17 title: string
18 status: PhaseStatus
19 note: string | null
20 groups: RoadmapGroup[]
21 done: number
22 total: number
23}
24
25export type Roadmap = {
26 path: string
27 title: string
28 summary: string | null
29 phases: RoadmapPhase[]
30 done: number
31 total: number
32 mtimeMs: number
33}
34
35export type RoadmapFilter = 'all' | 'open' | 'done'
36
37declare module 'claude-code' {
38 interface PluginState {
39 'roadmap-pane': {
40 doc: Roadmap | null
41 path: string | null
42 error: string | null
43 filter: RoadmapFilter
44 collapsed: Record<string, boolean>
45 }
46 }
47}
48