[k-mods 한국 수정판] Claude가 일하는 동안 입력창 위에 냥캣·Clawd 같은 픽셀 장면을 띄우고, 턴마다 자라는 펫을 키워요. 원본: hoobnn/hoobnn-agent-mods (MIT)

Claude가 일하는 동안 입력창 위에 냥캣·Clawd 같은 픽셀 장면을 띄우고, 턴마다 자라는 펫을 키워요. hoobnn/hoobnn-agent-mods의 spinner를 한국 사용자에게 맞게 고친 k-mods 수정판이에요.
<img src="https://raw.githubusercontent.com/hoobnn/hoobnn-agent-mods/8fb6f67cad9fd023cc7c06b7923518b4bbc45aea/claude-code/spinner/assets/nyan.gif" alt="냥캣 장면" width="640">
/plugin marketplace add SeongGwangJu/k-mods
/plugin install spinner@k-mods
설치하면 바로 동작해요. Claude가 일하는 동안 입력창 위 띠에 장면이 흘러가고, 턴이 끝나면 짧은 피날레가 나와요.
/spinner: 지금 테마와 펫 상태, 사용법/spinner <테마>: 테마 고르기 (nyan, clawd, thunder, chomp, dino, ocean 등 15종, random)/spinner off / on: 애니메이션 끄기·켜기/spinner companion on: 입력창 위 펫 줄 켜기| 원본 | 한국 수정판 | |
|---|---|---|
| 언어 | 자동 | 한국어 |
| 펫 줄(companion) | 켜짐 | 꺼짐 (장면 띠만) |
| 하단 Spinner 버튼 | 켜짐 | 꺼짐 (/spinner off로 충분) |
| 스피너 줄 앞 마스코트 | 그림 | 안 그림 (줄 앞 들여쓰기가 생겨서) |
random 테마 | 14종 중 세션마다 | nyan·clawd·thunder·chomp 중 턴마다 |
모두 /plugin configure spinner@k-mods 또는 /config에서 원본처럼 되돌릴 수 있어요(마스코트와 random 범위는 코드 수정이라 제외). 자세한 내역은 CHANGES-KO.md에 있어요.
화면을 그리고 로컬 저장소($.store)에 펫 상태를 저장해요. audio 테마를 고를 때만 macOS에서 swiftc로 작은 오디오 탭을 컴파일해 시스템 출력 소리의 크기만 읽어요. 네트워크는 쓰지 않아요.
원작: hoobnn, hoobnn/hoobnn-agent-mods 커밋 8fb6f67의 claude-code/spinner. MIT 라이선스이며 원본 저작권 표기를 LICENSE에 그대로 뒀어요. 원본의 그림 파일(assets)은 담지 않고 원본 저장소 주소로 보여줘요.
테스트한 Claude Code 버전: 2.1.291
hooks/register.tsx 621 lines1import { atom, 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 } 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// k-mods 수정: 스피너 줄 옆 마스코트 끄기
24const SPINNER_MASCOT = false
25
26// The theme drawn this session ('' until session.start picks one).
27const theme = atom({ plugin: 'spinner', key: 'theme' } as const, '')
28// The `theme` row: a theme, or `random`.
29const choice = atom({ plugin: 'spinner', key: 'choice' } as const, 'random')
30// Session mirrors of the `visible`, `stage` and `companion` rows, so a command shows at once.
31const isHidden = atom({ plugin: 'spinner', key: 'isHidden' } as const, false)
32const isStageOff = atom({ plugin: 'spinner', key: 'isStageOff' } as const, false)
33const isCompanionOff = atom({ plugin: 'spinner', key: 'isCompanionOff' } as const, false)
34const finale = atom({ plugin: 'spinner', key: 'finale' } as const, null)
35const preview = atom({ plugin: 'spinner', key: 'preview' } as const, null)
36const activity = atom({ plugin: 'spinner', key: 'activity' } as const, { act: 'think' as Act })
37const mood = atom({ plugin: 'spinner', key: 'mood' } as const, 'hello' as Mood)
38const pet = atom({ plugin: 'spinner', key: 'pet' } as const, { xp: 0, love: 0 })
39const pat = atom({ plugin: 'spinner', key: 'pat' } as const, null)
40const news = atom({ plugin: 'spinner', key: 'news' } as const, null)
41// The pet for whoever draws it (hud beside its rows, or this plugin above the prompt).
42const dock = atom({ plugin: 'spinner', key: 'dock' } as const, null as DockPet | null)
43const isTurn = atom({ plugin: 'spinner', key: 'isTurn' } as const, false)
44// True while a picker is open above the band (see kit/band).
45const isPicking = atom({ plugin: 'spinner', key: 'isPicking' } as const, false)
46// The audio theme's tap: whether anything plays, and why it could not run.
47const tapState = atom({ plugin: 'spinner', key: 'tap' } as const, { isAudible: false, error: null as string | null })
48// hud's say: true while it draws the pet beside its rows.
49const hudDock = atom({ plugin: 'hud', key: 'dock' } as const, false)
50
51const PREVIEW_MS = 8000
52/** Below this many columns the band draws the pet in one row. */
53const COMPACT_COLUMNS = 60
54/** Cells the pet's bubble and stats take beside it above the prompt. */
55const PET_LABEL_W = 24
56/** How long the pet speaks of tests or a commit. */
57const NEWS_MS = 4000
58/** How long a pat's hearts float. */
59const PAT_MS = 2500
60/** How long an `ask` must stand before the pet shows it: the mode settles most at once. */
61const ASK_DELAY_MS = 600
62/** Quiet this long after a turn, the companion dozes off. */
63const SLEEP_MS = 5 * 60_000
64
65/** The kit's hold on this mod's store and `/config` rows. */
66function prefsOf($: EngineInterface): Prefs {
67 return {
68 kept: key => $.store.get(key),
69 forget: key => $.store.delete(key),
70 write: (field, value) => $.config.set({ key: `spinner.${field}`, value }),
71 }
72}
73
74/**
75 * The theme a choice names. `random` keeps the one this session already drew
76 * at random (a reload brought by another row's change), unless `isFresh`.
77 */
78async function choose($: EngineInterface, picked: Choice, isFresh = false): Promise<ThemeName> {
79 const current = await read($, theme)
80 const wasRandom = (await read($, choice)) === 'random'
81 const name =
82 picked !== 'random' ? picked : !isFresh && wasRandom && isThemeName(current) ? current : pickRandom(await $.clock.now())
83 await update($, choice, () => picked)
84 await update($, theme, () => name)
85 await publishPet($)
86 return name
87}
88
89/** The pet's stats as the store keeps them, shared by every session. */
90async function keptPet($: EngineInterface): Promise<PetStats> {
91 const stats = (await $.store.get('pet')) as Partial<PetStats> | undefined
92 return { xp: Number(stats?.xp) || 0, love: Number(stats?.love) || 0 }
93}
94
95/**
96 * Adds `by` to the kept stats, read again just before: sessions running side
97 * by side all raise the one pet instead of writing back their own copy.
98 */
99async function bumpPet($: EngineInterface, by: PetStats): Promise<PetStats> {
100 const kept = await keptPet($)
101 const next = { xp: kept.xp + by.xp, love: kept.love + by.love }
102 await $.store.set('pet', next)
103 return update($, pet, () => next)
104}
105
106/** One more xp, kept, and a toast when it brings a level. */
107async function gainXp($: EngineInterface): Promise<void> {
108 const after = await bumpPet($, { xp: 1, love: 0 })
109 if (levelOf(after.xp) > levelOf(after.xp - 1)) {
110 $.ui.toast(m('pet.levelUp', { theme: await read($, theme), level: levelOf(after.xp) }))
111 }
112}
113
114/** Tests or a commit the main thread just ran: a word from the pet for a moment, and xp for good news. */
115async function noteNews($: EngineInterface, command: string, isError: boolean, id: string): Promise<void> {
116 const kind = newsOf(command, isError)
117 if (kind === null) return
118 await update($, news, () => ({ kind, id }))
119 if (kind !== 'testFail') await gainXp($)
120 $.clock.after(NEWS_MS, async () => {
121 if ((await read($, news))?.id !== id) return
122 await update($, news, () => null)
123 await publishPet($)
124 })
125}
126
127/** A pat: hearts in the band, one more point of affection, kept. */
128async function patPet($: EngineInterface): Promise<PetStats> {
129 const next = await bumpPet($, { xp: 0, love: 1 })
130 const id = String(next.love)
131 await update($, pat, () => id)
132 if ((await read($, mood)) === 'sleep') await update($, mood, () => 'hello' as Mood)
133 await publishPet($)
134 $.clock.after(PAT_MS, async () => {
135 if ((await read($, pat)) !== id) return
136 await update($, pat, () => null)
137 await publishPet($)
138 })
139 return next
140}
141
142/** Reduced motion (the `reducedMotion` row): every animation one still frame. */
143let isStill = false
144
145const TONE: Partial<Record<PetState, DockPet['tone']>> = { ask: 'ask', error: 'error', aborted: 'aborted', sleep: 'sleep' }
146
147/** Publishes the pet as it is now (`spinner.dock`), or null while it is off. */
148async function publishPet($: EngineInterface): Promise<void> {
149 if ((await read($, isHidden)) || (await read($, isCompanionOff))) {
150 if ((await read($, dock)) !== null) await update($, dock, () => null)
151 return
152 }
153 const name = await read($, theme)
154 const now = await read($, activity)
155 const state: PetState = (await read($, isTurn)) ? now.act : await read($, mood)
156 const stats = await read($, pet)
157 const patId = await read($, pat)
158 // A permission prompt waiting outranks news: it is the one the person must act on.
159 const said = state === 'ask' ? null : await read($, news)
160 const art = petArtOf(name, isThemeName(name) ? THEMES[name].color : THEMES.clawd.color)
161 const view = {
162 id: `${name}:${state}:${patId ?? ''}`,
163 bubble: said ? m(`pet.${said.kind}`) : bubbleOf(state, now.tool),
164 tone: said?.kind === 'testFail' ? 'error' : (TONE[state] ?? 'plain'),
165 stats: `Lv.${levelOf(stats.xp)} ♥${stats.love}`,
166 }
167 const was = await read($, dock)
168 // The same loop with new words keeps its frames.
169 const next = was?.id === view.id ? { ...was, ...view } : dockPetOf(art, state, view, patId !== null, isStill)
170 if (was?.id === next.id && was.bubble === next.bubble && was.stats === next.stats && was.tone === next.tone) return
171 await update($, dock, () => next)
172}
173
174// ---- the audio theme's tap ----------------------------------------------------
175
176/** The tap's levels, read by the band each frame. */
177const meter = new AudioMeter()
178/** The running tap, while the audio theme is drawn. */
179let tap: { stop: () => void } | null = null
180/** The props each band instance was last drawn with, by its key: a frame's answer carries them. */
181const stageProps = new Map<string, StageProps>()
182
183/** The tap's binary, built from its source beside it on first use (and again when the source is newer). */
184async function buildTap($: EngineInterface): Promise<string> {
185 const src = `${$.plugin.root}/hooks/audio-tap.swift`
186 const bin = `${$.plugin.root}/hooks/audio-tap`
187 const [built, source] = await Promise.all([$.fs.stat(bin).catch(() => null), $.fs.stat(src)])
188 if (built && built.mtimeMs >= source.mtimeMs) return bin
189 let out
190 try {
191 out = await $.process.run(['swiftc', '-O', src, '-o', bin], { timeoutMs: 120_000 })
192 } catch (err) {
193 // The engine's word first (no processes on this surface, or no swiftc), then the usual fix.
194 throw new Error(`${err instanceof Error ? err.message : String(err)}; swiftc comes with xcode-select --install`)
195 }
196 if (out.exitCode !== 0) throw new Error(`swiftc: ${out.stderr.trim().split('\n')[0] || `exit ${out.exitCode}`}`)
197 return bin
198}
199
200/** Starts the tap: its lines into the meter, `spinner.tap` told when sound starts or stops, or why it ended. */
201function startTap($: EngineInterface): { stop: () => void } {
202 let isStopped = false
203 let stream: AsyncIterator<unknown> | undefined
204 const fail = (error: string) => (isStopped ? undefined : update($, tapState, () => ({ isAudible: false, error })))
205 void (async () => {
206 let said = ''
207 try {
208 const bin = await buildTap($)
209 if (isStopped) return
210 const child = $.process.spawn({ argv: [bin, String(AUDIO_BANDS)] })
211 stream = child[Symbol.asyncIterator]()
212 const split = lineSplitter()
213 let wasAudible = false
214 for await (const chunk of child) {
215 if (isStopped) break
216 if (chunk.stream === 'stderr') {
217 said = chunk.text.trim()
218 continue
219 }
220 for (const line of split(chunk.text)) meter.push(line)
221 if (meter.isAudible !== wasAudible) {
222 wasAudible = meter.isAudible
223 await update($, tapState, () => ({ isAudible: wasAudible, error: null }))
224 }
225 }
226 await fail(said || 'the tap stopped')
227 } catch (err) {
228 await fail(said || (err instanceof Error ? err.message : String(err)))
229 }
230 })()
231 return {
232 stop: () => {
233 isStopped = true
234 void stream?.return?.()
235 },
236 }
237}
238
239/** Runs the tap exactly while the audio theme's band can show. */
240async function syncTap($: EngineInterface): Promise<void> {
241 const wants = (await read($, theme)) === 'audio' && !(await read($, isHidden)) && !(await read($, isStageOff))
242 if (wants && !tap) {
243 await update($, tapState, () => ({ isAudible: false, error: null }))
244 tap = startTap($)
245 } else if (!wants && tap) {
246 tap.stop()
247 tap = null
248 await update($, tapState, () => ({ isAudible: false, error: null }))
249 }
250}
251
252/** What the band draws for the audio theme now: the meter's view, or null when the tap could not run. */
253async function feedOf($: EngineInterface): Promise<AudioView | null> {
254 return (await read($, tapState)).error ? null : meter.view()
255}
256
257/** Busy with the latest tool still running (subagents counted), else back to thinking. */
258async function settle($: EngineInterface, running: Map<string, string>): Promise<void> {
259 const last = busyLabel([...running.values()])
260 await update($, activity, () => (last ? { act: 'tool' as Act, tool: last } : { act: 'think' as Act }))
261 await publishPet($)
262}
263
264/** Sets a switch's session mirror and, when it changed, its row. */
265async function setSwitch($: EngineInterface, field: 'visible' | 'stage' | 'companion', isOn: boolean): Promise<void> {
266 // Each mirror spelled out: the engine reads which state a hook touches from the source.
267 const wasOff =
268 field === 'visible'
269 ? await read($, isHidden)
270 : field === 'stage'
271 ? await read($, isStageOff)
272 : await read($, isCompanionOff)
273 if (wasOff === !isOn) return
274 if (field === 'visible') await update($, isHidden, () => !isOn)
275 else if (field === 'stage') await update($, isStageOff, () => !isOn)
276 else await update($, isCompanionOff, () => !isOn)
277 await publishPet($)
278 await syncTap($)
279 await persist(prefsOf($), field, isOn)
280}
281
282/** `/spinner` with no arguments: the theme, the pet, what is off, the themes and the usage. */
283async function status($: EngineInterface): Promise<string> {
284 const current = await read($, theme)
285 const stats = await read($, pet)
286 const lines = [
287 m('cmd.status', { theme: `${current}${(await read($, choice)) === 'random' ? m('cmd.randomNote') : ''}` }),
288 m('cmd.petStats', { theme: current, level: levelOf(stats.xp), xp: stats.xp, love: stats.love }),
289 ]
290 if (await read($, isHidden)) lines.push(m('cmd.hidden'))
291 if (await read($, isStageOff)) lines.push(m('cmd.stageOff'))
292 if (await read($, isCompanionOff)) lines.push(m('cmd.companionOff'))
293 if (current === 'audio') {
294 const { error } = await read($, tapState)
295 lines.push(error ? m('cmd.audioOff', { reason: error }) : m('cmd.audioOn'))
296 }
297 lines.push(m('cmd.themes', { list: THEME_NAMES.map(n => `${n} ${THEMES[n].happy}`).join(' · ') }), m('cmd.usage'))
298 return lines.join('\n')
299}
300
301// What versions before 0.3 kept in the store, as `/config` rows.
302const STORE_MOVES = {
303 theme: (kept: unknown) => (kept === 'random' || isThemeName(kept) ? (['theme', kept] as const) : null),
304 isHidden: (kept: unknown) => ['visible', kept !== true] as const,
305 isStageOff: (kept: unknown) => ['stage', kept !== true] as const,
306 isCompanionOff: (kept: unknown) => ['companion', kept !== true] as const,
307}
308
309export const register: Register = (on, options) => {
310 const config = readConfig(options)
311 isStill = config.isStill
312 // Tool calls running now: a pet stays busy until the last of parallel calls ends.
313 const running = new Map<string, string>()
314 // Every call still open, a subagent's too: an ask is shown only while its call is.
315 const open = new Set<string>()
316 let sleepTimer: { cancel: () => void } | undefined
317
318 on('session.start', async ($, e, next) => {
319 const settings = (await $.settings.read().catch(() => ({}))) as { language?: unknown }
320 const locale = await Promise.all([
321 $.env.get('LC_ALL').catch(() => undefined),
322 $.env.get('LC_MESSAGES').catch(() => undefined),
323 $.env.get('LANG').catch(() => undefined),
324 ])
325 setLang(resolveLanguage(config.language, settings.language, locale))
326 await $.command.register({ name: 'spinner', description: m('cmd.description'), argumentHint: USAGE })
327
328 const rows = readConfig({ ...options, ...(await keptRows(prefsOf($), STORE_MOVES)) })
329 await choose($, rows.theme)
330 await update($, isHidden, () => !rows.isVisible)
331 await update($, isStageOff, () => !rows.hasStage)
332 await update($, isCompanionOff, () => !rows.hasCompanion)
333 const stats = await keptPet($)
334 await update($, pet, () => stats)
335 await publishPet($)
336 await syncTap($)
337 const result = await next(e)
338 await migrateStore(prefsOf($), STORE_MOVES)
339 return result
340 })
341
342 on('turn.start', async ($, e, next) => {
343 // k-mods 수정: 무작위 테마를 세션마다가 아니라 턴마다 새로 고른다
344 if ((await read($, choice)) === 'random') await choose($, 'random', true)
345 running.clear()
346 sleepTimer?.cancel()
347 await update($, activity, () => ({ act: 'think' as Act }))
348 await update($, isTurn, () => true)
349 if (await read($, finale)) await update($, finale, () => null)
350 await publishPet($)
351 return next(e)
352 })
353
354 on('tool.call', async ($, e, next) => {
355 // A subagent's call is no news for the bubble, but its permission ask is.
356 if (e.agentId) {
357 open.add(e.tool_use_id)
358 try {
359 return await next(e)
360 } finally {
361 open.delete(e.tool_use_id)
362 // Its ask answered: back to what the main thread is doing.
363 if ((await read($, activity)).act === 'ask') await settle($, running)
364 }
365 }
366 open.add(e.tool_use_id)
367 const label = toolLabel(e as unknown as { tool: string } & Record<string, unknown>)
368 running.set(e.tool_use_id, label)
369 await update($, activity, () => (e.tool === 'AskUserQuestion' ? { act: 'ask' as Act } : { act: 'tool' as Act, tool: busyLabel([...running.values()]) }))
370 await publishPet($)
371 try {
372 const result = await next(e)
373 const command = (e as unknown as { command?: unknown }).command
374 if (e.tool === 'Bash' && typeof command === 'string' && !('deny' in result && result.deny !== undefined)) {
375 await noteNews($, command, result.isError === true, e.tool_use_id)
376 }
377 return result
378 } finally {
379 open.delete(e.tool_use_id)
380 running.delete(e.tool_use_id)
381 await settle($, running)
382 }
383 })
384
385 // A call the engine puts to the person: `ask` from the permission check. The
386 // mode often settles an ask by itself at once, so the pet waits a moment and
387 // asks only if the call is still open.
388 on('tool.check', async ($, e, next) => {
389 const verdict = await next(e)
390 const id = e.tool_use_id
391 if (verdict.decision === 'ask' && id !== undefined && open.has(id)) {
392 $.clock.after(ASK_DELAY_MS, async () => {
393 if (!open.has(id)) return
394 await update($, activity, a => ({ ...a, act: 'ask' as Act }))
395 await publishPet($)
396 })
397 }
398 return verdict
399 })
400
401 on('turn.complete', async ($, e, next) => {
402 const result = await next(e)
403 if (e.agentId) return result
404 running.clear()
405 await update($, activity, () => ({ act: 'think' as Act }))
406 await update($, isTurn, () => false)
407
408 const kind = finaleOf(e.reason)
409 await update($, mood, () => (kind === 'answer' ? 'ready' : kind))
410 sleepTimer?.cancel()
411 sleepTimer = $.clock.after(SLEEP_MS, async () => {
412 await update($, mood, () => 'sleep' as Mood)
413 await publishPet($)
414 })
415
416 if (kind === 'answer') {
417 await gainXp($)
418 }
419
420 await publishPet($)
421 if (config.hasFinale) {
422 const label =
423 kind === 'answer' ? m('finale.done', { time: formatDuration(e.durationMs) }) : m(kind === 'aborted' ? 'finale.aborted' : 'finale.error')
424 const id = e.turnId
425 await update($, finale, () => ({ kind, label, id }))
426 $.clock.after(FINALE_MS, () => void update($, finale, f => (f?.id === id ? null : f)))
427 }
428 return result
429 })
430
431 // A click on the pet, where this plugin draws it.
432 // The audio band asking for this frame's levels: its props again, with them.
433 on('ui.message', async ($, e, next) => {
434 const data = e.data as { pat?: unknown; audio?: unknown } | null
435 if (data?.pat === true) await patPet($)
436 const drawn = data?.audio === true ? stageProps.get(e.element) : undefined
437 const heard = await next(e)
438 return drawn ? { ...heard, props: { ...drawn, audio: await feedOf($) } } : heard
439 })
440
441 // A click on the pet where hud draws it: hud counts it in `hud.petPats`.
442 on('state.set', { plugin: 'hud', key: 'petPats' }, async ($, e, next) => {
443 const result = await next(e)
444 await patPet($)
445 return result
446 })
447
448 on('command.run', { command: 'spinner' }, async ($, e) => {
449 const command = parseCommand(e.args)
450 const list = THEME_NAMES.join(' · ')
451 switch (command.kind) {
452 case 'status':
453 return { text: await status($) }
454 case 'visible':
455 await setSwitch($, 'visible', command.isOn)
456 return { text: m(command.isOn ? 'cmd.shown' : 'cmd.hidden') }
457 case 'stage':
458 await setSwitch($, 'stage', command.isOn)
459 return { text: m(command.isOn ? 'cmd.stageOn' : 'cmd.stageOff') }
460 case 'companion':
461 await setSwitch($, 'companion', command.isOn)
462 return { text: m(command.isOn ? 'cmd.companionOn' : 'cmd.companionOff') }
463 case 'pat': {
464 const stats = await patPet($)
465 return { text: m('cmd.pat', { theme: await read($, theme), love: stats.love }) }
466 }
467 case 'preview': {
468 const current = await read($, theme)
469 const name: ThemeName = command.theme ?? (isThemeName(current) ? current : 'clawd')
470 const id = String(await $.clock.now())
471 await update($, preview, () => ({ theme: name, id }))
472 $.clock.after(PREVIEW_MS, () => void update($, preview, p => (p?.id === id ? null : p)))
473 return { text: m('cmd.preview', { theme: name }) }
474 }
475 case 'pick': {
476 // 2-4 options fit the dialog: random and three others; any theme typed under Other.
477 const current = await read($, theme)
478 const others = THEME_NAMES.filter(n => n !== current)
479 const start = (await $.clock.now()) % others.length
480 const offered = ['random', ...[0, 1, 2].map(i => others[(start + i) % others.length]!)]
481 let answer: string
482 try {
483 answer = (await $.ui.ask(m('cmd.pick', { list }), { options: offered, header: 'Theme' })).trim().toLowerCase()
484 } catch {
485 // Dismissed, or no one to ask (-p): what `/spinner` alone says.
486 return { text: await status($) }
487 }
488 if (answer !== 'random' && !isThemeName(answer)) return { text: m('cmd.unknown', { name: answer, list }) }
489 const name = await choose($, answer, true)
490 await syncTap($)
491 await persist(prefsOf($), 'theme', answer)
492 return { text: answer === 'random' ? m('cmd.random', { theme: name }) : m('cmd.switched', { theme: name }) }
493 }
494 case 'theme': {
495 const name = await choose($, command.theme, true)
496 await syncTap($)
497 await persist(prefsOf($), 'theme', command.theme)
498 return { text: command.theme === 'random' ? m('cmd.random', { theme: name }) : m('cmd.switched', { theme: name }) }
499 }
500 case 'unknown':
501 return { text: m('cmd.unknown', { name: command.name, list }) }
502 }
503 })
504
505 // The mascot rides in front of the engine's own line, which keeps its word,
506 // elapsed time and tokens.
507 // A picker (`/` commands, `@` files) opens above the band: the band steps aside meanwhile.
508 on('prompt.edit', async ($, e, next) => {
509 const box = await next(e)
510 const isOpen = isPickerOpen(box.text, box.cursor)
511 if ((await read($, isPicking)) !== isOpen) await update($, isPicking, () => isOpen)
512 return box
513 })
514 on('prompt.submit', async ($, e, next) => {
515 if (await read($, isPicking)) await update($, isPicking, () => false)
516 return next(e)
517 })
518
519 // A button in the prompt footer: `/spinner off` / `on`. Mode labels other plugins add stay beside it.
520 if (config.hasFooterButton) {
521 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
522 const hidden = await read($, isHidden)
523 const below = await next(e)
524 const { Box, Button } = $.ui.resolve(e)
525 return (
526 <Box flexDirection="row" alignItems="center" gap={1}>
527 <Button key="spinner-toggle" plain dimColor={hidden} label="Spinner" onPress={() => setSwitch($, 'visible', hidden)} />
528 {below}
529 </Box>
530 )
531 })
532 }
533
534 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
535 // k-mods 수정: 스피너 줄 옆 마스코트는 그리지 않는다 (줄 앞에 들여쓰기가 생긴다). 장면은 입력창 위 띠에서만.
536 if (!SPINNER_MASCOT) return next(e)
537 // One mascot at a time: with the companion's row showing, it stays there.
538 if ((await read($, isHidden)) || !(await read($, isCompanionOff))) return next(e)
539 const ui = $.ui.resolve(e)
540 if (!('Client' in ui)) return next(e)
541 const { Box, Client } = ui
542 const current = await read($, theme)
543 const name: ThemeName = isThemeName(current) ? current : 'clawd'
544 const native = await next(e)
545 return (
546 <Box flexDirection="row" columnGap={1}>
547 {/* The engine's line opens with a blank row; the mascot sits on the line itself. */}
548 <Box marginTop={1}>
549 <Client key="sprite" module="./sprite.tsx" width={spriteWidth(name)} props={{ theme: name, mode: e.props.mode, still: isStill }} />
550 </Box>
551 {native}
552 </Box>
553 )
554 })
555
556 // The band: a preview, else the running turn's scene or its finale, and the
557 // pet right-aligned beside it unless hud has it beside its rows.
558 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
559 if (e.props.hasSurvey || (await read($, isPicking))) return next(e)
560 const ui = $.ui.resolve(e)
561 if (!('Client' in ui)) return next(e)
562 const { Box, Client } = ui
563
564 const shown: Preview | null = await read($, preview)
565 const current = await read($, theme)
566 const name: ThemeName = isThemeName(current) ? current : 'clawd'
567 const pet = (await read($, isHidden)) || (await read($, hudDock)) ? null : await read($, dock)
568 // Two cells in, and the pet's block on the right.
569 const petColumns = pet ? pet.width + 1 + PET_LABEL_W : 0
570 const columns = e.props.bodyColumns - 2 - petColumns
571 const sceneFits = (rows: number) => e.props.maxRows >= Math.max(rows, pet ? PET_ROWS : 0)
572 let scene = null
573 // The audio theme's band, fed by the tap each frame; its props kept for the frames' answers.
574 const heard = await read($, tapState)
575 const live = name === 'audio' && tap !== null
576 stageProps.clear()
577 const audioStage = async (key: string, props: StageProps) => {
578 const fed: StageProps = live ? { ...props, audio: await feedOf($), feed: !isStill } : props
579 stageProps.set(key, fed)
580 return <Client key={key} module="./stage.tsx" width={columns} props={fed} />
581 }
582
583 if (shown && isThemeName(shown.theme)) {
584 const props: StageProps = { theme: shown.theme, columns, act: 'think', still: isStill }
585 scene = shown.theme === 'audio' ? await audioStage(`preview-${shown.id}`, props) : <Client key={`preview-${shown.id}`} module="./stage.tsx" width={columns} props={props} />
586 } else if (!(await read($, isHidden)) && !(await read($, isStageOff)) && sceneFits(THEMES[name].rows)) {
587 const ended: FinaleState | null = await read($, finale)
588 const now: Activity = await read($, activity)
589 if (e.props.isWorking) {
590 const props: StageProps = { theme: name, columns, act: now.act, still: isStill }
591 scene = live ? await audioStage('work', props) : <Client key="work" module="./stage.tsx" width={columns} props={props} />
592 } else if (live && !ended && heard.isAudible) {
593 // Between turns the audio theme stays up while something plays.
594 scene = await audioStage('listen', { theme: name, columns, act: 'wait', still: isStill })
595 } else if (ended) {
596 const props = { theme: name, columns, act: now.act, finale: ended.kind, label: ended.label, still: isStill }
597 scene = <Client key={`finale-${ended.id}`} module="./stage.tsx" width={columns} props={props} />
598 }
599 }
600 if (!scene && !pet) return next(e)
601
602 // Too short or narrow for the pet's block: the pet in one row, the scene left out.
603 if (pet && !shown && (e.props.maxRows < PET_ROWS || e.props.bodyColumns < COMPACT_COLUMNS)) {
604 const mascot = { text: THEMES[name].sprite.say[0] ?? '', color: THEMES[name].color }
605 return stackAbove(ui, <Box justifyContent="flex-end">{drawPetLine(ui, pet, mascot)}</Box>, await next(e))
606 }
607 const band = (
608 <Box flexDirection="row" justifyContent="space-between" alignItems="flex-end">
609 {scene ?? <Box />}
610 {pet ? <Box width={petColumns} justifyContent="flex-end">{drawPet(ui, pet, 'pet')}</Box> : null}
611 </Box>
612 )
613 return stackAbove(ui, band, await next(e))
614 })
615}
616
617/** Cells the mascot's region takes: its widest frame, with room for a symbol a terminal draws wide. */
618function spriteWidth(name: ThemeName): number {
619 return Math.max(...Object.values(THEMES[name].sprite).flat().map(textWidth)) + 2
620}
621hooks/command.ts 38 lines1// `/spinner`'s arguments to what they ask for; register.tsx carries it out.
2import { isThemeName } from './themes'
3import type { ThemeName } from './themes'
4
5export type SpinnerCommand =
6 | { kind: 'status' }
7 | { kind: 'visible'; isOn: boolean }
8 | { kind: 'stage'; isOn: boolean }
9 | { kind: 'companion'; isOn: boolean }
10 | { kind: 'pat' }
11 | { kind: 'preview'; theme: ThemeName | null }
12 | { kind: 'theme'; theme: ThemeName | 'random' }
13 | { kind: 'pick' }
14 | { kind: 'unknown'; name: string }
15
16export const USAGE = '[theme|random|pet|preview|off|on|stage off|companion off]'
17
18export function parseCommand(args: string): SpinnerCommand {
19 const [verb = '', arg = ''] = args.trim().toLowerCase().split(/\s+/)
20 const isSwitch = arg === 'off' || arg === 'on'
21 if (verb === '') return { kind: 'status' }
22 if (verb === 'off' || verb === 'on') return { kind: 'visible', isOn: verb === 'on' }
23 if (verb === 'stage' && isSwitch) return { kind: 'stage', isOn: arg === 'on' }
24 if (verb === 'companion' && isSwitch) return { kind: 'companion', isOn: arg === 'on' }
25 if (verb === 'pet') return { kind: 'pat' }
26 if (verb === 'preview') {
27 if (!arg) return { kind: 'preview', theme: null }
28 return isThemeName(arg) ? { kind: 'preview', theme: arg } : { kind: 'unknown', name: arg }
29 }
30 // `theme` alone asks which; `theme <name>` is the same as `<name>`.
31 if (verb === 'theme') {
32 if (!arg) return { kind: 'pick' }
33 return arg === 'random' || isThemeName(arg) ? { kind: 'theme', theme: arg } : { kind: 'unknown', name: arg }
34 }
35 if (verb === 'random' || isThemeName(verb)) return { kind: 'theme', theme: verb }
36 return { kind: 'unknown', name: verb }
37}
38hooks/config.ts 39 lines1// spinner's options (plugin.json `userConfig`), read once into a typed config.
2import type { PluginOptions } from 'claude-code'
3
4import { flag, oneOf, text } from './kit/options'
5import { THEME_NAMES } from './themes'
6import type { ThemeName } from './themes'
7
8export type Choice = ThemeName | 'random'
9
10export const CHOICES: readonly Choice[] = ['random', ...THEME_NAMES]
11
12export type Config = {
13 /** A theme, or `random` for a new one each session. */
14 theme: Choice
15 isVisible: boolean
16 /** A Spinner button in the prompt footer. */
17 hasFooterButton: boolean
18 hasStage: boolean
19 hasFinale: boolean
20 hasCompanion: boolean
21 /** Every animation drawn as one still frame. */
22 isStill: boolean
23 /** `auto` or a language; the kit resolves it (kit/lang.ts). */
24 language: string
25}
26
27export function readConfig(options: PluginOptions): Config {
28 return {
29 theme: oneOf(options.theme, CHOICES, 'random'),
30 isVisible: flag(options.visible, true),
31 hasFooterButton: flag(options.footerButton, true),
32 hasStage: flag(options.stage, true),
33 hasFinale: flag(options.celebrate, true),
34 hasCompanion: flag(options.companion, true),
35 isStill: flag(options.reducedMotion, false),
36 language: text(options.language, 'auto'),
37 }
38}
39hooks/i18n.ts 396 lines1// spinner's messages; the language is resolved by the kit (kit/lang.ts).
2import { createMessages } from './kit/lang'
3import type { Lang } from './kit/lang'
4
5export { parseLanguage, resolveLanguage } from './kit/lang'
6
7export type Key =
8 | 'cmd.description'
9 | 'cmd.status'
10 | 'cmd.randomNote'
11 | 'cmd.themes'
12 | 'cmd.usage'
13 | 'cmd.switched'
14 | 'cmd.random'
15 | 'cmd.hidden'
16 | 'cmd.shown'
17 | 'cmd.stageOff'
18 | 'cmd.stageOn'
19 | 'cmd.preview'
20 | 'cmd.unknown'
21 | 'cmd.pick'
22 | 'finale.done'
23 | 'finale.aborted'
24 | 'finale.error'
25 | 'pet.hello'
26 | 'pet.ask'
27 | 'pet.ready'
28 | 'pet.aborted'
29 | 'pet.error'
30 | 'pet.sleep'
31 | 'pet.levelUp'
32 | 'pet.testPass'
33 | 'pet.testFail'
34 | 'pet.commit'
35 | 'cmd.pat'
36 | 'cmd.petStats'
37 | 'cmd.companionOff'
38 | 'cmd.companionOn'
39 | 'cmd.audioOn'
40 | 'cmd.audioOff'
41
42export const MESSAGES: Record<Lang, Record<Key, string>> = {
43 en: {
44 'cmd.description': 'Spinner animations: themes, a companion pet, preview, off / on (kept across sessions)',
45 'cmd.status': 'Theme: {theme}',
46 'cmd.randomNote': ' (random each session)',
47 'cmd.themes': 'Themes: {list}',
48 'cmd.usage': 'Usage: /spinner <theme|random> · theme (pick) · pet · preview [theme] · off / on · stage off / on · companion off / on',
49 'cmd.switched': 'Switched to {theme}',
50 'cmd.random': 'A random theme each session; this one: {theme}',
51 'cmd.hidden': 'Spinner animations off',
52 'cmd.shown': 'Spinner animations on',
53 'cmd.stageOff': 'Animation band above the prompt off',
54 'cmd.stageOn': 'Animation band above the prompt on',
55 'cmd.preview': 'Previewing {theme} above the prompt',
56 'cmd.unknown': 'No theme called {name}. Themes: {list}',
57 'cmd.pick': 'Which theme? (Other: type one of {list})',
58 'finale.done': 'Done · {time}',
59 'finale.aborted': 'Interrupted',
60 'finale.error': 'Something went wrong',
61 'pet.hello': 'I’m here with you~',
62 'pet.ask': 'Waiting for your OK~',
63 'pet.ready': 'All done, take a look!',
64 'pet.aborted': 'Okay, stopped.',
65 'pet.error': 'Ouch… something broke',
66 'pet.sleep': 'Zzz…',
67 'pet.levelUp': '{theme} reached Lv.{level}!',
68 'pet.testPass': 'Tests passed!',
69 'pet.testFail': 'Tests failed… we’ll fix it',
70 'pet.commit': 'Committed!',
71 'cmd.pat': 'You patted {theme} (♥{love})',
72 'cmd.petStats': '{theme} · Lv.{level} · {xp} xp · ♥{love}',
73 'cmd.companionOff': 'Companion between turns hidden',
74 'cmd.companionOn': 'Companion between turns shown',
75 'cmd.audioOn': 'Audio: showing this computer\'s sound output (only levels, nothing kept or sent)',
76 'cmd.audioOff': 'Audio: nothing to show ({reason}). Needs macOS 14.2+, swiftc (Xcode Command Line Tools) and System Audio Recording allowed for your terminal',
77 },
78 'zh-Hans': {
79 'cmd.description': '运行动画:切换主题、宠物伴侣、预览、off / on(跨会话保持)',
80 'cmd.status': '当前主题:{theme}',
81 'cmd.randomNote': '(每个会话随机)',
82 'cmd.themes': '可选主题:{list}',
83 'cmd.usage': '用法:/spinner <主题|random> · theme 弹窗选择 · pet 摸摸 · preview [主题] · off / on · stage off / on · companion off / on',
84 'cmd.switched': '已切换到 {theme}',
85 'cmd.random': '每个会话随机一个主题,这次是 {theme}',
86 'cmd.hidden': '运行动画已关闭',
87 'cmd.shown': '运行动画已开启',
88 'cmd.stageOff': '输入框上方的动画带已关闭',
89 'cmd.stageOn': '输入框上方的动画带已开启',
90 'cmd.preview': '正在输入框上方预览 {theme}',
91 'cmd.unknown': '没有叫 {name} 的主题。可选:{list}',
92 'cmd.pick': '换哪个主题?(其他里可输入:{list})',
93 'finale.done': '完成 · {time}',
94 'finale.aborted': '已中断',
95 'finale.error': '出错了',
96 'pet.hello': '我在这儿陪你~',
97 'pet.ask': '等你确认一下~',
98 'pet.ready': '做完啦,快看看!',
99 'pet.aborted': '好吧,停下了',
100 'pet.error': '呜…出错了',
101 'pet.sleep': 'Zzz…',
102 'pet.levelUp': '{theme} 升到 Lv.{level} 啦!',
103 'pet.testPass': '测试通过啦!',
104 'pet.testFail': '测试没过…一起修吧',
105 'pet.commit': '提交好啦!',
106 'cmd.pat': '你摸了摸 {theme}(♥{love})',
107 'cmd.petStats': '{theme} · Lv.{level} · 经验 {xp} · ♥{love}',
108 'cmd.companionOff': '空闲时的宠物已隐藏',
109 'cmd.companionOn': '空闲时的宠物已显示',
110 'cmd.audioOn': '音频:正在显示本机的声音输出(只取电平,不保存、不外传)',
111 'cmd.audioOff': '音频:无法显示({reason})。需要 macOS 14.2 及以上、swiftc(Xcode 命令行工具),并在系统设置里允许终端“录制系统音频”',
112 },
113 'zh-Hant': {
114 'cmd.description': '執行動畫:切換主題、寵物夥伴、預覽、off / on(跨工作階段保留)',
115 'cmd.status': '目前主題:{theme}',
116 'cmd.randomNote': '(每個工作階段隨機)',
117 'cmd.themes': '可選主題:{list}',
118 'cmd.usage': '用法:/spinner <主題|random> · theme 彈窗選擇 · pet 摸摸 · preview [主題] · off / on · stage off / on · companion off / on',
119 'cmd.switched': '已切換到 {theme}',
120 'cmd.random': '每個工作階段隨機一個主題,這次是 {theme}',
121 'cmd.hidden': '執行動畫已關閉',
122 'cmd.shown': '執行動畫已開啟',
123 'cmd.stageOff': '輸入框上方的動畫帶已關閉',
124 'cmd.stageOn': '輸入框上方的動畫帶已開啟',
125 'cmd.preview': '正在輸入框上方預覽 {theme}',
126 'cmd.unknown': '沒有名為 {name} 的主題。可選:{list}',
127 'cmd.pick': '換哪個主題?(其他裡可輸入:{list})',
128 'finale.done': '完成 · {time}',
129 'finale.aborted': '已中斷',
130 'finale.error': '出錯了',
131 'pet.hello': '我在這裡陪你~',
132 'pet.ask': '等你確認一下~',
133 'pet.ready': '做完了,快看看!',
134 'pet.aborted': '好吧,停下了',
135 'pet.error': '嗚…出錯了',
136 'pet.sleep': 'Zzz…',
137 'pet.levelUp': '{theme} 升到 Lv.{level} 了!',
138 'pet.testPass': '測試通過了!',
139 'pet.testFail': '測試沒過…一起修吧',
140 'pet.commit': '提交好了!',
141 'cmd.pat': '你摸了摸 {theme}(♥{love})',
142 'cmd.petStats': '{theme} · Lv.{level} · 經驗 {xp} · ♥{love}',
143 'cmd.companionOff': '閒置時的寵物已隱藏',
144 'cmd.companionOn': '閒置時的寵物已顯示',
145 'cmd.audioOn': '音訊:正在顯示本機的聲音輸出(只取音量,不儲存、不外傳)',
146 'cmd.audioOff': '音訊:無法顯示({reason})。需要 macOS 14.2 以上、swiftc(Xcode 命令列工具),並在系統設定允許終端機「錄製系統音訊」',
147 },
148 ja: {
149 'cmd.description': '実行中アニメーション:テーマ、ペット、プレビュー、off / on(セッションをまたいで保持)',
150 'cmd.status': 'テーマ:{theme}',
151 'cmd.randomNote': '(セッションごとにランダム)',
152 'cmd.themes': 'テーマ一覧:{list}',
153 'cmd.usage': '使い方:/spinner <テーマ|random> · theme 選択 · pet なでる · preview [テーマ] · off / on · stage off / on · companion off / on',
154 'cmd.switched': '{theme} に切り替えました',
155 'cmd.random': 'セッションごとにランダムなテーマ。今回は {theme}',
156 'cmd.hidden': '実行中アニメーションをオフにしました',
157 'cmd.shown': '実行中アニメーションをオンにしました',
158 'cmd.stageOff': '入力欄上のアニメーションバーをオフにしました',
159 'cmd.stageOn': '入力欄上のアニメーションバーをオンにしました',
160 'cmd.preview': '入力欄の上で {theme} をプレビュー中',
161 'cmd.unknown': '{name} というテーマはありません。テーマ一覧:{list}',
162 'cmd.pick': 'どのテーマにする?(その他で入力可:{list})',
163 'finale.done': '完了 · {time}',
164 'finale.aborted': '中断しました',
165 'finale.error': 'エラーが発生しました',
166 'pet.hello': 'そばにいるよ~',
167 'pet.ask': '確認を待ってるよ~',
168 'pet.ready': 'できたよ、見てみて!',
169 'pet.aborted': 'はい、止めたよ',
170 'pet.error': 'うぅ…エラーだ',
171 'pet.sleep': 'Zzz…',
172 'pet.levelUp': '{theme} が Lv.{level} になった!',
173 'pet.testPass': 'テスト通った!',
174 'pet.testFail': 'テスト失敗…直そう',
175 'pet.commit': 'コミットした!',
176 'cmd.pat': '{theme} をなでた(♥{love})',
177 'cmd.petStats': '{theme} · Lv.{level} · 経験値 {xp} · ♥{love}',
178 'cmd.companionOff': '待機中のペットを非表示にしました',
179 'cmd.companionOn': '待機中のペットを表示しました',
180 'cmd.audioOn': 'オーディオ:このコンピュータの音声出力を表示中(レベルのみ、保存・送信なし)',
181 'cmd.audioOff': 'オーディオ:表示できません({reason})。macOS 14.2 以降、swiftc(Xcode コマンドラインツール)、ターミナルへの「システムオーディオ録音」の許可が必要です',
182 },
183 ko: {
184 'cmd.description': '실행 애니메이션: 테마, 펫, 미리 보기, off / on(세션 간 유지)',
185 'cmd.status': '현재 테마: {theme}',
186 'cmd.randomNote': ' (세션마다 무작위)',
187 'cmd.themes': '테마 목록: {list}',
188 'cmd.usage': '사용법: /spinner <테마|random> · theme 선택 · pet 쓰다듬기 · preview [테마] · off / on · stage off / on · companion off / on',
189 'cmd.switched': '{theme}(으)로 전환했습니다',
190 'cmd.random': '세션마다 무작위 테마, 이번에는 {theme}',
191 'cmd.hidden': '실행 애니메이션을 껐습니다',
192 'cmd.shown': '실행 애니메이션을 켰습니다',
193 'cmd.stageOff': '입력창 위 애니메이션 띠를 껐습니다',
194 'cmd.stageOn': '입력창 위 애니메이션 띠를 켰습니다',
195 'cmd.preview': '입력창 위에서 {theme} 미리 보는 중',
196 'cmd.unknown': '{name} 테마가 없습니다. 테마 목록: {list}',
197 'cmd.pick': '어떤 테마로 할까요? (기타에 입력: {list})',
198 'finale.done': '완료 · {time}',
199 'finale.aborted': '중단됨',
200 'finale.error': '오류가 발생했습니다',
201 'pet.hello': '여기서 함께할게요~',
202 'pet.ask': '확인을 기다려요~',
203 'pet.ready': '다 했어요, 확인해 보세요!',
204 'pet.aborted': '네, 멈췄어요',
205 'pet.error': '으… 오류가 났어요',
206 'pet.sleep': 'Zzz…',
207 'pet.levelUp': '{theme} Lv.{level} 달성!',
208 'pet.testPass': '테스트 통과!',
209 'pet.testFail': '테스트 실패… 같이 고쳐요',
210 'pet.commit': '커밋 완료!',
211 'cmd.pat': '{theme}을(를) 쓰다듬었어요 (♥{love})',
212 'cmd.petStats': '{theme} · Lv.{level} · 경험치 {xp} · ♥{love}',
213 'cmd.companionOff': '대기 중 펫을 숨겼습니다',
214 'cmd.companionOn': '대기 중 펫을 표시했습니다',
215 'cmd.audioOn': '오디오: 이 컴퓨터의 소리 출력을 표시 중 (레벨만, 저장·전송 없음)',
216 'cmd.audioOff': '오디오: 표시할 수 없음 ({reason}). macOS 14.2 이상, swiftc(Xcode 명령줄 도구), 터미널의 \'시스템 오디오 녹음\' 허용이 필요합니다',
217 },
218 es: {
219 'cmd.description': 'Animaciones del spinner: temas, mascota, vista previa, off / on (se mantiene entre sesiones)',
220 'cmd.status': 'Tema: {theme}',
221 'cmd.randomNote': ' (al azar en cada sesión)',
222 'cmd.themes': 'Temas: {list}',
223 'cmd.usage': 'Uso: /spinner <tema|random> · theme (elegir) · pet · preview [tema] · off / on · stage off / on · companion off / on',
224 'cmd.switched': 'Cambiado a {theme}',
225 'cmd.random': 'Un tema al azar en cada sesión; esta vez: {theme}',
226 'cmd.hidden': 'Animaciones del spinner desactivadas',
227 'cmd.shown': 'Animaciones del spinner activadas',
228 'cmd.stageOff': 'Barra animada sobre el prompt desactivada',
229 'cmd.stageOn': 'Barra animada sobre el prompt activada',
230 'cmd.preview': 'Vista previa de {theme} sobre el prompt',
231 'cmd.unknown': 'No hay ningún tema llamado {name}. Temas: {list}',
232 'cmd.pick': '¿Qué tema? (En Otro escribe uno de: {list})',
233 'finale.done': 'Listo · {time}',
234 'finale.aborted': 'Interrumpido',
235 'finale.error': 'Algo salió mal',
236 'pet.hello': '¡Aquí estoy contigo!',
237 'pet.ask': 'Esperando tu OK~',
238 'pet.ready': '¡Listo, échale un vistazo!',
239 'pet.aborted': 'Vale, me detengo.',
240 'pet.error': 'Ay… algo falló',
241 'pet.sleep': 'Zzz…',
242 'pet.levelUp': '¡{theme} subió a Nv.{level}!',
243 'pet.testPass': '¡Pasan las pruebas!',
244 'pet.testFail': 'Fallan las pruebas… lo arreglamos',
245 'pet.commit': '¡Commit hecho!',
246 'cmd.pat': 'Acariciaste a {theme} (♥{love})',
247 'cmd.petStats': '{theme} · Nv.{level} · {xp} xp · ♥{love}',
248 'cmd.companionOff': 'Mascota entre turnos oculta',
249 'cmd.companionOn': 'Mascota entre turnos visible',
250 'cmd.audioOn': 'Audio: mostrando la salida de sonido del equipo (solo niveles; nada se guarda ni se envía)',
251 'cmd.audioOff': 'Audio: nada que mostrar ({reason}). Requiere macOS 14.2+, swiftc (Xcode Command Line Tools) y permitir Grabación de audio del sistema a tu terminal',
252 },
253 fr: {
254 'cmd.description': 'Animations du spinner : thèmes, compagnon, aperçu, off / on (réglage conservé entre les sessions)',
255 'cmd.status': 'Thème : {theme}',
256 'cmd.randomNote': ' (au hasard à chaque session)',
257 'cmd.themes': 'Thèmes : {list}',
258 'cmd.usage': 'Usage : /spinner <thème|random> · theme (choisir) · pet · preview [thème] · off / on · stage off / on · companion off / on',
259 'cmd.switched': 'Thème {theme} activé',
260 'cmd.random': 'Un thème au hasard à chaque session ; cette fois : {theme}',
261 'cmd.hidden': 'Animations du spinner désactivées',
262 'cmd.shown': 'Animations du spinner activées',
263 'cmd.stageOff': 'Bande animée au-dessus du prompt désactivée',
264 'cmd.stageOn': 'Bande animée au-dessus du prompt activée',
265 'cmd.preview': 'Aperçu de {theme} au-dessus du prompt',
266 'cmd.unknown': 'Aucun thème nommé {name}. Thèmes : {list}',
267 'cmd.pick': 'Quel thème ? (Autre : tape l’un de {list})',
268 'finale.done': 'Terminé · {time}',
269 'finale.aborted': 'Interrompu',
270 'finale.error': 'Une erreur est survenue',
271 'pet.hello': 'Je suis là avec toi~',
272 'pet.ask': 'J’attends ton accord~',
273 'pet.ready': 'Fini, jette un œil\u00a0!',
274 'pet.aborted': 'D’accord, j’arrête.',
275 'pet.error': 'Aïe… une erreur',
276 'pet.sleep': 'Zzz…',
277 'pet.levelUp': '{theme} passe au niv.\u00a0{level}\u00a0!',
278 'pet.testPass': 'Tests réussis\u00a0!',
279 'pet.testFail': 'Tests en échec… on répare',
280 'pet.commit': 'Commit fait\u00a0!',
281 'cmd.pat': 'Tu as caressé {theme} (♥{love})',
282 'cmd.petStats': '{theme} · niv.\u00a0{level} · {xp}\u00a0xp · ♥{love}',
283 'cmd.companionOff': 'Compagnon entre les tours masqué',
284 'cmd.companionOn': 'Compagnon entre les tours affiché',
285 'cmd.audioOn': 'Audio : affiche la sortie son de l’ordinateur (niveaux seulement, rien n’est gardé ni envoyé)',
286 'cmd.audioOff': 'Audio : rien à afficher ({reason}). Il faut macOS 14.2+, swiftc (outils en ligne de commande Xcode) et autoriser l’enregistrement audio du système pour votre terminal',
287 },
288 de: {
289 'cmd.description': 'Spinner-Animationen: Themen, Begleiter, Vorschau, off / on (bleibt über Sitzungen erhalten)',
290 'cmd.status': 'Thema: {theme}',
291 'cmd.randomNote': ' (jede Sitzung zufällig)',
292 'cmd.themes': 'Themen: {list}',
293 'cmd.usage': 'Verwendung: /spinner <Thema|random> · theme (auswählen) · pet · preview [Thema] · off / on · stage off / on · companion off / on',
294 'cmd.switched': 'Zu {theme} gewechselt',
295 'cmd.random': 'Jede Sitzung ein zufälliges Thema; diesmal: {theme}',
296 'cmd.hidden': 'Spinner-Animationen aus',
297 'cmd.shown': 'Spinner-Animationen an',
298 'cmd.stageOff': 'Animationsleiste über der Eingabe aus',
299 'cmd.stageOn': 'Animationsleiste über der Eingabe an',
300 'cmd.preview': 'Vorschau von {theme} über der Eingabe',
301 'cmd.unknown': 'Kein Thema namens {name}. Themen: {list}',
302 'cmd.pick': 'Welches Thema? (Unter Andere eintippen: {list})',
303 'finale.done': 'Fertig · {time}',
304 'finale.aborted': 'Abgebrochen',
305 'finale.error': 'Etwas ist schiefgelaufen',
306 'pet.hello': 'Ich bin bei dir~',
307 'pet.ask': 'Warte auf dein OK~',
308 'pet.ready': 'Fertig, schau mal!',
309 'pet.aborted': 'Okay, angehalten.',
310 'pet.error': 'Autsch… ein Fehler',
311 'pet.sleep': 'Zzz…',
312 'pet.levelUp': '{theme} ist jetzt Lv.{level}!',
313 'pet.testPass': 'Tests bestanden!',
314 'pet.testFail': 'Tests rot… wir reparieren das',
315 'pet.commit': 'Committet!',
316 'cmd.pat': 'Du hast {theme} gestreichelt (♥{love})',
317 'cmd.petStats': '{theme} · Lv.{level} · {xp} EP · ♥{love}',
318 'cmd.companionOff': 'Begleiter zwischen den Runden ausgeblendet',
319 'cmd.companionOn': 'Begleiter zwischen den Runden eingeblendet',
320 'cmd.audioOn': 'Audio: zeigt die Tonausgabe dieses Computers (nur Pegel, nichts wird gespeichert oder gesendet)',
321 'cmd.audioOff': 'Audio: nichts anzuzeigen ({reason}). Benötigt macOS 14.2+, swiftc (Xcode Command Line Tools) und die Freigabe „Systemaudioaufnahme“ für dein Terminal',
322 },
323 'pt-BR': {
324 'cmd.description': 'Animações do spinner: temas, mascote, prévia, off / on (configuração mantida entre sessões)',
325 'cmd.status': 'Tema: {theme}',
326 'cmd.randomNote': ' (aleatório a cada sessão)',
327 'cmd.themes': 'Temas: {list}',
328 'cmd.usage': 'Uso: /spinner <tema|random> · theme (escolher) · pet · preview [tema] · off / on · stage off / on · companion off / on',
329 'cmd.switched': 'Trocado para {theme}',
330 'cmd.random': 'Um tema aleatório a cada sessão; desta vez: {theme}',
331 'cmd.hidden': 'Animações do spinner desativadas',
332 'cmd.shown': 'Animações do spinner ativadas',
333 'cmd.stageOff': 'Faixa animada acima do prompt desativada',
334 'cmd.stageOn': 'Faixa animada acima do prompt ativada',
335 'cmd.preview': 'Prévia de {theme} acima do prompt',
336 'cmd.unknown': 'Nenhum tema chamado {name}. Temas: {list}',
337 'cmd.pick': 'Qual tema? (Em Outro digite um de: {list})',
338 'finale.done': 'Pronto · {time}',
339 'finale.aborted': 'Interrompido',
340 'finale.error': 'Algo deu errado',
341 'pet.hello': 'Estou aqui com você~',
342 'pet.ask': 'Esperando seu OK~',
343 'pet.ready': 'Pronto, dá uma olhada!',
344 'pet.aborted': 'Tá, parei.',
345 'pet.error': 'Ai… deu erro',
346 'pet.sleep': 'Zzz…',
347 'pet.levelUp': '{theme} chegou ao Nv.{level}!',
348 'pet.testPass': 'Testes passaram!',
349 'pet.testFail': 'Testes falharam… vamos consertar',
350 'pet.commit': 'Commit feito!',
351 'cmd.pat': 'Você fez carinho em {theme} (♥{love})',
352 'cmd.petStats': '{theme} · Nv.{level} · {xp} xp · ♥{love}',
353 'cmd.companionOff': 'Mascote entre turnos oculto',
354 'cmd.companionOn': 'Mascote entre turnos visível',
355 'cmd.audioOn': 'Áudio: mostrando a saída de som do computador (só níveis; nada é guardado nem enviado)',
356 'cmd.audioOff': 'Áudio: nada para mostrar ({reason}). Requer macOS 14.2+, swiftc (Xcode Command Line Tools) e permitir Gravação de áudio do sistema ao seu terminal',
357 },
358 ru: {
359 'cmd.description': 'Анимации спиннера: темы, питомец, предпросмотр, off / on (сохраняется между сессиями)',
360 'cmd.status': 'Тема: {theme}',
361 'cmd.randomNote': ' (случайная в каждой сессии)',
362 'cmd.themes': 'Темы: {list}',
363 'cmd.usage': 'Использование: /spinner <тема|random> · theme (выбор) · pet · preview [тема] · off / on · stage off / on · companion off / on',
364 'cmd.switched': 'Тема переключена на {theme}',
365 'cmd.random': 'Случайная тема в каждой сессии; сейчас: {theme}',
366 'cmd.hidden': 'Анимации спиннера выключены',
367 'cmd.shown': 'Анимации спиннера включены',
368 'cmd.stageOff': 'Анимированная полоса над вводом выключена',
369 'cmd.stageOn': 'Анимированная полоса над вводом включена',
370 'cmd.preview': 'Предпросмотр {theme} над вводом',
371 'cmd.unknown': 'Темы {name} нет. Темы: {list}',
372 'cmd.pick': 'Какую тему? (В «Другое» введите одну из: {list})',
373 'finale.done': 'Готово · {time}',
374 'finale.aborted': 'Прервано',
375 'finale.error': 'Что-то пошло не так',
376 'pet.hello': 'Я рядом~',
377 'pet.ask': 'Жду твоего OK~',
378 'pet.ready': 'Готово, посмотри!',
379 'pet.aborted': 'Ладно, остановился.',
380 'pet.error': 'Ой… ошибка',
381 'pet.sleep': 'Zzz…',
382 'pet.levelUp': '{theme} достиг ур.{level}!',
383 'pet.testPass': 'Тесты прошли!',
384 'pet.testFail': 'Тесты упали… починим',
385 'pet.commit': 'Закоммичено!',
386 'cmd.pat': 'Ты погладил {theme} (♥{love})',
387 'cmd.petStats': '{theme} · ур.{level} · {xp} оп. · ♥{love}',
388 'cmd.companionOff': 'Питомец между ходами скрыт',
389 'cmd.companionOn': 'Питомец между ходами показан',
390 'cmd.audioOn': 'Аудио: показывает звук, который выводит компьютер (только уровни, ничего не сохраняется и не отправляется)',
391 'cmd.audioOff': 'Аудио: нечего показать ({reason}). Нужны macOS 14.2+, swiftc (Xcode Command Line Tools) и разрешение «Запись системного аудио» для терминала',
392 },
393}
394
395export const { m, setLang } = createMessages(MESSAGES)
396hooks/kit/band.tsx 31 lines1// Generated from claude-code/kit/band.tsx by scripts/sync-kit.sh: edit the source, then re-run it.
2// A mod's rows in the band above the prompt. Several mods draw there, so each
3// stacks its rows over what the rest of the chain drew (`await next(e)`)
4// instead of replacing it, two cells in, as the engine indents the lines under
5// the prompt. The hook stays the mod's own (`on('ui.render', ...)` is spelled
6// in the hooks module): it yields to a survey, draws, and hands both here.
7import type { Elements, RenderElement } from 'claude-code'
8
9/**
10 * Whether the prompt's draft has a picker open: a slash command being named
11 * (`/sp`, before any space) or a file being mentioned (`@src/a`, the word at the
12 * cursor). The engine draws the picker above the band, so a band steps aside
13 * while one is open and the picker sits right on the prompt.
14 */
15export function isPickerOpen(text: string, cursor = text.length): boolean {
16 const before = text.slice(0, cursor)
17 return /^\/\S*$/.test(before) || /(^|\s)@\S*$/.test(before)
18}
19
20export function stackAbove(ui: Pick<Elements['terminal'], 'Box'>, mine: RenderElement, below: RenderElement): RenderElement {
21 const { Box } = ui
22 return (
23 <Box flexDirection="column">
24 <Box flexDirection="column" paddingLeft={2}>
25 {mine}
26 </Box>
27 {below}
28 </Box>
29 )
30}
31hooks/kit/pet.tsx 87 lines1// Generated from claude-code/kit/pet.tsx by scripts/sync-kit.sh: edit the source, then re-run it.
2// The companion pet wherever it stands. spinner owns it: it publishes the pet
3// as plain data (`spinner.dock`, a DockPet: its frames, bubble and stats) and
4// draws it itself above the prompt; hud, below the prompt, may give it a place
5// beside its rows, and says so in `hud.dock`. Either one draws the block here,
6// the frames played by the Client module `kit/pet-player.tsx`.
7import type { Elements, RenderElement } from 'claude-code'
8
9import type { DockPet } from '../../types'
10
11/** Cells the bubble gets at least beside the pet when docked. */
12export const LABEL_MIN = 14
13/** Room a docked pet must gain before it moves in, so a row's length changing does not toss it back and forth. */
14export const DOCK_SLACK = 6
15
16/** Cells `text` takes as the terminal draws it: CJK, fullwidth forms and emoji two, the rest (box drawing and blocks included) one. */
17export function cellWidth(text: string): number {
18 let w = 0
19 for (const ch of text) {
20 const cp = ch.codePointAt(0)!
21 const isWide =
22 (cp >= 0x1100 && cp <= 0x115f) ||
23 (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) ||
24 (cp >= 0xac00 && cp <= 0xd7a3) ||
25 (cp >= 0xf900 && cp <= 0xfaff) ||
26 (cp >= 0xfe30 && cp <= 0xfe4f) ||
27 (cp >= 0xff00 && cp <= 0xff60) ||
28 (cp >= 0xffe0 && cp <= 0xffe6) ||
29 (cp >= 0x1f300 && cp <= 0x1faff) ||
30 (cp >= 0x20000 && cp <= 0x3fffd)
31 w += isWide ? 2 : 1
32 }
33 return w
34}
35
36/** Whether the pet fits beside rows `rowWidth` wide in `columns`; once docked it stays while it fits at all. */
37export function dockFits(rowWidth: number, columns: number, petWidth: number, isDocked: boolean): boolean {
38 const spare = columns - rowWidth - 2 - petWidth - LABEL_MIN
39 return spare >= (isDocked ? 0 : DOCK_SLACK)
40}
41
42const TONE: Record<DockPet['tone'], { color?: string; bold?: boolean; dimColor?: boolean }> = {
43 plain: { color: '#b8b8be' },
44 ask: { color: '#ffd166', bold: true },
45 error: { color: '#e63946' },
46 aborted: { color: '#adb5bd' },
47 sleep: { color: '#6c757d', dimColor: true },
48}
49
50type Ui = Pick<Elements['terminal'], 'Box' | 'Text' | 'Client'>
51
52/** The pet in one row, for a band too short or narrow for its block: `mascot`, the bubble, the stats. */
53export function drawPetLine(ui: Pick<Ui, 'Box' | 'Text'>, pet: DockPet, mascot: { text: string; color?: string }): RenderElement {
54 const { Box, Text } = ui
55 return (
56 <Box flexDirection="row" columnGap={1} flexShrink={1}>
57 <Text color={mascot.color} bold>{mascot.text}</Text>
58 {pet.bubble ? (
59 <Text {...TONE[pet.tone]} wrap="truncate-end">
60 {pet.tone === 'ask' ? '❯ ' : ''}
61 {pet.bubble}
62 </Text>
63 ) : null}
64 <Text color="#6c757d">{pet.stats}</Text>
65 </Box>
66 )
67}
68
69/** The bubble and stats, right-aligned, then the pet: `rows` tall. */
70export function drawPet(ui: Ui, pet: DockPet, key: string): RenderElement {
71 const { Box, Text, Client } = ui
72 return (
73 <Box flexDirection="row" flexShrink={1} columnGap={1}>
74 <Box flexDirection="column" alignItems="flex-end" flexShrink={1}>
75 <Text color="#6c757d" wrap="truncate-end">{pet.stats}</Text>
76 {pet.bubble ? (
77 <Text {...TONE[pet.tone]} wrap="truncate-end">
78 {pet.tone === 'ask' ? '❯ ' : ''}
79 {pet.bubble}
80 </Text>
81 ) : null}
82 </Box>
83 <Client key={key} module="./pet-player.tsx" width={pet.width} props={pet} />
84 </Box>
85 )
86}
87hooks/kit/lang.ts 68 lines1// Generated from claude-code/kit/lang.ts by scripts/sync-kit.sh: edit the source, then re-run it.
2// A mod's language: the `language` option, or with `auto` Claude Code's own
3// `language` setting (free text: "简体中文", "Japanese", "pt-BR"), then the locale
4// (LC_ALL, LC_MESSAGES, LANG), then English. The messages stay the mod's own:
5// `createMessages(MESSAGES)` gives it `m` over them.
6
7export type Lang = 'en' | 'zh-Hans' | 'zh-Hant' | 'ja' | 'ko' | 'es' | 'fr' | 'de' | 'pt-BR' | 'ru'
8
9export const LANGS: readonly Lang[] = ['en', 'zh-Hans', 'zh-Hant', 'ja', 'ko', 'es', 'fr', 'de', 'pt-BR', 'ru']
10
11/** The `language` option's values, as a manifest's `options` lists them. */
12export const LANGUAGE_OPTIONS = ['auto', ...LANGS] as const
13
14// Names a person may write, folded (lowercase, no spaces, `-`, `_` or `.`).
15const NAMES: Record<string, Lang> = {
16 en: 'en', english: 'en', 英语: 'en', 英文: 'en', 英語: 'en',
17 zh: 'zh-Hans', zhcn: 'zh-Hans', zhsg: 'zh-Hans', zhhans: 'zh-Hans', chinese: 'zh-Hans',
18 simplifiedchinese: 'zh-Hans', 中文: 'zh-Hans', 简体中文: 'zh-Hans', 简体: 'zh-Hans', 汉语: 'zh-Hans', 普通话: 'zh-Hans',
19 zhtw: 'zh-Hant', zhhk: 'zh-Hant', zhmo: 'zh-Hant', zhhant: 'zh-Hant', traditionalchinese: 'zh-Hant',
20 繁體中文: 'zh-Hant', 繁体中文: 'zh-Hant', 正體中文: 'zh-Hant', 繁體: 'zh-Hant', 繁体: 'zh-Hant',
21 ja: 'ja', japanese: 'ja', 日本語: 'ja', 日语: 'ja',
22 ko: 'ko', korean: 'ko', 한국어: 'ko', 韩语: 'ko', 韓語: 'ko',
23 es: 'es', spanish: 'es', español: 'es', espanol: 'es', castellano: 'es', 西班牙语: 'es',
24 fr: 'fr', french: 'fr', français: 'fr', francais: 'fr', 法语: 'fr',
25 de: 'de', german: 'de', deutsch: 'de', 德语: 'de',
26 pt: 'pt-BR', ptbr: 'pt-BR', portuguese: 'pt-BR', brazilianportuguese: 'pt-BR', português: 'pt-BR', portugues: 'pt-BR', 葡萄牙语: 'pt-BR',
27 ru: 'ru', russian: 'ru', русский: 'ru', 俄语: 'ru',
28}
29
30const fold = (s: string) => s.trim().toLowerCase().replace(/[\s_.-]/g, '')
31
32/** "简体中文", "zh_TW.UTF-8", "es-MX", "Deutsch" → a language the mods have, else null. */
33export function parseLanguage(text: unknown): Lang | null {
34 if (typeof text !== 'string' || !text.trim()) return null
35 const bare = text.split('.')[0]!.split('@')[0]!
36 const named = NAMES[fold(bare)]
37 if (named) return named
38 // A tag with a region or script this table does not list: its first subtag.
39 const [primary = '', ...rest] = bare.trim().toLowerCase().split(/[-_\s]/)
40 if (primary === 'zh') return rest.some(s => ['tw', 'hk', 'mo', 'hant'].includes(s)) ? 'zh-Hant' : 'zh-Hans'
41 return NAMES[primary] ?? null
42}
43
44/** The language to draw in: the option unless `auto`, then the setting, then the locale. */
45export function resolveLanguage(option: unknown, setting: unknown, locale: readonly (string | undefined)[]): Lang {
46 if (typeof option === 'string' && option !== 'auto' && (LANGS as readonly string[]).includes(option)) return option as Lang
47 return parseLanguage(setting) ?? locale.map(parseLanguage).find(Boolean) ?? 'en'
48}
49
50
51export type Params = Record<string, string | number>
52
53/**
54 * `m(key, params?, lang?)` over a mod's table: the message in the current
55 * language (English when missing), its `{placeholders}` filled.
56 */
57export function createMessages<K extends string>(table: Record<Lang, Record<K, string>>) {
58 let current: Lang = 'en'
59 return {
60 m: (key: K, params: Params = {}, lang: Lang = current): string =>
61 (table[lang][key] ?? table.en[key]).replace(/\{(\w+)\}/g, (_, k: string) => String(params[k] ?? '')),
62 setLang: (lang: Lang): void => {
63 current = lang
64 },
65 lang: (): Lang => current,
66 }
67}
68hooks/kit/prefs.ts 60 lines1// Generated from claude-code/kit/prefs.ts by scripts/sync-kit.sh: edit the source, then re-run it.
2// `/config` is where a mod's settings live: a slash command that changes one
3// (`/ts off`, `/spinner neon`) writes that row as the person would in the menu,
4// so the menu shows it, settings.json keeps it, and the engine reloads the
5// module with it. The command also sets the mod's session state, so the change
6// shows at once, and stays for the session where no row can be written
7// (`claude -p` has no plugin rows).
8//
9// The engine follows `$` only within the hooks module's own file, so the mod
10// hands the kit closures over `$` (a `Prefs`) rather than `$` itself.
11import type { ConfigValue } from 'claude-code'
12
13/** The mod's store and its own `/config` rows, as closures the hooks module builds over `$`. */
14export type Prefs = {
15 kept: (key: string) => Promise<unknown>
16 forget: (key: string) => Promise<void>
17 /** `$.config.set` on `<plugin>.<field>`. */
18 write: (field: string, value: ConfigValue) => Promise<{ deny?: string }>
19}
20
21/** Writes one of the mod's rows; false when no row took it. */
22export async function persist(prefs: Prefs, field: string, value: ConfigValue): Promise<boolean> {
23 const result = await prefs.write(field, value).catch(() => null)
24 return result !== null && result.deny === undefined
25}
26
27/** What a value kept in the store under an older version becomes: a row and its value, or nothing. */
28export type Move = (kept: unknown) => readonly [field: string, value: ConfigValue] | null
29
30/** The rows values older versions kept in `$.store` stand for, which the session applies at once. */
31export async function keptRows(prefs: Prefs, moves: Readonly<Record<string, Move>>): Promise<Record<string, ConfigValue>> {
32 const rows: Record<string, ConfigValue> = {}
33 for (const [key, move] of Object.entries(moves)) {
34 const kept = await prefs.kept(key)
35 const target = kept === undefined ? null : move(kept)
36 if (target) rows[target[0]] = target[1]
37 }
38 return rows
39}
40
41/**
42 * Moves those values to their `/config` rows, once: a key is dropped only
43 * after its row took the value (or it has none to give). Run it after
44 * `session.start`'s `next(e)`: the plugin's rows join `/config` as the
45 * session comes up, and the write brings a reload that applies them.
46 */
47export async function migrateStore(prefs: Prefs, moves: Readonly<Record<string, Move>>): Promise<void> {
48 for (const [key, move] of Object.entries(moves)) {
49 const kept = await prefs.kept(key)
50 if (kept === undefined) continue
51 const target = move(kept)
52 if (!target || (await persist(prefs, target[0], target[1]))) await prefs.forget(key)
53 }
54}
55
56/** `off` → true, `on` → false, anything else flips `isOff`. */
57export function switchArg(arg: string, isOff: boolean): boolean {
58 return arg === 'off' ? true : arg === 'on' ? false : !isOff
59}
60hooks/pet.ts 61 lines1// What the companion knows and says: its level, its bubble, and the labels of
2// the turn it watches.
3import { m } from './i18n'
4import { textWidth } from './themes'
5import type { Act, Finale, Mood } from './themes'
6
7/** `12s`, `3m 05s`, `1h 02m`. */
8export function formatDuration(ms: number): string {
9 const s = Math.max(0, Math.round(ms / 1000))
10 if (s < 60) return `${s}s`
11 if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
12 return `${Math.floor(s / 3600)}h ${String(Math.floor(s / 60) % 60).padStart(2, '0')}m`
13}
14
15/** Lv.1 at 0 xp, Lv.2 at 2, Lv.3 at 8, Lv.4 at 18: one xp a finished turn. */
16export function levelOf(xp: number): number {
17 return Math.floor(Math.sqrt(Math.max(0, xp) / 2)) + 1
18}
19
20/** What a tool call is about, short: `Bash: npm test`, `Edit: themes.ts`, `WebSearch`. */
21export function toolLabel(e: { tool: string } & Record<string, unknown>): string {
22 const clip = (s: string) => (textWidth(s) > 32 ? `${Array.from(s).slice(0, 31).join('')}…` : s)
23 if (typeof e.command === 'string') return clip(`${e.tool}: ${e.command.split('\n')[0]!.trim()}`)
24 const path = [e.file_path, e.notebook_path, e.path].find(p => typeof p === 'string') as string | undefined
25 if (path) return clip(`${e.tool}: ${path.split('/').pop()}`)
26 if (typeof e.pattern === 'string') return clip(`${e.tool}: ${e.pattern}`)
27 // A subagent: what it was sent to do.
28 if (typeof e.description === 'string' && e.description.trim()) return clip(`${e.tool}: ${e.description.trim()}`)
29 return clip(e.tool.replace(/^mcp__[^_]+__/, ''))
30}
31
32/** What the pet makes of a finished shell command: tests that ran, a commit. */
33export type News = 'testPass' | 'testFail' | 'commit'
34
35// A test runner at the head of a command or after `&&`, `;`, `|`, `(`: npm test, pytest, go test…
36const TEST = /(?:^|[;&|(]\s*)(?:\S+=\S*\s+)*(?:(?:npm|pnpm|yarn|bun|deno)\s+(?:run\s+)?test\b|npx\s+(?:jest|vitest|mocha|playwright\s+test)\b|(?:jest|vitest|mocha|pytest|rspec|phpunit|tox|nox)\b|python3?\s+-m\s+(?:pytest|unittest)\b|(?:go|cargo|swift|dotnet|mix|mvn|gradle|\.\/gradlew|make|zig)\s+test\b|claude\s+plugin\s+test\b)/
37const COMMIT = /(?:^|[;&|(]\s*)git\s+(?:-\S+\s+(?:\S+\s+)?)*commit\b/
38
39export function newsOf(command: string, isError: boolean): News | null {
40 if (TEST.test(command)) return isError ? 'testFail' : 'testPass'
41 if (COMMIT.test(command) && !isError && !/--dry-run/.test(command)) return 'commit'
42 return null
43}
44
45/** The bubble for the calls running now: subagents side by side are counted, else the latest call. */
46export function busyLabel(labels: readonly string[]): string | undefined {
47 const agents = labels.filter(label => /^(?:Agent|Task)\b/.test(label))
48 return agents.length > 1 ? `Agent ×${agents.length}` : labels[labels.length - 1]
49}
50
51export function finaleOf(reason: string): Finale {
52 return reason === 'answer' ? 'answer' : reason === 'aborted' ? 'aborted' : 'error'
53}
54
55/** The bubble says what the engine's spinner line does not: the tool, a prompt waiting, how the turn ended. */
56export function bubbleOf(state: Act | Mood, tool: string | undefined): string {
57 if (state === 'tool') return tool ?? ''
58 if (state === 'think' || state === 'say' || state === 'wait') return ''
59 return m(`pet.${state}`)
60}
61hooks/themes.ts 694 lines1// Frame tables and scenes, shared by the hooks module and both surface modules
2// (sprite.tsx draws the spinner's mascot, stage.tsx the band). Pure:
3// everything here is a function of the theme, the tick and the width.
4import { blank, frame, hsl, mod, noise, padTo, put, textWidth } from './cells'
5import type { Grid, Style } from './cells'
6import { SCENE_ROWS, bluecatScene, chompScene, clawdScene, nyanScene, sparkyScene, thunderScene } from './scenes'
7import { AUDIO_BANDS, demoView } from './audio'
8import type { AudioFeed } from './audio'
9
10export * from './cells'
11
12export const THEME_NAMES = [
13 'clawd',
14 'thunder',
15 'chomp',
16 'sparky',
17 'bluecat',
18 'nyan',
19 'cat',
20 'bunny',
21 'sakura',
22 'mecha',
23 'neon',
24 'dino',
25 'ocean',
26 'matrix',
27 'audio',
28] as const
29export type ThemeName = (typeof THEME_NAMES)[number]
30/** Themes that start a process of their own: chosen by name, never drawn at random. */
31const OPT_IN: readonly ThemeName[] = ['audio']
32
33export type Mode = 'requesting' | 'responding' | 'thinking' | 'tool-input' | 'tool-use'
34export type Pose = 'think' | 'tool' | 'say' | 'wait'
35/** What the turn is doing, as the band's companion tells it. */
36export type Act = 'think' | 'tool' | 'ask' | 'say' | 'wait'
37export type Finale = 'answer' | 'aborted' | 'error'
38/** The companion's mood between turns. */
39export type Mood = 'hello' | 'ready' | 'aborted' | 'error' | 'sleep'
40
41export type Theme = {
42 name: ThemeName
43 /** The mascot's color, and a second one for its props. */
44 color: string
45 accent: string
46 /** Frames of the mascot beside the spinner's word, by what the turn is doing. */
47 sprite: Record<Pose, string[]>
48 /** Each character of the mascot in its own color, cycling. */
49 isRainbow?: boolean
50 happy: string
51 sad: string
52 dead: string
53 sleep: string
54 /** Particles of the finale's burst, and their colors. */
55 confetti: string[]
56 palette: string[]
57 /** Rows the scene takes in the band. */
58 rows: number
59 /** The band's scene while a turn runs: `rows` rows of `w` cells; `audio` feeds the audio theme. */
60 scene: (t: number, w: number, act: Act, audio?: AudioFeed) => Grid
61}
62
63/** Milliseconds per frame: the mascot's, the band's, the companion's between turns. */
64export const SPRITE_MS = 140
65export const STAGE_MS = 100
66export const IDLE_MS = 400
67/** How long the finale stays after a turn ends. */
68export const FINALE_MS = 3000
69
70export function poseOf(mode: string): Pose {
71 if (mode === 'thinking' || mode === 'think') return 'think'
72 if (mode === 'tool-use' || mode === 'tool-input' || mode === 'tool') return 'tool'
73 if (mode === 'responding' || mode === 'say') return 'say'
74 return 'wait'
75}
76
77export function isThemeName(name: unknown): name is ThemeName {
78 return typeof name === 'string' && (THEME_NAMES as readonly string[]).includes(name)
79}
80
81/** k-mods 수정: `random`이 고르는 테마 */
82export const RANDOM_POOL: ThemeName[] = ['nyan', 'clawd', 'thunder', 'chomp']
83
84/** A theme drawn from `seed`, for the `random` choice. */
85export function pickRandom(seed: number): ThemeName {
86 // k-mods 수정: 무작위는 좁은 터미널·한국어 환경에서 알아보기 쉬운 4종에서만 고른다 (원본은 audio를 뺀 14종)
87 const pool = RANDOM_POOL
88 return pool[Math.floor(noise(seed) * pool.length)]!
89}
90
91// ---- scenes --------------------------------------------------------------
92
93function catScene(t: number, w: number): Grid {
94 const g = blank(w, 2)
95 for (let x = 0; x < w; x++) {
96 const n = noise(x + 1000 * Math.floor((x + t) / w))
97 if (mod(x + t, 7) === 0) put(g, x, 1, n < 0.5 ? '˙' : ',', { c: '#7fb069', d: true })
98 }
99 const lap = w + 8
100 const cx = mod(Math.floor(t * 0.6), lap) - 6
101 put(g, cx, 1, 'ᓚᘏᗢ', { c: frame(['#ffb3c6', '#ffc8dd'], t >> 1), b: true })
102 put(g, cx + 5, 1, frame(['◐', '◓', '◑', '◒'], t), { c: '#ffd166' })
103 // Hearts float up behind the cat and fade.
104 for (let i = 0; i < 3; i++) {
105 const age = mod(t + i * 5, 15)
106 if (age < 10) put(g, cx - 2 - Math.floor(age / 3), 0, age < 6 ? '♡' : '·', { c: '#ff8fab', d: age >= 6 })
107 }
108 if (mod(t, 40) < 20) put(g, cx + 3, 0, frame(['♪', '♫'], t >> 2), { c: '#cdb4db' })
109 return g
110}
111
112function bunnyScene(t: number, w: number): Grid {
113 const g = blank(w, 2)
114 for (let x = 0; x < w; x++) {
115 if (mod(x * 7 + 3, 11) === 0) put(g, x, 1, '"', { c: '#80b918', d: true })
116 }
117 const lap = w + 11
118 const bx = mod(Math.floor(t * 0.5), lap) - 11
119 // Carrots ahead of the bunny; the ones it has passed are eaten.
120 for (let x = 6; x < w; x += 13) {
121 if (x > bx + 9 || bx < -6) put(g, x, 1, 'ɣ', { c: '#ff9f1c', b: true })
122 }
123 const isUp = mod(t, 6) < 3
124 put(g, bx, isUp ? 0 : 1, isUp ? '૮ ˶ᵔ ᵕ ᵔ˶ ა' : '૮ ˶• ᴗ •˶ ა', { c: '#f8f9fa', b: true })
125 if (!isUp) put(g, bx - 2, 1, '⁎', { c: '#adb5bd', d: true })
126 return g
127}
128
129function sakuraScene(t: number, w: number): Grid {
130 const g = blank(w, 2)
131 const pinks = ['#ffb7c5', '#ff8fab', '#fb6f92', '#ffc2d1', '#ffe5ec']
132 const count = Math.max(6, Math.floor(w / 5))
133 const gust = Math.sin(t * 0.05) * 0.4
134 for (let i = 0; i < count; i++) {
135 const speed = 0.25 + noise(i) * 0.45 + gust
136 const x = mod(noise(i + 7) * w - t * speed + Math.sin(t * 0.2 + i) * 1.5, w)
137 const y = mod(Math.floor(t * (0.05 + noise(i + 3) * 0.08) + i), 2)
138 put(g, x, y, frame(['✿', '❀', '❁', '✾', '·'], i + (t >> 3)), { c: frame(pinks, i) })
139 }
140 const walker = mod(Math.floor(t * 0.2), w + 12) - 10
141 put(g, walker, 1, frame(['(˶ᵔ ᵕ ᵔ˶)', '(˶ᵔ ᵕ ᵔ˶)~'], t >> 3), { c: '#ffafcc', b: true })
142 return g
143}
144
145function mechaScene(t: number, w: number): Grid {
146 const g = blank(w, 2)
147 const cycle = 36
148 const phase = mod(t, cycle)
149 const score = Math.floor(t / cycle)
150 const rx = 2
151 put(g, rx - 2, 1, frame(['≋', '≈', '∼'], t), { c: frame(['#ff9f1c', '#ffbf69', '#ff595e'], t) })
152 put(g, rx, 1, frame(['[◉_◉]', '[◉_◉]', '[◎_◎]'], t >> 1), { c: '#4cc9f0', b: true })
153 const near = rx + 9
154 const tx = Math.max(near, Math.round(w - 2 - phase * 2))
155 if (tx > near) {
156 put(g, tx, 1, frame(['◇', '◆'], t >> 1), { c: '#f72585' })
157 } else {
158 const boom = phase - Math.ceil((w - 2 - near) / 2)
159 if (boom < 3) put(g, rx + 5, 1, '━'.repeat(Math.max(0, tx - rx - 5)), { c: '#4cc9f0', b: true })
160 if (boom >= 2 && boom < 8) put(g, tx - (boom > 4 ? 1 : 0), 1, boom > 4 ? '· ✶ ·' : '✶', { c: '#ffd60a', b: true })
161 }
162 // A radar sweep and a target counter across the top.
163 const sweep = mod(t, Math.max(1, w - 14))
164 put(g, 0, 0, '▕', { c: '#4361ee', d: true })
165 for (let x = 1; x < w - 13; x++) {
166 const dist = sweep - x
167 if (dist >= 0 && dist < 6) put(g, x, 0, '▔▁▂▃▅▇'[5 - dist]!, { c: '#4cc9f0', d: dist > 2 })
168 }
169 put(g, w - 12, 0, `TGT ${String(score).padStart(4, '0')}`, { c: '#4361ee', b: true })
170 return g
171}
172
173function neonScene(t: number, w: number): Grid {
174 const g = blank(w, 2)
175 const blocks = ' ▁▂▃▄▅▆▇█'
176 for (let x = 0; x < w; x++) {
177 const v = (Math.sin(x * 0.33 + t * 0.35) + Math.sin(x * 0.11 - t * 0.21) + 2) / 4
178 const h = Math.round(v * 16)
179 const c = hsl(mod(x * 7 + t * 9, 360), 0.95, 0.62)
180 put(g, x, 1, blocks[Math.min(8, h)]!, { c })
181 if (h > 8) put(g, x, 0, blocks[h - 8]!, { c })
182 }
183 return g
184}
185
186function dinoScene(t: number, w: number): Grid {
187 const g = blank(w, 2)
188 const rx = 4
189 const body = 'ᕕ(ᐛ)ᕗ'
190 for (let x = 0; x < w; x++) {
191 if (mod(x + t, 9) === 0) put(g, x, 1, '_', { c: '#6c757d', d: true })
192 }
193 for (let x = 0; x < w; x += 23) {
194 put(g, mod(x - Math.floor(t * 0.3), w + 6) - 3, 0, '~~', { c: '#adb5bd', d: true })
195 }
196 // A cactus every 20 cells or so; the runner jumps the ones in reach.
197 let isAirborne = false
198 const first = Math.floor((t - rx - 10) / 20)
199 for (let k = Math.max(0, first); k * 20 < t + w; k++) {
200 const x = k * 20 + Math.floor(noise(k) * 10) + 30 - t
201 if (x < 0 || x >= w) continue
202 put(g, x, 1, 'ψ', { c: '#2a9d8f', b: true })
203 if (x >= rx - 1 && x <= rx + textWidth(body) + 1) isAirborne = true
204 }
205 put(g, rx, isAirborne ? 0 : 1, isAirborne ? 'ᕕ(ᐛ)ᕗ' : frame(['ᕕ(ᐛ)ᕗ', 'ᕕ( ᐛ)ᕗ'], t >> 1), { c: '#e9c46a', b: true })
206 put(g, w - 9, 0, `HI ${String(t).padStart(5, '0')}`, { c: '#adb5bd' })
207 return g
208}
209
210function oceanScene(t: number, w: number): Grid {
211 const g = blank(w, 2)
212 for (let x = 0; x < w; x++) {
213 put(g, x, 0, mod(x + Math.floor(t / 2), 6) < 3 ? '~' : '≈', { c: '#48cae4', d: mod(x - t, 5) !== 0 })
214 }
215 const fx = mod(Math.floor(t * 0.5), w + 10) - 8
216 put(g, fx, 1, frame(['><(((º>', '><(((°>'], t >> 2), { c: '#ffb703', b: true })
217 const sx = w - 1 - mod(Math.floor(t * 0.8), w + 5)
218 put(g, sx, 1, '<><', { c: '#fb8500' })
219 for (let i = 0; i < 3; i++) {
220 const age = mod(t + i * 4, 12)
221 const bx = fx + 8 + (i % 2)
222 if (age < 4) put(g, bx, 1, 'o', { c: '#caf0f8', d: true })
223 else if (age < 8) put(g, bx + 1, 0, '°', { c: '#caf0f8' })
224 }
225 return g
226}
227
228function matrixScene(t: number, w: number): Grid {
229 const g = blank(w, 2)
230 const glyphs = 'アイウエオカキクケコサシスセソタチツテト0123456789ハヒフヘホ'
231 for (let x = 0; x < w; x++) {
232 const speed = 1 + Math.floor(noise(x) * 3)
233 const head = mod(Math.floor(t / speed) + Math.floor(noise(x + 50) * 20), 14)
234 for (let y = 0; y < 2; y++) {
235 const age = head - y
236 if (age < 0 || age > 6) continue
237 const ch = glyphs[Math.floor(noise(x * 31 + y + Math.floor(t / 3)) * glyphs.length)]!
238 put(g, x, y, ch, age === 0 ? { c: '#d8f3dc', b: true } : { c: age < 3 ? '#52b788' : '#2d6a4f', d: age >= 3 })
239 }
240 }
241 return g
242}
243
244/**
245 * The computer's sound as a spectrum: a bar per band, its peak falling above
246 * it, and a dancer on the left who moves on the beat. Without a tap (`null`)
247 * the bars lie flat and say so; with no feed at all, a made-up signal plays.
248 */
249function audioScene(t: number, w: number, act: Act, audio?: AudioFeed): Grid {
250 const rows = SCENE_ROWS
251 const g = blank(w, rows)
252 const view = audio === undefined ? demoView(t) : audio
253 const isLive = view !== null && view.b.some(v => v > 0)
254
255 // The dancer: on the beat while sound plays, else as the turn goes.
256 const hasDancer = w >= 32
257 const x0 = hasDancer ? 13 : 0
258 if (hasDancer) {
259 const beat = view?.beat ?? 0
260 const pose =
261 view === null
262 ? '(-ω-) zZ'
263 : isLive
264 ? frame(['♪┏(・o・)┛', '♫┗(・o・)┓'], beat)
265 : act === 'ask'
266 ? '(・ω・)?'
267 : act === 'tool'
268 ? frame(['(・ω・)ノ', '(・ω・)ノ'], t >> 2)
269 : frame(['(・ω・) ', '(・ω・).'], t >> 3)
270 put(g, 0, rows - 1, pose, { c: '#e0aaff', b: true })
271 if (isLive) {
272 // Notes float up from the dancer, one per beat.
273 for (let i = 0; i < 3; i++) {
274 const age = mod(t + i * 4, 12)
275 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 })
276 }
277 }
278 }
279
280 const span = w - x0
281 if (view === null) {
282 for (let x = 0; x < span; x += 2) put(g, x0 + x, rows - 1, '·', { c: '#6c757d', d: true })
283 return g
284 }
285 // Wide enough: bars two cells wide with a gap; narrower, one band per column.
286 const per = span / AUDIO_BANDS
287 const blocks = ' ▁▂▃▄▅▆▇█'
288 const full = rows * 8
289 for (let x = 0; x < span; x++) {
290 const band = Math.min(AUDIO_BANDS - 1, Math.floor(x / per))
291 if (per >= 3 && x - Math.floor(band * per) >= Math.floor(per) - 1) continue
292 const h = Math.round(((view.b[band] ?? 0) * full) / 99)
293 const peak = Math.round(((view.p[band] ?? 0) * full) / 99)
294 const hue = 290 - (band / AUDIO_BANDS) * 200
295 if (h === 0 && peak === 0) {
296 put(g, x0 + x, rows - 1, '▁', { c: '#3c3c44' })
297 continue
298 }
299 for (let r = 0; r < rows; r++) {
300 const y = rows - 1 - r
301 const fill = Math.max(0, Math.min(8, h - r * 8))
302 if (fill > 0) put(g, x0 + x, y, blocks[fill]!, { c: hsl(hue, 0.85, 0.5 + r * 0.06), b: r === rows - 1 })
303 }
304 // The peak's cap, where nothing of the bar is.
305 const pr = Math.min(rows - 1, Math.floor(Math.max(0, peak - 1) / 8))
306 if (peak > h && peak - pr * 8 > Math.max(0, h - pr * 8)) put(g, x0 + x, rows - 1 - pr, '▔', { c: '#f8f9fa', d: true })
307 }
308 return g
309}
310
311// ---- the newer scenes: Claude's mascot and pixel art --------------------
312
313
314const CLAUDE = '#d77757'
315const RAINBOW = ['#ff0000', '#ff9900', '#ffff00', '#33ff00', '#0099ff', '#6633ff']
316
317// ---- themes --------------------------------------------------------------
318
319export const THEMES: Record<ThemeName, Theme> = {
320 cat: {
321 name: 'cat',
322 rows: 2,
323 sleep: '(=-ω-=) zZ',
324 color: '#ffb3c6',
325 accent: '#ffd166',
326 sprite: {
327 think: ['(=^・ω・^=) ', '(=^・ω・^=) ? ', '(=^-ω-^=) ? ', '(=^・ω・^=) ?!'],
328 tool: ['ฅ(=^・ω・^=) ', ' (=^・ω・^=)ฅ', 'ฅ(=^・ω・^=)ฅ'],
329 say: ['(=^・ω・^=)ノ ♪', '(=^・ω・^=)ノ ♫', '(=^▽^=)ノ ♪'],
330 wait: ['(=^・ω・^=) . ', '(=^・ω・^=) .. ', '(=^・ω・^=) ...'],
331 },
332 happy: '(=^▽^=)ノ',
333 sad: '(=;ω;=)',
334 dead: '(=×ω×=)',
335 confetti: ['✿', '♡', '*', '·', '✦'],
336 palette: ['#ffb3c6', '#ffd166', '#cdb4db', '#a2d2ff', '#ff8fab'],
337 scene: catScene,
338 },
339 bunny: {
340 name: 'bunny',
341 rows: 2,
342 sleep: '૮ ˶- ᴗ -˶ ა zZ',
343 color: '#f1faee',
344 accent: '#ff9f1c',
345 sprite: {
346 think: ['૮ ˶• ᴗ •˶ ა ', '૮ ˶- ᴗ -˶ ა ?', '૮ ˶• ᴗ •˶ ა ?'],
347 tool: ['૮ ˶ᵔ ᵕ ᵔ˶ ა ɣ', '૮ ˶ᵔ ᵕ ᵔ˶ აɣ ', '૮ ˶ᵔ ᵕ ᵔ˶ ა '],
348 say: ['૮ ˶ᵔ ᵕ ᵔ˶ ა ♪', '૮ ˶ᵔ ᵕ ᵔ˶ ა ♫'],
349 wait: ['૮ ˶• ᴗ •˶ ა . ', '૮ ˶• ᴗ •˶ ა .. ', '૮ ˶• ᴗ •˶ ა ...'],
350 },
351 happy: '૮ ˶ᵔ ᵕ ᵔ˶ ა',
352 sad: '૮ ˶; ﹏ ;˶ ა',
353 dead: '૮ ˶x ﹏ x˶ ა',
354 confetti: ['ɣ', '*', '·', '✦', '"'],
355 palette: ['#ff9f1c', '#80b918', '#f1faee', '#ffbf69'],
356 scene: bunnyScene,
357 },
358 sakura: {
359 name: 'sakura',
360 rows: 2,
361 sleep: '(˶- ᵕ -˶) zZ',
362 color: '#ff8fab',
363 accent: '#ffc2d1',
364 sprite: {
365 think: ['✿ (˶ᵔ ᵕ ᵔ˶)', '❀ (˶ᵔ ᵕ ᵔ˶)?', '❁ (˶- ᵕ -˶)?'],
366 tool: ['✿ (˶•ᴗ•˶)و ', '❀ (˶•ᴗ•˶)و✧'],
367 say: ['✿ (˶ᵔ ᵕ ᵔ˶) ♪', '❀ (˶ᵔ ᵕ ᵔ˶) ♫'],
368 wait: ['✿ (˶ᵔ ᵕ ᵔ˶) . ', '❀ (˶ᵔ ᵕ ᵔ˶) .. ', '❁ (˶ᵔ ᵕ ᵔ˶) ...'],
369 },
370 happy: '(˶ᵔ ᵕ ᵔ˶)✿',
371 sad: '(˶; ᴗ ;˶)',
372 dead: '(˶x ᴗ x˶)',
373 confetti: ['✿', '❀', '❁', '✾', '·'],
374 palette: ['#ffb7c5', '#ff8fab', '#fb6f92', '#ffe5ec'],
375 scene: sakuraScene,
376 },
377 mecha: {
378 name: 'mecha',
379 rows: 2,
380 sleep: '[-_-] zZ',
381 color: '#4cc9f0',
382 accent: '#f72585',
383 sprite: {
384 think: ['[◉_◉] ▖', '[◉_◉] ▘', '[◉_◉] ▝', '[◉_◉] ▗'],
385 tool: ['[◉_◉]⊃━ ', '[◉_◉]⊃━━ ', '[◉_◉]⊃━━━☆'],
386 say: ['[◕‿◕] ▸ ', '[◕‿◕] ▸▸ ', '[◕‿◕] ▸▸▸'],
387 wait: ['[○_○] · ', '[◎_◎] ·· ', '[◉_◉] ···'],
388 },
389 happy: '[◕‿◕]b',
390 sad: '[╥_╥]',
391 dead: '[×_×]',
392 confetti: ['✶', '✦', '+', '·', '◆'],
393 palette: ['#4cc9f0', '#f72585', '#ffd60a', '#4361ee'],
394 scene: mechaScene,
395 },
396 neon: {
397 name: 'neon',
398 rows: 2,
399 sleep: '◢◤ zZ',
400 color: '#f72585',
401 accent: '#4cc9f0',
402 isRainbow: true,
403 sprite: {
404 think: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'].map(c => `${c} ◢◤◢◤`),
405 tool: ['▰▱▱▱▱', '▰▰▱▱▱', '▱▰▰▱▱', '▱▱▰▰▱', '▱▱▱▰▰', '▱▱▱▱▰'],
406 say: ['▁▃▅▇▅▃', '▃▅▇▅▃▁', '▅▇▅▃▁▃', '▇▅▃▁▃▅', '▅▃▁▃▅▇', '▃▁▃▅▇▅'],
407 wait: ['◐ ◆', '◓ ◇', '◑ ◆', '◒ ◇'],
408 },
409 happy: '✦ ◢◤ ✦',
410 sad: '▁▁▁',
411 dead: '✕ ◢◤ ✕',
412 confetti: ['✦', '✧', '+', '·', '◆'],
413 palette: ['#f72585', '#b5179e', '#7209b7', '#4cc9f0', '#ffd60a'],
414 scene: neonScene,
415 },
416 dino: {
417 name: 'dino',
418 rows: 2,
419 sleep: 'ᕕ(-ᐛ-)ᕗ zZ',
420 color: '#e9c46a',
421 accent: '#2a9d8f',
422 sprite: {
423 think: ['ᕕ(ᐛ)ᕗ ', 'ᕕ(ᐛ)ᕗ ? ', 'ᕕ( ᐛ)ᕗ ?'],
424 tool: ['ᕕ(ᐛ)ᕗ ψ', 'ᕕ(ᐛ)ᕗ ψ ', ' ᕕ(ᐛ)ᕗψ '],
425 say: ['ᕕ(ᐛ)ᕗ ε=', 'ᕕ(ᐛ)ᕗε=ε='],
426 wait: ['ᕕ(ᐛ)ᕗ . ', 'ᕕ(ᐛ)ᕗ .. ', 'ᕕ(ᐛ)ᕗ ...'],
427 },
428 happy: 'ヽ(ᐛ)ノ',
429 sad: '(ᐛ;)',
430 dead: '(×ᐛ×)',
431 confetti: ['ψ', '*', '+', '·', '✦'],
432 palette: ['#e9c46a', '#2a9d8f', '#f4a261', '#e76f51'],
433 scene: dinoScene,
434 },
435 ocean: {
436 name: 'ocean',
437 rows: 2,
438 sleep: '><(((-> zZ',
439 color: '#ffb703',
440 accent: '#48cae4',
441 sprite: {
442 think: ['><(((º> ', '><(((º> o ', '><(((º> °'],
443 tool: ['><(((º>~ ', '><(((º>≈≈ ', '><(((º>~≈~'],
444 say: ['><(((º> ♪', '><(((º> ♫'],
445 wait: ['><(((º> . ', '><(((º> .. ', '><(((º> ...'],
446 },
447 happy: '><(((º> ♪',
448 sad: '><(((;>',
449 dead: '><(((×>',
450 confetti: ['°', 'o', '~', '≈', '·'],
451 palette: ['#48cae4', '#caf0f8', '#ffb703', '#fb8500'],
452 scene: oceanScene,
453 },
454 matrix: {
455 name: 'matrix',
456 rows: 2,
457 sleep: '⟦IDLE⟧',
458 color: '#52b788',
459 accent: '#d8f3dc',
460 sprite: {
461 think: ['ア1ウ0', '1ウ0カ', 'ウ0カ1', '0カ1ア'].map(s => `⟦${s}⟧`),
462 tool: ['⟦▓▒░ ⟧', '⟦ ▓▒░⟧', '⟦░ ▓▒⟧', '⟦▒░ ▓⟧'],
463 say: ['⟦>_ ⟧', '⟦>_▌ ⟧', '⟦>__▌⟧'],
464 wait: ['⟦· ⟧', '⟦·· ⟧', '⟦··· ⟧'],
465 },
466 happy: '⟦ OK ⟧',
467 sad: '⟦ ^C ⟧',
468 dead: '⟦ERR!⟧',
469 confetti: ['ア', '0', '1', 'ハ', '·'],
470 palette: ['#52b788', '#d8f3dc', '#2d6a4f', '#95d5b2'],
471 scene: matrixScene,
472 }, clawd: {
473 name: 'clawd',
474 rows: SCENE_ROWS,
475 color: CLAUDE,
476 accent: '#e9b49a',
477 sprite: {
478 think: ['·', '✢', '✳', '✶', '✻', '✽', '✻', '✶', '✳', '✢'].map(s => `▐▛█▜▌ ${s}`),
479 tool: ['▐▛█▜▌▭▭', '▐▛█▜▌▬▭', '▐▛█▜▌▭▬'],
480 say: ['▐▛█▜▌ ✎ ', '▐▛█▜▌ ✎· ', '▐▛█▜▌ ✎··'],
481 wait: ['▐▛█▜▌ . ', '▐▛█▜▌ .. ', '▐▛█▜▌ ...'],
482 },
483 happy: '▐▛█▜▌ ✻',
484 sad: '▐▀█▀▌',
485 dead: '▐x█x▌',
486 sleep: '▐▄█▄▌ zZ',
487 confetti: ['✻', '✶', '✳', '·', '✢'],
488 palette: [CLAUDE, '#e9b49a', '#f5e6d3', '#c15f3c'],
489 scene: clawdScene,
490 },
491 thunder: {
492 name: 'thunder',
493 rows: SCENE_ROWS,
494 color: '#4cc9f0',
495 accent: '#ffd60a',
496 sprite: {
497 think: ['=▷ · ', '=▷ · ', '=▷ · ', '=▷ · '],
498 tool: ['=▷━ ◆', '=▷ ━ ◆', '=▷ ━ ◆', '=▷ ✶'],
499 say: ['=▷ ⁘ ⁘ ', '=▷⁘ ⁘ ⁘'],
500 wait: ['=▷ . ', '=▷ .. ', '=▷ ...'],
501 },
502 happy: '=▷ ✶✶✶',
503 sad: '=▷ ...',
504 dead: '=▷ ✕',
505 sleep: '=▷ zZ',
506 confetti: ['✶', '✦', '+', '·', '◆'],
507 palette: ['#4cc9f0', '#ffd60a', '#f72585', '#ff9f1c'],
508 scene: thunderScene,
509 },
510 chomp: {
511 name: 'chomp',
512 rows: SCENE_ROWS,
513 color: '#ffd60a',
514 accent: '#ff595e',
515 sprite: {
516 think: ['ᗧ • • •', '● • • •'],
517 tool: ['ᗧ••• ᗣ', '●•• ᗣ ', 'ᗧ• ᗣ ', '● ᗣ '],
518 say: ['ᗧ ♪ ', '● ♫'],
519 wait: ['ᗧ . ', '● .. ', 'ᗧ ...'],
520 },
521 happy: 'ᗧ ᗣᗣᗣ',
522 sad: 'ᗣ ᗧ',
523 dead: '✕ᗧ✕',
524 sleep: 'ᗧ zZ',
525 confetti: ['•', '●', '·', 'ᗣ', '+'],
526 palette: ['#ffd60a', '#ff595e', '#ffafcc', '#00f5d4', '#ff9f1c'],
527 scene: chompScene,
528 },
529 sparky: {
530 name: 'sparky',
531 rows: SCENE_ROWS,
532 color: '#ffd60a',
533 accent: '#e63946',
534 sprite: {
535 think: ['ϟ(•ᴥ•) ', 'ϟ(•ᴥ•) ?', ' (•ᴥ•)ϟ?'],
536 tool: ['ϟϟ(>ᴥ<)ϟϟ', ' ϟ(>ᴥ<)ϟ '],
537 say: ['(•ᴥ•)ノϟ ', '(•ᴥ•)ノ ϟ'],
538 wait: ['(•ᴥ•) . ', '(•ᴥ•) .. ', '(•ᴥ•) ...'],
539 },
540 happy: 'ϟ(^ᴥ^)ϟ',
541 sad: '(;ᴥ;)',
542 dead: '(×ᴥ×)',
543 sleep: '(-ᴥ-) zZ',
544 confetti: ['ϟ', '✦', '*', '·', '+'],
545 palette: ['#ffd60a', '#e63946', '#fff3b0', '#c68b59'],
546 scene: sparkyScene,
547 },
548 bluecat: {
549 name: 'bluecat',
550 rows: SCENE_ROWS,
551 color: '#00b4d8',
552 accent: '#e63946',
553 sprite: {
554 think: ['⊂(◉‿◉)つ ', '⊂(◉‿◉)つ ?', '⊂(◔‿◔)つ ?'],
555 tool: ['⊂(◉‿◉)つ✦ ', '⊂(◉‿◉)つ ✧ ', '⊂(◉‿◉)つ ✦'],
556 say: ['⊂(◕‿◕)つ ♪', '⊂(◕‿◕)つ ♫'],
557 wait: ['⊂(◉‿◉)つ . ', '⊂(◉‿◉)つ .. ', '⊂(◉‿◉)つ ...'],
558 },
559 happy: '⊂(◕‿◕)つ',
560 sad: '⊂(╥﹏╥)つ',
561 dead: '⊂(×﹏×)つ',
562 sleep: '⊂(-‿-)つ zZ',
563 confetti: ['✦', '✧', '●', '·', '+'],
564 palette: ['#00b4d8', '#e63946', '#ffd60a', '#ffffff'],
565 scene: bluecatScene,
566 },
567 nyan: {
568 name: 'nyan',
569 rows: SCENE_ROWS,
570 color: '#ff99cc',
571 accent: '#999999',
572 isRainbow: true,
573 sprite: {
574 think: ['~=[,,_,,]:3', '=~[,,_,,]:3'],
575 tool: ['~=[,,_,,]:3 *', '=~[,,_,,]:3 +'],
576 say: ['~=[,,o,,]:3', '=~[,,_,,]:3'],
577 wait: ['~=[,,_,,]:3 . ', '=~[,,_,,]:3 .. ', '~=[,,_,,]:3 ...'],
578 },
579 happy: '~=[,,^,,]:3',
580 sad: '~=[,,;,,]:3',
581 dead: '~=[,,x,,]:3',
582 sleep: '~=[,,-,,]:3 zZ',
583 confetti: ['*', '+', '·', '✦', '✧'],
584 palette: RAINBOW,
585 scene: nyanScene,
586 },
587 audio: {
588 name: 'audio',
589 rows: SCENE_ROWS,
590 color: '#c77dff',
591 accent: '#4cc9f0',
592 sprite: {
593 think: ['♪(・ω・) ', '♪(・ω・) ?', '♫(・ω・) ?'],
594 tool: ['♪┏(・o・)┛', '♫┗(・o・)┓'],
595 say: ['(・▽・)ノ♪ ', '(・▽・)ノ ♫'],
596 wait: ['(・ω・) . ', '(・ω・) .. ', '(・ω・) ...'],
597 },
598 happy: '♪ヽ(・▽・)ノ♫',
599 sad: '(・へ・)',
600 dead: '(×_×)',
601 sleep: '(-ω-) zZ',
602 confetti: ['♪', '♫', '♬', '·', '✦'],
603 palette: ['#c77dff', '#4cc9f0', '#ff8fab', '#ffd60a'],
604 scene: audioScene,
605 },
606}
607
608export function themeOf(name: unknown): Theme {
609 return isThemeName(name) ? THEMES[name] : THEMES.clawd
610}
611
612// ---- finale --------------------------------------------------------------
613
614/** After a turn: a burst of the theme's confetti around its mascot and the label. */
615export function finaleScene(theme: Theme, kind: Finale, label: string, t: number, w: number): Grid {
616 const rows = theme.rows
617 const g = blank(w, rows)
618 const face = kind === 'answer' ? theme.happy : kind === 'aborted' ? theme.sad : theme.dead
619 const text = `${face} ${label}`
620 const tw = textWidth(text)
621 const cx = Math.floor(w / 2)
622 const left = Math.max(0, cx - Math.floor(tw / 2))
623 const mid = Math.floor(rows / 2)
624
625 if (kind === 'answer') {
626 const count = Math.min(40 * rows / 2, Math.max(12, Math.floor((w / 3) * rows / 2)))
627 for (let i = 0; i < count; i++) {
628 const dir = noise(i) < 0.5 ? -1 : 1
629 const speed = 0.8 + noise(i + 9) * 2.2
630 const reach = Math.min(t, 18) * speed
631 const x = cx + dir * (tw / 2 + reach + noise(i + 4) * 3)
632 const y = Math.floor(noise(i + 2) * rows)
633 const isFading = t > 18 + noise(i + 6) * 8
634 put(g, x, y, frame(theme.confetti, i + (t >> 2)), { c: frame(theme.palette, i), d: isFading })
635 }
636 } else if (kind === 'aborted') {
637 for (let x = 0; x < w; x += 4) put(g, mod(x + (t >> 1), w), mod(x, rows), '·', { c: '#6c757d', d: true })
638 } else {
639 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 })
640 }
641
642 const style: Style = kind === 'answer' ? { c: theme.color, b: true } : kind === 'aborted' ? { c: '#adb5bd' } : { c: '#e63946', b: true }
643 const flicker = kind === 'error' && mod(t, 6) === 0
644 put(g, left - 1, mid, ' '.repeat(tw + 2))
645 if (!flicker) put(g, left, mid, text, style)
646 return g
647}
648
649// ---- companion -----------------------------------------------------------
650
651/** What the companion row shows: the turn's act while it runs, else the mood. */
652export type PetView = {
653 state: Act | Mood
654 /** The bubble beside the mascot, already in the person's language. */
655 bubble: string
656 /** `Lv.3 ♥12`. */
657 stats: string
658}
659
660const BUBBLE: Partial<Record<Act | Mood, Style>> = {
661 ask: { c: '#ffd166', b: true },
662 error: { c: '#e63946' },
663 aborted: { c: '#adb5bd' },
664 sleep: { c: '#6c757d', d: true },
665}
666
667/** One row: the mascot in its current mood, its bubble, its level and affection; hearts after a pat. */
668export function petRow(theme: Theme, pet: PetView, t: number, w: number, hearts: number): Grid {
669 const g = blank(w, 1)
670 const s = pet.state
671 let face: string
672 if (s === 'think' || s === 'tool' || s === 'say' || s === 'wait' || s === 'ask') {
673 const frames = theme.sprite[poseOf(s)]
674 face = padTo(frame(frames, t), Math.max(...frames.map(textWidth)))
675 } else {
676 face = s === 'error' ? theme.dead : s === 'aborted' ? theme.sad : s === 'sleep' ? theme.sleep : theme.happy
677 }
678 const faceStyle = (i: number): Style =>
679 theme.isRainbow ? { c: hsl((i * 40 + t * 24) % 360, 0.95, 0.62), b: true } : { c: theme.color, b: true }
680 let x = 0
681 Array.from(face).forEach((ch, i) => {
682 put(g, x, 0, ch, faceStyle(i))
683 x += textWidth(ch)
684 })
685 x += 1
686 if (hearts > 0) {
687 put(g, x, 0, frame(['♡', '♥', '♡ ♥', '♥ ♡'], hearts), { c: '#ff8fab', b: true })
688 x += 4
689 }
690 if (pet.bubble) put(g, x, 0, `${s === 'ask' ? '❯ ' : '· '}${pet.bubble}`, BUBBLE[s] ?? { c: '#b8b8be' })
691 put(g, w - textWidth(pet.stats) - 1, 0, pet.stats, { c: '#6c757d' })
692 return g
693}
694hooks/pets.ts 305 lines1// The companion: a pixel pet three rows tall (6 px in half blocks), drawn the
2// same wherever it stands (beside the HUD, or right-aligned above the prompt).
3// A theme gives its body and where its eyes sit; blinking, breathing, the
4// expressions and the props beside it are the same for every pet.
5import { blank, canvas, cells, draw, mod, noise, plot, segments } from './cells'
6import type { Canvas, Grid } from './cells'
7import type { DockPet } from '../types'
8import type { Act, Mood } from './themes'
9
10export type PetState = Act | Mood
11
12export type PetArt = {
13 /** The body, 5 px tall: letters keyed in `colors`, `.` clear. Up to BODY_W wide. */
14 body: readonly string[]
15 /** The same body with its feet moved: a step while it works. */
16 step?: readonly string[]
17 colors: Record<string, string>
18 /** Each eye's column in the body; an eye is 1 px wide, 2 px tall, from row `eyeRow`. */
19 eyes: readonly number[]
20 eyeRow: number
21 /** The pupils' color, and the body's color where an eye closes. */
22 eye: string
23 lid: string
24 /** The mouth's column and row, opened while it talks. */
25 mouth?: readonly [x: number, y: number]
26 /** Its cheeks' color when happy. */
27 blush?: string
28 /** Props beside it: sparks while a tool runs, dots while it thinks. */
29 spark: string
30}
31
32/** Columns the pet takes: its props on the left, its body on the right. */
33export const PROP_W = 4
34export const BODY_W = 12
35export const PET_W = PROP_W + BODY_W
36export const PET_ROWS = 3
37/** Milliseconds per frame of the pet. */
38export const PET_MS = 140
39
40const QUESTION = ['.YY.', '...Y', '..Y.', '....', '..Y.']
41const HEART = ['P.P', 'PPP', '.P.']
42const ZED = ['ZZZZ', '..Z.', '.Z..', 'ZZZZ']
43const STAR = ['.S.', 'SSS', '.S.']
44
45/** Frames in one loop of a state: long enough for a blink and a breath. */
46export function loopOf(state: PetState): number {
47 return state === 'sleep' ? 48 : state === 'think' || state === 'tool' || state === 'say' ? 24 : 40
48}
49
50/** The pet at tick `t` in `state`, `hearts` frames into a pat (0: none). */
51export function petFrame(art: PetArt, state: PetState, t: number, hearts = 0): Grid {
52 const cv = canvas(PET_W, PET_ROWS)
53 const isBusy = state === 'tool' || state === 'say' || state === 'think'
54 // Breathing: up a pixel for part of each beat, quicker while it works; a hop when happy.
55 const beat = isBusy ? 6 : 16
56 let lift = mod(t, beat) < beat / 2 ? 1 : 0
57 if (state === 'ready' || state === 'hello') lift = mod(t, 10) < 2 ? 1 : lift
58 if (state === 'sleep' || state === 'error') lift = 0
59 const y = 1 - lift
60 const isStep = (state === 'tool' || state === 'say') && mod(t, 4) < 2
61 const body = isStep && art.step ? art.step : art.body
62 const colors = state === 'error' ? desaturate(art.colors) : art.colors
63 draw(cv, PROP_W, y, body, colors)
64 drawFace(cv, art, state, t, y)
65 drawProps(cv, art, state, t)
66 if (hearts > 0) {
67 const rise = Math.min(4, hearts >> 1)
68 draw(cv, 0, Math.max(0, 3 - rise), HEART, { P: hearts % 4 < 2 ? '#ff6b9d' : '#ff8fab' })
69 if (hearts > 3) draw(cv, PROP_W + BODY_W - 3, Math.max(0, 2 - (rise >> 1)), HEART, { P: '#ff8fab' })
70 }
71 return cells(cv)
72}
73
74function drawFace(cv: Canvas, art: PetArt, state: PetState, t: number, y: number): void {
75 const top = y + art.eyeRow
76 // A blink every few seconds, two frames long, at a different beat per eye pair.
77 const isBlink = mod(t, 28) < 2 && state !== 'sleep'
78 const lookAside = state === 'think' && mod(t, 24) >= 12 ? 1 : 0
79 for (const ex of art.eyes) {
80 const x = PROP_W + ex
81 // Clear the eye's two pixels to the lid first, then draw what this state shows.
82 plot(cv, x, top, art.lid)
83 plot(cv, x, top + 1, art.lid)
84 if (state === 'sleep' || isBlink || state === 'aborted') {
85 plot(cv, x, top + 1, art.eye)
86 } else if (state === 'ready' || state === 'hello') {
87 // Eyes curved up: the top pixel alone.
88 plot(cv, x, top, art.eye)
89 } else if (state === 'error') {
90 plot(cv, x, top, '#e63946')
91 plot(cv, x, top + 1, '#e63946')
92 } else {
93 plot(cv, x + lookAside, top, art.eye)
94 plot(cv, x + lookAside, top + 1, art.eye)
95 }
96 if ((state === 'ready' || state === 'hello') && art.blush) {
97 plot(cv, x + (ex < BODY_W / 2 ? -1 : 1), top + 1, art.blush)
98 }
99 }
100 if (state === 'aborted') {
101 // A tear under the first eye, falling.
102 const ex = PROP_W + art.eyes[0]!
103 plot(cv, ex, top + 2 + (mod(t, 8) < 4 ? 0 : 1), '#7ec8e3')
104 }
105 if (art.mouth && (state === 'say' || state === 'ask') && mod(t, 4) < 2) {
106 const [mx, my] = art.mouth
107 plot(cv, PROP_W + mx, y + my, art.eye)
108 plot(cv, PROP_W + mx + 1, y + my, art.eye)
109 }
110}
111
112function drawProps(cv: Canvas, art: PetArt, state: PetState, t: number): void {
113 if (state === 'think') {
114 // Three dots rising one after another.
115 for (let i = 0; i < 3; i++) {
116 const p = mod(t - i * 4, 12)
117 if (p < 6) plot(cv, 1 + i, 5 - p, i === 2 ? art.spark : '#8a8a8a')
118 }
119 } else if (state === 'tool') {
120 // Sparks flying off where it works.
121 for (let i = 0; i < 4; i++) {
122 if (noise(i * 31 + Math.floor(t / 2)) < 0.55) plot(cv, Math.floor(noise(i + t) * PROP_W), 1 + Math.floor(noise(i * 7 + t) * 4), i % 2 ? art.spark : '#ffd166')
123 }
124 } else if (state === 'ask') {
125 if (mod(t, 8) < 6) draw(cv, 0, 0, QUESTION, { Y: '#ffd166' })
126 } else if (state === 'sleep') {
127 const p = mod(t, 24)
128 draw(cv, 0, Math.max(0, 2 - (p >> 3)), ZED, { Z: p < 16 ? '#8d99ae' : '#5c677d' })
129 } else if (state === 'ready' || state === 'hello') {
130 if (mod(t, 20) < 10) draw(cv, 0, mod(t, 20) < 5 ? 1 : 2, STAR, { S: art.spark })
131 }
132}
133
134function desaturate(colors: Record<string, string>): Record<string, string> {
135 return Object.fromEntries(Object.entries(colors).map(([k, hex]) => [k, gray(hex)]))
136}
137
138function gray(hex: string): string {
139 const n = Number.parseInt(hex.slice(1), 16)
140 const l = Math.round(((n >> 16) * 0.3 + ((n >> 8) & 255) * 0.59 + (n & 255) * 0.11) * 0.7)
141 const h = l.toString(16).padStart(2, '0')
142 return `#${h}${h}${h}`
143}
144
145/** A blank pet-sized grid, for a theme with no pet. */
146export function noPet(): Grid {
147 return blank(PET_W, PET_ROWS)
148}
149
150// ---- the pets ------------------------------------------------------------------
151
152/** Clawd, as Claude Code draws him: a warm orange body, arms out, four legs. */
153export const CLAWD_PET: PetArt = {
154 body: [
155 '.LLOOOOOOOO.',
156 '.OOOOOOOOOO.',
157 'OOOOOOOOOOOO',
158 '.DOOOOOOOOD.',
159 '..D.D..D.D..',
160 ],
161 step: [
162 '.LLOOOOOOOO.',
163 '.OOOOOOOOOO.',
164 'OOOOOOOOOOOO',
165 '.DOOOOOOOOD.',
166 '.D.D....D.D.',
167 ],
168 colors: { O: '#d77757', L: '#e8957a', D: '#a9553b' },
169 eyes: [3, 8],
170 eyeRow: 1,
171 eye: '#2b1d18',
172 lid: '#d77757',
173 mouth: [5, 3],
174 blush: '#f2a28c',
175 spark: '#f4a261',
176}
177
178/** One loop of the pet in `state` as DockPet data: distinct frames and their order. */
179export function dockPetOf(
180 art: PetArt,
181 state: PetState,
182 view: Pick<DockPet, 'id' | 'bubble' | 'tone' | 'stats'>,
183 isPatted = false,
184 isStill = false,
185): DockPet {
186 const frames: DockPet['frames'] = []
187 const seen = new Map<string, number>()
188 const order: number[] = []
189 // Still: the loop's first frame alone, which the player never has to redraw.
190 const loop = isStill ? 1 : loopOf(state)
191 for (let t = 0; t < loop; t++) {
192 // A pat floats hearts over the first frames of the loop.
193 const rows = petFrame(art, state, t, isPatted && t < 16 ? t + 1 : 0).map(row => segments(row).map(plainSeg))
194 const key = JSON.stringify(rows)
195 let at = seen.get(key)
196 if (at === undefined) {
197 at = frames.length
198 frames.push(rows)
199 seen.set(key, at)
200 }
201 order.push(at)
202 }
203 return { ...view, frames, order, ms: PET_MS, width: PET_W }
204}
205
206/** A run as plain data: a key left undefined is refused in state and Client props. */
207function plainSeg(seg: ReturnType<typeof segments>[number]): DockPet['frames'][number][number][number] {
208 return Object.fromEntries(Object.entries(seg).filter(([, v]) => v !== undefined)) as DockPet['frames'][number][number][number]
209}
210
211/** A small fighter with a face in its canopy, its exhaust flickering. */
212export const THUNDER_PET: PetArt = {
213 body: ['..AA........', '.ABBBBWWWW..', 'FBBBBBWWWWBC', '.ABBBBBBBB..', '..AA........'],
214 step: ['..AA........', '.ABBBBWWWW..', 'GBBBBBWWWWBC', '.ABBBBBBBB..', '..AA........'],
215 colors: { A: '#4361ee', B: '#4cc9f0', W: '#e0fbfc', C: '#f72585', F: '#ff9f1c', G: '#ffd60a' },
216 eyes: [7, 9],
217 eyeRow: 1,
218 eye: '#14213d',
219 lid: '#e0fbfc',
220 mouth: [7, 3],
221 blush: '#ff8fab',
222 spark: '#ffd60a',
223}
224
225/** A maze ghost, its skirt rippling. */
226export const CHOMP_PET: PetArt = {
227 body: ['...GGGGGG...', '..GWWGGWWG..', '.GGWWGGWWGG.', '.GGGGGGGGGG.', '.G.GG.GG.GG.'],
228 step: ['...GGGGGG...', '..GWWGGWWG..', '.GGWWGGWWGG.', '.GGGGGGGGGG.', '.GG.GG.GG.G.'],
229 colors: { G: '#ff5d8f', W: '#ffffff' },
230 eyes: [4, 8],
231 eyeRow: 1,
232 eye: '#2121de',
233 lid: '#ffffff',
234 mouth: [5, 3],
235 spark: '#ffff00',
236}
237
238/** An electric mouse: black-tipped ears, red cheeks. */
239export const SPARKY_PET: PetArt = {
240 body: ['KY.....YK.Y.', '.YYYYYYY.YY.', 'YYYYYYYYY.Y.', 'RYYYYYYYR...', '.YY.YY.YY...'],
241 step: ['KY.....YK..Y', '.YYYYYYY.YY.', 'YYYYYYYYYY..', 'RYYYYYYYR...', 'YY..YY..YY..'],
242 colors: { K: '#5a3a22', Y: '#ffd60a', R: '#e63946' },
243 eyes: [2, 6],
244 eyeRow: 1,
245 eye: '#5a3a22',
246 lid: '#ffd60a',
247 blush: '#ff8fab',
248 spark: '#fff3b0',
249}
250
251/** A blue robot cat: white face, red nose, a bell on its collar. */
252export const BLUECAT_PET: PetArt = {
253 body: ['..BBBBBBBB..', '.BBWWWWWWBB.', 'BBWWWWWWWWBB', 'BBWWWRRWWWBB', '.BRRRYYRRRB.'],
254 colors: { B: '#0096c7', W: '#ffffff', R: '#e63946', Y: '#ffd60a' },
255 eyes: [4, 7],
256 eyeRow: 1,
257 eye: '#1b1b1b',
258 lid: '#ffffff',
259 mouth: [5, 3],
260 blush: '#ff8fab',
261 spark: '#80ffdb',
262}
263
264/** A pop-tart cat, its head poking out on the right. */
265export const NYAN_PET: PetArt = {
266 body: ['.TTTTT.G...G', 'TPSPPTGGGGGG', 'TPPSPTGGGGGG', 'TPSPPTGGGGGG', '.L.L.L..L.L.'],
267 step: ['.TTTTT.G...G', 'TPSPPTGGGGGG', 'TPPSPTGGGGGG', 'TPSPPTGGGGGG', 'L.L.L..L.L..'],
268 colors: { T: '#ffcc99', P: '#ff99cc', S: '#ff3399', G: '#a0a0a0', L: '#a0a0a0' },
269 eyes: [8, 10],
270 eyeRow: 1,
271 eye: '#1b1b1b',
272 lid: '#a0a0a0',
273 blush: '#ff99cc',
274 spark: '#ffff00',
275}
276
277/** A round listener in big headphones, swaying to whatever plays. */
278export const AUDIO_PET: PetArt = {
279 body: ['..HHHHHHHH..', '.H.BBBBBB.H.', 'CCBBBBBBBBCC', 'CCBBBBBBBBCC', '.B.B....B.B.'],
280 step: ['..HHHHHHHH..', '.H.BBBBBB.H.', 'CCBBBBBBBBCC', 'CCBBBBBBBBCC', '..B.B..B.B..'],
281 colors: { H: '#5a189a', C: '#c77dff', B: '#e0aaff' },
282 eyes: [4, 7],
283 eyeRow: 1,
284 eye: '#240046',
285 lid: '#e0aaff',
286 mouth: [5, 3],
287 blush: '#ff8fab',
288 spark: '#4cc9f0',
289}
290
291const PETS: Record<string, PetArt> = {
292 clawd: CLAWD_PET,
293 thunder: THUNDER_PET,
294 chomp: CHOMP_PET,
295 sparky: SPARKY_PET,
296 bluecat: BLUECAT_PET,
297 nyan: NYAN_PET,
298 audio: AUDIO_PET,
299}
300
301/** A theme's pet; a theme without one of its own borrows Clawd's shape in its color. */
302export function petArtOf(theme: string, color: string): PetArt {
303 return PETS[theme] ?? { ...CLAWD_PET, colors: { O: color, L: color, D: color }, lid: color }
304}
305hooks/audio.ts 116 lines1// The `audio` theme's feed: lines from the tap (hooks/audio-tap.swift) to what
2// its scene draws. The meter lives in the hooks module; the band's Client asks
3// for its latest view each frame (register.tsx, `ui.message`). Pure but for the
4// meter's own memory.
5
6/** Bands the tap is asked for, low to high. */
7export const AUDIO_BANDS = 32
8/** The tap's line period, in milliseconds. */
9export const TAP_MS = 50
10
11/**
12 * One frame for the scene: band levels and their falling peaks (0..99, gained
13 * to the last few seconds' loudest), and a count of beats so far.
14 */
15export type AudioView = { b: number[]; p: number[]; beat: number }
16
17/**
18 * What the scene is given: live levels, `null` when the tap could not run,
19 * or nothing at all (a preview, a screenshot, a surface without processes)
20 * for a made-up signal.
21 */
22export type AudioFeed = AudioView | null | undefined
23
24/** A tap line: `L <loudness> <band>...`, each 0..99; anything else is null. */
25export function parseTapLine(line: string): { loud: number; bands: number[] } | null {
26 const parts = line.trim().split(' ')
27 if (parts[0] !== 'L' || parts.length < 3) return null
28 const nums = parts.slice(1).map(Number)
29 if (nums.some(n => !Number.isFinite(n))) return null
30 const [loud, ...bands] = nums.map(n => Math.max(0, Math.min(99, n)))
31 return { loud: loud!, bands }
32}
33
34/** Splits streamed text into whole lines, keeping a cut-off tail for the next piece. */
35export function lineSplitter(): (text: string) => string[] {
36 let rest = ''
37 return text => {
38 const lines = (rest + text).split('\n')
39 rest = lines.pop() ?? ''
40 return lines
41 }
42}
43
44/** Below this a band is drawn as nothing: the tap's floor of hiss. */
45const FLOOR = 6
46/** The gain's ceiling never drops under this, so near-silence is not blown up. */
47const MIN_CEILING = 35
48/** Per line: the ceiling eases down this much toward the bands' level (about 7 s to half). */
49const CEILING_DECAY = 0.995
50/** Per line: how far a peak falls. */
51const PEAK_FALL = 4
52/** A beat: loudness this far over its running average, at least this loud, this many lines apart. */
53const BEAT_RATIO = 1.3
54const BEAT_MIN = 12
55const BEAT_GAP = 5
56/** Lines of quiet before the meter counts as silent (3 s). */
57const QUIET_LINES = 60
58
59/** The tap's lines in, the scene's view out: gain, peaks, beats and whether anything plays. */
60export class AudioMeter {
61 private ceiling = MIN_CEILING
62 private levels: number[] = new Array(AUDIO_BANDS).fill(0)
63 private peaks: number[] = new Array(AUDIO_BANDS).fill(0)
64 private average = 0
65 private beats = 0
66 private sinceBeat = BEAT_GAP
67 private sinceSound = QUIET_LINES
68
69 /** Takes one tap line; returns whether it was a reading. */
70 push(line: string): boolean {
71 const reading = parseTapLine(line)
72 if (!reading) return false
73 const { loud, bands } = reading
74 const top = Math.max(0, ...bands)
75 this.ceiling = Math.max(MIN_CEILING, top, this.ceiling * CEILING_DECAY)
76 this.levels = Array.from({ length: AUDIO_BANDS }, (_, i) => {
77 const raw = bands[Math.floor((i * bands.length) / AUDIO_BANDS)] ?? 0
78 return raw < FLOOR ? 0 : Math.round(Math.min(99, (raw * 99) / this.ceiling))
79 })
80 this.peaks = this.peaks.map((p, i) => Math.max(this.levels[i]!, p - PEAK_FALL))
81
82 this.sinceBeat++
83 if (loud >= BEAT_MIN && loud > this.average * BEAT_RATIO && this.sinceBeat >= BEAT_GAP) {
84 this.beats++
85 this.sinceBeat = 0
86 }
87 this.average = this.average * 0.9 + loud * 0.1
88 this.sinceSound = top >= FLOOR ? 0 : this.sinceSound + 1
89 return true
90 }
91
92 /** Whether anything has played in the last few seconds. */
93 get isAudible(): boolean {
94 return this.sinceSound < QUIET_LINES
95 }
96
97 view(): AudioView {
98 return { b: [...this.levels], p: [...this.peaks], beat: this.beats }
99 }
100}
101
102/** A made-up signal with a beat, for when no tap feeds the scene. */
103export function demoView(t: number): AudioView {
104 const kick = Math.max(0, 1 - (t % 5) / 3)
105 const b = Array.from({ length: AUDIO_BANDS }, (_, i) => {
106 const u = i / AUDIO_BANDS
107 const v =
108 0.12 +
109 0.5 * kick * Math.max(0, 1 - u * 3) +
110 0.3 * (0.5 + 0.5 * Math.sin(t * 0.45 + i * 0.6)) * (1 - u * 0.5) +
111 0.2 * (0.5 + 0.5 * Math.sin(t * 0.9 - i * 1.3))
112 return Math.round(Math.min(99, v * 90))
113 })
114 return { b, p: b.map(v => Math.min(99, v + 10)), beat: Math.floor(t / 5) }
115}
116