A to-do progress bar above the prompt and a pane of pixel creatures, one per sub-agent Claude sends out

Seven mods, each installed on its own. They also work side by side: each draws its part of the band above the prompt, and the others' parts stay.
| Mod | What it does |
|---|---|
| clawd-hud | A pixel spark above the prompt, usage batteries, a prompt-cache timer and a context meter |
| task-hud | A to-do progress bar, and a pane with a pixel creature for each sub-agent |
| followups | Claude flags what it notices, asks whether to track it, and keeps a to-do you can export |
| rtl-toggle | Fixes the reading order of lines that mix English and Hebrew |
| goal-run | Asks for a goal when a session starts, has Claude plan it, and runs a pixel Claude to 100% as the tasks get done |
| brain-hud | Agent memory with its skill built in: mistake-brain logs and routes lessons, a pixel Claude with a brain reacts to each entry, and a pane draws the memory as a graph |
| factory | /factory <feature>: a spec, your approval of its plan, a build on its own branch, checks, parallel reviews and an approver, then Claude opens the PR |
Add the marketplace once, then install the ones you want:
/plugin marketplace add Amitro123/claudemod
/plugin install <mod>@claudemod
The installer may say some options are "not yet set". That's fine: every option has a default, and the mods run on those until you change one with /plugin configure <mod>@claudemod.
If you added this marketplace before it was renamed from clawd-hud, remove it with /plugin marketplace remove clawd-hud, add it again, and reinstall your mods as <mod>@claudemod.

