A pixel Clawd acts out every tool call above the prompt; helpers wander in by model color. Terminal-first, no model calls.

A pixel Clawd that acts out what Claude Code is doing, in a band above your prompt.
Claude Fables draws a model-written cartoon of your session in the desktop app. clawd-tales does it in the terminal from templates, so it costs no tokens.

The 45-second /tales demo in the dungeon: Clawd hatches and changes into Opus, helpers walk in, slimes get loose and Clawd fights them, Claude calls for you, a note goes up on the wall, and Clawd grows up.
Glance down and you know what Claude is doing. A red ! and a jumping Clawd means it is waiting on you. Rain means the context is nearly full.
Every tool call gets a pose and a one-line caption. Claude reads a book for Read, digs for Edit, sneaks for Grep, runs for Bash, flies for web search. Subagents walk in as their own critters, colored by model.
You ask Claude to fix a failing test. Clawd runs off to pytest and three bugs drop onto the ground. It reads the test file with a book open, then digs into the code. A red ! appears and Clawd jumps: Claude wants to run a command. You approve within a few seconds, and Clawd beams with hearts. The tests go green, Clawd eats all three bugs, and a turn that long ends in a dance.
| Happening in Claude Code | In the band |
|---|---|
| A tool call | Clawd's pose and a caption naming the file, pattern or command |
| A subagent starts | A helper walks in: purple for Opus, teal for Sonnet, green for Haiku, gold for Fable |
| A subagent finishes | It runs back to Clawd and they cheer, with hearts |
| A permission dialog opens | Clawd jumps next to a red ! and the caption turns red |
| A test run fails | Bugs drop onto the ground, one per failing test (up to 8) |
| The tests go green | Clawd eats the bugs |
| A todo list | Pending items sit on the ground as pellets. Finishing one is a gobble |
| Context fills up | Clouds at 50%, rain and an umbrella at 70%, lightning at 85% |
| The turn ends | A nod if it took under 4 seconds, a hop up to a minute, a dance up to five, and past that a flag planted at the summit. Then Clawd sits, yawns and falls asleep. The band hides after 3 minutes; your next prompt wakes Clawd, and a new helper brings the band back |
| A plan limit hits 100% | Clawd sleeps until the reset time. A minute before, it stretches, and once the limit resets it's up |
| Claude waits on a subagent | Clawd puts on headphones. With two or more out, it juggles one ball per helper, each in that helper's color |
| Three or more subagents out | Clawd wears a tie. It's the boss now |
| An MCP tool call | A ring of color spreads from Clawd. Each server gets its own color |
| Every 50K tokens | Confetti, and the count in the turn's last caption |
| Claude thinks for a while between tool calls | A thought bubble fills in over the pose it is holding |
| Five clean tool calls in a row | A combo counter. At ten, Clawd wears a crown |
| A subagent fails | It trips, then walks back to Clawd for a pat on the head |
| Four or more subagents out at once | The idle ones walk in a line behind the first one |
| Two errors in a row | Clawd goes cross-eyed with stars circling. A third flips the table |
| You approve a permission prompt quickly | Hearts. Leave one waiting past 30 seconds and Clawd starts to sweat, with a timer in the caption |
| The conversation is compacted | Clawd sweeps the stage with a broom and the weather clears |
Claude writes to a memory file (a MEMORY.md, or anything in a memory/ folder) | Clawd holds up a sticky note and pins it to the wall |
| You switch models between turns | Clawd hops in the new model's hat for a few seconds: a sprout for Haiku, a beret for Sonnet, a purple crown for Opus, a gold one for Fable |
| A turn dies on an API error | Clawd ends it next to a sparking grey cloud, sweating |
| A session starts | Clawd drops in from the sky. A resumed session gets a wave hello. The first new session after you install hatches Clawd from an egg, and progress starts counting from there |
| 200 tool calls | The hatchling loses its eggshell cap and grows up into whatever work it did most: a scholar (reading), a detective (searching), a builder (editing), a hacker (commands), an explorer (the web) or a captain (helpers). Each wears its own hat, and the form can change as your habits do |
git commit wraps a parcel and pops confetti. git push sends a paper plane off the stage. rm -rf makes Clawd cover its eyes. Package installs drop boxes from the sky.sl train across the stage./tales calm skips these.Under the caption, a status line shows ctx 74% · 5h 22% · 7d 23%, plus the todo count and a roster of the helpers. Numbers turn yellow at 80% and red at 95%.
/tales theme dungeon sends Clawd down a brick hallway lit by wall lamps. The story is the same, told underground:
The band shrinks with your terminal: the full band at 8 rows, no sky at 6, then a single line.
Needs a Claude Code build with mods (function hooks). Built and tested on 2.1.289; run claude --version to see yours. After installing, start a new session and type /: if /tales isn't in the list, the mod didn't load, and your build probably doesn't support mods yet.
claude plugin marketplace add plaxagoras/clawd-tales
claude plugin install clawd-tales@clawd-tales
Start a new session, then run /tales demo.
To try it for one session without installing:
git clone https://github.com/plaxagoras/clawd-tales
claude --plugin-dir ./clawd-tales
| Command | What it does |
|---|---|
/tales demo | Plays a short story: a hatching, a model switch, helpers, bugs, pellets, weather, a permission call, a memory note and growing up. Your own Clawd's progress isn't touched |
/tales theme dungeon / meadow | Swaps the meadow for a lamplit dungeon hallway (/tales dungeon works too). Saved between sessions |
/tales calm | One frame a second, and nobody wanders between tool calls |
/tales lively | Back to 5 frames a second (the default) |
/tales off / /tales on | Hide or show the band |
/tales | Shows the current state |
/tales hat <name> | Puts on a top hat, grad cap, captain's hat, wizard hat, hard hat, deerstalker, beanie, pith helmet or eggshell (none takes it off). Saved between sessions |
/tales face <name> | Glasses, shades or a mustache (none takes them off). Saved between sessions |
/tales scarf on / off | Each project gets its own scarf color, picked from the project folder, so you can tell repos apart. On by default |
/tales holiday <name> | Previews a holiday look until the next restart (auto goes back to the calendar) |
Your on/off and calm settings are saved between sessions.
It is a Claude Code mod: a plugin of function hooks in hooks/register.tsx. It listens to tool.call, agent.spawn, classic.PermissionRequest, classic.UserPromptSubmit (for plan mode), session.measure and the turn events, and draws with ui.render on the AbovePrompt band. Each terminal cell is two pixels (▀ with a foreground and a background color).
Bash output of commands that look like test runs (npm test, pytest, cargo test, go test and similar).$.agent.list().Poses are pixel rows in sprite() in hooks/art.ts. Which tool gets which pose and caption is beat() in hooks/story.ts. Run claude --plugin-dir . and your edits reload as you go; claude plugin test . runs the tests. Pull requests with a short GIF of the new pose are welcome.
The walk and cheer frames and the base palette come from Claude Fables by henrik-thevibe (MIT). See NOTICE.
Clawd is Anthropic's mascot. This is an unofficial fan project, not affiliated with Anthropic.
MIT
hooks/register.tsx 1100 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Action, Emote, Face, Form, Fx, FxKind, Hat, Hero, Holiday, Limit, Stats, Theme, Worker } from '../types'
5import { CONGA_AT, HERO_W, SCENE_COUNT, TIER_COLOR, glyph, lampsOf, transition, lines, speed, stage, tierOf, weatherOf } from './art'
6import type { Egg } from './story'
7import {
8 COMBO_AT,
9 CROWN_AT,
10 FORM_HAT,
11 GROW_AT,
12 ambushCaption,
13 dungeonBeat,
14 victoryCaption,
15 NEW_STATS,
16 REST,
17 THINKING,
18 bashEggs,
19 beat,
20 clockTime,
21 countCall,
22 eggBeat,
23 eggDone,
24 ending,
25 endingAction,
26 fidgetAt,
27 fishing,
28 formOf,
29 growCaption,
30 holidayOf,
31 isLate,
32 isMidnightNewYear,
33 modelSwitch,
34 moodOf,
35 notFound,
36 readStats,
37 restCaption,
38 scarfColor,
39 skyOf,
40 startCaption,
41 stuckCaption,
42 testCounts,
43 tokenMilestone,
44 tripCaption,
45 waitBeat,
46 warpColor,
47} from './story'
48
49const IDLE: Hero = { mode: 'idle', action: 'walk', caption: '', x: 0, dir: 1, scene: 0, steps: 0, since: 0 }
50
51const hero = atom({ plugin: 'clawd-tales', key: 'hero' } as const, IDLE)
52const workers = atom({ plugin: 'clawd-tales', key: 'workers' } as const, [])
53const frame = atom({ plugin: 'clawd-tales', key: 'frame' } as const, 0)
54const isOn = atom({ plugin: 'clawd-tales', key: 'isOn' } as const, true)
55const alert = atom({ plugin: 'clawd-tales', key: 'alert' } as const, null)
56const context = atom({ plugin: 'clawd-tales', key: 'context' } as const, null)
57const limits = atom({ plugin: 'clawd-tales', key: 'limits' } as const, [])
58const calm = atom({ plugin: 'clawd-tales', key: 'calm' } as const, false)
59const bugs = atom({ plugin: 'clawd-tales', key: 'bugs' } as const, 0)
60const todos = atom({ plugin: 'clawd-tales', key: 'todos' } as const, null)
61const fx = atom({ plugin: 'clawd-tales', key: 'fx' } as const, [])
62const shiny = atom({ plugin: 'clawd-tales', key: 'shiny' } as const, null)
63const combo = atom({ plugin: 'clawd-tales', key: 'combo' } as const, 0)
64const gold = atom({ plugin: 'clawd-tales', key: 'gold' } as const, 0)
65const theme = atom({ plugin: 'clawd-tales', key: 'theme' } as const, 'meadow' as Theme)
66
67const TICK_MS = 200
68const CALM_TICK_MS = 1000 // calm mode: one beat a second
69const CLIP_STEP_MS = TICK_MS // each in-between pose holds for one beat
70const POLL_EVERY = 5 // ticks between $.agent.list() checks
71const THINK_AFTER_MS = 15000 // a helper with no tool call for this long wanders again
72const TRIP_MS = 2500
73const LINGER_MS = 8000 // the closing cheer lasts this long, then the rest ladder
74const HUG_MS = 3000 // a helper that made it back stays this long with hearts
75const RETURN_MAX_MS = 10_000 // a helper that can't reach Claude hands in anyway
76const ENDED = new Set(['completed', 'failed', 'killed'])
77const THINK_POSE_MS = 8000 // no tool call for this long mid-turn: a thought bubble
78const MOOD_MS = 4000 // a blush or a sweat drop lasts this long
79const VISITOR_ODDS = 1 / 25 // per prompt
80const SHINY_ODDS = 1 / 100 // per session
81const FX_MS: Record<FxKind, number> = { confetti: 2200, plane: 3500, boxes: 6000, train: 8000, whale: 16000, ufo: 10000, warp: 1200, coin: 900, coins: 1600, poof: 700 }
82const HOLIDAYS: readonly Holiday[] = ['halloween', 'christmas', 'newyear', 'valentine', 'aprilfools']
83
84// Module-level: a reload starts these over, and session.start picks the story back up.
85let timer: Timer | undefined
86let isCalm = false // mirrors the calm atom for the tick's own pacing
87let isDungeon = false // mirrors the theme atom, for captions
88let ticks = 0
89let cols = 100
90let heroCalls = 0 // main-loop tool calls still running: a trip waits for them to end
91let tripUntil = 0
92let startedAt = 0
93let lastCallAt = 0 // the last main-loop tool call's end, for the thought bubble
94let best = 0 // the turn's best combo
95let fxId = 0
96let holidayPick: Holiday | 'none' | null = null // /tales holiday: a preview, this load only
97let alertAt: number | null = null // when the current call for the user began
98let errStreak = 0 // main-loop errors in a row
99let nextGlanceAt = 0
100let planMode = false // the last prompt went in under plan mode: the wizard hat
101let lastModel: string | null = null // the session's model at the last turn start
102let modelHat: { hat: Hat; until: number } | null = null // tried on after a model switch
103const MODEL_HAT_MS = 8000
104let agentCalls = 0 // main-loop Agent calls still running: Claude is waiting on helpers
105let sessionTokens = 0 // input, cache writes and output this session; cache reads left out
106let tokensSeen = 0 // the count at the last main-loop ending, so a helper's crossing waits for it
107const HARDHAT_MS = 120_000 // two minutes into a turn: hard hat on
108const WAKE_MS = 60_000 // a minute before a limit resets, Claude stretches; a minute after, it's up
109const TIE_AT = 3 // helpers out for the boss tie
110const FAST_YES_MS = 6000 // approved and done this fast: hearts
111const NERVOUS_MS = 30_000 // a call for the user waiting this long: a sweat drop and a timer
112const FLIP_MS = 4000
113let wornHat: Hat | null = null // /tales hat, saved in $.store
114let wornFace: Face | null = null // /tales face, saved in $.store
115const HATS_TO_WEAR: readonly Hat[] = ['tophat', 'gradcap', 'captain', 'wizard', 'hardhat', 'deerstalker', 'beanie', 'pith', 'shell', 'witch', 'santa', 'party', 'nightcap', 'crown']
116let stats: Stats | null = null // lifetime progress from $.store; null until Clawd has hatched
117let scarfOn = true // /tales scarf, saved in $.store
118let scarfKey: string | null = null // the project's scarf color
119const HATCH_MS = 2400
120let demoGrowth: Form | 'shell' | null = null // /tales demo's own hatch and growth; real stats untouched
121const FACES_TO_WEAR: readonly Face[] = ['glasses', 'shades', 'mustache']
122const workerBeatAt = new Map<string, number>()
123const workerTripUntil = new Map<string, number>()
124const returningSince = new Map<string, number>()
125
126/** Steps x along the stage, turning at either edge. */
127function walk<T extends { x: number; dir: 1 | -1 }>(it: T, action: Action): T {
128 const v = speed(action)
129 if (v <= 0) return it
130 const max = Math.max(0, cols - HERO_W)
131 let x = it.x + v * it.dir
132 let dir = it.dir
133 if (x >= max) [x, dir] = [max, -1]
134 if (x <= 0) [x, dir] = [0, 1]
135 return { ...it, x, dir }
136}
137
138/** The rest ladder, by time since the turn's cheer ended. Null once Claude has gone. */
139function restStep(since: number, now: number, lim: Limit | null, last?: Action): Pick<Hero, 'action' | 'caption' | 'emote'> | null {
140 const resets = lim?.resetsAt ? Date.parse(lim.resetsAt) : NaN
141 // The figure stays at 100 until the next response, so past the reset the clock decides.
142 if (lim && lim.percent >= 100 && !(now >= resets + WAKE_MS)) {
143 if (now >= resets) return { action: 'cheer', caption: `The ${lim.kind} limit has reset. Claude is up and ready!`, emote: null }
144 if (now >= resets - WAKE_MS) return { action: 'stretch', caption: `Claude stretches. The ${lim.kind} limit resets at ${clockTime(lim.resetsAt)}.`, emote: null }
145 return { action: 'sleep', caption: `Claude sleeps until the ${lim.kind} limit resets at ${clockTime(lim.resetsAt)}. z z z`, emote: null }
146 }
147 const t = now - since
148 if (t >= REST.hideMs) return null
149 const action = t < REST.sitMs ? 'sit' : t < REST.yawnMs ? 'yawn' : 'sleep'
150 const fidget = action === 'sit' ? (fishing(t) ?? fidgetAt(t, Math.floor(since / 1000))) : null
151 if (fidget) return { action: fidget.action, caption: fidget.caption, emote: fidget.emote ?? null }
152 return { action, caption: restCaption(action, last, isDungeon), emote: null }
153}
154
155function holidayNow(d: Date): Holiday | null {
156 if (holidayPick === 'none') return null
157 return holidayPick ?? holidayOf(d)
158}
159
160/** Claude's eyes dart toward something for a moment. */
161async function glanceAt($: EngineInterface, dir: -1 | 1, ms = 1500) {
162 const now = await $.clock.now()
163 await update($, hero, (cur): Hero => ({ ...cur, glance: dir, glanceUntil: now + ms }))
164}
165
166async function addFx($: EngineInterface, kind: FxKind, x: number) {
167 if ((await read($, hero)).mode === 'idle') return
168 const start = await $.clock.now()
169 fxId += 1
170 const one: Fx = { id: fxId, kind, start, dur: FX_MS[kind], x }
171 await update($, fx, list => [...list.filter(f => f.kind !== kind), one])
172 ensureTimer($)
173}
174
175/** A rare visitor crosses during the turn: a sky whale, a UFO, or the train. */
176function maybeVisitor($: EngineInterface, force?: FxKind) {
177 if (!force && Math.random() >= VISITOR_ODDS) return
178 const kinds: FxKind[] = ['whale', 'ufo', 'train']
179 const kind = force ?? kinds[Math.floor(Math.random() * kinds.length)] ?? 'whale'
180 $.clock.after(force ? 0 : 3000 + Math.floor(Math.random() * 12000), () => void addFx($, kind, 0))
181}
182
183function ensureTimer($: EngineInterface) {
184 if (timer) return
185 timer = $.clock.every(isCalm ? CALM_TICK_MS : TICK_MS, async () => {
186 ticks += 1
187 const now = await $.clock.now()
188 let cur = await read($, hero)
189 const crew = await read($, workers)
190
191 if (cur.mode === 'ending' && now - cur.since >= LINGER_MS) {
192 cur = { ...cur, mode: 'resting', since: now, action: 'sit', caption: restCaption('sit', undefined, isDungeon) }
193 await update($, hero, () => cur)
194 }
195 if (cur.emote && cur.mode !== 'resting' && cur.emote !== 'think' && now > (cur.emoteUntil ?? 0)) {
196 cur = { ...cur, emote: null }
197 await update($, hero, () => cur)
198 }
199 if (cur.mode === 'resting') {
200 const step = restStep(cur.since, now, topLimit(await read($, limits)), cur.last)
201 const next: Hero = step ? { ...cur, ...step } : { ...cur, mode: 'idle', emote: null }
202 if (next.action !== cur.action || next.mode !== cur.mode || next.caption !== cur.caption || next.emote !== cur.emote) {
203 cur = next
204 await update($, hero, () => next)
205 if (next.mode === 'idle') await update($, fx, () => [])
206 }
207 }
208 let effects = await read($, fx)
209 if (effects.some(f => now - f.start >= f.dur)) {
210 effects = effects.filter(f => now - f.start < f.dur)
211 await update($, fx, () => effects)
212 }
213 if (cur.mode === 'idle' && crew.length === 0 && effects.length === 0) {
214 timer?.cancel()
215 timer = undefined
216 return
217 }
218 const f = (await read($, frame)) + 1
219 await update($, frame, () => f)
220 // Now and then the eyes wander: a look left or right for a second.
221 if (cur.mode !== 'idle' && now >= nextGlanceAt) {
222 if (nextGlanceAt > 0 && cur.action !== 'sleep') await glanceAt($, Math.random() < 0.5 ? -1 : 1, 1200)
223 nextGlanceAt = now + 5000 + Math.floor(Math.random() * 7000)
224 }
225
226 if (cur.mode === 'working' && !(await read($, alert))) {
227 let next: Hero = cur
228 // Claude keeps the last pose through the model's thinking until the next tool call;
229 // only a trip, once played, goes back to wandering.
230 if ((cur.action === 'trip' || cur.action === 'dizzy' || cur.action === 'flip') && heroCalls === 0 && now >= tripUntil) {
231 next = { ...next, action: 'walk', caption: THINKING[f % THINKING.length] ?? '' }
232 }
233 // A long think: the pose and caption stay, a thought bubble rises.
234 if (!cur.emote && heroCalls === 0 && now - lastCallAt >= THINK_POSE_MS && next.action !== 'trip' && next.action !== 'sweep') {
235 next = { ...next, emote: 'think', emoteUntil: Infinity }
236 }
237 // Waiting on an Agent call, Claude sits with the helpers' music or juggling: no wandering.
238 if (!isCalm && agentCalls === 0) next = walk(next, next.action)
239 if (next !== cur) await update($, hero, () => next)
240 }
241
242 if (crew.length > 0) {
243 const homeX = cur.x
244 const arrived: string[] = []
245 await update($, workers, list =>
246 list.map(w => {
247 if (w.state === 'returning') {
248 const gap = homeX - w.x
249 const late = now - (returningSince.get(w.id) ?? now) > RETURN_MAX_MS
250 if (Math.abs(gap) <= HERO_W - 4 || late) {
251 arrived.push(w.id)
252 return { ...w, state: 'done', action: w.sad ? 'sit' : 'cheer', dir: gap >= 0 ? 1 : -1 }
253 }
254 const dir: 1 | -1 = gap > 0 ? 1 : -1
255 const step = Math.min(Math.abs(gap), isCalm ? 8 : 1)
256 return { ...w, dir, x: w.x + dir * step }
257 }
258 if (w.state !== 'running') return w
259 const quiet = now - (workerBeatAt.get(w.id) ?? 0) > THINK_AFTER_MS
260 const tripping = now < (workerTripUntil.get(w.id) ?? 0)
261 const action: Action = quiet && !tripping ? 'walk' : w.action
262 return isCalm ? { ...w, action } : walk({ ...w, action }, action)
263 }),
264 )
265 if (!isCalm) await update($, workers, conga)
266 for (const id of arrived) $.clock.after(HUG_MS, () => void leave($, id))
267 if (ticks % POLL_EVERY === 0) await settle($, crew)
268 }
269 })
270}
271
272/** Four or more helpers out: the ones wandering fall in line behind the first. */
273function conga(list: Worker[]): Worker[] {
274 const line = list.filter(w => w.state === 'running' && speed(w.action) > 0)
275 if (list.filter(w => w.state === 'running').length < CONGA_AT || line.length < 2) return list
276 const max = Math.max(0, cols - HERO_W)
277 const placed = new Map<string, Worker>()
278 let ahead = line[0]!
279 for (const w of line.slice(1)) {
280 const want = Math.min(max, Math.max(0, ahead.x - ahead.dir * 12))
281 const gap = want - w.x
282 const step = Math.min(Math.abs(gap), 1)
283 const moved: Worker = { ...w, action: 'walk', x: w.x + Math.sign(gap) * step, dir: gap === 0 ? ahead.dir : gap > 0 ? 1 : -1 }
284 placed.set(w.id, moved)
285 ahead = moved
286 }
287 return list.map(w => placed.get(w.id) ?? w)
288}
289
290async function leave($: EngineInterface, id: string) {
291 workerBeatAt.delete(id)
292 workerTripUntil.delete(id)
293 returningSince.delete(id)
294 await update($, workers, list => list.filter(w => w.id !== id))
295}
296
297/** A helper ends: done ones run home to Claude, failed ones trip and leave. */
298async function finish($: EngineInterface, id: string, state: 'done' | 'failed') {
299 if (state === 'done') {
300 returningSince.set(id, await $.clock.now())
301 await update($, workers, list => list.map(w => (w.id === id ? { ...w, state: 'returning', action: 'run' } : w)))
302 ensureTimer($)
303 return
304 }
305 await update($, workers, list => list.map(w => (w.id === id ? { ...w, state: 'failed', action: 'trip', sad: true } : w)))
306 // It trips, dusts off, and walks back to Claude for a pat on the head.
307 $.clock.after(TRIP_MS, async () => {
308 returningSince.set(id, await $.clock.now())
309 await update($, workers, list => list.map(w => (w.id === id ? { ...w, state: 'returning', action: 'walk' } : w)))
310 })
311}
312
313async function settle($: EngineInterface, list: readonly Worker[]) {
314 const running = list.filter(w => w.state === 'running' && !w.id.startsWith('demo-'))
315 if (running.length === 0) return
316 const agents = await $.agent.list()
317 for (const w of running) {
318 const a = agents.find(x => x.id === w.id)
319 if (!a) await finish($, w.id, 'done')
320 else if (ENDED.has(a.status)) await finish($, w.id, a.status === 'completed' ? 'done' : 'failed')
321 }
322}
323
324/** The spot on the stage farthest from Claude and every helper already out. */
325function openSpot(taken: readonly number[], max: number): number {
326 let best = 0
327 let bestGap = -1
328 for (let x = 0; x <= max; x += 2) {
329 const gap = Math.min(...taken.map(t => Math.abs(t - x)), Infinity)
330 if (gap > bestGap) [best, bestGap] = [x, gap]
331 }
332 return best
333}
334
335async function addWorker($: EngineInterface, id: string, label: string, model: string | undefined) {
336 const max = Math.max(1, cols - HERO_W)
337 const taken = [(await read($, hero)).x, ...(await read($, workers)).map(w => w.x)]
338 const x = openSpot(taken, max)
339 const w: Worker = {
340 id,
341 label,
342 tier: tierOf(model),
343 state: 'running',
344 action: 'walk',
345 x,
346 dir: x > max / 2 ? -1 : 1,
347 steps: 0,
348 }
349 workerBeatAt.set(id, await $.clock.now())
350 await update($, workers, list => [...list.filter(x => x.id !== id), w])
351 await glanceAt($, x >= taken[0]! ? 1 : -1)
352 ensureTimer($)
353}
354
355async function workerBeat($: EngineInterface, id: string, action: Action, isError: boolean) {
356 const now = await $.clock.now()
357 workerBeatAt.set(id, now)
358 if (isError) workerTripUntil.set(id, now + TRIP_MS)
359 await update($, workers, list =>
360 list.map(w => (w.id === id && w.state === 'running' ? { ...w, action, steps: w.steps + (isError ? 0 : 1) } : w)),
361 )
362}
363
364/** A main-thread beat: action and caption, unless Claude is waiting on the user. */
365const POSES = new Set<Action>(['read', 'dig', 'sneak', 'run', 'fly', 'carry', 'cover'])
366
367async function heroBeat($: EngineInterface, b: Pick<Hero, 'action' | 'caption'>, step = 1, mood?: Emote) {
368 const now = await $.clock.now()
369 lastCallAt = now
370 await update($, hero, (cur): Hero => {
371 if (cur.mode !== 'working') return cur
372 const emote = mood ?? (cur.emote === 'think' ? null : cur.emote)
373 return {
374 ...cur,
375 ...b,
376 steps: cur.steps + step,
377 emote,
378 emoteUntil: mood ? now + MOOD_MS : cur.emoteUntil,
379 last: POSES.has(b.action) ? b.action : cur.last,
380 }
381 })
382}
383
384async function begin($: EngineInterface, text = '') {
385 const now = await $.clock.now()
386 const d = new Date(now)
387 startedAt = now
388 lastCallAt = now
389 heroCalls = 0
390 tripUntil = 0
391 best = 0
392 errStreak = 0
393 alertAt = null
394 const mood = moodOf(text)
395 await update($, combo, () => 0)
396 await update($, alert, () => null)
397 await update($, hero, (cur): Hero => ({
398 ...cur,
399 mode: 'working',
400 action: 'walk',
401 caption: startCaption({ mood, woke: cur.action === 'sleep', holiday: holidayNow(d), hour: d.getHours(), dungeon: isDungeon }),
402 scene: cur.mode === 'idle' ? (cur.scene + 1) % SCENE_COUNT : cur.scene,
403 steps: 0,
404 since: now,
405 emote: mood === 'thanks' ? 'smitten' : mood === 'scold' ? 'sheepish' : null,
406 emoteUntil: now + MOOD_MS,
407 // Scolded, Claude can't quite look you in the eye.
408 glance: mood === 'scold' ? -1 : cur.glance,
409 glanceUntil: mood === 'scold' ? now + MOOD_MS : cur.glanceUntil,
410 }))
411 ensureTimer($)
412}
413
414async function close($: EngineInterface, caption: string, action: Action = 'cheer') {
415 const now = await $.clock.now()
416 await update($, alert, () => null)
417 await update($, hero, (cur): Hero => ({ ...cur, mode: 'ending', action, caption, since: now, emote: null }))
418 ensureTimer($)
419}
420
421/** An MCP call: a puff in the server's own color as Claude warps a message off. */
422async function onMcp($: EngineInterface, tool: string) {
423 const server = tool.split('__')[1] ?? tool
424 const x = (await read($, hero)).x
425 await addFx($, 'warp', x)
426 await update($, fx, list => list.map(f => (f.kind === 'warp' && f.id === fxId ? { ...f, color: warpColor(server) } : f)))
427}
428
429/** Dungeon gold: a coin pops over Claude, a burst for a chest or a fight won. */
430async function earn($: EngineInterface, n: number, burst = false) {
431 if (!isDungeon || n <= 0) return
432 await update($, gold, g => g + n)
433 await addFx($, burst ? 'coins' : 'coin', (await read($, hero)).x)
434}
435
436/** The call as a beat, in the stage's own words. */
437async function themed($: EngineInterface, tool: string, args: Record<string, unknown>, seed: string) {
438 return isDungeon ? dungeonBeat(tool, args, seed, await read($, bugs)) : beat(tool, args, seed)
439}
440
441/** Test output: failures drop bugs into the scene, a green run lets Claude eat them. */
442async function onTests($: EngineInterface, command: string, output: string, isMain: boolean) {
443 const counts = testCounts(command, output)
444 if (!counts) return
445 const had = await read($, bugs)
446 if (counts.failed > 0) {
447 const n = Math.min(counts.failed, 8)
448 await update($, bugs, () => n)
449 const s = counts.failed === 1 ? '' : 's'
450 const caption = isDungeon ? `${counts.failed} slime${s} ooze${s ? '' : 's'} out of the test run` : `${counts.failed} bug${s} crawl out of the test run`
451 if (isMain) await heroBeat($, { action: 'trip', caption }, 0)
452 tripUntil = (await $.clock.now()) + TRIP_MS
453 } else if (counts.passed > 0 && had > 0) {
454 const s = had === 1 ? '' : 's'
455 const caption = isDungeon ? `Claude squashes ${had === 1 ? 'the slime' : `all ${had} slimes`}. Tests are green. +${had} gold` : `Claude eats all ${had} bug${s}. Tests are green.`
456 if (isMain) await heroBeat($, { action: 'cheer', caption }, 0)
457 if (isMain) await earn($, had, true)
458 for (let n = 1; n <= had; n++) $.clock.after(n * 350, () => void update($, bugs, b => Math.max(0, b - 1)))
459 }
460}
461
462/** TodoWrite: pending items are pellets; finishing one is a gobble. */
463async function onTodos($: EngineInterface, input: Record<string, unknown>) {
464 const list = Array.isArray(input.todos) ? (input.todos as Record<string, unknown>[]) : []
465 if (list.length === 0) return
466 const done = list.filter(t => t.status === 'completed')
467 const prev = await read($, todos)
468 const next = done.length === list.length ? null : { done: done.length, total: list.length }
469 await update($, todos, () => next)
470 if (prev && done.length > prev.done) {
471 const last = done[done.length - 1]
472 const what = typeof last?.content === 'string' ? last.content : 'a task'
473 const caption = isDungeon
474 ? next ? `Claude opens a treasure chest: ${what}` : 'Claude opens the last chest on the map!'
475 : next ? `Claude gobbles a pellet: ${what}` : 'Claude clears the whole quest list!'
476 await heroBeat($, { action: 'cheer', caption }, 0)
477 await earn($, 3, true)
478 }
479}
480
481/** Clean calls in a row: a combo from 5, a crown at 10. */
482async function onCombo($: EngineInterface) {
483 let n = 0
484 await update($, combo, c => (n = c + 1))
485 best = Math.max(best, n)
486 if (n === CROWN_AT) await heroBeat($, { action: 'cheer', caption: `${CROWN_AT} clean calls in a row. Claude earns a crown!` }, 0)
487}
488
489/** A fun Bash command went through: confetti, a paper plane, boxes. */
490async function onEggs($: EngineInterface, eggs: readonly Egg[], command: string) {
491 const x = (await read($, hero)).x
492 for (const egg of eggs) {
493 if (egg === 'commit') await addFx($, 'confetti', x)
494 if (egg === 'push') await addFx($, 'plane', x)
495 if (egg === 'install') await addFx($, 'boxes', Math.min(Math.max(0, cols - 4), x + HERO_W))
496 }
497 const done = eggs.map(egg => eggDone(egg, command)).filter(Boolean)
498 if (done.length > 0) await heroBeat($, { action: 'cheer', caption: done.join('. ') }, 0)
499}
500
501function readLimits(rates: readonly { kind: string; percentUsed: number; resetsAt?: string }[]): Limit[] {
502 return rates.map(r => ({ kind: limitLabel(r.kind), percent: Math.round(r.percentUsed), resetsAt: r.resetsAt }))
503}
504
505/** 'five_hour' and friends as the short names people know. */
506function limitLabel(kind: string): string {
507 if (/five|5.?h/i.test(kind)) return '5h'
508 if (/seven|7.?d|week/i.test(kind)) return '7d'
509 return kind.replace(/_/g, ' ')
510}
511
512function topLimit(list: readonly Limit[]): Limit | null {
513 let top: Limit | null = null
514 for (const l of list) if (!top || l.percent > top.percent) top = l
515 return top
516}
517
518/** Restarts the tick at the new pace. */
519async function setCalm($: EngineInterface, on: boolean) {
520 isCalm = on
521 await update($, calm, () => on)
522 timer?.cancel()
523 timer = undefined
524 const cur = await read($, hero)
525 if (cur.mode !== 'idle' || (await read($, workers)).length > 0) ensureTimer($)
526}
527
528function runDemo($: EngineInterface) {
529 const at = (ms: number, fn: () => Promise<unknown> | unknown) => $.clock.after(ms, () => void fn())
530 const steps: [number, string, Record<string, unknown>][] = [
531 [1500, 'Grep', { pattern: 'register' }],
532 [4000, 'Read', { file_path: '/src/hooks/register.tsx' }],
533 [6500, 'Agent', { description: 'Review the diff' }],
534 [9000, 'Edit', { file_path: '/src/hooks/register.tsx' }],
535 [15000, 'WebSearch', { query: 'half block pixel art' }],
536 ]
537 // It opens on an egg and ends with the hatchling growing up.
538 demoGrowth = 'shell'
539 at(0, async () => {
540 const now = await $.clock.now()
541 await update($, hero, (h): Hero => ({ ...h, action: 'hatch', caption: 'An egg wobbles…', since: now }))
542 })
543 at(1100, () => heroBeat($, { action: 'cheer', caption: 'A Clawd hatches! Hello!' }, 0))
544 for (const [ms, tool, args] of steps) {
545 at(ms, async () => {
546 await heroBeat($, await themed($, tool, args, `demo${ms}`))
547 await earn($, 1)
548 })
549 }
550 at(2000, () => onTodos($, { todos: [1, 2, 3, 4, 5].map(n => ({ content: `Task ${n}`, status: n === 1 ? 'in_progress' : 'pending' })) }))
551 at(3000, () => update($, context, () => 74)) // rain
552 at(11000, () => heroBeat($, isDungeon ? { action: 'cast', caption: 'Claude casts a spell: run the test suite' } : { action: 'run', caption: 'Claude runs off to run the test suite' }))
553 // The dungeon cut: with slimes on the floor, the next edit is a sword fight.
554 at(13500, async () => {
555 if (isDungeon) await heroBeat($, await themed($, 'Edit', { file_path: '/src/hooks/story.ts' }, 'demo13500'))
556 })
557 at(12000, () => onTests($, 'npm test', 'Tests: 3 failed, 9 passed', true))
558 // 0.6.0 moments: a model switch hat, then a memory note.
559 at(5200, async () => {
560 modelHat = { hat: 'regal', until: (await $.clock.now()) + MODEL_HAT_MS }
561 await heroBeat($, { action: 'cheer', caption: 'Claude changes into Opus' }, 0)
562 })
563 at(22300, async () => heroBeat($, await themed($, 'Write', { file_path: '/home/you/.claude/memory/MEMORY.md' }, 'demo22300')))
564 at(17500, () => update($, alert, () => 'Claude needs you: allow Bash(rm -rf build)?'))
565 at(21000, () => update($, alert, () => null))
566 at(21500, () => onTests($, 'npm test', 'Tests: 0 failed, 12 passed', true))
567 at(23000, () => onTodos($, { todos: [1, 2, 3, 4, 5].map(n => ({ content: `Task ${n}`, status: n <= 3 ? 'completed' : 'pending' })) }))
568 at(24000, () => update($, context, () => 90)) // storm
569 const crew: [string, string, string, Action[], 'done' | 'failed'][] = [
570 ['demo-1', 'Review the diff', 'claude-sonnet-5-5', ['read', 'sneak', 'read'], 'done'],
571 ['demo-2', 'Judge the art', 'claude-opus-5-5', ['read', 'dig', 'run'], 'done'],
572 ['demo-3', 'Scout old toys', 'claude-haiku-4-5', ['fly', 'fly', 'sneak'], 'failed'],
573 ['demo-4', 'Write the fable', 'claude-fable-5-1', ['dig', 'read', 'dig'], 'done'],
574 ]
575 crew.forEach(([id, label, model, acts, end], n) => {
576 const t = 7000 + n * 800
577 at(t, () => addWorker($, id, label, model))
578 acts.forEach((a, i) => at(t + 1500 + i * 2500, () => workerBeat($, id, a, false)))
579 at(t + 1500 + acts.length * 2500 + n * 600, () => finish($, id, end))
580 })
581 at(16500, () => heroBeat($, beat('mcp__claude_ai_Gmail__search_threads', {}, 'demo16500')))
582 at(16500, () => onMcp($, 'mcp__claude_ai_Gmail__search_threads'))
583 at(24500, () => maybeVisitor($, 'whale'))
584 at(25000, () => heroBeat($, eggBeat('commit', 'git commit -m "Teach Clawd new tricks"')))
585 at(26000, async () => {
586 await heroBeat($, { action: 'cheer', caption: eggDone('commit', 'git commit -m "Teach Clawd new tricks"') ?? '' }, 0)
587 await addFx($, 'confetti', (await read($, hero)).x)
588 })
589 at(27000, () => update($, context, () => 30)) // the storm passes before the big moment
590 at(28000, async () => {
591 demoGrowth = 'explorer'
592 await close($, growCaption('explorer', null))
593 await addFx($, 'confetti', (await read($, hero)).x)
594 })
595 at(46000, () => (demoGrowth = null))
596 at(40000, () => update($, todos, () => null)) // the fable's quest list leaves with it
597}
598
599// The pose last drawn, and the in-between poses still to play before the new one shows.
600let drawnAction: Action | undefined
601let clip: { steps: readonly Action[]; at: number } | null = null
602
603export async function between($: Pick<EngineInterface, 'clock'>, action: Action): Promise<Action> {
604 const now = await $.clock.now()
605 if (action !== drawnAction) {
606 const steps = drawnAction && !isCalm ? transition(drawnAction, action) : []
607 clip = steps.length > 0 ? { steps, at: now } : null
608 drawnAction = action
609 }
610 const step = clip?.steps[Math.floor((now - clip.at) / CLIP_STEP_MS)]
611 if (!step) clip = null
612 return step ?? action
613}
614
615export const register: Register = on => {
616 on('session.start', async ($, e, next) => {
617 await $.command.register({ name: 'tales', description: 'Clawd tales: on, off, calm, lively, demo, theme, hat, face, scarf' })
618 sessionTokens = 0
619 tokensSeen = 0
620 const hatSaved = await $.store.get('hat')
621 wornHat = HATS_TO_WEAR.includes(hatSaved as Hat) ? (hatSaved as Hat) : null
622 const faceSaved = await $.store.get('face')
623 wornFace = FACES_TO_WEAR.includes(faceSaved as Face) ? (faceSaved as Face) : null
624 stats = readStats(await $.store.get('stats'))
625 scarfOn = (await $.store.get('scarf')) !== false
626 isDungeon = (await $.store.get('theme')) === 'dungeon'
627 await update($, theme, () => (isDungeon ? 'dungeon' : 'meadow'))
628 try {
629 lastModel = await $.session.model()
630 } catch {
631 lastModel = null
632 }
633 try {
634 scarfKey = scarfColor(await $.session.root())
635 } catch {
636 scarfKey = null
637 }
638 const saved = await $.store.get('isOn')
639 if (typeof saved === 'boolean') await update($, isOn, () => saved)
640 const savedCalm = await $.store.get('calm')
641 isCalm = savedCalm === true
642 await update($, calm, () => isCalm)
643 try {
644 const usage = await $.session.usage()
645 await update($, context, () => usage.context.percent ?? null)
646 await update($, limits, () => readLimits(usage.rateLimits))
647 } catch {
648 // No usage yet: the first measure fills it in.
649 }
650 if ((await read($, shiny)) === null) {
651 const lucky = Math.random() < SHINY_ODDS
652 await update($, shiny, () => lucky)
653 if (lucky) await $.ui.toast('✨ A shiny Clawd appeared this session!')
654 }
655 if ((await read($, hero)).mode !== 'idle' || (await read($, workers)).length > 0) ensureTimer($)
656 return next(e)
657 })
658
659 on('command.run', { command: 'tales' }, async ($, e) => {
660 const arg = (e.args ?? '').trim().toLowerCase()
661 if (arg === 'off' || arg === 'on') {
662 await update($, isOn, () => arg === 'on')
663 await $.store.set('isOn', arg === 'on')
664 return { text: `Clawd tales ${arg}.` }
665 }
666 if (arg === 'calm' || arg === 'lively') {
667 await setCalm($, arg === 'calm')
668 await $.store.set('calm', arg === 'calm')
669 return { text: arg === 'calm' ? 'Calm: one beat a second, Claude stays put between tool calls.' : 'Lively: 5 fps, wandering on.' }
670 }
671 const [word, name] = arg.split(/\s+/)
672 // /tales theme dungeon|meadow, or just /tales dungeon.
673 const pickTheme = word === 'theme' ? name : word === 'dungeon' || word === 'meadow' ? word : undefined
674 if (word === 'theme' || pickTheme) {
675 if (pickTheme !== 'dungeon' && pickTheme !== 'meadow') return { text: `The stage is the ${isDungeon ? 'dungeon' : 'meadow'}. Use /tales theme dungeon|meadow.` }
676 isDungeon = pickTheme === 'dungeon'
677 await update($, theme, () => pickTheme)
678 await $.store.set('theme', pickTheme)
679 return {
680 text: isDungeon
681 ? 'Into the dungeon: lamps for context, scrolls, treasure chests for tasks, gold for clean calls, monsters for failures.'
682 : 'Back to the meadow.',
683 }
684 }
685 if (word === 'scarf') {
686 if (name !== 'on' && name !== 'off') return { text: `The scarf is ${scarfOn ? 'on' : 'off'}. Use /tales scarf on|off.` }
687 scarfOn = name === 'on'
688 await $.store.set('scarf', scarfOn)
689 return { text: scarfOn ? 'Clawd wraps on the project scarf.' : 'Clawd takes off the scarf.' }
690 }
691 if (arg === 'demo') {
692 await begin($)
693 runDemo($)
694 return { text: 'A short fable: helpers, bugs, pellets, weather and a call for you. About 45 seconds.' }
695 }
696 if (word === 'hat' || word === 'face') {
697 const list: readonly string[] = word === 'hat' ? HATS_TO_WEAR : FACES_TO_WEAR
698 if (name === 'none' || name === 'off') {
699 if (word === 'hat') wornHat = null
700 else wornFace = null
701 await $.store.set(word, null)
702 return { text: `Clawd takes off the ${word === 'hat' ? 'hat' : 'face gear'}.` }
703 }
704 if (!name || !list.includes(name)) return { text: `${word === 'hat' ? 'Hats' : 'Face'}: ${list.join(', ')}, none.` }
705 if (word === 'hat') wornHat = name as Hat
706 else wornFace = name as Face
707 await $.store.set(word, name)
708 return { text: `Clawd puts on the ${name}.` }
709 }
710 // Unlisted: preview a holiday look (this load only). "auto" goes back to the calendar.
711 if (word === 'holiday') {
712 if (name === 'auto') holidayPick = null
713 else if (name === 'off') holidayPick = 'none'
714 else if (HOLIDAYS.includes(name as Holiday)) holidayPick = name as Holiday
715 else return { text: `Holidays: ${HOLIDAYS.join(', ')}, off, auto.` }
716 return { text: `Holiday look: ${name}.` }
717 }
718 const mode = (await read($, calm)) ? 'calm' : 'lively'
719 const sparkle = (await read($, shiny)) ? ' ✨ Shiny Clawd this session.' : ''
720 const growth = !stats
721 ? ' Clawd has not hatched yet.'
722 : stats.form
723 ? ` Clawd is a ${stats.form} (${stats.xp} tool calls).`
724 : ` Clawd is a hatchling: ${stats.xp}/${GROW_AT} tool calls to grow up.`
725 return {
726 text: `Clawd tales is ${(await read($, isOn)) ? 'on' : 'off'} (${mode}) in the ${isDungeon ? 'dungeon' : 'meadow'}.${growth}${sparkle} Use /tales on|off|calm|lively|demo, /tales theme dungeon|meadow, /tales hat <name>, /tales face <name>, /tales scarf on|off.`,
727 }
728 })
729
730 // Plan mode only shows on the classic hook's input; a mid-turn shift+tab waits for the next prompt.
731 on('classic.UserPromptSubmit', async ($, e, next) => {
732 planMode = e.permission_mode === 'plan'
733 return next(e)
734 })
735
736 on('prompt.submit', async ($, e, next) => {
737 await begin($, e.text)
738 maybeVisitor($)
739 return next(e)
740 })
741
742 // A turn without a typed prompt (a background agent's notice, a continuation) raises no
743 // prompt.submit: without this Claude kept fishing and dozing through the whole turn.
744 on('turn.start', async ($, e, next) => {
745 if ((await read($, hero)).mode !== 'working') await begin($, e.text)
746 // A /model switch since the last turn: Claude hops in the new model's hat.
747 let model: string | null = null
748 try {
749 model = await $.session.model()
750 } catch {
751 // No model to read: nothing to compare.
752 }
753 const swap = model ? modelSwitch(lastModel, model) : null
754 if (model) lastModel = model
755 if (swap) {
756 if (swap.hat) modelHat = { hat: swap.hat, until: (await $.clock.now()) + MODEL_HAT_MS }
757 await heroBeat($, { action: 'cheer', caption: swap.caption }, 0)
758 }
759 return next(e)
760 })
761
762 on('session.measure', async ($, e, next) => {
763 await update($, context, () => e.context.percent ?? null)
764 await update($, limits, () => readLimits(e.rateLimits))
765 return next(e)
766 })
767
768 // A permission dialog is about to show (not just an "ask" verdict, which auto mode
769 // may settle by itself): Claude jumps and waves until the call goes on.
770 on('classic.PermissionRequest', async ($, e, next) => {
771 const input = e.tool_input
772 const cmd = typeof input === 'object' && input && 'command' in input ? String(input.command) : ''
773 const what = cmd ? `${e.tool_name}(${cmd.length > 40 ? cmd.slice(0, 39) + '…' : cmd})` : e.tool_name
774 alertAt = await $.clock.now()
775 await update($, alert, () => `Claude needs you: allow ${what}?`)
776 ensureTimer($)
777 return next(e)
778 })
779
780 on('agent.spawn', async ($, e, next) => {
781 const result = await next(e)
782 if (!result.deny && result.agentId) await addWorker($, result.agentId, e.description || e.subagentType, result.model)
783 return result
784 })
785
786 on('tool.call', async ($, e, next) => {
787 const seed = e.tool_use_id ?? e.tool
788 const args = e as unknown as Record<string, unknown>
789 const owner = e.agentId
790 const command = e.tool === 'Bash' && typeof args.command === 'string' ? args.command : ''
791 const eggs = owner ? [] : bashEggs(command)
792 const egg = eggs[0] ? eggBeat(eggs[0], command) : null
793 const b = egg ?? (await themed($, e.tool, args, seed))
794 const asks = e.tool === 'AskUserQuestion' || e.tool === 'ExitPlanMode'
795 // A main-loop call means Claude is working, whatever the band missed.
796 if (!owner && (await read($, hero)).mode !== 'working') await begin($)
797 if (owner) await workerBeat($, owner, b.action, false)
798 else {
799 await heroBeat($, b, 1, egg?.emote)
800 // No counting before the hatch: a mid-session install would skip the egg forever.
801 // A sword fight counts as the work it interrupted.
802 if (stats) stats = countCall(stats, b.action === 'fight' ? beat(e.tool, args, seed).action : b.action)
803 }
804 if (eggs.includes('sl')) await addFx($, 'train', 0)
805 if (asks) {
806 alertAt = await $.clock.now()
807 await update($, alert, () => (e.tool === 'ExitPlanMode' ? 'Claude needs you: approve the plan?' : 'Claude needs you: a question is waiting'))
808 ensureTimer($)
809 }
810 const delegates = !owner && e.tool === 'Agent'
811 if (!owner && e.tool.startsWith('mcp__')) await onMcp($, e.tool)
812 if (!owner) heroCalls += 1
813 if (delegates) agentCalls += 1
814 let result: Awaited<ReturnType<typeof next>>
815 try {
816 result = await next(e)
817 } finally {
818 if (!owner) {
819 heroCalls = Math.max(0, heroCalls - 1)
820 lastCallAt = await $.clock.now()
821 }
822 if (delegates) agentCalls = Math.max(0, agentCalls - 1)
823 }
824 if (e.tool === 'ExitPlanMode' && result.deny === undefined && !result.isError) planMode = false
825 // Whatever it was waiting on, the call has gone ahead (or been refused).
826 const waited = alertAt !== null && (await read($, alert)) ? (await $.clock.now()) - alertAt : null
827 alertAt = null
828 await update($, alert, () => null)
829 if (waited !== null && waited < FAST_YES_MS && result.deny === undefined && !result.isError && !owner) {
830 const now = await $.clock.now()
831 await update($, hero, (cur): Hero => ({ ...cur, emote: 'blush', emoteUntil: now + 3000, caption: 'Claude beams at the quick yes' }))
832 }
833 if (result.deny !== undefined || result.isError) {
834 if (owner) {
835 await workerBeat($, owner, 'trip', true)
836 } else {
837 tripUntil = (await $.clock.now()) + TRIP_MS
838 const missing = command && typeof result.text === 'string' ? notFound(result.text) : null
839 errStreak += 1
840 if (missing) {
841 await heroBeat($, { action: 'trip', caption: `Choo choo! “${missing}” isn't a command` }, 0)
842 await addFx($, 'train', 0)
843 } else if (errStreak >= 3) {
844 tripUntil = (await $.clock.now()) + FLIP_MS
845 const caption = isDungeon ? ambushCaption(e.tool, errStreak, seed) : `(╯°□°)╯︵ ┻━┻ ${errStreak} stumbles in a row. Claude flips the table`
846 await heroBeat($, { action: 'flip', caption }, 0)
847 } else if (errStreak === 2) {
848 tripUntil = (await $.clock.now()) + FLIP_MS
849 await heroBeat($, { action: 'dizzy', caption: isDungeon ? ambushCaption(e.tool, 2, seed) : 'Claude sees stars: two stumbles in a row' }, 0)
850 } else {
851 await heroBeat($, { action: 'trip', caption: isDungeon ? ambushCaption(e.tool, 1, seed) : tripCaption(e.tool, seed) }, 0)
852 }
853 await update($, combo, () => 0)
854 }
855 } else if (!owner) {
856 const beaten = errStreak
857 errStreak = 0
858 if (isDungeon && beaten > 0) {
859 // The skeletons fall: a puff where they stood, and their coins.
860 const h = await read($, hero)
861 await addFx($, 'poof', h.dir > 0 ? h.x + HERO_W : h.x - 6)
862 await heroBeat($, { action: 'cheer', caption: victoryCaption(beaten) }, 0)
863 await earn($, beaten + 1, true)
864 } else {
865 await earn($, 1)
866 }
867 await onCombo($)
868 if (eggs.length > 0) await onEggs($, eggs, command)
869 }
870 if (result.deny === undefined && e.tool === 'Bash' && typeof result.text === 'string') {
871 await onTests($, typeof args.command === 'string' ? args.command : '', result.text, !owner)
872 }
873 if (e.tool === 'TodoWrite' && !owner) await onTodos($, args)
874 return result
875 })
876
877 // Compaction: Claude sweeps the stage. Between turns it gets the band to itself, then rests.
878 on('session.compact', async ($, e, next) => {
879 if (e.trigger === 'precompute' || e.agentId) return next(e)
880 const cur = await read($, hero)
881 const solo = cur.mode !== 'working'
882 if (solo) {
883 const now = await $.clock.now()
884 await update($, hero, (h): Hero => ({ ...h, mode: 'working', action: 'sweep', caption: isDungeon ? 'Claude sweeps the hall and refills the lamps…' : 'Claude sweeps up the old context…', since: now, emote: null }))
885 ensureTimer($)
886 } else {
887 await heroBeat($, { action: 'sweep', caption: isDungeon ? 'Claude sweeps the hall and refills the lamps…' : 'Claude sweeps up the old context…' }, 0)
888 }
889 const result = await next(e)
890 if (result.messages) {
891 await update($, context, () => null) // the storm clears; the next measure says by how much
892 if (solo) await close($, 'Squeaky clean. The stage is swept.')
893 else await heroBeat($, { action: 'cheer', caption: 'Squeaky clean. Back to work.' }, 0)
894 } else if (solo) {
895 await update($, hero, (h): Hero => ({ ...h, mode: 'idle' }))
896 }
897 return result
898 })
899
900 // A session opens: a fresh one gets Clawd dropping in, a resumed one a wave hello.
901 on('classic.SessionStart', async ($, e, next) => {
902 const result = await next(e)
903 // The very first session: an egg instead of the drop-in.
904 const unhatched = e.source === 'startup' && !readStats(await $.store.get('stats'))
905 if (unhatched && (await read($, hero)).mode === 'idle') {
906 stats = stats ?? { ...NEW_STATS }
907 await $.store.set('stats', stats)
908 const now = await $.clock.now()
909 await update($, hero, (h): Hero => ({ ...h, action: 'hatch', caption: 'An egg wobbles…', mode: 'ending', since: now, emote: null }))
910 $.clock.after(HATCH_MS, async () => {
911 const at = await $.clock.now()
912 await update($, hero, (h): Hero => (h.action === 'hatch' ? { ...h, action: 'cheer', caption: 'A Clawd hatches! Hello!', since: at } : h))
913 })
914 ensureTimer($)
915 return result
916 }
917 const entrance: Pick<Hero, 'action' | 'caption'> | null =
918 e.source === 'startup'
919 ? { action: 'drop', caption: 'Clawd drops in. Hello!' }
920 : e.source === 'resume' || e.source === 'fork'
921 ? { action: 'wave', caption: 'Welcome back! Clawd waves hello.' }
922 : e.source === 'clear'
923 ? { action: 'sweep', caption: 'A fresh start. Clawd sweeps the stage.' }
924 : null
925 if (entrance && (await read($, hero)).mode === 'idle') {
926 const now = await $.clock.now()
927 await update($, hero, (h): Hero => ({ ...h, ...entrance, mode: 'ending', since: now, emote: null }))
928 ensureTimer($)
929 }
930 return result
931 })
932
933 on('turn.complete', async ($, e, next) => {
934 const u = e.usage
935 if (u) sessionTokens += u.input_tokens + u.cache_creation_input_tokens + u.output_tokens
936 // A helper's turn ends inside the main one: it counts toward tokens, not toward the ending.
937 if (e.agentId) return next(e)
938 const cur = await read($, hero)
939 if (cur.mode === 'working' && e.reason === 'error') {
940 await close($, stuckCaption(isDungeon), 'stuck')
941 } else if (cur.mode === 'working') {
942 const seconds = Math.round((e.durationMs ?? (await $.clock.now()) - startedAt) / 1000)
943 const crossed = tokenMilestone(tokensSeen, sessionTokens)
944 tokensSeen = sessionTokens
945 const grown = stats ? formOf(stats) : null
946 if (stats && grown && grown !== stats.form) {
947 const was = stats.form
948 stats = { ...stats, form: grown }
949 await close($, growCaption(grown, was), 'cheer')
950 await addFx($, 'confetti', cur.x)
951 } else {
952 await close($, ending(cur.steps, seconds, e.isAborted, best, crossed), endingAction(seconds, e.isAborted))
953 if (crossed) await addFx($, 'confetti', cur.x)
954 }
955 if (stats) await $.store.set('stats', stats)
956 }
957 return next(e)
958 })
959
960 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
961 if (e.surface === 'mobile' || e.props.hasSurvey) return next(e)
962 const cur = await read($, hero)
963 const crew = await read($, workers)
964 const asking = await read($, alert)
965 if ((cur.mode === 'idle' && crew.length === 0 && !asking) || !(await read($, isOn))) return next(e)
966 const { Box, Text } = $.ui.resolve(e)
967 cols = Math.max(HERO_W + 4, e.props.bodyColumns ?? 80)
968 const rows = e.props.maxRows ?? 12
969 const f = await read($, frame)
970 const out = crew.filter(w => w.state === 'running').length || crew.length
971 const running = crew.filter(w => w.state === 'running')
972 let shown: Hero = cur
973 // Turn over, or the main loop blocked on an Agent call: Claude idles while the helpers work.
974 if (cur.mode === 'idle' || (cur.mode === 'working' && agentCalls > 0 && running.length > 0)) {
975 shown = { ...cur, ...waitBeat(out, running[0]?.label ?? crew[0]?.label) }
976 }
977 if (asking) {
978 const waited = alertAt !== null ? Math.floor(((await $.clock.now()) - alertAt) / 1000) : 0
979 const nervous = waited * 1000 >= NERVOUS_MS
980 shown = { ...shown, action: 'alert', caption: nervous ? `${asking} (waiting ${waited}s)` : asking, emote: nervous ? 'sweat' : shown.emote }
981 }
982 shown = { ...shown, action: await between($, shown.action) }
983 const fit = (s: string, room: number) => (s.length > room ? s.slice(0, Math.max(1, room - 1)) + '…' : s)
984 const caption = asking ? (
985 <Text color="#e5534b" bold>
986 {fit(shown.caption, cols)}
987 </Text>
988 ) : (
989 <Text dimColor>{fit(shown.caption, cols)}</Text>
990 )
991 const below = await next(e)
992
993 // Degradation ladder: full stage, then stage without sky or roster, then stage without its
994 // caption (a fullscreen split pane leaves ~5 rows), then one line, then nothing. A call for
995 // the person keeps its caption and gives up stage instead.
996 if (rows < 1) return below
997 const captionRows = asking || rows >= 6 ? 1 : 0
998 const room = rows - captionRows
999 if (room < 4 || cols < 30) {
1000 return (
1001 <Box flexDirection="column">
1002 <Box flexDirection="row">
1003 <Text color={asking ? '#e5534b' : '#d77757'}>{`${glyph(shown.action, f)} `}</Text>
1004 {caption}
1005 </Box>
1006 {below}
1007 </Box>
1008 )
1009 }
1010
1011 const task = await read($, todos)
1012 const now = await $.clock.now()
1013 const day = new Date(now)
1014 const holiday = holidayNow(day)
1015 const hour = day.getHours()
1016 const streak = await read($, combo)
1017 const holidayHat: Hat | null = holiday === 'halloween' ? 'witch' : holiday === 'christmas' ? 'santa' : holiday === 'newyear' ? 'party' : null
1018 const longHaul = cur.mode === 'working' && now - startedAt >= HARDHAT_MS
1019 const hat: Hat | null =
1020 streak >= CROWN_AT
1021 ? 'crown'
1022 : ((modelHat && now < modelHat.until ? modelHat.hat : null) ??
1023 wornHat ??
1024 (planMode ? 'wizard' : null) ??
1025 holidayHat ??
1026 (longHaul ? 'hardhat' : null) ??
1027 (isLate(hour) ? 'nightcap' : null) ??
1028 (demoGrowth === 'shell' ? 'shell' : demoGrowth ? FORM_HAT[demoGrowth] : null) ??
1029 (stats?.form ? FORM_HAT[stats.form] : stats ? 'shell' : null))
1030 const dungeon = (await read($, theme)) === 'dungeon'
1031 const coins = await read($, gold)
1032 const extras = {
1033 weather: dungeon ? ('clear' as const) : weatherOf(await read($, context)),
1034 dungeon,
1035 lamps: lampsOf(await read($, context)),
1036 foes: dungeon ? errStreak : 0,
1037 bugs: await read($, bugs),
1038 todos: task,
1039 now,
1040 sky: skyOf(hour),
1041 holiday,
1042 fireworks: holiday === 'newyear' && isMidnightNewYear(day),
1043 hat,
1044 shiny: (await read($, shiny)) === true,
1045 late: isLate(hour),
1046 fx: await read($, fx),
1047 // Earned the crown this turn: shades on, the cool kind.
1048 face: wornFace ?? (best >= CROWN_AT ? 'shades' : null),
1049 tie: running.length >= TIE_AT,
1050 scarf: scarfOn ? scarfKey : null,
1051 }
1052 const all = lines(stage(shown, crew, f, cols, extras))
1053 // Standing Claude reaches the second line; only the alert crop cuts into him.
1054 const stageLines = all.slice(rows >= 8 ? 0 : Math.min(2, Math.max(1, all.length - room)))
1055 const ctx = await read($, context)
1056 const gauges = [
1057 ...(ctx === null ? [] : [{ label: 'ctx', percent: Math.round(ctx) }]),
1058 ...(await read($, limits)).map(l => ({ label: l.kind, percent: l.percent })),
1059 ]
1060 const tone = (p: number) => (p >= 95 ? '#e5534b' : p >= 80 ? '#e0b84c' : undefined)
1061 const hasRoom = rows >= stageLines.length + captionRows + 1
1062 const roster =
1063 !hasRoom || (crew.length === 0 && !task && gauges.length === 0 && streak < COMBO_AT && !(dungeon && coins > 0)) ? null : (
1064 <Box flexDirection="row">
1065 {gauges.map((g, n) => (
1066 <Text color={tone(g.percent)} dimColor={!tone(g.percent)}>
1067 {`${n ? ' · ' : ''}${g.label} ${g.percent}%`}
1068 </Text>
1069 ))}
1070 {gauges.length > 0 && (task || crew.length > 0 || streak >= COMBO_AT || (dungeon && coins > 0)) ? <Text dimColor>{' '}</Text> : null}
1071 {dungeon && coins > 0 ? <Text color="#f2d24b">{`◉ ${coins} gold `}</Text> : null}
1072 {streak >= COMBO_AT ? <Text color="#e0b84c" bold>{`×${streak} combo${streak >= CROWN_AT ? ' 👑' : ''} `}</Text> : null}
1073 {task ? <Text color="#f5d76e">{dungeon ? `▣ ${task.done}/${task.total} chests ` : `● ${task.done}/${task.total} tasks `}</Text> : null}
1074 {crew.slice(0, 4).map(w => (
1075 <Text color={TIER_COLOR[w.state === 'failed' ? 'other' : w.tier]}>
1076 {`${w.state === 'running' ? '●' : w.state === 'failed' ? '✗' : '✓'} ${fit(w.label, Math.max(8, Math.floor(cols / 5) - 3))} `}
1077 </Text>
1078 ))}
1079 {crew.length > 4 ? <Text dimColor>{`+${crew.length - 4}`}</Text> : null}
1080 </Box>
1081 )
1082 return (
1083 <Box flexDirection="column">
1084 {stageLines.map(line => (
1085 <Box flexDirection="row">
1086 {line.map(s => (
1087 <Text color={s.color} backgroundColor={s.backgroundColor}>
1088 {s.text}
1089 </Text>
1090 ))}
1091 </Box>
1092 ))}
1093 {captionRows ? caption : null}
1094 {roster}
1095 {below}
1096 </Box>
1097 )
1098 })
1099}
1100hooks/art.ts 1112 lines1/**
2 * The stage, drawn with half blocks: each terminal cell is two pixels, the top
3 * one the glyph's color and the bottom one its background.
4 * Hero walk and cheer frames from Claude Fables (henrik-thevibe/Claude-Fables), MIT;
5 * the other poses, the scenery, the weather and the critters are ours.
6 */
7import type { Action, Emote, Face, Fx, Hat, Hero, Holiday, Worker } from '../types'
8
9const PALETTE: Record<string, string> = {
10 o: '#d77757', // Clawd's body color in the Claude Code binary
11 k: '#1f1e1d', // ink
12 p: '#9b7fd4', // Opus purple
13 t: '#5fb8b0', // Sonnet teal
14 h: '#8fcf6a', // Haiku green
15 f: '#e0b84c', // Fable gold
16 e: '#8a8780', // other / failed: stone
17 b: '#5b8dd9', // book cover
18 B: '#ece6d6', // pages
19 s: '#8b5a2b', // handle
20 m: '#b8b8b8', // metal
21 d: '#6b4a2e', // dirt
22 w: '#f2f2f2', // wing, z
23 y: '#d9b35b', // box
24 z: '#f2d24b', // star
25 r: '#e5534b', // alert
26 q: '#f27fa5', // heart
27 v: '#a7afb8', // cloud
28 j: '#6fa8dc', // rain
29 l: '#fff27a', // lightning
30 U: '#c0504d', // umbrella
31 x: '#b03a2e', // bug
32 Y: '#f5d76e', // pellet
33 G: '#3f8f4a', // leaves
34 P: '#2f6b4a', // pine
35 T: '#7a5230', // trunk
36 C: '#5d9b4f', // cactus
37 R: '#7a3b3b', // roof
38 H: '#8a6f5a', // wall
39 L: '#f2d24b', // lit window
40 M: '#f2eecb', // moon
41 g: '#4f7f3a', // grass
42 S: '#d8b26e', // sand
43 N: '#3a3a5a', // night ground
44 W: '#e8eef2', // snow
45 n: '#f2a6c8', // shiny Claude
46 A: '#8d6cc4', // witch hat, bats
47 O: '#e8892b', // pumpkin, dusk sun
48 c: '#4fa3e0', // whale
49 D: '#25232b', // dungeon brick
50 K: '#35323d', // mortar
51 E: '#3b312c', // lamplit brick
52 J: '#4a3d33', // lamplit mortar
53 F: '#3d3945', // flagstone
54 Q: '#79c24a', // slime
55}
56
57type Sprite = readonly string[]
58
59// 9 pixels wide, 5 tall: body columns 1-7, arms at 0 and 8, four legs.
60const LEGS_A = '.o.o.o.o.'
61const LEGS_B = '..o.o.o.o'
62
63const HEAD = '.ooooooo.'
64const EYES = '.okoooko.'
65const ARMS = 'ooooooooo'
66/** Clawd's eyes are tall bars: the second pixel sits in the arms row. */
67const ARMS_EYES = 'ookoookoo'
68
69const STAND: Sprite = [HEAD, EYES, ARMS_EYES, HEAD, LEGS_A]
70const STRIDE: Sprite = [HEAD, EYES, ARMS_EYES, HEAD, LEGS_B]
71
72/** Arms up, eyes squeezed into > < : Clawd's happy face. */
73const CHEER: Sprite = ['o.......o', 'okoooooko', '.okoooko.', '.koooook.', LEGS_A]
74
75/** Arms up and a red "!" beside the head: something needs the user. */
76const ALERT: Sprite = ['o.......o.r', 'ooooooooo.r', '.okoooko..r', HEAD, '.o.o.o.o..r']
77
78const SIT: Sprite = [HEAD, EYES, ARMS_EYES, HEAD]
79const BLINK: Sprite = [HEAD, HEAD, ARMS, HEAD]
80const YAWN: Sprite = [HEAD, '.kkoookk.', 'ooookoooo', HEAD]
81const SLEEP: Sprite = ['..ooooo..', '.kkoookk.', ARMS]
82
83/** Seated poses for the rest-time fidgets. */
84const WAVE: Sprite[] = [
85 ['.ooooooo.o', '.okoooko.o', 'ookoooko.', HEAD],
86 ['.ooooooo..o', '.okoooko.o', 'ookoooko.', HEAD],
87]
88const SCRATCH: Sprite[] = [
89 ['oooooooo.', EYES, '.okoookoo', HEAD],
90 [HEAD, 'ookoooko.', '.okoookoo', HEAD],
91]
92const LOOK: Sprite[] = [
93 [HEAD, '.koookoo.', 'okoookooo', HEAD],
94 [HEAD, '.ookoook.', 'oookoooko', HEAD],
95]
96/** One eye shut to a dash. */
97const WINK: Sprite = [HEAD, '.okooooo.', 'ookookkoo', HEAD]
98
99/** Sitting with a rod out front; the line and water are drawn by the stage. */
100const FISH: Sprite = ['.ooooooo...m', '.okoooko..m.', 'ookoookoos..', HEAD]
101/** Rod bent up, eyes squeezed shut: something bit. */
102const REEL: Sprite = ['.ooooooo.mm.', '.kkoookk.m..', 'ooooooooos..', HEAD]
103
104/** Cross-eyed and wobbling after two stumbles in a row; stars circle the head. */
105const DIZZY: Sprite[] = [
106 [HEAD, '.ookokoo.', ARMS_EYES, HEAD, LEGS_A],
107 [HEAD, '.ookokoo.', ARMS_EYES, HEAD, LEGS_B],
108]
109/** Arms up, brows down: three in a row. The table is drawn by the stage. */
110const FLIP: Sprite = ['o.......o', 'ookoookoo', '.ookokoo.', HEAD, LEGS_A]
111/** A broom out front, bristles swishing: compaction. */
112const SWEEP: Sprite[] = [
113 [HEAD, EYES, 'ookoookoos..', '.ooooooo..s.', '.o.o.o.o..yy'],
114 [HEAD, EYES, 'ookoookoos..', '.ooooooo.s..', '.o.o.o.o.yy.'],
115]
116
117/** Paws over the eyes, knees knocking: rm -rf. */
118const COVER: Sprite[] = [
119 [HEAD, ARMS, HEAD, HEAD, LEGS_A],
120 [HEAD, ARMS, HEAD, HEAD, '.oo...oo.'],
121]
122
123const READ: Sprite[] = [
124 [HEAD, '.okoooko.BBb', 'ookoookoobBb', '.ooooooo.bbb', LEGS_A],
125 [HEAD, '.ooooooo.BBb', 'ooooooooobbB', '.ooooooo.bbb', LEGS_A], // blink, page turns
126]
127
128const DIG: Sprite[] = [
129 ['.ooooooo...m', '.okoooko..s.', 'ookoookoos..', HEAD, LEGS_A],
130 ['.ooooooo.d.d', EYES, 'ookoookoos..', '.ooooooo..s.', '.o.o.o.o..mm'],
131]
132
133const FLY: Sprite[] = [
134 ['w.......w', 'wooooooow', EYES, EYES, '..o...o..'],
135 ['.........', HEAD, EYES, 'wokoookow', 'w.o...o.w'],
136]
137
138const CARRY_TOP: Sprite = ['.yyyyyyy.', 'oysyyysyo', 'ooooooooo', EYES, EYES]
139const CARRY: Sprite[] = [
140 [...CARRY_TOP, LEGS_A],
141 [...CARRY_TOP, LEGS_B],
142]
143
144const SNEAK: Sprite[] = [
145 [HEAD, EYES, ARMS_EYES, LEGS_A],
146 [HEAD, EYES, ARMS_EYES, LEGS_B],
147]
148
149/** Flat on its back, legs up, stars circling. */
150const TRIP: Sprite[] = [
151 ['.z...z...', LEGS_A, HEAD, ARMS_EYES, '.okoooko.'],
152 ['z.....z..', LEGS_B, HEAD, ARMS_EYES, '.okoooko.'],
153]
154
155/** A short turn ends on a content blink, no fuss. */
156const NOD: Sprite[] = [STAND, [HEAD, HEAD, ARMS, HEAD, LEGS_A]]
157/** A long turn: one arm up, then the other, feet shuffling. */
158const DANCE: Sprite[] = [
159 ['o........', 'okoooooko', '.okoooko.', '.koooook.', LEGS_A],
160 ['........o', 'okoooooko', '.okoooko.', '.koooook.', LEGS_B],
161]
162/** A very long turn: a flag planted at the summit, flapping. */
163const FLAG: Sprite[] = [
164 ['.........srrr', '.ooooooo.srr.', '.okoooko.s...', 'ookoookoos...', '.ooooooo.s...', '.o.o.o.o.s'],
165 ['.........srr.', '.ooooooo.srrr', '.okoooko.s...', 'ookoookoos...', '.ooooooo.s...', '.o.o.o.o.s'],
166]
167/** Seated, juggling while the helpers work: arms take turns. The balls are drawn by the stage. */
168const JUGGLE: Sprite[] = [
169 ['o........', 'oooooooo.', EYES, '.okoookoo', HEAD],
170 ['........o', '.oooooooo', EYES, 'ookoooko.', HEAD],
171]
172/** Waking at the limit reset: arms high, eyes still shut. */
173const STRETCH: Sprite[] = [
174 ['o.......o', 'o.......o', HEAD, '.kkoookk.', HEAD, LEGS_A],
175 ['o.......o', HEAD, '.kkoookk.', HEAD, LEGS_A],
176]
177
178/** First session ever: an egg rocks and cracks, and Clawd hatches out. */
179const EGG: Sprite[] = [
180 ['...WWW...', '..WWWWW..', '.WWWWkWW.', '.WWWkWWW.', '..WWWWW..'],
181 ['..WWW....', '.WWWWW...', 'WWWWkWW..', 'WWWkWkWW.', '.WWWWW...'],
182 ['...WWW...', '..WWWWW..', '.WWkWkWW.', '.WkWWWkW.', '..WWWWW..'],
183 ['....WWW..', '...WWWWW.', '..WWkWWWW', '.WWkWkWWW', '...WWWWW.'],
184]
185
186/** Dungeon: a sword raised, then thrust at whatever is on the floor. */
187const FIGHT: Sprite[] = [
188 ['.ooooooo.m', '.okoooko.m', 'ookoookoos', HEAD, LEGS_A],
189 [HEAD, EYES, 'ookoookoosmmm', HEAD, LEGS_B],
190]
191/** Dungeon: a wand raised for a shell command, the star at its tip twinkling. */
192const CAST: Sprite[] = [
193 ['.ooooooo...z', '.okoooko..s.', 'ookoookoos..', HEAD, LEGS_A],
194 ['.ooooooo.A.A', '.okoooko..sz', 'ookoookoos.A', HEAD, LEGS_A],
195]
196/** Dungeon: Claude pulls out a scroll instead of a book. */
197const SCROLL: Sprite[] = [
198 ['.ooooooo.sss', '.okoooko.BkB', 'ookoookooBBB', '.ooooooo.sss', LEGS_A],
199 ['.ooooooo.sss', '.ooooooo.BkB', 'oooooooooBBB', '.ooooooo.sss', LEGS_A], // blink
200]
201
202/** A sticky note held up, then pressed to the wall with a red pin: a memory write. */
203const PIN: Sprite[] = [
204 [HEAD, EYES, 'ookoookooYY', '.ooooooo.YY', LEGS_A],
205 ['.oooooooorY', '.ookoookoYY', 'oookoook.', HEAD, LEGS_A],
206]
207/** Eyes on a grey cloud that sparks, a sweat drop running down: the API gave out. */
208const STUCK: Sprite[] = [
209 ['jooooooo..vv.', '.ookoook.vvvv', 'oookoooko..l.', HEAD, LEGS_A],
210 ['.ooooooo..vv.', 'jookoook.vrvv', 'oookoooko....', HEAD, LEGS_A],
211]
212
213/** In-between poses: knees bent on the way down to a seat or up from it, eyes drooping before sleep. */
214const CROUCH: Sprite = [HEAD, EYES, ARMS_EYES, LEGS_A]
215const DROWSY: Sprite = [HEAD, '.kkoookk.', ARMS, HEAD]
216
217type Posture = 'stand' | 'sit' | 'lie' | 'air' | 'egg'
218const SEATED = new Set<Action>(['sit', 'yawn', 'wave', 'scratch', 'look', 'wink', 'fish', 'reel', 'listen', 'juggle', 'drowsy'])
219
220function posture(action: Action): Posture {
221 if (SEATED.has(action)) return 'sit'
222 if (action === 'sleep' || action === 'trip') return 'lie'
223 if (action === 'fly') return 'air'
224 if (action === 'hatch') return 'egg'
225 return 'stand'
226}
227
228/**
229 * The in-between poses played, one beat each, when Clawd's posture changes, so it doesn't pop
230 * from standing to seated or from the air to the ground. A trip stays instant: the fall is the joke.
231 */
232export function transition(from: Action, to: Action): readonly Action[] {
233 const a = posture(from), b = posture(to)
234 if (a === b || a === 'egg' || b === 'egg' || to === 'trip' || from === 'crouch' || from === 'drowsy') return []
235 if (b === 'lie') return ['drowsy']
236 if (from === 'sleep') return b === 'sit' ? ['drowsy'] : ['drowsy', 'crouch']
237 return ['crouch']
238}
239
240const UMBRELLA: Sprite = ['..UUUUU..', '.UUUUUUU.', 'U...s...U']
241const HEART: Sprite = ['q.q', '.q.']
242const ZED: Sprite = ['ww', '.w', 'ww']
243const BUG: Sprite[] = [
244 ['.xx.', 'x.x.'],
245 ['.xx.', '.x.x'],
246]
247const CLOUD: Sprite = ['..vvv...', '.vvvvvv.', 'vvvvvvvv']
248const BOLT: Sprite = ['.l', 'l.', '.l']
249
250/** Pixels the critter floats above the ground, per action and frame. */
251function lift(action: Action, frame: number): number {
252 if (action === 'fly') return 2 + (frame % 4 < 2 ? 1 : 0)
253 if (action === 'cheer') return frame % 2 === 0 ? 1 : 0
254 if (action === 'alert') return frame % 4 < 2 ? 2 : 0
255 // Seated, Clawd breathes: a one-pixel bob, the official idle.
256 if (action === 'sit' || action === 'wink' || action === 'fish') return Math.floor(frame / 4) % 2
257 if (action === 'dance') return Math.floor(frame / 2) % 2
258 // Nodding along to the music.
259 if (action === 'listen') return Math.floor(frame / 3) % 2
260 return 0
261}
262
263export function sprite(action: Action, frame: number, dungeon = false): Sprite {
264 const two = frame % 2
265 switch (action) {
266 case 'read':
267 return (dungeon ? SCROLL : READ)[frame % 12 === 0 ? 1 : 0] ?? STAND
268 case 'cast':
269 return CAST[Math.floor(frame / 2) % 2] ?? STAND
270 case 'fight':
271 return FIGHT[Math.floor(frame / 2) % 2] ?? STAND
272 case 'dig':
273 return DIG[Math.floor(frame / 2) % 2] ?? STAND
274 case 'fly':
275 return FLY[two] ?? STAND
276 case 'carry':
277 return CARRY[two] ?? STAND
278 case 'sneak':
279 return SNEAK[Math.floor(frame / 2) % 2] ?? STAND
280 case 'trip':
281 return TRIP[Math.floor(frame / 3) % 2] ?? STAND
282 case 'cheer':
283 return CHEER
284 case 'alert':
285 return ALERT
286 case 'sit':
287 return frame % 25 === 0 ? BLINK : SIT
288 case 'yawn':
289 return YAWN
290 case 'sleep':
291 return SLEEP
292 case 'wave':
293 return WAVE[Math.floor(frame / 2) % 2] ?? SIT
294 case 'scratch':
295 return SCRATCH[Math.floor(frame / 2) % 2] ?? SIT
296 case 'look':
297 return LOOK[Math.floor(frame / 5) % 2] ?? SIT
298 case 'cover':
299 return COVER[two] ?? STAND
300 case 'dizzy':
301 return DIZZY[Math.floor(frame / 2) % 2] ?? STAND
302 case 'flip':
303 return FLIP
304 case 'sweep':
305 return SWEEP[Math.floor(frame / 2) % 2] ?? STAND
306 case 'drop':
307 return CHEER
308 case 'wink':
309 return WINK
310 case 'fish':
311 return FISH
312 case 'reel':
313 return REEL
314 case 'run':
315 return two ? STRIDE : STAND
316 case 'nod':
317 return NOD[frame % 10 < 6 ? 0 : 1] ?? STAND
318 case 'dance':
319 return DANCE[Math.floor(frame / 2) % 2] ?? CHEER
320 case 'flag':
321 return FLAG[Math.floor(frame / 3) % 2] ?? STAND
322 case 'listen':
323 return frame % 16 < 4 ? BLINK : SIT
324 case 'juggle':
325 return JUGGLE[Math.floor(frame / 2) % 2] ?? SIT
326 case 'stretch':
327 return STRETCH[Math.floor(frame / 5) % 2] ?? STAND
328 case 'hatch':
329 return EGG[Math.floor(frame / 2) % EGG.length] ?? STAND
330 case 'pin':
331 return PIN[Math.floor(frame / 3) % 2] ?? STAND
332 case 'stuck':
333 return STUCK[Math.floor(frame / 3) % 2] ?? STAND
334 case 'crouch':
335 return CROUCH
336 case 'drowsy':
337 return DROWSY
338 default:
339 return Math.floor(frame / 2) % 2 ? STRIDE : STAND
340 }
341}
342
343/** Columns moved per tick: fractional speeds are spread over ticks. */
344export function speed(action: Action): number {
345 switch (action) {
346 case 'run':
347 case 'fly':
348 return 1
349 case 'walk':
350 case 'carry':
351 return 0.5
352 case 'sneak':
353 return 0.34
354 default:
355 return 0
356 }
357}
358
359export const HERO_W = 13
360export const STAGE_ROWS = 12 // pixel rows: 6 terminal lines
361const GROUND = STAGE_ROWS - 1
362
363type SceneDef = { ground: string; prop: Sprite; sky?: Sprite }
364
365const SCENES: readonly SceneDef[] = [
366 {
367 ground: 'g',
368 prop: ['..GGG..', '.GGGGG.', 'GGGGGGG', '.GGGGG.', 'GGGGGGG', '...T...', '...T...'],
369 },
370 {
371 ground: 'S',
372 prop: ['..C....', '..C.C..', 'C.C.C..', 'CCC.C..', '..CCC..', '..C....', '..C....'],
373 },
374 {
375 ground: 'N',
376 prop: ['...R...', '..RRR..', '.RRRRR.', '.HLHLH.', '.HHHHH.', '.HLHHH.', '.HHHHH.'],
377 sky: ['.MM.', 'MMMM', '.MM.'],
378 },
379 {
380 ground: 'W',
381 prop: ['...P...', '..PPP..', '.PPPPP.', '..PPP..', '.PPPPP.', 'PPPPPPP', '...T...'],
382 },
383]
384
385/** The dungeon hallway's props, one per scene: a door, barrels, a portcullis, a bone pile. */
386const DUNGEON_PROPS: readonly Sprite[] = [
387 ['..eee..', '.eTTTe.', 'eTTTTTe', 'eTTTTTe', 'eTTTyTe', 'eTTTTTe', 'eTTTTTe'],
388 ['.......', '.......', '.TTT...', 'TsssT..', 'TTTTT.T', 'TsssTTs', 'TTTTTTT'],
389 ['mmmmmmm', 'm.m.m.m', 'm.m.m.m', 'mmmmmmm', 'm.m.m.m', 'm.m.m.m', 'm.m.m.m'],
390 ['.......', '.......', '.......', '..WWW..', '.WkWkW.', 'WWWWWWW', 'WkWWWkW'],
391]
392
393export const SCENE_COUNT = SCENES.length
394
395const TINT: Record<Worker['tier'], string> = { opus: 'p', sonnet: 't', haiku: 'h', fable: 'f', other: 'e' }
396export const TIER_COLOR: Record<Worker['tier'], string> = {
397 opus: '#9b7fd4',
398 sonnet: '#5fb8b0',
399 haiku: '#8fcf6a',
400 fable: '#e0b84c',
401 other: '#8a8780',
402}
403
404export function tierOf(model: string | undefined): Worker['tier'] {
405 const m = (model ?? '').toLowerCase()
406 for (const t of ['opus', 'sonnet', 'haiku', 'fable'] as const) if (m.includes(t)) return t
407 return 'other'
408}
409
410/** Context fill as weather: clear, clouds, rain, storm. */
411export type Weather = 'clear' | 'clouds' | 'rain' | 'storm'
412/** Context fill as lamp oil, in the dungeon: 0 all bright, 1 burning low, 2 half out, 3 one lamp left. */
413export function lampsOf(percent: number | null): 0 | 1 | 2 | 3 {
414 if (percent === null || percent < 50) return 0
415 if (percent < 70) return 1
416 if (percent < 85) return 2
417 return 3
418}
419
420export function weatherOf(percent: number | null): Weather {
421 if (percent === null || percent < 50) return 'clear'
422 if (percent < 70) return 'clouds'
423 if (percent < 85) return 'rain'
424 return 'storm'
425}
426
427export type Extras = {
428 weather: Weather
429 bugs: number
430 todos: { done: number; total: number } | null
431 /** Clock ms, for effects; time of day and the holiday come from the caller. */
432 now?: number
433 sky?: 'day' | 'dusk' | 'night'
434 holiday?: Holiday | null
435 fireworks?: boolean
436 hat?: Hat | null
437 shiny?: boolean
438 late?: boolean
439 fx?: readonly Fx[]
440 face?: Face | null
441 /** Three or more helpers out: the boss tie. */
442 tie?: boolean
443 /** The project's scarf, a palette key; null with /tales scarf off. */
444 scarf?: string | null
445 /** The dungeon theme: a lamplit hallway instead of the meadow and its weather. */
446 dungeon?: boolean
447 /** Dungeon lamp level from lampsOf. */
448 lamps?: 0 | 1 | 2 | 3
449 /** Dungeon: skeletons squaring up to Claude after failed calls. */
450 foes?: number
451}
452
453const NONE: Extras = { weather: 'clear', bugs: 0, todos: null }
454
455function paint(grid: string[][], art: Sprite, left: number, top: number, onlyEmpty = false) {
456 art.forEach((row, y) => {
457 const line = grid[top + y]
458 if (!line) return
459 for (let x = 0; x < row.length; x++) {
460 const c = row[x]
461 const at = left + x
462 if (!c || c === '.' || at < 0 || at >= line.length) continue
463 if (onlyEmpty && line[at] !== '.') continue
464 line[at] = c
465 }
466 })
467}
468
469export function mirror(art: Sprite): Sprite {
470 return art.map(r => r.padEnd(HERO_W, '.').split('').reverse().join(''))
471}
472
473/** The eye rows, shifted one column to glance left (-1) or right (1). */
474const GLANCES: readonly [string, string, string][] = [
475 [EYES, '.koookoo.', '.ookoook.'],
476 [ARMS_EYES, 'okoookooo', 'oookoooko'],
477]
478function glanced(rows: Sprite, look: -1 | 1): Sprite {
479 return rows.map(r => {
480 for (const [from, left, right] of GLANCES) if (r.includes(from)) return r.replace(from, look < 0 ? left : right)
481 return r
482 })
483}
484
485function critter(grid: string[][], action: Action, x: number, dir: 1 | -1, frame: number, body?: string, look?: -1 | 1 | null, rise = 0, dungeon = false) {
486 const raw = sprite(action, frame, dungeon)
487 const art = body ? raw.map(r => r.replaceAll('o', body)) : raw
488 let drawn = dir < 0 ? mirror(art) : art
489 if (look && !body) drawn = glanced(drawn, look)
490 const top = GROUND - drawn.length - lift(action, frame) - rise
491 paint(grid, drawn, Math.round(x), top)
492 return top
493}
494
495const STARS: readonly [number, number][] = [[0, 0], [2, -1], [4, -1], [6, -1], [8, 0], [6, 1], [4, 1], [2, 1]]
496const TABLE: Sprite[] = [
497 ['sssss', 's...s'],
498 ['s...s', 'sssss'],
499]
500
501/** Hats are the body's width (9), sitting on the pixel rows just above the head. */
502const HATS: Record<Hat, Sprite> = {
503 witch: ['....A....', '...AAA...', 'AAAzzzAAA'],
504 santa: ['.....rW..', '..rrrr...', '.WWWWWWW.'],
505 party: ['....z....', '...qbq...', '..bqbqb..'],
506 nightcap: ['......bw.', '..bbbbb..', '.WWWWWWW.'],
507 crown: ['.z.z.z.z.', '.zzzzzzz.'],
508 tophat: ['..eeeee..', '..eeeee..', '..WWWWW..', '.eeeeeee.'],
509 gradcap: ['eeeeeeeee', '..eeeeez.', '..eeeee.z'],
510 captain: ['..WWWWW..', '.WWWzWWW.', 'eeeeeeeee'],
511 wizard: ['....b....', '...bbb...', '..bbzbb..', 'bbbbbbbbb'],
512 hardhat: ['...zzz...', '.zzzzzzz.', 'yyyyyyyyy'],
513 shell: ['..W.W.W..', '..WWWWW..'],
514 deerstalker: ['...TTT...', '.TTsTsTT.', 'TTTTTTTTT'],
515 beanie: ['....W....', '..ggggg..', '.gGgGgGg.'],
516 pith: ['..SSSSS..', '.SSSSSSS.', 'SSSSSSSSS'],
517 sprout: ['...h.h...', '....h....'],
518 beret: ['.....t...', '..ttttt..', '.ttttttt.'],
519 regal: ['.p.p.p.p.', '.ppppppp.'],
520}
521
522/** Face accessories, drawn from the eye row down. */
523const FACES: Record<Face, Sprite> = {
524 glasses: ['.kkk.kkk.', 'kkWkkkWkk'],
525 shades: ['kkkkkkkkk', '.kkW.kkW.'],
526 mustache: ['.........', '.........', '.kkk.kkk.'],
527}
528/** Which sprite row the eyes are on, per pose; poses without a usable face are left out. */
529const EYE_ROW: Partial<Record<Action, number>> = {
530 walk: 1, run: 1, read: 1, dig: 1, sneak: 1, sit: 1, wave: 1, scratch: 1, look: 1, wink: 1, fish: 1,
531 cheer: 1, yawn: 1, alert: 2, fly: 2, carry: 3, nod: 1, dance: 1, flag: 2, listen: 1, juggle: 2, fight: 1, cast: 1, crouch: 1, drowsy: 1, pin: 1, stuck: 1,
532}
533/** Three or more helpers out: Claude is the boss, collar and tie under the eyes. */
534const TIE: Sprite = ['...WrW...', '....r....']
535/** Over each eye a heart, the notch filled in with the body color (B). */
536const HEART_EYES: Sprite = ['.qBq.qBq.', '..q...q..']
537/** Worried brows a row above the eyes: with the eyes glancing away they slant into a side-eye. */
538const BROWS: Sprite = ['..k...k..']
539const HEADPHONES: Sprite = ['..eeeee..', '.e.....e.', 'r.......r', 'r.......r']
540const NOTE: Sprite = ['.qq', '.q.', 'qq.']
541const BODY_W = 9
542
543/** The thought bubble, dots filling in. */
544function bubble(frame: number): Sprite {
545 const dots = ['wkwwwww', 'wkwkwww', 'wkwkwkw'][Math.floor(frame / 3) % 3] ?? 'wkwkwkw'
546 return ['.wwwww.', dots, '.wwwww.', 'w......']
547}
548
549const DREAM_ICONS: Partial<Record<Action, Sprite>> = {
550 read: ['BBbBB', 'BBbBB', 'bbbbb'],
551 dig: ['.m...', '..s..', '...s.'],
552 fly: ['w...w', 'ww.ww', '.w.w.'],
553 carry: ['yyyyy', 'ysyys', 'yyyyy'],
554 sneak: ['.mm..', 'm..m.', '.mm.s'],
555 run: ['..z..', '.zzz.', '..z..'],
556 cover: ['..r..', '..r..', '..r..'],
557}
558function dream(last: Action | undefined): Sprite {
559 const icon = (last && DREAM_ICONS[last]) || ['.q.q.', '.qqq.', '..q..']
560 return ['.wwwwwww.', ...icon.map(r => `w.${r}.w`), '.wwwwwww.', '.w.......']
561}
562
563const BUTTERFLY: Sprite[] = [
564 ['q.q', '.s.'],
565 ['.q.', '.s.'],
566]
567const DROP: Sprite = ['j', 'j']
568const BAT: Sprite[] = [
569 ['A.A.A', '.AAA.'],
570 ['.A.A.', 'AAAAA'],
571]
572const PUMPKIN: Sprite = ['..G..', '.OOO.', 'OLOLO', 'OOOOO', '.OOO.']
573const SUN: Sprite = ['.zz.', 'zzzz', 'zzzz', '.zz.']
574const DUSK_SUN: Sprite = ['.OO.', 'OOOO']
575const MOON: Sprite = ['.MM.', 'MM..', 'MM..', '.MM.']
576const MUG: Sprite[] = [
577 ['.w.', 'w..', 'ss.', 'sss'],
578 ['w..', '.w.', 'ss.', 'sss'],
579]
580
581const BOX: Sprite = ['yyy', 'ysy']
582const PLANE: Sprite = ['w....', 'www..', '.wwww']
583const WHALE: Sprite[] = [
584 ['..........j.j', '...cccccc..j.', '.cccccccccc.c', 'ccwccccccccc.', '.BBBBBBBccc..'],
585 ['...........j.', '...cccccc.j.j', '.cccccccccc..', 'ccwcccccccccc', '.BBBBBBBccc..'],
586]
587const UFO: Sprite[] = [
588 ['...mmm...', '.mmmmmmm.', 'lmlmlmlml', '.mmmmmmm.'],
589 ['...mmm...', '.mmmmmmm.', 'mlmlmlmlm', '.mmmmmmm.'],
590]
591const TRAIN_SMOKE: Sprite[] = [
592 ['..ww', '.ww.'],
593 ['.ww.', 'ww..'],
594]
595const TRAIN: Sprite = [
596 '..ee..................................',
597 '.eeeeeee.UUU..UUUUUUUUU. UUUUUUUUU...',
598 '.eeeeeeeeUUU..ULULULULU...ULULULULU...',
599 'eeeeeeeeeeeee.UUUUUUUUU...UUUUUUUUU...',
600 '.m.m...m.m.....m.....m.....m.....m....',
601].map(r => r.replace(/ /g, '.'))
602const CONFETTI = ['r', 'z', 't', 'q', 'b', 'h']
603/** A gold coin, spinning edge-on and back. */
604const COIN: Sprite[] = [['zz', 'Oz'], ['z', 'O'], ['zz', 'zO']]
605/** Dungeon todos: one closed chest per pending item. */
606const CHEST: Sprite = ['.TTT.', 'TyzyT', 'TTTTT']
607/** Dungeon test failures: slimes squishing along the floor. */
608const SLIME: Sprite[] = [
609 ['.QQQ.', 'QkQkQ', 'QQQQQ'],
610 ['.....', 'QkQkQ', 'QQQQQ'],
611]
612/** Dungeon tool errors: a skeleton with a club, facing Claude. */
613const SKELETON: Sprite[] = [
614 ['.WWW.', 'WkWkW', '.WWWs', 'WWWWs', '.W.W.'],
615 ['.WWW.', 'WkWkW', '.WWW.', 'WWWWs', '.W.Ws'],
616]
617const LAMP_FLAME: Sprite[] = [
618 ['.z.', 'zlz', '.O.'],
619 ['z..', '.lz', '.O.'],
620]
621const LAMP_LOW: Sprite = ['...', '.z.', '.O.']
622const LAMP_OUT: Sprite[] = [
623 ['.v.', 'v..', '...'],
624 ['v..', '.v.', '...'],
625]
626const SCONCE: Sprite = ['sss', '.s.']
627const LAMP_TOP = 2
628
629/** One effect at its age: where it is, drawn over the stage. */
630function effect(grid: string[][], fx: Fx, now: number, frame: number, cols: number) {
631 const age = Math.max(0, now - fx.start)
632 const t = Math.min(1, age / fx.dur)
633 switch (fx.kind) {
634 case 'confetti':
635 for (let n = 0; n < 14; n++) {
636 // Bursts out over the head, then flutters down.
637 const vx = ((n * 37) % 13) - 6
638 const x = Math.round(fx.x + 4 + vx * (0.6 + t * 2.5))
639 const y = Math.round(((n * 5) % 3) + 2 + t * t * 8)
640 paint(grid, [CONFETTI[n % CONFETTI.length] ?? 'z'], x, y)
641 }
642 return
643 case 'plane': {
644 const x = Math.round(fx.x + 8 + t * (cols + 10))
645 const y = Math.round(6 - t * 6 + Math.sin(t * 12) * 1.5)
646 paint(grid, PLANE, x, y)
647 return
648 }
649 case 'boxes':
650 for (let n = 0; n < 3; n++) {
651 const land = Math.min(1, Math.max(0, (age - n * 500) / 600))
652 if (age < n * 500) continue
653 const y = Math.round(land * (GROUND - 2 - n * 2))
654 paint(grid, BOX, Math.round(fx.x), y)
655 }
656 return
657 case 'train': {
658 const x = Math.round(cols - t * (cols + TRAIN[0]!.length + 4))
659 paint(grid, TRAIN, x, GROUND - TRAIN.length)
660 paint(grid, TRAIN_SMOKE[frame % 2] ?? [], x + 1, GROUND - TRAIN.length - 2)
661 return
662 }
663 case 'whale':
664 paint(grid, WHALE[Math.floor(frame / 4) % 2] ?? [], Math.round(-14 + t * (cols + 16)), 0, true)
665 return
666 case 'ufo':
667 paint(grid, UFO[frame % 2] ?? [], Math.round(cols - t * (cols + 10)), 0, true)
668 return
669 case 'coin': {
670 // A coin pops up over Claude's head, spinning, and fades.
671 const spin = COIN[Math.floor(frame / 2) % COIN.length] ?? []
672 if (t < 0.85) paint(grid, spin, Math.round(fx.x + 4), Math.max(0, Math.round(3 - t * 3)))
673 return
674 }
675 case 'coins':
676 for (let n = 0; n < 6; n++) {
677 // A burst arcs out from the chest or the slain monster and rains onto the floor.
678 const vx = (n - 2.5) * 1.6
679 const x = Math.round(fx.x + 4 + vx * (0.5 + t * 2))
680 const y = Math.min(GROUND - 1, Math.round(4 - Math.sin(t * Math.PI) * 4 + t * 6))
681 paint(grid, [(frame + n) % 3 === 0 ? 'O' : 'z'], x, y)
682 }
683 return
684 case 'poof': {
685 const r = 1 + t * 3
686 for (let n = 0; n < 8; n++) {
687 if (t > 0.5 && (n + frame) % 2 === 0) continue
688 const a = (n / 8) * Math.PI * 2
689 paint(grid, ['v'], Math.round(fx.x + 2 + Math.cos(a) * r * 1.6), Math.round(GROUND - 3 + Math.sin(a) * r * 0.7))
690 }
691 return
692 }
693 case 'warp': {
694 // A ring of the server's color spreads from Claude and thins out.
695 const r = 1 + t * 6
696 const c = fx.color ?? 'p'
697 for (let n = 0; n < 10; n++) {
698 if (t > 0.6 && (n + frame) % 2 === 0) continue
699 const a = (n / 10) * Math.PI * 2 + t
700 paint(grid, [c], Math.round(fx.x + 4 + Math.cos(a) * r * 1.8), Math.round(GROUND - 4 + Math.sin(a) * r * 0.8), true)
701 }
702 return
703 }
704 }
705}
706
707/** Night stars, a sun or a moon, all behind the weather. */
708function sky(grid: string[][], cols: number, frame: number, e: Extras, ownSky: boolean) {
709 if (!e.sky) return
710 if (e.sky === 'night') {
711 for (let x = 3; x < cols; x += 9) {
712 const y = (x * 7) % 4
713 if ((frame + x) % 23 !== 0) paint(grid, ['W'], x, y)
714 }
715 }
716 if (ownSky || e.weather !== 'clear') return
717 const art = e.sky === 'day' ? SUN : e.sky === 'dusk' ? DUSK_SUN : MOON
718 paint(grid, art, cols - 8, e.sky === 'dusk' ? 3 : 0)
719}
720
721function festive(grid: string[][], cols: number, frame: number, e: Extras) {
722 switch (e.holiday) {
723 case 'halloween':
724 for (let n = 0; n < 2; n++) {
725 const x = (Math.floor(frame / 2) + n * 40) % (cols + 10) - 5
726 paint(grid, BAT[(frame + n) % 2] ?? [], x, 1 + n * 2 + (Math.floor(frame / 3) % 2), true)
727 }
728 paint(grid, PUMPKIN, 1, GROUND - PUMPKIN.length)
729 return
730 case 'valentine':
731 for (let n = 0; n < 3; n++) {
732 const y = 8 - (Math.floor(frame / 3) + n * 3) % 9
733 paint(grid, HEART, (n * 31 + 7) % Math.max(1, cols - 3), y, true)
734 }
735 return
736 default:
737 return
738 }
739}
740
741function fireworks(grid: string[][], cols: number, frame: number) {
742 const burst = Math.floor(frame / 8)
743 const age = frame % 8
744 const x = (burst * 29) % Math.max(1, cols - 6) + 3
745 const c = CONFETTI[burst % CONFETTI.length] ?? 'z'
746 if (age < 2) paint(grid, [c], x, 5 - age * 2, true)
747 else if (age < 6) {
748 const r = age - 1
749 for (const [dx, dy] of [[0, -1], [1, 0], [0, 1], [-1, 0], [1, -1], [-1, -1], [1, 1], [-1, 1]] as const) {
750 paint(grid, [c], x + dx * r, 2 + dy * Math.ceil(r / 2), true)
751 }
752 }
753}
754
755/** Snow at Christmas falls whatever the weather. */
756function snow(grid: string[][], cols: number, frame: number) {
757 for (let x = 2; x < cols; x += 5) {
758 const y = (Math.floor(frame / 2) + x * 3) % GROUND
759 paint(grid, ['W'], x + (Math.floor(frame / 4) + x) % 2, y, true)
760 }
761}
762
763/** A sprite over the body, anchored like hats and face gear, flipped with Claude. */
764function onBody(grid: string[][], art: Sprite, x: number, dir: 1 | -1, y: number) {
765 paint(grid, dir > 0 ? art : art.map(r => r.padEnd(BODY_W, '.').split('').reverse().join('')), dir > 0 ? x : x + HERO_W - BODY_W, y)
766}
767
768function emote(grid: string[][], kind: Emote, x: number, dir: 1 | -1, top: number, frame: number, eyes?: number, body = 'o') {
769 switch (kind) {
770 case 'smitten':
771 if (eyes !== undefined) onBody(grid, HEART_EYES.map(r => r.replaceAll('B', body)), x, dir, top + eyes)
772 emote(grid, 'blush', x, dir, top, frame)
773 return
774 case 'sheepish':
775 if (eyes !== undefined) onBody(grid, BROWS, x, dir, top + eyes - 1)
776 emote(grid, 'sweat', x, dir, top, frame)
777 return
778 case 'think': {
779 // Clear of a hat brim on the side Claude faces.
780 const art = bubble(frame)
781 paint(grid, dir > 0 ? art : art.map(r => r.split('').reverse().join('')), dir > 0 ? x + 10 : x - 4, top - 3)
782 return
783 }
784 case 'blush':
785 paint(grid, ['q'], x + (dir > 0 ? 1 : 5), top + 3)
786 paint(grid, ['q'], x + (dir > 0 ? 7 : 11), top + 3)
787 paint(grid, HEART, dir > 0 ? x + 9 : x + 1, top - 1 - (Math.floor(frame / 3) % 2))
788 return
789 case 'sweat':
790 paint(grid, DROP, dir > 0 ? x + 8 : x + 4, top - 1 + (Math.floor(frame / 3) % 3))
791 return
792 case 'butterfly': {
793 const bx = x + 4 + Math.round(Math.sin(frame / 3) * 6)
794 const by = Math.max(0, top - 2 + Math.round(Math.cos(frame / 2)))
795 paint(grid, BUTTERFLY[frame % 2] ?? [], bx, by)
796 return
797 }
798 }
799}
800
801const FISHY: Sprite[] = [
802 ['.c.', 'ccc', '.c.'],
803 ['c..', 'ccc', '..c'],
804]
805
806/** The line from the rod tip to a puddle in front of Claude, a bobber, and on a bite a fish on the hook. */
807function fishingLine(grid: string[][], action: Action, x: number, dir: 1 | -1, top: number, frame: number, cols: number) {
808 // Sprite column c lands at x + c facing right, x + (HERO_W - 1 - c) mirrored.
809 const at = (c: number) => (dir > 0 ? x + c : x + HERO_W - 1 - c)
810 const pond = [13, 14, 15, 16, 17].map(at)
811 for (const c of pond) {
812 const row = grid[GROUND]
813 if (row && c >= 0 && c < cols) row[c] = 'j'
814 }
815 const lineCol = at(15)
816 if (action === 'fish') {
817 paint(grid, ['m'], at(12), top - 1)
818 paint(grid, ['m'], at(13), top - 1)
819 paint(grid, ['m'], at(14), top)
820 for (let y = top + 1; y < GROUND - 1; y++) paint(grid, ['m'], lineCol, y)
821 paint(grid, ['r'], lineCol, GROUND - 1 - (Math.floor(frame / 4) % 2))
822 return
823 }
824 // A bite: the line goes taut and a fish wriggles up out of the water.
825 paint(grid, ['m'], at(11), top - 1)
826 paint(grid, ['m'], at(12), top - 1)
827 paint(grid, ['m'], at(12), top)
828 const fish = FISHY[Math.floor(frame / 2) % 2] ?? []
829 paint(grid, dir > 0 ? fish : fish.map(r => r.split('').reverse().join('')), at(12) - 1, Math.min(top + 1, GROUND - 3))
830}
831
832/** A scarf on the first body row under the eyes, its tail streaming out behind. */
833function scarf(grid: string[][], rows: Sprite, color: string, x: number, dir: 1 | -1, top: number, eyes: number, frame: number) {
834 // Past the eyes and the arms (a blink frame's arm row has no eyes in it).
835 const arms = (r: string) => r[0] === 'o' && r[8] === 'o'
836 let neck = eyes + 1
837 while (neck < rows.length - 1 && ((rows[neck] ?? '').slice(1, 8).includes('k') || arms(rows[neck] ?? ''))) neck += 1
838 // Under a box or wings the eyes sit low and only legs are left: no scarf.
839 if (!/ooooo/.test(rows[neck] ?? '')) return
840 // Sprite column c lands at x + c facing right, x + (HERO_W - 1 - c) mirrored.
841 const at = (c: number) => (dir > 0 ? x + c : x + HERO_W - 1 - c)
842 const put = (c: number, y: number) => paint(grid, [color], at(c), top + y)
843 for (let c = 0; c <= 7; c++) put(c, neck)
844 put(0, neck + 1)
845 put(-1, neck + (frame % 6 < 3 ? 0 : 1)) // the tail flutters
846}
847
848const WALKING = new Set<Action>(['walk', 'run', 'sneak', 'carry'])
849
850/** Running helpers line up behind each other: four or more make a conga. */
851export const CONGA_AT = 4
852
853/** The stage as pixel rows: sky, weather, ground, scenery, pellets, bugs, helpers, Claude, then effects. */
854export function stage(hero: Hero, workers: readonly Worker[], frame: number, cols: number, extras: Extras = NONE): string[] {
855 const grid = Array.from({ length: STAGE_ROWS }, () => Array<string>(cols).fill('.'))
856 const def = SCENES[hero.scene % SCENES.length] ?? SCENES[0]
857 if (!def) return grid.map(r => r.join(''))
858 const dungeon = !!extras.dungeon
859 // Underground there is no weather: context fill burns the lamp oil instead.
860 const wet: Weather = dungeon ? 'clear' : extras.weather
861 const lit: [number, number][] = []
862 if (dungeon) {
863 grid[GROUND] = Array.from({ length: cols }, (_, x) => (x % 7 === 0 ? 'K' : 'F'))
864 const level = extras.lamps ?? 0
865 const spots: number[] = []
866 for (let x = 8 + ((hero.scene * 5) % 9); x < cols - 3; x += 22) spots.push(x)
867 // At the last level only the lamp nearest Claude still burns.
868 const nearest = spots.reduce((a, b) => (Math.abs(b - hero.x - 4) < Math.abs(a - hero.x - 4) ? b : a), spots[0] ?? 0)
869 spots.forEach((x, n) => {
870 const on = level < 2 || (level === 2 ? n % 2 === 0 : x === nearest && frame % 7 !== 0)
871 paint(grid, SCONCE, x, LAMP_TOP + 3)
872 if (!on) paint(grid, LAMP_OUT[Math.floor(frame / 4) % 2] ?? [], x, LAMP_TOP)
873 else if (level === 0) paint(grid, LAMP_FLAME[Math.floor(frame / 3) % 2] ?? [], x, LAMP_TOP)
874 else paint(grid, LAMP_LOW, x, LAMP_TOP)
875 if (on) lit.push([x + 1, level === 0 ? 4 + (frame % 5 === 0 ? 0 : 1) : 3])
876 })
877 } else {
878 grid[GROUND] = Array<string>(cols).fill(def.ground)
879 sky(grid, cols, frame, extras, !!def.sky)
880 if (def.sky && wet === 'clear') paint(grid, def.sky, cols - 8, 0)
881 }
882 if (wet !== 'clear') {
883 const drift = Math.floor(frame / 6)
884 for (let x = 0; x < cols + 20; x += 20) paint(grid, CLOUD, ((x + drift) % (cols + 20)) - 10, 0)
885 }
886 festive(grid, cols, frame, extras)
887 if (extras.fireworks) fireworks(grid, cols, frame)
888 const prop = dungeon ? (DUNGEON_PROPS[hero.scene % DUNGEON_PROPS.length] ?? def.prop) : def.prop
889 for (let x = 4 + ((hero.scene * 7) % 11); x < cols - 7; x += 26) {
890 paint(grid, prop, x, GROUND - prop.length)
891 }
892
893 // Todos on the ground, right to left: pellets in the meadow, treasure chests in the dungeon.
894 if (extras.todos && extras.todos.total > 0) {
895 const left = extras.todos.total - extras.todos.done
896 if (dungeon) for (let n = 0; n < Math.min(left, 8); n++) paint(grid, CHEST, cols - 6 - n * 7, GROUND - CHEST.length)
897 else for (let n = 0; n < Math.min(left, 12); n++) paint(grid, ['Y'], cols - 3 - n * 3, GROUND - 1)
898 }
899
900 // Failing tests: bugs crawl along the ground, or slimes squish along the dungeon floor.
901 for (let n = 0; n < Math.min(extras.bugs, 8); n++) {
902 const span = Math.max(1, cols - 6)
903 const x = (n * 37 + Math.floor((frame + n * 5) / (dungeon ? 5 : 3))) % span
904 if (dungeon) paint(grid, SLIME[Math.floor((frame + n * 3) / 3) % 2] ?? [], x, GROUND - 3)
905 else paint(grid, BUG[(frame + n) % 2] ?? [], x, GROUND - 2)
906 }
907
908 // Failed calls in the dungeon: skeletons line up in front of Claude, clubs toward it.
909 if (dungeon && extras.foes) {
910 for (let n = 0; n < Math.min(extras.foes, 3); n++) {
911 const art = SKELETON[Math.floor((frame + n * 2) / 3) % 2] ?? []
912 const ahead = hero.dir > 0 ? Math.round(hero.x) + HERO_W + n * 6 : Math.round(hero.x) - 6 - n * 6
913 paint(grid, hero.dir > 0 ? art.map(r => r.split('').reverse().join('')) : art, ahead, GROUND - art.length)
914 }
915 }
916
917 const now = extras.now ?? 0
918 const fx = extras.fx ?? []
919 for (const f of fx) if (f.kind === 'boxes') effect(grid, f, now, frame, cols)
920
921 workers.forEach((w, n) => {
922 const f = frame + n * 3 // out of step with each other
923 const body = w.state === 'failed' || w.sad ? 'e' : TINT[w.tier]
924 const top = critter(grid, w.action, w.x, w.dir, f, body, null, 0, !!extras.dungeon)
925 if (w.state === 'done' && w.sad) {
926 // Claude's paw pats the head of a helper that didn't make it.
927 paint(grid, ['ooo'], Math.round(w.x) + 3, Math.max(0, top - 1 - (Math.floor(f / 2) % 2)))
928 paint(grid, HEART, Math.round(w.x) + 8, Math.max(0, top - 2))
929 } else if (w.state === 'done') {
930 const rise = Math.floor(f / 3) % 2
931 paint(grid, HEART, Math.round(w.x) + 3, Math.max(0, top - 2 - rise))
932 }
933 })
934
935 // April 1st: Claude walks backwards.
936 const face: 1 | -1 = extras.holiday === 'aprilfools' && WALKING.has(hero.action) ? (hero.dir > 0 ? -1 : 1) : hero.dir
937 const x = Math.round(hero.x)
938 // Dropping in: falls from the sky over the first ~0.7 s, then a puff on landing.
939 const dropAge = hero.action === 'drop' ? Math.max(0, now - hero.since) : Infinity
940 const fall = Math.max(0, Math.round(12 - dropAge / 60))
941 // Heart eyes stay put; any other time the eyes may wander.
942 const glance = hero.glance && now < (hero.glanceUntil ?? 0) && hero.emote !== 'smitten' ? hero.glance : null
943 const top = critter(grid, hero.action, hero.x, face, frame, extras.shiny ? 'n' : undefined, glance, fall, dungeon)
944 if (hero.action === 'drop' && fall === 0 && dropAge < 1100) {
945 paint(grid, ['w', '.w'], x - 2, GROUND - 2)
946 paint(grid, ['.w', 'w'], x + 9, GROUND - 2)
947 }
948 if (hero.action === 'dizzy') {
949 for (let n = 0; n < 2; n++) {
950 const [dx, dy] = STARS[(Math.floor(frame / 2) + n * 4) % STARS.length] ?? [0, 0]
951 paint(grid, ['z'], x + dx, Math.max(0, top - 2 + dy))
952 }
953 }
954 if (hero.action === 'flip') {
955 const k = frame % 10
956 const tx = face > 0 ? x + 8 + k : x + 4 - k - 5
957 paint(grid, TABLE[Math.floor(frame / 2) % 2] ?? [], tx, Math.max(0, top - 1 - Math.floor(k / 2)))
958 }
959 if (hero.action === 'sweep' && frame % 4 < 2) {
960 paint(grid, ['v.v'], face > 0 ? x + 12 + (frame % 3) : x - 4 - (frame % 3), GROUND - 2 - (frame % 2), true)
961 }
962 if (extras.shiny && frame % 6 < 3) {
963 paint(grid, ['z'], x + ((frame * 5) % 10), Math.max(0, top - 1))
964 paint(grid, ['W'], x + ((frame * 11 + 7) % 10), top + 2)
965 }
966 const wearsHat = extras.hat && hero.action !== 'trip' && hero.action !== 'hatch' && !(wet === 'rain' || wet === 'storm')
967 if (wearsHat && extras.hat) {
968 const hat = HATS[extras.hat]
969 paint(grid, face > 0 ? hat : hat.map(r => r.split('').reverse().join('')), face > 0 ? x : x + HERO_W - BODY_W, top - hat.length)
970 }
971 const wear = extras.face ? FACES[extras.face] : null
972 const eyes = EYE_ROW[hero.action]
973 if (wear && eyes !== undefined) {
974 paint(grid, face > 0 ? wear : wear.map(r => r.split('').reverse().join('')), face > 0 ? x : x + HERO_W - BODY_W, top + eyes)
975 }
976 if (extras.scarf && eyes !== undefined) scarf(grid, sprite(hero.action, frame, dungeon), extras.scarf, x, face, top, eyes, frame)
977 if (extras.tie && eyes !== undefined) onBody(grid, TIE, x, face, top + eyes + 1)
978 if (hero.action === 'listen') paint(grid, HEADPHONES, x, top - 2)
979 if ((hero.action === 'listen' || hero.action === 'dance') && frame % 12 < 8) {
980 paint(grid, NOTE, face > 0 ? x + 10 : x - 3, Math.max(0, top - 2 - (Math.floor(frame / 4) % 2)), true)
981 }
982 if (hero.action === 'juggle') {
983 // One ball per helper out, in its tint, on an arc over Claude's head.
984 const balls = workers.filter(w => w.state === 'running').slice(0, 4)
985 const n = Math.max(2, balls.length)
986 for (let i = 0; i < n; i++) {
987 const a = frame * 0.45 + (i * Math.PI * 2) / n
988 const tint = balls[i] ? TINT[balls[i]!.tier] : 'z'
989 paint(grid, [tint], Math.round(x + 4 + Math.cos(a) * 5), Math.max(0, Math.round(top - 2 - Math.abs(Math.sin(a)) * 3)))
990 }
991 }
992 if (hero.action === 'fish' || hero.action === 'reel') fishingLine(grid, hero.action, x, face, top, frame, cols)
993 if (wet === 'rain' || wet === 'storm') paint(grid, UMBRELLA, x, Math.max(0, top - 3))
994 if (hero.action === 'sleep') {
995 if (Math.floor(frame / 15) % 2 === 1) {
996 paint(grid, dream(hero.last), x + 7, Math.max(0, top - 7))
997 } else {
998 const zx = x + 8 + (Math.floor(frame / 5) % 3)
999 paint(grid, ZED, zx, Math.max(0, top - 3 - (Math.floor(frame / 5) % 2)))
1000 }
1001 }
1002 if (extras.late && (hero.action === 'sit' || hero.action === 'look' || hero.action === 'scratch' || hero.action === 'wave')) {
1003 paint(grid, MUG[Math.floor(frame / 3) % 2] ?? [], face > 0 ? x + 10 : x - 1, GROUND - 4)
1004 }
1005 if (hero.emote) emote(grid, hero.emote, x, face, top, frame, eyes, extras.shiny ? 'n' : 'o')
1006 if (hero.action === 'cheer' && workers.some(w => w.state === 'done')) {
1007 paint(grid, HEART, x + 3, Math.max(0, top - 2))
1008 }
1009 for (const f of fx) if (f.kind !== 'boxes') effect(grid, f, now, frame, cols)
1010
1011 // Rain falls through the empty sky; a storm adds a flash of lightning.
1012 if (wet === 'rain' || wet === 'storm') {
1013 for (let x = 1; x < cols; x += 4) {
1014 const y = 2 + ((frame + x * 7) % (GROUND - 2))
1015 paint(grid, ['j'], x, y, true)
1016 }
1017 }
1018 if (extras.holiday === 'christmas' && !dungeon) snow(grid, cols, frame)
1019 if (wet === 'storm' && frame % 40 < 3) paint(grid, BOLT, (frame * 13) % Math.max(1, cols - 2), 2, true)
1020 if (dungeon) bricks(grid, lit)
1021 return grid.map(r => r.join(''))
1022}
1023
1024/** The hallway wall behind everything: bricks in every empty pixel, warm near a burning lamp. */
1025function bricks(grid: string[][], lit: readonly [number, number][]) {
1026 for (let y = 0; y < GROUND; y++) {
1027 const row = grid[y]
1028 if (!row) continue
1029 const course = Math.floor(y / 3)
1030 for (let x = 0; x < row.length; x++) {
1031 if (row[x] !== '.') continue
1032 const mortar = y % 3 === 2 || (x + (course % 2) * 4) % 8 === 0
1033 // Full warmth near the flame, a dithered rim further out.
1034 const d = Math.min(...lit.map(([lx, r]) => ((x - lx) / (r * 1.8)) ** 2 + ((y - LAMP_TOP - 1) / r) ** 2), Infinity)
1035 const warm = d <= 0.45 || (d <= 1 && (x + y) % 2 === 0)
1036 row[x] = warm ? (mortar ? 'J' : 'E') : mortar ? 'K' : 'D'
1037 }
1038 }
1039}
1040
1041export type Segment = { text: string; color?: string; backgroundColor?: string }
1042
1043/** Two pixel rows into one line of half-block segments, adjacent styles merged. */
1044export function halfBlocks(top: string, bottom: string): Segment[] {
1045 const out: Segment[] = []
1046 for (let i = 0; i < top.length; i++) {
1047 const a = PALETTE[top[i] ?? '.']
1048 const b = PALETTE[bottom[i] ?? '.']
1049 let seg: Segment
1050 if (!a && !b) seg = { text: ' ' }
1051 else if (a && !b) seg = { text: '▀', color: a }
1052 else if (!a && b) seg = { text: '▄', color: b }
1053 else seg = { text: '▀', color: a, backgroundColor: b }
1054 const last = out[out.length - 1]
1055 if (last && last.color === seg.color && last.backgroundColor === seg.backgroundColor) last.text += seg.text
1056 else out.push(seg)
1057 }
1058 return out
1059}
1060
1061export function lines(rows: readonly string[]): Segment[][] {
1062 const out: Segment[][] = []
1063 for (let y = 0; y < rows.length; y += 2) out.push(halfBlocks(rows[y] ?? '', rows[y + 1] ?? ''))
1064 return out
1065}
1066
1067/** One-glyph stand-in for the critter when there is no room for the stage. */
1068const GLYPHS: Record<Action, readonly string[]> = {
1069 walk: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
1070 run: ['⠁', '⠂', '⠄', '⡀', '⢀', '⠠', '⠐', '⠈'],
1071 sneak: ['∙∙∙', '●∙∙', '∙●∙', '∙∙●'],
1072 read: ['▤'],
1073 crouch: ['▃'],
1074 drowsy: ['◡'],
1075 dig: ['▖', '▘', '▝', '▗'],
1076 fly: ['◜', '◝', '◞', '◟'],
1077 carry: ['▰▱▱', '▰▰▱', '▰▰▰'],
1078 trip: ['✗'],
1079 cheer: ['✓'],
1080 alert: ['!', ' '],
1081 sit: ['·'],
1082 yawn: ['○'],
1083 sleep: ['z', 'zz', 'zzz'],
1084 wave: ['~', '≈'],
1085 scratch: ['#'],
1086 look: ['◂', '▸'],
1087 cover: ['◡', '◠'],
1088 dizzy: ['@', '◎'],
1089 flip: ['┻━┻'],
1090 sweep: ['⌇'],
1091 drop: ['↓', '✓'],
1092 wink: [';'],
1093 fish: ['⌐', '¬'],
1094 reel: ['><>'],
1095 nod: ['·', '˙'],
1096 dance: ['♪', '♫'],
1097 flag: ['⚑'],
1098 listen: ['♪'],
1099 juggle: ['∘°', '°∘'],
1100 stretch: ['\\o/'],
1101 hatch: ['◯', '◔', '◑', '◕'],
1102 fight: ['⚔', '†'],
1103 pin: ['¶'],
1104 stuck: ['☁', '☈'],
1105 cast: ['✦', '✧'],
1106}
1107
1108export function glyph(action: Action, frame: number): string {
1109 const set = GLYPHS[action]
1110 return set[Math.floor(frame / 2) % set.length] ?? '·'
1111}
1112hooks/story.ts 499 lines1/**
2 * Turns a tool call into an action and a one-line caption, from templates.
3 * No model calls: Claude Fables asks Sonnet every few seconds; this reads the call itself.
4 */
5import type { Action, Emote, Form, Hat, Holiday, Stats } from '../types'
6import { tierOf } from './art'
7
8export type Beat = { action: Action; caption: string }
9
10type Args = Record<string, unknown>
11
12function str(a: Args, key: string): string {
13 const v = a[key]
14 return typeof v === 'string' ? v : ''
15}
16
17function base(path: string): string {
18 return path.split('/').filter(Boolean).pop() ?? path
19}
20
21function host(url: string): string {
22 const m = /^[a-z]+:\/\/([^/]+)/i.exec(url)
23 return m?.[1] ?? url
24}
25
26function clip(text: string, room: number): string {
27 const one = text.replace(/\s+/g, ' ').trim()
28 return one.length > room ? one.slice(0, room - 1) + '…' : one
29}
30
31/** A stable pick among phrasings, so a reload or redraw keeps the same line. */
32function pick(lines: readonly string[], seed: string): string {
33 let h = 0
34 for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0
35 return lines[Math.abs(h) % lines.length] ?? lines[0] ?? ''
36}
37
38/** Auto memory and memory vaults: a MEMORY.md, or anything under a memory/ or claude-memory/ folder. */
39export function isMemoryPath(path: string): boolean {
40 return /(^|\/)(claude-)?memory\//i.test(path) || base(path) === 'MEMORY.md'
41}
42
43/** A write to a memory file is a note pinned up, in either theme. */
44function memoryBeat(tool: string, args: Args, seed: string, dungeon: boolean): Beat | null {
45 const path = str(args, 'file_path')
46 if ((tool !== 'Write' && tool !== 'Edit') || !isMemoryPath(path)) return null
47 const file = base(path)
48 return dungeon
49 ? { action: 'pin', caption: pick([`Claude nails a note to the dungeon wall: ${file}`, `Claude scratches ${file} into the wall to remember`], seed) }
50 : { action: 'pin', caption: pick([`Claude pins a note to memory: ${file}`, `Claude jots down ${file} so it won't forget`], seed) }
51}
52
53export function beat(tool: string, args: Args, seed: string): Beat {
54 const file = base(str(args, 'file_path') || str(args, 'notebook_path') || str(args, 'path'))
55 const memo = memoryBeat(tool, args, seed, false)
56 if (memo) return memo
57 switch (tool) {
58 case 'Read':
59 return {
60 action: 'read',
61 caption: pick([`Claude pores over ${file}`, `Claude studies ${file}`, `Claude reads ${file} by lamplight`], seed),
62 }
63 case 'Edit':
64 case 'NotebookEdit':
65 return {
66 action: 'dig',
67 caption: pick([`Claude digs into ${file}`, `Claude reshapes ${file}`, `Claude tunnels through ${file}`], seed),
68 }
69 case 'Write':
70 return { action: 'dig', caption: pick([`Claude builds ${file}`, `Claude lays the stones of ${file}`], seed) }
71 case 'Grep':
72 case 'Glob': {
73 const what = clip(str(args, 'pattern'), 40)
74 return {
75 action: 'sneak',
76 caption: pick([`Claude sniffs out “${what}”`, `Claude tracks “${what}” through the brush`], seed),
77 }
78 }
79 case 'Bash': {
80 const what = clip(str(args, 'description') || str(args, 'command'), 60)
81 return { action: 'run', caption: pick([`Claude runs off to ${lower(what)}`, `Claude hurries: ${what}`], seed) }
82 }
83 case 'WebFetch':
84 return { action: 'fly', caption: `Claude flies off to ${host(str(args, 'url'))}` }
85 case 'WebSearch':
86 return { action: 'fly', caption: `Claude scouts the skies for “${clip(str(args, 'query'), 40)}”` }
87 case 'Agent':
88 case 'Task':
89 return { action: 'carry', caption: `Claude hands a parcel to a helper: ${clip(str(args, 'description'), 50)}` }
90 case 'Skill':
91 return { action: 'read', caption: `Claude opens the ${str(args, 'skill') || 'old'} spellbook` }
92 case 'TodoWrite':
93 return { action: 'read', caption: 'Claude writes the quest list' }
94 case 'AskUserQuestion':
95 return { action: 'alert', caption: 'Claude waves you over with a question' }
96 default:
97 if (tool.startsWith('mcp__')) {
98 const server = tool.split('__')[1]?.replace(/^claude_ai_/, '').replace(/_/g, ' ') ?? 'a stranger'
99 return { action: 'carry', caption: `Claude sends word to ${server}` }
100 }
101 return { action: 'walk', caption: `Claude tries the ${tool}` }
102 }
103}
104
105/**
106 * The same call told in the dungeon: scrolls, runes, ravens and spells. With monsters on the
107 * floor, editing and running turn into a fight.
108 */
109export function dungeonBeat(tool: string, args: Args, seed: string, foes = 0): Beat {
110 const file = base(str(args, 'file_path') || str(args, 'notebook_path') || str(args, 'path'))
111 const memo = memoryBeat(tool, args, seed, true)
112 if (memo) return memo
113 const fight = (what: string): Beat | null =>
114 foes > 0 ? { action: 'fight', caption: pick([`Claude battles the monster${foes === 1 ? '' : 's'} in ${what}`, `Claude swings at the bugs in ${what}`], seed) } : null
115 switch (tool) {
116 case 'Read':
117 return { action: 'read', caption: pick([`Claude unrolls the scroll of ${file}`, `Claude reads ${file} by lamplight`, `Claude pulls out the scroll of ${file}`], seed) }
118 case 'Edit':
119 case 'NotebookEdit':
120 return fight(file) ?? { action: 'dig', caption: pick([`Claude chisels new runes into ${file}`, `Claude digs through ${file}`], seed) }
121 case 'Write':
122 return fight(file) ?? { action: 'dig', caption: pick([`Claude carves a new tablet: ${file}`, `Claude lays the stones of ${file}`], seed) }
123 case 'Grep':
124 case 'Glob':
125 return { action: 'sneak', caption: `Claude searches the dark halls for “${clip(str(args, 'pattern'), 40)}”` }
126 case 'Bash': {
127 const what = clip(str(args, 'description') || str(args, 'command'), 60)
128 const spell = pick(['cast', 'dash'], seed) === 'cast'
129 return fight(`the ${lower(what)}`) ?? (spell ? { action: 'cast', caption: `Claude casts a spell: ${lower(what)}` } : { action: 'run', caption: `Claude dashes down the hall to ${lower(what)}` })
130 }
131 case 'WebFetch':
132 return { action: 'fly', caption: `Claude sends a raven to ${host(str(args, 'url'))}` }
133 case 'WebSearch':
134 return { action: 'fly', caption: `Claude asks the oracle about “${clip(str(args, 'query'), 40)}”` }
135 case 'Agent':
136 case 'Task':
137 return { action: 'carry', caption: `Claude recruits a party member: ${clip(str(args, 'description'), 50)}` }
138 case 'Skill':
139 return { action: 'read', caption: `Claude opens the ${str(args, 'skill') || 'old'} grimoire` }
140 case 'TodoWrite':
141 return { action: 'read', caption: 'Claude marks the treasure map' }
142 default:
143 return beat(tool, args, seed)
144 }
145}
146
147/** A failed call in the dungeon: a skeleton steps out and lands a hit. */
148export function ambushCaption(tool: string, foes: number, seed: string): string {
149 if (foes >= 3) return `(╯°□°)╯︵ ┻━┻ ${foes} skeletons! Claude flips the table`
150 if (foes === 2) return 'A second skeleton! Claude sees stars'
151 return pick([`A skeleton jumps out of the ${tool}. Claude takes a hit`, `The ${tool} was a trap. A skeleton blocks the way`], seed)
152}
153
154/** The next clean call after an ambush. */
155export function victoryCaption(foes: number): string {
156 return foes === 1 ? 'Claude beats the skeleton. A coin drops!' : `Claude beats all ${foes} skeletons. Coins drop!`
157}
158
159function lower(s: string): string {
160 return s ? s.charAt(0).toLowerCase() + s.slice(1) : s
161}
162
163export function tripCaption(tool: string, seed: string): string {
164 return pick([`Claude trips over the ${tool} and dusts off`, `The ${tool} bites back. Claude regroups`], seed)
165}
166
167export const THINKING = ['Claude ponders the path ahead…', 'Claude wanders, thinking…', 'Claude mutters to itself…']
168
169export function ending(steps: number, seconds: number, isAborted: boolean, best = 0, milestone: number | null = null): string {
170 if (isAborted) return 'Claude stops mid-stride and takes a bow.'
171 const s = steps === 1 ? 'step' : 'steps'
172 const tokens = milestone ? ` · ${Math.round(milestone / 1000)}K tokens!` : ''
173 return `The end · ${steps} ${s} · ${seconds}s${best >= CROWN_AT ? ` · crown ×${best}` : ''}${tokens}`
174}
175
176/** The turn died on an API error (overloaded, offline, out of retries): Claude waits under the cloud. */
177export function stuckCaption(dungeon: boolean): string {
178 return dungeon ? 'The portal fizzles out. Claude waits for it to light again.' : 'The cloud stopped answering. Claude waits out the storm.'
179}
180
181const MODEL_HAT: Record<ReturnType<typeof tierOf>, Hat | null> = { haiku: 'sprout', sonnet: 'beret', opus: 'regal', fable: 'crown', other: null }
182
183/** A new model between turns: Claude tries on its hat. Null when nothing changed or there was no model before. */
184export function modelSwitch(before: string | null, after: string): { hat: Hat | null; caption: string } | null {
185 if (!before || !after || before === after) return null
186 const tier = tierOf(after)
187 const name = tier === 'other' ? after : tier.charAt(0).toUpperCase() + tier.slice(1)
188 return { hat: MODEL_HAT[tier], caption: `Claude changes into ${name}` }
189}
190
191/** The closing pose by turn length: a nod for a quick one, a hop, a dance, then a flag at the summit. */
192export function endingAction(seconds: number, isAborted: boolean): Action {
193 if (isAborted || seconds < 4) return 'nod'
194 if (seconds < 60) return 'cheer'
195 if (seconds < 300) return 'dance'
196 return 'flag'
197}
198
199/** Every 50K tokens the session spends is a small party. */
200export const TOKEN_MILESTONE = 50_000
201/** The milestone crossed going from before to after, or null. */
202export function tokenMilestone(before: number, after: number): number | null {
203 const m = Math.floor(after / TOKEN_MILESTONE)
204 return m > Math.floor(before / TOKEN_MILESTONE) && m > 0 ? m * TOKEN_MILESTONE : null
205}
206
207/** Claude idles while the helpers work: headphones for one, juggling for more. */
208export function waitBeat(out: number, label?: string): Beat {
209 if (out <= 1) return { action: 'listen', caption: `Claude puts on headphones while ${label ? `“${clip(label, 30)}”` : 'the helper'} works…` }
210 return { action: 'juggle', caption: `Claude juggles while ${out} helpers work…` }
211}
212
213function hashOf(text: string): number {
214 let h = 0
215 for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0
216 return Math.abs(h)
217}
218
219/** A stable palette key per MCP server, for its warp. */
220const WARP_COLORS = ['p', 't', 'h', 'f', 'c', 'q', 'O', 'b', 'z']
221export function warpColor(server: string): string {
222 return WARP_COLORS[hashOf(server) % WARP_COLORS.length] ?? 'p'
223}
224
225/** A scarf color per project, from its root folder; the helpers' tier colors are left out. */
226const SCARF_COLORS = ['r', 'b', 'W', 'A', 'c', 'Y', 'q', 'O'] // no green: it vanishes against the trees
227export function scarfColor(root: string): string {
228 return SCARF_COLORS[hashOf(root) % SCARF_COLORS.length] ?? 'r'
229}
230
231/** Tool calls before the hatchling grows up. */
232export const GROW_AT = 200
233const FORM_OF: Partial<Record<Action, Form>> = {
234 read: 'scholar',
235 sneak: 'detective',
236 dig: 'builder',
237 run: 'hacker',
238 cast: 'hacker',
239 fly: 'explorer',
240 carry: 'captain',
241}
242export const FORM_HAT: Record<Form, Hat> = {
243 scholar: 'gradcap',
244 detective: 'deerstalker',
245 builder: 'hardhat',
246 hacker: 'beanie',
247 explorer: 'pith',
248 captain: 'captain',
249}
250const FORM_WORK: Record<Form, string> = {
251 scholar: 'reading',
252 detective: 'searching',
253 builder: 'editing',
254 hacker: 'running commands',
255 explorer: 'browsing the web',
256 captain: 'sending helpers',
257}
258export const NEW_STATS: Stats = { xp: 0, by: {}, form: null }
259
260/** One main-loop tool call, counted toward the form its pose belongs to. */
261export function countCall(stats: Stats, action: Action): Stats {
262 const form = FORM_OF[action]
263 return { ...stats, xp: stats.xp + 1, by: form ? { ...stats.by, [form]: (stats.by[form] ?? 0) + 1 } : stats.by }
264}
265
266/** The form Clawd has grown into: none before GROW_AT, then the most-done work. A rival has to lead by 10% to take over. */
267export function formOf(stats: Stats): Form | null {
268 if (stats.xp < GROW_AT) return null
269 let top: Form | null = null
270 for (const [form, n] of Object.entries(stats.by) as [Form, number][]) if (!top || n > (stats.by[top] ?? 0)) top = form
271 const cur = stats.form
272 if (cur && top && (stats.by[cur] ?? 0) * 1.1 >= (stats.by[top] ?? 0)) return cur
273 return top
274}
275
276export function growCaption(form: Form, was: Form | null): string {
277 const a = /^[aeiou]/.test(form) ? 'an' : 'a'
278 return was
279 ? `Clawd is ${a} ${form} now: mostly ${FORM_WORK[form]} lately.`
280 : `Clawd grows up into ${a} ${form}! Mostly ${FORM_WORK[form]}.`
281}
282
283/** Old store data back into shape; anything unreadable starts over. */
284export function readStats(raw: unknown): Stats | null {
285 if (!raw || typeof raw !== 'object') return null
286 const r = raw as Partial<Stats>
287 if (typeof r.xp !== 'number') return null
288 const by = r.by && typeof r.by === 'object' ? r.by : {}
289 return { xp: r.xp, by, form: typeof r.form === 'string' && r.form in FORM_HAT ? r.form : null }
290}
291
292/** Clean calls in a row that show a combo, and that earn the crown. */
293export const COMBO_AT = 5
294export const CROWN_AT = 10
295
296/** How the prompt reads: thanks makes Claude blush, a scolding makes it sheepish. */
297export function moodOf(text: string): 'thanks' | 'scold' | null {
298 const t = text.slice(0, 400)
299 if (/\b(thanks|thank (you|u)|thx|tysm|good (job|work|bot)|great (job|work)|nice (job|work)|well done|love (it|this|you)|you('re| are) (the best|awesome|amazing))\b/i.test(t)) {
300 return 'thanks'
301 }
302 if (/^\s*(no+|nope|wrong|wtf|ugh+|stop)\b|\b(wtf|that'?s (wrong|not (it|right|what))|not what i (asked|said|meant|wanted)|i already told you|you broke)\b/i.test(t)) {
303 return 'scold'
304 }
305 return null
306}
307
308export type Egg = 'commit' | 'push' | 'nuke' | 'install' | 'sl'
309
310/** The fun Bash commands, most specific first. */
311export function bashEggs(command: string): Egg[] {
312 const out: Egg[] = []
313 if (/^\s*sl(\s|$)/.test(command)) out.push('sl')
314 if (/\bgit\s+(-C\s+\S+\s+)?commit\b/.test(command)) out.push('commit')
315 if (/\bgit\s+(-C\s+\S+\s+)?push\b/.test(command)) out.push('push')
316 if (/\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r|-r\s+-f|-f\s+-r|--recursive\s+--force|--force\s+--recursive)/.test(command)) {
317 out.push('nuke')
318 }
319 if (/\b(npm|pnpm|yarn|bun)\s+(i|install|add)\b|\bpip3?\s+install\b|\buv\s+(add|pip\s+install)\b|\bcargo\s+(add|install)\b|\b(apt|apt-get|brew|dnf)\s+install\b|\bgo\s+get\b/.test(command)) {
320 out.push('install')
321 }
322 return out
323}
324
325/** A commit's first line, from -m "…" or a heredoc. */
326export function commitMessage(command: string): string {
327 const doc = /<<-?\s*'?"?(\w+)'?"?\s*\n\s*([^\n]+)/.exec(command)
328 const flag = /\s-[a-zA-Z]*m\s+(["'])((?:(?!\1)[^\n])*)/.exec(command)
329 const msg = doc?.[2] ?? (flag?.[2]?.startsWith('$(') ? '' : flag?.[2]) ?? ''
330 return clip(msg, 50)
331}
332
333export function pushRemote(command: string): string {
334 const m = /\bgit\s+(?:-C\s+\S+\s+)?push\s+(?:-\S+\s+)*([\w.-]+)/.exec(command)
335 return m?.[1] ?? 'origin'
336}
337
338/** Before the command runs. */
339export function eggBeat(egg: Egg, command: string): Beat & { emote?: Emote } {
340 switch (egg) {
341 case 'commit': {
342 const msg = commitMessage(command)
343 return { action: 'carry', caption: msg ? `Claude wraps a parcel: “${msg}”` : 'Claude wraps up a commit' }
344 }
345 case 'push':
346 return { action: 'carry', caption: `Claude folds a paper plane for ${pushRemote(command)}` }
347 case 'nuke':
348 return { action: 'cover', caption: `Claude covers its eyes: ${clip(command.trim(), 40)}`, emote: 'sweat' }
349 case 'install':
350 return { action: 'carry', caption: 'Claude orders packages from the sky' }
351 case 'sl':
352 return { action: 'cheer', caption: 'Choo choo!' }
353 }
354}
355
356/** After it ran cleanly. */
357export function eggDone(egg: Egg, command: string): string | null {
358 switch (egg) {
359 case 'commit': {
360 const msg = commitMessage(command)
361 return msg ? `Claude seals the parcel and stamps it: “${msg}”` : 'Claude seals the parcel and stamps it'
362 }
363 case 'push':
364 return `The paper plane sails off to ${pushRemote(command)}`
365 case 'install':
366 return 'Packages rain down. Claude stacks them'
367 default:
368 return null
369 }
370}
371
372/** The program a "command not found" names, or null. */
373export function notFound(output: string): string | null {
374 const m = /(?:^|\n)(?:[\w/.-]+: )?(?:line \d+: )?([\w.+-]+): (?:command )?not found/.exec(output)
375 return m?.[1] ?? null
376}
377
378/** Today's holiday, by local date. */
379export function holidayOf(d: Date): Holiday | null {
380 const m = d.getMonth() + 1
381 const day = d.getDate()
382 if (m === 10 && day >= 25) return 'halloween'
383 if (m === 12 && day >= 18 && day <= 26) return 'christmas'
384 if ((m === 12 && day === 31) || (m === 1 && day === 1)) return 'newyear'
385 if (m === 2 && day === 14) return 'valentine'
386 if (m === 4 && day === 1) return 'aprilfools'
387 return null
388}
389
390/** Fireworks: the hour either side of midnight on New Year's. */
391export function isMidnightNewYear(d: Date): boolean {
392 return (d.getMonth() === 11 && d.getDate() === 31 && d.getHours() === 23) || (d.getMonth() === 0 && d.getDate() === 1 && d.getHours() === 0)
393}
394
395export type Sky = 'day' | 'dusk' | 'night'
396export function skyOf(hour: number): Sky {
397 if (hour >= 20 || hour < 6) return 'night'
398 if (hour < 7 || hour >= 18) return 'dusk'
399 return 'day'
400}
401
402/** 1 to 4 am: nightcap and coffee. */
403export function isLate(hour: number): boolean {
404 return hour >= 1 && hour < 5
405}
406
407const HOLIDAY_START: Record<Holiday, string> = {
408 halloween: 'Once upon a spooky prompt, Claude set out…',
409 christmas: 'Once upon a snowy prompt, Claude set out…',
410 newyear: 'Once upon a brand-new prompt, Claude set out…',
411 valentine: 'Once upon a lovely prompt, Claude set out…',
412 aprilfools: 'Once upon a prompt, Claude set out… backwards.',
413}
414
415/** The opening caption: the mood of the prompt first, then waking, the holiday, the hour. */
416export function startCaption(o: { mood: 'thanks' | 'scold' | null; woke: boolean; holiday: Holiday | null; hour: number; dungeon?: boolean }): string {
417 if (o.mood === 'thanks') return 'Claude blushes and sets out again…'
418 if (o.mood === 'scold') return 'Claude rubs its head sheepishly and tries again…'
419 if (o.woke) return 'Claude wakes with a start and sets out…'
420 if (o.holiday) return o.dungeon ? HOLIDAY_START[o.holiday].replace('set out', 'ventured into the dungeon') : HOLIDAY_START[o.holiday]
421 if (isLate(o.hour)) return 'Claude pours a coffee and sets out…'
422 return o.dungeon ? 'Once upon a prompt, Claude ventured into the dungeon…' : 'Once upon a prompt, Claude set out…'
423}
424
425export type Fidget = { action: Action; caption: string; emote?: Emote }
426export const FIDGETS: readonly Fidget[] = [
427 { action: 'wave', caption: 'Claude waves at you.' },
428 { action: 'scratch', caption: 'Claude scratches its head.' },
429 { action: 'look', caption: 'Claude looks around.' },
430 { action: 'sit', caption: 'A butterfly visits Claude.', emote: 'butterfly' },
431 { action: 'wink', caption: 'Claude winks at you.' },
432]
433export const FIDGET_SLOT_MS = 7000
434export const FIDGET_MS = 3000
435
436/** While sitting: a fidget for the first few seconds of every slot but the first. */
437export function fidgetAt(sitFor: number, seed: number): Fidget | null {
438 if (sitFor >= REST.fishMs) return null
439 const slot = Math.floor(sitFor / FIDGET_SLOT_MS)
440 if (slot === 0 || sitFor - slot * FIDGET_SLOT_MS >= FIDGET_MS) return null
441 return FIDGETS[Math.abs(seed + slot * 7) % FIDGETS.length] ?? null
442}
443
444const DREAMS: Partial<Record<Action, string>> = {
445 read: 'books',
446 dig: 'tunnels',
447 sneak: 'hidden trails',
448 run: 'open roads',
449 fly: 'the sky',
450 carry: 'parcels',
451 cover: 'scary commands',
452}
453export function dreamOf(last: Action | undefined): string {
454 return (last && DREAMS[last]) || 'electric sheep'
455}
456
457const TEST_CMD = /\b(test|tests|pytest|jest|vitest|mocha|rspec|phpunit|tox|nox)\b|cargo t\b|go test|npm t\b|bun test|plugin test/i
458
459/** Pass and fail counts from a test run's output, or null when it isn't one. */
460export function testCounts(command: string, output: string): { failed: number; passed: number } | null {
461 if (!TEST_CMD.test(command)) return null
462 const num = (re: RegExp) => {
463 let best = -1
464 for (const m of output.matchAll(re)) best = Math.max(best, Number(m[1]))
465 return best
466 }
467 const failed = num(/(\d+)\s+(?:failed|failing|fail\b|failures?\b|errors?\b)/gi)
468 const passed = num(/(\d+)\s+(?:passed|passing|pass\b|ok\b)/gi)
469 if (failed < 0 && passed < 0) return null
470 return { failed: Math.max(0, failed), passed: Math.max(0, passed) }
471}
472
473export const REST = { fishMs: 15_000, sitMs: 45_000, yawnMs: 53_000, hideMs: 180_000 }
474
475/** A long wait: Claude casts a line, and every so often something bites. */
476export const BITE_EVERY_MS = 12_000
477export const BITE_MS = 3_000
478export function fishing(sitFor: number): Fidget | null {
479 if (sitFor < REST.fishMs || sitFor >= REST.sitMs) return null
480 const t = sitFor - REST.fishMs
481 if (t > BITE_MS && t % BITE_EVERY_MS < BITE_MS) {
482 return { action: 'reel', caption: 'A bite! Claude reels in a fish… and lets it go.' }
483 }
484 return { action: 'fish', caption: 'Claude casts a line while it waits for you.' }
485}
486
487export function restCaption(action: 'sit' | 'yawn' | 'sleep', last?: Action, dungeon = false): string {
488 if (action === 'sit') return dungeon ? 'Claude sits by a lamp and waits for you.' : 'Claude sits by the path and waits for you.'
489 if (action === 'yawn') return 'Claude yawns…'
490 return `Claude dozes off, dreaming of ${dreamOf(last)}. z z z`
491}
492
493export function clockTime(iso: string | undefined): string {
494 if (!iso) return 'reset'
495 const d = new Date(iso)
496 if (Number.isNaN(d.getTime())) return 'reset'
497 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
498}
499types/index.d.ts 152 lines1export type Action =
2 | 'walk'
3 | 'run'
4 | 'sneak'
5 | 'read'
6 | 'dig'
7 | 'fly'
8 | 'carry'
9 | 'trip'
10 | 'cheer'
11 | 'alert'
12 | 'sit'
13 | 'yawn'
14 | 'sleep'
15 | 'wave'
16 | 'scratch'
17 | 'look'
18 | 'cover'
19 | 'wink'
20 | 'fish'
21 | 'reel'
22 | 'dizzy'
23 | 'flip'
24 | 'sweep'
25 | 'drop'
26 | 'nod'
27 | 'dance'
28 | 'flag'
29 | 'listen'
30 | 'juggle'
31 | 'stretch'
32 | 'hatch'
33 /** Dungeon theme: a sword swing while monsters are on the floor. */
34 | 'fight'
35 /** Dungeon theme: a wand raised for a shell command. */
36 | 'cast'
37 /** In-between poses, drawn for a beat when the posture changes (art.ts transition). */
38 | 'crouch'
39 | 'drowsy'
40 /** A note pinned up: a write to a memory file. */
41 | 'pin'
42 /** Under a sparking cloud, sweating: the turn died on an API error. */
43 | 'stuck'
44
45/**
46 * A small overlay on Claude: thought bubble, blush, sweat drop, a visiting butterfly;
47 * smitten adds heart eyes to the blush, sheepish adds worried brows to the sweat.
48 */
49export type Emote = 'think' | 'blush' | 'sweat' | 'butterfly' | 'smitten' | 'sheepish'
50
51/** Timed effects that cross or burst over the stage. */
52export type FxKind = 'confetti' | 'plane' | 'boxes' | 'train' | 'whale' | 'ufo' | 'warp' | 'coin' | 'coins' | 'poof'
53/** color: a palette key, for effects tinted per source (an MCP server's warp). */
54export type Fx = { id: number; kind: FxKind; start: number; dur: number; x: number; color?: string }
55
56/** The stage: the meadow with its weather, or a lamplit dungeon hallway. */
57export type Theme = 'meadow' | 'dungeon'
58
59export type Holiday = 'halloween' | 'christmas' | 'newyear' | 'valentine' | 'aprilfools'
60export type Hat =
61 | 'witch'
62 | 'santa'
63 | 'party'
64 | 'nightcap'
65 | 'crown'
66 | 'tophat'
67 | 'gradcap'
68 | 'captain'
69 | 'wizard'
70 | 'hardhat'
71 | 'shell'
72 | 'deerstalker'
73 | 'beanie'
74 | 'pith'
75 /** Worn for a few seconds after a model switch: Haiku's sprout, Sonnet's beret, Opus's purple crown (Fable keeps the gold one). */
76 | 'sprout'
77 | 'beret'
78 | 'regal'
79/** What Clawd grows into after GROW_AT tool calls, from the work it does most. */
80export type Form = 'scholar' | 'detective' | 'builder' | 'hacker' | 'explorer' | 'captain'
81/** Lifetime progress, saved in $.store: tool calls in all, per form, and the form it grew into. */
82export type Stats = { xp: number; by: Partial<Record<Form, number>>; form: Form | null }
83/** Worn over the face: /tales face. */
84export type Face = 'glasses' | 'shades' | 'mustache'
85
86export type Hero = {
87 /** idle draws nothing; resting is the sit, yawn, sleep ladder after a turn. */
88 mode: 'idle' | 'working' | 'ending' | 'resting'
89 action: Action
90 caption: string
91 x: number
92 dir: 1 | -1
93 scene: number
94 steps: number
95 /** When the current mode began (clock ms), for the rest ladder. */
96 since: number
97 emote?: Emote | null
98 /** Clock ms the emote ends; Infinity holds it until the next tool call. */
99 emoteUntil?: number
100 /** The last tool pose, which Claude dreams about. */
101 last?: Action
102 /** Eyes glancing left or right until glanceUntil (clock ms). */
103 glance?: -1 | 1 | null
104 glanceUntil?: number
105}
106
107/** A spawned subagent, drawn as a tinted critter in the same scene. */
108export type Worker = {
109 id: string
110 label: string
111 tier: 'opus' | 'sonnet' | 'haiku' | 'fable' | 'other'
112 /** returning: done, running back to Claude to hand in the result. */
113 state: 'running' | 'returning' | 'done' | 'failed'
114 action: Action
115 x: number
116 dir: 1 | -1
117 steps: number
118 /** Failed, then walked back to Claude for a pat on the head. */
119 sad?: boolean
120}
121
122export type Limit = { kind: string; percent: number; resetsAt?: string }
123
124declare module 'claude-code' {
125 interface PluginState {
126 'clawd-tales': {
127 hero: Hero
128 workers: Worker[]
129 frame: number
130 isOn: boolean
131 /** What Claude is waiting on the user for, or null. */
132 alert: string | null
133 /** Context fill 0-100, or null before the first measure. */
134 context: number | null
135 /** Plan rate-limit windows (5h, 7d), as last measured. */
136 limits: Limit[]
137 /** Calm mode: one frame a second, no wandering. */
138 calm: boolean
139 bugs: number
140 todos: { done: number; total: number } | null
141 fx: Fx[]
142 /** Rolled once a session: null until then. */
143 shiny: boolean | null
144 /** Clean main-loop tool calls in a row this turn. */
145 combo: number
146 /** Dungeon theme: gold coins earned this session. */
147 gold: number
148 theme: Theme
149 }
150 }
151}
152