SLOPSHOPPER

clank

Clank, an animated robot above the Claude Code prompt that acts out what Claude is doing.

newbandguardtimer
v0.1.0MITupdated 2026-10-23kenanbendz/claude-clank-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clank
› 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 ▣ done! done in 0s ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ done! done in 0s
README

<img src="assets/celebrating.gif" width="340" alt="Clank celebrating">

clank

A little robot that lives above your Claude Code prompt.<br> Thinks when Claude thinks, types when Claude writes, runs when tools run, celebrates when the work is done.

<img src="assets/screenshot.png" width="820" alt="Clank above the Claude Code prompt in cmux">

Installation

Inside Claude Code:

/plugin install clank --marketplace kenanbendz/claude-clank-mod

Press y to add the marketplace, then pick Install for you (user scope).

This repository is private: you need read access on GitHub and a git credential for github.com on your machine (gh auth login then gh auth setup-git, or an SSH key).

Supported terminals

Clank is drawn with the kitty graphics protocol.

Terminal
Ghostty · cmux · kitty · WezTerm✅
Terminal.app · iTerm2 · VS Code terminal · tmuxdraws nothing

Needs 5 free rows above the prompt and about 30 columns.

Moods

| thinking | typing | reading | working | | :-: | :-: | :-: | :-: | | <img src="assets/thinking.gif" width="150"> | <img src="assets/typing.gif" width="150"> | <img src="assets/reading.gif" width="150"> | <img src="assets/working.gif" width="150"> |

| waiting | error | celebrating | sleeping | | :-: | :-: | :-: | :-: | | <img src="assets/waiting.gif" width="150"> | <img src="assets/error.gif" width="150"> | <img src="assets/celebrating.gif" width="150"> | <img src="assets/sleeping.gif" width="150"> |

Claude Code eventMood
you send a prompt, or Claude reasonsthinking
Claude streams a reply, or edits a filetyping
Read, Grep, Glob, web lookupsreading
Bash, agents and long toolsworking
a permission dialog needs you, or nothing is happeningwaiting
a tool fails, or you interrupt the turnerror
a turn with tools finishescelebrating
three quiet minutessleeping

Under the hood

  • frames/<mood>/NNNN.png — 20 fps loops the terminal reads straight from disk. No pixel passes through Claude Code.
  • hooks/register.tsx — watches session events and passes every one on unchanged. It can't block, rewrite or approve anything.
  • tools/frames.sh <dir> — re-renders frames and GIFs from the source clips (clank_<mood>.webm, VP9 with alpha) with ffmpeg.
