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…

A Claude Code mod that puts Clawd above your prompt.
| Mood | When | What you see |
|---|---|---|
| Celebrating | a turn just finished with an answer (for 5 s) | happy eyes, arms in the air, confetti and all done! |
| Dizzy | a turn just ended in an error or a refusal (for 5 s) | x eyes, swaying, stars circling his head |
| Startled | you just interrupted a turn (for 5 s) | wide eyes, a !, then he settles back down |
| Vibing | Spotify is playing | Clawd in a headset dancing, floating notes, an equaliser and vibing to Artist - Track |
| Listening | you are typing in the prompt (until 2 s after the last key) | Clawd leans in and reads along, with typing dots |
| Bored | a conversation is going but nothing has happened for a while (2 min by default) | taps his foot, glances around, yawns, …still here |
| Awake | no music, a conversation is going | Clawd stands there, blinks, glances around, waves and stretches |
| Sleeping | no 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).
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:
| Option | What 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 logon | Creates a hidden scheduled task so you never start the watcher by hand. Off by default. |
| Seconds of silence before Clawd stops vibing | Default 2. |
| Seconds of quiet before Clawd gets bored | Default 120. 0 turns the bored mood off. |
Change them later from /config.
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:
/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.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
claude plugin validate .
claude plugin test .
claude --plugin-dir .
MIT
hooks/register.tsx 443 lines1import { 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}
443hooks/clawd.ts 201 lines1import 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}
201types/index.d.ts 16 lines1export 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