A small Claude Code mascot above the prompt, with usage, context, cache, directory and git status

A small mascot and a two line status band above the Claude Code prompt.
The left side shows how much of your usage window is gone, how full the context is, how long the prompt cache stays warm, which directory you are in, and the git state of that directory. The right side is a small mascot that blinks while idle and waves while Claude works. It is three rows tall, so the band is three rows too.
5h ━━──── 3% 7d ━───── 7% ctx 30% cache 59:12
~/www/app main ~1 ?2 ↑1 wt:app-topic
The mascot is the same one Claude Code draws at startup, in quadrant-block characters and 24-bit colour, so there are no images and nothing to download.
Inside Claude Code:
/plugin marketplace add prabowosd/clawd-buddy
/plugin install clawd-buddy@clawd-buddy
/reload-plugins
To try it without installing, clone the repo and start Claude Code with:
claude --plugin-dir plugins/clawd-buddy
It was built and tested on Claude Code 2.1.289, on macOS, in a terminal. The band is only drawn by Claude Code on the terminal and desktop surfaces.
| Item | Meaning |
|---|---|
| model | The main loop's model, as /model shows it, in the mascot's colour on its own row under the directory and git state, then the context window and the effort level in grey: Opus 4.8 · 1M · high. The id is shortened, so claude-opus-4-8[1m] reads Opus 4.8, and a name that is no known family is cut at 28 characters. The effort shows after the first request of the session. A band with room for only two rows puts all of it at the end of the second row. |
5h, 7d | Rate limit usage for the 5 hour and 7 day windows, as a bar and a percentage. Green below 70%, amber from 70%, red from 90%. A dash means Claude Code has not reported a value yet. |
ctx | Share of the context window in use, coloured the same way. |
cache | Countdown of the prompt cache. It restarts after each response and turns amber in the last minute. cold means it has expired or nothing has been sent yet. |
| directory | The session directory, with your home folder shortened to ~. |
| git | Branch, then ✓ when clean. Otherwise +n staged, ~n modified, ?n untracked, ↑n ahead and ↓n behind. Inside a linked worktree the worktree name follows as wt:name. |
The mascot has three moods: idle (blinks every couple of seconds), working (waves and shuffles its feet while a turn runs) and done (waves for three seconds after a turn finishes).
git status --porcelain=v1 -b and git rev-parse. Both are read only, --no-optional-locks keeps status from taking the index lock, and nothing touches the network. It refreshes when a turn ends and every 30 seconds, so a cd can take up to 30 seconds to show.When Claude sends you an image with the SendUserFile tool (PNG, JPEG, GIF, WebP, HEIC, BMP or TIFF, at most five per call):
Image pane. A PNG is read as it is, anything else is converted once with sips into /tmp/clawd-buddy. Preview opens too, but only when Claude sent the file with display: render.open, so on macOS it lands in Preview.To force the pane in a terminal that is not guessed right, start Claude Code with CLAWD_BUDDY_IMAGES=pane. CLAWD_BUDDY_IMAGES=viewer always uses the default viewer.
Other files are left alone. If a viewer is missing or fails the file still reaches you and nothing is shown. Images are handled after the tool call succeeds. The pane needs a wide terminal to seat on its own, so on a narrow one it waits.
.claude-plugin/marketplace.json marketplace entry
plugins/clawd-buddy/
.claude-plugin/plugin.json plugin manifest
hooks/register.tsx hooks, state and the band
hooks/sprite.ts the mascot frames
hooks/stats.ts bars, colours, the clock
hooks/git.ts git and worktree parsing
hooks/images.ts which sent files count as images
hooks/*.test.ts tests
types/index.d.ts state types
The mascot lives in sprite.ts as plain strings, one character per pixel, so changing it is a matter of editing a few rows.
claude plugin validate .
claude plugin test plugins/clawd-buddy
For a live loop, keep the plugin in a folder Claude Code watches for mods and edit it while a session is open. It reloads when a turn ends.
MIT
hooks/register.tsx 287 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Ctx, GitInfo, Limit, Mood, Picture } from '../types'
5import { parseGit, parseWorktree, shortPath } from './git'
6import { asAbsolute, drawsImages, imagesToOpen, isPng } from './images'
7import { BODY, BODY_W, sprite } from './sprite'
8import { AMBER, GREEN, bar, clockText, levelColor, shortModel, windowText } from './stats'
9
10
11const mood = atom({ plugin: 'clawd-buddy', key: 'mood' } as const, 'idle' as Mood)
12const moodAt = atom({ plugin: 'clawd-buddy', key: 'moodAt' } as const, 0)
13const now = atom({ plugin: 'clawd-buddy', key: 'now' } as const, 0)
14const cacheAt = atom({ plugin: 'clawd-buddy', key: 'cacheAt' } as const, null as number | null)
15const ctx = atom({ plugin: 'clawd-buddy', key: 'ctx' } as const, null as Ctx | null)
16const limits = atom({ plugin: 'clawd-buddy', key: 'limits' } as const, [] as Limit[])
17const cwd = atom({ plugin: 'clawd-buddy', key: 'cwd' } as const, '')
18const git = atom({ plugin: 'clawd-buddy', key: 'git' } as const, null as GitInfo | null)
19const picture = atom({ plugin: 'clawd-buddy', key: 'picture' } as const, null as Picture | null)
20const model = atom({ plugin: 'clawd-buddy', key: 'model' } as const, '')
21const effort = atom({ plugin: 'clawd-buddy', key: 'effort' } as const, '')
22
23const PANE = 'clawd-image'
24
25const BRANCH = '#8AB4F8'
26const WORKTREE = '#C792EA'
27const FRAME_MS = 250
28const GIT_REFRESH_MS = 30_000
29const DONE_MS = 3_000
30// Fixed 1h prompt cache TTL. Add a userConfig option if 5m is needed.
31const CACHE_TTL_MS = 60 * 60_000
32
33// Reads the directory and its git state; a missing repo or a slow git just leaves it blank.
34async function refreshGit($: EngineInterface) {
35 const dir = await $.session.cwd()
36 await update($, cwd, () => dir)
37 try {
38 const r = await $.process.run(['git', '--no-optional-locks', 'status', '--porcelain=v1', '-b'], { cwd: dir, timeoutMs: 5_000 })
39 const w = await $.process.run(['git', 'rev-parse', '--git-dir', '--git-common-dir'], { cwd: dir, timeoutMs: 5_000 })
40 const info = r.exitCode === 0 ? parseGit(r.stdout) : null
41 await update($, git, () => (info ? { ...info, worktree: w.exitCode === 0 ? parseWorktree(w.stdout) : null } : null))
42 } catch {
43 await update($, git, () => null)
44 }
45}
46
47// The model as /model shows it; a failed read keeps the last name.
48async function refreshModel($: EngineInterface) {
49 try {
50 const name = await $.session.model()
51 await update($, model, () => name)
52 } catch {
53 // keep what is shown
54 }
55}
56
57// Only the main loop sets the effort shown; a subagent's own level is left out.
58async function noteEffort($: EngineInterface, level: string | undefined, agentId: string | undefined) {
59 if (level && !agentId) await update($, effort, () => level)
60}
61
62async function terminalDraws($: EngineInterface) {
63 try {
64 const r = await $.process.run(['sh', '-c', 'printf "%s|%s|%s|%s" "$TERM_PROGRAM" "$TERM" "$KITTY_WINDOW_ID" "$CLAWD_BUDDY_IMAGES"'], { timeoutMs: 2_000 })
65 const [program = '', term = '', kitty = '', force = ''] = r.stdout.split('|')
66 return drawsImages({ program, term, kitty, force })
67 } catch {
68 return false
69 }
70}
71
72// The pane draws PNG only: anything else is converted once with sips into a temp file.
73async function pngFor($: EngineInterface, file: string) {
74 if (isPng(file)) return file
75 const out = `/tmp/clawd-buddy/${await $.clock.now()}.png`
76 try {
77 await $.process.run(['mkdir', '-p', '/tmp/clawd-buddy'], { timeoutMs: 2_000 })
78 const r = await $.process.run(['sips', '-s', 'format', 'png', file, '--out', out], { timeoutMs: 10_000 })
79 return r.exitCode === 0 ? out : null
80 } catch {
81 return null
82 }
83}
84
85export const register: Register = on => {
86 on('session.start', async ($, e, next) => {
87 await $.ui.status(undefined)
88 const u = await $.session.usage()
89 await update($, ctx, () => u.context)
90 await update($, limits, () => u.rateLimits)
91 $.clock.every(FRAME_MS, async () => {
92 const t = await $.clock.now()
93 await update($, now, () => t)
94 })
95 $.clock.every(GIT_REFRESH_MS, () => Promise.all([refreshGit($), refreshModel($)]))
96 void refreshGit($)
97 void refreshModel($)
98 return next(e)
99 })
100
101 on('session.measure', async ($, e, next) => {
102 await update($, ctx, () => e.context)
103 if (e.rateLimits.length) await update($, limits, () => e.rateLimits)
104 if (e.changed.includes('context')) {
105 const t = await $.clock.now()
106 await update($, cacheAt, () => t)
107 }
108 return next(e)
109 })
110
111 // The main loop's effort, read off each model request; a subagent's own is left out.
112 on('turn.step', async function* ($, e, next) {
113 if (!e.agentId && e.effort !== undefined) await update($, effort, () => String(e.effort))
114 return yield* next(e)
115 })
116
117 // A second source: the classic hooks carry the effective level too, after any downgrade for the model.
118 on('classic.PostToolUse', async ($, e, next) => {
119 await noteEffort($, e.effort?.level, e.agent_id)
120 return next(e)
121 })
122
123 on('classic.Stop', async ($, e, next) => {
124 await noteEffort($, e.effort?.level, e.agent_id)
125 return next(e)
126 })
127
128 on('turn.start', async ($, e, next) => {
129 await update($, mood, () => 'working' as Mood)
130 void refreshModel($)
131 return next(e)
132 })
133
134 on('turn.complete', async ($, e, next) => {
135 if (e.agentId) return next(e)
136 const t = await $.clock.now()
137 await update($, moodAt, () => t)
138 await update($, mood, () => (e.isAborted || e.reason === 'error' ? 'idle' : 'done') as Mood)
139 void refreshGit($)
140 void refreshModel($)
141 return next(e)
142 })
143
144 // Where the terminal can draw, a sent image shows in a pane and Preview opens only for display=render.
145 // Elsewhere every image opens in the default viewer. Best effort: no viewer, no harm.
146 on('tool.call', { tool: 'SendUserFile' }, async ($, e, next) => {
147 const sent = await next(e)
148 const images = imagesToOpen(e.files)
149 if (sent.deny !== undefined || sent.isError === true || images.length === 0) return sent
150
151 let viewer = images
152 if (await terminalDraws($)) {
153 const shown = asAbsolute(await $.session.cwd(), images[images.length - 1])
154 const png = await pngFor($, shown)
155 if (png) {
156 await update($, picture, p => ({ file: png, name: shown.split('/').pop() ?? shown, n: (p?.n ?? 0) + 1 }))
157 void $.ui.open({ id: PANE, title: 'Image' })
158 viewer = e.display === 'render' ? images : []
159 }
160 }
161 if (viewer.length) {
162 try {
163 await $.process.run(['open', ...viewer], { timeoutMs: 5_000 })
164 } catch {
165 // open is missing or refused: the file still reached the user.
166 }
167 }
168 return sent
169 })
170
171 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
172 const { Box, Text, Image } = $.ui.resolve(e)
173 const p = await read($, picture)
174 if (!p) return <Text dimColor>No image yet.</Text>
175 const columns = Math.max(10, Math.min(120, (e.viewport?.columns ?? 60) - 2))
176 const rows = Math.max(4, Math.min(60, (e.viewport?.rows ?? 24) - 4))
177 return (
178 <Box flexDirection="column">
179 <Text dimColor>{p.name}</Text>
180 <Image key="view" source={{ file: p.file, format: 'png', generation: p.n }} columns={columns} rows={rows} alt={p.name} />
181 </Box>
182 )
183 })
184
185 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
186 if (e.props.hasSurvey) return next(e)
187 const { Box, Text } = $.ui.resolve(e)
188 const t = await read($, now)
189 const stored = await read($, mood)
190 const doneAt = await read($, moodAt)
191 const shown: Mood = e.props.isWorking ? 'working' : stored === 'done' && t - doneAt < DONE_MS ? 'done' : 'idle'
192 const tick = Math.floor(t / FRAME_MS)
193 const rows = sprite(shown, tick)
194 // A band shorter than the mascot shows its top row only, so it is not scrolled away.
195 if (e.props.maxRows < 1) return next(e)
196 const shownRows = e.props.maxRows < rows.length ? rows.slice(0, 1) : rows
197
198 const rl = await read($, limits)
199 const c = await read($, ctx)
200 const at = await read($, cacheAt)
201 const left = at === null ? 0 : CACHE_TTL_MS - (t - at)
202 const warm = at !== null && left > 0
203 const meters = [
204 { kind: 'five_hour', label: '5h' },
205 { kind: 'seven_day', label: '7d' },
206 ].map(s => ({ ...s, lim: rl.find(l => l.kind === s.kind) }))
207 const dir = await read($, cwd)
208 const g = await read($, git)
209 const modelName = await read($, model)
210 // A third row needs the band to be 3 rows tall; otherwise the model joins the second row.
211 const modelOwnRow = e.props.maxRows >= 3
212 const effortName = await read($, effort)
213 const detail = [c?.window ? windowText(c.window) : '', effortName].filter(Boolean).join(' · ')
214 const modelText =
215 modelName === '' && detail === '' ? null : (
216 <Text>
217 <Text color={BODY}>{modelName === '' ? '' : shortModel(modelName)}</Text>
218 {detail === '' ? null : <Text dimColor>{modelName === '' ? detail : ` · ${detail}`}</Text>}
219 </Text>
220 )
221 const isClean = g !== null && g.staged + g.modified + g.untracked === 0
222
223 return (
224 <Box flexDirection="row" alignItems="center" justifyContent="space-between" paddingX={1}>
225 <Box flexDirection="column">
226 <Box flexDirection="row" gap={3}>
227 {meters.map(s => (
228 <Text key={s.kind}>
229 <Text dimColor>{s.label} </Text>
230 {s.lim ? (
231 <Text>
232 <Text color={levelColor(s.lim.percentUsed)}>{bar(s.lim.percentUsed).on}</Text>
233 <Text dimColor>{bar(s.lim.percentUsed).off}</Text>
234 <Text color={levelColor(s.lim.percentUsed)}> {Math.round(s.lim.percentUsed)}%</Text>
235 </Text>
236 ) : (
237 <Text dimColor>–</Text>
238 )}
239 </Text>
240 ))}
241 <Text>
242 <Text dimColor>ctx </Text>
243 <Text color={c?.percent === undefined ? undefined : levelColor(c.percent)} dimColor={c?.percent === undefined}>
244 {c?.percent === undefined ? '–' : `${c.percent}%`}
245 </Text>
246 </Text>
247 <Text>
248 <Text dimColor>cache </Text>
249 <Text color={!warm ? undefined : left < 60_000 ? AMBER : GREEN} dimColor={!warm}>
250 {warm ? clockText(left) : 'cold'}
251 </Text>
252 </Text>
253 </Box>
254 <Box flexDirection="row" gap={2}>
255 <Text dimColor>{dir === '' ? '–' : shortPath(dir)}</Text>
256 {g && (
257 <Text>
258 <Text color={BRANCH}>{g.branch}</Text>
259 {g.worktree ? <Text color={WORKTREE}> wt:{g.worktree}</Text> : null}
260 {isClean ? <Text color={GREEN}> ✓</Text> : null}
261 {g.staged > 0 ? <Text color={GREEN}> +{g.staged}</Text> : null}
262 {g.modified > 0 ? <Text color={AMBER}> ~{g.modified}</Text> : null}
263 {g.untracked > 0 ? <Text dimColor> ?{g.untracked}</Text> : null}
264 {g.ahead > 0 ? <Text dimColor> ↑{g.ahead}</Text> : null}
265 {g.behind > 0 ? <Text dimColor> ↓{g.behind}</Text> : null}
266 </Text>
267 )}
268 {modelOwnRow ? null : modelText}
269 </Box>
270 {modelOwnRow ? modelText : null}
271 </Box>
272 <Box width={BODY_W} flexDirection="column">
273 {shownRows.map((row, i) => (
274 <Text key={i}>
275 {row.map((cell, x) => (
276 <Text key={x} color={cell.color} backgroundColor={cell.backgroundColor}>
277 {cell.ch}
278 </Text>
279 ))}
280 </Text>
281 ))}
282 </Box>
283 </Box>
284 )
285 })
286}
287hooks/git.ts 30 lines1import type { GitInfo } from '../types'
2
3/** Home directory prefix becomes `~`. */
4export const shortPath = (cwd: string) => cwd.replace(/^(\/Users|\/home)\/[^/]+/, '~')
5
6/** Reads `git status --porcelain=v1 -b` output. */
7export function parseGit(stdout: string): GitInfo | null {
8 const [head, ...files] = stdout.split('\n')
9 if (!head?.startsWith('## ')) return null
10 const branch = head.slice(3).replace(/^No commits yet on /, '').split('...')[0]?.split(' ')[0] ?? ''
11 const count = (re: RegExp) => Number(re.exec(head)?.[1] ?? 0)
12 const info: GitInfo = { branch, staged: 0, modified: 0, untracked: 0, ahead: count(/ahead (\d+)/), behind: count(/behind (\d+)/), worktree: null }
13 for (const line of files) {
14 if (!line) continue
15 if (line.startsWith('??')) info.untracked += 1
16 else {
17 if (line[0] !== ' ') info.staged += 1
18 if (line[1] !== ' ') info.modified += 1
19 }
20 }
21 return info
22}
23
24/** A linked worktree has a git dir apart from the shared one; its admin folder carries the name. */
25export function parseWorktree(stdout: string): string | null {
26 const [gitDir, common] = stdout.split('\n')
27 if (!gitDir || !common || gitDir === common) return null
28 return gitDir.split('/').filter(Boolean).pop() ?? null
29}
30hooks/images.ts 21 lines1const IMAGE = /\.(png|jpe?g|gif|webp|heic|bmp|tiff?)$/i
2const MAX_OPEN = 5
3
4// Image paths worth opening: a known extension, never one that could pass for an option.
5export function imagesToOpen(files: readonly string[]): string[] {
6 return files.filter(f => IMAGE.test(f) && !f.startsWith('-')).slice(0, MAX_OPEN)
7}
8
9export const isPng = (file: string): boolean => /\.png$/i.test(file)
10
11export function asAbsolute(dir: string, file: string): string {
12 return file.startsWith('/') ? file : `${dir.replace(/\/$/, '')}/${file}`
13}
14
15// Only kitty and Ghostty draw an Image element. CLAWD_BUDDY_IMAGES=pane or viewer overrides the guess.
16export function drawsImages(env: { program: string; term: string; kitty: string; force?: string }): boolean {
17 if (env.force === 'pane') return true
18 if (env.force === 'viewer') return false
19 return env.kitty !== '' || /ghostty|kitty/i.test(`${env.program} ${env.term}`)
20}
21hooks/sprite.ts 58 lines1import type { Mood } from '../types'
2
3export type Cell = { ch: string; color?: string; backgroundColor?: string }
4
5export const BODY = '#D77757'
6export const BODY_W = 9
7const EYE_BG = '#000000'
8
9// Index bits UL=1 UR=2 LL=4 LR=8: the quadrant block that draws those pixels.
10const QUADRANT = [' ', '▘', '▝', '▀', '▖', '▌', '▞', '▛', '▗', '▚', '▐', '▜', '▄', '▙', '▟', '█']
11
12// The Claude Code mascot as 18x6 pixels, two per cell each way: o is body, e an eye, . nothing.
13const BASE = [
14 '...ooooooooooooo..',
15 '...ooeoooooooeoo..',
16 '.ooooooooooooooooo',
17 '...ooooooooooooo..',
18 '...o.o.......o.o..',
19 '..................',
20]
21
22function frame(mood: Mood, tick: number): string[] {
23 const g = BASE.map(r => [...r])
24 const set = (r: number, xs: number[], v: string) => xs.forEach(x => (g[r][x] = v))
25
26 if (mood === 'idle') {
27 if (tick % 8 === 7) set(1, [5, 13], 'o')
28 } else {
29 // One arm up, then the other: it waves while Claude works and when it finishes.
30 const left = tick % 2 === 0
31 set(2, left ? [1, 2] : [16, 17], '.')
32 set(1, left ? [1, 2] : [16, 17], 'o')
33 if (mood === 'working') {
34 set(4, [3, 5, 13, 15], '.')
35 set(4, tick % 4 < 2 ? [4, 6, 12, 14] : [2, 4, 14, 16], 'o')
36 }
37 }
38 return g.map(r => r.join(''))
39}
40
41/** Draws the mascot as 3 rows of BODY_W quadrant-block cells. */
42export function sprite(mood: Mood, tick: number): Cell[][] {
43 const g = frame(mood, tick)
44 const on = (r: number, x: number) => (g[r][x] === 'o' ? 1 : 0)
45 const rows: Cell[][] = []
46 for (let r = 0; r < g.length; r += 2) {
47 const row: Cell[] = []
48 for (let c = 0; c < BODY_W; c++) {
49 const bits = on(r, c * 2) + on(r, c * 2 + 1) * 2 + on(r + 1, c * 2) * 4 + on(r + 1, c * 2 + 1) * 8
50 const hasEye = g[r][c * 2] === 'e' || g[r][c * 2 + 1] === 'e' || g[r + 1][c * 2] === 'e' || g[r + 1][c * 2 + 1] === 'e'
51 // The eyes are the empty quadrants of the first row, on a black ground like the real one.
52 row.push(r === 0 && c >= 2 && c <= 7 ? { ch: QUADRANT[bits], color: BODY, backgroundColor: EYE_BG } : bits === 0 && !hasEye ? { ch: ' ' } : { ch: QUADRANT[bits], color: BODY })
53 }
54 rows.push(row)
55 }
56 return rows
57}
58hooks/stats.ts 35 lines1const cut = (name: string): string => (name.length > 28 ? `${name.slice(0, 27)}…` : name)
2
3// claude-opus-4-8[1m] becomes "Opus 4.8"; the context size is shown apart. An unknown name is only cut short.
4export function shortModel(name: string): string {
5 const low = name.toLowerCase()
6 const m = low.match(/(opus|sonnet|haiku|fable)[\s-]*(\d{1,2})(?!\d)(?:[\s.-]+(\d{1,2})(?!\d))?/)
7 if (!m) return cut(name)
8 const family = m[1][0].toUpperCase() + m[1].slice(1)
9 const version = m[3] ? `${m[2]}.${m[3]}` : m[2]
10 return `${family} ${version}`
11}
12
13// 1000000 reads 1M, 200000 reads 200K.
14export function windowText(tokens: number): string {
15 if (tokens >= 1_000_000) return `${Number((tokens / 1_000_000).toFixed(1))}M`
16 return `${Math.round(tokens / 1000)}K`
17}
18
19export const GREEN = '#4cc35a'
20export const AMBER = '#e5a33a'
21export const RED = '#e5534b'
22
23export const levelColor = (p: number) => (p >= 90 ? RED : p >= 70 ? AMBER : GREEN)
24
25/** A thin line: heavy for the used part, light for the rest. */
26export function bar(percent: number, width = 6): { on: string; off: string } {
27 const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)
28 return { on: '━'.repeat(filled), off: '─'.repeat(width - filled) }
29}
30
31export function clockText(ms: number): string {
32 const s = Math.max(0, Math.floor(ms / 1000))
33 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
34}
35types/index.d.ts 24 lines1export type Mood = 'idle' | 'working' | 'done'
2export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
3export type Ctx = { tokens?: number; window: number; percent?: number }
4export type Picture = { file: string; name: string; n: number }
5export type GitInfo = { branch: string; staged: number; modified: number; untracked: number; ahead: number; behind: number; worktree: string | null }
6
7declare module 'claude-code' {
8 interface PluginState {
9 'clawd-buddy': {
10 mood: Mood
11 moodAt: number
12 now: number
13 cacheAt: number | null
14 ctx: Ctx | null
15 limits: Limit[]
16 cwd: string
17 git: GitInfo | null
18 picture: Picture | null
19 model: string
20 effort: string
21 }
22 }
23}
24