Reads a phased plan into a live progress tree: phases, steps, elapsed time and an estimate of what is left

My Claude Code mods, one folder per mod. Needs Claude Code 2.1.287 or newer.
| Mod | What it does |
|---|---|
| usage-band | A band above the prompt showing context fill, tokens, cost and rate limits: stat tiles in the desktop app, a coloured row in the terminal |
| plan-progress | Reads a phased plan into a live progress tree: phases, steps, elapsed time and an estimate of what is left |
Both mods share one look: dark violet cards with a lilac accent. In the desktop app's Code tab they draw as images (cards, tiles, gradients); in the terminal they use the same colours as text.

At the prompt of a terminal Claude Code session:
/plugin install usage-band --marketplace WeaponizedLego/claude-code-mods
/plugin install plan-progress --marketplace WeaponizedLego/claude-code-mods
Answer y to add the marketplace, then pick the user scope. Or from a shell:
claude plugin marketplace add WeaponizedLego/claude-code-mods
claude plugin install usage-band@claude-code-mods
claude plugin install plan-progress@claude-code-mods
Update later with claude plugin marketplace update claude-code-mods, then claude plugin update usage-band@claude-code-mods.

When a plan with several phases is approved in plan mode, the mod reads its phases and steps (## Phase 1: ... headings, other work headings, or a nested list) into a tree and opens it in a pane. In the terminal it looks like this:
Move auth to sessions 20m in · ~40m left
▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱ 33% 2/6 steps · phase 2/3
✓ 1 Setup 20m
├ ✓ Add the sessions table 10m
└ ✓ Write the session store 10m
● 2 Switch over
├ ● Replace token checks in the middleware
├ ○ Update the login endpoint
└ ○ Remove the old token helpers
○ 3 Verify
└ ○ Run the auth test suite
The status line carries the short form (▰▰▱▱ 2/6 · phase 2/3: Switch over · 20m in · ~40m left). Steps move when Claude calls the mod's plan_mark tool, or by themselves when Claude's tasks or todos are named like a step. Without plan mode, ask Claude to lay the work out in phases and it calls plan_set. The estimate is the pace so far times the steps left.
/plan-progress opens the tree (it also works mid-turn); /plan-progress clear stops tracking.
Put it in its own folder (<mod>/.claude-plugin/plugin.json, <mod>/hooks/...) and add an entry to .claude-plugin/marketplace.json. Check it with claude plugin validate ..
hooks/register.tsx 406 lines1import { atom, read, update } from 'claude-code'
2import type { Color, EngineInterface, Register } from 'claude-code'
3
4import type { Plan, StepStatus } from '../types'
5import { C, emptySvg, planSvg } from './look'
6import {
7 TASK_STATUS,
8 bar,
9 duration,
10 findStep,
11 mark,
12 newPlan,
13 outline,
14 parsePlan,
15 phaseDuration,
16 phaseState,
17 stats,
18 statusLine,
19} from './plan'
20
21const PANE = 'plan-progress'
22const TITLE = 'Plan progress'
23const SET = 'mcp__plan-progress__plan_set'
24const MARK = 'mcp__plan-progress__plan_mark'
25const TICK_MS = 30_000
26// Roughly one cell of the desktop's code font, in CSS pixels: the pane's
27// width in pixels, for an image that fills it.
28const CELL_PX = 8
29
30const planAtom = atom({ plugin: 'plan-progress', key: 'plan' } as const, null)
31const nowAtom = atom({ plugin: 'plan-progress', key: 'now' } as const, 0)
32const tasksAtom = atom({ plugin: 'plan-progress', key: 'tasks' } as const, {})
33
34type PlanSetInput = { title?: string; phases?: { title?: string; steps?: string[] }[] }
35type PlanMarkInput = { phase?: number; step?: number; status?: StepStatus }
36
37// With nothing in progress, the first open step after the last one finished
38// becomes the current one, so its time starts counting.
39function advance(plan: Plan, now: number): Plan {
40 const steps = plan.phases.flatMap((p, pi) => p.steps.map((s, si) => ({ s, pi, si })))
41 if (steps.some(x => x.s.status === 'active')) return plan
42 const next = steps.find(x => x.s.status === 'pending')
43 return next ? mark(plan, next.pi, next.si, 'active', now) : plan
44}
45
46async function setPlan($: EngineInterface, plan: Plan | null) {
47 await update($, planAtom, () => plan)
48 const t = await $.clock.now()
49 await update($, nowAtom, () => t)
50 $.ui.status(plan ? statusLine(plan, t) : undefined)
51}
52
53async function change($: EngineInterface, fn: (plan: Plan, now: number) => Plan): Promise<Plan | null> {
54 const now = await $.clock.now()
55 const before = await read($, planAtom)
56 if (!before) return null
57 const after = advance(fn(before, now), now)
58 await setPlan($, after)
59 return after
60}
61
62const progressText = (plan: Plan, now: number) => {
63 const s = stats(plan, now)
64 if (s.isFinished) return `Plan finished: ${s.total} steps in ${duration(s.elapsedMs)}.`
65 const parts = [`${s.closed}/${s.total} steps (${s.percent}%)`]
66 if (s.currentPhase != null) parts.push(`phase ${s.currentPhase + 1}/${plan.phases.length}`)
67 parts.push(`${duration(s.elapsedMs)} in`)
68 if (s.remainingMs != null) parts.push(`~${duration(s.remainingMs)} left`)
69 return parts.join(' · ')
70}
71
72const howToKeepIt = (plan: Plan) =>
73 [
74 `plan-progress: the user sees this plan as a progress tree (${plan.phases.length} phases, ${plan.phases.flatMap(p => p.steps).length} steps):`,
75 outline(plan),
76 `Keep it current as you work: call ${MARK} with { phase, step, status } as you finish each step ("done"; the next step becomes current by itself) or skip one. ` +
77 `If you track the work with TaskCreate/TaskUpdate or TodoWrite instead, name the tasks exactly like the steps above and the tree follows them. ` +
78 `If this tree misreads the plan, call ${SET} with the right phases and steps.`,
79 ].join('\n\n')
80
81// A pane that cannot open (refused, or no surface to draw on) never costs the plan.
82async function openPane($: EngineInterface, isAsked: boolean) {
83 const opened = await $.ui.open({ id: PANE, title: TITLE }).catch(() => ({ isPlaced: false }))
84 if (!opened.isPlaced && !isAsked) $.ui.toast('plan-progress: run /plan-progress to see the phase tree')
85}
86
87export const register: Register = on => {
88 on('session.start', async ($, e, next) => {
89 const result = await next(e)
90
91 await $.tool.register({
92 name: 'plan_set',
93 description:
94 'Show the user a progress tree for a long, multi-phase task: the phases in order, each with its steps. ' +
95 'Use it when you start work that spans several phases and no plan-mode plan was approved, or to correct a tree that misread the plan. ' +
96 'Replaces the current tree. Then report progress with plan_mark.',
97 inputSchema: {
98 type: 'object',
99 properties: {
100 title: { type: 'string', description: 'A short name for the whole task' },
101 phases: {
102 type: 'array',
103 minItems: 1,
104 items: {
105 type: 'object',
106 properties: {
107 title: { type: 'string' },
108 steps: { type: 'array', items: { type: 'string' }, description: 'Short step titles, in order' },
109 },
110 required: ['title', 'steps'],
111 },
112 },
113 },
114 required: ['phases'],
115 },
116 isDeferred: false,
117 })
118 await $.tool.register({
119 name: 'plan_mark',
120 description:
121 "Update the user's plan progress tree. Mark a step done when you finish it (the next step becomes current by itself), " +
122 'active when you start one out of order, skipped when you drop it, or leave out step to mark a whole phase. ' +
123 'Phases and steps count from 1, as the outline shows them. Only use while a plan tree exists.',
124 inputSchema: {
125 type: 'object',
126 properties: {
127 phase: { type: 'integer', minimum: 1 },
128 step: { type: 'integer', minimum: 1, description: 'Leave out to mark every open step of the phase' },
129 status: { type: 'string', enum: ['done', 'active', 'skipped', 'pending'] },
130 },
131 required: ['phase', 'status'],
132 },
133 isDeferred: false,
134 })
135 await $.command.register({
136 name: 'plan-progress',
137 description: 'Show the phase tree of the current plan (clear: stop tracking it)',
138 argumentHint: '[clear]',
139 immediate: true,
140 })
141
142 // Elapsed times move while a plan runs: redraw them twice a minute.
143 $.clock.every(TICK_MS, () => {
144 void (async () => {
145 const plan = await read($, planAtom)
146 if (!plan || stats(plan, 0).isFinished) return
147 const t = await $.clock.now()
148 await update($, nowAtom, () => t)
149 $.ui.status(statusLine(plan, t))
150 })()
151 })
152
153 // After a reload the plan is still in the session's state.
154 const plan = await read($, planAtom)
155 if (plan) $.ui.status(statusLine(plan, await $.clock.now()))
156
157 return result
158 })
159
160 // An approved plan-mode plan becomes the tree.
161 on('tool.call', { tool: 'ExitPlanMode' }, async ($, e, next) => {
162 const ran = await next(e)
163 if (ran.deny !== undefined || ran.isError) return ran
164
165 const text = (ran.result as { plan?: string | null } | undefined)?.plan
166 if (!text) return ran
167 const parsed = parsePlan(text)
168 if (parsed.phases.flatMap(p => p.steps).length < 2) return ran
169
170 const now = await $.clock.now()
171 const plan = advance(newPlan(parsed.title, parsed.phases.map(p => ({ title: p.title, steps: p.steps.map(s => s.title) })), 'plan-mode', now), now)
172 await setPlan($, plan)
173 await openPane($, false)
174
175 return { ...ran, context: [...(ran.context ?? []), howToKeepIt(plan)] }
176 })
177
178 on('tool.call', { tool: SET }, async ($, e) => {
179 const input = e as unknown as PlanSetInput
180 const phases = (input.phases ?? [])
181 .map(p => ({ title: String(p.title ?? ''), steps: (p.steps ?? []).map(String) }))
182 .filter(p => p.title || p.steps.length)
183 if (phases.length === 0) return { deny: 'plan_set needs at least one phase with a title and steps.' }
184
185 const now = await $.clock.now()
186 const plan = advance(newPlan(input.title ?? 'Plan', phases, 'model', now), now)
187 await setPlan($, plan)
188 await openPane($, false)
189 return { result: `Tree shown to the user:\n${outline(plan)}\n\nReport progress with plan_mark.` }
190 })
191
192 on('tool.call', { tool: MARK }, async ($, e) => {
193 const input = e as unknown as PlanMarkInput
194 const plan = await read($, planAtom)
195 if (!plan) return { deny: 'No plan is being tracked. Call plan_set first.' }
196
197 const pi = Number(input.phase) - 1
198 const phase = plan.phases[pi]
199 if (!phase) return { deny: `There is no phase ${input.phase}; the plan has ${plan.phases.length}.\n${outline(plan)}` }
200 const si = input.step == null ? undefined : Number(input.step) - 1
201 if (si !== undefined && !phase.steps[si]) {
202 return { deny: `Phase ${input.phase} has no step ${input.step}; it has ${phase.steps.length}.\n${outline(plan)}` }
203 }
204 const status = input.status ?? 'done'
205
206 const after = (await change($, (p, now) => mark(p, pi, si, status, now)))!
207 const now = await $.clock.now()
208 const nextUp = after.phases.flatMap((p, i) => p.steps.map((s, j) => ({ s, at: `${i + 1}.${j + 1}` }))).find(x => x.s.status === 'active')
209 const what = si === undefined ? `Phase ${pi + 1}` : `Step ${pi + 1}.${si + 1}`
210 return { result: `${what} marked ${status}. ${progressText(after, now)}${nextUp ? `. Current: ${nextUp.at} ${nextUp.s.title}` : ''}` }
211 })
212
213 // Tasks and todos named like a step move that step.
214 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
215 const ran = await next(e)
216 if (ran.deny !== undefined || ran.isError || !(await read($, planAtom))) return ran
217 const todos = (e as unknown as { todos?: { content: string; status: string }[] }).todos ?? []
218 await change($, (plan, now) =>
219 todos.reduce((p, todo) => {
220 const at = findStep(p, todo.content)
221 const status = TASK_STATUS[todo.status]
222 const current = at && p.phases[at.phase]!.steps[at.step]!.status
223 return at && status && current !== status && current !== 'done' ? mark(p, at.phase, at.step, status, now) : p
224 }, plan),
225 )
226 return ran
227 })
228
229 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
230 const ran = await next(e)
231 const id = (ran.result as { task?: { id?: string } } | undefined)?.task?.id
232 const subject = (e as unknown as { subject?: string }).subject
233 if (id && subject) await update($, tasksAtom, t => ({ ...t, [id]: subject }))
234 return ran
235 })
236
237 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
238 const ran = await next(e)
239 const input = e as unknown as { taskId: string; subject?: string; status?: string }
240 if (ran.deny !== undefined || ran.isError) return ran
241 const tasks = await read($, tasksAtom)
242 const subject = input.subject ?? tasks[input.taskId]
243 if (input.subject) await update($, tasksAtom, t => ({ ...t, [input.taskId]: input.subject! }))
244 const status = input.status ? TASK_STATUS[input.status] : undefined
245 if (!subject || !status || !(await read($, planAtom))) return ran
246 await change($, (plan, now) => {
247 const at = findStep(plan, subject)
248 return at && plan.phases[at.phase]!.steps[at.step]!.status !== 'done' ? mark(plan, at.phase, at.step, status, now) : plan
249 })
250 return ran
251 })
252
253 on('command.run', { command: 'plan-progress' }, async ($, e) => {
254 if (e.args.trim() === 'clear') {
255 await setPlan($, null)
256 await $.ui.close({ id: PANE }).catch(() => {})
257 return { text: 'Stopped tracking the plan.' }
258 }
259 const plan = await read($, planAtom)
260 await openPane($, true)
261 if (!plan) return { text: 'No plan yet: approve a plan in plan mode, or ask Claude to lay the task out in phases.' }
262 return { text: `${plan.title}: ${progressText(plan, await $.clock.now())}` }
263 })
264
265 on('session.end', async ($, e, next) => {
266 if (e.reason === 'clear') await setPlan($, null)
267 return next(e)
268 })
269
270 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
271 const plan = await read($, planAtom)
272 await read($, nowAtom)
273 const now = await $.clock.now()
274
275 // Where the surface draws images, the pane is a stack of cards.
276 if (e.surface !== 'terminal') {
277 const { Box, Svg } = $.ui.resolve(e)
278 const width = e.props.bodyColumns * CELL_PX
279 if (!plan) {
280 const empty = emptySvg(width)
281 return <Svg source={empty.source} alt="No plan yet" width={Math.max(300, width)} height={empty.height} />
282 }
283 const drawn = planSvg(plan, now, width)
284 return (
285 <Box flexDirection="column" key="plan-progress">
286 <Svg source={drawn.source} alt={drawn.alt} width={Math.max(300, width)} height={drawn.height} />
287 </Box>
288 )
289 }
290
291 const { Box, Text } = $.ui.resolve(e)
292 if (!plan) {
293 return (
294 <Box flexDirection="column" borderStyle="round" borderColor={C.stroke} paddingX={1}>
295 <Text bold color={C.text}>
296 No plan yet
297 </Text>
298 <Text color={C.muted} wrap="wrap">
299 Approve a plan in plan mode, or ask Claude to lay a long task out in phases.
300 </Text>
301 </Box>
302 )
303 }
304
305 const s = stats(plan, now)
306 const room = Math.max(4, e.props.scroll.bodyRows)
307 const current = s.currentPhase ?? -1
308 // The current phase opens in a frame (two rows more); the phases to come,
309 // then the finished ones, open while the tree still fits.
310 const isOpen = plan.phases.map((_, i) => i === current)
311 const rows = () => 3 + plan.phases.reduce((n, p, i) => n + 1 + (isOpen[i] ? p.steps.length + (i === current ? 2 : 0) : 0), 0)
312 for (const wantDone of [false, true]) {
313 plan.phases.forEach((p, i) => {
314 if (!isOpen[i] && (phaseState(p) === 'done') === wantDone && rows() + p.steps.length <= room) isOpen[i] = true
315 })
316 }
317
318 const cells = Math.max(6, Math.min(24, e.props.bodyColumns - 36))
319 const { filled, track } = bar(s.percent, cells)
320 const glyph: Record<string, [string, Color]> = {
321 done: ['✓', C.accent],
322 active: ['●', C.glow],
323 pending: ['○', C.dim],
324 skipped: ['–', C.dim],
325 }
326 const phaseNo = Math.min(plan.phases.length, current + 1 || plan.phases.length)
327
328 const stepRow = (pi: number, si: number, inFrame: boolean) => {
329 const p = plan.phases[pi]!
330 const step = p.steps[si]!
331 const [sg, sc] = glyph[step.status]!
332 const took = step.startedAt != null ? (step.endedAt ?? now) - step.startedAt : null
333 const branch = inFrame ? '' : si === p.steps.length - 1 ? ' └ ' : ' ├ '
334 return (
335 <Box flexDirection="row" justifyContent="space-between" key={`step-${pi}-${si}`}>
336 <Box flexDirection="row" flexShrink={1}>
337 <Text color={C.dim}>{branch}</Text>
338 <Text color={sc}>{`${sg} `}</Text>
339 <Text
340 color={step.status === 'active' ? C.text : step.status === 'done' ? C.soft : C.muted}
341 bold={step.status === 'active'}
342 strikethrough={step.status === 'skipped'}
343 wrap="truncate"
344 >
345 {step.title}
346 </Text>
347 </Box>
348 <Text color={step.status === 'active' ? C.accent : C.muted}>{took != null && step.status !== 'skipped' ? ` ${duration(took)}` : ''}</Text>
349 </Box>
350 )
351 }
352
353 return (
354 <Box flexDirection="column" key="plan-progress">
355 <Box flexDirection="row" justifyContent="space-between">
356 <Text bold color={C.text} wrap="truncate">
357 {plan.title}
358 </Text>
359 <Box flexDirection="row">
360 <Text color={C.muted}>{` ${duration(s.elapsedMs)} ${s.isFinished ? 'total' : 'in'}`}</Text>
361 {!s.isFinished && s.remainingMs != null && <Text color={C.accent}>{` · ~${duration(s.remainingMs)} left`}</Text>}
362 </Box>
363 </Box>
364 <Box flexDirection="row">
365 <Text color={C.accent}>{filled}</Text>
366 <Text color={C.track}>{track}</Text>
367 <Text bold color={C.text}>
368 {` ${s.percent}%`}
369 </Text>
370 <Text color={C.muted}>{` ${s.closed}/${s.total} steps · phase ${phaseNo}/${plan.phases.length}`}</Text>
371 </Box>
372 <Text> </Text>
373 {plan.phases.flatMap((p, pi) => {
374 const state = phaseState(p)
375 const [g, color] = glyph[state]!
376 const took = phaseDuration(plan, pi, now)
377 const closed = p.steps.filter(x => x.status === 'done' || x.status === 'skipped').length
378 const right = [isOpen[pi] ? '' : `${closed}/${p.steps.length}`, took != null ? duration(took) : ''].filter(Boolean).join(' · ')
379 const head = (
380 <Box flexDirection="row" justifyContent="space-between" key={`phase-${pi}`}>
381 <Box flexDirection="row" flexShrink={1}>
382 <Text color={color}>{`${g} `}</Text>
383 <Text color={C.muted}>{`${pi + 1} `}</Text>
384 <Text bold={pi === current} color={state === 'pending' && pi !== current ? C.soft : C.text} wrap="truncate">
385 {p.title}
386 </Text>
387 </Box>
388 <Text color={C.muted}>{right ? ` ${right}` : ''}</Text>
389 </Box>
390 )
391 if (!isOpen[pi]) return [head]
392 if (pi === current) {
393 return [
394 <Box flexDirection="column" borderStyle="round" borderColor={C.deep} paddingX={1} key={`now-${pi}`}>
395 {head}
396 {p.steps.map((_, si) => stepRow(pi, si, true))}
397 </Box>,
398 ]
399 }
400 return [head, ...p.steps.map((_, si) => stepRow(pi, si, false))]
401 })}
402 </Box>
403 )
404 })
405}
406hooks/look.ts 171 lines1import type { Plan, Step } from '../types'
2import { duration, phaseDuration, phaseState, stats } from './plan'
3
4// The palette: dark violet cards with a lilac accent. An SVG is drawn as an
5// image, so it takes real colours, not theme keys; the terminal uses the same.
6export const C = {
7 card: '#23222b',
8 raised: '#2a2933',
9 stroke: '#363541',
10 text: '#ececf1',
11 soft: '#c9c7d3',
12 muted: '#8e8c9a',
13 dim: '#5f5d6b',
14 accent: '#a98bff',
15 deep: '#7d68c9',
16 glow: '#c4b2ff',
17 track: '#3b3650',
18 warn: '#ff8a4c',
19} as const
20
21const FONT = `font-family="Inter, 'Segoe UI', system-ui, -apple-system, sans-serif"`
22
23export const esc = (t: string) => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
24
25// Text in an image cannot wrap or truncate itself: cut it to an estimated width.
26const fit = (t: string, px: number, size: number) => {
27 const max = Math.max(3, Math.floor(px / (size * 0.52)))
28 return t.length > max ? `${t.slice(0, max - 1)}…` : t
29}
30
31const text = (x: number, y: number, size: number, fill: string, body: string, extra = '') =>
32 `<text x="${x}" y="${y}" font-size="${size}" fill="${fill}" ${extra}>${body}</text>`
33
34const clock = (x: number, y: number) =>
35 `<g transform="translate(${x} ${y})" fill="none" stroke="${C.soft}" stroke-width="1.2"><circle cx="5" cy="5" r="4.5"/><path d="M5 2.6V5l1.7 1.1"/></g>`
36
37const check = (cx: number, cy: number, r: number) =>
38 `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${C.accent}"/><path d="M${cx - r * 0.45} ${cy}l${r * 0.32} ${r * 0.34} ${r * 0.6}-${r * 0.68}" fill="none" stroke="${C.card}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>`
39
40function stepDot(s: Step, cx: number, cy: number, r: number): string {
41 if (s.status === 'done') return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${C.accent}"/>`
42 if (s.status === 'active') return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${C.deep}" stroke="${C.glow}" stroke-width="1.2"/>`
43 if (s.status === 'skipped') return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${C.dim}"/>`
44 return `<circle cx="${cx}" cy="${cy}" r="${r - 0.6}" fill="none" stroke="${C.dim}" stroke-width="1.2"/>`
45}
46
47const HEADER_H = 162
48const CARD_H = 60
49const STEP_H = 22
50const GAP = 10
51
52/** The whole pane as one SVG: a summary card, then a card per phase. */
53export function planSvg(plan: Plan, now: number, width: number): { source: string; height: number; alt: string } {
54 const W = Math.max(300, Math.round(width))
55 const s = stats(plan, now)
56 const parts: string[] = []
57 const steps = plan.phases.flatMap(p => p.steps)
58 const phaseNo = Math.min(plan.phases.length, (s.currentPhase ?? plan.phases.length - 1) + 1)
59
60 // ---- the summary card
61 parts.push(`<rect x="0.5" y="0.5" width="${W - 1}" height="${HEADER_H - 1}" rx="16" fill="${C.card}" stroke="${C.stroke}"/>`)
62 parts.push(`<circle cx="32" cy="32" r="14" fill="${C.text}"/>`)
63 parts.push(`<g fill="${C.card}"><rect x="25" y="32" width="3" height="6" rx="1.5"/><rect x="30.5" y="27" width="3" height="11" rx="1.5"/><rect x="36" y="29.5" width="3" height="8.5" rx="1.5"/></g>`)
64 const chip = s.isFinished ? 'Finished' : `Phase ${phaseNo}/${plan.phases.length}`
65 const chipW = Math.round(chip.length * 6.4 + 24)
66 parts.push(`<rect x="${W - 18 - chipW}" y="19" width="${chipW}" height="26" rx="13" fill="${C.raised}" stroke="${C.stroke}"/>`)
67 parts.push(text(W - 18 - chipW / 2, 36, 11.5, C.soft, esc(chip), 'text-anchor="middle"'))
68 parts.push(text(56, 37, 14.5, C.text, esc(fit(plan.title, W - 56 - chipW - 30, 14.5)), 'font-weight="600"'))
69
70 parts.push(text(18, 94, 36, C.text, `${s.percent}%`, 'font-weight="500" letter-spacing="-0.5"'))
71 const lead = s.isFinished ? `in ${duration(s.elapsedMs)}` : s.remainingMs != null ? `~${duration(s.remainingMs)} left` : 'pace after the first step'
72 const rest = s.isFinished ? ` · ${s.total} steps` : ` · ${duration(s.elapsedMs)} in · ${s.closed}/${s.total} steps`
73 parts.push(`<text x="18" y="118" font-size="12"><tspan fill="${C.accent}" font-weight="600">${esc(lead)}</tspan><tspan fill="${C.muted}">${esc(rest)}</tspan></text>`)
74
75 // One rounded cell per step, as a heat map row: filled done, glowing current, hatched to come.
76 const avail = W - 36
77 const cell = Math.max(4, Math.min(56, (avail - (steps.length - 1) * 5) / Math.max(1, steps.length)))
78 const gap = steps.length > 1 ? Math.min(5, (avail - cell * steps.length) / (steps.length - 1)) : 0
79 steps.forEach((st, i) => {
80 const x = 18 + i * (cell + gap)
81 const fill = st.status === 'done' ? C.accent : st.status === 'active' ? C.deep : st.status === 'skipped' ? C.dim : 'url(#hatch)'
82 const stroke = st.status === 'active' ? ` stroke="${C.glow}" stroke-width="1.2"` : ''
83 parts.push(`<rect x="${x.toFixed(1)}" y="130" width="${cell.toFixed(1)}" height="18" rx="${Math.min(5, cell / 3).toFixed(1)}" fill="${fill}"${stroke}/>`)
84 })
85
86 // ---- the phase cards; long plans fold the old and the far
87 let y = HEADER_H + GAP
88 const current = s.currentPhase ?? -1
89 const shown = plan.phases.map((_, i) => current < 0 || (i >= current - 2 && i <= current + 3))
90 const before = shown.indexOf(true)
91 const after = plan.phases.length - 1 - shown.lastIndexOf(true)
92 const fold = (label: string) => {
93 parts.push(`<rect x="0.5" y="${y + 0.5}" width="${W - 1}" height="34" rx="12" fill="none" stroke="${C.stroke}" stroke-dasharray="4 4"/>`)
94 parts.push(text(W / 2, y + 22, 11.5, C.muted, esc(label), 'text-anchor="middle"'))
95 y += 34 + GAP
96 }
97 if (before > 0) fold(`${before} earlier phase${before > 1 ? 's' : ''} done`)
98
99 plan.phases.forEach((p, pi) => {
100 if (!shown[pi]) return
101 const state = phaseState(p)
102 const isNow = state === 'active' || (state === 'pending' && pi === current)
103 const h = isNow ? 50 + p.steps.length * STEP_H + 8 : CARD_H
104 const closed = p.steps.filter(x => x.status === 'done' || x.status === 'skipped').length
105 const took = phaseDuration(plan, pi, now)
106
107 parts.push(
108 isNow
109 ? `<rect x="0.5" y="${y + 0.5}" width="${W - 1}" height="${h - 1}" rx="14" fill="url(#now)" stroke="${C.deep}"/>`
110 : `<rect x="0.5" y="${y + 0.5}" width="${W - 1}" height="${h - 1}" rx="14" fill="${C.raised}" stroke="${C.stroke}"/>`,
111 )
112 if (state === 'done') parts.push(check(W - 24, y + 20, 8))
113 else {
114 // Step dots, right to left, like the avatars on an event card.
115 const dots = p.steps.slice(0, 12)
116 dots.forEach((st, i) => parts.push(stepDot(st, W - 20 - (dots.length - 1 - i) * 13, y + 20, 4)))
117 }
118
119 const titleColor = state === 'pending' && !isNow ? C.soft : C.text
120 parts.push(
121 `<text x="16" y="${y + 25}" font-size="13.5" font-weight="600"><tspan fill="${C.muted}">${pi + 1} </tspan><tspan fill="${titleColor}">${esc(fit(p.title, W - 60 - Math.min(12, p.steps.length) * 13, 13.5))}</tspan></text>`,
122 )
123 const sub = state === 'done' ? `${p.steps.length} step${p.steps.length > 1 ? 's' : ''} done` : state === 'pending' && !isNow ? `${p.steps.length} step${p.steps.length > 1 ? 's' : ''} to go` : `${closed} of ${p.steps.length} steps`
124 parts.push(text(16, y + 43, 11, C.muted, esc(sub)))
125 if (took != null) {
126 const label = duration(took)
127 parts.push(clock(W - 22 - label.length * 6.2 - 14, y + 34))
128 parts.push(text(W - 18, y + 43, 11, C.soft, esc(label), 'text-anchor="end"'))
129 }
130
131 if (isNow) {
132 p.steps.forEach((st, si) => {
133 const sy = y + 50 + si * STEP_H + 14
134 parts.push(stepDot(st, 22, sy - 4, 4.5))
135 const color = st.status === 'active' ? C.text : st.status === 'done' ? C.soft : C.muted
136 const deco = st.status === 'skipped' ? ' text-decoration="line-through"' : ''
137 const weight = st.status === 'active' ? ' font-weight="600"' : ''
138 parts.push(text(36, sy, 12, color, esc(fit(st.title, W - 100, 12)), `${weight}${deco}`))
139 if (st.startedAt != null && st.status !== 'skipped') {
140 parts.push(text(W - 18, sy, 11, st.status === 'active' ? C.accent : C.muted, duration((st.endedAt ?? now) - st.startedAt), 'text-anchor="end"'))
141 }
142 })
143 }
144 y += h + GAP
145 })
146 if (after > 0) fold(`${after} more phase${after > 1 ? 's' : ''} after this`)
147
148 const height = Math.round(y - GAP)
149 const defs =
150 `<defs>` +
151 `<pattern id="hatch" width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="5" height="5" fill="${C.track}"/><rect width="2" height="5" fill="#4a4463"/></pattern>` +
152 `<linearGradient id="now" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#3d3260"/><stop offset="1" stop-color="${C.raised}"/></linearGradient>` +
153 `</defs>`
154 const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${height}" viewBox="0 0 ${W} ${height}" ${FONT}>${defs}${parts.join('')}</svg>`
155
156 const alt = `${plan.title}: ${s.percent}% done, ${s.closed} of ${s.total} steps, ${s.isFinished ? `finished in ${duration(s.elapsedMs)}` : `phase ${phaseNo} of ${plan.phases.length}, ${duration(s.elapsedMs)} in${s.remainingMs != null ? `, about ${duration(s.remainingMs)} left` : ''}`}`
157 return { source, height, alt }
158}
159
160export function emptySvg(width: number): { source: string; height: number } {
161 const W = Math.max(300, Math.round(width))
162 const H = 92
163 const source =
164 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" ${FONT}>` +
165 `<rect x="0.5" y="0.5" width="${W - 1}" height="${H - 1}" rx="16" fill="${C.card}" stroke="${C.stroke}" stroke-dasharray="4 4"/>` +
166 text(20, 38, 14, C.text, 'No plan yet', 'font-weight="600"') +
167 text(20, 60, 11.5, C.muted, esc(fit('Approve a plan in plan mode, or ask Claude to lay a long task out in phases.', W - 40, 11.5))) +
168 `</svg>`
169 return { source, height: H }
170}
171hooks/plan.ts 279 lines1import type { Phase, Plan, Step, StepStatus } from '../types'
2
3const MAX_PHASES = 20
4const MAX_STEPS = 30
5const MAX_TITLE = 90
6
7// Headings that read as a phase of work: "Phase 2: ...", "Step 3 - ...", "2. ...".
8const PHASE_HEADING = /^(phase|stage|step|part|milestone|sprint|iteration|wave)\b|^\d+[.):]?\s/i
9// Sections a plan carries that are not work to do.
10const NOT_WORK =
11 /^(context|background|overview|summary|tl;?dr|goals?|non-goals?|motivation|problem|why|risks?|notes?|open questions|questions|assumptions|references|(critical |key |relevant )?files|out of scope|alternatives( considered)?|decisions|trade-?offs|approach|recommendation)\b/i
12
13type Item = { indent: number; text: string }
14type Section = { level: number; title: string; items: Item[] }
15
16export function clean(text: string): string {
17 const out = text
18 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
19 .replace(/[*_`~]/g, '')
20 .replace(/\s+/g, ' ')
21 .replace(/[:\s]+$/, '')
22 .trim()
23 return out.length > MAX_TITLE ? `${out.slice(0, MAX_TITLE - 1)}…` : out
24}
25
26// "Phase 2: Build the API" -> "Build the API"; "3. Tests" -> "Tests".
27export function stripNumbering(title: string): string {
28 const stripped = title
29 .replace(/^(phase|stage|step|part|milestone|sprint|iteration|wave)\s*[\w.]*\s*[:.)\-–—]\s*/i, '')
30 .replace(/^\d+[.):]\s*/, '')
31 .trim()
32 return stripped || title
33}
34
35function sections(markdown: string): Section[] {
36 const out: Section[] = [{ level: 0, title: '', items: [] }]
37 let fenced = false
38 for (const raw of markdown.split(/\r?\n/)) {
39 if (/^\s*(```|~~~)/.test(raw)) {
40 fenced = !fenced
41 continue
42 }
43 if (fenced) continue
44
45 const heading = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(raw)
46 if (heading) {
47 out.push({ level: heading[1]!.length, title: clean(heading[2]!), items: [] })
48 continue
49 }
50 // A line that is only bold text works as a heading in many plans: "**Phase 1: Setup**".
51 const bold = /^\s*\*\*([^*]+)\*\*:?\s*$/.exec(raw)
52 if (bold) {
53 out.push({ level: 7, title: clean(bold[1]!), items: [] })
54 continue
55 }
56 const item = /^(\s*)(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?(.+)$/.exec(raw)
57 if (item) {
58 const text = clean(item[2]!)
59 if (text) out[out.length - 1]!.items.push({ indent: item[1]!.replace(/\t/g, ' ').length, text })
60 }
61 }
62 return out
63}
64
65const topLevel = (items: Item[]): string[] => {
66 const min = Math.min(...items.map(i => i.indent))
67 return items.filter(i => i.indent === min).map(i => i.text)
68}
69
70const toPhase = (title: string, steps: string[]): Phase => {
71 const name = stripNumbering(title)
72 const list = steps.length > 0 ? steps : [name]
73 return { title: name, steps: list.slice(0, MAX_STEPS).map(t => ({ title: t, status: 'pending' as const })) }
74}
75
76/**
77 * Reads a markdown plan into phases of steps. Phase headings win; failing
78 * those, every heading that holds a list and is not context; failing that,
79 * one list whose nested items are the steps, or one flat list as one phase.
80 */
81export function parsePlan(markdown: string): { title: string; phases: Phase[] } {
82 const all = sections(markdown)
83 const h1 = all.find(s => s.level === 1)
84 let title = h1 ? stripNumbering(h1.title.replace(/^plan\s*[:\-–—]\s*/i, '')) : ''
85 if (!title || /^plan$/i.test(title)) title = 'Plan'
86
87 const body = all.filter(s => s !== h1)
88 const named = body.filter(s => s.level > 0 && PHASE_HEADING.test(s.title) && !NOT_WORK.test(stripNumbering(s.title)))
89
90 let phases: Phase[] = []
91 if (named.length > 0) {
92 // A phase takes the items of the sections nested under it, up to the next phase.
93 for (let i = 0; i < body.length; i++) {
94 const s = body[i]!
95 if (!named.includes(s)) continue
96 const items = [...s.items]
97 for (let j = i + 1; j < body.length; j++) {
98 const sub = body[j]!
99 if (named.includes(sub) || sub.level <= s.level) break
100 if (sub.items.length === 0) items.push({ indent: 0, text: sub.title })
101 else items.push(...sub.items)
102 }
103 phases.push(toPhase(s.title, items.length ? topLevel(items) : []))
104 }
105 } else {
106 const work = body.filter(s => s.level > 0 && s.items.length > 0 && !NOT_WORK.test(s.title))
107 if (work.length >= 2) {
108 phases = work.map(s => toPhase(s.title, topLevel(s.items)))
109 } else {
110 const items = work[0]?.items ?? body.flatMap(s => s.items)
111 if (items.length > 0) {
112 const min = Math.min(...items.map(i => i.indent))
113 const hasNesting = items.some(i => i.indent > min)
114 if (hasNesting) {
115 // Each top item is a phase, the items under it its steps.
116 let current: { title: string; steps: Item[] } | null = null
117 const groups: { title: string; steps: Item[] }[] = []
118 for (const it of items) {
119 if (it.indent === min) groups.push((current = { title: it.text, steps: [] }))
120 else current?.steps.push(it)
121 }
122 phases = groups.map(g => toPhase(g.title, g.steps.length ? topLevel(g.steps) : []))
123 } else {
124 phases = [toPhase(work[0]?.title || title, items.map(i => i.text))]
125 }
126 }
127 }
128 }
129
130 return { title, phases: phases.slice(0, MAX_PHASES) }
131}
132
133export function newPlan(title: string, phases: { title: string; steps: string[] }[], source: Plan['source'], now: number): Plan {
134 return {
135 title: clean(title) || 'Plan',
136 source,
137 createdAt: now,
138 phases: phases.slice(0, MAX_PHASES).map(p => toPhase(clean(p.title), p.steps.map(clean).filter(Boolean))),
139 }
140}
141
142// ---------------------------------------------------------------- progress
143
144const isClosed = (s: Step) => s.status === 'done' || s.status === 'skipped'
145
146export type PhaseState = 'done' | 'active' | 'pending'
147
148export function phaseState(p: Phase): PhaseState {
149 if (p.steps.every(isClosed)) return 'done'
150 if (p.steps.some(s => s.status === 'active' || isClosed(s))) return 'active'
151 return 'pending'
152}
153
154const lastEnd = (plan: Plan): number =>
155 Math.max(plan.createdAt, ...plan.phases.flatMap(p => p.steps.map(s => s.endedAt ?? 0)))
156
157/** Sets one step, or every open step of a phase, to `status`, stamping times. */
158export function mark(plan: Plan, phase: number, step: number | undefined, status: StepStatus, now: number): Plan {
159 const since = lastEnd(plan)
160 const set = (s: Step): Step => {
161 if (status === 'pending') return { title: s.title, status }
162 if (status === 'active') return { ...s, status, startedAt: s.startedAt ?? now, endedAt: undefined }
163 return { ...s, status, startedAt: s.startedAt ?? since, endedAt: s.endedAt ?? now }
164 }
165 return {
166 ...plan,
167 phases: plan.phases.map((p, pi) =>
168 pi !== phase
169 ? p
170 : { ...p, steps: p.steps.map((s, si) => (step === undefined ? (isClosed(s) && status !== 'pending' ? s : set(s)) : si === step ? set(s) : s)) },
171 ),
172 }
173}
174
175export type Stats = {
176 total: number
177 closed: number
178 percent: number
179 elapsedMs: number
180 remainingMs: number | null
181 currentPhase: number | null
182 isFinished: boolean
183}
184
185export function stats(plan: Plan, now: number): Stats {
186 const steps = plan.phases.flatMap(p => p.steps)
187 const closed = steps.filter(isClosed).length
188 const isFinished = closed === steps.length
189 const end = isFinished ? lastEnd(plan) : now
190 const elapsedMs = Math.max(0, end - plan.createdAt)
191 // The pace so far: the time up to the last finished step over the steps finished.
192 const done = steps.filter(s => s.status === 'done').length
193 const paceMs = done > 0 ? (lastEnd(plan) - plan.createdAt) / done : null
194 const currentIdx = plan.phases.findIndex(p => phaseState(p) !== 'done')
195 return {
196 total: steps.length,
197 closed,
198 percent: steps.length ? Math.round((closed / steps.length) * 100) : 0,
199 elapsedMs,
200 remainingMs: isFinished ? 0 : paceMs == null ? null : Math.round(paceMs * (steps.length - closed)),
201 currentPhase: currentIdx === -1 ? null : currentIdx,
202 isFinished,
203 }
204}
205
206export function phaseDuration(plan: Plan, index: number, now: number): number | null {
207 const p = plan.phases[index]!
208 const starts = p.steps.map(s => s.startedAt).filter((t): t is number => t != null)
209 if (starts.length === 0) return null
210 const ends = p.steps.map(s => s.endedAt).filter((t): t is number => t != null)
211 const end = phaseState(p) === 'done' ? Math.max(...ends) : now
212 return Math.max(0, end - Math.min(...starts))
213}
214
215export function duration(ms: number): string {
216 const min = Math.round(ms / 60_000)
217 if (min < 1) return `${Math.max(0, Math.round(ms / 1000))}s`
218 if (min < 60) return `${min}m`
219 const h = Math.floor(min / 60)
220 return `${h}h${String(min % 60).padStart(2, '0')}m`
221}
222
223export function bar(percent: number, cells: number): { filled: string; track: string } {
224 const filled = Math.min(cells, Math.round((percent / 100) * cells))
225 return { filled: '▰'.repeat(filled), track: '▱'.repeat(cells - filled) }
226}
227
228/** One line for the status bar. */
229export function statusLine(plan: Plan, now: number): string {
230 const s = stats(plan, now)
231 const { filled, track } = bar(s.percent, 8)
232 if (s.isFinished) return `✓ ${plan.title} · ${s.total} steps in ${duration(s.elapsedMs)}`
233 const parts = [`${filled}${track} ${s.closed}/${s.total}`]
234 if (s.currentPhase != null) {
235 parts.push(`phase ${s.currentPhase + 1}/${plan.phases.length}: ${plan.phases[s.currentPhase]!.title}`)
236 }
237 parts.push(`${duration(s.elapsedMs)} in`)
238 if (s.remainingMs != null) parts.push(`~${duration(s.remainingMs)} left`)
239 return parts.join(' · ')
240}
241
242/** The tree as plain text, numbered as plan_mark takes it, for the model. */
243export function outline(plan: Plan): string {
244 return plan.phases
245 .map((p, pi) => {
246 const glyph = (st: StepStatus) => (st === 'done' ? '[x]' : st === 'active' ? '[>]' : st === 'skipped' ? '[-]' : '[ ]')
247 const steps = p.steps.map((s, si) => ` ${pi + 1}.${si + 1} ${glyph(s.status)} ${s.title}`)
248 return [`${pi + 1}. ${p.title}`, ...steps].join('\n')
249 })
250 .join('\n')
251}
252
253// ---------------------------------------------------------------- task sync
254
255const norm = (t: string) => t.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
256
257/** The step a task or todo names: an exact match, or one containing the other. */
258export function findStep(plan: Plan, text: string): { phase: number; step: number } | null {
259 const want = norm(text)
260 if (!want) return null
261 let loose: { phase: number; step: number } | null = null
262 for (let pi = 0; pi < plan.phases.length; pi++) {
263 const steps = plan.phases[pi]!.steps
264 for (let si = 0; si < steps.length; si++) {
265 const have = norm(steps[si]!.title)
266 if (have === want) return { phase: pi, step: si }
267 const shorter = have.length < want.length ? have : want
268 if (!loose && shorter.length >= 12 && (have.includes(want) || want.includes(have))) loose = { phase: pi, step: si }
269 }
270 }
271 return loose
272}
273
274export const TASK_STATUS: Record<string, StepStatus | undefined> = {
275 pending: undefined,
276 in_progress: 'active',
277 completed: 'done',
278}
279types/index.d.ts 26 lines1export type StepStatus = 'pending' | 'active' | 'done' | 'skipped'
2
3export type Step = { title: string; status: StepStatus; startedAt?: number; endedAt?: number }
4
5export type Phase = { title: string; steps: Step[] }
6
7export type Plan = {
8 title: string
9 // Where the tree came from: an approved plan-mode plan, or the model's plan_set.
10 source: 'plan-mode' | 'model'
11 createdAt: number
12 phases: Phase[]
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'plan-progress': {
18 plan: Plan | null
19 // Bumped by the ticker so elapsed times redraw while a plan runs.
20 now: number
21 // Task ids to subjects, so a TaskUpdate can be matched to a step.
22 tasks: Record<string, string>
23 }
24 }
25}
26