SLOPSHOPPER

spinner

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

newbandspinnerguardcommandtoast
★ 1v0.6.0-ko.1MITupdated 2026-10-06SeongGwangJu/k-mods/mods/spinner
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · spinner
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /spinner ⎿ spinner: 현재 테마: chomp (세션마다 무작위) ⎿ spinner: chomp · Lv.1 · 경험치 1 · ♥0 ⎿ spinner: 대기 중 펫을 숨겼습니다 ⎿ spinner: 테마 목록: clawd ▐▛█▜▌ ✻ · thunder =▷ ✶✶✶ · chomp ᗧ ᗣᗣᗣ · sparky ϟ(^ᴥ^)ϟ · bluecat ⊂(◕‿◕)つ · nyan ~=[,,^,,]:3 · ca ⎿ spinner: 사용법: /spinner <테마|random> · theme 선택 · pet 쓰다듬기 · preview [테마] · off / on · stage off / on · companion off / o ▣ client module ./stage.tsx ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ client module ./stage.tsx ⟨Claude Code's own drawing⟩
README

픽셀 장면 스피너 (한국 수정판)

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에 있어요.

권한 (이 mod가 내 컴퓨터에서 하는 일)

화면을 그리고 로컬 저장소($.store)에 펫 상태를 저장해요. audio 테마를 고를 때만 macOS에서 swiftc로 작은 오디오 탭을 컴파일해 시스템 출력 소리의 크기만 읽어요. 네트워크는 쓰지 않아요.

출처·라이선스

원작: hoobnn, hoobnn/hoobnn-agent-mods 커밋 8fb6f67의 claude-code/spinner. MIT 라이선스이며 원본 저작권 표기를 LICENSE에 그대로 뒀어요. 원본의 그림 파일(assets)은 담지 않고 원본 저장소 주소로 보여줘요.

테스트한 Claude Code 버전: 2.1.291

