SLOPSHOPPER

prompt-cats

Two pixel cats (bright golden and brown tabby) above the prompt: sitting while idle, walking in place while Claude works.

newbandtimer
v0.2.0no licenseupdated 2026-10-08Harvey1311/claude-code-mods-cat
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-cats
› 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 ⟨Claude Code's own drawing⟩ ▀▄▀ ▀▄▀ ▄▀▀▀▀▄ ▄▀▀▀▀▄ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ▀▄▀ ▀▄▀ ▄▀▀▀▀▄ ▄▀▀▀▀▄ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
README

prompt-cats

A Claude Code mod: two pixel cats on a thin ground line, just above the prompt, on the right.

  • Idle: they sit, now and then blinking or flicking a tail.
  • While Claude works: they walk in place until the reply is done.

The left cat is a bright golden British Shorthair, the right one a browner tabby.

Install

Type this at the prompt in Claude Code:

/plugin install prompt-cats --marketplace Harvey1311/claude-code-mods-cat

Answer y to add the marketplace, then pick the user scope so the cats appear in every session.

Update

/plugin marketplace update claude-code-mods-cat

Notes

  • Built on Claude Code's function-hooks plugin API, which is marked early access: a Claude Code update can change it.
  • Works in any terminal. The cats are drawn with half-block characters, so no image support is needed.
  • macOS Terminal.app draws block characters from its font, slightly short of a full line, which leaves a thin dark line between rows of the cats. There, and only there, the cats are drawn differently: the belly is solid fur color, and a dark fill goes under any belly dot without a leg beneath it. The fill is tuned for the "Clear Dark" profile; on a lighter or see-through background it shows as a small dark patch between the legs. Other terminals (VS Code, iTerm2, Ghostty, ...) are unaffected.
  • The cats take 3 rows above the prompt and hide while a survey is shown or when the terminal is narrower than 28 columns.
