An orange cat above the prompt that reacts to your commits, tests and builds

An orange pixel cat that lives above the Claude Code prompt. It strolls back and forth, chases a ball, and reacts to what Claude does in your repo: happy on a commit, grumpy when one fails, cheering when tests pass or a todo.md item gets ticked, hiding in a box when the build breaks, and asleep when you've been away.
Oyen is a Claude Code mod (a plugin of function hooks). Its mood, level, commits fed and daily streak are kept across sessions, on your machine only.
/plugin marketplace add zulfikar-ditya/claude-mods-oyen
/plugin install oyen@oyen
Needs Claude Code 2.1.287 or later. The pixel cat is drawn in the terminal; the desktop app shows an ASCII cat instead.
| When Claude… | Oyen |
|---|---|
| commits | is happy and gets fed (+10 xp) |
| tries a commit that fails or is blocked | is grumpy |
| runs tests that pass | cheers (+5 xp) |
| runs tests that fail | is grumpy |
| runs a build that fails | hides in a box |
ticks a - [ ] item in todo.md | cheers (+5 xp per item) |
| does nothing for 10 minutes | falls asleep, until your next prompt |
Test and build output is read too, so a failure piped through tail still counts. Only Claude's commands count; your own terminal is left alone.
/oyen: level, xp, mood, commits fed and streak/oyen feed: a fish/oyen pet: a purr (and a meow, if sound is on)/oyen play: a game of chasing the ballIn /config under oyen, or in ~/.claude/settings.json under pluginConfigs.oyen.options:
| Setting | Default | |
|---|---|---|
enabled | true | Show the cat |
language | en | en, ms, id, zh-Hans, zh-Hant or ja |
walk | true | Stroll and play while idle |
sound | false | Meow on level-up and on /oyen pet |
idleMinutes | 10 | Minutes before it falls asleep |
minColumns | 60 | Hidden on terminals narrower than this |
enabled to false. It still keeps count.walk or sound to false./plugin uninstall oyen@oyen.claude --plugin-dir . # run it from this folder
claude plugin validate .
claude plugin test .
The poses are drawn as letter grids in hooks/pixels.ts (o fur, k eyes and mouth, . see-through) and the lines live in hooks/lines.ts.
MIT, see LICENSE.
hooks/register.tsx 326 lines1/**
2 * Oyen: an orange cat above the prompt that reacts to commits, tests and builds.
3 *
4 * Turning it off:
5 * - Hide the cat: `/config` → oyen → "Show Oyen" off, or in ~/.claude/settings.json
6 * `"pluginConfigs": { "oyen": { "options": { "enabled": false } } }`. The hooks
7 * still count commits and xp; only the band is gone.
8 * - Quiet it: the same place, "Sound" off (`"sound": false`, the default) or
9 * "Stroll" off (`"walk": false`) to keep it still.
10 * - Turn the mod off entirely: `/plugin uninstall oyen@oyen` when installed from
11 * the marketplace; when loaded from a folder, drop it from `--plugin-dir` /
12 * `CLAUDE_CODE_PLUGIN_DIRS`; then `/reload-plugins`.
13 * - Forget its stats: they live in the mod's `$.store` under the key `stats`.
14 */
15import { atom, read, update } from 'claude-code'
16import type { EngineInterface, Register, Timer } from 'claude-code'
17
18import type { Pose, Stats } from '../types'
19import type { Language } from './cat'
20import {
21 asLanguage,
22 asStats,
23 countTicks,
24 dayOf,
25 FRESH,
26 grow,
27 kindOf,
28 lineFor,
29 moodBar,
30 outputFailed,
31 POSES,
32 visit,
33} from './cat'
34import { rasterOf, ROWS, WIDTH } from './pixels'
35import { restLength, spotOf, STEP_MS, strollLength, trackOf } from './walk'
36
37const pose = atom({ plugin: 'oyen', key: 'pose' } as const, 'idle')
38const line = atom({ plugin: 'oyen', key: 'line' } as const, '')
39const stats = atom({ plugin: 'oyen', key: 'stats' } as const, FRESH)
40const lastActiveAt = atom({ plugin: 'oyen', key: 'lastActiveAt' } as const, 0)
41const walk = atom({ plugin: 'oyen', key: 'walk' } as const, { step: 0, isWalking: false })
42
43const STORE_KEY = 'stats'
44const MEOW = 'sounds/meow.wav'
45const REACTION_MS = 8_000
46const IDLE_CHECK_MS = 30_000
47const ORANGE = '#ff8c1a'
48
49type Config = { language: Language; isSoundOn: boolean; isWalkOn: boolean }
50type Reaction = { pose: Pose; xp?: number; mood?: number; commits?: number }
51
52/** The pending return to the idle pose after a reaction. */
53let settle: Timer | undefined
54
55/** How often a stroll starts as a game of chasing the ball. */
56const PLAY_CHANCE = 0.25
57
58/** How long `/oyen play` runs after the ball, in steps. */
59const PLAY_STEPS = 26
60
61/** Steps left in the current stroll, and ticks left in the rest before the next one. */
62let stepsLeft = 0
63let restLeft = 3
64
65function meow($: EngineInterface, config: Config) {
66 if (config.isSoundOn) {
67 void $.audio.play({ asset: MEOW }).catch(() => undefined)
68 }
69}
70
71async function strike($: EngineInterface, config: Config, next: Pose) {
72 await update($, pose, () => next)
73 await update($, line, () => lineFor(next, config.language, Math.random()))
74}
75
76async function react($: EngineInterface, config: Config, reaction: Reaction) {
77 const before = await read($, stats)
78 const grown = grow(before, reaction.xp ?? 0, reaction.mood ?? 0)
79 const after: Stats = { ...grown.stats, commitsFed: grown.stats.commitsFed + (reaction.commits ?? 0) }
80
81 await update($, stats, () => after)
82 await $.store.set(STORE_KEY, after)
83 await strike($, config, reaction.pose)
84
85 if (grown.hasLevelledUp) {
86 $.ui.toast(`Oyen reached level ${after.level}!`)
87 meow($, config)
88 }
89
90 settle?.cancel()
91 settle = $.clock.after(REACTION_MS, () => {
92 void read($, pose).then(now => (now === 'sleep' ? undefined : strike($, config, 'idle')))
93 })
94
95 return after
96}
97
98async function touch($: EngineInterface, config: Config) {
99 const now = await $.clock.now()
100 await update($, lastActiveAt, () => now)
101
102 if ((await read($, pose)) === 'sleep') {
103 await strike($, config, 'idle')
104 }
105}
106
107async function watchTodo<T>($: EngineInterface, config: Config, path: string, run: () => Promise<T>) {
108 if (!/(^|\/)todo\.md$/.test(path)) {
109 return run()
110 }
111
112 const before = countTicks(await $.fs.read(path).catch(() => ''))
113 const ran = await run()
114 const ticked = countTicks(await $.fs.read(path).catch(() => '')) - before
115
116 if (ticked > 0) {
117 await react($, config, { pose: 'cheer', xp: 5 * ticked, mood: 3 })
118 }
119
120 return ran
121}
122
123async function doze($: EngineInterface, config: Config, idleMs: number) {
124 const quietFor = (await $.clock.now()) - (await read($, lastActiveAt))
125
126 if (quietFor >= idleMs && (await read($, pose)) !== 'sleep') {
127 settle?.cancel()
128 await strike($, config, 'sleep')
129 }
130}
131
132/**
133 * One tick of Oyen's strolling: a step, the end of a stroll, or a tick of rest.
134 * Only an idle or playing Oyen moves; now and then a stroll starts as a game
135 * of chasing the ball, which ends with the stroll.
136 */
137async function stroll($: EngineInterface, config: Config) {
138 const now = await read($, walk)
139 const current = await read($, pose)
140
141 if (current !== 'idle' && current !== 'play') {
142 if (now.isWalking) {
143 stepsLeft = 0
144 await update($, walk, held => ({ ...held, isWalking: false }))
145 }
146
147 return
148 }
149
150 if (stepsLeft > 0) {
151 stepsLeft -= 1
152 await update($, walk, held => ({ step: held.step + 1, isWalking: true }))
153 } else if (now.isWalking) {
154 restLeft = restLength(Math.random())
155 await update($, walk, held => ({ ...held, isWalking: false }))
156
157 if (current === 'play') {
158 await strike($, config, 'idle')
159 }
160 } else if (restLeft > 0) {
161 restLeft -= 1
162 } else {
163 stepsLeft = strollLength(Math.random())
164
165 if (Math.random() < PLAY_CHANCE) {
166 await strike($, config, 'play')
167 }
168 }
169}
170
171export const register: Register = (on, options) => {
172 const config: Config = {
173 language: asLanguage(options.language),
174 isSoundOn: options.sound === true,
175 isWalkOn: options.walk !== false,
176 }
177 const idleMs = Number(options.idleMinutes ?? 10) * 60_000
178 const minColumns = Number(options.minColumns ?? 60)
179
180 on('session.start', async ($, e, next) => {
181 await $.command.register({
182 name: 'oyen',
183 description: 'Feed, pet or play with Oyen, or see how it is doing',
184 argumentHint: '[feed|pet|play]',
185 })
186
187 const now = await $.clock.now()
188 const loaded = visit(asStats(await $.store.get(STORE_KEY)), dayOf(now))
189 await $.store.set(STORE_KEY, loaded)
190 await update($, stats, () => loaded)
191 await update($, lastActiveAt, () => now)
192
193 $.clock.every(IDLE_CHECK_MS, () => void doze($, config, idleMs))
194
195 if (config.isWalkOn) {
196 $.clock.every(STEP_MS, () => void stroll($, config))
197 }
198
199 return next(e)
200 })
201
202 on('prompt.submit', async ($, e, next) => {
203 await touch($, config)
204
205 return next(e)
206 })
207
208 on('turn.complete', async ($, e, next) => {
209 const now = await $.clock.now()
210 await update($, lastActiveAt, () => now)
211
212 return next(e)
213 })
214
215 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
216 const ran = await next(e)
217 const kind = kindOf(e.command)
218 const hasErrored = ran.deny !== undefined || ran.isError === true
219 const hasFailed = hasErrored || (kind !== 'commit' && outputFailed(ran.text ?? ''))
220
221 if (kind === 'commit') {
222 await react($, config, hasFailed ? { pose: 'grumpy', mood: -5 } : { pose: 'happy', xp: 10, mood: 5, commits: 1 })
223 } else if (kind === 'test') {
224 await react($, config, hasFailed ? { pose: 'grumpy', mood: -2 } : { pose: 'cheer', xp: 5, mood: 3 })
225 } else if (kind === 'build' && hasFailed) {
226 await react($, config, { pose: 'hide', mood: -3 })
227 }
228
229 return ran
230 })
231
232 on('tool.call', { tool: 'Edit' }, ($, e, next) => watchTodo($, config, e.file_path, () => next(e)))
233
234 on('tool.call', { tool: 'Write' }, ($, e, next) => watchTodo($, config, e.file_path, () => next(e)))
235
236 on('command.run', { command: 'oyen' }, async ($, e) => {
237 await touch($, config)
238 const wish = e.args.trim().toLowerCase()
239
240 if (wish === 'feed' || wish === 'pet') {
241 const reaction: Reaction = wish === 'feed' ? { pose: 'eat', xp: 3, mood: 15 } : { pose: 'purr', xp: 2, mood: 8 }
242 const after = await react($, config, reaction)
243
244 if (wish === 'pet') {
245 meow($, config)
246 }
247
248 return { text: `Oyen: ${await read($, line)} ${moodBar(after.mood)}` }
249 }
250
251 if (wish === 'play') {
252 const after = await react($, config, { pose: 'play', xp: 1, mood: 5 })
253 stepsLeft = Math.max(stepsLeft, PLAY_STEPS)
254 restLeft = 0
255
256 return { text: `Oyen: ${await read($, line)} ${moodBar(after.mood)}` }
257 }
258
259 if (wish !== '') {
260 return { text: 'Usage: /oyen [feed|pet|play]' }
261 }
262
263 const now = await read($, stats)
264
265 return {
266 text: [
267 `Oyen, level ${now.level} (${now.xp}/${now.level * 50} xp)`,
268 `mood ${moodBar(now.mood)} ${now.mood}/100`,
269 `${now.commitsFed} commits fed, ${now.streak}-day streak`,
270 ].join('\n'),
271 }
272 })
273
274 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
275 const isHidden =
276 options.enabled === false ||
277 e.props.hasSurvey ||
278 e.props.bodyColumns < minColumns ||
279 e.props.maxRows < ROWS
280
281 if (isHidden) {
282 return next(e)
283 }
284
285 const now = await read($, pose)
286 const said = await read($, line)
287 const grown = await read($, stats)
288 const strolled = await read($, walk)
289 const track = config.isWalkOn ? trackOf(e.props.bodyColumns) : WIDTH
290 const { x, isFacingRight } = spotOf(strolled.step, track - WIDTH)
291 const look = { isFacingRight, isStepping: strolled.isWalking && strolled.step % 2 === 1 }
292 const { Box, Text } = $.ui.resolve(e)
293
294 let cat
295 if (e.surface === 'terminal') {
296 const { Raster } = $.ui.resolve(e)
297 cat = <Raster key={`oyen-${now}`} columns={WIDTH} rows={ROWS} cells={rasterOf(now, look)} />
298 } else {
299 const [ears, face, paws] = POSES[now]
300 cat = (
301 <Box flexDirection="column">
302 <Text color={ORANGE}>{ears}</Text>
303 <Text color={ORANGE}>{face}</Text>
304 <Text color={ORANGE}>{paws}</Text>
305 </Box>
306 )
307 }
308
309 return (
310 <Box flexDirection="row">
311 <Box width={track} flexShrink={0} marginRight={2}>
312 <Box key="oyen-spot" marginLeft={x}>
313 {cat}
314 </Box>
315 </Box>
316 <Box flexDirection="column" paddingTop={1}>
317 <Text>{said === '' ? '...' : said}</Text>
318 <Text dimColor>
319 Oyen · Lv {grown.level} · {moodBar(grown.mood)} · {grown.streak}d streak · {grown.commitsFed} commits
320 </Text>
321 </Box>
322 </Box>
323 )
324 })
325}
326hooks/cat.ts 132 lines1import type { Pose, Stats } from '../types'
2import type { Language } from './lines'
3import { LANGUAGES, LINES } from './lines'
4
5export type { Language } from './lines'
6
7export const POSES: Record<Pose, readonly [string, string, string]> = {
8 idle: [' /\\_/\\ ', '( o.o )', ' > ^ < '],
9 happy: [' /\\_/\\ ', '( ^.^ )', ' > ♥ < '],
10 grumpy: [' /\\_/\\ ', '( >.< )', ' > ~ < '],
11 sleep: [' /\\_/\\ z', '( -.- ) Z', ' > ^ < '],
12 cheer: ['\\/\\_/\\/', '( ^o^ )', ' / \\ '],
13 hide: [' ___ ', ' |. .| ', ' |___| '],
14 eat: [' /\\_/\\ ', '( ^~^ )', ' > ><> '],
15 purr: [' /\\_/\\ ', '( =w= )', ' > ^ < '],
16 play: [' /\\_/\\ o', '( ^o^ ) ', ' /> > '],
17}
18
19export function asLanguage(value: unknown): Language {
20 return LANGUAGES.find(language => language === value) ?? 'en'
21}
22
23export function lineFor(pose: Pose, language: Language, roll: number): string {
24 const lines = LINES[language][pose]
25
26 return lines[Math.floor(roll * lines.length) % lines.length] ?? '...'
27}
28
29export const FRESH: Stats = {
30 mood: 70,
31 xp: 0,
32 level: 1,
33 commitsFed: 0,
34 streak: 0,
35 lastDay: '',
36}
37
38export function asStats(value: unknown): Stats {
39 if (typeof value !== 'object' || value === null) {
40 return FRESH
41 }
42
43 return { ...FRESH, ...(value as Partial<Stats>) }
44}
45
46export function xpToLevel(level: number): number {
47 return level * 50
48}
49
50/** Adds xp and mood, levelling up as often as the xp allows. */
51export function grow(stats: Stats, xp: number, mood: number): { stats: Stats; hasLevelledUp: boolean } {
52 let next = {
53 ...stats,
54 xp: stats.xp + xp,
55 mood: Math.max(0, Math.min(100, stats.mood + mood)),
56 }
57 let hasLevelledUp = false
58
59 while (next.xp >= xpToLevel(next.level)) {
60 next = { ...next, xp: next.xp - xpToLevel(next.level), level: next.level + 1 }
61 hasLevelledUp = true
62 }
63
64 return { stats: next, hasLevelledUp }
65}
66
67export function dayOf(ms: number): string {
68 const date = new Date(ms)
69 const month = String(date.getMonth() + 1).padStart(2, '0')
70 const day = String(date.getDate()).padStart(2, '0')
71
72 return `${date.getFullYear()}-${month}-${day}`
73}
74
75function daysBetween(from: string, to: string): number {
76 return Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000)
77}
78
79/** Counts today in the streak; a gap resets it and costs some mood. */
80export function visit(stats: Stats, today: string): Stats {
81 if (stats.lastDay === today) {
82 return stats
83 }
84
85 const gap = stats.lastDay === '' ? 1 : daysBetween(stats.lastDay, today)
86 const streak = gap === 1 ? stats.streak + 1 : 1
87 const mood = Math.max(20, stats.mood - 5 * Math.max(0, gap - 1))
88
89 return { ...stats, streak, mood, lastDay: today }
90}
91
92export function moodBar(mood: number): string {
93 const hearts = Math.round(mood / 20)
94
95 return '♥'.repeat(hearts) + '♡'.repeat(5 - hearts)
96}
97
98const COMMIT = /(^|[\s;&|(])git\s+(?:-\S+\s+(?:\S+\s+)?)*commit\b/
99const TEST = /\b(test|tests|pytest|vitest|jest|phpunit|pest)\b/
100const BUILD = /\b(build|tsc)\b/
101
102export type CommandKind = 'commit' | 'test' | 'build' | 'other'
103
104export function kindOf(command: string): CommandKind {
105 if (COMMIT.test(command)) {
106 return 'commit'
107 }
108
109 if (TEST.test(command)) {
110 return 'test'
111 }
112
113 return BUILD.test(command) ? 'build' : 'other'
114}
115
116const FAILURE_OUTPUT = [
117 /\b[1-9]\d* (?:fail|failed|failing|failures?|errors?)\b/i, // "1 fail", "2 failed", never "0 fail"
118 /^\s*(?:FAIL|FAILED|FAILURES!)(?:\s|$)/m, // jest, pytest, phpunit
119 /\(fail\)/, // bun
120 /\berror TS\d+/, // tsc
121 /^\s*(?:npm (?:ERR!|error)|error[:\s])/im,
122]
123
124/** Whether a command's output reports a failure, for runs whose exit code a pipe hid. */
125export function outputFailed(text: string): boolean {
126 return FAILURE_OUTPUT.some(pattern => pattern.test(text))
127}
128
129export function countTicks(text: string): number {
130 return (text.match(/^\s*[-*]\s+\[[xX]\]/gm) ?? []).length
131}
132hooks/pixels.ts 186 lines1import type { Pose } from '../types'
2
3/**
4 * Oyen as pixel art: 28 x 10 pixels, drawn two pixels per terminal cell
5 * (a half block whose foreground paints the top pixel and background the
6 * bottom one), so 28 columns by 5 rows. Columns 0-5 hold a prop (Z's, a
7 * heart, a fish); columns 6-27 hold the cat: its head facing out, its body
8 * lying behind it and its tail curling up at the far end.
9 */
10export const WIDTH = 28
11export const HEIGHT = 10
12export const ROWS = HEIGHT / 2
13
14const DEFAULT = 0x01000000
15
16/** One letter per colour; `.` is see-through (the terminal's own background). */
17const PALETTE: Record<string, number> = {
18 o: 0xf28c28, // fur: one flat orange
19 k: 0x000000, // eyes and mouth
20 p: 0xf4a0b0, // heart
21 g: 0x4fb3a9, // ball
22 b: 0x8b5a2b, // box
23 B: 0x6b4320, // box rim
24 f: 0x6fa8dc, // fish
25 z: 0xbcd4ff, // sleep Z
26 y: 0xffd84a, // sparkles
27 r: 0xe0453a, // anger mark
28}
29
30type Cat = readonly string[]
31
32/**
33 * One row of the cat: a margin pixel, the head (9), the body (9) and the
34 * tail (3), so 22 pixels.
35 */
36function row(head: string, body = '.........', tail = '...'): string {
37 return `.${head}${body}${tail}`
38}
39
40const BACK = 'ooooooooo'
41
42const SITTING: Cat = [
43 row('.o.....o.'),
44 row('ooo...ooo'),
45 row('ooooooooo', '.........', '.o.'),
46 row('ooooooooo', '.........', '.o.'),
47 row('ookoookoo', BACK, '.o.'),
48 row('ooookoooo', BACK, '.o.'),
49 row('.oooooooo', BACK, 'oo.'),
50 row('.oooooooo', BACK, 'o..'),
51 row('.oooooooo', BACK, 'o..'),
52 row('.oo..oo..', '....oo.oo'),
53]
54
55/** The sitting cat with some rows' heads swapped: `{ 4: 'okokokoko' }`. */
56function face(heads: Readonly<Record<number, string>>): Cat {
57 return SITTING.map((line, at) => {
58 const head = heads[at]
59
60 return head === undefined ? line : `.${head}${line.slice(10)}`
61 })
62}
63
64const HAPPY_EYES = { 3: 'ookoookoo', 4: 'okokokoko' }
65const CLOSED_EYES = { 4: 'okkoookko' }
66const OPEN_MOUTH = { 6: '.oookoooo' }
67
68const CATS: Record<Pose, Cat> = {
69 idle: SITTING,
70 happy: face(HAPPY_EYES),
71 grumpy: face({ 0: '.........', 1: 'oo.....oo', 3: 'okoooooko' }),
72 sleep: face(CLOSED_EYES),
73 cheer: face({ ...HAPPY_EYES, ...OPEN_MOUTH }),
74 hide: [
75 '......................',
76 '......................',
77 row('.o.....o.'),
78 row('ooo...ooo', '...o.....'),
79 row('ooooooooo', '...o.....'),
80 row('ookoookoo', '...o.....'),
81 'BBBBBBBBBBBBBB........',
82 'bbbbbbbbbbbbbb........',
83 'bbbbbbbbbbbbbb........',
84 'bbbbbbbbbbbbbb........',
85 ],
86 eat: face({ ...HAPPY_EYES, ...OPEN_MOUTH }),
87 purr: face(CLOSED_EYES),
88 play: face({ ...HAPPY_EYES, ...OPEN_MOUTH }),
89}
90
91/** Paws mid-stride: front and back paws each a pixel further along. */
92const STRIDE = row('..oo..oo.', '...oo.oo.')
93
94/** Poses that move along the strip, and so take a stride on every other step. */
95const MOVING: readonly Pose[] = ['idle', 'play']
96
97/** The ball Oyen chases, on the ground and in the air, alternating each step. */
98const BALL_DOWN = ['......', '......', '......', '......', '......', '......', '..gg..', '.gggg.', '.gggg.', '..gg..']
99const BALL_UP = ['......', '......', '..gg..', '.gggg.', '.gggg.', '..gg..', '......', '......', '......', '......']
100
101const NOTHING = ['......', '......', '......', '......', '......', '......', '......', '......', '......', '......']
102
103const PROPS: Partial<Record<Pose, readonly string[]>> = {
104 sleep: ['zzzzz.', '...z..', '..z...', '.z....', 'zzzzz.', '......', '......', '......', '......', '......'],
105 purr: ['......', '.p.p..', 'ppppp.', '.ppp..', '..p...', '......', '......', '......', '......', '......'],
106 cheer: ['......', '.y....', 'yyy...', '.y..y.', '...yyy', '....y.', '......', '......', '......', '......'],
107 grumpy: ['......', '.r.r..', '..r...', '.r.r..', '......', '......', '......', '......', '......', '......'],
108 eat: ['......', '......', '......', '......', '......', '......', 'f..ff.', 'ffffkf', 'f..ff.', '......'],
109}
110
111/** Which way Oyen faces and whether it is mid-stride. */
112export type Look = { isFacingRight: boolean; isStepping: boolean }
113
114const STILL: Look = { isFacingRight: false, isStepping: false }
115
116function mirror(line: string): string {
117 return [...line].reverse().join('')
118}
119
120/**
121 * A pose's pixel rows. Facing left (as drawn), the prop is on the left and
122 * the cat's head leads; facing right, the cat is flipped and the prop moves
123 * to its other side, unflipped so a Z stays a Z.
124 */
125export function pixelRows(pose: Pose, look: Look = STILL): readonly string[] {
126 const prop = pose === 'play' ? (look.isStepping ? BALL_UP : BALL_DOWN) : (PROPS[pose] ?? NOTHING)
127 const isStriding = look.isStepping && MOVING.includes(pose)
128 const cat = isStriding ? [...CATS[pose].slice(0, 9), STRIDE] : CATS[pose]
129
130 return cat.map((line, at) => {
131 const beside = prop[at] ?? '......'
132
133 return look.isFacingRight ? `${mirror(line)}${beside}` : `${beside}${line}`
134 })
135}
136
137function colourOf(letter: string | undefined): number | undefined {
138 return letter === undefined || letter === '.' ? undefined : PALETTE[letter]
139}
140
141/** The cells of one pose: [codePoint, foreground, background] per cell, row-major. */
142export function cellsOf(pose: Pose, look: Look = STILL): number[] {
143 const rows = pixelRows(pose, look)
144 const cells: number[] = []
145
146 for (let row = 0; row < ROWS; row += 1) {
147 for (let column = 0; column < WIDTH; column += 1) {
148 const top = colourOf(rows[row * 2]?.[column])
149 const bottom = colourOf(rows[row * 2 + 1]?.[column])
150
151 if (top === undefined && bottom === undefined) {
152 cells.push(0x20, DEFAULT, DEFAULT)
153 } else if (top === undefined) {
154 cells.push(0x2584, bottom ?? DEFAULT, DEFAULT) // ▄
155 } else {
156 cells.push(0x2580, top, bottom ?? DEFAULT) // ▀
157 }
158 }
159 }
160
161 return cells
162}
163
164const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
165
166/** Packs u32 words little-endian and encodes them as padded base64, as Raster's `cells` takes them. */
167export function packCells(words: readonly number[]): string {
168 const bytes = words.flatMap(word => [word & 0xff, (word >>> 8) & 0xff, (word >>> 16) & 0xff, (word >>> 24) & 0xff])
169 let text = ''
170
171 for (let at = 0; at < bytes.length; at += 3) {
172 const [a = 0, b = 0, c = 0] = bytes.slice(at, at + 3)
173 const left = bytes.length - at
174 text += BASE64[a >> 2]
175 text += BASE64[((a & 3) << 4) | (b >> 4)]
176 text += left > 1 ? BASE64[((b & 15) << 2) | (c >> 6)] : '='
177 text += left > 2 ? BASE64[c & 63] : '='
178 }
179
180 return text
181}
182
183export function rasterOf(pose: Pose, look: Look = STILL): string {
184 return packCells(cellsOf(pose, look))
185}
186hooks/walk.ts 40 lines1import { WIDTH } from './pixels'
2
3/** How often Oyen takes a step while strolling. */
4export const STEP_MS = 300
5
6/** Room left beside the strip for the speech line and the stats row. */
7const TEXT_ROOM = 50
8
9/** The furthest Oyen strolls from its starting spot, in columns. */
10const MAX_SPAN = 30
11
12/** The strip Oyen strolls along: as wide as the band spares, never narrower than the cat. */
13export function trackOf(bodyColumns: number): number {
14 return Math.max(WIDTH, Math.min(WIDTH + MAX_SPAN, bodyColumns - TEXT_ROOM))
15}
16
17/**
18 * Where `step` puts Oyen on a strip with `span` columns to spare: out to the
19 * right, then back, then out again, facing the way it goes.
20 */
21export function spotOf(step: number, span: number): { x: number; isFacingRight: boolean } {
22 if (span <= 0) {
23 return { x: 0, isFacingRight: false }
24 }
25
26 const at = step % (2 * span)
27
28 return at < span ? { x: at, isFacingRight: true } : { x: 2 * span - at, isFacingRight: false }
29}
30
31/** A stroll of 8 to 32 steps. */
32export function strollLength(roll: number): number {
33 return 8 + Math.floor(roll * 25)
34}
35
36/** A rest of 10 to 40 ticks (3 to 12 seconds) between strolls. */
37export function restLength(roll: number): number {
38 return 10 + Math.floor(roll * 31)
39}
40hooks/lines.ts 243 lines1import type { Pose } from '../types'
2
3export const LANGUAGES = ['en', 'ms', 'id', 'zh-Hans', 'zh-Hant', 'ja'] as const
4
5export type Language = (typeof LANGUAGES)[number]
6
7export const LINES: Record<Language, Record<Pose, readonly string[]>> = {
8 en: {
9 idle: [
10 '...',
11 'mrrp?',
12 '*stares at cursor*',
13 '*tail swish*',
14 '*watches you type*',
15 'is that a bug? can i chase it?',
16 '*sits on the keyboard*',
17 '*knocks a pen off the desk*',
18 'what are we building?',
19 '*grooms paw*',
20 ],
21 happy: [
22 'nice commit!',
23 'purrfect.',
24 '*happy chirp*',
25 'that one shipped!',
26 'clean history, clean fur.',
27 'one more for the log!',
28 '*proud tail up*',
29 'good human.',
30 ],
31 grumpy: [
32 'hiss! commit blocked.',
33 'not like that.',
34 '*flattens ears*',
35 'read the rules, human.',
36 '*swats the commit away*',
37 'nope. try again.',
38 '*turns back on you*',
39 'that message? really?',
40 ],
41 sleep: ['zzz', '*snore*', '*twitch*', '*dreams of fish*', '*curled up*', '*paw over eyes*'],
42 cheer: [
43 'tests pass! yay!',
44 'ticked one off!',
45 'progress!',
46 'all green!',
47 '*zoomies*',
48 'we did a thing!',
49 '*victory loaf*',
50 'checklist go brrr',
51 ],
52 hide: [
53 'build broke... hiding.',
54 '*hides in box*',
55 'i saw nothing.',
56 'call me when it compiles.',
57 '*peeks out*',
58 'not my fault.',
59 ],
60 eat: ['nom nom', 'more fish pls', '*crunch crunch*', 'tuna!', '*licks bowl*', 'chef\'s kiss'],
61 purr: ['prrrr', '*headbutt*', '*slow blink*', '*kneads blanket*', '*rolls over*', 'again. again.'],
62 play: ['ball!', '*pounce*', 'mine mine mine', '*chases the ball*', 'catch me!', '*bats the ball*', 'zoom zoom', 'one more throw!'],
63 },
64 ms: {
65 idle: [
66 '...',
67 'meow?',
68 '*tenung kursor*',
69 '*goyang ekor*',
70 '*tengok kau menaip*',
71 'tu bug ke? boleh aku kejar?',
72 '*duduk atas keyboard*',
73 '*tolak pen jatuh meja*',
74 'kita buat apa hari ni?',
75 '*jilat kaki*',
76 ],
77 happy: [
78 'commit power!',
79 'syok!',
80 '*bunyi gembira*',
81 'dah ship!',
82 'history bersih, bulu pun bersih.',
83 'satu lagi masuk log!',
84 '*ekor tegak bangga*',
85 'pandai tuan aku.',
86 ],
87 grumpy: [
88 'hiss! commit kena block.',
89 'bukan macam tu lah.',
90 '*telinga leper*',
91 'baca rules dulu, bos.',
92 '*tampar commit*',
93 'tak boleh. cuba lagi.',
94 '*pusing belakang*',
95 'message macam tu? serius?',
96 ],
97 sleep: ['zzz', '*dengkur*', '*kejang sikit*', '*mimpi ikan*', '*tidur bergulung*', '*tutup mata dengan kaki*'],
98 cheer: [
99 'test lulus! yeay!',
100 'satu lagi siap!',
101 'mantap!',
102 'semua hijau!',
103 '*lari satu rumah*',
104 'kita berjaya!',
105 '*duduk menang*',
106 'checklist laju!',
107 ],
108 hide: [
109 'build rosak... sorok dulu.',
110 '*masuk kotak*',
111 'aku tak nampak apa-apa.',
112 'panggil aku bila dah compile.',
113 '*intai sikit*',
114 'bukan salah aku.',
115 ],
116 eat: ['nyam nyam', 'nak ikan lagi', '*kunyah kunyah*', 'tuna!', '*jilat mangkuk*', 'sedap gila'],
117 purr: ['prrrr', '*geselkan kepala*', '*kelip mata perlahan*', '*uli selimut*', '*golek-golek*', 'lagi. lagi.'],
118 play: ['bola!', '*terkam*', 'aku punya!', '*kejar bola*', 'tangkap aku!', '*tampar bola*', 'laju laju', 'baling lagi!'],
119 },
120 id: {
121 idle: [
122 '...',
123 'meong?',
124 '*menatap kursor*',
125 '*kibas ekor*',
126 '*melihat kamu mengetik*',
127 'itu bug? boleh aku kejar?',
128 '*duduk di atas keyboard*',
129 '*menjatuhkan pulpen dari meja*',
130 'kita bikin apa hari ini?',
131 '*menjilat kaki*',
132 ],
133 happy: [
134 'commit mantap!',
135 'asik!',
136 '*suara senang*',
137 'sudah rilis!',
138 'history bersih, bulu juga bersih.',
139 'satu lagi masuk log!',
140 '*ekor tegak bangga*',
141 'manusia pintar.',
142 ],
143 grumpy: [
144 'hiss! commit diblokir.',
145 'bukan begitu.',
146 '*telinga turun*',
147 'baca aturannya dulu.',
148 '*tepis commit*',
149 'tidak. coba lagi.',
150 '*balik badan*',
151 'pesan seperti itu? serius?',
152 ],
153 sleep: ['zzz', '*ngorok*', '*kedut*', '*mimpi ikan*', '*tidur melingkar*', '*tutup mata pakai kaki*'],
154 cheer: [
155 'tes lulus! hore!',
156 'satu lagi beres!',
157 'maju terus!',
158 'semua hijau!',
159 '*lari keliling rumah*',
160 'kita berhasil!',
161 '*duduk menang*',
162 'checklist ngebut!',
163 ],
164 hide: [
165 'build rusak... sembunyi dulu.',
166 '*masuk kardus*',
167 'aku tidak lihat apa-apa.',
168 'panggil aku kalau sudah compile.',
169 '*mengintip*',
170 'bukan salahku.',
171 ],
172 eat: ['nyam nyam', 'minta ikan lagi', '*kriuk kriuk*', 'tuna!', '*jilat mangkuk*', 'enak banget'],
173 purr: ['prrrr', '*sundul kepala*', '*kedip pelan*', '*uleni selimut*', '*guling-guling*', 'lagi. lagi.'],
174 play: ['bola!', '*terkam*', 'punyaku!', '*kejar bola*', 'tangkap aku!', '*pukul bola*', 'wuss wuss', 'lempar lagi!'],
175 },
176 'zh-Hans': {
177 idle: [
178 '……',
179 '喵?',
180 '*盯着光标*',
181 '*甩尾巴*',
182 '*看你打字*',
183 '那是 bug 吗?我能抓吗?',
184 '*坐在键盘上*',
185 '*把笔推下桌子*',
186 '今天写什么?',
187 '*舔爪子*',
188 ],
189 happy: ['提交成功!', '完美喵。', '*开心地叫*', '上线啦!', '历史干净,毛也干净。', '又记一笔!', '*骄傲地竖尾巴*', '好人类。'],
190 grumpy: ['嘶!提交被拦了。', '不是这样的。', '*耳朵压平*', '先看规则,人类。', '*一爪拍飞提交*', '不行,再来。', '*转身不理你*', '这提交信息?认真的?'],
191 sleep: ['zzz', '*打呼噜*', '*抽动胡须*', '*梦到鱼*', '*蜷成一团*', '*用爪子捂眼睛*'],
192 cheer: ['测试通过!耶!', '又勾掉一项!', '有进展!', '全绿了!', '*满屋疯跑*', '我们做到了!', '*胜利揣手*', '清单冲冲冲!'],
193 hide: ['构建坏了……先躲起来。', '*躲进纸箱*', '我什么都没看见。', '编译好了再叫我。', '*偷偷探头*', '不关我的事。'],
194 eat: ['吧唧吧唧', '还要鱼', '*咔嚓咔嚓*', '金枪鱼!', '*舔碗*', '好吃!'],
195 purr: ['呼噜呼噜', '*用头蹭你*', '*慢慢眨眼*', '*踩奶*', '*打滚*', '再摸,再摸。'],
196 play: ['球!', '*扑过去*', '我的我的', '*追着球跑*', '来抓我呀!', '*拍球*', '冲冲冲', '再扔一次!'],
197 },
198 'zh-Hant': {
199 idle: [
200 '……',
201 '喵?',
202 '*盯著游標*',
203 '*甩尾巴*',
204 '*看你打字*',
205 '那是 bug 嗎?我能抓嗎?',
206 '*坐在鍵盤上*',
207 '*把筆推下桌子*',
208 '今天寫什麼?',
209 '*舔爪子*',
210 ],
211 happy: ['提交成功!', '完美喵。', '*開心地叫*', '上線啦!', '歷史乾淨,毛也乾淨。', '又記一筆!', '*驕傲地豎尾巴*', '好人類。'],
212 grumpy: ['嘶!提交被擋了。', '不是這樣的。', '*耳朵壓平*', '先看規則,人類。', '*一爪拍飛提交*', '不行,再來。', '*轉身不理你*', '這提交訊息?認真的?'],
213 sleep: ['zzz', '*打呼嚕*', '*抽動鬍鬚*', '*夢到魚*', '*蜷成一團*', '*用爪子摀眼睛*'],
214 cheer: ['測試通過!耶!', '又勾掉一項!', '有進展!', '全綠了!', '*滿屋亂跑*', '我們做到了!', '*勝利母雞蹲*', '清單衝衝衝!'],
215 hide: ['建置壞了……先躲起來。', '*躲進紙箱*', '我什麼都沒看見。', '編譯好了再叫我。', '*偷偷探頭*', '不關我的事。'],
216 eat: ['吧唧吧唧', '還要魚', '*喀滋喀滋*', '鮪魚!', '*舔碗*', '好吃!'],
217 purr: ['呼嚕呼嚕', '*用頭蹭你*', '*慢慢眨眼*', '*踏踏*', '*打滾*', '再摸,再摸。'],
218 play: ['球!', '*撲過去*', '我的我的', '*追著球跑*', '來抓我呀!', '*拍球*', '衝衝衝', '再丟一次!'],
219 },
220 ja: {
221 idle: [
222 '……',
223 'にゃ?',
224 '*カーソルをじっと見る*',
225 '*しっぽをふりふり*',
226 '*タイピングを見守る*',
227 'それバグ?追いかけていい?',
228 '*キーボードの上に座る*',
229 '*ペンを机から落とす*',
230 '今日は何を作るの?',
231 '*前足をなめる*',
232 ],
233 happy: ['いいコミット!', 'にゃいす。', '*うれしい声*', 'リリースだ!', '履歴もきれい、毛並みもきれい。', 'またひとつ記録!', '*しっぽピーン*', 'えらいぞ人間。'],
234 grumpy: ['シャー!コミット拒否。', 'そうじゃない。', '*イカ耳*', 'ルールを読んでね。', '*コミットをはたく*', 'だめ。やり直し。', '*そっぽを向く*', 'そのメッセージ?本気?'],
235 sleep: ['zzz', '*いびき*', '*ひげがぴくっ*', '*魚の夢*', '*アンモニャイト*', '*前足で目をかくす*'],
236 cheer: ['テスト通過!やった!', 'ひとつ完了!', '進んでる!', 'オールグリーン!', '*運動会*', 'できたね!', '*勝利の香箱座り*', 'チェックリスト爆速!'],
237 hide: ['ビルド壊れた……隠れる。', '*箱に入る*', '何も見てない。', 'コンパイルできたら呼んで。', '*ちらっ*', 'ぼくのせいじゃない。'],
238 eat: ['もぐもぐ', 'お魚もっと', '*カリカリ*', 'マグロ!', '*お皿をなめる*', 'うまい!'],
239 purr: ['ゴロゴロ', '*頭すりすり*', '*ゆっくりまばたき*', '*ふみふみ*', '*ごろーん*', 'もっと。もっと。'],
240 play: ['ボール!', '*とびかかる*', 'ぼくの!', '*ボールを追いかける*', 'つかまえてみて!', '*ちょいちょい*', 'ダッシュ!', 'もう一回投げて!'],
241 },
242}
243types/index.d.ts 38 lines1export type Pose =
2 | 'idle'
3 | 'happy'
4 | 'grumpy'
5 | 'sleep'
6 | 'cheer'
7 | 'hide'
8 | 'eat'
9 | 'purr'
10 | 'play'
11
12export type Stats = {
13 mood: number
14 xp: number
15 level: number
16 commitsFed: number
17 streak: number
18 lastDay: string
19}
20
21/** How far Oyen has strolled (steps taken since the session began) and whether it is mid-stroll. */
22export type Walk = {
23 step: number
24 isWalking: boolean
25}
26
27declare module 'claude-code' {
28 interface PluginState {
29 oyen: {
30 pose: Pose
31 line: string
32 stats: Stats
33 lastActiveAt: number
34 walk: Walk
35 }
36 }
37}
38