A Claude Code plugin that puts a little animated spark above the prompt, with a pixel battery for each of your usage limits, and adds a prompt-cache timer and a context meter beside the model picker. It works on personal plans and on enterprise seats.
Above the prompt (the AbovePrompt band)
| Mood | When | Scenes |
|---|---|---|
idle | waiting for you | drifting with a firefly, juggling, watering a sprout |
working | a turn is running | at the terminal, stacking blocks, pondering, sending out sub-agents |
working > 90s | a hard task | a smoke break on a crate joins the rotation |
loop | the same tool with the same input 3× in one turn | riding a coaster loop it can't get out of |
done | the turn answered | fireworks and a hop (a lightbulb if the task was hard) |
relax | 4 min after done | stargazing on a hill, a pool float, cocoa by a snowy window |
error | API error or refusal | goes gray under a little storm cloud, then idle |
| Personal (Pro / Max) | Enterprise | |
|---|---|---|
| Batteries | 5h window, 7d weekly | 7d weekly, mo monthly spend cap ("over by …" past 100%) |
| Cost | not shown | $ spent this session |
| "resets in" | the API's reset time | the API's reset time; for the spend cap without one, the 1st of next month |
Cache timer (auto) | 1 hour | 5 minutes |
Beside the model picker (the SessionMode slot)
ctx 34% · 68k/200k.In a terminal (which can't draw SVG) the same readings show up as a text spark ✦(•_•) and ▰▰▰▱▱ batteries. A one-line summary also goes to the status bar.
The spark shares both places with the other mods: what they draw in the band shows under its row, and whatever sits beside the model picker stays next to the bolt and capsule.
/plugin install clawd-hud@claudemod
Set with claude plugin configure clawd-hud@claudemod, or in the plugin settings:
| Option | Values | Default | What it does |
|---|---|---|---|
plan | auto, personal, enterprise | auto | Which limits to show. auto switches to enterprise as soon as the account reports a monthly spend limit (spend_limit). |
cacheTtl | auto, 5m, 1h | auto | The cache timer's length. auto is 1h on personal plans and 5m on enterprise. |
hooks/register.tsx hooks session.start, session.measure, turn.start, tool.call, turn.complete and ui.render, keeps its state in atoms, and redraws the spark 8 times a second while it moves (working, the loop, fireworks, the storm) and less often while it idles or rests: 4 times a second for the SVG scenes, once a second for the terminal's text face.hooks/scene.ts paints one frame of a mood on an 88×20 pixel stage. Every motion is a plain function of the time since the mood began, so a redraw lands on the same frame and never restarts an animation.hooks/gauges.ts draws the batteries, the bolt and the capsule as plain SVG, so the demo renderer uses the exact same art.tests/plan.test.tsx checks the personal and enterprise views, the spend-cap overflow and both cache timers. Run it with claude plugin test plugins/clawd-hud.0.6.2: the idle and resting SVG scenes redraw 4 times a second instead of once, so they move smoothly; the terminal stays at once a second. The terminal face's idle blink shows again (every 4 seconds).
0.6.1: plays well with the other mods: their bands, and other changes beside the model picker, are no longer hidden. Idle redraws drop from 8 a second to 1. No "clawd-hud loaded" toast on every start.
0.6.0: a new look. The spark mascot, its scenes, the stage and the batteries, bolt and capsule are all new, drawn by a new animation engine. Everything else carries over: plans, timers, loop detection, the hard-task and eureka moments.
0.5.0: renamed from pixel-buddy to clawd-hud (install again; old settings don't carry over). Enterprise mode. The loop detector's tool.call hook got a registration .catch, so it can never block a tool call.
0.4.1: only a real answer celebrates (a refusal gets the error mood). The cache countdown flips to "cold" even after the machine slept. Timers no longer stack up when session.start fires again. No more "resets in NaNm".
0.4.0: the hard-task smoke break, the eureka lightbulb and the loop coaster. 0.3.0: the error mood.

<sub>The creatures, the task bar and the progress lines are drawn by the mod's own art.ts; the window around them is a mock-up of the desktop app.</sub>
/plugin install task-hud@claudemod
TodoWrite, or TaskCreate/TaskUpdate): it's titled with the prompt that started the work, fills as each to-do gets ticked off (Tasks 4/5 · 80%), and shows the one in progress (Now: Write the script). The title survives a reload of the mod. When it shares the band with clawd-hud, it draws above the spark. A finished list clears on your next prompt./task-hud. Each agent gets a pixel creature in its own color that walks while it works. Its card shows the task's description, the agent type and model (general-purpose · Opus 5.5), its tokens and run time, the current step (Step 3 · Reading pre-outline.md), the steps before it and a progress line. Cards are grouped into Running and Finished. A finished agent closes its eyes and lists what it did (Wrote script.md). A failed one turns gray. An agent sent back to work gets its card back, or a new one if its old card was already dropped. Clear drops the finished cards.Nobody knows ahead of time how many steps a sub-agent will take, so a running agent's line creeps toward 90% as it works and fills only when it finishes. The token count is the size of the agent's context on its latest request.
Tests: claude plugin test plugins/task-hud.

<sub>A mock-up of version 0.5.0, not a screenshot. The dialog is Claude Code's own question dialog, so it looks like the one in your app rather than exactly like this. The pane follows the mod's drawing: orange open, blue in progress, green done, 🐞 for a bug, and an item offered from a sub-agent's open questions. A dialog closed without an answer leaves the item in a band above the prompt (Add to to-do / Skip / Add all), which isn't shown here.</sub>
While Claude works, it often notices things outside the task: a flaky test, a stale doc, a bug in nearby code. Those get lost unless somebody writes them down. This mod writes them down.
/plugin install followups@claudemod
flag tool and a short system-prompt section on when to use it: things outside the current task that need attention later. Claude doesn't fix them unless you ask.autoDetect option (claude plugin configure followups@claudemod).Unsure-about, Open questions or Open items, as the model router's routed-worker writes), each line in it is offered in the band as soon as the report comes back. This needs no model, so it works even where the after-answer look can't run. A section that says none offers nothing, and the turn's end-of-turn check doesn't stop Claude again for items already offered.not verified, untested, known gap, open questions, לא נבדק, פער, שאלות פתוחות, ...; Open questions: none doesn't count; not follow-up, this mod's own word) and it never called flag that turn, the turn is stopped once with a nudge to flag each item or say there are none. It reads the reply whatever its source, so findings relayed from a subagent's report count; phrases in code blocks and inline code don't. It stops at most once per turn, never when flag was called (even if you turned the item down), and only in interactive sessions (not -p or the SDK). Limits: it is a phrase match, so a reply that leaves something open in other words is missed (the second look above still reads it), and a phrase used in passing can stop a turn needlessly. Add phrases to STOP_PHRASES in hooks/register.tsx; turn it off with the stopCheck option.a), Skip (s) and Add all, so it isn't lost.○ open, blue ◐ in progress, green struck-through ✓ done. A red 🐞 marks bugs (Claude passes kind: "bug" to flag for broken behavior; /followup adds a plain task). ✓ Done / ↺ Reopen toggles an item, ✕ removes it, and /followup <text> adds one by hand. The header counts open, in progress and done. The list is saved per project and comes back in the next session (lists saved by older versions are migrated). Two sessions open in the same project don't overwrite each other: each save merges with what the other saved, so items added, changed or removed in either one are kept that way. When the project has open or in-progress items, the pane opens by itself as the session starts; the openOnStart option turns that off. The list is saved on the machine running Claude Code, so it carries over between local sessions but not between cloud sessions, whose container is gone when they end: use Export to → Memory for that.done tool with that id and the item turns green. done is idempotent, rejects unknown ids, and the prompt tells Claude not to use it for unrelated work. If the message can't be sent, the items go back to open, so you can start them again. Pressing Add to to-do twice keeps the item once.| Button | Writes to | Why |
|---|---|---|
| Memory | CLAUDE.md | Claude reads it at the start of every session |
| Docs | docs/FOLLOW-UPS.md | a page of its own |
| README | README.md, under Known issues & follow-ups | visible to everyone |
The export is a markdown checklist ([ ] open, [x] done, in-progress items tagged _(in progress)_, bugs _(bug)_) between <!-- followups:start --> and <!-- followups:end --> markers. Exporting again replaces that section in place, and the rest of the file is left alone.
Tests: claude plugin test plugins/followups.
| Toggle off | Toggle on |
|---|---|
![]() | ![]() |
A line that starts in English but is mostly Hebrew (or Arabic, or another right-to-left script) can render out of order, with the Hebrew words shifted. This mod fixes those lines in your messages and Claude's.
/plugin install rtl-toggle@claudemod
עברית: פעיל / עברית: כבוי) turns the fix on or off for the session. /rtl does the same on every surface, and /rtl on, /rtl off and /rtl status set or report it.dir or alignment prop, so alignment comes only from the RLM. That works where the surface picks the direction per block (the desktop app). In a terminal it depends on the terminal's own bidi support. Messages already on screen keep their layout until they're drawn again.It also lives in its own repo, Amitro123/RTL-claude-mod, which has the full rules and the performance notes.
Tests: claude plugin test plugins/rtl-toggle.

