Clawd, Claude Code's pixel crab, walks above your prompt while Claude works. Click it to crack the whip.

Clawd, Claude Code's pixel crab, walks above the prompt while Claude works. Click it for a little surprise.
claude plugin marketplace add https://github.com/HainanZhao/claude-mods
claude plugin install clawd@claude-mods
See the project README for more.
MIT licensed; see LICENSE.
hooks/register.mjs 223 lines1import { createMotion, advanceMotion, whipMotion, jumpMotion, pauseMotion, resumeMotion } from './motion.mjs';
2import { SPRITE_ROWS, SPRITE_WIDTH, quadrantRuns, lanePixels } from './art.mjs';
3
4// The host statically reads literal on(...) and $.noun.method(...) calls.
5// All helpers using $ stay in this file; pure art and motion are imported.
6let motion = createMotion(0);
7let timer = null;
8let audioController = null;
9let active = false;
10let nowMs = 0;
11let lastSoundAt = -Infinity;
12let generation = 0;
13let activityGeneration = 0;
14let resting = false;
15let currentTurnId = null;
16// Columns the crab can walk in, as last drawn; motion is in real columns so the
17// pace holds whatever the pane's width.
18let walkRoom = 80 - SPRITE_WIDTH;
19
20export function register(on) {
21 on('session.start', async ($, e, next) => {
22 const mine = ++generation;
23 activityGeneration += 1;
24 active = false;
25 stopAnimation();
26 stopSound();
27 const result = await next(e);
28 const time = await $.clock.now();
29 if (mine !== generation) return result;
30 nowMs = time;
31 // Rest until a task runs; walking only signals that Claude is working.
32 motion = pauseMotion(createMotion(nowMs), nowMs, walkRoom);
33 resting = true;
34 currentTurnId = null;
35 lastSoundAt = -Infinity;
36 active = e.isInteractive;
37 // The band may have been drawn before the session was ready.
38 if (active) $.ui.invalidate('ui.render');
39 return result;
40 }).catch(($, e, next) => next(e));
41
42 on('session.end', ($, e, next) => {
43 generation += 1;
44 activityGeneration += 1;
45 active = false;
46 stopAnimation();
47 stopSound();
48 return next(e);
49 });
50
51 on('turn.start', async ($, e, next) => {
52 const mine = generation;
53 const epoch = ++activityGeneration;
54 currentTurnId = e.turnId;
55 stopAnimation();
56 const time = await $.clock.now();
57 if (active && mine === generation && epoch === activityGeneration) {
58 nowMs = time;
59 resting = false;
60 motion = resumeMotion({ ...motion, boostUntil: 0, crackUntil: 0, jumpUntil: 0 }, nowMs);
61 startAnimation($);
62 $.ui.invalidate('ui.render');
63 }
64 return next(e);
65 }).catch(($, e, next) => next(e));
66
67 on('turn.complete', async ($, e, next) => {
68 const mine = generation;
69 const epoch = activityGeneration;
70 const result = await next(e);
71 if (e.agentId || !active || (currentTurnId && currentTurnId !== e.turnId)) return result;
72 const time = await $.clock.now();
73 if (!active || mine !== generation || epoch !== activityGeneration) return result;
74 activityGeneration += 1;
75 nowMs = time;
76 resting = true;
77 motion = { ...pauseMotion(motion, nowMs, walkRoom), boostUntil: 0, crackUntil: 0, jumpUntil: 0 };
78 stopAnimation();
79 stopSound();
80 $.ui.invalidate('ui.render');
81 return result;
82 }).catch(($, e, next) => next(e));
83
84 // A click on the band hands its focus ring to its first control. Refusing it
85 // on the crab keeps the crab from painting inverted; clicks still press it.
86 on('ui.focus', { component: 'AbovePrompt' }, ($, e, next) => (e.element === 'whip' ? {} : next(e)));
87
88 on('ui.message', ($, e, next) => {
89 if (e.element === 'crab-lane' && e.data?.whip) void whip($);
90 return next(e);
91 });
92
93 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
94 const props = e.props ?? e;
95 const previous = await next(e);
96 if (!active || props.hasSurvey) return previous;
97 const { Box, Text, Button, Client } = $.ui.resolve(e);
98 const columns = Math.max(1, Math.min(240, props.bodyColumns ?? e.viewport?.columns ?? 80));
99 // One row stays free for whatever draws below, such as the radio.
100 const room = (props.maxRows ?? 12) - bandRows(previous, columns) - 1;
101 if (room < SPRITE_ROWS || columns < SPRITE_WIDTH) return previous;
102 walkRoom = Math.max(0, columns - SPRITE_WIDTH);
103 const { pixels, x: pixel } = lanePixels(motion, columns);
104 let lane;
105 if (e.surface === 'terminal') {
106 // The whole lane takes the click, through a Client: a lane-wide Button
107 // would invert under the pointer and the focus.
108 lane = Client({ key: 'crab-lane', module: './lane.mjs', width: columns, height: SPRITE_ROWS,
109 props: { rows: quadrantRuns(pixels, 0, columns) } });
110 } else {
111 const picture = (from, to) => Text({ children: quadrantRuns(pixels, from, to).flatMap((runs, row) =>
112 [...(row ? ['\n'] : []), ...runs.map(({ text, ...colors }) => Text({ ...colors, children: text }))]) });
113 // The crab may straddle half a cell; the Button covers every cell it touches.
114 const [from, to] = [Math.floor(pixel / 2), Math.ceil(pixel / 2 + SPRITE_WIDTH)];
115 const cells = [];
116 if (from > 0) cells.push(picture(0, from));
117 cells.push(Button({ key: 'whip', plain: true, onPress: () => whip($), children: picture(from, to) }));
118 if (to < columns) cells.push(picture(to, columns));
119 lane = Box({ key: 'crab-lane', flexDirection: 'row', width: columns, height: SPRITE_ROWS,
120 overflow: 'hidden', children: cells });
121 }
122 // Clawd heads the band, so it sits above the radio whichever mod draws first.
123 return Box({ flexDirection: 'column', children: [Box({ key: 'clawd', children: [lane] }), previous] });
124 }).catch(($, e, next) => next(e));
125}
126
127const animating = () => !motion.paused || nowMs < motion.jumpUntil;
128
129function startAnimation($) {
130 if (timer || !active || !animating()) return;
131 const mine = generation;
132 const epoch = activityGeneration;
133 timer = $.clock.every(100, async () => {
134 const time = await $.clock.now();
135 if (!active || !animating() || mine !== generation || epoch !== activityGeneration) return;
136 nowMs = time;
137 motion = advanceMotion(motion, nowMs, walkRoom);
138 if (resting && nowMs >= motion.boostUntil && nowMs >= motion.jumpUntil) {
139 motion = pauseMotion(motion, nowMs, walkRoom);
140 stopAnimation();
141 stopSound();
142 }
143 $.ui.invalidate('ui.render');
144 });
145}
146
147function stopAnimation() {
148 timer?.cancel();
149 timer = null;
150}
151
152function stopSound() {
153 audioController?.abort();
154 audioController = null;
155}
156
157async function whip($) {
158 if (!active) return;
159 const mine = generation;
160 const epoch = activityGeneration;
161 const time = await $.clock.now();
162 if (!active || mine !== generation || epoch !== activityGeneration) return;
163 nowMs = time;
164 // Running only signals that Claude is working, so an idle whip just startles it.
165 motion = resting && nowMs >= motion.boostUntil ? jumpMotion(motion, nowMs) : whipMotion(motion, nowMs, walkRoom);
166 startAnimation($);
167 $.ui.invalidate('ui.render');
168 // Animation responds to every click. Limit sound retriggers to 8 per second.
169 if (nowMs - lastSoundAt < 125) return;
170 lastSoundAt = nowMs;
171 stopSound();
172 const controller = new AbortController();
173 audioController = controller;
174 // Fire and forget: speaker latency must never hold the button or the agent.
175 void $.audio.play({ asset: 'assets/whip-crack.wav' }, { gain: 1, signal: controller.signal })
176 .catch(() => {})
177 .finally(() => {
178 if (audioController === controller) audioController = null;
179 });
180}
181
182function inlineText(element) {
183 if (typeof element === 'string') return element;
184 return (element?.children ?? []).map(inlineText).join('');
185}
186
187// Conservative height estimate for the preceding mod's native tree. Never
188// truncate its content; yield our artwork when the combined band needs room.
189function bandRows(element, columns) {
190 if (!element) return 0;
191 if (typeof element === 'string') {
192 return element ? element.split('\n').reduce((rows, line) => rows + Math.max(1, Math.ceil(Array.from(line).length / columns)), 0) : 0;
193 }
194 const props = element.props ?? {};
195 const children = element.children ?? [];
196 // next(e) returns this opaque core placeholder in the real host. AbovePrompt
197 // has no core content outside a survey, to which our render hook already yields.
198 // It must not be treated like an unknown-height drawing from another mod.
199 if (element.type === 'engine') return 0;
200 if (element.type === 'Text') {
201 return bandRows(inlineText(element), columns);
202 }
203 if (element.type === 'Raster') return props.rows ?? 1;
204 if (element.type === 'Client' && typeof props.height === 'number') return props.height;
205 if (['Button', 'Input', 'Select', 'Link'].includes(element.type)) return 1;
206 // Image, Markdown, Client, or percentage sizing can depend on a surface's
207 // layout. If we cannot bound it here, give the existing mod all its space.
208 if (element.type !== 'Box') return Infinity;
209 if ([props.height, props.minHeight, props.width].some(size => typeof size === 'string')) return Infinity;
210 const innerColumns = Math.max(1, Math.min(columns, props.width ?? columns)
211 - 2 * (props.paddingX ?? props.padding ?? 0) - (props.borderStyle ? 2 : 0));
212 const heights = children.map(child => bandRows(child, innerColumns));
213 const content = props.flexDirection === 'row' && !props.flexWrap
214 ? Math.max(0, ...heights) : heights.reduce((rows, height) => rows + height, 0);
215 const padding = (props.paddingTop ?? props.paddingY ?? props.padding ?? 0)
216 + (props.paddingBottom ?? props.paddingY ?? props.padding ?? 0);
217 const margin = (props.marginTop ?? props.marginY ?? props.margin ?? 0)
218 + (props.marginBottom ?? props.marginY ?? props.margin ?? 0);
219 const gap = props.flexDirection === 'row' ? 0 : (props.gap ?? 0) * Math.max(0, children.length - 1);
220 return Math.max(content + padding + gap + (props.borderStyle ? 2 : 0),
221 props.height ?? 0, props.minHeight ?? 0) + margin;
222}
223hooks/motion.mjs 50 lines1export const WALK_SPEED = 5;
2export const RUN_SPEED = 15;
3export const BOOST_MS = 4000;
4export const CRACK_MS = 350;
5export const JUMP_MS = 500;
6
7export function createMotion(now) {
8 return { x: 0, direction: 1, updatedAt: now, boostUntil: 0,
9 crackUntil: 0, jumpUntil: 0, gait: 0, paused: false, whips: 0 };
10}
11
12export function advanceMotion(state, now, lane) {
13 const time = Math.max(state.updatedAt, now);
14 const width = Math.max(0, lane);
15 const startX = Math.min(width, Math.max(0, state.x));
16 const direction = startX >= width ? -1 : state.direction;
17 const runningMs = state.paused ? 0
18 : Math.max(0, Math.min(time, state.boostUntil) - state.updatedAt);
19 const elapsedMs = state.paused ? 0 : time - state.updatedAt;
20 const distance = (runningMs * RUN_SPEED + (elapsedMs - runningMs) * WALK_SPEED) / 1000;
21 if (width === 0) return { ...state, x: 0, updatedAt: time };
22 // Unfold the reflected lane onto a circle of circumference 2 * width.
23 const unfolded = direction > 0 ? startX : 2 * width - startX;
24 const phase = (unfolded + distance) % (2 * width);
25 return { ...state, x: phase <= width ? phase : 2 * width - phase,
26 direction: phase < width ? 1 : -1, updatedAt: time,
27 gait: state.gait + distance };
28}
29
30export function whipMotion(state, now, lane) {
31 const advanced = advanceMotion(state, now, lane);
32 return { ...advanced, paused: false, boostUntil: advanced.updatedAt + BOOST_MS,
33 crackUntil: advanced.updatedAt + CRACK_MS, whips: advanced.whips + 1 };
34}
35
36// A whip with no task running: the crab jumps in place once the lash lands.
37export function jumpMotion(state, now) {
38 const time = Math.max(state.updatedAt, now);
39 return { ...state, updatedAt: time, crackUntil: time + CRACK_MS,
40 jumpUntil: time + CRACK_MS + JUMP_MS, whips: state.whips + 1 };
41}
42
43export function pauseMotion(state, now, lane) {
44 return { ...advanceMotion(state, now, lane), paused: true };
45}
46
47export function resumeMotion(state, now) {
48 return { ...state, paused: false, updatedAt: Math.max(state.updatedAt, now) };
49}
50hooks/art.mjs 124 lines1import { CRACK_MS, JUMP_MS } from './motion.mjs';
2
3// The lane is drawn on a quadrant grid, as Claude Code's banner draws Clawd:
4// each terminal cell is 2x2 pixels in one color over the background.
5const ORANGE = '#d77757';
6const HANDLE_COLOR = '#3b2a1c';
7const WHIP_COLOR = '#7a5230';
8const CRACK_COLOR = '#fff3a0';
9const DUST_COLORS = ['#c9bfb2', '#b3a898', '#8f8679'];
10
11// Clawd pixel for pixel from the banner (▐▛███▜▌ / ▝▜█████▛▘ / ▘▘ ▝▝); its eyes
12// are gaps. The second leg frame shuffles half a cell toward travel.
13const BODY = [
14 '...############...',
15 '...##.######.##...',
16 '.################.',
17 '...############...',
18];
19const LEGS = ['....#.#....#.#....', '.....#.#....#.#...'];
20
21const SPRITE_PIXELS = BODY[0].length;
22export const SPRITE_WIDTH = SPRITE_PIXELS / 2;
23// Headroom for the idle jump, in pixels; even, so Clawd at rest sits on the
24// same cell boundaries as the banner and draws with its exact glyphs.
25const JUMP_HEIGHT = 2;
26const PIXEL_ROWS = JUMP_HEIGHT + BODY.length + 2;
27export const SPRITE_ROWS = PIXEL_ROWS / 2;
28// Columns the whip needs beside the crab.
29export const WHIP_REACH = 6;
30
31const FRAMES = LEGS.map(legs => [...BODY, legs]);
32const FRAMES_LEFT = FRAMES.map(frame => frame.map(row => [...row].reverse().join('')));
33
34// The lash: a curve from the handle to a tip that swings down onto the trailing
35// claw. Pixels measured back from the sprite's trailing edge, rows from its top.
36const HANDLE = [[11, 4], [7, 2]];
37const LASH_TIPS = [[4, -1], [2, -1], [0, 0], [0, 2]];
38const LASH_BENDS = [[12, -1], [8, -1], [4, -1], [4, 1]];
39
40// `state.x` is the crab's left edge in columns; it lands on half columns, so
41// the walk moves a pixel at a time. Returns `{ pixels, x }`: the scene as rows
42// of colored pixels (null is the terminal's own background), two per column,
43// and the crab's left pixel.
44export function lanePixels(state, columns) {
45 const cells = Math.max(0, Math.floor(columns));
46 if (cells < SPRITE_WIDTH) return null;
47 const width = cells * 2;
48 const pixels = Array.from({ length: PIXEL_ROWS }, () => Array(width).fill(null));
49 const x = Math.max(0, Math.min(width - SPRITE_PIXELS, Math.round(state.x * 2)));
50 const right = state.direction > 0;
51 // Pixel `offset` beyond the crab's left or right edge.
52 const beside = left => offset => left ? x - 1 - offset : x + SPRITE_PIXELS + offset;
53 const behind = beside(right);
54 // The lane has no margins, so at an edge the lash comes from the front;
55 // Clawd is symmetric, so it reads the same.
56 const whipSide = (right ? x : width - x - SPRITE_PIXELS) >= WHIP_REACH * 2 ? behind : beside(!right);
57 const plot = (column, row, color) => {
58 row += JUMP_HEIGHT;
59 if (column >= 0 && column < width && row >= 0 && row < PIXEL_ROWS) pixels[row][column] = color;
60 };
61 if (!state.paused && state.updatedAt < state.boostUntil) {
62 const drift = Math.floor(state.gait * 2) % 4;
63 for (const [offset, row, w, h, shade] of [[2, 3, 3, 2, 0], [7, 4, 2, 1, 1], [11, 4, 1, 1, 2]]) {
64 for (let dy = 0; dy < h; dy += 1) {
65 for (let dx = 0; dx < w; dx += 1) plot(behind(offset + drift + dx), row + dy, DUST_COLORS[shade]);
66 }
67 }
68 }
69 if (state.updatedAt < state.crackUntil) {
70 const phase = Math.min(3, Math.floor((CRACK_MS - state.crackUntil + state.updatedAt) / 90));
71 const curve = (from, bend, to, color) => {
72 for (let i = 0; i <= 24; i += 1) {
73 const t = i / 24;
74 const along = (a, b, c) => (1 - t) ** 2 * a + 2 * (1 - t) * t * b + t ** 2 * c;
75 plot(whipSide(Math.round(along(from[0], bend[0], to[0]))), Math.round(along(from[1], bend[1], to[1])), color);
76 }
77 };
78 curve(HANDLE[0], HANDLE[0], HANDLE[1], HANDLE_COLOR);
79 const tip = LASH_TIPS[phase];
80 curve(HANDLE[1], LASH_BENDS[phase], tip, WHIP_COLOR);
81 if (phase === 3) {
82 for (const [dx, dy] of [[0, 0], [1, -1], [1, 1], [2, 0], [0, -1], [0, 1]]) {
83 plot(whipSide(tip[0] + dx), tip[1] + dy, CRACK_COLOR);
84 }
85 }
86 }
87 const jump = JUMP_MS - Math.max(0, (state.jumpUntil ?? 0) - state.updatedAt);
88 const lift = jump >= 0 && jump < JUMP_MS ? Math.round(JUMP_HEIGHT * Math.sin(Math.PI * jump / JUMP_MS)) : 0;
89 const frame = (right ? FRAMES : FRAMES_LEFT)[Math.floor(state.gait / 2) % 2];
90 frame.forEach((line, row) => [...line].forEach((key, i) => {
91 if (key === '#') pixels[row + JUMP_HEIGHT - lift][x + i] = ORANGE;
92 }));
93 return { pixels, x };
94}
95
96const QUADRANTS = ' ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█';
97
98// Packs cells [from, to) into terminal rows of same-colored runs, one
99// quadrant glyph per cell: top-left, top-right, bottom-left, bottom-right.
100export function quadrantRuns(pixels, from, to) {
101 const rows = [];
102 for (let row = 0; row < pixels.length; row += 2) {
103 const runs = [];
104 for (let column = from; column < to; column += 1) {
105 const quad = [pixels[row][2 * column], pixels[row][2 * column + 1],
106 pixels[row + 1]?.[2 * column] ?? null, pixels[row + 1]?.[2 * column + 1] ?? null];
107 const counts = new Map();
108 for (const color of quad) if (color) counts.set(color, (counts.get(color) ?? 0) + 1);
109 const [main, other] = [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([color]) => color);
110 // A cell holds one color over the background, or two filling it. Where a
111 // second color meets a gap, the gap wins, so Clawd's eyes stay open.
112 const fill = other && !quad.includes(null) ? other : null;
113 const bits = quad.reduce((sum, color, i) => sum + (color === main ? 1 << i : 0), 0);
114 const cell = !main ? { text: ' ' }
115 : { text: QUADRANTS[bits], color: main, ...(fill ? { backgroundColor: fill } : {}) };
116 const last = runs.at(-1);
117 if (last && last.color === cell.color && last.backgroundColor === cell.backgroundColor) last.text += cell.text;
118 else runs.push(cell);
119 }
120 rows.push(runs);
121 }
122 return rows;
123}
124hooks/lane.mjs 17 lines1// Surface module for the crab's lane on the terminal. A Client takes a click
2// anywhere on the lane without a Button's focus and hover inversion; the press
3// travels to the hooks module as a post. No key listener: one would hold the
4// keyboard after a click and swallow the next prompt. `props.rows` are
5// quadrantRuns rows.
6export default function Lane(props, surface) {
7 const { Text } = surface.elements;
8 if (surface.state === undefined) {
9 surface.onPointer(event => {
10 if (event.type === 'down') surface.post({ whip: true });
11 });
12 surface.setState(true);
13 }
14 return Text({ children: (props?.rows ?? []).flatMap((runs, row) =>
15 [...(row ? ['\n'] : []), ...runs.map(({ text, ...colors }) => Text({ ...colors, children: text }))]) });
16}
17