A pixel spark above the prompt with usage batteries (personal or enterprise), a prompt-cache timer and a context meter by the model picker

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 401 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Ctx, Limit, Mood } from '../types'
5import { BATTERY, BOLT, LOW, OK, PILL, batterySvg, boltSvg, cellsText, charge, pillSvg } from './gauges'
6import { DONE_SECONDS, ERROR_SECONDS, HARD_SECONDS, SCENE_HEIGHT, SCENE_WIDTH, sceneSvg } from './scene'
7
8// ---------- state ----------
9
10const mood = atom({ plugin: 'clawd-hud', key: 'mood' } as const, 'idle' as Mood)
11const moodAt = atom({ plugin: 'clawd-hud', key: 'moodAt' } as const, 0)
12const frame = atom({ plugin: 'clawd-hud', key: 'frame' } as const, 0)
13// Whether the band last drew SVG scenes (desktop) rather than the terminal's text face. A render
14// can't write state, and after a reload the next render sets it again.
15let svgBand = false
16const cacheAt = atom({ plugin: 'clawd-hud', key: 'cacheAt' } as const, null as number | null)
17const now = atom({ plugin: 'clawd-hud', key: 'now' } as const, 0)
18const minute = atom({ plugin: 'clawd-hud', key: 'minute' } as const, 0)
19const ctx = atom({ plugin: 'clawd-hud', key: 'ctx' } as const, null as Ctx | null)
20const limits = atom({ plugin: 'clawd-hud', key: 'limits' } as const, [] as Limit[])
21const eureka = atom({ plugin: 'clawd-hud', key: 'eureka' } as const, false)
22const loopSince = atom({ plugin: 'clawd-hud', key: 'loopSince' } as const, null as number | null)
23const toolCounts = atom({ plugin: 'clawd-hud', key: 'toolCounts' } as const, {} as Record<string, number>)
24const cost = atom({ plugin: 'clawd-hud', key: 'cost' } as const, null as number | null)
25
26const MIN = 60_000
27// The mascot is redrawn as still frames at this interval (8 a second).
28const FRAME_MS = 125
29// Idle and resting: the terminal's face needs a frame a second; the SVG scenes look smooth at 4.
30const IDLE_FRAME_MS = 1_000
31const SVG_IDLE_FRAME_MS = 250
32// After the fireworks the spark rests this long before idling.
33const RELAX_MS = 4 * MIN
34// The same tool with the same input this many times in one turn means Claude is going in circles.
35const LOOP_REPEATS = 3
36const RESERVED = new Set(['tool', 'tool_use_id', 'agentId', 'consent'])
37
38// The intervals armed by session.start, kept so a later session.start can cancel them.
39const timers: { cancel: () => void }[] = []
40
41// ---------- plans ----------
42
43// Personal plans meter a 5-hour and a weekly window; enterprise seats meter the week and a
44// monthly spend cap (a gateway's `spend_limit`, which can run past 100%).
45type Cfg = { plan: string; cacheTtl: string }
46type Win = { kind: string; label: string }
47const PERSONAL: Win[] = [
48 { kind: 'five_hour', label: '5h' },
49 { kind: 'seven_day', label: '7d' },
50]
51const ENTERPRISE: Win[] = [
52 { kind: 'seven_day', label: '7d' },
53 { kind: 'spend_limit', label: 'mo' },
54]
55
56const isEnterprise = (cfg: Cfg, rl: Limit[]) =>
57 cfg.plan === 'enterprise' || (cfg.plan !== 'personal' && rl.some(l => l.kind === 'spend_limit'))
58
59const ttlOf = (cfg: Cfg, enterprise: boolean) =>
60 cfg.cacheTtl === '5m' ? 5 * MIN : cfg.cacheTtl === '1h' ? 60 * MIN : enterprise ? 5 * MIN : 60 * MIN
61
62// When the window resets: the API's own time, or for a spend cap without one, the 1st of next month.
63function resetAt(win: Win, lim: Limit | undefined, t: number): number | null {
64 if (lim?.resetsAt) return Date.parse(lim.resetsAt)
65 if (win.kind !== 'spend_limit') return null
66 const d = new Date(t)
67 return new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime()
68}
69
70async function planOf($: EngineInterface, cfg: Cfg) {
71 const rl = await read($, limits)
72 const enterprise = isEnterprise(cfg, rl)
73 return { rl, enterprise, wins: enterprise ? ENTERPRISE : PERSONAL, ttlMs: ttlOf(cfg, enterprise) }
74}
75
76// ---------- text ----------
77
78// "2d 4h", "3h 12m", "45m".
79function until(ms: number): string {
80 if (!Number.isFinite(ms)) return '–'
81 const mins = Math.max(0, Math.round(ms / MIN))
82 const d = Math.floor(mins / 1440)
83 const h = Math.floor(mins / 60) % 24
84 return d ? `${d}d ${h}h` : h ? `${h}h ${mins % 60}m` : `${mins % 60}m`
85}
86
87const mmss = (ms: number) => {
88 const s = Math.max(0, Math.ceil(ms / 1000))
89 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
90}
91
92const usd = (n: number) => `$${n.toFixed(2)}`
93const tok = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : `${Math.round(n / 1e3)}k`)
94
95// A window as what is left of it: "66% left", or past a spend cap, "over by 12%".
96function leftOf(lim: Limit | undefined) {
97 if (!lim) return { left: 0, over: false, text: '–' }
98 const used = lim.percentUsed
99 if (used > 100) return { left: 0, over: true, text: `over by ${Math.round(used - 100)}%` }
100 const left = Math.max(0, 100 - used)
101 return { left, over: false, text: `${Math.round(left)}% left` }
102}
103
104function cacheOf(at: number | null, tNow: number, ttlMs: number) {
105 const left = at === null ? 0 : ttlMs - (tNow - at)
106 const warm = at !== null && left > 0
107 return {
108 warm,
109 label: at === null ? 'cache –' : warm ? `cache ${mmss(left)}` : 'cache cold',
110 color: !warm ? '#59618a' : left < MIN ? LOW : OK,
111 }
112}
113
114// ---------- moods ----------
115
116async function setMood($: EngineInterface, m: Mood) {
117 const t = await $.clock.now()
118 await update($, moodAt, () => t)
119 await update($, mood, () => m)
120}
121
122// What plays `s` seconds into a stored mood: fireworks, a rest, then idle; or a short storm, then idle.
123function sceneFor(m: Mood, s: number): { mood: Mood; t: number } {
124 if (m === 'done') return s < DONE_SECONDS ? { mood: 'done', t: s } : sceneFor('relax', s - DONE_SECONDS)
125 if (m === 'error') return s < ERROR_SECONDS ? { mood: 'error', t: s } : { mood: 'idle', t: s - ERROR_SECONDS }
126 if (m === 'relax' && s >= RELAX_MS / 1000) return { mood: 'idle', t: s - RELAX_MS / 1000 }
127 return { mood: m, t: s }
128}
129
130const ALT: Record<Mood, string> = {
131 idle: 'drifting',
132 working: 'working',
133 loop: 'stuck riding a coaster loop',
134 done: 'setting off fireworks',
135 relax: 'taking it easy',
136 error: 'gone gray under a storm cloud',
137}
138
139// The terminal's spark, as text.
140const SPIN = ['◐', '◓', '◑', '◒']
141
142export function faceFor(m: Mood, t: number, idea: boolean): string {
143 const f = Math.floor((t * 1000) / FRAME_MS)
144 if (m === 'working') return `✦(•_•) ${SPIN[f % SPIN.length]}`
145 if (m === 'loop') return '✦(@_@) ↻'
146 if (m === 'done') return `✦\\(^o^)/${idea ? ' (!)' : ''}`
147 if (m === 'relax') return '✦(˘_˘) ♪'
148 if (m === 'error') return '✦(;_;) ϟ'
149 // On whole seconds, so the once-a-second idle redraw always lands on it.
150 return Math.floor(t) % 4 === 0 ? '✦(-_-)' : '✦(•_•)'
151}
152
153// ---------- loop detector ----------
154
155function hash(s: string): string {
156 let h = 5381
157 for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0
158 return (h >>> 0).toString(36)
159}
160
161async function countCall($: EngineInterface, e: { tool: string; agentId?: string }) {
162 if (e.agentId || (await read($, loopSince)) !== null) return
163 const args = Object.fromEntries(Object.entries(e).filter(([k]) => !RESERVED.has(k)))
164 const sig = `${e.tool}:${hash(JSON.stringify(args))}`
165 await update($, toolCounts, c => ({ ...c, [sig]: (c[sig] ?? 0) + 1 }))
166 if (((await read($, toolCounts))[sig] ?? 0) >= LOOP_REPEATS) {
167 const t = await $.clock.now()
168 await update($, loopSince, () => t)
169 }
170}
171
172// One line in the status bar with every reading, for surfaces that draw neither band.
173async function refreshStatus($: EngineInterface, cfg: Cfg) {
174 const { rl, enterprise, wins, ttlMs } = await planOf($, cfg)
175 const c = await read($, ctx)
176 const spent = await read($, cost)
177 const cache = cacheOf(await read($, cacheAt), await $.clock.now(), ttlMs)
178 const parts = [
179 ...wins.map(w => `${w.label} ${leftOf(rl.find(l => l.kind === w.kind)).text}`),
180 ...(enterprise && spent !== null ? [usd(spent)] : []),
181 cache.label,
182 c?.percent === undefined ? 'ctx –' : `ctx ${c.percent}%`,
183 ]
184 $.ui.status(`✦ ${parts.join(' · ')}`)
185}
186
187export const register: Register = (on, options) => {
188 const cfg: Cfg = { plan: String(options.plan ?? 'auto'), cacheTtl: String(options.cacheTtl ?? 'auto') }
189
190 on('session.start', async ($, e, next) => {
191 // session.start can fire again for the same module (an enable, a worker respawn):
192 // drop the previous timers so the intervals never stack up.
193 for (const timer of timers.splice(0)) timer.cancel()
194
195 const u = await $.session.usage()
196 await update($, ctx, () => u.context)
197 await update($, limits, () => u.rateLimits)
198 await update($, cost, () => u.cost?.usd ?? null)
199 const t0 = await $.clock.now()
200 await update($, minute, () => t0)
201 await update($, moodAt, () => t0)
202
203 // Keep "resets in" fresh.
204 timers.push($.clock.every(MIN, async () => {
205 const t = await $.clock.now()
206 await update($, minute, () => t)
207 await refreshStatus($, cfg)
208 }))
209 // Every motion is a function of elapsed time, so an idle or resting spark can redraw
210 // less often; working, loop, done and error scenes get every frame.
211 timers.push($.clock.every(FRAME_MS, async () => {
212 const t = await $.clock.now()
213 const scene = sceneFor(await read($, mood), (t - (await read($, moodAt))) / 1000).mood
214 const still = scene === 'idle' || scene === 'relax'
215 const every = svgBand ? SVG_IDLE_FRAME_MS : IDLE_FRAME_MS
216 if (still && t - (await read($, frame)) < every) return
217 await update($, frame, () => t)
218 }))
219 // Tick the cache countdown while it is warm, and once more after expiry: a late tick
220 // (the machine slept) must still flip the label to "cold" instead of freezing it.
221 timers.push($.clock.every(1_000, async () => {
222 const at = await read($, cacheAt)
223 if (at === null) return
224 const t = await $.clock.now()
225 const { ttlMs } = await planOf($, cfg)
226 const shownWarm = (await read($, now)) - at < ttlMs
227 if (t - at < ttlMs + 2_000 || shownWarm) {
228 await update($, now, () => t)
229 await refreshStatus($, cfg)
230 }
231 }))
232
233 await refreshStatus($, cfg)
234 return next(e)
235 })
236
237 // Each response refreshes the prompt cache and reports the live usage.
238 on('session.measure', async ($, e, next) => {
239 await update($, ctx, () => e.context)
240 if (e.rateLimits.length) await update($, limits, () => e.rateLimits)
241 if (e.cost) await update($, cost, () => e.cost?.usd ?? null)
242 if (e.changed.includes('context')) {
243 const t = await $.clock.now()
244 await update($, cacheAt, () => t)
245 await update($, now, () => t)
246 }
247 await refreshStatus($, cfg)
248 return next(e)
249 })
250
251 on('turn.start', async ($, e, next) => {
252 await update($, toolCounts, () => ({}))
253 await update($, loopSince, () => null)
254 await setMood($, 'working')
255 return next(e)
256 })
257
258 // tool.call gates every tool, so the loop detector is best-effort: its .catch lets the
259 // call through if counting fails or overruns the hook's budget.
260 on('tool.call', async ($, e, next) => {
261 await countCall($, e)
262 return next(e)
263 }).catch(($, e, next) => next(e))
264
265 // A real answer sets off the fireworks (with a bulb after a hard task); anything else but an
266 // interrupt gets the storm. The hand-offs are worked out from the clock on each frame.
267 on('turn.complete', async ($, e, next) => {
268 if (e.agentId) return next(e)
269 const t = await $.clock.now()
270 const startedAt = (await read($, mood)) === 'working' ? await read($, moodAt) : t
271 const solved = !e.isAborted && e.reason === 'answer'
272 await update($, eureka, () => solved && t - startedAt >= HARD_SECONDS * 1000)
273 await update($, loopSince, () => null)
274 await setMood($, e.isAborted ? 'idle' : solved ? 'done' : 'error')
275 return next(e)
276 })
277
278 // The band above the prompt: the spark on the left, a battery per usage window beside it.
279 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
280 if (e.props.hasSurvey) return next(e)
281 svgBand = e.surface !== 'terminal'
282 const { Box, Text } = $.ui.resolve(e)
283 // What the mods beneath draw stays, under the spark's row: this band is shared.
284 const below = await next(e)
285 const { rl, enterprise, wins } = await planOf($, cfg)
286 const spent = enterprise ? await read($, cost) : null
287 const t = await read($, minute)
288 const stored = await read($, mood)
289 const m: Mood = e.props.isWorking ? 'working' : stored === 'working' ? 'idle' : stored
290 const tFrame = await read($, frame)
291 // When the band's mood is not the stored one (the UI says "working" before turn.start
292 // lands, or the turn ended before turn.complete ran) there is no start time: run a
293 // free clock that stays under HARD_SECONDS rather than counting from the epoch.
294 const elapsed = m === stored ? (tFrame - (await read($, moodAt))) / 1000 : (tFrame / 1000) % HARD_SECONDS
295 const loopAt = await read($, loopSince)
296 const { mood: shown, t: sceneT } =
297 e.props.isWorking && loopAt !== null
298 ? { mood: 'loop' as Mood, t: Math.max(0, (tFrame - loopAt) / 1000) }
299 : sceneFor(m, Math.max(0, elapsed))
300 const idea = shown === 'done' && (await read($, eureka))
301
302 const meters = wins.map(w => {
303 const lim = rl.find(l => l.kind === w.kind)
304 const at = t ? resetAt(w, lim, t) : null
305 return { ...w, ...leftOf(lim), has: !!lim, resets: lim && at !== null && t ? `resets in ${until(at - t)}` : 'no reading yet' }
306 })
307
308 if (e.surface !== 'desktop' && e.surface !== 'vscode') {
309 const row = (
310 <Box flexDirection="row" flexWrap="wrap" gap={3}>
311 <Text bold color="#ffb547">{faceFor(shown, sceneT, idea)}</Text>
312 {meters.map(w => (
313 <Box key={w.kind} flexDirection="row" gap={1}>
314 <Text bold>{w.label}</Text>
315 <Text color={w.has ? (w.over ? '#ff5a5a' : charge(w.left)) : undefined} dimColor={!w.has}>{`[${cellsText(w.left, w.over)}]`}</Text>
316 <Text bold>{w.text}</Text>
317 <Text dimColor>{w.resets}</Text>
318 </Box>
319 ))}
320 {spent !== null ? <Text bold>{`${usd(spent)} this session`}</Text> : null}
321 </Box>
322 )
323 return (
324 <Box flexDirection="column">
325 {row}
326 {below}
327 </Box>
328 )
329 }
330 const { Svg } = $.ui.resolve(e as typeof e & { surface: 'desktop' })
331
332 const row = (
333 <Box flexDirection="row" alignItems="center" gap={2} paddingX={1}>
334 <Svg
335 source={sceneSvg(shown, sceneT, idea)}
336 alt={`The spark is ${idea ? 'having a eureka moment' : ALT[shown]}`}
337 width={SCENE_WIDTH}
338 height={SCENE_HEIGHT}
339 />
340 <Box flexDirection="row" alignItems="center" flexWrap="wrap" gap={3}>
341 {meters.map(w => (
342 <Box key={w.kind} flexDirection="row" alignItems="center" gap={1}>
343 <Svg source={batterySvg(w.left, w.over)} alt={`${w.label}: ${w.text}`} width={BATTERY.width} height={BATTERY.height} />
344 <Text bold>{`${w.label} ${w.text}`}</Text>
345 <Text dimColor>{w.resets}</Text>
346 </Box>
347 ))}
348 {spent !== null ? (
349 <Box flexDirection="row" alignItems="center" gap={1}>
350 <Text bold>{usd(spent)}</Text>
351 <Text dimColor>this session</Text>
352 </Box>
353 ) : null}
354 </Box>
355 </Box>
356 )
357 return (
358 <Box flexDirection="column">
359 {row}
360 {below}
361 </Box>
362 )
363 })
364
365 // Beside the model picker: the cache bolt and the context capsule.
366 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
367 const { Box, Text } = $.ui.resolve(e)
368 const { ttlMs } = await planOf($, cfg)
369 const cache = cacheOf(await read($, cacheAt), await read($, now), ttlMs)
370 const c = await read($, ctx)
371 const pct = c?.percent
372 const ctxLabel = pct === undefined ? 'ctx –' : `ctx ${pct}%${c?.tokens ? ` · ${tok(c.tokens)}/${tok(c.window)}` : ''}`
373 // The engine's own drawing here (the mode labels, or another mod's take on them) stays beside ours.
374 const below = await next(e)
375
376 // SessionMode is raised on the terminal and desktop alone.
377 if (e.surface !== 'desktop') {
378 return (
379 <Box flexDirection="row" gap={1}>
380 <Text color={cache.warm ? cache.color : undefined} dimColor={!cache.warm}>{`ϟ ${cache.label}`}</Text>
381 <Text dimColor>·</Text>
382 <Text dimColor>{ctxLabel}</Text>
383 {below}
384 </Box>
385 )
386 }
387 const { Svg } = $.ui.resolve(e as typeof e & { surface: 'desktop' })
388
389 return (
390 <Box flexDirection="row" alignItems="center" gap={1}>
391 <Svg source={boltSvg(cache.color)} alt="prompt cache" width={BOLT.width} height={BOLT.height} />
392 <Text color={cache.warm ? cache.color : undefined} dimColor={!cache.warm}>{cache.label}</Text>
393 <Text dimColor>·</Text>
394 <Svg source={pillSvg(pct ?? 0)} alt="context used" width={PILL.width} height={PILL.height} />
395 <Text dimColor>{ctxLabel}</Text>
396 {below}
397 </Box>
398 )
399 })
400}
401hooks/gauges.ts 60 lines1// The small pixel gauges beside the mascot, as plain SVG strings (no engine imports),
2// so the demo renderer can draw exactly what the plugin draws.
3
4export const OK = '#6be38b'
5export const LOW = '#ffb547'
6export const OUT = '#ff5a5a'
7const DIM = '#2c3354'
8const RIM = '#a7b0d6'
9const HOLE = '#11152a'
10
11// What is left of a window, as a color: plenty, running low, nearly out.
12export const charge = (left: number) => (left > 30 ? OK : left > 10 ? LOW : OUT)
13
14const cell = (x: number, y: number, w: number, h: number, c: string) =>
15 w > 0 && h > 0 ? `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${c}"/>` : ''
16
17const pixels = (w: number, h: number, scale: number, body: string) =>
18 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w * scale}" height="${h * scale}" shape-rendering="crispEdges">${body}</svg>`
19
20const lit = (left: number) => Math.min(5, Math.max(0, Math.ceil(left / 20)))
21
22export const BATTERY = { width: 48, height: 22 }
23
24// Five cells for what is left of a window. `over` is a spend cap run past 100%:
25// an empty shell with a red bolt in it.
26export function batterySvg(left: number, over = false): string {
27 const n = over ? 0 : lit(left)
28 let body = cell(0, 0, 22, 11, RIM) + cell(1, 1, 20, 9, HOLE) + cell(22, 3, 2, 5, RIM)
29 for (let i = 0; i < 5; i++) body += cell(2 + i * 4, 2, 3, 7, i < n ? charge(left) : DIM)
30 if (over) body += cell(12, 2, 2, 3, OUT) + cell(10, 5, 4, 1, OUT) + cell(10, 6, 2, 3, OUT)
31 return pixels(24, 11, 2, body)
32}
33
34export const BOLT = { width: 10, height: 14 }
35
36const BOLT_ROWS = ['..xxx', '.xxx.', 'xxxxx', '..xx.', '.xx..', 'xx...', 'x....']
37
38// The prompt cache: a lit bolt while warm, amber in its last minute, gray once cold.
39export function boltSvg(color: string): string {
40 let body = ''
41 BOLT_ROWS.forEach((row, y) => {
42 for (let x = 0; x < row.length; x++) if (row[x] === 'x') body += cell(x, y, 1, 1, color)
43 })
44 return pixels(5, 7, 2, body)
45}
46
47export const PILL = { width: 40, height: 6 }
48
49// How full the context window is, as a capsule that fills left to right.
50export function pillSvg(percent: number): string {
51 const p = Math.min(100, Math.max(0, percent))
52 return pixels(40, 6, 1, cell(0, 0, 40, 6, DIM) + cell(1, 1, Math.round((p / 100) * 38), 4, charge(100 - p)))
53}
54
55// The same battery for a terminal, which cannot draw SVG.
56export const cellsText = (left: number, over = false) => {
57 const n = over ? 0 : lit(left)
58 return '▰'.repeat(n) + '▱'.repeat(5 - n)
59}
60hooks/scene.ts 448 lines1// Scenes for the spark mascot. `sceneSvg` paints ONE frame of a mood, `t` seconds into it,
2// on an 88x20 pixel stage drawn at 3x. Every motion is a plain function of `t`, so a redraw
3// lands on the same frame and never restarts an animation.
4
5import type { Mood } from '../types'
6
7const W = 88
8const H = 20
9const SCALE = 3
10const FLOOR = 17
11
12export const SCENE_WIDTH = W * SCALE
13export const SCENE_HEIGHT = H * SCALE
14export const DONE_SECONDS = 6.5
15export const ERROR_SECONDS = 3.2
16// A turn that runs this long is a hard task: smoke breaks while on it, a lightbulb when it lands.
17export const HARD_SECONDS = 90
18
19const SKY = '#11152a'
20const INK: Record<string, string> = {
21 A: '#ffb547', // spark
22 a: '#d9822b', // spark, shaded
23 H: '#fff1c2', // spark, lit
24 s: '#8a90a8', // spark, gone gray
25 S: '#5d637c',
26 e: '#2b1606', // eyes
27 k: '#0a0c18',
28 N: '#1c2240',
29 d: '#2c3354',
30 L: '#59618a',
31 l: '#a7b0d6',
32 w: '#f4f6ff',
33 y: '#ffe066',
34 o: '#ff8a3d',
35 r: '#ff5a5a',
36 m: '#ff6fae',
37 c: '#5ad1ff',
38 C: '#2b7cb3',
39 g: '#6be38b',
40 G: '#2f8f55',
41 v: '#9d7bff',
42 b: '#8a5a33',
43 B: '#5c3a20',
44}
45
46// ---------- drawing ----------
47
48class Canvas {
49 private parts: string[] = []
50
51 rect(x: number, y: number, w: number, h: number, c: string) {
52 if (w <= 0 || h <= 0) return
53 this.parts.push(`<rect x="${Math.round(x)}" y="${Math.round(y)}" width="${w}" height="${h}" fill="${INK[c] ?? c}"/>`)
54 }
55
56 dot(x: number, y: number, c: string) {
57 this.rect(x, y, 1, 1, c)
58 }
59
60 // ASCII art, one char per pixel ('.' is empty); `swap` recolors chars as it draws.
61 art(rows: readonly string[], x: number, y: number, swap: Record<string, string> = {}) {
62 rows.forEach((row, j) => {
63 for (let i = 0; i < row.length; ) {
64 const ch = row[i] ?? '.'
65 let k = i + 1
66 while (k < row.length && row[k] === ch) k++
67 if (ch !== '.') this.rect(x + i, y + j, k - i, 1, swap[ch] ?? ch)
68 i = k
69 }
70 })
71 }
72
73 toString() {
74 return this.parts.join('')
75 }
76}
77
78// ---------- motion ----------
79
80const TAU = Math.PI * 2
81const osc = (t: number, period: number, amp: number, phase = 0) => Math.round(Math.sin((t / period + phase) * TAU) * amp)
82const tick = (t: number, fps: number, n: number) => ((Math.floor(t * fps) % n) + n) % n
83
84// A stable pseudo-random number in [0, 1) for an integer seed.
85function noise(n: number): number {
86 let x = Math.imul((n | 0) ^ 0x9e3779b9, 0x85ebca6b)
87 x ^= x >>> 13
88 x = Math.imul(x, 0xc2b2ae35)
89 x ^= x >>> 16
90 return (x >>> 0) / 4294967296
91}
92
93// Plays each scene for `each` seconds, in turn.
94function rotate(t: number, each: number, scenes: ((t: number) => void)[]) {
95 scenes[Math.floor(Math.max(0, t) / each) % scenes.length]?.(t)
96}
97
98// ---------- the spark ----------
99
100// 15x11: a four-point star; its core (rows 4-8) carries the face.
101const PLUS = [
102 '.......A.......',
103 '.......A.......',
104 '......AHA......',
105 '......AAA......',
106 '.....HAAAA.....',
107 '...AHAAAAAAA...',
108 'AAAAAAAAAAAAAAa',
109 '...AAAAAAAAa...',
110 '.....AAAAa.....',
111 '......aAa......',
112 '.......a.......',
113]
114// The same star turned 45 degrees, for twinkles and spins.
115const CROSS = [
116 '...............',
117 '..A.........A..',
118 '...A.......A...',
119 '....A.....A....',
120 '.....HAAAA.....',
121 '...AHAAAAAAA...',
122 '...AAAAAAAAA...',
123 '...AAAAAAAAa...',
124 '.....AAAAa.....',
125 '....a.....a....',
126 '...a.......a...',
127]
128const GRAY = { A: 's', a: 'S', H: 'l' }
129
130type Face = 'calm' | 'happy' | 'focus' | 'sleepy' | 'dizzy' | 'sad' | 'wow' | 'shades'
131
132function face(cv: Canvas, x: number, y: number, f: Face, t: number) {
133 switch (f) {
134 case 'calm':
135 if (t % 3.7 < 0.16) cv.art(['e...e'], x + 5, y + 6)
136 else cv.art(['e...e', 'e...e'], x + 5, y + 5)
137 return
138 case 'happy':
139 cv.art(['.e...e.', 'e.e.e.e'], x + 4, y + 5)
140 cv.dot(x + 3, y + 7, 'm')
141 cv.dot(x + 11, y + 7, 'm')
142 return
143 case 'focus':
144 cv.art(['aa...aa', 'ee...ee'], x + 4, y + 5)
145 return
146 case 'sleepy':
147 cv.art(['ee...ee'], x + 4, y + 6)
148 return
149 case 'dizzy':
150 cv.art(['e.e.e.e', '.e...e.', 'e.e.e.e'], x + 4, y + 5)
151 return
152 case 'sad':
153 cv.art(['.e...e.', '.e...e.', '...e...', '..e.e..'], x + 4, y + 5)
154 return
155 case 'wow':
156 cv.art(['.e...e.', '.e...e.', '...e...'], x + 4, y + 5)
157 return
158 case 'shades':
159 cv.art(['kwkk.kwkk', 'kkkkkkkkk'], x + 3, y + 5)
160 return
161 }
162}
163
164type SparkOpts = { face?: Face; turned?: boolean; gray?: boolean }
165
166function spark(cv: Canvas, x: number, y: number, t: number, o: SparkOpts = {}) {
167 cv.art(o.turned ? CROSS : PLUS, x, y, o.gray ? GRAY : {})
168 face(cv, x, y, o.face ?? 'calm', t)
169}
170
171// 7x7 helper sparks: sub-agents, the coaster rider.
172const MINI = ['...A...', '..AAA..', '.AeAeA.', 'AAAAAAa', '.AAAAa.', '..AAa..', '...a...']
173
174// ---------- stage ----------
175
176// Night sky with a few far stars, and a horizon floor whose lines run off to a vanishing point.
177function stage(cv: Canvas, t: number) {
178 for (let i = 0; i < 16; i++) {
179 const x = Math.floor(noise(i) * W)
180 const y = Math.floor(noise(i + 40) * 11)
181 cv.dot(x, y, tick(t + noise(i + 80) * 9, 1.5, 6) === 0 ? 'd' : 'L')
182 }
183 cv.rect(0, FLOOR, W, 1, 'L')
184 for (let k = -7; k <= 7; k++) {
185 cv.dot(W / 2 + k * 6, FLOOR + 1, 'd')
186 cv.dot(W / 2 + k * 9, FLOOR + 2, 'd')
187 }
188}
189
190function monitor(cv: Canvas, x: number, y: number, t: number, on = true) {
191 cv.rect(x, y, 14, 9, 'L')
192 cv.rect(x + 1, y + 1, 12, 7, 'k')
193 cv.rect(x + 6, y + 9, 2, 1, 'L')
194 cv.rect(x + 4, y + 10, 6, 1, 'L')
195 if (!on) {
196 cv.art(['www', '..w', '.w.', 'www'], x + 5, y + 2)
197 return
198 }
199 const scroll = Math.floor(t * 3)
200 for (let r = 0; r < 6; r++) {
201 const line = scroll + r
202 const indent = Math.floor(noise(line + 7) * 3)
203 const len = Math.min(10 - indent, 2 + Math.floor(noise(line) * 9))
204 cv.rect(x + 2 + indent, y + 2 + r, len, 1, ['g', 'c', 'v', 'y'][Math.floor(noise(line + 3) * 4)] ?? 'g')
205 }
206}
207
208// ---------- idle: drifting, juggling, gardening ----------
209
210function idle(cv: Canvas, t: number) {
211 rotate(t, 12, [
212 t => {
213 spark(cv, 36, 5 + osc(t, 2.6, 1), t, { turned: t % 5 > 4.7 })
214 const a = (t / 3) * TAU
215 cv.dot(43 + Math.round(Math.cos(a) * 13), 9 + Math.round(Math.sin(a) * 4), tick(t, 4, 2) ? 'y' : 'o')
216 },
217 t => {
218 spark(cv, 36, 6, t, { face: 'happy' })
219 for (let i = 0; i < 3; i++) {
220 const run = t * 0.8 + i / 3
221 const p = run % 1
222 const dir = Math.floor(run) % 2 === 0 ? 1 : -1
223 const x = 43 + Math.round((p - 0.5) * 16 * dir)
224 cv.art(['.x.', 'xxx', '.x.'], x - 1, 3 - Math.round(Math.sin(p * Math.PI) * 3), { x: ['c', 'm', 'g'][i] ?? 'c' })
225 }
226 },
227 t => {
228 spark(cv, 28, 6, t)
229 cv.art(['.lll..', 'llllll', 'llll..'], 43, 8)
230 const grow = Math.min(5, Math.floor((t % 12) / 2))
231 for (let i = 0; i < grow; i++) cv.dot(53, 13 - i, 'g')
232 if (grow > 1) cv.art(['g.g'], 52, 14 - grow)
233 cv.art(['bbbbb', '.bBb.', '.bBb.'], 51, 14)
234 for (let i = 0; i < 2; i++) cv.dot(50, 10 + ((tick(t, 6, 4) + i * 2) % 4), 'c')
235 },
236 ])
237}
238
239// ---------- working: terminal, stacking, pondering, delegating; smoke breaks once it's hard ----------
240
241function smokeBreak(cv: Canvas, t: number) {
242 monitor(cv, 18, 6, t, false)
243 cv.art(['bbbbbbbbb', 'bBBBBBBBb', 'bbbbbbbbb'], 38, 14)
244 const x = 35
245 const y = 3
246 spark(cv, x, y, t, { face: 'sleepy' })
247 cv.rect(x + 15, y + 6, 3, 1, 'w')
248 cv.dot(x + 18, y + 6, tick(t, 3, 2) ? 'o' : 'r')
249 for (let i = 0; i < 4; i++) {
250 const p = (t / 2.4 + i / 4) % 1
251 const px = x + 19 + Math.round(p * 6 + Math.sin(p * TAU * 1.5))
252 const py = y + 5 - Math.round(p * 8)
253 cv.rect(px, py, p > 0.6 ? 2 : 1, 1, p < 0.5 ? 'l' : 'L')
254 }
255 if (t % 3 < 2) cv.art(['.c.', 'ccc'], x + 2, y + 1)
256}
257
258function working(cv: Canvas, t: number) {
259 if (t >= HARD_SECONDS && Math.floor((t - HARD_SECONDS) / 8) % 4 === 0) return smokeBreak(cv, t)
260 rotate(t, 6, [
261 t => {
262 monitor(cv, 22, 6, t)
263 spark(cv, 40, 5 + tick(t, 6, 2), t, { face: 'focus' })
264 if (tick(t, 8, 3) === 0) cv.dot(38, 12, 'y')
265 },
266 t => {
267 const p = t % 6
268 const placed = Math.min(6, Math.floor(p / 0.9))
269 const colors = ['c', 'm', 'g', 'v', 'y', 'o']
270 for (let i = 0; i < placed; i++) cv.rect(58, FLOOR - 2 - i * 2, 6, 2, colors[i] ?? 'c')
271 const lift = Math.sin(((p % 0.9) / 0.9) * Math.PI)
272 const x = 34 + Math.round(lift * 6)
273 const y = 6 - Math.round(lift * 2)
274 spark(cv, x, y, t, { face: 'happy' })
275 cv.rect(x + 5, y - 2, 5, 2, colors[placed % colors.length] ?? 'c')
276 },
277 t => {
278 spark(cv, 36, 6, t, { face: 'wow', turned: tick(t, 7, 2) === 1 })
279 cv.art(['.wwwwwww.', 'wwwwwwwww', '.wwwwwww.'], 53, 1)
280 cv.dot(51, 5, 'w')
281 cv.dot(52, 4, 'w')
282 for (let i = 0; i < tick(t, 2, 4); i++) cv.dot(55 + i * 2, 2, 'k')
283 const a = t * 4
284 cv.dot(43 + Math.round(Math.cos(a) * 9), 11 + Math.round(Math.sin(a) * 3), 'y')
285 },
286 t => {
287 spark(cv, 26, 6, t, { face: 'focus' })
288 for (let i = 0; i < 3; i++) {
289 const p = (t / 2.4 + i / 3) % 1
290 const x = 42 + Math.round(p * 44)
291 const y = 9 + osc(t, 0.6, 1, i / 3)
292 cv.art(MINI, x, y)
293 cv.art(['www', 'wlw'], x + 2, y - 2)
294 }
295 },
296 ])
297}
298
299// ---------- loop: the same call again and again, as a coaster loop with no way out ----------
300
301function coaster(cv: Canvas, t: number) {
302 const cx = 52
303 const cy = 9
304 for (let k = 0; k < 40; k++) {
305 const a = (k / 40) * TAU
306 cv.dot(cx + Math.round(6 * Math.sin(a)), cy + Math.round(6 * Math.cos(a)), 'l')
307 }
308 cv.rect(26, cy + 6, 56, 1, 'l')
309 for (const x of [30, 38, 66, 74]) cv.rect(x, cy + 7, 1, FLOOR - cy - 7, 'L')
310 for (let back = 2; back >= 0; back--) {
311 const a = ((t - back * 0.08) / 1.4) * TAU
312 const x = cx + Math.round(4 * Math.sin(a)) - 3
313 const y = cy + Math.round(4 * Math.cos(a)) - 3
314 if (back) cv.dot(x + 3, y + 3, back === 1 ? 'o' : 'r')
315 else cv.art(MINI, x, y, tick(t, 6, 2) ? { e: 'w' } : {})
316 }
317 for (let i = 0; i < 3; i++) {
318 const a = t * 3 + (i * TAU) / 3
319 cv.dot(cx + Math.round(Math.cos(a) * 11), 2 + Math.round(Math.sin(a) * 1), 'y')
320 }
321}
322
323// ---------- done: fireworks and a hop; a lightbulb if the task was hard ----------
324
325const BURSTS = [
326 { at: 0.1, x: 18, y: 4, c: 'm' },
327 { at: 1.2, x: 68, y: 3, c: 'c' },
328 { at: 2.4, x: 58, y: 2, c: 'y' },
329 { at: 3.6, x: 78, y: 5, c: 'g' },
330 { at: 4.8, x: 10, y: 3, c: 'v' },
331]
332
333function fireworks(cv: Canvas, t: number) {
334 for (const b of BURSTS) {
335 const dt = t - b.at
336 if (dt < 0 || dt > 1.8) continue
337 if (dt < 0.5) {
338 const y = FLOOR - Math.round(((FLOOR - b.y) * dt) / 0.5)
339 cv.dot(b.x, y, 'w')
340 cv.dot(b.x, y + 1, 'o')
341 continue
342 }
343 const r = (dt - 0.5) * 9
344 for (let k = 0; k < 10; k++) {
345 const a = (k / 10) * TAU
346 cv.dot(b.x + Math.round(Math.cos(a) * r), b.y + Math.round(Math.sin(a) * r * 0.6), dt > 1.4 ? 'L' : b.c)
347 }
348 }
349}
350
351const BULB = ['.yyy.', 'yHHHy', 'yHHyy', '.yyy.', '.lLl.']
352
353function done(cv: Canvas, t: number, eureka: boolean) {
354 fireworks(cv, t)
355 const x = 36
356 // With the bulb on, the spark stays put so bulb and rays fit above it.
357 const y = eureka ? 6 : 6 - Math.round(Math.abs(Math.sin((t * Math.PI) / 0.45)) * 3)
358 spark(cv, x, y, t, { face: 'happy', turned: tick(t, 3, 2) === 1 })
359 if (!eureka) return
360 cv.art(BULB, x + 5, 0)
361 if (tick(t, 3, 2)) {
362 for (const [dx, dy] of [[3, 1], [11, 1], [4, 0], [10, 0]] as const) cv.dot(x + dx, dy, 'y')
363 }
364}
365
366// ---------- relax: stargazing, a pool float, cocoa by the window ----------
367
368const SUN = ['.yyy.', 'yyyyy', 'yyyyy', '.yyy.']
369
370function relax(cv: Canvas, t: number) {
371 rotate(t, 16, [
372 t => {
373 for (let x = 18; x <= 62; x++) {
374 const h = Math.round(3 - ((x - 40) / 22) ** 2 * 3)
375 cv.rect(x, FLOOR - h, 1, h, 'G')
376 if (h > 0) cv.dot(x, FLOOR - h, 'g')
377 }
378 spark(cv, 33, 3, t, { face: 'sleepy' })
379 cv.art(['.www', 'ww..', 'ww..', '.www'], 74, 1)
380 const p = (t % 5) / 0.8
381 if (p < 1) {
382 const sx = 82 - Math.round(p * 30)
383 const sy = 1 + Math.round(p * 6)
384 cv.dot(sx, sy, 'w')
385 cv.dot(sx + 1, sy, 'l')
386 cv.dot(sx + 2, sy - 1, 'L')
387 }
388 },
389 t => {
390 cv.art(SUN, 76, 1)
391 spark(cv, 33, 4 + osc(t, 3, 1), t, { face: 'shades' })
392 cv.rect(0, 13, W, H - 13, 'C')
393 for (let x = 0; x < W; x += 5) cv.rect(x + tick(t, 2, 5), 13, 2, 1, 'c')
394 cv.art(['.mwmwmwmwmwmwm.', 'mm...........mm', '.mwmwmwmwmwmwm.'], 33, 13 + osc(t, 3, 1))
395 },
396 t => {
397 cv.rect(56, 2, 18, 11, 'N')
398 for (let i = 0; i < 8; i++) {
399 cv.dot(57 + Math.floor(noise(i) * 16), 3 + (Math.floor(t * 3 + noise(i + 9) * 10) % 9), 'w')
400 }
401 cv.rect(56, 2, 18, 1, 'L')
402 cv.rect(56, 12, 18, 1, 'L')
403 cv.rect(56, 2, 1, 11, 'L')
404 cv.rect(73, 2, 1, 11, 'L')
405 cv.rect(64, 2, 1, 11, 'L')
406 cv.rect(56, 7, 18, 1, 'L')
407 spark(cv, 28, 6, t, { face: 'sleepy' })
408 cv.art(['wwww.', 'wbbww', 'wwww.', 'wwww.'], 44, 12)
409 for (let i = 0; i < 2; i++) {
410 const p = (t / 1.6 + i / 2) % 1
411 cv.dot(45 + i * 2 + osc(p, 1, 1), 11 - Math.round(p * 5), 'l')
412 }
413 },
414 ])
415}
416
417// ---------- error: gone gray under a little storm cloud ----------
418
419function storm(cv: Canvas, t: number) {
420 const glitch = t < 0.6
421 if (glitch) {
422 for (let i = 0; i < 12; i++) {
423 const seed = i + tick(t, 20, 100) * 13
424 cv.dot(Math.floor(noise(seed) * W), Math.floor(noise(seed + 300) * FLOOR), 'L')
425 }
426 }
427 spark(cv, 36 + (glitch ? tick(t, 20, 3) - 1 : 0), 6, t, { face: 'sad', gray: true })
428 cv.art(['..lll...', '.lllll.l', 'llllllll', '.LLLLLL.'], 39, 0)
429 if (tick(t, 3, 3) === 0) cv.art(['.y', 'y.', '.y'], 42, 4)
430 for (let i = 0; i < 3; i++) cv.dot(40 + i * 3, 4 + ((tick(t, 8, 3) + i) % 3), 'c')
431}
432
433// One frame of `mood`, `t` seconds after it began.
434export function sceneSvg(mood: Mood, t: number, eureka = false): string {
435 const cv = new Canvas()
436 stage(cv, t)
437 if (mood === 'working') working(cv, t)
438 else if (mood === 'loop') coaster(cv, t)
439 else if (mood === 'done') done(cv, t, eureka)
440 else if (mood === 'relax') relax(cv, t)
441 else if (mood === 'error') storm(cv, t)
442 else idle(cv, t)
443 return (
444 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${SCENE_WIDTH}" height="${SCENE_HEIGHT}" shape-rendering="crispEdges">` +
445 `<rect width="${W}" height="${H}" fill="${SKY}"/>${cv}</svg>`
446 )
447}
448types/index.d.ts 23 lines1export type Mood = 'idle' | 'working' | 'done' | 'relax' | 'error' | 'loop'
2export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
3export type Ctx = { tokens?: number; window: number; percent?: number }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'clawd-hud': {
8 mood: Mood
9 moodAt: number
10 frame: number
11 cacheAt: number | null
12 now: number
13 minute: number
14 ctx: Ctx | null
15 limits: Limit[]
16 eureka: boolean
17 loopSince: number | null
18 toolCounts: Record<string, number>
19 cost: number | null
20 }
21 }
22}
23