Clawd types on a tiny laptop under the spinner: the code Claude writes floats out of the screen, input tokens float in, and he turns to face you while thinking.

Clawd sits under the Claude Code spinner with a tiny laptop and works along with Claude.
hm... floats above his head.[♪] under Clawd (dim when off, lit when on), or run /clawd-sound; a quiet keyboard then plays while he types and stops while he thinks. The choice is remembered across sessions.Terminal only; other surfaces keep the normal spinner. The sound plays in macOS terminals; the click needs the fullscreen terminal ("tui": "fullscreen"), the command works everywhere.
/plugin marketplace add scoobynko/claude-code-mods
/plugin install clawd-spinner@scoobynko-mods
The poses are text art at the top of hooks/register.tsx, two characters per terminal cell horizontally and two per row vertically (quarter blocks):
| Mark | Drawn as |
|---|---|
o | body |
O | body in shade (the pressing arm) |
# | eye |
= | laptop |
l | thinking leg, a ⅝-width bar filling its whole cell |
. | empty |
A terminal cell can show two colours, so every 2×2 block of marks may hold at most two different ones. Save the file and a running session reloads the mod.
sounds/typing.wav is cut from Keyboard Soundpack #1 by unicaegames, released under CC0. Replace the file with your own recording to change the sound; any length loops.
claude plugin test plugins/clawd-spinnerhooks/register.tsx 485 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3const TYPING_POSES = [
4 `
5..oooooooooooo............
6..oo##oooooo##.........=..
7..oooooooooooooooo....=...
8..oooooooooooooooo...=....
9..oo....oo..oo.======.....
10..o.....o...o.............`,
11 `
12..oooooooooooo............
13..oo##oooooo##............
14..oooooooooooooo.......=..
15..ooooooooooooOOOO....=...
16..oo....oo..oo..OO...=....
17..o.....o...o..======.....`,
18 `
19..oooooooooooo............
20..oo##oooooo##............
21..oooooooooooooooo.....=..
22..oooooooooooo........=...
23..oo....oo..oo.......=....
24..o.....o...o..======.....`,
25]
26const THINKING_POSES = [
27 `
28..oooooooooooo............
29..oo##oooo##oo............
30oooooooooooooooo.......=..
31..oooooooooooo........=...
32..llll....llll.......=....
33..llll....llll.======.....`,
34 `
35..........................
36..oooooooooooo............
37..oo##oooo##oo.........=..
38oooooooooooooooo......=...
39..oooooooooooo.......=....
40..o.o.....o.o..======.....`,
41]
42
43const BODY_COLOR = 0xd77757
44const PIXEL_COLORS: Record<string, number> = {
45 o: BODY_COLOR,
46 l: BODY_COLOR,
47 O: 0xbd674b,
48 '#': 0x000000,
49 '=': 0x898989,
50}
51const BLOCK_GLYPHS: Record<string, number> = {
52 l: 0x258b,
53}
54const TERMINAL_DEFAULT = 0x01000000
55const SPACE = 0x20
56const QUADRANTS = [
57 0x20, 0x2598, 0x259d, 0x2580, 0x2596, 0x258c, 0x259e, 0x259b,
58 0x2597, 0x259a, 0x2590, 0x259c, 0x2584, 0x2599, 0x259f, 0x2588,
59]
60
61const POSE_PIXELS = (TYPING_POSES[0] ?? '').trim().split('\n')
62const SPRITE_COLUMNS = (POSE_PIXELS[0]?.length ?? 0) / 2
63const SPRITE_ROWS = POSE_PIXELS.length / 2
64const isGridPose = (pose: string) => {
65 const rows = pose.trim().split('\n')
66 return rows.length === SPRITE_ROWS * 2 && rows.every(row => row.length === SPRITE_COLUMNS * 2)
67}
68if (!Number.isInteger(SPRITE_COLUMNS) || !Number.isInteger(SPRITE_ROWS) || ![...TYPING_POSES, ...THINKING_POSES].every(isGridPose)) {
69 throw new Error('clawd-spinner: every pose must share one size with an even width and height')
70}
71const LANE_COLUMNS = 34
72const COLUMNS = SPRITE_COLUMNS + LANE_COLUMNS
73const HEAD_ROW = 0
74const SPRITE_TOP = HEAD_ROW + 1
75const ROWS = SPRITE_TOP + SPRITE_ROWS
76const FRAME_MS = 40
77const TYPE_MS = FRAME_MS
78const BOB_MS = 420
79const MS_PER_COLUMN = 70
80const LINE_RISE_MS = 450
81const LINE_ROWS = SPRITE_ROWS
82const LINE_GAP = 1
83const MAX_LINE = LANE_COLUMNS - LINE_GAP - LINE_ROWS
84const MAX_QUEUED_LINES = 6
85const MAX_SCANNED_LINE = 200
86const CODE_BRIGHT = 0xf0a27e
87const INTAKE_BRIGHT = 0x8ab4f8
88const FADED = 0x3a3a3a
89const HM_TEXT = 'hm...'
90const HM_COLOR = 0xd8d8d8
91const HM_LIFE_MS = 1800
92const HM_MS_PER_CHAR = 140
93const HM_MS_PER_DRIFT = 450
94const HM_START_COLUMNS = [1, 4, 2, 5]
95const CODE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'Bash'])
96const CODE_KEYS = ['content', 'new_string', 'new_source', 'command']
97const CODE_VALUE_START = new RegExp(`"(?:${CODE_KEYS.join('|')})"\\s*:\\s*"`, 'g')
98const KEY_PREFIX_WINDOW = 2 * Math.max(...CODE_KEYS.map(key => `"${key}":"`.length))
99const FENCE = /^\s*```/
100const KEY = 'clawd'
101const TYPING_SOUND = 'sounds/typing.wav'
102const TYPING_GAIN = 0.5
103const SOUND_KEY = 'sound'
104const SOUND_COMMAND = 'clawd-sound'
105const SOUND_LABEL = '[♪]'
106
107type Cell = readonly [number, number, number]
108type Floating = { text: string; bornAt: number }
109type ValueScan = { json: string; cursor: number; isInValue: boolean; line: string }
110type FenceScan = { line: string; isInFence: boolean }
111type Sound = { isOn: boolean; canPlay: boolean; loaded?: Promise<void>; stop?: AbortController }
112
113const BLANK: Cell = [SPACE, TERMINAL_DEFAULT, TERMINAL_DEFAULT]
114
115function spriteCells(pose: string): Cell[] {
116 const pixels = pose.trim().split('\n')
117 const markAt = (x: number, y: number) => pixels[y]?.[x] ?? '.'
118 const cells: Cell[] = []
119
120 for (let row = 0; row < SPRITE_ROWS; row++) {
121 for (let column = 0; column < SPRITE_COLUMNS; column++) {
122 const x = column * 2
123 const y = row * 2
124 const marks = [markAt(x, y), markAt(x + 1, y), markAt(x, y + 1), markAt(x + 1, y + 1)]
125 const quarter = marks.map(mark => PIXEL_COLORS[mark])
126 const fg = quarter.find(color => color !== undefined)
127 if (fg === undefined) {
128 cells.push(BLANK)
129 continue
130 }
131 const glyph = marks.every(mark => mark === marks[0]) ? BLOCK_GLYPHS[marks[0] ?? '.'] : undefined
132 if (glyph !== undefined) {
133 cells.push([glyph, fg, TERMINAL_DEFAULT])
134 continue
135 }
136 const bg = quarter.find(color => color !== undefined && color !== fg) ?? TERMINAL_DEFAULT
137 const mask = quarter.reduce<number>((bits, color, i) => (color === fg ? bits | (1 << i) : bits), 0)
138 cells.push([QUADRANTS[mask] ?? SPACE, fg, bg])
139 }
140 }
141
142 return cells
143}
144
145function baseFrame(sprite: Cell[]): Uint32Array {
146 const words = new Uint32Array(COLUMNS * ROWS * 3)
147 for (let i = 0; i < COLUMNS * ROWS; i++) words.set(BLANK, i * 3)
148 sprite.forEach((cell, i) => {
149 const row = SPRITE_TOP + Math.floor(i / SPRITE_COLUMNS)
150 words.set(cell, (row * COLUMNS + (i % SPRITE_COLUMNS)) * 3)
151 })
152 return words
153}
154
155const BLANK_FRAME = baseFrame([])
156const TYPING_FRAMES = TYPING_POSES.map(pose => baseFrame(spriteCells(pose)))
157const THINKING_FRAMES = THINKING_POSES.map(pose => baseFrame(spriteCells(pose)))
158
159const poseAt = (frames: Uint32Array[], elapsed: number, msPerPose: number) =>
160 frames[Math.floor(elapsed / msPerPose) % frames.length] ?? BLANK_FRAME
161
162const formatTokens = (tokens: number) =>
163 tokens >= 1000 ? `+${(tokens / 1000).toFixed(1)}k` : `+${tokens}`
164
165const isDrawable = (char: string) => {
166 const code = char.codePointAt(0) ?? 0
167 return (code >= 0x20 && code <= 0x7e) || (code >= 0xa1 && code <= 0x24f && code !== 0xad)
168}
169
170const displayLine = (raw: string) =>
171 [...raw.replace(/\t/g, ' ').trim()].filter(isDrawable).join('').slice(0, MAX_LINE)
172
173function scanCodeValues(scan: ValueScan, json: string, onLine: (line: string) => void) {
174 scan.json = scan.json.slice(scan.cursor) + json
175 scan.cursor = 0
176 const append = (text: string) => {
177 if (scan.line.length < MAX_SCANNED_LINE) scan.line += text
178 }
179
180 while (scan.cursor < scan.json.length) {
181 if (!scan.isInValue) {
182 CODE_VALUE_START.lastIndex = scan.cursor
183 const start = CODE_VALUE_START.exec(scan.json)
184 if (!start) {
185 scan.cursor = Math.max(scan.cursor, scan.json.length - KEY_PREFIX_WINDOW)
186 return
187 }
188 scan.cursor = start.index + start[0].length
189 scan.isInValue = true
190 scan.line = ''
191 continue
192 }
193
194 const char = scan.json[scan.cursor] ?? ''
195 if (char === '"') {
196 onLine(scan.line)
197 scan.isInValue = false
198 scan.cursor++
199 } else if (char === '\\') {
200 const escaped = scan.json[scan.cursor + 1]
201 if (escaped === undefined) return
202 if (escaped === 'u') {
203 const hex = scan.json.slice(scan.cursor + 2, scan.cursor + 6)
204 if (hex.length < 4) return
205 append(String.fromCharCode(parseInt(hex, 16)))
206 scan.cursor += 6
207 continue
208 }
209 if (escaped === 'n') {
210 onLine(scan.line)
211 scan.line = ''
212 } else if (escaped === 't') {
213 append('\t')
214 } else if (escaped !== 'r') {
215 append(escaped)
216 }
217 scan.cursor += 2
218 } else {
219 append(char)
220 scan.cursor++
221 }
222 }
223}
224
225function scanFencedCode(scan: FenceScan, text: string, onLine: (line: string) => void) {
226 const lines = (scan.line + text).split('\n')
227 scan.line = (lines.pop() ?? '').slice(0, MAX_SCANNED_LINE)
228 for (const line of lines) {
229 if (FENCE.test(line)) scan.isInFence = !scan.isInFence
230 else if (scan.isInFence) onLine(line)
231 }
232}
233
234const mix = (from: number, to: number, amount: number) => {
235 const channel = (shift: number) => {
236 const a = (from >> shift) & 0xff
237 const b = (to >> shift) & 0xff
238 return Math.round(a + (b - a) * amount) << shift
239 }
240 return channel(16) | channel(8) | channel(0)
241}
242
243const hmOpacity = (age: number) => {
244 const progress = age / HM_LIFE_MS
245 if (progress < 0.15) return progress / 0.15
246 if (progress > 0.6) return Math.max(0, (1 - progress) / 0.4)
247 return 1
248}
249
250function syncSound($: EngineInterface, sound: Sound, isTyping: boolean) {
251 const shouldPlay = sound.isOn && sound.canPlay && isTyping
252 if (shouldPlay === (sound.stop !== undefined)) return
253 if (!shouldPlay) {
254 sound.stop?.abort()
255 sound.stop = undefined
256 return
257 }
258
259 const stop = new AbortController()
260 sound.stop = stop
261 const giveUp = () => {
262 if (stop.signal.aborted) return
263 sound.canPlay = false
264 sound.stop = undefined
265 }
266 void $.audio.play({ asset: TYPING_SOUND }, { shouldLoop: true, gain: TYPING_GAIN, signal: stop.signal }).then(giveUp, giveUp)
267}
268
269function loadSound($: EngineInterface, sound: Sound) {
270 sound.loaded ??= $.store.get(SOUND_KEY).then(
271 saved => {
272 sound.isOn = saved === true
273 },
274 () => undefined,
275 )
276 return sound.loaded
277}
278
279async function toggleSound($: EngineInterface, sound: Sound, isTyping: boolean) {
280 await loadSound($, sound)
281 sound.isOn = !sound.isOn
282 sound.canPlay = true
283 syncSound($, sound, isTyping)
284 $.ui.invalidate('ui.render')
285 await $.store.set(SOUND_KEY, sound.isOn).catch(() => undefined)
286}
287
288function startTicker(
289 $: EngineInterface,
290 spinners: Set<string>,
291 nextFrame: () => string | undefined,
292 sound: Sound,
293 isTyping: () => boolean,
294): Timer {
295 return $.clock.every(FRAME_MS, () => {
296 const cells = nextFrame()
297 syncSound($, sound, isTyping())
298 if (cells === undefined) return
299 for (const requestId of spinners) {
300 void $.ui.blit({ requestId, key: KEY, cells }).then(
301 ({ deny }) => {
302 if (deny) spinners.delete(requestId)
303 },
304 () => spinners.delete(requestId),
305 )
306 }
307 })
308}
309
310export const register: Register = on => {
311 const spinners = new Set<string>()
312 const queuedLines: string[] = []
313 let intakes: Floating[] = []
314 let codeLines: Floating[] = []
315 let pendingIntake = 0
316 let isThinking = false
317 let hm: { bornAt: number; column: number } | undefined
318 let hmCount = 0
319 let ticks = 0
320 let ticker: Timer | undefined
321 const sound: Sound = { isOn: false, canPlay: true }
322
323 const now = () => ticks * FRAME_MS
324 const travelled = (intake: Floating) => Math.floor((now() - intake.bornAt) / MS_PER_COLUMN)
325 const risen = (line: Floating) => Math.floor((now() - line.bornAt) / LINE_RISE_MS)
326
327 const queueLine = (raw: string) => {
328 const line = displayLine(raw)
329 if (line && queuedLines.push(line) > MAX_QUEUED_LINES) queuedLines.shift()
330 }
331
332 const advance = () => {
333 ticks++
334 intakes = intakes.filter(intake => travelled(intake) < LANE_COLUMNS + intake.text.length)
335 codeLines = codeLines.filter(line => risen(line) < LINE_ROWS)
336
337 const newestIntake = intakes.at(-1)
338 if (pendingIntake > 0 && (!newestIntake || travelled(newestIntake) > newestIntake.text.length)) {
339 intakes.push({ text: formatTokens(pendingIntake), bornAt: now() })
340 pendingIntake = 0
341 }
342
343 const newestLine = codeLines.at(-1)
344 const nextLine = !newestLine || risen(newestLine) >= 1 ? queuedLines.shift() : undefined
345 if (nextLine) codeLines.push({ text: nextLine, bornAt: now() })
346
347 if (!hm || now() - hm.bornAt >= HM_LIFE_MS) {
348 hm = isThinking ? { bornAt: now(), column: HM_START_COLUMNS[hmCount++ % HM_START_COLUMNS.length] ?? 0 } : undefined
349 }
350 }
351
352 const frame = () => {
353 const words = (isThinking ? poseAt(THINKING_FRAMES, now(), BOB_MS) : poseAt(TYPING_FRAMES, now(), TYPE_MS)).slice()
354 const write = (row: number, column: number, text: string, color: number, minColumn = 0) =>
355 [...text].forEach((char, i) => {
356 const at = column + i
357 if (row >= 0 && row < ROWS && at >= minColumn && at < COLUMNS) {
358 words.set([char.codePointAt(0) ?? SPACE, color, TERMINAL_DEFAULT], (row * COLUMNS + at) * 3)
359 }
360 })
361
362 if (hm) {
363 const age = now() - hm.bornAt
364 const shown = HM_TEXT.slice(0, 1 + Math.floor(age / HM_MS_PER_CHAR))
365 write(HEAD_ROW, hm.column + Math.floor(age / HM_MS_PER_DRIFT), shown, mix(FADED, HM_COLOR, hmOpacity(age)))
366 }
367
368 for (const line of codeLines) {
369 const rows = risen(line)
370 const color = mix(CODE_BRIGHT, FADED, (now() - line.bornAt) / (LINE_ROWS * LINE_RISE_MS))
371 write(ROWS - 1 - rows, SPRITE_COLUMNS + LINE_GAP + rows, line.text, color)
372 }
373
374 for (const intake of intakes) {
375 const fromLaptop = LANE_COLUMNS - travelled(intake)
376 const color = mix(FADED, INTAKE_BRIGHT, Math.min(1, travelled(intake) / LANE_COLUMNS))
377 write(HEAD_ROW, SPRITE_COLUMNS + fromLaptop, intake.text, color, SPRITE_COLUMNS)
378 }
379
380 return btoa(String.fromCharCode(...new Uint8Array(words.buffer)))
381 }
382
383 const nextFrame = () => {
384 advance()
385 return spinners.size ? frame() : undefined
386 }
387
388 const isTyping = () => ticker !== undefined && spinners.size > 0 && !isThinking
389
390 on('session.start', async ($, e, next) => {
391 await $.command.register({ name: SOUND_COMMAND, description: 'Turn Clawd’s typing sound on or off' })
392
393 return next(e)
394 })
395
396 on('command.run', { command: SOUND_COMMAND }, async $ => {
397 await toggleSound($, sound, isTyping())
398
399 return { text: sound.isOn ? 'Typing sound on.' : 'Typing sound off.' }
400 })
401
402 on('turn.start', ($, e, next) => {
403 intakes = []
404 codeLines = []
405 queuedLines.length = 0
406 pendingIntake = 0
407 isThinking = false
408 hm = undefined
409 ticker ??= startTicker($, spinners, nextFrame, sound, isTyping)
410
411 return next(e)
412 })
413
414 on('turn.step', async function* ($, e, next) {
415 const expectedIntake = e.agentId
416 ? Promise.resolve(0)
417 : $.session.usage().then(
418 usage => usage.context.tokens ?? 0,
419 () => 0,
420 )
421 void expectedIntake.then(tokens => {
422 pendingIntake += tokens
423 })
424
425 const stream = next(e)
426 const toolNames = new Map<number, string>()
427 const valueScans = new Map<number, ValueScan>()
428 const fenceScan: FenceScan = { line: '', isInFence: false }
429
430 for await (const chunk of stream) {
431 if (chunk.kind === 'tool') {
432 toolNames.set(chunk.index, chunk.name)
433 } else if (chunk.kind === 'input' && CODE_TOOLS.has(toolNames.get(chunk.index) ?? '')) {
434 const scan = valueScans.get(chunk.index) ?? { json: '', cursor: 0, isInValue: false, line: '' }
435 valueScans.set(chunk.index, scan)
436 scanCodeValues(scan, chunk.json, queueLine)
437 } else if (chunk.kind === 'text') {
438 scanFencedCode(fenceScan, chunk.text, queueLine)
439 } else if (chunk.kind === 'stop' && chunk.usage) {
440 const { input_tokens, cache_creation_input_tokens, cache_read_input_tokens } = chunk.usage
441 const intake = input_tokens + cache_creation_input_tokens + cache_read_input_tokens
442 const expected = await expectedIntake
443 pendingIntake = Math.max(0, pendingIntake + intake - expected)
444 }
445 yield chunk
446 }
447
448 return await stream.result
449 })
450
451 on('turn.complete', ($, e, next) => {
452 if (!e.agentId) {
453 ticker?.cancel()
454 ticker = undefined
455 syncSound($, sound, false)
456 }
457
458 return next(e)
459 })
460
461 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
462 const line = await next(e)
463 if (e.surface !== 'terminal') return line
464
465 isThinking = e.props.mode === 'thinking'
466 spinners.add(e.requestId)
467 ticker ??= startTicker($, spinners, nextFrame, sound, isTyping)
468 await loadSound($, sound)
469 const { Box, Button, Raster } = $.ui.resolve(e)
470 const toggle = () => void toggleSound($, sound, isTyping())
471
472 return (
473 <Box flexDirection="column">
474 {line}
475 <Box marginLeft={2} marginTop={2}>
476 <Raster key={KEY} columns={COLUMNS} rows={ROWS} cells={frame()} />
477 </Box>
478 <Box marginLeft={2} marginTop={1}>
479 <Button key="sound" plain dimColor={!sound.isOn} label={SOUND_LABEL} onPress={toggle} />
480 </Box>
481 </Box>
482 )
483 })
484}
485