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

<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.
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.
claude --plugin-dir .
claude plugin test .
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.
hooks/register.tsx 165 lines1import 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