SLOPSHOPPER

petit-chat

A small animated braille cat above the prompt, ported from Mistral Vibe's PetitChat.

newbandtimer
v0.1.0Apache-2.0updated 2026-10-08Aler1x/claude-cat
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · petit-chat
› 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 ⠀⠀⡠⣒⠄⠀⠀⡔⢄⠔⡄ ⠀⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣ ⠀⠀⠉⠒⠣⠤⠵⠤⠬⠮⠆ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⠀⠀⡠⣒⠄⠀⠀⡔⢄⠔⡄ ⠀⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣ ⠀⠀⠉⠒⠣⠤⠵⠤⠬⠮⠆
README

petit-chat

<img width="551" height="184" alt="image" src="https://github.com/user-attachments/assets/42bc2103-ec10-4dcc-93ed-429b695c9a68" />

A small animated braille cat above the Claude Code prompt. The cat blinks, moves its tail and turns its head, then rests for 5 to 20 seconds.

Install

Type this line at the prompt of a Claude Code terminal session:

/plugin install petit-chat --marketplace Aler1x/claude-cat

Then type y to add the marketplace and select a scope.

Develop

claude --plugin-dir .
claude plugin test .

Credits

This is a port of PetitChat from mistral-vibe (vibe/cli/textual_ui/widgets/banner/petit_chat.py), licensed under Apache-2.0. See LICENSE.