<sub>Mock-ups, not screenshots: the track and the runner are drawn by the mod's own art.ts, and the band, the pane and the dialogs follow its layout. The dialogs are Claude Code's own, so yours will look like your app's.</sub>
/plugin install goal-run@claudemod

plan tool. Each task has a priority (high, medium or low) and the tasks it depends on. The plan is ordered highest priority first, except that a task comes after what it needs: tests come after the code they test, and say so (needs 1).update tool, and takes the first pending task in the list's current order. Each of your prompts while the goal is open carries the plan's state, so Claude keeps track of where it is.
add tool with a priority and what the task needs. The task goes in among the tasks that haven't started, by priority and after anything it needs. A dialog shows where it lands and what that does to the percentage (It goes in as #4, before "Document the endpoint". Progress 50% → 40%), and you choose Add it or Not now. Done and in-progress tasks stay where they are. The goal's text doesn't change, but the plan grows, so the percentage drops to match the work added./session-goal <text> sets a goal at any time, /session-goal shows it, and /session-goal clear drops it. Claude Code has its own /goal, so the mod's command has a different name.askOnStart (on / off) turns the start-of-session question off./factory plan-approved. If you already have a goal of your own that isn't done, it stays: the factory's plan isn't set as the goal, and the factory asks for approval in its own band. A factory plan starts fresh, without an earlier goal's done tasks.Tests: claude plugin test plugins/goal-run.

