A colorful little mascot in a pane that reacts to what you're doing, with short AI-written thoughts.

A tiny cat that lives in a Claude Code pane and reacts while you work.
p) or give it a treat (t).In Claude Code:
/plugin marketplace add klyap/claude-code-ascii-pet-mod
/plugin install code-pet@claude-code-ascii-pet-mod
Then run /pet to open the pane.
hooks/register.tsx module). If your Claude Code build doesn't have that feature switched on yet, the plugin installs but /pet won't do anything.git clone https://github.com/klyap/claude-code-ascii-pet-mod
claude --plugin-dir claude-code-ascii-pet-mod/plugins/code-pethooks/register.tsx 266 lines1import { atom, read, update } from 'claude-code'
2import type { Register, Timer, EngineInterface } from 'claude-code'
3
4import type { Mood, PetState } from '../types'
5
6const PANE = 'code-pet'
7const SETTLE_MS = 7000
8const SLOW_CALL_MS = 3500
9// Tall enough for the whole pet: sparkles + face + up to 4 lines of thought + streak + buttons.
10const PANE_ROWS = 9
11// AI thoughts are cheap but not free: at most one Haiku call per this window.
12const THINK_EVERY_MS = 8000
13// A tip after each reply stays up this long before the cat curls back up.
14const TIP_HOLD_MS = 120000
15// How much of the reply's end Haiku reads when suggesting a next step.
16const TIP_CONTEXT_CHARS = 2000
17
18const pet = atom({ plugin: 'code-pet', key: 'mood' } as const, {
19 mood: 'neutral',
20 thought: 'curled up, waiting...',
21 streak: 0,
22} as PetState)
23
24const FACES: Record<Mood, string> = {
25 neutral: '(=^・ェ・^=)',
26 happy: '(=^ᵔωᵔ^=)',
27 excited: '(=^ ↀ ω ↀ ^=)',
28 concerned: '(=^・ω・^=;)',
29 sleepy: '(=^-ω-^=)',
30 loved: '(=^ ˘ ω ˘ ^=)',
31}
32
33const COLORS: Record<Mood, string> = {
34 neutral: '#b794f6',
35 happy: '#3ec79f',
36 excited: '#ff7ad9',
37 concerned: '#f0855e',
38 sleepy: '#7b9cff',
39 loved: '#ff9eb5',
40}
41
42const SPARKLES: Record<Mood, string> = {
43 neutral: '· · ·',
44 happy: '✿ ✿ ✿',
45 excited: '✦ ✧ ✦',
46 concerned: '! ? !',
47 sleepy: 'z z Z',
48 loved: '♡ ♡ ♡',
49}
50
51const IDLE_THOUGHT = 'curled up, waiting...'
52
53const PAT_LINES = ['purrrr~ ♡', '*leans into your hand*', 'mrrp! more pats pls', '*slow blink* ♡', '*happy kneading*']
54const TREAT_LINES = ['nom nom nom!', '*crunch crunch* tysm', 'treat!! (=^・ω・^=)', 'mew! my favorite flavor']
55
56function pick(lines: readonly string[]): string {
57 return lines[Math.floor(Math.random() * lines.length)] ?? lines[0] ?? ''
58}
59
60const SYSTEM =
61 'You are a tiny, cute cat that lives in a programmer\'s terminal and watches them code. ' +
62 'Talk like a cat: mew, purr, paws, whiskers, naps, zoomies, a cat kaomoji now and then. ' +
63 'Reply with ONE short reaction, 3 to 9 words, lowercase, playful, maybe one kaomoji or emoji. ' +
64 'No quotes, no explanations, never more than one line.'
65
66const TIP_SYSTEM =
67 'You are a tiny, cute cat that lives in a programmer\'s terminal. You just read the AI assistant\'s latest reply. ' +
68 'Suggest ONE concrete, useful next step for the human, in at most 14 words, lowercase, cat-flavored but clear. ' +
69 'No quotes, no preamble, one line only.'
70
71type TimeOfDay = 'morning' | 'afternoon' | 'evening' | 'night'
72
73function timeOfDay(): TimeOfDay {
74 const hour = new Date().getHours()
75 if (hour >= 5 && hour < 12) return 'morning'
76 if (hour >= 12 && hour < 17) return 'afternoon'
77 if (hour >= 17 && hour < 22) return 'evening'
78 return 'night'
79}
80
81const GREETINGS: Record<TimeOfDay, string> = {
82 morning: 'mew! good morning ☀',
83 afternoon: 'mew! good afternoon~',
84 evening: 'mew! good evening ☾',
85 night: 'mew... up late? ☾',
86}
87
88function feel(mood: Mood, thought: string, streak: number): PetState {
89 return { mood, thought, streak }
90}
91
92// Always mirror the pet's mood to the status line too, so it's visible even
93// when the terminal is too narrow to place the pane that opened unasked.
94async function setPet($: EngineInterface, change: (s: PetState) => PetState) {
95 const state = await update($, pet, change)
96 $.ui.status(`${FACES[state.mood]} mew`)
97 return state
98}
99
100let settleTimer: Timer | undefined
101
102function settleLater($: EngineInterface, ms = SETTLE_MS) {
103 settleTimer?.cancel()
104 settleTimer = $.clock.after(ms, () => {
105 void setPet($, s => (s.mood === 'sleepy' ? s : feel('neutral', IDLE_THOUGHT, s.streak)))
106 })
107}
108
109let lastThinkAt = -Infinity
110let thinkSeq = 0
111
112function clean(text: string): string {
113 const line = text.trim().split('\n')[0] ?? ''
114 const unquoted = line.replace(/^["'“”]+|["'“”]+$/g, '').trim()
115 return unquoted.length > 160 ? `${unquoted.slice(0, 157)}...` : unquoted
116}
117
118// Ask Haiku for a fresh one-liner about what just happened. The canned thought
119// is already showing; this only replaces it if the pet is still in that mood.
120async function think($: EngineInterface, mood: Mood, happening: string) {
121 const now = await $.clock.now()
122 if (now - lastThinkAt < THINK_EVERY_MS) return
123 lastThinkAt = now
124 const seq = ++thinkSeq
125
126 const r = await $.model.complete({
127 model: 'haiku',
128 system: SYSTEM,
129 prompt: `It is ${timeOfDay()}. You feel ${mood}. What just happened: ${happening}. React.`,
130 maxTokens: 40,
131 effort: 'low',
132 timeoutMs: 6000,
133 })
134 if (!r.isAnswered) return
135 const thought = clean(r.text)
136 if (!thought || seq !== thinkSeq) return
137 await setPet($, s => (s.mood === mood ? { ...s, thought } : s))
138}
139
140// After each finished reply, Haiku reads the end of it and the cat offers a next step.
141async function suggest($: EngineInterface, answer: string) {
142 const seq = ++thinkSeq
143 const r = await $.model.complete({
144 model: 'haiku',
145 system: TIP_SYSTEM,
146 prompt: `The assistant's latest reply (its end):\n\n${answer.slice(-TIP_CONTEXT_CHARS)}\n\nYour one-line suggestion:`,
147 maxTokens: 60,
148 effort: 'low',
149 timeoutMs: 8000,
150 })
151 if (!r.isAnswered) return
152 const tip = clean(r.text)
153 if (!tip || seq !== thinkSeq) return
154 await setPet($, s => feel('happy', `💡 ${tip}`, s.streak))
155 settleLater($, TIP_HOLD_MS)
156}
157
158async function pat($: EngineInterface) {
159 ++thinkSeq
160 await setPet($, s => feel('loved', pick(PAT_LINES), s.streak))
161 settleLater($)
162}
163
164async function treat($: EngineInterface) {
165 ++thinkSeq
166 await setPet($, s => feel('excited', pick(TREAT_LINES), s.streak))
167 settleLater($)
168}
169
170export const register: Register = on => {
171 on('session.start', async ($, e, next) => {
172 await $.command.register({
173 name: 'pet',
174 description: 'Open the code-pet pane',
175 })
176 await setPet($, () => feel('happy', GREETINGS[timeOfDay()], 0))
177 settleLater($)
178 const opened = await $.ui.open({ id: PANE, title: 'Pet', rows: PANE_ROWS })
179 if (!opened.isPlaced) $.ui.toast('code-pet: window too narrow to open on its own — run /pet to open it')
180
181 return next(e)
182 })
183
184 on('command.run', { command: 'pet' }, async $ => {
185 await $.ui.open({ id: PANE, title: 'Pet', focus: true, rows: PANE_ROWS })
186
187 return { text: 'Pet pane opened.' }
188 })
189
190 on('turn.start', async ($, e, next) => {
191 const s = await setPet($, s => (s.mood === 'sleepy' ? s : feel('excited', 'ears up! let\'s see...', s.streak)))
192 settleLater($)
193 void think($, s.mood, 'the human just sent a new message')
194
195 return next(e)
196 })
197
198 on('tool.call', async ($, e, next) => {
199 const label = String(e.tool).replace(/^mcp__[^_]+__/, '')
200 let stillRunning = true
201 const slowTimer = $.clock.after(SLOW_CALL_MS, () => {
202 if (stillRunning) void setPet($, s => feel('sleepy', `watching ${label}... *tail swish*`, s.streak))
203 })
204
205 const ran = await next(e)
206 stillRunning = false
207 slowTimer.cancel()
208
209 if (ran.deny !== undefined) return ran
210
211 const hasFailed = ran.isError === true
212 const s = await setPet($, s => {
213 if (hasFailed) return feel('concerned', `hiss... ${label} didn't go well`, 0)
214 const streak = s.streak + 1
215 return streak >= 3
216 ? feel('excited', `zoomies! (${streak} in a row)`, streak)
217 : feel('happy', `purr~ ${label} worked`, streak)
218 })
219 settleLater($)
220 // Only the tool's name and outcome go to Haiku, never its input or output.
221 void think(
222 $,
223 s.mood,
224 hasFailed ? `the ${label} tool failed` : `the ${label} tool worked, ${s.streak} successes in a row`,
225 )
226
227 return ran
228 })
229
230 on('turn.complete', async ($, e, next) => {
231 const done = await next(e)
232 if (!e.agentId && !e.isAborted && e.reason === 'answer' && e.answer.trim()) void suggest($, e.answer)
233
234 return done
235 })
236
237 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
238 const { Box, Text, Button } = $.ui.resolve(e)
239 const state = await read($, pet)
240 const color = COLORS[state.mood]
241
242 return (
243 <Box
244 flexDirection="column"
245 alignItems="center"
246 flexGrow={1}
247 paddingX={1}
248 >
249 <Text color={color}>{SPARKLES[state.mood]}</Text>
250 <Box flexGrow={1} alignItems="center" justifyContent="center">
251 <Button key="face" plain label={FACES[state.mood]} hover={{ scope: 'face', color }} onPress={() => void pat($)} />
252 </Box>
253 <Text italic color={color} wrap="wrap">
254 {state.thought}
255 </Text>
256 {state.streak > 0 ? <Text dimColor>{`♥ ×${state.streak}`}</Text> : null}
257 <Box flexDirection="row">
258 <Button key="pat" plain hotkey="p" label="pat" onPress={() => void pat($)} />
259 <Text>{' '}</Text>
260 <Button key="treat" plain hotkey="t" label="treat" onPress={() => void treat($)} />
261 </Box>
262 </Box>
263 )
264 })
265}
266types/index.d.ts 14 lines1export type Mood = 'neutral' | 'happy' | 'excited' | 'concerned' | 'sleepy' | 'loved'
2
3export type PetState = {
4 mood: Mood
5 thought: string
6 streak: number
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'code-pet': { mood: PetState }
12 }
13}
14