SLOPSHOPPER

todo-pane

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

newpaneguardcommandstatusprompt
v0.2.0no licenseupdated 2026-10-26lpareja99/claude-mods/todo-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · todo-pane
│ ┃ Todos ✕ › fix the failing auth test and add an audit log call │ ┃ ░░░░░░░░░░ 0/0 done │ ┃ ⏺ Read(src/auth.ts) │ ┃ ──────────────────────────────────────────── ⎿ Read 6 lines │ ┃ ──────────── ⏺ Update(src/auth.ts) │ ┃ 1: filter-group2: filter-pending3: filter-do ⎿ Added 2 lines, removed 1 line │ ┃ ──────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ ──────────── ⎿ 3 pass, 1 fail │ ┃ No todos here. │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /todos │ ⎿ todo-pane: Todos pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Todos
░░░░░░░░░░ 0/0 done ──────────────────────────────────────────────────────── 1: filter-group2: filter-pending3: filter-done4: filter-all ──────────────────────────────────────────────────────── No todos here.
README

todo-pane

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.

Install

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.

What you see

███████░░░ 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.

MarkMeaning
● red / yellow / greenhigh / normal / low priority
· dimno priority set
✓ greendone
! redblocked: it waits for tasks that are not done
◐ cyanin progress
○pending
★mandatory: cannot be skipped
↳ and 12.1a 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.

How it gets its data

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:

MetadataValues
priority"high", "normal", "low"
mandatorytrue
parentid of the parent task
deferredtrue

You can also ask Claude directly to set them on existing tasks.

Following a list switch

/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.

Limits

  • The pane is read-only: clicking opens a card, it does not change tasks.
  • It sees task changes made after it loaded. At session start and on /todos-sync it reads the existing list; without a pinned list that read has no metadata, so older tasks show without priority.
  • A reload or /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 following the metadata guidance is likely but not guaranteed.
  • Built and tested against Claude Code 2.1.304.

Develop

claude plugin validate .
claude plugin test .

The tests are in hooks/register.test.tsx. Edit the files, then run /reload-plugins.

Author

Laura Pareja Prieto

Source 2 files
hooks/register.tsx 556 lines
1import { 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}
556
types/index.d.ts 40 lines
1export 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