Source 1 files
hooks/register.tsx 183 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// Two pixel cats at the bottom of the band, right-aligned, standing on a thin ground line drawn under their paws.
4// Each cat is 9 x 4 pixels, both facing left. One terminal cell holds two pixels stacked (a half block), so 4
5// pixels make 2 rows; the ground is a third row of upper-eighth blocks (▔), which hug the paws. Pixel codes: '.' see-through, 'O' fur, 'D' darker fur (shading, far legs), 'T' tail tip, 'E' eye, '0'-'6' the belly, lightest
6// to darkest.
7type Sprite = readonly string[]
8
9const SIT: Sprite = ['..O.O....', '..EOE....', '..OOOO...', '.TOOOOO..']
10const BLINK: Sprite = ['..O.O....', '..OOO....', '..OOOO...', '.TOOOOO..']
11// The tail tip lifts off the ground for a beat.
12const FLICK: Sprite = ['..O.O....', '..EOE....', '..OOOO...', 'T.OOOOO..']
13
14const walkFrame = (legs: string, isTailUp: boolean): Sprite => [
15  isTailUp ? 'O.O.....T' : 'O.O......',
16  isTailUp ? 'OOODODOD.' : 'OOODODODT',
17  '.0123456.',
18  legs,
19]
20// A four-step walk cycle: stride, legs passing, the other stride, legs passing; the tail sways every two steps.
21export const WALK: readonly Sprite[] = [
22  walkFrame('O.D..D.O.', false),
23  walkFrame('.OD..OD..', false),
24  walkFrame('D.O..O.D.', true),
25  walkFrame('.DO..DO..', true),
26]
27
28type Palette = { O: number; D: number; T: number; E: number; B: number }
29// Matched to a photo of the two real cats, sampled and lifted for a dark terminal, the left one kept brighter.
30// Left: a British Shorthair, black golden shaded (NY11): warm apricot gold with darker shading on the back. Right: a
31// duller, browner tabby with a dark tail tip (dark brown, as black would vanish into a dark background). As asked,
32// the left one's tail tip is its body gold, and the eyes (green) show only while sitting. Bellies (walking only; a
33// sitting cat is one block of color): a gradient from front to hind, starting at B (the left one white, the right one
34// a light shade of its body color) and darkening evenly to the body color O.
35export const ORANGE: Palette = { O: 0xd9a55c, D: 0x8c6230, T: 0xd9a55c, E: 0x2e9e44, B: 0xffffff }
36export const GREY: Palette = { O: 0xb08a55, D: 0x6e5a3e, T: 0x51473b, E: 0x2e9e44, B: 0xd8c4aa }
37
38const BELLY_STEPS = 7
39const mix = (from: number, to: number, t: number) =>
40  [16, 8, 0].reduce((rgb, shift) => {
41    const a = (from >> shift) & 0xff
42    const b = (to >> shift) & 0xff
43    return rgb | (Math.round(a + (b - a) * t) << shift)
44  }, 0)
45/** The belly's color at step 0 (front, lightest) to BELLY_STEPS - 1 (hind, the body color). */
46export const bellyShade = (palette: Palette, step: number) => mix(palette.B, palette.O, step / (BELLY_STEPS - 1))
47
48const CAT_WIDTH = 9
49const GAP = 2
50// The ground runs this far past the cats on each side.
51const MARGIN = 4
52export const COLUMNS = MARGIN + CAT_WIDTH + GAP + CAT_WIDTH + MARGIN
53const CAT_ROWS = 2
54/** The Raster's rows: the cats, then the ground. */
55export const ROWS = CAT_ROWS + 1
56const GROUND_COLOR = 0x555555
57export const KEY = 'cats'
58const TICK_MS = 160
59const DEFAULT_COLOR = 0x01000000
60
61// macOS Terminal.app only; VS Code draws the cells above unchanged. Terminal.app draws block glyphs from the font, a
62// little low and short, so the top few pixels of every line show only the cell's background. Where a cell has both
63// halves colored, the top half goes in the background and the bottom half is a ▄, which closes that strip. A belly dot
64// with no leg under it (3 and 4 always, 1, 6 and 7 while their leg is lifted) gets UNDER_BELLY in the empty half, a
65// dark color chosen to pass for the window: measured against a see-through "Clear Dark" profile, it can show as a faint block over a
66// brighter backdrop or another profile.
67export const UNDER_BELLY = 0x222233
68const BELLY_DOTS = [1, 2, 3, 4, 5, 6, 7]
69
70// Uint8Array.prototype.toBase64 is in the plugin environment (the Raster docs use it) but not yet in the es2023
71// lib types.
72type Base64Bytes = Uint8Array & { toBase64: () => string }
73
74const walkAt = (step: number): Sprite => WALK[step % WALK.length] ?? SIT
75
76export type Pose = { orange: Sprite; grey: Sprite }
77
78/**
79 * The cats at a tick. Working: both walk, half a cycle apart so they are not in lockstep. Idle: both sit, each
80 * blinking and flicking its tail on its own schedule (at 160 ms a tick, orange blinks every ~5 s, grey ~7 s).
81 */
82export const poseAt = (tick: number, isWorking: boolean): Pose => {
83  if (isWorking) return { orange: walkAt(tick), grey: walkAt(tick + 2) }
84  const orange = tick % 31 === 0 ? BLINK : tick % 53 === 20 || tick % 53 === 21 ? FLICK : SIT
85  const grey = tick % 43 === 7 ? BLINK : tick % 67 === 40 || tick % 67 === 41 ? FLICK : SIT
86  return { orange, grey }
87}
88
89// The Raster columns of the belly dots of both cats.
90const BELLY_COLUMNS = new Set(BELLY_DOTS.flatMap(x => [MARGIN + x, MARGIN + CAT_WIDTH + GAP + x]))
91
92/** The pose as Raster cells: COLUMNS x ROWS cells, each `[codePoint, foreground, background]`, base64. */
93export const encode = (pose: Pose, isAppleTerminal = false): string => {
94  const pixels: (number | null)[][] = Array.from({ length: CAT_ROWS * 2 }, (_, y) => {
95    const color = (sprite: Sprite, palette: Palette, x: number) => {
96      const code = sprite[y]?.[x] ?? '.'
97      if (code === '.') return null
98      // Terminal.app: the belly is solid, in the ears' color (O), instead of the gradient.
99      if (code >= '0' && code <= '9') return isAppleTerminal ? palette.O : bellyShade(palette, Number(code))
100      return palette[code as keyof Palette]
101    }
102    return [
103      ...Array.from({ length: MARGIN }, () => null),
104      ...Array.from({ length: CAT_WIDTH }, (_, x) => color(pose.orange, ORANGE, x)),
105      ...Array.from({ length: GAP }, () => null),
106      ...Array.from({ length: CAT_WIDTH }, (_, x) => color(pose.grey, GREY, x)),
107      ...Array.from({ length: MARGIN }, () => null),
108    ]
109  })
110  const words = new Uint32Array(COLUMNS * ROWS * 3)
111  for (let row = 0; row < CAT_ROWS; row++) {
112    for (let col = 0; col < COLUMNS; col++) {
113      const top = pixels[row * 2]?.[col] ?? null
114      const pixel = pixels[row * 2 + 1]?.[col] ?? null
115      const isUnderBelly = isAppleTerminal && row === 1 && BELLY_COLUMNS.has(col) && top !== null && pixel === null
116      const bottom = isUnderBelly ? UNDER_BELLY : pixel
117      const cell =
118        isAppleTerminal && top !== null && bottom !== null
119          ? [0x2584, bottom, top] // ▄
120          : top !== null
121            ? [0x2580, top, bottom ?? DEFAULT_COLOR] // ▀
122            : bottom !== null
123              ? [0x2584, bottom, DEFAULT_COLOR] // ▄
124              : [0x20, DEFAULT_COLOR, DEFAULT_COLOR]
125      words.set(cell, (row * COLUMNS + col) * 3)
126    }
127  }
128  for (let col = 0; col < COLUMNS; col++) {
129    words.set([0x2594, GROUND_COLOR, DEFAULT_COLOR], (CAT_ROWS * COLUMNS + col) * 3) // ▔
130  }
131  return (new Uint8Array(words.buffer) as Base64Bytes).toBase64()
132}
133
134/** Repaints the mounted cats with new cells. */
135const paint = ($: EngineInterface, requestId: string, cells: string) =>
136  $.ui.blit({ requestId, key: KEY, cells, columns: COLUMNS, rows: ROWS })
137
138export const register: Register = on => {
139  // Animation bookkeeping. A hot reload starts these over, which costs at most one frame.
140  let tick = 0
141  let isWorking = false
142  let band: string | null = null
143  let shown = ''
144  // Unknown until the first draw reads TERM_PROGRAM.
145  let isAppleTerminal: boolean | null = null
146
147  on('session.start', async ($, e, next) => {
148    const started = await next(e)
149    $.clock.every(TICK_MS, () => {
150      tick += 1
151      if (band === null) return
152      const cells = encode(poseAt(tick, isWorking), isAppleTerminal === true)
153      if (cells === shown) return
154      shown = cells
155      void paint($, band, cells)
156    })
157    return started
158  })
159
160  // The band is shared with other mods (Output Artifacts): draw whatever the rest of the chain draws first, then
161  // the cats on a row of their own beneath it, directly above the prompt.
162  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
163    const below = await next(e)
164    if (e.surface !== 'terminal' || e.props.hasSurvey || e.props.bodyColumns < COLUMNS) {
165      band = null
166      return below
167    }
168    isAppleTerminal ??= (await $.env.get('TERM_PROGRAM').catch(() => undefined)) === 'Apple_Terminal'
169    isWorking = e.props.isWorking
170    band = e.requestId
171    shown = encode(poseAt(tick, isWorking), isAppleTerminal)
172    const { Box, Raster } = $.ui.resolve(e)
173    return (
174      <Box flexDirection="column">
175        {below}
176        <Box flexDirection="row" justifyContent="flex-end">
177          <Raster key={KEY} columns={COLUMNS} rows={ROWS} cells={shown} />
178        </Box>
179      </Box>
180    )
181  })
182}
183