SLOPSHOPPER

clawdling

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%…

newbandguardcommandtoasttimer
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/clawdling
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawdling
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /clawdling ⎿ clawdling: Pinchy went back to the sea. /clawdling brings them back. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Clawdling

A pixel crab companion that lives above the prompt. Its moods follow the session:

MoodWhenWhat it does
loungingidleblinks, sways, looks around, smiles now and then
scuttlinga tool is runningruns side to side, legs clicking
ponderingClaude is thinkinga thought bubble whose dots light up in turn
oucha tool just failedfrowns, a sweat drop slides down
overheatingcontext over 80 %sweats and fans itself with a paper fan
drowsy5-hour limit over 90 %eyes closed, z z Z drifting up
celebrating5+ answered turns in a row, or a level-updances, claws up, sparkles and notes
fed & happy/clawdling feedhearts 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.

  • Terminal: a Client surface module draws the 16×8-pixel crab with half blocks (▀/▄, two pixels per cell) at 8 fps.
  • Desktop: the same sprite as a 24-frame SVG flipbook (CSS step animation), with sand, bubbles and an XP bar; light and dark aware.

Commands: /clawdling shows or hides it, /clawdling feed, /clawdling stats. The ✕ button hides it too. Setting: name (default Pinchy).

Türkçe

İ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).

Source 6 files
hooks/register.tsx 304 lines
1import { 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}
304
hooks/lib/kz.ts 510 lines
1// 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> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
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}
510
hooks/clawd.ts 132 lines
1// 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}
132
hooks/sprite.ts 288 lines
1// 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}
288
hooks/crab.tsx 82 lines
1// 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
82
types/index.d.ts 20 lines
1export 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