A pane summarising the remaining todos, each with Do, Chat, Defer and Delete

A pane beside the chat that summarises the remaining todos, two lines each, so the list is easy to scroll:
3 to do · 1 deferred
1. Play-test Harbour Lights [hl-playtest]
You, before Wed 14 Oct (first screenshot session)
[ Do ] Chat Defer Delete more
Re [id] <title>: in your prompt box so you can talk it through.▸ Deferred (n) list, where you can restore or delete it.▸ Done (n).Claude keeps the list itself: the mod gives it three tools (add, done, list) and a short instruction to add work that remains and mark it done when finished.
The list is kept per project and survives restarts. The pane opens by itself when something is left; /todos shows or hides it.
At the prompt of a Claude Code terminal session:
/plugin install todos --marketplace dbroadhurst/claude-mod-todos
Answer y to add the marketplace, then choose a scope. To turn it on for one project only, add this to the project's .claude/settings.json:
{
"extraKnownMarketplaces": {
"claude-mod-todos": { "source": { "source": "github", "repo": "dbroadhurst/claude-mod-todos" } }
},
"enabledPlugins": { "todos@claude-mod-todos": true }
}
In the fullscreen layout with a terminal at least 110 columns wide, the pane docks beside the transcript (144 columns when it opens by itself). Narrower, or on the main-screen layout, it sits above the prompt.
claude --plugin-dir <path>; check with claude plugin validate . and claude plugin test ..MIT licensed.
hooks/register.tsx 265 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Todo, TodoList, TodoStatus } from '../types'
5
6// One list of todos per project, kept across sessions in $.store.
7const PANE = 'todos'
8const TITLE = 'Todos'
9const list = atom({ plugin: 'todos', key: 'list' } as const, { root: '', items: [] })
10const expanded = atom({ plugin: 'todos', key: 'expanded' } as const, [])
11
12const MAX_DONE = 30
13const storeKey = (root: string) => `list:${root}`
14
15const GUIDE = [
16 'Todos pane (the `todos` plugin): the person sees the remaining todos in a pane beside the chat, each with Do, Chat, Defer and Delete.',
17 '- Keep it current: when work remains (for the person or for you), call `mcp__todos__add` with a short id, a title of a few words and a one-line summary (who, what, by when). Adding an existing id updates it.',
18 '- When a todo is finished, call `mcp__todos__done` with its id and a few words on the outcome.',
19 '- A prompt starting "Do [id]" came from the Do button: start that todo now. "Re [id]" is the person discussing it.',
20].join('\n')
21
22const isOpen = (t: Todo) => t.status === 'open' || t.status === 'doing'
23const oneLine = (s: string | undefined, max: number) => {
24 const t = (s ?? '').replace(/\s+/g, ' ').trim()
25 return t.length > max ? `${t.slice(0, max - 1)}…` : t
26}
27
28// Applies `fn` to the list, keeps the done history bounded and persists it.
29async function change($: EngineInterface, fn: (items: Todo[]) => Todo[]): Promise<TodoList> {
30 let saved: TodoList = { root: '', items: [] }
31 await update($, list, prev => {
32 const items = fn(prev.items)
33 const live = items.filter(t => t.status !== 'done')
34 const done = items
35 .filter(t => t.status === 'done')
36 .sort((a, b) => b.changedAt - a.changedAt)
37 .slice(0, MAX_DONE)
38 saved = { root: prev.root, items: [...live, ...done] }
39 return saved
40 })
41 await $.store.set(storeKey(saved.root), saved.items)
42 return saved
43}
44
45async function setStatus($: EngineInterface, id: string, status: TodoStatus, note?: string): Promise<TodoList> {
46 const now = await $.clock.now()
47 return change($, items => items.map(t => (t.id === id ? { ...t, status, note: note ?? t.note, changedAt: now } : t)))
48}
49
50async function openPane($: EngineInterface) {
51 return $.ui.open({ id: PANE, title: TITLE })
52}
53
54export const register: Register = on => {
55 on('session.start', async ($, e, next) => {
56 const root = await $.session.root()
57 const items = ((await $.store.get(storeKey(root))) as Todo[] | undefined) ?? []
58 await update($, list, () => ({ root, items }))
59
60 await $.command.register({
61 name: 'todos',
62 description: 'Show or hide the pane of remaining todos',
63 })
64 await $.tool.register({
65 name: 'add',
66 description:
67 'Adds (or updates, by id) a remaining todo on the Todos pane, with a one-line summary. Use it whenever work remains for the person or for you.',
68 inputSchema: {
69 type: 'object',
70 properties: {
71 id: { type: 'string', description: 'Short id or slug' },
72 title: { type: 'string', description: 'A few words naming the todo' },
73 summary: { type: 'string', description: 'One line: who, what, by when (about 100 characters)' },
74 detail: { type: 'string', description: 'Optional extra context, a line or two' },
75 },
76 required: ['id', 'title'],
77 },
78 isDeferred: false,
79 })
80 await $.tool.register({
81 name: 'done',
82 description: 'Marks a todo on the Todos pane as finished.',
83 inputSchema: {
84 type: 'object',
85 properties: {
86 id: { type: 'string' },
87 note: { type: 'string', description: 'The outcome, a few words' },
88 },
89 required: ['id'],
90 },
91 isDeferred: false,
92 })
93 await $.tool.register({
94 name: 'list',
95 description: 'Lists the todos on the Todos pane that are still open, doing or deferred.',
96 isDeferred: false,
97 })
98
99 if (items.some(isOpen)) void openPane($)
100
101 return next(e)
102 })
103
104 on('prompt.compose', async ($, e, next) => {
105 const composed = await next(e)
106 return {
107 ...composed,
108 sections: [...composed.sections, { id: 'todos:guide', text: GUIDE, scope: 'session' as const }],
109 }
110 })
111
112 on('command.run', { command: 'todos' }, async $ => {
113 const isUp = (await $.ui.panes()).some(p => p.id === PANE)
114 if (isUp) {
115 await $.ui.close({ id: PANE })
116 return { text: 'Todos pane closed.' }
117 }
118 await openPane($)
119 return { text: 'Todos pane opened.' }
120 })
121
122 on('tool.call', { tool: 'mcp__todos__add' }, async ($, e) => {
123 const input = e as unknown as Partial<Todo>
124 const id = oneLine(input.id, 40)
125 if (!id || !input.title) return { deny: 'todos: id and title are required.' }
126 const now = await $.clock.now()
127 const saved = await change($, items => {
128 const old = items.find(t => t.id === id)
129 const item: Todo = {
130 id,
131 title: oneLine(input.title, 80),
132 summary: input.summary ? oneLine(input.summary, 300) : old?.summary,
133 detail: input.detail ? oneLine(input.detail, 600) : old?.detail,
134 status: old && old.status !== 'done' ? old.status : 'open',
135 addedAt: old?.addedAt ?? now,
136 changedAt: now,
137 }
138 return old ? items.map(t => (t.id === id ? item : t)) : [...items, item]
139 })
140 void openPane($)
141 return { result: `Added ${id} (${saved.items.filter(isOpen).length} to do).` }
142 })
143 .catch(() => ({ deny: 'todos: the add tool failed.' }))
144
145 on('tool.call', { tool: 'mcp__todos__done' }, async ($, e) => {
146 const { id, note } = e as unknown as { id: string; note?: string }
147 const l = await read($, list)
148 if (!l.items.some(t => t.id === id)) return { result: `No todo ${id} on the pane.` }
149 const saved = await setStatus($, id, 'done', note ? oneLine(note, 120) : undefined)
150 return { result: `Done ${id}. ${saved.items.filter(isOpen).length} left.` }
151 })
152 .catch(() => ({ deny: 'todos: the done tool failed.' }))
153
154 on('tool.call', { tool: 'mcp__todos__list' }, async $ => {
155 const live = (await read($, list)).items.filter(t => t.status !== 'done')
156 const text = live.length
157 ? live.map(t => `${t.id} (${t.status}): ${t.title}${t.summary ? ` — ${t.summary}` : ''}`).join('\n')
158 : 'Nothing left to do.'
159 return { result: text }
160 })
161 .catch(() => ({ deny: 'todos: the list tool failed.' }))
162
163 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
164 const { Box, Text, Button } = $.ui.resolve(e)
165 const l = await read($, list)
166 const live = l.items.filter(isOpen).sort((x, y) => x.addedAt - y.addedAt)
167 const deferred = l.items.filter(t => t.status === 'deferred')
168 const done = l.items.filter(t => t.status === 'done')
169 const shown = new Set(await read($, expanded))
170 const width = Math.max(20, (e.props.bodyColumns ?? 40) - 2)
171
172 const toggle = (key: string) =>
173 update($, expanded, keys => (keys.includes(key) ? keys.filter(k => k !== key) : [...keys, key]))
174 const remove = (id: string) => change($, items => items.filter(t => t.id !== id))
175
176 const doIt = async (t: Todo) => {
177 await setStatus($, t.id, 'doing')
178 void $.prompt.submit({ text: `Do [${t.id}] ${t.title}${t.summary ? `: ${t.summary}` : ''}` })
179 }
180 const chat = async (t: Todo) => {
181 await $.prompt.fill({ text: `Re [${t.id}] ${t.title}: `, mode: 'replace' })
182 }
183
184 const header = [
185 live.length === 0 ? 'Nothing left to do' : `${live.length} to do`,
186 deferred.length ? `${deferred.length} deferred` : '',
187 ]
188 .filter(Boolean)
189 .join(' · ')
190
191 return (
192 <Box flexDirection="column" gap={1}>
193 <Text bold>{header}</Text>
194 {live.map((t, n) => {
195 const isMore = shown.has(t.id)
196 return (
197 <Box key={`item-${t.id}`} flexDirection="column">
198 <Text bold wrap="truncate-end">
199 {t.status === 'doing' ? '▶ ' : `${n + 1}. `}
200 {t.title} <Text dimColor>[{t.id}]</Text>
201 </Text>
202 {t.summary && (
203 <Text dimColor wrap={isMore ? 'wrap' : 'truncate-end'}>
204 {isMore ? t.summary : oneLine(t.summary, width * 2)}
205 </Text>
206 )}
207 {isMore && t.detail && <Text dimColor wrap="wrap">{t.detail}</Text>}
208 <Box flexDirection="row" gap={1}>
209 <Button key={`do-${t.id}`} variant="primary" label={t.status === 'doing' ? 'Doing' : 'Do'} onPress={() => doIt(t)} />
210 <Button key={`chat-${t.id}`} label="Chat" onPress={() => chat(t)} />
211 <Button key={`defer-${t.id}`} label="Defer" onPress={() => setStatus($, t.id, 'deferred')} />
212 <Button key={`delete-${t.id}`} dimColor label="Delete" onPress={() => remove(t.id)} />
213 {(t.detail || (t.summary ?? '').length > width) && (
214 <Button key={`more-${t.id}`} dimColor plain label={isMore ? 'less' : 'more'} onPress={() => toggle(t.id)} />
215 )}
216 </Box>
217 </Box>
218 )
219 })}
220 {deferred.length > 0 && (
221 <Box key="deferred" flexDirection="column">
222 <Button
223 key="deferred-toggle"
224 dimColor
225 plain
226 label={`${shown.has('#deferred') ? '▾' : '▸'} Deferred (${deferred.length})`}
227 onPress={() => toggle('#deferred')}
228 />
229 {shown.has('#deferred') &&
230 deferred.map(t => (
231 <Box key={`deferred-${t.id}`} flexDirection="row" gap={1}>
232 <Text dimColor wrap="truncate-end">
233 {t.title} [{t.id}]
234 </Text>
235 <Button key={`restore-${t.id}`} dimColor plain label="restore" onPress={() => setStatus($, t.id, 'open')} />
236 <Button key={`drop-${t.id}`} dimColor plain label="delete" onPress={() => remove(t.id)} />
237 </Box>
238 ))}
239 </Box>
240 )}
241 {done.length > 0 && (
242 <Box key="done" flexDirection="column">
243 <Button
244 key="done-toggle"
245 dimColor
246 plain
247 label={`${shown.has('#done') ? '▾' : '▸'} Done (${done.length})`}
248 onPress={() => toggle('#done')}
249 />
250 {shown.has('#done') &&
251 done.map(t => (
252 <Text key={`done-${t.id}`} dimColor wrap="truncate-end">
253 ✓ {t.title} [{t.id}]{t.note ? `: ${t.note}` : ''}
254 </Text>
255 ))}
256 {shown.has('#done') && (
257 <Button key="clear" dimColor plain label="clear done" onPress={() => change($, items => items.filter(x => x.status !== 'done'))} />
258 )}
259 </Box>
260 )}
261 </Box>
262 )
263 })
264}
265types/index.d.ts 21 lines1export type TodoStatus = 'open' | 'doing' | 'deferred' | 'done'
2
3export type Todo = {
4 id: string
5 title: string
6 summary?: string
7 detail?: string
8 status: TodoStatus
9 addedAt: number
10 changedAt: number
11 note?: string
12}
13
14export type TodoList = { root: string; items: Todo[] }
15
16declare module 'claude-code' {
17 interface PluginState {
18 todos: { list: TodoList; expanded: string[] }
19 }
20}
21