SLOPSHOPPER

spinner

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…

newbandspinnerguardcommandtoast
★ 5v0.6.1MITupdated 2026-10-09hoobnn/hoobnn-agent-mods/claude-code/spinner
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · spinner
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /spinner ⎿ spinner: Theme: sakura (random each session) ⎿ spinner: sakura · Lv.1 · 1 xp · ♥0 ⎿ spinner: Themes: clawd ▐▛█▜▌ ✻ · thunder =▷ ✶✶✶ · chomp ᗧ ᗣᗣᗣ · sparky ϟ(^ᴥ^)ϟ · bluecat ⊂(◕‿◕)つ · nyan ~=[,,^,,]:3 · c ⎿ spinner: Usage: /spinner <theme|random> · theme (pick) · pet · preview [theme] · off / on · stage off / on · companion ▣ client module ./stage.tsx Lv.1 ♥0 ▣ client module ./pet-player.tsx Tests failed… we’ll fix it ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts Spinner ⟨Claude Code's own drawing⟩

Draws

Band
▣ client module ./stage.tsx Lv.1 ♥0 ▣ client module ./pet-player. Tests failed… we’ll fix it ⟨Claude Code's own drawing⟩
README

spinner:Claude Code 运行动画与宠物伴侣

简体中文 · English

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

spinner:Clawd 主题跑完一轮,宠物气泡跟着工具变化,最后放庆祝动画

功能

  • 动画小剧场:Claude 工作时,输入框上方播放主题动画。其他 mod 的横条(hud、ts-band、hitokoto 等)排在它下面。
  • 宠物伴侣:参考 Codex Pets,按思考、调用工具、输出、等待切换姿势。气泡只说 Claude Code 自己的 Spinner 行没有的信息:正在跑的工具(Bash: npm test、Edit: themes.ts),或者有权限请求在等你确认;模型思考时它就埋头忙。多个子代理并行时显示数量(Agent ×3),只有一个时显示它的任务。Claude 跑测试或提交时,它会说几秒钟(测试通过、测试没过、提交好了)。
  • 养成:两轮之间宠物留在原地(完成、被中断、出错,安静 5 分钟后打瞌睡)。每跑完一轮、每次测试通过、每次提交都会涨经验升级(Lv.4);点它或输入 /spinner pet 可以摸摸它(♥12,会冒爱心)。等级和好感度跨会话保存,同时开的几个会话养的是同一只。
  • Spinner 行上的吉祥物:关掉宠物后,吉祥物会站到 Claude Code 自己的 Spinner 行前面([◉_◉]⊃━━ ✢ Choreographing… (4s · ↓ 14 tokens)),原来的提示词、用时和 token 数不变。同一时间只显示一个吉祥物。
  • 完成庆祝:一轮结束后 3 秒,吉祥物身边放彩带并显示用时((=^▽^=)ノ 完成 · 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 实拍:放音乐时的频谱

audio 需要 macOS 14.2 及以上和 swiftc(Xcode 命令行工具:xcode-select --install)。第一次使用时,mod 用 hooks/audio-tap.swift 编译一个小程序(约 2 秒),只在这套主题显示时运行:它通过 Core Audio 读取系统输出,每秒 20 次把各频段的电平交给 mod,不保存、不写盘、不外传声音。macOS 会询问一次是否允许终端录制系统音频,拒绝的话柱子一直是平的。读不到声音时(其他系统、桌面 App、编译失败),场景里的小人睡着,/spinner 会说明原因。预览和下面的动图用的是合成信号。

clawd<br>clawdthunder<br>thunder
chomp<br>chompsparky<br>sparky
bluecat<br>bluecatnyan<br>nyan
cat<br>catbunny<br>bunny
sakura<br>sakuramecha<br>mecha
neon<br>neondino<br>dino
ocean<br>oceanmatrix<br>matrix
audio<br>audio

所有主题的吉祥物、场景和宠物合在一张图里: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、ruauto

language 为 auto 时,依次跟随 Claude Code 的 language 设置和系统语言环境,都没有时用英语。

Claude 工作时,横条占主题的行数再加宠物一行;两轮之间只占一行。空间不够时先收起场景、保留宠物;不足 3 行或 60 列时,宠物缩成一行(吉祥物、气泡和等级)。吉祥物、场景和宠物在终端和桌面 App 里显示;其他界面保持 Claude Code 原本的 Spinner。

同系列 mod

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 重新渲染全部动图和静态图。
Source 19 files
hooks/register.tsx 628 lines
1import { 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}
628
hooks/command.ts 38 lines
1// `/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}
38
hooks/config.ts 39 lines
1// 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}
39
hooks/i18n.ts 396 lines
1// 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)
396
hooks/kit/band.tsx 31 lines
1// 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}
31
hooks/kit/pet.tsx 87 lines
1// 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}
87
hooks/kit/lang.ts 68 lines
1// 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}
68
hooks/kit/prefs.ts 60 lines
1// 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}
60
hooks/pet.ts 61 lines
1// 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}
61
hooks/themes.ts 690 lines
1// 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}
690
hooks/pets.ts 305 lines
1// 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}
305
hooks/audio.ts 116 lines
1// 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