[k-mods] Claude가 일하는 동안 입력창 위에 냥캣·Clawd·썬더 같은 픽셀 친구들이 지나가고, 턴마다 자라는 펫을 키워요. 엄선한 6가지 장면에 clawd-tales의 Clawd 이야기를 더한 한국어판. 원본: hoobnn/hoobnn-agent-mods의 spinner…

Claude가 일하는 동안 입력창 위에 냥캣·Clawd·썬더 같은 픽셀 친구들이 지나가요. 원작 두 mod에서 장면을 골라 합치고 한국어로 옮긴 k-mods 수정판이에요.
<img src="../../docs/assets/mods/pixel-pals.gif" alt="냥캣이 무지개를 끌고 지나가는 장면" width="640">
/plugin marketplace add SeongGwangJu/k-mods
/plugin install pixel-pals@k-mods
설치하면 바로 동작해요. Claude가 일하는 동안 입력창 위 띠에 장면이 흘러가고, 턴이 끝나면 짧은 피날레가 나와요. 테마는 턴마다 아래 7가지 중 하나로 바뀌어요.
| 테마 | 모습 |
|---|---|
nyan | 무지개를 끌고 날아가는 냥캣 |
clawd | Claude 마스코트 Clawd가 블록을 쌓아요 |
thunder | 우주선이 적을 맞히는 슈팅 게임 |
chomp | 점을 먹으며 유령을 쫓는 동그라미 |
sparky | 번개 꼬리 생쥐 |
bluecat | 주머니에서 도구를 꺼내는 파란 고양이 로봇 |
tales | clawd-tales의 Clawd 이야기. 도구 호출마다 몸짓과 한 줄 캡션, 서브에이전트 동료, 컨텍스트에 따른 날씨 |
/pals: 지금 테마와 펫 상태, 사용법/pals <테마> / /pals random: 테마 고르기 (random은 턴마다 새로)/pals off / on: 애니메이션 끄기·켜기. stage off·on은 장면 띠만, companion on은 입력창 위 펫 줄/pals preview <테마>: 8초 미리 보기 (tales는 /tales demo로)/tales demo: Clawd 이야기 45초 데모. /tales hat <이름>·face <이름>·scarf on|off로 꾸미고, calm은 1초에 한 번만 움직여요/config 또는 /plugin configure pixel-pals@k-mods)| 설정 | 기본값 | 설명 |
|---|---|---|
| 테마 | random | 위 7가지 중 하나, 또는 턴마다 새로 |
| 장면 띠 | 켜짐 | 모델이 일하는 동안 입력창 위 장면 |
| 피날레 | 켜짐 | 턴이 끝나면 걸린 시간과 축하 |
| 펫 | 꺼짐 | 입력창 위 한 줄 펫. 턴마다 레벨이 올라요 |
| 움직임 줄이기 | 꺼짐 | 장면과 펫을 정지 그림으로 |
| 언어 | ko | 응답·피날레·Clawd 이야기 캡션의 언어 (Clawd 이야기는 한국어·영어만) |
| 원본 | pixel-pals | |
|---|---|---|
| 구성 | spinner(hoobnn), clawd-tales(plaxagoras) 따로 | 한 mod. clawd-tales는 tales 테마 |
| 테마 | spinner 15종 + audio | 6종 엄선 + tales |
random | 세션마다, 14종 중 | 턴마다, 7종 중 |
| 언어 | 자동 / 영어(clawd-tales) | 한국어 기본, Clawd 이야기 캡션도 한국어 |
| 명령 | /spinner, /tales | /pals, /tales |
| 펫 줄·하단 버튼·줄 앞 마스코트 | 켜짐 | 꺼짐 (/config에서 켤 수 있어요) |
| audio 테마(swiftc로 소리 읽기) | 있음 | 없음 |
| hud(hoobnn) 연동 | 있음 | 없음 |
자세한 내역은 CHANGES-KO.md에 있어요.
random이면 7종 중 테마를 새로 골라요.tales면 clawd-tales의 무대가, 아니면 장면 띠가 그려요. 둘이 한 번에 그리지 않아요.화면을 그리고, 이 플러그인 전용 저장소($.store)에 펫 상태와 Clawd의 모자·성장 기록을 저장해요. 세션의 컨텍스트·사용량 수치와 실행 중인 서브에이전트 목록을 읽어 날씨와 동료로 보여줘요. 네트워크, 모델 호출, 프로그램 실행은 없어요.
tales 테마일 때만 띠가 그려져요./ 명령이나 @ 파일 선택 창이 열리면 장면 띠는 잠깐 비켜요.8fb6f67, MIT). LICENSEtales 테마: plaxagoras의 clawd-tales (커밋 89c7f95, MIT). LICENSE-clawd-tales원작자들께 감사드려요. 원본 저작권 표기와 라이선스는 위 파일에 그대로 뒀어요.
테스트한 Claude Code 버전: 2.1.296
hooks/register.tsx 1660 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Activity, DockPet, FinaleState, PetStats, Preview } from '../types'
5import type { Action, Emote, Face, Form, Fx, FxKind, Hat, Hero, Holiday, Limit, Stats, Worker } from '../types'
6import { USAGE, parseCommand } from './command'
7import { readConfig } from './config'
8import type { Choice } from './config'
9import { lang, m, setLang } from './i18n'
10import { isPickerOpen, stackAbove } from './kit/band'
11import { drawPet, drawPetLine } from './kit/pet'
12import { resolveLanguage } from './kit/lang'
13import { keptRows, migrateStore, persist } from './kit/prefs'
14import type { Prefs } from './kit/prefs'
15import { bubbleOf, busyLabel, finaleOf, formatDuration, formatDurationKo, levelOf, newsOf, toolLabel } from './pet'
16import { FINALE_MS, THEMES, THEME_NAMES, isThemeName, pickRandom, textWidth } from './themes'
17import type { Act, Mood, ThemeName } from './themes'
18import { PET_ROWS, dockPetOf, petArtOf } from './pets'
19import type { PetState } from './pets'
20import type { StageProps } from './stage'
21import { CONGA_AT, HERO_W, SCENE_COUNT, TIER_COLOR, glyph, lines, speed, stage, tierOf, weatherOf } from './tales/art'
22import type { Egg } from './tales/story'
23import {
24 fitCells,
25 COMBO_AT,
26 CROWN_AT,
27 FORM_HAT,
28 FORM_KO,
29 GROW_AT,
30 NEW_STATS,
31 REST,
32 THINKING,
33 THINKING_KO,
34 bashEggs,
35 beat,
36 clockTime,
37 countCall,
38 eggBeat,
39 eggDone,
40 ending,
41 endingAction,
42 fidgetAt,
43 fishing,
44 formOf,
45 growCaption,
46 holidayOf,
47 isLate,
48 isMidnightNewYear,
49 moodOf,
50 notFound,
51 readStats,
52 restCaption,
53 scarfColor,
54 skyOf,
55 startCaption,
56 testCounts,
57 tokenMilestone,
58 tr,
59 tripCaption,
60 waitBeat,
61 warpColor,
62} from './tales/story'
63
64// k-mods 수정: 스피너 줄 옆 마스코트 끄기
65const SPINNER_MASCOT = false
66
67// The theme drawn this session ('' until session.start picks one).
68const theme = atom({ plugin: 'pixel-pals', key: 'theme' } as const, '')
69// The `theme` row: a theme, or `random`.
70const choice = atom({ plugin: 'pixel-pals', key: 'choice' } as const, 'random')
71// Session mirrors of the `visible`, `stage` and `companion` rows, so a command shows at once.
72const isHidden = atom({ plugin: 'pixel-pals', key: 'isHidden' } as const, false)
73const isStageOff = atom({ plugin: 'pixel-pals', key: 'isStageOff' } as const, false)
74const isCompanionOff = atom({ plugin: 'pixel-pals', key: 'isCompanionOff' } as const, false)
75const finale = atom({ plugin: 'pixel-pals', key: 'finale' } as const, null)
76const preview = atom({ plugin: 'pixel-pals', key: 'preview' } as const, null)
77const activity = atom({ plugin: 'pixel-pals', key: 'activity' } as const, { act: 'think' as Act })
78const mood = atom({ plugin: 'pixel-pals', key: 'mood' } as const, 'hello' as Mood)
79const pet = atom({ plugin: 'pixel-pals', key: 'pet' } as const, { xp: 0, love: 0 })
80const pat = atom({ plugin: 'pixel-pals', key: 'pat' } as const, null)
81const news = atom({ plugin: 'pixel-pals', key: 'news' } as const, null)
82// The pet for whoever draws it (hud beside its rows, or this plugin above the prompt).
83const dock = atom({ plugin: 'pixel-pals', key: 'dock' } as const, null as DockPet | null)
84const isTurn = atom({ plugin: 'pixel-pals', key: 'isTurn' } as const, false)
85// True while a picker is open above the band (see kit/band).
86const isPicking = atom({ plugin: 'pixel-pals', key: 'isPicking' } as const, false)
87
88const PREVIEW_MS = 8000
89/** Below this many columns the band draws the pet in one row. */
90const COMPACT_COLUMNS = 60
91/** Cells the pet's bubble and stats take beside it above the prompt. */
92const PET_LABEL_W = 24
93/** How long the pet speaks of tests or a commit. */
94const NEWS_MS = 4000
95/** How long a pat's hearts float. */
96const PAT_MS = 2500
97/** How long an `ask` must stand before the pet shows it: the mode settles most at once. */
98const ASK_DELAY_MS = 600
99/** Quiet this long after a turn, the companion dozes off. */
100const SLEEP_MS = 5 * 60_000
101
102/** The kit's hold on this mod's store and `/config` rows. */
103function prefsOf($: EngineInterface): Prefs {
104 return {
105 kept: key => $.store.get(key),
106 forget: key => $.store.delete(key),
107 write: (field, value) => $.config.set({ key: `pixel-pals.${field}`, value }),
108 }
109}
110
111/**
112 * The theme a choice names. `random` keeps the one this session already drew
113 * at random (a reload brought by another row's change), unless `isFresh`.
114 */
115async function choose($: EngineInterface, picked: Choice, isFresh = false): Promise<ThemeName> {
116 const current = await read($, theme)
117 const wasRandom = (await read($, choice)) === 'random'
118 const name =
119 picked !== 'random' ? picked : !isFresh && wasRandom && isThemeName(current) ? current : pickRandom(await $.clock.now())
120 await update($, choice, () => picked)
121 await update($, theme, () => name)
122 await publishPet($)
123 return name
124}
125
126/** The pet's stats as the store keeps them, shared by every session. */
127async function keptPet($: EngineInterface): Promise<PetStats> {
128 const stats = (await $.store.get('pet')) as Partial<PetStats> | undefined
129 return { xp: Number(stats?.xp) || 0, love: Number(stats?.love) || 0 }
130}
131
132/**
133 * Adds `by` to the kept stats, read again just before: sessions running side
134 * by side all raise the one pet instead of writing back their own copy.
135 */
136async function bumpPet($: EngineInterface, by: PetStats): Promise<PetStats> {
137 const kept = await keptPet($)
138 const next = { xp: kept.xp + by.xp, love: kept.love + by.love }
139 await $.store.set('pet', next)
140 return update($, pet, () => next)
141}
142
143/** One more xp, kept, and a toast when it brings a level. */
144async function gainXp($: EngineInterface): Promise<void> {
145 const after = await bumpPet($, { xp: 1, love: 0 })
146 if (levelOf(after.xp) > levelOf(after.xp - 1)) {
147 $.ui.toast(m('pet.levelUp', { theme: await read($, theme), level: levelOf(after.xp) }))
148 }
149}
150
151/** Tests or a commit the main thread just ran: a word from the pet for a moment, and xp for good news. */
152async function noteNews($: EngineInterface, command: string, isError: boolean, id: string): Promise<void> {
153 const kind = newsOf(command, isError)
154 if (kind === null) return
155 await update($, news, () => ({ kind, id }))
156 if (kind !== 'testFail') await gainXp($)
157 $.clock.after(NEWS_MS, async () => {
158 if ((await read($, news))?.id !== id) return
159 await update($, news, () => null)
160 await publishPet($)
161 })
162}
163
164/** A pat: hearts in the band, one more point of affection, kept. */
165async function patPet($: EngineInterface): Promise<PetStats> {
166 const next = await bumpPet($, { xp: 0, love: 1 })
167 const id = String(next.love)
168 await update($, pat, () => id)
169 if ((await read($, mood)) === 'sleep') await update($, mood, () => 'hello' as Mood)
170 await publishPet($)
171 $.clock.after(PAT_MS, async () => {
172 if ((await read($, pat)) !== id) return
173 await update($, pat, () => null)
174 await publishPet($)
175 })
176 return next
177}
178
179/** Reduced motion (the `reducedMotion` row): every animation one still frame. */
180let isStill = false
181
182const TONE: Partial<Record<PetState, DockPet['tone']>> = { ask: 'ask', error: 'error', aborted: 'aborted', sleep: 'sleep' }
183
184/** Publishes the pet as it is now (`pixel-pals.dock`), or null while it is off. */
185async function publishPet($: EngineInterface): Promise<void> {
186 if ((await read($, isHidden)) || (await read($, isCompanionOff))) {
187 if ((await read($, dock)) !== null) await update($, dock, () => null)
188 return
189 }
190 const name = await read($, theme)
191 const now = await read($, activity)
192 const state: PetState = (await read($, isTurn)) ? now.act : await read($, mood)
193 const stats = await read($, pet)
194 const patId = await read($, pat)
195 // A permission prompt waiting outranks news: it is the one the person must act on.
196 const said = state === 'ask' ? null : await read($, news)
197 const art = petArtOf(name, isThemeName(name) ? THEMES[name].color : THEMES.clawd.color)
198 const view = {
199 id: `${name}:${state}:${patId ?? ''}`,
200 bubble: said ? m(`pet.${said.kind}`) : bubbleOf(state, now.tool),
201 tone: said?.kind === 'testFail' ? 'error' : (TONE[state] ?? 'plain'),
202 stats: `Lv.${levelOf(stats.xp)} ♥${stats.love}`,
203 }
204 const was = await read($, dock)
205 // The same loop with new words keeps its frames.
206 const next = was?.id === view.id ? { ...was, ...view } : dockPetOf(art, state, view, patId !== null, isStill)
207 if (was?.id === next.id && was.bubble === next.bubble && was.stats === next.stats && was.tone === next.tone) return
208 await update($, dock, () => next)
209}
210
211/** Busy with the latest tool still running (subagents counted), else back to thinking. */
212async function settle($: EngineInterface, running: Map<string, string>): Promise<void> {
213 const last = busyLabel([...running.values()])
214 await update($, activity, () => (last ? { act: 'tool' as Act, tool: last } : { act: 'think' as Act }))
215 await publishPet($)
216}
217
218/** Sets a switch's session mirror and, when it changed, its row. */
219async function setSwitch($: EngineInterface, field: 'visible' | 'stage' | 'companion', isOn: boolean): Promise<void> {
220 // Each mirror spelled out: the engine reads which state a hook touches from the source.
221 const wasOff =
222 field === 'visible'
223 ? await read($, isHidden)
224 : field === 'stage'
225 ? await read($, isStageOff)
226 : await read($, isCompanionOff)
227 if (wasOff === !isOn) return
228 if (field === 'visible') await update($, isHidden, () => !isOn)
229 else if (field === 'stage') await update($, isStageOff, () => !isOn)
230 else await update($, isCompanionOff, () => !isOn)
231 await publishPet($)
232 await persist(prefsOf($), field, isOn)
233}
234
235/** `/pals` with no arguments: the theme, the pet, what is off, the themes and the usage. */
236async function status($: EngineInterface): Promise<string> {
237 const current = await read($, theme)
238 const stats = await read($, pet)
239 const lines = [
240 m('cmd.status', { theme: `${current}${(await read($, choice)) === 'random' ? m('cmd.randomNote') : ''}` }),
241 m('cmd.petStats', { theme: current, level: levelOf(stats.xp), xp: stats.xp, love: stats.love }),
242 ]
243 if (await read($, isHidden)) lines.push(m('cmd.hidden'))
244 if (await read($, isStageOff)) lines.push(m('cmd.stageOff'))
245 if (await read($, isCompanionOff)) lines.push(m('cmd.companionOff'))
246 lines.push(m('cmd.themes', { list: THEME_NAMES.map(n => `${n} ${THEMES[n].happy}`).join(' · ') }), m('cmd.usage'))
247 return lines.join('\n')
248}
249
250// What versions before 0.3 kept in the store, as `/config` rows.
251const STORE_MOVES = {
252 theme: (kept: unknown) => (kept === 'random' || isThemeName(kept) ? (['theme', kept] as const) : null),
253 isHidden: (kept: unknown) => ['visible', kept !== true] as const,
254 isStageOff: (kept: unknown) => ['stage', kept !== true] as const,
255 isCompanionOff: (kept: unknown) => ['companion', kept !== true] as const,
256}
257
258export const register: Register = (on, options) => {
259 const config = readConfig(options)
260 isStill = config.isStill
261 // Tool calls running now: a pet stays busy until the last of parallel calls ends.
262 const running = new Map<string, string>()
263 // Every call still open, a subagent's too: an ask is shown only while its call is.
264 const open = new Set<string>()
265 let sleepTimer: { cancel: () => void } | undefined
266
267 on('session.start', async ($, e, next) => {
268 const settings = (await $.settings.read().catch(() => ({}))) as { language?: unknown }
269 const locale = await Promise.all([
270 $.env.get('LC_ALL').catch(() => undefined),
271 $.env.get('LC_MESSAGES').catch(() => undefined),
272 $.env.get('LANG').catch(() => undefined),
273 ])
274 setLang(resolveLanguage(config.language, settings.language, locale))
275 await $.command.register({ name: 'pals', description: m('cmd.description'), argumentHint: USAGE })
276
277 const rows = readConfig({ ...options, ...(await keptRows(prefsOf($), STORE_MOVES)) })
278 await choose($, rows.theme)
279 await update($, isHidden, () => !rows.isVisible)
280 await update($, isStageOff, () => !rows.hasStage)
281 await update($, isCompanionOff, () => !rows.hasCompanion)
282 const stats = await keptPet($)
283 await update($, pet, () => stats)
284 await publishPet($)
285 const result = await talesSessionStart($, e, next)
286 await migrateStore(prefsOf($), STORE_MOVES)
287 return result
288 })
289
290 on('turn.start', async ($, e, next) => {
291 // k-mods 수정: 무작위 테마를 세션마다가 아니라 턴마다 새로 고른다
292 if ((await read($, choice)) === 'random') await choose($, 'random', true)
293 running.clear()
294 sleepTimer?.cancel()
295 await update($, activity, () => ({ act: 'think' as Act }))
296 await update($, isTurn, () => true)
297 if (await read($, finale)) await update($, finale, () => null)
298 await publishPet($)
299 return talesTurnStart($, e, next)
300 })
301
302 on('tool.call', async ($, e, next) => {
303 // A subagent's call is no news for the bubble, but its permission ask is.
304 if (e.agentId) {
305 open.add(e.tool_use_id)
306 try {
307 return await talesToolCall($, e, next)
308 } finally {
309 open.delete(e.tool_use_id)
310 // Its ask answered: back to what the main thread is doing.
311 if ((await read($, activity)).act === 'ask') await settle($, running)
312 }
313 }
314 open.add(e.tool_use_id)
315 const label = toolLabel(e as unknown as { tool: string } & Record<string, unknown>)
316 running.set(e.tool_use_id, label)
317 await update($, activity, () => (e.tool === 'AskUserQuestion' ? { act: 'ask' as Act } : { act: 'tool' as Act, tool: busyLabel([...running.values()]) }))
318 await publishPet($)
319 try {
320 const result = await talesToolCall($, e, next)
321 const command = (e as unknown as { command?: unknown }).command
322 if (e.tool === 'Bash' && typeof command === 'string' && !('deny' in result && result.deny !== undefined)) {
323 await noteNews($, command, result.isError === true, e.tool_use_id)
324 }
325 return result
326 } finally {
327 open.delete(e.tool_use_id)
328 running.delete(e.tool_use_id)
329 await settle($, running)
330 }
331 })
332
333 // A call the engine puts to the person: `ask` from the permission check. The
334 // mode often settles an ask by itself at once, so the pet waits a moment and
335 // asks only if the call is still open.
336 on('tool.check', async ($, e, next) => {
337 const verdict = await next(e)
338 const id = e.tool_use_id
339 if (verdict.decision === 'ask' && id !== undefined && open.has(id)) {
340 $.clock.after(ASK_DELAY_MS, async () => {
341 if (!open.has(id)) return
342 await update($, activity, a => ({ ...a, act: 'ask' as Act }))
343 await publishPet($)
344 })
345 }
346 return verdict
347 })
348
349 on('turn.complete', async ($, e, next) => {
350 const result = await talesTurnComplete($, e, next)
351 if (e.agentId) return result
352 running.clear()
353 await update($, activity, () => ({ act: 'think' as Act }))
354 await update($, isTurn, () => false)
355
356 const kind = finaleOf(e.reason)
357 await update($, mood, () => (kind === 'answer' ? 'ready' : kind))
358 sleepTimer?.cancel()
359 sleepTimer = $.clock.after(SLEEP_MS, async () => {
360 await update($, mood, () => 'sleep' as Mood)
361 await publishPet($)
362 })
363
364 if (kind === 'answer') {
365 await gainXp($)
366 }
367
368 await publishPet($)
369 if (config.hasFinale) {
370 const label =
371 kind === 'answer' ? m('finale.done', { time: lang() === 'ko' ? formatDurationKo(e.durationMs) : formatDuration(e.durationMs) }) : m(kind === 'aborted' ? 'finale.aborted' : 'finale.error')
372 const id = e.turnId
373 await update($, finale, () => ({ kind, label, id }))
374 $.clock.after(FINALE_MS, () => void update($, finale, f => (f?.id === id ? null : f)))
375 }
376 return result
377 })
378
379 // A click on the pet, where this plugin draws it.
380 on('ui.message', async ($, e, next) => {
381 const data = e.data as { pat?: unknown } | null
382 if (data?.pat === true) await patPet($)
383 return next(e)
384 })
385
386 on('command.run', { command: 'pals' }, async ($, e) => {
387 const command = parseCommand(e.args)
388 const list = THEME_NAMES.join(' · ')
389 switch (command.kind) {
390 case 'status':
391 return { text: await status($) }
392 case 'visible':
393 await setSwitch($, 'visible', command.isOn)
394 return { text: m(command.isOn ? 'cmd.shown' : 'cmd.hidden') }
395 case 'stage':
396 await setSwitch($, 'stage', command.isOn)
397 return { text: m(command.isOn ? 'cmd.stageOn' : 'cmd.stageOff') }
398 case 'companion':
399 await setSwitch($, 'companion', command.isOn)
400 return { text: m(command.isOn ? 'cmd.companionOn' : 'cmd.companionOff') }
401 case 'pat': {
402 const stats = await patPet($)
403 return { text: m('cmd.pat', { theme: await read($, theme), love: stats.love }) }
404 }
405 case 'preview': {
406 const current = await read($, theme)
407 const name: ThemeName = command.theme ?? (isThemeName(current) ? current : 'clawd')
408 // tales는 장면이 아니라 이야기라서 미리 보기는 /tales demo가 맡는다
409 if (name === 'tales') return { text: m('cmd.talesPreview') }
410 const id = String(await $.clock.now())
411 await update($, preview, () => ({ theme: name, id }))
412 $.clock.after(PREVIEW_MS, () => void update($, preview, p => (p?.id === id ? null : p)))
413 return { text: m('cmd.preview', { theme: name }) }
414 }
415 case 'pick': {
416 // 2-4 options fit the dialog: random and three others; any theme typed under Other.
417 const current = await read($, theme)
418 const others = THEME_NAMES.filter(n => n !== current)
419 const start = (await $.clock.now()) % others.length
420 const offered = ['random', ...[0, 1, 2].map(i => others[(start + i) % others.length]!)]
421 let answer: string
422 try {
423 answer = (await $.ui.ask(m('cmd.pick', { list }), { options: offered, header: 'Theme' })).trim().toLowerCase()
424 } catch {
425 // Dismissed, or no one to ask (-p): what `/pals` alone says.
426 return { text: await status($) }
427 }
428 if (answer !== 'random' && !isThemeName(answer)) return { text: m('cmd.unknown', { name: answer, list }) }
429 const name = await choose($, answer, true)
430 await persist(prefsOf($), 'theme', answer)
431 return { text: answer === 'random' ? m('cmd.random', { theme: name }) : m('cmd.switched', { theme: name }) }
432 }
433 case 'theme': {
434 const name = await choose($, command.theme, true)
435 await persist(prefsOf($), 'theme', command.theme)
436 return { text: command.theme === 'random' ? m('cmd.random', { theme: name }) : m('cmd.switched', { theme: name }) }
437 }
438 case 'unknown':
439 return { text: m('cmd.unknown', { name: command.name, list }) }
440 }
441 })
442
443 // The mascot rides in front of the engine's own line, which keeps its word,
444 // elapsed time and tokens.
445 // A picker (`/` commands, `@` files) opens above the band: the band steps aside meanwhile.
446 on('prompt.edit', async ($, e, next) => {
447 const box = await next(e)
448 const isOpen = isPickerOpen(box.text, box.cursor)
449 if ((await read($, isPicking)) !== isOpen) await update($, isPicking, () => isOpen)
450 return box
451 })
452 on('prompt.submit', async ($, e, next) => {
453 if (await read($, isPicking)) await update($, isPicking, () => false)
454 return talesPromptSubmit($, e, next)
455 })
456
457 // A button in the prompt footer: `/pals off` / `on`. Mode labels other plugins add stay beside it.
458 if (config.hasFooterButton) {
459 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
460 const hidden = await read($, isHidden)
461 const below = await next(e)
462 const { Box, Button } = $.ui.resolve(e)
463 return (
464 <Box flexDirection="row" alignItems="center" gap={1}>
465 <Button key="pals-toggle" plain dimColor={hidden} label="Pals" onPress={() => setSwitch($, 'visible', hidden)} />
466 {below}
467 </Box>
468 )
469 })
470 }
471
472 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
473 // k-mods 수정: 스피너 줄 옆 마스코트는 그리지 않는다 (줄 앞에 들여쓰기가 생긴다). 장면은 입력창 위 띠에서만.
474 if (!SPINNER_MASCOT) return next(e)
475 // One mascot at a time: with the companion's row showing, it stays there.
476 if ((await read($, isHidden)) || !(await read($, isCompanionOff))) return next(e)
477 const ui = $.ui.resolve(e)
478 if (!('Client' in ui)) return next(e)
479 const { Box, Client } = ui
480 const current = await read($, theme)
481 const name: ThemeName = isThemeName(current) ? current : 'clawd'
482 const native = await next(e)
483 return (
484 <Box flexDirection="row" columnGap={1}>
485 {/* The engine's line opens with a blank row; the mascot sits on the line itself. */}
486 <Box marginTop={1}>
487 <Client key="sprite" module="./sprite.tsx" width={spriteWidth(name)} props={{ theme: name, mode: e.props.mode, still: isStill }} />
488 </Box>
489 {native}
490 </Box>
491 )
492 })
493
494 // The band: a preview, else the running turn's scene or its finale, and the
495 // pet right-aligned beside it.
496 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
497 if (e.props.hasSurvey || (await read($, isPicking))) return talesBand($, e, next)
498 const ui = $.ui.resolve(e)
499 if (!('Client' in ui)) return talesBand($, e, next)
500 const { Box, Client } = ui
501
502 const shown: Preview | null = await read($, preview)
503 const current = await read($, theme)
504 // pixel-pals: tales 테마는 clawd-tales의 무대(hooks/tales)가 띠를 통째로 그린다
505 if (!shown && current === 'tales') return talesBand($, e, next)
506 const name: ThemeName = isThemeName(current) ? current : 'clawd'
507 const pet = (await read($, isHidden)) ? null : await read($, dock)
508 // Two cells in, and the pet's block on the right.
509 const petColumns = pet ? pet.width + 1 + PET_LABEL_W : 0
510 const columns = e.props.bodyColumns - 2 - petColumns
511 const sceneFits = (rows: number) => e.props.maxRows >= Math.max(rows, pet ? PET_ROWS : 0)
512 let scene = null
513
514 if (shown && isThemeName(shown.theme)) {
515 const props: StageProps = { theme: shown.theme, columns, act: 'think', still: isStill }
516 scene = <Client key={`preview-${shown.id}`} module="./stage.tsx" width={columns} props={props} />
517 } else if (!(await read($, isHidden)) && !(await read($, isStageOff)) && sceneFits(THEMES[name].rows)) {
518 const ended: FinaleState | null = await read($, finale)
519 const now: Activity = await read($, activity)
520 if (e.props.isWorking) {
521 const props: StageProps = { theme: name, columns, act: now.act, still: isStill }
522 scene = <Client key="work" module="./stage.tsx" width={columns} props={props} />
523 } else if (ended) {
524 const props = { theme: name, columns, act: now.act, finale: ended.kind, label: ended.label, still: isStill }
525 scene = <Client key={`finale-${ended.id}`} module="./stage.tsx" width={columns} props={props} />
526 }
527 }
528 if (!scene && !pet) return talesBand($, e, next)
529
530 // Too short or narrow for the pet's block: the pet in one row, the scene left out.
531 if (pet && !shown && (e.props.maxRows < PET_ROWS || e.props.bodyColumns < COMPACT_COLUMNS)) {
532 const mascot = { text: THEMES[name].sprite.say[0] ?? '', color: THEMES[name].color }
533 return stackAbove(ui, <Box justifyContent="flex-end">{drawPetLine(ui, pet, mascot)}</Box>, await talesBand($, e, next))
534 }
535 const band = (
536 <Box flexDirection="row" justifyContent="space-between" alignItems="flex-end">
537 {scene ?? <Box />}
538 {pet ? <Box width={petColumns} justifyContent="flex-end">{drawPet(ui, pet, 'pet')}</Box> : null}
539 </Box>
540 )
541 return stackAbove(ui, band, await talesBand($, e, next))
542 })
543
544 // pixel-pals: clawd-tales(tales 테마)만 쓰는 이벤트. 겹치는 이벤트(session.start·turn·tool.call·prompt.submit·띠)는
545 // 위 훅이 next 자리에서 tales 함수를 불러 잇는다. 이야기는 테마와 상관없이 이어 가고, 띠는 tales 테마일 때만 그린다.
546 on('command.run', { command: 'tales' }, async ($, e) => talesCommand($, e))
547 on('classic.UserPromptSubmit', async ($, e, next) => talesClassicPromptSubmit($, e, next))
548 on('session.measure', async ($, e, next) => talesSessionMeasure($, e, next))
549 on('classic.PermissionRequest', async ($, e, next) => talesPermissionRequest($, e, next))
550 on('agent.spawn', async ($, e, next) => talesAgentSpawn($, e, next))
551 on('session.compact', async ($, e, next) => talesSessionCompact($, e, next))
552 on('classic.SessionStart', async ($, e, next) => talesClassicSessionStart($, e, next))
553}
554
555/** Cells the mascot's region takes: its widest frame, with room for a symbol a terminal draws wide. */
556function spriteWidth(name: ThemeName): number {
557 return Math.max(...Object.values(THEMES[name].sprite).flat().map(textWidth)) + 2
558}
559
560// ---- clawd-tales (tales 테마) ----------------------------------------------
561// plaxagoras/clawd-tales @ 89c7f95 (MIT)의 hooks/register.tsx. Claude Code는 플러그인마다 훅 모듈 하나만 받고 $는 같은
562// 파일의 함수로만 넘길 수 있어서 장면 쪽과 한 파일에 둔다. 그림은 tales/art.ts, 문구는 tales/story.ts에 있다.
563
564const IDLE: Hero = { mode: 'idle', action: 'walk', caption: '', x: 0, dir: 1, scene: 0, steps: 0, since: 0 }
565
566const hero = atom({ plugin: 'pixel-pals', key: 'hero' } as const, IDLE)
567const workers = atom({ plugin: 'pixel-pals', key: 'workers' } as const, [])
568const frame = atom({ plugin: 'pixel-pals', key: 'frame' } as const, 0)
569const isOn = atom({ plugin: 'pixel-pals', key: 'isOn' } as const, true)
570const alert = atom({ plugin: 'pixel-pals', key: 'alert' } as const, null)
571const context = atom({ plugin: 'pixel-pals', key: 'context' } as const, null)
572const limits = atom({ plugin: 'pixel-pals', key: 'limits' } as const, [])
573const calm = atom({ plugin: 'pixel-pals', key: 'calm' } as const, false)
574const bugs = atom({ plugin: 'pixel-pals', key: 'bugs' } as const, 0)
575const todos = atom({ plugin: 'pixel-pals', key: 'todos' } as const, null)
576const fx = atom({ plugin: 'pixel-pals', key: 'fx' } as const, [])
577const shiny = atom({ plugin: 'pixel-pals', key: 'shiny' } as const, null)
578const combo = atom({ plugin: 'pixel-pals', key: 'combo' } as const, 0)
579
580const TICK_MS = 200
581const CALM_TICK_MS = 1000 // calm mode: one beat a second
582const POLL_EVERY = 5 // ticks between $.agent.list() checks
583const THINK_AFTER_MS = 15000 // a helper with no tool call for this long wanders again
584const TRIP_MS = 2500
585const LINGER_MS = 8000 // the closing cheer lasts this long, then the rest ladder
586const HUG_MS = 3000 // a helper that made it back stays this long with hearts
587const RETURN_MAX_MS = 10_000 // a helper that can't reach Claude hands in anyway
588const ENDED = new Set(['completed', 'failed', 'killed'])
589const THINK_POSE_MS = 8000 // no tool call for this long mid-turn: a thought bubble
590const MOOD_MS = 4000 // a blush or a sweat drop lasts this long
591const VISITOR_ODDS = 1 / 25 // per prompt
592const SHINY_ODDS = 1 / 100 // per session
593const FX_MS: Record<FxKind, number> = { confetti: 2200, plane: 3500, boxes: 6000, train: 8000, whale: 16000, ufo: 10000, warp: 1200 }
594const HOLIDAYS: readonly Holiday[] = ['halloween', 'christmas', 'newyear', 'valentine', 'aprilfools']
595
596// Module-level: a reload starts these over, and session.start picks the story back up.
597let timer: Timer | undefined
598let isCalm = false // mirrors the calm atom for the tick's own pacing
599let ticks = 0
600let cols = 100
601let heroCalls = 0 // main-loop tool calls still running: a trip waits for them to end
602let tripUntil = 0
603let startedAt = 0
604let lastCallAt = 0 // the last main-loop tool call's end, for the thought bubble
605let best = 0 // the turn's best combo
606let fxId = 0
607let holidayPick: Holiday | 'none' | null = null // /tales holiday: a preview, this load only
608let alertAt: number | null = null // when the current call for the user began
609let errStreak = 0 // main-loop errors in a row
610let nextGlanceAt = 0
611let planMode = false // the last prompt went in under plan mode: the wizard hat
612let agentCalls = 0 // main-loop Agent calls still running: Claude is waiting on helpers
613let sessionTokens = 0 // input, cache writes and output this session; cache reads left out
614let tokensSeen = 0 // the count at the last main-loop ending, so a helper's crossing waits for it
615const HARDHAT_MS = 120_000 // two minutes into a turn: hard hat on
616const WAKE_MS = 60_000 // a minute before a limit resets, Claude stretches; a minute after, it's up
617const TIE_AT = 3 // helpers out for the boss tie
618const FAST_YES_MS = 6000 // approved and done this fast: hearts
619const NERVOUS_MS = 30_000 // a call for the user waiting this long: a sweat drop and a timer
620const FLIP_MS = 4000
621let wornHat: Hat | null = null // /tales hat, saved in $.store
622let wornFace: Face | null = null // /tales face, saved in $.store
623const HATS_TO_WEAR: readonly Hat[] = ['tophat', 'gradcap', 'captain', 'wizard', 'hardhat', 'deerstalker', 'beanie', 'pith', 'shell', 'witch', 'santa', 'party', 'nightcap', 'crown']
624let stats: Stats | null = null // lifetime progress from $.store; null until Clawd has hatched
625let scarfOn = true // /tales scarf, saved in $.store
626let scarfKey: string | null = null // the project's scarf color
627const HATCH_MS = 2400
628let demoGrowth: Form | 'shell' | null = null // /tales demo's own hatch and growth; real stats untouched
629const FACES_TO_WEAR: readonly Face[] = ['glasses', 'shades', 'mustache']
630const workerBeatAt = new Map<string, number>()
631const workerTripUntil = new Map<string, number>()
632const returningSince = new Map<string, number>()
633
634/** Steps x along the stage, turning at either edge. */
635function walk<T extends { x: number; dir: 1 | -1 }>(it: T, action: Action): T {
636 const v = speed(action)
637 if (v <= 0) return it
638 const max = Math.max(0, cols - HERO_W)
639 let x = it.x + v * it.dir
640 let dir = it.dir
641 if (x >= max) [x, dir] = [max, -1]
642 if (x <= 0) [x, dir] = [0, 1]
643 return { ...it, x, dir }
644}
645
646/** The rest ladder, by time since the turn's cheer ended. Null once Claude has gone. */
647function restStep(since: number, now: number, lim: Limit | null, last?: Action): Pick<Hero, 'action' | 'caption' | 'emote'> | null {
648 const resets = lim?.resetsAt ? Date.parse(lim.resetsAt) : NaN
649 // The figure stays at 100 until the next response, so past the reset the clock decides.
650 if (lim && lim.percent >= 100 && !(now >= resets + WAKE_MS)) {
651 if (now >= resets) {
652 return {
653 action: 'cheer',
654 caption: tr(`The ${lim.kind} limit has reset. Claude is up and ready!`, `${lim.kind} 한도가 초기화됐어요. Claude가 벌떡 일어나 준비 완료!`),
655 emote: null,
656 }
657 }
658 if (now >= resets - WAKE_MS) {
659 return {
660 action: 'stretch',
661 caption: tr(
662 `Claude stretches. The ${lim.kind} limit resets at ${clockTime(lim.resetsAt)}.`,
663 `Claude가 기지개를 켜요. ${lim.kind} 한도가 ${clockTime(lim.resetsAt)}에 초기화돼요.`,
664 ),
665 emote: null,
666 }
667 }
668 return {
669 action: 'sleep',
670 caption: tr(
671 `Claude sleeps until the ${lim.kind} limit resets at ${clockTime(lim.resetsAt)}. z z z`,
672 `Claude가 ${lim.kind} 한도가 ${clockTime(lim.resetsAt)}에 초기화될 때까지 자요. z z z`,
673 ),
674 emote: null,
675 }
676 }
677 const t = now - since
678 if (t >= REST.hideMs) return null
679 const action = t < REST.sitMs ? 'sit' : t < REST.yawnMs ? 'yawn' : 'sleep'
680 const fidget = action === 'sit' ? (fishing(t) ?? fidgetAt(t, Math.floor(since / 1000))) : null
681 if (fidget) return { action: fidget.action, caption: fidget.caption, emote: fidget.emote ?? null }
682 return { action, caption: restCaption(action, last), emote: null }
683}
684
685function holidayNow(d: Date): Holiday | null {
686 if (holidayPick === 'none') return null
687 return holidayPick ?? holidayOf(d)
688}
689
690/** Claude's eyes dart toward something for a moment. */
691async function glanceAt($: EngineInterface, dir: -1 | 1, ms = 1500) {
692 const now = await $.clock.now()
693 await update($, hero, (cur): Hero => ({ ...cur, glance: dir, glanceUntil: now + ms }))
694}
695
696async function addFx($: EngineInterface, kind: FxKind, x: number) {
697 if ((await read($, hero)).mode === 'idle') return
698 const start = await $.clock.now()
699 fxId += 1
700 const one: Fx = { id: fxId, kind, start, dur: FX_MS[kind], x }
701 await update($, fx, list => [...list.filter(f => f.kind !== kind), one])
702 ensureTimer($)
703}
704
705/** A rare visitor crosses during the turn: a sky whale, a UFO, or the train. */
706function maybeVisitor($: EngineInterface, force?: FxKind) {
707 if (!force && Math.random() >= VISITOR_ODDS) return
708 const kinds: FxKind[] = ['whale', 'ufo', 'train']
709 const kind = force ?? kinds[Math.floor(Math.random() * kinds.length)] ?? 'whale'
710 $.clock.after(force ? 0 : 3000 + Math.floor(Math.random() * 12000), () => void addFx($, kind, 0))
711}
712
713function ensureTimer($: EngineInterface) {
714 if (timer) return
715 timer = $.clock.every(isCalm ? CALM_TICK_MS : TICK_MS, async () => {
716 ticks += 1
717 const now = await $.clock.now()
718 let cur = await read($, hero)
719 const crew = await read($, workers)
720
721 if (cur.mode === 'ending' && now - cur.since >= LINGER_MS) {
722 cur = { ...cur, mode: 'resting', since: now, action: 'sit', caption: restCaption('sit') }
723 await update($, hero, () => cur)
724 }
725 if (cur.emote && cur.mode !== 'resting' && cur.emote !== 'think' && now > (cur.emoteUntil ?? 0)) {
726 cur = { ...cur, emote: null }
727 await update($, hero, () => cur)
728 }
729 if (cur.mode === 'resting') {
730 const step = restStep(cur.since, now, topLimit(await read($, limits)), cur.last)
731 const next: Hero = step ? { ...cur, ...step } : { ...cur, mode: 'idle', emote: null }
732 if (next.action !== cur.action || next.mode !== cur.mode || next.caption !== cur.caption || next.emote !== cur.emote) {
733 cur = next
734 await update($, hero, () => next)
735 if (next.mode === 'idle') await update($, fx, () => [])
736 }
737 }
738 let effects = await read($, fx)
739 if (effects.some(f => now - f.start >= f.dur)) {
740 effects = effects.filter(f => now - f.start < f.dur)
741 await update($, fx, () => effects)
742 }
743 if (cur.mode === 'idle' && crew.length === 0 && effects.length === 0) {
744 timer?.cancel()
745 timer = undefined
746 return
747 }
748 const f = (await read($, frame)) + 1
749 await update($, frame, () => f)
750 // Now and then the eyes wander: a look left or right for a second.
751 if (cur.mode !== 'idle' && now >= nextGlanceAt) {
752 if (nextGlanceAt > 0 && cur.action !== 'sleep') await glanceAt($, Math.random() < 0.5 ? -1 : 1, 1200)
753 nextGlanceAt = now + 5000 + Math.floor(Math.random() * 7000)
754 }
755
756 if (cur.mode === 'working' && !(await read($, alert))) {
757 let next: Hero = cur
758 // Claude keeps the last pose through the model's thinking until the next tool call;
759 // only a trip, once played, goes back to wandering.
760 if ((cur.action === 'trip' || cur.action === 'dizzy' || cur.action === 'flip') && heroCalls === 0 && now >= tripUntil) {
761 next = { ...next, action: 'walk', caption: tr(THINKING[f % THINKING.length] ?? '', THINKING_KO[f % THINKING_KO.length] ?? '') }
762 }
763 // A long think: the pose and caption stay, a thought bubble rises.
764 if (!cur.emote && heroCalls === 0 && now - lastCallAt >= THINK_POSE_MS && next.action !== 'trip' && next.action !== 'sweep') {
765 next = { ...next, emote: 'think', emoteUntil: Infinity }
766 }
767 // Waiting on an Agent call, Claude sits with the helpers' music or juggling: no wandering.
768 if (!isCalm && agentCalls === 0) next = walk(next, next.action)
769 if (next !== cur) await update($, hero, () => next)
770 }
771
772 if (crew.length > 0) {
773 const homeX = cur.x
774 const arrived: string[] = []
775 await update($, workers, list =>
776 list.map(w => {
777 if (w.state === 'returning') {
778 const gap = homeX - w.x
779 const late = now - (returningSince.get(w.id) ?? now) > RETURN_MAX_MS
780 if (Math.abs(gap) <= HERO_W - 4 || late) {
781 arrived.push(w.id)
782 return { ...w, state: 'done', action: w.sad ? 'sit' : 'cheer', dir: gap >= 0 ? 1 : -1 }
783 }
784 const dir: 1 | -1 = gap > 0 ? 1 : -1
785 const step = Math.min(Math.abs(gap), isCalm ? 8 : 1)
786 return { ...w, dir, x: w.x + dir * step }
787 }
788 if (w.state !== 'running') return w
789 const quiet = now - (workerBeatAt.get(w.id) ?? 0) > THINK_AFTER_MS
790 const tripping = now < (workerTripUntil.get(w.id) ?? 0)
791 const action: Action = quiet && !tripping ? 'walk' : w.action
792 return isCalm ? { ...w, action } : walk({ ...w, action }, action)
793 }),
794 )
795 if (!isCalm) await update($, workers, conga)
796 for (const id of arrived) $.clock.after(HUG_MS, () => void leave($, id))
797 if (ticks % POLL_EVERY === 0) await talesSettle($, crew)
798 }
799 })
800}
801
802/** Four or more helpers out: the ones wandering fall in line behind the first. */
803function conga(list: Worker[]): Worker[] {
804 const line = list.filter(w => w.state === 'running' && speed(w.action) > 0)
805 if (list.filter(w => w.state === 'running').length < CONGA_AT || line.length < 2) return list
806 const max = Math.max(0, cols - HERO_W)
807 const placed = new Map<string, Worker>()
808 let ahead = line[0]!
809 for (const w of line.slice(1)) {
810 const want = Math.min(max, Math.max(0, ahead.x - ahead.dir * 12))
811 const gap = want - w.x
812 const step = Math.min(Math.abs(gap), 1)
813 const moved: Worker = { ...w, action: 'walk', x: w.x + Math.sign(gap) * step, dir: gap === 0 ? ahead.dir : gap > 0 ? 1 : -1 }
814 placed.set(w.id, moved)
815 ahead = moved
816 }
817 return list.map(w => placed.get(w.id) ?? w)
818}
819
820async function leave($: EngineInterface, id: string) {
821 workerBeatAt.delete(id)
822 workerTripUntil.delete(id)
823 returningSince.delete(id)
824 await update($, workers, list => list.filter(w => w.id !== id))
825}
826
827/** A helper ends: done ones run home to Claude, failed ones trip and leave. */
828async function finish($: EngineInterface, id: string, state: 'done' | 'failed') {
829 if (state === 'done') {
830 returningSince.set(id, await $.clock.now())
831 await update($, workers, list => list.map(w => (w.id === id ? { ...w, state: 'returning', action: 'run' } : w)))
832 ensureTimer($)
833 return
834 }
835 await update($, workers, list => list.map(w => (w.id === id ? { ...w, state: 'failed', action: 'trip', sad: true } : w)))
836 // It trips, dusts off, and walks back to Claude for a pat on the head.
837 $.clock.after(TRIP_MS, async () => {
838 returningSince.set(id, await $.clock.now())
839 await update($, workers, list => list.map(w => (w.id === id ? { ...w, state: 'returning', action: 'walk' } : w)))
840 })
841}
842
843async function talesSettle($: EngineInterface, list: readonly Worker[]) {
844 const running = list.filter(w => w.state === 'running' && !w.id.startsWith('demo-'))
845 if (running.length === 0) return
846 const agents = await $.agent.list()
847 for (const w of running) {
848 const a = agents.find(x => x.id === w.id)
849 if (!a) await finish($, w.id, 'done')
850 else if (ENDED.has(a.status)) await finish($, w.id, a.status === 'completed' ? 'done' : 'failed')
851 }
852}
853
854/** The spot on the stage farthest from Claude and every helper already out. */
855function openSpot(taken: readonly number[], max: number): number {
856 let best = 0
857 let bestGap = -1
858 for (let x = 0; x <= max; x += 2) {
859 const gap = Math.min(...taken.map(t => Math.abs(t - x)), Infinity)
860 if (gap > bestGap) [best, bestGap] = [x, gap]
861 }
862 return best
863}
864
865async function addWorker($: EngineInterface, id: string, label: string, model: string | undefined) {
866 const max = Math.max(1, cols - HERO_W)
867 const taken = [(await read($, hero)).x, ...(await read($, workers)).map(w => w.x)]
868 const x = openSpot(taken, max)
869 const w: Worker = {
870 id,
871 label,
872 tier: tierOf(model),
873 state: 'running',
874 action: 'walk',
875 x,
876 dir: x > max / 2 ? -1 : 1,
877 steps: 0,
878 }
879 workerBeatAt.set(id, await $.clock.now())
880 await update($, workers, list => [...list.filter(x => x.id !== id), w])
881 await glanceAt($, x >= taken[0]! ? 1 : -1)
882 ensureTimer($)
883}
884
885async function workerBeat($: EngineInterface, id: string, action: Action, isError: boolean) {
886 const now = await $.clock.now()
887 workerBeatAt.set(id, now)
888 if (isError) workerTripUntil.set(id, now + TRIP_MS)
889 await update($, workers, list =>
890 list.map(w => (w.id === id && w.state === 'running' ? { ...w, action, steps: w.steps + (isError ? 0 : 1) } : w)),
891 )
892}
893
894/** A main-thread beat: action and caption, unless Claude is waiting on the user. */
895const POSES = new Set<Action>(['read', 'dig', 'sneak', 'run', 'fly', 'carry', 'cover'])
896
897async function heroBeat($: EngineInterface, b: Pick<Hero, 'action' | 'caption'>, step = 1, mood?: Emote) {
898 const now = await $.clock.now()
899 lastCallAt = now
900 await update($, hero, (cur): Hero => {
901 if (cur.mode !== 'working') return cur
902 const emote = mood ?? (cur.emote === 'think' ? null : cur.emote)
903 return {
904 ...cur,
905 ...b,
906 steps: cur.steps + step,
907 emote,
908 emoteUntil: mood ? now + MOOD_MS : cur.emoteUntil,
909 last: POSES.has(b.action) ? b.action : cur.last,
910 }
911 })
912}
913
914async function begin($: EngineInterface, text = '') {
915 const now = await $.clock.now()
916 const d = new Date(now)
917 startedAt = now
918 lastCallAt = now
919 heroCalls = 0
920 tripUntil = 0
921 best = 0
922 errStreak = 0
923 alertAt = null
924 const mood = moodOf(text)
925 await update($, combo, () => 0)
926 await update($, alert, () => null)
927 await update($, hero, (cur): Hero => ({
928 ...cur,
929 mode: 'working',
930 action: 'walk',
931 caption: startCaption({ mood, woke: cur.action === 'sleep', holiday: holidayNow(d), hour: d.getHours() }),
932 scene: cur.mode === 'idle' ? (cur.scene + 1) % SCENE_COUNT : cur.scene,
933 steps: 0,
934 since: now,
935 emote: mood === 'thanks' ? 'smitten' : mood === 'scold' ? 'sheepish' : null,
936 emoteUntil: now + MOOD_MS,
937 // Scolded, Claude can't quite look you in the eye.
938 glance: mood === 'scold' ? -1 : cur.glance,
939 glanceUntil: mood === 'scold' ? now + MOOD_MS : cur.glanceUntil,
940 }))
941 ensureTimer($)
942}
943
944async function close($: EngineInterface, caption: string, action: Action = 'cheer') {
945 const now = await $.clock.now()
946 await update($, alert, () => null)
947 await update($, hero, (cur): Hero => ({ ...cur, mode: 'ending', action, caption, since: now, emote: null }))
948 ensureTimer($)
949}
950
951/** An MCP call: a puff in the server's own color as Claude warps a message off. */
952async function onMcp($: EngineInterface, tool: string) {
953 const server = tool.split('__')[1] ?? tool
954 const x = (await read($, hero)).x
955 await addFx($, 'warp', x)
956 await update($, fx, list => list.map(f => (f.kind === 'warp' && f.id === fxId ? { ...f, color: warpColor(server) } : f)))
957}
958
959/** Test output: failures drop bugs into the scene, a green run lets Claude eat them. */
960async function onTests($: EngineInterface, command: string, output: string, isMain: boolean) {
961 const counts = testCounts(command, output)
962 if (!counts) return
963 const had = await read($, bugs)
964 if (counts.failed > 0) {
965 const n = Math.min(counts.failed, 8)
966 await update($, bugs, () => n)
967 if (isMain) {
968 await heroBeat(
969 $,
970 {
971 action: 'trip',
972 caption: tr(
973 `${counts.failed} bug${counts.failed === 1 ? '' : 's'} crawl out of the test run`,
974 `테스트에서 버그 ${counts.failed}마리가 기어 나와요`,
975 ),
976 },
977 0,
978 )
979 }
980 tripUntil = (await $.clock.now()) + TRIP_MS
981 } else if (counts.passed > 0 && had > 0) {
982 if (isMain) {
983 await heroBeat(
984 $,
985 {
986 action: 'cheer',
987 caption: tr(
988 `Claude eats all ${had} bug${had === 1 ? '' : 's'}. Tests are green.`,
989 `Claude가 버그 ${had}마리를 모두 먹어치워요. 테스트 초록불!`,
990 ),
991 },
992 0,
993 )
994 }
995 for (let n = 1; n <= had; n++) $.clock.after(n * 350, () => void update($, bugs, b => Math.max(0, b - 1)))
996 }
997}
998
999/** TodoWrite: pending items are pellets; finishing one is a gobble. */
1000async function onTodos($: EngineInterface, input: Record<string, unknown>) {
1001 const list = Array.isArray(input.todos) ? (input.todos as Record<string, unknown>[]) : []
1002 if (list.length === 0) return
1003 const done = list.filter(t => t.status === 'completed')
1004 const prev = await read($, todos)
1005 const next = done.length === list.length ? null : { done: done.length, total: list.length }
1006 await update($, todos, () => next)
1007 if (prev && done.length > prev.done) {
1008 const last = done[done.length - 1]
1009 const what = typeof last?.content === 'string' ? last.content : tr('a task', '할 일')
1010 await heroBeat(
1011 $,
1012 {
1013 action: 'cheer',
1014 caption: next
1015 ? tr(`Claude gobbles a pellet: ${what}`, `Claude가 알갱이를 꿀꺽해요: ${what}`)
1016 : tr('Claude clears the whole quest list!', 'Claude가 퀘스트 목록을 싹 비워요!'),
1017 },
1018 0,
1019 )
1020 }
1021}
1022
1023/** Clean calls in a row: a combo from 5, a crown at 10. */
1024async function onCombo($: EngineInterface) {
1025 let n = 0
1026 await update($, combo, c => (n = c + 1))
1027 best = Math.max(best, n)
1028 if (n === CROWN_AT) {
1029 await heroBeat(
1030 $,
1031 { action: 'cheer', caption: tr(`${CROWN_AT} clean calls in a row. Claude earns a crown!`, `연속 ${CROWN_AT}번 성공! Claude가 왕관을 얻어요!`) },
1032 0,
1033 )
1034 }
1035}
1036
1037/** A fun Bash command went through: confetti, a paper plane, boxes. */
1038async function onEggs($: EngineInterface, eggs: readonly Egg[], command: string) {
1039 const x = (await read($, hero)).x
1040 for (const egg of eggs) {
1041 if (egg === 'commit') await addFx($, 'confetti', x)
1042 if (egg === 'push') await addFx($, 'plane', x)
1043 if (egg === 'install') await addFx($, 'boxes', Math.min(Math.max(0, cols - 4), x + HERO_W))
1044 }
1045 const done = eggs.map(egg => eggDone(egg, command)).filter(Boolean)
1046 if (done.length > 0) await heroBeat($, { action: 'cheer', caption: done.join('. ') }, 0)
1047}
1048
1049function readLimits(rates: readonly { kind: string; percentUsed: number; resetsAt?: string }[]): Limit[] {
1050 return rates.map(r => ({ kind: limitLabel(r.kind), percent: Math.round(r.percentUsed), resetsAt: r.resetsAt }))
1051}
1052
1053/** 'five_hour' and friends as the short names people know. */
1054function limitLabel(kind: string): string {
1055 if (/five|5.?h/i.test(kind)) return '5h'
1056 if (/seven|7.?d|week/i.test(kind)) return '7d'
1057 return kind.replace(/_/g, ' ')
1058}
1059
1060function topLimit(list: readonly Limit[]): Limit | null {
1061 let top: Limit | null = null
1062 for (const l of list) if (!top || l.percent > top.percent) top = l
1063 return top
1064}
1065
1066/** Restarts the tick at the new pace. */
1067async function setCalm($: EngineInterface, on: boolean) {
1068 isCalm = on
1069 await update($, calm, () => on)
1070 timer?.cancel()
1071 timer = undefined
1072 const cur = await read($, hero)
1073 if (cur.mode !== 'idle' || (await read($, workers)).length > 0) ensureTimer($)
1074}
1075
1076function runDemo($: EngineInterface) {
1077 const at = (ms: number, fn: () => Promise<unknown> | unknown) => $.clock.after(ms, () => void fn())
1078 const steps: [number, string, Record<string, unknown>][] = [
1079 [1500, 'Grep', { pattern: 'register' }],
1080 [4000, 'Read', { file_path: '/src/hooks/register.tsx' }],
1081 [6500, 'Agent', { description: tr('Review the diff', 'diff 검토') }],
1082 [9000, 'Edit', { file_path: '/src/hooks/register.tsx' }],
1083 [15000, 'WebSearch', { query: tr('half block pixel art', '하프 블록 픽셀 아트') }],
1084 ]
1085 // It opens on an egg and ends with the hatchling growing up.
1086 demoGrowth = 'shell'
1087 at(0, async () => {
1088 const now = await $.clock.now()
1089 await update($, hero, (h): Hero => ({ ...h, action: 'hatch', caption: tr('An egg wobbles…', '알이 흔들흔들해요…'), since: now }))
1090 })
1091 at(1100, () => heroBeat($, { action: 'cheer', caption: tr('A Clawd hatches! Hello!', 'Clawd가 알을 깨고 나와요! 안녕!') }, 0))
1092 for (const [ms, tool, args] of steps) at(ms, () => heroBeat($, beat(tool, args, `demo${ms}`)))
1093 at(2000, () => onTodos($, { todos: [1, 2, 3, 4, 5].map(n => ({ content: tr(`Task ${n}`, `할 일 ${n}`), status: n === 1 ? 'in_progress' : 'pending' })) }))
1094 at(3000, () => update($, context, () => 74)) // rain
1095 at(11000, () => heroBeat($, { action: 'run', caption: tr('Claude runs off to run the test suite', 'Claude가 달려가요: 테스트 전체 실행') }))
1096 at(12000, () => onTests($, 'npm test', 'Tests: 3 failed, 9 passed', true))
1097 at(17500, () => update($, alert, () => tr('Claude needs you: allow Bash(rm -rf build)?', 'Claude가 부르고 있어요: Bash(rm -rf build) 허용할까요?')))
1098 at(21000, () => update($, alert, () => null))
1099 at(21500, () => onTests($, 'npm test', 'Tests: 0 failed, 12 passed', true))
1100 at(23000, () => onTodos($, { todos: [1, 2, 3, 4, 5].map(n => ({ content: tr(`Task ${n}`, `할 일 ${n}`), status: n <= 3 ? 'completed' : 'pending' })) }))
1101 at(24000, () => update($, context, () => 90)) // storm
1102 const crew: [string, string, string, Action[], 'done' | 'failed'][] = [
1103 ['demo-1', tr('Review the diff', 'diff 검토'), 'claude-sonnet-5-5', ['read', 'sneak', 'read'], 'done'],
1104 ['demo-2', tr('Judge the art', '그림 심사'), 'claude-opus-5-5', ['read', 'dig', 'run'], 'done'],
1105 ['demo-3', tr('Scout old toys', '옛 장난감 정찰'), 'claude-haiku-4-5', ['fly', 'fly', 'sneak'], 'failed'],
1106 ['demo-4', tr('Write the fable', '우화 쓰기'), 'claude-fable-5-1', ['dig', 'read', 'dig'], 'done'],
1107 ]
1108 crew.forEach(([id, label, model, acts, end], n) => {
1109 const t = 7000 + n * 800
1110 at(t, () => addWorker($, id, label, model))
1111 acts.forEach((a, i) => at(t + 1500 + i * 2500, () => workerBeat($, id, a, false)))
1112 at(t + 1500 + acts.length * 2500 + n * 600, () => finish($, id, end))
1113 })
1114 at(16500, () => heroBeat($, beat('mcp__claude_ai_Gmail__search_threads', {}, 'demo16500')))
1115 at(16500, () => onMcp($, 'mcp__claude_ai_Gmail__search_threads'))
1116 at(24500, () => maybeVisitor($, 'whale'))
1117 at(25000, () => heroBeat($, eggBeat('commit', tr('git commit -m "Teach Clawd new tricks"', 'git commit -m "Clawd에게 새 재주 가르치기"'))))
1118 at(26000, async () => {
1119 await heroBeat($, { action: 'cheer', caption: eggDone('commit', tr('git commit -m "Teach Clawd new tricks"', 'git commit -m "Clawd에게 새 재주 가르치기"')) ?? '' }, 0)
1120 await addFx($, 'confetti', (await read($, hero)).x)
1121 })
1122 at(27000, () => update($, context, () => 30)) // the storm passes before the big moment
1123 at(28000, async () => {
1124 demoGrowth = 'explorer'
1125 await close($, growCaption('explorer', null))
1126 await addFx($, 'confetti', (await read($, hero)).x)
1127 })
1128 at(46000, () => (demoGrowth = null))
1129 at(40000, () => update($, todos, () => null)) // the fable's quest list leaves with it
1130}
1131
1132/** clawd-tales의 `session.start` 훅. 위 register가 등록한다. */
1133async function talesSessionStart($: EngineInterface, e: any, next: (e: any) => Promise<any>): Promise<any> {
1134 await $.command.register({
1135 name: 'tales',
1136 description: tr('Clawd tales: on, off, calm, lively, demo, hat, face, scarf', 'Clawd 이야기: on, off, calm, lively, demo, hat, face, scarf'),
1137 })
1138 sessionTokens = 0
1139 tokensSeen = 0
1140 const hatSaved = await $.store.get('hat')
1141 wornHat = HATS_TO_WEAR.includes(hatSaved as Hat) ? (hatSaved as Hat) : null
1142 const faceSaved = await $.store.get('face')
1143 wornFace = FACES_TO_WEAR.includes(faceSaved as Face) ? (faceSaved as Face) : null
1144 stats = readStats(await $.store.get('stats'))
1145 scarfOn = (await $.store.get('scarf')) !== false
1146 try {
1147 scarfKey = scarfColor(await $.session.root())
1148 } catch {
1149 scarfKey = null
1150 }
1151 const saved = await $.store.get('isOn')
1152 if (typeof saved === 'boolean') await update($, isOn, () => saved)
1153 const savedCalm = await $.store.get('calm')
1154 isCalm = savedCalm === true
1155 await update($, calm, () => isCalm)
1156 try {
1157 const usage = await $.session.usage()
1158 await update($, context, () => usage.context.percent ?? null)
1159 await update($, limits, () => readLimits(usage.rateLimits))
1160 } catch {
1161 // No usage yet: the first measure fills it in.
1162 }
1163 if ((await read($, shiny)) === null) {
1164 const lucky = Math.random() < SHINY_ODDS
1165 await update($, shiny, () => lucky)
1166 if (lucky) await $.ui.toast(tr('✨ A shiny Clawd appeared this session!', '✨ 이번 세션에 반짝이는 Clawd가 나타났어요!'))
1167 }
1168 if ((await read($, hero)).mode !== 'idle' || (await read($, workers)).length > 0) ensureTimer($)
1169 return next(e)
1170}
1171
1172/** clawd-tales의 `command.run` tales 훅. 위 register가 등록한다. */
1173async function talesCommand($: EngineInterface, e: any): Promise<any> {
1174 const arg = (e.args ?? '').trim().toLowerCase()
1175 if (arg === 'off' || arg === 'on') {
1176 await update($, isOn, () => arg === 'on')
1177 await $.store.set('isOn', arg === 'on')
1178 return { text: tr(`Clawd tales ${arg}.`, `Clawd 이야기를 ${arg === 'on' ? '켰어요' : '껐어요'}.`) }
1179 }
1180 if (arg === 'calm' || arg === 'lively') {
1181 await setCalm($, arg === 'calm')
1182 await $.store.set('calm', arg === 'calm')
1183 return {
1184 text:
1185 arg === 'calm'
1186 ? tr('Calm: one beat a second, Claude stays put between tool calls.', '차분 모드: 1초에 한 박자, 도구 호출 사이엔 Claude가 제자리에 있어요.')
1187 : tr('Lively: 5 fps, wandering on.', '활기 모드: 초당 5프레임, Claude가 돌아다녀요.'),
1188 }
1189 }
1190 const [word, name] = arg.split(/\s+/)
1191 if (word === 'scarf') {
1192 if (name !== 'on' && name !== 'off') {
1193 return {
1194 text: tr(
1195 `The scarf is ${scarfOn ? 'on' : 'off'}. Use /tales scarf on|off.`,
1196 `스카프를 ${scarfOn ? '두르고' : '벗고'} 있어요. 바꾸기: /tales scarf on|off`,
1197 ),
1198 }
1199 }
1200 scarfOn = name === 'on'hooks/command.ts 38 lines1// `/spinner`'s arguments to what they ask for; register.tsx carries it out.
2import { isThemeName } from './themes'
3import type { ThemeName } from './themes'
4
5export type SpinnerCommand =
6 | { kind: 'status' }
7 | { kind: 'visible'; isOn: boolean }
8 | { kind: 'stage'; isOn: boolean }
9 | { kind: 'companion'; isOn: boolean }
10 | { kind: 'pat' }
11 | { kind: 'preview'; theme: ThemeName | null }
12 | { kind: 'theme'; theme: ThemeName | 'random' }
13 | { kind: 'pick' }
14 | { kind: 'unknown'; name: string }
15
16export const USAGE = '[theme|random|pet|preview|off|on|stage off|companion off]'
17
18export function parseCommand(args: string): SpinnerCommand {
19 const [verb = '', arg = ''] = args.trim().toLowerCase().split(/\s+/)
20 const isSwitch = arg === 'off' || arg === 'on'
21 if (verb === '') return { kind: 'status' }
22 if (verb === 'off' || verb === 'on') return { kind: 'visible', isOn: verb === 'on' }
23 if (verb === 'stage' && isSwitch) return { kind: 'stage', isOn: arg === 'on' }
24 if (verb === 'companion' && isSwitch) return { kind: 'companion', isOn: arg === 'on' }
25 if (verb === 'pet') return { kind: 'pat' }
26 if (verb === 'preview') {
27 if (!arg) return { kind: 'preview', theme: null }
28 return isThemeName(arg) ? { kind: 'preview', theme: arg } : { kind: 'unknown', name: arg }
29 }
30 // `theme` alone asks which; `theme <name>` is the same as `<name>`.
31 if (verb === 'theme') {
32 if (!arg) return { kind: 'pick' }
33 return arg === 'random' || isThemeName(arg) ? { kind: 'theme', theme: arg } : { kind: 'unknown', name: arg }
34 }
35 if (verb === 'random' || isThemeName(verb)) return { kind: 'theme', theme: verb }
36 return { kind: 'unknown', name: verb }
37}
38hooks/config.ts 39 lines1// spinner's options (plugin.json `userConfig`), read once into a typed config.
2import type { PluginOptions } from 'claude-code'
3
4import { flag, oneOf, text } from './kit/options'
5import { THEME_NAMES } from './themes'
6import type { ThemeName } from './themes'
7
8export type Choice = ThemeName | 'random'
9
10export const CHOICES: readonly Choice[] = ['random', ...THEME_NAMES]
11
12export type Config = {
13 /** A theme, or `random` for a new one each session. */
14 theme: Choice
15 isVisible: boolean
16 /** A Spinner button in the prompt footer. */
17 hasFooterButton: boolean
18 hasStage: boolean
19 hasFinale: boolean
20 hasCompanion: boolean
21 /** Every animation drawn as one still frame. */
22 isStill: boolean
23 /** `auto` or a language; the kit resolves it (kit/lang.ts). */
24 language: string
25}
26
27export function readConfig(options: PluginOptions): Config {
28 return {
29 theme: oneOf(options.theme, CHOICES, 'random'),
30 isVisible: flag(options.visible, true),
31 hasFooterButton: flag(options.footerButton, true),
32 hasStage: flag(options.stage, true),
33 hasFinale: flag(options.celebrate, true),
34 hasCompanion: flag(options.companion, true),
35 isStill: flag(options.reducedMotion, false),
36 language: text(options.language, 'auto'),
37 }
38}
39hooks/i18n.ts 407 lines1// pixel-pals 장면 쪽 메시지(원본 spinner); 언어는 kit/lang.ts가 정한다.
2import { createMessages } from './kit/lang'
3import type { Lang } from './kit/lang'
4
5export { parseLanguage, resolveLanguage } from './kit/lang'
6
7export type Key =
8 | 'cmd.description'
9 | 'cmd.status'
10 | 'cmd.randomNote'
11 | 'cmd.themes'
12 | 'cmd.usage'
13 | 'cmd.switched'
14 | 'cmd.random'
15 | 'cmd.hidden'
16 | 'cmd.shown'
17 | 'cmd.stageOff'
18 | 'cmd.stageOn'
19 | 'cmd.preview'
20 | 'cmd.unknown'
21 | 'cmd.pick'
22 | 'cmd.talesPreview'
23 | 'finale.done'
24 | 'finale.aborted'
25 | 'finale.error'
26 | 'pet.hello'
27 | 'pet.ask'
28 | 'pet.ready'
29 | 'pet.aborted'
30 | 'pet.error'
31 | 'pet.sleep'
32 | 'pet.levelUp'
33 | 'pet.testPass'
34 | 'pet.testFail'
35 | 'pet.commit'
36 | 'cmd.pat'
37 | 'cmd.petStats'
38 | 'cmd.companionOff'
39 | 'cmd.companionOn'
40 | 'cmd.audioOn'
41 | 'cmd.audioOff'
42
43export const MESSAGES: Record<Lang, Record<Key, string>> = {
44 en: {
45 'cmd.description': 'Pixel pals: themes, a companion pet, preview, off / on (kept across sessions)',
46 'cmd.status': 'Theme: {theme}',
47 'cmd.randomNote': ' (a new one each turn)',
48 'cmd.themes': 'Themes: {list}',
49 'cmd.usage': 'Usage: /pals <theme|random> · theme (pick) · pet · preview [theme] · off / on · stage off / on · companion off / on',
50 'cmd.switched': 'Switched to {theme}',
51 'cmd.random': 'A random theme each turn; this one: {theme}',
52 'cmd.hidden': 'Pixel pals off',
53 'cmd.shown': 'Pixel pals on',
54 'cmd.stageOff': 'Animation band above the prompt off',
55 'cmd.stageOn': 'Animation band above the prompt on',
56 'cmd.preview': 'Previewing {theme} above the prompt',
57 'cmd.unknown': 'No theme called {name}. Themes: {list}',
58 'cmd.pick': 'Which theme? (Other: type one of {list})',
59 'cmd.talesPreview': 'Preview tales with /tales demo (a 45-second story)',
60 'finale.done': 'Done · {time}',
61 'finale.aborted': 'Interrupted',
62 'finale.error': 'Something went wrong',
63 'pet.hello': 'I’m here with you~',
64 'pet.ask': 'Waiting for your OK~',
65 'pet.ready': 'All done, take a look!',
66 'pet.aborted': 'Okay, stopped.',
67 'pet.error': 'Ouch… something broke',
68 'pet.sleep': 'Zzz…',
69 'pet.levelUp': '{theme} reached Lv.{level}!',
70 'pet.testPass': 'Tests passed!',
71 'pet.testFail': 'Tests failed… we’ll fix it',
72 'pet.commit': 'Committed!',
73 'cmd.pat': 'You patted {theme} (♥{love})',
74 'cmd.petStats': '{theme} · Lv.{level} · {xp} xp · ♥{love}',
75 'cmd.companionOff': 'Companion between turns hidden',
76 'cmd.companionOn': 'Companion between turns shown',
77 'cmd.audioOn': 'Audio: showing this computer\'s sound output (only levels, nothing kept or sent)',
78 'cmd.audioOff': 'Audio: nothing to show ({reason}). Needs macOS 14.2+, swiftc (Xcode Command Line Tools) and System Audio Recording allowed for your terminal',
79 },
80 'zh-Hans': {
81 'cmd.description': '运行动画:切换主题、宠物伴侣、预览、off / on(跨会话保持)',
82 'cmd.status': '当前主题:{theme}',
83 'cmd.randomNote': '(每个会话随机)',
84 'cmd.themes': '可选主题:{list}',
85 'cmd.usage': '用法:/pals <主题|random> · theme 弹窗选择 · pet 摸摸 · preview [主题] · off / on · stage off / on · companion off / on',
86 'cmd.switched': '已切换到 {theme}',
87 'cmd.random': '每个会话随机一个主题,这次是 {theme}',
88 'cmd.hidden': '运行动画已关闭',
89 'cmd.shown': '运行动画已开启',
90 'cmd.stageOff': '输入框上方的动画带已关闭',
91 'cmd.stageOn': '输入框上方的动画带已开启',
92 'cmd.preview': '正在输入框上方预览 {theme}',
93 'cmd.unknown': '没有叫 {name} 的主题。可选:{list}',
94 'cmd.pick': '换哪个主题?(其他里可输入:{list})',
95 'cmd.talesPreview': 'Preview tales with /tales demo (a 45-second story)',
96 'finale.done': '完成 · {time}',
97 'finale.aborted': '已中断',
98 'finale.error': '出错了',
99 'pet.hello': '我在这儿陪你~',
100 'pet.ask': '等你确认一下~',
101 'pet.ready': '做完啦,快看看!',
102 'pet.aborted': '好吧,停下了',
103 'pet.error': '呜…出错了',
104 'pet.sleep': 'Zzz…',
105 'pet.levelUp': '{theme} 升到 Lv.{level} 啦!',
106 'pet.testPass': '测试通过啦!',
107 'pet.testFail': '测试没过…一起修吧',
108 'pet.commit': '提交好啦!',
109 'cmd.pat': '你摸了摸 {theme}(♥{love})',
110 'cmd.petStats': '{theme} · Lv.{level} · 经验 {xp} · ♥{love}',
111 'cmd.companionOff': '空闲时的宠物已隐藏',
112 'cmd.companionOn': '空闲时的宠物已显示',
113 'cmd.audioOn': '音频:正在显示本机的声音输出(只取电平,不保存、不外传)',
114 'cmd.audioOff': '音频:无法显示({reason})。需要 macOS 14.2 及以上、swiftc(Xcode 命令行工具),并在系统设置里允许终端“录制系统音频”',
115 },
116 'zh-Hant': {
117 'cmd.description': '執行動畫:切換主題、寵物夥伴、預覽、off / on(跨工作階段保留)',
118 'cmd.status': '目前主題:{theme}',
119 'cmd.randomNote': '(每個工作階段隨機)',
120 'cmd.themes': '可選主題:{list}',
121 'cmd.usage': '用法:/pals <主題|random> · theme 彈窗選擇 · pet 摸摸 · preview [主題] · off / on · stage off / on · companion off / on',
122 'cmd.switched': '已切換到 {theme}',
123 'cmd.random': '每個工作階段隨機一個主題,這次是 {theme}',
124 'cmd.hidden': '執行動畫已關閉',
125 'cmd.shown': '執行動畫已開啟',
126 'cmd.stageOff': '輸入框上方的動畫帶已關閉',
127 'cmd.stageOn': '輸入框上方的動畫帶已開啟',
128 'cmd.preview': '正在輸入框上方預覽 {theme}',
129 'cmd.unknown': '沒有名為 {name} 的主題。可選:{list}',
130 'cmd.pick': '換哪個主題?(其他裡可輸入:{list})',
131 'cmd.talesPreview': 'Preview tales with /tales demo (a 45-second story)',
132 'finale.done': '完成 · {time}',
133 'finale.aborted': '已中斷',
134 'finale.error': '出錯了',
135 'pet.hello': '我在這裡陪你~',
136 'pet.ask': '等你確認一下~',
137 'pet.ready': '做完了,快看看!',
138 'pet.aborted': '好吧,停下了',
139 'pet.error': '嗚…出錯了',
140 'pet.sleep': 'Zzz…',
141 'pet.levelUp': '{theme} 升到 Lv.{level} 了!',
142 'pet.testPass': '測試通過了!',
143 'pet.testFail': '測試沒過…一起修吧',
144 'pet.commit': '提交好了!',
145 'cmd.pat': '你摸了摸 {theme}(♥{love})',
146 'cmd.petStats': '{theme} · Lv.{level} · 經驗 {xp} · ♥{love}',
147 'cmd.companionOff': '閒置時的寵物已隱藏',
148 'cmd.companionOn': '閒置時的寵物已顯示',
149 'cmd.audioOn': '音訊:正在顯示本機的聲音輸出(只取音量,不儲存、不外傳)',
150 'cmd.audioOff': '音訊:無法顯示({reason})。需要 macOS 14.2 以上、swiftc(Xcode 命令列工具),並在系統設定允許終端機「錄製系統音訊」',
151 },
152 ja: {
153 'cmd.description': '実行中アニメーション:テーマ、ペット、プレビュー、off / on(セッションをまたいで保持)',
154 'cmd.status': 'テーマ:{theme}',
155 'cmd.randomNote': '(セッションごとにランダム)',
156 'cmd.themes': 'テーマ一覧:{list}',
157 'cmd.usage': '使い方:/pals <テーマ|random> · theme 選択 · pet なでる · preview [テーマ] · off / on · stage off / on · companion off / on',
158 'cmd.switched': '{theme} に切り替えました',
159 'cmd.random': 'セッションごとにランダムなテーマ。今回は {theme}',
160 'cmd.hidden': '実行中アニメーションをオフにしました',
161 'cmd.shown': '実行中アニメーションをオンにしました',
162 'cmd.stageOff': '入力欄上のアニメーションバーをオフにしました',
163 'cmd.stageOn': '入力欄上のアニメーションバーをオンにしました',
164 'cmd.preview': '入力欄の上で {theme} をプレビュー中',
165 'cmd.unknown': '{name} というテーマはありません。テーマ一覧:{list}',
166 'cmd.pick': 'どのテーマにする?(その他で入力可:{list})',
167 'cmd.talesPreview': 'Preview tales with /tales demo (a 45-second story)',
168 'finale.done': '完了 · {time}',
169 'finale.aborted': '中断しました',
170 'finale.error': 'エラーが発生しました',
171 'pet.hello': 'そばにいるよ~',
172 'pet.ask': '確認を待ってるよ~',
173 'pet.ready': 'できたよ、見てみて!',
174 'pet.aborted': 'はい、止めたよ',
175 'pet.error': 'うぅ…エラーだ',
176 'pet.sleep': 'Zzz…',
177 'pet.levelUp': '{theme} が Lv.{level} になった!',
178 'pet.testPass': 'テスト通った!',
179 'pet.testFail': 'テスト失敗…直そう',
180 'pet.commit': 'コミットした!',
181 'cmd.pat': '{theme} をなでた(♥{love})',
182 'cmd.petStats': '{theme} · Lv.{level} · 経験値 {xp} · ♥{love}',
183 'cmd.companionOff': '待機中のペットを非表示にしました',
184 'cmd.companionOn': '待機中のペットを表示しました',
185 'cmd.audioOn': 'オーディオ:このコンピュータの音声出力を表示中(レベルのみ、保存・送信なし)',
186 'cmd.audioOff': 'オーディオ:表示できません({reason})。macOS 14.2 以降、swiftc(Xcode コマンドラインツール)、ターミナルへの「システムオーディオ録音」の許可が必要です',
187 },
188 ko: {
189 'cmd.description': '픽셀 친구들: 테마, 펫, 미리 보기, off / on (세션 간 유지)',
190 'cmd.status': '현재 테마: {theme}',
191 'cmd.randomNote': ' (턴마다 무작위)',
192 'cmd.themes': '테마 목록: {list}',
193 'cmd.usage': '사용법: /pals <테마|random> · theme 선택 · pet 쓰다듬기 · preview [테마] · off / on · stage off / on · companion off / on',
194 'cmd.switched': '{theme}(으)로 바꿨어요',
195 'cmd.random': '턴마다 무작위 테마, 이번에는 {theme}',
196 'cmd.hidden': '픽셀 친구들을 숨겼어요',
197 'cmd.shown': '픽셀 친구들을 다시 보여줘요',
198 'cmd.stageOff': '입력창 위 애니메이션 띠를 껐어요',
199 'cmd.stageOn': '입력창 위 애니메이션 띠를 켰어요',
200 'cmd.preview': '입력창 위에서 {theme} 미리 보는 중',
201 'cmd.unknown': '{name} 테마가 없어요. 테마 목록: {list}',
202 'cmd.pick': '어떤 테마로 할까요? (기타에 입력: {list})',
203 'cmd.talesPreview': 'tales는 /tales demo로 45초짜리 이야기를 미리 볼 수 있어요',
204 'finale.done': '완료 · {time}',
205 'finale.aborted': '중단됨',
206 'finale.error': '오류가 났어요',
207 'pet.hello': '여기서 함께할게요~',
208 'pet.ask': '확인을 기다려요~',
209 'pet.ready': '다 했어요, 확인해 보세요!',
210 'pet.aborted': '네, 멈췄어요',
211 'pet.error': '으… 오류가 났어요',
212 'pet.sleep': 'Zzz…',
213 'pet.levelUp': '{theme} Lv.{level} 달성!',
214 'pet.testPass': '테스트 통과!',
215 'pet.testFail': '테스트 실패… 같이 고쳐요',
216 'pet.commit': '커밋 완료!',
217 'cmd.pat': '{theme}을(를) 쓰다듬었어요 (♥{love})',
218 'cmd.petStats': '{theme} · Lv.{level} · 경험치 {xp} · ♥{love}',
219 'cmd.companionOff': '대기 중 펫을 숨겼어요',
220 'cmd.companionOn': '대기 중 펫을 보여줘요',
221 'cmd.audioOn': '오디오: 이 컴퓨터의 소리 출력을 표시 중 (레벨만, 저장·전송 없음)',
222 'cmd.audioOff': '오디오: 표시할 수 없음 ({reason}). macOS 14.2 이상, swiftc(Xcode 명령줄 도구), 터미널의 \'시스템 오디오 녹음\' 허용이 필요합니다',
223 },
224 es: {
225 'cmd.description': 'Animaciones del spinner: temas, mascota, vista previa, off / on (se mantiene entre sesiones)',
226 'cmd.status': 'Tema: {theme}',
227 'cmd.randomNote': ' (al azar en cada sesión)',
228 'cmd.themes': 'Temas: {list}',
229 'cmd.usage': 'Uso: /pals <tema|random> · theme (elegir) · pet · preview [tema] · off / on · stage off / on · companion off / on',
230 'cmd.switched': 'Cambiado a {theme}',
231 'cmd.random': 'Un tema al azar en cada sesión; esta vez: {theme}',
232 'cmd.hidden': 'Animaciones del spinner desactivadas',
233 'cmd.shown': 'Animaciones del spinner activadas',
234 'cmd.stageOff': 'Barra animada sobre el prompt desactivada',
235 'cmd.stageOn': 'Barra animada sobre el prompt activada',
236 'cmd.preview': 'Vista previa de {theme} sobre el prompt',
237 'cmd.unknown': 'No hay ningún tema llamado {name}. Temas: {list}',
238 'cmd.pick': '¿Qué tema? (En Otro escribe uno de: {list})',
239 'cmd.talesPreview': 'Preview tales with /tales demo (a 45-second story)',
240 'finale.done': 'Listo · {time}',
241 'finale.aborted': 'Interrumpido',
242 'finale.error': 'Algo salió mal',
243 'pet.hello': '¡Aquí estoy contigo!',
244 'pet.ask': 'Esperando tu OK~',
245 'pet.ready': '¡Listo, échale un vistazo!',
246 'pet.aborted': 'Vale, me detengo.',
247 'pet.error': 'Ay… algo falló',
248 'pet.sleep': 'Zzz…',
249 'pet.levelUp': '¡{theme} subió a Nv.{level}!',
250 'pet.testPass': '¡Pasan las pruebas!',
251 'pet.testFail': 'Fallan las pruebas… lo arreglamos',
252 'pet.commit': '¡Commit hecho!',
253 'cmd.pat': 'Acariciaste a {theme} (♥{love})',
254 'cmd.petStats': '{theme} · Nv.{level} · {xp} xp · ♥{love}',
255 'cmd.companionOff': 'Mascota entre turnos oculta',
256 'cmd.companionOn': 'Mascota entre turnos visible',
257 'cmd.audioOn': 'Audio: mostrando la salida de sonido del equipo (solo niveles; nada se guarda ni se envía)',
258 'cmd.audioOff': 'Audio: nada que mostrar ({reason}). Requiere macOS 14.2+, swiftc (Xcode Command Line Tools) y permitir Grabación de audio del sistema a tu terminal',
259 },
260 fr: {
261 'cmd.description': 'Animations du spinner : thèmes, compagnon, aperçu, off / on (réglage conservé entre les sessions)',
262 'cmd.status': 'Thème : {theme}',
263 'cmd.randomNote': ' (au hasard à chaque session)',
264 'cmd.themes': 'Thèmes : {list}',
265 'cmd.usage': 'Usage : /pals <thème|random> · theme (choisir) · pet · preview [thème] · off / on · stage off / on · companion off / on',
266 'cmd.switched': 'Thème {theme} activé',
267 'cmd.random': 'Un thème au hasard à chaque session ; cette fois : {theme}',
268 'cmd.hidden': 'Animations du spinner désactivées',
269 'cmd.shown': 'Animations du spinner activées',
270 'cmd.stageOff': 'Bande animée au-dessus du prompt désactivée',
271 'cmd.stageOn': 'Bande animée au-dessus du prompt activée',
272 'cmd.preview': 'Aperçu de {theme} au-dessus du prompt',
273 'cmd.unknown': 'Aucun thème nommé {name}. Thèmes : {list}',
274 'cmd.pick': 'Quel thème ? (Autre : tape l’un de {list})',
275 'cmd.talesPreview': 'Preview tales with /tales demo (a 45-second story)',
276 'finale.done': 'Terminé · {time}',
277 'finale.aborted': 'Interrompu',
278 'finale.error': 'Une erreur est survenue',
279 'pet.hello': 'Je suis là avec toi~',
280 'pet.ask': 'J’attends ton accord~',
281 'pet.ready': 'Fini, jette un œil\u00a0!',
282 'pet.aborted': 'D’accord, j’arrête.',
283 'pet.error': 'Aïe… une erreur',
284 'pet.sleep': 'Zzz…',
285 'pet.levelUp': '{theme} passe au niv.\u00a0{level}\u00a0!',
286 'pet.testPass': 'Tests réussis\u00a0!',
287 'pet.testFail': 'Tests en échec… on répare',
288 'pet.commit': 'Commit fait\u00a0!',
289 'cmd.pat': 'Tu as caressé {theme} (♥{love})',
290 'cmd.petStats': '{theme} · niv.\u00a0{level} · {xp}\u00a0xp · ♥{love}',
291 'cmd.companionOff': 'Compagnon entre les tours masqué',
292 'cmd.companionOn': 'Compagnon entre les tours affiché',
293 'cmd.audioOn': 'Audio : affiche la sortie son de l’ordinateur (niveaux seulement, rien n’est gardé ni envoyé)',
294 'cmd.audioOff': 'Audio : rien à afficher ({reason}). Il faut macOS 14.2+, swiftc (outils en ligne de commande Xcode) et autoriser l’enregistrement audio du système pour votre terminal',
295 },
296 de: {
297 'cmd.description': 'Spinner-Animationen: Themen, Begleiter, Vorschau, off / on (bleibt über Sitzungen erhalten)',
298 'cmd.status': 'Thema: {theme}',
299 'cmd.randomNote': ' (jede Sitzung zufällig)',
300 'cmd.themes': 'Themen: {list}',
301 'cmd.usage': 'Verwendung: /pals <Thema|random> · theme (auswählen) · pet · preview [Thema] · off / on · stage off / on · companion off / on',
302 'cmd.switched': 'Zu {theme} gewechselt',
303 'cmd.random': 'Jede Sitzung ein zufälliges Thema; diesmal: {theme}',
304 'cmd.hidden': 'Spinner-Animationen aus',
305 'cmd.shown': 'Spinner-Animationen an',
306 'cmd.stageOff': 'Animationsleiste über der Eingabe aus',
307 'cmd.stageOn': 'Animationsleiste über der Eingabe an',
308 'cmd.preview': 'Vorschau von {theme} über der Eingabe',
309 'cmd.unknown': 'Kein Thema namens {name}. Themen: {list}',
310 'cmd.pick': 'Welches Thema? (Unter Andere eintippen: {list})',
311 'cmd.talesPreview': 'Preview tales with /tales demo (a 45-second story)',
312 'finale.done': 'Fertig · {time}',
313 'finale.aborted': 'Abgebrochen',
314 'finale.error': 'Etwas ist schiefgelaufen',
315 'pet.hello': 'Ich bin bei dir~',
316 'pet.ask': 'Warte auf dein OK~',
317 'pet.ready': 'Fertig, schau mal!',
318 'pet.aborted': 'Okay, angehalten.',
319 'pet.error': 'Autsch… ein Fehler',
320 'pet.sleep': 'Zzz…',
321 'pet.levelUp': '{theme} ist jetzt Lv.{level}!',
322 'pet.testPass': 'Tests bestanden!',
323 'pet.testFail': 'Tests rot… wir reparieren das',
324 'pet.commit': 'Committet!',
325 'cmd.pat': 'Du hast {theme} gestreichelt (♥{love})',
326 'cmd.petStats': '{theme} · Lv.{level} · {xp} EP · ♥{love}',
327 'cmd.companionOff': 'Begleiter zwischen den Runden ausgeblendet',
328 'cmd.companionOn': 'Begleiter zwischen den Runden eingeblendet',
329 'cmd.audioOn': 'Audio: zeigt die Tonausgabe dieses Computers (nur Pegel, nichts wird gespeichert oder gesendet)',
330 'cmd.audioOff': 'Audio: nichts anzuzeigen ({reason}). Benötigt macOS 14.2+, swiftc (Xcode Command Line Tools) und die Freigabe „Systemaudioaufnahme“ für dein Terminal',
331 },
332 'pt-BR': {
333 'cmd.description': 'Animações do spinner: temas, mascote, prévia, off / on (configuração mantida entre sessões)',
334 'cmd.status': 'Tema: {theme}',
335 'cmd.randomNote': ' (aleatório a cada sessão)',
336 'cmd.themes': 'Temas: {list}',
337 'cmd.usage': 'Uso: /pals <tema|random> · theme (escolher) · pet · preview [tema] · off / on · stage off / on · companion off / on',
338 'cmd.switched': 'Trocado para {theme}',
339 'cmd.random': 'Um tema aleatório a cada sessão; desta vez: {theme}',
340 'cmd.hidden': 'Animações do spinner desativadas',
341 'cmd.shown': 'Animações do spinner ativadas',
342 'cmd.stageOff': 'Faixa animada acima do prompt desativada',
343 'cmd.stageOn': 'Faixa animada acima do prompt ativada',
344 'cmd.preview': 'Prévia de {theme} acima do prompt',
345 'cmd.unknown': 'Nenhum tema chamado {name}. Temas: {list}',
346 'cmd.pick': 'Qual tema? (Em Outro digite um de: {list})',
347 'cmd.talesPreview': 'Preview tales with /tales demo (a 45-second story)',
348 'finale.done': 'Pronto · {time}',
349 'finale.aborted': 'Interrompido',
350 'finale.error': 'Algo deu errado',
351 'pet.hello': 'Estou aqui com você~',
352 'pet.ask': 'Esperando seu OK~',
353 'pet.ready': 'Pronto, dá uma olhada!',
354 'pet.aborted': 'Tá, parei.',
355 'pet.error': 'Ai… deu erro',
356 'pet.sleep': 'Zzz…',
357 'pet.levelUp': '{theme} chegou ao Nv.{level}!',
358 'pet.testPass': 'Testes passaram!',
359 'pet.testFail': 'Testes falharam… vamos consertar',
360 'pet.commit': 'Commit feito!',
361 'cmd.pat': 'Você fez carinho em {theme} (♥{love})',
362 'cmd.petStats': '{theme} · Nv.{level} · {xp} xp · ♥{love}',
363 'cmd.companionOff': 'Mascote entre turnos oculto',
364 'cmd.companionOn': 'Mascote entre turnos visível',
365 'cmd.audioOn': 'Áudio: mostrando a saída de som do computador (só níveis; nada é guardado nem enviado)',
366 'cmd.audioOff': 'Áudio: nada para mostrar ({reason}). Requer macOS 14.2+, swiftc (Xcode Command Line Tools) e permitir Gravação de áudio do sistema ao seu terminal',
367 },
368 ru: {
369 'cmd.description': 'Анимации спиннера: темы, питомец, предпросмотр, off / on (сохраняется между сессиями)',
370 'cmd.status': 'Тема: {theme}',
371 'cmd.randomNote': ' (случайная в каждой сессии)',
372 'cmd.themes': 'Темы: {list}',
373 'cmd.usage': 'Использование: /pals <тема|random> · theme (выбор) · pet · preview [тема] · off / on · stage off / on · companion off / on',
374 'cmd.switched': 'Тема переключена на {theme}',
375 'cmd.random': 'Случайная тема в каждой сессии; сейчас: {theme}',
376 'cmd.hidden': 'Анимации спиннера выключены',
377 'cmd.shown': 'Анимации спиннера включены',
378 'cmd.stageOff': 'Анимированная полоса над вводом выключена',
379 'cmd.stageOn': 'Анимированная полоса над вводом включена',
380 'cmd.preview': 'Предпросмотр {theme} над вводом',
381 'cmd.unknown': 'Темы {name} нет. Темы: {list}',
382 'cmd.pick': 'Какую тему? (В «Другое» введите одну из: {list})',
383 'cmd.talesPreview': 'Preview tales with /tales demo (a 45-second story)',
384 'finale.done': 'Готово · {time}',
385 'finale.aborted': 'Прервано',
386 'finale.error': 'Что-то пошло не так',
387 'pet.hello': 'Я рядом~',
388 'pet.ask': 'Жду твоего OK~',
389 'pet.ready': 'Готово, посмотри!',
390 'pet.aborted': 'Ладно, остановился.',
391 'pet.error': 'Ой… ошибка',
392 'pet.sleep': 'Zzz…',
393 'pet.levelUp': '{theme} достиг ур.{level}!',
394 'pet.testPass': 'Тесты прошли!',
395 'pet.testFail': 'Тесты упали… починим',
396 'pet.commit': 'Закоммичено!',
397 'cmd.pat': 'Ты погладил {theme} (♥{love})',
398 'cmd.petStats': '{theme} · ур.{level} · {xp} оп. · ♥{love}',
399 'cmd.companionOff': 'Питомец между ходами скрыт',
400 'cmd.companionOn': 'Питомец между ходами показан',
401 'cmd.audioOn': 'Аудио: показывает звук, который выводит компьютер (только уровни, ничего не сохраняется и не отправляется)',
402 'cmd.audioOff': 'Аудио: нечего показать ({reason}). Нужны macOS 14.2+, swiftc (Xcode Command Line Tools) и разрешение «Запись системного аудио» для терминала',
403 },
404}
405
406export const { m, setLang, lang } = createMessages(MESSAGES)
407hooks/kit/band.tsx 31 lines1// Generated from claude-code/kit/band.tsx by scripts/sync-kit.sh: edit the source, then re-run it.
2// A mod's rows in the band above the prompt. Several mods draw there, so each
3// stacks its rows over what the rest of the chain drew (`await next(e)`)
4// instead of replacing it, two cells in, as the engine indents the lines under
5// the prompt. The hook stays the mod's own (`on('ui.render', ...)` is spelled
6// in the hooks module): it yields to a survey, draws, and hands both here.
7import type { Elements, RenderElement } from 'claude-code'
8
9/**
10 * Whether the prompt's draft has a picker open: a slash command being named
11 * (`/sp`, before any space) or a file being mentioned (`@src/a`, the word at the
12 * cursor). The engine draws the picker above the band, so a band steps aside
13 * while one is open and the picker sits right on the prompt.
14 */
15export function isPickerOpen(text: string, cursor = text.length): boolean {
16 const before = text.slice(0, cursor)
17 return /^\/\S*$/.test(before) || /(^|\s)@\S*$/.test(before)
18}
19
20export function stackAbove(ui: Pick<Elements['terminal'], 'Box'>, mine: RenderElement, below: RenderElement): RenderElement {
21 const { Box } = ui
22 return (
23 <Box flexDirection="column">
24 <Box flexDirection="column" paddingLeft={2}>
25 {mine}
26 </Box>
27 {below}
28 </Box>
29 )
30}
31hooks/kit/pet.tsx 87 lines1// Generated from claude-code/kit/pet.tsx by scripts/sync-kit.sh: edit the source, then re-run it.
2// The companion pet wherever it stands. spinner owns it: it publishes the pet
3// as plain data (`spinner.dock`, a DockPet: its frames, bubble and stats) and
4// draws it itself above the prompt; hud, below the prompt, may give it a place
5// beside its rows, and says so in `hud.dock`. Either one draws the block here,
6// the frames played by the Client module `kit/pet-player.tsx`.
7import type { Elements, RenderElement } from 'claude-code'
8
9import type { DockPet } from '../../types'
10
11/** Cells the bubble gets at least beside the pet when docked. */
12export const LABEL_MIN = 14
13/** Room a docked pet must gain before it moves in, so a row's length changing does not toss it back and forth. */
14export const DOCK_SLACK = 6
15
16/** Cells `text` takes as the terminal draws it: CJK, fullwidth forms and emoji two, the rest (box drawing and blocks included) one. */
17export function cellWidth(text: string): number {
18 let w = 0
19 for (const ch of text) {
20 const cp = ch.codePointAt(0)!
21 const isWide =
22 (cp >= 0x1100 && cp <= 0x115f) ||
23 (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) ||
24 (cp >= 0xac00 && cp <= 0xd7a3) ||
25 (cp >= 0xf900 && cp <= 0xfaff) ||
26 (cp >= 0xfe30 && cp <= 0xfe4f) ||
27 (cp >= 0xff00 && cp <= 0xff60) ||
28 (cp >= 0xffe0 && cp <= 0xffe6) ||
29 (cp >= 0x1f300 && cp <= 0x1faff) ||
30 (cp >= 0x20000 && cp <= 0x3fffd)
31 w += isWide ? 2 : 1
32 }
33 return w
34}
35
36/** Whether the pet fits beside rows `rowWidth` wide in `columns`; once docked it stays while it fits at all. */
37export function dockFits(rowWidth: number, columns: number, petWidth: number, isDocked: boolean): boolean {
38 const spare = columns - rowWidth - 2 - petWidth - LABEL_MIN
39 return spare >= (isDocked ? 0 : DOCK_SLACK)
40}
41
42const TONE: Record<DockPet['tone'], { color?: string; bold?: boolean; dimColor?: boolean }> = {
43 plain: { color: '#b8b8be' },
44 ask: { color: '#ffd166', bold: true },
45 error: { color: '#e63946' },
46 aborted: { color: '#adb5bd' },
47 sleep: { color: '#6c757d', dimColor: true },
48}
49
50type Ui = Pick<Elements['terminal'], 'Box' | 'Text' | 'Client'>
51
52/** The pet in one row, for a band too short or narrow for its block: `mascot`, the bubble, the stats. */
53export function drawPetLine(ui: Pick<Ui, 'Box' | 'Text'>, pet: DockPet, mascot: { text: string; color?: string }): RenderElement {
54 const { Box, Text } = ui
55 return (
56 <Box flexDirection="row" columnGap={1} flexShrink={1}>
57 <Text color={mascot.color} bold>{mascot.text}</Text>
58 {pet.bubble ? (
59 <Text {...TONE[pet.tone]} wrap="truncate-end">
60 {pet.tone === 'ask' ? '❯ ' : ''}
61 {pet.bubble}
62 </Text>
63 ) : null}
64 <Text color="#6c757d">{pet.stats}</Text>
65 </Box>
66 )
67}
68
69/** The bubble and stats, right-aligned, then the pet: `rows` tall. */
70export function drawPet(ui: Ui, pet: DockPet, key: string): RenderElement {
71 const { Box, Text, Client } = ui
72 return (
73 <Box flexDirection="row" flexShrink={1} columnGap={1}>
74 <Box flexDirection="column" alignItems="flex-end" flexShrink={1}>
75 <Text color="#6c757d" wrap="truncate-end">{pet.stats}</Text>
76 {pet.bubble ? (
77 <Text {...TONE[pet.tone]} wrap="truncate-end">
78 {pet.tone === 'ask' ? '❯ ' : ''}
79 {pet.bubble}
80 </Text>
81 ) : null}
82 </Box>
83 <Client key={key} module="./pet-player.tsx" width={pet.width} props={pet} />
84 </Box>
85 )
86}
87hooks/kit/lang.ts 68 lines1// Generated from claude-code/kit/lang.ts by scripts/sync-kit.sh: edit the source, then re-run it.
2// A mod's language: the `language` option, or with `auto` Claude Code's own
3// `language` setting (free text: "简体中文", "Japanese", "pt-BR"), then the locale
4// (LC_ALL, LC_MESSAGES, LANG), then English. The messages stay the mod's own:
5// `createMessages(MESSAGES)` gives it `m` over them.
6
7export type Lang = 'en' | 'zh-Hans' | 'zh-Hant' | 'ja' | 'ko' | 'es' | 'fr' | 'de' | 'pt-BR' | 'ru'
8
9export const LANGS: readonly Lang[] = ['en', 'zh-Hans', 'zh-Hant', 'ja', 'ko', 'es', 'fr', 'de', 'pt-BR', 'ru']
10
11/** The `language` option's values, as a manifest's `options` lists them. */
12export const LANGUAGE_OPTIONS = ['auto', ...LANGS] as const
13
14// Names a person may write, folded (lowercase, no spaces, `-`, `_` or `.`).
15const NAMES: Record<string, Lang> = {
16 en: 'en', english: 'en', 英语: 'en', 英文: 'en', 英語: 'en',
17 zh: 'zh-Hans', zhcn: 'zh-Hans', zhsg: 'zh-Hans', zhhans: 'zh-Hans', chinese: 'zh-Hans',
18 simplifiedchinese: 'zh-Hans', 中文: 'zh-Hans', 简体中文: 'zh-Hans', 简体: 'zh-Hans', 汉语: 'zh-Hans', 普通话: 'zh-Hans',
19 zhtw: 'zh-Hant', zhhk: 'zh-Hant', zhmo: 'zh-Hant', zhhant: 'zh-Hant', traditionalchinese: 'zh-Hant',
20 繁體中文: 'zh-Hant', 繁体中文: 'zh-Hant', 正體中文: 'zh-Hant', 繁體: 'zh-Hant', 繁体: 'zh-Hant',
21 ja: 'ja', japanese: 'ja', 日本語: 'ja', 日语: 'ja',
22 ko: 'ko', korean: 'ko', 한국어: 'ko', 韩语: 'ko', 韓語: 'ko',
23 es: 'es', spanish: 'es', español: 'es', espanol: 'es', castellano: 'es', 西班牙语: 'es',
24 fr: 'fr', french: 'fr', français: 'fr', francais: 'fr', 法语: 'fr',
25 de: 'de', german: 'de', deutsch: 'de', 德语: 'de',
26 pt: 'pt-BR', ptbr: 'pt-BR', portuguese: 'pt-BR', brazilianportuguese: 'pt-BR', português: 'pt-BR', portugues: 'pt-BR', 葡萄牙语: 'pt-BR',
27 ru: 'ru', russian: 'ru', русский: 'ru', 俄语: 'ru',
28}
29
30const fold = (s: string) => s.trim().toLowerCase().replace(/[\s_.-]/g, '')
31
32/** "简体中文", "zh_TW.UTF-8", "es-MX", "Deutsch" → a language the mods have, else null. */
33export function parseLanguage(text: unknown): Lang | null {
34 if (typeof text !== 'string' || !text.trim()) return null
35 const bare = text.split('.')[0]!.split('@')[0]!
36 const named = NAMES[fold(bare)]
37 if (named) return named
38 // A tag with a region or script this table does not list: its first subtag.
39 const [primary = '', ...rest] = bare.trim().toLowerCase().split(/[-_\s]/)
40 if (primary === 'zh') return rest.some(s => ['tw', 'hk', 'mo', 'hant'].includes(s)) ? 'zh-Hant' : 'zh-Hans'
41 return NAMES[primary] ?? null
42}
43
44/** The language to draw in: the option unless `auto`, then the setting, then the locale. */
45export function resolveLanguage(option: unknown, setting: unknown, locale: readonly (string | undefined)[]): Lang {
46 if (typeof option === 'string' && option !== 'auto' && (LANGS as readonly string[]).includes(option)) return option as Lang
47 return parseLanguage(setting) ?? locale.map(parseLanguage).find(Boolean) ?? 'en'
48}
49
50
51export type Params = Record<string, string | number>
52
53/**
54 * `m(key, params?, lang?)` over a mod's table: the message in the current
55 * language (English when missing), its `{placeholders}` filled.
56 */
57export function createMessages<K extends string>(table: Record<Lang, Record<K, string>>) {
58 let current: Lang = 'en'
59 return {
60 m: (key: K, params: Params = {}, lang: Lang = current): string =>
61 (table[lang][key] ?? table.en[key]).replace(/\{(\w+)\}/g, (_, k: string) => String(params[k] ?? '')),
62 setLang: (lang: Lang): void => {
63 current = lang
64 },
65 lang: (): Lang => current,
66 }
67}
68hooks/kit/prefs.ts 60 lines1// Generated from claude-code/kit/prefs.ts by scripts/sync-kit.sh: edit the source, then re-run it.
2// `/config` is where a mod's settings live: a slash command that changes one
3// (`/ts off`, `/spinner neon`) writes that row as the person would in the menu,
4// so the menu shows it, settings.json keeps it, and the engine reloads the
5// module with it. The command also sets the mod's session state, so the change
6// shows at once, and stays for the session where no row can be written
7// (`claude -p` has no plugin rows).
8//
9// The engine follows `$` only within the hooks module's own file, so the mod
10// hands the kit closures over `$` (a `Prefs`) rather than `$` itself.
11import type { ConfigValue } from 'claude-code'
12
13/** The mod's store and its own `/config` rows, as closures the hooks module builds over `$`. */
14export type Prefs = {
15 kept: (key: string) => Promise<unknown>
16 forget: (key: string) => Promise<void>
17 /** `$.config.set` on `<plugin>.<field>`. */
18 write: (field: string, value: ConfigValue) => Promise<{ deny?: string }>
19}
20
21/** Writes one of the mod's rows; false when no row took it. */
22export async function persist(prefs: Prefs, field: string, value: ConfigValue): Promise<boolean> {
23 const result = await prefs.write(field, value).catch(() => null)
24 return result !== null && result.deny === undefined
25}
26
27/** What a value kept in the store under an older version becomes: a row and its value, or nothing. */
28export type Move = (kept: unknown) => readonly [field: string, value: ConfigValue] | null
29
30/** The rows values older versions kept in `$.store` stand for, which the session applies at once. */
31export async function keptRows(prefs: Prefs, moves: Readonly<Record<string, Move>>): Promise<Record<string, ConfigValue>> {
32 const rows: Record<string, ConfigValue> = {}
33 for (const [key, move] of Object.entries(moves)) {
34 const kept = await prefs.kept(key)
35 const target = kept === undefined ? null : move(kept)
36 if (target) rows[target[0]] = target[1]
37 }
38 return rows
39}
40
41/**
42 * Moves those values to their `/config` rows, once: a key is dropped only
43 * after its row took the value (or it has none to give). Run it after
44 * `session.start`'s `next(e)`: the plugin's rows join `/config` as the
45 * session comes up, and the write brings a reload that applies them.
46 */
47export async function migrateStore(prefs: Prefs, moves: Readonly<Record<string, Move>>): Promise<void> {
48 for (const [key, move] of Object.entries(moves)) {
49 const kept = await prefs.kept(key)
50 if (kept === undefined) continue
51 const target = move(kept)
52 if (!target || (await persist(prefs, target[0], target[1]))) await prefs.forget(key)
53 }
54}
55
56/** `off` → true, `on` → false, anything else flips `isOff`. */
57export function switchArg(arg: string, isOff: boolean): boolean {
58 return arg === 'off' ? true : arg === 'on' ? false : !isOff
59}
60hooks/pet.ts 69 lines1// What the companion knows and says: its level, its bubble, and the labels of
2// the turn it watches.
3import { m } from './i18n'
4import { textWidth } from './themes'
5import type { Act, Finale, Mood } from './themes'
6
7/** `12s`, `3m 05s`, `1h 02m`. */
8export function formatDuration(ms: number): string {
9 const s = Math.max(0, Math.round(ms / 1000))
10 if (s < 60) return `${s}s`
11 if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
12 return `${Math.floor(s / 3600)}h ${String(Math.floor(s / 60) % 60).padStart(2, '0')}m`
13}
14
15/** pixel-pals: 한국어 시간 표기 (12초, 3분 05초, 1시간 02분). 피날레에 쓴다. */
16export function formatDurationKo(ms: number): string {
17 const s = Math.max(0, Math.round(ms / 1000))
18 if (s < 60) return `${s}초`
19 if (s < 3600) return `${Math.floor(s / 60)}분 ${String(s % 60).padStart(2, '0')}초`
20 return `${Math.floor(s / 3600)}시간 ${String(Math.floor(s / 60) % 60).padStart(2, '0')}분`
21}
22
23/** Lv.1 at 0 xp, Lv.2 at 2, Lv.3 at 8, Lv.4 at 18: one xp a finished turn. */
24export function levelOf(xp: number): number {
25 return Math.floor(Math.sqrt(Math.max(0, xp) / 2)) + 1
26}
27
28/** What a tool call is about, short: `Bash: npm test`, `Edit: themes.ts`, `WebSearch`. */
29export function toolLabel(e: { tool: string } & Record<string, unknown>): string {
30 const clip = (s: string) => (textWidth(s) > 32 ? `${Array.from(s).slice(0, 31).join('')}…` : s)
31 if (typeof e.command === 'string') return clip(`${e.tool}: ${e.command.split('\n')[0]!.trim()}`)
32 const path = [e.file_path, e.notebook_path, e.path].find(p => typeof p === 'string') as string | undefined
33 if (path) return clip(`${e.tool}: ${path.split('/').pop()}`)
34 if (typeof e.pattern === 'string') return clip(`${e.tool}: ${e.pattern}`)
35 // A subagent: what it was sent to do.
36 if (typeof e.description === 'string' && e.description.trim()) return clip(`${e.tool}: ${e.description.trim()}`)
37 return clip(e.tool.replace(/^mcp__[^_]+__/, ''))
38}
39
40/** What the pet makes of a finished shell command: tests that ran, a commit. */
41export type News = 'testPass' | 'testFail' | 'commit'
42
43// A test runner at the head of a command or after `&&`, `;`, `|`, `(`: npm test, pytest, go test…
44const TEST = /(?:^|[;&|(]\s*)(?:\S+=\S*\s+)*(?:(?:npm|pnpm|yarn|bun|deno)\s+(?:run\s+)?test\b|npx\s+(?:jest|vitest|mocha|playwright\s+test)\b|(?:jest|vitest|mocha|pytest|rspec|phpunit|tox|nox)\b|python3?\s+-m\s+(?:pytest|unittest)\b|(?:go|cargo|swift|dotnet|mix|mvn|gradle|\.\/gradlew|make|zig)\s+test\b|claude\s+plugin\s+test\b)/
45const COMMIT = /(?:^|[;&|(]\s*)git\s+(?:-\S+\s+(?:\S+\s+)?)*commit\b/
46
47export function newsOf(command: string, isError: boolean): News | null {
48 if (TEST.test(command)) return isError ? 'testFail' : 'testPass'
49 if (COMMIT.test(command) && !isError && !/--dry-run/.test(command)) return 'commit'
50 return null
51}
52
53/** The bubble for the calls running now: subagents side by side are counted, else the latest call. */
54export function busyLabel(labels: readonly string[]): string | undefined {
55 const agents = labels.filter(label => /^(?:Agent|Task)\b/.test(label))
56 return agents.length > 1 ? `Agent ×${agents.length}` : labels[labels.length - 1]
57}
58
59export function finaleOf(reason: string): Finale {
60 return reason === 'answer' ? 'answer' : reason === 'aborted' ? 'aborted' : 'error'
61}
62
63/** The bubble says what the engine's spinner line does not: the tool, a prompt waiting, how the turn ended. */
64export function bubbleOf(state: Act | Mood, tool: string | undefined): string {
65 if (state === 'tool') return tool ?? ''
66 if (state === 'think' || state === 'say' || state === 'wait') return ''
67 return m(`pet.${state}`)
68}
69hooks/themes.ts 311 lines1// Frame tables and scenes, shared by the hooks module and both surface modules
2// (sprite.tsx draws the spinner's mascot, stage.tsx the band). Pure:
3// everything here is a function of the theme, the tick and the width.
4import { blank, frame, hsl, mod, noise, padTo, put, textWidth } from './cells'
5import type { Grid, Style } from './cells'
6import { SCENE_ROWS, bluecatScene, chompScene, clawdScene, nyanScene, sparkyScene, thunderScene } from './scenes'
7
8export * from './cells'
9
10export const THEME_NAMES = [
11 'clawd',
12 'thunder',
13 'chomp',
14 'sparky',
15 'bluecat',
16 'nyan',
17 // pixel-pals: clawd-tales의 무대(hooks/tales)가 띠를 통째로 그리는 테마
18 'tales',
19] as const
20export type ThemeName = (typeof THEME_NAMES)[number]
21
22export type Mode = 'requesting' | 'responding' | 'thinking' | 'tool-input' | 'tool-use'
23export type Pose = 'think' | 'tool' | 'say' | 'wait'
24/** What the turn is doing, as the band's companion tells it. */
25export type Act = 'think' | 'tool' | 'ask' | 'say' | 'wait'
26export type Finale = 'answer' | 'aborted' | 'error'
27/** The companion's mood between turns. */
28export type Mood = 'hello' | 'ready' | 'aborted' | 'error' | 'sleep'
29
30export type Theme = {
31 name: ThemeName
32 /** The mascot's color, and a second one for its props. */
33 color: string
34 accent: string
35 /** Frames of the mascot beside the spinner's word, by what the turn is doing. */
36 sprite: Record<Pose, string[]>
37 /** Each character of the mascot in its own color, cycling. */
38 isRainbow?: boolean
39 happy: string
40 sad: string
41 dead: string
42 sleep: string
43 /** Particles of the finale's burst, and their colors. */
44 confetti: string[]
45 palette: string[]
46 /** Rows the scene takes in the band. */
47 rows: number
48 /** The band's scene while a turn runs: `rows` rows of `w` cells. */
49 scene: (t: number, w: number, act: Act) => Grid
50}
51
52/** Milliseconds per frame: the mascot's, the band's, the companion's between turns. */
53export const SPRITE_MS = 140
54export const STAGE_MS = 100
55export const IDLE_MS = 400
56/** How long the finale stays after a turn ends. */
57export const FINALE_MS = 3000
58
59export function poseOf(mode: string): Pose {
60 if (mode === 'thinking' || mode === 'think') return 'think'
61 if (mode === 'tool-use' || mode === 'tool-input' || mode === 'tool') return 'tool'
62 if (mode === 'responding' || mode === 'say') return 'say'
63 return 'wait'
64}
65
66export function isThemeName(name: unknown): name is ThemeName {
67 return typeof name === 'string' && (THEME_NAMES as readonly string[]).includes(name)
68}
69
70/** pixel-pals: `random`이 턴마다 고르는 테마 (엄선한 7종 전부) */
71export const RANDOM_POOL: ThemeName[] = [...THEME_NAMES]
72
73/** A theme drawn from `seed`, for the `random` choice. */
74export function pickRandom(seed: number): ThemeName {
75 const pool = RANDOM_POOL
76 return pool[Math.floor(noise(seed) * pool.length)]!
77}
78
79// ---- the newer scenes: Claude's mascot and pixel art --------------------
80
81
82const CLAUDE = '#d77757'
83const RAINBOW = ['#ff0000', '#ff9900', '#ffff00', '#33ff00', '#0099ff', '#6633ff']
84
85// ---- themes --------------------------------------------------------------
86
87export const THEMES: Record<ThemeName, Theme> = {
88 clawd: {
89 name: 'clawd',
90 rows: SCENE_ROWS,
91 color: CLAUDE,
92 accent: '#e9b49a',
93 sprite: {
94 think: ['·', '✢', '✳', '✶', '✻', '✽', '✻', '✶', '✳', '✢'].map(s => `▐▛█▜▌ ${s}`),
95 tool: ['▐▛█▜▌▭▭', '▐▛█▜▌▬▭', '▐▛█▜▌▭▬'],
96 say: ['▐▛█▜▌ ✎ ', '▐▛█▜▌ ✎· ', '▐▛█▜▌ ✎··'],
97 wait: ['▐▛█▜▌ . ', '▐▛█▜▌ .. ', '▐▛█▜▌ ...'],
98 },
99 happy: '▐▛█▜▌ ✻',
100 sad: '▐▀█▀▌',
101 dead: '▐x█x▌',
102 sleep: '▐▄█▄▌ zZ',
103 confetti: ['✻', '✶', '✳', '·', '✢'],
104 palette: [CLAUDE, '#e9b49a', '#f5e6d3', '#c15f3c'],
105 scene: clawdScene,
106 },
107 thunder: {
108 name: 'thunder',
109 rows: SCENE_ROWS,
110 color: '#4cc9f0',
111 accent: '#ffd60a',
112 sprite: {
113 think: ['=▷ · ', '=▷ · ', '=▷ · ', '=▷ · '],
114 tool: ['=▷━ ◆', '=▷ ━ ◆', '=▷ ━ ◆', '=▷ ✶'],
115 say: ['=▷ ⁘ ⁘ ', '=▷⁘ ⁘ ⁘'],
116 wait: ['=▷ . ', '=▷ .. ', '=▷ ...'],
117 },
118 happy: '=▷ ✶✶✶',
119 sad: '=▷ ...',
120 dead: '=▷ ✕',
121 sleep: '=▷ zZ',
122 confetti: ['✶', '✦', '+', '·', '◆'],
123 palette: ['#4cc9f0', '#ffd60a', '#f72585', '#ff9f1c'],
124 scene: thunderScene,
125 },
126 chomp: {
127 name: 'chomp',
128 rows: SCENE_ROWS,
129 color: '#ffd60a',
130 accent: '#ff595e',
131 sprite: {
132 think: ['ᗧ • • •', '● • • •'],
133 tool: ['ᗧ••• ᗣ', '●•• ᗣ ', 'ᗧ• ᗣ ', '● ᗣ '],
134 say: ['ᗧ ♪ ', '● ♫'],
135 wait: ['ᗧ . ', '● .. ', 'ᗧ ...'],
136 },
137 happy: 'ᗧ ᗣᗣᗣ',
138 sad: 'ᗣ ᗧ',
139 dead: '✕ᗧ✕',
140 sleep: 'ᗧ zZ',
141 confetti: ['•', '●', '·', 'ᗣ', '+'],
142 palette: ['#ffd60a', '#ff595e', '#ffafcc', '#00f5d4', '#ff9f1c'],
143 scene: chompScene,
144 },
145 sparky: {
146 name: 'sparky',
147 rows: SCENE_ROWS,
148 color: '#ffd60a',
149 accent: '#e63946',
150 sprite: {
151 think: ['ϟ(•ᴥ•) ', 'ϟ(•ᴥ•) ?', ' (•ᴥ•)ϟ?'],
152 tool: ['ϟϟ(>ᴥ<)ϟϟ', ' ϟ(>ᴥ<)ϟ '],
153 say: ['(•ᴥ•)ノϟ ', '(•ᴥ•)ノ ϟ'],
154 wait: ['(•ᴥ•) . ', '(•ᴥ•) .. ', '(•ᴥ•) ...'],
155 },
156 happy: 'ϟ(^ᴥ^)ϟ',
157 sad: '(;ᴥ;)',
158 dead: '(×ᴥ×)',
159 sleep: '(-ᴥ-) zZ',
160 confetti: ['ϟ', '✦', '*', '·', '+'],
161 palette: ['#ffd60a', '#e63946', '#fff3b0', '#c68b59'],
162 scene: sparkyScene,
163 },
164 bluecat: {
165 name: 'bluecat',
166 rows: SCENE_ROWS,
167 color: '#00b4d8',
168 accent: '#e63946',
169 sprite: {
170 think: ['⊂(◉‿◉)つ ', '⊂(◉‿◉)つ ?', '⊂(◔‿◔)つ ?'],
171 tool: ['⊂(◉‿◉)つ✦ ', '⊂(◉‿◉)つ ✧ ', '⊂(◉‿◉)つ ✦'],
172 say: ['⊂(◕‿◕)つ ♪', '⊂(◕‿◕)つ ♫'],
173 wait: ['⊂(◉‿◉)つ . ', '⊂(◉‿◉)つ .. ', '⊂(◉‿◉)つ ...'],
174 },
175 happy: '⊂(◕‿◕)つ',
176 sad: '⊂(╥﹏╥)つ',
177 dead: '⊂(×﹏×)つ',
178 sleep: '⊂(-‿-)つ zZ',
179 confetti: ['✦', '✧', '●', '·', '+'],
180 palette: ['#00b4d8', '#e63946', '#ffd60a', '#ffffff'],
181 scene: bluecatScene,
182 },
183 nyan: {
184 name: 'nyan',
185 rows: SCENE_ROWS,
186 color: '#ff99cc',
187 accent: '#999999',
188 isRainbow: true,
189 sprite: {
190 think: ['~=[,,_,,]:3', '=~[,,_,,]:3'],
191 tool: ['~=[,,_,,]:3 *', '=~[,,_,,]:3 +'],
192 say: ['~=[,,o,,]:3', '=~[,,_,,]:3'],
193 wait: ['~=[,,_,,]:3 . ', '=~[,,_,,]:3 .. ', '~=[,,_,,]:3 ...'],
194 },
195 happy: '~=[,,^,,]:3',
196 sad: '~=[,,;,,]:3',
197 dead: '~=[,,x,,]:3',
198 sleep: '~=[,,-,,]:3 zZ',
199 confetti: ['*', '+', '·', '✦', '✧'],
200 palette: RAINBOW,
201 scene: nyanScene,
202 },
203 // pixel-pals: tales는 clawd-tales의 무대가 띠를 그린다. 여기 값은 목록·펫·미리보기에서만 쓴다.
204 tales: {
205 name: 'tales',
206 rows: SCENE_ROWS,
207 color: CLAUDE,
208 accent: '#7aa2f7',
209 sprite: {
210 think: ['▐▛█▜▌ ? ', '▐▛█▜▌ ?? ', '▐▛█▜▌ ...'],
211 tool: ['▐▛█▜▌ ⚒ ', '▐▛█▜▌ ⚒ ', '▐▛█▜▌ ⚒⚒ '],
212 say: ['▐▛█▜▌ ✎ ', '▐▛█▜▌ ✎· ', '▐▛█▜▌ ✎··'],
213 wait: ['▐▛█▜▌ . ', '▐▛█▜▌ .. ', '▐▛█▜▌ ...'],
214 },
215 happy: '▐▛█▜▌ ♪',
216 sad: '▐▀█▀▌',
217 dead: '▐x█x▌',
218 sleep: '▐▄█▄▌ zZ',
219 confetti: ['✻', '♥', '✶', '·', '✢'],
220 palette: [CLAUDE, '#7aa2f7', '#9ece6a', '#e0af68'],
221 scene: clawdScene,
222 },
223}
224
225export function themeOf(name: unknown): Theme {
226 return isThemeName(name) ? THEMES[name] : THEMES.clawd
227}
228
229// ---- finale --------------------------------------------------------------
230
231/** After a turn: a burst of the theme's confetti around its mascot and the label. */
232export function finaleScene(theme: Theme, kind: Finale, label: string, t: number, w: number): Grid {
233 const rows = theme.rows
234 const g = blank(w, rows)
235 const face = kind === 'answer' ? theme.happy : kind === 'aborted' ? theme.sad : theme.dead
236 const text = `${face} ${label}`
237 const tw = textWidth(text)
238 const cx = Math.floor(w / 2)
239 const left = Math.max(0, cx - Math.floor(tw / 2))
240 const mid = Math.floor(rows / 2)
241
242 if (kind === 'answer') {
243 const count = Math.min(40 * rows / 2, Math.max(12, Math.floor((w / 3) * rows / 2)))
244 for (let i = 0; i < count; i++) {
245 const dir = noise(i) < 0.5 ? -1 : 1
246 const speed = 0.8 + noise(i + 9) * 2.2
247 const reach = Math.min(t, 18) * speed
248 const x = cx + dir * (tw / 2 + reach + noise(i + 4) * 3)
249 const y = Math.floor(noise(i + 2) * rows)
250 const isFading = t > 18 + noise(i + 6) * 8
251 put(g, x, y, frame(theme.confetti, i + (t >> 2)), { c: frame(theme.palette, i), d: isFading })
252 }
253 } else if (kind === 'aborted') {
254 for (let x = 0; x < w; x += 4) put(g, mod(x + (t >> 1), w), mod(x, rows), '·', { c: '#6c757d', d: true })
255 } else {
256 for (let x = 0; x < w; x++) if (noise(x + t) < 0.08) put(g, x, mod(x, rows), frame(['▚', '▞', '░'], x + t), { c: '#e63946', d: true })
257 }
258
259 const style: Style = kind === 'answer' ? { c: theme.color, b: true } : kind === 'aborted' ? { c: '#adb5bd' } : { c: '#e63946', b: true }
260 const flicker = kind === 'error' && mod(t, 6) === 0
261 put(g, left - 1, mid, ' '.repeat(tw + 2))
262 if (!flicker) put(g, left, mid, text, style)
263 return g
264}
265
266// ---- companion -----------------------------------------------------------
267
268/** What the companion row shows: the turn's act while it runs, else the mood. */
269export type PetView = {
270 state: Act | Mood
271 /** The bubble beside the mascot, already in the person's language. */
272 bubble: string
273 /** `Lv.3 ♥12`. */
274 stats: string
275}
276
277const BUBBLE: Partial<Record<Act | Mood, Style>> = {
278 ask: { c: '#ffd166', b: true },
279 error: { c: '#e63946' },
280 aborted: { c: '#adb5bd' },
281 sleep: { c: '#6c757d', d: true },
282}
283
284/** One row: the mascot in its current mood, its bubble, its level and affection; hearts after a pat. */
285export function petRow(theme: Theme, pet: PetView, t: number, w: number, hearts: number): Grid {
286 const g = blank(w, 1)
287 const s = pet.state
288 let face: string
289 if (s === 'think' || s === 'tool' || s === 'say' || s === 'wait' || s === 'ask') {
290 const frames = theme.sprite[poseOf(s)]
291 face = padTo(frame(frames, t), Math.max(...frames.map(textWidth)))
292 } else {
293 face = s === 'error' ? theme.dead : s === 'aborted' ? theme.sad : s === 'sleep' ? theme.sleep : theme.happy
294 }
295 const faceStyle = (i: number): Style =>
296 theme.isRainbow ? { c: hsl((i * 40 + t * 24) % 360, 0.95, 0.62), b: true } : { c: theme.color, b: true }
297 let x = 0
298 Array.from(face).forEach((ch, i) => {
299 put(g, x, 0, ch, faceStyle(i))
300 x += textWidth(ch)
301 })
302 x += 1
303 if (hearts > 0) {
304 put(g, x, 0, frame(['♡', '♥', '♡ ♥', '♥ ♡'], hearts), { c: '#ff8fab', b: true })
305 x += 4
306 }
307 if (pet.bubble) put(g, x, 0, `${s === 'ask' ? '❯ ' : '· '}${pet.bubble}`, BUBBLE[s] ?? { c: '#b8b8be' })
308 put(g, w - textWidth(pet.stats) - 1, 0, pet.stats, { c: '#6c757d' })
309 return g
310}
311hooks/pets.ts 292 lines1// The companion: a pixel pet three rows tall (6 px in half blocks), drawn the
2// same wherever it stands (beside the HUD, or right-aligned above the prompt).
3// A theme gives its body and where its eyes sit; blinking, breathing, the
4// expressions and the props beside it are the same for every pet.
5import { blank, canvas, cells, draw, mod, noise, plot, segments } from './cells'
6import type { Canvas, Grid } from './cells'
7import type { DockPet } from '../types'
8import type { Act, Mood } from './themes'
9
10export type PetState = Act | Mood
11
12export type PetArt = {
13 /** The body, 5 px tall: letters keyed in `colors`, `.` clear. Up to BODY_W wide. */
14 body: readonly string[]
15 /** The same body with its feet moved: a step while it works. */
16 step?: readonly string[]
17 colors: Record<string, string>
18 /** Each eye's column in the body; an eye is 1 px wide, 2 px tall, from row `eyeRow`. */
19 eyes: readonly number[]
20 eyeRow: number
21 /** The pupils' color, and the body's color where an eye closes. */
22 eye: string
23 lid: string
24 /** The mouth's column and row, opened while it talks. */
25 mouth?: readonly [x: number, y: number]
26 /** Its cheeks' color when happy. */
27 blush?: string
28 /** Props beside it: sparks while a tool runs, dots while it thinks. */
29 spark: string
30}
31
32/** Columns the pet takes: its props on the left, its body on the right. */
33export const PROP_W = 4
34export const BODY_W = 12
35export const PET_W = PROP_W + BODY_W
36export const PET_ROWS = 3
37/** Milliseconds per frame of the pet. */
38export const PET_MS = 140
39
40const QUESTION = ['.YY.', '...Y', '..Y.', '....', '..Y.']
41const HEART = ['P.P', 'PPP', '.P.']
42const ZED = ['ZZZZ', '..Z.', '.Z..', 'ZZZZ']
43const STAR = ['.S.', 'SSS', '.S.']
44
45/** Frames in one loop of a state: long enough for a blink and a breath. */
46export function loopOf(state: PetState): number {
47 return state === 'sleep' ? 48 : state === 'think' || state === 'tool' || state === 'say' ? 24 : 40
48}
49
50/** The pet at tick `t` in `state`, `hearts` frames into a pat (0: none). */
51export function petFrame(art: PetArt, state: PetState, t: number, hearts = 0): Grid {
52 const cv = canvas(PET_W, PET_ROWS)
53 const isBusy = state === 'tool' || state === 'say' || state === 'think'
54 // Breathing: up a pixel for part of each beat, quicker while it works; a hop when happy.
55 const beat = isBusy ? 6 : 16
56 let lift = mod(t, beat) < beat / 2 ? 1 : 0
57 if (state === 'ready' || state === 'hello') lift = mod(t, 10) < 2 ? 1 : lift
58 if (state === 'sleep' || state === 'error') lift = 0
59 const y = 1 - lift
60 const isStep = (state === 'tool' || state === 'say') && mod(t, 4) < 2
61 const body = isStep && art.step ? art.step : art.body
62 const colors = state === 'error' ? desaturate(art.colors) : art.colors
63 draw(cv, PROP_W, y, body, colors)
64 drawFace(cv, art, state, t, y)
65 drawProps(cv, art, state, t)
66 if (hearts > 0) {
67 const rise = Math.min(4, hearts >> 1)
68 draw(cv, 0, Math.max(0, 3 - rise), HEART, { P: hearts % 4 < 2 ? '#ff6b9d' : '#ff8fab' })
69 if (hearts > 3) draw(cv, PROP_W + BODY_W - 3, Math.max(0, 2 - (rise >> 1)), HEART, { P: '#ff8fab' })
70 }
71 return cells(cv)
72}
73
74function drawFace(cv: Canvas, art: PetArt, state: PetState, t: number, y: number): void {
75 const top = y + art.eyeRow
76 // A blink every few seconds, two frames long, at a different beat per eye pair.
77 const isBlink = mod(t, 28) < 2 && state !== 'sleep'
78 const lookAside = state === 'think' && mod(t, 24) >= 12 ? 1 : 0
79 for (const ex of art.eyes) {
80 const x = PROP_W + ex
81 // Clear the eye's two pixels to the lid first, then draw what this state shows.
82 plot(cv, x, top, art.lid)
83 plot(cv, x, top + 1, art.lid)
84 if (state === 'sleep' || isBlink || state === 'aborted') {
85 plot(cv, x, top + 1, art.eye)
86 } else if (state === 'ready' || state === 'hello') {
87 // Eyes curved up: the top pixel alone.
88 plot(cv, x, top, art.eye)
89 } else if (state === 'error') {
90 plot(cv, x, top, '#e63946')
91 plot(cv, x, top + 1, '#e63946')
92 } else {
93 plot(cv, x + lookAside, top, art.eye)
94 plot(cv, x + lookAside, top + 1, art.eye)
95 }
96 if ((state === 'ready' || state === 'hello') && art.blush) {
97 plot(cv, x + (ex < BODY_W / 2 ? -1 : 1), top + 1, art.blush)
98 }
99 }
100 if (state === 'aborted') {
101 // A tear under the first eye, falling.
102 const ex = PROP_W + art.eyes[0]!
103 plot(cv, ex, top + 2 + (mod(t, 8) < 4 ? 0 : 1), '#7ec8e3')
104 }
105 if (art.mouth && (state === 'say' || state === 'ask') && mod(t, 4) < 2) {
106 const [mx, my] = art.mouth
107 plot(cv, PROP_W + mx, y + my, art.eye)
108 plot(cv, PROP_W + mx + 1, y + my, art.eye)
109 }
110}
111
112function drawProps(cv: Canvas, art: PetArt, state: PetState, t: number): void {
113 if (state === 'think') {
114 // Three dots rising one after another.
115 for (let i = 0; i < 3; i++) {
116 const p = mod(t - i * 4, 12)
117 if (p < 6) plot(cv, 1 + i, 5 - p, i === 2 ? art.spark : '#8a8a8a')
118 }
119 } else if (state === 'tool') {
120 // Sparks flying off where it works.
121 for (let i = 0; i < 4; i++) {
122 if (noise(i * 31 + Math.floor(t / 2)) < 0.55) plot(cv, Math.floor(noise(i + t) * PROP_W), 1 + Math.floor(noise(i * 7 + t) * 4), i % 2 ? art.spark : '#ffd166')
123 }
124 } else if (state === 'ask') {
125 if (mod(t, 8) < 6) draw(cv, 0, 0, QUESTION, { Y: '#ffd166' })
126 } else if (state === 'sleep') {
127 const p = mod(t, 24)
128 draw(cv, 0, Math.max(0, 2 - (p >> 3)), ZED, { Z: p < 16 ? '#8d99ae' : '#5c677d' })
129 } else if (state === 'ready' || state === 'hello') {
130 if (mod(t, 20) < 10) draw(cv, 0, mod(t, 20) < 5 ? 1 : 2, STAR, { S: art.spark })
131 }
132}
133
134function desaturate(colors: Record<string, string>): Record<string, string> {
135 return Object.fromEntries(Object.entries(colors).map(([k, hex]) => [k, gray(hex)]))
136}
137
138function gray(hex: string): string {
139 const n = Number.parseInt(hex.slice(1), 16)
140 const l = Math.round(((n >> 16) * 0.3 + ((n >> 8) & 255) * 0.59 + (n & 255) * 0.11) * 0.7)
141 const h = l.toString(16).padStart(2, '0')
142 return `#${h}${h}${h}`
143}
144
145/** A blank pet-sized grid, for a theme with no pet. */
146export function noPet(): Grid {
147 return blank(PET_W, PET_ROWS)
148}
149
150// ---- the pets ------------------------------------------------------------------
151
152/** Clawd, as Claude Code draws him: a warm orange body, arms out, four legs. */
153export const CLAWD_PET: PetArt = {
154 body: [
155 '.LLOOOOOOOO.',
156 '.OOOOOOOOOO.',
157 'OOOOOOOOOOOO',
158 '.DOOOOOOOOD.',
159 '..D.D..D.D..',
160 ],
161 step: [
162 '.LLOOOOOOOO.',
163 '.OOOOOOOOOO.',
164 'OOOOOOOOOOOO',
165 '.DOOOOOOOOD.',
166 '.D.D....D.D.',
167 ],
168 colors: { O: '#d77757', L: '#e8957a', D: '#a9553b' },
169 eyes: [3, 8],
170 eyeRow: 1,
171 eye: '#2b1d18',
172 lid: '#d77757',
173 mouth: [5, 3],
174 blush: '#f2a28c',
175 spark: '#f4a261',
176}
177
178/** One loop of the pet in `state` as DockPet data: distinct frames and their order. */
179export function dockPetOf(
180 art: PetArt,
181 state: PetState,
182 view: Pick<DockPet, 'id' | 'bubble' | 'tone' | 'stats'>,
183 isPatted = false,
184 isStill = false,
185): DockPet {
186 const frames: DockPet['frames'] = []
187 const seen = new Map<string, number>()
188 const order: number[] = []
189 // Still: the loop's first frame alone, which the player never has to redraw.
190 const loop = isStill ? 1 : loopOf(state)
191 for (let t = 0; t < loop; t++) {
192 // A pat floats hearts over the first frames of the loop.
193 const rows = petFrame(art, state, t, isPatted && t < 16 ? t + 1 : 0).map(row => segments(row).map(plainSeg))
194 const key = JSON.stringify(rows)
195 let at = seen.get(key)
196 if (at === undefined) {
197 at = frames.length
198 frames.push(rows)
199 seen.set(key, at)
200 }
201 order.push(at)
202 }
203 return { ...view, frames, order, ms: PET_MS, width: PET_W }
204}
205
206/** A run as plain data: a key left undefined is refused in state and Client props. */
207function plainSeg(seg: ReturnType<typeof segments>[number]): DockPet['frames'][number][number][number] {
208 return Object.fromEntries(Object.entries(seg).filter(([, v]) => v !== undefined)) as DockPet['frames'][number][number][number]
209}
210
211/** A small fighter with a face in its canopy, its exhaust flickering. */
212export const THUNDER_PET: PetArt = {
213 body: ['..AA........', '.ABBBBWWWW..', 'FBBBBBWWWWBC', '.ABBBBBBBB..', '..AA........'],
214 step: ['..AA........', '.ABBBBWWWW..', 'GBBBBBWWWWBC', '.ABBBBBBBB..', '..AA........'],
215 colors: { A: '#4361ee', B: '#4cc9f0', W: '#e0fbfc', C: '#f72585', F: '#ff9f1c', G: '#ffd60a' },
216 eyes: [7, 9],
217 eyeRow: 1,
218 eye: '#14213d',
219 lid: '#e0fbfc',
220 mouth: [7, 3],
221 blush: '#ff8fab',
222 spark: '#ffd60a',
223}
224
225/** A maze ghost, its skirt rippling. */
226export const CHOMP_PET: PetArt = {
227 body: ['...GGGGGG...', '..GWWGGWWG..', '.GGWWGGWWGG.', '.GGGGGGGGGG.', '.G.GG.GG.GG.'],
228 step: ['...GGGGGG...', '..GWWGGWWG..', '.GGWWGGWWGG.', '.GGGGGGGGGG.', '.GG.GG.GG.G.'],
229 colors: { G: '#ff5d8f', W: '#ffffff' },
230 eyes: [4, 8],
231 eyeRow: 1,
232 eye: '#2121de',
233 lid: '#ffffff',
234 mouth: [5, 3],
235 spark: '#ffff00',
236}
237
238/** An electric mouse: black-tipped ears, red cheeks. */
239export const SPARKY_PET: PetArt = {
240 body: ['KY.....YK.Y.', '.YYYYYYY.YY.', 'YYYYYYYYY.Y.', 'RYYYYYYYR...', '.YY.YY.YY...'],
241 step: ['KY.....YK..Y', '.YYYYYYY.YY.', 'YYYYYYYYYY..', 'RYYYYYYYR...', 'YY..YY..YY..'],
242 colors: { K: '#5a3a22', Y: '#ffd60a', R: '#e63946' },
243 eyes: [2, 6],
244 eyeRow: 1,
245 eye: '#5a3a22',
246 lid: '#ffd60a',
247 blush: '#ff8fab',
248 spark: '#fff3b0',
249}
250
251/** A blue robot cat: white face, red nose, a bell on its collar. */
252export const BLUECAT_PET: PetArt = {
253 body: ['..BBBBBBBB..', '.BBWWWWWWBB.', 'BBWWWWWWWWBB', 'BBWWWRRWWWBB', '.BRRRYYRRRB.'],
254 colors: { B: '#0096c7', W: '#ffffff', R: '#e63946', Y: '#ffd60a' },
255 eyes: [4, 7],
256 eyeRow: 1,
257 eye: '#1b1b1b',
258 lid: '#ffffff',
259 mouth: [5, 3],
260 blush: '#ff8fab',
261 spark: '#80ffdb',
262}
263
264/** A pop-tart cat, its head poking out on the right. */
265export const NYAN_PET: PetArt = {
266 body: ['.TTTTT.G...G', 'TPSPPTGGGGGG', 'TPPSPTGGGGGG', 'TPSPPTGGGGGG', '.L.L.L..L.L.'],
267 step: ['.TTTTT.G...G', 'TPSPPTGGGGGG', 'TPPSPTGGGGGG', 'TPSPPTGGGGGG', 'L.L.L..L.L..'],
268 colors: { T: '#ffcc99', P: '#ff99cc', S: '#ff3399', G: '#a0a0a0', L: '#a0a0a0' },
269 eyes: [8, 10],
270 eyeRow: 1,
271 eye: '#1b1b1b',
272 lid: '#a0a0a0',
273 blush: '#ff99cc',
274 spark: '#ffff00',
275}
276
277const PETS: Record<string, PetArt> = {
278 clawd: CLAWD_PET,
279 thunder: THUNDER_PET,
280 chomp: CHOMP_PET,
281 sparky: SPARKY_PET,
282 bluecat: BLUECAT_PET,
283 nyan: NYAN_PET,
284 // pixel-pals: tales의 펫은 Clawd
285 tales: CLAWD_PET,
286}
287
288/** A theme's pet; a theme without one of its own borrows Clawd's shape in its color. */
289export function petArtOf(theme: string, color: string): PetArt {
290 return PETS[theme] ?? { ...CLAWD_PET, colors: { O: color, L: color, D: color }, lid: color }
291}
292hooks/stage.tsx 48 lines1// The band's scene: the theme's scene while a turn runs, its finale once the
2// turn has ended. Runs on the drawing thread. The pet beside it is drawn
3// apart (kit/pet-player.tsx), so it can stand beside the HUD as well.
4import type { ClientModule } from 'claude-code'
5
6import { STAGE_MS, finaleScene, segments, themeOf } from './themes'
7import type { Act, Finale } from './themes'
8
9export type StageProps = {
10 theme: string
11 /** Cells the scene takes across. */
12 columns: number
13 act: Act
14 finale?: Finale
15 label?: string
16 /** One frame, never redrawn (reduced motion). */
17 still?: boolean
18}
19
20const Stage: ClientModule<StageProps, number> = (props, surface) => {
21 const { Box, Text } = surface.elements
22 if (surface.state === undefined && !props.still) {
23 let tick = 0
24 surface.every(STAGE_MS, () => surface.setState(++tick))
25 surface.setState(0)
26 }
27 const t = surface.state ?? 0
28 const theme = themeOf(props.theme)
29 const w = Math.max(16, Math.min(surface.columns || props.columns, props.columns))
30 const rows = props.finale ? finaleScene(theme, props.finale, props.label ?? '', t, w) : theme.scene(t, w, props.act)
31
32 return (
33 <Box flexDirection="column">
34 {rows.map(row => (
35 <Text wrap="truncate">
36 {segments(row).map(seg => (
37 <Text color={seg.c} backgroundColor={seg.bg} bold={seg.b} dimColor={seg.d}>
38 {seg.text}
39 </Text>
40 ))}
41 </Text>
42 ))}
43 </Box>
44 )
45}
46
47export default Stage
48