SLOPSHOPPER

task-board

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

newpaneguardcommandtimer
v0.1.0no licenseupdated 2026-10-07TeeratP/task-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · task-board
│ ┃ Tasks ✕ › fix the failing auth test and add an audit log call │ ┃ No tasks yet. │ ⏺ Read(src/auth.ts) │ ⎿ 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 │ │ › /tasks │ ⎿ task-board: Task board opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Tasks
No tasks yet.
README

task-board

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

Install

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.

Use

  • The panel opens by itself on terminals 144+ columns wide.
  • Type /tasks to open it at any width.

Update

claude plugin update task-board

then /reload-plugins in a running session.

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