SLOPSHOPPER

pixel-buddy

No description.

newbandspinnerprocesstimer
★ 13v?no licenseupdated 2026-10-08hamymarnie/marnie-plugins/plugins/pixel-buddy
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pixel-buddy
› 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 5h 31% 7d – ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts cache 60:00 · ctx 49% · 97k/200k · auto-accept edits

Draws

Band
5h 31% 7d –
README

Preview image

pixel-buddy

Simple Claude Code plugin that shows usage meters, and a cache timer. That's really about it.

The 5h and 7d meters also show what each window has cost in dollars, priced at API rates from every session transcript on this machine (~/.claude/projects). In the desktop app, hover or click the ⓘ after the 7d meter for a usage card: dollars left before each limit, the projected percent at reset, how many sessions are working or waiting on you, and the threads or projects that spent the window.

/plugin marketplace add hamymarnie/marnie-plugins
/plugin install pixel-buddy@marnie-plugins
Source 4 files
hooks/register.tsx 653 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Ctx, Limit, Mood, PopupView, Thread, UsageReport, WindowReport } from '../types'
5import { DONE_SECONDS, SCENE_HEIGHT, SCENE_WIDTH, sceneSvg } from './scene'
6import { parseLine, parseLines, READ_LIMIT, SLICE_MS, summarize } from './usage'
7import type { FileSummary, Listed, WindowSpec } from './usage'
8
9const mood = atom({ plugin: 'pixel-buddy', key: 'mood' } as const, 'idle' as Mood)
10const moodAt = atom({ plugin: 'pixel-buddy', key: 'moodAt' } as const, 0)
11const frame = atom({ plugin: 'pixel-buddy', key: 'frame' } as const, 0)
12const cacheAt = atom({ plugin: 'pixel-buddy', key: 'cacheAt' } as const, null as number | null)
13const now = atom({ plugin: 'pixel-buddy', key: 'now' } as const, 0)
14const minute = atom({ plugin: 'pixel-buddy', key: 'minute' } as const, 0)
15const ctx = atom({ plugin: 'pixel-buddy', key: 'ctx' } as const, null as Ctx | null)
16const limits = atom({ plugin: 'pixel-buddy', key: 'limits' } as const, [] as Limit[])
17const isPinned = atom({ plugin: 'pixel-buddy', key: 'isPinned' } as const, false)
18const view = atom({ plugin: 'pixel-buddy', key: 'view' } as const, { window: 'five_hour', group: 'threads' } as PopupView)
19
20// The "task complete" party plays once, then Claude relaxes for a while before idling.
21const RELAX_MS = 4 * 60_000
22
23// Transcripts are re-priced this often; a finished turn or Refresh asks sooner.
24const SCAN_MS = 60_000
25const WINDOW_MS = { five_hour: 5 * 3_600_000, seven_day: 7 * 86_400_000 } as const
26// The hover group joining the info icon and the usage card it reveals.
27const USAGE_SCOPE = 'pixel-buddy-usage'
28
29const GREEN = '#4cc35a'
30const AMBER = '#e5a33a'
31const RED = '#e5534b'
32const levelColor = (p: number) => (p >= 90 ? RED : p >= 70 ? AMBER : GREEN)
33
34function span(ms: number): string {
35  const m = Math.max(0, Math.round(ms / 60_000))
36  const d = Math.floor(m / 1440)
37  const h = Math.floor((m % 1440) / 60)
38  if (d) return `${d}d ${h}h`
39  if (h) return `${h}h ${m % 60}m`
40  return `${m}m`
41}
42
43function clockText(ms: number): string {
44  const s = Math.max(0, Math.ceil(ms / 1000))
45  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
46}
47
48function ringSvg(percent: number, icon: 'clock' | 'cal'): string {
49  const C = 2 * Math.PI * 12
50  const p = Math.min(100, Math.max(0, percent)) / 100
51  const glyph =
52    icon === 'clock'
53      ? '<circle cx="15" cy="15" r="5.5" fill="none" stroke="#a0a0a0" stroke-width="1.6"/><path d="M15 12v3.4h2.6" fill="none" stroke="#a0a0a0" stroke-width="1.6" stroke-linecap="round"/>'
54      : '<rect x="10" y="11" width="10" height="9" rx="1.5" fill="none" stroke="#a0a0a0" stroke-width="1.6"/><path d="M10 14h10M12.5 9.5v3M17.5 9.5v3" stroke="#a0a0a0" stroke-width="1.6" stroke-linecap="round"/>'
55  return (
56    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30" width="30" height="30">` +
57    `<circle cx="15" cy="15" r="12" fill="none" stroke="#3a3a3a" stroke-width="2.6"/>` +
58    (p > 0
59      ? `<circle cx="15" cy="15" r="12" fill="none" stroke="${levelColor(percent)}" stroke-width="2.6" stroke-linecap="round" ` +
60        `stroke-dasharray="${(C * p).toFixed(2)} ${C.toFixed(2)}" transform="rotate(-90 15 15)"/>`
61      : '') +
62    glyph +
63    `</svg>`
64  )
65}
66
67// A pixel hourglass: sand on top drains as the cache TTL runs out.
68function hourglassSvg(frac: number): string {
69  const sand = frac > 0 ? (frac > 0.2 ? GREEN : AMBER) : '#555'
70  const frame = '#9a9a9a'
71  let px = ''
72  const r = (x: number, y: number, w: number, h: number, c: string) => {
73    px += `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${c}"/>`
74  }
75  r(0, 0, 7, 1, frame)
76  r(0, 8, 7, 1, frame)
77  r(1, 1, 1, 2, frame); r(5, 1, 1, 2, frame); r(2, 3, 1, 1, frame); r(4, 3, 1, 1, frame)
78  r(3, 4, 1, 1, frame)
79  r(2, 5, 1, 1, frame); r(4, 5, 1, 1, frame); r(1, 6, 1, 2, frame); r(5, 6, 1, 2, frame)
80  const top = Math.ceil(2 * frac)
81  if (top > 0) r(2, 3 - top, 3, top, sand)
82  r(2, 7, 3, 1, sand)
83  if (frac < 1 && frac > 0) r(2, 6, 3, 1, sand)
84  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 7 9" width="14" height="18" shape-rendering="crispEdges">${px}</svg>`
85}
86
87function ctxBarSvg(percent: number): string {
88  let px = ''
89  const lit = Math.round(percent / 10)
90  for (let i = 0; i < 10; i++) {
91    px += `<rect x="${i * 4}" y="0" width="3" height="8" fill="${i < lit ? levelColor(percent) : '#3a3a3a'}"/>`
92  }
93  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 39 8" width="39" height="8" shape-rendering="crispEdges">${px}</svg>`
94}
95
96async function setMood($: EngineInterface, m: Mood) {
97  const t = await $.clock.now()
98  await update($, moodAt, () => t)
99  await update($, mood, () => m)
100  await update($, frame, () => t)
101  // The browser animates each scene; the band redraws only when the scene changes.
102  if (m === 'done') {
103    $.clock.after(DONE_SECONDS * 1000 + 50, () => void tickFrame($).catch(() => {}))
104    $.clock.after(DONE_SECONDS * 1000 + RELAX_MS + 50, () => void tickFrame($).catch(() => {}))
105  }
106}
107
108async function tickFrame($: EngineInterface) {
109  const t = await $.clock.now()
110  await update($, frame, () => t)
111}
112
113// Which scene shows `elapsed` seconds into a mood: done hands off to relax, relax to idle.
114function sceneFor(m: Mood, elapsed: number): { mood: Mood; t: number } {
115  const relaxS = RELAX_MS / 1000
116  if (m === 'done') {
117    if (elapsed < DONE_SECONDS) return { mood: 'done', t: elapsed }
118    return sceneFor('relax', elapsed - DONE_SECONDS)
119  }
120  if (m === 'relax' && elapsed >= relaxS) return { mood: 'idle', t: elapsed - relaxS }
121  return { mood: m, t: elapsed }
122}
123
124function money(usd: number): string {
125  if (usd >= 100) return `$${Math.round(usd).toLocaleString('en-US')}`
126  return `$${usd.toFixed(2)}`
127}
128
129function bigTokens(n: number): string {
130  if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`
131  if (n >= 1_000_000) return `${Math.round(n / 1_000_000)}M`
132  return `${Math.round(n / 1000)}k`
133}
134
135const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
136
137// Where each window began: its reset less its length, or a rolling window before a reading.
138function windowSpecs(rl: Limit[], now: number): WindowSpec[] {
139  return (['five_hour', 'seven_day'] as const).map(kind => {
140    const at = rl.find(l => l.kind === kind)?.resetsAt
141    const reset = at ? Date.parse(at) : NaN
142    return { kind, start: Number.isFinite(reset) && reset > now ? reset - WINDOW_MS[kind] : now - WINDOW_MS[kind] }
143  })
144}
145
146// Dollars per percent of the window, from what it has cost and how full it is.
147function outlook(lim: Limit | undefined, w: WindowReport | undefined, now: number) {
148  if (!lim || !w) return undefined
149  const len = WINDOW_MS[lim.kind as keyof typeof WINDOW_MS] ?? 0
150  const reset = lim.resetsAt ? Date.parse(lim.resetsAt) : NaN
151  const left = Number.isFinite(reset) ? Math.max(0, reset - now) : undefined
152  const perPct = lim.percentUsed >= 1 && w.usd > 0 ? w.usd / lim.percentUsed : undefined
153  const elapsed = left === undefined || !len ? 0 : (len - left) / len
154  return {
155    left,
156    perPct,
157    toGo: perPct === undefined ? undefined : Math.max(0, 100 - lim.percentUsed) * perPct,
158    atReset: elapsed > 0.02 ? Math.round(lim.percentUsed / elapsed) : undefined,
159  }
160}
161
162function barSvg(percent: number): string {
163  const p = Math.min(100, Math.max(0, percent))
164  return (
165    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 8" width="400" height="8">` +
166    `<rect x="0" y="0" width="400" height="8" rx="4" fill="#3a3a3a"/>` +
167    (p > 0 ? `<rect x="0" y="0" width="${Math.max(8, p * 4).toFixed(1)}" height="8" rx="4" fill="${levelColor(p)}"/>` : '') +
168    `</svg>`
169  )
170}
171
172function byProject(threads: Thread[]): Thread[] {
173  const out = new Map<string, Thread>()
174  for (const t of threads) {
175    const key = t.project || '(no project)'
176    const p = out.get(key)
177    if (!p) out.set(key, { ...t, id: key, title: key })
178    else {
179      p.usd += t.usd
180      p.tokens += t.tokens
181      p.firstAt = Math.min(p.firstAt, t.firstAt)
182      p.lastAt = Math.max(p.lastAt, t.lastAt)
183    }
184  }
185  return [...out.values()].sort((a, b) => b.usd - a.usd)
186}
187
188// The scan's own state: it lives as long as the module, so a reload re-reads once.
189const files = new Map<string, FileSummary>()
190let projectsDir: string | undefined
191let host: string | undefined
192let isRunning = false
193let pending: string[] = []
194let listing = new Map<string, Listed>()
195let windows: WindowSpec[] = []
196
197// The report the band and the usage card draw; written once each scan is whole.
198const report = atom({ plugin: 'pixel-buddy', key: 'report' } as const, null as UsageReport | null)
199
200async function resolveDir($: EngineInterface): Promise<string | undefined> {
201  if (projectsDir) return projectsDir
202  const stored = await $.store.get('projectsDir')
203  if (typeof stored === 'string' && (await $.fs.exists(stored).catch(() => false))) return (projectsDir = stored)
204  const isWin = /^[A-Za-z]:/.test(await $.session.cwd())
205  const argv = isWin
206    ? ['cmd', '/d', '/c', 'if defined CLAUDE_CONFIG_DIR (echo %CLAUDE_CONFIG_DIR%) else (echo %USERPROFILE%\\.claude)']
207    : ['sh', '-c', 'printf "%s\\n" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"']
208  const r = await $.process.run(argv, { timeoutMs: 5000 }).catch(() => undefined)
209  const dir = r?.stdout.trim()
210  if (!dir) return undefined
211  return setProjectsDir($, `${dir}${isWin ? '\\' : '/'}projects`)
212}
213
214async function setProjectsDir($: EngineInterface, dir: string) {
215  if (dir === projectsDir) return dir
216  projectsDir = dir
217  await $.store.set('projectsDir', dir)
218  return dir
219}
220
221async function hostName($: EngineInterface) {
222  if (host !== undefined) return host
223  const r = await $.process.run(['hostname'], { timeoutMs: 5000 }).catch(() => undefined)
224  host = r?.stdout.trim().split('.')[0] ?? ''
225  return host
226}
227
228async function readWhole($: EngineInterface, path: string, size: number, f: FileSummary) {
229  const seen = new Set<string>()
230  const carry = { rest: '' }
231  if (size <= READ_LIMIT) {
232    parseLines(await $.fs.read(path), f, carry, seen)
233  } else {
234    const isWin = /^[A-Za-z]:/.test(path)
235    const argv = isWin ? ['cmd', '/d', '/c', 'type', path] : ['cat', path]
236    for await (const piece of $.process.spawn({ argv })) {
237      if (piece.stream === 'stdout') parseLines(piece.text, f, carry, seen)
238    }
239  }
240  parseLine(carry.rest, f, seen)
241}
242
243// Lists every transcript touched since `since`: each main session file, and the
244// subagent transcripts under its folder, billed to that session.
245async function listFiles($: EngineInterface, dir: string, since: number) {
246  const sep = dir.includes('\\') ? '\\' : '/'
247  const out: Listed[] = []
248  const projects = await $.fs.list(dir).catch(() => [])
249  for (const p of projects) {
250    if (p.kind !== 'dir') continue
251    const pdir = `${dir}${sep}${p.name}`
252    const entries = await $.fs.list(pdir).catch(() => [])
253    for (const e of entries) {
254      if (e.kind !== 'file' || !e.name.endsWith('.jsonl') || e.mtimeMs < since) continue
255      const session = e.name.slice(0, -6)
256      out.push({ path: `${pdir}${sep}${e.name}`, size: e.size, mtimeMs: e.mtimeMs, session, isMain: true })
257      if (!entries.some(x => x.kind === 'dir' && x.name === session)) continue
258      const sdir = `${pdir}${sep}${session}${sep}subagents`
259      for (const s of await $.fs.list(sdir).catch(() => [])) {
260        if (s.kind !== 'file' || !s.name.endsWith('.jsonl') || s.mtimeMs < since) continue
261        out.push({ path: `${sdir}${sep}${s.name}`, size: s.size, mtimeMs: s.mtimeMs, session, isMain: false })
262      }
263    }
264  }
265  return out
266}
267
268// The transcript a classic hook names is the surest way to the projects folder.
269async function noteTranscript($: EngineInterface, transcriptPath: string | undefined) {
270  if (!transcriptPath) return
271  const cut = transcriptPath.split(/[\\/]/)
272  if (cut.length < 3) return
273  const sep = transcriptPath.includes('\\') ? '\\' : '/'
274  await setProjectsDir($, cut.slice(0, -2).join(sep))
275}
276
277// Re-reads the transcripts that changed since the last scan, a slice per dispatch,
278// and writes the report once the last file is in. Resolves true when that write is
279// done or coming.
280async function scan($: EngineInterface, specs: WindowSpec[]): Promise<boolean> {
281  if (isRunning) return false
282  isRunning = true
283  let isHandedOff = false
284  try {
285    const dir = await resolveDir($)
286    if (!dir) return false
287    const now = await $.clock.now()
288    windows = specs
289    const listed = await listFiles($, dir, Math.min(...specs.map(w => w.start)))
290    listing = new Map(listed.map(l => [l.path, l]))
291    for (const path of files.keys()) if (!listing.has(path)) files.delete(path)
292    pending = listed
293      .filter(l => {
294        const f = files.get(l.path)
295        if (!f) return true
296        if (f.size === l.size && f.mtimeMs === l.mtimeMs) return false
297        // A big file still being written is re-read at most every five minutes.
298        return l.size <= READ_LIMIT || now - f.parsedAt > 5 * 60_000
299      })
300      .sort((a, b) => b.mtimeMs - a.mtimeMs)
301      .map(l => l.path)
302    isHandedOff = await scanSlice($)
303    return true
304  } finally {
305    if (!isHandedOff) isRunning = false
306  }
307}
308
309// Resolves true when it handed the rest of the files to a later timer.
310async function scanSlice($: EngineInterface): Promise<boolean> {
311  const began = await $.clock.now()
312  while (pending.length) {
313    const path = pending.shift()!
314    const l = listing.get(path)
315    if (!l) continue
316    const f: FileSummary = {
317      size: l.size,
318      mtimeMs: l.mtimeMs,
319      parsedAt: began,
320      session: l.session,
321      isMain: l.isMain,
322      entries: [],
323      last: undefined,
324    }
325    try {
326      await readWhole($, path, l.size, f)
327      files.set(path, f)
328    } catch {
329      // Unreadable now (locked, vanished): keep what was there and retry next scan.
330    }
331    if ((await $.clock.now()) - began > SLICE_MS && pending.length) {
332      $.clock.after(50, () => {
333        scanSlice($).then(
334          isLater => void (isLater || (isRunning = false)),
335          () => void (isRunning = false),
336        )
337      })
338      return true
339    }
340  }
341  const t = await $.clock.now()
342  const r = summarize(files, windows, t, await hostName($))
343  // Written together, so the band redraws once for the new report and its clock.
344  await update($, report, () => r)
345  await update($, minute, () => t)
346  return false
347}
348
349// Prices every transcript on the machine against the windows the limits name.
350async function rescan($: EngineInterface): Promise<boolean> {
351  return scan($, windowSpecs(await read($, limits), await $.clock.now()))
352}
353
354// The band's once-a-minute redraw: a rescan, which moves the clock when it writes the
355// report; when no scan runs, the clock alone.
356async function tickMinute($: EngineInterface) {
357  const isWriting = await rescan($).catch(() => false)
358  if (!isWriting) {
359    const t = await $.clock.now()
360    await update($, minute, () => t)
361  }
362}
363
364const kTokens = (n: number) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : `${Math.round(n / 1000)}k`)
365
366export const register: Register = (on, options) => {
367  const ttlMs = options.cacheTtl === '5m' ? 5 * 60_000 : 60 * 60_000
368
369  on('session.start', async ($, e, next) => {
370    const u = await $.session.usage()
371    await update($, ctx, () => u.context)
372    await update($, limits, () => u.rateLimits)
373    const t0 = await $.clock.now()
374    await update($, frame, () => t0)
375    await update($, minute, () => t0)
376
377    // Keep "resets in" and the prices fresh, and tick the cache countdown while it is
378    // warm (only the model picker's row reads that one, so the band is left alone).
379    await update($, moodAt, () => t0)
380    $.clock.every(1_000, async () => {
381      const at = await read($, cacheAt)
382      if (at === null) return
383      const t = await $.clock.now()
384      if (t - at < ttlMs + 2_000) await update($, now, () => t)
385    })
386
387    // Price every transcript on the machine once the session is up, then keep it fresh.
388    $.clock.after(1_500, () => void rescan($).catch(() => {}))
389    $.clock.every(SCAN_MS, () => void tickMinute($).catch(() => {}))
390
391    return next(e)
392  })
393
394  // A classic hook names this session's transcript, and so the folder they all live in.
395  on('classic.SessionStart', async ($, e, next) => {
396    await noteTranscript($, e.transcript_path).catch(() => {})
397    return next(e)
398  }).catch(($, e, next) => next(e))
399  on('classic.UserPromptSubmit', async ($, e, next) => {
400    await noteTranscript($, e.transcript_path).catch(() => {})
401    return next(e)
402  }).catch(($, e, next) => next(e))
403
404  // Each response refreshes the prompt cache and reports the live usage.
405  on('session.measure', async ($, e, next) => {
406    await update($, ctx, () => e.context)
407    // Only a changed reading is written: each write redraws the band.
408    if (e.rateLimits.length && JSON.stringify(e.rateLimits) !== JSON.stringify(await read($, limits))) {
409      await update($, limits, () => e.rateLimits)
410    }
411    if (e.changed.includes('context')) {
412      const t = await $.clock.now()
413      await update($, cacheAt, () => t)
414      await update($, now, () => t)
415    }
416    return next(e)
417  })
418
419  on('turn.start', async ($, e, next) => {
420    await setMood($, 'working')
421    return next(e)
422  })
423
424  // Task complete: the party plays once, then Claude relaxes, then idles. The hand-offs
425  // are worked out from the clock (see sceneFor); timers redraw the band at each one,
426  // and the minute tick catches any a reload lost.
427  on('turn.complete', async ($, e, next) => {
428    if (e.agentId) return next(e)
429    await setMood($, e.isAborted || e.reason === 'error' ? 'idle' : 'done')
430    $.clock.after(2_000, () => void rescan($).catch(() => {}))
431    return next(e)
432  })
433
434  // The band above the prompt: two usage rings on the left, the mascot on the right.
435  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
436    if (e.props.hasSurvey) return next(e)
437    const ui = $.ui.resolve(e)
438    const { Box, Text } = ui
439    const rl = await read($, limits)
440    const t = await read($, minute)
441    const stored = await read($, mood)
442    const m: Mood = e.props.isWorking ? 'working' : stored === 'working' ? 'idle' : stored
443    // Read so the hand-off timers redraw the band; the scene itself is placed by the
444    // clock, so a redraw (which reloads the drawing) picks the animation up mid-step.
445    await read($, frame)
446    const tNow = await $.clock.now()
447    const since = m === stored ? await read($, moodAt) : tNow
448    const { mood: shownMood, t: sceneT } = sceneFor(m, Math.max(0, (tNow - since) / 1000))
449    const shown = [
450      { kind: 'five_hour', label: '5h', icon: 'clock' as const },
451      { kind: 'seven_day', label: '7d', icon: 'cal' as const },
452    ].map(s => ({ ...s, lim: rl.find(l => l.kind === s.kind) }))
453
454    const r = await read($, report)
455    const caption = (lim: Limit | undefined, label: string) =>
456      lim?.resetsAt && t ? `${label} · resets in ${span(Date.parse(lim.resetsAt) - t)}` : `${label} · waiting for a reading`
457    const pctText = (lim: Limit | undefined) => (lim ? `${Math.round(lim.percentUsed)}%` : '–')
458    const usdText = (kind: string) => {
459      const w = r?.[kind as 'five_hour' | 'seven_day']
460      return w ? money(w.usd) : undefined
461    }
462
463    if (e.surface !== 'desktop' && e.surface !== 'vscode') {
464      return (
465        <Box>
466          <Text dimColor>
467            {shown.map(s => [s.label, pctText(s.lim), usdText(s.kind)].filter(Boolean).join(' ')).join('   ')}
468          </Text>
469        </Box>
470      )
471    }
472    const { Svg, Button, Select } = $.ui.resolve(e as typeof e & { surface: 'desktop' })
473    const pinned = await read($, isPinned)
474    const v = await read($, view)
475
476    // The usage card: each window's outlook, then what spent it.
477    const limitBlock = (kind: 'five_hour' | 'seven_day', title: string) => {
478      const lim = rl.find(l => l.kind === kind)
479      const o = outlook(lim, r?.[kind], t)
480      const parts = [
481        o?.left !== undefined ? `${span(o.left)} left` : undefined,
482        o?.toGo !== undefined ? `≈${money(o.toGo)} to go` : r?.[kind] ? `${money(r[kind]!.usd)} spent` : undefined,
483        o?.atReset !== undefined ? `~${o.atReset}% at reset` : undefined,
484      ].filter(Boolean)
485      return (
486        <Box flexDirection="column">
487          <Box flexDirection="row" justifyContent="space-between">
488            <Text bold>{title}</Text>
489            <Text bold color={lim ? levelColor(lim.percentUsed) : undefined}>{pctText(lim)}</Text>
490          </Box>
491          <Svg source={barSvg(lim?.percentUsed ?? 0)} alt={`${title} used`} />
492          <Text dimColor>{parts.length ? parts.join(' · ') : 'waiting for a reading'}</Text>
493        </Box>
494      )
495    }
496    const w = r?.[v.window]
497    const winLim = rl.find(l => l.kind === v.window)
498    const perPct = outlook(winLim, w, t)?.perPct
499    // The band shows the top `maxRows` rows and scrolls the rest: the meters row and
500    // the card's fixed rows (about 16) come first, then two rows a thread.
501    const fits = Math.floor((e.props.maxRows - 16) / 2)
502    const rows = w ? (v.group === 'threads' ? w.threads : byProject(w.threads)).slice(0, Math.max(1, Math.min(6, fits))) : []
503    const winLabel = v.window === 'five_hour' ? '5h' : '7d'
504    const ago = r ? Math.max(0, Math.round((t - r.at) / 60_000)) : undefined
505    const card = (
506      <Box
507        width={Math.max(30, Math.min(58, e.props.bodyColumns - 2))}
508        marginTop={1}
509        flexDirection="column"
510        paddingX={2}
511        borderStyle="round"
512        borderColor="#3a3a3a"
513        backgroundColor="#1c1c1c"
514        // Shown while the icon or the card is hovered; a click on the icon pins it open.
515        display={pinned ? 'flex' : 'none'}
516        hover={pinned ? undefined : { scope: USAGE_SCOPE, display: 'flex' }}
517      >
518        <Box flexDirection="row" justifyContent="space-between" alignItems="center">
519          <Text dimColor>Usage</Text>
520          {pinned ? (
521            <Button key="pb-close" label="✕" plain role="dismiss" onPress={() => update($, isPinned, () => false)} />
522          ) : null}
523        </Box>
524        {limitBlock('five_hour', '5h limit')}
525        {limitBlock('seven_day', '7d limit')}
526        <Text>
527          {r ? `Sessions: ${r.working} working · ${r.yourTurn} your turn` : 'Reading transcripts…'}
528        </Text>
529        <Box flexDirection="row" justifyContent="space-between" alignItems="center">
530          <Box flexDirection="row" gap={1}>
531            <Select
532              key="pb-group"
533              options={[
534                { value: 'threads', label: 'Threads' },
535                { value: 'projects', label: 'Projects' },
536              ]}
537              value={v.group}
538              onSelect={g => update($, view, x => ({ ...x, group: g === 'projects' ? ('projects' as const) : ('threads' as const) }))}
539            />
540            <Select
541              key="pb-window"
542              options={[
543                { value: 'five_hour', label: '5h window' },
544                { value: 'seven_day', label: '7d window' },
545              ]}
546              value={v.window}
547              onSelect={k => update($, view, x => ({ ...x, window: k === 'seven_day' ? ('seven_day' as const) : ('five_hour' as const) }))}
548            />
549          </Box>
550          <Box flexDirection="row" gap={1} alignItems="center">
551            <Text dimColor>{ago === undefined ? '' : ago === 0 ? 'now' : `${ago}m ago`}</Text>
552            <Button key="pb-refresh" label="↻ Refresh" plain onPress={() => void rescan($).catch(() => {})} />
553          </Box>
554        </Box>
555        <Box flexDirection="row" justifyContent="space-between">
556          <Text dimColor bold>{`${v.group.toUpperCase()} · ${winLabel.toUpperCase()} WINDOW`}</Text>
557          <Text bold>{w ? money(w.usd) : '–'}</Text>
558        </Box>
559        {rows.length ? (
560          <Box flexDirection="column">
561            {rows.map(th => (
562              <Box key={`pb-${th.id}`} flexDirection="column">
563                <Box flexDirection="row" justifyContent="space-between">
564                  <Text>{clip(th.title.replace(/\s+/g, ' '), 44)}</Text>
565                  <Text bold>{money(th.usd)}</Text>
566                </Box>
567                <Text dimColor>
568                  {[
569                    perPct ? `≈${(th.usd / perPct).toFixed(1)}%` : undefined,
570                    `${bigTokens(th.tokens)} tok`,
571                    span(Math.max(60_000, th.lastAt - th.firstAt)),
572                    r?.host || undefined,
573                    v.group === 'threads' ? th.project || undefined : undefined,
574                  ]
575                    .filter(Boolean)
576                    .join(' · ')}
577                </Text>
578              </Box>
579            ))}
580          </Box>
581        ) : (
582          <Text dimColor>{r ? `Nothing spent in this ${winLabel} window yet` : 'Pricing every session on this machine…'}</Text>
583        )}
584        <Text dimColor>{"≈ API-rate cost of this machine's transcripts"}</Text>
585      </Box>
586    )
587
588    // The card sits in the band's own flow below the meters (a placed Box would be
589    // clipped to the band), so the band grows to hold it while it shows. Below, not
590    // above: a band taller than its rows scrolls from the top, and a card above would
591    // push the icon out from under the pointer, ending the hover that shows it.
592    return (
593      <Box flexDirection="column" paddingX={1}>
594        <Box flexDirection="row" alignItems="center" justifyContent="space-between">
595          <Box flexDirection="row" alignItems="center" gap={3}>
596            {shown.map(s => (
597              <Box key={s.kind} flexDirection="row" alignItems="center" gap={1}>
598                <Svg source={ringSvg(s.lim?.percentUsed ?? 0, s.icon)} alt={`${s.label} usage ring`} width={30} height={30} />
599                <Text bold>{pctText(s.lim)}</Text>
600                {usdText(s.kind) ? <Text color={GREEN}>{usdText(s.kind)}</Text> : null}
601                <Text dimColor>{caption(s.lim, s.label)}</Text>
602              </Box>
603            ))}
604            <Box flexDirection="row" alignItems="center" hover={{ scope: USAGE_SCOPE }}>
605              <Button key="pb-info" label="ⓘ" plain dimColor onPress={() => update($, isPinned, x => !x)} />
606            </Box>
607          </Box>
608          <Svg
609            source={sceneSvg(shownMood, sceneT)}
610            isInteractive
611            alt={`Claude is ${shownMood === 'done' ? 'celebrating' : shownMood === 'relax' ? 'relaxing' : shownMood}`}
612            width={SCENE_WIDTH}
613            height={SCENE_HEIGHT}
614          />
615        </Box>
616        {card}
617      </Box>
618    )
619  })
620
621  // Beside the model picker: prompt-cache countdown and context fill.
622  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
623    const { Box, Text } = $.ui.resolve(e)
624    const at = await read($, cacheAt)
625    const tNow = await read($, now)
626    const c = await read($, ctx)
627    const left = at === null ? 0 : ttlMs - (tNow - at)
628    const warm = at !== null && left > 0
629    const cacheLabel = at === null ? 'cache –' : warm ? `cache ${clockText(left)}` : 'cache cold'
630    const cacheColor = !warm ? undefined : left < 60_000 ? AMBER : GREEN
631    const pct = c?.percent
632    const ctxLabel =
633      pct === undefined ? 'ctx –' : `ctx ${pct}%${c?.tokens ? ` · ${kTokens(c.tokens)}/${kTokens(c.window)}` : ''}`
634    const modes = e.props.modes.join(' & ')
635
636    if (e.surface !== 'desktop' && e.surface !== 'vscode') {
637      return <Text dimColor>{[cacheLabel, ctxLabel, modes].filter(Boolean).join('  ·  ')}</Text>
638    }
639    const { Svg } = $.ui.resolve(e as typeof e & { surface: 'desktop' })
640
641    return (
642      <Box flexDirection="row" alignItems="center" gap={1}>
643        <Svg source={hourglassSvg(warm ? left / ttlMs : 0)} alt="prompt cache timer" width={14} height={18} />
644        <Text color={cacheColor} dimColor={!warm}>{cacheLabel}</Text>
645        <Text dimColor>·</Text>
646        <Svg source={ctxBarSvg(pct ?? 0)} alt="context used" width={39} height={8} />
647        <Text dimColor>{ctxLabel}</Text>
648        {modes ? <Text dimColor>{`· ${modes}`}</Text> : null}
649      </Box>
650    )
651  })
652}
653
hooks/scene.ts 669 lines
1// Pixel-art scenes for the mascot: every pixel is a <rect> in a 100x18 grid drawn at
2// 3x, and the motion comes from keyframe tracks. `sceneSvg` hands the tracks to the
3// browser as CSS animations, so the band need not redraw to animate (a redraw every
4// frame resets the hover and press of everything else drawn in it); `sceneFrameSvg`
5// evaluates them here into one still frame at time `t`.
6
7import type { Mood } from '../types'
8
9// The band's own color, painted behind the scene.
10export const BAND_BG = '#212121'
11
12const W = 100
13const H = 18
14
15const PAL: Record<string, string> = {
16  o: '#d97757', // claude orange
17  O: '#b65a3c', // orange shade
18  e: '#1a1a1a', // eyes
19  K: '#262626',
20  k: '#121212',
21  w: '#f4efe4',
22  y: '#f7dc6f',
23  Y: '#e8a33d',
24  t: '#c9a46a',
25  b: '#8a5a2b',
26  B: '#5b3a1c',
27  g: '#8a8a8a',
28  G: '#4a4a4a',
29  c: '#7fd6e8',
30  C: '#9fd8f0',
31  n: '#5fbf6a',
32  N: '#2f7d3a',
33  r: '#e5534b',
34  R: '#a8322c',
35  p: '#f08fb0',
36  u: '#5a8fe6',
37  U: '#2d4f9e',
38  V: '#1b2c5c',
39  l: '#c3c7cf',
40  L: '#7d838c',
41  P: '#efe8d6',
42  s: '#3a2a52',
43  m: '#7d3b63',
44  d: '#d9673f',
45  f: '#f2a14a',
46  h: '#ffd27a',
47  v: '#9b6be0',
48}
49
50const fill = (c: string) => PAL[c] ?? c
51const rect = (x: number, y: number, w: number, h: number, c: string) =>
52  `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill(c)}"/>`
53
54// An ASCII sprite: one char per pixel, '.' is transparent; runs merge into one rect.
55function spr(rows: string[], x = 0, y = 0): string {
56  let out = ''
57  rows.forEach((row, j) => {
58    let i = 0
59    while (i < row.length) {
60      const c = row[i]
61      if (c === '.' || c === ' ') {
62        i++
63        continue
64      }
65      let k = i
66      while (k < row.length && row[k] === c) k++
67      out += rect(x + i, y + j, k - i, 1, c)
68      i = k
69    }
70  })
71  return out
72}
73
74// Deterministic noise so the art is the same on every frame.
75const rnd = (n: number) => {
76  const s = Math.sin(n * 127.1 + 311.7) * 43758.5453
77  return s - Math.floor(s)
78}
79
80// ---------- keyframe tracks ----------
81
82type Pose = { tx?: number; ty?: number; o?: number; sx?: number; sy?: number }
83type Track = [number, Pose][]
84const BASE: Required<Pose> = { tx: 0, ty: 0, o: 1, sx: 1, sy: 1 }
85
86const KF: Record<string, Track> = {
87  bob: [[0, { ty: 0 }], [50, { ty: -1 }], [100, { ty: 0 }]],
88  hop: [[0, { ty: 0 }], [50, { ty: -3 }], [100, { ty: 0 }]],
89  wave: [[0, { ty: 0 }], [50, { ty: -2 }], [100, { ty: 0 }]],
90  blink: [[0, { o: 1 }], [92, { o: 1 }], [94, { o: 0 }], [97, { o: 0 }], [100, { o: 1 }]],
91  sweep: [[0, { tx: 0 }], [50, { tx: -2 }], [100, { tx: 0 }]],
92  dust: [[0, { tx: 0, ty: 0, o: 1 }], [100, { tx: -9, ty: -3, o: 0 }]],
93  tw: [[0, { o: 0 }], [30, { o: 1 }], [60, { o: 1 }], [100, { o: 0 }]],
94  rise: [[0, { tx: 0, ty: 0, o: 0 }], [15, { o: 1 }], [100, { tx: 0, ty: -10, o: 0 }]],
95  riseR: [[0, { tx: 0, ty: 0, o: 0 }], [15, { o: 1 }], [100, { tx: 4, ty: -9, o: 0 }]],
96  walk: [[0, { tx: -36 }], [45, { tx: 0 }], [50, { tx: 0 }], [95, { tx: -36 }], [100, { tx: -36 }]],
97  look: [[0, { tx: 1 }], [50, { tx: -1 }], [100, { tx: -1 }]],
98  scan: [[0, { tx: -1 }], [50, { tx: 1 }], [100, { tx: -1 }]],
99  grow: [[0, { sx: 0 }], [70, { sx: 1 }], [100, { sx: 1 }]],
100  growY: [[0, { sy: 0 }], [60, { sy: 1 }], [100, { sy: 1 }]],
101  pull: [[0, { ty: 9 }], [12, { ty: 0 }], [100, { ty: 0 }]],
102  fly: [[0, { tx: 0, ty: 0, o: 1 }], [100, { tx: -14, ty: -12, o: 0 }]],
103  dispatch: [[0, { tx: 0, o: 0 }], [6, { o: 1 }], [88, { o: 1 }], [100, { tx: -48, o: 0 }]],
104  fall: [[0, { tx: 0, ty: -4 }], [50, { tx: 2, ty: 10 }], [100, { tx: -1, ty: 24 }]],
105  dance: [
106    [0, { tx: 0, ty: 0 }], [12, { tx: 0, ty: -4 }], [25, { tx: 0, ty: 0 }], [37, { tx: 0, ty: -4 }], [50, { tx: 0, ty: 0 }],
107    [62, { tx: -2, ty: -1 }], [75, { tx: 2, ty: 0 }], [87, { tx: -2, ty: -1 }], [100, { tx: 0, ty: 0 }],
108  ],
109  sink: [[0, { ty: 0 }], [100, { ty: 3 }]],
110  swing: [[0, { tx: -1 }], [50, { tx: 1 }], [100, { tx: -1 }]],
111  on: [[0, { o: 1 }], [50, { o: 0 }], [100, { o: 0 }]],
112  off: [[0, { o: 0 }], [50, { o: 1 }], [100, { o: 1 }]],
113  leap: [
114    [0, { tx: 0, ty: 0, o: 0 }], [78, { tx: 0, ty: 0, o: 0 }], [82, { tx: -2, ty: -4, o: 1 }],
115    [88, { tx: -4, ty: -5, o: 1 }], [94, { tx: -6, ty: 0, o: 0 }], [100, { tx: 0, ty: 0, o: 0 }],
116  ],
117  drift: [[0, { tx: 0, ty: 0 }], [100, { tx: -70, ty: -2 }]],
118  zoom: [[0, { tx: 0 }], [100, { tx: -72 }]],
119  flip: [[0, { sx: 1 }], [60, { sx: 1 }], [70, { sx: 0.5 }], [80, { sx: -0.5 }], [90, { sx: -1 }], [100, { sx: -1 }]],
120  carry: [[0, { tx: 0 }], [8, { tx: 0 }], [45, { tx: -22 }], [55, { tx: -22 }], [92, { tx: 0 }], [100, { tx: 0 }]],
121  pace: [[0, { tx: 0 }], [50, { tx: -6 }], [100, { tx: 0 }]],
122  magscan: [[0, { tx: 0, ty: 0 }], [25, { tx: -14, ty: -2 }], [50, { tx: -8, ty: 3 }], [75, { tx: -18, ty: 2 }], [100, { tx: 0, ty: 0 }]],
123  dash: [[0, { tx: 0 }], [100, { tx: -8 }]],
124  flipfood: [[0, { ty: 0 }], [70, { ty: 0 }], [80, { ty: -3 }], [90, { ty: -2 }], [100, { ty: 0 }]],
125  vis2: [[0, { o: 1 }], [50, { o: 0 }], [100, { o: 0 }]],
126}
127const visTrack = (n: number): Track => [[0, { o: 1 }], [100 / n, { o: 0 }], [100, { o: 0 }]]
128
129type Timing = { kind: 'step' } | { kind: 'linear' } | { kind: 'steps'; n: number }
130type Anim = { track: Track; dur: number; delay: number; timing: Timing; count: number; fwd: boolean; back: boolean }
131
132const animCache = new Map<string, Anim>()
133// Reads a CSS-style shorthand: "bob 1s step-end infinite -0.5s", "fall 2s steps(12) 0.4s 2 both".
134function parseAnim(def: string): Anim {
135  const hit = animCache.get(def)
136  if (hit) return hit
137  const parts = def.trim().split(/\s+/)
138  const name = parts[0]
139  const track = KF[name] ?? (name.startsWith('vis') ? visTrack(Number(name.slice(3))) : [[0, {}], [100, {}]] as Track)
140  const times: number[] = []
141  let timing: Timing = { kind: 'linear' }
142  let count = 1
143  let fwd = false
144  let back = false
145  for (const p of parts.slice(1)) {
146    if (/^-?[\d.]+s$/.test(p)) times.push(parseFloat(p))
147    else if (p === 'step-end') timing = { kind: 'step' }
148    else if (p === 'linear') timing = { kind: 'linear' }
149    else if (p.startsWith('steps(')) timing = { kind: 'steps', n: parseInt(p.slice(6), 10) }
150    else if (p === 'infinite') count = Infinity
151    else if (/^\d+$/.test(p)) count = Number(p)
152    else if (p === 'forwards') fwd = true
153    else if (p === 'backwards') back = true
154    else if (p === 'both') fwd = back = true
155  }
156  const a: Anim = { track, dur: times[0] ?? 1, delay: times[1] ?? 0, timing, count, fwd, back }
157  animCache.set(def, a)
158  return a
159}
160
161function sampleProp(track: Track, key: keyof Pose, pct: number, timing: Timing): number {
162  const pts = track.filter(([, p]) => p[key] !== undefined).map(([at, p]) => [at, p[key] as number] as const)
163  if (!pts.length) return BASE[key]
164  if (pts[0][0] > 0) pts.unshift([0, BASE[key]])
165  if (pts[pts.length - 1][0] < 100) pts.push([100, BASE[key]])
166  for (let i = 0; i < pts.length - 1; i++) {
167    const [a, va] = pts[i]
168    const [b, vb] = pts[i + 1]
169    if (pct >= a && pct < b) {
170      let f = (pct - a) / (b - a)
171      if (timing.kind === 'step') f = 0
172      else if (timing.kind === 'steps') f = Math.floor(f * timing.n) / timing.n
173      return va + (vb - va) * f
174    }
175  }
176  return pts[pts.length - 1][1]
177}
178
179// The pose of an animation at time t, or null where it has no effect (before or after its run).
180function poseAt(def: string, t: number): Required<Pose> | null {
181  const a = parseAnim(def)
182  const el = t - a.delay
183  let pct: number
184  if (el < 0) {
185    if (!a.back) return null
186    pct = 0
187  } else if (el >= a.dur * a.count) {
188    if (!a.fwd) return null
189    pct = 100
190  } else {
191    pct = ((el % a.dur) / a.dur) * 100
192  }
193  const keys: (keyof Pose)[] = ['tx', 'ty', 'o', 'sx', 'sy']
194  const out = { ...BASE }
195  for (const k of keys) out[k] = pct === 100 ? lastValue(a.track, k) : sampleProp(a.track, k, pct, a.timing)
196  return out
197}
198function lastValue(track: Track, key: keyof Pose): number {
199  for (let i = track.length - 1; i >= 0; i--) if (track[i][1][key] !== undefined) return track[i][1][key] as number
200  return BASE[key]
201}
202
203const n2 = (v: number) => Math.round(v * 100) / 100
204
205// The tracks as CSS keyframes, each property interpolated as sampleProp does.
206function keyframesCss(name: string, track: Track): string {
207  const has = (k: keyof Pose) => track.some(([, p]) => p[k] !== undefined)
208  const moves = has('tx') || has('ty') || has('sx') || has('sy')
209  const stops = [...new Set([0, ...track.map(([at]) => at), 100])].sort((a, b) => a - b)
210  const linear: Timing = { kind: 'linear' }
211  const at = (k: keyof Pose, pct: number) =>
212    n2(pct >= 100 ? lastValue(track, k) : sampleProp(track, k, pct, linear))
213  const steps = stops.map(pct => {
214    const decl: string[] = []
215    if (moves) {
216      decl.push(
217        `transform:translate(${at('tx', pct)}px,${at('ty', pct)}px) scale(${at('sx', pct)},${at('sy', pct)})`,
218      )
219    }
220    if (has('o')) decl.push(`opacity:${at('o', pct)}`)
221    return `${pct}%{${decl.join(';')}}`
222  })
223  return `@keyframes ${name}{${steps.join('')}}`
224}
225
226// The shorthand with its delay (the second time in it) as a slot sceneSvg fills in.
227const DELAY_SLOT = /\{\{(-?[\d.]+)\}\}/g
228function withDelaySlot(anim: string): string {
229  let times = 0
230  let hasDelay = false
231  const parts = anim
232    .trim()
233    .split(/\s+/)
234    .map(p => {
235      if (!/^-?[\d.]+s$/.test(p) || ++times !== 2) return p
236      hasDelay = true
237      return `{{${parseFloat(p)}}}`
238    })
239  if (!hasDelay) parts.push('{{0}}')
240  return parts.join(' ')
241}
242
243class Frame {
244  // Absent: the frame is drawn as CSS animations rather than posed at a time.
245  readonly t: number | undefined
246  readonly used = new Set<string>()
247  constructor(t?: number) {
248    this.t = t
249  }
250  // A group posed by `anim` at this frame, placed at (x, y); `origin` is where it scales from.
251  g(anim: string | null, inner: string, x = 0, y = 0, origin: [number, number] = [0, 0]): string {
252    let body = inner
253    if (anim && this.t === undefined) {
254      this.used.add(anim.trim().split(/\s+/)[0] ?? '')
255      body = `<g style="animation:${withDelaySlot(anim)};transform-origin:${origin[0]}px ${origin[1]}px">${inner}</g>`
256    } else if (anim) {
257      const p = poseAt(anim, this.t ?? 0)
258      if (p) {
259        if (p.o <= 0.02) return ''
260        const tf: string[] = []
261        if (p.tx || p.ty) tf.push(`translate(${n2(p.tx)} ${n2(p.ty)})`)
262        if (p.sx !== 1 || p.sy !== 1) {
263          tf.push(`translate(${origin[0]} ${origin[1]}) scale(${n2(p.sx) || 0.001} ${n2(p.sy) || 0.001}) translate(${-origin[0]} ${-origin[1]})`)
264        }
265        const attrs = (tf.length ? ` transform="${tf.join(' ')}"` : '') + (p.o < 1 ? ` opacity="${n2(p.o)}"` : '')
266        if (attrs) body = `<g${attrs}>${inner}</g>`
267      }
268    }
269    return x || y ? `<g transform="translate(${x} ${y})">${body}</g>` : body
270  }
271  // Shows sub-scene i of n for its slice of a T-second cycle, building only the one on screen.
272  cycle(scenes: (() => string)[], T: number): string {
273    const n = scenes.length
274    if (this.t === undefined) {
275      return scenes.map((scene, i) => this.g(`vis${n} ${T}s step-end infinite ${n2(i * (T / n) - T)}s`, scene())).join('')
276    }
277    const i = Math.floor((((this.t % T) + T) % T) / (T / n))
278    return scenes[i]!()
279  }
280
281  // The @keyframes of every track this frame's groups name.
282  css(): string {
283    return [...this.used]
284      .map(name => {
285        const track = KF[name] ?? (name.startsWith('vis') ? visTrack(Number(name.slice(3))) : undefined)
286        return track ? keyframesCss(name, track) : ''
287      })
288      .join('')
289  }
290}
291
292// ---------- the mascot ----------
293
294type Eyes = 'normal' | 'look' | 'lookL' | 'lookR' | 'down' | 'focus' | 'happy' | 'shades' | 'closed' | 'scan'
295type ClawdOpts = {
296  eyes?: Eyes
297  bob?: string | null
298  armL?: string | null
299  armR?: string | null
300  armLUp?: boolean
301  armRUp?: boolean
302  walk?: boolean
303  sit?: boolean
304  extra?: string
305}
306
307function eyes(f: Frame, kind: Eyes): string {
308  const pair = rect(4, 1, 1, 2, 'e') + rect(11, 1, 1, 2, 'e')
309  switch (kind) {
310    case 'normal':
311      return f.g('blink 4s step-end infinite', pair)
312    case 'look':
313      return f.g('look 8s step-end infinite', pair)
314    case 'scan':
315      return f.g('scan .9s step-end infinite', pair)
316    case 'lookL':
317      return f.g('blink 3.5s step-end infinite', rect(3, 1, 1, 2, 'e') + rect(10, 1, 1, 2, 'e'))
318    case 'lookR':
319      return f.g('blink 3.1s step-end infinite', rect(5, 1, 1, 2, 'e') + rect(12, 1, 1, 2, 'e'))
320    case 'down':
321      return rect(3, 2, 1, 2, 'e') + rect(10, 2, 1, 2, 'e')
322    case 'focus':
323      return rect(3, 2, 2, 1, 'e') + rect(10, 2, 2, 1, 'e') + rect(3, 1, 2, 1, 'O') + rect(10, 1, 2, 1, 'O')
324    case 'happy':
325      return spr(['.e......e.', 'e.e....e.e'], 3, 1)
326    case 'closed':
327      return rect(3, 2, 2, 1, 'e') + rect(10, 2, 2, 1, 'e')
328    case 'shades':
329      return spr(['kkkkk.kkkkk', 'kkkkkkkkkkk', '.kkk...kkk.'], 2, 1) + rect(3, 1, 1, 1, 'w') + rect(9, 1, 1, 1, 'w')
330  }
331}
332
333// 16x8: body 12x6, arms 2x2 at the sides, four 1x2 legs.
334function clawd(f: Frame, x: number, y: number, o: ClawdOpts = {}): string {
335  const body = rect(2, 0, 12, 6, 'o') + rect(2, 5, 12, 1, 'O')
336  const armL = f.g(o.armL ?? null, o.armLUp ? rect(0, 0, 2, 2, 'o') + rect(1, 2, 1, 1, 'o') : rect(0, 2, 2, 2, 'o'))
337  const armR = f.g(o.armR ?? null, o.armRUp ? rect(14, 0, 2, 2, 'o') + rect(14, 2, 1, 1, 'o') : rect(14, 2, 2, 2, 'o'))
338  let legs = ''
339  if (!o.sit) {
340    const a = rect(3, 6, 1, 2, 'o') + rect(10, 6, 1, 2, 'o')
341    const c = rect(5, 6, 1, 2, 'o') + rect(12, 6, 1, 2, 'o')
342    legs = o.walk ? f.g('bob .3s step-end infinite', a) + f.g('bob .3s step-end infinite -.15s', c) : a + c
343  }
344  return f.g(o.bob ?? null, legs + body + armL + armR + eyes(f, o.eyes ?? 'normal') + (o.extra ?? ''), x, y)
345}
346
347// 8x5 helper mascot.
348function mini(f: Frame, x: number, y: number, carry = '', walk = true): string {
349  const body = rect(1, 0, 6, 3, 'o') + rect(0, 1, 1, 1, 'o') + rect(7, 1, 1, 1, 'o') + rect(2, 1, 1, 1, 'e') + rect(5, 1, 1, 1, 'e')
350  const la = rect(2, 3, 1, 2, 'o')
351  const lb = rect(5, 3, 1, 2, 'o')
352  const legs = walk ? f.g('bob .3s step-end infinite', la) + f.g('bob .3s step-end infinite -.15s', lb) : la + lb
353  return `<g transform="translate(${x} ${y})">${legs}${body}${carry}</g>`
354}
355
356const SPARK = ['.y.', 'yyy', '.y.']
357const sparkle = (f: Frame, x: number, y: number, d: number, dur = 1.6) => f.g(`tw ${dur}s step-end infinite ${-d}s`, spr(SPARK), x, y)
358
359const BANG = ['.wwww.', 'ww.www', 'ww.www', 'wwwwww', 'ww.www', '.wwww.', '.w....']
360const BOX = ['BbbbB', 'bbYbb', 'bbbbb', 'BbbbB']
361const SUN = ['..hhhh..', '.hhhhhh.', 'hhyyyyhh', 'hyyyyyyh', 'hyyyyyyh']
362
363// A horizontally banded backdrop for the right of the band, its left edge dithered.
364function backdrop(bands: [string, number][], x0 = 30): string {
365  let out = ''
366  let y = 0
367  for (const [c, h] of bands) {
368    for (let j = 0; j < h; j++, y++) {
369      out += rect(x0 + 4, y, W - x0 - 4, 1, c)
370      for (let dx = 0; dx < 4; dx++) {
371        const xx = x0 + dx
372        const on = dx === 3 ? (xx + y) % 4 !== 1 : dx === 2 ? (xx + y) % 2 === 0 : dx === 1 ? (xx + y) % 4 === 0 : false
373        if (on) out += rect(xx, y, 1, 1, c)
374      }
375    }
376  }
377  return out
378}
379
380// The dotted field that fades in from the left, as in the band's art.
381function dots(f: Frame): string {
382  let out = ''
383  for (let x = 0; x < W; x += 2) {
384    for (let y = 1; y < H; y += 2) {
385      const r = rnd(x * 31 + y)
386      const p = Math.pow(x / W, 1.6) * 0.6
387      if (r < p) {
388        out += r < p * 0.12
389          ? f.g(`tw ${(2 + rnd(x + y * 7) * 3).toFixed(2)}s step-end infinite ${(-rnd(x * y + 1) * 4).toFixed(2)}s`, rect(x, y, 1, 1, '#555'))
390          : rect(x, y, 1, 1, r < p * 0.35 ? '#3c3c3c' : '#2c2c2c')
391      }
392    }
393  }
394  return out
395}
396
397const stars = (f: Frame, n: number, seed: number) =>
398  Array.from({ length: n }, (_, i) => {
399    const x = 36 + Math.floor(rnd(seed + i * 4.3) * 62)
400    const y = Math.floor(rnd(seed + i * 8.9) * 8)
401    return f.g(`tw ${(1.5 + rnd(i + seed) * 2).toFixed(2)}s step-end infinite -${(rnd(i * 2 + seed) * 2).toFixed(2)}s`, rect(x, y, 1, 1, 'w'))
402  }).join('')
403
404const palm = (x: number) =>
405  rect(x, 4, 2, 14, 'b') + rect(x, 6, 2, 1, 'B') + rect(x, 10, 2, 1, 'B') + spr(['.nnn.nnn.', 'nnNnnnNnn', 'n..nNn..n', '...yny...'], x - 3, 1)
406
407// ---------- idle: sweeping, walking, phone, chatting, reading ----------
408
409function idle(f: Frame): string {
410  const sweeping = () =>
411    clawd(f, 60, 10, { bob: 'bob 1.2s step-end infinite' }) +
412    f.g('sweep .6s step-end infinite', rect(76, 4, 1, 11, 't') + rect(75, 14, 4, 1, 'B') + spr(['YYYYY', 'YyYyY', 'Y.Y.Y'], 74, 15)) +
413    [0, 0.4, 0.8].map((d, i) => f.g(`dust 1.2s linear infinite ${-d}s`, rect(0, 0, 1 + (i % 2), 1, 'g'), 73, 16 - i)).join('') +
414    sparkle(f, 86, 3, 0) + sparkle(f, 92, 9, 0.6) + sparkle(f, 83, 12, 1.1) +
415    f.g('tw 1.2s step-end infinite -.3s', spr(['yyy', '.y.']), 90, 1)
416
417  const walking = () => {
418    const butterfly = f.g('on .3s step-end infinite', spr(['v.v', '.k.'])) + f.g('off .3s step-end infinite', spr(['...', 'vkv']))
419    return (
420      f.g('walk 8s steps(18) infinite', clawd(f, 70, 10, { walk: true, eyes: 'look' })) +
421      f.g('walk 8s steps(18) infinite -.5s', f.g('bob .9s step-end infinite', butterfly), 82, 4)
422    )
423  }
424
425  const phone = () =>
426    clawd(f, 66, 10, { eyes: 'down', bob: 'bob 2.4s step-end infinite' }) +
427    spr(['KKKK', 'KccK', 'KccK', 'KccK', 'KKKK'], 62, 9) +
428    f.g('on .6s step-end infinite', rect(63, 10, 2, 1, 'w') + rect(63, 12, 1, 1, 'w')) +
429    f.g('off .6s step-end infinite', rect(63, 11, 2, 1, 'w') + rect(64, 13, 1, 1, 'w')) +
430    f.g('rise 2.4s linear infinite', spr(['p.p', 'ppp', '.p.']), 60, 6) +
431    f.g('rise 2.4s linear infinite -1.2s', spr(['y', 'y', '.', 'y']), 64, 5) +
432    f.g('rise 2.4s linear infinite -.6s', spr(['nnn', 'n.n', 'nnn']), 56, 7)
433
434  const chatting = () =>
435    clawd(f, 40, 10, { eyes: 'lookR', bob: 'bob .6s step-end infinite', armR: 'wave 1.2s step-end infinite' }) +
436    clawd(f, 64, 10, { eyes: 'lookL', bob: 'bob .6s step-end infinite -.3s', armL: 'wave 1.2s step-end infinite -.6s' }) +
437    f.g('on 2.4s step-end infinite', spr(['.wwwwwww.', 'wwwwwwwww', 'wwKwKwKww', 'wwwwwwwww', '.wwwwwww.', '..ww.....', '..w......'], 48, 1)) +
438    f.g('off 2.4s step-end infinite', spr(['.wwwwwww.', 'wwwpwpwww', 'wwwpppwww', 'wwwwpwwww', '.wwwwwww.', '.....ww..', '......w..'], 60, 1)) +
439    f.g('tw 4.8s step-end infinite -2s', spr(['y.y', '.y.', 'y.y']), 58, 9)
440
441  const reading = () =>
442    clawd(f, 64, 10, { eyes: 'down', bob: 'bob 3s step-end infinite' }) +
443    spr(['UwwwwUwwwwU', 'UwgggUwgggU', 'UwwwwUwwwwU', 'UwgggUwggwU', 'UUUUUUUUUUU'], 55, 11) +
444    f.g('flip 3s step-end infinite', spr(['wwww', 'wggw', 'wwww', 'wgww']), 61, 11) +
445    f.g('tw 3s step-end infinite -1s', spr(['.y.', 'yyy', 'yyy', '.w.']), 80, 3)
446
447  return f.cycle([sweeping, walking, phone, chatting, reading], 40)
448}
449
450// ---------- working: laptop, papers, delegating, boxes, office, analysis ----------
451
452function working(f: Frame): string {
453  const typing = () => {
454    const lines = [4, 6, 3, 5]
455      .map((w, i) => f.g(`grow 1.6s steps(${w}) infinite ${-i * 0.4}s`, rect(54, 9 + i, w, 1, i % 2 ? 'c' : 'n'), 0, 0, [54, 9 + i]))
456      .join('')
457    const laptop = f.g('pull 6s steps(3) infinite', rect(52, 8, 10, 6, 'L') + rect(53, 8, 8, 5, 'k') + lines + rect(50, 14, 14, 1, 'l') + rect(51, 15, 12, 1, 'L'))
458    const bits = [0, 0.5, 1, 1.5].map((d, i) => f.g(`rise 2s linear infinite ${-d}s`, i % 2 ? spr(['n', 'n', 'n']) : spr(['nn', 'nn', 'nn']), 54 + i * 2, 6)).join('')
459    return laptop + bits + clawd(f, 64, 10, { eyes: 'focus', bob: 'bob .6s step-end infinite', armL: 'bob .3s step-end infinite', armR: 'bob .3s step-end infinite -.15s' })
460  }
461
462  const papers = () => {
463    const sheet = spr(['PPP', 'PgP', 'PPP', 'PgP'])
464    return (
465      spr(['PPPPPP', 'PggggP', 'PPPPPP', 'PgggPP', 'PPPPPP', 'PggggP'], 50, 12) +
466      [0, 0.5, 1].map(d => f.g(`fly 1.5s steps(7) infinite ${-d}s`, sheet, 52, 10)).join('') +
467      clawd(f, 66, 10, { eyes: 'scan' }) +
468      f.g('bob 1.2s step-end infinite', spr(['PPPP', 'PggP', 'PPPP', 'PgPP', 'PPPP']), 63, 11) +
469      f.g('tw 2s step-end infinite', spr(['y', 'y', '.', 'y']), 72, 5)
470    )
471  }
472
473  const delegate = () => {
474    const box = rect(2, -2, 4, 2, 'b') + rect(3, -2, 2, 1, 'Y')
475    const doc = rect(2, -3, 3, 3, 'P') + rect(3, -2, 1, 1, 'g')
476    return (
477      clawd(f, 74, 10, { armLUp: true, armL: 'wave .6s step-end infinite' }) +
478      spr(BANG, 66, 2) +
479      [0, 2, 4].map((d, i) => f.g(`dispatch 6s steps(24) infinite ${-d}s`, mini(f, 0, 0, i === 1 ? doc : box), 66, 13)).join('')
480    )
481  }
482
483  const boxes = () =>
484    spr(BOX, 32, 14) + spr(BOX, 38, 14) + spr(BOX, 35, 10) + spr(BOX, 86, 14) + spr(BOX, 92, 14) +
485    f.g('off 6s step-end infinite', spr(BOX, 41, 10)) +
486    f.g('carry 6s steps(24) infinite',
487      clawd(f, 66, 10, { walk: true, armLUp: true, armRUp: true, extra: f.g('on 6s step-end infinite', spr(BOX, 5, -4)) }))
488
489  const office = () =>
490    [28, 43, 58].map((x, i) =>
491      f.g(`bob .3s step-end infinite ${-i * 0.15}s`, mini(f, x, 13, '', false)) +
492      spr(['LLLLL', 'LkkkL', 'LkkkL', 'LLLLL', '..L..'], x + 8, 9) +
493      f.g(`on .6s step-end infinite ${-i * 0.3}s`, rect(x + 9, 10, 2, 1, 'n') + rect(x + 9, 11, 3, 1, 'c')) +
494      f.g(`off .6s step-end infinite ${-i * 0.3}s`, rect(x + 9, 10, 3, 1, 'c') + rect(x + 10, 11, 2, 1, 'n')) +
495      rect(x + 7, 14, 7, 1, 'b') + rect(x + 8, 15, 1, 3, 'B') + rect(x + 12, 15, 1, 3, 'B'),
496    ).join('') +
497    f.g('pace 4s steps(6) infinite', clawd(f, 80, 10, { walk: true, armLUp: true, armL: 'wave .6s step-end infinite' })) +
498    f.g('tw 2s step-end infinite', spr(BANG), 86, 1)
499
500  const analyze = () =>
501    rect(36, 2, 22, 13, 'L') + rect(37, 3, 20, 11, 'k') + rect(45, 15, 4, 2, 'L') + rect(42, 17, 10, 1, 'L') +
502    [2, 5, 3, 7, 4, 8]
503      .map((h, i) => f.g(`growY 3s steps(${h}) infinite ${-i * 0.3}s`, rect(39 + i * 3, 13 - h, 2, h, i % 2 ? 'c' : 'n'), 0, 0, [40 + i * 3, 13]))
504      .join('') +
505    clawd(f, 68, 10, { eyes: 'focus', armLUp: true }) +
506    f.g('magscan 5s steps(10) infinite', spr(['.lll...', 'lcccl..', 'lcccl..', 'lcccl..', '.lllb..', '....bb.', '.....bb']), 58, 5) +
507    f.g('tw 5s step-end infinite', spr(['www', '..w', '.ww', '...', '.w.']), 78, 1)
508
509  return f.cycle([typing, papers, delegate, boxes, office, analyze], 36)
510}
511
512// ---------- task complete: one burst of confetti, jumping and dancing ----------
513
514export const DONE_SECONDS = 6.5
515
516function done(f: Frame): string {
517  const colors = ['r', 'y', 'c', 'n', 'p', 'v', 'f', 'u']
518  let confetti = ''
519  for (let i = 0; i < 34; i++) {
520    const x = 26 + Math.floor(rnd(i * 3.7) * 72)
521    const dur = (1.6 + rnd(i * 5.1) * 1.2).toFixed(2)
522    const d = (rnd(i * 9.3) * 1.2).toFixed(2)
523    const c = colors[i % colors.length]
524    const piece =
525      f.g(`on ${(0.3 + Math.round(rnd(i) * 2) * 0.15).toFixed(2)}s step-end infinite`, rnd(i) > 0.5 ? rect(0, 0, 1, 2, c) : rect(0, 0, 2, 1, c)) +
526      f.g(`off ${(0.3 + Math.round(rnd(i) * 2) * 0.15).toFixed(2)}s step-end infinite`, rect(0, 0, 1, 1, colors[(i + 3) % colors.length]))
527    confetti += f.g(`fall ${dur}s steps(12) ${d}s 2 both`, piece, x, 0)
528  }
529  return (
530    confetti +
531    f.g('dance 2.4s step-end 0s 2 forwards', clawd(f, 66, 10, { eyes: 'happy', armL: 'wave .3s step-end infinite', armR: 'wave .3s step-end infinite -.15s', walk: true })) +
532    f.g('hop .6s step-end 0s 9', mini(f, 52, 13, '', false)) +
533    f.g('hop .6s step-end -.3s 9', mini(f, 86, 13, '', false)) +
534    sparkle(f, 60, 2, 0, 0.9) + sparkle(f, 88, 4, 0.45, 0.9) + sparkle(f, 46, 6, 0.3, 0.9)
535  )
536}
537
538// ---------- relaxing: sunset chair, fishing, hammock, campfire, barbecue, sunset drive ----------
539
540function relax(f: Frame): string {
541  const sunset = () =>
542    backdrop([['s', 3], ['m', 3], ['d', 3], ['f', 3], ['U', 3], ['t', 3]]) +
543    f.g('sink 8s steps(3) infinite', spr(SUN), 76, 7) +
544    rect(34, 12, 66, 3, 'U') +
545    [0, 0.4, 0.8, 1.2].map((d, i) => f.g(`tw 1.6s step-end infinite ${-d}s`, rect(0, 0, 2 + (i % 2), 1, 'h'), 72 + i * 4, 12 + (i % 3))).join('') +
546    f.g('drift 16s linear infinite', spr(['K.K', '.K.']) + spr(['K.K', '.K.'], 5, 2), 96, 2) +
547    spr(['r......', 'wr.....', 'rwr....', 'wrwrwrw', 'b.....b', 'b.....b'], 46, 10) +
548    clawd(f, 46, 8, { eyes: 'shades', bob: 'bob 3s step-end infinite' }) +
549    spr(['..p..', '.ppp.', '..t..', 'cccc.', 'cccc.', '.cc..'], 63, 10) +
550    f.g('riseR 3s linear infinite', spr(['.ww', '.w.', 'ww.']), 58, 5) +
551    f.g('riseR 3s linear infinite -1.5s', spr(['ww', 'w.', 'w.']), 52, 5)
552
553  const fishing = () =>
554    backdrop([['V', 4], ['s', 4], ['V', 3], ['U', 7]]) +
555    stars(f, 14, 3) +
556    spr(['.ww.', 'wwww', 'wwww', '.ww.'], 90, 1) +
557    [0, 1, 2].map(i => f.g(`tw 2s step-end infinite ${-i * 0.6}s`, rect(0, 0, 3, 1, 'u'), 40 + i * 15, 13 + i)).join('') +
558    rect(38, 10, 26, 2, 'b') + rect(38, 10, 26, 1, 't') + rect(40, 12, 1, 6, 'B') + rect(60, 12, 1, 6, 'B') +
559    clawd(f, 46, 4, { eyes: 'closed', bob: 'bob 3s step-end infinite' }) +
560    spr(['.......t', '......t.', '.....t..', '....t...', '...t....', '..t.....', '.t......'], 60, 0) +
561    rect(68, 1, 1, 12, 'g') +
562    f.g('bob 1.2s step-end infinite', rect(67, 12, 3, 1, 'r') + rect(67, 13, 3, 1, 'w')) +
563    f.g('leap 8s steps(10) infinite', spr(['.uu.u', 'uuuuu', '.uu.u']), 86, 13)
564
565  const hammockScene = () => {
566    // The hammock sags between the palms: tied high at the trunks, lowest in the middle.
567    let hammock = ''
568    for (let x = 48; x <= 82; x++) {
569      const t = (x - 65) / 17
570      hammock += rect(x, Math.round(9 + 4 * (1 - t * t)), 1, 1, x % 3 ? 'w' : 'r')
571    }
572    return (
573      backdrop([['C', 11], ['c', 2], ['t', 5]]) +
574      palm(46) + palm(83) +
575      f.g('swing 3s step-end infinite', clawd(f, 57, 7, { eyes: 'closed', sit: true }) + hammock) +
576      f.g('riseR 3s linear infinite', spr(['www', '.w.', 'www']), 70, 2) +
577      f.g('riseR 3s linear infinite -1.5s', spr(['ww', 'ww']), 74, 3)
578    )
579  }
580
581  const campfire = () =>
582    backdrop([['V', 5], ['s', 5], ['m', 2], ['N', 6]]) +
583    stars(f, 12, 11) +
584    f.g('on .3s step-end infinite', spr(['..r...', '.rfr..', '.rfyr.', 'rfyyfr']), 70, 10) +
585    f.g('off .3s step-end infinite', spr(['...r..', '..rfr.', '.ryfr.', 'rfyyfr']), 70, 10) +
586    spr(['BB..BB', '.BBBB.', 'BB..BB'], 70, 14) +
587    [0, 0.7, 1.4].map((d, i) => f.g(`rise 2.1s linear infinite ${-d}s`, rect(0, 0, 1, 1, 'h'), 71 + i * 2, 8)).join('') +
588    f.g('on .6s step-end infinite', rect(64, 16, 18, 1, 'O')) +
589    rect(46, 15, 14, 3, 'B') +
590    clawd(f, 47, 9, { eyes: 'closed', bob: 'bob 3.6s step-end infinite' }) +
591    spr(['www.', 'wwwP', 'www.'], 61, 12) +
592    f.g('rise 2.4s linear infinite', spr(['g', '.', 'g']), 62, 9) +
593    f.g('rise 2.4s linear infinite -1.2s', spr(['g', 'g']), 63, 9)
594
595  const barbecue = () => {
596    let fence = ''
597    for (let x = 35; x < W; x += 3) fence += rect(x, 7, 2, 6, 'w')
598    return (
599      backdrop([['C', 13], ['n', 2], ['N', 3]]) +
600      fence + rect(34, 9, 66, 1, 'w') +
601      spr(['KKKKKKKKKKKK', '.GGGGGGGGGG.', '..GGGGGGGG..', '...G....G...', '...G....G...', '..GG....GG..'], 46, 11) +
602      f.g('on .6s step-end infinite', rect(49, 12, 6, 1, 'd') + rect(56, 12, 1, 1, 'f')) +
603      f.g('off .6s step-end infinite', rect(48, 12, 2, 1, 'f') + rect(51, 12, 6, 1, 'd')) +
604      f.g('flipfood 2s steps(4) infinite', rect(47, 10, 3, 1, 'B')) +
605      rect(51, 10, 3, 1, 'r') +
606      f.g('flipfood 2s steps(4) infinite -1s', rect(55, 10, 2, 1, 'B')) +
607      [0, 0.8, 1.6].map((d, i) => f.g(`rise 2.4s linear infinite ${-d}s`, spr(['g.', '.g']), 48 + i * 3, 6)).join('') +
608      clawd(f, 64, 10, { eyes: 'happy', extra: spr(['.wwwwww.', 'wwwwwwww', '.wwwwww.'], 4, -3) }) +
609      f.g('wave .6s step-end infinite', rect(60, 11, 4, 1, 't') + rect(58, 10, 2, 2, 'l'))
610    )
611  }
612
613  const drive = () => {
614    const palmSmall = rect(0, 4, 1, 8, 'B') + spr(['.N.N.', 'NNNNN', 'N.N.N'], -2, 2)
615    let dashes = ''
616    for (let x = 40; x < W + 8; x += 8) dashes += rect(x, 16, 4, 1, 'y')
617    const car = spr([
618      '.................c....',
619      '.................c....',
620      'rrrrrrrrrrrrrrrrrrrrrr',
621      'rrrrrrrrrrrrrrrrrrrrhh',
622      'RRRRRRRRRRRRRRRRRRRRRR',
623    ])
624    const wheels =
625      spr(['.KKK............KKK...', '.KKK............KKK...'], 0, 5) +
626      f.g('on .3s step-end infinite', rect(2, 5, 1, 1, 'g') + rect(18, 5, 1, 1, 'g')) +
627      f.g('off .3s step-end infinite', rect(2, 6, 1, 1, 'g') + rect(18, 6, 1, 1, 'g'))
628    return (
629      backdrop([['s', 3], ['m', 3], ['d', 3], ['f', 3], ['N', 2], ['G', 4]]) +
630      spr(SUN, 74, 8) + rect(34, 12, 66, 2, 'N') + rect(34, 14, 66, 4, 'G') +
631      f.g('dash .6s steps(4) infinite', dashes) +
632      f.g('zoom 2.4s steps(24) infinite', palmSmall, 100, 0) +
633      f.g('zoom 2.4s steps(24) infinite -1.2s', palmSmall, 100, 0) +
634      f.g('drift 16s linear infinite', spr(['K.K', '.K.']), 96, 2) +
635      f.g('bob .6s step-end infinite', clawd(f, 48, 6, { eyes: 'shades', sit: true, armRUp: true, armR: 'wave 1.2s step-end infinite' }) + `<g transform="translate(46 9)">${car}${wheels}</g>`) +
636      [0, 0.3].map(d => f.g(`dust .6s linear infinite ${-d}s`, rect(0, 0, 2, 1, 'g'), 45, 13)).join('')
637    )
638  }
639
640  return f.cycle([sunset, fishing, hammockScene, campfire, barbecue, drive], 48)
641}
642
643function draw(mood: Mood, f: Frame): string {
644  const body = mood === 'working' ? working(f) : mood === 'done' ? done(f) : mood === 'relax' ? relax(f) : idle(f)
645  const field = dots(f)
646  const style = f.t === undefined ? `<style>${f.css()}</style>` : ''
647  return (
648    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W * 3}" height="${H * 3}" shape-rendering="crispEdges">` +
649    `${style}<rect x="-2" y="-2" width="${W + 4}" height="${H + 4}" fill="${BAND_BG}"/>${field}${body}</svg>`
650  )
651}
652
653const animated = new Map<Mood, string>()
654// `mood` animated by the browser, starting `t` seconds in: every delay is moved back
655// by `t`, so the drawing a redraw reloads carries on from where the last one was.
656export function sceneSvg(mood: Mood, t = 0): string {
657  let svg = animated.get(mood)
658  if (!svg) animated.set(mood, (svg = draw(mood, new Frame())))
659  return svg.replace(DELAY_SLOT, (_, d: string) => `${n2(parseFloat(d) - t)}s`)
660}
661
662// One still frame of `mood`, `t` seconds after it began.
663export function sceneFrameSvg(mood: Mood, t: number): string {
664  return draw(mood, new Frame(t))
665}
666
667export const SCENE_WIDTH = W * 3
668export const SCENE_HEIGHT = H * 3
669
hooks/usage.ts 211 lines
1// Dollar figures for the usage meters. Claude Code writes every session's transcript
2// under <config>/projects/<project>/<session>.jsonl; each assistant line carries the
3// API usage of one request. Pricing those lines, across every session on this machine,
4// gives what each rate-limit window has cost so far, and against the window's percent
5// how many dollars one percent is worth.
6
7import type { Thread, UsageReport, WindowReport } from '../types'
8
9// Per million tokens: [input, output, cache read]. Cache writes are 1.25x input for
10// the 5-minute cache and 2x for the hour cache. Longest matching prefix wins.
11const PRICES: [string, number, number, number][] = (
12  [
13    ['claude-fable-5-1', 10, 50, 0.25],
14    ['claude-fable-5', 10, 50, 1],
15    ['claude-mythos', 10, 50, 1],
16    ['claude-opus-5-5', 4, 20, 0.2],
17    ['claude-opus-5', 5, 25, 0.5],
18    ['claude-opus-4-8', 5, 25, 0.5],
19    ['claude-opus-4-7', 5, 25, 0.5],
20    ['claude-opus-4-6', 5, 25, 0.5],
21    ['claude-opus-4-5', 5, 25, 0.5],
22    ['claude-opus-4', 15, 75, 1.5],
23    ['claude-3-opus', 15, 75, 1.5],
24    ['claude-sonnet-5-5', 2, 10, 0.2],
25    ['claude-sonnet-5', 2, 10, 0.2],
26    ['claude-sonnet-4', 3, 15, 0.3],
27    ['claude-3-7-sonnet', 3, 15, 0.3],
28    ['claude-3-5-sonnet', 3, 15, 0.3],
29    ['claude-haiku-5-5', 0.1, 0.5, 0.01],
30    ['claude-haiku-4-5', 1, 5, 0.1],
31    ['claude-3-5-haiku', 0.8, 4, 0.08],
32    ['claude-3-haiku', 0.25, 1.25, 0.03],
33  ] as [string, number, number, number][]
34).sort((a, b) => b[0].length - a[0].length)
35
36type Usage = {
37  input_tokens?: number
38  output_tokens?: number
39  cache_creation_input_tokens?: number
40  cache_read_input_tokens?: number
41  cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number }
42  server_tool_use?: { web_search_requests?: number }
43  speed?: string
44}
45
46function price(model: string, u: Usage): number {
47  const id = model.toLowerCase().replace(/^(us\.|eu\.|global\.)?anthropic\./, '')
48  const p = PRICES.find(([prefix]) => id.startsWith(prefix))
49  if (!p) return 0
50  const [, inp, out, read] = p
51  const write = u.cache_creation_input_tokens ?? 0
52  const w1h = u.cache_creation?.ephemeral_1h_input_tokens ?? 0
53  const w5m = u.cache_creation ? (u.cache_creation.ephemeral_5m_input_tokens ?? 0) : write
54  const usd =
55    ((u.input_tokens ?? 0) * inp +
56      (u.output_tokens ?? 0) * out +
57      (u.cache_read_input_tokens ?? 0) * read +
58      w5m * inp * 1.25 +
59      w1h * inp * 2) /
60    1_000_000
61  return usd * (u.speed === 'fast' ? 2 : 1) + (u.server_tool_use?.web_search_requests ?? 0) * 0.01
62}
63
64const tokensOf = (u: Usage) =>
65  (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0)
66
67// One priced request. `id` dedupes the lines a streamed message writes, and the
68// copies a resumed or forked session carries over from its parent.
69type Entry = { id: string; at: number; usd: number; tok: number }
70
71export type FileSummary = {
72  size: number
73  mtimeMs: number
74  parsedAt: number
75  session: string // the main session the file belongs to (a subagent's parent)
76  isMain: boolean
77  entries: Entry[]
78  cwd?: string
79  title?: string
80  agentName?: string
81  lastPrompt?: string
82  // What the session is doing: waiting on the model, or on the person.
83  last: 'user' | 'assistant' | 'idle' | undefined
84  lastStop?: string
85}
86
87export type WindowSpec = { kind: 'five_hour' | 'seven_day'; start: number }
88
89// Files over this are streamed through a child process, since one $.fs.read stops at 4 MiB.
90export const READ_LIMIT = 4 * 1024 * 1024 - 1024
91// Work done in one dispatch before handing the rest to a fresh timer.
92export const SLICE_MS = 800
93const WORKING_STALE_MS = 10 * 60_000
94
95function baseName(p: string): string {
96  const parts = p.split(/[\\/]/).filter(Boolean)
97  return parts[parts.length - 1] ?? p
98}
99
100export function parseLines(text: string, f: FileSummary, carry: { rest: string }, seen: Set<string>) {
101  const chunk = carry.rest + text
102  const lines = chunk.split('\n')
103  carry.rest = lines.pop() ?? ''
104  for (const line of lines) parseLine(line, f, seen)
105}
106
107export function parseLine(line: string, f: FileSummary, seen: Set<string>) {
108  if (line.length < 2) return
109  const head = line.slice(0, 400)
110  if (head.startsWith('{"parentUuid"') && head.includes('"type":"user"')) {
111    f.last = line.includes('[Request interrupted by user') ? 'idle' : 'user'
112    return
113  }
114  if (line.includes('"type":"assistant"') && line.includes('"usage"')) {
115    let d: any
116    try {
117      d = JSON.parse(line)
118    } catch {
119      return
120    }
121    if (d?.type !== 'assistant' || !d.message?.usage) return
122    if (d.cwd) f.cwd = d.cwd
123    f.last = 'assistant'
124    f.lastStop = d.message.stop_reason ?? f.lastStop
125    const id = `${d.message.id ?? d.uuid}:${d.requestId ?? ''}`
126    if (seen.has(id)) return
127    seen.add(id)
128    const at = Date.parse(d.timestamp)
129    if (!Number.isFinite(at)) return
130    const u = d.message.usage as Usage
131    f.entries.push({ id, at, usd: price(String(d.message.model ?? ''), u), tok: tokensOf(u) })
132    return
133  }
134  if (!f.isMain || line.length > 4000) return
135  if (head.includes('"type":"custom-title"') || head.includes('"type":"agent-name"') || head.includes('"type":"last-prompt"') || head.includes('"type":"summary"')) {
136    try {
137      const d = JSON.parse(line)
138      if (d.type === 'custom-title' && d.customTitle) f.title = d.customTitle
139      else if (d.type === 'agent-name' && d.agentName) f.agentName = d.agentName
140      else if (d.type === 'last-prompt' && d.lastPrompt) f.lastPrompt = d.lastPrompt
141      else if (d.type === 'summary' && d.summary && !f.title) f.agentName ??= d.summary
142    } catch {}
143  }
144}
145
146export type Listed = { path: string; size: number; mtimeMs: number; session: string; isMain: boolean }
147
148// Totals each window from every file read, each request billed once.
149export function summarize(
150  files: ReadonlyMap<string, FileSummary>,
151  specs: WindowSpec[],
152  now: number,
153  hostLabel: string,
154): UsageReport {
155  const owner = new Map<string, string>() // entry id -> the first file that billed it
156  const sessions = new Map<string, FileSummary[]>()
157  for (const [path, f] of files) {
158    const list = sessions.get(f.session) ?? []
159    list.push(f)
160    sessions.set(f.session, list)
161    for (const e of f.entries) if (!owner.has(e.id)) owner.set(e.id, path)
162  }
163  const meta = (id: string) => {
164    const list = sessions.get(id) ?? []
165    const main = list.find(f => f.isMain)
166    const cwd = main?.cwd ?? list.find(f => f.cwd)?.cwd
167    const title = main?.title ?? main?.agentName ?? main?.lastPrompt ?? id.slice(0, 8)
168    return { title, project: cwd ? baseName(cwd) : '' }
169  }
170
171  const out: Partial<Record<WindowSpec['kind'], WindowReport>> = {}
172  for (const w of specs) {
173    const threads = new Map<string, Thread>()
174    let usd = 0
175    let tok = 0
176    for (const [path, f] of files) {
177      for (const e of f.entries) {
178        if (e.at < w.start || owner.get(e.id) !== path) continue
179        usd += e.usd
180        tok += e.tok
181        let t = threads.get(f.session)
182        if (!t) {
183          const m = meta(f.session)
184          t = { id: f.session, title: m.title, project: m.project, usd: 0, tokens: 0, firstAt: e.at, lastAt: e.at }
185          threads.set(f.session, t)
186        }
187        t.usd += e.usd
188        t.tokens += e.tok
189        t.firstAt = Math.min(t.firstAt, e.at)
190        t.lastAt = Math.max(t.lastAt, e.at)
191      }
192    }
193    out[w.kind] = { start: w.start, usd, tokens: tok, threads: [...threads.values()].sort((a, b) => b.usd - a.usd) }
194  }
195
196  // Sessions with activity in the 5h window: working while the model holds the turn.
197  const since = specs.find(w => w.kind === 'five_hour')?.start ?? now - 5 * 3_600_000
198  let working = 0
199  let yourTurn = 0
200  for (const [, list] of sessions) {
201    const main = list.find(f => f.isMain)
202    if (!main || main.mtimeMs < since) continue
203    const isBusy =
204      now - main.mtimeMs < WORKING_STALE_MS &&
205      (main.last === 'user' || (main.last === 'assistant' && main.lastStop !== 'end_turn'))
206    if (isBusy) working++
207    else yourTurn++
208  }
209  return { at: now, host: hostLabel, working, yourTurn, five_hour: out.five_hour, seven_day: out.seven_day }
210}
211
types/index.d.ts 44 lines
1export type Mood = 'idle' | 'working' | 'done' | 'relax'
2export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
3export type Ctx = { tokens?: number; window: number; percent?: number }
4
5// One session's spend inside a rate-limit window, its subagents included.
6export type Thread = {
7  id: string
8  title: string
9  project: string
10  usd: number
11  tokens: number
12  firstAt: number
13  lastAt: number
14}
15export type WindowReport = { start: number; usd: number; tokens: number; threads: Thread[] }
16// What every transcript on this machine has cost, priced at API rates, per window.
17export type UsageReport = {
18  at: number
19  host: string
20  working: number
21  yourTurn: number
22  five_hour?: WindowReport
23  seven_day?: WindowReport
24}
25export type PopupView = { window: 'five_hour' | 'seven_day'; group: 'threads' | 'projects' }
26
27declare module 'claude-code' {
28  interface PluginState {
29    'pixel-buddy': {
30      mood: Mood
31      moodAt: number
32      frame: number
33      cacheAt: number | null
34      now: number
35      minute: number
36      ctx: Ctx | null
37      limits: Limit[]
38      report: UsageReport | null
39      isPinned: boolean
40      view: PopupView
41    }
42  }
43}
44