Shows the running plan's current step, elapsed times and estimates as a timeline above the prompt

Moved. plan-progress now lives in liveweird/claude-mods (folder
plan-progress/), together with the other mods. This repository is archived. Install from there:/plugin install plan-progress --marketplace liveweird/claude-mods
A Claude Code mod that shows the plan Claude is executing as a live timeline above the prompt:
Plan · Checkup 3 · step 3/5: Fix tier A
━━━━━━━━│━━━━━━━━━━━━━│━━━━━━━──────│─────────────────│──────
elapsed 1h12m · step 8m of ~15m · ~45m left · ETA 14:32
At the prompt of a Claude Code terminal session:
/plugin install plan-progress --marketplace liveweird/plan-progress
Answer y to add the marketplace, then pick the user scope. It is active at once, and in every later session.
Claude reports progress through the plan_progress tool the mod registers (start with steps and minute estimates, step on each move, revise, finish, clear). To make it do so by default, add to ~/.claude/CLAUDE.md:
When executing a multi-step plan and the
plan_progresstool (the plan-progress mod) is available, report it:startwith every step and an estimate in minutes,stepeach time you move on,revisewhen steps or estimates change,finishat the end. Skip it for one-step tasks.
/plan-progress opens a pane with the timeline and one row per step./plan-progress clear drops the plan.A finished plan stays on the band for ten minutes, then leaves it.
claude plugin validate . and claude plugin test .; after an edit, /reload-plugins in a session that installed it from this folder.
hooks/register.tsx 257 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Plan } from '../types'
5import {
6 clockTime,
7 describe,
8 fmt,
9 goTo,
10 revise,
11 type Segment,
12 startPlan,
13 stepDurationMs,
14 type StepInput,
15 summarize,
16 timeline,
17} from './plan'
18
19const PANE = 'plan-progress'
20const TOOL = 'mcp__plan-progress__plan_progress'
21const TICK_MS = 10_000
22/** A finished plan stays on the band this long, then leaves it. */
23const LINGER_MS = 10 * 60_000
24
25const plan = atom({ plugin: 'plan-progress', key: 'plan' } as const, null)
26
27const COLOR: Record<Segment['kind'], string> = {
28 done: 'success',
29 now: 'suggestion',
30 over: 'warning',
31 left: 'suggestion',
32 todo: 'inactive',
33 gap: 'subtle',
34}
35
36/** Done is green, the running step blue (solid for time spent, thin for its rest), later steps grey. */
37const DIM: ReadonlySet<Segment['kind']> = new Set(['todo', 'gap'])
38
39const STEPS_SCHEMA = {
40 type: 'array',
41 items: {
42 type: 'object',
43 properties: {
44 title: { type: 'string', description: 'Short step name (a few words).' },
45 estimateMin: { type: 'number', description: 'Estimated duration in minutes.' },
46 },
47 required: ['title', 'estimateMin'],
48 },
49}
50
51export const register: Register = on => {
52 on('session.start', async ($, e, next) => {
53 await $.tool.register({
54 name: 'plan_progress',
55 description: [
56 'Reports progress through the plan being executed, shown to the user as a timeline above the prompt.',
57 'Call action "start" when you begin executing a multi-step plan (title + steps, each with an estimate in minutes).',
58 'Call action "step" with the 1-based step number each time you move on to a step.',
59 'Call action "revise" when steps or estimates change (steps replace the list from step `from`, default the running one).',
60 'Call action "finish" when the plan is done, "clear" to drop it, "show" to read it back.',
61 ].join(' '),
62 inputSchema: {
63 type: 'object',
64 properties: {
65 action: { type: 'string', enum: ['start', 'step', 'revise', 'finish', 'clear', 'show'] },
66 title: { type: 'string', description: 'start: the plan name.' },
67 steps: { ...STEPS_SCHEMA, description: 'start/revise: the steps in order.' },
68 step: { type: 'number', description: 'step: the 1-based step now starting.' },
69 from: { type: 'number', description: 'revise: the 1-based step the new list starts at.' },
70 },
71 required: ['action'],
72 },
73 })
74 await $.command.register({
75 name: 'plan-progress',
76 description: 'Show the running plan as a timeline (args: clear)',
77 argumentHint: '[clear]',
78 })
79 $.clock.every(TICK_MS, async () => {
80 const p = await read($, plan)
81 if (p !== null && p.finishedAt === undefined) $.ui.invalidate('ui.render')
82 })
83
84 return next(e)
85 })
86
87 on('tool.call', { tool: TOOL }, async ($, e) => {
88 const now = await $.clock.now()
89 const current = await read($, plan)
90 const steps = Array.isArray(e.steps) ? (e.steps as StepInput[]) : undefined
91 const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : undefined)
92 let nextPlan: Plan | null = current
93
94 switch (e.action) {
95 case 'start':
96 if (!steps?.length) return { result: 'start needs a non-empty steps list.', isError: true }
97 nextPlan = startPlan(typeof e.title === 'string' && e.title.trim() ? e.title.trim() : 'Plan', steps, now)
98 break
99 case 'step': {
100 const n = num(e.step)
101 if (current === null) return { result: 'No plan is running; call start first.', isError: true }
102 if (n === undefined || n < 1 || n > current.steps.length) {
103 return { result: `step must be 1..${current.steps.length}.`, isError: true }
104 }
105 nextPlan = goTo(current, n - 1, now)
106 break
107 }
108 case 'revise': {
109 if (current === null) return { result: 'No plan is running; call start first.', isError: true }
110 if (!steps) return { result: 'revise needs steps.', isError: true }
111 const from = num(e.from)
112 nextPlan = revise(current, steps, from === undefined ? undefined : from - 1, now)
113 if (typeof e.title === 'string' && e.title.trim()) nextPlan = { ...nextPlan, title: e.title.trim() }
114 break
115 }
116 case 'finish':
117 if (current === null) return { result: 'No plan is running.', isError: true }
118 nextPlan = goTo(current, current.steps.length, now)
119 break
120 case 'clear':
121 nextPlan = null
122 break
123 case 'show':
124 break
125 default:
126 return { result: 'action must be one of start, step, revise, finish, clear, show.', isError: true }
127 }
128
129 if (nextPlan !== current) await update($, plan, () => nextPlan)
130 return { result: nextPlan === null ? 'No plan.' : describe(nextPlan, now) }
131 })
132
133 on('command.run', { command: 'plan-progress' }, async ($, e) => {
134 if (e.args.trim() === 'clear') {
135 await update($, plan, () => null)
136 return { text: 'Plan progress cleared.' }
137 }
138 await $.ui.open({ id: PANE, title: 'Plan progress' })
139 return { text: 'Plan progress pane opened.' }
140 })
141
142 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
143 const p = await read($, plan)
144 if (e.props.hasSurvey || p === null) return next(e)
145 const now = await $.clock.now()
146 if (p.finishedAt !== undefined && now - p.finishedAt > LINGER_MS) return next(e)
147
148 const { Box, Text } = $.ui.resolve(e)
149 const width = Math.max(10, e.props.bodyColumns)
150 const sum = summarize(p, now)
151 const step = p.steps[p.current]
152
153 return (
154 <Box flexDirection="column">
155 <Text wrap="truncate-end">
156 <Text dimColor>Plan · {p.title} · </Text>
157 {sum.isFinished ? (
158 <Text color="success" bold>
159 ✓ done, {p.steps.length} steps
160 </Text>
161 ) : (
162 <Text bold color={sum.isOver ? 'warning' : 'suggestion'}>
163 step {p.current + 1}/{p.steps.length}: {step?.title}
164 </Text>
165 )}
166 </Text>
167 {bar($, e, p, now, width)}
168 <Text wrap="truncate-end" dimColor>
169 {statusLine(p, now)}
170 </Text>
171 </Box>
172 )
173 })
174
175 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
176 const { Box, Text } = $.ui.resolve(e)
177 const p = await read($, plan)
178 if (p === null) {
179 return (
180 <Text dimColor>
181 No plan is running. Claude reports one with its plan_progress tool when it starts executing a plan.
182 </Text>
183 )
184 }
185 const now = await $.clock.now()
186 const width = Math.max(10, e.props.bodyColumns)
187 const sum = summarize(p, now)
188 let cursor = now + Math.max(0, sum.stepEstimateMs - sum.stepElapsedMs)
189
190 return (
191 <Box flexDirection="column">
192 <Text bold wrap="truncate-end">
193 {p.title}
194 </Text>
195 <Text dimColor wrap="truncate-end">
196 {statusLine(p, now)}
197 </Text>
198 {bar($, e, p, now, width)}
199 <Text> </Text>
200 {p.steps.map((s, i) => {
201 const isDone = sum.isFinished || i < p.current
202 const isNow = !sum.isFinished && i === p.current
203 const took = stepDurationMs(p, i, now)
204 let right: string
205 let color: string | undefined
206 if (isDone) {
207 right = `${fmt(took ?? 0)} / est ${s.estimateMin}m`
208 color = 'success'
209 } else if (isNow) {
210 right = `${fmt(took ?? 0)} / est ${s.estimateMin}m`
211 color = sum.isOver ? 'warning' : 'suggestion'
212 } else {
213 right = `est ${s.estimateMin}m · ≈${clockTime(cursor)}`
214 cursor += s.estimateMin * 60_000
215 }
216 return (
217 <Box flexDirection="row" key={`step-${i}`}>
218 <Box flexGrow={1} flexShrink={1}>
219 <Text wrap="truncate-end" color={color} bold={isNow} dimColor={!isDone && !isNow}>
220 {isDone ? '✓' : isNow ? '▶' : '·'} {i + 1}. {s.title}
221 </Text>
222 </Box>
223 <Text color={isNow ? color : undefined} dimColor={!isNow}> {right}</Text>
224 </Box>
225 )
226 })}
227 </Box>
228 )
229 })
230}
231
232function statusLine(p: Plan, now: number): string {
233 const sum = summarize(p, now)
234 if (sum.isFinished) {
235 const est = p.steps.reduce((t, s) => t + s.estimateMin, 0)
236 return `took ${fmt(sum.elapsedMs)} (estimated ${fmt(est * 60_000)}) · finished ${clockTime(p.finishedAt ?? now)}`
237 }
238 const stepPart = sum.isOver
239 ? `step ${fmt(sum.stepElapsedMs)}, ${fmt(sum.stepElapsedMs - sum.stepEstimateMs)} over its ${fmt(sum.stepEstimateMs)}`
240 : `step ${fmt(sum.stepElapsedMs)} of ~${fmt(sum.stepEstimateMs)}`
241 return `elapsed ${fmt(sum.elapsedMs)} · ${stepPart} · ~${fmt(sum.remainingMs)} left · ETA ${clockTime(now + sum.remainingMs)}`
242}
243
244function bar($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0], p: Plan, now: number, width: number) {
245 const { Box, Text } = $.ui.resolve(e)
246 const segments = timeline(p, now, width)
247 return (
248 <Box flexDirection="row">
249 {segments.map(s => (
250 <Text color={COLOR[s.kind]} bold={s.kind === 'now'} dimColor={DIM.has(s.kind)}>
251 {s.text}
252 </Text>
253 ))}
254 </Box>
255 )
256}
257hooks/plan.ts 170 lines1import type { Plan, PlanStep } from '../types'
2
3export type StepInput = { title: string; estimateMin: number }
4
5const MINUTE = 60_000
6
7export function startPlan(title: string, steps: StepInput[], now: number): Plan {
8 return {
9 title,
10 startedAt: now,
11 current: 0,
12 steps: steps.map((s, i) => ({ ...clean(s), ...(i === 0 ? { startedAt: now } : {}) })),
13 }
14}
15
16/** Ends the running step and starts `index` (0-based); steps jumped over count as skipped. */
17export function goTo(plan: Plan, index: number, now: number): Plan {
18 const target = Math.max(0, Math.min(index, plan.steps.length))
19 const steps = plan.steps.map((s, i): PlanStep => {
20 if (i < target) {
21 return s.endedAt !== undefined ? s : { ...s, startedAt: s.startedAt ?? now, endedAt: now }
22 }
23 if (i === target) {
24 return { title: s.title, estimateMin: s.estimateMin, startedAt: s.startedAt !== undefined && s.endedAt === undefined ? s.startedAt : now }
25 }
26 return { title: s.title, estimateMin: s.estimateMin }
27 })
28 const isDone = target >= steps.length
29 return { ...plan, steps, current: target, ...(isDone ? { finishedAt: now } : { finishedAt: undefined }) }
30}
31
32/** Replaces the steps from `from` (default: the running one) on, keeping what already ran. */
33export function revise(plan: Plan, steps: StepInput[], from: number | undefined, now: number): Plan {
34 const at = Math.max(0, Math.min(from ?? plan.current, plan.steps.length))
35 const kept = plan.steps.slice(0, at)
36 const fresh = steps.map((s, i): PlanStep => {
37 const old = plan.steps[at + i]
38 const isRunning = at + i === plan.current
39 return { ...clean(s), ...(isRunning ? { startedAt: old?.startedAt ?? now } : {}) }
40 })
41 const merged = [...kept, ...fresh]
42 const current = Math.min(plan.current, merged.length)
43 return { ...plan, steps: merged, current, ...(current >= merged.length && merged.length > 0 ? { finishedAt: plan.finishedAt ?? now } : { finishedAt: undefined }) }
44}
45
46function clean(s: StepInput): PlanStep {
47 return { title: String(s.title).trim() || 'Untitled step', estimateMin: Math.max(0, Number(s.estimateMin) || 0) }
48}
49
50export type Summary = {
51 elapsedMs: number
52 stepElapsedMs: number
53 stepEstimateMs: number
54 /** Estimated time still to go: the running step's unspent estimate plus every later step's. */
55 remainingMs: number
56 isOver: boolean
57 isFinished: boolean
58}
59
60export function summarize(plan: Plan, now: number): Summary {
61 const end = plan.finishedAt ?? now
62 const running = plan.steps[plan.current]
63 const isFinished = plan.finishedAt !== undefined || running === undefined
64 const stepElapsedMs = !isFinished && running?.startedAt !== undefined ? now - running.startedAt : 0
65 const stepEstimateMs = isFinished ? 0 : (running?.estimateMin ?? 0) * MINUTE
66 const later = plan.steps.slice(plan.current + 1).reduce((sum, s) => sum + s.estimateMin * MINUTE, 0)
67 return {
68 elapsedMs: end - plan.startedAt,
69 stepElapsedMs,
70 stepEstimateMs,
71 remainingMs: isFinished ? 0 : Math.max(0, stepEstimateMs - stepElapsedMs) + later,
72 isOver: !isFinished && stepEstimateMs > 0 && stepElapsedMs > stepEstimateMs,
73 isFinished,
74 }
75}
76
77export function stepDurationMs(plan: Plan, i: number, now: number): number | undefined {
78 const s = plan.steps[i]
79 if (s?.startedAt === undefined) return undefined
80 return (s.endedAt ?? now) - s.startedAt
81}
82
83export type Segment = { text: string; kind: 'done' | 'now' | 'left' | 'over' | 'todo' | 'gap' }
84
85/**
86 * The timeline as runs of cells, one block per step sized by its actual time (done),
87 * its larger of elapsed and estimate (running) or its estimate (to do).
88 */
89export function timeline(plan: Plan, now: number, width: number): Segment[] {
90 const n = plan.steps.length
91 if (n === 0 || width < n) return []
92 /** Separators only where every step still gets a cell of its own. */
93 const hasGaps = width - (n - 1) >= n
94 const room = hasGaps ? width - (n - 1) : width
95 const weights = plan.steps.map((s, i) => {
96 const est = s.estimateMin * MINUTE
97 if (i < plan.current || plan.finishedAt !== undefined) return Math.max(stepDurationMs(plan, i, now) ?? 0, 1)
98 if (i === plan.current) return Math.max(est, stepDurationMs(plan, i, now) ?? 0, 1)
99 return Math.max(est, 1)
100 })
101 const cells = allocate(weights, room)
102 const out: Segment[] = []
103 plan.steps.forEach((s, i) => {
104 if (i > 0 && hasGaps) out.push({ text: '│', kind: 'gap' })
105 const w = cells[i] ?? 1
106 if (i < plan.current || plan.finishedAt !== undefined) {
107 out.push({ text: '━'.repeat(w), kind: 'done' })
108 } else if (i === plan.current) {
109 const spent = stepDurationMs(plan, i, now) ?? 0
110 const est = s.estimateMin * MINUTE
111 if (est > 0 && spent > est) {
112 const ok = Math.max(1, Math.round((w * est) / spent))
113 out.push({ text: '━'.repeat(Math.min(ok, w)), kind: 'now' })
114 if (w - ok > 0) out.push({ text: '━'.repeat(w - ok), kind: 'over' })
115 } else {
116 const fill = est > 0 ? Math.min(w, Math.max(1, Math.round((w * spent) / est))) : w
117 out.push({ text: '━'.repeat(fill), kind: 'now' })
118 if (w - fill > 0) out.push({ text: '─'.repeat(w - fill), kind: 'left' })
119 }
120 } else {
121 out.push({ text: '─'.repeat(w), kind: 'todo' })
122 }
123 })
124 return out
125}
126
127/** Every weight gets at least one cell; the rest go by largest remainder. */
128function allocate(weights: number[], room: number): number[] {
129 const spare = room - weights.length
130 const total = weights.reduce((a, b) => a + b, 0)
131 const exact = weights.map(w => (spare * w) / total)
132 const cells = exact.map(x => 1 + Math.floor(x))
133 let left = room - cells.reduce((a, b) => a + b, 0)
134 const order = exact.map((x, i) => [x - Math.floor(x), i] as const).sort((a, b) => b[0] - a[0])
135 for (const [, i] of order) {
136 if (left <= 0) break
137 cells[i] = (cells[i] ?? 0) + 1
138 left -= 1
139 }
140 return cells
141}
142
143export function fmt(ms: number): string {
144 const s = Math.max(0, Math.round(ms / 1000))
145 if (s < 60) return `${s}s`
146 const m = Math.floor(s / 60)
147 if (m < 60) return `${m}m`
148 const h = Math.floor(m / 60)
149 return `${h}h${String(m % 60).padStart(2, '0')}m`
150}
151
152export function clockTime(ms: number): string {
153 const d = new Date(ms)
154 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
155}
156
157/** The plan as text: the tool's answer and the pane's fallback. */
158export function describe(plan: Plan, now: number): string {
159 const sum = summarize(plan, now)
160 const head = sum.isFinished
161 ? `Plan "${plan.title}" finished in ${fmt(sum.elapsedMs)}.`
162 : `Plan "${plan.title}": step ${plan.current + 1}/${plan.steps.length}, ${fmt(sum.elapsedMs)} elapsed, ~${fmt(sum.remainingMs)} left.`
163 const rows = plan.steps.map((s, i) => {
164 const took = stepDurationMs(plan, i, now)
165 const mark = i < plan.current || sum.isFinished ? 'done' : i === plan.current ? 'now ' : 'todo'
166 return ` ${mark} ${i + 1}. ${s.title} — est ${s.estimateMin}m${took !== undefined ? `, ${fmt(took)}` : ''}`
167 })
168 return [head, ...rows].join('\n')
169}
170types/index.d.ts 23 lines1export type PlanStep = {
2 title: string
3 /** The estimate in minutes, as last given. */
4 estimateMin: number
5 startedAt?: number
6 endedAt?: number
7}
8
9export type Plan = {
10 title: string
11 startedAt: number
12 finishedAt?: number
13 /** Index of the running step; equals steps.length once finished. */
14 current: number
15 steps: PlanStep[]
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'plan-progress': { plan: Plan | null }
21 }
22}
23