Your Claude usage limits in the status line, and a band above the prompt with a row for the main session (task list, progress gauge, model, context, time) and…

<img src="docs/crabs-light.gif" alt="Ten pixel crabs dancing in their outfits: wizard, scientist, painter, doll, cowboy, ninja, gardener, musician, explorer and a plain crab" width="100%">
crab-band, an unofficial Claude Code mod (a plugin built on function hooks) that does three things:
5h ▓▓▓░░░░░░░ 31% ↺2h14m 7d ▓▓▓▓▓░░░░░ 52% ↺4d. It works in the desktop app's Code tab, where a statusLine command is not drawn.[crab] ● Write the parser · 2/5 ▓▓░░░░ · Opus 5.5 · ctx 24% · 2:13
[crab] ● Count hooks · Haiku 5.5 · ctx 6% · 0:15
/agents-info, a pane with big crabs. The same agents on a plain background, about twice the size: the main session, the subagents running now, and every subagent that finished this session (the band keeps a finished one only 30 seconds). /agents-info opens or closes it, /agents-info open only opens it. It runs at once, even typed while Claude is working, without stopping the turn. While the pane is on screen the band steps aside, so each agent has one crab; it comes back when the pane closes or sits behind another tab.There is also crab-band for claude.ai, a separate Chrome extension that brings the crab to claude.ai in the browser.
The band in the desktop app (an illustration, not a screenshot):
<img src="docs/band-illustration.png" alt="Three band rows: a scientist crab, a musician crab and an explorer crab, each followed by a status dot, the task, the model, context and time" width="520">
Crab outfits:
| Agent | Crab | Dance | Props |
|---|---|---|---|
| Fable | wizard | float and twirl | waves its wand, a star blinks |
| Opus, effort max | scientist | the robot, then the boom | the flask bubbles, then blows up in a puff of smoke |
| Opus, effort xhigh | painter | sway | strokes the brush, colours appear one by one |
| Opus, effort high | doll | disco | flips her hair, the skirt flares, a sparkle twinkles, hearts float up |
| Opus, effort medium | cowboy | line dance | spins a lasso |
| Opus, effort low | ninja | back flip | the headband flutters, a throwing star spins |
| Haiku | gardener | sprinkler | tips the watering can, drops fall |
| Sonnet | musician | b-boy: toprock, headspin, freeze, hop | notes float up |
| Explore | explorer | moonwalk | glances left and right, the compass needle swings |
| anything else | plain crab | crab shuffle |
The main row reads the task list the model keeps (the desktop app's task list, or TodoWrite in the terminal). It only reads those calls and never changes them. Between turns it rests, dimmed, on the last state.
At the prompt of a terminal session:
/plugin install crab-band --marketplace MaxHarfen/crab-band
Answer y to add the marketplace, then pick a scope (user: every project). From a shell:
claude plugin install crab-band --marketplace MaxHarfen/crab-band
Start a new session afterwards. The status line and the band appear by themselves; /agents-info opens the pane.
A separate Chrome extension in chrome/ brings the crab to claude.ai in the browser. While Claude answers, a crab dances and walks slowly under the reply, dressed for the model and effort in the model picker, and the 5-hour and 7-day limits show next to the picker. It uses no tokens, asks for no permissions and runs only on claude.ai. It installs on its own, in Chrome, and has nothing to do with the plugin install above. Install it from the Chrome Web Store. chrome/README.md says what it reads and how to build it; chrome/PRIVACY.md is its privacy policy.
The plugin is crab-band/: hooks/register.tsx holds every hook, hooks/crab.ts the crab art (the ART block) and the props' motion, hooks/moves.ts the dances (the MOVES block). Check it with claude plugin validate crab-band and claude plugin test crab-band. The art and the dances each live in one place: the JSON between // ART:BEGIN and // ART:END in crab-band/hooks/crab.ts, and between // MOVES:BEGIN and // MOVES:END in crab-band/hooks/moves.ts. A routine poses the whole crab, the body, each claw and each leg pair over a 4-second loop, and its box is the view that holds every frame. Edit either block and run python3 docs/make_crab_gif.py docs (it needs Pillow) to redraw the GIFs above from them; python3 docs/make_crab_gif.py --check draws nothing and fails if any frame leaves its routine's box. The GIF script keeps a Python copy of the props' motion only, so a new prop or prop animation needs adding there too.
The Chrome extension imports crab.ts, moves.ts, costume.ts and limits-format.ts from crab-band/hooks/ when it is built, so both draw the same crabs. After changing any of them, also run npm test in chrome/.
MIT (see LICENSE).
crab-band is a fan-made mod. It is not made, endorsed or supported by Anthropic.
Credits: the crab is drawn after Clawd, Claude Code's pixel mascot, by Anthropic. crab-band began as a fork of johnnyvizz/claude-kit's savvy-progress, which had the idea of dressing each agent's crab by its model. The outfits, their animations and the dances here are crab-band's own; the parts of the code still adapted from savvy-progress are listed, with its MIT license, in crab-band/NOTICE.md.
hooks/register.tsx 390 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit, Timer } from 'claude-code'
3
4import type { Main, Run, RunStatus } from '../types'
5import { costumeFor, savvyTier } from './costume'
6import { COSTUME_ACCENT, COSTUME_NAME, crabCss, crabMarkup } from './crab'
7import { isVisible, mainMeta, mainRowText, mainTitle, rowText, runMeta, runTitle } from './format'
8import { formatLimits } from './limits-format'
9import { MOVES, routineOf } from './moves'
10import { PLAN_TOOL, TODO_TOOL, WRAP_TOOL, applyPlanCall, applyTodos, finishAll, isFinished } from './plan'
11
12const IDLE: Main = { model: '', isWorking: false, turnStartedAt: 0, contextTokens: 0, plan: [] }
13
14const runs = atom({ plugin: 'crab-band', key: 'runs' } as const, [])
15const now = atom({ plugin: 'crab-band', key: 'now' } as const, 0)
16const main = atom({ plugin: 'crab-band', key: 'main' } as const, IDLE)
17const paneShown = atom({ plugin: 'crab-band', key: 'paneShown' } as const, false)
18
19const MAX_ROWS = 5
20const MAX_KEPT = 50
21// The agents pane: the same crabs, about twice the size, on a plain background. In the band a grid
22// unit is one px (two device pixels on a Retina screen), so the crab's edges stay even; in the pane,
23// the 30-unit crab is BIG_W px wide.
24const PANE = 'crab-agents'
25const PANE_TITLE = 'Agents'
26const BIG_W = 63
27const BIG_UNIT = BIG_W / 30
28const MAX_FINISHED = 20
29
30const MARK: Record<RunStatus, string> = { running: '●', done: '✓', failed: '✗' }
31const COLOR: Record<RunStatus, string> = { running: '#8f8cf4', done: '#5fbf8f', failed: '#e5484d' }
32
33const costumeOf = (run: Run): string => costumeFor(savvyTier(run.type ?? ''), run.type ?? '', run.model, run.effort)
34const mainCostume = (m: Main): string => costumeFor('other', 'main', m.model, m.effort)
35
36// Every crab is as wide as the union of the dances' boxes, so the marks and text after the crabs line
37// up row after row and the crabs stand on one grid; its height is its own dance's box.
38const VIEW_X = Math.min(...Object.values(MOVES).map(r => r.box[0]))
39const VIEW_W = Math.max(...Object.values(MOVES).map(r => r.box[0] + r.box[2])) - VIEW_X
40
41// One crab as its own SVG, `unit` px per grid unit, dancing while the agent runs. The view holds
42// every frame of the routine, props and all; `phase` (seconds the agent has run) keeps the dance
43// going across the once-a-second redraw.
44const crabSize = (costume: string, unit = 1): [number, number] => {
45 const [, , , h] = routineOf(costume).box
46 return [Math.round(VIEW_W * unit), Math.round(h * unit)]
47}
48const crabSvg = (costume: string, isWalking: boolean, phase: number, unit = 1): string => {
49 const [, y, , h] = routineOf(costume).box
50 const [width, height] = crabSize(costume, unit)
51 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="${VIEW_X} ${y} ${VIEW_W} ${h}" shape-rendering="crispEdges">${crabCss(costume)}${crabMarkup(costume, COSTUME_ACCENT[costume] ?? '#888780', isWalking, phase)}</svg>`
52}
53
54// The status line: the 5-hour and weekly limits with their reset countdowns, redrawn once a
55// minute so the countdowns keep moving while idle.
56const LIMITS_TICK_MS = 60_000
57
58async function drawLimits($: EngineInterface, limits: readonly SessionRateLimit[]) {
59 $.ui.status(formatLimits(limits, await $.clock.now()))
60}
61
62// Whether the agents pane is on screen: open, placed and the tab its surface shows. No event says
63// when the person switches tabs, so the tick asks again every second; open, close and a reload
64// ask at once. Written only when it changes, so the band redraws only then.
65async function syncPane($: EngineInterface) {
66 let isShown = false
67 try {
68 isShown = (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced && pane.isShown)
69 } catch {
70 return // the record is unreadable: leave the band as it is
71 }
72 if ((await read($, paneShown)) !== isShown) await update($, paneShown, () => isShown)
73}
74
75// Moves the band's clock once a second while something on it moves: the main session at work,
76// a running subagent, or a finished one still lingering (and once more as it leaves). Asks after
77// the pane every second regardless.
78async function tick($: EngineInterface) {
79 await syncPane($)
80 const at = await $.clock.now()
81 const list = await read($, runs)
82 const shown = await read($, now)
83 const m = await read($, main)
84 if (m.isWorking || list.some(r => isVisible(r, at) || isVisible(r, shown))) {
85 await update($, now, () => at)
86 }
87}
88
89export const register: Register = on => {
90 let ticker: Timer | undefined
91 let limitsTicker: Timer | undefined
92 let limits: readonly SessionRateLimit[] = []
93
94 on('session.start', async ($, e, next) => {
95 const started = await next(e)
96
97 // A reload or a resume starts with the limits the engine already has, rather than a blank
98 // line until the next turn ends.
99 try {
100 limits = (await $.session.usage()).rateLimits
101 } catch {
102 limits = []
103 }
104 await drawLimits($, limits)
105 limitsTicker?.cancel()
106 limitsTicker = $.clock.every(LIMITS_TICK_MS, () => void drawLimits($, limits))
107 let model = ''
108 try {
109 model = await $.session.model()
110 } catch {
111 model = ''
112 }
113 await update($, main, () => ({ ...IDLE, model }))
114 ticker?.cancel()
115 ticker = $.clock.every(1000, () => void tick($))
116 // Last, and on its own: a host that refuses the command still gets the status line and the band.
117 // `immediate`: typed while a turn is in flight, it opens the pane at once instead of waiting.
118 try {
119 await $.command.register({
120 name: 'agents-info',
121 description: 'Show or hide the agents pane: the main session and every subagent of this session, with big crabs',
122 argumentHint: '[open]',
123 immediate: true,
124 })
125 } catch {
126 // the pane stays unreachable; everything else works
127 }
128 // A reload keeps the pane up: the band starts aside if it is on screen.
129 await syncPane($)
130 return started
131 })
132
133 on('session.end', async ($, e, next) => {
134 ticker?.cancel()
135 ticker = undefined
136 limitsTicker?.cancel()
137 limitsTicker = undefined
138 return next(e)
139 })
140
141 // A prompt starts the main session's turn. A finished task list is cleared, so a new
142 // request does not show the last one's "All steps done"; an unfinished one stays.
143 on('prompt.submit', async ($, e, next) => {
144 const submitted = await next(e)
145 if (submitted.drop !== undefined || e.isMeta) return submitted
146 const at = await $.clock.now()
147 await update($, main, m => ({
148 ...m,
149 isWorking: true,
150 turnStartedAt: m.isWorking ? m.turnStartedAt : at,
151 plan: isFinished(m.plan) ? [] : m.plan,
152 }))
153 await update($, now, () => at)
154 return submitted
155 }).catch(($, e, next) => next(e))
156
157 on('agent.spawn', async ($, e, next) => {
158 const started = await next(e)
159 if (started.deny !== undefined) return started
160
161 const at = await $.clock.now()
162 const run: Run = {
163 id: started.agentId ?? e.tool_use_id,
164 agentId: started.agentId,
165 description: e.description || e.subagentType,
166 type: e.subagentType,
167 model: started.model,
168 status: 'running',
169 startedAt: at,
170 contextTokens: 0,
171 }
172 await update($, runs, list => [...list.filter(r => r.id !== run.id), run].slice(-MAX_KEPT))
173 await update($, now, () => at)
174 return started
175 }).catch(($, e, next) => next(e)) // bookkeeping only: a failure here never blocks a spawn
176
177 // The task list: read from the calls that keep it, after they went through. Never changes them.
178 on('tool.call', async ($, e, next) => {
179 const result = await next(e)
180 const name = String(e.tool)
181 if (e.agentId || result.deny !== undefined || result.isError) return result
182 if (name !== PLAN_TOOL && name !== WRAP_TOOL && name !== TODO_TOOL) return result
183 const call = e as unknown as Record<string, unknown>
184 await update($, main, m => ({
185 ...m,
186 plan: name === WRAP_TOOL ? finishAll(m.plan) : name === TODO_TOOL ? applyTodos(call.todos) : applyPlanCall(m.plan, call),
187 }))
188 return result
189 }).catch(($, e, next) => next(e))
190
191 // Each model request. The model and effort are known before it is sent, so the crab is dressed
192 // at once (the first answer can stream for a while, task list and all); how full the window is
193 // comes with the reply.
194 on('turn.step', async function* ($, e, next) {
195 const effort = typeof e.effort === 'string' ? e.effort : undefined
196 if (!e.agentId) {
197 await update($, main, m => ({ ...m, model: e.model || m.model, effort: effort ?? m.effort, isWorking: true }))
198 } else {
199 await update($, runs, list =>
200 list.map((r): Run => (r.agentId === e.agentId ? { ...r, model: e.model || r.model, effort: effort ?? r.effort } : r)),
201 )
202 }
203
204 const result = yield* next(e)
205 const usage = result.usage
206 if (!usage) return result
207 const tokens =
208 (usage.input_tokens || 0) +
209 (usage.cache_read_input_tokens || 0) +
210 (usage.cache_creation_input_tokens || 0) +
211 (usage.output_tokens || 0)
212 if (!e.agentId) {
213 await update($, main, m => ({ ...m, model: usage.model || m.model, contextTokens: tokens }))
214 return result
215 }
216 await update($, runs, list =>
217 list.map((r): Run =>
218 r.agentId === e.agentId
219 ? { ...r, model: usage.model || r.model, contextTokens: tokens, status: 'running', endedAt: undefined }
220 : r,
221 ),
222 )
223 return result
224 })
225
226 // The engine's own measurements, after each turn and whenever a limit moves a point: the main
227 // window's fill for the band, the limits for the status line.
228 on('session.measure', async ($, e, next) => {
229 if (e.changed.includes('rateLimits')) {
230 limits = e.rateLimits
231 await drawLimits($, limits)
232 }
233 if (e.context.percent !== undefined) {
234 const percent = e.context.percent
235 await update($, main, m => ({ ...m, contextPercent: percent }))
236 }
237 return next(e)
238 })
239
240 on('turn.complete', async ($, e, next) => {
241 const at = await $.clock.now()
242 if (e.agentId) {
243 const status: RunStatus = e.reason === 'answer' ? 'done' : 'failed'
244 await update($, runs, list => list.map((r): Run => (r.agentId === e.agentId ? { ...r, status, endedAt: at } : r)))
245 } else {
246 await update($, main, m => ({ ...m, isWorking: false }))
247 }
248 await update($, now, () => at)
249 return next(e)
250 })
251
252 // `/agents-info` toggles the pane; `/agents-info open` opens it (or brings it forward) without toggling.
253 on('command.run', { command: 'agents-info' }, async ($, e) => {
254 const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
255 if (isOpen && e.args.trim() !== 'open') {
256 await $.ui.close({ id: PANE }) // the ui.close hook below brings the band back
257 return { text: 'Agents pane closed.' }
258 }
259 await $.ui.open({ id: PANE, title: PANE_TITLE })
260 await syncPane($)
261 return { text: 'Agents pane opened.' }
262 })
263
264 // Every close of the pane (this command, the person's close mark, an unload) brings the band back
265 // at once, without waiting for the tick.
266 on('ui.close', async ($, e, next) => {
267 const closed = await next(e)
268 if (e.id === PANE) await update($, paneShown, () => false)
269 return closed
270 }).catch(($, e, next) => next(e))
271
272 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
273 const ui = $.ui.resolve(e)
274 const { Box, Text } = ui
275 const Svg = e.surface !== 'terminal' && 'Svg' in ui ? ui.Svg : undefined
276 const at = await read($, now)
277 const m = await read($, main)
278 const list = await read($, runs)
279 const running = list.filter(r => r.status === 'running').reverse()
280 const finished = list.filter(r => r.status !== 'running').reverse().slice(0, MAX_FINISHED)
281
282 const big = (costume: string, isWalking: boolean, phase: number, key: string) =>
283 Svg && (
284 <Svg
285 key={key}
286 source={crabSvg(costume, isWalking, phase, BIG_UNIT)}
287 alt={`${COSTUME_NAME[costume] ?? 'crab'} crab`}
288 width={crabSize(costume, BIG_UNIT)[0]}
289 height={crabSize(costume, BIG_UNIT)[1]}
290 />
291 )
292 const agent = (r: Run) => (
293 <Box key={`crabs-pane-${r.id}`} flexDirection="row" alignItems="center" gap={2}>
294 {big(costumeOf(r), r.status === 'running', ((r.endedAt ?? at) - r.startedAt) / 1000, `crabs-pane-crab-${r.id}`)}
295 <Box flexDirection="column">
296 <Text bold wrap="truncate-end">
297 {MARK[r.status]} {runTitle(r)}
298 </Text>
299 <Text dimColor wrap="truncate-end">
300 {r.type ? `${r.type} · ` : ''}
301 {runMeta(r, at)}
302 </Text>
303 </Box>
304 </Box>
305 )
306
307 return (
308 <Box flexDirection="column" gap={1}>
309 <Box key="crabs-pane-main" flexDirection="row" alignItems="center" gap={2}>
310 {big(mainCostume(m), m.isWorking, m.turnStartedAt > 0 ? (at - m.turnStartedAt) / 1000 : 0, 'crabs-pane-main-crab')}
311 <Box flexDirection="column">
312 <Text bold wrap="truncate-end">
313 {m.isWorking ? '●' : isFinished(m.plan) ? '✓' : '○'} {mainTitle(m)}
314 </Text>
315 <Text dimColor wrap="truncate-end">
316 Main session{mainMeta(m, at) ? ` · ${mainMeta(m, at)}` : ''}
317 </Text>
318 </Box>
319 </Box>
320 <Text dimColor>Running · {running.length}</Text>
321 {running.map(agent)}
322 {finished.length > 0 && <Text dimColor>Finished · {finished.length}</Text>}
323 {finished.map(agent)}
324 {list.length === 0 && <Text dimColor>No subagents in this session yet.</Text>}
325 </Box>
326 )
327 })
328
329 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
330 if (e.props.hasSurvey) return next(e)
331 // While the agents pane shows the same crabs, the band draws nothing of its own.
332 if (await read($, paneShown)) return next(e)
333
334 const at = await read($, now)
335 const m = await read($, main)
336 const visible = (await read($, runs)).filter(r => isVisible(r, at))
337 // VS Code pins a "From the crab-band plugin" mark on the band and paints it over the slash-command
338 // menu, so there the band shows only while the session or a subagent is at work.
339 if (e.surface === 'vscode' && !m.isWorking && visible.length === 0) return next(e)
340
341 // Other mods' bands draw beneath these rows. The engine's
342 // own placeholder ({ type: 'engine' }) is never nested: the desktop drops a band that nests it.
343 const drawn = await Promise.resolve(next(e)).catch(() => null)
344 const below = drawn && typeof drawn === 'object' && (drawn as { type?: string }).type !== 'engine' ? drawn : null
345
346 const ui = $.ui.resolve(e)
347 const { Box, Text } = ui
348 // The desktop draws an SVG; the terminal's table has an Svg that draws nothing, so test the surface too.
349 const Svg = e.surface !== 'terminal' && 'Svg' in ui ? ui.Svg : undefined
350 const ordered = [...visible.filter(r => r.status === 'running'), ...visible.filter(r => r.status !== 'running')]
351 // The main row always shows; subagents share what is left of the room.
352 const room = Math.max(0, Math.min(MAX_ROWS, (e.props.maxRows || MAX_ROWS) - 1) - 1)
353 const rows = ordered.slice(0, room)
354 const hidden = ordered.length - rows.length
355 const crab = (costume: string, isWalking: boolean, phase: number, key: string) =>
356 Svg && (
357 <Svg
358 key={key}
359 source={crabSvg(costume, isWalking, phase)}
360 alt={`${COSTUME_NAME[costume] ?? 'crab'} crab`}
361 width={crabSize(costume)[0]}
362 height={crabSize(costume)[1]}
363 />
364 )
365 const mainMark = m.isWorking ? '●' : isFinished(m.plan) ? '✓' : '○'
366 const mainColor = m.isWorking ? COLOR.running : isFinished(m.plan) ? COLOR.done : '#9a9a96'
367
368 return (
369 <Box flexDirection="column">
370 <Box key="crabs-main" flexDirection="row" alignItems="center" gap={1}>
371 {crab(mainCostume(m), m.isWorking, m.turnStartedAt > 0 ? (at - m.turnStartedAt) / 1000 : 0, 'crabs-main-crab')}
372 <Text color={mainColor}>{mainMark} </Text>
373 <Text wrap="truncate-end" dimColor={!m.isWorking}>
374 {mainRowText(m, at)}
375 </Text>
376 </Box>
377 {rows.map(r => (
378 <Box key={`crabs-${r.id}`} flexDirection="row" alignItems="center" gap={1}>
379 {crab(costumeOf(r), r.status === 'running', ((r.endedAt ?? at) - r.startedAt) / 1000, `crabs-crab-${r.id}`)}
380 <Text color={COLOR[r.status]}>{MARK[r.status]} </Text>
381 <Text wrap="truncate-end">{rowText(r, at)}</Text>
382 </Box>
383 ))}
384 {hidden > 0 && <Text dimColor>+{hidden} more · /agents-info</Text>}
385 {below}
386 </Box>
387 )
388 })
389}
390hooks/costume.ts 43 lines1// Which outfit an agent wears, by its model and effort, so the crabs tell apart what each agent
2// costs. A savvy-flow worker (savvy-careful, ...) wears its tier's outfit; Explore is the explorer.
3//
4// Fable fable (wizard)
5// Opus, effort max heavy (scientist)
6// Opus, effort xhigh careful (painter)
7// Opus, effort high doll (doll)
8// Opus, effort medium medium (cowboy)
9// Opus, effort low light (ninja)
10// Haiku haiku (gardener)
11// Sonnet sonnet (musician)
12//
13// An Opus agent whose effort is not known yet (no model request seen) stays a plain crab
14// until its first request reports it.
15export const costumeFor = (tier: string, type: string, model: string, effort?: string): string => {
16 if (tier !== 'other') return tier
17 if (type === 'Explore') return 'explore'
18
19 const m = model.toLowerCase()
20 if (/fable|mythos/.test(m)) return 'fable'
21 if (/opus/.test(m)) {
22 if (effort === 'max') return 'heavy'
23 if (effort === 'xhigh') return 'careful'
24 if (effort === 'high') return 'doll'
25 if (effort === 'medium') return 'medium'
26 if (effort === 'low') return 'light'
27 return 'other'
28 }
29 if (/sonnet/.test(m)) return 'sonnet'
30 if (/haiku/.test(m)) return 'haiku'
31 return 'other'
32}
33
34const SAVVY_TIERS = ['fable', 'heavy', 'careful', 'medium', 'light']
35
36// The savvy tier a subagent type names (`savvy-careful`, or `savvy-flow:savvy-careful` from the plugin),
37// else 'other': the first argument costumeFor takes.
38export const savvyTier = (type: string): string => {
39 const bare = type.replace(/^[^:]*:/, '')
40 const tier = bare.replace(/^savvy-/, '').toLowerCase()
41 return bare.startsWith('savvy-') && SAVVY_TIERS.includes(tier) ? tier : 'other'
42}
43hooks/crab.ts 939 lines1// The crabs: a Clawd-style crab (Claude Code's mascot, by Anthropic) on a 30 x 28 grid, in outfits of
2// crab-band's own, and how each one moves.
3//
4// The art is the ART block below: strict JSON between the ART:BEGIN and ART:END lines, so
5// docs/make_crab_gif.py reads the very same pictures. Edit it there and both the plugin and the
6// README GIFs follow. Each picture is a list of rows, one character per pixel ('.' is empty); its
7// legend gives a character's colour ("accent" is the outfit's accent colour). A pixel belongs to a
8// group that moves as one: bd the body (bobs), la / lb the two leg pairs (step in turn), cl / cr the
9// arms, eye the eyes, and each outfit's props (it, star, bub, pop, boom, smoke, pa, pb, pc, loop,
10// flag, can, drop, nt, nt2, needle, hair, skirt, sp, ht).
11
12import { DANCE_SECONDS, keyframes, move, routineCss } from './moves'
13
14type Part = { x: number; y: number; group: string; rows: string[]; legend: Record<string, string> }
15type Art = {
16 // A base legend entry is [colour, group], or [colour, group, underlay]: a body pixel in that colour
17 // drawn beneath it, for a group that moves on its own (the eyes glance without leaving a hole).
18 base: { rows: string[]; legend: Record<string, [string, string] | [string, string, string]> }
19 outfits: Record<string, { parts: Part[] }>
20}
21
22export const ART: Art =
23// ART:BEGIN
24{
25 "base": {
26 "rows": [
27 "..............................",
28 "..............................",
29 "..............................",
30 "..............................",
31 "..............................",
32 "..............................",
33 "..............................",
34 "..............................",
35 "..............................",
36 "..............................",
37 ".......cccccccccccccccc.......",
38 ".......cccccccccccccccc.......",
39 ".......cckkcccccccckkcc.......",
40 ".......cckkcccccccckkcc.......",
41 "...LLLLccccccccccccccccRRRR...",
42 "...LLLLccccccccccccccccRRRR...",
43 "...LLLLccccccccccccccccRRRR...",
44 "...LLLLccccccccccccccccRRRR...",
45 ".......cccccccccccccccc.......",
46 ".......cccccccccccccccc.......",
47 ".......cccccccccccccccc.......",
48 ".......cccccccccccccccc.......",
49 ".......11..22....11..22.......",
50 ".......11..22....11..22.......",
51 ".......11..22....11..22.......",
52 ".......11..22....11..22.......",
53 "..............................",
54 ".............................."
55 ],
56 "legend": {
57 "c": [
58 "#D97757",
59 "bd"
60 ],
61 "k": ["#1F1E1D", "eye", "#D97757"],
62 "L": [
63 "#D97757",
64 "cl"
65 ],
66 "R": [
67 "#D97757",
68 "cr"
69 ],
70 "1": [
71 "#D97757",
72 "la"
73 ],
74 "2": [
75 "#D97757",
76 "lb"
77 ]
78 }
79 },
80 "outfits": {
81 "fable": {
82 "parts": [
83 {
84 "x": 4,
85 "y": 0,
86 "group": "bd",
87 "rows": [
88 "...............tt.....",
89 "..............tt......",
90 ".............ttt......",
91 "............tttt......",
92 "...........tYttt......",
93 ".........ttttttt......",
94 "........tYtttttt......",
95 "......tttttttYttt.....",
96 ".....ttYtttYttttt.....",
97 "BBBBBBBBBBBBBBBBBBBBBB"
98 ],
99 "legend": {
100 "t": "accent",
101 "Y": "#F5C542",
102 "B": "#4E46A8"
103 }
104 },
105 {
106 "x": 25,
107 "y": 9,
108 "group": "it",
109 "rows": [
110 "..n",
111 ".n.",
112 ".n.",
113 "n..",
114 "n.."
115 ],
116 "legend": {
117 "n": "#8A5A33"
118 }
119 },
120 {
121 "x": 26,
122 "y": 5,
123 "group": "star",
124 "rows": [
125 ".y.",
126 "yWy",
127 ".y."
128 ],
129 "legend": {
130 "y": "#F5C542",
131 "W": "#FFF6C8"
132 }
133 }
134 ]
135 },
136 "heavy": {
137 "parts": [
138 {
139 "x": 7,
140 "y": 8,
141 "group": "bd",
142 "rows": [
143 "kkkkkkkkkkkkkkkk",
144 "kggggkkkkkkggggk"
145 ],
146 "legend": {
147 "k": "#3B3B40",
148 "g": "#9AA0A8"
149 }
150 },
151 {
152 "x": 8,
153 "y": 7,
154 "group": "bd",
155 "rows": [
156 ".gg.......gg.",
157 "gvvg.....gvvg",
158 "gvvg.....gvvg",
159 ".gg.......gg."
160 ],
161 "legend": {
162 "g": "#9AA0A8",
163 "v": "#BFE3F7"
164 }
165 },
166 {
167 "x": 2,
168 "y": 8,
169 "group": "bd",
170 "rows": [
171 "..GG..",
172 "..GG..",
173 ".GttG.",
174 "GttttG",
175 "GttttG",
176 ".GGGG."
177 ],
178 "legend": {
179 "G": "#DCE6EE",
180 "t": "accent"
181 }
182 },
183 {
184 "x": 3,
185 "y": 5,
186 "group": "bub",
187 "rows": [
188 "b.",
189 "..",
190 ".b"
191 ],
192 "legend": {
193 "b": "accent"
194 }
195 },
196 {
197 "x": 2,
198 "y": 4,
199 "group": "pop",
200 "rows": [
201 ".y.",
202 "yoy",
203 ".y."
204 ],
205 "legend": {
206 "y": "#F5C542",
207 "o": "#F08A24"
208 }
209 },
210 {
211 "x": 0,
212 "y": 1,
213 "group": "boom",
214 "rows": [
215 "y.y.y.",
216 ".yoooy",
217 "yorroo",
218 ".oorry",
219 "yoooy.",
220 ".y.y.y"
221 ],
222 "legend": {
223 "y": "#F5C542",
224 "o": "#F08A24",
225 "r": "#E5484D"
226 }
227 },
228 {
229 "x": 1,
230 "y": 0,
231 "group": "smoke",
232 "rows": [
233 ".ss.",
234 "ssss",
235 ".ss."
236 ],
237 "legend": {
238 "s": "#B8BEC6"
239 }
240 }
241 ]
242 },
243 "careful": {
244 "parts": [
245 {
246 "x": 7,
247 "y": 7,
248 "group": "bd",
249 "rows": [
250 ".........tt......",
251 "...bbbbbbbbbbbb..",
252 ".bbbbbbbbbbbbbbbb",
253 "..BBBBBBBBBBBBBB."
254 ],
255 "legend": {
256 "t": "accent",
257 "b": "#2E3A59",
258 "B": "#1F2840"
259 }
260 },
261 {
262 "x": 1,
263 "y": 11,
264 "group": "bd",
265 "rows": [
266 ".PPPP.",
267 "PrPbPP",
268 "PPyPP."
269 ],
270 "legend": {
271 "P": "#C98E54",
272 "r": "#E5484D",
273 "b": "#3B82F6",
274 "y": "#F5C542"
275 }
276 },
277 {
278 "x": 25,
279 "y": 7,
280 "group": "it",
281 "rows": [
282 "..p",
283 ".pp",
284 ".g.",
285 ".n.",
286 "n..",
287 "n..",
288 "n.."
289 ],
290 "legend": {
291 "p": "#3B82F6",
292 "g": "#9AA0A8",
293 "n": "#8A5A33"
294 }
295 },
296 {
297 "x": 27,
298 "y": 5,
299 "group": "pa",
300 "rows": [
301 "rrr"
302 ],
303 "legend": {
304 "r": "#E5484D"
305 }
306 },
307 {
308 "x": 26,
309 "y": 3,
310 "group": "pb",
311 "rows": [
312 "yyy."
313 ],
314 "legend": {
315 "y": "#F5C542"
316 }
317 },
318 {
319 "x": 27,
320 "y": 1,
321 "group": "pc",
322 "rows": [
323 "bbb"
324 ],
325 "legend": {
326 "b": "#3B82F6"
327 }
328 }
329 ]
330 },
331 "doll": {
332 "parts": [
333 {
334 "x": 4,
335 "y": 5,
336 "group": "hair",
337 "rows": [
338 "......hhhhhhhhhh......",
339 "....hhhhhhhhhhhhhh....",
340 "...hhhHHhhhhhhhhhhh...",
341 "..hhhHhhhhhhhhhhhhhh..",
342 "..hhHhhhhhhhhhhhhhhh..",
343 ".hhhhhhhhhhhhhhhhhhhh.",
344 ".hHh..............hhd.",
345 ".hHd..............dhh.",
346 ".hHd..............dhh.",
347 ".hHd..............dhh.",
348 ".hHd..............dhh.",
349 ".hHd..............dhh.",
350 ".hHd..............dhh.",
351 ".hHd..............dhh.",
352 "hhd................dhh",
353 "hd..................dh"
354 ],
355 "legend": {
356 "h": "#F2CF5B",
357 "H": "#FBE7A1",
358 "d": "#D4A93A"
359 }
360 },
361 {
362 "x": 19,
363 "y": 2,
364 "group": "bd",
365 "rows": [
366 "aa...aa",
367 "aaa.aaa",
368 "aaaAaaa",
369 "aaa.aaa",
370 "aa...aa"
371 ],
372 "legend": {
373 "a": "accent",
374 "A": "#A3165F"
375 }
376 },
377 {
378 "x": 8,
379 "y": 11,
380 "group": "bd",
381 "rows": [
382 "k"
383 ],
384 "legend": {
385 "k": "#1F1E1D"
386 }
387 },
388 {
389 "x": 21,
390 "y": 11,
391 "group": "bd",
392 "rows": [
393 "k"
394 ],
395 "legend": {
396 "k": "#1F1E1D"
397 }
398 },
399 {
400 "x": 9,
401 "y": 15,
402 "group": "bd",
403 "rows": [
404 "pp"
405 ],
406 "legend": {
407 "p": "#F59BC3"
408 }
409 },
410 {
411 "x": 19,
412 "y": 15,
413 "group": "bd",
414 "rows": [
415 "pp"
416 ],
417 "legend": {
418 "p": "#F59BC3"
419 }
420 },
421 {
422 "x": 26,
423 "y": 0,
424 "group": "sp",
425 "rows": [
426 ".p.",
427 "pwp",
428 ".p."
429 ],
430 "legend": {
431 "w": "#FFFFFF",
432 "p": "#F7A8D0"
433 }
434 },
435 {
436 "x": 0,
437 "y": 5,
438 "group": "ht",
439 "rows": [
440 "aa.aa",
441 "aaaaa",
442 ".aaa.",
443 "..a.."
444 ],
445 "legend": {
446 "a": "accent"
447 }
448 },
449 {
450 "x": 5,
451 "y": 18,
452 "group": "skirt",
453 "rows": [
454 "..AAAAAAAAAAAAAAAA..",
455 "..aalaalaalaalaala..",
456 ".aalaalaalaalaalaaa.",
457 "aalaalaalaalaalaalaa"
458 ],
459 "legend": {
460 "a": "accent",
461 "A": "#A3165F",
462 "l": "#F59BC3"
463 }
464 }
465 ]
466 },
467 "medium": {
468 "parts": [
469 {
470 "x": 4,
471 "y": 5,
472 "group": "bd",
473 "rows": [
474 ".......bbb..bbb.......",
475 "......bbbbbbbbbb......",
476 "......tttttttttt......",
477 ".b....bbbbbbbbbb....b.",
478 "..bbbbbbbbbbbbbbbbbb.."
479 ],
480 "legend": {
481 "b": "#8B5A2B",
482 "t": "accent"
483 }
484 },
485 {
486 "x": 22,
487 "y": 0,
488 "group": "loop",
489 "rows": [
490 "..rrrr..",
491 ".r....r.",
492 "r......r",
493 ".r....r.",
494 "..rrrr.."
495 ],
496 "legend": {
497 "r": "#C8A165"
498 }
499 },
500 {
501 "x": 23,
502 "y": 5,
503 "group": "bd",
504 "rows": [
505 "r..",
506 "r..",
507 ".r.",
508 ".r.",
509 "..r",
510 "..r",
511 "..r",
512 "..r"
513 ],
514 "legend": {
515 "r": "#C8A165"
516 }
517 }
518 ]
519 },
520 "light": {
521 "parts": [
522 {
523 "x": 7,
524 "y": 10,
525 "group": "bd",
526 "rows": [
527 "tttttttttttttttt",
528 "tttttttttttttttt"
529 ],
530 "legend": {
531 "t": "accent"
532 }
533 },
534 {
535 "x": 23,
536 "y": 9,
537 "group": "flag",
538 "rows": [
539 "..tt",
540 "ttt.",
541 ".ttt"
542 ],
543 "legend": {
544 "t": "accent"
545 }
546 },
547 {
548 "x": 0,
549 "y": 9,
550 "group": "it",
551 "rows": [
552 ".s.",
553 "sks",
554 ".s."
555 ],
556 "legend": {
557 "s": "#B8BEC6",
558 "k": "#3B3B40"
559 }
560 }
561 ]
562 },
563 "haiku": {
564 "parts": [
565 {
566 "x": 5,
567 "y": 5,
568 "group": "bd",
569 "rows": [
570 ".......ssssss.......",
571 "......ssSsssSs......",
572 "......tttttttt......",
573 "..ssssssssssssssss..",
574 ".sSssSssSssSssSssSs."
575 ],
576 "legend": {
577 "s": "#E2C27A",
578 "S": "#C9A65A",
579 "t": "accent"
580 }
581 },
582 {
583 "x": 0,
584 "y": 9,
585 "group": "can",
586 "rows": [
587 "c.....",
588 ".c.hh.",
589 "..cGGh",
590 "..GGGG",
591 "..GGGG"
592 ],
593 "legend": {
594 "c": "#3E8E5C",
595 "h": "#3E8E5C",
596 "G": "#4FA36B"
597 }
598 },
599 {
600 "x": 0,
601 "y": 11,
602 "group": "drop",
603 "rows": [
604 "d",
605 ".",
606 "d"
607 ],
608 "legend": {
609 "d": "#5AB0F0"
610 }
611 }
612 ]
613 },
614 "sonnet": {
615 "parts": [
616 {
617 "x": 5,
618 "y": 5,
619 "group": "bd",
620 "rows": [
621 "....kkkkkkkkkkkk....",
622 "..kk............kk..",
623 ".k................k.",
624 ".k................k.",
625 "ttt..............ttt",
626 "tpt..............tpt",
627 "tpt..............tpt",
628 "tpt..............tpt",
629 "ttt..............ttt"
630 ],
631 "legend": {
632 "k": "#76767B",
633 "t": "accent",
634 "p": "#55514C"
635 }
636 },
637 {
638 "x": 25,
639 "y": 2,
640 "group": "nt",
641 "rows": [
642 ".nn",
643 ".n.",
644 "nn.",
645 "nn."
646 ],
647 "legend": {
648 "n": "accent"
649 }
650 },
651 {
652 "x": 0,
653 "y": 5,
654 "group": "nt2",
655 "rows": [
656 ".nn",
657 ".n.",
658 "nn.",
659 "nn."
660 ],
661 "legend": {
662 "n": "accent"
663 }
664 }
665 ]
666 },
667 "explore": {
668 "parts": [
669 {
670 "x": 7,
671 "y": 5,
672 "group": "bd",
673 "rows": [
674 ".....hhhhhh.....",
675 "...hhhhhhhhhh...",
676 "..hhhhhhhhhhhh..",
677 "..tttttttttttt..",
678 "hhhhhhhhhhhhhhhh"
679 ],
680 "legend": {
681 "h": "#D8C49A",
682 "t": "accent"
683 }
684 },
685 {
686 "x": 24,
687 "y": 8,
688 "group": "bd",
689 "rows": [
690 ".YYYY.",
691 "YWWWWY",
692 "YWWWWY",
693 "YWWWWY",
694 "YWWWWY",
695 ".YYYY."
696 ],
697 "legend": {
698 "Y": "#C9A13B",
699 "W": "#F7F4EC"
700 }
701 },
702 {
703 "x": 26,
704 "y": 9,
705 "group": "needle",
706 "rows": [
707 ".r",
708 ".r",
709 "k.",
710 "k."
711 ],
712 "legend": {
713 "r": "#E5484D",
714 "k": "#3B3B40"
715 }
716 }
717 ]
718 },
719 "other": {
720 "parts": []
721 }
722 }
723}
724// ART:END
725
726// Each outfit's accent ("accent" in the art).
727export const COSTUME_ACCENT: Record<string, string> = {
728 fable: '#7F77DD',
729 heavy: '#D85A30',
730 careful: '#BA7517',
731 doll: '#E0218A',
732 medium: '#378ADD',
733 light: '#1D9E75',
734 haiku: '#3E9B5F',
735 sonnet: '#C2569B',
736 explore: '#888780',
737 other: '#888780',
738}
739
740// What each outfit is called, for an image's alt text.
741export const COSTUME_NAME: Record<string, string> = {
742 fable: 'wizard',
743 heavy: 'scientist',
744 careful: 'painter',
745 doll: 'doll',
746 medium: 'cowboy',
747 light: 'ninja',
748 haiku: 'gardener',
749 sonnet: 'musician',
750 explore: 'explorer',
751 other: 'crab',
752}
753
754// --- Motion -------------------------------------------------------------------------------------
755
756// Every crab dances its outfit's routine (moves.ts) while its agent works; its props keep their own
757// motion on top. Loops longer than the once-a-second redraw (the dances, the scientist's flask, the
758// painter's strokes, the explorer's glances, the wizard's star, the doll's flip and twinkle) run from
759// the phase the drawing passes in (`--d`, a negative delay), so they carry on where they were instead
760// of starting over.
761export { DANCE_SECONDS }
762
763// Whether `ms` into the loop falls in the second half of a `period` that starts `delay` in.
764const late = (ms: number, period: number, delay: number): boolean => (((ms - delay) % period) + period) % period >= period / 2
765
766// A prop that dims and brightens over the 4 s loop, read every `stepMs`, held between changes.
767const blinks = (name: string, stepMs: number, dimmed: string, isDim: (ms: number) => boolean): string => {
768 const steps: Array<[number, string]> = []
769 for (let ms = 0; ms < DANCE_SECONDS * 1000; ms += stepMs) {
770 const rule = isDim(ms) ? `opacity:${dimmed}` : 'opacity:1'
771 if (steps[steps.length - 1]?.[1] !== rule) steps.push([ms / 1000, rule])
772 }
773 return keyframes(name, steps)
774}
775
776// The scientist's bubbles: three rise from the flask, then the fourth second is the blast.
777const BUBBLES: Array<[number, string]> = [0, 1, 3].flatMap((k): Array<[number, string]> => [
778 [k, 'transform:translateY(1px);opacity:0'],
779 [k + 0.25, 'opacity:1'],
780 [k + 0.99, 'transform:translateY(-4px);opacity:0'],
781]).concat([[2, 'transform:translateY(1px);opacity:0'], [2.99, 'transform:translateY(1px);opacity:0']])
782
783const flashes = (on: number, off: number): Array<[number, string]> => [[0, 'opacity:0'], [on, 'opacity:1'], [off, 'opacity:0']]
784const stroke = (start: number): Array<[number, string]> => [
785 [0, 'opacity:0'], [start - 0.01, 'opacity:0'], [start, 'opacity:1'], [1.6, 'opacity:1'], [2, 'opacity:0'],
786]
787const loop = (cls: string, name: string, timing: string, outfit: string, extra = ''): string =>
788 `.c-${outfit}.run .${cls}{${extra}animation:${name} ${DANCE_SECONDS}s ${timing} infinite var(--d,0s)}`
789
790// Each outfit's props and how they move, with the routine's timing where they follow the dance.
791const PROP_CSS: Record<string, string[]> = {
792 fable: [
793 '.c-fable.run .it{transform-origin:0 100%;animation:wand 1s ease-in-out infinite}',
794 '@keyframes wand{25%{transform:rotate(15deg)}75%{transform:rotate(-15deg)}}',
795 // The star blinks every half second, and twice as fast while he rises (1 s to 2.5 s).
796 blinks('star', 125, '.2', ms => late(ms, ms >= 1000 && ms < 2500 ? 250 : 500, 125)),
797 loop('star', 'star', 'steps(1,end)', 'fable'),
798 ],
799 heavy: [
800 keyframes('bub', BUBBLES),
801 keyframes('pop', flashes(2, 2.15)),
802 keyframes('boom', flashes(2.15, 2.45)),
803 keyframes('smoke', [[0, 'transform:translateY(0);opacity:0'], [2.4, 'transform:translateY(0);opacity:0'],
804 [2.401, 'transform:translateY(0);opacity:1'], [3.2, 'transform:translateY(-3px);opacity:0']]),
805 '.c-heavy .pop,.c-heavy .boom,.c-heavy .smoke{opacity:0}',
806 loop('bub', 'bub', 'ease-out', 'heavy'),
807 loop('pop', 'pop', 'steps(1,end)', 'heavy'),
808 loop('boom', 'boom', 'steps(1,end)', 'heavy'),
809 loop('smoke', 'smoke', 'linear', 'heavy'),
810 ],
811 careful: [
812 '.c-careful.run .it{transform-origin:50% 100%;animation:brush .5s ease-in-out infinite}',
813 '@keyframes brush{50%{transform:rotate(-25deg)}}',
814 keyframes('pa', stroke(0.2), 2),
815 keyframes('pb', stroke(0.5), 2),
816 keyframes('pc', stroke(0.8), 2),
817 ...['pa', 'pb', 'pc'].map(cls => `.c-careful.run .${cls}{animation:${cls} 2s linear infinite var(--d,0s)}`),
818 ],
819 doll: [
820 // The hair swings out on the flip, the skirt flares on the turn, the sparkle twinkles (faster in
821 // the closing pose) and a heart floats up every second.
822 keyframes('hair', move([[0, 'rotate(0deg)'], [2, 'rotate(10deg)'], [2.15, 'rotate(-12deg)'], [2.3, 'rotate(0deg)']])),
823 loop('hair', 'hair', 'steps(1,end)', 'doll', 'transform-origin:50% 18.75%;'),
824 keyframes('flare', move([[0, 'scaleX(1)'], [2.5, 'scaleX(1.2)'], [2.875, 'scaleX(1)']])),
825 loop('skirt', 'flare', 'steps(1,end)', 'doll', 'transform-origin:50% 0;'),
826 blinks('twinkle', 25, '.15', ms => late(ms, ms >= 3000 ? 250 : 500, 100)),
827 loop('sp', 'twinkle', 'steps(1,end)', 'doll'),
828 '@keyframes heart{0%{transform:translateY(2px);opacity:0}25%{opacity:1}100%{transform:translateY(-5px);opacity:0}}',
829 '.c-doll.run .ht{animation:heart 1s ease-out infinite var(--d,0s)}',
830 ],
831 medium: [
832 '.c-medium.run .loop{transform-origin:50% 50%;animation:lasso 1s ease-in-out infinite}',
833 '@keyframes lasso{50%{transform:scaleX(.2)}}',
834 ],
835 light: [
836 '.c-light.run .flag{transform-origin:0 50%;animation:flutter .25s steps(1) infinite}',
837 '.c-light.run .it{transform-origin:50% 50%;animation:spin .25s steps(1) infinite}',
838 '@keyframes flutter{50%{transform:skewY(-14deg) scaleX(.85)}}@keyframes spin{50%{transform:rotate(45deg)}}',
839 ],
840 haiku: [
841 '.c-haiku.run .can{transform-origin:100% 50%;animation:pour 1s ease-in-out infinite}',
842 '.c-haiku.run .drop{animation:drip 1s linear infinite}',
843 '@keyframes pour{20%,40%{transform:rotate(-15deg)}60%{transform:rotate(0)}}',
844 '@keyframes drip{0%{transform:translateY(0);opacity:1}100%{transform:translateY(4px);opacity:0}}',
845 ],
846 sonnet: [
847 '.c-sonnet.run .nt{animation:rise 1s ease-out infinite var(--d,0s)}.c-sonnet.run .nt2{animation:rise 1s ease-out infinite calc(var(--d,0s) - .5s)}',
848 '@keyframes rise{0%{transform:translateY(3px);opacity:0}25%{opacity:1}100%{transform:translateY(-4px);opacity:0}}',
849 ],
850 explore: [
851 '.c-explore.run .needle{transform-origin:50% 50%;animation:swing 1s ease-in-out infinite}',
852 '@keyframes swing{25%{transform:rotate(30deg)}75%{transform:rotate(-30deg)}}',
853 keyframes('look', [[0, 'transform:translateX(-1px)'], [0.8, 'transform:translateX(0)'], [1, 'transform:translateX(1px)'],
854 [1.8, 'transform:translateX(0)']], 2),
855 '.c-explore.run .eye{animation:look 2s steps(1,end) infinite var(--d,0s)}',
856 ],
857 other: [],
858}
859
860// An outfit nobody drew (a new savvy tier, a typo) is the plain crab, its dance and all.
861const outfitOf = (costume: string): string => (ART.outfits[costume] ? costume : 'other')
862
863// One crab's stylesheet: pure CSS, run by the compositor. It holds only that crab's outfit: the
864// shared rules, its props' motion and its dance. Reduced motion freezes all of it.
865export const crabCss = (costume: string): string => {
866 const outfit = outfitOf(costume)
867 return [
868 '<style>',
869 '.run g{transform-box:fill-box}',
870 ...(PROP_CSS[outfit] ?? []),
871 routineCss(outfit),
872 '@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}',
873 '</style>',
874 ].join('\n')
875}
876
877// --- Drawing ------------------------------------------------------------------------------------
878
879type Run = { x: number; y: number; w: number; colour: string; group: string }
880
881// The outfit's pixels, merged into horizontal runs of one colour in one group, base first.
882const runsOf = (outfit: string, accent: string): Run[] => {
883 const pixels: Array<{ x: number; y: number; colour: string; group: string }> = []
884 ART.base.rows.forEach((row, y) =>
885 [...row].forEach((ch, x) => {
886 const cell = ART.base.legend[ch]
887 if (!cell) return
888 if (cell[2]) pixels.push({ x, y, colour: cell[2], group: 'bd' })
889 pixels.push({ x, y, colour: cell[0], group: cell[1] })
890 }),
891 )
892 for (const part of ART.outfits[outfit]?.parts ?? []) {
893 part.rows.forEach((row, j) =>
894 [...row].forEach((ch, i) => {
895 const colour = part.legend[ch]
896 if (colour) pixels.push({ x: part.x + i, y: part.y + j, colour: colour === 'accent' ? accent : colour, group: part.group })
897 }),
898 )
899 }
900 const runs: Run[] = []
901 for (const p of pixels) {
902 const last = runs[runs.length - 1]
903 if (last && last.y === p.y && last.x + last.w === p.x && last.colour === p.colour && last.group === p.group) last.w += 1
904 else runs.push({ x: p.x, y: p.y, w: 1, colour: p.colour, group: p.group })
905 }
906 return runs
907}
908
909// Props that stay put while the crab dances: drawn outside the dancing group.
910const STILL_PROPS = new Set(['nt', 'nt2'])
911// The claws, drawn after every other prop so they sit in front of the outfit (the doll's hair).
912const CLAWS = ['cl', 'cr']
913
914// One crab's markup in its own 30 x 28 grid, for a caller to place and scale. Body and props nest
915// inside `bd` so a prop rides the squat and adds its own motion, the claws last; legs stay outside it
916// and lift on their own; `dn` holds all of them (and a frame that fixes its box, the dance's pivot),
917// and the still props sit outside it. `phase` is how long the agent has run, in seconds: where the
918// dance and the long loops pick up after a redraw.
919export const crabMarkup = (costume: string, accent: string, isWalking: boolean, phase = 0): string => {
920 const outfit = outfitOf(costume)
921 const groups = new Map<string, string[]>([['bd', []]])
922 for (const r of runsOf(outfit, accent)) {
923 if (!groups.has(r.group)) groups.set(r.group, [])
924 groups.get(r.group)?.push(`<rect x="${r.x}" y="${r.y}" width="${r.w}" height="1" fill="${r.colour}"/>`)
925 }
926 const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
927 const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
928 const moving = [...props.filter(k => !STILL_PROPS.has(k) && !CLAWS.includes(k)), ...CLAWS.filter(k => groups.has(k))]
929 const still = props.filter(k => STILL_PROPS.has(k))
930 const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${moving.map(group).join('')}</g>`
931 const delay = isWalking && phase > 0 ? ` style="--d:-${(phase % DANCE_SECONDS).toFixed(2)}s"` : ''
932 return (
933 `<g class="c-${outfit}${isWalking ? ' run' : ''}"${delay}>` +
934 `<g class="dn"><rect x="0" y="0" width="30" height="28" fill="none"/>${body}${group('la')}${group('lb')}</g>` +
935 `${still.map(group).join('')}</g>`
936 )
937}
938
939hooks/format.ts 79 lines1import type { Main, Run } from '../types'
2import { planSummary } from './plan'
3
4// How long a finished agent's row stays in the band before it leaves.
5export const LINGER_MS = 30_000
6
7const MAX_TITLE = 48
8
9// The context window by model: 200K for Haiku 4 and older models, 1M otherwise.
10export const windowOf = (model: string): number => (/haiku-4|claude-3/i.test(model) ? 200_000 : 1_000_000)
11
12// claude-haiku-5-5 -> Haiku 5.5; anything else keeps its own name.
13export const modelName = (id: string): string => {
14 const [, family = '', major = '', minor] = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id) ?? []
15 if (!family) {
16 const bare = id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
17 return bare ? bare.charAt(0).toUpperCase() + bare.slice(1) : '—'
18 }
19 const name = family.charAt(0).toUpperCase() + family.slice(1).toLowerCase()
20 return minor ? `${name} ${major}.${minor}` : `${name} ${major}`
21}
22
23const pad = (n: number): string => String(n).padStart(2, '0')
24
25// 0:15 / 12:04 / 1:02:05
26export const elapsed = (ms: number): string => {
27 const s = Math.max(0, Math.floor(ms / 1000))
28 const h = Math.floor(s / 3600)
29 const m = Math.floor((s % 3600) / 60)
30 return h > 0 ? `${h}:${pad(m)}:${pad(s % 60)}` : `${m}:${pad(s % 60)}`
31}
32
33export const isVisible = (run: Run, at: number): boolean =>
34 run.status === 'running' || (run.endedAt !== undefined && at - run.endedAt < LINGER_MS)
35
36const clip = (text: string, max: number): string => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
37
38// A subagent's details: Haiku 5.5 · ctx 6% · 0:15
39export const runMeta = (run: Run, at: number): string => {
40 const parts = [modelName(run.model)]
41 if (run.contextTokens > 0) {
42 parts.push(`ctx ${Math.round((run.contextTokens / windowOf(run.model)) * 100)}%`)
43 }
44 parts.push(elapsed((run.endedAt ?? at) - run.startedAt))
45 return parts.join(' · ')
46}
47
48export const runTitle = (run: Run): string => clip(run.description, MAX_TITLE)
49
50// Count hooks · Haiku 5.5 · ctx 6% · 0:15
51export const rowText = (run: Run, at: number): string => `${runTitle(run)} · ${runMeta(run, at)}`
52
53// A 6-cell gauge of steps done: ▓▓▓░░░
54export const stepBar = (done: number, total: number): string => {
55 const filled = total > 0 ? Math.round((done / total) * 6) : 0
56 return '▓'.repeat(filled) + '░'.repeat(6 - filled)
57}
58
59// What the main session is on: Write the parser · 2/5 ▓▓░░░░ (or Working / Waiting for you / Ready)
60export const mainTitle = (main: Main): string => {
61 const { current, done, total } = planSummary(main.plan)
62 if (total === 0) return main.isWorking ? 'Working' : main.turnStartedAt > 0 ? 'Waiting for you' : 'Ready'
63 return `${clip(done === total ? 'All steps done' : (current ?? 'Next step'), MAX_TITLE)} · ${done}/${total} ${stepBar(done, total)}`
64}
65
66// Its details: Opus 5.5 · ctx 24% · 2:13 (the time only while it works)
67export const mainMeta = (main: Main, at: number): string => {
68 const parts: string[] = []
69 if (main.model) parts.push(modelName(main.model))
70 const percent =
71 main.contextPercent ?? (main.contextTokens > 0 && main.model ? (main.contextTokens / windowOf(main.model)) * 100 : undefined)
72 if (percent !== undefined) parts.push(`ctx ${Math.round(percent)}%`)
73 if (main.isWorking && main.turnStartedAt > 0) parts.push(elapsed(at - main.turnStartedAt))
74 return parts.join(' · ')
75}
76
77// The main session's band row: Write the parser · 2/5 ▓▓░░░░ · Opus 5.5 · ctx 24% · 2:13
78export const mainRowText = (main: Main, at: number): string => [mainTitle(main), mainMeta(main, at)].filter(Boolean).join(' · ')
79hooks/limits-format.ts 43 lines1import type { SessionRateLimit } from 'claude-code'
2
3// One gauge per window, then the countdown to its reset:
4// 5h ▓▓▓░░░░░░░ 31% ↺2h14m 7d ▓▓▓▓▓░░░░░ 52% ↺4d
5const LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
6const MINUTE = 60_000
7
8const gauge = (percent: number): string => {
9 const filled = Math.min(10, Math.max(0, Math.round(percent / 10)))
10 return '▓'.repeat(filled) + '░'.repeat(10 - filled)
11}
12
13// Compact countdown: 4d3h / 2h14m / 47m / <1m / now
14export const eta = (ms: number): string => {
15 if (ms <= 0) return 'now'
16 const minutes = Math.floor(ms / MINUTE)
17 const d = Math.floor(minutes / 1440)
18 const h = Math.floor((minutes % 1440) / 60)
19 const m = minutes % 60
20 if (d > 0) return h > 0 ? `${d}d${h}h` : `${d}d`
21 if (h > 0) return m > 0 ? `${h}h${m}m` : `${h}h`
22 return m > 0 ? `${m}m` : '<1m'
23}
24
25// One segment per window; undefined (clears the line) when there is no reading,
26// as off a subscription or before the session's first API response.
27export const formatLimits = (
28 limits: readonly SessionRateLimit[],
29 now: number,
30): string | undefined => {
31 if (limits.length === 0) return undefined
32
33 return limits
34 .map(limit => {
35 const label = LABELS[limit.kind] ?? limit.kind
36 const percent = Math.round(limit.percentUsed)
37 const resetsAt = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt)
38 const reset = Number.isNaN(resetsAt) ? '' : ` ↺${eta(resetsAt - now)}`
39 return `${label} ${gauge(percent)} ${percent}%${reset}`
40 })
41 .join(' ')
42}
43hooks/moves.ts 455 lines1// The dances: each outfit's 4 s routine, as data. A routine poses the crab's groups over the loop:
2// the whole crab (`dn`: jx, jy, rot about `origin`, mirror), a squat of the body (`bd`: b), each claw
3// (`cl`, `cr`: [dx, dy] on top of the squat) and each leg pair (`la`, `lb`: a lift), in grid units.
4// A key holds until the next. A missing field is at rest: 0, false or [0, 0]. `box` is the view that
5// holds every frame of the routine, props and all: [x, y, width, height] on the 30 x 28 grid.
6//
7// The MOVES block is strict JSON between the MOVES:BEGIN and MOVES:END lines, so docs/make_crab_gif.py
8// reads the very same routines.
9
10// Every routine, and every prop loop longer than the once-a-second redraw, lasts this long.
11export const DANCE_SECONDS = 4
12
13export const pct = (second: number, loop = DANCE_SECONDS): string => `${((second / loop) * 100).toFixed(3)}%`
14export const keyframes = (name: string, steps: Array<[number, string]>, loop = DANCE_SECONDS): string =>
15 `@keyframes ${name}{${steps.map(([second, rule]) => `${pct(second, loop)}{${rule}}`).join('')}}`
16export const move = (steps: Array<[number, string]>): Array<[number, string]> =>
17 steps.map(([s, tf]) => [s, `transform:${tf}`])
18
19export type Pose = {
20 jx?: number
21 jy?: number
22 rot?: number
23 mirror?: boolean
24 b?: number
25 cl?: [number, number]
26 cr?: [number, number]
27 la?: number
28 lb?: number
29}
30export type Routine = { origin: [number, number]; box: [number, number, number, number]; keys: Array<[number, Pose]> }
31
32// Exported once from the approved samples (sampled every 50 ms, held steps); edit it here from now on.
33export const MOVES: Record<string, Routine> & { other: Routine } =
34// MOVES:BEGIN
35{
36 "fable": {
37 "origin": [15, 17],
38 "box": [-4, -8, 38, 38],
39 "keys": [
40 [0, {"rot": 8, "cr": [0, -3]}],
41 [0.2, {"jy": -1, "rot": 8, "cr": [0, -3]}],
42 [0.25, {"jy": -1, "rot": -8, "cl": [0, -3]}],
43 [0.35, {"jy": -2, "rot": -8, "cl": [0, -3]}],
44 [0.5, {"jy": -2, "rot": 8, "cr": [0, -3]}],
45 [0.7, {"jy": -1, "rot": 8, "cr": [0, -3]}],
46 [0.75, {"jy": -1, "rot": -8, "cl": [0, -3]}],
47 [0.85, {"rot": -8, "cl": [0, -3]}],
48 [1, {"jy": -2, "cl": [0, -5], "cr": [0, -5]}],
49 [1.25, {"jy": -3, "cl": [0, -5], "cr": [0, -5]}],
50 [1.45, {"jy": -4, "cl": [0, -5], "cr": [0, -5]}],
51 [1.6, {"jy": -5, "cl": [0, -5], "cr": [0, -5]}],
52 [1.8, {"jy": -6, "cl": [0, -5], "cr": [0, -5]}],
53 [2, {"jy": -6, "rot": 45, "cl": [0, -3], "cr": [0, -3]}],
54 [2.1, {"jy": -6, "rot": 90, "cl": [0, -3], "cr": [0, -3]}],
55 [2.15, {"jy": -6, "rot": 135, "cl": [0, -3], "cr": [0, -3]}],
56 [2.2, {"jy": -6, "rot": 180, "cl": [0, -3], "cr": [0, -3]}],
57 [2.25, {"jy": -6, "rot": 225, "cl": [0, -3], "cr": [0, -3]}],
58 [2.35, {"jy": -6, "rot": 270, "cl": [0, -3], "cr": [0, -3]}],
59 [2.4, {"jy": -6, "rot": 315, "cl": [0, -3], "cr": [0, -3]}],
60 [2.45, {"jy": -6, "cl": [0, -3], "cr": [0, -3]}],
61 [2.5, {"jy": -6, "rot": 8, "cl": [0, -2], "cr": [0, -2]}],
62 [2.75, {"jy": -5, "rot": -8, "cl": [0, -2], "cr": [0, -2]}],
63 [2.95, {"jy": -4, "rot": -8, "cl": [0, -2], "cr": [0, -2]}],
64 [3, {"jy": -4, "rot": 8, "cl": [0, -2], "cr": [0, -2]}],
65 [3.1, {"jy": -3, "rot": 8, "cl": [0, -2], "cr": [0, -2]}],
66 [3.25, {"jy": -3, "rot": -8, "cl": [0, -2], "cr": [0, -2]}],
67 [3.3, {"jy": -2, "rot": -8, "cl": [0, -2], "cr": [0, -2]}],
68 [3.5, {"jy": -2, "rot": 8, "cr": [0, -3]}],
69 [3.7, {"jy": -1, "rot": 8, "cr": [0, -3]}],
70 [3.75, {"jy": -1, "rot": -8, "cl": [0, -3]}],
71 [3.85, {"rot": -8, "cl": [0, -3]}]
72 ]
73 },
74 "heavy": {
75 "origin": [15, 17],
76 "box": [-3, -5, 34, 34],
77 "keys": [
78 [0, {"cl": [0, -3]}],
79 [0.25, {"jx": 1, "rot": 6, "cr": [0, -3]}],
80 [0.5, {"jx": 2, "rot": 6, "cl": [0, -3]}],
81 [0.75, {"jx": 1, "cr": [0, -3]}],
82 [1, {"b": 1, "cl": [0, -3], "cr": [0, -1]}],
83 [1.15, {"cl": [0, -1], "cr": [0, -3]}],
84 [1.25, {"b": 1, "cl": [0, -3], "cr": [0, -1]}],
85 [1.4, {"cl": [0, -1], "cr": [0, -3]}],
86 [1.5, {"jx": -1, "rot": -6, "cl": [0, -3]}],
87 [1.75, {"jx": -2, "rot": -6, "cr": [0, -3]}],
88 [2, {"jx": -1}],
89 [2.15, {"jx": -1, "jy": -3, "cl": [0, -5], "cr": [0, -5], "la": -1, "lb": -1}],
90 [2.25, {"jx": 1, "jy": -3, "cl": [0, -5], "cr": [0, -5], "la": -1, "lb": -1}],
91 [2.45, {"rot": -10, "b": 1}],
92 [2.6, {"rot": 10, "b": 1}],
93 [2.85, {"rot": -10, "b": 1}],
94 [3, {"rot": 10, "b": 1}],
95 [3.2, {"cl": [0, -3]}],
96 [3.45, {"jx": 1, "rot": 6, "cr": [0, -3]}],
97 [3.7, {"jx": 2, "rot": 6, "cl": [0, -3]}],
98 [3.95, {"jx": 1, "cr": [0, -3]}]
99 ]
100 },
101 "careful": {
102 "origin": [15, 26],
103 "box": [-4, -3, 41, 32],
104 "keys": [
105 [0, {"b": 1}],
106 [0.05, {"rot": 1.6, "b": 1}],
107 [0.1, {"rot": 3.1, "b": 1, "cr": [0, -1]}],
108 [0.15, {"jx": 1, "rot": 4.5, "b": 1, "cr": [0, -1]}],
109 [0.2, {"jx": 1, "rot": 5.9, "b": 1, "cr": [0, -2]}],
110 [0.25, {"jx": 1, "rot": 7.1, "cr": [0, -2]}],
111 [0.3, {"jx": 1, "rot": 8.1, "cr": [0, -2]}],
112 [0.35, {"jx": 1, "rot": 8.9, "cr": [0, -3]}],
113 [0.4, {"jx": 1, "rot": 9.5, "cr": [0, -3]}],
114 [0.45, {"jx": 1, "rot": 9.9, "cr": [0, -3]}],
115 [0.5, {"jx": 2, "rot": 10, "b": 1, "cr": [0, -3]}],
116 [0.55, {"jx": 1, "rot": 9.9, "b": 1, "cr": [0, -3]}],
117 [0.6, {"jx": 1, "rot": 9.5, "b": 1, "cr": [0, -3]}],
118 [0.65, {"jx": 1, "rot": 8.9, "b": 1, "cr": [0, -3]}],
119 [0.7, {"jx": 1, "rot": 8.1, "b": 1, "cr": [0, -2]}],
120 [0.75, {"jx": 1, "rot": 7.1, "cr": [0, -2]}],
121 [0.8, {"jx": 1, "rot": 5.9, "cr": [0, -2]}],
122 [0.85, {"jx": 1, "rot": 4.5, "cr": [0, -1]}],
123 [0.9, {"rot": 3.1, "cr": [0, -1]}],
124 [0.95, {"rot": 1.6}],
125 [1, {"b": 1}],
126 [1.05, {"rot": -1.6, "b": 1}],
127 [1.1, {"rot": -3.1, "b": 1, "cl": [0, -1]}],
128 [1.15, {"jx": -1, "rot": -4.5, "b": 1, "cl": [0, -1]}],
129 [1.2, {"jx": -1, "rot": -5.9, "b": 1, "cl": [0, -2]}],
130 [1.25, {"jx": -1, "rot": -7.1, "cl": [0, -2]}],
131 [1.3, {"jx": -1, "rot": -8.1, "cl": [0, -2]}],
132 [1.35, {"jx": -1, "rot": -8.9, "cl": [0, -3]}],
133 [1.4, {"jx": -1, "rot": -9.5, "cl": [0, -3]}],
134 [1.45, {"jx": -1, "rot": -9.9, "cl": [0, -3]}],
135 [1.5, {"jx": -2, "rot": -10, "b": 1, "cl": [0, -3]}],
136 [1.55, {"jx": -1, "rot": -9.9, "b": 1, "cl": [0, -3]}],
137 [1.6, {"jx": -1, "rot": -9.5, "b": 1, "cl": [0, -3]}],
138 [1.65, {"jx": -1, "rot": -8.9, "b": 1, "cl": [0, -3]}],
139 [1.7, {"jx": -1, "rot": -8.1, "b": 1, "cl": [0, -2]}],
140 [1.75, {"jx": -1, "rot": -7.1, "cl": [0, -2]}],
141 [1.8, {"jx": -1, "rot": -5.9, "cl": [0, -2]}],
142 [1.85, {"jx": -1, "rot": -4.5, "cl": [0, -1]}],
143 [1.9, {"rot": -3.1, "cl": [0, -1]}],
144 [1.95, {"rot": -1.6}],
145 [2, {"b": 1}],
146 [2.05, {"rot": 1.6, "b": 1}],
147 [2.1, {"rot": 3.1, "b": 1, "cr": [0, -1]}],
148 [2.15, {"jx": 1, "rot": 4.5, "b": 1, "cr": [0, -1]}],
149 [2.2, {"jx": 1, "rot": 5.9, "b": 1, "cr": [0, -2]}],
150 [2.25, {"jx": 1, "rot": 7.1, "cr": [0, -2]}],
151 [2.3, {"jx": 1, "rot": 8.1, "cr": [0, -2]}],
152 [2.35, {"jx": 1, "rot": 8.9, "cr": [0, -3]}],
153 [2.4, {"jx": 1, "rot": 9.5, "cr": [0, -3]}],
154 [2.45, {"jx": 1, "rot": 9.9, "cr": [0, -3]}],
155 [2.5, {"jx": 2, "rot": 10, "b": 1, "cr": [0, -3]}],
156 [2.55, {"jx": 1, "rot": 9.9, "b": 1, "cr": [0, -3]}],
157 [2.6, {"jx": 1, "rot": 9.5, "b": 1, "cr": [0, -3]}],
158 [2.65, {"jx": 1, "rot": 8.9, "b": 1, "cr": [0, -3]}],
159 [2.7, {"jx": 1, "rot": 8.1, "b": 1, "cr": [0, -2]}],
160 [2.75, {"jx": 1, "rot": 7.1, "cr": [0, -2]}],
161 [2.8, {"jx": 1, "rot": 5.9, "cr": [0, -2]}],
162 [2.85, {"jx": 1, "rot": 4.5, "cr": [0, -1]}],
163 [2.9, {"rot": 3.1, "cr": [0, -1]}],
164 [2.95, {"rot": 1.6}],
165 [3, {"b": 1}],
166 [3.05, {"rot": -1.6, "b": 1}],
167 [3.1, {"rot": -3.1, "b": 1, "cl": [0, -1]}],
168 [3.15, {"jx": -1, "rot": -4.5, "b": 1, "cl": [0, -1]}],
169 [3.2, {"jx": -1, "rot": -5.9, "b": 1, "cl": [0, -2]}],
170 [3.25, {"jx": -1, "rot": -7.1, "cl": [0, -2]}],
171 [3.3, {"jx": -1, "rot": -8.1, "cl": [0, -2]}],
172 [3.35, {"jx": -1, "rot": -8.9, "cl": [0, -3]}],
173 [3.4, {"jx": -1, "rot": -9.5, "cl": [0, -3]}],
174 [3.45, {"jx": -1, "rot": -9.9, "cl": [0, -3]}],
175 [3.5, {"jx": -2, "rot": -10, "b": 1, "cl": [0, -3]}],
176 [3.55, {"jx": -1, "rot": -9.9, "b": 1, "cl": [0, -3]}],
177 [3.6, {"jx": -1, "rot": -9.5, "b": 1, "cl": [0, -3]}],
178 [3.65, {"jx": -1, "rot": -8.9, "b": 1, "cl": [0, -3]}],
179 [3.7, {"jx": -1, "rot": -8.1, "b": 1, "cl": [0, -2]}],
180 [3.75, {"jx": -1, "rot": -7.1, "cl": [0, -2]}],
181 [3.8, {"jx": -1, "rot": -5.9, "cl": [0, -2]}],
182 [3.85, {"jx": -1, "rot": -4.5, "cl": [0, -1]}],
183 [3.9, {"rot": -3.1, "cl": [0, -1]}],
184 [3.95, {"rot": -1.6}]
185 ]
186 },
187 "doll": {
188 "origin": [15, 17],
189 "box": [-4, -4, 37, 33],
190 "keys": [
191 [0, {"jx": 1, "rot": 6, "cr": [1, -6]}],
192 [0.25, {"rot": -4, "b": 1, "cr": [-3, 1]}],
193 [0.5, {"jx": 1, "rot": 6, "cr": [1, -6]}],
194 [0.75, {"rot": -4, "b": 1, "cr": [-3, 1]}],
195 [1, {"jx": -1, "rot": -6, "cl": [-1, -6]}],
196 [1.25, {"rot": 4, "b": 1, "cl": [3, 1]}],
197 [1.5, {"jx": -1, "rot": -6, "cl": [-1, -6]}],
198 [1.75, {"rot": 4, "b": 1, "cl": [3, 1]}],
199 [2, {"rot": -12, "cl": [0, -2], "cr": [0, -2]}],
200 [2.15, {"rot": 10, "cl": [0, -3], "cr": [0, -3]}],
201 [2.3, {"b": 1}],
202 [2.5, {"jy": -1, "mirror": true, "cl": [0, -2], "cr": [0, -2]}],
203 [2.65, {"jy": -2, "cl": [0, -3], "cr": [0, -3]}],
204 [2.75, {"jy": -1, "mirror": true, "cl": [0, -2], "cr": [0, -2]}],
205 [2.9, {"b": 1}],
206 [3, {"cl": [-1, -5], "cr": [1, -5]}],
207 [3.5, {"b": 1, "cl": [-1, -5], "cr": [1, -5]}],
208 [3.65, {"cl": [-1, -5], "cr": [1, -5]}],
209 [3.75, {"b": 1, "cl": [-1, -5], "cr": [1, -5]}],
210 [3.9, {"cl": [-1, -5], "cr": [1, -5]}]
211 ]
212 },
213 "medium": {
214 "origin": [15, 17],
215 "box": [-1, -4, 35, 31],
216 "keys": [
217 [0, {"jx": 1, "jy": -1, "rot": 8, "la": -3}],
218 [0.25, {"jx": 1, "b": 1}],
219 [0.5, {"jx": 2, "jy": -1, "rot": 8, "lb": -3}],
220 [0.75, {"jx": 2, "b": 1}],
221 [1, {"jx": -1, "jy": -1, "rot": -8, "lb": -3}],
222 [1.25, {"jx": -1, "b": 1}],
223 [1.5, {"jx": -2, "jy": -1, "rot": -8, "la": -3}],
224 [1.75, {"jx": -2, "b": 1}],
225 [2, {"jy": -3, "cl": [1, -4], "cr": [-1, -4], "la": -1, "lb": -1}],
226 [2.25, {"b": 1}],
227 [2.5, {"jy": -3, "cl": [1, -4], "cr": [-1, -4], "la": -1, "lb": -1}],
228 [2.75, {"b": 1}],
229 [3, {"jy": -1, "rot": 8, "mirror": true, "la": -3}],
230 [3.25, {"mirror": true, "b": 1}],
231 [3.5, {"jy": -1, "rot": -8, "lb": -3}],
232 [3.75, {"b": 1}]
233 ]
234 },
235 "light": {
236 "origin": [15, 17],
237 "box": [-3, -5, 35, 34],
238 "keys": [
239 [0, {"rot": -5, "cl": [-2, -1], "cr": [0, -1]}],
240 [0.15, {"jy": -1, "rot": -5, "cl": [-2, -1], "cr": [0, -1]}],
241 [0.25, {"rot": 5, "cl": [0, -1], "cr": [2, -1]}],
242 [0.4, {"jy": -1, "rot": 5, "cl": [0, -1], "cr": [2, -1]}],
243 [0.5, {"rot": -5, "cl": [-2, -1], "cr": [0, -1]}],
244 [0.65, {"jy": -1, "rot": -5, "cl": [-2, -1], "cr": [0, -1]}],
245 [0.75, {"rot": 5, "cl": [0, -1], "cr": [2, -1]}],
246 [0.9, {"jy": -1, "rot": 5, "cl": [0, -1], "cr": [2, -1]}],
247 [1, {"b": 2, "cl": [0, 1], "cr": [0, 1]}],
248 [1.25, {"jy": -4, "rot": 315, "cl": [0, -1], "cr": [0, -1], "la": -2, "lb": -2}],
249 [1.35, {"jy": -4, "rot": 270, "cl": [0, -1], "cr": [0, -1], "la": -2, "lb": -2}],
250 [1.4, {"jy": -6, "rot": 225, "cl": [0, -1], "cr": [0, -1], "la": -2, "lb": -2}],
251 [1.45, {"jy": -6, "rot": 180, "cl": [0, -1], "cr": [0, -1], "la": -2, "lb": -2}],
252 [1.5, {"jy": -6, "rot": 135, "cl": [0, -1], "cr": [0, -1], "la": -2, "lb": -2}],
253 [1.6, {"jy": -6, "rot": 90, "cl": [0, -1], "cr": [0, -1], "la": -2, "lb": -2}],
254 [1.65, {"jy": -4, "rot": 45, "cl": [0, -1], "cr": [0, -1], "la": -2, "lb": -2}],
255 [1.7, {"jy": -4, "cl": [0, -1], "cr": [0, -1], "la": -2, "lb": -2}],
256 [1.75, {"b": 2, "cl": [0, 1], "cr": [0, 1]}],
257 [2, {"jx": -1, "rot": -8, "cl": [0, -2], "cr": [0, -2], "lb": -3}],
258 [2.15, {"jx": 1, "rot": 8, "cl": [0, -2], "cr": [0, -2], "la": -3}],
259 [2.25, {"jx": -1, "rot": -8, "cl": [0, -2], "cr": [0, -2], "lb": -3}],
260 [2.4, {"jx": 1, "rot": 8, "cl": [0, -2], "cr": [0, -2], "la": -3}],
261 [2.5, {"jx": -1, "rot": -8, "cl": [0, -2], "cr": [0, -2], "lb": -3}],
262 [2.65, {"jx": 1, "rot": 8, "cl": [0, -2], "cr": [0, -2], "la": -3}],
263 [2.75, {"jx": -1, "rot": -8, "cl": [0, -2], "cr": [0, -2], "lb": -3}],
264 [2.9, {"jx": 1, "rot": 8, "cl": [0, -2], "cr": [0, -2], "la": -3}],
265 [3, {"rot": -5, "cl": [-2, -1], "cr": [0, -1]}],
266 [3.15, {"jy": -1, "rot": -5, "cl": [-2, -1], "cr": [0, -1]}],
267 [3.25, {"rot": 5, "cl": [0, -1], "cr": [2, -1]}],
268 [3.4, {"jy": -1, "rot": 5, "cl": [0, -1], "cr": [2, -1]}],
269 [3.5, {"rot": -5, "cl": [-2, -1], "cr": [0, -1]}],
270 [3.65, {"jy": -1, "rot": -5, "cl": [-2, -1], "cr": [0, -1]}],
271 [3.75, {"rot": 5, "cl": [0, -1], "cr": [2, -1]}],
272 [3.9, {"jy": -1, "rot": 5, "cl": [0, -1], "cr": [2, -1]}]
273 ]
274 },
275 "haiku": {
276 "origin": [15, 26],
277 "box": [-6, 2, 39, 28],
278 "keys": [
279 [0, {"rot": -20, "b": 1, "cl": [-1, -1], "cr": [0, -5]}],
280 [0.1, {"rot": -15, "cl": [-1, -1], "cr": [0, -5]}],
281 [0.2, {"rot": -10, "b": 1, "cl": [-1, -1], "cr": [0, -5]}],
282 [0.35, {"rot": -5, "cl": [-1, -1], "cr": [0, -5]}],
283 [0.4, {"b": 1, "cl": [-1, -1], "cr": [0, -5]}],
284 [0.5, {"rot": 5, "b": 1, "cl": [-1, -1], "cr": [0, -5]}],
285 [0.55, {"rot": 5, "cl": [-1, -1], "cr": [0, -5]}],
286 [0.65, {"rot": 10, "b": 1, "cl": [-1, -1], "cr": [0, -5]}],
287 [0.75, {"rot": 15, "cl": [-1, -1], "cr": [0, -5]}],
288 [0.8, {"rot": 20, "b": 1, "cl": [-1, -1], "cr": [0, -5]}],
289 [0.9, {"rot": 10, "b": 1, "cl": [-1, -1], "cr": [0, -5]}],
290 [0.95, {"rot": 10, "cl": [-1, -1], "cr": [0, -5]}],
291 [1, {"cl": [-1, -1], "cr": [0, -5]}],
292 [1.05, {"rot": -10, "b": 1, "cl": [-1, -1], "cr": [0, -5]}],
293 [1.1, {"rot": -10, "cl": [-1, -1], "cr": [0, -5]}],
294 [1.15, {"rot": -20, "cl": [-1, -1], "cr": [0, -5]}],
295 [1.2, {"rot": -20, "b": 1, "cl": [-1, -1], "cr": [0, -5]}],
296 [1.3, {"rot": -15, "cl": [-1, -1], "cr": [0, -5]}],
297 [1.45, {"rot": -10, "b": 1, "cl": [-1, -1], "cr": [0, -5]}],
298 [1.5, {"rot": -5, "cl": [-1, -1], "cr": [0, -5]}],
299 [1.6, {"b": 1, "cl": [-1, -1], "cr": [0, -5]}],
300 [1.7, {"rot": 5, "b": 1, "cl": [-1, -1], "cr": [0, -5]}],
301 [1.75, {"rot": 5, "cl": [-1, -1], "cr": [0, -5]}],
302 [1.8, {"rot": 10, "b": 1, "cl": [-1, -1], "cr": [0, -5]}],
303 [1.95, {"rot": 15, "cl": [-1, -1], "cr": [0, -5]}],
304 [2, {"rot": 20, "b": 1, "cl": [-1, -1], "cr": [0, -5]}],
305 [2.1, {"rot": 10, "cl": [-1, -1], "cr": [0, -5]}],
306 [2.2, {"b": 1, "cl": [-1, -1], "cr": [0, -5]}],
307 [2.25, {"rot": -10, "b": 1, "cl": [-1, -1], "cr": [0, -5]}],
308 [2.35, {"rot": -20, "cl": [-1, -1], "cr": [0, -5]}],
309 [2.4, {"jy": -2, "cl": [0, -3], "cr": [0, -3], "la": -1, "lb": -1}],
310 [2.6, {"cl": [0, -3], "cr": [0, -3]}],
311 [2.85, {"jy": -2, "cl": [0, -3], "cr": [0, -3], "la": -1, "lb": -1}],
312 [3, {"cl": [0, -3], "cr": [0, -3]}],
313 [3.2, {"b": 1, "cl": [0, -2]}],
314 [3.25, {}],
315 [3.5, {"b": 1, "cl": [0, -2]}],
316 [3.75, {}]
317 ]
318 },
319 "sonnet": {
320 "origin": [15, 16],
321 "box": [-1, -3, 30, 35],
322 "keys": [
323 [0, {"jx": -1, "cl": [0, -3], "la": -1}],
324 [0.25, {"jy": 1, "cr": [0, -3], "lb": -1}],
325 [0.5, {"jx": 1, "cl": [0, -3], "la": -1}],
326 [0.75, {"jy": 1, "cr": [0, -3], "lb": -1}],
327 [1, {"jx": -1, "cl": [0, -3], "la": -1}],
328 [1.25, {"jy": 1, "cr": [0, -3], "lb": -1}],
329 [1.5, {"jy": 1, "rot": 45}],
330 [1.55, {"jy": 1, "rot": 90}],
331 [1.6, {"jy": 1, "rot": 135}],
332 [1.65, {"jy": 1, "rot": 180, "la": -1, "lb": -1}],
333 [1.75625, {"jy": 1, "rot": 225, "la": -1, "lb": -1}],
334 [1.8625, {"jy": 1, "rot": 270, "la": -1, "lb": -1}],
335 [1.96875, {"jy": 1, "rot": 315, "la": -1, "lb": -1}],
336 [2.075, {"jy": 1, "rot": 360, "la": -1, "lb": -1}],
337 [2.18125, {"jy": 1, "rot": 405, "la": -1, "lb": -1}],
338 [2.2875, {"jy": 1, "rot": 450, "la": -1, "lb": -1}],
339 [2.39375, {"jy": 1, "rot": 495, "la": -1, "lb": -1}],
340 [2.5, {"jy": 2, "rot": 180, "cr": [0, -4]}],
341 [2.56, {"jy": 2, "rot": 135, "cr": [0, -4]}],
342 [3.5, {"jy": 1, "rot": 90}],
343 [3.55, {"jy": 1, "rot": 45}],
344 [3.6, {"jy": 1}],
345 [3.65, {"jy": -2}],
346 [3.7, {"jy": -3}],
347 [3.8, {"jy": -2}],
348 [3.85, {"jy": -1}],
349 [3.9, {}]
350 ]
351 },
352 "explore": {
353 "origin": [15, 17],
354 "box": [-4, 1, 38, 27],
355 "keys": [
356 [0, {"jx": 3, "rot": -8, "cr": [0, -2], "lb": -1}],
357 [0.2, {"jx": 2, "rot": -8, "cr": [0, -2], "lb": -1}],
358 [0.25, {"jx": 2, "rot": -8, "b": 1, "cl": [0, -2], "la": -1}],
359 [0.5, {"jx": 1, "rot": -8, "cr": [0, -2], "lb": -1}],
360 [0.75, {"jx": 1, "rot": -8, "b": 1, "cl": [0, -2], "la": -1}],
361 [0.8, {"rot": -8, "b": 1, "cl": [0, -2], "la": -1}],
362 [1, {"rot": -8, "cr": [0, -2], "lb": -1}],
363 [1.1, {"jx": -1, "rot": -8, "cr": [0, -2], "lb": -1}],
364 [1.25, {"jx": -1, "rot": -8, "b": 1, "cl": [0, -2], "la": -1}],
365 [1.4, {"jx": -2, "rot": -8, "b": 1, "cl": [0, -2], "la": -1}],
366 [1.5, {"jx": -2, "rot": -8, "cr": [0, -2], "lb": -1}],
367 [1.7, {"jx": -3, "rot": -8, "cr": [0, -2], "lb": -1}],
368 [1.75, {"jx": -3, "rot": -8, "b": 1, "cl": [0, -2], "la": -1}],
369 [1.85, {"jx": -3, "jy": -3, "cl": [0, -3], "cr": [0, -3], "la": -1, "lb": -1}],
370 [2, {"jx": -3, "jy": -3, "mirror": true, "cl": [0, -3], "cr": [0, -3], "la": -1, "lb": -1}],
371 [2.15, {"jx": -3, "rot": 8, "mirror": true, "cr": [0, -2], "lb": -1}],
372 [2.25, {"jx": -3, "rot": 8, "mirror": true, "b": 1, "cl": [0, -2], "la": -1}],
373 [2.35, {"jx": -2, "rot": 8, "mirror": true, "b": 1, "cl": [0, -2], "la": -1}],
374 [2.5, {"jx": -2, "rot": 8, "mirror": true, "cr": [0, -2], "lb": -1}],
375 [2.65, {"jx": -1, "rot": 8, "mirror": true, "cr": [0, -2], "lb": -1}],
376 [2.75, {"jx": -1, "rot": 8, "mirror": true, "b": 1, "cl": [0, -2], "la": -1}],
377 [2.95, {"rot": 8, "mirror": true, "b": 1, "cl": [0, -2], "la": -1}],
378 [3, {"rot": 8, "mirror": true, "cr": [0, -2], "lb": -1}],
379 [3.25, {"jx": 1, "rot": 8, "mirror": true, "b": 1, "cl": [0, -2], "la": -1}],
380 [3.5, {"jx": 1, "rot": 8, "mirror": true, "cr": [0, -2], "lb": -1}],
381 [3.55, {"jx": 2, "rot": 8, "mirror": true, "cr": [0, -2], "lb": -1}],
382 [3.75, {"jx": 2, "rot": 8, "mirror": true, "b": 1, "cl": [0, -2], "la": -1}],
383 [3.85, {"jx": 3, "rot": 8, "mirror": true, "b": 1, "cl": [0, -2], "la": -1}]
384 ]
385 },
386 "other": {
387 "origin": [15, 17],
388 "box": [-1, 4, 32, 25],
389 "keys": [
390 [0, {"jx": -2, "rot": -10, "cl": [0, -3], "la": -1}],
391 [0.25, {"jx": -2, "rot": -10, "b": 1, "cl": [0, -3], "lb": -1}],
392 [0.5, {"cl": [0, -2], "cr": [0, -2], "la": -1}],
393 [0.75, {"b": 1, "lb": -1}],
394 [1, {"jx": 2, "rot": 10, "cr": [0, -3], "la": -1}],
395 [1.25, {"jx": 2, "rot": 10, "b": 1, "cr": [0, -3], "lb": -1}],
396 [1.5, {"cl": [0, -2], "cr": [0, -2], "la": -1}],
397 [1.75, {"b": 1, "lb": -1}],
398 [2, {"cl": [1, -4], "cr": [-1, -4]}],
399 [2.15, {"cl": [0, -2], "cr": [0, -2]}],
400 [2.25, {"cl": [1, -4], "cr": [-1, -4]}],
401 [2.4, {"cl": [0, -2], "cr": [0, -2]}],
402 [2.5, {"b": 2}],
403 [2.75, {"jy": -3, "cl": [0, -5], "cr": [0, -5], "la": -1, "lb": -1}],
404 [3, {"jy": -4, "cl": [0, -5], "cr": [0, -5], "la": -2, "lb": -2}],
405 [3.25, {"jy": -2, "cl": [0, -3], "cr": [0, -3], "la": -1, "lb": -1}],
406 [3.5, {"b": 2}],
407 [3.75, {"b": 1}]
408 ]
409 }
410}
411// MOVES:END
412
413const keyOf = (costume: string): string => (MOVES[costume] ? costume : 'other')
414
415// An outfit with no routine of its own (a new savvy tier, a typo) dances the plain crab's.
416export const routineOf = (costume: string): Routine => MOVES[keyOf(costume)] ?? MOVES.other
417
418const GROUPS = ['dn', 'bd', 'cl', 'cr', 'la', 'lb'] as const
419type Group = (typeof GROUPS)[number]
420
421const transformOf = (group: Group, p: Pose): string => {
422 if (group === 'dn') return `translate(${p.jx ?? 0}px,${p.jy ?? 0}px) rotate(${p.rot ?? 0}deg) scale(${p.mirror ? -1 : 1},1)`
423 if (group === 'bd') return `translate(0,${p.b ?? 0}px)`
424 if (group === 'cl' || group === 'cr') {
425 const [dx, dy] = p[group] ?? [0, 0]
426 return `translate(${dx}px,${dy}px)`
427 }
428 return `translateY(${p[group] ?? 0}px)`
429}
430
431// Where the whole crab turns, as a share of the 30 x 28 frame `dn` fills.
432const pivot = ([x, y]: [number, number]): string => `${+((x / 30) * 100).toFixed(3)}% ${+((y / 28) * 100).toFixed(3)}%`
433
434// One outfit's routine as CSS: a keyframe track for each group that moves, held steps on the 4 s
435// loop from the drawing's phase (`--d`). Every name carries the outfit (`mv-<outfit>-<group>`) and
436// every rule its class, so crabs of different outfits never share a rule, even inlined on one page.
437export const routineCss = (costume: string): string => {
438 const outfit = keyOf(costume)
439 const routine = routineOf(outfit)
440 return GROUPS.flatMap(group => {
441 const steps: Array<[number, string]> = []
442 for (const [second, pose] of routine.keys) {
443 const tf = transformOf(group, pose)
444 if (steps[steps.length - 1]?.[1] !== tf) steps.push([second, tf])
445 }
446 if (steps.every(([, tf]) => tf === transformOf(group, {}))) return []
447 const name = `mv-${outfit}-${group}`
448 const origin = group === 'dn' ? `transform-origin:${pivot(routine.origin)};` : ''
449 return [
450 keyframes(name, move(steps)),
451 `.c-${outfit}.run .${group}{${origin}animation:${name} ${DANCE_SECONDS}s steps(1,end) infinite var(--d,0s)}`,
452 ]
453 }).join('\n')
454}
455hooks/plan.ts 73 lines1import type { PlanItem, PlanStatus } from '../types'
2
3// The main session's task list, replayed from the calls the model makes to keep it: the desktop
4// app's update_plan (a whole list, or advance / done / start), its wrap_up (ticks everything off),
5// and the terminal's TodoWrite (a whole list). Read only: the calls go on unchanged.
6export const PLAN_TOOL = 'mcp__ccd_turn__update_plan'
7export const WRAP_TOOL = 'mcp__ccd_turn__wrap_up'
8export const TODO_TOOL = 'TodoWrite'
9
10const MAX_ITEMS = 50
11const STATUSES: readonly PlanStatus[] = ['pending', 'in_progress', 'completed']
12const statusOf = (value: unknown): PlanStatus =>
13 STATUSES.includes(value as PlanStatus) ? (value as PlanStatus) : 'pending'
14
15type PlanCall = { items?: unknown; advance?: unknown; done?: unknown; start?: unknown }
16
17export const applyPlanCall = (plan: readonly PlanItem[], call: PlanCall): PlanItem[] => {
18 if (Array.isArray(call.items)) {
19 return call.items
20 .flatMap((raw): PlanItem[] => {
21 const item = raw as { content?: unknown; status?: unknown }
22 return typeof item?.content === 'string' ? [{ title: item.content, status: statusOf(item.status) }] : []
23 })
24 .slice(0, MAX_ITEMS)
25 }
26 const next = plan.map(item => ({ ...item }))
27 if (call.advance === true) {
28 const current = next.findIndex(item => item.status === 'in_progress')
29 const finished = next[current]
30 if (finished) finished.status = 'completed'
31 const following = next.find((item, k) => k > current && item.status === 'pending')
32 if (following) following.status = 'in_progress'
33 }
34 if (Array.isArray(call.done)) {
35 for (const n of call.done) {
36 const item = next[Number(n) - 1]
37 if (item) item.status = 'completed'
38 }
39 }
40 if (typeof call.start === 'number') {
41 const item = next[call.start - 1]
42 if (item) item.status = 'in_progress'
43 }
44 return next
45}
46
47export const applyTodos = (todos: unknown): PlanItem[] =>
48 Array.isArray(todos)
49 ? todos
50 .flatMap((raw): PlanItem[] => {
51 const todo = raw as { content?: unknown; status?: unknown; activeForm?: unknown }
52 if (typeof todo?.content !== 'string') return []
53 const status = statusOf(todo.status)
54 const title = status === 'in_progress' && typeof todo.activeForm === 'string' ? todo.activeForm : todo.content
55 return [{ title, status }]
56 })
57 .slice(0, MAX_ITEMS)
58 : []
59
60export const finishAll = (plan: readonly PlanItem[]): PlanItem[] =>
61 plan.map(item => ({ ...item, status: 'completed' }))
62
63export const isFinished = (plan: readonly PlanItem[]): boolean =>
64 plan.length > 0 && plan.every(item => item.status === 'completed')
65
66// What the row says: the step in progress (or the next one), and how many are done.
67export const planSummary = (plan: readonly PlanItem[]): { current?: string; done: number; total: number } => {
68 const done = plan.filter(item => item.status === 'completed').length
69 const current =
70 plan.find(item => item.status === 'in_progress')?.title ?? plan.find(item => item.status === 'pending')?.title
71 return { current, done, total: plan.length }
72}
73types/index.d.ts 50 lines1export type RunStatus = 'running' | 'done' | 'failed'
2
3export type Run = {
4 id: string
5 agentId?: string
6 description: string
7 /** The subagent type (Explore, general-purpose, savvy-light...): picks the crab's costume. */
8 type?: string
9 model: string
10 /** The effort its model requests ran at, once one reported it. */
11 effort?: string
12 status: RunStatus
13 startedAt: number
14 endedAt?: number
15 /** The last model request's prompt plus reply: how full the agent's window is. */
16 contextTokens: number
17}
18
19export type PlanStatus = 'pending' | 'in_progress' | 'completed'
20
21export type PlanItem = { title: string; status: PlanStatus }
22
23/** The main session: the row that always leads the band. */
24export type Main = {
25 /** The main loop's model, '' until known. */
26 model: string
27 effort?: string
28 isWorking: boolean
29 /** When the turn in progress (or the last one) began; 0 before the first. */
30 turnStartedAt: number
31 /** Tokens of its last model request, the fallback when no measurement has come. */
32 contextTokens: number
33 /** The engine's own measurement of the context window's fill, when it has one. */
34 contextPercent?: number
35 /** Its task list, as the model last set it. */
36 plan: PlanItem[]
37}
38
39declare module 'claude-code' {
40 interface PluginState {
41 'crab-band': {
42 runs: Run[]
43 now: number
44 main: Main
45 /** Whether the agents pane is on screen (open, placed and the shown tab): the band steps aside. */
46 paneShown: boolean
47 }
48 }
49}
50