SLOPSHOPPER

plan-progress

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

newpaneguardcommandtoaststatus
v0.2.0no licenseupdated 2026-10-08WeaponizedLego/claude-code-mods/plan-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · plan-progress
│ ┃ Plan progress ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ No plan yet │ ⏺ Read(src/auth.ts) │ ┃ │ Approve a plan in plan mode, or ask │ ⎿ Read 6 lines │ ┃ │ Claude to lay a long task out in phases. │ ⏺ 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-progress │ ⎿ plan-progress: No plan yet: approve a plan in plan mode, or ask │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Plan progress
╭──────────────────────────────────────────────────────────╮ │ No plan yet │ │ Approve a plan in plan mode, or ask Claude to lay a long │ │ task out in phases. │ ╰──────────────────────────────────────────────────────────╯
README

claude-code-mods

My Claude Code mods, one folder per mod. Needs Claude Code 2.1.287 or newer.

ModWhat it does
usage-bandA 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-progressReads 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.

usage-band in the desktop app

Install

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.

plan-progress

plan-progress in the desktop app

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.

Adding a mod

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

Source 4 files
hooks/register.tsx 406 lines
1import { 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}
406
hooks/look.ts 171 lines
1import 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
171
hooks/plan.ts 279 lines
1import 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}
279
types/index.d.ts 26 lines
1export 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