<sub>The scenes and the brain map are the mod's own SVG output; the windows around them are mock-ups. The data is a real project's memory, with one new lesson just routed into Projects (✦).</sub>
brain-hud gives Claude a memory that lasts between sessions, and shows it. The mistake-brain skill ships inside it, with its hooks. Claude logs what happened to MISTAKES.md (a mistake, a success, a decision or a handoff). It routes those entries into PARA folders under .agent-brain/shared/ (Projects, Areas, Resources, Archives), promotes a lesson that repeats into a rule (with your approval), and now and then reorganizes the memory ("dream"). The mod shows that memory as it grows. Nothing else needs installing.
/plugin install brain-hud@claudemod
Then, in each project that should remember: /brain-hud:mistake-brain init, or Set up memory in the Brain pane (/brain). It creates MISTAKES.md, the PARA folders, the index .agent-brain/shared/MEMORY.md and the standing rules. A CLAUDE.md tracked in git is left as it is; the instruction goes into .claude/rules/mistake-brain.md.
/brain-hud:mistake-brain whooks/register.tsx 382 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Activity, Agent, Todo, TodoStatus } from '../types'
5import {
6 BAR, CREATURE, LINE, barText, colorOf, creatureSvg, creatureText, progressLineSvg, taskBarSvg,
7} from './art'
8
9// ---------- state ----------
10
11const todos = atom({ plugin: 'task-hud', key: 'todos' } as const, [] as Todo[])
12const title = atom({ plugin: 'task-hud', key: 'title' } as const, '')
13// The prompt that started the work, named on the band once a to-do list shows up.
14const lastPrompt = atom({ plugin: 'task-hud', key: 'lastPrompt' } as const, '')
15const agents = atom({ plugin: 'task-hud', key: 'agents' } as const, [] as Agent[])
16const tick = atom({ plugin: 'task-hud', key: 'tick' } as const, 0)
17
18const PANE = 'task-hud'
19const PANE_TITLE = 'Sub-agents'
20// Redraws while an agent runs: its timer and its legs.
21const TICK_MS = 500
22// Activity lines kept per agent, and agents kept in the pane.
23const KEEP_LINES = 3
24const KEEP_AGENTS = 24
25// An agent that has done nothing for this long is shown as stopped, so one whose turn never
26// completes (a crash, an abort the engine did not report) cannot keep the timer running.
27const STALE_MS = 10 * 60_000
28
29const timers: { cancel: () => void }[] = []
30
31// ---------- text ----------
32
33const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
34const base = (p: unknown) => String(p ?? '').split(/[\\/]/).filter(Boolean).pop() ?? ''
35const firstLine = (s: string) => s.trim().split('\n')[0]?.trim() ?? ''
36
37const tok = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${n}`)
38
39const mmss = (ms: number) => {
40 const s = Math.max(0, Math.floor(ms / 1000))
41 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
42}
43
44// "claude-opus-5-5" → "Opus 5.5", "haiku" → "Haiku".
45export function modelName(id: string): string {
46 const m = /(opus|sonnet|haiku|fable)(?:-(\d+)(?:-(\d{1,2}))?)?/i.exec(id)
47 if (!m?.[1]) return id
48 const fam = m[1].charAt(0).toUpperCase() + m[1].slice(1).toLowerCase()
49 return m[2] ? `${fam} ${m[2]}${m[3] ? `.${m[3]}` : ''}` : fam
50}
51
52// What a sub-agent's tool call reads as on its card.
53export function activityOf(tool: string, input: Record<string, unknown>): Activity {
54 const file = base(input.file_path ?? input.notebook_path ?? input.path)
55 const say = (now: string, past: string, what: string) => ({ now: clip(`${now} ${what}`.trim(), 60), past: clip(`${past} ${what}`.trim(), 60) })
56 switch (tool) {
57 case 'Read': return say('Reading', 'Read', file)
58 case 'Write': return say('Writing', 'Wrote', file)
59 case 'Edit':
60 case 'NotebookEdit': return say('Editing', 'Edited', file)
61 case 'Bash': return say('Running', 'Ran', String(input.description ?? firstLine(String(input.command ?? ''))))
62 case 'Grep':
63 case 'Glob': return say('Searching for', 'Searched for', String(input.pattern ?? ''))
64 case 'WebSearch': return say('Searching the web for', 'Searched the web for', String(input.query ?? ''))
65 case 'WebFetch': {
66 let host = String(input.url ?? '')
67 try { host = new URL(host).host } catch { /* keep it as given */ }
68 return say('Fetching', 'Fetched', host)
69 }
70 case 'Skill': return say('Reading the', 'Read the', `${input.skill} skill`)
71 case 'Agent': return say('Sending out', 'Sent out', String(input.description ?? 'a sub-agent'))
72 case 'TodoWrite': {
73 const list = (input.todos ?? []) as { status: string; activeForm: string; content: string }[]
74 const cur = list.find(t => t.status === 'in_progress')
75 return cur ? say('', '', cur.activeForm) : say('Planning', 'Planned', `${list.length} to-dos`)
76 }
77 default: return say('Using', 'Used', tool.replace(/^mcp__[^_]+__/, ''))
78 }
79}
80
81// How far along a card's line is. The number of steps an agent will take is unknown, so a
82// running one creeps toward 90% as it works; a finished one is full.
83const progressOf = (a: Agent) => (a.state !== 'running' ? 1 : Math.min(0.9, a.steps / (a.steps + 6)))
84
85// ---------- updates ----------
86
87const patchAgent = async ($: EngineInterface, id: string, fn: (a: Agent) => Agent) => {
88 const t = await $.clock.now()
89 await update($, agents, list => list.map(a => (a.id === id ? { ...fn(a), updatedAt: t } : a)))
90}
91
92const stopTicking = () => { for (const timer of timers.splice(0)) timer.cancel() }
93const isStale = (a: Agent, t: number) => a.state === 'running' && t - (a.updatedAt ?? a.startedAt) > STALE_MS
94
95// An agent stopped for silence alone that does something was still working: it runs again.
96const revive = (a: Agent): Agent => (a.isSilent ? { ...a, state: 'running', endedAt: null, isSilent: false } : a)
97
98async function reviveIfSilent($: EngineInterface, id: string) {
99 if ((await read($, agents)).some(a => a.id === id && a.isSilent)) {
100 await patchAgent($, id, revive)
101 await ensureTicking($)
102 }
103}
104
105async function ensureTicking($: EngineInterface) {
106 if (timers.length) return
107 timers.push($.clock.every(TICK_MS, async () => {
108 const t = await $.clock.now()
109 await update($, tick, () => t)
110 const list = await update($, agents, l =>
111 l.some(a => isStale(a, t)) ? l.map(a => (isStale(a, t) ? { ...a, state: 'failed', endedAt: a.updatedAt ?? a.startedAt, isSilent: true } : a)) : l)
112 if (!list.some(a => a.state === 'running')) stopTicking()
113 }))
114}
115
116// `next` is the new list, or a change to the current one (applied atomically, so two calls at once both land).
117async function setTodos($: EngineInterface, next: Todo[] | ((list: Todo[]) => Todo[])) {
118 const before = await read($, todos)
119 const after = await update($, todos, list => (typeof next === 'function' ? next(list) : next))
120 if (before.length || !after.length) return
121 const prompt = await read($, lastPrompt)
122 await update($, title, () => clip(firstLine(prompt), 48))
123}
124
125const isStatus = (s: unknown): s is TodoStatus => s === 'pending' || s === 'in_progress' || s === 'completed'
126
127export const register: Register = on => {
128 on('session.start', async ($, e, next) => {
129 stopTicking()
130 await $.command.register({ name: 'task-hud', description: 'Show the sub-agents pane' })
131 if ((await read($, agents)).some(a => a.state === 'running')) await ensureTicking($)
132 return next(e)
133 }).catch(($, e, next) => next(e))
134
135 // The session is over: nothing should keep redrawing.
136 on('session.end', async ($, e, next) => {
137 stopTicking()
138 return next(e)
139 }).catch(($, e, next) => next(e))
140
141 on('command.run', { command: 'task-hud' }, async $ => {
142 await $.ui.open({ id: PANE, title: PANE_TITLE })
143 return { text: 'Sub-agents pane opened.' }
144 })
145
146 // A new request from the person: remember it as the band's title, and drop a finished list.
147 on('turn.start', async ($, e, next) => {
148 if (e.agentId !== undefined || !e.text) return next(e)
149 await update($, lastPrompt, () => e.text)
150 const list = await read($, todos)
151 if (list.length && list.every(t => t.status === 'completed')) await update($, todos, () => [])
152 return next(e)
153 }).catch(($, e, next) => next(e))
154
155 // ---------- the to-do list ----------
156
157 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
158 const ran = await next(e)
159 if (e.agentId === undefined && !('deny' in ran && ran.deny)) {
160 await setTodos($, e.todos.map((t, i) => ({ id: `todo-${i}`, subject: t.content, activeForm: t.activeForm, status: t.status })))
161 }
162 return ran
163 }).catch(($, e, next) => next(e))
164
165 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
166 const ran = await next(e)
167 const id = (ran.result as { task?: { id?: string } } | undefined)?.task?.id
168 if (id) {
169 await setTodos($, list => [...list, { id, subject: e.subject, activeForm: e.activeForm ?? e.subject, status: 'pending' }])
170 }
171 return ran
172 }).catch(($, e, next) => next(e))
173
174 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
175 const ran = await next(e)
176 const ok = (ran.result as { success?: boolean } | undefined)?.success
177 if (ok) {
178 await update($, todos, list =>
179 e.status === 'deleted'
180 ? list.filter(t => t.id !== e.taskId)
181 : list.map(t => t.id !== e.taskId ? t : {
182 ...t,
183 subject: e.subject ?? t.subject,
184 activeForm: e.activeForm ?? t.activeForm,
185 status: isStatus(e.status) ? e.status : t.status,
186 }))
187 }
188 return ran
189 }).catch(($, e, next) => next(e))
190
191 // ---------- sub-agents ----------
192
193 on('agent.spawn', async ($, e, next) => {
194 const ran = await next(e)
195 if (!ran.agentId) return ran
196 const list = await read($, agents)
197 // A resumed agent picks up its card; one whose card was already dropped gets a fresh one.
198 if (e.resume && list.some(a => a.id === ran.agentId)) {
199 await patchAgent($, ran.agentId, a => ({ ...a, state: 'running', endedAt: null }))
200 await ensureTicking($)
201 return ran
202 }
203 const t = await $.clock.now()
204 const agent: Agent = {
205 id: ran.agentId,
206 description: e.description || e.subagentType,
207 type: e.subagentType,
208 model: modelName(ran.model || e.model || e.parentModel),
209 slot: list.length ? Math.max(...list.map(a => a.slot)) + 1 : 0,
210 startedAt: t,
211 endedAt: null,
212 updatedAt: t,
213 state: 'running',
214 steps: 0,
215 tokens: 0,
216 activity: [],
217 }
218 await update($, agents, l => [...l, agent].slice(-KEEP_AGENTS))
219 await update($, tick, () => t)
220 await ensureTicking($)
221 void $.ui.open({ id: PANE, title: PANE_TITLE })
222 return ran
223 }).catch(($, e, next) => next(e))
224
225 on('tool.call', async ($, e, next) => {
226 if (e.agentId !== undefined) {
227 const id = e.agentId
228 const act = activityOf(e.tool, e as unknown as Record<string, unknown>)
229 await reviveIfSilent($, id)
230 await patchAgent($, id, a => ({ ...a, steps: a.steps + 1, activity: [...a.activity, act].slice(-KEEP_LINES) }))
231 }
232 return next(e)
233 }).catch(($, e, next) => next(e))
234
235 // Each model request reports the agent's context so far: its size is the card's token count.
236 on('turn.step', async function* ($, e, next) {
237 const r = yield* next(e)
238 const u = r.usage
239 if (e.agentId !== undefined && u) {
240 const used = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens
241 await reviveIfSilent($, e.agentId)
242 await patchAgent($, e.agentId, a => ({ ...a, tokens: used }))
243 }
244 return r
245 }).catch(async function* ($, e, next) { return yield* next(e) })
246
247 on('turn.complete', async ($, e, next) => {
248 if (e.agentId !== undefined) {
249 const t = await $.clock.now()
250 const failed = e.reason === 'error' || e.reason === 'refusal' || e.isAborted
251 await patchAgent($, e.agentId!, a => ({ ...a, state: failed ? 'failed' : 'done', endedAt: t, isSilent: false }))
252 }
253 return next(e)
254 }).catch(($, e, next) => next(e))
255
256 // ---------- the band: the to-do bar ----------
257
258 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
259 const list = await read($, todos)
260 if (e.props.hasSurvey || !list.length) return next(e)
261 const { Box, Text } = $.ui.resolve(e)
262 const done = list.filter(t => t.status === 'completed').length
263 const pct = Math.round((done / list.length) * 100)
264 const cur = list.find(t => t.status === 'in_progress')
265 const name = (await read($, title)) || 'Tasks'
266 const now = done === list.length ? 'All done' : cur ? `Now: ${cur.activeForm}` : `Next: ${list.find(t => t.status === 'pending')?.subject ?? ''}`
267 const below = await next(e)
268
269 if (e.surface === 'terminal') {
270 const cells = Math.max(10, Math.min(40, e.props.bodyColumns - name.length - 24))
271 return (
272 <Box flexDirection="column">
273 <Box flexDirection="row" gap={1}>
274 <Text color="#3b7dd8">●</Text>
275 <Text bold>{name}</Text>
276 <Text color="#3b7dd8">{barText(done / list.length, cells)}</Text>
277 <Text bold>{`Tasks ${done}/${list.length}`}</Text>
278 <Text dimColor>{`${pct}%`}</Text>
279 </Box>
280 <Text dimColor wrap="truncate-end">{` ${now}`}</Text>
281 {below}
282 </Box>
283 )
284 }
285 const { Svg } = $.ui.resolve(e as typeof e & { surface: 'desktop' })
286 return (
287 <Box flexDirection="column">
288 <Box flexDirection="column" paddingX={1}>
289 <Box flexDirection="row" alignItems="center" gap={2}>
290 <Text color="#3b7dd8">●</Text>
291 <Text bold>{name}</Text>
292 <Svg source={taskBarSvg(done, list.length)} alt={`Tasks ${done} of ${list.length}`} width={BAR.width} height={BAR.height} />
293 <Text>{`${pct}%`}</Text>
294 </Box>
295 <Text dimColor wrap="truncate-end">{now}</Text>
296 </Box>
297 {below}
298 </Box>
299 )
300 })
301
302 // ---------- the pane: one creature per sub-agent ----------
303
304 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
305 const { Box, Button, Text } = $.ui.resolve(e)
306 const list = await read($, agents)
307 const t = await read($, tick)
308 const frame = Math.floor(t / TICK_MS)
309 const running = list.filter(a => a.state === 'running')
310 const finished = list.filter(a => a.state !== 'running').reverse()
311 const Svg = e.surface === 'terminal' ? null : $.ui.resolve(e as typeof e & { surface: 'desktop' }).Svg
312 const elapsed = (a: Agent) => mmss((a.endedAt ?? t) - a.startedAt)
313 const total = list.reduce((n, a) => n + a.tokens, 0)
314 const first = list.length ? Math.min(...list.map(a => a.startedAt)) : t
315 const last = running.length ? t : Math.max(first, ...list.map(a => a.endedAt ?? first))
316
317 const card = (a: Agent) => {
318 const color = a.state === 'failed' ? '#8b8fa3' : colorOf(a.slot)
319 const head = a.activity[a.activity.length - 1]
320 const status = a.state === 'running'
321 ? `Step ${a.steps} · ${head?.now ?? 'Starting'}`
322 : a.state === 'done' ? `Done · ${a.steps} steps` : `Stopped · ${a.steps} steps`
323 const lines = (a.state === 'running' ? a.activity.slice(0, -1) : a.activity).map(x => (a.state === 'running' ? x.now : x.past))
324 return (
325 <Box key={a.id} flexDirection="row" gap={2} borderStyle="round" borderColor={a.state === 'running' ? color : undefined} paddingX={1}>
326 {Svg
327 ? <Svg source={creatureSvg(a.slot, a.state, frame)} alt={`${a.description}: ${a.state}`} width={CREATURE.width} height={CREATURE.height} />
328 : <Text color={color}>{creatureText(a.state, frame)}</Text>}
329 <Box flexDirection="column" flexGrow={1}>
330 <Box flexDirection="row" justifyContent="space-between" gap={1}>
331 <Text bold wrap="truncate-end">{a.description}</Text>
332 <Text color={a.state === 'failed' ? '#e0533d' : color}>{a.state === 'running' ? '●' : a.state === 'done' ? '✓' : '✕'}</Text>
333 </Box>
334 <Box flexDirection="row" justifyContent="space-between" gap={1}>
335 <Text dimColor wrap="truncate-end">{`${a.type} · ${a.model}`}</Text>
336 <Text dimColor>{`${tok(a.tokens)} tokens · ${elapsed(a)}`}</Text>
337 </Box>
338 <Text color={a.state === 'running' ? undefined : color} bold={a.state !== 'running'} wrap="truncate-end">{status}</Text>
339 {lines.map((l, i) => <Text key={`${a.id}-${i}`} dimColor wrap="truncate-end">{` ${l}`}</Text>)}
340 {Svg
341 ? <Svg source={progressLineSvg(progressOf(a), color)} alt={`${Math.round(progressOf(a) * 100)}%`} width={LINE.width} height={LINE.height} />
342 : <Text color={color}>{barText(progressOf(a), 20)}</Text>}
343 </Box>
344 </Box>
345 )
346 }
347
348 if (!list.length) {
349 return (
350 <Box flexDirection="column">
351 <Text dimColor>No sub-agents yet. When Claude sends some out, each shows up here.</Text>
352 </Box>
353 )
354 }
355
356 return (
357 <Box flexDirection="column" gap={1}>
358 <Box flexDirection="row" gap={2}>
359 <Text>{`${list.length} sub-agent${list.length === 1 ? '' : 's'}`}</Text>
360 <Text dimColor>{`${tok(total)} tokens`}</Text>
361 <Text dimColor>{mmss(last - first)}</Text>
362 </Box>
363 {running.length ? (
364 <Box flexDirection="column">
365 <Text bold color="#1f9d6b">{`▾ Running · ${running.length}`}</Text>
366 {running.map(card)}
367 </Box>
368 ) : null}
369 {finished.length ? (
370 <Box flexDirection="column">
371 <Box flexDirection="row" gap={2}>
372 <Text bold color="#c9a227">{`▾ Finished · ${finished.length}`}</Text>
373 <Button key="clear" label="Clear" plain onPress={() => update($, agents, l => l.filter(a => a.state === 'running'))} />
374 </Box>
375 {finished.map(card)}
376 </Box>
377 ) : null}
378 </Box>
379 )
380 })
381}
382hooks/art.ts 72 lines1// The pixel art: one creature per sub-agent, the band's task bar and a card's progress line,
2// all as plain SVG strings so every surface that has `Svg` draws the same thing.
3
4export const COLORS = ['#1f9d6b', '#e0533d', '#3b7dd8', '#d6409f', '#8a5cf0', '#e08a1e', '#16a3b8', '#c9a227']
5const EYE = '#1d1f2b'
6const GRAY = '#8b8fa3'
7
8export const colorOf = (slot: number) => COLORS[slot % COLORS.length] ?? COLORS[0]!
9
10// 11×8 cells. `#` body, `o` eye, `-` a shut eye (done), legs drawn per frame.
11const BODY = ['..#######..', '.#########.', '###o###o###', '###########', '###########', '.#########.']
12const DONE_EYES = '###-###-###'
13const LEGS = ['.#.#.#.#.#.', '#.#.#.#.#.#']
14const CELL = 4
15export const CREATURE = { width: 11 * CELL, height: 8 * CELL }
16
17export function creatureSvg(slot: number, state: 'running' | 'done' | 'failed', frame: number): string {
18 const body = state === 'failed' ? GRAY : colorOf(slot)
19 const rows = [...BODY]
20 if (state !== 'running') rows[2] = DONE_EYES
21 // Running creatures shuffle their legs and bob a pixel; finished ones stand still.
22 const walk = state === 'running' ? frame % 2 : 0
23 rows.push(LEGS[walk] ?? '', '...........')
24 const dy = state === 'running' && walk ? 1 : 0
25 let rects = ''
26 rows.forEach((row, y) => {
27 for (let x = 0; x < row.length; x++) {
28 const c = row[x]
29 if (c === '.') continue
30 const fill = c === '#' ? body : EYE
31 const h = c === '-' ? CELL / 2 : CELL
32 const top = (y + dy) * CELL + (c === '-' ? CELL / 2 : 0)
33 rects += `<rect x="${x * CELL}" y="${top}" width="${CELL}" height="${h}" fill="${fill}"/>`
34 }
35 })
36 return `<svg xmlns="http://www.w3.org/2000/svg" width="${CREATURE.width}" height="${CREATURE.height}" viewBox="0 0 ${CREATURE.width} ${CREATURE.height}" shape-rendering="crispEdges">${rects}</svg>`
37}
38
39export const BAR = { width: 420, height: 22 }
40
41// The band's bar: a track, the done part filled, "Tasks 4/5" riding the fill's right edge.
42export function taskBarSvg(done: number, total: number): string {
43 const { width: w, height: h } = BAR
44 const r = h / 2
45 const fill = total ? Math.max(h, Math.round((w * done) / total)) : 0
46 const label = `Tasks ${done}/${total}`
47 const lw = label.length * 7 + 16
48 const lx = Math.min(w - lw, Math.max(0, fill - lw))
49 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
50<rect width="${w}" height="${h}" rx="${r}" fill="#3b7dd8" fill-opacity="0.22"/>
51${done ? `<rect width="${fill}" height="${h}" rx="${r}" fill="#3b7dd8"/>` : ''}
52<rect x="${lx}" width="${lw}" height="${h}" rx="3" fill="#1d4fa8"/>
53<text x="${lx + lw / 2}" y="${h / 2 + 4.5}" text-anchor="middle" font-family="system-ui,sans-serif" font-size="12" font-weight="600" fill="#fff">${label}</text>
54</svg>`
55}
56
57export const LINE = { width: 220, height: 4 }
58
59export function progressLineSvg(fraction: number, color: string): string {
60 const { width: w, height: h } = LINE
61 const f = Math.round(w * Math.max(0, Math.min(1, fraction)))
62 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><rect width="${w}" height="${h}" rx="2" fill="${GRAY}" fill-opacity="0.25"/><rect width="${f}" height="${h}" rx="2" fill="${color}"/></svg>`
63}
64
65// The terminal's versions.
66export const barText = (fraction: number, cells: number) => {
67 const n = Math.round(Math.max(0, Math.min(1, fraction)) * cells)
68 return '▰'.repeat(n) + '▱'.repeat(cells - n)
69}
70export const creatureText = (state: 'running' | 'done' | 'failed', frame: number) =>
71 state === 'done' ? '▟(-‿-)▙' : state === 'failed' ? '▟(×_×)▙' : frame % 2 ? '▟(•_•)▙' : '▙(•_•)▟'
72types/index.d.ts 36 lines1export type TodoStatus = 'pending' | 'in_progress' | 'completed'
2export type Todo = { id: string; subject: string; activeForm: string; status: TodoStatus }
3export type AgentState = 'running' | 'done' | 'failed'
4// One line of what an agent did: "Reading notes.md" while it runs, "Read notes.md" after.
5export type Activity = { now: string; past: string }
6export type Agent = {
7 id: string
8 description: string
9 type: string
10 model: string
11 // Which creature (color) it is drawn as: the order it was spawned in.
12 slot: number
13 startedAt: number
14 endedAt: number | null
15 // The last time the agent did anything; one silent for too long is shown as stopped.
16 updatedAt: number
17 // Set while it is shown as stopped for that silence alone: anything it does next revives it.
18 isSilent?: boolean
19 state: AgentState
20 steps: number
21 tokens: number
22 activity: Activity[]
23}
24
25declare module 'claude-code' {
26 interface PluginState {
27 'task-hud': {
28 todos: Todo[]
29 title: string
30 lastPrompt: string
31 agents: Agent[]
32 tick: number
33 }
34 }
35}
36