Shows where you and Claude are in a plan file in a side pane: done, in progress and what is left

A Claude Code mod that shows where you and Claude are in a plan file, in a side pane: what is done, what Claude is working on right now, and what is left.

- [ ] checkbox counts, grouped under its nearest heading. Superpowers plans (### Task N with step checkboxes) and flat task lists both work.report tool. Claude marks an item doing, done or blocked, with an optional note. done ticks the checkbox in the plan file, so the file stays the record./clear, a restart or the next day the pane picks it up again./theme recolors the pane right away. /plugin marketplace add JanSuthacheeva/claude-code-mods /plugin install plan-progress@claude-code-mods ``/plan-progress <file>.For development, link a clone of this repository instead:
ln -s "$PWD/claude-code-mods/plan-progress" ~/.claude/skills/plan-progress
| Part | Meaning |
|---|---|
| Header | Plan title (its first # heading, or the file name) and checked items out of all items |
| Now | The item Claude reported as doing, how long ago and its note. Blocked when stuck |
| Next | The open item after it. Without a report, the first open item |
| Groups | One row per heading with its count. Only the active group shows its items |
| Recent | The last reports besides the current item, one row per item, in its status color |
A blocked item keeps its ! until it is done, so you see what still needs you after Claude moves on.

When every box is ticked, the bar and the header turn to the theme's success color.

Items are numbered group.item (2.1), or just 1, 2, 3 in a plan with a single group. Claude uses these refs, and so can you when talking about the plan.
Claude Code docks a pane opened on its own only in a terminal of 144 columns or more. Below that, /plan-progress shows it. Closing the pane keeps it closed until you run /plan-progress again.
/plan-progress shows or hides the pane./plan-progress <file> tracks that plan in this project and shows the pane. An @ file mention works too./plan-progress stop stops tracking.Claude sees it as mcp__plan-progress__report:
| Field | Meaning |
|---|---|
plan | Path of the plan file. Only on the first call, or to switch plans |
item | A ref like 2.1, a group / item pair, or part of the item's text |
status | doing, done or blocked |
note | Optional, a few words on what is happening |
It answers in one line, such as ok, 4/9 done - after this: 2.3 Step 3: Commit. An item that matches several checkboxes is refused with the candidates listed, so no wrong box gets ticked. A heading as item covers all its checkboxes.
The checkboxes live in the plan file. The tracked plan, the current item and the recent reports live in the mod's store, keyed by the project folder.
claude plugin validate .
claude plugin test .hooks/register.tsx 409 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PlanView, Progress, ReportStatus } from '../types'
5import { resolveItem } from './matching'
6import { parseOutline, withTicked } from './outline'
7import { dockColumns, paneModel, terminalColumnsBesideDock } from './pane'
8import type { ItemState, PaneModel } from './pane'
9import {
10 absolutePath,
11 emptyProgress,
12 fileName,
13 linesToTick,
14 summaryLine,
15 targetRef,
16 targetText,
17 withReport,
18} from './progress'
19
20const paneId = 'plan-progress'
21const paneTitle = 'Plan'
22const commandName = 'plan-progress'
23const toolName = 'report'
24const pollIntervalMs = 2_000
25const clockIntervalMs = 30_000
26const terminalColumnsKey = 'terminalColumns'
27const statuses: readonly ReportStatus[] = ['doing', 'done', 'blocked']
28
29const emptyView: PlanView = { progress: null, outline: null, error: null }
30
31const view = atom({ plugin: 'plan-progress', key: 'view' } as const, emptyView)
32const isClosedByPerson = atom({ plugin: 'plan-progress', key: 'isClosedByPerson' } as const, false)
33const now = atom({ plugin: 'plan-progress', key: 'now' } as const, 0)
34
35const toolDescription = [
36 'Report progress on the markdown plan you are working through, shown to the user in the plan-progress pane.',
37 "Call with status 'doing' right before you start each checkbox item, 'done' as soon as it is finished, 'blocked' when you are stuck.",
38 "'done' ticks the checkbox in the plan file for you; do not edit checkboxes yourself.",
39 'Pass plan on the first call. The result says what is next.',
40].join(' ')
41
42const toolSchema = {
43 type: 'object',
44 properties: {
45 plan: { type: 'string', description: 'Path of the plan file; only needed on the first call or to switch plans' },
46 item: { type: 'string', description: "The item's ref from a previous result (e.g. 2.1), or part of its text" },
47 status: { type: 'string', enum: statuses },
48 note: { type: 'string', description: 'Optional, a few words on what is happening' },
49 },
50 required: ['item', 'status'],
51}
52
53let lastModifiedMs: number | null = null
54
55// Plan file and store
56
57async function storeKey($: EngineInterface): Promise<string> {
58 return `project:${await $.session.root()}`
59}
60
61async function saveProgress($: EngineInterface, progress: Progress | null): Promise<void> {
62 const key = await storeKey($)
63 if (progress === null) await $.store.delete(key)
64 else await $.store.set(key, progress)
65}
66
67async function storedProgress($: EngineInterface): Promise<Progress | null> {
68 const value = await $.store.get(await storeKey($))
69 return typeof value === 'object' && value !== null && 'plan' in value ? (value as Progress) : null
70}
71
72async function modifiedMs($: EngineInterface, path: string): Promise<number | null> {
73 try {
74 return (await $.fs.stat(path)).mtimeMs
75 } catch {
76 return null
77 }
78}
79
80async function showPlan($: EngineInterface, progress: Progress | null): Promise<void> {
81 if (progress === null) {
82 lastModifiedMs = null
83 await update($, view, () => emptyView)
84 return
85 }
86
87 lastModifiedMs = await modifiedMs($, progress.plan)
88 if (lastModifiedMs === null) {
89 await update($, view, () => ({ progress, outline: null, error: `Plan file not found: ${progress.plan}` }))
90 return
91 }
92 const outline = parseOutline(await $.fs.read(progress.plan))
93 await update($, view, () => ({ progress, outline, error: null }))
94}
95
96async function trackPlan($: EngineInterface, path: string): Promise<Progress> {
97 const plan = absolutePath(await $.session.cwd(), path)
98 const current = (await read($, view)).progress
99 const progress = current?.plan === plan ? current : emptyProgress(plan)
100 await saveProgress($, progress)
101 await showPlan($, progress)
102 return progress
103}
104
105async function pollPlan($: EngineInterface): Promise<void> {
106 const { progress } = await read($, view)
107 if (progress === null) return
108 const modified = await modifiedMs($, progress.plan)
109 if (modified !== lastModifiedMs) await showPlan($, progress)
110}
111
112// Pane
113
114async function isPaneOpen($: EngineInterface): Promise<boolean> {
115 return (await $.ui.panes()).some(pane => pane.id === paneId)
116}
117
118async function isPaneOnScreen($: EngineInterface): Promise<boolean> {
119 return (await $.ui.panes()).some(pane => pane.id === paneId && pane.isShown && pane.isPlaced)
120}
121
122async function storedTerminalColumns($: EngineInterface): Promise<number | null> {
123 const value = await $.store.get(terminalColumnsKey)
124 return typeof value === 'number' ? value : null
125}
126
127async function openPane($: EngineInterface): Promise<void> {
128 const terminalColumns = await storedTerminalColumns($)
129 const width = terminalColumns === null ? {} : { columns: dockColumns(terminalColumns) }
130 await $.ui.open({ id: paneId, title: paneTitle, ...width })
131}
132
133async function openPaneUnasked($: EngineInterface): Promise<void> {
134 if ((await read($, isClosedByPerson)) || (await isPaneOpen($))) return
135 await openPane($)
136}
137
138async function followTerminalWidth($: EngineInterface, columns: number): Promise<void> {
139 if ((await storedTerminalColumns($)) === columns) return
140 await $.store.set(terminalColumnsKey, columns)
141 if (await isPaneOpen($)) await openPane($)
142}
143
144// Tool
145
146function textField(value: unknown): string | null {
147 return typeof value === 'string' && value.trim() !== '' ? value.trim() : null
148}
149
150async function report($: EngineInterface, input: Record<string, unknown>): Promise<string> {
151 const plan = textField(input['plan'])
152 const item = textField(input['item'])
153 const status = statuses.find(one => one === input['status'])
154 if (item === null || status === undefined) throw new Error(`Pass item and a status of ${statuses.join(', ')}.`)
155
156 const progress = plan !== null ? await trackPlan($, plan) : (await read($, view)).progress
157 if (progress === null) throw new Error('No plan is tracked yet. Pass plan with the path of the plan file.')
158 if ((await modifiedMs($, progress.plan)) === null) throw new Error(`Plan file not found: ${progress.plan}`)
159
160 const markdown = await $.fs.read(progress.plan)
161 const resolution = resolveItem(parseOutline(markdown), item)
162 if ('error' in resolution) throw new Error(resolution.error)
163
164 const lines = status === 'done' ? linesToTick(resolution.target) : []
165 if (lines.length > 0) await $.fs.write(progress.plan, withTicked(markdown, lines))
166
167 const reported = withReport(progress, {
168 ref: targetRef(resolution.target),
169 text: targetText(resolution.target),
170 status,
171 note: textField(input['note']),
172 at: await $.clock.now(),
173 })
174 await update($, now, () => reported.recent[0]?.at ?? 0)
175 await saveProgress($, reported)
176 await showPlan($, reported)
177 await openPaneUnasked($)
178
179 const { outline } = await read($, view)
180 return outline === null ? 'ok' : summaryLine(outline, reported.recent[0] ?? null)
181}
182
183// Drawing
184
185const icons: Record<ItemState, { glyph: string; color: string }> = {
186 done: { glyph: '✓', color: 'success' },
187 active: { glyph: '▸', color: 'claude' },
188 blocked: { glyph: '!', color: 'warning' },
189 open: { glyph: '○', color: 'text' },
190}
191
192const panePadding = 1
193
194const statusColors: Record<ReportStatus, string> = { doing: 'claude', done: 'success', blocked: 'warning' }
195
196// Hooks
197
198export const register: Register = on => {
199 on('session.start', async ($, e, next) => {
200 await $.command.register({
201 name: commandName,
202 description: 'Toggle the plan progress pane, or track a plan: /plan-progress <file>, /plan-progress stop',
203 argumentHint: '[file | stop]',
204 })
205 await $.tool.register({ name: toolName, description: toolDescription, inputSchema: toolSchema, isDeferred: false })
206
207 await update($, now, () => 0)
208 await showPlan($, await storedProgress($))
209 $.clock.every(pollIntervalMs, () => void pollPlan($).catch(() => undefined))
210 $.clock.every(clockIntervalMs, () => void $.clock.now().then(time => update($, now, () => time)))
211 void $.clock.now().then(time => update($, now, () => time))
212 if ((await read($, view)).progress !== null && e.isInteractive) void openPaneUnasked($)
213
214 return next(e)
215 })
216
217 on('tool.call', { tool: 'mcp__plan-progress__report' }, async ($, e) => {
218 try {
219 return { result: await report($, e as Record<string, unknown>) }
220 } catch (error) {
221 return { deny: error instanceof Error ? error.message : String(error) }
222 }
223 }).catch(() => ({ deny: 'plan-progress could not record the report.' }))
224
225 on('command.run', { command: commandName }, async ($, e) => {
226 const argument = e.args.trim()
227 await $.store.set(terminalColumnsKey, e.presentation.columns)
228
229 if (argument === 'stop') {
230 await saveProgress($, null)
231 await showPlan($, null)
232 await $.ui.close({ id: paneId })
233 return { text: 'Stopped tracking the plan.' }
234 }
235
236 if (argument !== '') {
237 const progress = await trackPlan($, argument)
238 await update($, isClosedByPerson, () => false)
239 await openPane($)
240 return { text: `Tracking ${fileName(progress.plan)}.` }
241 }
242
243 if (await isPaneOnScreen($)) {
244 await $.ui.close({ id: paneId })
245 return { text: 'Plan progress hidden.' }
246 }
247 await update($, isClosedByPerson, () => false)
248 await openPane($)
249 return { text: 'Plan progress shown.' }
250 }).catch(($, e, next) => next(e))
251
252 on('ui.close', { id: paneId }, async ($, e, next) => {
253 if (e.origin.kind === 'person') await update($, isClosedByPerson, () => true)
254 return next(e)
255 }).catch(($, e, next) => next(e))
256
257 on('ui.render', { component: 'Pane', requestId: paneId }, async ($, e) => {
258 const { Box, Text } = $.ui.resolve(e)
259 if (e.surface === 'terminal' && e.props.placement === 'dock' && e.viewport !== undefined) {
260 void followTerminalWidth($, terminalColumnsBesideDock(e.viewport.columns, e.props.bodyColumns)).catch(() => undefined)
261 }
262 const { progress, outline, error } = await read($, view)
263 const time = await read($, now)
264
265 if (progress === null) {
266 return (
267 <Box flexDirection="column" gap={1} paddingX={panePadding}>
268 <Text weight="bold">No plan tracked in this project</Text>
269 <Text>
270 Claude starts tracking when it works through a plan with checkboxes, or run /plan-progress <file>.
271 </Text>
272 </Box>
273 )
274 }
275
276 if (error !== null || outline === null) {
277 return (
278 <Box flexDirection="column" gap={1} paddingX={panePadding}>
279 <Text color="warning">{error ?? 'Reading the plan...'}</Text>
280 <Text>Run /plan-progress <file> to track another plan, or /plan-progress stop.</Text>
281 </Box>
282 )
283 }
284
285 const model: PaneModel = paneModel(progress.plan, outline, progress, time, e.props.bodyColumns - 2 * panePadding)
286
287 if (model.total === 0) {
288 return (
289 <Box flexDirection="column" gap={1} paddingX={panePadding}>
290 <Text weight="bold" wrap="truncate-end">
291 {model.title}
292 </Text>
293 <Text>No checkbox items in {fileName(progress.plan)}. Add "- [ ]" items to track progress.</Text>
294 </Box>
295 )
296 }
297
298 const statusColumnWidth = Math.max(0, ...model.recent.map(row => row.status.length)) + 1
299 const barColor = model.isComplete ? 'success' : 'claude'
300 const focusColor = model.focus?.label === 'Blocked' ? 'warning' : model.focus?.label === 'Now' ? 'claude' : 'subtle'
301
302 return (
303 <Box flexDirection="column" paddingX={panePadding}>
304 <Box gap={2}>
305 <Box flexGrow={1} flexShrink={1}>
306 <Text weight="bold" wrap="truncate-end">
307 {model.title}
308 </Text>
309 </Box>
310 {model.isComplete ? (
311 <Text color="success">complete</Text>
312 ) : (
313 <Text dimColor>
314 {model.done}/{model.total}
315 </Text>
316 )}
317 </Box>
318 <Text wrap="truncate-end">
319 <Text color={barColor}>{'━'.repeat(model.bar.filled)}</Text>
320 <Text color="inactive">{'━'.repeat(model.bar.empty)}</Text>
321 </Text>
322
323 {model.focus !== null && (
324 <Box flexDirection="column" marginTop={1}>
325 <Box gap={1}>
326 <Box width={7} flexShrink={0}>
327 <Text color={focusColor} weight="bold">
328 {model.focus.label}
329 </Text>
330 </Box>
331 <Box flexGrow={1} flexShrink={1}>
332 <Text wrap="truncate-end">
333 <Text dimColor>{model.focus.ref}</Text> {model.focus.text}
334 </Text>
335 </Box>
336 {model.focus.age !== null && <Text dimColor>{model.focus.age}</Text>}
337 </Box>
338 {model.focus.note !== null && (
339 <Box gap={1}>
340 <Box width={7} flexShrink={0} />
341 <Text dimColor wrap="truncate-end">
342 {model.focus.note}
343 </Text>
344 </Box>
345 )}
346 {model.next !== null && (
347 <Box gap={1}>
348 <Box width={7} flexShrink={0}>
349 <Text weight="bold">Next</Text>
350 </Box>
351 <Text wrap="truncate-end">
352 <Text dimColor>{model.next.ref}</Text> {model.next.text}
353 </Text>
354 </Box>
355 )}
356 </Box>
357 )}
358
359 <Box flexDirection="column" marginTop={1}>
360 {model.groups.map(group => (
361 <Box key={group.ref} flexDirection="column">
362 {group.title !== null && (
363 <Box gap={1}>
364 <Text color={icons[group.state].color}>{icons[group.state].glyph}</Text>
365 <Box flexGrow={1} flexShrink={1}>
366 <Text wrap="truncate-end" weight={group.state === 'active' || group.state === 'blocked' ? 'bold' : 'regular'}>
367 {group.title}
368 </Text>
369 </Box>
370 <Text dimColor>
371 {group.done}/{group.total}
372 </Text>
373 </Box>
374 )}
375 {group.items?.map(item => (
376 <Box key={item.ref} gap={1} paddingLeft={(group.title === null ? 0 : 2) + item.depth * 2}>
377 <Text color={icons[item.state].color}>{icons[item.state].glyph}</Text>
378 <Text wrap="truncate-end">{item.text}</Text>
379 </Box>
380 ))}
381 </Box>
382 ))}
383 </Box>
384
385 {model.recent.length > 0 && (
386 <Box flexDirection="column" marginTop={1}>
387 <Text weight="bold">Recent</Text>
388 <Box flexDirection="column">
389 {model.recent.map(row => (
390 <Box key={row.key} gap={1}>
391 <Box width={statusColumnWidth} flexShrink={0}>
392 <Text color={statusColors[row.status]}>{row.status}</Text>
393 </Box>
394 <Box flexGrow={1} flexShrink={1}>
395 <Text wrap="truncate-end">
396 <Text dimColor>{row.ref}</Text> {row.text}
397 </Text>
398 </Box>
399 <Text dimColor>{row.age}</Text>
400 </Box>
401 ))}
402 </Box>
403 </Box>
404 )}
405 </Box>
406 )
407 })
408}
409hooks/matching.ts 93 lines1import type { Outline, PlanGroup, PlanItem } from '../types'
2import { allItems } from './outline'
3
4export type Target = { kind: 'item'; item: PlanItem } | { kind: 'group'; group: PlanGroup }
5
6export type Resolution = { target: Target } | { error: string }
7
8const refPattern = /^(?:t|#)?\s*(\d+(?:\.\d+)?)$/i
9const maxCandidates = 5
10
11function lower(text: string): string {
12 return text.toLowerCase()
13}
14
15function byRef(outline: Outline, ref: string): Target | null {
16 const item = allItems(outline).find(one => one.ref === ref)
17 if (item !== undefined) return { kind: 'item', item }
18 const group = outline.groups.find(one => one.ref === ref)
19 return group === undefined ? null : { kind: 'group', group }
20}
21
22function describe(target: Target): string {
23 return target.kind === 'item'
24 ? `${target.item.ref} ${target.item.text}`
25 : `${target.group.ref} ${target.group.title ?? ''}`.trim()
26}
27
28function pick(candidates: Target[], query: string): Resolution | null {
29 if (candidates.length === 0) return null
30 const [only] = candidates
31 if (candidates.length === 1 && only !== undefined) return { target: only }
32
33 const listed = candidates.slice(0, maxCandidates).map(describe).join('; ')
34 const more = candidates.length > maxCandidates ? ` and ${String(candidates.length - maxCandidates)} more` : ''
35 return { error: `"${query}" matches several items: ${listed}${more}. Pass the ref.` }
36}
37
38function itemTargets(items: PlanItem[]): Target[] {
39 return items.map(item => ({ kind: 'item', item }))
40}
41
42function groupTargets(groups: PlanGroup[]): Target[] {
43 return groups.map(group => ({ kind: 'group', group }))
44}
45
46function byGroupAndItem(outline: Outline, query: string): Resolution | null {
47 const [groupPart, itemPart] = query.split(' / ').map(part => lower(part.trim()))
48 if (groupPart === undefined || itemPart === undefined || itemPart === '') return null
49
50 const items = outline.groups
51 .filter(group => group.ref === groupPart.replace(refPattern, '$1') || lower(group.title ?? '').includes(groupPart))
52 .flatMap(group => group.items)
53 .filter(item => lower(item.text).includes(itemPart))
54 return pick(itemTargets(items), query)
55}
56
57function byText(outline: Outline, query: string): Resolution | null {
58 const wanted = lower(query)
59 const items = allItems(outline)
60 const titled = outline.groups.filter(group => group.title !== null)
61 const tiers: ((text: string) => boolean)[] = [
62 text => text === wanted,
63 text => text.startsWith(wanted),
64 text => text.includes(wanted),
65 ]
66
67 for (const matches of tiers) {
68 const found = pick(
69 [
70 ...itemTargets(items.filter(item => matches(lower(item.text)))),
71 ...groupTargets(titled.filter(group => matches(lower(group.title ?? '')))),
72 ],
73 query,
74 )
75 if (found !== null) return found
76 }
77 return null
78}
79
80export function resolveItem(outline: Outline, query: string): Resolution {
81 const trimmed = query.trim()
82 const ref = refPattern.exec(trimmed)?.[1]
83 const target = ref === undefined ? null : byRef(outline, ref)
84 if (target !== null) return { target }
85
86 return (
87 byGroupAndItem(outline, trimmed) ??
88 byText(outline, trimmed) ?? {
89 error: `No item matches "${trimmed}". Pass a ref like 1.2 or part of the item's text.`,
90 }
91 )
92}
93hooks/outline.ts 91 lines1import type { Outline, PlanGroup, PlanItem } from '../types'
2
3const headingPattern = /^(#{1,6})\s+(.*?)\s*#*\s*$/
4const checkboxPattern = /^(\s*)[-*+]\s+\[([ xX])\]\s+(.*)$/
5const fencePattern = /^\s*(```|~~~)/
6const indentWidth = 2
7
8export function plainText(markdown: string): string {
9 return markdown
10 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
11 .replace(/(\*\*|__)(.*?)\1/g, '$2')
12 .replace(/`([^`]*)`/g, '$1')
13 .trim()
14}
15
16function indentOf(whitespace: string): number {
17 return whitespace.replace(/\t/g, ' ').length
18}
19
20export function parseOutline(markdown: string): Outline {
21 const lines = markdown.split(/\r?\n/)
22 const groups: { title: string | null; items: Omit<PlanItem, 'ref'>[] }[] = [{ title: null, items: [] }]
23 let title: string | null = null
24 let isInFence = false
25
26 lines.forEach((line, index) => {
27 if (fencePattern.test(line)) {
28 isInFence = !isInFence
29 return
30 }
31 if (isInFence) return
32
33 const heading = headingPattern.exec(line)
34 if (heading !== null) {
35 const text = plainText(heading[2] ?? '')
36 if (heading[1] === '#' && title === null) title = text
37 else groups.push({ title: text, items: [] })
38 return
39 }
40
41 const checkbox = checkboxPattern.exec(line)
42 if (checkbox === null) return
43 groups[groups.length - 1]?.items.push({
44 text: plainText(checkbox[3] ?? ''),
45 line: index,
46 depth: Math.floor(indentOf(checkbox[1] ?? '') / indentWidth),
47 isDone: checkbox[2] !== ' ',
48 })
49 })
50
51 const filled = groups.filter(group => group.items.length > 0)
52 const isFlat = filled.length === 1
53
54 return {
55 title,
56 groups: filled.map((group, groupIndex): PlanGroup => {
57 const groupRef = String(groupIndex + 1)
58 return {
59 ref: groupRef,
60 title: group.title,
61 items: group.items.map((item, itemIndex) => ({
62 ...item,
63 ref: isFlat ? String(itemIndex + 1) : `${groupRef}.${String(itemIndex + 1)}`,
64 })),
65 }
66 }),
67 }
68}
69
70export function allItems(outline: Outline): PlanItem[] {
71 return outline.groups.flatMap(group => group.items)
72}
73
74export function counts(outline: Outline): { done: number; total: number } {
75 const items = allItems(outline)
76 return { done: items.filter(item => item.isDone).length, total: items.length }
77}
78
79export function firstOpenItem(outline: Outline): PlanItem | null {
80 return allItems(outline).find(item => !item.isDone) ?? null
81}
82
83export function withTicked(markdown: string, lines: readonly number[]): string {
84 const targets = new Set(lines)
85 const newline = markdown.includes('\r\n') ? '\r\n' : '\n'
86 return markdown
87 .split(/\r?\n/)
88 .map((line, index) => (targets.has(index) ? line.replace(/\[ \]/, '[x]') : line))
89 .join(newline)
90}
91hooks/pane.ts 176 lines1import type { Outline, PlanGroup, PlanItem, Progress, Report, ReportStatus } from '../types'
2import { allItems, counts } from './outline'
3import { fileName, nextItem, recentRows } from './progress'
4
5export type ItemState = 'done' | 'active' | 'blocked' | 'open'
6
7export interface ItemRow {
8 ref: string
9 text: string
10 depth: number
11 state: ItemState
12}
13
14export interface GroupRow {
15 ref: string
16 title: string | null
17 done: number
18 total: number
19 state: ItemState
20 items: ItemRow[] | null
21}
22
23export interface Focus {
24 label: 'Now' | 'Blocked' | 'Next'
25 ref: string
26 text: string
27 note: string | null
28 age: string | null
29}
30
31export interface RecentRow {
32 key: string
33 age: string
34 status: ReportStatus
35 ref: string
36 text: string
37}
38
39export interface PaneModel {
40 title: string
41 done: number
42 total: number
43 isComplete: boolean
44 bar: { filled: number; empty: number }
45 focus: Focus | null
46 next: PlanItem | null
47 groups: GroupRow[]
48 recent: RecentRow[]
49}
50
51const dockShare = 1 / 3
52const dockBorderColumns = 1
53
54export function dockColumns(terminalColumns: number): number {
55 return Math.max(1, Math.round(terminalColumns * dockShare) - dockBorderColumns)
56}
57
58export function terminalColumnsBesideDock(transcriptColumns: number, bodyColumns: number): number {
59 return transcriptColumns + dockBorderColumns + bodyColumns
60}
61
62const minuteMs = 60_000
63const hourMs = 60 * minuteMs
64const dayMs = 24 * hourMs
65
66export function relativeAge(at: number, now: number): string {
67 const elapsed = Math.max(0, now - at)
68 if (elapsed < minuteMs) return 'now'
69 if (elapsed < hourMs) return `${String(Math.floor(elapsed / minuteMs))}m`
70 if (elapsed < dayMs) return `${String(Math.floor(elapsed / hourMs))}h`
71 return `${String(Math.floor(elapsed / dayMs))}d`
72}
73
74function isInGroup(group: PlanGroup, ref: string): boolean {
75 return group.ref === ref || group.items.some(item => item.ref === ref)
76}
77
78function liveCurrent(outline: Outline, current: Report | null): Report | null {
79 if (current === null) return null
80 const item = allItems(outline).find(one => one.ref === current.ref)
81 if (item !== undefined) return item.isDone ? null : current
82 const group = outline.groups.find(one => one.ref === current.ref)
83 return group !== undefined && group.items.some(one => !one.isDone) ? current : null
84}
85
86function isSameReport(report: Report, other: Report | null): boolean {
87 return other !== null && report.ref === other.ref && report.at === other.at
88}
89
90function itemState(item: PlanItem, current: Report | null, blockedRefs: Set<string>): ItemState {
91 if (item.isDone) return 'done'
92 if (current?.ref === item.ref) return current.status === 'blocked' ? 'blocked' : 'active'
93 return blockedRefs.has(item.ref) ? 'blocked' : 'open'
94}
95
96function groupState(group: PlanGroup, isActive: boolean, current: Report | null, blockedRefs: Set<string>): ItemState {
97 if (group.items.every(item => item.isDone)) return 'done'
98 const isCurrentHere = current !== null && isInGroup(group, current.ref)
99 if (isActive && isCurrentHere) return current.status === 'blocked' ? 'blocked' : 'active'
100 const hasBlocked = blockedRefs.has(group.ref) || group.items.some(item => !item.isDone && blockedRefs.has(item.ref))
101 if (hasBlocked) return 'blocked'
102 return isActive ? 'active' : 'open'
103}
104
105export function paneModel(
106 plan: string,
107 outline: Outline,
108 progress: Progress,
109 now: number,
110 bodyColumns: number,
111): PaneModel {
112 const { done, total } = counts(outline)
113 const current = liveCurrent(outline, progress.current)
114 const items = allItems(outline)
115 const firstOpen = items.find(item => !item.isDone) ?? null
116
117 const activeGroup =
118 (current === null ? undefined : outline.groups.find(group => isInGroup(group, current.ref))) ??
119 outline.groups.find(group => group.items.some(item => !item.isDone))
120 const isFlat = outline.groups.length === 1
121 const blockedRefs = new Set(progress.recent.filter(report => report.status === 'blocked').map(report => report.ref))
122
123 const groups = outline.groups.map(group => {
124 const isActive = group === activeGroup
125 return {
126 ref: group.ref,
127 title: group.title,
128 done: group.items.filter(item => item.isDone).length,
129 total: group.items.length,
130 state: groupState(group, isActive, current, blockedRefs),
131 items:
132 isActive || isFlat
133 ? group.items.map(item => ({ ...item, state: itemState(item, current, blockedRefs) }))
134 : null,
135 }
136 })
137
138 const next = current === null ? null : nextItem(outline, current)
139 const focus: Focus | null =
140 current !== null
141 ? {
142 label: current.status === 'blocked' ? 'Blocked' : 'Now',
143 ref: current.ref,
144 text: current.text,
145 note: current.note,
146 age: relativeAge(current.at, now),
147 }
148 : firstOpen !== null
149 ? { label: 'Next', ref: firstOpen.ref, text: firstOpen.text, note: null, age: null }
150 : null
151
152 const width = Math.max(1, bodyColumns)
153 const filled = total === 0 ? 0 : Math.round((done / total) * width)
154
155 return {
156 title: outline.title ?? fileName(plan).replace(/\.md$/i, ''),
157 done,
158 total,
159 isComplete: total > 0 && done === total,
160 bar: { filled, empty: width - filled },
161 focus,
162 next,
163 groups,
164 recent: progress.recent
165 .filter(report => !isSameReport(report, current))
166 .slice(0, recentRows)
167 .map(report => ({
168 key: `${report.ref}-${String(report.at)}`,
169 age: relativeAge(report.at, now),
170 status: report.status,
171 ref: report.ref,
172 text: report.text,
173 })),
174 }
175}
176hooks/progress.ts 66 lines1import type { Outline, PlanItem, Progress, Report } from '../types'
2import type { Target } from './matching'
3import { allItems, counts } from './outline'
4
5export const recentRows = 3
6const recentLimit = recentRows + 1
7
8export function emptyProgress(plan: string): Progress {
9 return { plan, current: null, recent: [] }
10}
11
12export function absolutePath(cwd: string, path: string): string {
13 const trimmed = path.trim().replace(/^@/, '')
14 if (trimmed.startsWith('/') || /^[A-Za-z]:[\\/]/.test(trimmed)) return trimmed
15 return `${cwd.replace(/[\\/]$/, '')}/${trimmed.replace(/^\.\//, '')}`
16}
17
18export function fileName(path: string): string {
19 return path.split(/[\\/]/).pop() ?? path
20}
21
22export function targetRef(target: Target): string {
23 return target.kind === 'item' ? target.item.ref : target.group.ref
24}
25
26export function targetText(target: Target): string {
27 return target.kind === 'item' ? target.item.text : (target.group.title ?? `Group ${target.group.ref}`)
28}
29
30export function linesToTick(target: Target): number[] {
31 const items = target.kind === 'item' ? [target.item] : target.group.items
32 return items.filter(item => !item.isDone).map(item => item.line)
33}
34
35export function withReport(progress: Progress, report: Report): Progress {
36 const isCurrentFinished = report.status === 'done' && progress.current?.ref === report.ref
37 const current = report.status === 'done' ? (isCurrentFinished ? null : progress.current) : report
38 const recent = [report, ...progress.recent.filter(one => one.ref !== report.ref)].slice(0, recentLimit)
39 return { ...progress, current, recent }
40}
41
42function isCoveredBy(item: PlanItem, ref: string): boolean {
43 return item.ref === ref || item.ref.startsWith(`${ref}.`)
44}
45
46export function nextItem(outline: Outline, after: Report | null): PlanItem | null {
47 const items = allItems(outline)
48 const isOpen = (item: PlanItem): boolean => !item.isDone && (after === null || !isCoveredBy(item, after.ref))
49 const anchor = after === null ? -1 : items.findLastIndex(item => isCoveredBy(item, after.ref))
50 return items.slice(anchor + 1).find(isOpen) ?? items.slice(0, anchor + 1).find(isOpen) ?? null
51}
52
53export function summaryLine(outline: Outline, current: Report | null): string {
54 const { done, total } = counts(outline)
55 if (total === 0) return 'ok, but the plan has no checkbox items yet'
56
57 const isWorking = current !== null && current.status !== 'done'
58 const next = nextItem(outline, current)
59 const label = isWorking ? 'after this' : 'next'
60 if (next !== null) return `ok, ${String(done)}/${String(total)} done - ${label}: ${next.ref} ${next.text}`
61 if (done === total) return `ok, all ${String(total)} done - plan complete`
62 return isWorking
63 ? `ok, ${String(done)}/${String(total)} done - this is the last open item`
64 : `ok, ${String(done)}/${String(total)} done`
65}
66types/index.d.ts 47 lines1export interface PlanItem {
2 ref: string
3 text: string
4 line: number
5 depth: number
6 isDone: boolean
7}
8
9export interface PlanGroup {
10 ref: string
11 title: string | null
12 items: PlanItem[]
13}
14
15export interface Outline {
16 title: string | null
17 groups: PlanGroup[]
18}
19
20export type ReportStatus = 'doing' | 'done' | 'blocked'
21
22export interface Report {
23 ref: string
24 text: string
25 status: ReportStatus
26 note: string | null
27 at: number
28}
29
30export interface Progress {
31 plan: string
32 current: Report | null
33 recent: Report[]
34}
35
36export interface PlanView {
37 progress: Progress | null
38 outline: Outline | null
39 error: string | null
40}
41
42declare module 'claude-code' {
43 interface PluginState {
44 'plan-progress': { view: PlanView; isClosedByPerson: boolean; now: number }
45 }
46}
47