A side pane that shows Claude's todo list live, grouped by status, priority and blockers, so intermediate steps are not lost in the conversation

A side pane for Claude Code that shows the todo list Claude keeps, live, so the intermediate steps of a long task do not get lost in the conversation.
It is a mod: everything it needs is in this folder. Nothing goes in your CLAUDE.md.
In a Claude Code session:
/plugin install todo-pane --marketplace lpareja99/claude-mods
Or from a local copy of this repository:
claude plugin marketplace add <path to this repository>
claude plugin install todo-pane@local-mods
Then run /reload-plugins (or start a new session) and open the pane with /todos.
███████░░░ 11/15 done ◐ 1 ! 2 ◌ 2
◐ Running the tests
──────────────────────────────────────
group todo 4 done 11 all 15
──────────────────────────────────────
Ready (2)
● ◐ 20 Writing the parser
● ○ 17 ★ Update the docs
──────────────────────────────────────
Blocked (1)
· ! 22 ★ Ship it waits for 21
──────────────────────────────────────
Deferred (1)
· ○ 23 Rewrite the importer
──────────────────────────────────────
▸ Done (11)
Each row is: priority dot, status, number, then the name.
| Mark | Meaning |
|---|---|
● red / yellow / green | high / normal / low priority |
· dim | no priority set |
✓ green | done |
! red | blocked: it waits for tasks that are not done |
◐ cyan | in progress |
○ | pending |
★ | mandatory: cannot be skipped |
↳ and 12.1 | a subtask of 12, numbered under its parent |
Tabs: group (Ready, Blocked, Deferred, Done), todo (everything unfinished), done, all (flat). Click a tab or press 1-4. Press d to fold or unfold the Done group.
Click a task to open its card: description, facts (priority, mandatory, deferred, parent) and its status timeline.
The status line also shows progress (3/8 · current step) when the pane is closed.
The mod watches Claude's TodoWrite, TaskCreate and TaskUpdate calls. Blocked tasks come from addBlockedBy, which Claude already uses.
For priority, mandatory, subtasks and deferred, the mod adds a short section to Claude's system prompt (only when the task tools are available) asking it to set task metadata:
| Metadata | Values |
|---|---|
priority | "high", "normal", "low" |
mandatory | true |
parent | id of the parent task |
deferred | true |
You can also ask Claude directly to set them on existing tasks.
/todos-sync re-reads the session's active task list and replaces what the pane shows. The task-lists mod runs it after you pick another list; you can also type it.
When a list is pinned with CLAUDE_CODE_TASK_LIST_ID, the mod reads that list's folder directly, so descriptions, priority, subtasks and blockers come along. Otherwise it asks TaskList, which leaves the metadata out.
/todos-sync it reads the existing list; without a pinned list that read has no metadata, so older tasks show without priority./todos-sync resets each task's timeline to a single entry: the task files do not store transitions.TodoWrite lists have no ids, blockers or metadata, so those rows are plain.claude plugin validate .
claude plugin test .
The tests are in hooks/register.test.tsx. Edit the files, then run /reload-plugins.
Laura Pareja Prieto
hooks/register.tsx 556 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { TodoFilter, TodoItem, TodoPriority, TodoStatus } from '../types'
5
6const PANE = 'todo-pane'
7const items = atom({ plugin: 'todo-pane', key: 'items' } as const, [] as TodoItem[])
8const selected = atom({ plugin: 'todo-pane', key: 'selectedId' } as const, null as string | null)
9const doneOpen = atom({ plugin: 'todo-pane', key: 'doneOpen' } as const, false)
10const filter = atom({ plugin: 'todo-pane', key: 'filter' } as const, 'group' as TodoFilter)
11
12/** The `todo` tab holds everything unfinished, in-progress included. */
13const matches = (item: TodoItem, by: TodoFilter) =>
14 by === 'all' || by === 'group' || (by === 'pending' ? item.status !== 'done' : item.status === by)
15
16const FILTERS: { value: TodoFilter; label: string }[] = [
17 { value: 'group', label: 'group' },
18 { value: 'pending', label: 'todo' },
19 { value: 'done', label: 'done' },
20 { value: 'all', label: 'all' },
21]
22
23type ToolStatus = 'pending' | 'in_progress' | 'completed'
24
25type ListResult = {
26 tasks: { id: string; subject: string; status: ToolStatus; blockedBy: string[] }[]
27}
28
29const fromTool = (status: ToolStatus): TodoStatus =>
30 status === 'completed' ? 'done' : status
31
32/** Applies a change of status, appending to the history only when it moved. */
33const withStatus = (item: TodoItem, status: TodoStatus, at: number): TodoItem =>
34 item.status === status
35 ? item
36 : { ...item, status, updatedAt: at, history: [...item.history, { status, at }] }
37
38/** Mirroring is best-effort: a failure here must never fail Claude's tool call. */
39const safely = async ($: EngineInterface, fn: () => Promise<unknown>) => {
40 try {
41 await fn()
42 await showStatus($)
43 } catch {
44 // The pane may lag; the tool call itself is unaffected.
45 }
46}
47
48/** "3/8 · Writing tests": progress stays visible with the pane closed. */
49const showStatus = async ($: EngineInterface) => {
50 const list = await read($, items)
51 if (list.length === 0) return $.ui.status(undefined)
52 const done = list.filter(item => item.status === 'done').length
53 const current = list.find(item => item.status === 'in_progress')
54
55 return $.ui.status(`${done}/${list.length}${current ? ` · ${current.activeForm ?? current.text}` : ''}`)
56}
57
58type Group = 'ready' | 'blocked' | 'deferred' | 'done'
59
60/** The blockers of an item that are still open; a blocker that is gone no longer blocks. */
61const openBlockers = (item: TodoItem, list: TodoItem[]) =>
62 item.status === 'done'
63 ? []
64 : item.blockedBy.filter(id => list.some(one => one.id === id && one.status !== 'done'))
65
66const groupOf = (item: TodoItem, list: TodoItem[]): Group =>
67 item.status === 'done'
68 ? 'done'
69 : item.deferred
70 ? 'deferred'
71 : openBlockers(item, list).length > 0
72 ? 'blocked'
73 : 'ready'
74
75const GROUPS: { group: Group; title: string }[] = [
76 { group: 'ready', title: 'Ready' },
77 { group: 'blocked', title: 'Blocked' },
78 { group: 'deferred', title: 'Deferred' },
79 { group: 'done', title: 'Done' },
80]
81
82const PRIORITIES: TodoPriority[] = ['high', 'normal', 'low']
83
84/** Reads the metadata convention (priority, mandatory); anything else is ignored. */
85const readMeta = (meta: unknown): Partial<Pick<TodoItem, 'priority' | 'mandatory' | 'parent' | 'deferred'>> => {
86 const m = (meta ?? {}) as Record<string, unknown>
87
88 return {
89 ...(PRIORITIES.includes(m.priority as TodoPriority) ? { priority: m.priority as TodoPriority } : {}),
90 ...(typeof m.mandatory === 'boolean' ? { mandatory: m.mandatory } : {}),
91 ...(typeof m.deferred === 'boolean' ? { deferred: m.deferred } : {}),
92 ...(typeof m.parent === 'string' || typeof m.parent === 'number' ? { parent: String(m.parent) } : {}),
93 }
94}
95
96/** An update may clear a key by setting it to null, as TaskUpdate's metadata does. */
97const mergeMeta = (item: TodoItem, meta: unknown): TodoItem => {
98 const m = (meta ?? {}) as Record<string, unknown>
99 const next = { ...item, ...readMeta(meta) }
100 if (m.priority === null) delete next.priority
101 if (m.mandatory === null) delete next.mandatory
102 if (m.parent === null) delete next.parent
103 if (m.deferred === null) delete next.deferred
104
105 return next
106}
107
108/** Mandatory first, then high to low; the sort is stable for equals. */
109const rank = (item: TodoItem) =>
110 (item.mandatory ? 0 : 3) + (item.priority ? PRIORITIES.indexOf(item.priority) : 1)
111
112const badge = (item: TodoItem) => (item.mandatory ? '★' : '')
113
114/** The traffic-light dot in front of a row: red high, yellow normal, green low, a dim dot with none. */
115const PRIORITY_COLOR: Record<TodoPriority, string> = { high: 'red', normal: 'yellow', low: 'green' }
116
117/** "3", or "3.1" for the first subtask of 3; a TodoWrite item's `todo-3` shows as 3 too. */
118const label = (item: TodoItem, list: TodoItem[], depth = 0): string => {
119 const parent = list.find(one => one.id === item.parent && one.id !== item.id)
120 if (!parent || depth >= 3) return item.id.replace(/^todo-/, '')
121 const siblings = list.filter(one => one.parent === parent.id && one.id !== parent.id)
122
123 return `${label(parent, list, depth + 1)}.${siblings.indexOf(item) + 1}`
124}
125
126const isTop = (item: TodoItem, list: TodoItem[]) =>
127 !item.parent || item.parent === item.id || !list.some(one => one.id === item.parent)
128
129/** An item then its subtasks, each with its depth; depth is capped so a cycle ends. */
130const flatten = (
131 item: TodoItem,
132 list: TodoItem[],
133 depth = 0,
134 fold: (one: TodoItem) => boolean = () => false,
135): { item: TodoItem; depth: number }[] => [
136 { item, depth },
137 ...(depth >= 3 || fold(item)
138 ? []
139 : list
140 .filter(kid => kid.parent === item.id && kid.id !== item.id)
141 .flatMap(kid => flatten(kid, list, depth + 1, fold))),
142]
143
144const LABEL: Record<TodoStatus, string> = { pending: 'todo', in_progress: 'doing', done: 'done' }
145
146const clock = (at: number) => {
147 const d = new Date(at)
148
149 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
150}
151
152const fresh = (id: string, text: string, status: TodoStatus, at: number): TodoItem => ({
153 id,
154 text,
155 blockedBy: [],
156 status,
157 updatedAt: at,
158 history: [{ status, at }],
159})
160
161type RawTask = {
162 id?: unknown
163 subject?: unknown
164 status?: unknown
165 blockedBy?: unknown
166 description?: unknown
167 activeForm?: unknown
168 metadata?: unknown
169}
170
171/**
172 * The tasks of a list pinned with CLAUDE_CODE_TASK_LIST_ID, read from its folder so
173 * descriptions and metadata come along (TaskList leaves them out). Undefined when
174 * no list is pinned; a pinned list with no folder yet is an empty one.
175 */
176const readPinned = async ($: EngineInterface, at: number): Promise<TodoItem[] | undefined> => {
177 const id = await $.env.get('CLAUDE_CODE_TASK_LIST_ID')
178 if (!id) return undefined
179 const dir = await $.env.get('CLAUDE_CONFIG_DIR')
180 const home = await $.env.get('HOME')
181 const folder = `${dir ? dir.replace(/\/$/, '') : `${home ?? ''}/.claude`}/tasks/${id}`
182 const files = (await $.fs.list(folder).catch(() => [])).filter(
183 f => f.kind === 'file' && f.name.endsWith('.json'),
184 )
185 const raw = await Promise.all(
186 files.map(async f => {
187 try {
188 return JSON.parse(await $.fs.read(`${folder}/${f.name}`)) as RawTask
189 } catch {
190 return undefined
191 }
192 }),
193 )
194
195 return raw
196 .filter((t): t is RawTask => t !== undefined && t.id !== undefined && typeof t.subject === 'string')
197 .sort((a, b) => Number(a.id) - Number(b.id))
198 .map(t => {
199 const status: ToolStatus =
200 t.status === 'completed' || t.status === 'in_progress' ? t.status : 'pending'
201
202 return {
203 ...fresh(String(t.id), String(t.subject), fromTool(status), at),
204 blockedBy: Array.isArray(t.blockedBy) ? t.blockedBy.map(String) : [],
205 ...(typeof t.description === 'string' ? { description: t.description } : {}),
206 ...(typeof t.activeForm === 'string' ? { activeForm: t.activeForm } : {}),
207 ...readMeta(t.metadata),
208 }
209 })
210}
211
212/** The session's active task list: the pinned one, else whatever TaskList answers. */
213const readActive = async ($: EngineInterface, at: number): Promise<TodoItem[]> => {
214 const pinned = await readPinned($, at)
215 if (pinned) return pinned
216 const listed = await $.tool.call({ tool: 'TaskList' })
217 const tasks = (listed.result as ListResult | undefined)?.tasks ?? []
218
219 return tasks.map(t => ({
220 ...fresh(t.id, t.subject, fromTool(t.status), at),
221 blockedBy: t.blockedBy,
222 }))
223}
224
225/** What the model is told so the pane can show more than status: the metadata the pane reads. */
226const GUIDE = [
227 'todo-pane shows your tasks in a side pane. When you create tasks with TaskCreate, set `metadata` so it can sort and group them:',
228 '- `priority`: "high", "normal" or "low".',
229 '- `mandatory`: true only for steps that cannot be skipped.',
230 '- `parent`: the id of the parent task, for a subtask.',
231 '- `deferred`: true for a task parked on purpose.',
232 'Leave metadata off trivial tasks. Use `addBlockedBy` for real dependencies: the pane lists those tasks as blocked.',
233].join('\n')
234
235export const register: Register = on => {
236 // The mod brings its own instructions, so it needs nothing in anyone's CLAUDE.md.
237 on('prompt.compose', async ($, e, next) => {
238 const composed = await next(e)
239 if (!e.tools.includes('TaskCreate')) return composed
240
241 return {
242 sections: [...composed.sections, { id: 'todo-pane:task-metadata', text: GUIDE, scope: 'session' }],
243 }
244 })
245
246 on('session.start', async ($, e, next) => {
247 // Never let the pane's setup keep the session from starting.
248 await safely($, () =>
249 $.command.register({
250 name: 'todos',
251 description: "Show Claude's todo list in a pane",
252 }),
253 )
254 // Other mods (task-lists) run this after pointing the task tools at another list.
255 await safely($, () =>
256 $.command.register({
257 name: 'todos-sync',
258 description: 'Re-read the active task list into the Todos pane',
259 }),
260 )
261 void $.ui.open({ id: PANE, title: 'Todos' })
262
263 // Pick up tasks that exist already, e.g. after a mod reload; off the start path.
264 void safely($, async () => {
265 const tasks = await readActive($, Date.now())
266 await update($, items, list => (list.length > 0 ? list : tasks))
267 })
268
269 return next(e)
270 })
271
272 on('command.run', { command: 'todos' }, async $ => {
273 await $.ui.open({ id: PANE, title: 'Todos', focus: true })
274
275 return { text: 'Todos pane opened.' }
276 })
277
278 // The active list changed under the pane: show the new one, not the old one's events.
279 on('command.run', { command: 'todos-sync' }, async $ => {
280 await safely($, async () => {
281 const tasks = await readActive($, Date.now())
282 await update($, items, () => tasks)
283 await update($, selected, () => null)
284 })
285 const shown = await read($, items)
286
287 return { text: `Todos pane now shows the active task list (${shown.length} tasks).` }
288 })
289
290 // TodoWrite replaces the whole list; keep history for items that persist.
291 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
292 const ran = await next(e)
293 if (ran.deny !== undefined || ran.isError) return ran
294
295 const at = Date.now()
296 await safely($, () => update($, items, list =>
297 e.todos.map((todo, i) => {
298 const status = fromTool(todo.status)
299 const known = list.find(item => item.text === todo.content)
300 const item = known ?? fresh(`todo-${i + 1}`, todo.content, status, at)
301
302 return { ...withStatus(item, status, at), activeForm: todo.activeForm }
303 }),
304 ))
305
306 return ran
307 })
308
309 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
310 const ran = await next(e)
311 if (ran.deny !== undefined || ran.isError) return ran
312
313 const task = ran.result.task
314 await safely($, () => update($, items, list => [
315 ...list.filter(item => item.id !== task.id),
316 {
317 ...fresh(task.id, task.subject, 'pending', Date.now()),
318 activeForm: e.activeForm,
319 description: e.description,
320 ...readMeta(e.metadata),
321 },
322 ]))
323
324 return ran
325 })
326
327 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
328 const ran = await next(e)
329 if (ran.deny !== undefined || ran.isError || !ran.result.success) return ran
330
331 const at = Date.now()
332 const status = e.status === 'deleted' ? undefined : e.status
333 await safely($, () => update($, items, list =>
334 e.status === 'deleted'
335 ? list.filter(item => item.id !== e.taskId)
336 : list.map(item => {
337 if (item.id !== e.taskId) return item
338 const moved = status ? withStatus(item, fromTool(status), at) : item
339
340 return {
341 ...mergeMeta(moved, e.metadata),
342 text: e.subject ?? moved.text,
343 description: e.description ?? moved.description,
344 activeForm: e.activeForm ?? moved.activeForm,
345 blockedBy: [...new Set([...moved.blockedBy, ...(e.addBlockedBy ?? [])])],
346 updatedAt: at,
347 }
348 }),
349 ))
350
351 return ran
352 })
353
354 // The tabs select as the focus ring lands on them, so the arrows switch tabs.
355 on('ui.focus', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
356 const tab = FILTERS.find(f => `filter-${f.value}` === e.element)
357 if (tab) await safely($, () => update($, filter, () => tab.value))
358
359 return next(e)
360 })
361
362 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
363 const { Box, Text, Button } = $.ui.resolve(e)
364 const list = await read($, items)
365 const open = await read($, selected)
366 const by = await read($, filter)
367 const isGrouped = by === 'group'
368 // Group and all show top-level tasks with their subtasks nested; the rest are flat.
369 const nest = by === 'group' || by === 'all'
370 const isDoneOpen = await read($, doneOpen)
371 const done = list.filter(item => item.status === 'done').length
372 const current = list.find(item => item.status === 'in_progress')
373 const visible = nest ? list.filter(item => isTop(item, list)) : list.filter(item => matches(item, by))
374 // The pane's own visible rows: header block (5 lines) and one more-line come off the top,
375 // and in the group view every section keeps a header line and a rule below it.
376 const bodyRows = e.props.scroll.bodyRows
377 const sectionCount = isGrouped ? GROUPS.filter(g => visible.some(item => groupOf(item, list) === g.group)).length : 0
378 const room = Math.max(1, bodyRows - 6 - sectionCount * 2)
379 const rule = '─'.repeat(Math.max(10, Math.min(60, e.props.bodyColumns)))
380 const chips = {
381 doing: list.filter(item => item.status === 'in_progress').length,
382 blocked: list.filter(item => groupOf(item, list) === 'blocked').length,
383 deferred: list.filter(item => groupOf(item, list) === 'deferred').length,
384 }
385 const filled = list.length > 0 ? Math.round((10 * done) / list.length) : 0
386
387 // Grouped: ready (doing first), blocked, deferred, done. Flat: the latest items.
388 const sections = isGrouped
389 ? GROUPS.map(({ group, title }) => ({
390 title,
391 rows: visible
392 .filter(item => groupOf(item, list) === group)
393 .sort(
394 (a, b) =>
395 Number(b.status === 'in_progress') - Number(a.status === 'in_progress') ||
396 rank(a) - rank(b),
397 ),
398 })).filter(section => section.rows.length > 0)
399 : [{ title: '', rows: visible.length > room ? visible.slice(-room) : visible }]
400 let budget = isGrouped ? room : Infinity
401 const trimmed = sections.map(section => {
402 // The Done group stays folded until opened; its header still counts it.
403 const isFolded = isGrouped && section.title === 'Done' && !isDoneOpen
404 const rows = isFolded ? [] : section.rows.slice(0, Math.max(0, budget))
405 budget -= rows.length
406
407 return { ...section, rows, total: section.rows.length, isFolded }
408 })
409 const hidden = isGrouped
410 ? trimmed.reduce((n, section) => n + (section.isFolded ? 0 : section.total - section.rows.length), 0)
411 : visible.length - (sections[0]?.rows.length ?? 0)
412 // A finished parent folds its subtasks behind the rollup, unless it is opened.
413 const fold = (one: TodoItem) => one.status === 'done' && open !== one.id
414
415 // Numbers are right-aligned to the widest one in the whole list, so tabs don't shift them.
416 const labelWidth = Math.max(1, ...list.map(one => label(one, list).length))
417
418 // What a clicked task opens: its description, its facts, and its timeline.
419 const card = (item: TodoItem) => {
420 const parent = list.find(one => one.id === item.parent && one.id !== item.id)
421 const facts = [
422 item.priority ? `${item.priority} priority` : '',
423 item.mandatory ? 'mandatory' : '',
424 item.deferred ? 'deferred' : '',
425 parent ? `subtask of ${label(parent, list)}` : '',
426 ].filter(Boolean)
427
428 return [
429 item.description ?? '',
430 facts.join(' · '),
431 item.history.map(step => `${LABEL[step.status]} ${clock(step.at)}`).join(' → '),
432 ].filter(Boolean)
433 }
434
435 const row = (item: TodoItem, depth: number) => {
436 const waits = openBlockers(item, list)
437 const kids = nest ? list.filter(kid => kid.parent === item.id && kid.id !== item.id) : []
438
439 return (
440 <Box flexDirection="column" paddingLeft={depth * 2}>
441 <Button
442 key={item.id}
443 plain
444 onPress={() => update($, selected, now => (now === item.id ? null : item.id))}
445 >
446 {depth > 0 && <Text dimColor>{'↳ '}</Text>}
447 {item.priority ? (
448 <Text color={PRIORITY_COLOR[item.priority]}>{'● '}</Text>
449 ) : (
450 <Text dimColor>{'· '}</Text>
451 )}
452 {/* One status slot: done, blocked, doing or pending, so every row lines up. */}
453 {item.status === 'done' ? (
454 <Text bold color="green">
455 {'✓ '}
456 </Text>
457 ) : waits.length > 0 ? (
458 <Text bold color="red">
459 {'! '}
460 </Text>
461 ) : item.status === 'in_progress' ? (
462 <Text color="cyan">{'◐ '}</Text>
463 ) : (
464 <Text>{'○ '}</Text>
465 )}
466 <Text
467 bold={item.status === 'in_progress'}
468 dimColor={item.status === 'done'}
469 color={item.status === 'in_progress' ? 'cyan' : undefined}
470 >
471 {label(item, list).padStart(labelWidth)} {badge(item) ? `${badge(item)} ` : ''}{item.text}
472 </Text>
473 </Button>
474 {kids.length > 0 && (
475 <Text dimColor>
476 {' '}
477 {kids.filter(kid => kid.status === 'done').length}/{kids.length} subtasks
478 </Text>
479 )}
480 {waits.length > 0 && (
481 <Text dimColor>
482 {' '}waits for {waits.map(id => { const blocker = list.find(one => one.id === id); return blocker ? label(blocker, list) : id }).join(', ')}
483 </Text>
484 )}
485 {open === item.id &&
486 card(item).map((line, i, lines) => (
487 <Text dimColor>
488 {' '}
489 {i === lines.length - 1 ? '╰ ' : '│ '}
490 {line}
491 </Text>
492 ))}
493 </Box>
494 )
495 }
496
497 return (
498 <Box flexDirection="column">
499 <Box>
500 <Text color="green">{'█'.repeat(filled)}</Text>
501 <Text dimColor>{'░'.repeat(10 - filled)}</Text>
502 <Text bold>{` ${done}/${list.length} done`}</Text>
503 {chips.doing > 0 && <Text color="cyan">{` ◐ ${chips.doing}`}</Text>}
504 {chips.blocked > 0 && <Text color="red">{` ! ${chips.blocked}`}</Text>}
505 {chips.deferred > 0 && <Text dimColor>{` ◌ ${chips.deferred}`}</Text>}
506 </Box>
507 <Text color="cyan">{current ? `◐ ${current.activeForm ?? current.text}` : ' '}</Text>
508 <Text dimColor>{rule}</Text>
509 <Box>
510 {FILTERS.map((f, i) => {
511 const count = list.filter(item => matches(item, f.value)).length
512
513 return (
514 <Button
515 key={`filter-${f.value}`}
516 plain
517 hotkey={String(i + 1)}
518 onPress={() => update($, filter, () => f.value)}
519 >
520 <Text bold={by === f.value} inverse={by === f.value} dimColor={by !== f.value}>
521 {` ${f.label}${f.value === 'group' ? '' : ` ${count}`} `}
522 </Text>
523 <Text>{' '}</Text>
524 </Button>
525 )
526 })}
527 </Box>
528 <Text dimColor>{rule}</Text>
529 {visible.length === 0 && <Text dimColor>No todos here.</Text>}
530 {!isGrouped && hidden > 0 && <Text dimColor>… {hidden} earlier</Text>}
531 {trimmed.map((section, i) => (
532 <Box flexDirection="column">
533 {isGrouped && i > 0 && <Text dimColor>{rule}</Text>}
534 {isGrouped && section.title === 'Done' && (
535 <Button key="done-toggle" plain hotkey="d" onPress={() => update($, doneOpen, was => !was)}>
536 <Text bold dimColor>
537 {section.isFolded ? '▸' : '▾'} Done ({section.total})
538 </Text>
539 </Button>
540 )}
541 {isGrouped && section.title !== 'Done' && (
542 <Text bold dimColor>
543 {section.title} ({section.total})
544 </Text>
545 )}
546 {section.rows
547 .flatMap(top => (nest ? flatten(top, list, 0, fold) : [{ item: top, depth: 0 }]))
548 .map(r => row(r.item, r.depth))}
549 </Box>
550 ))}
551 {isGrouped && hidden > 0 && <Text dimColor>… {hidden} more</Text>}
552 </Box>
553 )
554 })
555}
556types/index.d.ts 40 lines1export type TodoStatus = 'pending' | 'in_progress' | 'done'
2
3/** One status change of a todo, kept so intermediate steps are not lost. */
4export type TodoTransition = { status: TodoStatus; at: number }
5
6export type TodoItem = {
7 id: string
8 text: string
9 /** Present-continuous label ("Running tests"), when the tool gave one. */
10 activeForm?: string
11 /** Longer description, from TaskCreate. */
12 description?: string
13 blockedBy: string[]
14 priority?: TodoPriority
15 mandatory?: boolean
16 /** Id of the parent task, from metadata.parent: this item is its subtask. */
17 parent?: string
18 /** From metadata.deferred: parked on purpose, shown in its own group. */
19 deferred?: boolean
20 status: TodoStatus
21 updatedAt: number
22 history: TodoTransition[]
23}
24
25/** Set by Claude through task metadata: `priority` and `mandatory`. */
26export type TodoPriority = 'high' | 'normal' | 'low'
27
28export type TodoFilter = 'group' | 'all' | TodoStatus
29
30declare module 'claude-code' {
31 interface PluginState {
32 'todo-pane': {
33 items: TodoItem[]
34 selectedId: string | null
35 filter: TodoFilter
36 doneOpen: boolean
37 }
38 }
39}
40