A project todo list in .todo/*.md, its progress shown in one dim line under the prompt

A project todo list in .todo/*.md, its progress shown at the end of the dim hint line under the prompt.
? for shortcuts ☐ 3/10 · Adapt the tests
# Nuxt migration
- [x] Switch the router
Summary of what was done.
- [~] Adapt the tests
- [ ] Update the docs
| Mark | Means |
|---|---|
[ ] | open |
[~] | running |
[x] | done |
Only unindented items count; indented lines are the summary of the item above.
/todo opens the active list in a pane. Every action shows its key on its button.
| Key | Does |
|---|---|
1-9, Enter | select an item and show its summary, again to deselect |
x | check the selected item off, or reopen it |
s | mark the selected item as running |
a | add an item; more lines become its summary |
d | delete the selected item with its summary; press again to confirm |
p | show the projects, or back to the items |
↑ ↓, Enter on the title | edit the title; more lines are added to its summary |
↑ ↓, Enter on a summary line | edit that line; empty removes it, more lines are added below |
Esc | step back: an open field, a pending second press, the projects; else close |
p switches to the projects, every .todo/*.md with its progress:
| Key | Does |
|---|---|
1-9, Enter | make that list the active one and show its items |
n | start a new list from a title: Nuxt Migration becomes nuxt-migration.md |
c | move the active list to .todo/archive/; press again to confirm |
p, Esc | back to the items |
A change is written straight into the file, after reading it again, so an edit made meanwhile is kept; when the line moved, nothing is written and the pane says so. Nothing is overwritten: a new list or an archived one whose name exists already is refused.
The pane closes by itself after two minutes without a key press, never while a field is open. On mobile it shows the list, but has no fields to type in.
.todo/.active, and to propose a list in chat first, writing it only once you agreed.TodoWrite, TaskCreate, TaskUpdate, TaskGet, TaskList) are refused in the main conversation; subagents and teammates keep them..todo/*.md, broken item lines are reported back to it right away. A clean edit adds nothing..todo/.active (just the file name, e.g. nuxt.md). Without it, or when it names no list, the most recently changed .todo/*.md is used..todo/ out of git, e.g. once in ~/.config/git/ignore..todo folder nothing is shown under the prompt.mv (cmd /c move on Windows) in the project root, the one shell command the mod uses./plugin install clautodo --marketplace Flo0806/fh-claude-modshooks/register.tsx 403 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
3
4import type { Item, Mode, Project, Status, TodoList } from '../types'
5import { disable, formatNote, isTodoFile, pass } from './guards'
6import { moveCommands } from './move'
7import { drawPane } from './pane'
8import type { Actions } from './pane'
9import { RULES } from './rules'
10import {
11 addItem,
12 deleteItem,
13 editSummary,
14 fileName,
15 label,
16 parse,
17 renameItem,
18 setStatus,
19 summarize,
20} from './todo'
21
22// Everything that takes `$` and every state value lives in this file: the engine follows `$` and
23// reads state references only within the module's own file, never across an import.
24
25type Field = Exclude<Mode, 'view'>
26
27const list = atom({ plugin: 'clautodo', key: 'list' } as const, null)
28const selected = atom({ plugin: 'clautodo', key: 'selected' } as const, null)
29const mode = atom({ plugin: 'clautodo', key: 'mode' } as const, 'view')
30// Shown in the pane, where a toast would wait until the pane closes.
31const notice = atom({ plugin: 'clautodo', key: 'notice' } as const, null)
32const line = atom({ plugin: 'clautodo', key: 'line' } as const, null)
33const pending = atom({ plugin: 'clautodo', key: 'pending' } as const, null)
34const projects = atom({ plugin: 'clautodo', key: 'projects' } as const, null)
35
36const PANE = 'clautodo'
37
38const PANE_OPEN = { id: PANE, title: 'Todo', focus: true, closeOnEscape: true } as const
39
40const POLL_MS = 2000
41
42const IDLE_MS = 2 * 60 * 1000
43
44const IDLE_CHECK_MS = 15 * 1000
45
46const ACTIVE = '.active'
47
48const todoDir = async ($: EngineInterface) => `${await $.session.root()}/.todo`
49
50const isList = (entry: { kind: string; name: string }) =>
51 entry.kind === 'file' && entry.name.endsWith('.md')
52
53// The name in .active and the mtime it was read at, so a poll reads it again only after a change.
54let pointer = { mtimeMs: -1, name: '' }
55
56// The list named in .active, or the most recently changed one when that names none.
57const activeList = async ($: EngineInterface, dir: string) => {
58 const entries = await $.fs.list(dir).catch(() => [])
59 const lists = entries.filter(isList)
60 const marker = entries.find((entry) => entry.kind === 'file' && entry.name === ACTIVE)
61
62 if (marker && marker.mtimeMs !== pointer.mtimeMs) {
63 const name = String(await $.fs.read(`${dir}/${ACTIVE}`).catch(() => '')).trim()
64 pointer = { mtimeMs: marker.mtimeMs, name }
65 }
66 const named = marker && lists.find((entry) => entry.name === pointer.name)
67 return named ?? lists.toSorted((a, b) => b.mtimeMs - a.mtimeMs)[0]
68}
69
70// Name and mtime of the list last read, so a poll reads it again only after a change.
71let seen = ''
72
73const load = async ($: EngineInterface, force = false) => {
74 const dir = await todoDir($)
75 const entry = await activeList($, dir)
76 const stamp = entry ? `${entry.name}:${entry.mtimeMs}` : ''
77 if (stamp === seen && !force) return
78 seen = stamp
79
80 const path = entry && `${dir}/${entry.name}`
81 const text = path ? String(await $.fs.read(path).catch(() => '')) : ''
82 const latest: TodoList | null = path ? { path, ...parse(text) } : null
83 await update($, list, () => latest)
84}
85
86// An empty name hands the choice back to the newest list.
87const setActive = async ($: EngineInterface, name: string) => {
88 await $.fs.write(`${await todoDir($)}/${ACTIVE}`, name ? `${name}\n` : '')
89 pointer = { mtimeMs: -1, name }
90 await load($, true)
91}
92
93const readProjects = async ($: EngineInterface): Promise<Project[]> => {
94 const dir = await todoDir($)
95 const entries = await $.fs.list(dir).catch(() => [])
96 const names = entries
97 .filter(isList)
98 .map((entry) => entry.name)
99 .toSorted()
100
101 return Promise.all(
102 names.map(async (name) => {
103 const { title, items } = parse(String(await $.fs.read(`${dir}/${name}`).catch(() => '')))
104 const done = items.filter((item) => item.status === 'done').length
105 return { name, title, done, total: items.length }
106 }),
107 )
108}
109
110const checkFormat = async (
111 $: EngineInterface,
112 path: string,
113 ran: ToolCallResult,
114): Promise<ToolCallResult> => {
115 if (!isTodoFile(path) || ran.deny !== undefined || ran.isError) return ran
116
117 const note = formatNote(path, String(await $.fs.read(path).catch(() => '')))
118 return note ? { ...ran, context: [...(ran.context ?? []), note] } : ran
119}
120
121let lastActivity = 0
122
123// Every action counts as activity, and any action but the second press drops what waits for it.
124const touch = async ($: EngineInterface) => {
125 lastActivity = await $.clock.now()
126 await update($, pending, () => null)
127}
128
129// Never while a field is open, so typing is not cut off.
130const closeWhenIdle = async ($: EngineInterface) => {
131 const isOpen = (await $.ui.panes()).some((pane) => pane.id === PANE)
132 const isIdle = (await $.clock.now()) - lastActivity >= IDLE_MS
133 if (isOpen && isIdle && (await read($, mode)) === 'view') await $.ui.close({ id: PANE })
134}
135
136const openPane = async ($: EngineInterface) => {
137 await touch($)
138 await load($, true)
139 await update($, selected, () => null)
140 await update($, mode, () => 'view')
141 await update($, notice, () => null)
142 await update($, projects, () => null)
143 await $.ui.open(PANE_OPEN)
144}
145
146// Reads the file again before writing, so an edit made meanwhile is kept.
147const rewrite = async (
148 $: EngineInterface,
149 path: string,
150 change: (text: string) => string | null,
151) => {
152 await touch($)
153 const changed = change(String(await $.fs.read(path)))
154 if (changed !== null) await $.fs.write(path, changed)
155 await update($, notice, () =>
156 changed === null ? 'The list changed meanwhile, try again.' : null,
157 )
158 await load($, true)
159}
160
161// True on the second press of the same action, else marks it as waiting.
162const confirmed = async ($: EngineInterface, action: 'delete' | 'archive') => {
163 const isSecond = (await read($, pending)) === action
164 await touch($)
165 if (!isSecond) await update($, pending, () => action)
166 return isSecond
167}
168
169const select = async ($: EngineInterface, index: number) => {
170 await touch($)
171 await update($, notice, () => null)
172 await update($, selected, (current) => (current === index ? null : index))
173}
174
175const remove = async ($: EngineInterface, path: string, item: Item) => {
176 if (!(await confirmed($, 'delete'))) return
177 await rewrite($, path, (text) => deleteItem(text, item))
178 await update($, selected, () => null)
179}
180
181// The field handlers below close their field; an empty or unchanged entry changes nothing.
182
183// The new item is the last one; the list is read again, as it may have changed since it was drawn.
184const add = async ($: EngineInterface, path: string, entry: string) => {
185 await touch($)
186 if (entry.trim() !== '') {
187 await rewrite($, path, (text) => addItem(text, entry))
188 const count = (await read($, list))?.items.length ?? 0
189 if (count > 0) await update($, selected, () => count - 1)
190 }
191 await update($, mode, () => 'view')
192}
193
194const rename = async ($: EngineInterface, path: string, item: Item, entry: string) => {
195 await touch($)
196 if (entry.trim() !== '' && entry.trim() !== item.title) {
197 await rewrite($, path, (text) => renameItem(text, item, entry))
198 }
199 await update($, mode, () => 'view')
200}
201
202// Unlike a title, an emptied summary line is removed.
203const changeLine = async (
204 $: EngineInterface,
205 path: string,
206 item: Item,
207 index: number,
208 entry: string,
209) => {
210 await touch($)
211 if (entry.trim() !== item.summary[index]) {
212 await rewrite($, path, (text) => editSummary(text, item, index, entry))
213 }
214 await update($, mode, () => 'view')
215}
216
217const create = async ($: EngineInterface, title: string) => {
218 await touch($)
219 await update($, mode, () => 'view')
220 const trimmed = title.trim()
221 if (trimmed === '') return
222
223 const name = fileName(trimmed)
224 const path = `${await todoDir($)}/${name}`
225 if (await $.fs.exists(path)) {
226 await update($, notice, () => `${name} exists already.`)
227 return
228 }
229 await $.fs.write(path, `# ${trimmed}\n\n`)
230 await activate($, name)
231}
232
233const activate = async ($: EngineInterface, name: string) => {
234 await touch($)
235 await update($, projects, () => null)
236 await update($, selected, () => null)
237 await setActive($, name)
238}
239
240// Moves the active list to .todo/archive/; the newest list left becomes the active one.
241const archive = async ($: EngineInterface, name: string) => {
242 if (!(await confirmed($, 'archive'))) return
243
244 const root = await $.session.root()
245 const target = `.todo/archive/${name}`
246 if (await $.fs.exists(`${root}/${target}`)) {
247 await update($, notice, () => `${target} exists already.`)
248 return
249 }
250
251 for (const argv of moveCommands(root, `.todo/${name}`, target, '.todo/archive')) {
252 const { exitCode, stderr } = await $.process.run(argv, { cwd: root })
253 if (exitCode !== 0) {
254 await update($, notice, () => `Could not archive ${name}: ${stderr.trim() || exitCode}`)
255 return
256 }
257 }
258
259 await setActive($, '')
260 const left = await readProjects($)
261 await update($, projects, () => left)
262}
263
264const toggleProjects = async ($: EngineInterface) => {
265 await touch($)
266 await update($, notice, () => null)
267 const isOpen = (await read($, projects)) !== null
268 const next = isOpen ? null : await readProjects($)
269 await update($, projects, () => next)
270}
271
272const FIELD: Record<Field, string> = {
273 add: 'new-item',
274 edit: 'edit-item',
275 line: 'summary-line',
276 project: 'new-project',
277}
278
279const openField = async ($: EngineInterface, field: Field) => {
280 await touch($)
281 await update($, mode, () => field)
282 // Moving the ring is a convenience: without it the field still takes a click or Tab.
283 await $.ui.focus({ requestId: PANE, key: FIELD[field] }).catch(() => undefined)
284}
285
286const openLine = async ($: EngineInterface, index: number) => {
287 await update($, line, () => index)
288 await openField($, 'line')
289}
290
291// The element a cancelled field hands the ring back to.
292const returnTo = async ($: EngineInterface, field: Field) => {
293 if (field === 'line') return `summary-${await read($, line)}`
294 if (field === 'project') return 'new'
295 const index = await read($, selected)
296 const item = index === null ? undefined : (await read($, list))?.items[index]
297 return field === 'edit' && item ? 'title' : 'add'
298}
299
300// Escape handed the keys back to the prompt: opening the pane again takes them back.
301const refocus = async ($: EngineInterface, key: string) => {
302 await $.ui.open(PANE_OPEN)
303 await $.ui.focus({ requestId: PANE, key }).catch(() => undefined)
304}
305
306// Escape leaves an open field, then a pending second press, then the projects; false when there is
307// nothing left to leave and the pane should close.
308const stepBack = async ($: EngineInterface) => {
309 const field = await read($, mode)
310 const waiting = await read($, pending)
311
312 if (field !== 'view') {
313 await touch($)
314 await update($, mode, () => 'view')
315 await refocus($, await returnTo($, field))
316 } else if (waiting !== null) {
317 await touch($)
318 await refocus($, waiting)
319 } else if ((await read($, projects)) !== null) {
320 await toggleProjects($)
321 await refocus($, 'projects')
322 } else {
323 return false
324 }
325 return true
326}
327
328// The pane's buttons and fields bound to this session; `path` is the drawn list's.
329const actionsFor = ($: EngineInterface, path: string): Actions => ({
330 select: (index) => select($, index),
331 changeStatus: (item: Item, status: Status) =>
332 rewrite($, path, (text) => setStatus(text, item, status)),
333 remove: (item) => remove($, path, item),
334 add: (entry) => add($, path, entry),
335 rename: (item, entry) => rename($, path, item, entry),
336 changeLine: (item, index, entry) => changeLine($, path, item, index, entry),
337 openField: (field) => openField($, field),
338 openLine: (index) => openLine($, index),
339 toggleProjects: () => toggleProjects($),
340 activate: (name) => activate($, name),
341 create: (title) => create($, title),
342 archive: (name) => archive($, name),
343})
344
345export const register: Register = (on) => {
346 on('session.start', async ($, e, next) => {
347 await $.command.register({ name: 'todo', description: 'Show the todo list' })
348 await load($, true)
349 $.clock.every(POLL_MS, () => void load($))
350 $.clock.every(IDLE_CHECK_MS, () => void closeWhenIdle($))
351 return next(e)
352 })
353
354 on('command.run', { command: 'todo' }, async ($) => {
355 await openPane($)
356 return {}
357 })
358
359 on('ui.close', { id: PANE }, async ($, e, next) =>
360 e.origin.kind === 'person' && (await stepBack($)) ? { value: undefined } : next(e),
361 ).catch(pass)
362
363 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
364 const todos = await read($, list)
365 const view = {
366 todos,
367 projects: await read($, projects),
368 selected: await read($, selected),
369 mode: await read($, mode),
370 line: await read($, line),
371 pending: await read($, pending),
372 notice: await read($, notice),
373 }
374 return drawPane($.ui.resolve(e), view, actionsFor($, todos?.path ?? ''))
375 })
376
377 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
378 const current = summarize((await read($, list))?.items ?? [])
379 if (current === null) return next(e)
380
381 const tail = e.props.tail ? `${e.props.tail} ${label(current)}` : label(current)
382 return next({ ...e, props: { ...e.props, tail } })
383 })
384
385 on('prompt.compose', async ($, e, next) => {
386 const { sections } = await next(e)
387 return { sections: [...sections, { id: 'clautodo:rules', text: RULES, scope: 'session' }] }
388 })
389
390 on('tool.call', { tool: 'TodoWrite' }, disable).catch(pass)
391 on('tool.call', { tool: 'TaskCreate' }, disable).catch(pass)
392 on('tool.call', { tool: 'TaskUpdate' }, disable).catch(pass)
393 on('tool.call', { tool: 'TaskGet' }, disable).catch(pass)
394 on('tool.call', { tool: 'TaskList' }, disable).catch(pass)
395
396 on('tool.call', { tool: 'Edit' }, async ($, e, next) =>
397 checkFormat($, e.file_path, await next(e)),
398 ).catch(pass)
399 on('tool.call', { tool: 'Write' }, async ($, e, next) =>
400 checkFormat($, e.file_path, await next(e)),
401 ).catch(pass)
402}
403hooks/guards.ts 23 lines1import { DISABLED } from './rules'
2import { problems } from './todo'
3
4export const isTodoFile = (path: string) => /(^|\/)\.todo\/[^/]+\.md$/.test(path)
5
6// What the model reads after editing a todo file it left lines in the mod cannot read.
7export const formatNote = (path: string, text: string) => {
8 const found = problems(text)
9 return found.length === 0
10 ? undefined
11 : `clautodo: ${path} has lines that are not valid todo items (use \`- [ ]\`, \`- [~]\` or \`- [x]\`):\n${found.join('\n')}`
12}
13
14// Subagents and teammates keep the built-in list, a team coordinates over it.
15export function disable<E extends { agentId?: string }, R>(_$: unknown, e: E, next: (e: E) => R) {
16 return e.agentId === undefined ? { deny: DISABLED } : next(e)
17}
18
19// A failing hook lets the event through rather than blocking the session.
20export function pass<E, R>(_$: unknown, e: E, next: (e: E) => R) {
21 return next(e)
22}
23hooks/move.ts 16 lines1const win = (path: string) => path.replaceAll('/', '\\')
2
3// Commands run in the project root on relative paths, so only the separators differ on Windows.
4export const moveCommands = (root: string, from: string, to: string, dir: string) => {
5 if (!/^[A-Za-z]:|\\/.test(root)) {
6 return [
7 ['mkdir', '-p', dir],
8 ['mv', from, to],
9 ]
10 }
11 return [
12 ['cmd', '/c', 'if', 'not', 'exist', win(dir), 'mkdir', win(dir)],
13 ['cmd', '/c', 'move', win(from), win(to)],
14 ]
15}
16hooks/pane.tsx 267 lines1import type { Elements, RenderSurface } from 'claude-code'
2
3import type { Item, Mode, Project, Status, TodoList } from '../types'
4
5type Ui = Elements[RenderSurface]
6
7export type View = {
8 todos: TodoList | null
9 projects: Project[] | null
10 selected: number | null
11 mode: Mode
12 line: number | null
13 pending: 'delete' | 'archive' | null
14 notice: string | null
15}
16
17export type Actions = {
18 select: (index: number) => Promise<void>
19 changeStatus: (item: Item, status: Status) => Promise<void>
20 remove: (item: Item) => Promise<void>
21 add: (entry: string) => Promise<void>
22 rename: (item: Item, entry: string) => Promise<void>
23 changeLine: (item: Item, index: number, entry: string) => Promise<void>
24 openField: (field: Exclude<Mode, 'view'>) => Promise<void>
25 openLine: (index: number) => Promise<void>
26 toggleProjects: () => Promise<void>
27 activate: (name: string) => Promise<void>
28 create: (title: string) => Promise<void>
29 archive: (name: string) => Promise<void>
30}
31
32const GLYPH: Record<Status, string> = { open: '○', running: '◐', done: '●' }
33
34const COLOR: Record<Status, string | undefined> = {
35 open: undefined,
36 running: 'warning',
37 done: 'success',
38}
39
40export const drawPane = (ui: Ui, view: View, act: Actions) =>
41 view.projects ? drawProjects(ui, view, view.projects, act) : drawItems(ui, view, act)
42
43const switchButton = ({ Button }: Ui, isProjects: boolean, act: Actions) => (
44 <Button
45 key="projects"
46 plain
47 hotkey="p"
48 label={isProjects ? 'items' : 'projects'}
49 onPress={act.toggleProjects}
50 />
51)
52
53// Mobile has no text field, so nothing can be typed there.
54const inputOf = (ui: Ui) => ('Input' in ui ? ui.Input : undefined)
55
56const drawProjects = (ui: Ui, view: View, shown: Project[], act: Actions) => {
57 const { Box, Button, Text } = ui
58 const Input = inputOf(ui)
59 const active = view.todos?.path.split('/').pop()
60 const isArchiving = view.pending === 'archive' && active !== undefined
61 const warning = isArchiving
62 ? `Archive "${view.todos?.title ?? active}"? Press c again.`
63 : view.notice
64
65 return (
66 <Box flexDirection="column" gap={1}>
67 <Text bold>Projects</Text>
68 <Box flexDirection="column">
69 {shown.length === 0 && <Text dimColor>No lists in .todo/ yet.</Text>}
70 {shown.map((project, i) => (
71 <Button
72 key={`project-${project.name}`}
73 plain
74 hotkey={i < 9 ? String(i + 1) : undefined}
75 onPress={() => act.activate(project.name)}
76 >
77 <Text
78 bold={project.name === active}
79 color={project.name === active ? 'claude' : undefined}
80 >
81 {project.title ?? project.name}
82 </Text>{' '}
83 <Text dimColor>
84 {project.done}/{project.total}
85 </Text>
86 </Button>
87 ))}
88 </Box>
89 {view.mode === 'project' && Input ? (
90 <Input
91 key="new-project"
92 label="New project"
93 placeholder="Title, Enter to create, empty to cancel"
94 autoFocus
95 onSubmit={act.create}
96 />
97 ) : (
98 <Box flexDirection="column" gap={1}>
99 {warning && <Text color="warning">{warning}</Text>}
100 <Box gap={2}>
101 {switchButton(ui, true, act)}
102 {Input && (
103 <Button
104 key="new"
105 plain
106 hotkey="n"
107 label="new"
108 onPress={() => act.openField('project')}
109 />
110 )}
111 {active && (
112 <Button
113 key="archive"
114 plain
115 hotkey="c"
116 label={isArchiving ? 'confirm' : 'archive'}
117 onPress={() => act.archive(active)}
118 />
119 )}
120 </Box>
121 </Box>
122 )}
123 </Box>
124 )
125}
126
127const drawItems = (ui: Ui, view: View, act: Actions) => {
128 const { Box, Button, Text } = ui
129 const Input = inputOf(ui)
130 const { todos } = view
131
132 if (todos === null) {
133 return (
134 <Box flexDirection="column" gap={1}>
135 <Text dimColor>No todo list in .todo/ yet.</Text>
136 <Box gap={2}>{switchButton(ui, false, act)}</Box>
137 </Box>
138 )
139 }
140
141 const { path, title, items } = todos
142 const index = view.selected !== null && view.selected < items.length ? view.selected : null
143 const item = index === null ? undefined : items[index]
144 const isAsking = view.pending === 'delete' && item !== undefined
145 const warning = isAsking ? `Delete "${item.title}" and its summary? Press d again.` : view.notice
146 const isAdding = view.mode === 'add'
147 const isEditing = view.mode === 'edit' && item !== undefined
148 const lineIndex = view.mode === 'line' ? view.line : null
149 const hasField = (isAdding || isEditing || lineIndex !== null) && Input !== undefined
150
151 return (
152 <Box flexDirection="column" gap={1}>
153 <Text bold>{title ?? path.split('/').pop()}</Text>
154
155 <Box flexDirection="column">
156 {items.length === 0 && <Text dimColor>No items yet.</Text>}
157 {items.map((one, i) => (
158 <Button
159 key={`item-${one.line}`}
160 plain
161 hotkey={i < 9 ? String(i + 1) : undefined}
162 onPress={() => act.select(i)}
163 >
164 <Text color={COLOR[one.status]}>{GLYPH[one.status]}</Text>{' '}
165 <Text
166 bold={i === index}
167 color={i === index ? 'claude' : undefined}
168 dimColor={one.status === 'done' && i !== index}
169 >
170 {one.title}
171 </Text>
172 </Button>
173 ))}
174 </Box>
175
176 {isAdding && Input ? (
177 <Input
178 key="new-item"
179 label="New item"
180 placeholder="Title, Enter to add, empty to cancel"
181 autoFocus
182 onSubmit={act.add}
183 />
184 ) : item ? (
185 <Box flexDirection="column" borderStyle="round" paddingX={1}>
186 {isEditing && Input ? (
187 <Input
188 key="edit-item"
189 value={item.title}
190 placeholder="More lines are added to the summary, empty to cancel"
191 autoFocus
192 onSubmit={(value: string) => act.rename(item, value)}
193 />
194 ) : Input ? (
195 <Button key="title" plain onPress={() => act.openField('edit')}>
196 <Text bold>{item.title}</Text>
197 </Button>
198 ) : (
199 <Text bold>{item.title}</Text>
200 )}
201 {item.summary.length > 0 ? (
202 item.summary.map((text, j) =>
203 j === lineIndex && Input ? (
204 <Input
205 key="summary-line"
206 value={text}
207 placeholder="Empty removes the line, more lines are added below"
208 autoFocus
209 onSubmit={(value: string) => act.changeLine(item, j, value)}
210 />
211 ) : Input ? (
212 <Button key={`summary-${j}`} plain onPress={() => act.openLine(j)}>
213 <Text dimColor>{text}</Text>
214 </Button>
215 ) : (
216 <Text dimColor>{text}</Text>
217 ),
218 )
219 ) : (
220 <Text dimColor>No summary.</Text>
221 )}
222 </Box>
223 ) : (
224 items.length > 0 && <Text dimColor>Select an item: 1-9 or Enter</Text>
225 )}
226
227 {warning && <Text color="warning">{warning}</Text>}
228
229 {!hasField && (
230 <Box gap={2}>
231 {item && (
232 <Button
233 key="done"
234 plain
235 hotkey="x"
236 label={item.status === 'done' ? 'reopen' : 'done'}
237 onPress={() => act.changeStatus(item, item.status === 'done' ? 'open' : 'done')}
238 />
239 )}
240 {item && item.status !== 'running' && (
241 <Button
242 key="start"
243 plain
244 hotkey="s"
245 label="start"
246 onPress={() => act.changeStatus(item, 'running')}
247 />
248 )}
249 {Input && (
250 <Button key="add" plain hotkey="a" label="add" onPress={() => act.openField('add')} />
251 )}
252 {switchButton(ui, false, act)}
253 {item && (
254 <Button
255 key="delete"
256 plain
257 hotkey="d"
258 label={isAsking ? 'confirm' : 'delete'}
259 onPress={() => act.remove(item)}
260 />
261 )}
262 </Box>
263 )}
264 </Box>
265 )
266}
267hooks/rules.ts 12 lines1export const RULES = `# Todo list (clautodo)
2
3Keep the todo list in \`.todo/<project>.md\` at the project root. The active list is the file named in \`.todo/.active\`, or the most recently changed one when that names none. The built-in todo tools (TodoWrite, TaskCreate, TaskUpdate, TaskGet, TaskList) are disabled in this session.
4
5- When a task takes more than a few steps and no list fits it, do not create one on your own: propose the steps in chat, discuss the approach with the user, and write the file only once they agreed, then put its file name into \`.todo/.active\`.
6- Format: a \`# Title\` line, then one unindented item per line: \`- [ ]\` open, \`- [~]\` running, \`- [x]\` done. Indented lines under an item are its summary.
7- Mark an item \`[~]\` when you start it and \`[x]\` when it is done, with a short summary indented below it.
8- The user edits the list too: read it before editing and change only what your work requires.`
9
10export const DISABLED =
11 'clautodo: the built-in todo list is disabled here. Keep the todo list in .todo/<project>.md as the clautodo section of the system prompt describes.'
12hooks/todo.ts 172 lines1import type { Item, Status, Summary } from '../types'
2
3const MARKS: Record<string, Status> = { ' ': 'open', '~': 'running', x: 'done', X: 'done' }
4
5const MARK_OF: Record<Status, string> = { open: ' ', running: '~', done: 'x' }
6
7// Only unindented items count, indented lines are an item's summary.
8const ITEM = /^- \[([ ~xX])\] (.+)$/
9
10const HEADING = /^# (.+)$/
11
12export const parse = (text: string): { title?: string; items: Item[] } => {
13 const items: Item[] = []
14 let title: string | undefined
15 // The item whose summary the following indented lines extend.
16 let current: Item | undefined
17
18 text.split('\n').forEach((raw, line) => {
19 const trimmed = raw.trimEnd()
20 const match = ITEM.exec(trimmed)
21 const status = match && MARKS[match[1] ?? '']
22 const heading = HEADING.exec(trimmed)
23
24 if (match && status) {
25 current = { title: match[2]!.trim(), status, summary: [], line }
26 items.push(current)
27 } else if (current && /^\s+\S/.test(raw)) {
28 current.summary.push(raw.trim())
29 } else if (trimmed !== '') {
30 current = undefined
31 title ??= heading?.[1]?.trim()
32 }
33 })
34
35 return { title, items }
36}
37
38// The item's line split into mark and title; null when the line no longer holds that item.
39const itemAt = (lines: string[], item: Item) => {
40 const match = ITEM.exec(lines[item.line]?.trimEnd() ?? '')
41 return match && match[2]!.trim() === item.title ? { mark: match[1]!, title: match[2]! } : null
42}
43
44// The first line of an entry is a title, the others its summary; blank lines drop out.
45const splitEntry = (entry: string) => {
46 const [title = '', ...summary] = entry
47 .split(/\r?\n/)
48 .map((line) => line.trim())
49 .filter((line) => line !== '')
50 return { title, summary: summary.map((line) => ` ${line}`) }
51}
52
53// The index right after an item and its summary, trailing blank lines left out.
54const blockEnd = (lines: string[], line: number) => {
55 let at = line + 1
56 while (at < lines.length && (lines[at]!.trim() === '' || /^\s+\S/.test(lines[at]!))) at++
57 while (at > line + 1 && lines[at - 1]!.trim() === '') at--
58 return at
59}
60
61export const setStatus = (text: string, item: Item, status: Status): string | null => {
62 const lines = text.split('\n')
63 const found = itemAt(lines, item)
64 if (!found) return null
65
66 lines[item.line] = `- [${MARK_OF[status]}] ${found.title}`
67 return lines.join('\n')
68}
69
70// Keeps the mark; further lines of `entry` are added to the end of the summary.
71export const renameItem = (text: string, item: Item, entry: string): string | null => {
72 const lines = text.split('\n')
73 const found = itemAt(lines, item)
74 const { title, summary } = splitEntry(entry)
75 if (!found || title === '') return null
76
77 lines.splice(blockEnd(lines, item.line), 0, ...summary)
78 lines[item.line] = `- [${found.mark}] ${title}`
79 return lines.join('\n')
80}
81
82export const deleteItem = (text: string, item: Item): string | null => {
83 const lines = text.split('\n')
84 if (!itemAt(lines, item)) return null
85
86 lines.splice(item.line, blockEnd(lines, item.line) - item.line)
87 return lines.join('\n')
88}
89
90// Replaces one summary line, keeping its indent; an empty entry removes it, more lines take its place.
91export const editSummary = (
92 text: string,
93 item: Item,
94 index: number,
95 entry: string,
96): string | null => {
97 const lines = text.split('\n')
98 if (!itemAt(lines, item)) return null
99
100 const at = summaryLines(lines, item.line)[index]
101 if (at === undefined || lines[at]!.trim() !== item.summary[index]) return null
102
103 const indent = /^\s*/.exec(lines[at]!)![0]
104 const { title, summary } = splitEntry(entry)
105 const replaced = title === '' ? [] : [title, ...summary.map((line) => line.trim())]
106 lines.splice(at, 1, ...replaced.map((line) => `${indent}${line}`))
107 return lines.join('\n')
108}
109
110// The file indices of an item's summary lines, as parse collects them.
111const summaryLines = (lines: string[], line: number) => {
112 const found: number[] = []
113 for (let at = line + 1; at < lines.length; at++) {
114 if (/^\s+\S/.test(lines[at]!)) found.push(at)
115 else if (lines[at]!.trim() !== '') break
116 }
117 return found
118}
119
120// Adds an open item right after the last one and its summary, or below the heading without items.
121// The first line of `entry` is the title, the rest becomes its indented summary.
122export const addItem = (text: string, entry: string): string => {
123 const { title, summary } = splitEntry(entry)
124 const added = [`- [ ] ${title}`, ...summary]
125 const lines = text.split('\n')
126 const last = lines.findLastIndex((line) => ITEM.test(line.trimEnd()))
127
128 if (last === -1) {
129 while (lines.at(-1)?.trim() === '') lines.pop()
130 const head = lines.length > 0 ? [...lines, ''] : []
131 return [...head, ...added, ''].join('\n')
132 }
133
134 lines.splice(blockEnd(lines, last), 0, ...added)
135 return lines.join('\n')
136}
137
138// An unindented line that starts like an item but is not one.
139const BROKEN = /^[-*+]\s*\[/
140
141export const problems = (text: string): string[] =>
142 text.split('\n').flatMap((line, index) => {
143 const isBroken = BROKEN.test(line) && !ITEM.test(line.trimEnd())
144 return isBroken ? [`line ${index + 1}: \`${line.trim()}\``] : []
145 })
146
147export const summarize = (items: Item[]): Summary | null => {
148 if (items.length === 0) return null
149 return {
150 done: items.filter((item) => item.status === 'done').length,
151 total: items.length,
152 running: items.find((item) => item.status === 'running')?.title,
153 }
154}
155
156export const label = ({ done, total, running }: Summary) => {
157 const count = `${done === total ? '☑' : '☐'} ${done}/${total}`
158 return running ? `${count} · ${running}` : count
159}
160
161// A file name for a project title: `Nuxt Migration` is `nuxt-migration.md`.
162export const fileName = (title: string) => {
163 const slug = title
164 .toLowerCase()
165 .replaceAll('ß', 'ss')
166 .normalize('NFKD')
167 .replace(/[\u0300-\u036f]/g, '')
168 .replace(/[^a-z0-9]+/g, '-')
169 .replace(/^-|-$/g, '')
170 return `${slug || 'todo'}.md`
171}
172types/index.d.ts 32 lines1export type Status = 'open' | 'running' | 'done'
2
3// `line` is the item's index in the file, so a change can be written back in place.
4export type Item = { title: string; status: Status; summary: string[]; line: number }
5
6export type TodoList = { path: string; title?: string; items: Item[] }
7
8// Which field the pane shows, if any: to add an item, edit its title or one line of its summary,
9// or name a new project.
10export type Mode = 'view' | 'add' | 'edit' | 'line' | 'project'
11
12export type Summary = { done: number; total: number; running?: string }
13
14// One list in .todo/ as the project view shows it.
15export type Project = { name: string; title?: string; done: number; total: number }
16
17declare module 'claude-code' {
18 interface PluginState {
19 clautodo: {
20 list: TodoList | null
21 selected: number | null
22 mode: Mode
23 notice: string | null
24 line: number | null
25 // What waits for a second press: deleting the selected item or archiving the active list.
26 pending: 'delete' | 'archive' | null
27 // The lists the project view shows; null while it is closed.
28 projects: Project[] | null
29 }
30 }
31}
32