10x8 pixel Chiikawa above the prompt that moves while working and reacts with moods

Claude Code 입력 창 위, 작은 풀밭에 치이카와가 살아요.
코딩하는 동안 옆에서 산책하고, 숨 쉬고, 같이 열일하고, 끝나면 기뻐해 줘요.
<img src="assets/stroll.gif" alt="치이카와가 입력 창 위 풀밭을 깡총깡총 산책하는 모습" width="720">
비영리 목적의 팬 프로젝트 (Non-commercial fan project) 치이카와를 좋아하는 마음으로 만든 비공식 팬 프로젝트예요. 상업적으로 이용하지 않으며, 치이카와의 모든 권리는 나가노 및 권리자에게 있어요.
| 🌱 풀밭 위 산책 | 쉬는 동안 풀밭을 깡총깡총 걸어 다니다가 멈춰서 숨을 골라요. 눈과 입은 걸어가는 쪽을 봐요. |
| 💨 숨 쉬는 모션 | 가만히 있을 때는 발을 땅에 붙인 채 배가 부풀었다 줄었다 해요. |
| 💪 같이 열일 | Claude가 일하는 동안 제자리에서 빠르게 숨 쉬며 열일 중...을 띄워요. |
| 🎭 표정 7가지 | 요청을 보내면 신나 하고, 끝나면 행복해하고, 오래 걸리면 지치고, 오류가 나면 눈물을 흘리며 걱정해요. |
| 🍙 먹는 얘기엔 반응 | 밥·커피·간식 얘기를 하면 맛있는거 최고! |
| 😴 낮잠 | 5분 동안 조용하면 파란 이불을 덮고 잠들어요. |
| 🌗 테마 맞춤 | 풀밭은 쨍하지 않은 연한 색으로, 라이트/다크 테마 배경에 맞춰 섞여요. |
요청을 보내면 → 신나! → 열일 중... → 행복해!
<img src="assets/working.gif" alt="요청을 보내자 신나 하다가, 열일 중 동안 숨 쉬고, 끝나자 행복해하는 치이카와" width="720">
<img src="assets/moods.png" alt="평소, 행복해, 신나, 맛있는거 최고, 지쳐, 걱정돼, 잘자 표정" width="100%">
/chiikawa <표정>으로 직접 바꿀 수도 있어요.
/chiikawa happy /chiikawa excited /chiikawa yummy
/chiikawa tired /chiikawa worried /chiikawa sleepy /chiikawa idle
sleepy와 idle은 다른 표정으로 바뀔 때까지 유지되고, 나머지는 몇 초 뒤 평소 모습으로 돌아와요.
Claude Code 터미널 세션의 입력 창에서:
/plugin install chiikawa --marketplace BBAAMM/claude-chiikawa
Add marketplace?에 y를 누르고, 설치 범위는 맨 위의 user를 Enter로 고르면 바로 치이카와가 나타나요.
셸에서 설치하려면:
claude plugin install chiikawa --marketplace BBAAMM/claude-chiikawa
데스크톱 앱에서는 (/plugin이 동작하지 않아요) 입력 창 옆 + → Plugins → Add marketplace에 BBAAMM/claude-chiikawa 입력 → Add plugin.
◐ medium · /effort 줄과 입력 창 테두리는 Claude Code가 직접 그리는 부분이라 바뀌지 않아요.A tiny Chiikawa living on a strip of soft grass right above your Claude Code prompt.
Non-commercial fan project. Made out of love for Chiikawa. It is unofficial and not used for any commercial purpose.
열일 중... ("hard at work...") caption while Claude is busy.맛있는거 최고! ("yummy is the best!").Set a mood by hand with /chiikawa <mood>: idle, happy, excited, yummy, tired, worried, sleepy.
At the prompt of a Claude Code terminal session:
/plugin install chiikawa --marketplace BBAAMM/claude-chiikawa
Answer y to add the marketplace, then press Enter for the user scope.
From a shell:
claude plugin install chiikawa --marketplace BBAAMM/claude-chiikawa
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with BBAAMM/claude-chiikawa, then Add plugin.
Claude Code の入力欄の上、小さな草原にちいかわが住んでいます。
非営利のファンプロジェクトです。 ちいかわが大好きな気持ちで作った非公式のファンメイド作品で、商用利用は一切していません。ちいかわに関するすべての権利はナガノ先生および権利者に帰属します。
열일 중...(「お仕事中…」)と表示します。맛있는거 최고!(「おいしいもの最高!」)。/chiikawa <表情> で表情を手動で切り替えられます: idle, happy, excited, yummy, tired, worried, sleepy
Claude Code のターミナルセッションの入力欄で:
/plugin install chiikawa --marketplace BBAAMM/claude-chiikawa
Add marketplace? に y を入力し、インストール範囲は一番上の user を Enter で選ぶと、すぐにちいかわが現れます。
シェルからインストールする場合:
claude plugin install chiikawa --marketplace BBAAMM/claude-chiikawa
デスクトップアプリでは(/plugin は動作しません)、入力欄の横の + → Plugins → Add marketplace に BBAAMM/claude-chiikawa を入力 → Add plugin。
<sub>The images above are rendered from the plugin's own sprite code on a dark theme. Non-commercial fan project: fan-made pixel art, unofficial and not for profit. Chiikawa belongs to Nagano and its rights holders. 이 프로젝트는 비영리 목적의 비공식 팬 프로젝트이며, 치이카와의 권리는 나가노 및 권리자에게 있어요.</sub>
hooks/register.tsx 202 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Mood } from '../types'
5import { CAPTIONS, MOODS, isFoodTalk } from './moods'
6import { groundRows, grassPalette } from './ground'
7import { PALETTE, spriteFor, toCells } from './sprite'
8import { START_POSE, stepIdle } from './walk'
9
10const mood = atom({ plugin: 'chiikawa', key: 'mood' } as const, 'idle')
11// When the shown mood ends, kept in state so it survives a reload; null holds it until changed.
12const moodUntil = atom({ plugin: 'chiikawa', key: 'moodUntil' } as const, null as number | null)
13const frame = atom({ plugin: 'chiikawa', key: 'frame' } as const, 0)
14const pose = atom({ plugin: 'chiikawa', key: 'pose' } as const, START_POSE)
15
16const TICK_MS = 250
17const MOOD_MS = 4000
18const SLEEP_MS = 5 * 60 * 1000
19const LONG_TURN_MS = 2 * 60 * 1000
20const SPRITE_COLUMNS = 11
21const CAPTION_COLUMNS = 16
22
23let ticker: Timer | undefined
24let sleepTimer: Timer | undefined
25let isWorking = false
26let startedAt = 0
27let palette: Record<string, string> | undefined
28
29// Mixes the grass into the theme's background once, and again when the theme changes.
30async function loadPalette($: EngineInterface) {
31 const theme = (await $.config.list()).find(row => row.key === 'theme')
32 palette = { ...PALETTE, ...grassPalette(String(theme?.value ?? '').includes('light')) }
33}
34
35// Shows a mood for a while; the ticker puts it back to idle once it ends.
36async function feel($: EngineInterface, next: Mood, ms: number | null = MOOD_MS) {
37 const until = ms === null ? null : (await $.clock.now()) + ms
38 await update($, moodUntil, () => until)
39 await update($, mood, () => next)
40}
41
42// Dozes off under the blanket after a quiet while.
43function scheduleSleep($: EngineInterface) {
44 sleepTimer?.cancel()
45 sleepTimer = $.clock.after(SLEEP_MS, () => void feel($, 'sleepy', null))
46}
47
48// Keeps the breathing clock running, and strolls while idle.
49async function tick($: EngineInterface) {
50 const until = await read($, moodUntil)
51 let current = await read($, mood)
52 const isOver = until === null ? current !== 'sleepy' : (await $.clock.now()) >= until
53 if (current !== 'idle' && isOver) {
54 await feel($, 'idle', null)
55 current = 'idle'
56 }
57
58 await update($, frame, n => (n + 1) % 1200)
59 if (!isWorking && current === 'idle') {
60 await update($, pose, stepIdle)
61 }
62}
63
64function startTicker($: EngineInterface) {
65 ticker?.cancel()
66 ticker = $.clock.every(TICK_MS, () => void tick($).catch(() => undefined))
67}
68
69export const register: Register = on => {
70 on('session.start', async ($, e, next) => {
71 await $.command.register({
72 name: 'chiikawa',
73 description: '치이카와의 표정을 바꿔요 (happy, excited, tired, worried, yummy, sleepy)',
74 })
75 startTicker($)
76 scheduleSleep($)
77
78 return next(e)
79 })
80
81 on('command.run', { command: 'chiikawa' }, async ($, e) => {
82 const picked = e.args.trim() as Mood
83 if (!MOODS.includes(picked)) {
84 return { text: `사용법: /chiikawa <${MOODS.join(' | ')}>` }
85 }
86 sleepTimer?.cancel()
87 await feel($, picked, picked === 'sleepy' || picked === 'idle' ? null : MOOD_MS)
88 scheduleSleep($)
89
90 return { text: `치이카와: ${CAPTIONS[picked] || '...'}` }
91 })
92
93 on('prompt.submit', async ($, e, next) => {
94 sleepTimer?.cancel()
95 isWorking = true
96 await $.clock
97 .now()
98 .then(now => {
99 startedAt = now
100 return feel($, isFoodTalk(e.text) ? 'yummy' : 'excited')
101 })
102 .catch(() => undefined)
103
104 return next(e)
105 })
106
107 on('tool.call', async ($, e, next) => {
108 const ran = await next(e)
109 if (ran.isError) {
110 await feel($, 'worried').catch(() => undefined)
111 }
112
113 return ran
114 })
115
116 on('turn.complete', async ($, e, next) => {
117 isWorking = false
118 await update($, frame, () => 0)
119 const tookMs = (await $.clock.now()) - startedAt
120 if (e.reason === 'answer') {
121 await feel($, tookMs > LONG_TURN_MS ? 'tired' : 'happy')
122 } else {
123 await feel($, 'worried')
124 }
125 scheduleSleep($)
126
127 return next(e)
128 })
129
130 on('config.set', { key: 'theme' }, async ($, e, next) => {
131 const result = await next(e)
132 palette = undefined
133
134 return result
135 })
136
137 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
138 if (e.props.hasSurvey || e.props.maxRows < 5) {
139 return next(e)
140 }
141
142 // A reload mid-session may skip session.start; drawing restarts the ticker too.
143 isWorking = e.props.isWorking
144 if (!ticker) startTicker($)
145
146 if (!palette) await loadPalette($).catch(() => undefined)
147 const current = await read($, mood)
148 const ticks = await read($, frame)
149 const where = await read($, pose)
150 const working = e.props.isWorking
151 const strolling = current === 'idle' && !working
152 const isWalking = strolling && where.mode === 'walk'
153 // Ticks per breath half: quick while working, slow while asleep.
154 const pace = working ? 2 : current === 'sleepy' ? 6 : 4
155 const breath = isWalking ? 0 : ((Math.floor(ticks / pace) % 2) as 0 | 1)
156
157 const sprite = spriteFor(current, {
158 isWalking,
159 frame: where.step,
160 facing: strolling ? where.facing : undefined,
161 lift: isWalking ? where.lift : 0,
162 breath,
163 })
164 const dots = '.'.repeat((Math.floor(ticks / 2) % 3) + 1)
165 const caption = CAPTIONS[current] || (working ? `열일 중${dots}` : isWalking ? '♪' : '')
166 const room = Math.max(0, e.props.bodyColumns - SPRITE_COLUMNS - CAPTION_COLUMNS)
167 const x = Math.min(where.x, room)
168 const cells = toCells(sprite.slice(0, 8), palette)
169 const ground = toCells(groundRows(sprite[8]!, x, e.props.bodyColumns), palette)[0]!
170 const { Box, Text } = $.ui.resolve(e)
171
172 return (
173 <Box flexDirection="column">
174 <Box flexDirection="row">
175 <Box width={x} flexShrink={0} />
176 <Box flexDirection="column" flexShrink={0}>
177 {cells.map((row, y) => (
178 <Text key={`r${y}`}>
179 {row.map((cell, i) => (
180 <Text key={`c${i}`} color={cell.color} backgroundColor={cell.backgroundColor}>
181 {cell.char}
182 </Text>
183 ))}
184 </Text>
185 ))}
186 </Box>
187 <Box flexDirection="column" justifyContent="center" paddingLeft={1}>
188 <Text dimColor>{caption}</Text>
189 </Box>
190 </Box>
191 <Text>
192 {ground.map((cell, i) => (
193 <Text key={`g${i}`} color={cell.color} backgroundColor={cell.backgroundColor}>
194 {cell.char}
195 </Text>
196 ))}
197 </Text>
198 </Box>
199 )
200 })
201}
202hooks/moods.ts 21 lines1import type { Mood } from '../types'
2
3// Captions from the character sheet's 다양한 표정.
4export const CAPTIONS: Record<Mood, string> = {
5 idle: '',
6 happy: '행복해!',
7 excited: '신나!',
8 tired: '지쳐...',
9 worried: '걱정돼...',
10 yummy: '맛있는거 최고!',
11 sleepy: '잘자... zZ',
12}
13
14export const MOODS = Object.keys(CAPTIONS) as Mood[]
15
16const FOOD = /밥|먹|맛있|간식|배고|커피|버블티|라면|피자|치킨|food|eat|snack|lunch|dinner|coffee/i
17
18export function isFoodTalk(text: string): boolean {
19 return FOOD.test(text)
20}
21hooks/ground.ts 37 lines1// A soft strip of grass along the band's bottom edge, right above the engine's own line.
2// Terminals have no alpha, so the grass greens are mixed into the theme's background.
3const DARK_BACKGROUND = '#1e1e1e'
4const LIGHT_BACKGROUND = '#ffffff'
5const GRASS = '#9fd47f'
6const GRASS_SHADE = '#7fbf6a'
7export const GRASS_ALPHA = 0.28
8
9function mix(color: string, under: string, alpha: number): string {
10 const channel = (hex: string, i: number) => parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16)
11 const out = [0, 1, 2].map(i => Math.round(channel(color, i) * alpha + channel(under, i) * (1 - alpha)))
12 return `#${out.map(n => n.toString(16).padStart(2, '0')).join('')}`
13}
14
15// g and h are the grass's two tones; t is a taller tuft.
16export function grassPalette(isLight: boolean): Record<string, string> {
17 const under = isLight ? LIGHT_BACKGROUND : DARK_BACKGROUND
18 return {
19 g: mix(GRASS, under, GRASS_ALPHA),
20 h: mix(GRASS_SHADE, under, GRASS_ALPHA),
21 t: mix(GRASS, under, GRASS_ALPHA * 0.8),
22 }
23}
24
25// The bottom two pixel rows across the band: the feet with a tuft here and there, then grass.
26// Patterns follow the column, so the grass stays put while Chiikawa walks over it.
27export function groundRows(feet: string, x: number, width: number): [string, string] {
28 let top = ''
29 let grass = ''
30 for (let col = 0; col < width; col++) {
31 const pixel = col >= x && col < x + feet.length ? feet[col - x]! : '.'
32 top += pixel !== '.' ? pixel : col % 7 === 3 || col % 11 === 8 ? 't' : '.'
33 grass += col % 5 === 2 || col % 3 === 0 ? 'h' : 'g'
34 }
35 return [top, grass]
36}
37hooks/sprite.ts 116 lines1import type { Facing, Mood } from '../types'
2
3// 10x8 Chiikawa: a round white body with little bear ears, no outline. One character per pixel; '.' is transparent.
4export const PALETTE: Record<string, string> = {
5 W: '#ffffff', // body
6 K: '#2a2a2a', // eyes
7 k: '#8a8a8a', // closed or tired eyes, small mouth
8 P: '#f5a3b5', // cheeks
9 M: '#e8607a', // mouth
10 Y: '#ffd84a', // sparkly eyes
11 S: '#7ec8f0', // tears
12 B: '#8fa8e8', // blanket
13 b: '#5f78c0', // blanket shade
14}
15
16export const SPRITE = [
17 '..W....W..',
18 '.WWWWWWWW.',
19 'WWKWWWWKWW',
20 'WPWWkkWWPW',
21 'WWWWWWWWWW',
22 '.WWWWWWWW.',
23 '..WWWWWW..',
24 '...W..W...',
25]
26
27// Rows 1-4 (the face) per mood; the ears stay the same. The middle rows bulge a pixel each side so the cheeks round out.
28const FACES: Record<Exclude<Mood, 'sleepy'>, string[]> = {
29 idle: ['.WWWWWWWW.', 'WWKWWWWKWW', 'WPWWkkWWPW', 'WWWWWWWWWW'],
30 happy: ['.WWWWWWWW.', 'WWKWWWWKWW', 'WPWWMMWWPW', 'WWWWMMWWWW'],
31 excited: ['.WWWWWWWW.', 'WWYWWWWYWW', 'WPWWMMWWPW', 'WWWWMMWWWW'],
32 tired: ['.WWWWWWWW.', 'WWkWWWWkWW', 'WWWWWWWWWW', 'WWWWkkWWWW'],
33 // 까-앙: tears running down under each eye.
34 worried: ['.WWWWWWWW.', 'WWKWWWWKWW', 'WPSWMMWSPW', 'WWSWWWWSWW'],
35 yummy: ['.WWWWWWWW.', 'WWkWWWWkWW', 'WPWWMMWWPW', 'WWWWMMWWWW'],
36}
37
38// Idle eyes and mouth glance the way the stroll goes.
39const IDLE_EYES: Record<Facing, string> = { 1: 'WWWKWWWWKW', [-1]: 'WKWWWWKWWW' }
40const IDLE_MOUTH: Record<Facing, string> = { 1: 'WPWWWkkWPW', [-1]: 'WPWkkWWWPW' }
41
42// 잘자...: tucked into a blue blanket, eyes closed.
43const SLEEPY = [
44 '..........',
45 '..W....W..',
46 '.WWWWWWWW.',
47 'WWkWWWWkWW',
48 'WPWWWWWWPW',
49 'bBBBBBBBBb',
50 'BBBBBBBBBB',
51 '.bbbbbbbb.',
52]
53
54// Breathing in stretches the belly a row, so the head rises while the feet stay put.
55const BELLY = { body: '.WWWWWWWW.', sleepy: 'BBBBBBBBBB' }
56
57const FEET = ['...W..W...', '..W....W..']
58const BLANK = '...........'
59
60export type SpriteOptions = {
61 // Strolling: shuffle the feet and glance toward `facing`.
62 isWalking?: boolean
63 frame?: number
64 facing?: Facing
65 // Raised a pixel, feet and all: a hop while walking.
66 lift?: 0 | 1
67 // Breathing in: the body grows a row with the feet planted.
68 breath?: 0 | 1
69}
70
71// Picks the pixels for a mood on an 11x9 canvas, so the band never changes size.
72export function spriteFor(mood: Mood, options: SpriteOptions = {}): string[] {
73 const { isWalking = false, frame = 0, facing, lift = 0, breath = 0 } = options
74 let body: string[]
75
76 if (mood === 'sleepy') {
77 body = [...SLEEPY]
78 } else {
79 const face = [...FACES[mood]]
80 if (mood === 'idle' && facing !== undefined) {
81 face[1] = IDLE_EYES[facing]
82 face[2] = IDLE_MOUTH[facing]
83 }
84 body = [SPRITE[0]!, ...face, SPRITE[5]!, SPRITE[6]!, FEET[isWalking ? frame % 2 : 0]!]
85 }
86
87 if (breath) body.splice(6, 0, mood === 'sleepy' ? BELLY.sleepy : BELLY.body)
88 const rows = body.map(row => `${row}.`)
89
90 // Feet sit on the last pixel row, where the grass row below picks them up.
91 if (breath) return rows
92 return lift ? [...rows, BLANK] : [BLANK, ...rows]
93}
94
95export type Cell = { char: string; color?: string; backgroundColor?: string }
96
97// Packs two pixel rows into one terminal row with half blocks, so pixels stay square.
98export function toCells(sprite: string[] = SPRITE, palette: Record<string, string> = PALETTE): Cell[][] {
99 const rows: Cell[][] = []
100 for (let y = 0; y < sprite.length; y += 2) {
101 const top = sprite[y] ?? ''
102 const bottom = sprite[y + 1] ?? ''
103 const row: Cell[] = []
104 for (let x = 0; x < top.length; x++) {
105 const t = palette[top[x]!]
106 const b = palette[bottom[x] ?? '.']
107 if (t && b) row.push({ char: '▀', color: t, backgroundColor: b })
108 else if (t) row.push({ char: '▀', color: t })
109 else if (b) row.push({ char: '▄', color: b })
110 else row.push({ char: ' ' })
111 }
112 rows.push(row)
113 }
114 return rows
115}
116hooks/walk.ts 32 lines1import type { Pose } from '../types'
2
3export const WALK_RANGE = 24
4
5export const START_POSE: Pose = { x: 0, facing: 1, mode: 'rest', step: 0, lift: 0, ticksLeft: 8, tick: 0 }
6
7function between(rand: () => number, min: number, max: number): number {
8 return min + Math.floor(rand() * (max - min + 1))
9}
10
11// One tick of the idle stroll: hop a cell per step, then stop a while (the drawing breathes).
12export function stepIdle(pose: Pose, rand: () => number = Math.random, range = WALK_RANGE): Pose {
13 const tick = pose.tick + 1
14
15 if (pose.ticksLeft <= 0) {
16 if (pose.mode === 'walk') {
17 return { ...pose, mode: 'rest', step: 0, lift: 0, ticksLeft: between(rand, 12, 28), tick }
18 }
19 const facing = pose.x <= 0 ? 1 : pose.x >= range ? -1 : rand() < 0.5 ? 1 : -1
20 return { ...pose, mode: 'walk', facing, ticksLeft: between(rand, 6, 16), tick }
21 }
22
23 if (pose.mode === 'rest') {
24 return { ...pose, ticksLeft: pose.ticksLeft - 1, tick }
25 }
26
27 const facing = (pose.x + pose.facing < 0 || pose.x + pose.facing > range ? -pose.facing : pose.facing) as Pose['facing']
28 const step = (pose.step ^ 1) as 0 | 1
29
30 return { ...pose, x: pose.x + facing, facing, step, lift: step, ticksLeft: pose.ticksLeft - 1, tick }
31}
32types/index.d.ts 21 lines1export type Mood = 'idle' | 'happy' | 'excited' | 'tired' | 'worried' | 'yummy' | 'sleepy'
2
3export type Facing = 1 | -1
4
5// Where the idle stroll is: walking hops along, resting breathes in place.
6export type Pose = {
7 x: number
8 facing: Facing
9 mode: 'walk' | 'rest'
10 step: 0 | 1
11 lift: 0 | 1
12 ticksLeft: number
13 tick: number
14}
15
16declare module 'claude-code' {
17 interface PluginState {
18 'chiikawa': { mood: Mood; moodUntil: number | null; frame: number; pose: Pose }
19 }
20}
21