SLOPSHOPPER

clawd-spinner

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.

newspinnercommandtimeraudio
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd-spinner
› 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-sound ⎿ clawd-spinner: Typing sound on. ✻ Thinking… █▀██▀█ ▀██████▀ ▞ ▋▋ ▋▋▗▄▄▞ [♪] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Spinner
✻ Thinking… █▀██▀█ ▀██████▀ ▞ ▋▋ ▋▋▗▄▄▞ [♪]
README

clawd-spinner

Clawd sits under the Claude Code spinner with a tiny laptop and works along with Claude.

  • Typing: side view, his hand pressing the keys and the laptop bouncing under it. Every line of code Claude writes (file writes, edits, shell commands, and code blocks in replies) floats out of the screen and fades as it rises.
  • Thinking: he turns to face you, arms out, bobbing gently while hm... floats above his head.
  • Input tokens: each request's input (fresh plus cached) floats into the laptop from the right in blue, topped up to the exact count when the response ends.
  • Typing sound: off until you switch it on. Click the [♪] 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.

Install

/plugin marketplace add scoobynko/claude-code-mods
/plugin install clawd-spinner@scoobynko-mods

Change his look

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

MarkDrawn as
obody
Obody in shade (the pressing arm)
#eye
=laptop
lthinking 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.

Sound

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.

Test

claude plugin test plugins/clawd-spinner
Source 1 files
hooks/register.tsx 485 lines
1import 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