Source 1 files
hooks/register.tsx 165 lines
1import type { Register } from 'claude-code'
2
3// Port of PetitChat from mistral-vibe (Apache-2.0):
4// vibe/cli/textual_ui/widgets/banner/petit_chat.py
5
6const WIDTH = 22
7const HEIGHT = 12
8const FRAME_MS = 160
9const PAUSE_MIN_MS = 5_000
10const PAUSE_MAX_MS = 20_000
11const PAUSE_CHANCE = 0.25
12// Frames right after the head settles with the eyes open: the cat may rest there.
13const EYES_OPEN_PAUSE_FRAMES = new Set([5, 11, 21, 24])
14
15// Dots by row: { y: [x, ...] }, origin at the top left.
16type Rows = Record<number, number[]>
17type Step = { remove: Set<number>; add: Set<number> }
18
19const dots = (rows: Rows) =>
20  new Set(Object.entries(rows).flatMap(([y, xs]) => xs.map(x => Number(y) * WIDTH + x)))
21const step = (remove: Rows, add: Rows): Step => ({ remove: dots(remove), add: dots(add) })
22const reverse = ({ remove, add }: Step): Step => ({ remove: add, add: remove })
23
24const START: Rows = {
25  1: [6, 7, 15, 19],
26  2: [5, 8, 14, 16, 18, 20],
27  3: [4, 6, 7, 14, 17, 20],
28  4: [3, 5, 10, 11, 12, 14, 20],
29  5: [3, 5, 9, 13, 14, 16, 18, 20],
30  6: [3, 5, 8, 13, 17, 21],
31  7: [3, 6, 7, 8, 11, 14, 15, 16, 18, 19, 20],
32  8: [4, 5, 8, 12, 17, 19],
33  9: [6, 7, 8, 13, 18, 20],
34  10: [9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20],
35}
36const HEAD_HIGH: Rows = {
37  1: [15, 19],
38  2: [14, 16, 18, 20],
39  3: [17],
40  5: [17, 19],
41  6: [13, 18, 21],
42  7: [14, 15, 16, 19, 20],
43}
44const HEAD_LOW: Rows = {
45  2: [15, 19],
46  3: [16, 18],
47  4: [17],
48  6: [14, 17, 19, 20],
49  7: [13, 21],
50  8: [14, 15, 16, 18, 20],
51}
52
53const WAIT = step({}, {})
54const TAIL_RIGHT_TO_MID = step(
55  { 1: [6, 7], 2: [8], 3: [4, 6, 7], 8: [4, 5] },
56  { 1: [4], 2: [3], 3: [3, 5], 7: [5], 8: [3], 9: [4, 5] },
57)
58const TAIL_MID_TO_LEFT = step(
59  { 1: [4], 2: [5], 3: [3, 5], 7: [5], 8: [3], 9: [4, 5] },
60  { 1: [1, 2], 2: [0], 3: [1, 2, 4], 8: [4, 5] },
61)
62const HEAD_RIGHT = step({ 5: [16, 18], 6: [17] }, { 5: [17, 19], 6: [18] })
63const HEAD_DOWN = step(HEAD_HIGH, HEAD_LOW)
64const BLINK_HIGH = [step({ 5: [16, 18] }, {}), step({}, { 5: [16, 18] })]
65const BLINK_LOW = [step({ 6: [17, 19] }, {}), step({}, { 6: [17, 19] })]
66
67const TRANSITIONS = [
68  ...BLINK_HIGH,
69  WAIT,
70  TAIL_RIGHT_TO_MID,
71  HEAD_RIGHT,
72  WAIT,
73  TAIL_MID_TO_LEFT,
74  WAIT,
75  reverse(TAIL_MID_TO_LEFT),
76  WAIT,
77  HEAD_DOWN,
78  WAIT,
79  reverse(TAIL_RIGHT_TO_MID),
80  ...BLINK_LOW,
81  WAIT,
82  TAIL_RIGHT_TO_MID,
83  WAIT,
84  TAIL_MID_TO_LEFT,
85  WAIT,
86  reverse(HEAD_DOWN),
87  WAIT,
88  reverse(TAIL_MID_TO_LEFT),
89  reverse(HEAD_RIGHT),
90  WAIT,
91  reverse(TAIL_RIGHT_TO_MID),
92]
93
94// Braille cell bit for the sub-dot (sx, sy): see https://en.wikipedia.org/wiki/Braille_Patterns
95const bit = (sx: number, sy: number) => 1 << (sy < 3 ? sy + 3 * sx : 6 + sx)
96
97function render(cat: Set<number>): string {
98  const rows: string[] = []
99  for (let cy = 0; cy < HEIGHT / 4; cy++) {
100    let row = ''
101    for (let cx = 0; cx < WIDTH / 2; cx++) {
102      let bits = 0
103      for (let sy = 0; sy < 4; sy++) {
104        for (let sx = 0; sx < 2; sx++) {
105          if (cat.has((cy * 4 + sy) * WIDTH + cx * 2 + sx)) bits |= bit(sx, sy)
106        }
107      }
108      // Blank braille (U+2800), not a space: the desktop font draws a space narrower.
109      row += String.fromCharCode(0x2800 + bits)
110    }
111    rows.push(row)
112  }
113  return rows.join('\n')
114}
115
116export const register: Register = on => {
117  // ponytail: module state, a hot reload restarts the cat from its first frame
118  const cat = dots(START)
119  let resumeFrame: number | undefined
120
121  // Applies the transitions forever, yielding the index of the next one.
122  function* frames(): Generator<number, never> {
123    for (;;) {
124      for (const [i, { remove, add }] of TRANSITIONS.entries()) {
125        remove.forEach(d => cat.delete(d))
126        add.forEach(d => cat.add(d))
127        yield (i + 1) % TRANSITIONS.length
128      }
129    }
130  }
131  const steps = frames()
132
133  on('session.start', async ($, e, next) => {
134    const play = () => {
135      const timer = $.clock.every(FRAME_MS, () => {
136        const frame = steps.next().value
137        $.ui.invalidate('ui.render')
138
139        // After a rest, play a full cycle back to the same frame before the next rest.
140        if (resumeFrame !== undefined && frame !== resumeFrame) return
141        resumeFrame = undefined
142
143        if (frame === 0 || (EYES_OPEN_PAUSE_FRAMES.has(frame) && Math.random() < PAUSE_CHANCE)) {
144          timer.cancel()
145          resumeFrame = frame
146          $.clock.after(PAUSE_MIN_MS + Math.random() * (PAUSE_MAX_MS - PAUSE_MIN_MS), play)
147        }
148      })
149    }
150    play()
151
152    return next(e)
153  })
154
155  on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
156    if (e.props.hasSurvey) {
157      return next(e)
158    }
159
160    const { Text } = $.ui.resolve(e)
161
162    return <Text color="claude">{render(cat)}</Text>
163  })
164}
165