SLOPSHOPPER

animate

Gives Claude a tool that plays truecolor animations inline in the chat

newrowsguardtooltimer
v0.1.0MITupdated 2026-10-26Ishu-dev/claude-code-mods/mods/animate
A shopper browsing a rack in a slop shop
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 1 files
hooks/register.tsx 274 lines
1import type { Register } from 'claude-code'
2
3const TOOL = 'mcp__animate__animate'
4type Scene = 'aurora' | 'plasma' | 'sunset' | 'warp' | 'breathe' | 'fireworks' | 'rain'
5const SCENE_IDS: Scene[] = ['aurora', 'plasma', 'sunset', 'warp', 'breathe', 'fireworks', 'rain']
6
7type RGB = [number, number, number]
8
9
10// ---------- colour helpers ----------
11const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v)))
12const mix = (a: RGB, b: RGB, k: number): RGB => [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k]
13const scale = (c: RGB, k: number): RGB => [c[0] * k, c[1] * k, c[2] * k]
14const pack = (c: RGB) => (clamp(c[0]) << 16) | (clamp(c[1]) << 8) | clamp(c[2])
15const ramp = (stops: RGB[], k: number): RGB => {
16  const t = Math.max(0, Math.min(0.9999, k)) * (stops.length - 1)
17  const i = Math.floor(t)
18  return mix(stops[i]!, stops[i + 1]!, t - i)
19}
20const smooth = (e0: number, e1: number, x: number) => {
21  const t = Math.max(0, Math.min(1, (x - e0) / (e1 - e0)))
22  return t * t * (3 - 2 * t)
23}
24
25// Palettes follow the clock: night, dawn, day, dusk
26const palette = (): RGB[] => {
27  const h = new Date().getHours()
28  if (h >= 5 && h < 8) return [[255, 120, 150], [255, 180, 120], [180, 120, 255]]
29  if (h >= 8 && h < 17) return [[60, 200, 255], [120, 255, 200], [90, 140, 255]]
30  if (h >= 17 && h < 20) return [[255, 110, 60], [255, 60, 140], [140, 70, 255]]
31  return [[40, 255, 170], [60, 180, 255], [190, 90, 255]]
32}
33
34// ---------- shaders: (x, y in 0..1, t seconds) -> colour ----------
35const aurora = (x: number, y: number, t: number, pal: RGB[], energy: number): RGB => {
36  const sky: RGB = mix([4, 6, 20], [10, 14, 40], y)
37  let col = sky
38  for (let band = 0; band < 3; band++) {
39    const center = 0.5 + 0.28 * Math.sin(x * (3 + band) + t * (0.25 + band * 0.1) * energy + band * 2)
40      + 0.08 * Math.sin(x * 11 - t * 0.7 * energy + band)
41    const d = Math.abs(y - center)
42    const glow = Math.exp(-d * d * 60) * (0.55 + 0.45 * Math.sin(x * 20 + t * 1.5 * energy + band * 1.7))
43    col = mix(col, pal[band % pal.length]!, Math.min(1, glow * (0.5 + 0.5 * energy)))
44  }
45  return col
46}
47
48const plasma = (x: number, y: number, t: number): RGB => {
49  const v = Math.sin(x * 10 + t) + Math.sin((y * 10 + t) / 2) + Math.sin((x * 10 + y * 10 + t) / 2)
50    + Math.sin(Math.sqrt((x - 0.5) ** 2 * 100 + (y - 0.5) ** 2 * 100) + t)
51  const k = (v + 4) / 8
52  return ramp([[20, 0, 60], [255, 40, 120], [255, 180, 60], [40, 220, 255], [20, 0, 60]], (k + t * 0.05) % 1)
53}
54
55const sunset = (x: number, y: number, t: number): RGB => {
56  const horizon = 0.55
57  if (y < horizon) {
58    const sky = ramp([[30, 10, 70], [190, 50, 120], [255, 140, 70], [255, 210, 130]], y / horizon)
59    const sun = Math.hypot((x - 0.5) * 1.6, y - horizon + 0.08)
60    const disc = smooth(0.17, 0.15, sun)
61    const stripes = y > horizon - 0.12 && Math.sin(y * 120) > 0.4 ? 0 : 1
62    return mix(sky, [255, 230, 150], disc * stripes)
63  }
64  const depth = (y - horizon) / (1 - horizon)
65  const wave = Math.sin(x * 40 + t * 2 + depth * 30) * Math.sin(x * 13 - t + depth * 10)
66  const water = ramp([[120, 40, 110], [40, 20, 80], [10, 10, 40]], depth)
67  const glitter = Math.abs(x - 0.5) < 0.12 * (1 - depth * 0.5) && wave > 0.5 ? 0.7 : 0
68  return mix(water, [255, 200, 120], glitter)
69}
70
71const warp = (x: number, y: number, t: number): RGB => {
72  const dx = x - 0.5
73  const dy = (y - 0.5) * 0.6
74  const ang = Math.atan2(dy, dx)
75  const r = Math.hypot(dx, dy)
76  const lane = Math.floor((ang + Math.PI) * 18)
77  const seed = Math.sin(lane * 91.7) * 43758.5
78  const speed = 0.5 + (seed - Math.floor(seed))
79  const pos = (t * speed * 0.6 + (seed - Math.floor(seed))) % 1
80  const streak = Math.exp(-Math.abs(r - pos * 0.8) * 40) * smooth(0.02, 0.15, r)
81  const hue = ramp([[120, 160, 255], [255, 255, 255], [255, 120, 220]], (seed * 7) % 1 < 0 ? 0 : ((seed * 7) % 1))
82  return mix([2, 2, 10], hue, Math.min(1, streak * 1.5))
83}
84
85// Breathing lotus: in 4s, hold 4s, out 6s
86const BREATH = 14
87const breathPhase = (t: number) => {
88  const c = t % BREATH
89  if (c < 4) return { label: 'breathe in', left: 4 - c, k: smooth(0, 1, c / 4) }
90  if (c < 8) return { label: 'hold', left: 8 - c, k: 1 }
91  return { label: 'breathe out', left: BREATH - c, k: 1 - smooth(0, 1, (c - 8) / 6) }
92}
93const breathe = (x: number, y: number, t: number, aspect: number): RGB => {
94  const { k } = breathPhase(t)
95  const dx = (x - 0.5) * aspect
96  const dy = y - 0.5
97  const r = Math.hypot(dx, dy)
98  const ang = Math.atan2(dy, dx)
99  const size = 0.12 + 0.22 * k
100  const petals = size * (0.75 + 0.25 * Math.abs(Math.cos(ang * 4 + t * 0.2)))
101  const inner = size * 0.45 * (0.8 + 0.2 * Math.cos(ang * 8 - t * 0.3))
102  const bg: RGB = mix([6, 10, 24], [18, 12, 40], y)
103  const halo = Math.exp(-((r - petals) ** 2) * 300) * 0.6
104  let col = mix(bg, [120, 90, 255], halo)
105  if (r < petals) col = mix([255, 120, 190], [255, 200, 230], 1 - r / petals)
106  if (r < inner) col = mix([255, 220, 120], [255, 250, 220], 1 - r / inner)
107  return col
108}
109
110// Fireworks: bursts that expand and fade, seeded per cycle
111const hash = (n: number) => {
112  const x = Math.sin(n * 127.1) * 43758.5453
113  return x - Math.floor(x)
114}
115const fireworks = (x: number, y: number, t: number, aspect: number): RGB => {
116  let col: RGB = mix([2, 2, 12], [10, 6, 30], y)
117  for (let k = 0; k < 4; k++) {
118    const cycle = Math.floor(t / 2.2 + k * 0.37)
119    const age = (t / 2.2 + k * 0.37) - cycle
120    const seed = cycle * 7 + k
121    const cx = 0.15 + 0.7 * hash(seed)
122    const cy = 0.2 + 0.4 * hash(seed + 1)
123    const hue = ramp([[255, 80, 80], [255, 200, 60], [80, 255, 160], [90, 160, 255], [230, 90, 255]], hash(seed + 2))
124    const dx = (x - cx) * aspect
125    const dy = y - cy
126    const r = Math.hypot(dx, dy)
127    const ang = Math.atan2(dy, dx)
128    const radius = age * 0.35
129    const spokes = Math.pow(Math.max(0, Math.cos(ang * 9 + seed)), 6)
130    const ring = Math.exp(-((r - radius) ** 2) * 900) * spokes
131    const fade = (1 - age) * (1 - age)
132    col = mix(col, hue, Math.min(1, ring * fade * 2.5))
133  }
134  return col
135}
136
137// Rain: Matrix-style falling streams
138const rain = (x: number, y: number, t: number, cols: number): RGB => {
139  const lane = Math.floor(x * cols)
140  const speed = 0.4 + hash(lane) * 0.8
141  const head = (t * speed + hash(lane + 99)) % 1.4
142  const d = head - y
143  const glow = d >= 0 && d < 0.5 ? (1 - d / 0.5) ** 2 : 0
144  const isHead = d >= 0 && d < 0.03
145  return isHead ? [200, 255, 210] : mix([0, 6, 2], [30, 255, 90], glow)
146}
147
148const render = (cols: number, rows: number, shade: (x: number, y: number) => RGB, caption?: string): string => {
149  const words = new Uint32Array(cols * rows * 3)
150  const ph = rows * 2
151  for (let r = 0; r < rows; r++) {
152    for (let c = 0; c < cols; c++) {
153      const i = (r * cols + c) * 3
154      words[i] = 0x2580
155      words[i + 1] = pack(shade(c / cols, (r * 2) / ph))
156      words[i + 2] = pack(shade(c / cols, (r * 2 + 1) / ph))
157    }
158  }
159  if (caption) {
160    const text = caption.replace(/[^\x20-\x7e]/g, '').slice(0, cols)
161    const start = Math.max(0, Math.floor((cols - text.length) / 2))
162    const row = rows - 1
163    for (let k = 0; k < text.length; k++) {
164      const i = (row * cols + start + k) * 3
165      words[i] = text.charCodeAt(k)
166      words[i + 1] = 0xffffff
167    }
168  }
169  return (new Uint8Array(words.buffer) as unknown as { toBase64: () => string }).toBase64()
170}
171
172type Spec = { scene: Scene; seconds: number; caption: string; rows: number }
173type Live = Spec & { cols: number; startedAt: number }
174
175const parseSpec = (out: unknown): Spec | null => {
176  const text = typeof out === 'string' ? out
177    : Array.isArray(out) ? out.map(b => (b && typeof b === 'object' && 'text' in b ? String((b as { text: unknown }).text) : '')).join('')
178    : ''
179  try {
180    const s = JSON.parse(text) as Spec
181    return SCENE_IDS.includes(s.scene) ? s : null
182  } catch {
183    return null
184  }
185}
186
187export const register: Register = on => {
188  // Rows currently playing, by tool_use_id (the row's site for $.ui.blit)
189  const live = new Map<string, Live>()
190
191  const frame = (l: Live, now: number) => {
192    const t = (now - l.startedAt) / 1000
193    const aspect = l.cols / (l.rows * 2)
194    const pal = palette()
195    let caption = l.caption
196    if (l.scene === 'breathe') {
197      const b = breathPhase(t)
198      caption = `${b.label}  ${Math.ceil(b.left)}`
199    }
200    const shade = (x: number, y: number): RGB =>
201      l.scene === 'aurora' ? aurora(x, y, t, pal, 1)
202      : l.scene === 'plasma' ? plasma(x, y, t)
203      : l.scene === 'sunset' ? sunset(x, y, t)
204      : l.scene === 'warp' ? warp(x, y, t)
205      : l.scene === 'breathe' ? breathe(x, y, t, aspect)
206      : l.scene === 'fireworks' ? fireworks(x, y, t, aspect)
207      : rain(x, y, t, l.cols)
208    return render(l.cols, l.rows, shade, caption)
209  }
210
211  on('session.start', async ($, e, next) => {
212    await $.tool.register({
213      name: 'animate',
214      description:
215        'Play a short truecolor animation inline in the chat, in this tool\'s own row. Use when the user asks for an animation, ' +
216        'a celebration (fireworks), a calm moment (breathe: a 4-4-6 breathing lotus), or ambient visuals. ' +
217        'scene: aurora | plasma | sunset | warp | breathe | fireworks | rain. seconds: 3 to 120 (default 20). caption: optional short text.',
218      inputSchema: {
219        type: 'object',
220        properties: {
221          scene: { type: 'string', enum: SCENE_IDS },
222          seconds: { type: 'number' },
223          caption: { type: 'string' },
224          rows: { type: 'number', description: 'height in terminal rows, 4 to 20, default 10' },
225        },
226        required: ['scene'],
227      },
228    })
229
230    // ~15 fps: repaint only the pixels of live rows; drop finished ones
231    $.clock.every(66, () => {
232      const now = Date.now()
233      for (const [id, l] of live) {
234        if (now - l.startedAt > l.seconds * 1000) {
235          live.delete(id)
236          continue
237        }
238        void $.ui.blit({ requestId: id, key: 'anim', cells: frame(l, now) }).then(r => {
239          if (r && 'deny' in r && r.deny) live.delete(id)
240        }).catch(() => live.delete(id))
241      }
242    })
243    return next(e)
244  })
245
246  on('tool.call', { tool: TOOL }, async ($, e) => {
247    const input = e as unknown as Partial<Spec>
248    const scene = SCENE_IDS.includes(input.scene as Scene) ? (input.scene as Scene) : 'aurora'
249    const spec: Spec = {
250      scene,
251      seconds: Math.max(3, Math.min(120, Number(input.seconds) || 20)),
252      caption: String(input.caption ?? ''),
253      rows: Math.max(4, Math.min(20, Number(input.rows) || 10)),
254    }
255    return { result: JSON.stringify({ ...spec, note: `Playing ${scene} for ${spec.seconds}s in the chat.` }) }
256  })
257
258  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
259    if (e.props.tool !== TOOL || e.props.isErrored || e.surface !== 'terminal') return next(e)
260    const spec = parseSpec(e.props.output)
261    if (!spec) return next(e)
262    const { Raster } = $.ui.resolve(e)
263    const id = e.props.tool_use_id
264    const cols = Math.max(20, Math.min(512, (e.viewport?.columns ?? 100) - 6))
265    const existing = live.get(id)
266    const l: Live = existing && existing.cols === cols ? existing
267      : { ...spec, cols, startedAt: existing?.startedAt ?? Date.now() }
268    // Only (re)start rows that are still within their play time
269    if (!existing && Date.now() - l.startedAt < spec.seconds * 1000) live.set(id, l)
270    if (existing && existing.cols !== cols) live.set(id, l)
271    return <Raster key="anim" columns={cols} rows={spec.rows} cells={frame(l, Date.now())} />
272  })
273}
274