Live pane with every step of the current plan, the current step marked, and a done/total count

A Claude Code plugin that always shows the whole plan in a pane:
2/5 done · ~45 min left · task list
✓ 1. Read ticket (~5 min)
✓ 2. Write OpenSpec proposal (~15 min)
▶ 3. Implement context functions (~20 min)
○ 4. Add tests (~10 min)
○ 5. Commit and open PR (~5 min)
> Add a step after the current one
✓ done, ▶ current, ○ pendingdone/total count and the time leftPlan 2/5 · ~45 min left ▶ Implement context functionsThe plugin asks Claude to end each step with an estimate, for example (~10 min).
Press ctrl+x tab to focus the pane, Tab to the text box, type the step, and press Enter. The step goes after the current step.
- [ ] line into tasks.md after the current step.TodoWrite, or TaskCreate / TaskUpdate / TaskList) for the main conversation.openspec/changes/<change>/tasks.md (the archive folder is skipped). The pane refreshes when that file changes and at the end of each turn./plugin marketplace add hoangbits/claude-implement-plan-tracker
/plugin install implement-plan-tracker@implement-plan-tracker
The plugin uses Claude Code function hooks (early access). It needs a Claude Code build with plugin function hooks.
/plan to open the pane at any time.ctrl+x tab moves focus to the pane. Esc or ctrl+x x closes it.claude plugin validate .
claude plugin test .
claude --plugin-dir .hooks/register.tsx 317 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { OpenSpecPlan, Plan, Step } from '../types'
5import {
6 addTask,
7 currentIndex,
8 doneCount,
9 formatDuration,
10 fromTaskList,
11 fromTodos,
12 insertAfterCurrent,
13 insertTaskLine,
14 isTasksMd,
15 parseTasksMd,
16 patchTask,
17 remainingMs,
18 trackTiming,
19} from './plan'
20
21const PANE = 'plan'
22const TITLE = 'Plan'
23const CHANGES = 'openspec/changes'
24const TICK_MS = 30_000
25
26const ESTIMATE_RULE = [
27 'When you write a task list (TodoWrite or TaskCreate) or OpenSpec tasks.md checkboxes,',
28 'end each step with a time estimate in the form `(~N min)` or `(~N h)`,',
29 'for example `Add migration (~5 min)`.',
30].join(' ')
31
32const plan = atom({ plugin: 'implement-plan-tracker', key: 'plan' } as const, null)
33const openspec = atom({ plugin: 'implement-plan-tracker', key: 'openspec' } as const, null)
34const timing = atom({ plugin: 'implement-plan-tracker', key: 'timing' } as const, {})
35const tick = atom({ plugin: 'implement-plan-tracker', key: 'tick' } as const, 0)
36
37const MARK: Record<Step['status'], string> = {
38 completed: '✓',
39 in_progress: '▶',
40 pending: '○',
41}
42
43type Shown = { label: string; steps: Step[] }
44
45const shown = (live: Plan | null, spec: OpenSpecPlan | null): Shown | null => {
46 if (live && live.steps.length > 0) {
47 return { label: live.source === 'todos' ? 'task list' : 'tasks', steps: live.steps }
48 }
49 if (spec && spec.steps.length > 0) {
50 return { label: `openspec: ${spec.change}`, steps: spec.steps }
51 }
52
53 return null
54}
55
56const readShown = async ($: EngineInterface): Promise<Shown | null> =>
57 shown(await read($, plan), await read($, openspec))
58
59const timeLeft = async ($: EngineInterface, steps: Step[]): Promise<string> => {
60 if (doneCount(steps) === steps.length) return ''
61 const ms = remainingMs(steps, await read($, timing), await $.clock.now())
62
63 return ms === null ? 'estimating…' : `~${formatDuration(ms)} left`
64}
65
66const newestChange = async ($: EngineInterface): Promise<string | null> => {
67 if (!(await $.fs.exists(CHANGES))) return null
68 const dirs = (await $.fs.list(CHANGES)).filter(
69 entry => entry.kind === 'dir' && entry.name !== 'archive',
70 )
71 const stamped = await Promise.all(
72 dirs.map(async dir => {
73 const stat = await $.fs.stat(`${CHANGES}/${dir.name}/tasks.md`).catch(() => undefined)
74
75 return { name: dir.name, mtimeMs: stat?.mtimeMs ?? -1 }
76 }),
77 )
78 const newest = stamped
79 .filter(one => one.mtimeMs >= 0)
80 .sort((a, b) => b.mtimeMs - a.mtimeMs)[0]
81
82 return newest?.name ?? null
83}
84
85const refresh = async ($: EngineInterface): Promise<void> => {
86 const view = await readShown($)
87 if (!view) {
88 $.ui.status(undefined)
89
90 return
91 }
92 const now = await $.clock.now()
93 await update($, timing, old => trackTiming(view.steps, old, now))
94 const step = view.steps[currentIndex(view.steps)]
95 const left = await timeLeft($, view.steps)
96 const current = step ? ` ▶ ${step.text.slice(0, 40)}` : ' ✓ all done'
97 $.ui.status(`Plan ${doneCount(view.steps)}/${view.steps.length}${left ? ` · ${left}` : ''}${current}`)
98}
99
100const refreshOpenSpec = async ($: EngineInterface): Promise<void> => {
101 const change = await newestChange($).catch(() => null)
102 const steps = change ? parseTasksMd(await $.fs.read(`${CHANGES}/${change}/tasks.md`)) : []
103 await update($, openspec, () => (change ? { change, steps } : null))
104 await refresh($)
105}
106
107const setSteps = async (
108 $: EngineInterface,
109 source: Plan['source'],
110 fn: (steps: Step[]) => Step[],
111): Promise<void> => {
112 await update($, plan, old => ({
113 source,
114 steps: fn(old?.source === source ? old.steps : []),
115 }))
116 await refresh($)
117}
118
119let notes: string[] = []
120let isWriting = false
121
122function takeNotes(): string[] {
123 const taken = notes
124 notes = []
125
126 return taken
127}
128
129async function addStep($: EngineInterface, raw: string): Promise<void> {
130 const text = raw.trim()
131 const live = await read($, plan)
132 const spec = await read($, openspec)
133 const view = shown(live, spec)
134 if (!text || !view) return
135
136 const current = view.steps[currentIndex(view.steps)]
137 const after = current ? ` Do it right after the current step "${current.text}".` : ''
138 isWriting = true
139 try {
140 if (live && live.steps.length > 0 && live.source === 'todos') {
141 const steps = insertAfterCurrent(live.steps, { id: 'new', text, status: 'pending' })
142 await $.tool.call({
143 tool: 'TodoWrite',
144 todos: steps.map(step => ({
145 content: step.text,
146 status: step.status,
147 activeForm: step.activeForm ?? step.text,
148 })),
149 })
150 await setSteps($, 'todos', () => steps)
151 notes = [
152 ...notes,
153 `The user added a step to your task list from the plan pane: "${text}".${after} Keep it in the list when you update it.`,
154 ]
155 } else if (live && live.steps.length > 0) {
156 const made = await $.tool.call({
157 tool: 'TaskCreate',
158 subject: text,
159 description: 'Added by the user from the plan pane.',
160 })
161 if (made.deny === undefined && made.isError !== true) {
162 const { id, subject } = made.result.task
163 await setSteps($, 'tasks', steps => addTask(steps, id, subject))
164 }
165 notes = [...notes, `The user added a task from the plan pane: "${text}".${after}`]
166 } else if (spec) {
167 const path = `${CHANGES}/${spec.change}/tasks.md`
168 await $.fs.write(path, insertTaskLine(await $.fs.read(path), spec.steps, text))
169 await refreshOpenSpec($)
170 notes = [...notes, `The user added a step to ${path} from the plan pane: "${text}".${after}`]
171 }
172 } finally {
173 isWriting = false
174 }
175 $.ui.toast(`Step added: ${text}`)
176}
177
178export const register: Register = on => {
179 on('session.start', async ($, e, next) => {
180 await $.command.register({
181 name: 'plan',
182 description: 'Show every step of the current plan in a pane',
183 })
184 $.clock.every(TICK_MS, () => void update($, tick, n => n + 1))
185 await refreshOpenSpec($)
186 void $.ui.open({ id: PANE, title: TITLE })
187
188 return next(e)
189 })
190
191 on('command.run', { command: 'plan' }, async $ => {
192 await refreshOpenSpec($)
193 await $.ui.open({ id: PANE, title: TITLE })
194
195 return { text: 'Plan pane opened.' }
196 })
197
198 on('prompt.compose', async ($, e, next) => {
199 const { sections } = await next(e)
200
201 return {
202 sections: [
203 ...sections,
204 { id: 'implement-plan-tracker:estimates', text: ESTIMATE_RULE, scope: 'session' },
205 ],
206 }
207 })
208
209 on('prompt.submit', ($, e, next) => {
210 const taken = takeNotes()
211
212 return taken.length === 0 ? next(e) : next({ ...e, context: [...(e.context ?? []), ...taken] })
213 })
214
215 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
216 const ran = await next(e)
217 if (e.agentId === undefined && ran.deny === undefined && ran.isError !== true) {
218 await setSteps($, 'todos', () => fromTodos(e.todos))
219 }
220
221 return ran
222 })
223
224 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
225 const ran = await next(e)
226 if (e.agentId === undefined && ran.deny === undefined && ran.isError !== true) {
227 const { id, subject } = ran.result.task
228 await setSteps($, 'tasks', steps => addTask(steps, id, subject))
229 }
230
231 return ran
232 })
233
234 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
235 const ran = await next(e)
236 if (e.agentId === undefined && ran.deny === undefined && ran.isError !== true) {
237 await setSteps($, 'tasks', steps => patchTask(steps, e))
238 }
239
240 return ran
241 })
242
243 on('tool.call', { tool: 'TaskList' }, async ($, e, next) => {
244 const ran = await next(e)
245 if (e.agentId === undefined && ran.deny === undefined && ran.isError !== true) {
246 const { tasks } = ran.result
247 await setSteps($, 'tasks', () => fromTaskList(tasks))
248 }
249
250 return ran
251 })
252
253 on('tool.call', async ($, e, next) => {
254 const ran = await next(e)
255 const path = 'file_path' in e && typeof e.file_path === 'string' ? e.file_path : ''
256 if (isTasksMd(path)) await refreshOpenSpec($)
257 const canCarry = !isWriting && e.agentId === undefined && ran.deny === undefined
258 if (!canCarry || notes.length === 0) return ran
259
260 return { ...ran, context: [...(ran.context ?? []), ...takeNotes()] }
261 })
262
263 on('turn.complete', async ($, e, next) => {
264 await refreshOpenSpec($)
265
266 return next(e)
267 })
268
269 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
270 const table = $.ui.resolve(e)
271 const { Box, Text } = table
272 const Input = 'Input' in table ? table.Input : undefined
273 await read($, tick)
274 const view = await readShown($)
275
276 if (!view) {
277 return (
278 <Box flexDirection="column">
279 <Text dimColor>No plan yet.</Text>
280 <Text dimColor>It shows the task list, or the newest OpenSpec tasks.md.</Text>
281 </Box>
282 )
283 }
284
285 const at = currentIndex(view.steps)
286 const left = await timeLeft($, view.steps)
287
288 return (
289 <Box flexDirection="column">
290 <Text bold>
291 {doneCount(view.steps)}/{view.steps.length} done{left ? ` · ${left}` : ''}{' '}
292 <Text dimColor>· {view.label}</Text>
293 </Text>
294 {view.steps.map((step, i) => (
295 <Text
296 key={`step_${step.id}`}
297 wrap="truncate-end"
298 bold={i === at}
299 color={i === at ? 'cyan' : undefined}
300 dimColor={step.status === 'completed'}
301 >
302 {MARK[i === at ? 'in_progress' : step.status]} {i + 1}. {step.text}
303 </Text>
304 ))}
305 {Input && (
306 <Input
307 key="add"
308 placeholder="Add a step after the current one"
309 submitLabel="Add"
310 onSubmit={(value: string) => void addStep($, value)}
311 />
312 )}
313 </Box>
314 )
315 })
316}
317hooks/plan.ts 146 lines1import type { Step, StepStatus, Timing } from '../types'
2
3type Todo = { content: string; status: StepStatus; activeForm?: string }
4type TaskRow = { id: string; subject: string; status: StepStatus }
5type TaskPatch = { taskId: string; subject?: string; status?: StepStatus | 'deleted' }
6
7const CHECKBOX = /^\s*[-*]\s+\[([ xX])\]\s+(.+?)\s*$/
8
9export const fromTodos = (todos: readonly Todo[]): Step[] =>
10 todos.map((todo, i) => ({
11 id: String(i),
12 text: todo.content,
13 status: todo.status,
14 activeForm: todo.activeForm,
15 }))
16
17export const fromTaskList = (tasks: readonly TaskRow[]): Step[] =>
18 tasks.map(task => ({ id: task.id, text: task.subject, status: task.status }))
19
20export const addTask = (steps: Step[], id: string, subject: string): Step[] => [
21 ...steps.filter(step => step.id !== id),
22 { id, text: subject, status: 'pending' },
23]
24
25export const patchTask = (steps: Step[], { taskId, subject, status }: TaskPatch): Step[] =>
26 status === 'deleted'
27 ? steps.filter(step => step.id !== taskId)
28 : steps.map(step =>
29 step.id === taskId
30 ? { ...step, text: subject ?? step.text, status: status ?? step.status }
31 : step,
32 )
33
34export const parseTasksMd = (text: string): Step[] =>
35 text.split('\n').flatMap((line, i) => {
36 const match = CHECKBOX.exec(line)
37 const label = match?.[2]
38 if (!match || label === undefined) return []
39 const status: StepStatus = match[1] === ' ' ? 'pending' : 'completed'
40
41 return [{ id: String(i), text: label, status }]
42 })
43
44export const currentIndex = (steps: readonly Step[]): number => {
45 const active = steps.findIndex(step => step.status === 'in_progress')
46
47 return active >= 0 ? active : steps.findIndex(step => step.status === 'pending')
48}
49
50export const doneCount = (steps: readonly Step[]): number =>
51 steps.filter(step => step.status === 'completed').length
52
53export const isTasksMd = (path: string): boolean =>
54 /(^|\/)openspec\/changes\/(?!archive\/)[^/]+\/tasks\.md$/.test(path)
55
56const ESTIMATE = /\(~\s*(\d+(?:\.\d+)?)\s*(min|m|h|hr|hrs|hours?)\)/i
57const MINUTE = 60_000
58const MIN_MEASURED = 5_000
59
60export const estimateMs = (text: string): number | null => {
61 const match = ESTIMATE.exec(text)
62 if (!match?.[1] || !match[2]) return null
63 const amount = Number(match[1])
64
65 return match[2].toLowerCase().startsWith('h') ? amount * 60 * MINUTE : amount * MINUTE
66}
67
68export const stepKey = (step: Step): string => step.text.replace(ESTIMATE, '').trim()
69
70export const trackTiming = (steps: readonly Step[], timing: Timing, now: number): Timing => {
71 const at = currentIndex(steps)
72 const next: Timing = {}
73 for (const [i, step] of steps.entries()) {
74 const key = stepKey(step)
75 const old = timing[key]
76 if (step.status === 'completed') {
77 next[key] = { startedAt: old?.startedAt ?? now, finishedAt: old?.finishedAt ?? now }
78 } else if (i === at) {
79 next[key] = { startedAt: old?.startedAt ?? now }
80 }
81 }
82
83 return next
84}
85
86const sum = (values: readonly number[]): number => values.reduce((a, b) => a + b, 0)
87
88export const remainingMs = (steps: readonly Step[], timing: Timing, now: number): number | null => {
89 const measured = steps.flatMap(step => {
90 const time = timing[stepKey(step)]
91 const took = time?.finishedAt === undefined ? 0 : time.finishedAt - time.startedAt
92 if (step.status !== 'completed' || took < MIN_MEASURED) return []
93
94 return [{ took, estimate: estimateMs(step.text) }]
95 })
96 const left = steps.filter(step => step.status !== 'completed')
97 const estimates = left.map(step => estimateMs(step.text))
98 const known = estimates.filter((ms): ms is number => ms !== null)
99 const average =
100 measured.length > 0
101 ? sum(measured.map(one => one.took)) / measured.length
102 : known.length > 0
103 ? sum(known) / known.length
104 : null
105 if (average === null) return null
106
107 const isCalibrated = measured.length > 0 && measured.every(one => one.estimate !== null)
108 const ratio = isCalibrated
109 ? sum(measured.map(one => one.took)) / sum(measured.map(one => one.estimate ?? 0))
110 : 1
111 const current = steps[currentIndex(steps)]
112
113 return sum(
114 left.map((step, i) => {
115 const estimate = estimates[i] ?? null
116 const full = estimate !== null && (isCalibrated || measured.length === 0) ? estimate * ratio : average
117 const startedAt = step === current ? timing[stepKey(step)]?.startedAt : undefined
118
119 return startedAt === undefined ? full : Math.max(0, full - (now - startedAt))
120 }),
121 )
122}
123
124export const formatDuration = (ms: number): string => {
125 const minutes = Math.max(1, Math.round(ms / MINUTE))
126 const hours = Math.floor(minutes / 60)
127
128 return hours === 0 ? `${minutes} min` : `${hours} h ${minutes % 60} min`
129}
130
131export const insertAfterCurrent = (steps: readonly Step[], step: Step): Step[] => {
132 const at = currentIndex(steps)
133 const cut = at < 0 ? steps.length : at + 1
134
135 return [...steps.slice(0, cut), step, ...steps.slice(cut)]
136}
137
138export const insertTaskLine = (markdown: string, steps: readonly Step[], text: string): string => {
139 const lines = markdown.split('\n')
140 const current = steps[currentIndex(steps)]
141 const anchor = current ? Number(current.id) : Number(steps.at(-1)?.id ?? lines.length - 1)
142 const indent = /^\s*/.exec(lines[anchor] ?? '')?.[0] ?? ''
143
144 return [...lines.slice(0, anchor + 1), `${indent}- [ ] ${text}`, ...lines.slice(anchor + 1)].join('\n')
145}
146types/index.d.ts 25 lines1export type StepStatus = 'pending' | 'in_progress' | 'completed'
2
3export type Step = { id: string; text: string; status: StepStatus; activeForm?: string }
4
5export type PlanSource = 'todos' | 'tasks'
6
7export type Plan = { source: PlanSource; steps: Step[] }
8
9export type OpenSpecPlan = { change: string; steps: Step[] }
10
11export type StepTiming = { startedAt: number; finishedAt?: number }
12
13export type Timing = { [stepKey: string]: StepTiming }
14
15declare module 'claude-code' {
16 interface PluginState {
17 'implement-plan-tracker': {
18 plan: Plan | null
19 openspec: OpenSpecPlan | null
20 timing: Timing
21 tick: number
22 }
23 }
24}
25