Live pane with the session's tasks (done / in progress / pending) and a little critter for every agent working on them

A Claude Code mod that opens a live Tareas pane beside the conversation:
It follows TaskCreate / TaskUpdate, TodoWrite and the desktop app's plan, including calls made inside subagents. When a session is resumed it rebuilds the board from the transcript and the session's agent list.
The pane opens by itself where the window is wide enough; /tablero opens it at any width. In a terminal it draws a text version.
At the prompt of a Claude Code terminal session:
/plugin install task-board --marketplace angelitolm/task-board
Answer y to add the marketplace, then pick the user scope so it loads in every session.
From a shell:
claude plugin install task-board --marketplace angelitolm/task-board
In the Claude desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with angelitolm/task-board, then Add plugin.
claude plugin validate .
claude plugin test .
Icons from Iconsax (iconsax-react, MIT License, © Erfan Khadivar).
hooks/register.tsx 366 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentSeen, Task, TaskStatus } from '../types'
5import { bar, colorFor, icon, MAIN_COLOR, statusGlyph, toy } from './art'
6import type { ToyMood } from './art'
7
8const PANE = 'task-board'
9const PLAN_TOOL = 'mcp__ccd_turn__update_plan'
10const tasks = atom({ plugin: 'task-board', key: 'tasks' } as const, [])
11const agents = atom({ plugin: 'task-board', key: 'agents' } as const, [])
12
13const MAIN = 'main'
14// Bookkeeping never blocks a tool call or a spawn: a failed write only leaves the board stale.
15const safe = (p: Promise<unknown>) => p.catch(() => undefined)
16const MARK: Record<TaskStatus, string> = { completed: '✅', in_progress: '🔄', pending: '⏳' }
17
18// ponytail: icon by keyword in the agent type; extend the table for new agent types
19const ICONS: [RegExp, string][] = [
20 [/explore|investigat|locator/i, '🔍'],
21 [/plan|architect/i, '🧭'],
22 [/review|audit/i, '🧐'],
23 [/test|tdd|e2e/i, '🧪'],
24 [/build|fix|resolver/i, '🔧'],
25 [/doc|writer/i, '📝'],
26 [/secur/i, '🛡️'],
27]
28export const iconFor = (type: string): string =>
29 type === MAIN ? '🧠' : (ICONS.find(([re]) => re.test(type))?.[1] ?? '🤖')
30
31const addAgent = (list: string[], loop: string) => (list.includes(loop) ? list : [...list, loop])
32
33/** Replaces one source's tasks (ids starting with `prefix`), keeping the rest. */
34export const replaceSource = (
35 all: Task[],
36 prefix: string,
37 items: { title: string; status: TaskStatus }[],
38 loop: string,
39): Task[] => {
40 const old = new Map(all.filter(t => t.id.startsWith(prefix)).map(t => [t.id, t]))
41 const fresh = items.map((it, i): Task => {
42 const id = prefix + i
43 const kept = old.get(id)?.agents ?? []
44 return { id, title: it.title, status: it.status, agents: it.status === 'pending' ? kept : addAgent(kept, loop), loop }
45 })
46 return [...all.filter(t => !t.id.startsWith(prefix)), ...fresh]
47}
48
49/** Applies an `update_plan` call (items | advance | done/start) to the plan tasks. */
50export const applyPlan = (all: Task[], input: Record<string, unknown>, loop: string): Task[] => {
51 const prefix = `plan:${loop}:`
52 if (Array.isArray(input.items)) {
53 const items = (input.items as { content?: string; status?: TaskStatus }[]).map(it => ({
54 title: String(it.content ?? ''),
55 status: it.status ?? 'pending',
56 }))
57 return replaceSource(all, prefix, items, loop)
58 }
59 const plan = all.filter(t => t.id.startsWith(prefix))
60 const items = plan.map(t => ({ title: t.title, status: t.status }))
61 const set = (n: number, status: TaskStatus) => {
62 if (items[n]) items[n] = { ...items[n], status }
63 }
64 if (input.advance === true) {
65 const cur = items.findIndex(t => t.status === 'in_progress')
66 if (cur >= 0) set(cur, 'completed')
67 const nxt = items.findIndex(t => t.status === 'pending')
68 if (nxt >= 0) set(nxt, 'in_progress')
69 }
70 for (const n of Array.isArray(input.done) ? (input.done as number[]) : []) set(n - 1, 'completed')
71 if (typeof input.start === 'number') set(input.start - 1, 'in_progress')
72 return replaceSource(all, prefix, items, loop)
73}
74
75const setTask = (all: Task[], id: string, patch: Partial<Task>, loop: string): Task[] =>
76 all.map(t =>
77 t.id !== id
78 ? t
79 : { ...t, ...patch, agents: patch.status === 'in_progress' ? addAgent(t.agents, loop) : t.agents },
80 )
81
82const TRACKED = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', PLAN_TOOL])
83
84/** One task-tool call applied to the board; `result` only matters for TaskCreate (its id). */
85export const applyCall = (all: Task[], tool: string, input: Record<string, unknown>, loop: string, result?: unknown): Task[] => {
86 if (tool === 'TodoWrite') {
87 const todos = (Array.isArray(input.todos) ? input.todos : []) as { content: string; status: TaskStatus; activeForm?: string }[]
88 const items = todos.map(t => ({ title: t.status === 'in_progress' ? t.activeForm || t.content : t.content, status: t.status }))
89 return replaceSource(all, `todo:${loop}:`, items, loop)
90 }
91 if (tool === PLAN_TOOL) return applyPlan(all, input, loop)
92 if (tool === 'TaskUpdate') {
93 const id = `task:${String(input.taskId)}`
94 if (input.status === 'deleted') return all.filter(t => t.id !== id)
95 const patch: Partial<Task> = {}
96 if (typeof input.status === 'string') patch.status = input.status as TaskStatus
97 if (typeof input.subject === 'string') patch.title = input.subject
98 return setTask(all, id, patch, typeof input.owner === 'string' ? input.owner : loop)
99 }
100 if (tool === 'TaskCreate') {
101 const id = (result as { task?: { id?: string } } | undefined)?.task?.id
102 return id ? [...all, { id: `task:${id}`, title: String(input.subject ?? ''), status: 'pending', agents: [], loop }] : all
103 }
104 return all
105}
106
107const base = (p: unknown) => String(p ?? '').split(/[\\/]/).pop() || ''
108const cut = (s: string, n = 60) => (s.length > n ? s.slice(0, n - 1) + '…' : s)
109
110/** One tool call in plain words, for the line under an agent: "Editando register.tsx". */
111export const describe = (tool: string, input: Record<string, unknown>): string => {
112 const str = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
113 switch (tool) {
114 case 'Read': return `Leyendo ${base(input.file_path)}`
115 case 'Edit': case 'MultiEdit': case 'Write': case 'NotebookEdit': return `Editando ${base(input.file_path ?? input.notebook_path)}`
116 case 'Bash': case 'PowerShell': return cut(str('description') || `Ejecutando ${str('command')}`)
117 case 'Grep': return cut(`Buscando "${str('pattern')}"`)
118 case 'Glob': return cut(`Buscando archivos ${str('pattern')}`)
119 case 'WebFetch': return cut(`Leyendo ${str('url').replace(/^https?:\/\//, '').split('/')[0]}`)
120 case 'WebSearch': return cut(`Buscando en la web: ${str('query')}`)
121 case 'Agent': return cut(`Delegando: ${str('description')}`)
122 case 'Skill': return `Usando la skill ${str('skill')}`
123 }
124 if (TRACKED.has(tool)) return 'Actualizando su plan'
125 if (tool.startsWith('mcp__')) return cut(`Usando ${tool.split('__').pop()}`)
126 return tool
127}
128
129/** Claude's own row lives in the same list under id `main`, made on its first call. */
130export const setActivity = (list: AgentSeen[], id: string, activity: string | undefined): AgentSeen[] =>
131 list.some(a => a.id === id)
132 ? list.map(a => (a.id === id ? { ...a, activity } : a))
133 : id === MAIN
134 ? [...list, { id: MAIN, type: MAIN, name: 'Claude', isDone: false, activity }]
135 : list
136
137const DONE_STATUSES = new Set(['completed', 'failed', 'killed', 'idle'])
138
139/**
140 * A resumed or restarted session: replay the main transcript's task calls and take the
141 * agents from the engine's own list, so the board shows what happened before it loaded.
142 */
143const rebuild = async ($: EngineInterface) => {
144 const held = await read($, tasks)
145 if (held.length === 0) {
146 const rows = await $.session.messages()
147 let board: Task[] = []
148 for (const row of rows) for (const use of row.toolUses) if (TRACKED.has(use.tool)) board = applyCall(board, use.tool, use.input, MAIN, use.result)
149 if (board.length > 0) await update($, tasks, () => board)
150 }
151 const list = await $.agent.list()
152 const fresh: AgentSeen[] = list.map(a => ({
153 id: a.id,
154 type: a.type,
155 name: agentName(a.name, a.description, a.type),
156 isDone: DONE_STATUSES.has(a.status),
157 }))
158 await update($, agents, old => [...old.filter(o => !fresh.some(f => f.id === o.id)), ...fresh].slice(-100))
159}
160
161export const register: Register = on => {
162 on('session.start', async ($, e, next) => {
163 // Open first: a refused command must never keep the pane closed.
164 void $.ui.open({ id: PANE, title: 'Tareas' })
165 await safe($.command.register({ name: 'tablero', description: 'Abre el panel de tareas en vivo' }))
166 // Off the start path: reading history must never slow the session down.
167 void safe(rebuild($))
168 return next(e)
169 })
170
171 on('command.run', { command: 'tablero' }, async $ => {
172 const opened = await $.ui.open({ id: PANE, title: 'Tareas' })
173 return { text: opened.isPlaced ? 'Panel de tareas abierto.' : `Panel no colocado: ${opened.reason}` }
174 })
175
176 on('tool.call', async ($, e, next) => {
177 const tool = String(e.tool)
178 const loop = e.agentId ?? MAIN
179 const input = e as unknown as Record<string, unknown>
180 const activity = describe(tool, input)
181 await safe(update($, agents, list => setActivity(list, loop, activity)))
182 if (!TRACKED.has(tool)) return next(e)
183 // TaskCreate learns its id from the result; the others act on what they were asked.
184 if (tool === 'TaskCreate') {
185 const ran = await next(e)
186 await safe(update($, tasks, all => applyCall(all, tool, input, loop, ran.result)))
187 return ran
188 }
189 await safe(update($, tasks, all => applyCall(all, tool, input, loop)))
190 return next(e)
191 })
192
193 on('agent.spawn', async ($, e, next) => {
194 const ran = await next(e)
195 const id = 'agentId' in ran ? ran.agentId : undefined
196 if (!id) return ran
197 const parent = e.parentAgentId ?? MAIN
198 const seen: AgentSeen = { id, type: e.subagentType, name: agentName(e.name, e.description, e.subagentType), isDone: false }
199 await safe(update($, agents, list => [...list.filter(a => a.id !== id), seen].slice(-100)))
200 // The spawned agent works on whatever its parent has in progress right now.
201 await safe(update($, tasks, all =>
202 all.map(t => (t.status === 'in_progress' && t.agents.includes(parent) ? { ...t, agents: addAgent(t.agents, id) } : t)),
203 ))
204 return ran
205 })
206
207 on('turn.complete', async ($, e, next) => {
208 const id = e.agentId
209 if (id) await safe(update($, agents, list => list.map(a => (a.id === id ? { ...a, isDone: true, activity: undefined } : a))))
210 else await safe(update($, agents, list => setActivity(list, MAIN, undefined)))
211 return next(e)
212 })
213
214 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
215 const all = await read($, tasks)
216 const seen = await read($, agents)
217 const byId = new Map<string, AgentSeen>(seen.map(a => [a.id, a]))
218 // Your plan is what the main loop wrote; a subagent's own steps nest under that agent.
219 const list = all.filter(t => (t.loop ?? MAIN) === MAIN)
220 const stepsOf = (id: string) => all.filter(t => t.loop === id)
221 const count = (s: TaskStatus) => list.filter(t => t.status === s).length
222 const mainBusy = list.some(t => t.status === 'in_progress' && t.agents.includes(MAIN))
223 const claude: AgentSeen = { id: MAIN, type: MAIN, name: 'Claude', isDone: false, ...byId.get(MAIN) }
224 const subagents = seen.filter(a => a.id !== MAIN)
225 // Team: Claude first, then running agents, then the last few that finished.
226 const team: AgentSeen[] = [claude, ...subagents.filter(a => !a.isDone), ...subagents.filter(a => a.isDone).slice(-4)]
227 // Under a task in progress: every subagent that worked on it, or Claude when it works alone.
228 const crewOf = (t: Task): AgentSeen[] => {
229 const subs = t.agents.filter(id => id !== MAIN).map(id => byId.get(id)).filter((a): a is AgentSeen => !!a)
230 return subs.length > 0 ? subs : t.agents.includes(MAIN) ? [claude] : []
231 }
232 const placed = new Set(list.filter(t => t.status === 'in_progress').flatMap(t => t.agents))
233 const loose = subagents.filter(a => !a.isDone && !placed.has(a.id))
234 const lineOf = (a: AgentSeen) => (a.isDone ? 'Terminado' : (a.activity ?? (a.id === MAIN ? 'Pensando' : 'Arrancando')))
235
236 if (e.surface === 'terminal') {
237 const { Box, Text } = $.ui.resolve(e)
238 const agentLines = (a: AgentSeen) => (
239 <Box flexDirection="column" paddingLeft={3}>
240 <Text dimColor={a.isDone}>
241 {a.isDone ? '✓' : '↳'} {iconFor(a.type)} {a.name}
242 <Text dimColor> — {lineOf(a)}</Text>
243 </Text>
244 {stepsOf(a.id).map(st => (
245 <Text dimColor>
246 {' '}
247 {MARK[st.status]} {st.title}
248 </Text>
249 ))}
250 </Box>
251 )
252 return (
253 <Box flexDirection="column">
254 <Text bold>
255 ✅ {count('completed')} 🔄 {count('in_progress')} ⏳ {count('pending')}
256 </Text>
257 {list.length === 0 && <Text dimColor>Sin tareas todavía.</Text>}
258 {list.map(t => (
259 <Box flexDirection="column">
260 <Text dimColor={t.status === 'completed'} bold={t.status === 'in_progress'}>
261 {MARK[t.status]} {t.title}
262 </Text>
263 {t.status === 'in_progress' && crewOf(t).map(agentLines)}
264 </Box>
265 ))}
266 {loose.length > 0 && <Text dimColor>Sin tarea asignada</Text>}
267 {loose.map(agentLines)}
268 </Box>
269 )
270 }
271
272 const { Box, Text, Svg } = $.ui.resolve(e)
273 const done = count('completed')
274 const total = list.length
275 const toyOf = (a: AgentSeen, mood: ToyMood) => toy(a.id === MAIN ? MAIN_COLOR : colorFor(a.id), mood)
276 const moodOf = (a: AgentSeen): ToyMood => (a.isDone ? 'done' : a.id === MAIN && !mainBusy ? 'idle' : 'working')
277 const LABEL: Record<TaskStatus, string> = { completed: 'Completada', in_progress: 'En curso', pending: 'Pendiente' }
278
279 // One agent under a task: its critter, name and live activity, then its own steps.
280 const agentRow = (a: AgentSeen, scope: string) => (
281 <Box key={`${scope}-${a.id}`} flexDirection="column" paddingLeft={3}>
282 <Box flexDirection="row" alignItems="center" gap={1}>
283 <Svg source={toyOf(a, moodOf(a))} alt={a.name} width={22} height={19} isInteractive={!a.isDone} />
284 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
285 <Text size="footnote" weight="medium" dimColor={a.isDone} wrap="truncate-end">
286 {a.name}
287 </Text>
288 <Text size="caption" dimColor wrap="truncate-end">
289 {lineOf(a)}
290 </Text>
291 </Box>
292 </Box>
293 {stepsOf(a.id).map(st => (
294 <Box key={`${scope}-${a.id}-${st.id}`} flexDirection="row" alignItems="center" gap={1} paddingLeft={4}>
295 <Svg source={statusGlyph[st.status]} alt={LABEL[st.status]} width={12} height={12} isInteractive={st.status === 'in_progress'} />
296 <Text size="caption" dimColor={st.status !== 'in_progress'} wrap="truncate-end">
297 {st.title}
298 </Text>
299 </Box>
300 ))}
301 </Box>
302 )
303
304 return (
305 <Box flexDirection="column" gap={1}>
306 <Box flexDirection="column" gap={0}>
307 <Box flexDirection="row" alignItems="center" gap={1}>
308 <Svg source={icon('task', '#C9A4FF')} alt="" width={18} height={18} />
309 <Text weight="semibold">Progreso</Text>
310 <Text dimColor tabular>
311 {done}/{total}
312 </Text>
313 </Box>
314 <Svg source={bar(done, total)} alt={`${done} de ${total} completadas`} height={6} />
315 </Box>
316
317 <Box flexDirection="column" gap={0}>
318 <Box flexDirection="row" alignItems="center" gap={1}>
319 <Svg source={icon('people', '#F4B39A')} alt="" width={18} height={18} />
320 <Text weight="semibold">Equipo</Text>
321 </Box>
322 <Box flexDirection="row" flexWrap="wrap" gap={1}>
323 {team.map(a => (
324 <Box key={`toy-${a.id}`} flexDirection="column" alignItems="center" width={10}>
325 <Svg source={toyOf(a, moodOf(a))} alt={`${a.name}: ${moodOf(a)}`} width={52} height={45} isInteractive />
326 <Text size="footnote" weight={a.isDone ? 'regular' : 'medium'} dimColor={a.isDone} wrap="truncate-end">
327 {a.name}
328 </Text>
329 </Box>
330 ))}
331 </Box>
332 </Box>
333
334 <Box flexDirection="column" borderStyle="round" paddingX={2} paddingY={1} gap={0}>
335 {list.length === 0 && <Text dimColor>Sin tareas todavía.</Text>}
336 {list.map(t => (
337 <Box key={`task-${t.id}`} flexDirection="column">
338 <Box flexDirection="row" alignItems="center" gap={1}>
339 <Svg source={statusGlyph[t.status]} alt={LABEL[t.status]} width={16} height={16} isInteractive={t.status === 'in_progress'} />
340 <Box flexGrow={1} flexShrink={1}>
341 <Text dimColor={t.status === 'completed'} wrap="truncate-end">
342 {t.title}
343 </Text>
344 </Box>
345 </Box>
346 {t.status === 'in_progress' && crewOf(t).map(a => agentRow(a, t.id))}
347 </Box>
348 ))}
349 {loose.length > 0 && (
350 <Box flexDirection="column" marginTop={1}>
351 <Text size="footnote" dimColor>
352 Sin tarea asignada
353 </Text>
354 {loose.map(a => agentRow(a, 'loose'))}
355 </Box>
356 )}
357 </Box>
358 </Box>
359 )
360 })
361}
362
363/** What the toy is called: the agent's own name, else its short task, else its type. */
364export const agentName = (name: string | undefined, description: string | undefined, type: string): string =>
365 cut((name || description || type).trim(), 40)
366hooks/art.ts 78 lines1// Icons: Iconsax "Bulk" variant (iconsax-react 0.0.8, MIT, https://iconsax-react.pages.dev/). `C` is the color slot.
2const ICONSAX = {
3 tick: '<path opacity=".4" d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Z" fill="C"/><path d="M10.58 15.582a.75.75 0 0 1-.53-.22l-2.83-2.83a.754.754 0 0 1 0-1.06c.29-.29.77-.29 1.06 0l2.3 2.3 5.14-5.14c.29-.29.77-.29 1.06 0 .29.29.29.77 0 1.06l-5.67 5.67a.75.75 0 0 1-.53.22Z" fill="C"/>',
4 refresh: '<path fill="C" d="M14.55 22.42c-.33 0-.64-.22-.73-.56-.1-.4.13-.81.53-.92a9.256 9.256 0 006.89-8.95c0-5.1-4.15-9.25-9.25-9.25-4.33 0-7.17 2.53-8.5 4.06h2.94c.41 0 .75.34.75.75s-.32.76-.74.76H2.01c-.07 0-.14-.01-.21-.03a.899.899 0 01-.24-.12.659.659 0 01-.21-.23c-.05-.1-.09-.2-.1-.31V3c0-.41.34-.75.75-.75s.75.34.75.75v2.39C4.38 3.64 7.45 1.25 12 1.25c5.93 0 10.75 4.82 10.75 10.75 0 4.88-3.29 9.16-8.01 10.4-.06.01-.13.02-.19.02z" opacity=".4"/><path fill="C" d="M11.29 22.73c-.02 0-.05 0-.07-.01-1.07-.07-2.12-.31-3.12-.7a.751.751 0 01-.43-.97c.15-.38.59-.58.97-.43.86.34 1.77.54 2.69.61h.01c.4.02.7.36.7.76v.04a.76.76 0 01-.75.7zm-5.51-2.15c-.17 0-.33-.05-.47-.16-.84-.67-1.57-1.46-2.18-2.35a.73.73 0 01.19-1.04.76.76 0 011.04.19c.53.77 1.16 1.45 1.89 2.02.17.14.28.35.28.58 0 .17-.05.34-.16.48-.14.18-.36.28-.59.28zM2.44 15.7c-.33 0-.62-.21-.71-.52-.32-1.03-.48-2.1-.48-3.18 0-.41.34-.75.75-.75s.75.34.75.75c0 .93.14 1.85.41 2.73.02.07.03.15.03.23 0 .33-.21.61-.52.71-.08.02-.15.03-.23.03z"/><path fill="C" d="M12 16a4 4 0 100-8 4 4 0 000 8z" opacity=".4"/>',
5 timer: '<path opacity=".4" d="M12 22a8.67 8.67 0 1 0 0-17.34A8.67 8.67 0 0 0 12 22Z" fill="C"/><path d="M12 13.75c-.41 0-.75-.34-.75-.75V8c0-.41.34-.75.75-.75s.75.34.75.75v5c0 .41-.34.75-.75.75ZM14.89 3.45H9.11c-.4 0-.72-.32-.72-.72 0-.4.32-.73.72-.73h5.78c.4 0 .72.32.72.72 0 .4-.32.73-.72.73Z" fill="C"/>',
6 task: '<path opacity=".4" d="M14.19.969H5.81C2.17.969 0 3.139 0 6.779v8.38c0 3.64 2.17 5.81 5.81 5.81h8.38c3.64 0 5.81-2.17 5.81-5.81v-8.38c0-3.64-2.17-5.81-5.81-5.81Z" fill="C" transform="translate(2 1)"/><path d="M16.31 7.84c0 .41-.33.75-.75.75h-5.25c-.41 0-.75-.34-.75-.75s.34-.75.75-.75h5.25c.42 0 .75.34.75.75ZM7.97 6.867l-2.25 2.25c-.15.15-.34.22-.53.22s-.39-.07-.53-.22l-.75-.75c-.3-.29-.3-.77 0-1.06.29-.29.76-.29 1.06 0l.22.22 1.72-1.72c.29-.29.76-.29 1.06 0 .29.29.29.77 0 1.06ZM16.31 14.84c0 .41-.33.75-.75.75h-5.25c-.41 0-.75-.34-.75-.75s.34-.75.75-.75h5.25c.42 0 .75.34.75.75ZM7.97 13.867l-2.25 2.25c-.15.15-.34.22-.53.22s-.39-.07-.53-.22l-.75-.75c-.3-.29-.3-.77 0-1.06.29-.29.76-.29 1.06 0l.22.22 1.72-1.72c.29-.29.76-.29 1.06 0 .29.29.29.77 0 1.06Z" fill="C" transform="translate(2 1)"/>',
7 people: '<path opacity=".4" d="M17.53 7.77a.739.739 0 0 0-.21 0 2.874 2.874 0 0 1-2.78-2.88C14.54 3.3 15.83 2 17.43 2c1.59 0 2.89 1.29 2.89 2.89a2.89 2.89 0 0 1-2.79 2.88ZM20.792 14.7c-1.12.75-2.69 1.03-4.14.84.38-.82.58-1.73.59-2.69 0-1-.22-1.95-.64-2.78 1.48-.2 3.05.08 4.18.83 1.58 1.04 1.58 2.75.01 3.8ZM6.438 7.77c.07-.01.14-.01.21 0a2.874 2.874 0 0 0 2.78-2.88C9.428 3.3 8.138 2 6.538 2c-1.59 0-2.89 1.29-2.89 2.89a2.89 2.89 0 0 0 2.79 2.88ZM6.551 12.85c0 .97.21 1.89.59 2.72-1.41.15-2.88-.15-3.96-.86-1.58-1.05-1.58-2.76 0-3.81 1.07-.72 2.58-1.01 4-.85-.41.84-.63 1.79-.63 2.8Z" fill="C"/><path d="M12.12 15.87a1.13 1.13 0 0 0-.26 0 3.425 3.425 0 0 1-3.31-3.43c0-1.9 1.53-3.44 3.44-3.44 1.9 0 3.44 1.54 3.44 3.44 0 1.86-1.46 3.37-3.31 3.43ZM8.87 17.94c-1.51 1.01-1.51 2.67 0 3.67 1.72 1.15 4.54 1.15 6.26 0 1.51-1.01 1.51-2.67 0-3.67-1.71-1.15-4.53-1.15-6.26 0Z" fill="C"/>',
8} as const
9
10export type IconName = keyof typeof ICONSAX
11
12export const icon = (name: IconName, color: string): string =>
13 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">${ICONSAX[name].replaceAll('"C"', `"${color}"`)}</svg>`
14
15// Toys: one soft blob per agent, its color fixed by its id.
16const PALETTE = ['#F2EDE6', '#FFD6A5', '#CDE7FF', '#D7F5D0', '#F9D0E3', '#E3D9FF', '#FFF1A8', '#C9F1EC']
17export const MAIN_COLOR = '#D97757'
18
19export const colorFor = (id: string): string => {
20 let h = 0
21 for (const ch of id) h = (h * 31 + ch.charCodeAt(0)) >>> 0
22 return PALETTE[h % PALETTE.length] ?? PALETTE[0]!
23}
24
25export type ToyMood = 'working' | 'idle' | 'done'
26
27/**
28 * A boxy critter in the spirit of Claude's mascot: square body, side nubs, four legs.
29 * Working: it walks (legs alternate, body bobs) and blinks. Done: eyes shut, faded.
30 */
31export const toy = (color: string, mood: ToyMood): string => {
32 const eyes =
33 mood === 'done'
34 ? '<path d="M20 22h5M35 22h5" stroke="#2a2522" stroke-width="2.4" stroke-linecap="round"/>'
35 : `<rect x="20" y="17" width="4" height="8" rx="1" fill="#2a2522">${BLINK}</rect><rect x="36" y="17" width="4" height="8" rx="1" fill="#2a2522">${BLINK}</rect>`
36 const walking = mood === 'working'
37 const step = (begin: string) =>
38 walking
39 ? `<animateTransform attributeName="transform" type="translate" values="0 0;0 -3;0 0" dur="0.5s" begin="${begin}" repeatCount="indefinite"/>`
40 : ''
41 const bob = walking
42 ? '<animateTransform attributeName="transform" type="translate" values="0 0;0 -1.5;0 0" dur="0.25s" repeatCount="indefinite"/>'
43 : ''
44 const leg = (x: number) => `<rect x="${x}" y="36" width="5" height="9" rx="1" fill="${color}"/>`
45 return (
46 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 52">' +
47 `<ellipse cx="30" cy="48" rx="20" ry="2.4" fill="#000" opacity="${walking ? '.18' : '.3'}"/>` +
48 `<g opacity="${mood === 'done' ? '.7' : '1'}">` +
49 `<g>${step('0s')}${leg(13)}${leg(33)}</g>` +
50 `<g>${step('0.25s')}${leg(22)}${leg(42)}</g>` +
51 `<g>${bob}` +
52 `<rect x="3" y="18" width="7" height="8" rx="1" fill="${color}"/>` +
53 `<rect x="50" y="18" width="7" height="8" rx="1" fill="${color}"/>` +
54 `<rect x="9" y="5" width="42" height="33" rx="3" fill="${color}"/>` +
55 `${eyes}</g></g></svg>`
56 )
57}
58
59const BLINK =
60 '<animate attributeName="height" values="8;8;1;8" keyTimes="0;0.92;0.96;1" dur="3.4s" repeatCount="indefinite"/>'
61
62/** A thin rounded progress bar. */
63export const bar = (done: number, total: number): string => {
64 const w = total === 0 ? 0 : Math.round((done / total) * 200)
65 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 6"><rect width="200" height="6" rx="3" fill="#ffffff" opacity=".12"/><rect width="${w}" height="6" rx="3" fill="#4CC38A"/></svg>`
66}
67
68// Status glyphs in the app's own todo style: grey filled check, spinning arc, dashed ring.
69export const statusGlyph = {
70 completed: icon('tick', '#B9B4AC'),
71 in_progress:
72 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="none" stroke="#ffffff" stroke-opacity=".18" stroke-width="2"/>' +
73 '<path d="M12 4a8 8 0 0 1 8 8" fill="none" stroke="#ECE9E4" stroke-width="2" stroke-linecap="round">' +
74 '<animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="0.9s" repeatCount="indefinite"/></path></svg>',
75 pending:
76 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="none" stroke="#9A968F" stroke-width="1.6" stroke-dasharray="2.6 2.6"/></svg>',
77} as const
78types/index.d.ts 13 lines1export type TaskStatus = 'pending' | 'in_progress' | 'completed'
2/** One task; `agents` lists the loops working on it ('main' or a subagent id), `loop` the one that wrote it. */
3export type Task = { id: string; title: string; status: TaskStatus; agents: string[]; loop?: string }
4/** One agent seen this session. */
5/** `activity`: what its latest tool call is doing, in words. */
6export type AgentSeen = { id: string; type: string; name: string; isDone: boolean; activity?: string }
7
8declare module 'claude-code' {
9 interface PluginState {
10 'task-board': { tasks: Task[]; agents: AgentSeen[] }
11 }
12}
13