SLOPSHOPPER

clawd-vibe

Clawd above the prompt: dances with a headset while Spotify plays, cheers when a turn finishes, reads along as you type, gets bored, idles during a…

newbandcommandpromptprocesstimer
v0.2.0MITupdated 2026-10-08jrpferreira2002-jpg/clawd-vibe
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd-vibe
› 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-setup ⎿ clawd-vibe: Set the Spotify status file option for clawd-vibe first, then run this again. * + * ▐▙███▟▌ ▝▜█████▛▘ all done! ▘▘ ▝▝ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
* + * ▐▙███▟▌ ▝▜█████▛▘ all done! ▘▘ ▝▝ ⟨Claude Code's own drawing⟩
README

clawd-vibe

A Claude Code mod that puts Clawd above your prompt.

MoodWhenWhat you see
Celebratinga turn just finished with an answer (for 5 s)happy eyes, arms in the air, confetti and all done!
Dizzya turn just ended in an error or a refusal (for 5 s)x eyes, swaying, stars circling his head
Startledyou just interrupted a turn (for 5 s)wide eyes, a !, then he settles back down
VibingSpotify is playingClawd in a headset dancing, floating notes, an equaliser and vibing to Artist - Track
Listeningyou are typing in the prompt (until 2 s after the last key)Clawd leans in and reads along, with typing dots
Boreda conversation is going but nothing has happened for a while (2 min by default)taps his foot, glances around, yawns, …still here
Awakeno music, a conversation is goingClawd stands there, blinks, glances around, waves and stretches
Sleepingno music, no conversation yet (fresh session, or after /clear)closed eyes, dim colours, drifting Zs and snore bubbles

The moods are listed from strongest to weakest: a reaction to a finished turn shows even over the music, and typing wakes a sleeping Clawd.

