A band above the prompt: an animated Clawd sprite, five-hour and weekly usage bars, and a countdown to each window's reset

A Claude Code mod that puts a band above the prompt: an animated pixel-art Clawd that acts out what Claude is doing, your weekly and five-hour usage bars, and a live countdown to each window's reset.

┌ sprite ┐ 7d ███████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 26% ↻ 4d 7h
│ 12 × 3 │ 5h ███████████████░░░░░░░░░░░░░░░░░░░░░░░░░░ 38% ↻ 2:14:09
Opus 5.5 high
claude-opus-5-5 becomes Opus 5.5), with the reasoning effort beside it.7d is the weekly window, 5h the five-hour window. Each bar turns from green to amber at 60% and to red at 85%.h:mm:ss under a day, Nd Nh beyond. A window whose reset has passed drops to 0% straight away.The pose follows Claude's current step: thinking, replying, or the tool it is calling. When the turn ends, Clawd goes back to idle.
Nothing running. Clawd stands and blinks now and then.
<img src="docs/demo/idle.gif" alt="clawd-band, idle pose">
The model is thinking. A hand scratches the head while a thought bubble rises.
<img src="docs/demo/thinking.gif" alt="clawd-band, thinking pose">
The model is replying, or calls any tool not listed below (TodoWrite, an MCP tool, …). Both arms pump.
<img src="docs/demo/working.gif" alt="clawd-band, working pose">
Read, Grep, Glob, LS, NotebookRead, ToolSearch, and the Claude Docs connector's read, query and guide. Clawd holds up a book, the eyes run along the line, and a page turns.
<img src="docs/demo/reading.gif" alt="clawd-band, reading pose">
Edit, Write, MultiEdit, NotebookEdit, and every other Claude Docs tool (create, update, batch, …). A pencil leaves a line of ink.
<img src="docs/demo/writing.gif" alt="clawd-band, writing pose">
Bash, BashOutput, KillShell, TaskStop. A stride and a bounce, with speed lines.
<img src="docs/demo/running.gif" alt="clawd-band, running pose">
WebFetch, WebSearch. A magnifying glass, held up on one side and then the other.
<img src="docs/demo/searching.gif" alt="clawd-band, searching pose">
Agent, Task, SendMessage, Workflow, Skill. Clawd waves a small Clawd off on its errand.
<img src="docs/demo/delegating.gif" alt="clawd-band, delegating pose">
AskUserQuestion. A "?" bobs beside the head while Claude writes the question.
<img src="docs/demo/questioning.gif" alt="clawd-band, questioning pose">
While an AskUserQuestion menu is open, Claude Code hides the band. The mod also swaps the sprite for Claude Code's text logo and stops animating until the menu closes, so a kitty image can't cover the menu.
kitty's graphics protocol draws the sprite as an image on its own layer, and terminal recorders such as asciinema record only the text stream, so a recording would show the bars with no Clawd. The GIFs above are therefore rendered by scripts/render-demo.ts from the mod's own frame data (hooks/sprite.ts). The script uses the band's layout from hooks/register.tsx and Claude Code's dark-theme colours. To re-render them:
node scripts/render-demo.ts # needs node 23.6+, rsvg-convert and ImageMagick
--.At the prompt of a Claude Code terminal session:
/plugin install clawd-band --marketplace nninkovic95/clawd-band
Answer y to add the marketplace, then pick a scope. From a shell instead:
claude plugin install clawd-band --marketplace nninkovic95/clawd-band
To run it from a clone while you work on it: claude --plugin-dir /path/to/clawd-band.
The options are the plugin's userConfig fields, Claude Code's standard way for a plugin to take settings. You can set them on the screen shown at install, or later in /config, where each one is a row. A change reloads the band at once. Claude Code stores them in ~/.claude/settings.json under pluginConfigs:
{
"pluginConfigs": {
"clawd-band@clawd-band": {
"options": {
"show_model": false,
"sprite_color": "#6a9bcc",
"bar_color_low": "suggestion",
"bar_color_mid": "#ffc107",
"bar_color_high": "red",
"bar_color_empty": "subtle"
}
}
}
}
| Option | Default | What it does |
|---|---|---|
show_model | true | Shows the model and effort line under Clawd. Turned off, the band is three rows tall. |
sprite_color | #d77757 | Clawd's body colour, as hex. The props keep their own colours. |
bar_color_low | success | Bar colour below 60%. |
bar_color_mid | warning | Bar colour from 60%. |
bar_color_high | error | Bar colour from 85%. |
bar_color_empty | inactive | The unfilled part of each bar. |
Each bar colour can be a Claude Code theme key, which follows your /theme: success, warning, error, inactive, subtle, claude, suggestion, permission, planMode, autoAccept, ide and others. It can also be a colour name (green, magenta) or hex (#4eba65). A value that is none of these falls back to the default. The key under pluginConfigs is clawd-band@<marketplace> for an installed copy, and clawd-band (or clawd-band@inline) when loaded with --plugin-dir.
This section is clawd-band's privacy policy: what the mod reads, stores and sends. clawd-band has no server of its own and collects nothing.
The one host it contacts is api.anthropic.com, with one request: GET https://api.anthropic.com/api/oauth/usage (hooks/register.tsx, fetchAccountUsage).
/clear, but only if Claude Code has no rate-limit reading yet. Usually a reading already exists, because Claude Code records it from the response headers of its own requests, and then nothing is fetched.$.session.authorize() (the mod never sees the token), and the header anthropic-beta: oauth-2025-04-20. It has no body. Nothing from the conversation, your files or your settings is sent./usage shows. An API-key session makes no request.What it reads from the session (all of it stays on your machine):
Read or Bash), to pick the pose. It does not read tool inputs, tool results or message text./clear (which starts a new session in the same process) and load the band again. It is compared with the last id seen and never sent.What it stores: the last usage reading and the last model and effort, in Claude Code's own plugin store on your machine, so that the band is filled at startup. A cached effort is shown only for the same model. Each new reading replaces the old one; uninstalling the plugin and deleting its store removes them.
No telemetry, no analytics, no third parties, no other network calls, and no files written outside the plugin store. The usage request goes to Anthropic under your own account, and Anthropic's privacy policy applies to it. Questions or concerns: open an issue.
scripts/ holds two developer tools that the plugin never runs. render-demo.ts writes the demo GIFs in docs/demo/, and render-icon.ts writes .claude-plugin/icon.png. Both are run by hand with node, and both write images; neither reads or runs one.
claude plugin validate .
claude plugin test .
claude plugin test runs hooks/band.test.tsx: pose mapping, usage parsing and merging, countdowns and colours, whole RGBA frames, the band's content, the survey case, a /clear reload, and the options (checking, the model line, bar and sprite colours). Saving a file while the mod is loaded through --plugin-dir hot-reloads it.
The sprites are original to this project. They are drawn in code (hooks/sprite.ts) on a 28 × 14 grid, and Clawd's body is Claude Code's own startup logo (▐▛███▜▌ / ▝▜█████▛▘ / ▘▘ ▝▝) traced pixel for pixel. Every prop (book, pencil, magnifying glass, thought bubble, "?", mini Clawd) is drawn here.
Clawd is the Claude Code mascot. This is an unofficial community project, not affiliated with or endorsed by Anthropic.
hooks/register.tsx 420 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register, SessionRateLimit } from 'claude-code'
3
4import type { Model, Pose, Usage, Window } from '../types'
5import { ALT, CLAUDE_ORANGE, FRAMES, framesFor, PIXEL_HEIGHT, PIXEL_WIDTH } from './sprite'
6import type { Rgb } from './sprite'
7
8const poseAtom = atom({ plugin: 'clawd-band', key: 'pose' } as const, 'idle')
9const usageAtom = atom({ plugin: 'clawd-band', key: 'usage' } as const, null)
10const modelAtom = atom({ plugin: 'clawd-band', key: 'model' } as const, { name: null, effort: null })
11
12const SPRITE_COLUMNS = 12
13const SPRITE_ROWS = 3
14const FRAME_MS = 400 // stop-motion: a few frames, each held
15const LABEL = 3 // "7d "
16const PERCENT = 5 // " 100%"
17const RESET = 10 // " ↻ 4:59:59" or " ↻ 6d 23h"
18const DOCS_PREFIX = 'mcp__claude_ai_Claude_Docs__'
19
20const READING = ['Read', 'Grep', 'Glob', 'LS', 'NotebookRead', 'ToolSearch']
21const WRITING = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
22const RUNNING = ['Bash', 'BashOutput', 'KillShell', 'TaskStop']
23const SEARCHING = ['WebFetch', 'WebSearch']
24const DELEGATING = ['Agent', 'Task', 'SendMessage', 'Workflow', 'Skill']
25const DOCS_READING = ['read', 'query', 'guide']
26
27export const poseFor = (tool: string): Pose => {
28 if (tool === 'AskUserQuestion') return 'questioning'
29 if (tool.startsWith(DOCS_PREFIX)) return DOCS_READING.includes(tool.slice(DOCS_PREFIX.length)) ? 'reading' : 'writing'
30 if (READING.includes(tool)) return 'reading'
31 if (WRITING.includes(tool)) return 'writing'
32 if (RUNNING.includes(tool)) return 'running'
33 if (SEARCHING.includes(tool)) return 'searching'
34 if (DELEGATING.includes(tool)) return 'delegating'
35 return 'working'
36}
37
38const toWindow = (limits: readonly SessionRateLimit[], kind: string): Window | null => {
39 const found = limits.find(l => l.kind === kind)
40 return found ? { percentUsed: found.percentUsed, resetsAt: found.resetsAt ?? null } : null
41}
42
43export const toUsage = (limits: readonly SessionRateLimit[]): Usage | null =>
44 limits.length === 0 ? null : { fiveHour: toWindow(limits, 'five_hour'), sevenDay: toWindow(limits, 'seven_day') }
45
46export const countdown = (resetsAt: string | null | undefined, now: number): string => {
47 if (!resetsAt) return '-:--:--'
48 const total = Math.max(0, Math.floor((Date.parse(resetsAt) - now) / 1000))
49 const days = Math.floor(total / 86400)
50 if (days > 0) return `${days}d ${Math.floor((total % 86400) / 3600)}h`
51 // Not `h`: in a .tsx file that name is the JSX factory.
52 const hours = Math.floor(total / 3600)
53 const minutes = Math.floor((total % 3600) / 60)
54 const seconds = total % 60
55 return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
56}
57
58// "claude-opus-5-5" -> "Opus 5.5"; a name already shown as words is kept.
59export const shortModel = (model: string): string => {
60 const m = /^claude-([a-z]+)-(\d+)(?:-(\d+))?/.exec(model)
61 if (!m) return model
62 return `${m[1].charAt(0).toUpperCase()}${m[1].slice(1)} ${m[2]}${m[3] ? `.${m[3]}` : ''}`
63}
64
65async function setModel($: EngineInterface, next: Partial<Model>) {
66 let changed: Model | null = null
67 await update($, modelAtom, (current: Model | undefined) => {
68 const base = current ?? { name: null, effort: null }
69 const merged = { name: next.name ?? base.name, effort: next.effort ?? base.effort }
70 if (merged.name === base.name && merged.effort === base.effort) return base
71 changed = merged
72 return merged
73 })
74 if (changed) await $.store.set('model', changed)
75}
76
77// A window whose reset has passed is empty: 0% until the next request starts a
78// new one and a response reports its reset.
79export const resetExpired = (usage: Usage | null, now: number): Usage | null => {
80 if (!usage) return null
81 const live = (w: Window | null) =>
82 w?.resetsAt && Date.parse(w.resetsAt) <= now ? { percentUsed: 0, resetsAt: null } : w
83 return { fiveHour: live(usage.fiveHour), sevenDay: live(usage.sevenDay) }
84}
85
86// A reading names only the windows it has; one it leaves out keeps its last value.
87export const mergeUsage = (current: Usage | null | undefined, limits: readonly SessionRateLimit[]): Usage | null => {
88 const fresh = toUsage(limits)
89 if (!fresh) return current ?? null
90 return { fiveHour: fresh.fiveHour ?? current?.fiveHour ?? null, sevenDay: fresh.sevenDay ?? current?.sevenDay ?? null }
91}
92
93async function setUsage($: EngineInterface, limits: readonly SessionRateLimit[]) {
94 if (limits.length === 0) return
95 let merged: Usage | null = null
96 await update($, usageAtom, (current: Usage | null | undefined) => (merged = mergeUsage(current, limits)))
97 if (merged) await $.store.set('usage', merged)
98}
99
100// The person's userConfig values (plugin.json), checked: a colour that is not
101// a theme key, a colour name or hex falls back to its default.
102export type Settings = {
103 showModel: boolean
104 spriteColor: string
105 spriteRgb: Rgb
106 low: string
107 mid: string
108 high: string
109 empty: string
110}
111
112const HEX = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i
113const COLOR = /^([a-z]+|#[0-9a-f]{3}|#[0-9a-f]{6})$/i // a theme key, a colour name, or hex
114
115const toRgb = (hex: string): Rgb | null => {
116 const m = HEX.exec(hex.trim())
117 if (!m) return null
118 const digits = m[1].length === 3 ? [...m[1]].map(d => d + d).join('') : m[1]
119 return [0, 2, 4].map(i => parseInt(digits.slice(i, i + 2), 16)) as Rgb
120}
121
122const textColor = (value: unknown, fallback: string): string =>
123 typeof value === 'string' && COLOR.test(value.trim()) ? value.trim() : fallback
124
125export const settings = (options: PluginOptions): Settings => {
126 const parsed = typeof options.sprite_color === 'string' ? toRgb(options.sprite_color) : null
127 const custom = parsed && parsed.join() !== CLAUDE_ORANGE.join() ? parsed : null
128 const spriteRgb = custom ?? CLAUDE_ORANGE
129 return {
130 showModel: options.show_model !== false,
131 // The text logo shown while a question is open: the theme's own Claude
132 // colour unless the sprite has one of its own.
133 spriteColor: custom ? `#${custom.map(c => c.toString(16).padStart(2, '0')).join('')}` : 'claude',
134 spriteRgb,
135 low: textColor(options.bar_color_low, 'success'),
136 mid: textColor(options.bar_color_mid, 'warning'),
137 high: textColor(options.bar_color_high, 'error'),
138 empty: textColor(options.bar_color_empty, 'inactive'),
139 }
140}
141
142// Set by register from the person's options; a change there reloads the module.
143let config = settings({})
144let frames = FRAMES
145
146export const barColor = (percent: number, colors: Settings = config) =>
147 percent >= 85 ? colors.high : percent >= 60 ? colors.mid : colors.low
148
149// The timer reads these between draws; a reload starts them over.
150const live: {
151 pose: Pose
152 isWorking: boolean
153 frame: number
154 bandId: string | null
155 isStarted: boolean
156 isAsking: boolean
157 sessionId: string | null
158} = {
159 pose: 'idle',
160 isWorking: false,
161 frame: 0,
162 bandId: null,
163 isStarted: false,
164 isAsking: false,
165 sessionId: null,
166}
167
168const shown = (): Pose => (live.isWorking ? live.pose : 'idle')
169
170async function setPose($: EngineInterface, next: Pose) {
171 if (next === live.pose) return
172 live.pose = next
173 live.frame = 0
174 await update($, poseAtom, () => next)
175}
176
177async function tick($: EngineInterface) {
178 live.frame += 1
179 // Frozen while a question dialog is open: frame swaps kept it from showing as a menu.
180 if (!live.bandId || live.isAsking) return
181 const poseFrames = frames[shown()]
182 const source = { rgba: poseFrames[live.frame % poseFrames.length], width: PIXEL_WIDTH, height: PIXEL_HEIGHT }
183 try {
184 await $.ui.blit({ requestId: live.bandId, key: 'clawd', source })
185 } catch {
186 // The band was taken down between draws; the next render places it again.
187 }
188}
189
190const USAGE_WINDOWS = ['five_hour', 'seven_day']
191
192// The account usage endpoint (what /usage reads): `{ five_hour: { utilization,
193// resets_at }, seven_day: { ... } }`, utilization in percent, a window null
194// while none is open.
195export const parseAccountUsage = (text: string): SessionRateLimit[] => {
196 let body: Record<string, { utilization?: unknown; resets_at?: unknown } | null>
197 try {
198 body = JSON.parse(text)
199 } catch {
200 return []
201 }
202 if (!body || typeof body !== 'object') return []
203 return USAGE_WINDOWS.flatMap(kind => {
204 const w = body[kind]
205 if (!w || typeof w.utilization !== 'number') return []
206 const reset = typeof w.resets_at === 'string' && !Number.isNaN(Date.parse(w.resets_at)) ? w.resets_at : undefined
207 return [{ kind, percentUsed: Math.round(w.utilization * 10) / 10, resetsAt: reset }]
208 })
209}
210
211async function fetchAccountUsage($: EngineInterface): Promise<SessionRateLimit[]> {
212 const auth = await $.session.authorize()
213 if (auth?.kind !== 'bearer') return []
214 // The one request the mod makes: GET, to this address only, with the
215 // person's own sign-in; it sends nothing from the conversation.
216 const res = await $.http.fetch('https://api.anthropic.com/api/oauth/usage', {
217 auth: auth.handle,
218 headers: { 'anthropic-beta': 'oauth-2025-04-20' },
219 })
220 return res.ok ? parseAccountUsage(res.text) : []
221}
222
223// Rate limits come from response headers and effort from the turn, so a fresh
224// session has neither until its first reply: start from the last ones seen,
225// then ask the account for both windows as they stand now.
226async function loadUsage($: EngineInterface) {
227 const stored = await $.store.get('usage')
228 const now = await $.clock.now()
229 const cached = resetExpired((stored as Usage | undefined) ?? null, now)
230 if (cached) await update($, usageAtom, (current: Usage | null | undefined) => current ?? cached)
231 const { rateLimits } = await $.session.usage()
232 if (rateLimits.length > 0) return setUsage($, rateLimits)
233 await setUsage($, await fetchAccountUsage($).catch(() => []))
234}
235
236async function loadModel($: EngineInterface) {
237 const name = await $.session.model()
238 const stored = await $.store.get('model')
239 const cached = stored as Model | undefined
240 // The stored effort belongs to the stored model; another model may differ.
241 const effort = cached && cached.name === name ? cached.effort : null
242 await setModel($, { name, effort: effort ?? undefined })
243}
244
245// A /clear goes on in the same process under a new session id, with empty
246// $.state and no session.start: a changed id loads usage and model again.
247async function loadSession($: EngineInterface) {
248 let id = ''
249 try {
250 id = await $.session.id()
251 } catch {
252 // An id that cannot be read still loads once.
253 }
254 if (id === live.sessionId) return
255 live.sessionId = id
256 await Promise.all([loadUsage($).catch(() => undefined), loadModel($).catch(() => undefined)])
257}
258
259// Started by whichever hook runs first: a resumed session can load the module
260// without raising session.start, which left the band undrawn until a reload.
261function ensureStarted($: EngineInterface) {
262 if (live.isStarted) return
263 live.isStarted = true
264 $.clock.every(FRAME_MS, () => tick($))
265 $.clock.every(1000, () => {
266 loadSession($).catch(() => undefined)
267 if (!live.isAsking) $.ui.invalidate('ui.render')
268 })
269 $.clock.after(0, () => {
270 loadSession($).catch(() => undefined)
271 })
272 $.ui.invalidate('ui.render')
273}
274
275export const register: Register = (on, options) => {
276 config = settings(options)
277 frames = config.spriteRgb === CLAUDE_ORANGE ? FRAMES : framesFor(config.spriteRgb)
278
279 on('session.start', async ($, e, next) => {
280 const result = await next(e)
281 ensureStarted($)
282 return result
283 })
284
285 on('session.measure', async ($, e, next) => {
286 ensureStarted($)
287 if (e.changed.includes('rateLimits')) await setUsage($, e.rateLimits)
288 return next(e)
289 })
290
291 on('turn.start', async ($, e, next) => {
292 ensureStarted($)
293 await setPose($, 'working')
294 return next(e)
295 })
296
297 // The main thread's stream says when the model is thinking, writing, or
298 // starting a tool call; subagents' streams are passed through untouched.
299 on('turn.step', async function* ($, e, next) {
300 const stream = next(e)
301 if (e.agentId) return yield* stream
302 await setModel($, { name: e.model, effort: e.effort === undefined ? undefined : String(e.effort) })
303 for await (const chunk of stream) {
304 if (chunk.kind === 'thinking') await setPose($, 'thinking')
305 else if (chunk.kind === 'text') await setPose($, 'working')
306 else if (chunk.kind === 'tool') await setPose($, poseFor(chunk.name))
307 yield chunk
308 }
309 return stream.result
310 })
311
312 // Hold the pose while the tool runs (an AskUserQuestion waits on the person).
313 on('tool.call', async ($, e, next) => {
314 const tool = String(e.tool)
315 await setPose($, poseFor(tool))
316 // The band holds still while the question dialog is open (one redraw for
317 // the pose, then no frame swaps or countdown ticks until it closes).
318 const isQuestion = tool === 'AskUserQuestion'
319 if (isQuestion) {
320 live.isAsking = true
321 $.ui.invalidate('ui.render')
322 }
323 try {
324 return await next(e)
325 } finally {
326 if (isQuestion) {
327 live.isAsking = false
328 $.ui.invalidate('ui.render')
329 }
330 await setPose($, 'working')
331 }
332 }).catch(($, e, next) => next(e))
333
334 on('turn.complete', async ($, e, next) => {
335 await setPose($, 'idle')
336 return next(e)
337 })
338
339 // The sprite is the only thing moving: the native spinner draws empty.
340 on('ui.render', { component: 'Spinner' }, ($, e, next) => {
341 ensureStarted($)
342 if (e.surface !== 'terminal') return next(e)
343 const { Box } = $.ui.resolve(e)
344 return <Box />
345 })
346
347 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
348 ensureStarted($)
349 if (e.surface !== 'terminal' || e.props.hasSurvey) {
350 live.bandId = null
351 return next(e)
352 }
353
354 live.bandId = e.requestId
355 live.isWorking = e.props.isWorking
356 live.pose = await read($, poseAtom)
357 const now = await $.clock.now()
358 // A window that resets mid-session drops to 0% on the next tick, not the next reply.
359 const usage = resetExpired(await read($, usageAtom), now)
360 const model = await read($, modelAtom)
361 const { Box, Text, Image } = $.ui.resolve(e)
362
363 const current = shown()
364 const poseFrames = frames[current]
365 const modelName = model.name ? shortModel(model.name) : '--'
366 const caption = model.effort ? `${modelName} ${model.effort}` : modelName
367 const left = config.showModel ? Math.max(SPRITE_COLUMNS + 1, caption.length) : SPRITE_COLUMNS + 1
368 const barWidth = Math.max(4, e.props.bodyColumns - left - 1 - LABEL - PERCENT - RESET)
369
370 const bar = (label: string, window: Window | null | undefined) => {
371 const percent = window?.percentUsed ?? 0
372 const filled = Math.min(barWidth, Math.round((percent / 100) * barWidth))
373 return (
374 <Box flexDirection="row">
375 <Text dimColor>{label} </Text>
376 <Text color={barColor(percent)}>{'█'.repeat(filled)}</Text>
377 <Text color={config.empty}>{'░'.repeat(barWidth - filled)}</Text>
378 <Text dimColor={!window}>{window ? ` ${String(Math.round(percent)).padStart(3)}%` : ' --'}</Text>
379 <Text dimColor> ↻ </Text>
380 <Text bold={!!window?.resetsAt}>{countdown(window?.resetsAt, now).padStart(RESET - 3)}</Text>
381 </Box>
382 )
383 }
384
385 return (
386 <Box flexDirection="row" alignItems="center">
387 <Box flexDirection="column" width={left} alignItems="center">
388 {live.isAsking ? (
389 // A kitty image sits on a layer above the text and covered the
390 // question menu; while it is open, Clawd is the CLI's text logo.
391 <Box flexDirection="column">
392 <Text color={config.spriteColor}> ▐▛███▜▌ </Text>
393 <Text color={config.spriteColor}>▝▜█████▛▘</Text>
394 <Text color={config.spriteColor}> ▘▘ ▝▝ </Text>
395 </Box>
396 ) : (
397 <Image
398 key="clawd"
399 source={{ rgba: poseFrames[live.frame % poseFrames.length], width: PIXEL_WIDTH, height: PIXEL_HEIGHT }}
400 columns={SPRITE_COLUMNS}
401 rows={SPRITE_ROWS}
402 alt={ALT[current]}
403 />
404 )}
405 {config.showModel ? (
406 <Box key="model" flexDirection="row">
407 <Text bold>{modelName}</Text>
408 {model.effort ? <Text dimColor> {model.effort}</Text> : null}
409 </Box>
410 ) : null}
411 </Box>
412 <Box flexDirection="column" marginLeft={1}>
413 {bar('7d', usage?.sevenDay)}
414 {bar('5h', usage?.fiveHour)}
415 </Box>
416 </Box>
417 )
418 })
419}
420hooks/sprite.ts 205 lines1import type { Pose } from '../types'
2
3// Clawd as the CLI prints it at startup (▐▛███▜▌ / ▝▜█████▛▘ / ▘▘ ▝▝): a
4// quadrant is twice as tall as wide, so each logo pixel is drawn here as one
5// column by two rows of square art pixels. The body sits at the bottom of the
6// canvas; the rows above it and the columns beside it hold the props.
7export const ART_WIDTH = 28
8export const ART_HEIGHT = 14
9export const SCALE = 5
10export const PIXEL_WIDTH = ART_WIDTH * SCALE
11export const PIXEL_HEIGHT = ART_HEIGHT * SCALE
12
13export type Rgb = [number, number, number]
14
15export const CLAUDE_ORANGE: Rgb = [215, 119, 87]
16
17const COLORS: Record<string, Rgb> = {
18 '#': CLAUDE_ORANGE, // the body, replaced by the sprite_color option
19 w: [235, 232, 225], // thought dots, speed lines, ink
20 y: [240, 190, 60], // pencil, "?"
21 k: [70, 60, 55], // pencil tip, glass handle
22 b: [240, 236, 226], // book pages
23 l: [150, 120, 100], // book spine
24 g: [170, 200, 220], // magnifying glass
25}
26
27type Grid = string[][]
28
29const blank = (): Grid => Array.from({ length: ART_HEIGHT }, () => Array<string>(ART_WIDTH).fill('.'))
30
31// Paints a pattern at (x, y): a space leaves the pixel, a dot clears it.
32const stamp = (g: Grid, pattern: string[], x: number, y: number): Grid => {
33 pattern.forEach((row, r) =>
34 row.split('').forEach((ch, c) => {
35 const gy = y + r
36 const gx = x + c
37 if (ch === ' ' || gy < 0 || gy >= ART_HEIGHT || gx < 0 || gx >= ART_WIDTH) return
38 g[gy][gx] = ch
39 }),
40 )
41 return g
42}
43
44const fill = (w: number, h: number, ch = '#'): string[] => Array.from({ length: h }, () => ch.repeat(w))
45
46type Arm = 'out' | 'up' | 'down' | 'none'
47
48type Body = {
49 y?: number
50 eyes?: 'open' | 'shut' | 'left' | 'right' | 'down'
51 left?: Arm
52 right?: Arm
53 legs?: 'stand' | 'stride' | 'long'
54}
55
56const X = 5 // the body's left edge, arms included
57
58const body = ({ y = 4, eyes = 'open', left = 'out', right = 'out', legs = 'stand' }: Body = {}): Grid => {
59 const g = blank()
60 stamp(g, fill(12, 8), X + 3, y)
61
62 const eyeRows = eyes === 'down' ? [y + 3] : [y + 2, y + 3]
63 const shift = eyes === 'left' ? -1 : eyes === 'right' ? 1 : 0
64 if (eyes !== 'shut') for (const r of eyeRows) for (const c of [X + 5, X + 12]) g[r][c + shift] = '.'
65
66 const arm = (side: Arm, col: number) => {
67 if (side === 'out') stamp(g, fill(2, 2), col, y + 4)
68 if (side === 'up') stamp(g, fill(2, 5), col, y - 1)
69 if (side === 'down') stamp(g, fill(2, 2), col, y + 6)
70 }
71 arm(left, X + 1)
72 arm(right, X + 15)
73
74 const feet = [X + 4, X + 6, X + 11, X + 13]
75 const spread = [X + 3, X + 7, X + 10, X + 14]
76 for (const c of feet) stamp(g, ['#'], c, y + 8)
77 for (const c of legs === 'stride' ? spread : feet) stamp(g, ['#'], c, y + 9)
78 if (legs === 'long') for (const c of spread) stamp(g, ['#'], c, y + 10)
79 return g
80}
81
82// Idle: stands, blinks now and then.
83const stand = body()
84const idle = [stand, stand, stand, stand, stand, body({ eyes: 'shut' })]
85
86// Thinking: a hand on the head, scratching, and a thought bubble rising.
87const scratch = (handX: number, dots: number): Grid => {
88 const g = body({ eyes: 'left', right: 'up' })
89 stamp(g, ['###'], handX, 3)
90 if (dots >= 1) stamp(g, ['w'], 6, 2)
91 if (dots >= 2) stamp(g, ['w'], 4, 1)
92 if (dots >= 3) stamp(g, ['ww', 'ww'], 1, 0)
93 return g
94}
95const thinking = [scratch(17, 1), scratch(16, 2), scratch(17, 3), scratch(16, 3)]
96
97// Questioning: a "?" that bobs beside the head.
98const QUESTION = [' yyy ', 'y y', ' y', ' y ', ' y ', ' ', ' y ']
99const asking = (y: number): Grid => stamp(body({ eyes: 'right' }), QUESTION, 22, y)
100const questioning = [asking(0), asking(0), asking(1), asking(1)]
101
102// Working: both arms pumping.
103const working = [body({ left: 'up', right: 'up' }), stand, body({ left: 'down', right: 'down' }), stand]
104
105// Reading: a book held up, the eyes running along the line, then a page turns.
106const BOOK = fill(5, 4, 'b').map(r => `${r}ll${r}`)
107const reader = (eyes: Body['eyes'], turning = false): Grid => {
108 const g = stamp(body({ eyes }), BOOK, X + 3, 8)
109 if (turning) stamp(g, [' bbbb', ' bbbb'], X + 10, 6)
110 return g
111}
112const reading = [reader('left'), reader('open'), reader('right'), reader('down', true)]
113
114// Writing: a pencil in the right hand, leaving a line of ink as it moves.
115const PENCIL = ['y ', ' y ', ' y ', ' k']
116const writer = (step: number): Grid => {
117 const g = body({ eyes: 'down' })
118 const px = 21 + step
119 stamp(g, ['#'.repeat(step + 1)], 21, 8)
120 stamp(g, PENCIL, px, 9)
121 stamp(g, ['w'.repeat(px + 3 - 21)], 21, 13)
122 return g
123}
124const writing = [writer(0), writer(1), writer(2), writer(2)]
125
126// Running: a stride, then a bounce with the legs out, speed lines behind.
127const runA = stamp(stamp(body({ legs: 'stride', left: 'up', right: 'down' }), ['www'], 0, 6), ['ww'], 1, 10)
128const runB = stamp(stamp(body({ y: 3, legs: 'long', left: 'down', right: 'up' }), ['ww'], 1, 5), ['www'], 0, 9)
129const running = [runA, runB]
130
131// Searching: a magnifying glass held up on one side, then the other.
132const GLASS = [' ggg ', 'g g', 'g g', 'g g', ' ggg ']
133const glassRight = stamp(stamp(body({ eyes: 'right', right: 'none' }), GLASS, 22, 0), ['k', '#', '##'], 22, 5)
134const glassLeft = stamp(stamp(body({ eyes: 'left', left: 'none' }), GLASS, 1, 0), [' k', ' #', '##'], 4, 5)
135const searching = [glassRight, glassRight, glassLeft, glassLeft]
136
137// Delegating: waving a small Clawd off on its errand.
138const MINI = ['.###.', '#####', '.###.', '.#.#.']
139const waver = (handX: number, miniX: number, miniY: number): Grid => {
140 const g = body({ left: 'up', eyes: 'right' })
141 stamp(g, ['##'], handX, 2)
142 return stamp(g, MINI, miniX, miniY)
143}
144const delegating = [waver(5, 22, 10), waver(4, 23, 9)]
145
146const ART: Record<Pose, Grid[]> = {
147 idle,
148 thinking,
149 questioning,
150 working,
151 reading,
152 writing,
153 running,
154 searching,
155 delegating,
156}
157
158const toBase64 = (art: Grid, colors: Record<string, Rgb>): string => {
159 const px = new Uint8Array(PIXEL_WIDTH * PIXEL_HEIGHT * 4)
160 for (let y = 0; y < PIXEL_HEIGHT; y++) {
161 const row = art[Math.floor(y / SCALE)]
162 for (let x = 0; x < PIXEL_WIDTH; x++) {
163 const rgb = colors[row[Math.floor(x / SCALE)]]
164 if (!rgb) continue
165 const i = (y * PIXEL_WIDTH + x) * 4
166 px[i] = rgb[0]
167 px[i + 1] = rgb[1]
168 px[i + 2] = rgb[2]
169 px[i + 3] = 255
170 }
171 }
172 return px.toBase64()
173}
174
175// Every frame encoded once at load, so a tick only swaps a string.
176export const framesFor = (body: Rgb): Record<Pose, string[]> => {
177 const colors = { ...COLORS, '#': body }
178 const encode = (art: Grid) => toBase64(art, colors)
179 return {
180 idle: ART.idle.map(encode),
181 thinking: ART.thinking.map(encode),
182 questioning: ART.questioning.map(encode),
183 working: ART.working.map(encode),
184 reading: ART.reading.map(encode),
185 writing: ART.writing.map(encode),
186 running: ART.running.map(encode),
187 searching: ART.searching.map(encode),
188 delegating: ART.delegating.map(encode),
189 }
190}
191
192export const FRAMES = framesFor(CLAUDE_ORANGE)
193
194export const ALT: Record<Pose, string> = {
195 idle: 'Clawd resting',
196 thinking: 'Clawd thinking',
197 questioning: 'Clawd asking',
198 working: 'Clawd working',
199 reading: 'Clawd reading',
200 writing: 'Clawd writing',
201 running: 'Clawd running a command',
202 searching: 'Clawd searching the web',
203 delegating: 'Clawd handing off to a subagent',
204}
205types/index.d.ts 23 lines1export type Pose =
2 | 'idle'
3 | 'thinking'
4 | 'questioning'
5 | 'working'
6 | 'reading'
7 | 'writing'
8 | 'running'
9 | 'searching'
10 | 'delegating'
11
12export type Window = { percentUsed: number; resetsAt: string | null }
13
14export type Model = { name: string | null; effort: string | null }
15
16export type Usage = { fiveHour: Window | null; sevenDay: Window | null }
17
18declare module 'claude-code' {
19 interface PluginState {
20 'clawd-band': { pose: Pose; usage: Usage | null; model: Model }
21 }
22}
23