Animations and a companion pet while the model works: a mascot beside the spinner, a scene above the prompt (a shoot-em-up, Clawd, pixel homages and more), a…

简体中文 · English
Claude 干活时,输入框上方会演一段小动画:像素风的横版射击、Claude 的吉祥物 Clawd、吃豆人、彩虹猫……还有一只会跟着 Claude 的动作变换姿势的宠物。一轮结束时放一小段彩带,显示这轮用了多久。共 15 套主题,随时切换,其中一套把电脑正在播放的声音画成频谱。

Bash: npm test、Edit: themes.ts),或者有权限请求在等你确认;模型思考时它就埋头忙。多个子代理并行时显示数量(Agent ×3),只有一个时显示它的任务。Claude 跑测试或提交时,它会说几秒钟(测试通过、测试没过、提交好了)。Lv.4);点它或输入 /spinner pet 可以摸摸它(♥12,会冒爱心)。等级和好感度跨会话保存,同时开的几个会话养的是同一只。[◉_◉]⊃━━ ✢ Choreographing… (4s · ↓ 14 tokens)),原来的提示词、用时和 token 数不变。同一时间只显示一个吉祥物。(=^▽^=)ノ 完成 · 12s);被中断是一张难过的脸,出错是一阵故障闪烁。reducedMotion 后,所有动画都只画静止的一帧,但仍会随 Claude 的状态变化。像素风场景,三行半格像素:
clawd:Claude Code 的吉祥物 Clawd 散步路过,停下来干活,身边转着 Claude 的星芒;跑工具时敲笔记本,等你确认时头上冒 ?。thunder:雷霆战机式横版射击,战机自动瞄准一波波敌机,有爆炸和计分;跑工具时敌机来得更快。chomp:吃豆人被四只幽灵追着跑,直到吞下能量豆。sparky:电气鼠一路冲过去,脸颊噼啪放电,跑工具时落下闪电。bluecat:蓝色机器猫戴着竹蜻蜓飞,跑工具时口袋里掉出道具。nyan:彩虹猫拖着彩虹飞过星空。角色场景,两行:cat(猫)、bunny(兔子)、sakura(樱花)、mecha(机甲)、neon(霓虹)、dino(小恐龙)、ocean(小鱼)、matrix(字符雨)。也可以选 random,每个会话随机一套。
声音场景,四行:audio 把 Mac 正在播放的声音(音乐、视频、通话)实时画成频谱,每个频段一根柱子,顶上的峰值慢慢落下;左边的小人踩着节拍跳舞,宠物戴着大耳机。有声音时两轮之间也会显示,声音停几秒后收起。random 不会抽到它,要按名字选。