Clawd only shows while Claude is idle. The animation needs block characters, so use a terminal that draws them properly (in VS Code's terminal keep terminal.integrated.gpuAcceleration on).

Requirements

  • Claude Code with function-hook mods (early access).
  • Windows + the Spotify desktop app for the music mood. The watcher reads Spotify's window title, so it does not work with the web player. Without it the mod still works, it just never vibes.

Install

In a Claude Code terminal session:

/plugin install clawd-vibe --marketplace jrpferreira2002-jpg/clawd-vibe

Answer y to add the marketplace, pick a scope, and the install screen asks for these options:

OptionWhat it is
Spotify status file (optional — without it Clawd never dances to music)Full path of the JSON file the watcher writes and the mod reads, e.g. C:\Users\you\AppData\Local\clawd-vibe\.clawd-spotify.json. From WSL use /mnt/c/Users/you/....
Start the watcher at every Windows logonCreates a hidden scheduled task so you never start the watcher by hand. Off by default.
Seconds of silence before Clawd stops vibingDefault 2.
Seconds of quiet before Clawd gets boredDefault 120. 0 turns the bored mood off.

Change them later from /config.

The Spotify watcher

scripts/clawd-spotify-watch.ps1 writes {"isPlaying":true,"track":"Artist - Track","ts":<unix ms>} to the status file once a second. A status older than 15 s counts as silence, so Clawd goes to sleep if the watcher stops.

Pick one way to run it:

  • Automatically at logon: turn on the autostart option, or run /clawd-setup in Claude Code at any time. Both copy the watcher to %LOCALAPPDATA%\clawd-vibe\ and register a scheduled task named Clawd Spotify Watch that starts at every logon and runs now.
  • By hand: ``powershell powershell -ExecutionPolicy Bypass -File .\scripts\clawd-spotify-watch.ps1 -OutFile "C:\path\you\chose\.clawd-spotify.json" ``

Remove the task with:

Unregister-ScheduledTask -TaskName 'Clawd Spotify Watch' -Confirm:$false

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .

License

MIT

Source 3 files
hooks/register.tsx 443 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { NowPlaying, Reaction } from '../types'
5import {
6  BORED_AFTER_MS,
7  BORED_FRAMES,
8  FRAME_MS,
9  FRAMES,
10  IDLE_FRAMES,
11  IDLE_TICKS,
12  LISTEN_FRAMES,
13  LISTEN_MS,
14  POLL_MS,
15  REACTION_MS,
16  REACTIONS,
17  SILENT,
18  SLEEP_AFTER_MS,
19  TASK_NAME,
20  SLEEP_FRAMES,
21  SLEEP_TICKS,
22  parseStatus,
23  reactionFor,
24  runs,
25} from './clawd'
26import type { Pose } from './clawd'
27
28const nowPlaying = atom({ plugin: 'clawd-vibe', key: 'nowPlaying' } as const, SILENT)
29const frame = atom({ plugin: 'clawd-vibe', key: 'frame' } as const, 0)
30const inConversation = atom({ plugin: 'clawd-vibe', key: 'inConversation' } as const, false)
31const reaction = atom({ plugin: 'clawd-vibe', key: 'reaction' } as const, null as Reaction | null)
32const listening = atom({ plugin: 'clawd-vibe', key: 'listening' } as const, false)
33const bored = atom({ plugin: 'clawd-vibe', key: 'bored' } as const, false)
34
35// Each writes its flag only when it changes, so a poll redraws nothing it need not.
36async function setListening($: EngineInterface, value: boolean) {
37  if ((await read($, listening)) !== value) {
38    await update($, listening, () => value)
39  }
40}
41
42async function setBored($: EngineInterface, value: boolean) {
43  if ((await read($, bored)) !== value) {
44    await update($, bored, () => value)
45  }
46}
47
48// Registers the Windows scheduled task that runs the watcher at every logon.
49// Skipped when the task already exists, unless `force`.
50async function installWatcher($: EngineInterface, statusFile: string, force: boolean) {
51  if (!statusFile) {
52    return 'Set the Spotify status file option for clawd-vibe first, then run this again.'
53  }
54
55  try {
56    if (!force) {
57      const found = await $.process.run(['schtasks', '/Query', '/TN', TASK_NAME])
58
59      if (found.exitCode === 0) {
60        return `The '${TASK_NAME}' task already exists.`
61      }
62    }
63
64    const ran = await $.process.run([
65      'powershell',
66      '-NoProfile',
67      '-ExecutionPolicy',
68      'Bypass',
69      '-File',
70      `${$.plugin.root}\\scripts\\install-watcher.ps1`,
71      '-StatusFile',
72      statusFile,
73    ])
74
75    return ran.exitCode === 0
76      ? ran.stdout.trim()
77      : `Could not install the watcher task: ${(ran.stderr || ran.stdout).trim()}`
78  } catch (error) {
79    return `Could not install the watcher task (Windows only): ${String(error)}`
80  }
81}
82
83// The mod's mutable state, kept in one object so the polling functions can sit at the top level of the file.
84type Ctx = {
85  statusFile: string
86  sleepAfterMs: number
87  // The last known play status, kept beside $.state so a state reset (/clear)
88  // never flashes the sleep frames while music plays.
89  known: NowPlaying
90  lastPlayedAt: number
91  // /clear keeps the old turn count, so the turn count alone cannot say a
92  // conversation is going: `cleared` holds from a /clear to the next turn.
93  cleared: boolean
94  conversing: boolean
95  boredAfterMs: number
96  // How the last turn ended, shown until `reactionUntil`.
97  reaction: Reaction | null
98  reactionUntil: number
99  // The last keystroke in the prompt, and the last thing anyone did.
100  typedAt: number
101  lastActiveAt: number
102  timers: Timer[]
103  // A resume swaps the session without a session.start and can end the old
104  // session's timers, so each poll stamps this and `ensurePolling` restarts
105  // the timers from whichever hook runs next once the stamp goes stale.
106  lastPollAt: number
107}
108
109async function poll($: EngineInterface, ctx: Ctx) {
110  try {
111    const now = await $.clock.now()
112    ctx.lastPollAt = now
113    let parsed = SILENT
114
115    try {
116      parsed = parseStatus(await $.fs.read(ctx.statusFile), now)
117    } catch {
118      // missing or unreadable: silent
119    }
120
121    if (parsed.isPlaying) {
122      ctx.lastPlayedAt = now
123      ctx.known = parsed
124    } else if (now - ctx.lastPlayedAt >= ctx.sleepAfterMs) {
125      ctx.known = SILENT
126    }
127
128    const stored = await read($, nowPlaying)
129
130    if (stored.isPlaying !== ctx.known.isPlaying || stored.track !== ctx.known.track) {
131      const status = ctx.known
132      await update($, nowPlaying, () => status)
133    }
134
135    // Only ever wakes Clawd: a reload loses `conversing`, and the turn
136    // count can read 0 outside a turn, so neither may put him to sleep.
137    // Only a /clear (session.end) does that.
138    const isGoing = ctx.conversing || (!ctx.cleared && (await $.session.turns()) > 0)
139
140    if (isGoing && !(await read($, inConversation))) {
141      await update($, inConversation, () => true)
142    }
143
144    if (ctx.reaction && now >= ctx.reactionUntil) {
145      ctx.reaction = null
146    }
147
148    if ((await read($, reaction)) !== ctx.reaction) {
149      const shown = ctx.reaction
150      await update($, reaction, () => shown)
151    }
152
153    await setListening($, now - ctx.typedAt < LISTEN_MS)
154    await setBored(
155      $,
156      (await read($, inConversation)) &&
157        ctx.boredAfterMs > 0 && now - ctx.lastActiveAt >= ctx.boredAfterMs,
158    )
159  } catch {
160    // the next poll tries again
161  }
162}
163
164async function startPolling($: EngineInterface, ctx: Ctx) {
165  if (!ctx.lastActiveAt) {
166    ctx.lastActiveAt = await $.clock.now()
167  }
168
169  for (const timer of ctx.timers) {
170    timer.cancel()
171  }
172
173  ctx.timers = [
174    $.clock.every(POLL_MS, () => void poll($, ctx)),
175    $.clock.every(FRAME_MS, () => void update($, frame, n => n + 1).catch(() => {})),
176  ]
177  await poll($, ctx)
178}
179
180async function ensurePolling($: EngineInterface, ctx: Ctx) {
181  try {
182    if ((await $.clock.now()) - ctx.lastPollAt > POLL_MS * 3) {
183      await startPolling($, ctx)
184    }
185  } catch {
186    // the next hook tries again
187  }
188}
189
190export const register: Register = (on, options) => {
191  const ctx: Ctx = {
192    statusFile: typeof options.statusFile === 'string' ? options.statusFile : '',
193    sleepAfterMs:
194      typeof options.sleepAfterSeconds === 'number' && options.sleepAfterSeconds >= 0
195        ? options.sleepAfterSeconds * 1000
196        : SLEEP_AFTER_MS,
197    known: SILENT,
198    lastPlayedAt: -Infinity,
199    cleared: false,
200    conversing: false,
201    boredAfterMs:
202      typeof options.boredAfterSeconds === 'number' && options.boredAfterSeconds >= 0
203        ? options.boredAfterSeconds * 1000
204        : BORED_AFTER_MS,
205    reaction: null,
206    reactionUntil: 0,
207    typedAt: -Infinity,
208    lastActiveAt: 0,
209    timers: [],
210    lastPollAt: 0,
211  }
212  const { statusFile } = ctx
213
214  on('session.start', async ($, e, next) => {
215    await $.command.register({
216      name: 'clawd-setup',
217      description: 'Install the Spotify watcher as a Windows scheduled task that starts at logon',
218    })
219
220    if (options.autostart === true) {
221      void installWatcher($, statusFile, false).then(text => $.ui.log(`clawd-vibe: ${text}`, { to: 'debug' }))
222    }
223
224    ctx.cleared = false
225    await startPolling($, ctx)
226
227    return next(e)
228  })
229
230  on('command.run', { command: 'clawd-setup' }, async $ => ({
231    text: await installWatcher($, statusFile, true),
232  }))
233
234  on('turn.start', async ($, e, next) => {
235    ctx.cleared = false
236    ctx.conversing = true
237    ctx.reaction = null
238    ctx.typedAt = -Infinity
239    ctx.lastActiveAt = await $.clock.now()
240    await update($, inConversation, () => true)
241    await update($, reaction, () => null)
242    await setListening($, false)
243    await setBored($, false)
244    await ensurePolling($, ctx)
245
246    return next(e)
247  })
248
249  // A subagent's turns end inside the main one: only the main loop's count.
250  on('turn.complete', async ($, e, next) => {
251    if (e.agentId === undefined) {
252      const now = await $.clock.now()
253      const kind = reactionFor(e.reason)
254      ctx.reaction = kind
255      ctx.reactionUntil = now + REACTION_MS
256      ctx.lastActiveAt = now
257      await update($, reaction, () => kind)
258      await setBored($, false)
259    }
260
261    return next(e)
262  })
263
264  // Every keystroke passes here, so this only notes the time and never waits.
265  on('prompt.edit', ($, e, next) => {
266    void (async () => {
267      const now = await $.clock.now()
268      ctx.typedAt = now
269      ctx.lastActiveAt = now
270      await setListening($, true)
271      await setBored($, false)
272    })().catch(() => {})
273
274    return next(e)
275  })
276
277  on('session.end', async ($, e, next) => {
278    if (e.reason === 'clear') {
279      ctx.cleared = true
280      ctx.conversing = false
281      ctx.reaction = null
282      await update($, inConversation, () => false)
283      await update($, reaction, () => null)
284      await setBored($, false)
285    } else if (e.reason === 'resume') {
286      // A resume loads a conversation that already has turns: lift the /clear
287      // hold so the next poll, which counts them, wakes Clawd.
288      ctx.cleared = false
289      await ensurePolling($, ctx)
290    }
291
292    return next(e)
293  })
294
295  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
296    if (e.props.isWorking || e.props.hasSurvey) {
297      return next(e)
298    }
299
300    void ensurePolling($, ctx)
301
302    const { Box, Text } = $.ui.resolve(e)
303    const tick = await read($, frame)
304    const stored = await read($, nowPlaying)
305    const playing = stored.isPlaying ? stored : ctx.known
306    const isAwake = await read($, inConversation)
307    const reacting = await read($, reaction)
308    const isListening = await read($, listening)
309    const isBored = await read($, bored)
310    const band = await next(e)
311
312    // Letter eyes (x, o) sit on a filled cell so the head stays closed.
313    const head = (row: string) =>
314      row.split(/([xo])/).map(part =>
315        part === 'x' || part === 'o' ? (
316          <Text color="inverseText" backgroundColor="claude">
317            {part}
318          </Text>
319        ) : (
320          <Text color="claude">{part}</Text>
321        ),
322      )
323
324    // Stars, confetti or dots above Clawd, then his three rows with a label beside the middle one.
325    const posed = (pose: Pose, label: string, topColor: 'warning' | 'error' | 'suggestion' | 'inactive') => {
326      const key = (i: number) => <Text color="suggestion">{pose.keys?.[i]}</Text>
327
328      return (
329        <Box flexDirection="column">
330          <Box>
331            <Text color={topColor}>{pose.top}</Text>
332            {key(0)}
333          </Box>
334          <Box>
335            {head(pose.rows[0])}
336            {key(1)}
337          </Box>
338          <Box>
339            <Text color="claude">{pose.rows[1]}</Text>
340            {key(2)}
341            <Text dimColor wrap="truncate-end">
342              {' '}
343              {label}
344            </Text>
345          </Box>
346          <Box>
347            <Text color="claude">{pose.rows[2]}</Text>
348            {key(3)}
349          </Box>
350          {band}
351        </Box>
352      )
353    }
354
355    if (reacting) {
356      const { frames, ticks, label, color } = REACTIONS[reacting]
357
358      return posed(frames[Math.floor(tick / ticks) % frames.length]!, label, color)
359    }
360
361    if (playing.isPlaying) {
362      const { notes, eq, body } = FRAMES[tick % FRAMES.length]!
363      const row = (text: string) => (
364        <Box>
365          {runs(text).map(run => (
366            <Text color={run.isHeadset ? 'inactive' : 'claude'}>{run.text}</Text>
367          ))}
368        </Box>
369      )
370
371      return (
372        <Box flexDirection="column">
373          <Text color="suggestion">{notes}</Text>
374          {row(body[0]!)}
375          {row(body[1]!)}
376          <Box>
377            {row(body[2]!)}
378            <Text color="success"> {eq}</Text>
379            <Text dimColor wrap="truncate-end">
380              {' '}
381              vibing to {playing.track || 'Spotify'}
382            </Text>
383          </Box>
384          {row(body[3]!)}
385          {band}
386        </Box>
387      )
388    }
389
390    if (isListening) {
391      return posed(LISTEN_FRAMES[tick % LISTEN_FRAMES.length]!, 'listening…', 'suggestion')
392    }
393
394    if (isAwake && isBored) {
395      return posed(BORED_FRAMES[Math.floor(tick / IDLE_TICKS) % BORED_FRAMES.length]!, '…still here', 'inactive')
396    }
397
398    if (isAwake) {
399      const rows = IDLE_FRAMES[Math.floor(tick / IDLE_TICKS) % IDLE_FRAMES.length]!
400
401      return (
402        <Box flexDirection="column">
403          <Text color="claude">{rows[0]}</Text>
404          <Box>
405            <Text color="claude">{rows[1]}</Text>
406            <Text dimColor wrap="truncate-end">
407              {' '}
408              awake, waiting for you…
409            </Text>
410          </Box>
411          <Text color="claude">{rows[2]}</Text>
412          {band}
413        </Box>
414      )
415    }
416
417    const { zs, body } = SLEEP_FRAMES[Math.floor(tick / SLEEP_TICKS) % SLEEP_FRAMES.length]!
418
419    return (
420      <Box flexDirection="column">
421        <Text color="inactive">{zs[0]}</Text>
422        <Text color="inactive">{zs[1]}</Text>
423        <Text color="claude" dimColor>
424          {body[0]}
425        </Text>
426        <Box>
427          <Text color="claude" dimColor>
428            {body[1]}
429          </Text>
430          <Text dimColor wrap="truncate-end">
431            {' '}
432            sleeping…
433          </Text>
434        </Box>
435        <Text color="claude" dimColor>
436          {body[2]}
437        </Text>
438        {band}
439      </Box>
440    )
441  })
442}
443
hooks/clawd.ts 201 lines
1import type { NowPlaying, Reaction } from '../types'
2
3export const POLL_MS = 1000
4export const FRAME_MS = 350
5export const SLEEP_AFTER_MS = 2_000
6export const STALE_AFTER_MS = 15_000
7export const TASK_NAME = 'Clawd Spotify Watch'
8export const SLEEP_TICKS = 3
9export const IDLE_TICKS = 2
10export const REACTION_MS = 5_000
11export const LISTEN_MS = 2_000
12export const BORED_AFTER_MS = 120_000
13
14export const SILENT: NowPlaying = { isPlaying: false, track: '' }
15
16// The watcher's file: {"isPlaying":true,"track":"Artist - Track","ts":<unix ms>}.
17// Anything else (bad JSON, a missing or old ts, not playing) is silence.
18export function parseStatus(text: string, now: number): NowPlaying {
19  try {
20    const status: unknown = JSON.parse(text.replace(/^/, ''))
21
22    if (typeof status !== 'object' || status === null) {
23      return SILENT
24    }
25
26    const { isPlaying, track, ts } = status as Record<string, unknown>
27    const isFresh = typeof ts === 'number' && now - ts <= STALE_AFTER_MS
28
29    if (isPlaying !== true || !isFresh) {
30      return SILENT
31    }
32
33    return { isPlaying: true, track: typeof track === 'string' ? track : '' }
34  } catch {
35    return SILENT
36  }
37}
38
39export const HEADSET = '╭─╮▐▌'
40
41export const FRAMES = [
42  { notes: '   ♪        ', eq: '▃▆▂▅', body: ['  ╭─────╮   ', '  ▐▛███▜▌   ', ' ▝▜█████▛▘  ', '   ▘▘ ▝▝    '] },
43  { notes: '        ♫   ', eq: '▅▃▇▂', body: ['   ╭─────╮  ', '   ▐▛███▜▌  ', '  ▝▜█████▛▘ ', '    ▘▘ ▝▝   '] },
44  { notes: '  ♫     ♪   ', eq: '▇▅▃▆', body: ['  ╭─────╮   ', ' ▗▐▛███▜▌▖  ', '  ▜█████▛   ', '   ▝▝ ▘▘    '] },
45  { notes: '      ♪     ', eq: '▂▇▅▃', body: [' ╭─────╮    ', ' ▐▛███▜▌    ', '▝▜█████▛▘   ', '  ▘▘ ▝▝     '] },
46  { notes: '  ♪   ♫     ', eq: '▆▂▇▄', body: ['  ╭─────╮   ', ' ▘▐▙███▟▌▝  ', '  ▜█████▛   ', '   ▝▘ ▝▘    '] },
47  { notes: '         ♪  ', eq: '▃▇▅▂', body: ['    ╭─────╮ ', '    ▐▛███▜▌ ', '   ▝▜█████▛▖', '     ▘▘ ▝▝  '] },
48]
49
50export const SLEEP_FRAMES = [
51  { zs: ['            ', '         z  '], body: ['  ▐▀███▀▌   ', ' ▝▜█████▛▘  ', '   ▘▘ ▝▝    '] },
52  { zs: ['            ', '        z Z '], body: ['  ▐▀███▀▌   ', ' ▝▜█████▛▘  ', '   ▘▘ ▝▝    '] },
53  { zs: ['          Z ', '        z  z'], body: ['  ▐▀███▀▌   ', ' ▝▜█████▛▘  ', '   ▘▘ ▝▝    '] },
54  { zs: ['         Z  ', '            '], body: ['  ▐▀███▀▌   ', '  ▜█████▛   ', '   ▘▘ ▝▝    '] },
55  { zs: ['            ', '         o  '], body: ['  ▐▀███▀▌   ', ' ▝▜█████▛▘  ', '   ▘▘ ▝▝    '] },
56  { zs: ['         O  ', '            '], body: ['  ▐▀███▀▌   ', ' ▝▜█████▛▘  ', '   ▘▘ ▝▝    '] },
57]
58
59type Face = '▛███▜' | '▜██▛█' | '█▜██▛' | '▙███▟' | '█████' | '▀███▀' | 'x███x' | 'o███o'
60const AHEAD: Face = '▛███▜', LEFT: Face = '▜██▛█', RIGHT: Face = '█▜██▛', UP: Face = '▙███▟', BLINK: Face = '█████'
61const SHUT: Face = '▀███▀', DIZZY: Face = 'x███x', WIDE: Face = 'o███o'
62
63type Rows = [string, string, string]
64
65const HEAD = (face: Face) => `  ▐${face}▌   `
66const ARMS = ' ▝▜█████▛▘  '
67const LEGS = '   ▘▘ ▝▝    '
68// Both arms up beside the head, the body bare beneath them.
69const CHEER = (face: Face): Rows => [` ▗▐${face}▌▖  `, '  ▜█████▛   ', '   ▝▘ ▝▘    ']
70
71function awake(face: Face, dx = 0): Rows {
72  const shift = (row: string) => (' '.repeat(dx) + row).slice(0, 12)
73
74  return [shift(HEAD(face)), shift(ARMS), shift(LEGS)]
75}
76
77// One hand raised and waving: `hand` is the corner of the block it shows.
78const wave = (hand: '▖' | '▗'): Rows => [`  ▐${AHEAD}▌${hand}  `, ' ▝▜█████▛   ', LEGS]
79const tap: Rows = [HEAD(AHEAD), ARMS, '   ▘▘ ▝▘    ']
80
81export const IDLE_FRAMES: Rows[] = [
82  awake(AHEAD), awake(AHEAD), awake(AHEAD), awake(AHEAD), awake(BLINK), awake(AHEAD), awake(AHEAD),
83  awake(LEFT), awake(AHEAD), awake(AHEAD), awake(RIGHT), awake(AHEAD), awake(AHEAD),
84  awake(AHEAD), awake(BLINK), awake(AHEAD), awake(AHEAD, 1), awake(AHEAD, 1), awake(AHEAD, 1),
85  awake(AHEAD), awake(AHEAD), awake(UP), awake(AHEAD), awake(AHEAD),
86  wave('▖'), wave('▗'), wave('▖'), wave('▗'), awake(AHEAD), awake(AHEAD),
87  awake(AHEAD), CHEER(UP), CHEER(UP), awake(BLINK), awake(AHEAD), awake(AHEAD),
88]
89
90// A mood drawn as a row above Clawd (stars, confetti, dots) and his three rows.
91// `keys` is a 6-wide column right of the top row and each body row.
92export type Pose = { top: string; rows: Rows; keys?: [string, string, string, string] }
93
94// What Clawd shows the moment a turn ends, for REACTION_MS.
95export const REACTIONS: Record<Reaction, { frames: Pose[]; ticks: number; label: string; color: 'warning' | 'error' | 'suggestion' }> = {
96  celebrating: {
97    ticks: 1,
98    label: 'all done!',
99    color: 'warning',
100    frames: [
101      { top: ' *    +   * ', rows: awake(UP) },
102      { top: '   +  *  ·  ', rows: CHEER(UP) },
103      { top: ' ·  *    +  ', rows: awake(UP) },
104      { top: '  *   · *   ', rows: CHEER(UP) },
105    ],
106  },
107  dizzy: {
108    ticks: 2,
109    label: 'ouch, something went wrong',
110    color: 'error',
111    frames: [
112      { top: '  *  ·  °   ', rows: awake(DIZZY) },
113      { top: '   ·  °  *  ', rows: awake(DIZZY, 1) },
114      { top: '  °  *  ·   ', rows: awake(DIZZY) },
115      { top: '   *  ·  °  ', rows: awake(DIZZY, 1) },
116    ],
117  },
118  startled: {
119    ticks: 2,
120    label: 'whoa, stopping!',
121    color: 'suggestion',
122    frames: [
123      { top: '     !      ', rows: CHEER(WIDE) },
124      { top: '     !      ', rows: awake(WIDE) },
125      { top: '            ', rows: awake(WIDE) },
126      { top: '            ', rows: awake(BLINK) },
127      { top: '            ', rows: awake(AHEAD) },
128      { top: '            ', rows: awake(AHEAD) },
129    ],
130  },
131}
132
133// You are typing: keys float up beside Clawd and his eyes follow the highest one.
134const KEYS = ['a', 's', '⏎', 'k', '␣', 'e', '⌫', 't', 'j', '⇥', 'r', 'n']
135const KEY_COLUMNS = [1, 4, 2, 5, 0, 3, 4, 1, 5, 2, 0, 3]
136const LISTEN_LENGTH = 12
137
138export const LISTEN_FRAMES: Pose[] = Array.from({ length: LISTEN_LENGTH }, (_, f) => {
139  const keys: [string, string, string, string] = ['      ', '      ', '      ', '      ']
140  let highest = 4
141
142  // A key is spawned on every even frame and rises one row per frame.
143  for (let spawn = 0; spawn < LISTEN_LENGTH; spawn += 2) {
144    const row = 3 - ((f - spawn + LISTEN_LENGTH) % LISTEN_LENGTH)
145
146    if (row >= 0 && row <= 3) {
147      const cells = keys[row].split('')
148      cells[KEY_COLUMNS[spawn]!] = KEYS[spawn]!
149      keys[row] = cells.join('')
150      highest = Math.min(highest, row)
151    }
152  }
153
154  const face = highest === 0 ? UP : highest < 4 ? RIGHT : AHEAD
155
156  return { top: '            ', rows: awake(face), keys }
157})
158
159// A conversation is going but nobody has said anything for a while.
160export const BORED_FRAMES: Pose[] = [
161  { top: '            ', rows: awake(AHEAD) },
162  { top: '            ', rows: tap },
163  { top: '            ', rows: awake(AHEAD) },
164  { top: '            ', rows: tap },
165  { top: '            ', rows: awake(LEFT) },
166  { top: '            ', rows: awake(AHEAD) },
167  { top: '         o  ', rows: awake(SHUT) },
168  { top: '        O   ', rows: awake(SHUT) },
169  { top: '            ', rows: awake(SHUT) },
170  { top: '            ', rows: awake(BLINK) },
171  { top: '            ', rows: awake(UP) },
172  { top: '            ', rows: awake(AHEAD) },
173]
174
175// How a turn's end makes Clawd react: only the main conversation's turns count.
176export function reactionFor(reason: string): Reaction {
177  if (reason === 'answer') {
178    return 'celebrating'
179  }
180
181  return reason === 'aborted' ? 'startled' : 'dizzy'
182}
183
184// A body row as runs of headset and body characters, each drawn in its own colour.
185export function runs(row: string): { text: string; isHeadset: boolean }[] {
186  const out: { text: string; isHeadset: boolean }[] = []
187
188  for (const char of row) {
189    const isHeadset = HEADSET.includes(char)
190    const last = out.at(-1)
191
192    if (last && last.isHeadset === isHeadset) {
193      last.text += char
194    } else {
195      out.push({ text: char, isHeadset })
196    }
197  }
198
199  return out
200}
201
types/index.d.ts 16 lines
1export type NowPlaying = { isPlaying: boolean; track: string }
2export type Reaction = 'celebrating' | 'dizzy' | 'startled'
3
4declare module 'claude-code' {
5  interface PluginState {
6    'clawd-vibe': {
7      nowPlaying: NowPlaying
8      frame: number
9      inConversation: boolean
10      reaction: Reaction | null
11      listening: boolean
12      bored: boolean
13    }
14  }
15}
16