An unofficial fan-made pixel cat that lives in the band above the prompt and reacts to what Claude is doing: thinking, editing, craning after a search, yowling…

珊瑚色のドット絵の猫が、プロンプト入力欄のすぐ上に住み着いて、Claude Code が いまやっていることに反応して動きます。
Claude Mod(function hooks)です。描画しかしません — ファイルの読み書きも、 通信も、設定の保存もしません。
非公式のファン制作物です。色と丸い体は Anthropic の Claude マスコット(通称 Clawd)に 着想を得ていますが、ドットはすべて自分で描いたもので、Anthropic とは無関係です。
██ ██
██▒▒██ ██▒▒██
████████████
████████████████
████░░████████░░████
██████████████████████
██████░░██░░██████
████████████
████████
clawd is thinking
| 状態 | きっかけ | 見た目 |
|---|---|---|
idle | ターンとターンのあいだ | ゆっくり息をする |
sleep | 27 秒ほど放置 | 耳を倒し、目を閉じ、z が流れる |
think | ターン開始、未知のツール | 目が上を向き、ひらめきが飛ぶ |
edit | Edit Write MultiEdit NotebookEdit | 右耳のわきで鉛筆を動かす |
search | Read Grep Glob WebFetch WebSearch Task | 頭ごと左、そして右へ首を伸ばす |
shell | Bash BashOutput KillShell | 耳を倒し、火花が散り、口を開けて鳴く |
ouch | ツールがエラーを返した | 目をぎゅっとつぶって体が揺れる |
Claude Code 2.1.273 以降 + ターミナル(デスクトップ版・IDE では出ません)。
claude plugin marketplace add tomada1114/clawd-band
claude plugin install clawd-band@clawd-band
git clone は不要です。
function hooks は early access なので、環境変数を渡して起動します。
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude
これを忘れると、エラーも警告も出ずにただ猫が出ません。 出ないときはまずここです。 毎回打つのが面倒ならエイリアスにしてください。
alias claude='CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude'
claude plugin uninstall clawd-band
claude plugin marketplace remove clawd-band
設定にも $.store にも何も書き込まないので、痕跡は残りません。
猫は plugins/clawd-band/hooks/sprites.ts に アスキーアートで直書きされています。1 フレームが 13 文字 × 9 行、1 文字が 1 ピクセル (ターミナルのセルは縦長なので、横 2 列ぶん使って描画)。
idle: [
[
'...#.....#...',
'..#i#...#i#..',
'...#*#####...',
'..#########..',
'.##e#####e##.',
'n#####m#####n',
'..###m#m###..',
'...#######...',
'....%%%%%....',
],
// ...
],
. 透過 # 体 * つや % 影 i 耳の内側 n ひげ
e 目 x つぶった目 m 鼻と口 o 開いた口
z 寒色(zzz、虫眼鏡) y 暖色(火花、鉛筆) r 警告
文字を書き換えて再起動すれば絵が変わります。PNG も変換スクリプトもビルドもありません。 耳と頭はアートそのものなので、耳を倒したり頭ごと傾けたりできます。 状態を足すときは MascotState FRAMES CAPTIONS CAPTION_COLORS の 4 か所に。
clone して直接動かす場合:
git clone https://github.com/tomada1114/clawd-band.git
cd clawd-band
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir plugins/clawd-band
AbovePrompt の帯に Raster(セルのグリッド)を 1 枚置き、$.ui.blit でその場で 塗り替えています。再描画が要るのはキャプションも変わる状態遷移のときだけです。 状態機械は turn.start / turn.complete / tool.call / session.start の 4 フック。
claude plugin validate plugins/clawd-band # 触る API が全部出ます
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test plugins/clawd-band
MIT
hooks/register.tsx 169 lines1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4
5// Never name a local `h` in this file: every JSX tag compiles to a call of `h`,
6// which the engine injects as an ambient global.
7
8import type { EngineInterface, On } from 'claude-code'
9import {
10 CAPTIONS,
11 CAPTION_COLORS,
12 cellsFor,
13 COLUMNS,
14 FRAMES,
15 ROWS,
16 type MascotState,
17} from './sprites.ts'
18
19/** How long one frame is held. The band may repaint 30 times a second. */
20const FRAME_MS = 180
21
22/** Ticks of nobody asking anything before the blob dozes off (~27s). */
23const SLEEP_AFTER_TICKS = 150
24
25/** Ticks a flinch is held before the blob goes back to whatever it was doing. */
26const OUCH_TICKS = 9
27
28/** The `Raster`'s address for `$.ui.blit`. */
29const RASTER_KEY = 'clawd'
30
31/** Which tools read the world, which ones change it, which ones are a shell. */
32const TOOL_STATES: Readonly<Record<string, MascotState>> = {
33 Edit: 'edit',
34 MultiEdit: 'edit',
35 Write: 'edit',
36 NotebookEdit: 'edit',
37 Read: 'search',
38 Grep: 'search',
39 Glob: 'search',
40 WebFetch: 'search',
41 WebSearch: 'search',
42 Task: 'search',
43 Bash: 'shell',
44 BashOutput: 'shell',
45 KillShell: 'shell',
46}
47
48// The mascot's whole mind. Module scope, not `register`'s closure, because the
49// static scan the engine runs before loading only lets `$` reach a function
50// declared at the top of the file.
51let state: MascotState = 'idle'
52let frame = 0
53let quietTicks = 0
54let ouchTicks = 0
55let isWorking = false
56/** What to go back to once a flinch or a turn is over. */
57let working: MascotState = 'think'
58/** The band's current draw, so a blit knows where to land. */
59let requestId: string | null = null
60let ticking = false
61
62function currentFrame(): readonly string[] {
63 const frames = FRAMES[state]
64 return frames[frame % frames.length] ?? frames[0]!
65}
66
67/**
68 * A mounted Raster is repainted in place — no redraw, so the animation costs
69 * nothing but the cells.
70 */
71function paint($: EngineInterface): void {
72 if (requestId === null) return
73 void $.ui
74 .blit({ requestId, key: RASTER_KEY, cells: cellsFor(currentFrame()) })
75 .catch(() => {})
76}
77
78/** The caption is an element, so a state change asks for a real redraw too. */
79function transition($: EngineInterface, next: MascotState): void {
80 if (state === next) return
81 state = next
82 frame = 0
83 quietTicks = 0
84 $.ui.invalidate('ui.render')
85 paint($)
86}
87
88function tick($: EngineInterface): void {
89 frame += 1
90 if (ouchTicks > 0) {
91 ouchTicks -= 1
92 if (ouchTicks === 0) {
93 // Back to work, or back to waiting for the next prompt.
94 transition($, isWorking ? working : 'idle')
95 return
96 }
97 } else if (state === 'idle') {
98 quietTicks += 1
99 if (quietTicks >= SLEEP_AFTER_TICKS) {
100 transition($, 'sleep')
101 return
102 }
103 }
104 paint($)
105}
106
107export function register(on: On) {
108 on('session.start', ($, e, next) => {
109 if (!ticking) {
110 ticking = true
111 $.clock.every(FRAME_MS, () => tick($))
112 }
113 return next(e)
114 })
115
116 on('turn.start', ($, e, next) => {
117 isWorking = true
118 working = 'think'
119 if (ouchTicks === 0) transition($, 'think')
120 return next(e)
121 })
122
123 on('turn.complete', ($, e, next) => {
124 isWorking = false
125 quietTicks = 0
126 if (ouchTicks === 0) transition($, 'idle')
127 return next(e)
128 })
129
130 on('tool.call', async ($, e, next) => {
131 working = TOOL_STATES[e.tool] ?? 'think'
132 // A flinch owns the blob until it wears off, however busy things get.
133 if (ouchTicks === 0) transition($, working)
134
135 const result = await next(e)
136
137 if (result.isError === true) {
138 ouchTicks = OUCH_TICKS
139 transition($, 'ouch')
140 }
141 return result
142 })
143
144 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
145 // Other surfaces draw their own band, a survey owns it when one is up, and
146 // a band too short for the blob is better left alone.
147 if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
148 if (e.props.maxRows < ROWS + 1) return next(e)
149
150 requestId = e.requestId
151
152 const { Box, Text, Raster } = $.ui.resolve(e)
153
154 return (
155 <Box flexDirection="column">
156 <Raster
157 key={RASTER_KEY}
158 columns={COLUMNS}
159 rows={ROWS}
160 cells={cellsFor(currentFrame())}
161 />
162 <Text color={CAPTION_COLORS[state]} dimColor={state === 'sleep'}>
163 {CAPTIONS[state]}
164 </Text>
165 </Box>
166 )
167 })
168}
169hooks/sprites.ts 309 lines1// The whole mascot, drawn as text.
2//
3// Every frame is nine rows of thirteen characters. One character is one pixel;
4// a pixel is drawn two terminal columns wide, because a terminal cell is about
5// twice as tall as it is wide and the blob wants to be round. Edit the art
6// below and the mascot changes — there is no PNG, no build step, no generator.
7//
8// . nothing (the terminal's own background shows through)
9// # body * sheen % body shade
10// i inner ear n whisker
11// e eye x dizzy eye m nose and mouth o open mouth
12// z cool accent (zzz, the magnifier) y warm accent (sparks, pencil)
13// r alarm accent
14//
15// The ears and the head are part of the art, not an overlay, so a state can
16// fold the ears back, perk them up, or lean the whole head to one side.
17
18/** A colour as `Raster` wants it: 0x00RRGGBB. */
19type Rgb = number
20
21/** `Raster`'s sentinel for "whatever the terminal already uses". */
22const DEFAULT_COLOR = 0x01000000
23
24const FULL_BLOCK = 0x2588
25const SPACE = 0x20
26
27/** Coral, because the mascot this one is a fan of is coral. */
28export const PALETTE: Readonly<Record<string, Rgb>> = {
29 '#': 0xd97757,
30 '*': 0xf3b394,
31 '%': 0xbe6144,
32 i: 0xe89a8c,
33 n: 0xffe4d6,
34 e: 0x2b1a14,
35 x: 0x2b1a14,
36 m: 0x5e2317,
37 o: 0x3d1710,
38 z: 0x8fb8d8,
39 y: 0xf5c542,
40 r: 0xe05252,
41}
42
43/** Pixels across and down in the art, before the 2x horizontal stretch. */
44export const ART_COLUMNS = 13
45export const ART_ROWS = 9
46
47/** How many terminal cells wide one art pixel is drawn. */
48export const PIXEL_WIDTH = 2
49
50export const COLUMNS = ART_COLUMNS * PIXEL_WIDTH
51export const ROWS = ART_ROWS
52
53/** What the mascot can be doing. */
54export type MascotState =
55 | 'idle'
56 | 'sleep'
57 | 'think'
58 | 'edit'
59 | 'search'
60 | 'shell'
61 | 'ouch'
62
63export type Frame = readonly string[]
64
65/**
66 * The flipbook. Frames cycle in order at one tick each, so a two-frame state
67 * breathes and a three-frame state drifts.
68 */
69export const FRAMES: Readonly<Record<MascotState, readonly Frame[]>> = {
70 // Between turns: ears up, a slow breath in and out.
71 idle: [
72 [
73 '...#.....#...',
74 '..#i#...#i#..',
75 '...#*#####...',
76 '..#########..',
77 '.##e#####e##.',
78 'n#####m#####n',
79 '..###m#m###..',
80 '...#######...',
81 '....%%%%%....',
82 ],
83 [
84 '...#.....#...',
85 '..#i#...#i#..',
86 '..#*#######..',
87 '.###########.',
88 '.##e#####e##.',
89 'n#####m#####n',
90 '.####m#m####.',
91 '..#########..',
92 '...%%%%%%%...',
93 ],
94 ],
95
96 // Left alone long enough: ears folded, eyes shut, a z drifting off.
97 sleep: [
98 [
99 '.........z...',
100 '..##.....##..',
101 '...#*#####...',
102 '..#########..',
103 '.##ee###ee##.',
104 'n#####m#####n',
105 '..#########..',
106 '...#######...',
107 '....%%%%%....',
108 ],
109 [
110 '..........z..',
111 '..##.....##..',
112 '...#*#####...',
113 '..#########..',
114 '.##ee###ee##.',
115 'n#####m#####n',
116 '..#########..',
117 '...#######...',
118 '....%%%%%....',
119 ],
120 [
121 '.............',
122 '..##.....##..',
123 '..#*#######..',
124 '.###########.',
125 '.##ee###ee##.',
126 'n#####m#####n',
127 '.###########.',
128 '..#########..',
129 '...%%%%%%%...',
130 ],
131 ],
132
133 // Working, nothing more specific: eyes roll up, ideas pop overhead.
134 think: [
135 [
136 '...#..z..#...',
137 '..#i#...#i#..',
138 '...#*#####...',
139 '..#e#####e#..',
140 '.###########.',
141 'n#####m#####n',
142 '..###m#m###..',
143 '...#######...',
144 '....%%%%%....',
145 ],
146 [
147 '...#.z.z.#...',
148 '..#i#...#i#..',
149 '...#*#####...',
150 '..#########..',
151 '.##e#####e##.',
152 'n#####m#####n',
153 '..###m#m###..',
154 '...#######...',
155 '....%%%%%....',
156 ],
157 ],
158
159 // Edit / Write: a pencil stub jabbing past the right ear.
160 edit: [
161 [
162 '...#.....#...',
163 '..#i#...#i#..',
164 '...#*#####...',
165 '..#########y.',
166 '.##e#####e#y.',
167 'n#####m#####n',
168 '..###m#m###..',
169 '...#######...',
170 '....%%%%%....',
171 ],
172 [
173 '...#.....#...',
174 '..#i#...#i#..',
175 '...#*#####.y.',
176 '..#########y.',
177 '.##e#####e##.',
178 'n#####m#####n',
179 '..###m#m###..',
180 '...#######...',
181 '....%%%%%....',
182 ],
183 ],
184
185 // Grep / Glob / Read: the whole head cranes left, then right, after the glass.
186 search: [
187 [
188 '..#.....#..z.',
189 '.#i#...#i#...',
190 '..#*#####....',
191 '.#########...',
192 '##e#####e##..',
193 'n#####m#####n',
194 '..###m#m###..',
195 '...#######...',
196 '....%%%%%....',
197 ],
198 [
199 '.z..#.....#..',
200 '...#i#...#i#.',
201 '....#*#####..',
202 '...#########.',
203 '..##e#####e##',
204 'n#####m#####n',
205 '..###m#m###..',
206 '...#######...',
207 '....%%%%%....',
208 ],
209 ],
210
211 // Bash: ears back, sparks off the top, and a yowl on the second frame.
212 shell: [
213 [
214 '..y.y...y.y..',
215 '..##.....##..',
216 '...#*#####...',
217 '..#########..',
218 '.##e#####e##.',
219 'n#####m#####n',
220 '..###m#m###..',
221 '...#######...',
222 '....%%%%%....',
223 ],
224 [
225 '.y.y.y.y.y.y.',
226 '..##.....##..',
227 '...#*#####...',
228 '..#########..',
229 '.##e#####e##.',
230 'n#####m#####n',
231 '..###ooo###..',
232 '...#######...',
233 '....%%%%%....',
234 ],
235 ],
236
237 // A tool came back with an error: ears flat, eyes crossed, the cat wobbles.
238 ouch: [
239 [
240 '.r.........r.',
241 '..##.....##..',
242 '...#######...',
243 '..#x#####x#..',
244 '.###x###x###.',
245 '###x#####x###',
246 '..###ooo###..',
247 '...#######...',
248 '....%%%%%....',
249 ],
250 [
251 'r.........r..',
252 '...##.....##.',
253 '....#######..',
254 '...#x#####x#.',
255 '..###x###x###',
256 '.###x#####x##',
257 '...###ooo###.',
258 '....#######..',
259 '.....%%%%%...',
260 ],
261 ],
262}
263
264/** The line under the mascot. */
265export const CAPTIONS: Readonly<Record<MascotState, string>> = {
266 idle: 'clawd is waiting',
267 sleep: 'clawd is asleep z z z',
268 think: 'clawd is thinking',
269 edit: 'clawd is editing',
270 search: 'clawd is looking around',
271 shell: 'clawd is in the shell',
272 ouch: 'ouch! that tool bit back',
273}
274
275/** The caption colour, so the line matches whatever the blob is up to. */
276export const CAPTION_COLORS: Readonly<Record<MascotState, string>> = {
277 idle: '#d97757',
278 sleep: '#8fb8d8',
279 think: '#8fb8d8',
280 edit: '#f5c542',
281 search: '#8fb8d8',
282 shell: '#f5c542',
283 ouch: '#e05252',
284}
285
286/**
287 * One frame's art to the base64 `cells` a `Raster` takes: `columns * rows`
288 * little-endian u32 triplets of `[codePoint, foreground, background]`.
289 */
290export function cellsFor(frame: Frame): string {
291 const words = new Uint32Array(COLUMNS * ROWS * 3)
292 for (let y = 0; y < ROWS; y++) {
293 const row = frame[y] ?? ''
294 for (let ax = 0; ax < ART_COLUMNS; ax++) {
295 const paint = PALETTE[row[ax] ?? '.']
296 for (let sub = 0; sub < PIXEL_WIDTH; sub++) {
297 const i = (y * COLUMNS + ax * PIXEL_WIDTH + sub) * 3
298 words[i] = paint === undefined ? SPACE : FULL_BLOCK
299 words[i + 1] = paint === undefined ? DEFAULT_COLOR : paint
300 words[i + 2] = DEFAULT_COLOR
301 }
302 }
303 }
304 // Uint8Array.prototype.toBase64 is in the hooks environment but not in the
305 // es2023 lib the tsconfig names, so the cast is the whole of the workaround.
306 const bytes = new Uint8Array(words.buffer) as unknown as { toBase64: () => string }
307 return bytes.toBase64()
308}
309