Source 19 files
hooks/register.tsx 621 lines
1import { 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}
621
hooks/command.ts 38 lines
1// `/spinner`'s arguments to what they ask for; register.tsx carries it out.
2import { isThemeName } from './themes'
3import type { ThemeName } from './themes'
4
5export type SpinnerCommand =
6  | { kind: 'status' }
7  | { kind: 'visible'; isOn: boolean }
8  | { kind: 'stage'; isOn: boolean }
9  | { kind: 'companion'; isOn: boolean }
10  | { kind: 'pat' }
11  | { kind: 'preview'; theme: ThemeName | null }
12  | { kind: 'theme'; theme: ThemeName | 'random' }
13  | { kind: 'pick' }
14  | { kind: 'unknown'; name: string }
15
16export const USAGE = '[theme|random|pet|preview|off|on|stage off|companion off]'
17
18export function parseCommand(args: string): SpinnerCommand {
19  const [verb = '', arg = ''] = args.trim().toLowerCase().split(/\s+/)
20  const isSwitch = arg === 'off' || arg === 'on'
21  if (verb === '') return { kind: 'status' }
22  if (verb === 'off' || verb === 'on') return { kind: 'visible', isOn: verb === 'on' }
23  if (verb === 'stage' && isSwitch) return { kind: 'stage', isOn: arg === 'on' }
24  if (verb === 'companion' && isSwitch) return { kind: 'companion', isOn: arg === 'on' }
25  if (verb === 'pet') return { kind: 'pat' }
26  if (verb === 'preview') {
27    if (!arg) return { kind: 'preview', theme: null }
28    return isThemeName(arg) ? { kind: 'preview', theme: arg } : { kind: 'unknown', name: arg }
29  }
30  // `theme` alone asks which; `theme <name>` is the same as `<name>`.
31  if (verb === 'theme') {
32    if (!arg) return { kind: 'pick' }
33    return arg === 'random' || isThemeName(arg) ? { kind: 'theme', theme: arg } : { kind: 'unknown', name: arg }
34  }
35  if (verb === 'random' || isThemeName(verb)) return { kind: 'theme', theme: verb }
36  return { kind: 'unknown', name: verb }
37}
38
hooks/config.ts 39 lines
1// spinner's options (plugin.json `userConfig`), read once into a typed config.
2import type { PluginOptions } from 'claude-code'
3
4import { flag, oneOf, text } from './kit/options'
5import { THEME_NAMES } from './themes'
6import type { ThemeName } from './themes'
7
8export type Choice = ThemeName | 'random'
9
10export const CHOICES: readonly Choice[] = ['random', ...THEME_NAMES]
11
12export type Config = {
13  /** A theme, or `random` for a new one each session. */
14  theme: Choice
15  isVisible: boolean
16  /** A Spinner button in the prompt footer. */
17  hasFooterButton: boolean
18  hasStage: boolean
19  hasFinale: boolean
20  hasCompanion: boolean
21  /** Every animation drawn as one still frame. */
22  isStill: boolean
23  /** `auto` or a language; the kit resolves it (kit/lang.ts). */
24  language: string
25}
26
27export function readConfig(options: PluginOptions): Config {
28  return {
29    theme: oneOf(options.theme, CHOICES, 'random'),
30    isVisible: flag(options.visible, true),
31    hasFooterButton: flag(options.footerButton, true),
32    hasStage: flag(options.stage, true),
33    hasFinale: flag(options.celebrate, true),
34    hasCompanion: flag(options.companion, true),
35    isStill: flag(options.reducedMotion, false),
36    language: text(options.language, 'auto'),
37  }
38}
39
hooks/i18n.ts 396 lines
1// spinner's messages; the language is resolved by the kit (kit/lang.ts).
2import { createMessages } from './kit/lang'
3import type { Lang } from './kit/lang'
4
5export { parseLanguage, resolveLanguage } from './kit/lang'
6
7export type Key =
8  | 'cmd.description'
9  | 'cmd.status'
10  | 'cmd.randomNote'
11  | 'cmd.themes'
12  | 'cmd.usage'
13  | 'cmd.switched'
14  | 'cmd.random'
15  | 'cmd.hidden'
16  | 'cmd.shown'
17  | 'cmd.stageOff'
18  | 'cmd.stageOn'
19  | 'cmd.preview'
20  | 'cmd.unknown'
21  | 'cmd.pick'
22  | 'finale.done'
23  | 'finale.aborted'
24  | 'finale.error'
25  | 'pet.hello'
26  | 'pet.ask'
27  | 'pet.ready'
28  | 'pet.aborted'
29  | 'pet.error'
30  | 'pet.sleep'
31  | 'pet.levelUp'
32  | 'pet.testPass'
33  | 'pet.testFail'
34  | 'pet.commit'
35  | 'cmd.pat'
36  | 'cmd.petStats'
37  | 'cmd.companionOff'
38  | 'cmd.companionOn'
39  | 'cmd.audioOn'
40  | 'cmd.audioOff'
41
42export const MESSAGES: Record<Lang, Record<Key, string>> = {
43  en: {
44    'cmd.description': 'Spinner animations: themes, a companion pet, preview, off / on (kept across sessions)',
45    'cmd.status': 'Theme: {theme}',
46    'cmd.randomNote': ' (random each session)',
47    'cmd.themes': 'Themes: {list}',
48    'cmd.usage': 'Usage: /spinner <theme|random> · theme (pick) · pet · preview [theme] · off / on · stage off / on · companion off / on',
49    'cmd.switched': 'Switched to {theme}',
50    'cmd.random': 'A random theme each session; this one: {theme}',
51    'cmd.hidden': 'Spinner animations off',
52    'cmd.shown': 'Spinner animations on',
53    'cmd.stageOff': 'Animation band above the prompt off',
54    'cmd.stageOn': 'Animation band above the prompt on',
55    'cmd.preview': 'Previewing {theme} above the prompt',
56    'cmd.unknown': 'No theme called {name}. Themes: {list}',
57    'cmd.pick': 'Which theme? (Other: type one of {list})',
58    'finale.done': 'Done · {time}',
59    'finale.aborted': 'Interrupted',
60    'finale.error': 'Something went wrong',
61    'pet.hello': 'I’m here with you~',
62    'pet.ask': 'Waiting for your OK~',
63    'pet.ready': 'All done, take a look!',
64    'pet.aborted': 'Okay, stopped.',
65    'pet.error': 'Ouch… something broke',
66    'pet.sleep': 'Zzz…',
67    'pet.levelUp': '{theme} reached Lv.{level}!',
68    'pet.testPass': 'Tests passed!',
69    'pet.testFail': 'Tests failed… we’ll fix it',
70    'pet.commit': 'Committed!',
71    'cmd.pat': 'You patted {theme} (♥{love})',
72    'cmd.petStats': '{theme} · Lv.{level} · {xp} xp · ♥{love}',
73    'cmd.companionOff': 'Companion between turns hidden',
74    'cmd.companionOn': 'Companion between turns shown',
75    'cmd.audioOn': 'Audio: showing this computer\'s sound output (only levels, nothing kept or sent)',
76    'cmd.audioOff': 'Audio: nothing to show ({reason}). Needs macOS 14.2+, swiftc (Xcode Command Line Tools) and System Audio Recording allowed for your terminal',
77  },
78  'zh-Hans': {
79    'cmd.description': '运行动画:切换主题、宠物伴侣、预览、off / on(跨会话保持)',
80    'cmd.status': '当前主题:{theme}',
81    'cmd.randomNote': '(每个会话随机)',
82    'cmd.themes': '可选主题:{list}',
83    'cmd.usage': '用法:/spinner <主题|random> · theme 弹窗选择 · pet 摸摸 · preview [主题] · off / on · stage off / on · companion off / on',
84    'cmd.switched': '已切换到 {theme}',
85    'cmd.random': '每个会话随机一个主题,这次是 {theme}',
86    'cmd.hidden': '运行动画已关闭',
87    'cmd.shown': '运行动画已开启',
88    'cmd.stageOff': '输入框上方的动画带已关闭',
89    'cmd.stageOn': '输入框上方的动画带已开启',
90    'cmd.preview': '正在输入框上方预览 {theme}',
91    'cmd.unknown': '没有叫 {name} 的主题。可选:{list}',
92    'cmd.pick': '换哪个主题?(其他里可输入:{list})',
93    'finale.done': '完成 · {time}',
94    'finale.aborted': '已中断',
95    'finale.error': '出错了',
96    'pet.hello': '我在这儿陪你~',
97    'pet.ask': '等你确认一下~',
98    'pet.ready': '做完啦,快看看!',
99    'pet.aborted': '好吧,停下了',
100    'pet.error': '呜…出错了',
101    'pet.sleep': 'Zzz…',
102    'pet.levelUp': '{theme} 升到 Lv.{level} 啦!',
103    'pet.testPass': '测试通过啦!',
104    'pet.testFail': '测试没过…一起修吧',
105    'pet.commit': '提交好啦!',
106    'cmd.pat': '你摸了摸 {theme}(♥{love})',
107    'cmd.petStats': '{theme} · Lv.{level} · 经验 {xp} · ♥{love}',
108    'cmd.companionOff': '空闲时的宠物已隐藏',
109    'cmd.companionOn': '空闲时的宠物已显示',
110    'cmd.audioOn': '音频:正在显示本机的声音输出(只取电平,不保存、不外传)',
111    'cmd.audioOff': '音频:无法显示({reason})。需要 macOS 14.2 及以上、swiftc(Xcode 命令行工具),并在系统设置里允许终端“录制系统音频”',
112  },
113  'zh-Hant': {
114    'cmd.description': '執行動畫:切換主題、寵物夥伴、預覽、off / on(跨工作階段保留)',
115    'cmd.status': '目前主題:{theme}',
116    'cmd.randomNote': '(每個工作階段隨機)',
117    'cmd.themes': '可選主題:{list}',
118    'cmd.usage': '用法:/spinner <主題|random> · theme 彈窗選擇 · pet 摸摸 · preview [主題] · off / on · stage off / on · companion off / on',
119    'cmd.switched': '已切換到 {theme}',
120    'cmd.random': '每個工作階段隨機一個主題,這次是 {theme}',
121    'cmd.hidden': '執行動畫已關閉',
122    'cmd.shown': '執行動畫已開啟',
123    'cmd.stageOff': '輸入框上方的動畫帶已關閉',
124    'cmd.stageOn': '輸入框上方的動畫帶已開啟',
125    'cmd.preview': '正在輸入框上方預覽 {theme}',
126    'cmd.unknown': '沒有名為 {name} 的主題。可選:{list}',
127    'cmd.pick': '換哪個主題?(其他裡可輸入:{list})',
128    'finale.done': '完成 · {time}',
129    'finale.aborted': '已中斷',
130    'finale.error': '出錯了',
131    'pet.hello': '我在這裡陪你~',
132    'pet.ask': '等你確認一下~',
133    'pet.ready': '做完了,快看看!',
134    'pet.aborted': '好吧,停下了',
135    'pet.error': '嗚…出錯了',
136    'pet.sleep': 'Zzz…',
137    'pet.levelUp': '{theme} 升到 Lv.{level} 了!',
138    'pet.testPass': '測試通過了!',
139    'pet.testFail': '測試沒過…一起修吧',
140    'pet.commit': '提交好了!',
141    'cmd.pat': '你摸了摸 {theme}(♥{love})',
142    'cmd.petStats': '{theme} · Lv.{level} · 經驗 {xp} · ♥{love}',
143    'cmd.companionOff': '閒置時的寵物已隱藏',
144    'cmd.companionOn': '閒置時的寵物已顯示',
145    'cmd.audioOn': '音訊:正在顯示本機的聲音輸出(只取音量,不儲存、不外傳)',
146    'cmd.audioOff': '音訊:無法顯示({reason})。需要 macOS 14.2 以上、swiftc(Xcode 命令列工具),並在系統設定允許終端機「錄製系統音訊」',
147  },
148  ja: {
149    'cmd.description': '実行中アニメーション:テーマ、ペット、プレビュー、off / on(セッションをまたいで保持)',
150    'cmd.status': 'テーマ:{theme}',
151    'cmd.randomNote': '(セッションごとにランダム)',
152    'cmd.themes': 'テーマ一覧:{list}',
153    'cmd.usage': '使い方:/spinner <テーマ|random> · theme 選択 · pet なでる · preview [テーマ] · off / on · stage off / on · companion off / on',
154    'cmd.switched': '{theme} に切り替えました',
155    'cmd.random': 'セッションごとにランダムなテーマ。今回は {theme}',
156    'cmd.hidden': '実行中アニメーションをオフにしました',
157    'cmd.shown': '実行中アニメーションをオンにしました',
158    'cmd.stageOff': '入力欄上のアニメーションバーをオフにしました',
159    'cmd.stageOn': '入力欄上のアニメーションバーをオンにしました',
160    'cmd.preview': '入力欄の上で {theme} をプレビュー中',
161    'cmd.unknown': '{name} というテーマはありません。テーマ一覧:{list}',
162    'cmd.pick': 'どのテーマにする?(その他で入力可:{list})',
163    'finale.done': '完了 · {time}',
164    'finale.aborted': '中断しました',
165    'finale.error': 'エラーが発生しました',
166    'pet.hello': 'そばにいるよ~',
167    'pet.ask': '確認を待ってるよ~',
168    'pet.ready': 'できたよ、見てみて!',
169    'pet.aborted': 'はい、止めたよ',
170    'pet.error': 'うぅ…エラーだ',
171    'pet.sleep': 'Zzz…',
172    'pet.levelUp': '{theme} が Lv.{level} になった!',
173    'pet.testPass': 'テスト通った!',
174    'pet.testFail': 'テスト失敗…直そう',
175    'pet.commit': 'コミットした!',
176    'cmd.pat': '{theme} をなでた(♥{love})',
177    'cmd.petStats': '{theme} · Lv.{level} · 経験値 {xp} · ♥{love}',
178    'cmd.companionOff': '待機中のペットを非表示にしました',
179    'cmd.companionOn': '待機中のペットを表示しました',
180    'cmd.audioOn': 'オーディオ:このコンピュータの音声出力を表示中(レベルのみ、保存・送信なし)',
181    'cmd.audioOff': 'オーディオ:表示できません({reason})。macOS 14.2 以降、swiftc(Xcode コマンドラインツール)、ターミナルへの「システムオーディオ録音」の許可が必要です',
182  },
183  ko: {
184    'cmd.description': '실행 애니메이션: 테마, 펫, 미리 보기, off / on(세션 간 유지)',
185    'cmd.status': '현재 테마: {theme}',
186    'cmd.randomNote': ' (세션마다 무작위)',
187    'cmd.themes': '테마 목록: {list}',
188    'cmd.usage': '사용법: /spinner <테마|random> · theme 선택 · pet 쓰다듬기 · preview [테마] · off / on · stage off / on · companion off / on',
189    'cmd.switched': '{theme}(으)로 전환했습니다',
190    'cmd.random': '세션마다 무작위 테마, 이번에는 {theme}',
191    'cmd.hidden': '실행 애니메이션을 껐습니다',
192    'cmd.shown': '실행 애니메이션을 켰습니다',
193    'cmd.stageOff': '입력창 위 애니메이션 띠를 껐습니다',
194    'cmd.stageOn': '입력창 위 애니메이션 띠를 켰습니다',
195    'cmd.preview': '입력창 위에서 {theme} 미리 보는 중',
196    'cmd.unknown': '{name} 테마가 없습니다. 테마 목록: {list}',
197    'cmd.pick': '어떤 테마로 할까요? (기타에 입력: {list})',
198    'finale.done': '완료 · {time}',
199    'finale.aborted': '중단됨',
200    'finale.error': '오류가 발생했습니다',
201    'pet.hello': '여기서 함께할게요~',
202    'pet.ask': '확인을 기다려요~',
203    'pet.ready': '다 했어요, 확인해 보세요!',
204    'pet.aborted': '네, 멈췄어요',
205    'pet.error': '으… 오류가 났어요',
206    'pet.sleep': 'Zzz…',
207    'pet.levelUp': '{theme} Lv.{level} 달성!',
208    'pet.testPass': '테스트 통과!',
209    'pet.testFail': '테스트 실패… 같이 고쳐요',
210    'pet.commit': '커밋 완료!',
211    'cmd.pat': '{theme}을(를) 쓰다듬었어요 (♥{love})',
212    'cmd.petStats': '{theme} · Lv.{level} · 경험치 {xp} · ♥{love}',
213    'cmd.companionOff': '대기 중 펫을 숨겼습니다',
214    'cmd.companionOn': '대기 중 펫을 표시했습니다',
215    'cmd.audioOn': '오디오: 이 컴퓨터의 소리 출력을 표시 중 (레벨만, 저장·전송 없음)',
216    'cmd.audioOff': '오디오: 표시할 수 없음 ({reason}). macOS 14.2 이상, swiftc(Xcode 명령줄 도구), 터미널의 \'시스템 오디오 녹음\' 허용이 필요합니다',
217  },
218  es: {
219    'cmd.description': 'Animaciones del spinner: temas, mascota, vista previa, off / on (se mantiene entre sesiones)',
220    'cmd.status': 'Tema: {theme}',
221    'cmd.randomNote': ' (al azar en cada sesión)',
222    'cmd.themes': 'Temas: {list}',
223    'cmd.usage': 'Uso: /spinner <tema|random> · theme (elegir) · pet · preview [tema] · off / on · stage off / on · companion off / on',
224    'cmd.switched': 'Cambiado a {theme}',
225    'cmd.random': 'Un tema al azar en cada sesión; esta vez: {theme}',
226    'cmd.hidden': 'Animaciones del spinner desactivadas',
227    'cmd.shown': 'Animaciones del spinner activadas',
228    'cmd.stageOff': 'Barra animada sobre el prompt desactivada',
229    'cmd.stageOn': 'Barra animada sobre el prompt activada',
230    'cmd.preview': 'Vista previa de {theme} sobre el prompt',
231    'cmd.unknown': 'No hay ningún tema llamado {name}. Temas: {list}',
232    'cmd.pick': '¿Qué tema? (En Otro escribe uno de: {list})',
233    'finale.done': 'Listo · {time}',
234    'finale.aborted': 'Interrumpido',
235    'finale.error': 'Algo salió mal',
236    'pet.hello': '¡Aquí estoy contigo!',
237    'pet.ask': 'Esperando tu OK~',
238    'pet.ready': '¡Listo, échale un vistazo!',
239    'pet.aborted': 'Vale, me detengo.',
240    'pet.error': 'Ay… algo falló',
241    'pet.sleep': 'Zzz…',
242    'pet.levelUp': '¡{theme} subió a Nv.{level}!',
243    'pet.testPass': '¡Pasan las pruebas!',
244    'pet.testFail': 'Fallan las pruebas… lo arreglamos',
245    'pet.commit': '¡Commit hecho!',
246    'cmd.pat': 'Acariciaste a {theme} (♥{love})',
247    'cmd.petStats': '{theme} · Nv.{level} · {xp} xp · ♥{love}',
248    'cmd.companionOff': 'Mascota entre turnos oculta',
249    'cmd.companionOn': 'Mascota entre turnos visible',
250    'cmd.audioOn': 'Audio: mostrando la salida de sonido del equipo (solo niveles; nada se guarda ni se envía)',
251    'cmd.audioOff': 'Audio: nada que mostrar ({reason}). Requiere macOS 14.2+, swiftc (Xcode Command Line Tools) y permitir Grabación de audio del sistema a tu terminal',
252  },
253  fr: {
254    'cmd.description': 'Animations du spinner : thèmes, compagnon, aperçu, off / on (réglage conservé entre les sessions)',
255    'cmd.status': 'Thème : {theme}',
256    'cmd.randomNote': ' (au hasard à chaque session)',
257    'cmd.themes': 'Thèmes : {list}',
258    'cmd.usage': 'Usage : /spinner <thème|random> · theme (choisir) · pet · preview [thème] · off / on · stage off / on · companion off / on',
259    'cmd.switched': 'Thème {theme} activé',
260    'cmd.random': 'Un thème au hasard à chaque session ; cette fois : {theme}',
261    'cmd.hidden': 'Animations du spinner désactivées',
262    'cmd.shown': 'Animations du spinner activées',
263    'cmd.stageOff': 'Bande animée au-dessus du prompt désactivée',
264    'cmd.stageOn': 'Bande animée au-dessus du prompt activée',
265    'cmd.preview': 'Aperçu de {theme} au-dessus du prompt',
266    'cmd.unknown': 'Aucun thème nommé {name}. Thèmes : {list}',
267    'cmd.pick': 'Quel thème ? (Autre : tape l’un de {list})',
268    'finale.done': 'Terminé · {time}',
269    'finale.aborted': 'Interrompu',
270    'finale.error': 'Une erreur est survenue',
271    'pet.hello': 'Je suis là avec toi~',
272    'pet.ask': 'J’attends ton accord~',
273    'pet.ready': 'Fini, jette un œil\u00a0!',
274    'pet.aborted': 'D’accord, j’arrête.',
275    'pet.error': 'Aïe… une erreur',
276    'pet.sleep': 'Zzz…',
277    'pet.levelUp': '{theme} passe au niv.\u00a0{level}\u00a0!',
278    'pet.testPass': 'Tests réussis\u00a0!',
279    'pet.testFail': 'Tests en échec… on répare',
280    'pet.commit': 'Commit fait\u00a0!',
281    'cmd.pat': 'Tu as caressé {theme} (♥{love})',
282    'cmd.petStats': '{theme} · niv.\u00a0{level} · {xp}\u00a0xp · ♥{love}',
283    'cmd.companionOff': 'Compagnon entre les tours masqué',
284    'cmd.companionOn': 'Compagnon entre les tours affiché',
285    'cmd.audioOn': 'Audio : affiche la sortie son de l’ordinateur (niveaux seulement, rien n’est gardé ni envoyé)',
286    'cmd.audioOff': 'Audio : rien à afficher ({reason}). Il faut macOS 14.2+, swiftc (outils en ligne de commande Xcode) et autoriser l’enregistrement audio du système pour votre terminal',
287  },
288  de: {
289    'cmd.description': 'Spinner-Animationen: Themen, Begleiter, Vorschau, off / on (bleibt über Sitzungen erhalten)',
290    'cmd.status': 'Thema: {theme}',
291    'cmd.randomNote': ' (jede Sitzung zufällig)',
292    'cmd.themes': 'Themen: {list}',
293    'cmd.usage': 'Verwendung: /spinner <Thema|random> · theme (auswählen) · pet · preview [Thema] · off / on · stage off / on · companion off / on',
294    'cmd.switched': 'Zu {theme} gewechselt',
295    'cmd.random': 'Jede Sitzung ein zufälliges Thema; diesmal: {theme}',
296    'cmd.hidden': 'Spinner-Animationen aus',
297    'cmd.shown': 'Spinner-Animationen an',
298    'cmd.stageOff': 'Animationsleiste über der Eingabe aus',
299    'cmd.stageOn': 'Animationsleiste über der Eingabe an',
300    'cmd.preview': 'Vorschau von {theme} über der Eingabe',
301    'cmd.unknown': 'Kein Thema namens {name}. Themen: {list}',
302    'cmd.pick': 'Welches Thema? (Unter Andere eintippen: {list})',
303    'finale.done': 'Fertig · {time}',
304    'finale.aborted': 'Abgebrochen',
305    'finale.error': 'Etwas ist schiefgelaufen',
306    'pet.hello': 'Ich bin bei dir~',
307    'pet.ask': 'Warte auf dein OK~',
308    'pet.ready': 'Fertig, schau mal!',
309    'pet.aborted': 'Okay, angehalten.',
310    'pet.error': 'Autsch… ein Fehler',
311    'pet.sleep': 'Zzz…',
312    'pet.levelUp': '{theme} ist jetzt Lv.{level}!',
313    'pet.testPass': 'Tests bestanden!',
314    'pet.testFail': 'Tests rot… wir reparieren das',
315    'pet.commit': 'Committet!',
316    'cmd.pat': 'Du hast {theme} gestreichelt (♥{love})',
317    'cmd.petStats': '{theme} · Lv.{level} · {xp} EP · ♥{love}',
318    'cmd.companionOff': 'Begleiter zwischen den Runden ausgeblendet',
319    'cmd.companionOn': 'Begleiter zwischen den Runden eingeblendet',
320    'cmd.audioOn': 'Audio: zeigt die Tonausgabe dieses Computers (nur Pegel, nichts wird gespeichert oder gesendet)',
321    'cmd.audioOff': 'Audio: nichts anzuzeigen ({reason}). Benötigt macOS 14.2+, swiftc (Xcode Command Line Tools) und die Freigabe „Systemaudioaufnahme“ für dein Terminal',
322  },
323  'pt-BR': {
324    'cmd.description': 'Animações do spinner: temas, mascote, prévia, off / on (configuração mantida entre sessões)',
325    'cmd.status': 'Tema: {theme}',
326    'cmd.randomNote': ' (aleatório a cada sessão)',
327    'cmd.themes': 'Temas: {list}',
328    'cmd.usage': 'Uso: /spinner <tema|random> · theme (escolher) · pet · preview [tema] · off / on · stage off / on · companion off / on',
329    'cmd.switched': 'Trocado para {theme}',
330    'cmd.random': 'Um tema aleatório a cada sessão; desta vez: {theme}',
331    'cmd.hidden': 'Animações do spinner desativadas',
332    'cmd.shown': 'Animações do spinner ativadas',
333    'cmd.stageOff': 'Faixa animada acima do prompt desativada',
334    'cmd.stageOn': 'Faixa animada acima do prompt ativada',
335    'cmd.preview': 'Prévia de {theme} acima do prompt',
336    'cmd.unknown': 'Nenhum tema chamado {name}. Temas: {list}',
337    'cmd.pick': 'Qual tema? (Em Outro digite um de: {list})',
338    'finale.done': 'Pronto · {time}',
339    'finale.aborted': 'Interrompido',
340    'finale.error': 'Algo deu errado',
341    'pet.hello': 'Estou aqui com você~',
342    'pet.ask': 'Esperando seu OK~',
343    'pet.ready': 'Pronto, dá uma olhada!',
344    'pet.aborted': 'Tá, parei.',
345    'pet.error': 'Ai… deu erro',
346    'pet.sleep': 'Zzz…',
347    'pet.levelUp': '{theme} chegou ao Nv.{level}!',
348    'pet.testPass': 'Testes passaram!',
349    'pet.testFail': 'Testes falharam… vamos consertar',
350    'pet.commit': 'Commit feito!',
351    'cmd.pat': 'Você fez carinho em {theme} (♥{love})',
352    'cmd.petStats': '{theme} · Nv.{level} · {xp} xp · ♥{love}',
353    'cmd.companionOff': 'Mascote entre turnos oculto',
354    'cmd.companionOn': 'Mascote entre turnos visível',
355    'cmd.audioOn': 'Áudio: mostrando a saída de som do computador (só níveis; nada é guardado nem enviado)',
356    'cmd.audioOff': 'Áudio: nada para mostrar ({reason}). Requer macOS 14.2+, swiftc (Xcode Command Line Tools) e permitir Gravação de áudio do sistema ao seu terminal',
357  },
358  ru: {
359    'cmd.description': 'Анимации спиннера: темы, питомец, предпросмотр, off / on (сохраняется между сессиями)',
360    'cmd.status': 'Тема: {theme}',
361    'cmd.randomNote': ' (случайная в каждой сессии)',
362    'cmd.themes': 'Темы: {list}',
363    'cmd.usage': 'Использование: /spinner <тема|random> · theme (выбор) · pet · preview [тема] · off / on · stage off / on · companion off / on',
364    'cmd.switched': 'Тема переключена на {theme}',
365    'cmd.random': 'Случайная тема в каждой сессии; сейчас: {theme}',
366    'cmd.hidden': 'Анимации спиннера выключены',
367    'cmd.shown': 'Анимации спиннера включены',
368    'cmd.stageOff': 'Анимированная полоса над вводом выключена',
369    'cmd.stageOn': 'Анимированная полоса над вводом включена',
370    'cmd.preview': 'Предпросмотр {theme} над вводом',
371    'cmd.unknown': 'Темы {name} нет. Темы: {list}',
372    'cmd.pick': 'Какую тему? (В «Другое» введите одну из: {list})',
373    'finale.done': 'Готово · {time}',
374    'finale.aborted': 'Прервано',
375    'finale.error': 'Что-то пошло не так',
376    'pet.hello': 'Я рядом~',
377    'pet.ask': 'Жду твоего OK~',
378    'pet.ready': 'Готово, посмотри!',
379    'pet.aborted': 'Ладно, остановился.',
380    'pet.error': 'Ой… ошибка',
381    'pet.sleep': 'Zzz…',
382    'pet.levelUp': '{theme} достиг ур.{level}!',
383    'pet.testPass': 'Тесты прошли!',
384    'pet.testFail': 'Тесты упали… починим',
385    'pet.commit': 'Закоммичено!',
386    'cmd.pat': 'Ты погладил {theme} (♥{love})',
387    'cmd.petStats': '{theme} · ур.{level} · {xp} оп. · ♥{love}',
388    'cmd.companionOff': 'Питомец между ходами скрыт',
389    'cmd.companionOn': 'Питомец между ходами показан',
390    'cmd.audioOn': 'Аудио: показывает звук, который выводит компьютер (только уровни, ничего не сохраняется и не отправляется)',
391    'cmd.audioOff': 'Аудио: нечего показать ({reason}). Нужны macOS 14.2+, swiftc (Xcode Command Line Tools) и разрешение «Запись системного аудио» для терминала',
392  },
393}
394
395export const { m, setLang } = createMessages(MESSAGES)
396
hooks/kit/band.tsx 31 lines
1// Generated from claude-code/kit/band.tsx by scripts/sync-kit.sh: edit the source, then re-run it.
2// A mod's rows in the band above the prompt. Several mods draw there, so each
3// stacks its rows over what the rest of the chain drew (`await next(e)`)
4// instead of replacing it, two cells in, as the engine indents the lines under
5// the prompt. The hook stays the mod's own (`on('ui.render', ...)` is spelled
6// in the hooks module): it yields to a survey, draws, and hands both here.
7import type { Elements, RenderElement } from 'claude-code'
8
9/**
10 * Whether the prompt's draft has a picker open: a slash command being named
11 * (`/sp`, before any space) or a file being mentioned (`@src/a`, the word at the
12 * cursor). The engine draws the picker above the band, so a band steps aside
13 * while one is open and the picker sits right on the prompt.
14 */
15export function isPickerOpen(text: string, cursor = text.length): boolean {
16  const before = text.slice(0, cursor)
17  return /^\/\S*$/.test(before) || /(^|\s)@\S*$/.test(before)
18}
19
20export function stackAbove(ui: Pick<Elements['terminal'], 'Box'>, mine: RenderElement, below: RenderElement): RenderElement {
21  const { Box } = ui
22  return (
23    <Box flexDirection="column">
24      <Box flexDirection="column" paddingLeft={2}>
25        {mine}
26      </Box>
27      {below}
28    </Box>
29  )
30}
31
hooks/kit/pet.tsx 87 lines
1// Generated from claude-code/kit/pet.tsx by scripts/sync-kit.sh: edit the source, then re-run it.
2// The companion pet wherever it stands. spinner owns it: it publishes the pet
3// as plain data (`spinner.dock`, a DockPet: its frames, bubble and stats) and
4// draws it itself above the prompt; hud, below the prompt, may give it a place
5// beside its rows, and says so in `hud.dock`. Either one draws the block here,
6// the frames played by the Client module `kit/pet-player.tsx`.
7import type { Elements, RenderElement } from 'claude-code'
8
9import type { DockPet } from '../../types'
10
11/** Cells the bubble gets at least beside the pet when docked. */
12export const LABEL_MIN = 14
13/** Room a docked pet must gain before it moves in, so a row's length changing does not toss it back and forth. */
14export const DOCK_SLACK = 6
15
16/** Cells `text` takes as the terminal draws it: CJK, fullwidth forms and emoji two, the rest (box drawing and blocks included) one. */
17export function cellWidth(text: string): number {
18  let w = 0
19  for (const ch of text) {
20    const cp = ch.codePointAt(0)!
21    const isWide =
22      (cp >= 0x1100 && cp <= 0x115f) ||
23      (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) ||
24      (cp >= 0xac00 && cp <= 0xd7a3) ||
25      (cp >= 0xf900 && cp <= 0xfaff) ||
26      (cp >= 0xfe30 && cp <= 0xfe4f) ||
27      (cp >= 0xff00 && cp <= 0xff60) ||
28      (cp >= 0xffe0 && cp <= 0xffe6) ||
29      (cp >= 0x1f300 && cp <= 0x1faff) ||
30      (cp >= 0x20000 && cp <= 0x3fffd)
31    w += isWide ? 2 : 1
32  }
33  return w
34}
35
36/** Whether the pet fits beside rows `rowWidth` wide in `columns`; once docked it stays while it fits at all. */
37export function dockFits(rowWidth: number, columns: number, petWidth: number, isDocked: boolean): boolean {
38  const spare = columns - rowWidth - 2 - petWidth - LABEL_MIN
39  return spare >= (isDocked ? 0 : DOCK_SLACK)
40}
41
42const TONE: Record<DockPet['tone'], { color?: string; bold?: boolean; dimColor?: boolean }> = {
43  plain: { color: '#b8b8be' },
44  ask: { color: '#ffd166', bold: true },
45  error: { color: '#e63946' },
46  aborted: { color: '#adb5bd' },
47  sleep: { color: '#6c757d', dimColor: true },
48}
49
50type Ui = Pick<Elements['terminal'], 'Box' | 'Text' | 'Client'>
51
52/** The pet in one row, for a band too short or narrow for its block: `mascot`, the bubble, the stats. */
53export function drawPetLine(ui: Pick<Ui, 'Box' | 'Text'>, pet: DockPet, mascot: { text: string; color?: string }): RenderElement {
54  const { Box, Text } = ui
55  return (
56    <Box flexDirection="row" columnGap={1} flexShrink={1}>
57      <Text color={mascot.color} bold>{mascot.text}</Text>
58      {pet.bubble ? (
59        <Text {...TONE[pet.tone]} wrap="truncate-end">
60          {pet.tone === 'ask' ? '❯ ' : ''}
61          {pet.bubble}
62        </Text>
63      ) : null}
64      <Text color="#6c757d">{pet.stats}</Text>
65    </Box>
66  )
67}
68
69/** The bubble and stats, right-aligned, then the pet: `rows` tall. */
70export function drawPet(ui: Ui, pet: DockPet, key: string): RenderElement {
71  const { Box, Text, Client } = ui
72  return (
73    <Box flexDirection="row" flexShrink={1} columnGap={1}>
74      <Box flexDirection="column" alignItems="flex-end" flexShrink={1}>
75        <Text color="#6c757d" wrap="truncate-end">{pet.stats}</Text>
76        {pet.bubble ? (
77          <Text {...TONE[pet.tone]} wrap="truncate-end">
78            {pet.tone === 'ask' ? '❯ ' : ''}
79            {pet.bubble}
80          </Text>
81        ) : null}
82      </Box>
83      <Client key={key} module="./pet-player.tsx" width={pet.width} props={pet} />
84    </Box>
85  )
86}
87
hooks/kit/lang.ts 68 lines
1// Generated from claude-code/kit/lang.ts by scripts/sync-kit.sh: edit the source, then re-run it.
2// A mod's language: the `language` option, or with `auto` Claude Code's own
3// `language` setting (free text: "简体中文", "Japanese", "pt-BR"), then the locale
4// (LC_ALL, LC_MESSAGES, LANG), then English. The messages stay the mod's own:
5// `createMessages(MESSAGES)` gives it `m` over them.
6
7export type Lang = 'en' | 'zh-Hans' | 'zh-Hant' | 'ja' | 'ko' | 'es' | 'fr' | 'de' | 'pt-BR' | 'ru'
8
9export const LANGS: readonly Lang[] = ['en', 'zh-Hans', 'zh-Hant', 'ja', 'ko', 'es', 'fr', 'de', 'pt-BR', 'ru']
10
11/** The `language` option's values, as a manifest's `options` lists them. */
12export const LANGUAGE_OPTIONS = ['auto', ...LANGS] as const
13
14// Names a person may write, folded (lowercase, no spaces, `-`, `_` or `.`).
15const NAMES: Record<string, Lang> = {
16  en: 'en', english: 'en', 英语: 'en', 英文: 'en', 英語: 'en',
17  zh: 'zh-Hans', zhcn: 'zh-Hans', zhsg: 'zh-Hans', zhhans: 'zh-Hans', chinese: 'zh-Hans',
18  simplifiedchinese: 'zh-Hans', 中文: 'zh-Hans', 简体中文: 'zh-Hans', 简体: 'zh-Hans', 汉语: 'zh-Hans', 普通话: 'zh-Hans',
19  zhtw: 'zh-Hant', zhhk: 'zh-Hant', zhmo: 'zh-Hant', zhhant: 'zh-Hant', traditionalchinese: 'zh-Hant',
20  繁體中文: 'zh-Hant', 繁体中文: 'zh-Hant', 正體中文: 'zh-Hant', 繁體: 'zh-Hant', 繁体: 'zh-Hant',
21  ja: 'ja', japanese: 'ja', 日本語: 'ja', 日语: 'ja',
22  ko: 'ko', korean: 'ko', 한국어: 'ko', 韩语: 'ko', 韓語: 'ko',
23  es: 'es', spanish: 'es', español: 'es', espanol: 'es', castellano: 'es', 西班牙语: 'es',
24  fr: 'fr', french: 'fr', français: 'fr', francais: 'fr', 法语: 'fr',
25  de: 'de', german: 'de', deutsch: 'de', 德语: 'de',
26  pt: 'pt-BR', ptbr: 'pt-BR', portuguese: 'pt-BR', brazilianportuguese: 'pt-BR', português: 'pt-BR', portugues: 'pt-BR', 葡萄牙语: 'pt-BR',
27  ru: 'ru', russian: 'ru', русский: 'ru', 俄语: 'ru',
28}
29
30const fold = (s: string) => s.trim().toLowerCase().replace(/[\s_.-]/g, '')
31
32/** "简体中文", "zh_TW.UTF-8", "es-MX", "Deutsch" → a language the mods have, else null. */
33export function parseLanguage(text: unknown): Lang | null {
34  if (typeof text !== 'string' || !text.trim()) return null
35  const bare = text.split('.')[0]!.split('@')[0]!
36  const named = NAMES[fold(bare)]
37  if (named) return named
38  // A tag with a region or script this table does not list: its first subtag.
39  const [primary = '', ...rest] = bare.trim().toLowerCase().split(/[-_\s]/)
40  if (primary === 'zh') return rest.some(s => ['tw', 'hk', 'mo', 'hant'].includes(s)) ? 'zh-Hant' : 'zh-Hans'
41  return NAMES[primary] ?? null
42}
43
44/** The language to draw in: the option unless `auto`, then the setting, then the locale. */
45export function resolveLanguage(option: unknown, setting: unknown, locale: readonly (string | undefined)[]): Lang {
46  if (typeof option === 'string' && option !== 'auto' && (LANGS as readonly string[]).includes(option)) return option as Lang
47  return parseLanguage(setting) ?? locale.map(parseLanguage).find(Boolean) ?? 'en'
48}
49
50
51export type Params = Record<string, string | number>
52
53/**
54 * `m(key, params?, lang?)` over a mod's table: the message in the current
55 * language (English when missing), its `{placeholders}` filled.
56 */
57export function createMessages<K extends string>(table: Record<Lang, Record<K, string>>) {
58  let current: Lang = 'en'
59  return {
60    m: (key: K, params: Params = {}, lang: Lang = current): string =>
61      (table[lang][key] ?? table.en[key]).replace(/\{(\w+)\}/g, (_, k: string) => String(params[k] ?? '')),
62    setLang: (lang: Lang): void => {
63      current = lang
64    },
65    lang: (): Lang => current,
66  }
67}
68
hooks/kit/prefs.ts 60 lines
1// Generated from claude-code/kit/prefs.ts by scripts/sync-kit.sh: edit the source, then re-run it.
2// `/config` is where a mod's settings live: a slash command that changes one
3// (`/ts off`, `/spinner neon`) writes that row as the person would in the menu,
4// so the menu shows it, settings.json keeps it, and the engine reloads the
5// module with it. The command also sets the mod's session state, so the change
6// shows at once, and stays for the session where no row can be written
7// (`claude -p` has no plugin rows).
8//
9// The engine follows `$` only within the hooks module's own file, so the mod
10// hands the kit closures over `$` (a `Prefs`) rather than `$` itself.
11import type { ConfigValue } from 'claude-code'
12
13/** The mod's store and its own `/config` rows, as closures the hooks module builds over `$`. */
14export type Prefs = {
15  kept: (key: string) => Promise<unknown>
16  forget: (key: string) => Promise<void>
17  /** `$.config.set` on `<plugin>.<field>`. */
18  write: (field: string, value: ConfigValue) => Promise<{ deny?: string }>
19}
20
21/** Writes one of the mod's rows; false when no row took it. */
22export async function persist(prefs: Prefs, field: string, value: ConfigValue): Promise<boolean> {
23  const result = await prefs.write(field, value).catch(() => null)
24  return result !== null && result.deny === undefined
25}
26
27/** What a value kept in the store under an older version becomes: a row and its value, or nothing. */
28export type Move = (kept: unknown) => readonly [field: string, value: ConfigValue] | null
29
30/** The rows values older versions kept in `$.store` stand for, which the session applies at once. */
31export async function keptRows(prefs: Prefs, moves: Readonly<Record<string, Move>>): Promise<Record<string, ConfigValue>> {
32  const rows: Record<string, ConfigValue> = {}
33  for (const [key, move] of Object.entries(moves)) {
34    const kept = await prefs.kept(key)
35    const target = kept === undefined ? null : move(kept)
36    if (target) rows[target[0]] = target[1]
37  }
38  return rows
39}
40
41/**
42 * Moves those values to their `/config` rows, once: a key is dropped only
43 * after its row took the value (or it has none to give). Run it after
44 * `session.start`'s `next(e)`: the plugin's rows join `/config` as the
45 * session comes up, and the write brings a reload that applies them.
46 */
47export async function migrateStore(prefs: Prefs, moves: Readonly<Record<string, Move>>): Promise<void> {
48  for (const [key, move] of Object.entries(moves)) {
49    const kept = await prefs.kept(key)
50    if (kept === undefined) continue
51    const target = move(kept)
52    if (!target || (await persist(prefs, target[0], target[1]))) await prefs.forget(key)
53  }
54}
55
56/** `off` → true, `on` → false, anything else flips `isOff`. */
57export function switchArg(arg: string, isOff: boolean): boolean {
58  return arg === 'off' ? true : arg === 'on' ? false : !isOff
59}
60
hooks/pet.ts 61 lines
1// What the companion knows and says: its level, its bubble, and the labels of
2// the turn it watches.
3import { m } from './i18n'
4import { textWidth } from './themes'
5import type { Act, Finale, Mood } from './themes'
6
7/** `12s`, `3m 05s`, `1h 02m`. */
8export function formatDuration(ms: number): string {
9  const s = Math.max(0, Math.round(ms / 1000))
10  if (s < 60) return `${s}s`
11  if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
12  return `${Math.floor(s / 3600)}h ${String(Math.floor(s / 60) % 60).padStart(2, '0')}m`
13}
14
15/** Lv.1 at 0 xp, Lv.2 at 2, Lv.3 at 8, Lv.4 at 18: one xp a finished turn. */
16export function levelOf(xp: number): number {
17  return Math.floor(Math.sqrt(Math.max(0, xp) / 2)) + 1
18}
19
20/** What a tool call is about, short: `Bash: npm test`, `Edit: themes.ts`, `WebSearch`. */
21export function toolLabel(e: { tool: string } & Record<string, unknown>): string {
22  const clip = (s: string) => (textWidth(s) > 32 ? `${Array.from(s).slice(0, 31).join('')}…` : s)
23  if (typeof e.command === 'string') return clip(`${e.tool}: ${e.command.split('\n')[0]!.trim()}`)
24  const path = [e.file_path, e.notebook_path, e.path].find(p => typeof p === 'string') as string | undefined
25  if (path) return clip(`${e.tool}: ${path.split('/').pop()}`)
26  if (typeof e.pattern === 'string') return clip(`${e.tool}: ${e.pattern}`)
27  // A subagent: what it was sent to do.
28  if (typeof e.description === 'string' && e.description.trim()) return clip(`${e.tool}: ${e.description.trim()}`)
29  return clip(e.tool.replace(/^mcp__[^_]+__/, ''))
30}
31
32/** What the pet makes of a finished shell command: tests that ran, a commit. */
33export type News = 'testPass' | 'testFail' | 'commit'
34
35// A test runner at the head of a command or after `&&`, `;`, `|`, `(`: npm test, pytest, go test…
36const TEST = /(?:^|[;&|(]\s*)(?:\S+=\S*\s+)*(?:(?:npm|pnpm|yarn|bun|deno)\s+(?:run\s+)?test\b|npx\s+(?:jest|vitest|mocha|playwright\s+test)\b|(?:jest|vitest|mocha|pytest|rspec|phpunit|tox|nox)\b|python3?\s+-m\s+(?:pytest|unittest)\b|(?:go|cargo|swift|dotnet|mix|mvn|gradle|\.\/gradlew|make|zig)\s+test\b|claude\s+plugin\s+test\b)/
37const COMMIT = /(?:^|[;&|(]\s*)git\s+(?:-\S+\s+(?:\S+\s+)?)*commit\b/
38
39export function newsOf(command: string, isError: boolean): News | null {
40  if (TEST.test(command)) return isError ? 'testFail' : 'testPass'
41  if (COMMIT.test(command) && !isError && !/--dry-run/.test(command)) return 'commit'
42  return null
43}
44
45/** The bubble for the calls running now: subagents side by side are counted, else the latest call. */
46export function busyLabel(labels: readonly string[]): string | undefined {
47  const agents = labels.filter(label => /^(?:Agent|Task)\b/.test(label))
48  return agents.length > 1 ? `Agent ×${agents.length}` : labels[labels.length - 1]
49}
50
51export function finaleOf(reason: string): Finale {
52  return reason === 'answer' ? 'answer' : reason === 'aborted' ? 'aborted' : 'error'
53}
54
55/** The bubble says what the engine's spinner line does not: the tool, a prompt waiting, how the turn ended. */
56export function bubbleOf(state: Act | Mood, tool: string | undefined): string {
57  if (state === 'tool') return tool ?? ''
58  if (state === 'think' || state === 'say' || state === 'wait') return ''
59  return m(`pet.${state}`)
60}
61
hooks/themes.ts 694 lines
1// Frame tables and scenes, shared by the hooks module and both surface modules
2// (sprite.tsx draws the spinner's mascot, stage.tsx the band). Pure:
3// everything here is a function of the theme, the tick and the width.
4import { blank, frame, hsl, mod, noise, padTo, put, textWidth } from './cells'
5import type { Grid, Style } from './cells'
6import { SCENE_ROWS, bluecatScene, chompScene, clawdScene, nyanScene, sparkyScene, thunderScene } from './scenes'
7import { AUDIO_BANDS, demoView } from './audio'
8import type { AudioFeed } from './audio'
9
10export * from './cells'
11
12export const THEME_NAMES = [
13  'clawd',
14  'thunder',
15  'chomp',
16  'sparky',
17  'bluecat',
18  'nyan',
19  'cat',
20  'bunny',
21  'sakura',
22  'mecha',
23  'neon',
24  'dino',
25  'ocean',
26  'matrix',
27  'audio',
28] as const
29export type ThemeName = (typeof THEME_NAMES)[number]
30/** Themes that start a process of their own: chosen by name, never drawn at random. */
31const OPT_IN: readonly ThemeName[] = ['audio']
32
33export type Mode = 'requesting' | 'responding' | 'thinking' | 'tool-input' | 'tool-use'
34export type Pose = 'think' | 'tool' | 'say' | 'wait'
35/** What the turn is doing, as the band's companion tells it. */
36export type Act = 'think' | 'tool' | 'ask' | 'say' | 'wait'
37export type Finale = 'answer' | 'aborted' | 'error'
38/** The companion's mood between turns. */
39export type Mood = 'hello' | 'ready' | 'aborted' | 'error' | 'sleep'
40
41export type Theme = {
42  name: ThemeName
43  /** The mascot's color, and a second one for its props. */
44  color: string
45  accent: string
46  /** Frames of the mascot beside the spinner's word, by what the turn is doing. */
47  sprite: Record<Pose, string[]>
48  /** Each character of the mascot in its own color, cycling. */
49  isRainbow?: boolean
50  happy: string
51  sad: string
52  dead: string
53  sleep: string
54  /** Particles of the finale's burst, and their colors. */
55  confetti: string[]
56  palette: string[]
57  /** Rows the scene takes in the band. */
58  rows: number
59  /** The band's scene while a turn runs: `rows` rows of `w` cells; `audio` feeds the audio theme. */
60  scene: (t: number, w: number, act: Act, audio?: AudioFeed) => Grid
61}
62
63/** Milliseconds per frame: the mascot's, the band's, the companion's between turns. */
64export const SPRITE_MS = 140
65export const STAGE_MS = 100
66export const IDLE_MS = 400
67/** How long the finale stays after a turn ends. */
68export const FINALE_MS = 3000
69
70export function poseOf(mode: string): Pose {
71  if (mode === 'thinking' || mode === 'think') return 'think'
72  if (mode === 'tool-use' || mode === 'tool-input' || mode === 'tool') return 'tool'
73  if (mode === 'responding' || mode === 'say') return 'say'
74  return 'wait'
75}
76
77export function isThemeName(name: unknown): name is ThemeName {
78  return typeof name === 'string' && (THEME_NAMES as readonly string[]).includes(name)
79}
80
81/** 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}
694
hooks/pets.ts 305 lines
1// The companion: a pixel pet three rows tall (6 px in half blocks), drawn the
2// same wherever it stands (beside the HUD, or right-aligned above the prompt).
3// A theme gives its body and where its eyes sit; blinking, breathing, the
4// expressions and the props beside it are the same for every pet.
5import { blank, canvas, cells, draw, mod, noise, plot, segments } from './cells'
6import type { Canvas, Grid } from './cells'
7import type { DockPet } from '../types'
8import type { Act, Mood } from './themes'
9
10export type PetState = Act | Mood
11
12export type PetArt = {
13  /** The body, 5 px tall: letters keyed in `colors`, `.` clear. Up to BODY_W wide. */
14  body: readonly string[]
15  /** The same body with its feet moved: a step while it works. */
16  step?: readonly string[]
17  colors: Record<string, string>
18  /** Each eye's column in the body; an eye is 1 px wide, 2 px tall, from row `eyeRow`. */
19  eyes: readonly number[]
20  eyeRow: number
21  /** The pupils' color, and the body's color where an eye closes. */
22  eye: string
23  lid: string
24  /** The mouth's column and row, opened while it talks. */
25  mouth?: readonly [x: number, y: number]
26  /** Its cheeks' color when happy. */
27  blush?: string
28  /** Props beside it: sparks while a tool runs, dots while it thinks. */
29  spark: string
30}
31
32/** Columns the pet takes: its props on the left, its body on the right. */
33export const PROP_W = 4
34export const BODY_W = 12
35export const PET_W = PROP_W + BODY_W
36export const PET_ROWS = 3
37/** Milliseconds per frame of the pet. */
38export const PET_MS = 140
39
40const QUESTION = ['.YY.', '...Y', '..Y.', '....', '..Y.']
41const HEART = ['P.P', 'PPP', '.P.']
42const ZED = ['ZZZZ', '..Z.', '.Z..', 'ZZZZ']
43const STAR = ['.S.', 'SSS', '.S.']
44
45/** Frames in one loop of a state: long enough for a blink and a breath. */
46export function loopOf(state: PetState): number {
47  return state === 'sleep' ? 48 : state === 'think' || state === 'tool' || state === 'say' ? 24 : 40
48}
49
50/** The pet at tick `t` in `state`, `hearts` frames into a pat (0: none). */
51export function petFrame(art: PetArt, state: PetState, t: number, hearts = 0): Grid {
52  const cv = canvas(PET_W, PET_ROWS)
53  const isBusy = state === 'tool' || state === 'say' || state === 'think'
54  // Breathing: up a pixel for part of each beat, quicker while it works; a hop when happy.
55  const beat = isBusy ? 6 : 16
56  let lift = mod(t, beat) < beat / 2 ? 1 : 0
57  if (state === 'ready' || state === 'hello') lift = mod(t, 10) < 2 ? 1 : lift
58  if (state === 'sleep' || state === 'error') lift = 0
59  const y = 1 - lift
60  const isStep = (state === 'tool' || state === 'say') && mod(t, 4) < 2
61  const body = isStep && art.step ? art.step : art.body
62  const colors = state === 'error' ? desaturate(art.colors) : art.colors
63  draw(cv, PROP_W, y, body, colors)
64  drawFace(cv, art, state, t, y)
65  drawProps(cv, art, state, t)
66  if (hearts > 0) {
67    const rise = Math.min(4, hearts >> 1)
68    draw(cv, 0, Math.max(0, 3 - rise), HEART, { P: hearts % 4 < 2 ? '#ff6b9d' : '#ff8fab' })
69    if (hearts > 3) draw(cv, PROP_W + BODY_W - 3, Math.max(0, 2 - (rise >> 1)), HEART, { P: '#ff8fab' })
70  }
71  return cells(cv)
72}
73
74function drawFace(cv: Canvas, art: PetArt, state: PetState, t: number, y: number): void {
75  const top = y + art.eyeRow
76  // A blink every few seconds, two frames long, at a different beat per eye pair.
77  const isBlink = mod(t, 28) < 2 && state !== 'sleep'
78  const lookAside = state === 'think' && mod(t, 24) >= 12 ? 1 : 0
79  for (const ex of art.eyes) {
80    const x = PROP_W + ex
81    // Clear the eye's two pixels to the lid first, then draw what this state shows.
82    plot(cv, x, top, art.lid)
83    plot(cv, x, top + 1, art.lid)
84    if (state === 'sleep' || isBlink || state === 'aborted') {
85      plot(cv, x, top + 1, art.eye)
86    } else if (state === 'ready' || state === 'hello') {
87      // Eyes curved up: the top pixel alone.
88      plot(cv, x, top, art.eye)
89    } else if (state === 'error') {
90      plot(cv, x, top, '#e63946')
91      plot(cv, x, top + 1, '#e63946')
92    } else {
93      plot(cv, x + lookAside, top, art.eye)
94      plot(cv, x + lookAside, top + 1, art.eye)
95    }
96    if ((state === 'ready' || state === 'hello') && art.blush) {
97      plot(cv, x + (ex < BODY_W / 2 ? -1 : 1), top + 1, art.blush)
98    }
99  }
100  if (state === 'aborted') {
101    // A tear under the first eye, falling.
102    const ex = PROP_W + art.eyes[0]!
103    plot(cv, ex, top + 2 + (mod(t, 8) < 4 ? 0 : 1), '#7ec8e3')
104  }
105  if (art.mouth && (state === 'say' || state === 'ask') && mod(t, 4) < 2) {
106    const [mx, my] = art.mouth
107    plot(cv, PROP_W + mx, y + my, art.eye)
108    plot(cv, PROP_W + mx + 1, y + my, art.eye)
109  }
110}
111
112function drawProps(cv: Canvas, art: PetArt, state: PetState, t: number): void {
113  if (state === 'think') {
114    // Three dots rising one after another.
115    for (let i = 0; i < 3; i++) {
116      const p = mod(t - i * 4, 12)
117      if (p < 6) plot(cv, 1 + i, 5 - p, i === 2 ? art.spark : '#8a8a8a')
118    }
119  } else if (state === 'tool') {
120    // Sparks flying off where it works.
121    for (let i = 0; i < 4; i++) {
122      if (noise(i * 31 + Math.floor(t / 2)) < 0.55) plot(cv, Math.floor(noise(i + t) * PROP_W), 1 + Math.floor(noise(i * 7 + t) * 4), i % 2 ? art.spark : '#ffd166')
123    }
124  } else if (state === 'ask') {
125    if (mod(t, 8) < 6) draw(cv, 0, 0, QUESTION, { Y: '#ffd166' })
126  } else if (state === 'sleep') {
127    const p = mod(t, 24)
128    draw(cv, 0, Math.max(0, 2 - (p >> 3)), ZED, { Z: p < 16 ? '#8d99ae' : '#5c677d' })
129  } else if (state === 'ready' || state === 'hello') {
130    if (mod(t, 20) < 10) draw(cv, 0, mod(t, 20) < 5 ? 1 : 2, STAR, { S: art.spark })
131  }
132}
133
134function desaturate(colors: Record<string, string>): Record<string, string> {
135  return Object.fromEntries(Object.entries(colors).map(([k, hex]) => [k, gray(hex)]))
136}
137
138function gray(hex: string): string {
139  const n = Number.parseInt(hex.slice(1), 16)
140  const l = Math.round(((n >> 16) * 0.3 + ((n >> 8) & 255) * 0.59 + (n & 255) * 0.11) * 0.7)
141  const h = l.toString(16).padStart(2, '0')
142  return `#${h}${h}${h}`
143}
144
145/** A blank pet-sized grid, for a theme with no pet. */
146export function noPet(): Grid {
147  return blank(PET_W, PET_ROWS)
148}
149
150// ---- the pets ------------------------------------------------------------------
151
152/** Clawd, as Claude Code draws him: a warm orange body, arms out, four legs. */
153export const CLAWD_PET: PetArt = {
154  body: [
155    '.LLOOOOOOOO.',
156    '.OOOOOOOOOO.',
157    'OOOOOOOOOOOO',
158    '.DOOOOOOOOD.',
159    '..D.D..D.D..',
160  ],
161  step: [
162    '.LLOOOOOOOO.',
163    '.OOOOOOOOOO.',
164    'OOOOOOOOOOOO',
165    '.DOOOOOOOOD.',
166    '.D.D....D.D.',
167  ],
168  colors: { O: '#d77757', L: '#e8957a', D: '#a9553b' },
169  eyes: [3, 8],
170  eyeRow: 1,
171  eye: '#2b1d18',
172  lid: '#d77757',
173  mouth: [5, 3],
174  blush: '#f2a28c',
175  spark: '#f4a261',
176}
177
178/** One loop of the pet in `state` as DockPet data: distinct frames and their order. */
179export function dockPetOf(
180  art: PetArt,
181  state: PetState,
182  view: Pick<DockPet, 'id' | 'bubble' | 'tone' | 'stats'>,
183  isPatted = false,
184  isStill = false,
185): DockPet {
186  const frames: DockPet['frames'] = []
187  const seen = new Map<string, number>()
188  const order: number[] = []
189  // Still: the loop's first frame alone, which the player never has to redraw.
190  const loop = isStill ? 1 : loopOf(state)
191  for (let t = 0; t < loop; t++) {
192    // A pat floats hearts over the first frames of the loop.
193    const rows = petFrame(art, state, t, isPatted && t < 16 ? t + 1 : 0).map(row => segments(row).map(plainSeg))
194    const key = JSON.stringify(rows)
195    let at = seen.get(key)
196    if (at === undefined) {
197      at = frames.length
198      frames.push(rows)
199      seen.set(key, at)
200    }
201    order.push(at)
202  }
203  return { ...view, frames, order, ms: PET_MS, width: PET_W }
204}
205
206/** A run as plain data: a key left undefined is refused in state and Client props. */
207function plainSeg(seg: ReturnType<typeof segments>[number]): DockPet['frames'][number][number][number] {
208  return Object.fromEntries(Object.entries(seg).filter(([, v]) => v !== undefined)) as DockPet['frames'][number][number][number]
209}
210
211/** A small fighter with a face in its canopy, its exhaust flickering. */
212export const THUNDER_PET: PetArt = {
213  body: ['..AA........', '.ABBBBWWWW..', 'FBBBBBWWWWBC', '.ABBBBBBBB..', '..AA........'],
214  step: ['..AA........', '.ABBBBWWWW..', 'GBBBBBWWWWBC', '.ABBBBBBBB..', '..AA........'],
215  colors: { A: '#4361ee', B: '#4cc9f0', W: '#e0fbfc', C: '#f72585', F: '#ff9f1c', G: '#ffd60a' },
216  eyes: [7, 9],
217  eyeRow: 1,
218  eye: '#14213d',
219  lid: '#e0fbfc',
220  mouth: [7, 3],
221  blush: '#ff8fab',
222  spark: '#ffd60a',
223}
224
225/** A maze ghost, its skirt rippling. */
226export const CHOMP_PET: PetArt = {
227  body: ['...GGGGGG...', '..GWWGGWWG..', '.GGWWGGWWGG.', '.GGGGGGGGGG.', '.G.GG.GG.GG.'],
228  step: ['...GGGGGG...', '..GWWGGWWG..', '.GGWWGGWWGG.', '.GGGGGGGGGG.', '.GG.GG.GG.G.'],
229  colors: { G: '#ff5d8f', W: '#ffffff' },
230  eyes: [4, 8],
231  eyeRow: 1,
232  eye: '#2121de',
233  lid: '#ffffff',
234  mouth: [5, 3],
235  spark: '#ffff00',
236}
237
238/** An electric mouse: black-tipped ears, red cheeks. */
239export const SPARKY_PET: PetArt = {
240  body: ['KY.....YK.Y.', '.YYYYYYY.YY.', 'YYYYYYYYY.Y.', 'RYYYYYYYR...', '.YY.YY.YY...'],
241  step: ['KY.....YK..Y', '.YYYYYYY.YY.', 'YYYYYYYYYY..', 'RYYYYYYYR...', 'YY..YY..YY..'],
242  colors: { K: '#5a3a22', Y: '#ffd60a', R: '#e63946' },
243  eyes: [2, 6],
244  eyeRow: 1,
245  eye: '#5a3a22',
246  lid: '#ffd60a',
247  blush: '#ff8fab',
248  spark: '#fff3b0',
249}
250
251/** A blue robot cat: white face, red nose, a bell on its collar. */
252export const BLUECAT_PET: PetArt = {
253  body: ['..BBBBBBBB..', '.BBWWWWWWBB.', 'BBWWWWWWWWBB', 'BBWWWRRWWWBB', '.BRRRYYRRRB.'],
254  colors: { B: '#0096c7', W: '#ffffff', R: '#e63946', Y: '#ffd60a' },
255  eyes: [4, 7],
256  eyeRow: 1,
257  eye: '#1b1b1b',
258  lid: '#ffffff',
259  mouth: [5, 3],
260  blush: '#ff8fab',
261  spark: '#80ffdb',
262}
263
264/** A pop-tart cat, its head poking out on the right. */
265export const NYAN_PET: PetArt = {
266  body: ['.TTTTT.G...G', 'TPSPPTGGGGGG', 'TPPSPTGGGGGG', 'TPSPPTGGGGGG', '.L.L.L..L.L.'],
267  step: ['.TTTTT.G...G', 'TPSPPTGGGGGG', 'TPPSPTGGGGGG', 'TPSPPTGGGGGG', 'L.L.L..L.L..'],
268  colors: { T: '#ffcc99', P: '#ff99cc', S: '#ff3399', G: '#a0a0a0', L: '#a0a0a0' },
269  eyes: [8, 10],
270  eyeRow: 1,
271  eye: '#1b1b1b',
272  lid: '#a0a0a0',
273  blush: '#ff99cc',
274  spark: '#ffff00',
275}
276
277/** A round listener in big headphones, swaying to whatever plays. */
278export const AUDIO_PET: PetArt = {
279  body: ['..HHHHHHHH..', '.H.BBBBBB.H.', 'CCBBBBBBBBCC', 'CCBBBBBBBBCC', '.B.B....B.B.'],
280  step: ['..HHHHHHHH..', '.H.BBBBBB.H.', 'CCBBBBBBBBCC', 'CCBBBBBBBBCC', '..B.B..B.B..'],
281  colors: { H: '#5a189a', C: '#c77dff', B: '#e0aaff' },
282  eyes: [4, 7],
283  eyeRow: 1,
284  eye: '#240046',
285  lid: '#e0aaff',
286  mouth: [5, 3],
287  blush: '#ff8fab',
288  spark: '#4cc9f0',
289}
290
291const PETS: Record<string, PetArt> = {
292  clawd: CLAWD_PET,
293  thunder: THUNDER_PET,
294  chomp: CHOMP_PET,
295  sparky: SPARKY_PET,
296  bluecat: BLUECAT_PET,
297  nyan: NYAN_PET,
298  audio: AUDIO_PET,
299}
300
301/** A theme's pet; a theme without one of its own borrows Clawd's shape in its color. */
302export function petArtOf(theme: string, color: string): PetArt {
303  return PETS[theme] ?? { ...CLAWD_PET, colors: { O: color, L: color, D: color }, lid: color }
304}
305
hooks/audio.ts 116 lines
1// The `audio` theme's feed: lines from the tap (hooks/audio-tap.swift) to what
2// its scene draws. The meter lives in the hooks module; the band's Client asks
3// for its latest view each frame (register.tsx, `ui.message`). Pure but for the
4// meter's own memory.
5
6/** Bands the tap is asked for, low to high. */
7export const AUDIO_BANDS = 32
8/** The tap's line period, in milliseconds. */
9export const TAP_MS = 50
10
11/**
12 * One frame for the scene: band levels and their falling peaks (0..99, gained
13 * to the last few seconds' loudest), and a count of beats so far.
14 */
15export type AudioView = { b: number[]; p: number[]; beat: number }
16
17/**
18 * What the scene is given: live levels, `null` when the tap could not run,
19 * or nothing at all (a preview, a screenshot, a surface without processes)
20 * for a made-up signal.
21 */
22export type AudioFeed = AudioView | null | undefined
23
24/** A tap line: `L <loudness> <band>...`, each 0..99; anything else is null. */
25export function parseTapLine(line: string): { loud: number; bands: number[] } | null {
26  const parts = line.trim().split(' ')
27  if (parts[0] !== 'L' || parts.length < 3) return null
28  const nums = parts.slice(1).map(Number)
29  if (nums.some(n => !Number.isFinite(n))) return null
30  const [loud, ...bands] = nums.map(n => Math.max(0, Math.min(99, n)))
31  return { loud: loud!, bands }
32}
33
34/** Splits streamed text into whole lines, keeping a cut-off tail for the next piece. */
35export function lineSplitter(): (text: string) => string[] {
36  let rest = ''
37  return text => {
38    const lines = (rest + text).split('\n')
39    rest = lines.pop() ?? ''
40    return lines
41  }
42}
43
44/** Below this a band is drawn as nothing: the tap's floor of hiss. */
45const FLOOR = 6
46/** The gain's ceiling never drops under this, so near-silence is not blown up. */
47const MIN_CEILING = 35
48/** Per line: the ceiling eases down this much toward the bands' level (about 7 s to half). */
49const CEILING_DECAY = 0.995
50/** Per line: how far a peak falls. */
51const PEAK_FALL = 4
52/** A beat: loudness this far over its running average, at least this loud, this many lines apart. */
53const BEAT_RATIO = 1.3
54const BEAT_MIN = 12
55const BEAT_GAP = 5
56/** Lines of quiet before the meter counts as silent (3 s). */
57const QUIET_LINES = 60
58
59/** The tap's lines in, the scene's view out: gain, peaks, beats and whether anything plays. */
60export class AudioMeter {
61  private ceiling = MIN_CEILING
62  private levels: number[] = new Array(AUDIO_BANDS).fill(0)
63  private peaks: number[] = new Array(AUDIO_BANDS).fill(0)
64  private average = 0
65  private beats = 0
66  private sinceBeat = BEAT_GAP
67  private sinceSound = QUIET_LINES
68
69  /** Takes one tap line; returns whether it was a reading. */
70  push(line: string): boolean {
71    const reading = parseTapLine(line)
72    if (!reading) return false
73    const { loud, bands } = reading
74    const top = Math.max(0, ...bands)
75    this.ceiling = Math.max(MIN_CEILING, top, this.ceiling * CEILING_DECAY)
76    this.levels = Array.from({ length: AUDIO_BANDS }, (_, i) => {
77      const raw = bands[Math.floor((i * bands.length) / AUDIO_BANDS)] ?? 0
78      return raw < FLOOR ? 0 : Math.round(Math.min(99, (raw * 99) / this.ceiling))
79    })
80    this.peaks = this.peaks.map((p, i) => Math.max(this.levels[i]!, p - PEAK_FALL))
81
82    this.sinceBeat++
83    if (loud >= BEAT_MIN && loud > this.average * BEAT_RATIO && this.sinceBeat >= BEAT_GAP) {
84      this.beats++
85      this.sinceBeat = 0
86    }
87    this.average = this.average * 0.9 + loud * 0.1
88    this.sinceSound = top >= FLOOR ? 0 : this.sinceSound + 1
89    return true
90  }
91
92  /** Whether anything has played in the last few seconds. */
93  get isAudible(): boolean {
94    return this.sinceSound < QUIET_LINES
95  }
96
97  view(): AudioView {
98    return { b: [...this.levels], p: [...this.peaks], beat: this.beats }
99  }
100}
101
102/** A made-up signal with a beat, for when no tap feeds the scene. */
103export function demoView(t: number): AudioView {
104  const kick = Math.max(0, 1 - (t % 5) / 3)
105  const b = Array.from({ length: AUDIO_BANDS }, (_, i) => {
106    const u = i / AUDIO_BANDS
107    const v =
108      0.12 +
109      0.5 * kick * Math.max(0, 1 - u * 3) +
110      0.3 * (0.5 + 0.5 * Math.sin(t * 0.45 + i * 0.6)) * (1 - u * 0.5) +
111      0.2 * (0.5 + 0.5 * Math.sin(t * 0.9 - i * 1.3))
112    return Math.round(Math.min(99, v * 90))
113  })
114  return { b, p: b.map(v => Math.min(99, v + 10)), beat: Math.floor(t / 5) }
115}
116