SLOPSHOPPER

pixel-pals

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

newbandspinnerguardcommandtoast
★ 2v1.0.0MITupdated 2026-10-10SeongGwangJu/k-mods/mods/pixel-pals
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pixel-pals
› 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 › /pals ⎿ pixel-pals: 현재 테마: tales (턴마다 무작위) ⎿ pixel-pals: tales · Lv.1 · 경험치 1 · ♥0 ⎿ pixel-pals: 대기 중 펫을 숨겼어요 ⎿ pixel-pals: 테마 목록: clawd ▐▛█▜▌ ✻ · thunder =▷ ✶✶✶ · chomp ᗧ ᗣᗣᗣ · sparky ϟ(^ᴥ^)ϟ · bluecat ⊂(◕‿◕)つ · nyan ~=[,,^,,]:3 · ta ⎿ pixel-pals: 사용법: /pals <테마|random> · theme 선택 · pet 쓰다듬기 · preview [테마] · off / on · stage off / on · companion off / on ▄▀▀▄ ▄ ▄ ▄ ▀▀▀▀ ▄ ▀▀▀▀▀▀▀▄ ▄▀▀▀▄ ▄▀▀▀▄ ▄▀▀▀▄ ▀▀▀▀▀▀▀▀▀▀▀▀▄▄ ▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀ ▀▄▀▄▄▄▄▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 끝 · 9걸음 · 42초 ctx 49% · 5h 31% ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▄▀▀▄ ▄ ▄ ▄ ▀▀▀▀ ▄ ▀▀▀▀▀▀▀▄ ▄▀▀▀▄ ▄▀▀▀▄ ▄▀▀▀▄ ▀▀▀▀▀▀▀▀▀▀▀▀▄▄ ▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀ ▀▄▀▄▄▄▄▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 끝 · 9걸음 · 42초 ctx 49% · 5h 31% ⟨Claude Code's own drawing⟩
README

pixel-pals (픽셀 친구들)

Claude가 일하는 동안 입력창 위에 냥캣·Clawd·썬더 같은 픽셀 친구들이 지나가요. 원작 두 mod에서 장면을 골라 합치고 한국어로 옮긴 k-mods 수정판이에요.

<img src="../../docs/assets/mods/pixel-pals.gif" alt="냥캣이 무지개를 끌고 지나가는 장면" width="640">

설치

/plugin marketplace add SeongGwangJu/k-mods
/plugin install pixel-pals@k-mods

쓰는 법

설치하면 바로 동작해요. Claude가 일하는 동안 입력창 위 띠에 장면이 흘러가고, 턴이 끝나면 짧은 피날레가 나와요. 테마는 턴마다 아래 7가지 중 하나로 바뀌어요.

테마모습
nyan무지개를 끌고 날아가는 냥캣
clawdClaude 마스코트 Clawd가 블록을 쌓아요
thunder우주선이 적을 맞히는 슈팅 게임
chomp점을 먹으며 유령을 쫓는 동그라미
sparky번개 꼬리 생쥐
bluecat주머니에서 도구를 꺼내는 파란 고양이 로봇
talesclawd-tales의 Clawd 이야기. 도구 호출마다 몸짓과 한 줄 캡션, 서브에이전트 동료, 컨텍스트에 따른 날씨
  • /pals: 지금 테마와 펫 상태, 사용법
  • /pals <테마> / /pals random: 테마 고르기 (random은 턴마다 새로)
  • /pals off / on: 애니메이션 끄기·켜기. stage off·on은 장면 띠만, companion on은 입력창 위 펫 줄
  • /pals preview <테마>: 8초 미리 보기 (tales는 /tales demo로)
  • /tales demo: Clawd 이야기 45초 데모. /tales hat <이름>·face <이름>·scarf on|off로 꾸미고, calm은 1초에 한 번만 움직여요

설정 (/config 또는 /plugin configure pixel-pals@k-mods)

설정기본값설명
테마random위 7가지 중 하나, 또는 턴마다 새로
장면 띠켜짐모델이 일하는 동안 입력창 위 장면
피날레켜짐턴이 끝나면 걸린 시간과 축하
펫꺼짐입력창 위 한 줄 펫. 턴마다 레벨이 올라요
움직임 줄이기꺼짐장면과 펫을 정지 그림으로
언어ko응답·피날레·Clawd 이야기 캡션의 언어 (Clawd 이야기는 한국어·영어만)

원본과 다른 점

원본pixel-pals
구성spinner(hoobnn), clawd-tales(plaxagoras) 따로한 mod. clawd-tales는 tales 테마
테마spinner 15종 + audio6종 엄선 + tales
random세션마다, 14종 중턴마다, 7종 중
언어자동 / 영어(clawd-tales)한국어 기본, Clawd 이야기 캡션도 한국어
명령/spinner, /tales/pals, /tales
펫 줄·하단 버튼·줄 앞 마스코트켜짐꺼짐 (/config에서 켤 수 있어요)
audio 테마(swiftc로 소리 읽기)있음없음
hud(hoobnn) 연동있음없음

자세한 내역은 CHANGES-KO.md에 있어요.

어떻게 동작하나요

  • 턴이 시작할 때: random이면 7종 중 테마를 새로 골라요.
  • 도구를 부를 때·확인을 기다릴 때: 펫과 Clawd가 지금 하는 일에 맞춰 몸짓을 바꿔요. 지켜보기만 하고 도구 호출을 막거나 바꾸지 않아요.
  • 입력창 위를 그릴 때: 테마가 tales면 clawd-tales의 무대가, 아니면 장면 띠가 그려요. 둘이 한 번에 그리지 않아요.
  • 턴이 끝날 때: 피날레를 잠깐 보여주고 펫이 경험치를 얻어요.

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

화면을 그리고, 이 플러그인 전용 저장소($.store)에 펫 상태와 Clawd의 모자·성장 기록을 저장해요. 세션의 컨텍스트·사용량 수치와 실행 중인 서브에이전트 목록을 읽어 날씨와 동료로 보여줘요. 네트워크, 모델 호출, 프로그램 실행은 없어요.

한계

  • 장면은 터미널에서만 보여요. 데스크톱 앱에서는 tales 테마일 때만 띠가 그려져요.
  • / 명령이나 @ 파일 선택 창이 열리면 장면 띠는 잠깐 비켜요.
  • nyan·chomp·sparky·bluecat은 원작자가 픽셀 오마주라고 부른 장면이고, Clawd는 Anthropic의 마스코트예요. 비공식 팬 작품이에요.
  • 원본 spinner나 clawd-tales와 같이 켜면 띠가 겹쳐 보여요.

출처·라이선스

원작자들께 감사드려요. 원본 저작권 표기와 라이선스는 위 파일에 그대로 뒀어요.

테스트한 Claude Code 버전: 2.1.296

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