SLOPSHOPPER

avatar

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

newpanebandspinnerguardcommand
v0.1.0MITupdated 2026-10-26Ishu-dev/claude-code-mods/mods/avatar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · avatar
│ ┃ buddy ✕ › 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 │ ┃ ▀▀▀▀▀▀▀▀ │ ┃ Nice, shipped. ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /buddy │ ⎿ avatar: Buddy is now one line above the prompt, right side. /bud │ │ (^‿^) Nice, shipped. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
(^‿^) Nice, shipped.
Pane · buddy
▀ ▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀ Nice, shipped.
README

Claude Code mods

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.

The mods

ModHow you use itWhat it does
interview-coach/coachSpoken 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/nihongoJapanese 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/pondAnimated 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, /auroraTruecolor 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/buddyA pixel companion docked beside the chat. Its mood follows Claude: thinking, happy after a reply, worried after a failure, sleepy after midnight.
chartClaude calls itGives Claude a tool that draws bar, line, area and donut charts inline in the chat, with axes, labels and legends.
heatmapClaude calls itInline truecolor heatmaps with heat, green and diverging palettes.
diagramClaude calls itFlowcharts drawn in the terminal: coloured boxes by role, laid out top to bottom with labelled arrows.
animateClaude calls itPlays short truecolor animations (fireworks, rain, aurora and others) inside a chat row at about 15 fps.
pixelClaude calls itDraws a pixel-art image in the chat from a JSON grid of colours.
change-ledger/changesPane listing every file Claude created or edited this session.
em-dash-guardautomaticBlocks any write or edit that adds an em dash, my house style for documents.
hello-mod/hiStarter 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.

Try one

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.

What I learned building them

  • The $ 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.
  • Redrawing a pane on a timer rebuilds its buttons and swallows clicks. Animations repaint pixels with $.ui.blit instead.
  • A tool registered in session.start becomes callable only from the next prompt.
  • Plugin tool results must be strings, so structured results go back as JSON text.
  • ffmpeg's -progress flag must come before -i, or progress output never arrives.
Source 2 files
hooks/register.tsx 242 lines
1import { 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}
242
types/index.d.ts 9 lines
1export 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