Pixel buddy above the prompt whose mood follows Claude: idle, thinking, happy, worried, sleepy

Thirteen mods I wrote for Claude Code in October 2026, the week mods were announced. A mod is a plugin of TypeScript hooks that runs inside Claude Code: it can draw panes and bands in the terminal, add slash commands, give Claude new tools, and block or react to tool calls. I built these to see how far the system goes, and a few of them I now use every day.
The one I use most is interview-coach: it asks a placement interview question aloud, records my spoken answer, transcribes it on the Mac with Whisper, and scores pace, filler words and structure against my own CV stories.
| Mod | How you use it | What it does |
|---|---|---|
| interview-coach | /coach | Spoken mock interview in a side pane. Asks a question aloud, records from the mic, transcribes locally (Whisper large-v3-turbo via whisper.cpp), then shows speaking time against the target, words per minute, filler words, time spent on each step of the answer framework, and two stronger example answers drawn from your profile. Keeps a WPM trend across sessions. |
| nihongo | /nihongo | Japanese tutor pane: kana drill that weights look-alike pairs and brings misses back sooner, 44 N5 word cards, and a sentence builder where Claude Haiku sets a line from the week's grammar and corrects your kana answer. A word band above the prompt shows a new word each turn. |
| joka-pond | /pond | Animated ASCII pond that earns XP for every tool Claude uses and climbs a consulting ladder from Intern to Managing Partner. Storms when a tool fails, keeps a daily streak. |
| aurora | /visuals, /aurora | Truecolor pixel art: an aurora band above the prompt whose colours follow the time of day, plus a gallery (plasma, ocean sunset, warp, a 4-4-6 breathing lotus). |
| avatar | /buddy | A pixel companion docked beside the chat. Its mood follows Claude: thinking, happy after a reply, worried after a failure, sleepy after midnight. |
| chart | Claude calls it | Gives Claude a tool that draws bar, line, area and donut charts inline in the chat, with axes, labels and legends. |
| heatmap | Claude calls it | Inline truecolor heatmaps with heat, green and diverging palettes. |
| diagram | Claude calls it | Flowcharts drawn in the terminal: coloured boxes by role, laid out top to bottom with labelled arrows. |
| animate | Claude calls it | Plays short truecolor animations (fireworks, rain, aurora and others) inside a chat row at about 15 fps. |
| pixel | Claude calls it | Draws a pixel-art image in the chat from a JSON grid of colours. |
| change-ledger | /changes | Pane listing every file Claude created or edited this session. |
| em-dash-guard | automatic | Blocks any write or edit that adds an em dash, my house style for documents. |
| hello-mod | /hi | Starter mod: an instant reply with no model call, a guard that blocks rm -rf, and a toast at the end of each turn. |
statusline/statusline-command.sh is my status line: model, effort, context used, 5-hour and weekly usage with time left, folder and git branch, colour-coded green, yellow and red.
claude --plugin-dir ./mods/chart
Pass --plugin-dir once per mod you want. claude plugin validate ./mods/chart checks a mod before loading it.
interview-coach needs ffmpeg, whisper-cpp (brew install ffmpeg whisper-cpp) and a Whisper model file. Set the paths at the top of mods/interview-coach/hooks/register.tsx, and copy profile.example.md to the profile path with your own experiences.
$ context object can't be stored or passed around. Long-running work has to start from a worker in session.start; buttons only queue jobs for it.$.ui.blit instead.session.start becomes callable only from the next prompt.-progress flag must come before -i, or progress output never arrives.hooks/register.tsx 242 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Mood, Place } from '../types'
5
6const mood = atom({ plugin: 'avatar', key: 'mood' } as const, 'idle')
7const line = atom({ plugin: 'avatar', key: 'line' } as const, 'Hi Ishu.')
8const place = atom({ plugin: 'avatar', key: 'place' } as const, 'side')
9const PANE = 'buddy'
10const PLACES: Place[] = ['side', 'right', 'left', 'mini', 'off']
11
12// One-line text faces for the compact places
13const face = (m: Mood, t: number) => {
14 if (m === 'happy') return '(^‿^)'
15 if (m === 'worried') return '(°﹏°;)'
16 if (m === 'sleepy') return ['(-_-) z', '(-_-) zZ', '(-_-) zZz'][Math.floor(t) % 3]!
17 if (m === 'thinking') return ['( •_•)', '(•_• )'][Math.floor(t * 2) % 2]!
18 return t % 4 < 0.4 ? '(-‿-)' : '(•‿•)'
19}
20
21const W = 16
22const PH = 16 // pixel rows; drawn as 8 terminal rows
23const ROWS = PH / 2
24
25const LINES: Record<Mood, string[]> = {
26 idle: ['Ready when you are.', 'Slow pace wins interviews.', 'Breathe in 4, out 6.', 'Day 0 is close. You are prepared.'],
27 thinking: ['On it...', 'Thinking this through...', 'Working on it...'],
28 happy: ['Done!', 'That worked.', 'Nice, shipped.'],
29 worried: ['Hmm, that failed. Fixing it.', 'Small hiccup, trying again.'],
30 sleepy: ['It is late. Sleep is a placement strategy.', 'Zzz... wrap up soon?'],
31}
32const pick = (m: Mood) => LINES[m][Math.floor(Math.random() * LINES[m].length)]!
33
34type RGB = [number, number, number]
35const pack = (c: RGB) => (c[0] << 16) | (c[1] << 8) | c[2]
36const NONE = 0x01000000
37const BODY: RGB = [217, 119, 87]
38const SHADE: RGB = [180, 90, 64]
39const LIGHT: RGB = [240, 160, 125]
40const INK: RGB = [30, 22, 20]
41const CHEEK: RGB = [235, 110, 120]
42const WHITE: RGB = [250, 245, 240]
43
44// Draw one frame of the sprite as a pixel grid (null = transparent)
45const sprite = (m: Mood, t: number): (RGB | null)[][] => {
46 const g: (RGB | null)[][] = Array.from({ length: PH }, () => Array<RGB | null>(W).fill(null))
47 const bounce = m === 'happy' ? Math.round(Math.abs(Math.sin(t * 6))) : m === 'thinking' ? Math.round((Math.sin(t * 4) + 1) / 2) : 0
48 const breathe = m === 'sleepy' ? (Math.sin(t * 1.5) > 0 ? 1 : 0) : 0
49 const cx = 7.5
50 const cy = 9 - bounce
51 const rx = 6.6 + breathe * 0.4
52 const ry = 6 - breathe * 0.3
53 const set = (x: number, y: number, c: RGB) => { if (x >= 0 && x < W && y >= 0 && y < PH) g[y]![x] = c }
54
55 // body with a little shading
56 for (let y = 0; y < PH; y++) {
57 for (let x = 0; x < W; x++) {
58 const d = ((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2
59 if (d <= 1) set(x, y, d > 0.7 && x > cx ? SHADE : x < cx - 2 && y < cy - 2 ? LIGHT : BODY)
60 }
61 }
62 // antenna sprout
63 set(7, Math.round(cy - ry - 1), SHADE)
64 set(8, Math.round(cy - ry - 2), m === 'happy' ? [255, 210, 90] : LIGHT)
65
66 // eyes
67 const ey = Math.round(cy - 1)
68 const look = m === 'thinking' ? (Math.sin(t * 2) > 0 ? 1 : -1) : 0
69 const blink = m === 'idle' && t % 4 < 0.15
70 for (const ex of [5 + look, 10 + look]) {
71 if (m === 'sleepy' || blink) {
72 set(ex, ey + 1, INK); set(ex + 1, ey + 1, INK)
73 } else if (m === 'happy') {
74 set(ex, ey + 1, INK); set(ex + 1, ey, INK); set(ex + 2, ey + 1, INK) // ^ eyes
75 } else {
76 set(ex, ey, INK); set(ex, ey + 1, INK); set(ex + 1, ey, WHITE); set(ex + 1, ey + 1, INK)
77 }
78 }
79 if (m === 'worried') { set(4, ey - 2, INK); set(5, ey - 3, INK); set(11, ey - 3, INK); set(12, ey - 2, INK) }
80
81 // cheeks
82 if (m === 'happy' || m === 'idle') { set(3, ey + 2, CHEEK); set(13, ey + 2, CHEEK) }
83
84 // mouth
85 const my = ey + 3
86 if (m === 'happy') { for (let x = 6; x <= 10; x++) set(x, my, INK); set(5, my - 1, INK); set(11, my - 1, INK); set(8, my + 1, CHEEK) }
87 else if (m === 'worried') { set(6, my + 1, INK); set(7, my, INK); set(8, my, INK); set(9, my + 1, INK); set(10, my, INK) }
88 else if (m === 'sleepy') { set(8, my, INK) }
89 else if (m === 'thinking') { set(7, my, INK); set(8, my, INK); set(9, my, INK) }
90 else { set(7, my, INK); set(8, my + (t % 3 < 1.5 ? 0 : 0), INK); set(9, my, INK) }
91
92 // sweat drop when worried, Zzz pixels when sleepy
93 if (m === 'worried') { set(14, ey - 1, [120, 190, 255]); set(14, ey, [120, 190, 255]) }
94 if (m === 'sleepy') { const z = Math.floor(t * 1.5) % 3; for (let k = 0; k <= z; k++) set(12 + k, 3 - k, WHITE) }
95 return g
96}
97
98const toCells = (g: (RGB | null)[][]) => {
99 const words = new Uint32Array(W * ROWS * 3)
100 for (let r = 0; r < ROWS; r++) {
101 for (let x = 0; x < W; x++) {
102 const top = g[r * 2]![x]
103 const bot = g[r * 2 + 1]![x]
104 const i = (r * W + x) * 3
105 words[i] = top || bot ? 0x2580 : 0x20
106 words[i + 1] = top ? pack(top) : NONE
107 words[i + 2] = bot ? pack(bot) : NONE
108 }
109 }
110 return (new Uint8Array(words.buffer) as unknown as { toBase64: () => string }).toBase64()
111}
112
113export const register: Register = on => {
114 let current: Mood = 'idle'
115 let paneShown = false
116 let textPlace = false
117 let tick = 0
118 let moodUntil = 0
119 let working = false
120
121 const isLate = () => { const h = new Date().getHours(); return h >= 0 && h < 5 }
122 const resting = (): Mood => (isLate() ? 'sleepy' : 'idle')
123
124 on('session.start', async ($, e, next) => {
125 await $.command.register({
126 name: 'buddy',
127 description: 'Move the avatar buddy: side (right dock), right or left (one line above prompt), mini (in the hint line), off',
128 argumentHint: 'side | right | left | mini | off',
129 })
130 const setMood = async (m: Mood) => {
131 if (m === current) return
132 current = m
133 await update($, mood, () => m)
134 await update($, line, () => pick(m))
135 }
136 // animate the sprite and drift moods back to resting
137 $.clock.every(120, () => {
138 const now = Date.now()
139 if (!working && now > moodUntil && current !== resting()) void setMood(resting())
140 if (working && now > moodUntil && current !== 'thinking') void setMood('thinking')
141 if (paneShown) void $.ui.blit({ requestId: PANE, key: 'buddy', cells: toCells(sprite(current, now / 1000)) }).catch(() => {})
142 tick += 1
143 if (tick % 4 === 0 && textPlace) $.ui.invalidate('ui.render')
144 })
145 const saved = (await $.store.get('place')) as Place | undefined
146 const p = saved && PLACES.includes(saved) ? saved : 'side'
147 await update($, place, () => p)
148 if (p === 'side') void $.ui.open({ id: PANE, title: 'Buddy', columns: 22, rows: 9 })
149 await update($, mood, () => resting())
150 current = resting()
151 await update($, line, () => pick(current))
152 return next(e)
153 })
154
155 on('command.run', { command: 'buddy' }, async ($, e) => {
156 const asked = e.args.trim().toLowerCase() as Place
157 const cur = await read($, place)
158 const nextPlace: Place = PLACES.includes(asked) ? asked : PLACES[(PLACES.indexOf(cur) + 1) % PLACES.length]!
159 await update($, place, () => nextPlace)
160 await $.store.set('place', nextPlace)
161 if (nextPlace === 'side') await $.ui.open({ id: PANE, title: 'Buddy', columns: 22, rows: 9 })
162 else if (cur === 'side') { paneShown = false; await $.ui.close({ id: PANE }).catch(() => {}) }
163 const where: Record<Place, string> = {
164 side: 'docked on the right beside the chat',
165 right: 'one line above the prompt, right side',
166 left: 'one line above the prompt, left side',
167 mini: 'inside the hint line under the prompt (takes no space)',
168 off: 'hidden',
169 }
170 return { text: `Buddy is now ${where[nextPlace]}. /buddy side | right | left | mini | off` }
171 })
172
173 on('prompt.submit', async ($, e, next) => {
174 working = true
175 moodUntil = 0
176 current = 'thinking'
177 await update($, mood, () => 'thinking')
178 await update($, line, () => pick('thinking'))
179 return next(e)
180 })
181
182 on('tool.call', async ($, e, next) => {
183 const ran = await next(e)
184 if (ran.deny !== undefined || ran.isError === true) {
185 current = 'worried'
186 moodUntil = Date.now() + 5000
187 await update($, mood, () => 'worried')
188 await update($, line, () => pick('worried'))
189 }
190 return ran
191 })
192
193 on('turn.complete', async ($, e, next) => {
194 working = false
195 current = 'happy'
196 moodUntil = Date.now() + 6000
197 await update($, mood, () => 'happy')
198 await update($, line, () => pick('happy'))
199 return next(e)
200 })
201
202 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
203 const p = await read($, place)
204 textPlace = p === 'right' || p === 'left' || p === 'mini'
205 if (e.props.hasSurvey || (p !== 'right' && p !== 'left')) return next(e)
206 const m = await read($, mood)
207 const said = await read($, line)
208 const { Box, Text } = $.ui.resolve(e)
209 const color = m === 'worried' ? 'red' : m === 'happy' ? 'green' : m === 'sleepy' ? 'magenta' : 'cyan'
210 return (
211 <Box flexDirection="row" justifyContent={p === 'right' ? 'flex-end' : 'flex-start'} width="100%">
212 <Text color={color} bold>{face(m, Date.now() / 1000)}</Text>
213 <Text dimColor> {said}</Text>
214 </Box>
215 )
216 })
217
218 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
219 if ((await read($, place)) !== 'mini') return next(e)
220 const m = await read($, mood)
221 const said = await read($, line)
222 return next({ ...e, props: { ...e.props, tail: ` ${face(m, Date.now() / 1000)} ${said}` } })
223 })
224
225 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
226 paneShown = true
227 const m = await read($, mood)
228 const said = await read($, line)
229 const { Box, Text } = $.ui.resolve(e)
230 const Raster = e.surface === 'terminal' ? $.ui.resolve(e).Raster : null
231 const color = m === 'worried' ? 'red' : m === 'happy' ? 'green' : m === 'sleepy' ? 'magenta' : 'cyan'
232 return (
233 <Box flexDirection="column" alignItems="center">
234 {Raster
235 ? <Raster key="buddy" columns={W} rows={ROWS} cells={toCells(sprite(m, Date.now() / 1000))} />
236 : <Text>{face(m, Date.now() / 1000)}</Text>}
237 <Text color={color} wrap="wrap">{said}</Text>
238 </Box>
239 )
240 })
241}
242types/index.d.ts 9 lines1export type Mood = 'idle' | 'thinking' | 'happy' | 'worried' | 'sleepy'
2export type Place = 'side' | 'right' | 'left' | 'mini' | 'off'
3
4declare module 'claude-code' {
5 interface PluginState {
6 avatar: { mood: Mood; line: string; place: Place }
7 }
8}
9