SLOPSHOPPER

chiikawa

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

bandguardcommandprompttimer
v0.1.0no licenseupdated 2026-10-18BBAAMM/claude-chiikawa
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · chiikawa
› 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 › /chiikawa ⎿ chiikawa: 사용법: /chiikawa <idle | happy | excited | tired | worried | yummy | sleepy> ▄ ▄ 행복해! ▄▀▀▀▀▀▀▀▀▄ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀ ▄▄▄▀▄▄▀▄▀▄▀▄▄▄▄▄▄▀▄▀▄▄▄▄▀▄▄▄▄▄▀▀▄▄▄▄▄▄▀▄▄▀▄▄▄▀▄▄▄▄▄▄▀▄▄▄▄▄▄▀▄▄▄▀▄▄▀▄▄▄▄▄▄▀▀▄▄▄▄▄▀▄▄▄▄▀▄▀▄▄▄▄▄▄▀▄▀▄▄▄ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▄ ▄ 행복해! ▄▀▀▀▀▀▀▀▀▄ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀ ▄▄▄▀▄▄▀▄▀▄▀▄▄▄▄▄▄▀▄▀▄▄▄▄▀▄▄▄▄▄▀▀▄▄▄▄▄▄▀▄▄▀▄▄▄▀▄▄▄▄▄▄▀▄▄▄▄▄▄▀▄▄▄▀▄▄▀▄▄▄▄▄▄▀▀▄▄▄▄▄▀▄▄▄▄▀▄▀▄▄▄▄▄▄▀▄▀▄▄▄
README

🤍 치이카와 (Chiikawa · ちいかわ) for Claude Code

Claude Code 입력 창 위, 작은 풀밭에 치이카와가 살아요.

코딩하는 동안 옆에서 산책하고, 숨 쉬고, 같이 열일하고, 끝나면 기뻐해 줘요.

한국어 · English · 日本語

<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가 직접 그리는 부분이라 바뀌지 않아요.
  • 풀밭 색은 터미널이 투명도를 지원하지 않아서, 테마 배경색과 미리 섞은 색이에요.
  • 트루컬러를 지원하는 터미널에서 가장 예뻐요.

English

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.

What it does

  • 🌱 Strolls along the grass while you're idle, hopping a cell at a time, its eyes and mouth glancing the way it walks.
  • 💨 Breathes in place when resting, feet planted, belly rising and falling.
  • 💪 Works with you: quick breaths and a 열일 중... ("hard at work...") caption while Claude is busy.
  • 🎭 Seven moods: excited when you send a prompt, happy when the turn ends, tired after a long one, teary and worried on an error.
  • 🍙 Food talk (rice, coffee, snacks…) makes it go 맛있는거 최고! ("yummy is the best!").
  • 😴 Naps under a blue blanket after 5 quiet minutes.
  • 🌗 Theme-aware grass, softly blended into your light or dark background so Chiikawa stays the star.

Set a mood by hand with /chiikawa <mood>: idle, happy, excited, yummy, tired, worried, sleepy.

Install

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 の入力欄の上、小さな草原にちいかわが住んでいます。

非営利のファンプロジェクトです。 ちいかわが大好きな気持ちで作った非公式のファンメイド作品で、商用利用は一切していません。ちいかわに関するすべての権利はナガノ先生および権利者に帰属します。

できること

  • 🌱 おさんぽ: 待機中は草原をぴょこぴょこ歩き回ります。目と口は進む方向を向きます。
  • 💨 呼吸モーション: じっとしているときは足を地面につけたまま、おなかがふくらんだりしぼんだりします。
  • 💪 いっしょにお仕事: Claude が作業している間は、その場で速く呼吸しながら 열일 중...(「お仕事中…」)と表示します。
  • 🎭 7つの表情: プロンプトを送るとわくわく、終わるとにっこり、長引くとくたくた、エラーが出ると涙目で心配します。
  • 🍙 食べ物の話に反応: ごはん・コーヒー・おやつの話をすると 맛있는거 최고!(「おいしいもの最高!」)。
  • 😴 おひるね: 5分間静かだと、青いおふとんをかけて眠ります。
  • 🌗 テーマに合わせた草原: 草原はやわらかい色で、ライト/ダークテーマの背景になじみます。

/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>

Source 6 files
hooks/register.tsx 202 lines
1import { 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}
202
hooks/moods.ts 21 lines
1import 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}
21
hooks/ground.ts 37 lines
1// 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}
37
hooks/sprite.ts 116 lines
1import 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}
116
hooks/walk.ts 32 lines
1import 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}
32
types/index.d.ts 21 lines
1export 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