Show, add and tick off items in the project's todo.md

A Claude Code mod that turns your project's todo.md into a live checklist pane. Add tasks, tick them off and track progress without leaving the session, while the file stays plain Markdown that you (and Claude) can edit by hand.
code and links/todo init creates an empty todo.md/todo <text> from the prompt, one task per line/todo review suggests what to start with, /todo tidy cleans up the file and asks before merging duplicates, /todo check ticks off tasks the project shows are already donetodo.mdWorks in the Claude Code desktop app (Code tab) and the terminal.
In Claude Code:
/plugin install todo-md --marketplace ElieM90/todo-md
Then start a new session. Installed plugins load when a session starts.
| Command | What it does |
|---|---|
/todo or /todo open | Open the Todos pane |
/todo init | Create an empty todo.md (just a # Todo heading) and open the pane. An existing file is left as is |
/todo <text> | Add a task to todo.md |
/todo + several lines | Add one task per line (Shift+Enter for new lines) |
/todo review | Claude reads todo.md and suggests the 1-3 tasks to start with, and flags unclear or oversized ones. It doesn't edit the file |
/todo tidy | Claude normalises the checkboxes, indentation and blank lines without changing any task, then lists likely duplicates and asks you which to keep |
/todo check | Claude looks through the code, tests and git history for each open task and ticks the ones that are clearly done, citing the evidence. Unverifiable or partly done tasks stay open and are listed |
In the pane:
The status line under the prompt shows ☰ 3 open · /todo open, ☰ ✓ all done, or a hint while the list is empty. Projects without a todo.md show nothing.
You can also just ask Claude, e.g. "mark the SSO task done in todo.md" or "what's left in todo.md?". The pane updates when the turn ends.
todo.md fileThe mod reads and writes todo.md in the session's working directory. /todo init creates it, or it's created on the first add. Any Markdown list line (-, *, + or 1.), nested or not, counts as a task:
# Sprint
- [ ] Review open pull requests
- [x] Archive last sprint board
- Schedule weekly sync ← no checkbox: treated as open
* [ ] **Bold**, `code` and [links](https://example.com) render in the pane
[x] or [X] means done; [ ] or no box means open.- [ ] , * or 1. prefixes are stripped when adding, so copying a list in just works.Tip: add todo.md to your project's .gitignore if the list is personal.
claude plugin marketplace update todo-md
claude plugin update todo-md@todo-md
Then start a new session.
.claude-plugin/ plugin.json (name, version) and marketplace.json
hooks/ register.tsx (/todo command, pane, status line, review/tidy/check prompts),
todos.ts (parsing and editing todo.md) and *.test.ts(x)
types/ state contract for the pane's values
Check and test from the repo root:
claude plugin validate .
claude plugin test .
To try changes live, copy the folder into a session's mods folder and enable hot reloading when Claude Code asks, or start a session with claude --plugin-dir <path to this folder>.
Bump version in .claude-plugin/plugin.json with every release. Installed copies only update when it changes.
0.4.0: /todo init creates an empty todo.md
0.3.0: /todo check ticks off tasks that are already done, with evidence
0.2.0
/todo review and /todo tidytodo.md+ and numbered lists count as tasks; lists inside code blocks are ignored0.1.1: task text renders as Markdown
0.1.0: first release
open, review, tidy or check can't be added with /todo; use the pane instead./todo review, tidy and check start a normal Claude turn, so they use your usage like any other prompt.hooks/register.tsx 208 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { append, clearDone, locate, parse, toggle } from './todos'
5
6const FILE = 'todo.md'
7const PANE = 'todo-md'
8const todos = atom({ plugin: 'todo-md', key: 'todos' } as const, [])
9
10// null: the project has no todo.md
11const readFile = ($: EngineInterface) => $.fs.read(FILE).then(t => t as string, () => null)
12const load = async ($: EngineInterface) => (await readFile($)) ?? ''
13
14const refresh = async ($: EngineInterface) => {
15 const md = await readFile($)
16 const list = parse(md ?? '')
17 await update($, todos, () => list)
18 const open = list.filter(t => !t.done).length
19 // no status line in projects without a todo.md: the plugin is installed for every project
20 await $.ui.status(md === null ? undefined : !list.length ? '☰ no todos · /todo <text>' : open ? `☰ ${open} open · /todo open` : '☰ ✓ all done')
21}
22
23const add = async ($: EngineInterface, text: string) => {
24 const md = await load($)
25 const next = append(md, text)
26 if (next === md) return
27 await $.fs.write(FILE, next)
28 await refresh($)
29}
30
31// the file may have changed since the pane was drawn: find the row by its text, not only its position
32const flip = async ($: EngineInterface, index: number, text: string) => {
33 const md = await load($)
34 const at = locate(parse(md), index, text)
35 if (at >= 0) await $.fs.write(FILE, toggle(md, at))
36 await refresh($)
37}
38
39const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Todos' })
40
41// starts an empty todo.md; an existing one is never overwritten
42const init = async ($: EngineInterface) => {
43 if ((await readFile($)) !== null) return false
44 await $.fs.write(FILE, '# Todo\n\n')
45 await refresh($)
46 return true
47}
48
49// /todo review, tidy and check hand the work to Claude as an ordinary prompt
50const ASK = {
51 review: `Review ${FILE} in the project root and tell me what to start with. Read it first, then:
52- Pick the 1-3 open tasks I should do first, each with a one-line reason (unblocks other tasks, quick win, urgency, risk).
53- Point out any task that is unclear or too big, and suggest how to split it.
54- Keep it short. Do not edit the file.`,
55 tidy: `Tidy up ${FILE} in the project root without losing any task. Read it first, then:
56- Make every task a consistent "- [ ] " or "- [x] " line, fix indentation, trim stray whitespace and collapse extra blank lines.
57- Keep each task's done/open state, its wording (fixing obvious typos and formatting only), the existing headings and the order.
58- Look for tasks that mean the same thing, even when worded differently. Do not remove any yourself: list each likely duplicate pair and ask me which one to keep (or whether to merge them), then apply my answer.
59- Finish with a short summary of what changed.`,
60 check: `Check which open tasks in ${FILE} (project root) are already done. Read it first, then for each open task look for evidence in this project: the code, tests, config, docs and recent git history.
61- Mark a task done ("[ ]" to "[x]") only when the evidence clearly shows it is complete, and cite that evidence in one line (file:line or commit).
62- Do not mark tasks that are partly done or that you can't verify; list them separately with what is still missing.
63- Change nothing else in the file: no rewording, reordering or removing.
64- Finish with a short summary: marked done, still open, unclear.`,
65}
66
67type Filter = 'all' | 'open' | 'done'
68const filter = atom({ plugin: 'todo-md', key: 'filter' } as const, 'all' as Filter)
69
70const clear = async ($: EngineInterface) => {
71 await $.fs.write(FILE, clearDone(await load($)))
72 await refresh($)
73}
74
75const draft = atom({ plugin: 'todo-md', key: 'draft' } as const, '')
76
77const submit = async ($: EngineInterface, text: string) => {
78 await add($, text)
79 await update($, draft, () => '')
80}
81
82// text follows the person's theme (light or dark); the mockup's orange is the one fixed colour
83const C = { accent: '#F0883E', text: 'text', soft: 'inactive', faint: 'subtle', border: 'subtle', track: '#8E8E9455' } as const
84
85// thin rounded progress bar; desktop only (the terminal gets a block-character bar)
86const bar = (pct: number) =>
87 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 8" preserveAspectRatio="none">`
88 + `<rect width="1000" height="8" rx="4" fill="${C.track}"/>`
89 + (pct ? `<rect width="${pct * 10}" height="8" rx="4" fill="${C.accent}"/>` : '') + `</svg>`
90const RULE = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1" preserveAspectRatio="none"><rect width="1000" height="1" fill="${C.track}"/></svg>`
91
92export const register: Register = on => {
93 on('session.start', async ($, e, next) => {
94 await $.command.register({ name: 'todo', description: 'todo.md: /todo open · /todo init · /todo <text> to add · /todo review · /todo tidy · /todo check' })
95 await refresh($)
96 return next(e)
97 })
98
99 // todo.md may be edited by hand or by Claude between turns
100 on('turn.complete', async ($, e, next) => {
101 await refresh($)
102 return next(e)
103 })
104
105 on('command.run', { command: 'todo' }, async ($, e) => {
106 const args = e.args.trim()
107 if (Object.hasOwn(ASK, args)) {
108 if ((await readFile($)) === null) return { text: `No ${FILE} in this project yet. Create one with /todo init or add a task with /todo <text>.` }
109 // a command hook can't queue a turn while it runs: submit just after it returns
110 $.clock.after(0, () => void $.prompt.submit({ text: ASK[args as keyof typeof ASK], asUser: true })
111 .catch(() => $.ui.toast(`Couldn't send /todo ${args} to Claude. Try again when the current turn ends.`)))
112 return { text: `Asking Claude to ${args} ${FILE}…` }
113 }
114 if (args === 'init') {
115 const created = await init($)
116 await openPane($)
117 return { text: created ? `Created ${FILE}.` : `${FILE} already exists; left it as is.` }
118 }
119 if (args && args !== 'open') {
120 await add($, e.args)
121 return { text: `Added to ${FILE}.` }
122 }
123 await refresh($)
124 await openPane($)
125 return { text: 'Todos pane opened.' }
126 })
127
128 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
129 const ui = $.ui.resolve(e)
130 const { Box, Text, Button } = ui
131 const list = await read($, todos)
132 const show = await read($, filter)
133 const rows = list.map((item, i) => ({ ...item, i }))
134 const nDone = rows.filter(r => r.done).length
135 const nOpen = rows.length - nDone
136 const pct = rows.length ? Math.round((nDone / rows.length) * 100) : 0
137 const shown = rows.filter(r => show === 'all' || (show === 'done') === r.done)
138
139 const tab = (id: Filter, label: string, n: number) => (
140 <Button key={`tab-${id}`} {...(show === id ? { variant: 'secondary' as const } : { plain: true as const })}
141 label={`${label} ${n}`} onPress={() => update($, filter, () => id)} />
142 )
143
144 const text = await read($, draft)
145
146 return (
147 <Box flexDirection="column" paddingX={1} gap={1}>
148 <Box>
149 <Box borderStyle="round" borderColor={C.border} paddingX={1}>
150 <Text color={C.soft}>🗎 {FILE}</Text>
151 </Box>
152 </Box>
153
154 <Box flexDirection="column">
155 <Box justifyContent="space-between">
156 <Text color={C.soft}><Text color={C.text} bold>{nOpen}</Text> open · <Text color={C.text} bold>{nDone}</Text> done</Text>
157 <Text color={C.soft}>{pct}%</Text>
158 </Box>
159 {'Svg' in ui
160 ? <ui.Svg key="bar" source={bar(pct)} alt={`${pct}% done`} height={6} />
161 : <Text><Text color={C.accent}>{'█'.repeat(Math.round(pct / 5))}</Text><Text color={C.faint}>{'░'.repeat(20 - Math.round(pct / 5))}</Text></Text>}
162 </Box>
163
164 {'Input' in ui && (
165 <Box borderStyle="round" borderColor={C.border} paddingX={1} gap={1} alignItems="center">
166 <Text color={C.faint}>+</Text>
167 <Box flexGrow={1}>
168 <ui.Input key="new" placeholder="Add a task…" submitLabel="↵" value={text} autoFocus
169 onInput={(v: string) => update($, draft, () => v)} onSubmit={(v: string) => submit($, v)} />
170 </Box>
171 <Button key="add" variant="primary" label="Add" onPress={() => submit($, text)} />
172 </Box>
173 )}
174
175 <Box gap={1}>
176 {tab('all', 'All', rows.length)}
177 {tab('open', 'Open', nOpen)}
178 {tab('done', 'Done', nDone)}
179 </Box>
180
181 <Box flexDirection="column" gap={1}>
182 {shown.length === 0 && (
183 <Box flexDirection="column" alignItems="center" paddingY={2}>
184 <Text color={C.text}>Nothing here</Text>
185 <Text color={C.soft}>{show === 'done' ? 'Completed tasks will show up here.' : 'Add a task above to get started.'}</Text>
186 </Box>
187 )}
188 {shown.map(r => (
189 <Box key={`row${r.i}`} gap={1}>
190 <Button key={`done${r.i}`} plain label={r.done ? '✔' : '○'} onPress={() => flip($, r.i, r.text)} />
191 <Box flexGrow={1} flexShrink={1}>
192 <ui.Markdown key={`md${r.i}`} text={r.done ? `~~${r.text}~~` : r.text} dimColor={r.done} />
193 </Box>
194 </Box>
195 ))}
196 </Box>
197
198 {'Svg' in ui && <ui.Svg key="rule" source={RULE} alt="" height={1} />}
199 {nDone > 0 && (
200 <Box justifyContent="flex-end">
201 <Button key="clear" variant="secondary" label="Clear completed" onPress={() => clear($)} />
202 </Box>
203 )}
204 </Box>
205 )
206 })
207}
208hooks/todos.ts 51 lines1import type { Todo } from '../types'
2
3// any list line (-, *, +, 1. or 1)) is a todo, nested ones included; headings and prose ignored
4const ITEM = /^(\s*(?:[-*+]|\d+[.)])\s+)(?:\[([ xX])\]\s*)?(.+)$/
5const FENCE = /^\s*(?:```|~~~)/
6
7const isDone = (m: RegExpExecArray) => m[2] === 'x' || m[2] === 'X'
8
9// walks the todo lines outside code fences, in order; fn returns a replacement line,
10// null to drop the line, or undefined to keep it. Line endings are preserved.
11const scan = (md: string, fn: (m: RegExpExecArray, n: number) => string | null | undefined): string => {
12 let fenced = false
13 let n = -1
14 return md.split(/(?<=\n)/).map(raw => {
15 const eol = /\r?\n$/.exec(raw)?.[0] ?? ''
16 const line = raw.slice(0, raw.length - eol.length)
17 if (FENCE.test(line)) {
18 fenced = !fenced
19 return raw
20 }
21 const m = fenced ? null : ITEM.exec(line)
22 if (!m?.[3]?.trim()) return raw
23 const out = fn(m, ++n)
24 return out === null ? '' : out === undefined ? raw : out + eol
25 }).join('')
26}
27
28export const parse = (md: string): Todo[] => {
29 const list: Todo[] = []
30 scan(md, m => void list.push({ text: m[3]!.trim(), done: isDone(m) }))
31 return list
32}
33
34// one todo per non-empty line; a pasted "- [ ] x" / "* x" / "1. x" keeps just its text
35export const append = (md: string, text: string): string => {
36 const items = text.split(/\r?\n/).map(l => l.replace(/^\s*(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s*)?/, '').trim()).filter(Boolean)
37 if (!items.length) return md
38 return `${md}${md === '' || md.endsWith('\n') ? '' : '\n'}${items.map(i => `- [ ] ${i}\n`).join('')}`
39}
40
41// flips the index-th todo between [ ] and [x]; other lines untouched
42export const toggle = (md: string, index: number): string =>
43 scan(md, (m, n) => n === index ? `${m[1]}[${isDone(m) ? ' ' : 'x'}] ${m[3]}` : undefined)
44
45// drops every done todo line; other lines untouched
46export const clearDone = (md: string): string => scan(md, m => isDone(m) ? null : undefined)
47
48// where a todo shown at `index` with `text` is now: the file may have changed since it was drawn
49export const locate = (list: Todo[], index: number, text: string): number =>
50 list[index]?.text === text ? index : list.findIndex(t => t.text === text)
51types/index.d.ts 8 lines1export type Todo = { text: string; done: boolean }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'todo-md': { todos: Todo[]; filter: 'all' | 'open' | 'done'; draft: string }
6 }
7}
8