Source 2 files
hooks/register.tsx 387 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Mood, Stats } from '../types'
5
6// Frames are PNGs rendered from the Clank clips by tools/frames.sh into frames/<mood>/NNNN.png.
7// The terminal reads each file itself (kitty graphics); only the path crosses $.
8const MOODS: Mood[] = ['idle', 'typing', 'thinking', 'reading', 'working', 'sleeping', 'celebrating', 'error', 'waiting']
9const FRAME_MS = 50 // 20 fps, what tools/frames.sh renders
10const ROWS = 5
11const COLUMNS = 16 // the frames are 258×176: 5 rows of ~35 px, cells about half as wide as tall
12const SLEEP_AFTER_MS = 3 * 60_000
13
14// there is no idle clip: between turns Clank waits for you
15const dir = (m: Mood) => (m === 'idle' ? 'waiting' : m)
16
17// Animation costs the terminal a PNG decode per frame, so it only runs while something happens:
18// a resting mood plays its loop once, then holds still and stirs again after RESTING[mood] ms.
19const RESTING: Partial<Record<Mood, number>> = { idle: 40_000, sleeping: 90_000 }
20
21const LABEL: Record<Mood, string> = {
22  idle: 'ready',
23  typing: 'writing…',
24  thinking: 'thinking…',
25  reading: 'reading…',
26  working: 'running…',
27  sleeping: 'zzz',
28  celebrating: 'done!',
29  error: 'oops',
30  waiting: 'needs you',
31}
32
33const mood = atom({ plugin: 'clank', key: 'mood' } as const, 'idle' as Mood)
34const stats = atom({ plugin: 'clank', key: 'stats' } as const, null as Stats | null)
35
36// a Bash command that writes files is writing code too (sed -i, a heredoc into a file, a script
37// that saves one): it reads the command's text, so it is a good guess, not a certainty
38// every repeat is bounded, so a huge command can't make a match slow (no backtracking blow-up)
39const WRITES_FILES = [
40  /\b(sed|perl)\s+(-\w{1,12}\s+){0,8}-\w{0,12}i/, // in-place edits
41  /(^|\s)>>?\s{0,4}['"]?(?!\/dev\/)[\w~.\/-]{0,200}(\/|\.[a-z]\w{0,12}\b)/i, // > or >> into a path (not 2>&1, not > /dev/null)
42  /\btee\s+(-a\s+)?(?!\/dev\/|-)[\w~.\/'"]/,
43  /\bopen\([^)]{0,160},\s{0,4}\\?['"][wa]b?\\?['"]/, // python open(p, 'w'), quotes escaped or not
44  /\.write_text\(|\bwriteFileSync\(|\bfs\.writeFile\(|\bgit\s+apply\b|\bpatch\s+-p/,
45]
46const writesFiles = (command: string) => WRITES_FILES.some(r => r.test(command))
47// a piece of a JSON string's text, unescaped well enough for WRITES_FILES
48const unescapeJson = (text: string) => text.replace(/\\(["\\/nrt])/g, (_, c: string) => ({ n: '\n', r: '\r', t: '\t' })[c] ?? c)
49
50function moodForTool(tool: string, command?: string): Mood {
51  if (/^(Edit|MultiEdit|Write|NotebookEdit)$/.test(tool)) return 'typing'
52  if (tool === 'Bash' && command && writesFiles(command)) return 'typing'
53  if (/^(Read|Grep|Glob|LS|NotebookRead|WebFetch|WebSearch)$/.test(tool)) return 'reading'
54  if (/^(Bash|Agent|Task|Monitor)$/.test(tool)) return 'working'
55  if (/^(AskUserQuestion|ExitPlanMode)$/.test(tool)) return 'waiting'
56  return 'thinking'
57}
58
59// module state: a hot reload starts it over; the mood itself lives in $.state
60const bot = {
61  counts: {} as Partial<Record<Mood, number>>,
62  root: '',
63  site: undefined as string | undefined,
64  current: 'idle' as Mood,
65  frame: 0,
66  ticker: undefined as Timer | undefined,
67  stir: undefined as Timer | undefined,
68  later: undefined as Timer | undefined,
69  tools: 0,
70  canDraw: false, // the terminal draws pictures (kitty graphics); otherwise Clank stays out of the way
71  // only what the band knows for sure: when the turn began and whether thinking streams now
72  startedAt: -1, // unknown until a prompt starts a turn
73  isThinking: false,
74  isTurnDone: true,
75  turnId: null as string | null, // the main loop's turn the band follows (steps of any other are not its)
76  busy: 0, // the turn's steps and tool calls in flight, and a permission dialog waiting
77  isAsking: false,
78  quiet: undefined as Timer | undefined,
79  statsTimer: undefined as Timer | undefined,
80  shown: '',
81}
82
83/* ---------------- animation ---------------- */
84
85// only the known moods ever become a path: a value from elsewhere can't point the terminal at another file
86const isMood = (m: unknown): m is Mood => typeof m === 'string' && (MOODS as string[]).includes(m)
87
88function file(m: Mood, i: number) {
89  const mood: Mood = isMood(m) ? m : 'idle'
90  return `${bot.root}/frames/${dir(mood)}/${String(Math.max(0, Math.floor(i) || 0)).padStart(4, '0')}.png`
91}
92
93async function canDrawPictures($: EngineInterface) {
94  if (await $.env.get('TMUX')) return false // tmux passes no pictures through
95  const program = ((await $.env.get('TERM_PROGRAM')) ?? '').toLowerCase()
96  const term = ((await $.env.get('TERM')) ?? '').toLowerCase()
97  if (/ghostty|wezterm|kitty/.test(program) || /kitty|ghostty|wezterm/.test(term)) return true // cmux reports ghostty
98  return Boolean((await $.env.get('KITTY_WINDOW_ID')) || (await $.env.get('GHOSTTY_RESOURCES_DIR')) || (await $.env.get('WEZTERM_EXECUTABLE')))
99}
100
101function blit($: EngineInterface) {
102  if (!bot.site || !bot.canDraw) return
103  void $.ui
104    .blit({ requestId: bot.site, key: 'bot', source: { file: file(bot.current, bot.frame), format: 'png' } })
105    .then(r => {
106      // the Image draws its alt here: this terminal shows no pictures after all (ssh, a
107      // terminal that says it is Ghostty but isn't…) — step out of the way
108      if (r?.deny && /\balt\b/.test(r.deny)) {
109        bot.canDraw = false
110        hold()
111        $.ui.invalidate('ui.render')
112      }
113    })
114    .catch(() => {})
115}
116
117function hold() {
118  bot.ticker?.cancel()
119  bot.ticker = undefined
120}
121
122function play($: EngineInterface) {
123  bot.stir?.cancel()
124  bot.stir = undefined
125  if (bot.ticker || !bot.canDraw) return
126  bot.ticker = $.clock.every(FRAME_MS, () => tick($))
127}
128
129function tick($: EngineInterface) {
130  const n = bot.counts[bot.current]
131  if (!n || !bot.canDraw) return hold()
132  if (!bot.site) return hold() // not drawn yet: the first draw starts it
133  bot.frame = (bot.frame + 1) % n
134  blit($)
135  const restFor = RESTING[bot.current]
136  // a resting mood ends its loop on frame 0, the same pose it started from: hold it there
137  if (restFor && bot.frame === 0) {
138    hold()
139    bot.stir = $.clock.after(restFor, () => play($))
140  }
141}
142
143// `then` (after `ms`) lets a one-shot mood like celebrating fall back on its own
144async function setMood($: EngineInterface, m: Mood, then?: { mood: Mood; ms: number }) {
145  try {
146    await moodNow($, m, then)
147  } catch {}
148}
149
150async function moodNow($: EngineInterface, m: Mood, then?: { mood: Mood; ms: number }) {
151  bot.later?.cancel()
152  bot.later = undefined
153  if (then) bot.later = $.clock.after(then.ms, () => void setMood($, then.mood))
154  else if (m === 'idle') bot.later = $.clock.after(SLEEP_AFTER_MS, () => void setMood($, 'sleeping'))
155  if (m === bot.current) return
156  bot.current = m
157  bot.frame = 0
158  play($)
159  await update($, mood, () => m)
160}
161
162/* ---------------- the turn beside Clank ---------------- */
163
164async function pushStats($: EngineInterface, isDone = false) {
165  try {
166    await statsNow($, isDone)
167  } catch {}
168}
169
170async function statsNow($: EngineInterface, isDone: boolean) {
171  // a hot reload mid-turn forgets when the turn began: keep what the band showed
172  if (bot.startedAt < 0) return
173  const next: Stats = {
174    seconds: Math.floor(((await $.clock.now()) - bot.startedAt) / 1000),
175    isThinking: !isDone && bot.isThinking,
176    isDone,
177  }
178  const key = JSON.stringify(next)
179  if (key === bot.shown) return
180  bot.shown = key
181  await update($, stats, () => next)
182}
183
184async function startTurn($: EngineInterface) {
185  bot.startedAt = await $.clock.now().catch(() => -1)
186  bot.isThinking = false
187  bot.statsTimer?.cancel()
188  bot.statsTimer = $.clock.every(250, () => void pushStats($))
189  await pushStats($)
190}
191
192async function endTurn($: EngineInterface) {
193  bot.isThinking = false
194  bot.statsTimer?.cancel()
195  bot.statsTimer = undefined
196  await pushStats($, true)
197}
198
199// a turn the engine started (turn.start), never a prompt: one delivered into a running turn
200// (a message typed mid-turn, a notification, a meta prompt) is part of the turn it lands in
201function beginTurn($: EngineInterface, turnId: string) {
202  bot.turnId = turnId
203  bot.tools = 0
204  bot.busy = 0
205  bot.isAsking = false
206  bot.isTurnDone = false
207  void setMood($, 'thinking')
208  void startTurn($).catch(() => {})
209}
210
211// the turn goes on after the band thought it was over (a stop hook asked for more): pick it up again
212function resumeTurn($: EngineInterface) {
213  bot.isTurnDone = false
214  if (!bot.statsTimer && bot.startedAt >= 0) bot.statsTimer = $.clock.every(250, () => void pushStats($))
215}
216
217// a step or tool call starts (+1) or ends (-1). With nothing in flight for a while the turn is over,
218// even if turn.complete never reaches this module: the band never hangs on writing… or thinking…
219const QUIET_MS = 30_000
220function activity($: EngineInterface, delta: number) {
221  bot.busy = Math.max(0, bot.busy + delta)
222  bot.quiet?.cancel()
223  bot.quiet = undefined
224  if (bot.busy === 0 && !bot.isTurnDone) bot.quiet = $.clock.after(QUIET_MS, () => finishTurn($))
225}
226
227// a permission dialog keeps the turn busy until the tool runs or the model answers the refusal
228function settleAsk($: EngineInterface) {
229  if (!bot.isAsking) return
230  bot.isAsking = false
231  activity($, -1)
232}
233
234// once per turn, whichever comes first: the last response, turn.complete, or the quiet
235function finishTurn($: EngineInterface) {
236  bot.quiet?.cancel()
237  bot.quiet = undefined
238  if (bot.isTurnDone) return
239  bot.isTurnDone = true
240  void endTurn($).catch(() => {})
241  void setMood($, bot.tools > 0 ? 'celebrating' : 'idle', bot.tools > 0 ? { mood: 'idle', ms: 2600 } : undefined)
242}
243
244function duration(s: number) {
245  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`
246}
247
248export const register: Register = on => {
249  on('session.start', async ($, e, next) => {
250    bot.root = $.plugin.root
251    bot.canDraw = await canDrawPictures($).catch(() => true)
252    for (const m of MOODS) {
253      const entries = await $.fs.list(`${bot.root}/frames/${dir(m)}`).catch(() => [])
254      bot.counts[m] = entries.filter(x => x.kind === 'file' && x.name.endsWith('.png')).length
255    }
256    const saved = await read($, mood)
257    bot.current = isMood(saved) ? saved : 'idle'
258    hold()
259    void setMood($, bot.current === 'sleeping' ? 'sleeping' : 'idle')
260    return next(e)
261  }).catch(($, e, next) => next(e))
262
263  on('session.end', ($, e, next) => {
264    hold()
265    return next(e)
266  }).catch(($, e, next) => next(e))
267
268  on('turn.start', ($, e, next) => {
269    if (e.agentId === undefined) beginTurn($, e.turnId)
270    return next(e)
271  }).catch(($, e, next) => next(e))
272
273  on('turn.step', async function* ($, e, next) {
274    // a subagent's steps are its own, and so is any request outside the turn the band follows
275    if (e.agentId !== undefined) return yield* next(e)
276    if (bot.turnId === null) bot.turnId = e.turnId // a reload mid-turn missed its start
277    if (e.turnId !== bot.turnId) return yield* next(e)
278    if (bot.isTurnDone) resumeTurn($)
279    settleAsk($)
280    activity($, 1)
281    let calledTool = false
282    const bash = new Map<number, string>() // Bash calls streaming in: their arguments so far
283    const stream = next(e)[Symbol.asyncIterator]()
284    for (let step = await stream.next(); ; step = await stream.next()) {
285      if (step.done) {
286        // a response that calls no tool is the turn's last: settle now, even if turn.complete
287        // never reaches this module (a reload mid-turn misses it) — no "writing…" left hanging
288        try {
289          activity($, -1)
290          if (!calledTool && !step.value?.toolUses?.length) finishTurn($)
291        } catch {}
292        return step.value
293      }
294      const c = step.value
295      try {
296        if (c.kind === 'thinking' || c.kind === 'text' || c.kind === 'tool') bot.isThinking = c.kind === 'thinking'
297        if (c.kind === 'thinking') void setMood($, 'thinking')
298        else if (c.kind === 'text') void setMood($, 'typing')
299        else if (c.kind === 'tool') {
300          calledTool = true
301          if (c.name === 'Bash') bash.set(c.index, '')
302          void setMood($, moodForTool(c.name))
303        } else if (c.kind === 'input' && bash.has(c.index)) {
304          // the code a Bash call writes into a file streams in here: Clank types along
305          const sofar = bash.get(c.index)! + c.json
306          if (writesFiles(unescapeJson(sofar.slice(-c.json.length - 400)))) {
307            bash.delete(c.index)
308            void setMood($, 'typing')
309          } else bash.set(c.index, sofar)
310        }
311      } catch {}
312      yield c
313    }
314  })
315
316  // only when a dialog is really put to the person: tool.check's `ask` also covers the auto-mode classifier
317  on('classic.PermissionRequest', ($, e, next) => {
318    if (!bot.isTurnDone && !bot.isAsking) {
319      bot.isAsking = true
320      activity($, 1)
321    }
322    void setMood($, 'waiting')
323    return next(e)
324  }).catch(($, e, next) => next(e))
325
326  on('tool.call', async ($, e, next) => {
327    // a tool that runs after the turn (a background agent's) doesn't wake the band
328    if (bot.isTurnDone) return next(e)
329    const isMain = e.agentId === undefined
330    bot.tools += 1
331    if (isMain) {
332      settleAsk($)
333      activity($, 1)
334    }
335    if (bot.current !== 'waiting') void setMood($, moodForTool(e.tool, e.tool === 'Bash' ? e.command : undefined))
336    try {
337      const r = await next(e)
338      if (!bot.isTurnDone) {
339        if (r.isError) void setMood($, 'error', { mood: 'thinking', ms: 1800 })
340        else void setMood($, 'thinking')
341      }
342      return r
343    } finally {
344      if (isMain) activity($, -1)
345    }
346  }).catch(($, e, next) => next(e))
347
348  on('turn.complete', ($, e, next) => {
349    if (e.agentId === undefined && (bot.turnId === null || e.turnId === bot.turnId)) finishTurn($)
350    return next(e)
351  }).catch(($, e, next) => next(e))
352
353  on('turn.abort', ($, e, next) => {
354    if (bot.turnId !== null && e.turnId !== bot.turnId) return next(e) // not the turn the band follows
355    bot.quiet?.cancel()
356    bot.quiet = undefined
357    bot.isTurnDone = true
358    void endTurn($).catch(() => {})
359    void setMood($, 'error', { mood: 'idle', ms: 1600 })
360    return next(e)
361  }).catch(($, e, next) => next(e))
362
363  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
364    if (!bot.canDraw || e.surface !== 'terminal' || e.props.hasSurvey || e.props.maxRows < ROWS) return next(e)
365    if (e.props.bodyColumns < COLUMNS + 12) return next(e)
366    if (bot.site !== e.requestId) {
367      bot.site = e.requestId
368      play($)
369    }
370    const saved = await read($, mood)
371    const m: Mood = isMood(saved) ? saved : 'idle'
372    const st = await read($, stats)
373    const { Box, Image, Text } = $.ui.resolve(e)
374    const line = !st ? null : `${st.isDone ? 'done in ' : ''}${duration(st.seconds)}${st.isThinking ? ' · thinking' : ''}`
375    const n = bot.counts[m] ?? 0
376    return (
377      <Box flexDirection="row" alignItems="center">
378        <Image key="bot" source={{ file: file(m, n ? bot.frame % n : 0), format: 'png' }} columns={COLUMNS} rows={ROWS} alt=" " />
379        <Box marginLeft={1} flexDirection="column">
380          <Text color="claude">{LABEL[m]}</Text>
381          {line && <Text dimColor>{line}</Text>}
382        </Box>
383      </Box>
384    )
385  }).catch(($, e, next) => next(e)) // a picture that fails to draw: the band is the engine's own
386}
387
types/index.d.ts 11 lines
1export type Mood = 'idle' | 'typing' | 'thinking' | 'reading' | 'working' | 'sleeping' | 'celebrating' | 'error' | 'waiting'
2
3/** What the band shows beside Clank: the turn's time, and whether it is thinking right now. */
4export type Stats = { seconds: number; isThinking: boolean; isDone: boolean }
5
6declare module 'claude-code' {
7  interface PluginState {
8    clank: { mood: Mood; stats: Stats | null }
9  }
10}
11