SLOPSHOPPER

clawd-band

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

newbandspinnerguardnetworktimer
★ 1v0.2.2MITupdated 2026-10-15nninkovic95/clawd-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd-band
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ▣ Clawd resting 7d ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ -- ↻ -:--:-- -- 5h ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ -- ↻ -:--:-- ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ Clawd resting 7d ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ -- ↻ -:--: -- 5h ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ -- ↻ -:--:
README

clawd-band

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.

clawd-band cycling through every pose

The band

 ┌ sprite ┐  7d ███████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  26% ↻   4d 7h
 │ 12 × 3 │  5h ███████████████░░░░░░░░░░░░░░░░░░░░░░░░░░  38% ↻ 2:14:09
 Opus 5.5 high
  • Sprite: Clawd, drawn with kitty's graphics protocol at 12 columns by 3 rows, in stop-motion (a few frames, each held for 400 ms). It is the only thing that moves: the mod replaces Claude Code's own spinner with an empty box.
  • Model and effort: the session's model, shortened (claude-opus-5-5 becomes Opus 5.5), with the reasoning effort beside it.
  • Usage bars: 7d is the weekly window, 5h the five-hour window. Each bar turns from green to amber at 60% and to red at 85%.
  • Countdown: the time to each window's reset, h:mm:ss under a day, Nd Nh beyond. A window whose reset has passed drops to 0% straight away.

Every pose

The pose follows Claude's current step: thinking, replying, or the tool it is calling. When the turn ends, Clawd goes back to idle.

idle

Nothing running. Clawd stands and blinks now and then.

<img src="docs/demo/idle.gif" alt="clawd-band, idle pose">

thinking

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">

working

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">

reading

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">

writing

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">

running

Bash, BashOutput, KillShell, TaskStop. A stride and a bounce, with speed lines.

<img src="docs/demo/running.gif" alt="clawd-band, running pose">

searching

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">

delegating

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">

questioning

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.

About these demos

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

Requirements

  • Claude Code with function-hook plugins (mods): built against 2.1.296, tested on 2.1.299. The mod API is early access and may change between releases.
  • A terminal that supports the kitty graphics protocol, for the sprite. It has been tested in kitty; other terminals are untested.
  • A claude.ai subscription login (Pro or Max) for the usage bars: the five-hour and weekly windows belong to a subscription, and without them the bars read --.

Install

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.

Configuration

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"
      }
    }
  }
}
OptionDefaultWhat it does
show_modeltrueShows the model and effort line under Clawd. Turned off, the band is three rows tall.
sprite_color#d77757Clawd's body colour, as hex. The props keep their own colours.
bar_color_lowsuccessBar colour below 60%.
bar_color_midwarningBar colour from 60%.
bar_color_higherrorBar colour from 85%.
bar_color_emptyinactiveThe 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.

Privacy

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).

  • When: once at the start of a session, and again after /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.
  • What is sent: the request carries your own claude.ai sign-in, which Claude Code attaches through $.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.
  • What comes back: your five-hour and weekly usage percentages and reset times. This is the same data /usage shows. An API-key session makes no request.

What it reads from the session (all of it stays on your machine):

  • The name of each tool Claude calls (for example Read or Bash), to pick the pose. It does not read tool inputs, tool results or message text.
  • Whether the model's output stream is in a thinking, text or tool-call part, to switch between the thinking and working poses. It does not read the content of those parts.
  • The model name and reasoning effort, for the line under the sprite.
  • The rate-limit readings Claude Code already holds.
  • The session id, to notice a /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.

Development

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.

Credits

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.

License

MIT

Source 3 files
hooks/register.tsx 420 lines
1import { 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}
420
hooks/sprite.ts 205 lines
1import 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}
205
types/index.d.ts 23 lines
1export 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