Side panel listing every todo, task and subagent with live status

A Claude Code mod that docks a panel on the right showing every todo, task and subagent in the session, with live status, elapsed time and an overall progress bar.
███████░░░░░░░░░░░░░ 1/3
Todos (1/3)
● Write schema 0s
◐ Migrate data 42s
○ Run tests
○ pending · ◐ running · ● done · ✗ failed
At the Claude Code prompt in a terminal:
/plugin install task-board --marketplace TeeratP/task-board
Answer y to add the marketplace, then press Enter for the user scope. The panel is active immediately and in every new session.
/tasks to open it at any width.claude plugin update task-board
then /reload-plugins in a running session.
hooks/register.tsx 146 lines1import { atom, read, update } from 'claude-code'
2import type { Register, AgentStatus } from 'claude-code'
3
4import type { Item, ItemStatus } from '../types'
5
6const PANE = 'task-board'
7const todos = atom({ plugin: 'task-board', key: 'todos' } as const, [])
8const tasks = atom({ plugin: 'task-board', key: 'tasks' } as const, [])
9const agents = atom({ plugin: 'task-board', key: 'agents' } as const, [])
10
11const fromTodo = (s: string): ItemStatus =>
12 s === 'completed' ? 'done' : s === 'in_progress' ? 'running' : 'pending'
13
14const fromAgent = (s: AgentStatus): ItemStatus =>
15 s === 'completed' || s === 'idle' ? 'done'
16 : s === 'failed' || s === 'killed' ? 'failed'
17 : s === 'pending' ? 'pending' : 'running'
18
19const ICON: Record<ItemStatus, string> = { pending: '○', running: '◐', done: '●', failed: '✗' }
20const COLOR: Record<ItemStatus, string> = { pending: 'gray', running: 'yellow', done: 'green', failed: 'red' }
21
22const elapsed = (it: Item, now: number) => {
23 if (!it.started) return ''
24 const s = Math.round(((it.ended ?? now) - it.started) / 1000)
25 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
26}
27
28export const register: Register = on => {
29 on('session.start', async ($, e, next) => {
30 await $.command.register({ name: 'tasks', description: 'Show all todos, tasks and subagents in a side panel' })
31 void $.ui.open({ id: PANE, title: 'Tasks' })
32
33 // Subagents (incl. background ones) are polled so their status stays live.
34 $.clock.every(2000, async () => {
35 const list = await $.agent.list()
36 const now = await $.clock.now()
37 await update($, agents, prev =>
38 list.map(a => {
39 const old = prev.find(p => p.id === a.id)
40 const status = fromAgent(a.status)
41 const started = old?.started ?? now
42 const ended = status === 'done' || status === 'failed' ? (old?.ended ?? now) : undefined
43 return { id: a.id, kind: 'agent', title: `${a.description} (${a.type})`, status, started, ended }
44 }),
45 )
46 })
47
48 return next(e)
49 })
50
51 on('command.run', { command: 'tasks' }, async $ => {
52 await $.ui.open({ id: PANE, title: 'Tasks' })
53 return { text: 'Task board opened.' }
54 })
55
56 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
57 const ran = await next(e)
58 if (e.tool === 'TodoWrite' && !('deny' in ran && ran.deny)) {
59 const now = await $.clock.now()
60 await update($, todos, prev =>
61 e.todos.map((t, i) => {
62 const old = prev.find(p => p.title === t.content)
63 const status = fromTodo(t.status)
64 const started = old?.started ?? (status !== 'pending' ? now : undefined)
65 const ended = status === 'done' ? (old?.ended ?? now) : undefined
66 return { id: `todo-${i}`, kind: 'todo', title: t.content, status, started, ended }
67 }),
68 )
69 }
70 return ran
71 })
72
73 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
74 const ran = await next(e)
75 if (e.tool === 'TaskCreate' && ran.result && !ran.isError) {
76 const task = ran.result.task
77 await update($, tasks, prev => [...prev, { id: task.id, kind: 'task', title: task.subject, status: 'pending' } satisfies Item])
78 }
79 return ran
80 })
81
82 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
83 const ran = await next(e)
84 if (e.tool === 'TaskUpdate' && ran.result && !ran.isError) {
85 const now = await $.clock.now()
86 await update($, tasks, prev =>
87 e.status === 'deleted'
88 ? prev.filter(t => t.id !== e.taskId)
89 : prev.map(t => {
90 if (t.id !== e.taskId) return t
91 const status = e.status ? fromTodo(e.status) : t.status
92 return {
93 ...t,
94 title: e.subject ?? t.title,
95 status,
96 started: t.started ?? (status !== 'pending' ? now : undefined),
97 ended: status === 'done' ? (t.ended ?? now) : undefined,
98 }
99 }),
100 )
101 }
102 return ran
103 })
104
105 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
106 const { Box, Text } = $.ui.resolve(e)
107 const now = await $.clock.now()
108 const groups: [string, Item[]][] = [
109 ['Todos', await read($, todos)],
110 ['Tasks', await read($, tasks)],
111 ['Subagents', await read($, agents)],
112 ]
113 const all = groups.flatMap(([, items]) => items)
114 const done = all.filter(i => i.status === 'done').length
115 const width = 20
116 const filled = all.length ? Math.round((done / all.length) * width) : 0
117
118 return (
119 <Box flexDirection="column">
120 {all.length === 0 ? (
121 <Text dimColor>No tasks yet.</Text>
122 ) : (
123 <Text>
124 {'█'.repeat(filled)}{'░'.repeat(width - filled)} {done}/{all.length}
125 </Text>
126 )}
127 {groups
128 .filter(([, items]) => items.length > 0)
129 .map(([name, items]) => (
130 <Box flexDirection="column" marginTop={1}>
131 <Text bold>
132 {name} ({items.filter(i => i.status === 'done').length}/{items.length})
133 </Text>
134 {items.map(it => (
135 <Text wrap="truncate-end" dimColor={it.status === 'done'}>
136 <Text color={COLOR[it.status]}>{ICON[it.status]}</Text> {it.title}
137 {it.started ? <Text dimColor> {elapsed(it, now)}</Text> : null}
138 </Text>
139 ))}
140 </Box>
141 ))}
142 </Box>
143 )
144 })
145}
146types/index.d.ts 9 lines1export type ItemStatus = 'pending' | 'running' | 'done' | 'failed'
2export type Item = { id: string; kind: 'todo' | 'task' | 'agent'; title: string; status: ItemStatus; started?: number; ended?: number }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'task-board': { todos: Item[]; tasks: Item[]; agents: Item[] }
7 }
8}
9