No description.

![]()
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-pluginshooks/register.tsx 653 lines1import { 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}
653hooks/scene.ts 669 lines1// 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
669hooks/usage.ts 211 lines1// 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}
211types/index.d.ts 44 lines1export 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