SLOPSHOPPER

clautodo

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

newpanespinnerguardcommandprompt
v0.1.0no licenseupdated 2026-10-09Flo0806/fh-claude-mods/plugins/clautodo
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clautodo
│ ┃ Todo ✕ › fix the failing auth test and add an audit log call │ ┃ No todo list in .todo/ yet. │ ┃ ⏺ Read(src/auth.ts) │ ┃ p: projects ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ 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 │ │ › /todo │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Todo
No todo list in .todo/ yet. p: projects
README

clautodo

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

Format

# Nuxt migration

- [x] Switch the router
  Summary of what was done.
- [~] Adapt the tests
- [ ] Update the docs
MarkMeans
[ ]open
[~]running
[x]done

Only unindented items count; indented lines are the summary of the item above.

The list

/todo opens the active list in a pane. Every action shows its key on its button.

KeyDoes
1-9, Enterselect an item and show its summary, again to deselect
xcheck the selected item off, or reopen it
smark the selected item as running
aadd an item; more lines become its summary
ddelete the selected item with its summary; press again to confirm
pshow the projects, or back to the items
↑ ↓, Enter on the titleedit the title; more lines are added to its summary
↑ ↓, Enter on a summary lineedit that line; empty removes it, more lines are added below
Escstep back: an open field, a pending second press, the projects; else close

p switches to the projects, every .todo/*.md with its progress:

KeyDoes
1-9, Entermake that list the active one and show its items
nstart a new list from a title: Nuxt Migration becomes nuxt-migration.md
cmove the active list to .todo/archive/; press again to confirm
p, Escback 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.

For Claude

  • A short section in the system prompt (about 200 tokens, cached) tells Claude the format, to name a new list in .todo/.active, and to propose a list in chat first, writing it only once you agreed.
  • The built-in todo tools (TodoWrite, TaskCreate, TaskUpdate, TaskGet, TaskList) are refused in the main conversation; subagents and teammates keep them.
  • After Claude edits a .todo/*.md, broken item lines are reported back to it right away. A clean edit adds nothing.

Notes

  • The active list is the file named in .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.
  • The list is your own working note, so keep .todo/ out of git, e.g. once in ~/.config/git/ignore.
  • Without a .todo folder nothing is shown under the prompt.
  • Archiving runs mv (cmd /c move on Windows) in the project root, the one shell command the mod uses.

Install

/plugin install clautodo --marketplace Flo0806/fh-claude-mods
Source 7 files
hooks/register.tsx 403 lines
1import { 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}
403
hooks/guards.ts 23 lines
1import { 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}
23
hooks/move.ts 16 lines
1const 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}
16
hooks/pane.tsx 267 lines
1import 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}
267
hooks/rules.ts 12 lines
1export 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.'
12
hooks/todo.ts 172 lines
1import 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}
172
types/index.d.ts 32 lines
1export 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