audio 需要 macOS 14.2 及以上和 swiftc(Xcode 命令行工具:xcode-select --install)。第一次使用时,mod 用 hooks/audio-tap.swift 编译一个小程序(约 2 秒),只在这套主题显示时运行:它通过 Core Audio 读取系统输出,每秒 20 次把各频段的电平交给 mod,不保存、不写盘、不外传声音。macOS 会询问一次是否允许终端录制系统音频,拒绝的话柱子一直是平的。读不到声音时(其他系统、桌面 App、编译失败),场景里的小人睡着,/spinner 会说明原因。预览和下面的动图用的是合成信号。
clawd<br>![]() | thunder<br>![]() |
chomp<br>![]() | sparky<br>![]() |
bluecat<br>![]() | nyan<br>![]() |
cat<br>![]() | bunny<br>![]() |
sakura<br>![]() | mecha<br>![]() |
neon<br>![]() | dino<br>![]() |
ocean<br>![]() | matrix<br>![]() |
audio<br>![]() |
所有主题的吉祥物、场景和宠物合在一张图里:assets/gallery.png;每套主题的静态图在 assets/<主题>.png。
chomp、sparky、bluecat、nyan 是从零绘制、另起名字的同人致敬;clawd 照着 Claude Code 欢迎界面上的样子绘制。
claude plugin marketplace add hoobnn/hoobnn-agent-mods
claude plugin install spinner@hoobnn-agent-mods
像素场景需要支持真彩色的终端(Ghostty、iTerm2、WezTerm、kitty 等)。
/spinner:查看当前主题、宠物的等级和好感度,以及主题列表。/spinner <主题>、/spinner random:立即切换主题;/spinner theme 弹窗选择(给出随机和三套主题,其他主题可以在 Other 里输入)。/spinner preview [主题]:在输入框上方试播 8 秒。/spinner pet:摸摸宠物。/spinner off / on:关闭或打开全部动画;/spinner stage off / on 只管动画场景;/spinner companion off / on 只管宠物。这些命令改的设置都会写回 /config,以后的会话也会沿用,并立即生效。选了 random 时,同一个会话里重新加载也不会换主题。
在 /config 里修改,或写在 ~/.claude/settings.json 的 pluginConfigs 里:
| 选项 | 作用 | 默认 |
|---|---|---|
visible | 全部动画的总开关(/spinner off / on,或输入框底栏的 Spinner 按钮) | 开 |
footerButton | 在输入框底栏显示 Spinner 开关按钮 | 开 |
theme | 主题 | random |
stage | 输入框上方的动画场景 | 开 |
celebrate | 一轮结束时的庆祝动画 | 开 |
companion | 宠物伴侣 | 开 |
reducedMotion | 吉祥物、场景和宠物只画静止画面,仍随状态变化 | 关 |
language | /spinner 回复、宠物气泡和庆祝文字的语言:auto、en、zh-Hans、zh-Hant、ja、ko、es、fr、de、pt-BR、ru | auto |
language 为 auto 时,依次跟随 Claude Code 的 language 设置和系统语言环境,都没有时用英语。
Claude 工作时,横条占主题的行数再加宠物一行;两轮之间只占一行。空间不够时先收起场景、保留宠物;不足 3 行或 60 列时,宠物缩成一行(吉祥物、气泡和等级)。吉祥物、场景和宠物在终端和桌面 App 里显示;其他界面保持 Claude Code 原本的 Spinner。
hoobnn-agent-mods 里还有状态栏 HUD(hud)、任务进度条(todo-bar)、回合回执(receipt)、Tailscale 节点状态(ts-band)和一言(hitokoto),可以搭配使用。
hooks/register.tsx:钩子(会话开始,回合事件:活动、庆祝、宠物,/spinner,Spinner 行上的吉祥物和横条)。hooks/config.ts:选项,一次性读成类型化的 Config。hooks/command.ts:解析 /spinner 的参数。hooks/pet.ts:宠物的等级和气泡、工具标签、时长。hooks/themes.ts、hooks/cells.ts:主题和它们绘制用的字符网格;hooks/stage.tsx、hooks/sprite.tsx:负责播放动画的 Client 模块。hooks/audio.ts、hooks/audio-tap.swift:audio 主题的电平处理(增益、峰值、节拍)和它读取的系统音频小程序;register.tsx 负责编译、启动和停止它,场景每帧通过 ui.message 取最新电平。hooks/i18n.ts:各语言文案。hooks/kit/:claude-code/kit 的副本;改源文件后运行 scripts/sync-kit.sh。scripts/spinner-shots.ts 从 hooks/themes.ts 重新渲染全部动图和静态图。hooks/register.tsx 628 lines1import { atom, derive, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Activity, DockPet, FinaleState, PetStats, Preview } from '../types'
5import { USAGE, parseCommand } from './command'
6import { readConfig } from './config'
7import type { Choice, Config } from './config'
8import { m, setLang } from './i18n'
9import { isPickerOpen, stackAbove } from './kit/band'
10import { drawPet, drawPetLine } from './kit/pet'
11import { resolveLanguage } from './kit/lang'
12import { keptRows, migrateStore, persist } from './kit/prefs'
13import type { Prefs } from './kit/prefs'
14import { bubbleOf, busyLabel, finaleOf, formatDuration, levelOf, newsOf, toolLabel } from './pet'
15import { FINALE_MS, THEMES, THEME_NAMES, isThemeName, pickRandom, textWidth } from './themes'
16import type { Act, Mood, ThemeName } from './themes'
17import { PET_ROWS, dockPetOf, petArtOf } from './pets'
18import type { PetState } from './pets'
19import { AUDIO_BANDS, AudioMeter, lineSplitter } from './audio'
20import type { AudioView } from './audio'
21import type { StageProps } from './stage'
22
23// The theme drawn this session ('' until session.start, or the first turn of a
24// session resumed or cleared, which gets no session.start, picks one).
25const theme = atom({ plugin: 'spinner', key: 'theme' } as const, '')
26// The `theme` row: a theme, or `random`.
27const choice = atom({ plugin: 'spinner', key: 'choice' } as const, 'random')
28// Session mirrors of the `visible`, `stage` and `companion` rows, so a command shows at once;
29// null in a session resumed or cleared, which gets no session.start: the rows' then.
30const hiddenSet = atom({ plugin: 'spinner', key: 'isHidden' } as const, null as boolean | null)
31const stageOffSet = atom({ plugin: 'spinner', key: 'isStageOff' } as const, null as boolean | null)
32const companionOffSet = atom({ plugin: 'spinner', key: 'isCompanionOff' } as const, null as boolean | null)
33// The rows themselves, set in register.
34let rows: Config | null = null
35const isHidden = derive([hiddenSet], set => set ?? !(rows?.isVisible ?? true))
36const isStageOff = derive([stageOffSet], set => set ?? !(rows?.hasStage ?? true))
37const isCompanionOff = derive([companionOffSet], set => set ?? !(rows?.hasCompanion ?? true))
38const finale = atom({ plugin: 'spinner', key: 'finale' } as const, null)
39const preview = atom({ plugin: 'spinner', key: 'preview' } as const, null)
40const activity = atom({ plugin: 'spinner', key: 'activity' } as const, { act: 'think' as Act })
41const mood = atom({ plugin: 'spinner', key: 'mood' } as const, 'hello' as Mood)
42const pet = atom({ plugin: 'spinner', key: 'pet' } as const, { xp: 0, love: 0 })
43const pat = atom({ plugin: 'spinner', key: 'pat' } as const, null)
44const news = atom({ plugin: 'spinner', key: 'news' } as const, null)
45// The pet for whoever draws it (hud beside its rows, or this plugin above the prompt).
46const dock = atom({ plugin: 'spinner', key: 'dock' } as const, null as DockPet | null)
47const isTurn = atom({ plugin: 'spinner', key: 'isTurn' } as const, false)
48// True while a picker is open above the band (see kit/band).
49const isPicking = atom({ plugin: 'spinner', key: 'isPicking' } as const, false)
50// The audio theme's tap: whether anything plays, and why it could not run.
51const tapState = atom({ plugin: 'spinner', key: 'tap' } as const, { isAudible: false, error: null as string | null })
52// hud's say: true while it draws the pet beside its rows.
53const hudDock = atom({ plugin: 'hud', key: 'dock' } as const, false)
54
55const PREVIEW_MS = 8000
56/** Below this many columns the band draws the pet in one row. */
57const COMPACT_COLUMNS = 60
58/** Cells the pet's bubble and stats take beside it above the prompt. */
59const PET_LABEL_W = 24
60/** How long the pet speaks of tests or a commit. */
61const NEWS_MS = 4000
62/** How long a pat's hearts float. */
63const PAT_MS = 2500
64/** How long an `ask` must stand before the pet shows it: the mode settles most at once. */
65const ASK_DELAY_MS = 600
66/** Quiet this long after a turn, the companion dozes off. */
67const SLEEP_MS = 5 * 60_000
68
69/** The kit's hold on this mod's store and `/config` rows. */
70function prefsOf($: EngineInterface): Prefs {
71 return {
72 kept: key => $.store.get(key),
73 forget: key => $.store.delete(key),
74 write: (field, value) => $.config.set({ key: `spinner.${field}`, value }),
75 }
76}
77
78/**
79 * The theme a choice names. `random` keeps the one this session already drew
80 * at random (a reload brought by another row's change), unless `isFresh`.
81 */
82async function choose($: EngineInterface, picked: Choice, isFresh = false): Promise<ThemeName> {
83 const current = await read($, theme)
84 const wasRandom = (await read($, choice)) === 'random'
85 const name =
86 picked !== 'random' ? picked : !isFresh && wasRandom && isThemeName(current) ? current : pickRandom(await $.clock.now())
87 await update($, choice, () => picked)
88 await update($, theme, () => name)
89 await publishPet($)
90 return name
91}
92
93/** The pet's stats as the store keeps them, shared by every session. */
94async function keptPet($: EngineInterface): Promise<PetStats> {
95 const stats = (await $.store.get('pet')) as Partial<PetStats> | undefined
96 return { xp: Number(stats?.xp) || 0, love: Number(stats?.love) || 0 }
97}
98
99/**
100 * Adds `by` to the kept stats, read again just before: sessions running side
101 * by side all raise the one pet instead of writing back their own copy.
102 */
103async function bumpPet($: EngineInterface, by: PetStats): Promise<PetStats> {
104 const kept = await keptPet($)
105 const next = { xp: kept.xp + by.xp, love: kept.love + by.love }
106 await $.store.set('pet', next)
107 return update($, pet, () => next)
108}
109
110/** One more xp, kept, and a toast when it brings a level. */
111async function gainXp($: EngineInterface): Promise<void> {
112 const after = await bumpPet($, { xp: 1, love: 0 })
113 if (levelOf(after.xp) > levelOf(after.xp - 1)) {
114 $.ui.toast(m('pet.levelUp', { theme: await read($, theme), level: levelOf(after.xp) }))
115 }
116}
117
118/** Tests or a commit the main thread just ran: a word from the pet for a moment, and xp for good news. */
119async function noteNews($: EngineInterface, command: string, isError: boolean, id: string): Promise<void> {
120 const kind = newsOf(command, isError)
121 if (kind === null) return
122 await update($, news, () => ({ kind, id }))
123 if (kind !== 'testFail') await gainXp($)
124 $.clock.after(NEWS_MS, async () => {
125 if ((await read($, news))?.id !== id) return
126 await update($, news, () => null)
127 await publishPet($)
128 })
129}
130
131/** A pat: hearts in the band, one more point of affection, kept. */
132async function patPet($: EngineInterface): Promise<PetStats> {
133 const next = await bumpPet($, { xp: 0, love: 1 })
134 const id = String(next.love)
135 await update($, pat, () => id)
136 if ((await read($, mood)) === 'sleep') await update($, mood, () => 'hello' as Mood)
137 await publishPet($)
138 $.clock.after(PAT_MS, async () => {
139 if ((await read($, pat)) !== id) return
140 await update($, pat, () => null)
141 await publishPet($)
142 })
143 return next
144}
145
146/** Reduced motion (the `reducedMotion` row): every animation one still frame. */
147let isStill = false
148
149const TONE: Partial<Record<PetState, DockPet['tone']>> = { ask: 'ask', error: 'error', aborted: 'aborted', sleep: 'sleep' }
150
151/** Publishes the pet as it is now (`spinner.dock`), or null while it is off. */
152async function publishPet($: EngineInterface): Promise<void> {
153 if ((await read($, isHidden)) || (await read($, isCompanionOff))) {
154 if ((await read($, dock)) !== null) await update($, dock, () => null)
155 return
156 }
157 const name = await read($, theme)
158 const now = await read($, activity)
159 const state: PetState = (await read($, isTurn)) ? now.act : await read($, mood)
160 const stats = await read($, pet)
161 const patId = await read($, pat)
162 // A permission prompt waiting outranks news: it is the one the person must act on.
163 const said = state === 'ask' ? null : await read($, news)
164 const art = petArtOf(name, isThemeName(name) ? THEMES[name].color : THEMES.clawd.color)
165 const view = {
166 id: `${name}:${state}:${patId ?? ''}`,
167 bubble: said ? m(`pet.${said.kind}`) : bubbleOf(state, now.tool),
168 tone: said?.kind === 'testFail' ? 'error' : (TONE[state] ?? 'plain'),
169 stats: `Lv.${levelOf(stats.xp)} ♥${stats.love}`,
170 }
171 const was = await read($, dock)
172 // The same loop with new words keeps its frames.
173 const next = was?.id === view.id ? { ...was, ...view } : dockPetOf(art, state, view, patId !== null, isStill)
174 if (was?.id === next.id && was.bubble === next.bubble && was.stats === next.stats && was.tone === next.tone) return
175 await update($, dock, () => next)
176}
177
178// ---- the audio theme's tap ----------------------------------------------------
179
180/** The tap's levels, read by the band each frame. */
181const meter = new AudioMeter()
182/** The running tap, while the audio theme is drawn. */
183let tap: { stop: () => void } | null = null
184/** The props each band instance was last drawn with, by its key: a frame's answer carries them. */
185const stageProps = new Map<string, StageProps>()
186
187/** The tap's binary, built from its source beside it on first use (and again when the source is newer). */
188async function buildTap($: EngineInterface): Promise<string> {
189 const src = `${$.plugin.root}/hooks/audio-tap.swift`
190 const bin = `${$.plugin.root}/hooks/audio-tap`
191 const [built, source] = await Promise.all([$.fs.stat(bin).catch(() => null), $.fs.stat(src)])
192 if (built && built.mtimeMs >= source.mtimeMs) return bin
193 let out
194 try {
195 out = await $.process.run(['swiftc', '-O', src, '-o', bin], { timeoutMs: 120_000 })
196 } catch (err) {
197 // The engine's word first (no processes on this surface, or no swiftc), then the usual fix.
198 throw new Error(`${err instanceof Error ? err.message : String(err)}; swiftc comes with xcode-select --install`)
199 }
200 if (out.exitCode !== 0) throw new Error(`swiftc: ${out.stderr.trim().split('\n')[0] || `exit ${out.exitCode}`}`)
201 return bin
202}
203
204/** Starts the tap: its lines into the meter, `spinner.tap` told when sound starts or stops, or why it ended. */
205function startTap($: EngineInterface): { stop: () => void } {
206 let isStopped = false
207 let stream: AsyncIterator<unknown> | undefined
208 const fail = (error: string) => (isStopped ? undefined : update($, tapState, () => ({ isAudible: false, error })))
209 void (async () => {
210 let said = ''
211 try {
212 const bin = await buildTap($)
213 if (isStopped) return
214 const child = $.process.spawn({ argv: [bin, String(AUDIO_BANDS)] })
215 stream = child[Symbol.asyncIterator]()
216 const split = lineSplitter()
217 let wasAudible = false
218 for await (const chunk of child) {
219 if (isStopped) break
220 if (chunk.stream === 'stderr') {
221 said = chunk.text.trim()
222 continue
223 }
224 for (const line of split(chunk.text)) meter.push(line)
225 if (meter.isAudible !== wasAudible) {
226 wasAudible = meter.isAudible
227 await update($, tapState, () => ({ isAudible: wasAudible, error: null }))
228 }
229 }
230 await fail(said || 'the tap stopped')
231 } catch (err) {
232 await fail(said || (err instanceof Error ? err.message : String(err)))
233 }
234 })()
235 return {
236 stop: () => {
237 isStopped = true
238 void stream?.return?.()
239 },
240 }
241}
242
243/** Runs the tap exactly while the audio theme's band can show. */
244async function syncTap($: EngineInterface): Promise<void> {
245 const wants = (await read($, theme)) === 'audio' && !(await read($, isHidden)) && !(await read($, isStageOff))
246 if (wants && !tap) {
247 await update($, tapState, () => ({ isAudible: false, error: null }))
248 tap = startTap($)
249 } else if (!wants && tap) {
250 tap.stop()
251 tap = null
252 await update($, tapState, () => ({ isAudible: false, error: null }))
253 }
254}
255
256/** What the band draws for the audio theme now: the meter's view, or null when the tap could not run. */
257async function feedOf($: EngineInterface): Promise<AudioView | null> {
258 return (await read($, tapState)).error ? null : meter.view()
259}
260
261/** Busy with the latest tool still running (subagents counted), else back to thinking. */
262async function settle($: EngineInterface, running: Map<string, string>): Promise<void> {
263 const last = busyLabel([...running.values()])
264 await update($, activity, () => (last ? { act: 'tool' as Act, tool: last } : { act: 'think' as Act }))
265 await publishPet($)
266}
267
268/** Sets a switch's session mirror and, when it changed, its row. */
269async function setSwitch($: EngineInterface, field: 'visible' | 'stage' | 'companion', isOn: boolean): Promise<void> {
270 // Each mirror spelled out: the engine reads which state a hook touches from the source.
271 const wasOff =
272 field === 'visible'
273 ? await read($, isHidden)
274 : field === 'stage'
275 ? await read($, isStageOff)
276 : await read($, isCompanionOff)
277 if (wasOff === !isOn) return
278 if (field === 'visible') await update($, hiddenSet, () => !isOn)
279 else if (field === 'stage') await update($, stageOffSet, () => !isOn)
280 else await update($, companionOffSet, () => !isOn)
281 await publishPet($)
282 await syncTap($)
283 await persist(prefsOf($), field, isOn)
284}
285
286/** `/spinner` with no arguments: the theme, the pet, what is off, the themes and the usage. */
287async function status($: EngineInterface): Promise<string> {
288 const current = await read($, theme)
289 const stats = await read($, pet)
290 const lines = [
291 m('cmd.status', { theme: `${current}${(await read($, choice)) === 'random' ? m('cmd.randomNote') : ''}` }),
292 m('cmd.petStats', { theme: current, level: levelOf(stats.xp), xp: stats.xp, love: stats.love }),
293 ]
294 if (await read($, isHidden)) lines.push(m('cmd.hidden'))
295 if (await read($, isStageOff)) lines.push(m('cmd.stageOff'))
296 if (await read($, isCompanionOff)) lines.push(m('cmd.companionOff'))
297 if (current === 'audio') {
298 const { error } = await read($, tapState)
299 lines.push(error ? m('cmd.audioOff', { reason: error }) : m('cmd.audioOn'))
300 }
301 lines.push(m('cmd.themes', { list: THEME_NAMES.map(n => `${n} ${THEMES[n].happy}`).join(' · ') }), m('cmd.usage'))
302 return lines.join('\n')
303}
304
305/** The session's theme, switches and pet, from the rows. */
306async function seed($: EngineInterface, rows: Config): Promise<void> {
307 await choose($, rows.theme)
308 await update($, hiddenSet, () => !rows.isVisible)
309 await update($, stageOffSet, () => !rows.hasStage)
310 await update($, companionOffSet, () => !rows.hasCompanion)
311 const stats = await keptPet($)
312 await update($, pet, () => stats)
313 await publishPet($)
314 await syncTap($)
315}
316
317// What versions before 0.3 kept in the store, as `/config` rows.
318const STORE_MOVES = {
319 theme: (kept: unknown) => (kept === 'random' || isThemeName(kept) ? (['theme', kept] as const) : null),
320 isHidden: (kept: unknown) => ['visible', kept !== true] as const,
321 isStageOff: (kept: unknown) => ['stage', kept !== true] as const,
322 isCompanionOff: (kept: unknown) => ['companion', kept !== true] as const,
323}
324
325export const register: Register = (on, options) => {
326 const config = readConfig(options)
327 rows = config
328 isStill = config.isStill
329 // Tool calls running now: a pet stays busy until the last of parallel calls ends.
330 const running = new Map<string, string>()
331 // Every call still open, a subagent's too: an ask is shown only while its call is.
332 const open = new Set<string>()
333 let sleepTimer: { cancel: () => void } | undefined
334
335 on('session.start', async ($, e, next) => {
336 const settings = (await $.settings.read().catch(() => ({}))) as { language?: unknown }
337 const locale = await Promise.all([
338 $.env.get('LC_ALL').catch(() => undefined),
339 $.env.get('LC_MESSAGES').catch(() => undefined),
340 $.env.get('LANG').catch(() => undefined),
341 ])
342 setLang(resolveLanguage(config.language, settings.language, locale))
343 await $.command.register({ name: 'spinner', description: m('cmd.description'), argumentHint: USAGE })
344
345 await seed($, readConfig({ ...options, ...(await keptRows(prefsOf($), STORE_MOVES)) }))
346 const result = await next(e)
347 await migrateStore(prefsOf($), STORE_MOVES)
348 return result
349 })
350
351 on('turn.start', async ($, e, next) => {
352 // A session resumed or cleared: no session.start came to pick its theme or read its pet.
353 if ((await read($, theme)) === '') await seed($, config)
354 running.clear()
355 sleepTimer?.cancel()
356 await update($, activity, () => ({ act: 'think' as Act }))
357 await update($, isTurn, () => true)
358 if (await read($, finale)) await update($, finale, () => null)
359 await publishPet($)
360 return next(e)
361 })
362
363 on('tool.call', async ($, e, next) => {
364 // A subagent's call is no news for the bubble, but its permission ask is.
365 if (e.agentId) {
366 open.add(e.tool_use_id)
367 try {
368 return await next(e)
369 } finally {
370 open.delete(e.tool_use_id)
371 // Its ask answered: back to what the main thread is doing.
372 if ((await read($, activity)).act === 'ask') await settle($, running)
373 }
374 }
375 open.add(e.tool_use_id)
376 const label = toolLabel(e as unknown as { tool: string } & Record<string, unknown>)
377 running.set(e.tool_use_id, label)
378 await update($, activity, () => (e.tool === 'AskUserQuestion' ? { act: 'ask' as Act } : { act: 'tool' as Act, tool: busyLabel([...running.values()]) }))
379 await publishPet($)
380 try {
381 const result = await next(e)
382 const command = (e as unknown as { command?: unknown }).command
383 if (e.tool === 'Bash' && typeof command === 'string' && !('deny' in result && result.deny !== undefined)) {
384 await noteNews($, command, result.isError === true, e.tool_use_id)
385 }
386 return result
387 } finally {
388 open.delete(e.tool_use_id)
389 running.delete(e.tool_use_id)
390 await settle($, running)
391 }
392 })
393
394 // A call the engine puts to the person: `ask` from the permission check. The
395 // mode often settles an ask by itself at once, so the pet waits a moment and
396 // asks only if the call is still open.
397 on('tool.check', async ($, e, next) => {
398 const verdict = await next(e)
399 const id = e.tool_use_id
400 if (verdict.decision === 'ask' && id !== undefined && open.has(id)) {
401 $.clock.after(ASK_DELAY_MS, async () => {
402 if (!open.has(id)) return
403 await update($, activity, a => ({ ...a, act: 'ask' as Act }))
404 await publishPet($)
405 })
406 }
407 return verdict
408 })
409
410 on('turn.complete', async ($, e, next) => {
411 const result = await next(e)
412 if (e.agentId) return result
413 running.clear()
414 await update($, activity, () => ({ act: 'think' as Act }))
415 await update($, isTurn, () => false)
416
417 const kind = finaleOf(e.reason)
418 await update($, mood, () => (kind === 'answer' ? 'ready' : kind))
419 sleepTimer?.cancel()
420 sleepTimer = $.clock.after(SLEEP_MS, async () => {
421 await update($, mood, () => 'sleep' as Mood)
422 await publishPet($)
423 })
424
425 if (kind === 'answer') {
426 await gainXp($)
427 }
428
429 await publishPet($)
430 if (config.hasFinale) {
431 const label =
432 kind === 'answer' ? m('finale.done', { time: formatDuration(e.durationMs) }) : m(kind === 'aborted' ? 'finale.aborted' : 'finale.error')
433 const id = e.turnId
434 await update($, finale, () => ({ kind, label, id }))
435 $.clock.after(FINALE_MS, () => void update($, finale, f => (f?.id === id ? null : f)))
436 }
437 return result
438 })
439
440 // A click on the pet, where this plugin draws it.
441 // The audio band asking for this frame's levels: its props again, with them.
442 on('ui.message', async ($, e, next) => {
443 const data = e.data as { pat?: unknown; audio?: unknown } | null
444 if (data?.pat === true) await patPet($)
445 const drawn = data?.audio === true ? stageProps.get(e.element) : undefined
446 const heard = await next(e)
447 return drawn ? { ...heard, props: { ...drawn, audio: await feedOf($) } } : heard
448 })
449
450 // A click on the pet where hud draws it: hud counts it in `hud.petPats`.
451 on('state.set', { plugin: 'hud', key: 'petPats' }, async ($, e, next) => {
452 const result = await next(e)
453 await patPet($)
454 return result
455 })
456
457 on('command.run', { command: 'spinner' }, async ($, e) => {
458 const command = parseCommand(e.args)
459 const list = THEME_NAMES.join(' · ')
460 switch (command.kind) {
461 case 'status':
462 return { text: await status($) }
463 case 'visible':
464 await setSwitch($, 'visible', command.isOn)
465 return { text: m(command.isOn ? 'cmd.shown' : 'cmd.hidden') }
466 case 'stage':
467 await setSwitch($, 'stage', command.isOn)
468 return { text: m(command.isOn ? 'cmd.stageOn' : 'cmd.stageOff') }
469 case 'companion':
470 await setSwitch($, 'companion', command.isOn)
471 return { text: m(command.isOn ? 'cmd.companionOn' : 'cmd.companionOff') }
472 case 'pat': {
473 const stats = await patPet($)
474 return { text: m('cmd.pat', { theme: await read($, theme), love: stats.love }) }
475 }
476 case 'preview': {
477 const current = await read($, theme)
478 const name: ThemeName = command.theme ?? (isThemeName(current) ? current : 'clawd')
479 const id = String(await $.clock.now())
480 await update($, preview, () => ({ theme: name, id }))
481 $.clock.after(PREVIEW_MS, () => void update($, preview, p => (p?.id === id ? null : p)))
482 return { text: m('cmd.preview', { theme: name }) }
483 }
484 case 'pick': {
485 // 2-4 options fit the dialog: random and three others; any theme typed under Other.
486 const current = await read($, theme)
487 const others = THEME_NAMES.filter(n => n !== current)
488 const start = (await $.clock.now()) % others.length
489 const offered = ['random', ...[0, 1, 2].map(i => others[(start + i) % others.length]!)]
490 let answer: string
491 try {
492 answer = (await $.ui.ask(m('cmd.pick', { list }), { options: offered, header: 'Theme' })).trim().toLowerCase()
493 } catch {
494 // Dismissed, or no one to ask (-p): what `/spinner` alone says.
495 return { text: await status($) }
496 }
497 if (answer !== 'random' && !isThemeName(answer)) return { text: m('cmd.unknown', { name: answer, list }) }
498 const name = await choose($, answer, true)
499 await syncTap($)
500 await persist(prefsOf($), 'theme', answer)
501 return { text: answer === 'random' ? m('cmd.random', { theme: name }) : m('cmd.switched', { theme: name }) }
502 }
503 case 'theme': {
504 const name = await choose($, command.theme, true)
505 await syncTap($)
506 await persist(prefsOf($), 'theme', command.theme)
507 return { text: command.theme === 'random' ? m('cmd.random', { theme: name }) : m('cmd.switched', { theme: name }) }
508 }
509 case 'unknown':
510 return { text: m('cmd.unknown', { name: command.name, list }) }
511 }
512 })
513
514 // The mascot rides in front of the engine's own line, which keeps its word,
515 // elapsed time and tokens.
516 // A picker (`/` commands, `@` files) opens above the band: the band steps aside meanwhile.
517 on('prompt.edit', async ($, e, next) => {
518 const box = await next(e)
519 const isOpen = isPickerOpen(box.text, box.cursor)
520 if ((await read($, isPicking)) !== isOpen) await update($, isPicking, () => isOpen)
521 return box
522 })
523 on('prompt.submit', async ($, e, next) => {
524 if (await read($, isPicking)) await update($, isPicking, () => false)
525 return next(e)
526 })
527
528 // A button in the prompt footer: `/spinner off` / `on`. Mode labels other plugins add stay beside it.
529 if (config.hasFooterButton) {
530 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
531 const hidden = await read($, isHidden)
532 const below = await next(e)
533 const { Box, Button } = $.ui.resolve(e)
534 return (
535 <Box flexDirection="row" alignItems="center" gap={1}>
536 <Button key="spinner-toggle" plain dimColor={hidden} label="Spinner" onPress={() => setSwitch($, 'visible', hidden)} />
537 {below}
538 </Box>
539 )
540 })
541 }
542
543 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
544 // One mascot at a time: with the companion's row showing, it stays there.
545 if ((await read($, isHidden)) || !(await read($, isCompanionOff))) return next(e)
546 const ui = $.ui.resolve(e)
547 if (!('Client' in ui)) return next(e)
548 const { Box, Client } = ui
549 const current = await read($, theme)
550 const name: ThemeName = isThemeName(current) ? current : 'clawd'
551 const native = await next(e)
552 return (
553 <Box flexDirection="row" columnGap={1}>
554 {/* The engine's line opens with a blank row; the mascot sits on the line itself. */}
555 <Box marginTop={1}>
556 <Client key="sprite" module="./sprite.tsx" width={spriteWidth(name)} props={{ theme: name, mode: e.props.mode, still: isStill }} />
557 </Box>
558 {native}
559 </Box>
560 )
561 })
562
563 // The band: a preview, else the running turn's scene or its finale, and the
564 // pet right-aligned beside it unless hud has it beside its rows.
565 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
566 if (e.props.hasSurvey || (await read($, isPicking))) return next(e)
567 const ui = $.ui.resolve(e)
568 if (!('Client' in ui)) return next(e)
569 const { Box, Client } = ui
570
571 const shown: Preview | null = await read($, preview)
572 const current = await read($, theme)
573 const name: ThemeName = isThemeName(current) ? current : 'clawd'
574 const pet = (await read($, isHidden)) || (await read($, hudDock)) ? null : await read($, dock)
575 // Two cells in, and the pet's block on the right.
576 const petColumns = pet ? pet.width + 1 + PET_LABEL_W : 0
577 const columns = e.props.bodyColumns - 2 - petColumns
578 const sceneFits = (rows: number) => e.props.maxRows >= Math.max(rows, pet ? PET_ROWS : 0)
579 let scene = null
580 // The audio theme's band, fed by the tap each frame; its props kept for the frames' answers.
581 const heard = await read($, tapState)
582 const live = name === 'audio' && tap !== null
583 stageProps.clear()
584 const audioStage = async (key: string, props: StageProps) => {
585 const fed: StageProps = live ? { ...props, audio: await feedOf($), feed: !isStill } : props
586 stageProps.set(key, fed)
587 return <Client key={key} module="./stage.tsx" width={columns} props={fed} />
588 }
589
590 if (shown && isThemeName(shown.theme)) {
591 const props: StageProps = { theme: shown.theme, columns, act: 'think', still: isStill }
592 scene = shown.theme === 'audio' ? await audioStage(`preview-${shown.id}`, props) : <Client key={`preview-${shown.id}`} module="./stage.tsx" width={columns} props={props} />
593 } else if (!(await read($, isHidden)) && !(await read($, isStageOff)) && sceneFits(THEMES[name].rows)) {
594 const ended: FinaleState | null = await read($, finale)
595 const now: Activity = await read($, activity)
596 if (e.props.isWorking) {
597 const props: StageProps = { theme: name, columns, act: now.act, still: isStill }
598 scene = live ? await audioStage('work', props) : <Client key="work" module="./stage.tsx" width={columns} props={props} />
599 } else if (live && !ended && heard.isAudible) {
600 // Between turns the audio theme stays up while something plays.
601 scene = await audioStage('listen', { theme: name, columns, act: 'wait', still: isStill })
602 } else if (ended) {
603 const props = { theme: name, columns, act: now.act, finale: ended.kind, label: ended.label, still: isStill }
604 scene = <Client key={`finale-${ended.id}`} module="./stage.tsx" width={columns} props={props} />
605 }
606 }
607 if (!scene && !pet) return next(e)
608
609 // Too short or narrow for the pet's block: the pet in one row, the scene left out.
610 if (pet && !shown && (e.props.maxRows < PET_ROWS || e.props.bodyColumns < COMPACT_COLUMNS)) {
611 const mascot = { text: THEMES[name].sprite.say[0] ?? '', color: THEMES[name].color }
612 return stackAbove(ui, <Box justifyContent="flex-end">{drawPetLine(ui, pet, mascot)}</Box>, await next(e))
613 }
614 const band = (
615 <Box flexDirection="row" justifyContent="space-between" alignItems="flex-end">
616 {scene ?? <Box />}
617 {pet ? <Box width={petColumns} justifyContent="flex-end">{drawPet(ui, pet, 'pet')}</Box> : null}
618 </Box>
619 )
620 return stackAbove(ui, band, await next(e))
621 })
622}
623
624/** Cells the mascot's region takes: its widest frame, with room for a symbol a terminal draws wide. */
625function spriteWidth(name: ThemeName): number {
626 return Math.max(...Object.values(THEMES[name].sprite).flat().map(textWidth)) + 2
627}
628hooks/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 396 lines1// spinner's messages; the language is resolved by the kit (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 | 'finale.done'
23 | 'finale.aborted'
24 | 'finale.error'
25 | 'pet.hello'
26 | 'pet.ask'
27 | 'pet.ready'
28 | 'pet.aborted'
29 | 'pet.error'
30 | 'pet.sleep'
31 | 'pet.levelUp'
32 | 'pet.testPass'
33 | 'pet.testFail'
34 | 'pet.commit'
35 | 'cmd.pat'
36 | 'cmd.petStats'
37 | 'cmd.companionOff'
38 | 'cmd.companionOn'
39 | 'cmd.audioOn'
40 | 'cmd.audioOff'
41
42export const MESSAGES: Record<Lang, Record<Key, string>> = {
43 en: {
44 'cmd.description': 'Spinner animations: themes, a companion pet, preview, off / on (kept across sessions)',
45 'cmd.status': 'Theme: {theme}',
46 'cmd.randomNote': ' (random each session)',
47 'cmd.themes': 'Themes: {list}',
48 'cmd.usage': 'Usage: /spinner <theme|random> · theme (pick) · pet · preview [theme] · off / on · stage off / on · companion off / on',
49 'cmd.switched': 'Switched to {theme}',
50 'cmd.random': 'A random theme each session; this one: {theme}',
51 'cmd.hidden': 'Spinner animations off',
52 'cmd.shown': 'Spinner animations on',
53 'cmd.stageOff': 'Animation band above the prompt off',
54 'cmd.stageOn': 'Animation band above the prompt on',
55 'cmd.preview': 'Previewing {theme} above the prompt',
56 'cmd.unknown': 'No theme called {name}. Themes: {list}',
57 'cmd.pick': 'Which theme? (Other: type one of {list})',
58 'finale.done': 'Done · {time}',
59 'finale.aborted': 'Interrupted',
60 'finale.error': 'Something went wrong',
61 'pet.hello': 'I’m here with you~',
62 'pet.ask': 'Waiting for your OK~',
63 'pet.ready': 'All done, take a look!',
64 'pet.aborted': 'Okay, stopped.',
65 'pet.error': 'Ouch… something broke',
66 'pet.sleep': 'Zzz…',
67 'pet.levelUp': '{theme} reached Lv.{level}!',
68 'pet.testPass': 'Tests passed!',
69 'pet.testFail': 'Tests failed… we’ll fix it',
70 'pet.commit': 'Committed!',
71 'cmd.pat': 'You patted {theme} (♥{love})',
72 'cmd.petStats': '{theme} · Lv.{level} · {xp} xp · ♥{love}',
73 'cmd.companionOff': 'Companion between turns hidden',
74 'cmd.companionOn': 'Companion between turns shown',
75 'cmd.audioOn': 'Audio: showing this computer\'s sound output (only levels, nothing kept or sent)',
76 'cmd.audioOff': 'Audio: nothing to show ({reason}). Needs macOS 14.2+, swiftc (Xcode Command Line Tools) and System Audio Recording allowed for your terminal',
77 },
78 'zh-Hans': {
79 'cmd.description': '运行动画:切换主题、宠物伴侣、预览、off / on(跨会话保持)',
80 'cmd.status': '当前主题:{theme}',
81 'cmd.randomNote': '(每个会话随机)',
82 'cmd.themes': '可选主题:{list}',
83 'cmd.usage': '用法:/spinner <主题|random> · theme 弹窗选择 · pet 摸摸 · preview [主题] · off / on · stage off / on · companion off / on',
84 'cmd.switched': '已切换到 {theme}',
85 'cmd.random': '每个会话随机一个主题,这次是 {theme}',
86 'cmd.hidden': '运行动画已关闭',
87 'cmd.shown': '运行动画已开启',
88 'cmd.stageOff': '输入框上方的动画带已关闭',
89 'cmd.stageOn': '输入框上方的动画带已开启',
90 'cmd.preview': '正在输入框上方预览 {theme}',
91 'cmd.unknown': '没有叫 {name} 的主题。可选:{list}',
92 'cmd.pick': '换哪个主题?(其他里可输入:{list})',
93 'finale.done': '完成 · {time}',
94 'finale.aborted': '已中断',
95 'finale.error': '出错了',
96 'pet.hello': '我在这儿陪你~',
97 'pet.ask': '等你确认一下~',
98 'pet.ready': '做完啦,快看看!',
99 'pet.aborted': '好吧,停下了',
100 'pet.error': '呜…出错了',
101 'pet.sleep': 'Zzz…',
102 'pet.levelUp': '{theme} 升到 Lv.{level} 啦!',
103 'pet.testPass': '测试通过啦!',
104 'pet.testFail': '测试没过…一起修吧',
105 'pet.commit': '提交好啦!',
106 'cmd.pat': '你摸了摸 {theme}(♥{love})',
107 'cmd.petStats': '{theme} · Lv.{level} · 经验 {xp} · ♥{love}',
108 'cmd.companionOff': '空闲时的宠物已隐藏',
109 'cmd.companionOn': '空闲时的宠物已显示',
110 'cmd.audioOn': '音频:正在显示本机的声音输出(只取电平,不保存、不外传)',
111 'cmd.audioOff': '音频:无法显示({reason})。需要 macOS 14.2 及以上、swiftc(Xcode 命令行工具),并在系统设置里允许终端“录制系统音频”',
112 },
113 'zh-Hant': {
114 'cmd.description': '執行動畫:切換主題、寵物夥伴、預覽、off / on(跨工作階段保留)',
115 'cmd.status': '目前主題:{theme}',
116 'cmd.randomNote': '(每個工作階段隨機)',
117 'cmd.themes': '可選主題:{list}',
118 'cmd.usage': '用法:/spinner <主題|random> · theme 彈窗選擇 · pet 摸摸 · preview [主題] · off / on · stage off / on · companion off / on',
119 'cmd.switched': '已切換到 {theme}',
120 'cmd.random': '每個工作階段隨機一個主題,這次是 {theme}',
121 'cmd.hidden': '執行動畫已關閉',
122 'cmd.shown': '執行動畫已開啟',
123 'cmd.stageOff': '輸入框上方的動畫帶已關閉',
124 'cmd.stageOn': '輸入框上方的動畫帶已開啟',
125 'cmd.preview': '正在輸入框上方預覽 {theme}',
126 'cmd.unknown': '沒有名為 {name} 的主題。可選:{list}',
127 'cmd.pick': '換哪個主題?(其他裡可輸入:{list})',
128 'finale.done': '完成 · {time}',
129 'finale.aborted': '已中斷',
130 'finale.error': '出錯了',
131 'pet.hello': '我在這裡陪你~',
132 'pet.ask': '等你確認一下~',
133 'pet.ready': '做完了,快看看!',
134 'pet.aborted': '好吧,停下了',
135 'pet.error': '嗚…出錯了',
136 'pet.sleep': 'Zzz…',
137 'pet.levelUp': '{theme} 升到 Lv.{level} 了!',
138 'pet.testPass': '測試通過了!',
139 'pet.testFail': '測試沒過…一起修吧',
140 'pet.commit': '提交好了!',
141 'cmd.pat': '你摸了摸 {theme}(♥{love})',
142 'cmd.petStats': '{theme} · Lv.{level} · 經驗 {xp} · ♥{love}',
143 'cmd.companionOff': '閒置時的寵物已隱藏',
144 'cmd.companionOn': '閒置時的寵物已顯示',
145 'cmd.audioOn': '音訊:正在顯示本機的聲音輸出(只取音量,不儲存、不外傳)',
146 'cmd.audioOff': '音訊:無法顯示({reason})。需要 macOS 14.2 以上、swiftc(Xcode 命令列工具),並在系統設定允許終端機「錄製系統音訊」',
147 },
148 ja: {
149 'cmd.description': '実行中アニメーション:テーマ、ペット、プレビュー、off / on(セッションをまたいで保持)',
150 'cmd.status': 'テーマ:{theme}',
151 'cmd.randomNote': '(セッションごとにランダム)',
152 'cmd.themes': 'テーマ一覧:{list}',
153 'cmd.usage': '使い方:/spinner <テーマ|random> · theme 選択 · pet なでる · preview [テーマ] · off / on · stage off / on · companion off / on',
154 'cmd.switched': '{theme} に切り替えました',
155 'cmd.random': 'セッションごとにランダムなテーマ。今回は {theme}',
156 'cmd.hidden': '実行中アニメーションをオフにしました',
157 'cmd.shown': '実行中アニメーションをオンにしました',
158 'cmd.stageOff': '入力欄上のアニメーションバーをオフにしました',
159 'cmd.stageOn': '入力欄上のアニメーションバーをオンにしました',
160 'cmd.preview': '入力欄の上で {theme} をプレビュー中',
161 'cmd.unknown': '{name} というテーマはありません。テーマ一覧:{list}',
162 'cmd.pick': 'どのテーマにする?(その他で入力可:{list})',
163 'finale.done': '完了 · {time}',
164 'finale.aborted': '中断しました',
165 'finale.error': 'エラーが発生しました',
166 'pet.hello': 'そばにいるよ~',
167 'pet.ask': '確認を待ってるよ~',
168 'pet.ready': 'できたよ、見てみて!',
169 'pet.aborted': 'はい、止めたよ',
170 'pet.error': 'うぅ…エラーだ',
171 'pet.sleep': 'Zzz…',
172 'pet.levelUp': '{theme} が Lv.{level} になった!',
173 'pet.testPass': 'テスト通った!',
174 'pet.testFail': 'テスト失敗…直そう',
175 'pet.commit': 'コミットした!',
176 'cmd.pat': '{theme} をなでた(♥{love})',
177 'cmd.petStats': '{theme} · Lv.{level} · 経験値 {xp} · ♥{love}',
178 'cmd.companionOff': '待機中のペットを非表示にしました',
179 'cmd.companionOn': '待機中のペットを表示しました',
180 'cmd.audioOn': 'オーディオ:このコンピュータの音声出力を表示中(レベルのみ、保存・送信なし)',
181 'cmd.audioOff': 'オーディオ:表示できません({reason})。macOS 14.2 以降、swiftc(Xcode コマンドラインツール)、ターミナルへの「システムオーディオ録音」の許可が必要です',
182 },
183 ko: {
184 'cmd.description': '실행 애니메이션: 테마, 펫, 미리 보기, off / on(세션 간 유지)',
185 'cmd.status': '현재 테마: {theme}',
186 'cmd.randomNote': ' (세션마다 무작위)',
187 'cmd.themes': '테마 목록: {list}',
188 'cmd.usage': '사용법: /spinner <테마|random> · theme 선택 · pet 쓰다듬기 · preview [테마] · off / on · stage off / on · companion off / on',
189 'cmd.switched': '{theme}(으)로 전환했습니다',
190 'cmd.random': '세션마다 무작위 테마, 이번에는 {theme}',
191 'cmd.hidden': '실행 애니메이션을 껐습니다',
192 'cmd.shown': '실행 애니메이션을 켰습니다',
193 'cmd.stageOff': '입력창 위 애니메이션 띠를 껐습니다',
194 'cmd.stageOn': '입력창 위 애니메이션 띠를 켰습니다',
195 'cmd.preview': '입력창 위에서 {theme} 미리 보는 중',
196 'cmd.unknown': '{name} 테마가 없습니다. 테마 목록: {list}',
197 'cmd.pick': '어떤 테마로 할까요? (기타에 입력: {list})',
198 'finale.done': '완료 · {time}',
199 'finale.aborted': '중단됨',
200 'finale.error': '오류가 발생했습니다',
201 'pet.hello': '여기서 함께할게요~',
202 'pet.ask': '확인을 기다려요~',
203 'pet.ready': '다 했어요, 확인해 보세요!',
204 'pet.aborted': '네, 멈췄어요',
205 'pet.error': '으… 오류가 났어요',
206 'pet.sleep': 'Zzz…',
207 'pet.levelUp': '{theme} Lv.{level} 달성!',
208 'pet.testPass': '테스트 통과!',
209 'pet.testFail': '테스트 실패… 같이 고쳐요',
210 'pet.commit': '커밋 완료!',
211 'cmd.pat': '{theme}을(를) 쓰다듬었어요 (♥{love})',
212 'cmd.petStats': '{theme} · Lv.{level} · 경험치 {xp} · ♥{love}',
213 'cmd.companionOff': '대기 중 펫을 숨겼습니다',
214 'cmd.companionOn': '대기 중 펫을 표시했습니다',
215 'cmd.audioOn': '오디오: 이 컴퓨터의 소리 출력을 표시 중 (레벨만, 저장·전송 없음)',
216 'cmd.audioOff': '오디오: 표시할 수 없음 ({reason}). macOS 14.2 이상, swiftc(Xcode 명령줄 도구), 터미널의 \'시스템 오디오 녹음\' 허용이 필요합니다',
217 },
218 es: {
219 'cmd.description': 'Animaciones del spinner: temas, mascota, vista previa, off / on (se mantiene entre sesiones)',
220 'cmd.status': 'Tema: {theme}',
221 'cmd.randomNote': ' (al azar en cada sesión)',
222 'cmd.themes': 'Temas: {list}',
223 'cmd.usage': 'Uso: /spinner <tema|random> · theme (elegir) · pet · preview [tema] · off / on · stage off / on · companion off / on',
224 'cmd.switched': 'Cambiado a {theme}',
225 'cmd.random': 'Un tema al azar en cada sesión; esta vez: {theme}',
226 'cmd.hidden': 'Animaciones del spinner desactivadas',
227 'cmd.shown': 'Animaciones del spinner activadas',
228 'cmd.stageOff': 'Barra animada sobre el prompt desactivada',
229 'cmd.stageOn': 'Barra animada sobre el prompt activada',
230 'cmd.preview': 'Vista previa de {theme} sobre el prompt',
231 'cmd.unknown': 'No hay ningún tema llamado {name}. Temas: {list}',
232 'cmd.pick': '¿Qué tema? (En Otro escribe uno de: {list})',
233 'finale.done': 'Listo · {time}',
234 'finale.aborted': 'Interrumpido',
235 'finale.error': 'Algo salió mal',
236 'pet.hello': '¡Aquí estoy contigo!',
237 'pet.ask': 'Esperando tu OK~',
238 'pet.ready': '¡Listo, échale un vistazo!',
239 'pet.aborted': 'Vale, me detengo.',
240 'pet.error': 'Ay… algo falló',
241 'pet.sleep': 'Zzz…',
242 'pet.levelUp': '¡{theme} subió a Nv.{level}!',
243 'pet.testPass': '¡Pasan las pruebas!',
244 'pet.testFail': 'Fallan las pruebas… lo arreglamos',
245 'pet.commit': '¡Commit hecho!',
246 'cmd.pat': 'Acariciaste a {theme} (♥{love})',
247 'cmd.petStats': '{theme} · Nv.{level} · {xp} xp · ♥{love}',
248 'cmd.companionOff': 'Mascota entre turnos oculta',
249 'cmd.companionOn': 'Mascota entre turnos visible',
250 'cmd.audioOn': 'Audio: mostrando la salida de sonido del equipo (solo niveles; nada se guarda ni se envía)',
251 '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',
252 },
253 fr: {
254 'cmd.description': 'Animations du spinner : thèmes, compagnon, aperçu, off / on (réglage conservé entre les sessions)',
255 'cmd.status': 'Thème : {theme}',
256 'cmd.randomNote': ' (au hasard à chaque session)',
257 'cmd.themes': 'Thèmes : {list}',
258 'cmd.usage': 'Usage : /spinner <thème|random> · theme (choisir) · pet · preview [thème] · off / on · stage off / on · companion off / on',
259 'cmd.switched': 'Thème {theme} activé',
260 'cmd.random': 'Un thème au hasard à chaque session ; cette fois : {theme}',
261 'cmd.hidden': 'Animations du spinner désactivées',
262 'cmd.shown': 'Animations du spinner activées',
263 'cmd.stageOff': 'Bande animée au-dessus du prompt désactivée',
264 'cmd.stageOn': 'Bande animée au-dessus du prompt activée',
265 'cmd.preview': 'Aperçu de {theme} au-dessus du prompt',
266 'cmd.unknown': 'Aucun thème nommé {name}. Thèmes : {list}',
267 'cmd.pick': 'Quel thème ? (Autre : tape l’un de {list})',
268 'finale.done': 'Terminé · {time}',
269 'finale.aborted': 'Interrompu',
270 'finale.error': 'Une erreur est survenue',
271 'pet.hello': 'Je suis là avec toi~',
272 'pet.ask': 'J’attends ton accord~',
273 'pet.ready': 'Fini, jette un œil\u00a0!',
274 'pet.aborted': 'D’accord, j’arrête.',
275 'pet.error': 'Aïe… une erreur',
276 'pet.sleep': 'Zzz…',
277 'pet.levelUp': '{theme} passe au niv.\u00a0{level}\u00a0!',
278 'pet.testPass': 'Tests réussis\u00a0!',
279 'pet.testFail': 'Tests en échec… on répare',
280 'pet.commit': 'Commit fait\u00a0!',
281 'cmd.pat': 'Tu as caressé {theme} (♥{love})',
282 'cmd.petStats': '{theme} · niv.\u00a0{level} · {xp}\u00a0xp · ♥{love}',
283 'cmd.companionOff': 'Compagnon entre les tours masqué',
284 'cmd.companionOn': 'Compagnon entre les tours affiché',
285 'cmd.audioOn': 'Audio : affiche la sortie son de l’ordinateur (niveaux seulement, rien n’est gardé ni envoyé)',
286 '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',
287 },
288 de: {
289 'cmd.description': 'Spinner-Animationen: Themen, Begleiter, Vorschau, off / on (bleibt über Sitzungen erhalten)',
290 'cmd.status': 'Thema: {theme}',
291 'cmd.randomNote': ' (jede Sitzung zufällig)',
292 'cmd.themes': 'Themen: {list}',
293 'cmd.usage': 'Verwendung: /spinner <Thema|random> · theme (auswählen) · pet · preview [Thema] · off / on · stage off / on · companion off / on',
294 'cmd.switched': 'Zu {theme} gewechselt',
295 'cmd.random': 'Jede Sitzung ein zufälliges Thema; diesmal: {theme}',
296 'cmd.hidden': 'Spinner-Animationen aus',
297 'cmd.shown': 'Spinner-Animationen an',
298 'cmd.stageOff': 'Animationsleiste über der Eingabe aus',
299 'cmd.stageOn': 'Animationsleiste über der Eingabe an',
300 'cmd.preview': 'Vorschau von {theme} über der Eingabe',
301 'cmd.unknown': 'Kein Thema namens {name}. Themen: {list}',
302 'cmd.pick': 'Welches Thema? (Unter Andere eintippen: {list})',
303 'finale.done': 'Fertig · {time}',
304 'finale.aborted': 'Abgebrochen',
305 'finale.error': 'Etwas ist schiefgelaufen',
306 'pet.hello': 'Ich bin bei dir~',
307 'pet.ask': 'Warte auf dein OK~',
308 'pet.ready': 'Fertig, schau mal!',
309 'pet.aborted': 'Okay, angehalten.',
310 'pet.error': 'Autsch… ein Fehler',
311 'pet.sleep': 'Zzz…',
312 'pet.levelUp': '{theme} ist jetzt Lv.{level}!',
313 'pet.testPass': 'Tests bestanden!',
314 'pet.testFail': 'Tests rot… wir reparieren das',
315 'pet.commit': 'Committet!',
316 'cmd.pat': 'Du hast {theme} gestreichelt (♥{love})',
317 'cmd.petStats': '{theme} · Lv.{level} · {xp} EP · ♥{love}',
318 'cmd.companionOff': 'Begleiter zwischen den Runden ausgeblendet',
319 'cmd.companionOn': 'Begleiter zwischen den Runden eingeblendet',
320 'cmd.audioOn': 'Audio: zeigt die Tonausgabe dieses Computers (nur Pegel, nichts wird gespeichert oder gesendet)',
321 'cmd.audioOff': 'Audio: nichts anzuzeigen ({reason}). Benötigt macOS 14.2+, swiftc (Xcode Command Line Tools) und die Freigabe „Systemaudioaufnahme“ für dein Terminal',
322 },
323 'pt-BR': {
324 'cmd.description': 'Animações do spinner: temas, mascote, prévia, off / on (configuração mantida entre sessões)',
325 'cmd.status': 'Tema: {theme}',
326 'cmd.randomNote': ' (aleatório a cada sessão)',
327 'cmd.themes': 'Temas: {list}',
328 'cmd.usage': 'Uso: /spinner <tema|random> · theme (escolher) · pet · preview [tema] · off / on · stage off / on · companion off / on',
329 'cmd.switched': 'Trocado para {theme}',
330 'cmd.random': 'Um tema aleatório a cada sessão; desta vez: {theme}',
331 'cmd.hidden': 'Animações do spinner desativadas',
332 'cmd.shown': 'Animações do spinner ativadas',
333 'cmd.stageOff': 'Faixa animada acima do prompt desativada',
334 'cmd.stageOn': 'Faixa animada acima do prompt ativada',
335 'cmd.preview': 'Prévia de {theme} acima do prompt',
336 'cmd.unknown': 'Nenhum tema chamado {name}. Temas: {list}',
337 'cmd.pick': 'Qual tema? (Em Outro digite um de: {list})',
338 'finale.done': 'Pronto · {time}',
339 'finale.aborted': 'Interrompido',
340 'finale.error': 'Algo deu errado',
341 'pet.hello': 'Estou aqui com você~',
342 'pet.ask': 'Esperando seu OK~',
343 'pet.ready': 'Pronto, dá uma olhada!',
344 'pet.aborted': 'Tá, parei.',
345 'pet.error': 'Ai… deu erro',
346 'pet.sleep': 'Zzz…',
347 'pet.levelUp': '{theme} chegou ao Nv.{level}!',
348 'pet.testPass': 'Testes passaram!',
349 'pet.testFail': 'Testes falharam… vamos consertar',
350 'pet.commit': 'Commit feito!',
351 'cmd.pat': 'Você fez carinho em {theme} (♥{love})',
352 'cmd.petStats': '{theme} · Nv.{level} · {xp} xp · ♥{love}',
353 'cmd.companionOff': 'Mascote entre turnos oculto',
354 'cmd.companionOn': 'Mascote entre turnos visível',
355 'cmd.audioOn': 'Áudio: mostrando a saída de som do computador (só níveis; nada é guardado nem enviado)',
356 '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',
357 },
358 ru: {
359 'cmd.description': 'Анимации спиннера: темы, питомец, предпросмотр, off / on (сохраняется между сессиями)',
360 'cmd.status': 'Тема: {theme}',
361 'cmd.randomNote': ' (случайная в каждой сессии)',
362 'cmd.themes': 'Темы: {list}',
363 'cmd.usage': 'Использование: /spinner <тема|random> · theme (выбор) · pet · preview [тема] · off / on · stage off / on · companion off / on',
364 'cmd.switched': 'Тема переключена на {theme}',
365 'cmd.random': 'Случайная тема в каждой сессии; сейчас: {theme}',
366 'cmd.hidden': 'Анимации спиннера выключены',
367 'cmd.shown': 'Анимации спиннера включены',
368 'cmd.stageOff': 'Анимированная полоса над вводом выключена',
369 'cmd.stageOn': 'Анимированная полоса над вводом включена',
370 'cmd.preview': 'Предпросмотр {theme} над вводом',
371 'cmd.unknown': 'Темы {name} нет. Темы: {list}',
372 'cmd.pick': 'Какую тему? (В «Другое» введите одну из: {list})',
373 'finale.done': 'Готово · {time}',
374 'finale.aborted': 'Прервано',
375 'finale.error': 'Что-то пошло не так',
376 'pet.hello': 'Я рядом~',
377 'pet.ask': 'Жду твоего OK~',
378 'pet.ready': 'Готово, посмотри!',
379 'pet.aborted': 'Ладно, остановился.',
380 'pet.error': 'Ой… ошибка',
381 'pet.sleep': 'Zzz…',
382 'pet.levelUp': '{theme} достиг ур.{level}!',
383 'pet.testPass': 'Тесты прошли!',
384 'pet.testFail': 'Тесты упали… починим',
385 'pet.commit': 'Закоммичено!',
386 'cmd.pat': 'Ты погладил {theme} (♥{love})',
387 'cmd.petStats': '{theme} · ур.{level} · {xp} оп. · ♥{love}',
388 'cmd.companionOff': 'Питомец между ходами скрыт',
389 'cmd.companionOn': 'Питомец между ходами показан',
390 'cmd.audioOn': 'Аудио: показывает звук, который выводит компьютер (только уровни, ничего не сохраняется и не отправляется)',
391 'cmd.audioOff': 'Аудио: нечего показать ({reason}). Нужны macOS 14.2+, swiftc (Xcode Command Line Tools) и разрешение «Запись системного аудио» для терминала',
392 },
393}
394
395export const { m, setLang } = createMessages(MESSAGES)
396hooks/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 61 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/** Lv.1 at 0 xp, Lv.2 at 2, Lv.3 at 8, Lv.4 at 18: one xp a finished turn. */
16export function levelOf(xp: number): number {
17 return Math.floor(Math.sqrt(Math.max(0, xp) / 2)) + 1
18}
19
20/** What a tool call is about, short: `Bash: npm test`, `Edit: themes.ts`, `WebSearch`. */
21export function toolLabel(e: { tool: string } & Record<string, unknown>): string {
22 const clip = (s: string) => (textWidth(s) > 32 ? `${Array.from(s).slice(0, 31).join('')}…` : s)
23 if (typeof e.command === 'string') return clip(`${e.tool}: ${e.command.split('\n')[0]!.trim()}`)
24 const path = [e.file_path, e.notebook_path, e.path].find(p => typeof p === 'string') as string | undefined
25 if (path) return clip(`${e.tool}: ${path.split('/').pop()}`)
26 if (typeof e.pattern === 'string') return clip(`${e.tool}: ${e.pattern}`)
27 // A subagent: what it was sent to do.
28 if (typeof e.description === 'string' && e.description.trim()) return clip(`${e.tool}: ${e.description.trim()}`)
29 return clip(e.tool.replace(/^mcp__[^_]+__/, ''))
30}
31
32/** What the pet makes of a finished shell command: tests that ran, a commit. */
33export type News = 'testPass' | 'testFail' | 'commit'
34
35// A test runner at the head of a command or after `&&`, `;`, `|`, `(`: npm test, pytest, go test…
36const 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)/
37const COMMIT = /(?:^|[;&|(]\s*)git\s+(?:-\S+\s+(?:\S+\s+)?)*commit\b/
38
39export function newsOf(command: string, isError: boolean): News | null {
40 if (TEST.test(command)) return isError ? 'testFail' : 'testPass'
41 if (COMMIT.test(command) && !isError && !/--dry-run/.test(command)) return 'commit'
42 return null
43}
44
45/** The bubble for the calls running now: subagents side by side are counted, else the latest call. */
46export function busyLabel(labels: readonly string[]): string | undefined {
47 const agents = labels.filter(label => /^(?:Agent|Task)\b/.test(label))
48 return agents.length > 1 ? `Agent ×${agents.length}` : labels[labels.length - 1]
49}
50
51export function finaleOf(reason: string): Finale {
52 return reason === 'answer' ? 'answer' : reason === 'aborted' ? 'aborted' : 'error'
53}
54
55/** The bubble says what the engine's spinner line does not: the tool, a prompt waiting, how the turn ended. */
56export function bubbleOf(state: Act | Mood, tool: string | undefined): string {
57 if (state === 'tool') return tool ?? ''
58 if (state === 'think' || state === 'say' || state === 'wait') return ''
59 return m(`pet.${state}`)
60}
61hooks/themes.ts 690 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'
7import { AUDIO_BANDS, demoView } from './audio'
8import type { AudioFeed } from './audio'
9
10export * from './cells'
11
12export const THEME_NAMES = [
13 'clawd',
14 'thunder',
15 'chomp',
16 'sparky',
17 'bluecat',
18 'nyan',
19 'cat',
20 'bunny',
21 'sakura',
22 'mecha',
23 'neon',
24 'dino',
25 'ocean',
26 'matrix',
27 'audio',
28] as const
29export type ThemeName = (typeof THEME_NAMES)[number]
30/** Themes that start a process of their own: chosen by name, never drawn at random. */
31const OPT_IN: readonly ThemeName[] = ['audio']
32
33export type Mode = 'requesting' | 'responding' | 'thinking' | 'tool-input' | 'tool-use'
34export type Pose = 'think' | 'tool' | 'say' | 'wait'
35/** What the turn is doing, as the band's companion tells it. */
36export type Act = 'think' | 'tool' | 'ask' | 'say' | 'wait'
37export type Finale = 'answer' | 'aborted' | 'error'
38/** The companion's mood between turns. */
39export type Mood = 'hello' | 'ready' | 'aborted' | 'error' | 'sleep'
40
41export type Theme = {
42 name: ThemeName
43 /** The mascot's color, and a second one for its props. */
44 color: string
45 accent: string
46 /** Frames of the mascot beside the spinner's word, by what the turn is doing. */
47 sprite: Record<Pose, string[]>
48 /** Each character of the mascot in its own color, cycling. */
49 isRainbow?: boolean
50 happy: string
51 sad: string
52 dead: string
53 sleep: string
54 /** Particles of the finale's burst, and their colors. */
55 confetti: string[]
56 palette: string[]
57 /** Rows the scene takes in the band. */
58 rows: number
59 /** The band's scene while a turn runs: `rows` rows of `w` cells; `audio` feeds the audio theme. */
60 scene: (t: number, w: number, act: Act, audio?: AudioFeed) => Grid
61}
62
63/** Milliseconds per frame: the mascot's, the band's, the companion's between turns. */
64export const SPRITE_MS = 140
65export const STAGE_MS = 100
66export const IDLE_MS = 400
67/** How long the finale stays after a turn ends. */
68export const FINALE_MS = 3000
69
70export function poseOf(mode: string): Pose {
71 if (mode === 'thinking' || mode === 'think') return 'think'
72 if (mode === 'tool-use' || mode === 'tool-input' || mode === 'tool') return 'tool'
73 if (mode === 'responding' || mode === 'say') return 'say'
74 return 'wait'
75}
76
77export function isThemeName(name: unknown): name is ThemeName {
78 return typeof name === 'string' && (THEME_NAMES as readonly string[]).includes(name)
79}
80
81/** A theme drawn from `seed`, for the `random` choice. */
82export function pickRandom(seed: number): ThemeName {
83 const pool = THEME_NAMES.filter(n => !OPT_IN.includes(n))
84 return pool[Math.floor(noise(seed) * pool.length)]!
85}
86
87// ---- scenes --------------------------------------------------------------
88
89function catScene(t: number, w: number): Grid {
90 const g = blank(w, 2)
91 for (let x = 0; x < w; x++) {
92 const n = noise(x + 1000 * Math.floor((x + t) / w))
93 if (mod(x + t, 7) === 0) put(g, x, 1, n < 0.5 ? '˙' : ',', { c: '#7fb069', d: true })
94 }
95 const lap = w + 8
96 const cx = mod(Math.floor(t * 0.6), lap) - 6
97 put(g, cx, 1, 'ᓚᘏᗢ', { c: frame(['#ffb3c6', '#ffc8dd'], t >> 1), b: true })
98 put(g, cx + 5, 1, frame(['◐', '◓', '◑', '◒'], t), { c: '#ffd166' })
99 // Hearts float up behind the cat and fade.
100 for (let i = 0; i < 3; i++) {
101 const age = mod(t + i * 5, 15)
102 if (age < 10) put(g, cx - 2 - Math.floor(age / 3), 0, age < 6 ? '♡' : '·', { c: '#ff8fab', d: age >= 6 })
103 }
104 if (mod(t, 40) < 20) put(g, cx + 3, 0, frame(['♪', '♫'], t >> 2), { c: '#cdb4db' })
105 return g
106}
107
108function bunnyScene(t: number, w: number): Grid {
109 const g = blank(w, 2)
110 for (let x = 0; x < w; x++) {
111 if (mod(x * 7 + 3, 11) === 0) put(g, x, 1, '"', { c: '#80b918', d: true })
112 }
113 const lap = w + 11
114 const bx = mod(Math.floor(t * 0.5), lap) - 11
115 // Carrots ahead of the bunny; the ones it has passed are eaten.
116 for (let x = 6; x < w; x += 13) {
117 if (x > bx + 9 || bx < -6) put(g, x, 1, 'ɣ', { c: '#ff9f1c', b: true })
118 }
119 const isUp = mod(t, 6) < 3
120 put(g, bx, isUp ? 0 : 1, isUp ? '૮ ˶ᵔ ᵕ ᵔ˶ ა' : '૮ ˶• ᴗ •˶ ა', { c: '#f8f9fa', b: true })
121 if (!isUp) put(g, bx - 2, 1, '⁎', { c: '#adb5bd', d: true })
122 return g
123}
124
125function sakuraScene(t: number, w: number): Grid {
126 const g = blank(w, 2)
127 const pinks = ['#ffb7c5', '#ff8fab', '#fb6f92', '#ffc2d1', '#ffe5ec']
128 const count = Math.max(6, Math.floor(w / 5))
129 const gust = Math.sin(t * 0.05) * 0.4
130 for (let i = 0; i < count; i++) {
131 const speed = 0.25 + noise(i) * 0.45 + gust
132 const x = mod(noise(i + 7) * w - t * speed + Math.sin(t * 0.2 + i) * 1.5, w)
133 const y = mod(Math.floor(t * (0.05 + noise(i + 3) * 0.08) + i), 2)
134 put(g, x, y, frame(['✿', '❀', '❁', '✾', '·'], i + (t >> 3)), { c: frame(pinks, i) })
135 }
136 const walker = mod(Math.floor(t * 0.2), w + 12) - 10
137 put(g, walker, 1, frame(['(˶ᵔ ᵕ ᵔ˶)', '(˶ᵔ ᵕ ᵔ˶)~'], t >> 3), { c: '#ffafcc', b: true })
138 return g
139}
140
141function mechaScene(t: number, w: number): Grid {
142 const g = blank(w, 2)
143 const cycle = 36
144 const phase = mod(t, cycle)
145 const score = Math.floor(t / cycle)
146 const rx = 2
147 put(g, rx - 2, 1, frame(['≋', '≈', '∼'], t), { c: frame(['#ff9f1c', '#ffbf69', '#ff595e'], t) })
148 put(g, rx, 1, frame(['[◉_◉]', '[◉_◉]', '[◎_◎]'], t >> 1), { c: '#4cc9f0', b: true })
149 const near = rx + 9
150 const tx = Math.max(near, Math.round(w - 2 - phase * 2))
151 if (tx > near) {
152 put(g, tx, 1, frame(['◇', '◆'], t >> 1), { c: '#f72585' })
153 } else {
154 const boom = phase - Math.ceil((w - 2 - near) / 2)
155 if (boom < 3) put(g, rx + 5, 1, '━'.repeat(Math.max(0, tx - rx - 5)), { c: '#4cc9f0', b: true })
156 if (boom >= 2 && boom < 8) put(g, tx - (boom > 4 ? 1 : 0), 1, boom > 4 ? '· ✶ ·' : '✶', { c: '#ffd60a', b: true })
157 }
158 // A radar sweep and a target counter across the top.
159 const sweep = mod(t, Math.max(1, w - 14))
160 put(g, 0, 0, '▕', { c: '#4361ee', d: true })
161 for (let x = 1; x < w - 13; x++) {
162 const dist = sweep - x
163 if (dist >= 0 && dist < 6) put(g, x, 0, '▔▁▂▃▅▇'[5 - dist]!, { c: '#4cc9f0', d: dist > 2 })
164 }
165 put(g, w - 12, 0, `TGT ${String(score).padStart(4, '0')}`, { c: '#4361ee', b: true })
166 return g
167}
168
169function neonScene(t: number, w: number): Grid {
170 const g = blank(w, 2)
171 const blocks = ' ▁▂▃▄▅▆▇█'
172 for (let x = 0; x < w; x++) {
173 const v = (Math.sin(x * 0.33 + t * 0.35) + Math.sin(x * 0.11 - t * 0.21) + 2) / 4
174 const h = Math.round(v * 16)
175 const c = hsl(mod(x * 7 + t * 9, 360), 0.95, 0.62)
176 put(g, x, 1, blocks[Math.min(8, h)]!, { c })
177 if (h > 8) put(g, x, 0, blocks[h - 8]!, { c })
178 }
179 return g
180}
181
182function dinoScene(t: number, w: number): Grid {
183 const g = blank(w, 2)
184 const rx = 4
185 const body = 'ᕕ(ᐛ)ᕗ'
186 for (let x = 0; x < w; x++) {
187 if (mod(x + t, 9) === 0) put(g, x, 1, '_', { c: '#6c757d', d: true })
188 }
189 for (let x = 0; x < w; x += 23) {
190 put(g, mod(x - Math.floor(t * 0.3), w + 6) - 3, 0, '~~', { c: '#adb5bd', d: true })
191 }
192 // A cactus every 20 cells or so; the runner jumps the ones in reach.
193 let isAirborne = false
194 const first = Math.floor((t - rx - 10) / 20)
195 for (let k = Math.max(0, first); k * 20 < t + w; k++) {
196 const x = k * 20 + Math.floor(noise(k) * 10) + 30 - t
197 if (x < 0 || x >= w) continue
198 put(g, x, 1, 'ψ', { c: '#2a9d8f', b: true })
199 if (x >= rx - 1 && x <= rx + textWidth(body) + 1) isAirborne = true
200 }
201 put(g, rx, isAirborne ? 0 : 1, isAirborne ? 'ᕕ(ᐛ)ᕗ' : frame(['ᕕ(ᐛ)ᕗ', 'ᕕ( ᐛ)ᕗ'], t >> 1), { c: '#e9c46a', b: true })
202 put(g, w - 9, 0, `HI ${String(t).padStart(5, '0')}`, { c: '#adb5bd' })
203 return g
204}
205
206function oceanScene(t: number, w: number): Grid {
207 const g = blank(w, 2)
208 for (let x = 0; x < w; x++) {
209 put(g, x, 0, mod(x + Math.floor(t / 2), 6) < 3 ? '~' : '≈', { c: '#48cae4', d: mod(x - t, 5) !== 0 })
210 }
211 const fx = mod(Math.floor(t * 0.5), w + 10) - 8
212 put(g, fx, 1, frame(['><(((º>', '><(((°>'], t >> 2), { c: '#ffb703', b: true })
213 const sx = w - 1 - mod(Math.floor(t * 0.8), w + 5)
214 put(g, sx, 1, '<><', { c: '#fb8500' })
215 for (let i = 0; i < 3; i++) {
216 const age = mod(t + i * 4, 12)
217 const bx = fx + 8 + (i % 2)
218 if (age < 4) put(g, bx, 1, 'o', { c: '#caf0f8', d: true })
219 else if (age < 8) put(g, bx + 1, 0, '°', { c: '#caf0f8' })
220 }
221 return g
222}
223
224function matrixScene(t: number, w: number): Grid {
225 const g = blank(w, 2)
226 const glyphs = 'アイウエオカキクケコサシスセソタチツテト0123456789ハヒフヘホ'
227 for (let x = 0; x < w; x++) {
228 const speed = 1 + Math.floor(noise(x) * 3)
229 const head = mod(Math.floor(t / speed) + Math.floor(noise(x + 50) * 20), 14)
230 for (let y = 0; y < 2; y++) {
231 const age = head - y
232 if (age < 0 || age > 6) continue
233 const ch = glyphs[Math.floor(noise(x * 31 + y + Math.floor(t / 3)) * glyphs.length)]!
234 put(g, x, y, ch, age === 0 ? { c: '#d8f3dc', b: true } : { c: age < 3 ? '#52b788' : '#2d6a4f', d: age >= 3 })
235 }
236 }
237 return g
238}
239
240/**
241 * The computer's sound as a spectrum: a bar per band, its peak falling above
242 * it, and a dancer on the left who moves on the beat. Without a tap (`null`)
243 * the bars lie flat and say so; with no feed at all, a made-up signal plays.
244 */
245function audioScene(t: number, w: number, act: Act, audio?: AudioFeed): Grid {
246 const rows = SCENE_ROWS
247 const g = blank(w, rows)
248 const view = audio === undefined ? demoView(t) : audio
249 const isLive = view !== null && view.b.some(v => v > 0)
250
251 // The dancer: on the beat while sound plays, else as the turn goes.
252 const hasDancer = w >= 32
253 const x0 = hasDancer ? 13 : 0
254 if (hasDancer) {
255 const beat = view?.beat ?? 0
256 const pose =
257 view === null
258 ? '(-ω-) zZ'
259 : isLive
260 ? frame(['♪┏(・o・)┛', '♫┗(・o・)┓'], beat)
261 : act === 'ask'
262 ? '(・ω・)?'
263 : act === 'tool'
264 ? frame(['(・ω・)ノ', '(・ω・)ノ'], t >> 2)
265 : frame(['(・ω・) ', '(・ω・).'], t >> 3)
266 put(g, 0, rows - 1, pose, { c: '#e0aaff', b: true })
267 if (isLive) {
268 // Notes float up from the dancer, one per beat.
269 for (let i = 0; i < 3; i++) {
270 const age = mod(t + i * 4, 12)
271 if (age < 9) put(g, 2 + i * 3 + (age >> 2), rows - 2 - (age >> 2), frame(['♪', '♫', '♬'], beat + i), { c: frame(['#c77dff', '#4cc9f0', '#ff8fab'], i), d: age > 5 })
272 }
273 }
274 }
275
276 const span = w - x0
277 if (view === null) {
278 for (let x = 0; x < span; x += 2) put(g, x0 + x, rows - 1, '·', { c: '#6c757d', d: true })
279 return g
280 }
281 // Wide enough: bars two cells wide with a gap; narrower, one band per column.
282 const per = span / AUDIO_BANDS
283 const blocks = ' ▁▂▃▄▅▆▇█'
284 const full = rows * 8
285 for (let x = 0; x < span; x++) {
286 const band = Math.min(AUDIO_BANDS - 1, Math.floor(x / per))
287 if (per >= 3 && x - Math.floor(band * per) >= Math.floor(per) - 1) continue
288 const h = Math.round(((view.b[band] ?? 0) * full) / 99)
289 const peak = Math.round(((view.p[band] ?? 0) * full) / 99)
290 const hue = 290 - (band / AUDIO_BANDS) * 200
291 if (h === 0 && peak === 0) {
292 put(g, x0 + x, rows - 1, '▁', { c: '#3c3c44' })
293 continue
294 }
295 for (let r = 0; r < rows; r++) {
296 const y = rows - 1 - r
297 const fill = Math.max(0, Math.min(8, h - r * 8))
298 if (fill > 0) put(g, x0 + x, y, blocks[fill]!, { c: hsl(hue, 0.85, 0.5 + r * 0.06), b: r === rows - 1 })
299 }
300 // The peak's cap, where nothing of the bar is.
301 const pr = Math.min(rows - 1, Math.floor(Math.max(0, peak - 1) / 8))
302 if (peak > h && peak - pr * 8 > Math.max(0, h - pr * 8)) put(g, x0 + x, rows - 1 - pr, '▔', { c: '#f8f9fa', d: true })
303 }
304 return g
305}
306
307// ---- the newer scenes: Claude's mascot and pixel art --------------------
308
309
310const CLAUDE = '#d77757'
311const RAINBOW = ['#ff0000', '#ff9900', '#ffff00', '#33ff00', '#0099ff', '#6633ff']
312
313// ---- themes --------------------------------------------------------------
314
315export const THEMES: Record<ThemeName, Theme> = {
316 cat: {
317 name: 'cat',
318 rows: 2,
319 sleep: '(=-ω-=) zZ',
320 color: '#ffb3c6',
321 accent: '#ffd166',
322 sprite: {
323 think: ['(=^・ω・^=) ', '(=^・ω・^=) ? ', '(=^-ω-^=) ? ', '(=^・ω・^=) ?!'],
324 tool: ['ฅ(=^・ω・^=) ', ' (=^・ω・^=)ฅ', 'ฅ(=^・ω・^=)ฅ'],
325 say: ['(=^・ω・^=)ノ ♪', '(=^・ω・^=)ノ ♫', '(=^▽^=)ノ ♪'],
326 wait: ['(=^・ω・^=) . ', '(=^・ω・^=) .. ', '(=^・ω・^=) ...'],
327 },
328 happy: '(=^▽^=)ノ',
329 sad: '(=;ω;=)',
330 dead: '(=×ω×=)',
331 confetti: ['✿', '♡', '*', '·', '✦'],
332 palette: ['#ffb3c6', '#ffd166', '#cdb4db', '#a2d2ff', '#ff8fab'],
333 scene: catScene,
334 },
335 bunny: {
336 name: 'bunny',
337 rows: 2,
338 sleep: '૮ ˶- ᴗ -˶ ა zZ',
339 color: '#f1faee',
340 accent: '#ff9f1c',
341 sprite: {
342 think: ['૮ ˶• ᴗ •˶ ა ', '૮ ˶- ᴗ -˶ ა ?', '૮ ˶• ᴗ •˶ ა ?'],
343 tool: ['૮ ˶ᵔ ᵕ ᵔ˶ ა ɣ', '૮ ˶ᵔ ᵕ ᵔ˶ აɣ ', '૮ ˶ᵔ ᵕ ᵔ˶ ა '],
344 say: ['૮ ˶ᵔ ᵕ ᵔ˶ ა ♪', '૮ ˶ᵔ ᵕ ᵔ˶ ა ♫'],
345 wait: ['૮ ˶• ᴗ •˶ ა . ', '૮ ˶• ᴗ •˶ ა .. ', '૮ ˶• ᴗ •˶ ა ...'],
346 },
347 happy: '૮ ˶ᵔ ᵕ ᵔ˶ ა',
348 sad: '૮ ˶; ﹏ ;˶ ა',
349 dead: '૮ ˶x ﹏ x˶ ა',
350 confetti: ['ɣ', '*', '·', '✦', '"'],
351 palette: ['#ff9f1c', '#80b918', '#f1faee', '#ffbf69'],
352 scene: bunnyScene,
353 },
354 sakura: {
355 name: 'sakura',
356 rows: 2,
357 sleep: '(˶- ᵕ -˶) zZ',
358 color: '#ff8fab',
359 accent: '#ffc2d1',
360 sprite: {
361 think: ['✿ (˶ᵔ ᵕ ᵔ˶)', '❀ (˶ᵔ ᵕ ᵔ˶)?', '❁ (˶- ᵕ -˶)?'],
362 tool: ['✿ (˶•ᴗ•˶)و ', '❀ (˶•ᴗ•˶)و✧'],
363 say: ['✿ (˶ᵔ ᵕ ᵔ˶) ♪', '❀ (˶ᵔ ᵕ ᵔ˶) ♫'],
364 wait: ['✿ (˶ᵔ ᵕ ᵔ˶) . ', '❀ (˶ᵔ ᵕ ᵔ˶) .. ', '❁ (˶ᵔ ᵕ ᵔ˶) ...'],
365 },
366 happy: '(˶ᵔ ᵕ ᵔ˶)✿',
367 sad: '(˶; ᴗ ;˶)',
368 dead: '(˶x ᴗ x˶)',
369 confetti: ['✿', '❀', '❁', '✾', '·'],
370 palette: ['#ffb7c5', '#ff8fab', '#fb6f92', '#ffe5ec'],
371 scene: sakuraScene,
372 },
373 mecha: {
374 name: 'mecha',
375 rows: 2,
376 sleep: '[-_-] zZ',
377 color: '#4cc9f0',
378 accent: '#f72585',
379 sprite: {
380 think: ['[◉_◉] ▖', '[◉_◉] ▘', '[◉_◉] ▝', '[◉_◉] ▗'],
381 tool: ['[◉_◉]⊃━ ', '[◉_◉]⊃━━ ', '[◉_◉]⊃━━━☆'],
382 say: ['[◕‿◕] ▸ ', '[◕‿◕] ▸▸ ', '[◕‿◕] ▸▸▸'],
383 wait: ['[○_○] · ', '[◎_◎] ·· ', '[◉_◉] ···'],
384 },
385 happy: '[◕‿◕]b',
386 sad: '[╥_╥]',
387 dead: '[×_×]',
388 confetti: ['✶', '✦', '+', '·', '◆'],
389 palette: ['#4cc9f0', '#f72585', '#ffd60a', '#4361ee'],
390 scene: mechaScene,
391 },
392 neon: {
393 name: 'neon',
394 rows: 2,
395 sleep: '◢◤ zZ',
396 color: '#f72585',
397 accent: '#4cc9f0',
398 isRainbow: true,
399 sprite: {
400 think: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'].map(c => `${c} ◢◤◢◤`),
401 tool: ['▰▱▱▱▱', '▰▰▱▱▱', '▱▰▰▱▱', '▱▱▰▰▱', '▱▱▱▰▰', '▱▱▱▱▰'],
402 say: ['▁▃▅▇▅▃', '▃▅▇▅▃▁', '▅▇▅▃▁▃', '▇▅▃▁▃▅', '▅▃▁▃▅▇', '▃▁▃▅▇▅'],
403 wait: ['◐ ◆', '◓ ◇', '◑ ◆', '◒ ◇'],
404 },
405 happy: '✦ ◢◤ ✦',
406 sad: '▁▁▁',
407 dead: '✕ ◢◤ ✕',
408 confetti: ['✦', '✧', '+', '·', '◆'],
409 palette: ['#f72585', '#b5179e', '#7209b7', '#4cc9f0', '#ffd60a'],
410 scene: neonScene,
411 },
412 dino: {
413 name: 'dino',
414 rows: 2,
415 sleep: 'ᕕ(-ᐛ-)ᕗ zZ',
416 color: '#e9c46a',
417 accent: '#2a9d8f',
418 sprite: {
419 think: ['ᕕ(ᐛ)ᕗ ', 'ᕕ(ᐛ)ᕗ ? ', 'ᕕ( ᐛ)ᕗ ?'],
420 tool: ['ᕕ(ᐛ)ᕗ ψ', 'ᕕ(ᐛ)ᕗ ψ ', ' ᕕ(ᐛ)ᕗψ '],
421 say: ['ᕕ(ᐛ)ᕗ ε=', 'ᕕ(ᐛ)ᕗε=ε='],
422 wait: ['ᕕ(ᐛ)ᕗ . ', 'ᕕ(ᐛ)ᕗ .. ', 'ᕕ(ᐛ)ᕗ ...'],
423 },
424 happy: 'ヽ(ᐛ)ノ',
425 sad: '(ᐛ;)',
426 dead: '(×ᐛ×)',
427 confetti: ['ψ', '*', '+', '·', '✦'],
428 palette: ['#e9c46a', '#2a9d8f', '#f4a261', '#e76f51'],
429 scene: dinoScene,
430 },
431 ocean: {
432 name: 'ocean',
433 rows: 2,
434 sleep: '><(((-> zZ',
435 color: '#ffb703',
436 accent: '#48cae4',
437 sprite: {
438 think: ['><(((º> ', '><(((º> o ', '><(((º> °'],
439 tool: ['><(((º>~ ', '><(((º>≈≈ ', '><(((º>~≈~'],
440 say: ['><(((º> ♪', '><(((º> ♫'],
441 wait: ['><(((º> . ', '><(((º> .. ', '><(((º> ...'],
442 },
443 happy: '><(((º> ♪',
444 sad: '><(((;>',
445 dead: '><(((×>',
446 confetti: ['°', 'o', '~', '≈', '·'],
447 palette: ['#48cae4', '#caf0f8', '#ffb703', '#fb8500'],
448 scene: oceanScene,
449 },
450 matrix: {
451 name: 'matrix',
452 rows: 2,
453 sleep: '⟦IDLE⟧',
454 color: '#52b788',
455 accent: '#d8f3dc',
456 sprite: {
457 think: ['ア1ウ0', '1ウ0カ', 'ウ0カ1', '0カ1ア'].map(s => `⟦${s}⟧`),
458 tool: ['⟦▓▒░ ⟧', '⟦ ▓▒░⟧', '⟦░ ▓▒⟧', '⟦▒░ ▓⟧'],
459 say: ['⟦>_ ⟧', '⟦>_▌ ⟧', '⟦>__▌⟧'],
460 wait: ['⟦· ⟧', '⟦·· ⟧', '⟦··· ⟧'],
461 },
462 happy: '⟦ OK ⟧',
463 sad: '⟦ ^C ⟧',
464 dead: '⟦ERR!⟧',
465 confetti: ['ア', '0', '1', 'ハ', '·'],
466 palette: ['#52b788', '#d8f3dc', '#2d6a4f', '#95d5b2'],
467 scene: matrixScene,
468 }, clawd: {
469 name: 'clawd',
470 rows: SCENE_ROWS,
471 color: CLAUDE,
472 accent: '#e9b49a',
473 sprite: {
474 think: ['·', '✢', '✳', '✶', '✻', '✽', '✻', '✶', '✳', '✢'].map(s => `▐▛█▜▌ ${s}`),
475 tool: ['▐▛█▜▌▭▭', '▐▛█▜▌▬▭', '▐▛█▜▌▭▬'],
476 say: ['▐▛█▜▌ ✎ ', '▐▛█▜▌ ✎· ', '▐▛█▜▌ ✎··'],
477 wait: ['▐▛█▜▌ . ', '▐▛█▜▌ .. ', '▐▛█▜▌ ...'],
478 },
479 happy: '▐▛█▜▌ ✻',
480 sad: '▐▀█▀▌',
481 dead: '▐x█x▌',
482 sleep: '▐▄█▄▌ zZ',
483 confetti: ['✻', '✶', '✳', '·', '✢'],
484 palette: [CLAUDE, '#e9b49a', '#f5e6d3', '#c15f3c'],
485 scene: clawdScene,
486 },
487 thunder: {
488 name: 'thunder',
489 rows: SCENE_ROWS,
490 color: '#4cc9f0',
491 accent: '#ffd60a',
492 sprite: {
493 think: ['=▷ · ', '=▷ · ', '=▷ · ', '=▷ · '],
494 tool: ['=▷━ ◆', '=▷ ━ ◆', '=▷ ━ ◆', '=▷ ✶'],
495 say: ['=▷ ⁘ ⁘ ', '=▷⁘ ⁘ ⁘'],
496 wait: ['=▷ . ', '=▷ .. ', '=▷ ...'],
497 },
498 happy: '=▷ ✶✶✶',
499 sad: '=▷ ...',
500 dead: '=▷ ✕',
501 sleep: '=▷ zZ',
502 confetti: ['✶', '✦', '+', '·', '◆'],
503 palette: ['#4cc9f0', '#ffd60a', '#f72585', '#ff9f1c'],
504 scene: thunderScene,
505 },
506 chomp: {
507 name: 'chomp',
508 rows: SCENE_ROWS,
509 color: '#ffd60a',
510 accent: '#ff595e',
511 sprite: {
512 think: ['ᗧ • • •', '● • • •'],
513 tool: ['ᗧ••• ᗣ', '●•• ᗣ ', 'ᗧ• ᗣ ', '● ᗣ '],
514 say: ['ᗧ ♪ ', '● ♫'],
515 wait: ['ᗧ . ', '● .. ', 'ᗧ ...'],
516 },
517 happy: 'ᗧ ᗣᗣᗣ',
518 sad: 'ᗣ ᗧ',
519 dead: '✕ᗧ✕',
520 sleep: 'ᗧ zZ',
521 confetti: ['•', '●', '·', 'ᗣ', '+'],
522 palette: ['#ffd60a', '#ff595e', '#ffafcc', '#00f5d4', '#ff9f1c'],
523 scene: chompScene,
524 },
525 sparky: {
526 name: 'sparky',
527 rows: SCENE_ROWS,
528 color: '#ffd60a',
529 accent: '#e63946',
530 sprite: {
531 think: ['ϟ(•ᴥ•) ', 'ϟ(•ᴥ•) ?', ' (•ᴥ•)ϟ?'],
532 tool: ['ϟϟ(>ᴥ<)ϟϟ', ' ϟ(>ᴥ<)ϟ '],
533 say: ['(•ᴥ•)ノϟ ', '(•ᴥ•)ノ ϟ'],
534 wait: ['(•ᴥ•) . ', '(•ᴥ•) .. ', '(•ᴥ•) ...'],
535 },
536 happy: 'ϟ(^ᴥ^)ϟ',
537 sad: '(;ᴥ;)',
538 dead: '(×ᴥ×)',
539 sleep: '(-ᴥ-) zZ',
540 confetti: ['ϟ', '✦', '*', '·', '+'],
541 palette: ['#ffd60a', '#e63946', '#fff3b0', '#c68b59'],
542 scene: sparkyScene,
543 },
544 bluecat: {
545 name: 'bluecat',
546 rows: SCENE_ROWS,
547 color: '#00b4d8',
548 accent: '#e63946',
549 sprite: {
550 think: ['⊂(◉‿◉)つ ', '⊂(◉‿◉)つ ?', '⊂(◔‿◔)つ ?'],
551 tool: ['⊂(◉‿◉)つ✦ ', '⊂(◉‿◉)つ ✧ ', '⊂(◉‿◉)つ ✦'],
552 say: ['⊂(◕‿◕)つ ♪', '⊂(◕‿◕)つ ♫'],
553 wait: ['⊂(◉‿◉)つ . ', '⊂(◉‿◉)つ .. ', '⊂(◉‿◉)つ ...'],
554 },
555 happy: '⊂(◕‿◕)つ',
556 sad: '⊂(╥﹏╥)つ',
557 dead: '⊂(×﹏×)つ',
558 sleep: '⊂(-‿-)つ zZ',
559 confetti: ['✦', '✧', '●', '·', '+'],
560 palette: ['#00b4d8', '#e63946', '#ffd60a', '#ffffff'],
561 scene: bluecatScene,
562 },
563 nyan: {
564 name: 'nyan',
565 rows: SCENE_ROWS,
566 color: '#ff99cc',
567 accent: '#999999',
568 isRainbow: true,
569 sprite: {
570 think: ['~=[,,_,,]:3', '=~[,,_,,]:3'],
571 tool: ['~=[,,_,,]:3 *', '=~[,,_,,]:3 +'],
572 say: ['~=[,,o,,]:3', '=~[,,_,,]:3'],
573 wait: ['~=[,,_,,]:3 . ', '=~[,,_,,]:3 .. ', '~=[,,_,,]:3 ...'],
574 },
575 happy: '~=[,,^,,]:3',
576 sad: '~=[,,;,,]:3',
577 dead: '~=[,,x,,]:3',
578 sleep: '~=[,,-,,]:3 zZ',
579 confetti: ['*', '+', '·', '✦', '✧'],
580 palette: RAINBOW,
581 scene: nyanScene,
582 },
583 audio: {
584 name: 'audio',
585 rows: SCENE_ROWS,
586 color: '#c77dff',
587 accent: '#4cc9f0',
588 sprite: {
589 think: ['♪(・ω・) ', '♪(・ω・) ?', '♫(・ω・) ?'],
590 tool: ['♪┏(・o・)┛', '♫┗(・o・)┓'],
591 say: ['(・▽・)ノ♪ ', '(・▽・)ノ ♫'],
592 wait: ['(・ω・) . ', '(・ω・) .. ', '(・ω・) ...'],
593 },
594 happy: '♪ヽ(・▽・)ノ♫',
595 sad: '(・へ・)',
596 dead: '(×_×)',
597 sleep: '(-ω-) zZ',
598 confetti: ['♪', '♫', '♬', '·', '✦'],
599 palette: ['#c77dff', '#4cc9f0', '#ff8fab', '#ffd60a'],
600 scene: audioScene,
601 },
602}
603
604export function themeOf(name: unknown): Theme {
605 return isThemeName(name) ? THEMES[name] : THEMES.clawd
606}
607
608// ---- finale --------------------------------------------------------------
609
610/** After a turn: a burst of the theme's confetti around its mascot and the label. */
611export function finaleScene(theme: Theme, kind: Finale, label: string, t: number, w: number): Grid {
612 const rows = theme.rows
613 const g = blank(w, rows)
614 const face = kind === 'answer' ? theme.happy : kind === 'aborted' ? theme.sad : theme.dead
615 const text = `${face} ${label}`
616 const tw = textWidth(text)
617 const cx = Math.floor(w / 2)
618 const left = Math.max(0, cx - Math.floor(tw / 2))
619 const mid = Math.floor(rows / 2)
620
621 if (kind === 'answer') {
622 const count = Math.min(40 * rows / 2, Math.max(12, Math.floor((w / 3) * rows / 2)))
623 for (let i = 0; i < count; i++) {
624 const dir = noise(i) < 0.5 ? -1 : 1
625 const speed = 0.8 + noise(i + 9) * 2.2
626 const reach = Math.min(t, 18) * speed
627 const x = cx + dir * (tw / 2 + reach + noise(i + 4) * 3)
628 const y = Math.floor(noise(i + 2) * rows)
629 const isFading = t > 18 + noise(i + 6) * 8
630 put(g, x, y, frame(theme.confetti, i + (t >> 2)), { c: frame(theme.palette, i), d: isFading })
631 }
632 } else if (kind === 'aborted') {
633 for (let x = 0; x < w; x += 4) put(g, mod(x + (t >> 1), w), mod(x, rows), '·', { c: '#6c757d', d: true })
634 } else {
635 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 })
636 }
637
638 const style: Style = kind === 'answer' ? { c: theme.color, b: true } : kind === 'aborted' ? { c: '#adb5bd' } : { c: '#e63946', b: true }
639 const flicker = kind === 'error' && mod(t, 6) === 0
640 put(g, left - 1, mid, ' '.repeat(tw + 2))
641 if (!flicker) put(g, left, mid, text, style)
642 return g
643}
644
645// ---- companion -----------------------------------------------------------
646
647/** What the companion row shows: the turn's act while it runs, else the mood. */
648export type PetView = {
649 state: Act | Mood
650 /** The bubble beside the mascot, already in the person's language. */
651 bubble: string
652 /** `Lv.3 ♥12`. */
653 stats: string
654}
655
656const BUBBLE: Partial<Record<Act | Mood, Style>> = {
657 ask: { c: '#ffd166', b: true },
658 error: { c: '#e63946' },
659 aborted: { c: '#adb5bd' },
660 sleep: { c: '#6c757d', d: true },
661}
662
663/** One row: the mascot in its current mood, its bubble, its level and affection; hearts after a pat. */
664export function petRow(theme: Theme, pet: PetView, t: number, w: number, hearts: number): Grid {
665 const g = blank(w, 1)
666 const s = pet.state
667 let face: string
668 if (s === 'think' || s === 'tool' || s === 'say' || s === 'wait' || s === 'ask') {
669 const frames = theme.sprite[poseOf(s)]
670 face = padTo(frame(frames, t), Math.max(...frames.map(textWidth)))
671 } else {
672 face = s === 'error' ? theme.dead : s === 'aborted' ? theme.sad : s === 'sleep' ? theme.sleep : theme.happy
673 }
674 const faceStyle = (i: number): Style =>
675 theme.isRainbow ? { c: hsl((i * 40 + t * 24) % 360, 0.95, 0.62), b: true } : { c: theme.color, b: true }
676 let x = 0
677 Array.from(face).forEach((ch, i) => {
678 put(g, x, 0, ch, faceStyle(i))
679 x += textWidth(ch)
680 })
681 x += 1
682 if (hearts > 0) {
683 put(g, x, 0, frame(['♡', '♥', '♡ ♥', '♥ ♡'], hearts), { c: '#ff8fab', b: true })
684 x += 4
685 }
686 if (pet.bubble) put(g, x, 0, `${s === 'ask' ? '❯ ' : '· '}${pet.bubble}`, BUBBLE[s] ?? { c: '#b8b8be' })
687 put(g, w - textWidth(pet.stats) - 1, 0, pet.stats, { c: '#6c757d' })
688 return g
689}
690hooks/pets.ts 305 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
277/** A round listener in big headphones, swaying to whatever plays. */
278export const AUDIO_PET: PetArt = {
279 body: ['..HHHHHHHH..', '.H.BBBBBB.H.', 'CCBBBBBBBBCC', 'CCBBBBBBBBCC', '.B.B....B.B.'],
280 step: ['..HHHHHHHH..', '.H.BBBBBB.H.', 'CCBBBBBBBBCC', 'CCBBBBBBBBCC', '..B.B..B.B..'],
281 colors: { H: '#5a189a', C: '#c77dff', B: '#e0aaff' },
282 eyes: [4, 7],
283 eyeRow: 1,
284 eye: '#240046',
285 lid: '#e0aaff',
286 mouth: [5, 3],
287 blush: '#ff8fab',
288 spark: '#4cc9f0',
289}
290
291const PETS: Record<string, PetArt> = {
292 clawd: CLAWD_PET,
293 thunder: THUNDER_PET,
294 chomp: CHOMP_PET,
295 sparky: SPARKY_PET,
296 bluecat: BLUECAT_PET,
297 nyan: NYAN_PET,
298 audio: AUDIO_PET,
299}
300
301/** A theme's pet; a theme without one of its own borrows Clawd's shape in its color. */
302export function petArtOf(theme: string, color: string): PetArt {
303 return PETS[theme] ?? { ...CLAWD_PET, colors: { O: color, L: color, D: color }, lid: color }
304}
305hooks/audio.ts 116 lines1// The `audio` theme's feed: lines from the tap (hooks/audio-tap.swift) to what
2// its scene draws. The meter lives in the hooks module; the band's Client asks
3// for its latest view each frame (register.tsx, `ui.message`). Pure but for the
4// meter's own memory.
5
6/** Bands the tap is asked for, low to high. */
7export const AUDIO_BANDS = 32
8/** The tap's line period, in milliseconds. */
9export const TAP_MS = 50
10
11/**
12 * One frame for the scene: band levels and their falling peaks (0..99, gained
13 * to the last few seconds' loudest), and a count of beats so far.
14 */
15export type AudioView = { b: number[]; p: number[]; beat: number }
16
17/**
18 * What the scene is given: live levels, `null` when the tap could not run,
19 * or nothing at all (a preview, a screenshot, a surface without processes)
20 * for a made-up signal.
21 */
22export type AudioFeed = AudioView | null | undefined
23
24/** A tap line: `L <loudness> <band>...`, each 0..99; anything else is null. */
25export function parseTapLine(line: string): { loud: number; bands: number[] } | null {
26 const parts = line.trim().split(' ')
27 if (parts[0] !== 'L' || parts.length < 3) return null
28 const nums = parts.slice(1).map(Number)
29 if (nums.some(n => !Number.isFinite(n))) return null
30 const [loud, ...bands] = nums.map(n => Math.max(0, Math.min(99, n)))
31 return { loud: loud!, bands }
32}
33
34/** Splits streamed text into whole lines, keeping a cut-off tail for the next piece. */
35export function lineSplitter(): (text: string) => string[] {
36 let rest = ''
37 return text => {
38 const lines = (rest + text).split('\n')
39 rest = lines.pop() ?? ''
40 return lines
41 }
42}
43
44/** Below this a band is drawn as nothing: the tap's floor of hiss. */
45const FLOOR = 6
46/** The gain's ceiling never drops under this, so near-silence is not blown up. */
47const MIN_CEILING = 35
48/** Per line: the ceiling eases down this much toward the bands' level (about 7 s to half). */
49const CEILING_DECAY = 0.995
50/** Per line: how far a peak falls. */
51const PEAK_FALL = 4
52/** A beat: loudness this far over its running average, at least this loud, this many lines apart. */
53const BEAT_RATIO = 1.3
54const BEAT_MIN = 12
55const BEAT_GAP = 5
56/** Lines of quiet before the meter counts as silent (3 s). */
57const QUIET_LINES = 60
58
59/** The tap's lines in, the scene's view out: gain, peaks, beats and whether anything plays. */
60export class AudioMeter {
61 private ceiling = MIN_CEILING
62 private levels: number[] = new Array(AUDIO_BANDS).fill(0)
63 private peaks: number[] = new Array(AUDIO_BANDS).fill(0)
64 private average = 0
65 private beats = 0
66 private sinceBeat = BEAT_GAP
67 private sinceSound = QUIET_LINES
68
69 /** Takes one tap line; returns whether it was a reading. */
70 push(line: string): boolean {
71 const reading = parseTapLine(line)
72 if (!reading) return false
73 const { loud, bands } = reading
74 const top = Math.max(0, ...bands)
75 this.ceiling = Math.max(MIN_CEILING, top, this.ceiling * CEILING_DECAY)
76 this.levels = Array.from({ length: AUDIO_BANDS }, (_, i) => {
77 const raw = bands[Math.floor((i * bands.length) / AUDIO_BANDS)] ?? 0
78 return raw < FLOOR ? 0 : Math.round(Math.min(99, (raw * 99) / this.ceiling))
79 })
80 this.peaks = this.peaks.map((p, i) => Math.max(this.levels[i]!, p - PEAK_FALL))
81
82 this.sinceBeat++
83 if (loud >= BEAT_MIN && loud > this.average * BEAT_RATIO && this.sinceBeat >= BEAT_GAP) {
84 this.beats++
85 this.sinceBeat = 0
86 }
87 this.average = this.average * 0.9 + loud * 0.1
88 this.sinceSound = top >= FLOOR ? 0 : this.sinceSound + 1
89 return true
90 }
91
92 /** Whether anything has played in the last few seconds. */
93 get isAudible(): boolean {
94 return this.sinceSound < QUIET_LINES
95 }
96
97 view(): AudioView {
98 return { b: [...this.levels], p: [...this.peaks], beat: this.beats }
99 }
100}
101
102/** A made-up signal with a beat, for when no tap feeds the scene. */
103export function demoView(t: number): AudioView {
104 const kick = Math.max(0, 1 - (t % 5) / 3)
105 const b = Array.from({ length: AUDIO_BANDS }, (_, i) => {
106 const u = i / AUDIO_BANDS
107 const v =
108 0.12 +
109 0.5 * kick * Math.max(0, 1 - u * 3) +
110 0.3 * (0.5 + 0.5 * Math.sin(t * 0.45 + i * 0.6)) * (1 - u * 0.5) +
111 0.2 * (0.5 + 0.5 * Math.sin(t * 0.9 - i * 1.3))
112 return Math.round(Math.min(99, v * 90))
113 })
114 return { b, p: b.map(v => Math.min(99, v + 10)), beat: Math.floor(t / 5) }
115}
116