KOZMOS Clawdling: a pixel crab companion that lives above the prompt. It blinks and sways, scuttles while tools run, thinks in bubbles, sweats past 80%…

A pixel crab companion that lives above the prompt. Its moods follow the session:
| Mood | When | What it does |
|---|---|---|
| lounging | idle | blinks, sways, looks around, smiles now and then |
| scuttling | a tool is running | runs side to side, legs clicking |
| pondering | Claude is thinking | a thought bubble whose dots light up in turn |
| ouch | a tool just failed | frowns, a sweat drop slides down |
| overheating | context over 80 % | sweats and fans itself with a paper fan |
| drowsy | 5-hour limit over 90 % | eyes closed, z z Z drifting up |
| celebrating | 5+ answered turns in a row, or a level-up | dances, claws up, sparkles and notes |
| fed & happy | /clawdling feed | hearts float up |
It earns XP: +1 per successful tool call, +10 per answered turn. Level n needs 100 × n XP, and levels unlock accessories: Lv 3 party hat, Lv 5 sunglasses, Lv 8 scarf, Lv 12 crown. XP is kept across sessions.
Beside the crab: its name, Lv 4 · 230/400 XP, a typed-out quip that fits the mood, and a status line.
Client surface module draws the 16×8-pixel crab with half blocks (▀/▄, two pixels per cell) at 8 fps.Commands: /clawdling shows or hides it, /clawdling feed, /clawdling stats. The ✕ button hides it too. Setting: name (default Pinchy).
İstemin üstünde yaşayan piksel bir yengeç. Ruh hâli oturumu izler: boşta göz kırpar ve salınır, araç çalışırken yan yan koşar, düşünürken baloncuk çıkarır, araç hata verince üzülüp terler, bağlam yüzde 80'i geçince yelpazelenir, 5 saatlik limit yüzde 90'ı geçince uyuklar (z z Z), art arda 5 başarılı turda dans eder, /clawdling feed ile beslenince kalpler uçurur.
XP kazanır: başarılı her araç +1, tamamlanan her tur +10. Seviyeler aksesuar açar: Lv 3 parti şapkası, Lv 5 güneş gözlüğü, Lv 8 atkı, Lv 12 taç. XP oturumlar arasında saklanır.
Terminalde yarım bloklarla 8 fps animasyon (Client modülü), masaüstünde aynı sprite'tan 24 kareli SVG flipbook. Komutlar: /clawdling (göster/gizle), /clawdling feed, /clawdling stats. Ayar: name (varsayılan Pinchy).
hooks/register.tsx 304 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ClawdlingSnap } from '../types'
5import { fitText, pxOf, svg, svgBar, svgText, textWidth, xml } from './lib/kz.ts'
6import {
7 MOOD_LABEL, UNLOCKS, XP_TOOL, XP_TURN, accessoriesAt, cleanName, levelOf, moodOf, nextUnlock, quipFor, unlocksBetween,
8} from './clawd.ts'
9import type { Senses } from './clawd.ts'
10import { PERIOD, STAGE_H, STAGE_W, crabFrame, frameSvg } from './sprite.ts'
11import type { Mood } from './sprite.ts'
12
13const snapAtom = atom({ plugin: 'clawdling', key: 'snap' } as const, null)
14const hiddenAtom = atom({ plugin: 'clawdling', key: 'isHidden' } as const, false)
15
16const SAD_MS = 8000
17const DANCE_MS = 12_000
18const LOVE_MS = 5000
19const QUIP_MS = 25_000
20
21// Module memory; XP is loaded from $.store on session.start.
22let name = 'Pinchy'
23let xp = 0
24let isDirty = false
25let streak = 0
26let senses: Senses = { now: 0, loveUntil: 0, sadUntil: 0, danceUntil: 0, ctxPercent: 0, limitPercent: 0, isTurnActive: false, toolsRunning: 0 }
27let lastMood: Mood | '' = ''
28let quipSeed = 0
29let quipAt = 0
30let lastPublished = ''
31
32async function publish($: EngineInterface): Promise<void> {
33 const now = await $.clock.now()
34 senses.now = now
35 const mood = moodOf(senses)
36 if (mood !== lastMood || now - quipAt > QUIP_MS) {
37 quipSeed += 1 + Math.floor(now / 997) % 3
38 quipAt = now
39 lastMood = mood
40 }
41 const lv = levelOf(xp)
42 const snap: ClawdlingSnap = {
43 mood,
44 name,
45 xp,
46 level: lv.level,
47 into: lv.into,
48 need: lv.need,
49 acc: accessoriesAt(lv.level),
50 quip: quipFor(mood, quipSeed),
51 streak,
52 ctxPercent: Math.round(senses.ctxPercent),
53 limitPercent: Math.round(senses.limitPercent),
54 }
55 const key = JSON.stringify(snap)
56 if (key === lastPublished) return
57 lastPublished = key
58 await update($, snapAtom, () => snap)
59}
60
61async function save($: EngineInterface): Promise<void> {
62 if (!isDirty) return
63 isDirty = false
64 try {
65 await $.store.set('xp', xp)
66 } catch {
67 isDirty = true
68 }
69}
70
71async function tick($: EngineInterface): Promise<void> {
72 await publish($)
73 await save($)
74}
75
76async function gain($: EngineInterface, n: number): Promise<void> {
77 const before = levelOf(xp).level
78 xp += n
79 isDirty = true
80 const after = levelOf(xp).level
81 if (after > before) {
82 const now = await $.clock.now()
83 senses.danceUntil = now + DANCE_MS
84 const got = unlocksBetween(before, after)
85 $.ui.toast(`🦀 ${name} reached Lv ${after}!${got.length ? ` ${got.map(u => `${u.icon} ${u.name}`).join(', ')} unlocked` : ''}`, { timeoutMs: 8000 })
86 await save($)
87 }
88}
89
90async function readUsage($: EngineInterface): Promise<void> {
91 try {
92 const u = await $.session.usage()
93 senses.ctxPercent = u.context.percent ?? 0
94 senses.limitPercent = u.rateLimits.find(l => l.kind === 'five_hour')?.percentUsed ?? 0
95 } catch {
96 // Kept as they were.
97 }
98}
99
100async function setHidden($: EngineInterface, value: boolean): Promise<void> {
101 await update($, hiddenAtom, () => value)
102 try {
103 await $.store.set('hidden', value)
104 } catch {
105 // Session only.
106 }
107}
108
109export const register: Register = (on, options) => {
110 on('session.start', async ($, e, next) => {
111 const started = await next(e)
112 name = cleanName(options.name)
113 streak = 0
114 lastMood = ''
115 lastPublished = ''
116 senses = { now: 0, loveUntil: 0, sadUntil: 0, danceUntil: 0, ctxPercent: 0, limitPercent: 0, isTurnActive: false, toolsRunning: 0 }
117 try {
118 const stored = await $.store.get('xp')
119 xp = typeof stored === 'number' && Number.isFinite(stored) ? Math.max(0, stored) : 0
120 const isHidden = (await $.store.get('hidden')) === true
121 await update($, hiddenAtom, () => isHidden)
122 } catch {
123 xp = 0
124 }
125 isDirty = false
126 await $.command.register({ name: 'clawdling', description: `KOZMOS: show or hide ${name} the crab (feed, stats)`, argumentHint: '[feed | stats]', immediate: true })
127 await readUsage($)
128 await publish($)
129 $.clock.every(1000, () => void tick($).catch(() => undefined))
130 $.clock.every(10_000, () => void readUsage($)) // readUsage never throws
131 return started
132 })
133
134 on('command.run', { command: 'clawdling' }, async ($, e) => {
135 const verb = e.args.trim().toLowerCase()
136 const lv = levelOf(xp)
137 if (verb === 'feed') {
138 senses.loveUntil = (await $.clock.now()) + LOVE_MS
139 if (await read($, hiddenAtom)) await setHidden($, false)
140 await publish($)
141 return { text: `🦀 ${name}: ${quipFor('love', quipSeed + 1)}` }
142 }
143 if (verb === 'stats') {
144 const up = nextUnlock(lv.level)
145 const owned = UNLOCKS.filter(u => u.level <= lv.level).map(u => u.icon).join(' ')
146 return { text: `🦀 ${name} · Lv ${lv.level} · ${lv.into}/${lv.need} XP (${xp} total) · streak ${streak}${owned ? ` · wearing ${owned}` : ''}${up ? ` · next: ${up.icon} ${up.name} at Lv ${up.level}` : ' · fully dressed'}` }
147 }
148 const hidden = !(await read($, hiddenAtom))
149 await setHidden($, hidden)
150 return { text: hidden ? `${name} went back to the sea. /clawdling brings them back.` : `🦀 ${name} is back!` }
151 })
152
153 on('session.measure', async ($, e, next) => {
154 const r = await next(e)
155 senses.ctxPercent = e.context.percent ?? senses.ctxPercent
156 senses.limitPercent = e.rateLimits.find(l => l.kind === 'five_hour')?.percentUsed ?? senses.limitPercent
157 await publish($)
158 return r
159 }).catch(($, e, next) => next(e))
160
161 on('turn.start', async ($, e, next) => {
162 senses.isTurnActive = true
163 senses.toolsRunning = 0
164 await publish($)
165 return next(e)
166 }).catch(($, e, next) => next(e))
167
168 on('tool.call', async ($, e, next) => {
169 const isMain = e.agentId === undefined
170 if (isMain) {
171 senses.toolsRunning++
172 void publish($).catch(() => undefined)
173 }
174 let ran
175 try {
176 ran = await next(e)
177 } finally {
178 if (isMain) senses.toolsRunning = Math.max(0, senses.toolsRunning - 1)
179 }
180 if (ran.isError === true || ran.deny !== undefined) senses.sadUntil = (await $.clock.now()) + SAD_MS
181 else await gain($, XP_TOOL)
182 void publish($).catch(() => undefined)
183 return ran
184 }).catch(($, e, next) => next(e))
185
186 on('turn.complete', async ($, e, next) => {
187 const done = await next(e)
188 if (e.agentId !== undefined) return done
189 senses.isTurnActive = false
190 senses.toolsRunning = 0
191 if (e.reason === 'answer') {
192 streak++
193 await gain($, XP_TURN)
194 if (streak >= 5) senses.danceUntil = (await $.clock.now()) + DANCE_MS
195 } else streak = 0
196 await publish($)
197 await save($)
198 return done
199 }).catch(($, e, next) => next(e))
200
201 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
202 const drawn = await next(e)
203 const snap = await read($, snapAtom)
204 if (!snap || e.props.hasSurvey || (await read($, hiddenAtom))) return drawn
205 const { Box, Button } = $.ui.resolve(e)
206 const cols = Math.max(40, e.props.bodyColumns || 80)
207 const hide = <Button key="clawdling-hide" label="✕" plain dimColor role="dismiss" onPress={() => void setHidden($, true)} />
208 const icons = UNLOCKS.filter(u => u.level <= snap.level).map(u => u.icon).join('')
209 let mine
210 if (e.surface === 'terminal') {
211 const { Client } = $.ui.resolve(e)
212 const props = {
213 cols: cols - 3, mood: snap.mood, name: snap.name, level: snap.level, into: snap.into, need: snap.need,
214 quip: snap.quip, acc: snap.acc, icons, status: statusOf(snap),
215 }
216 mine = <Client key="clawdling-crab" module="./crab.tsx" width={cols - 3} height={4} props={props} />
217 } else {
218 // Desktop, VS Code and mobile: the flipbook card.
219 const { Svg } = $.ui.resolve(e)
220 const W = pxOf(cols)
221 mine = <Svg source={crabCard(snap, W, icons)} alt={altOf(snap)} width={W} height={CARD_H} />
222 }
223 return (
224 <Box flexDirection="column">
225 {drawn}
226 <Box key="clawdling" flexDirection="row">
227 {mine}
228 {hide}
229 </Box>
230 </Box>
231 )
232 })
233}
234
235// ---------------------------------------------------------------------------
236
237function statusOf(s: ClawdlingSnap): string {
238 const bits = [MOOD_LABEL[s.mood]]
239 if (s.streak >= 2) bits.push(`🔥${s.streak}`)
240 if (s.ctxPercent > 0) bits.push(`ctx ${s.ctxPercent}%`)
241 if (s.limitPercent > 0) bits.push(`5h ${s.limitPercent}%`)
242 const up = nextUnlock(s.level)
243 if (up) bits.push(`next ${up.icon} Lv ${up.level}`)
244 return bits.join(' · ')
245}
246
247function altOf(s: ClawdlingSnap): string {
248 return `${s.name}, a pixel crab, is ${MOOD_LABEL[s.mood]}. Level ${s.level}, ${s.into} of ${s.need} XP. “${s.quip}”`
249}
250
251const SCALE = 5
252const CARD_H = 60
253const FRAME_MS = 125
254
255/** The desktop band: a 24-frame flipbook of the same sprite, then name, level, XP and the quip. */
256function crabCard(s: ClawdlingSnap, W: number, icons: string): string {
257 const T = (PERIOD * FRAME_MS) / 1000
258 const slice = (100 / PERIOD).toFixed(4)
259 const css = `
260.cw-fr{opacity:0;animation:cfl ${T}s step-end infinite}
261@keyframes cfl{0%{opacity:1}${slice}%{opacity:0}100%{opacity:0}}
262@media (prefers-reduced-motion: reduce){.cw-f0{opacity:1}}
263.cw-bub{animation:cbu 4s ease-in infinite;opacity:0}@keyframes cbu{0%{opacity:0;transform:translateY(0)}20%{opacity:.55}100%{opacity:0;transform:translateY(-34px)}}
264.cw-typ{animation:cty 1.2s steps(24) both}@keyframes cty{from{clip-path:inset(0 100% 0 0)}to{clip-path:inset(0 0 0 0)}}
265`
266 const stageW = STAGE_W * SCALE
267 const stageH = STAGE_H * SCALE
268 const sx = 10
269 const sy = 6
270 const parts: string[] = []
271 parts.push(`<defs><linearGradient id="cxp" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#fb923c"/><stop offset="1" stop-color="#f43f5e"/></linearGradient>` +
272 `<linearGradient id="csea" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#38bdf8" stop-opacity="0"/><stop offset="1" stop-color="#38bdf8" stop-opacity=".16"/></linearGradient></defs>`)
273 parts.push(`<rect class="p" x="0" y="0" width="${W}" height="${CARD_H}" rx="14"/>`)
274 parts.push(`<rect x="0" y="0" width="${stageW + sx * 2}" height="${CARD_H}" rx="14" fill="url(#csea)"/>`)
275 // Sand and two pebbles.
276 parts.push(`<rect x="${sx - 4}" y="${sy + stageH + 2}" width="${stageW + 8}" height="4" rx="2" fill="#e9c46a" opacity=".55"/>`)
277 parts.push(`<circle cx="${sx + stageW - 8}" cy="${sy + stageH + 2}" r="2.4" fill="#a8a29e"/><circle cx="${sx + 6}" cy="${sy + stageH + 2.5}" r="1.8" fill="#d6d3d1"/>`)
278 // Rising bubbles, out of phase.
279 for (const [x, d, r] of [[sx + 18, 0, 2], [sx + stageW - 30, 1.6, 1.6], [sx + stageW / 2, 2.8, 1.3]] as const) {
280 parts.push(`<circle class="cw-bub" style="animation-delay:${d}s" cx="${x}" cy="${sy + stageH - 2}" r="${r}" fill="none" stroke="#7dd3fc" stroke-width=".9"/>`)
281 }
282 // The flipbook.
283 parts.push(`<g transform="translate(${sx} ${sy})">`)
284 for (let i = 0; i < PERIOD; i++) {
285 const fr = crabFrame({ mood: s.mood, f: i, acc: s.acc })
286 parts.push(`<g class="cw-fr cw-f${i}" style="animation-delay:${((i * FRAME_MS) / 1000).toFixed(3)}s">${frameSvg(fr, SCALE)}</g>`)
287 }
288 parts.push(`</g>`)
289 // Side: name and level, XP bar, quip, status.
290 const x = sx * 2 + stageW + 8
291 const room = W - x - 14
292 parts.push(svgText(x, 19, s.name, { size: 14, weight: 750, fill: '#f05a3c' }))
293 const nx = x + textWidth(s.name, 14) + 8
294 parts.push(`<rect x="${nx}" y="7" width="36" height="16" rx="8" fill="#fbbf24" opacity=".2"/>`)
295 parts.push(svgText(nx + 18, 19, `Lv ${s.level}`, { size: 10.5, weight: 750, anchor: 'middle', fill: '#d97706' }))
296 if (icons) parts.push(`<text x="${nx + 44}" y="19" font-size="12">${xml(icons)}</text>`)
297 const barW = Math.max(60, Math.min(200, room - 90))
298 parts.push(svgBar(x, 27, barW, 6, s.into / s.need, 'url(#cxp)')) // need: 100 XP or more
299 parts.push(svgText(x + barW + 8, 33, `${s.into}/${s.need} XP`, { cls: 'm', size: 10 }))
300 parts.push(`<g class="cw-typ">${svgText(x, 48, fitText(`“${s.quip}”`, 11.5, room), { cls: 's', size: 11.5 }).replace('<text ', '<text font-style="italic" ')}</g>`)
301 if (room > 300) parts.push(svgText(W - 14, 19, fitText(statusOf(s), 10, Math.max(80, room - textWidth(s.name, 14) - 120)), { cls: 'm', size: 10, anchor: 'end' }))
302 return svg(W, CARD_H, parts.join(''), css)
303}
304hooks/lib/kz.ts 510 lines1// GENERATED by scripts/sync-shared.mjs from shared/kz.ts. Do not edit here.
2// KOZMOS shared kit. Source of truth: shared/kz.ts at the bundle root.
3// `node scripts/sync-shared.mjs` copies it into every mod as hooks/lib/kz.ts,
4// because a hooks module may only import files inside its own plugin.
5// Edit the root copy, never a mod's copy.
6
7// ---------------------------------------------------------------------------
8// Palette: one neon family across every KOZMOS mod.
9
10export const KZ = {
11 violet: '#a78bfa',
12 magenta: '#f472b6',
13 cyan: '#22d3ee',
14 teal: '#2dd4bf',
15 green: '#4ade80',
16 lime: '#a3e635',
17 yellow: '#facc15',
18 amber: '#fb923c',
19 red: '#f87171',
20 blue: '#60a5fa',
21 clay: '#d97757',
22 ink: '#1f1e1d',
23 mist: '#9ca3af',
24} as const
25
26/** Tool families, colored the same in every mod. */
27export function toolColor(tool: string): string {
28 const t = String(tool)
29 if (t === 'Bash' || t === 'PowerShell') return KZ.green
30 if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return KZ.yellow
31 if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP') return KZ.blue
32 if (t === 'Agent' || t === 'Task' || t === 'Workflow') return KZ.violet
33 if (t.startsWith('Web')) return KZ.cyan
34 if (t.startsWith('Todo') || t.startsWith('Task')) return KZ.teal
35 if (t.startsWith('mcp__')) return KZ.magenta
36 return KZ.mist
37}
38
39/** A short glyph per tool family. */
40export function toolGlyph(tool: string): string {
41 const t = String(tool)
42 if (t === 'Bash' || t === 'PowerShell') return '$'
43 if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit') return '✎'
44 if (t === 'Read') return '◉'
45 if (t === 'Glob' || t === 'Grep') return '⌕'
46 if (t === 'Agent') return '◈'
47 if (t.startsWith('Web')) return '◍'
48 if (t.startsWith('Todo') || t.startsWith('Task')) return '☑'
49 if (t.startsWith('mcp__')) return '⬡'
50 return '•'
51}
52
53// ---------------------------------------------------------------------------
54// Colors.
55
56export function hexToRgb(hex: string): [number, number, number] {
57 const h = hex.replace('#', '')
58 const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
59 return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
60}
61
62export function rgbToHex(r: number, g: number, b: number): string {
63 const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
64 return `#${c(r)}${c(g)}${c(b)}`
65}
66
67export function mix(a: string, b: string, t: number): string {
68 const [r1, g1, b1] = hexToRgb(a)
69 const [r2, g2, b2] = hexToRgb(b)
70 const k = clamp01(t)
71 return rgbToHex(r1 + (r2 - r1) * k, g1 + (g2 - g1) * k, b1 + (b2 - b1) * k)
72}
73
74/** Green at 0, yellow at 0.6, red at 1: the heat of a gauge. */
75export function heat(t: number): string {
76 const k = clamp01(t)
77 return k < 0.6 ? mix(KZ.green, KZ.yellow, k / 0.6) : mix(KZ.yellow, KZ.red, (k - 0.6) / 0.4)
78}
79
80/** A smooth rainbow for hues 0..1 (for auroras, plasma and the like). */
81export function hue(h: number, s = 0.75, l = 0.6): string {
82 const k = ((h % 1) + 1) % 1
83 const a = s * Math.min(l, 1 - l)
84 const f = (n: number) => {
85 const x = (n + k * 12) % 12
86 return l - a * Math.max(-1, Math.min(x - 3, 9 - x, 1))
87 }
88 return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255)
89}
90
91export function hexToInt(hex: string): number {
92 const [r, g, b] = hexToRgb(hex)
93 return (r << 16) | (g << 8) | b
94}
95
96// ---------------------------------------------------------------------------
97// Numbers and text.
98
99export const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0)
100
101export function fmtTokens(n: number): string {
102 if (!Number.isFinite(n)) return '—'
103 // Each unit starts where the one below would round up to 1000 of itself.
104 if (n >= 999.5e6) return `${(n / 1e9).toFixed(1)}B`
105 if (n >= 999_500) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
106 if (n >= 999.5) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
107 return `${Math.round(n)}`
108}
109
110export function fmtUsd(usd: number): string {
111 if (!Number.isFinite(usd)) return '$—'
112 if (usd < 0.01 && usd > 0) return '<$0.01'
113 return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
114}
115
116/** 0:42, 3:07, 1:02:33. */
117export function fmtClock(ms: number): string {
118 const s = Math.max(0, Math.round(ms / 1000))
119 const h = Math.floor(s / 3600)
120 const m = Math.floor((s % 3600) / 60)
121 const ss = String(s % 60).padStart(2, '0')
122 return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
123}
124
125/** 42s, 7m, 2h41m, 3d4h: compact durations for countdowns. */
126export function fmtSpan(ms: number): string {
127 const s = Math.max(0, Math.round(ms / 1000))
128 if (s < 60) return `${s}s`
129 const m = Math.floor(s / 60)
130 if (m < 60) return `${m}m`
131 const h = Math.floor(m / 60)
132 if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
133 return `${Math.floor(h / 24)}d${h % 24}h`
134}
135
136export function fmtPct(p: number | undefined): string {
137 return p === undefined || !Number.isFinite(p) ? '—' : `${Math.round(p)}%`
138}
139
140export function clip(s: string, max: number): string {
141 const one = s.replace(/\s+/g, ' ').trim()
142 return one.length > max ? one.slice(0, Math.max(1, max - 1)) + '…' : one
143}
144
145export function padEnd(s: string, n: number): string {
146 return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length)
147}
148
149export function padStart(s: string, n: number): string {
150 return s.length >= n ? s.slice(s.length - n) : ' '.repeat(n - s.length) + s
151}
152
153/** `claude-opus-5-5[1m]` → `Opus 5.5`. */
154export function modelName(id: string | undefined): string {
155 if (!id) return '—'
156 const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
157 if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
158 const fam = m[1]!
159 return `${fam.charAt(0).toUpperCase()}${fam.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
160}
161
162/** Last path segment, either slash. */
163export function baseName(p: string): string {
164 const parts = p.split(/[\\/]/).filter(Boolean)
165 return parts[parts.length - 1] ?? p
166}
167
168/** A stable small hash for seeding. */
169export function hash(s: string): number {
170 let h = 2166136261
171 for (let i = 0; i < s.length; i++) {
172 h ^= s.charCodeAt(i)
173 h = Math.imul(h, 16777619)
174 }
175 return h >>> 0
176}
177
178/** Deterministic 0..1 noise from two ints. */
179export function noise(x: number, y: number): number {
180 const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
181 return s - Math.floor(s)
182}
183
184/** A seeded PRNG (mulberry32). */
185export function rng(seed: number): () => number {
186 let a = seed >>> 0
187 return () => {
188 a = (a + 0x6d2b79f5) >>> 0
189 let t = a
190 t = Math.imul(t ^ (t >>> 15), t | 1)
191 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
192 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
193 }
194}
195
196// ---------------------------------------------------------------------------
197// Text gauges for the terminal.
198
199const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
200
201/** A smooth bar with eighth blocks: `█████▍ `. */
202export function bar(ratio: number, width: number, empty = '░'): string {
203 const w = Math.max(1, Math.floor(width))
204 const exact = clamp01(ratio) * w
205 const full = Math.floor(exact)
206 const part = EIGHTHS[Math.floor((exact - full) * 8)] ?? ''
207 const used = full + (part ? 1 : 0)
208 return '█'.repeat(full) + part + empty.repeat(Math.max(0, w - used))
209}
210
211/** A segmented gauge: `▰▰▰▱▱▱`. */
212export function pips(ratio: number, width: number, on = '▰', off = '▱'): string {
213 const w = Math.max(1, Math.floor(width))
214 const n = Math.round(clamp01(ratio) * w)
215 return on.repeat(n) + off.repeat(w - n)
216}
217
218const SPARK = '▁▂▃▄▅▆▇█'
219
220/** A sparkline of the last `width` values, scaled to their own max (or `max`). */
221export function sparkline(values: readonly number[], width: number, max?: number): string {
222 const tail = values.slice(-Math.max(1, width))
223 const top = max ?? Math.max(1e-9, ...tail)
224 const line = tail.map(v => SPARK[Math.min(7, Math.max(0, Math.round((v / top) * 7)))] ?? '▁').join('')
225 return line.padStart(width, ' ')
226}
227
228/** Braille line graph, 2 samples per cell, `rows` cells tall. */
229export function brailleGraph(values: readonly number[], width: number, rows: number, max?: number): string[] {
230 const samples = values.slice(-(width * 2))
231 const top = max ?? Math.max(1e-9, ...samples)
232 const dotsTall = rows * 4
233 const grid: number[][] = Array.from({ length: rows }, () => Array.from({ length: width }, () => 0))
234 const offset = width * 2 - samples.length
235 // Dot bits per column, from the bottom row of a cell up.
236 const LEFT = [0x40, 0x04, 0x02, 0x01]
237 const RIGHT = [0x80, 0x20, 0x10, 0x08]
238 samples.forEach((v, i) => {
239 const x = offset + i
240 const col = Math.floor(x / 2)
241 const isRight = x % 2 === 1
242 const h = Math.round(clamp01(v / top) * (dotsTall - 1))
243 for (let d = 0; d <= h; d++) {
244 const row = rows - 1 - Math.floor(d / 4)
245 const bits = isRight ? RIGHT : LEFT
246 const cellRow = grid[row]
247 if (cellRow && col >= 0) cellRow[col] = (cellRow[col] ?? 0) | bits[d % 4]!
248 }
249 })
250 return grid.map(r => r.map(b => String.fromCharCode(0x2800 + b)).join(''))
251}
252
253// ---------------------------------------------------------------------------
254// Raster: a grid of colored cells, packed as the engine wants it.
255
256const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
257
258export function toBase64(bytes: Uint8Array): string {
259 let out = ''
260 let i = 0
261 for (; i + 2 < bytes.length; i += 3) {
262 const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
263 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
264 }
265 const rest = bytes.length - i
266 if (rest === 1) {
267 const n = bytes[i]! << 16
268 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
269 } else if (rest === 2) {
270 const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
271 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
272 }
273 return out
274}
275
276/** The terminal's own default color, for a cell's foreground or background. */
277export const DEFAULT_COLOR = 0x01000000
278
279export class Canvas {
280 readonly cols: number
281 readonly rows: number
282 private readonly words: Uint32Array
283
284 constructor(cols: number, rows: number) {
285 this.cols = Math.max(1, Math.min(512, Math.floor(cols)))
286 this.rows = Math.max(1, Math.min(256, Math.floor(rows)))
287 this.words = new Uint32Array(this.cols * this.rows * 3)
288 this.clear()
289 }
290
291 clear(bg: number = DEFAULT_COLOR): void {
292 for (let i = 0; i < this.cols * this.rows; i++) {
293 this.words[i * 3] = 0x20
294 this.words[i * 3 + 1] = DEFAULT_COLOR
295 this.words[i * 3 + 2] = bg
296 }
297 }
298
299 /** Puts one width-1 character; colors are '#rrggbb' or a packed int. */
300 set(x: number, y: number, ch: string, fg?: string | number, bg?: string | number): void {
301 const cx = Math.floor(x)
302 const cy = Math.floor(y)
303 if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
304 const i = (cy * this.cols + cx) * 3
305 const code = ch.codePointAt(0) ?? 0x20
306 this.words[i] = code > 0xffff || code < 0x20 ? 0x20 : code
307 if (fg !== undefined) this.words[i + 1] = typeof fg === 'number' ? fg : hexToInt(fg)
308 if (bg !== undefined) this.words[i + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
309 }
310
311 /** Writes a string left to right, clipped to the canvas. */
312 text(x: number, y: number, s: string, fg?: string | number, bg?: string | number): void {
313 let cx = x
314 for (const ch of s) {
315 this.set(cx, y, ch, fg, bg)
316 cx++
317 }
318 }
319
320 /** Paints a cell's background only, keeping its character. */
321 paint(x: number, y: number, bg: string | number): void {
322 const cx = Math.floor(x)
323 const cy = Math.floor(y)
324 if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
325 this.words[(cy * this.cols + cx) * 3 + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
326 }
327
328 /** Two vertical pixels per cell with the upper-half block: `py` is in half-cells. */
329 pixel(x: number, py: number, color: string): void {
330 const cx = Math.floor(x)
331 const cy = Math.floor(py / 2)
332 if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
333 const i = (cy * this.cols + cx) * 3
334 const c = hexToInt(color)
335 const isTop = Math.floor(py) % 2 === 0
336 if (this.words[i] !== 0x2580) {
337 this.words[i] = 0x2580
338 this.words[i + 1] = DEFAULT_COLOR
339 this.words[i + 2] = DEFAULT_COLOR
340 }
341 if (isTop) this.words[i + 1] = c
342 else this.words[i + 2] = c
343 }
344
345 /** The `cells` prop of a Raster. */
346 encode(): string {
347 return toBase64(new Uint8Array(this.words.buffer))
348 }
349}
350
351// ---------------------------------------------------------------------------
352// SVG for the desktop: one drawing per row (the desktop wraps siblings).
353
354export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif"
355export const MONO = "ui-monospace,'Cascadia Code','SF Mono',Consolas,monospace"
356
357const XML_ESC: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
358
359export function xml(s: string): string {
360 return s.replace(/[&<>"']/g, c => XML_ESC[c]!)
361}
362
363/** Theme-aware classes every KOZMOS drawing shares: t text, s secondary, m muted, k track, p panel. */
364export const SVG_BASE_CSS = `
365.t{fill:#1f1f1f}.s{fill:#5f5f5c}.m{fill:#8e8e8a}.k{fill:#e7e5e0}.p{fill:#f5f4f1}.ln{stroke:#e1dfda}
366@media (prefers-color-scheme: dark){.t{fill:#ededed}.s{fill:#b4b4b0}.m{fill:#7c7c78}.k{fill:#2d2d2b}.p{fill:#232322}.ln{stroke:#363634}}
367.pulse{animation:kzp 1.6s ease-in-out infinite}@keyframes kzp{50%{opacity:.35}}
368.spin{transform-box:fill-box;transform-origin:center;animation:kzs 2s linear infinite}@keyframes kzs{to{transform:rotate(360deg)}}
369@media (prefers-reduced-motion: reduce){*{animation:none!important}}
370`
371
372export function svg(width: number, height: number, body: string, css = ''): string {
373 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${SVG_BASE_CSS}${css}</style>${body}</svg>`
374}
375
376/** Rough width of UI text in px, for fitting labels. */
377export function textWidth(s: string, size: number): number {
378 let w = 0
379 for (const ch of s) w += (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%MWmw]/.test(ch) ? 0.72 : 0.56) * size
380 return w
381}
382
383export function fitText(s: string, size: number, maxW: number): string {
384 if (textWidth(s, size) <= maxW) return s
385 let out = ''
386 for (const ch of s) {
387 if (textWidth(out + ch + '…', size) > maxW) break
388 out += ch
389 }
390 return out + '…'
391}
392
393/** A rounded progress bar as SVG markup. */
394export function svgBar(x: number, y: number, w: number, h: number, ratio: number, color: string): string {
395 const fw = Math.max(0, Math.min(w, w * clamp01(ratio)))
396 return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
397 (fw > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fw)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : '')
398}
399
400/** SVG text helper. */
401export function svgText(x: number, y: number, s: string, opts: { cls?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string; mono?: boolean } = {}): string {
402 const { cls = 't', size = 12, weight = 400, anchor = 'start', fill, mono = false } = opts
403 return `<text ${fill ? `fill="${fill}"` : `class="${cls}"`} x="${x}" y="${y}" font-family="${mono ? MONO : FONT}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-variant-numeric="tabular-nums">${xml(s)}</text>`
404}
405
406/** Pixel width a desktop pane or band gives a drawing for `columns` reported columns. */
407export function pxOf(columns: number | undefined, fallback = 60, slack = 8): number {
408 return Math.max(200, Math.min(1600, (columns || fallback) * 8 - slack))
409}
410
411// ---------------------------------------------------------------------------
412// Prices, USD per million tokens: input, output, cache read, cache write (5 min).
413// Anthropic first-party rates as of 2026-10. Used only where the engine reports
414// tokens and not money (a subagent's own spend); the session total comes from
415// $.session.usage().cost, which is the engine's.
416
417const PRICES: [RegExp, [number, number, number, number]][] = [
418 [/fable|mythos/i, [10, 50, 0.25, 12.5]],
419 [/opus-5-5/i, [4, 20, 0.2, 5]],
420 [/opus/i, [5, 25, 0.5, 6.25]],
421 [/sonnet-5/i, [2, 10, 0.2, 2.5]],
422 [/sonnet/i, [3, 15, 0.3, 3.75]],
423 [/haiku-5/i, [0.1, 0.5, 0.01, 0.125]],
424 [/haiku/i, [1, 5, 0.1, 1.25]],
425]
426
427export type Usage = {
428 input_tokens?: number
429 output_tokens?: number
430 cache_read_input_tokens?: number
431 cache_creation_input_tokens?: number
432}
433
434export function priceOf(model: string): [number, number, number, number] {
435 return PRICES.find(([re]) => re.test(model))?.[1] ?? [4, 20, 0.2, 5]
436}
437
438export function costOf(model: string, u: Usage | null | undefined): number {
439 if (!u) return 0
440 const [i, o, r, w] = priceOf(model)
441 return ((u.input_tokens ?? 0) * i + (u.output_tokens ?? 0) * o + (u.cache_read_input_tokens ?? 0) * r + (u.cache_creation_input_tokens ?? 0) * w) / 1e6
442}
443
444export function tokensOf(u: Usage | null | undefined): number {
445 if (!u) return 0
446 return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
447}
448
449/** The context a request filled: everything it read plus what it wrote. */
450export function contextOf(u: Usage | null | undefined): number {
451 return tokensOf(u)
452}
453
454export function windowOf(model: string): number {
455 return /haiku-4/i.test(model) ? 200_000 : 1_000_000
456}
457
458// ---------------------------------------------------------------------------
459// Rate limits.
460
461export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
462
463export function limitLabel(kind: string): string {
464 if (kind === 'five_hour') return '5h'
465 if (kind === 'seven_day') return '7d'
466 if (kind === 'spend_limit') return '$'
467 return kind.replace(/_/g, ' ')
468}
469
470/** Milliseconds until a window resets, or undefined. */
471export function untilReset(l: Limit, now: number): number | undefined {
472 if (!l.resetsAt) return undefined
473 const t = Date.parse(l.resetsAt)
474 return Number.isFinite(t) ? Math.max(0, t - now) : undefined
475}
476
477/** The length of a window in ms, for burn-rate math. */
478export function windowMs(kind: string): number | undefined {
479 if (kind === 'five_hour') return 5 * 3600_000
480 if (kind === 'seven_day') return 7 * 24 * 3600_000
481 return undefined
482}
483
484// ---------------------------------------------------------------------------
485// Tool calls, described in a few words.
486
487/** What a tool call is about: the command, the file, the pattern, the agent's task. */
488export function toolDetail(tool: string, input: unknown): string {
489 const e = (input ?? {}) as Record<string, unknown>
490 const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
491 const t = String(tool)
492 if (t === 'Bash' || t === 'PowerShell') return clip(s('description') || s('command'), 60)
493 if (s('file_path')) return baseName(s('file_path'))
494 if (s('notebook_path')) return baseName(s('notebook_path'))
495 if (t === 'Agent') return clip(s('description') || s('subagent_type'), 60)
496 if (s('pattern')) return clip(s('pattern'), 60)
497 if (s('url')) return clip(s('url').replace(/^https?:\/\//, ''), 60)
498 if (s('query')) return clip(s('query'), 60)
499 if (s('subject')) return clip(s('subject'), 60)
500 if (Array.isArray(e.todos)) return `${(e.todos as unknown[]).length} todos`
501 if (t.startsWith('mcp__')) return t.split('__').slice(1).join(' · ')
502 return ''
503}
504
505/** `mcp__server__tool` → `server·tool`; built-ins unchanged. */
506export function toolName(tool: string): string {
507 const t = String(tool)
508 return t.startsWith('mcp__') ? t.split('__').slice(1).join('·') : t
509}
510hooks/clawd.ts 132 lines1// Clawdling's mind, pure: XP and levels, unlocks, which mood wins, and what it says.
2import type { Acc, Mood } from './sprite.ts'
3
4export const XP_TOOL = 1
5export const XP_TURN = 10
6
7/** XP needed to go from `level` to the next: 100, 200, 300, ... */
8export function needFor(level: number): number {
9 return 100 * Math.max(1, level)
10}
11
12/** Total XP at which `level` begins (Lv 1 at 0, Lv 2 at 100, Lv 3 at 300, ...). */
13export function xpAtLevel(level: number): number {
14 return (100 * (level - 1) * level) / 2
15}
16
17export function levelOf(xp: number): { level: number; into: number; need: number } {
18 const total = Math.max(0, Math.floor(xp))
19 let level = 1
20 while (xpAtLevel(level + 1) <= total) level++
21 return { level, into: total - xpAtLevel(level), need: needFor(level) }
22}
23
24export const UNLOCKS: readonly { level: number; key: keyof Acc; name: string; icon: string }[] = [
25 { level: 3, key: 'hat', name: 'party hat', icon: '🎉' },
26 { level: 5, key: 'glasses', name: 'sunglasses', icon: '😎' },
27 { level: 8, key: 'scarf', name: 'scarf', icon: '🧣' },
28 { level: 12, key: 'crown', name: 'crown', icon: '👑' },
29]
30
31export function accessoriesAt(level: number): Acc {
32 return { hat: level >= 3, glasses: level >= 5, scarf: level >= 8, crown: level >= 12 }
33}
34
35/** The unlocks crossed going from one level to another. */
36export function unlocksBetween(from: number, to: number): typeof UNLOCKS[number][] {
37 return UNLOCKS.filter(u => u.level > from && u.level <= to)
38}
39
40export function nextUnlock(level: number): typeof UNLOCKS[number] | undefined {
41 return UNLOCKS.find(u => u.level > level)
42}
43
44export type Senses = {
45 now: number
46 loveUntil: number
47 sadUntil: number
48 danceUntil: number
49 ctxPercent: number
50 limitPercent: number
51 isTurnActive: boolean
52 toolsRunning: number
53}
54
55/** The mood that wins, by priority: a treat, a fresh error, heat, sleepiness, work, thought, a party, rest. */
56export function moodOf(s: Senses): Mood {
57 if (s.now < s.loveUntil) return 'love'
58 if (s.now < s.sadUntil) return 'sad'
59 if (s.ctxPercent > 80) return 'hot'
60 if (s.limitPercent > 90) return 'sleepy'
61 if (s.isTurnActive) return s.toolsRunning > 0 ? 'working' : 'thinking'
62 if (s.now < s.danceUntil) return 'dance'
63 return 'idle'
64}
65
66export const MOOD_LABEL: Record<Mood, string> = {
67 idle: 'lounging', working: 'scuttling', thinking: 'pondering', sad: 'ouch', hot: 'overheating',
68 sleepy: 'drowsy', dance: 'celebrating', love: 'fed & happy',
69}
70
71export const QUIPS: Record<Mood, readonly string[]> = {
72 idle: [
73 'Tide’s calm. What are we building?',
74 'I polished my shell while you were away.',
75 'Sideways is still forwards, technically.',
76 'Ready when you are, captain.',
77 'Counting grains of sand. 4,021… 4,022…',
78 ],
79 working: [
80 'Scuttling through your files!',
81 'Pinch, pinch — tools at work.',
82 'Carrying bytes, two claws at a time.',
83 'Hold on, digging a tunnel in the codebase.',
84 'Busy as a crab at low tide.',
85 ],
86 thinking: [
87 'Hmm… let me chew on that.',
88 'Bubbles mean thoughts. Lots of bubbles.',
89 'Consulting the ancient sea scrolls…',
90 'Thinking sideways, as one does.',
91 ],
92 sad: [
93 'Oof. That one pinched back.',
94 'A tool tripped. We’ll get it next wave.',
95 'Ow, my claw. Let’s try again.',
96 'Not every wave brings treasure.',
97 ],
98 hot: [
99 'It’s getting crowded in this context…',
100 'Phew! The window’s nearly full.',
101 'Fanning myself. Maybe compact soon?',
102 'My shell is steaming up in here.',
103 ],
104 sleepy: [
105 'Five-hour limit nearly spent… so sleepy…',
106 'Z z… wake me when the window resets.',
107 'Running on fumes and seawater.',
108 ],
109 dance: [
110 'Five in a row! Crab rave!',
111 'Streak! Click-clack goes the dance floor.',
112 'Look at us go — flawless tide!',
113 'Shimmy shimmy, pinch pinch!',
114 ],
115 love: [
116 'Nom! Thank you ♥',
117 'Best snack in the whole ocean.',
118 'My claws are full of gratitude.',
119 'Crunchy plankton, my favorite!',
120 ],
121}
122
123export function quipFor(mood: Mood, seed: number): string {
124 const list = QUIPS[mood]
125 return list[Math.abs(Math.floor(seed)) % list.length]! // every mood has quips
126}
127
128export function cleanName(v: unknown): string {
129 const s = typeof v === 'string' ? v.replace(/\s+/g, ' ').trim() : ''
130 return s ? s.slice(0, 24) : 'Pinchy'
131}
132hooks/sprite.ts 288 lines1// Clawdling's pixel crab, pure and self-contained: the terminal surface
2// module and the desktop flipbook both draw from `crabFrame`, so the two
3// surfaces show the same animation. Every cycle repeats within 24 frames.
4//
5// The crab is 16 x 8 pixels on a 30 x 8 stage (a pixel is half a terminal
6// cell, two stacked per cell). Letters name the palette:
7// W eye white P pupil s stalk / closed eye R shell H highlight
8// D shell shade c claw a arm k leg m mouth
9// b blush G glasses g glass glint Y gold J gem
10// h / i party-hat stripes S / T scarf stripes B bubble q bubble outline o bubble dot (dim)
11// O bubble dot (lit) w sweat F / f fan
12
13export type Mood = 'idle' | 'working' | 'thinking' | 'sad' | 'hot' | 'sleepy' | 'dance' | 'love'
14export type Acc = { hat: boolean; glasses: boolean; scarf: boolean; crown: boolean }
15export type Mark = { x: number; row: number; ch: string; color: string }
16export type Frame = { px: (string | null)[][]; marks: Mark[] }
17
18export const STAGE_W = 30
19export const STAGE_H = 8
20export const PERIOD = 24
21
22export const PALETTE: Record<string, string> = {
23 W: '#ffffff', P: '#1f2937', s: '#9f1239', R: '#f05a3c', H: '#ffb4a2', D: '#b8322a',
24 c: '#e2412f', a: '#c53a2c', k: '#8c2a22', m: '#4a0f0b', b: '#fda4af',
25 G: '#111827', g: '#93c5fd', Y: '#fbbf24', J: '#22d3ee', h: '#ec4899', i: '#a78bfa',
26 S: '#14b8a6', T: '#99f6e4', B: '#f8fafc', q: '#94a3b8', o: '#cbd5e1', O: '#334155', w: '#7dd3fc', F: '#fde047', f: '#f59e0b',
27}
28
29const BODY = [
30 '...WP......PW...',
31 'cc..s......s..cc',
32 'c...RRRRRRRR...c',
33 'ccaRRHHRRRRRRacc',
34 '...RRRRRRRRRR...',
35 '...DRRRRRRRRD...',
36 '....DDDDDDDD....',
37 '................',
38]
39
40const LEGS_A: [number, number][] = [[2, 6], [13, 6], [1, 7], [3, 7], [12, 7], [14, 7]]
41const LEGS_B: [number, number][] = [[3, 6], [12, 6], [2, 7], [4, 7], [11, 7], [13, 7]]
42
43const LEFT_CLAW: [number, number, string][] = [[0, 1, 'c'], [1, 1, 'c'], [0, 2, 'c'], [0, 3, 'c'], [1, 3, 'c'], [2, 3, 'a']]
44const RIGHT_CLAW: [number, number, string][] = LEFT_CLAW.map(([x, y, k]) => [15 - x, y, k])
45
46function blank(): (string | null)[][] {
47 return Array.from({ length: STAGE_H }, () => Array.from({ length: STAGE_W }, () => null as string | null))
48}
49
50/** A triangle wave 0..1..0 over `period` frames. */
51function tri(f: number, period: number): number {
52 const t = (((f % period) + period) % period) / period
53 return t < 0.5 ? t * 2 : 2 - t * 2
54}
55
56export type FrameOpts = { mood: Mood; f: number; acc: Acc }
57
58export function crabFrame({ mood, f: frame, acc }: FrameOpts): Frame {
59 const f = ((frame % PERIOD) + PERIOD) % PERIOD
60 const px = blank()
61 const marks: Mark[] = []
62
63 // Where the crab stands and how it holds itself.
64 let ox = 4
65 let leftClawDy = 0
66 let rightClawDy = 0
67 let legs = LEGS_A
68 if (mood === 'idle') ox = 4 + (f < 12 ? 0 : 1)
69 if (mood === 'working') {
70 ox = Math.round(tri(f, PERIOD) * 10)
71 legs = f % 2 === 0 ? LEGS_A : LEGS_B
72 leftClawDy = f % 4 < 2 ? 0 : -1
73 rightClawDy = f % 4 < 2 ? -1 : 0
74 }
75 if (mood === 'dance') {
76 ox = 4 + (Math.floor(f / 3) % 2 === 0 ? -1 : 1)
77 const up = Math.floor(f / 3) % 2 === 0
78 leftClawDy = up ? -1 : 0
79 rightClawDy = up ? 0 : -1
80 legs = Math.floor(f / 3) % 2 === 0 ? LEGS_A : LEGS_B
81 }
82 if (mood === 'love') {
83 leftClawDy = -1
84 rightClawDy = -1
85 }
86 if (mood === 'hot') rightClawDy = f % 4 < 2 ? -1 : 0
87 if (mood === 'sleepy') ox = 4
88 if (mood === 'thinking') ox = 3
89
90 // Every pixel drawn below lands on the stage: x + ox stays within 0..29
91 // (the widest reach is the working scuttle's right claw, 15 + 10) and y
92 // within 0..7 (a raised claw goes up to row 0).
93 const put = (x: number, y: number, k: string | null) => {
94 px[y]![x + ox] = k
95 }
96
97 // Body.
98 BODY.forEach((line, y) => {
99 for (let x = 0; x < line.length; x++) {
100 const k = line.charAt(x)
101 if (k !== '.' && k !== 'c' && k !== 'a') put(x, y, k)
102 }
103 })
104 for (const [x, y, k] of LEFT_CLAW) put(x, y + leftClawDy, k)
105 for (const [x, y, k] of RIGHT_CLAW) put(x, y + rightClawDy, k)
106 if (leftClawDy < 0) put(2, 2, 'a')
107 if (rightClawDy < 0) put(13, 2, 'a')
108 for (const [x, y] of legs) put(x, y, 'k')
109
110 // Eyes: look around when idle, follow the scuttle, close, cry or hide behind shades.
111 const blink = (mood === 'idle' || mood === 'thinking' || mood === 'dance') && f === 23
112 const look: 'in' | 'left' | 'right' =
113 mood === 'idle' ? (f < 8 ? 'in' : f < 16 ? 'left' : 'right')
114 : mood === 'working' ? (f < PERIOD / 2 ? 'right' : 'left')
115 : mood === 'thinking' ? 'right'
116 : 'in'
117 const closed = mood === 'sleepy' || blink || (mood === 'love' && f % 12 < 6)
118 if (acc.glasses && !closed) {
119 put(3, 0, 'G'); put(4, 0, f % 12 === 0 ? 'g' : 'G')
120 put(11, 0, 'G'); put(12, 0, f % 12 === 6 ? 'g' : 'G')
121 } else if (closed) {
122 put(3, 0, 's'); put(4, 0, 's'); put(11, 0, 's'); put(12, 0, 's')
123 } else if (mood === 'sad') {
124 put(3, 0, 's'); put(4, 0, 'P'); put(11, 0, 'P'); put(12, 0, 's')
125 } else {
126 const lp = look === 'left' ? 3 : 4
127 const rp = look === 'right' ? 12 : 11
128 put(3, 0, 'W'); put(4, 0, 'W'); put(11, 0, 'W'); put(12, 0, 'W')
129 put(lp, 0, 'P'); put(rp, 0, 'P')
130 }
131
132 // Mouth.
133 const happy = mood === 'dance' || mood === 'love' || (mood === 'idle' && f >= 16)
134 if (happy) {
135 put(6, 4, 'm'); put(9, 4, 'm'); put(7, 5, 'm'); put(8, 5, 'm')
136 put(4, 5, 'b'); put(11, 5, 'b')
137 } else if (mood === 'sad') {
138 put(7, 4, 'm'); put(8, 4, 'm'); put(6, 5, 'm'); put(9, 5, 'm')
139 } else if (mood === 'hot') {
140 put(7, 4, 'm'); put(8, 4, 'm'); put(7, 5, 'm'); put(8, 5, 'm')
141 } else if (mood === 'sleepy') {
142 put(7, 5, 'm')
143 } else {
144 put(7, 5, 'm'); put(8, 5, 'm')
145 }
146
147 // Headwear: the crown outranks the party hat.
148 if (acc.crown) {
149 for (const x of [5, 7, 8, 10]) put(x, 0, 'Y')
150 for (const x of [5, 6, 9, 10]) put(x, 1, 'Y')
151 put(7, 1, 'J'); put(8, 1, f % 8 < 4 ? 'J' : 'W')
152 } else if (acc.hat) {
153 put(7, 0, f % 6 < 3 ? 'Y' : 'W'); put(8, 0, 'h')
154 put(6, 1, 'h'); put(7, 1, 'i'); put(8, 1, 'h'); put(9, 1, 'i')
155 }
156 if (acc.scarf) {
157 for (let x = 4; x <= 11; x++) put(x, 6, x % 2 ? 'S' : 'T')
158 put(12, 6, 'S')
159 put(f % 8 < 4 ? 13 : 12, 7, 'T')
160 }
161
162 // Mood effects beside the crab.
163 const side = ox + 17
164 if (mood === 'thinking') {
165 // A thought bubble with an outline, its three dots lighting in turn.
166 put(16, 4, 'q')
167 put(17, 3, 'q')
168 for (let x = 19; x <= 25; x++) {
169 put(x, 0, 'q')
170 put(x, 1, 'B')
171 put(x, 2, 'B')
172 put(x, 3, 'q')
173 }
174 put(18, 1, 'q'); put(18, 2, 'q'); put(26, 1, 'q'); put(26, 2, 'q')
175 const lit = Math.floor(f / 4) % 4
176 for (const [i, x] of [[1, 20], [2, 22], [3, 24]] as const) {
177 put(x, 1, lit >= i ? 'O' : 'o')
178 put(x, 2, lit >= i ? 'O' : 'o')
179 }
180 }
181 if (mood === 'sad') {
182 // A sweat drop sliding down beside the head.
183 const y = Math.floor(f / 6) % 4
184 put(14, y, 'w')
185 if (y > 0) put(14, y - 1, null)
186 }
187 if (mood === 'hot') {
188 // Fanning with the right claw, drops flying off both sides.
189 const up = f % 4 < 2
190 if (up) {
191 put(16, 0, 'F'); put(17, 0, 'f'); put(18, 0, 'F')
192 put(16, 1, 'f'); put(17, 1, 'F')
193 } else {
194 put(17, 1, 'F'); put(18, 1, 'f'); put(19, 1, 'F')
195 put(17, 2, 'f'); put(18, 2, 'F')
196 }
197 const d = Math.floor(f / 3) % 4
198 put(-1 - d, 1 + (d % 3), 'w')
199 put(1 - d, 0, 'w')
200 }
201 if (mood === 'sleepy') {
202 const rise = Math.floor(f / 6) % 4
203 marks.push({ x: side, row: Math.max(0, 3 - rise), ch: 'z', color: '#93c5fd' })
204 if (rise >= 1) marks.push({ x: side + 2, row: Math.max(0, 3 - rise), ch: 'z', color: '#a5b4fc' })
205 if (rise >= 2) marks.push({ x: side + 4, row: Math.max(0, 2 - rise + 1), ch: 'Z', color: '#c4b5fd' })
206 }
207 if (mood === 'dance') {
208 const tw = ['✦', '✧', '⋆', '✶']
209 const hues = ['#facc15', '#f472b6', '#22d3ee', '#a78bfa']
210 for (let i = 0; i < 4; i++) {
211 if ((f + i * 3) % 6 < 3) continue
212 const x = i % 2 === 0 ? ox - 3 + i : ox + 17 + i
213 marks.push({ x: Math.max(0, x), row: i % 3, ch: tw[(i + Math.floor(f / 6)) % tw.length]!, color: hues[i]! })
214 }
215 marks.push({ x: ox + 20, row: 1 + (Math.floor(f / 4) % 2), ch: f % 12 < 6 ? '♪' : '♫', color: '#f9a8d4' })
216 }
217 if (mood === 'love') {
218 const hearts: [x: number, phase: number][] = [[ox + 17, 0], [ox + 19, 6], [ox - 2, 3], [ox + 21, 9]]
219 hearts.forEach(([x, phase], i) => {
220 const t = ((f + phase) % 12) / 12
221 const row = 3 - Math.floor(t * 4) // 0..3: t is under 1
222 marks.push({ x: Math.max(0, x), row, ch: '♥', color: i % 2 ? '#f472b6' : '#ef4444' })
223 })
224 }
225
226 // Keep marks in empty cells only.
227 const free = marks.filter(m => m.x >= 0 && m.x < STAGE_W && m.row >= 0 && m.row < STAGE_H / 2 && !px[m.row * 2]?.[m.x] && !px[m.row * 2 + 1]?.[m.x])
228 return { px, marks: free }
229}
230
231// ---------------------------------------------------------------------------
232// Terminal cells: two pixels per cell with the half blocks.
233
234export type Run = { text: string; fg?: string; bg?: string }
235
236/** Rows of runs (same fg/bg merged) for a frame: `▀` top over bottom, `▄` bottom alone. */
237export function frameRuns(fr: Frame): Run[][] {
238 const rows: Run[][] = []
239 for (let r = 0; r < STAGE_H / 2; r++) {
240 const runs: Run[] = []
241 for (let x = 0; x < STAGE_W; x++) {
242 const top = fr.px[r * 2]?.[x] ?? null
243 const bot = fr.px[r * 2 + 1]?.[x] ?? null
244 const mark = fr.marks.find(m => m.row === r && m.x === x)
245 let cell: Run
246 if (mark) cell = { text: mark.ch, fg: mark.color }
247 else if (top && bot) cell = { text: '▀', fg: PALETTE[top], bg: PALETTE[bot] }
248 else if (top) cell = { text: '▀', fg: PALETTE[top] }
249 else if (bot) cell = { text: '▄', fg: PALETTE[bot] }
250 else cell = { text: ' ' }
251 const last = runs[runs.length - 1]
252 if (last && last.fg === cell.fg && last.bg === cell.bg) last.text += cell.text
253 else runs.push(cell)
254 }
255 rows.push(runs)
256 }
257 return rows
258}
259
260// ---------------------------------------------------------------------------
261// Desktop: the same frames as SVG paths, one path per color per frame.
262
263export function frameSvg(fr: Frame, scale: number): string {
264 const byColor = new Map<string, string>()
265 for (let y = 0; y < STAGE_H; y++) {
266 let x = 0
267 const row = fr.px[y] ?? []
268 while (x < STAGE_W) {
269 const k = row[x]
270 if (!k) {
271 x++
272 continue
273 }
274 let end = x + 1
275 while (end < STAGE_W && row[end] === k) end++
276 const d = `M${x * scale} ${y * scale}h${(end - x) * scale}v${scale}h${-(end - x) * scale}z`
277 byColor.set(k, (byColor.get(k) ?? '') + d)
278 x = end
279 }
280 }
281 let out = ''
282 for (const [k, d] of byColor) out += `<path fill="${PALETTE[k] ?? '#000'}" d="${d}"/>`
283 for (const m of fr.marks) {
284 out += `<text x="${(m.x + 0.5) * scale}" y="${(m.row * 2 + 1.6) * scale}" font-size="${scale * 2}" text-anchor="middle" fill="${m.color}" font-family="'Segoe UI Symbol','Apple Symbols',sans-serif">${m.ch}</text>`
285 }
286 return out
287}
288hooks/crab.tsx 82 lines1// Clawdling on the terminal: the pixel crab in half blocks, animated by the
2// surface at 8 fps, with its name, level, XP bar and a typed-out quip beside it.
3import type { ClientModule, ClientSurface } from 'claude-code'
4
5import { STAGE_W, crabFrame, frameRuns } from './sprite.ts'
6import type { Acc, Mood } from './sprite.ts'
7
8type P = {
9 cols: number
10 mood: Mood
11 name: string
12 level: number
13 into: number
14 need: number
15 quip: string
16 acc: Acc
17 icons: string
18 status: string
19}
20type S = { f: number; quip: string; typed: number }
21
22const SHELL = '#f05a3c'
23
24/** The first frame: state at 0 and the 8 fps tick (frames and typed characters). */
25function start(surface: ClientSurface<S>, props: P): S {
26 const first = { f: 0, quip: props.quip, typed: 0 }
27 surface.setState(first)
28 surface.every(125, () => {
29 const s = surface.state! // set above, never cleared
30 surface.setState({ f: s.f + 1, quip: s.quip, typed: s.typed + 2 })
31 })
32 return first
33}
34
35const Crab: ClientModule<P, S> = (props, surface) => {
36 const st = surface.state ?? start(surface, props)
37 const typed = st.quip === props.quip ? st.typed : 0
38 if (st.quip !== props.quip) surface.setState({ f: st.f, quip: props.quip, typed: 0 })
39
40 const { Box, Text } = surface.elements
41 const rows = frameRuns(crabFrame({ mood: props.mood, f: st.f, acc: props.acc }))
42 const sideW = Math.max(0, (props.cols || 80) - STAGE_W - 2)
43
44 const ratio = props.need > 0 ? Math.max(0, Math.min(1, props.into / props.need)) : 0
45 const barW = Math.max(6, Math.min(24, sideW - 16))
46 const fill = Math.round(ratio * barW)
47 const quip = props.quip.slice(0, Math.min(props.quip.length, typed))
48 const caret = typed < props.quip.length && st.f % 2 === 0 ? '▌' : ''
49
50 return (
51 <Box flexDirection="row">
52 <Box flexDirection="column" width={STAGE_W} flexShrink={0}>
53 {rows.map((runs, r) => (
54 <Text key={`row${r}`}>
55 {runs.map((run, i) => (
56 <Text key={`c${i}`} {...(run.fg ? { color: run.fg } : {})} {...(run.bg ? { backgroundColor: run.bg } : {})}>{run.text}</Text>
57 ))}
58 </Text>
59 ))}
60 </Box>
61 {sideW >= 12 ? (
62 <Box flexDirection="column" marginLeft={2} width={sideW}>
63 <Text wrap="truncate-end">
64 <Text bold color={SHELL}>{props.name}</Text>
65 <Text color="#fbbf24" bold> Lv {props.level}</Text>
66 {props.icons ? <Text> {props.icons}</Text> : null}
67 </Text>
68 <Text wrap="truncate-end">
69 <Text color="#fb923c">{'▰'.repeat(fill)}</Text>
70 <Text color="#4b5563">{'▱'.repeat(barW - fill)}</Text>
71 <Text dimColor> {props.into}/{props.need} XP</Text>
72 </Text>
73 <Text wrap="truncate-end" italic>“{quip}{caret}”</Text>
74 <Text wrap="truncate-end" dimColor>{props.status}</Text>
75 </Box>
76 ) : null}
77 </Box>
78 )
79}
80
81export default Crab
82types/index.d.ts 20 lines1export type ClawdlingSnap = {
2 mood: 'idle' | 'working' | 'thinking' | 'sad' | 'hot' | 'sleepy' | 'dance' | 'love'
3 name: string
4 xp: number
5 level: number
6 into: number
7 need: number
8 acc: { hat: boolean; glasses: boolean; scarf: boolean; crown: boolean }
9 quip: string
10 streak: number
11 ctxPercent: number
12 limitPercent: number
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 clawdling: { snap: ClawdlingSnap | null; isHidden: boolean }
18 }
19}
20