SLOPSHOPPER

plan-progress

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

newpaneguardcommandtooltimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · plan-progress
│ ┃ Plan ✕ › fix the failing auth test and add an audit log call │ ┃ No plan tracked in this project │ ┃ ⏺ Read(src/auth.ts) │ ┃ Claude starts tracking when it works ⎿ Read 6 lines │ ┃ through a plan with checkboxes, or run ⏺ Update(src/auth.ts) │ ┃ /plan-progress <file>. ⎿ 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: Plan progress shown. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Plan
No plan tracked in this project Claude starts tracking when it works through a plan with checkboxes, or run /plan-progress <file>.
README

plan-progress

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.

Claude works through a plan while the pane shows the current item, the next one, the plan's tasks and the last reports

Features

  • Works with plain markdown plans: any - [ ] checkbox counts, grouped under its nearest heading. Superpowers plans (### Task N with step checkboxes) and flat task lists both work.
  • Claude reports as it goes: the mod gives Claude a small 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.
  • Survives sessions: the tracked plan is remembered per project, so after /clear, a restart or the next day the pane picks it up again.
  • Follows your edits: tick a box yourself and the pane updates within two seconds.
  • Follows your Claude Code theme: all colors come from the theme, and /theme recolors the pane right away.
  • Light on tokens: the tool schema is about 90 cached tokens per request, and a report costs about 55. Parsing, polling and drawing run in the mod and never reach the context.

Requirements

  • Claude Code with function-hook plugins (mods), tested on 2.1.293. The mod API is in early access and may change between releases.

Install

  1. In Claude Code, add the marketplace and install the mod: `` /plugin marketplace add JanSuthacheeva/claude-code-mods /plugin install plan-progress@claude-code-mods ``
  2. Restart Claude Code. The pane opens once Claude reports on a plan, or run /plan-progress <file>.

For development, link a clone of this repository instead:

ln -s "$PWD/claude-code-mods/plan-progress" ~/.claude/skills/plan-progress

The pane

PartMeaning
HeaderPlan title (its first # heading, or the file name) and checked items out of all items
NowThe item Claude reported as doing, how long ago and its note. Blocked when stuck
NextThe open item after it. Without a report, the first open item
GroupsOne row per heading with its count. Only the active group shows its items
RecentThe 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.

Claude is on 2.2 while 2.1 stays marked blocked and shows as blocked under Recent

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

A finished plan: all three tasks ticked and the header says complete

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.

Commands

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

The report tool

Claude sees it as mcp__plan-progress__report:

FieldMeaning
planPath of the plan file. Only on the first call, or to switch plans
itemA ref like 2.1, a group / item pair, or part of the item's text
statusdoing, done or blocked
noteOptional, 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.

Storage

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.

Development

claude plugin validate .
claude plugin test .
Source 6 files
hooks/register.tsx 409 lines
1import { 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 &lt;file&gt;.
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 &lt;file&gt; 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}
409
hooks/matching.ts 93 lines
1import 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}
93
hooks/outline.ts 91 lines
1import 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}
91
hooks/pane.ts 176 lines
1import 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}
176
hooks/progress.ts 66 lines
1import 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}
66
types/index.d.ts 47 lines
1export 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