SLOPSHOPPER

clawd

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

newbandtimeraudio
v1.0.0MITupdated 2026-10-13HainanZhao/claude-mods/mods/clawd
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd
› 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 ▣ client module ./lane.mjs ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ client module ./lane.mjs ⟨Claude Code's own drawing⟩
README

Clawd

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.

Source 4 files
hooks/register.mjs 223 lines
1import { 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}
223
hooks/motion.mjs 50 lines
1export 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}
50
hooks/art.mjs 124 lines
1import { 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}
124
hooks/lane.mjs 17 lines
1// 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