SLOPSHOPPER

clawd-dash

Session dashboard under the prompt: usage limits, context, cost, model, effort, session stats, git status, file changes, and an animated Clawd.

newspinnerguardprocesstimer
★ 1v0.2.0Apache-2.0updated 2026-10-06pinkpixel-dev/clawd-dash
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd-dash
› 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% ↻ NaNm │ Opus 5.5 ▣ clawd done 7d waiting for first reading │ $0.42 · up 363d 6h · last 42s context ██████▉ 49% 97k/200k │ 1 prompt · 9 tools ? for shortcuts

Draws

Prompt hint
5h ████▍ 31% ↻ NaNm │ Opus 5.5 7d waiting for first reading │ $0.42 · up 363d 6h · last 42s context ██████▉ 49% 97k/200k │ 1 prompt · 9 tools ? for shortcuts
README

clawd-dash

clawd-dash

clawd-dash is a Claude Code mod that puts a little session dashboard under the prompt: plan limits, context, cost, model, effort, session stats, git status, and file changes, with an animated pixel Clawd on the right that reacts to what Claude is doing.

What it shows

The stats sit in three columns that spread out to fill the space between the hint text and the crab.

Limits

  • 5h and 7d plan limits as a block gauge, the percent used, and when each window resets (↻ 44m)
  • Context as the same kind of gauge, with the percent full and tokens used out of the window (like 84k/200k)

Session

  • Model and effort (like Opus 5.5 · high)
  • Cost in dollars, how long the session has been up, and how long the last turn took
  • Prompts you've sent and tool calls Claude has made

Repo

  • Git branch, with ↑/↓ for commits ahead of or behind the remote, and ●3 for three changed files (or ✓ when the tree is clean)
  • File changes: lines added and removed, plus how many files Claude has edited or written
  • The Claude Code version

The normal hint line (? for shortcuts, esc to interrupt) stays as the last row, so you don't lose it.

The gauges turn yellow at 50% and red at 80%, and the numbers are always shown too, so you're not reading status from color alone. On narrower terminals the columns drop from the right: repo goes first, then session.

The mascot

Clawd has four moods, and each one cycles through a few little scenes:

MoodWhenWhat happens
workingWhile Claude is running a turnTyping on a laptop, shuffling papers, carrying boxes, running charts
doneRight after a turn finishesOne burst of confetti and a dance
idleAfter the celebrationSweeping, walking, scrolling a phone, reading
relaxAfter about 3 minutes with nothing happeningSunset chair, fishing, hammock, campfire, barbecue, a drive

Requirements

  • Claude Code v2.1.287 or later (that's when mods shipped). Check with claude --version.
  • A terminal that supports the kitty graphics protocol, like kitty or Ghostty, if you want to see the crab. Other terminals still get the stats, and the crab falls back to a short text label.
  • A Claude subscription if you want the 5h and 7d limits. On an API key, those rows just say no limits were reported.

Install

This repo is also a small plugin marketplace called pinkpixel, so you can install it straight from GitHub. Inside Claude Code:

/plugin marketplace add pinkpixel-dev/clawd-dash
/plugin install clawd-dash@pinkpixel

Or from your shell:

claude plugin marketplace add pinkpixel-dev/clawd-dash
claude plugin install clawd-dash@pinkpixel

Run /plugin and you should see clawd-dash in the mods active line under the tabs. To pick up new versions later, run claude plugin marketplace update pinkpixel.

Load it from a local folder

If you've cloned the repo and want to hack on it, you can load it from the folder instead. To try it for one session:

claude --plugin-dir /home/sizzlebop/PINKPIXEL/PROJECTS/CURRENT/clawd-dash

To load it every time, add the folder to CLAUDE_CODE_PLUGIN_DIRS, either in your shell or in the env block of ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/home/sizzlebop/PINKPIXEL/PROJECTS/CURRENT/clawd-dash"
  }
}

Run /plugin inside Claude Code and you should see clawd-dash in the mods active line under the tabs.

To turn it off, remove the folder from that setting, or start Claude Code with --safe-mode for one session.

Good to know

  • Mods run with your permissions. This one reads usage numbers, session info (model, prompt count, version), and the results of Claude's Edit/Write/MultiEdit/NotebookEdit calls. The only process it runs is git status --porcelain=v1 --branch, at session start, after each Bash or edit tool call, and when a turn finishes. It doesn't write files or make network requests. You can check that yourself with claude plugin validate ., which lists every hook and call the mod makes.
  • Effort shows up after your first message, since it comes from the first model request.
  • The file change and tool call counters start over whenever the mod reloads (for example, while you're editing it).
  • The tool call count includes calls made by subagents.
  • On terminals narrower than 90 columns, the crab hides and the stats stay.
  • The dashboard only draws in the terminal. In the Desktop app and the VS Code panel, you get the normal hint line.

How it works

The mod is a few small TypeScript files in hooks/:

  • register.tsx hooks into Claude Code's events. session.measure feeds the usage numbers, turn.start and turn.complete switch the mood and update the prompt count, last turn time, and git status, turn.step picks up the model and effort, and tool.call counts tool calls and lines from edit results. A ui.render hook on PromptHint draws the dashboard in the hint row under the prompt.
  • dash.tsx builds the three stat columns and decides how many fit.
  • git.ts runs git status and reads the branch, ahead/behind counts, and changed files out of it.
  • scene.ts draws each animation frame of a mood as a 100x18 pixel SVG.
  • raster.ts turns that SVG into RGBA pixels, crops off the mostly empty left side, and scales it up 4x so it stays crisp. The terminal can't draw SVG, but it can draw an Image.
  • A timer repaints the crab about 8 times a second with $.ui.blit, which swaps the picture without redrawing the rest of the dashboard.

There's more detail in DOCS/OVERVIEW.md.

Development

Validate the manifest and see what the mod hooks and calls:

claude plugin validate .

Run the tests:

claude plugin test .

The tests render the dashboard at a few widths, check the column breakpoints, the gauge, and git status parsing, and make sure every mood actually draws the mascot.

When Claude Code loads the mod from this folder, it writes type declarations into .claude-plugin/types/, and tsconfig.json extends them. After one load, npx -p typescript tsc -p . type-checks the mod. That folder is generated, so it's in .gitignore.

If you start Claude Code with --plugin-dir pointing here, saving a file reloads the mod live.

License

Apache 2.0. See LICENSE.

Clawd is Anthropic's mascot. This is a fan-made mod and isn't affiliated with Anthropic.

Made with 💖 by Pink Pixel

Source 7 files
hooks/register.tsx 188 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, SessionMeasureInput } from 'claude-code'
3
4import type { Limit, Mood, Stats } from '../types'
5import { columnsFor, statsGrid } from './dash'
6import { readGit } from './git'
7import { rasterize } from './raster'
8import { DONE_SECONDS, sceneSvg } from './scene'
9
10// The runtime has the ES2026 Uint8Array base64 methods; es2023's lib doesn't declare them.
11declare global {
12  interface Uint8Array {
13    toBase64(): string
14  }
15}
16
17const SITE = 'PromptHint'
18const FRAME_MS = 125
19const RELAX_AFTER_S = 180
20// The scene is 100x18 art pixels; the left 24 columns are mostly empty dots.
21const ART_W = 100
22const ART_H = 18
23const CROP_X = 24
24const SCALE = 4
25const SCENE_COLS = 36
26const SCENE_ROWS = 4
27const MIN_COLS_FOR_SCENE = 90
28// Room left of the dash for Claude Code's own mode label ("auto mode on ·").
29const MODE_LABEL_COLS = 20
30// Keeps the uptime and reset countdowns current between stat changes.
31const CLOCK_MS = 15_000
32const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
33// Tools after which the working tree may have changed.
34const GIT_TOOLS = new Set([...EDIT_TOOLS, 'Bash'])
35
36const stats = atom({ plugin: 'clawd-dash', key: 'stats' } as const, { added: 0, removed: 0, files: [], tools: 0 } as Stats)
37
38let mood: Mood = 'idle'
39let moodAt = Date.now()
40let site: string | undefined
41
42function setMood(next: Mood) {
43  if (next === mood) return
44  mood = next
45  moodAt = Date.now()
46}
47
48function frame() {
49  const t = (Date.now() - moodAt) / 1000
50  if (mood === 'done' && t > DONE_SECONDS) setMood('idle')
51  else if (mood === 'idle' && t > RELAX_AFTER_S) setMood('relax')
52  const px = rasterize(sceneSvg(mood, (Date.now() - moodAt) / 1000), ART_W, ART_H, CROP_X, SCALE)
53  return { rgba: px.rgba.toBase64(), width: px.width, height: px.height }
54}
55
56function fromUsage(u: Omit<SessionMeasureInput, 'changed'>): Partial<Stats> {
57  const pick = (kind: string): Limit | undefined => {
58    const r = u.rateLimits.find(l => l.kind === kind)
59    return r && { percent: r.percentUsed, resetsAt: r.resetsAt }
60  }
61  return {
62    fiveHour: pick('five_hour'),
63    sevenDay: pick('seven_day'),
64    contextPercent: u.context.percent,
65    contextTokens: u.context.tokens,
66    contextWindow: u.context.window,
67    costUsd: u.cost?.usd,
68  }
69}
70
71export const register: Register = on => {
72  on('session.start', async ($, e, next) => {
73    moodAt = Date.now()
74    const [usage, model, prompts, version, git] = await Promise.all([
75      $.session.usage(),
76      $.session.model(),
77      $.session.turns(),
78      $.session.version(),
79      readGit(argv => $.process.run(argv)),
80    ])
81    await update($, stats, s => ({
82      ...s,
83      ...fromUsage(usage),
84      model,
85      prompts,
86      version: version.base ?? version.version,
87      startedAt: usage.startedAt,
88      git,
89    }))
90
91    $.clock.every(CLOCK_MS, () => $.ui.invalidate('ui.render'))
92
93    let busy = false
94    $.clock.every(FRAME_MS, () => {
95      if (!site || busy) return
96      busy = true
97      void $.ui
98        .blit({ requestId: site, key: 'scene', source: frame() })
99        // Not mounted, or the terminal can't show pixels: wait for the next render.
100        .then(r => { if (r.deny) site = undefined })
101        .finally(() => { busy = false })
102    })
103    return next(e)
104  })
105
106  on('session.measure', async ($, e, next) => {
107    await update($, stats, s => ({ ...s, ...fromUsage(e) }))
108    return next(e)
109  })
110
111  on('turn.start', async ($, e, next) => {
112    setMood('working')
113    const prompts = await $.session.turns()
114    await update($, stats, s => ({ ...s, prompts }))
115    return next(e)
116  })
117
118  on('turn.complete', async ($, e, next) => {
119    if (!e.agentId) {
120      setMood('done')
121      const git = await readGit(argv => $.process.run(argv))
122      await update($, stats, s => ({ ...s, lastTurnMs: e.durationMs, git }))
123    }
124    return next(e)
125  })
126
127  on('turn.step', async function* ($, e, next) {
128    if (!e.agentId) {
129      const effort = e.effort === undefined ? undefined : String(e.effort)
130      await update($, stats, s => ({ ...s, model: e.model, effort }))
131    }
132    return yield* next(e)
133  })
134
135  on('tool.call', async ($, e, next) => {
136    const res = await next(e)
137    const tool = String(e.tool)
138    await update($, stats, s => ({ ...s, tools: s.tools + 1 }))
139    if (GIT_TOOLS.has(tool)) {
140      const git = await readGit(argv => $.process.run(argv))
141      await update($, stats, s => ({ ...s, git }))
142    }
143    if (!EDIT_TOOLS.has(tool) || res.result === undefined || res.isError) return res
144
145    const r = res.result as { filePath?: string; type?: string; content?: string; structuredPatch?: { lines: string[] }[] }
146    let added = 0
147    let removed = 0
148    for (const hunk of r.structuredPatch ?? []) {
149      for (const line of hunk.lines) {
150        if (line.startsWith('+')) added++
151        else if (line.startsWith('-')) removed++
152      }
153    }
154    if (r.type === 'create' && r.content) added += r.content.split('\n').length
155    const path = r.filePath
156    await update($, stats, s => ({
157      ...s,
158      added: s.added + added,
159      removed: s.removed + removed,
160      files: path && !s.files.includes(path) ? [...s.files, path] : s.files,
161    }))
162    return res
163  })
164
165  on('ui.render', { component: SITE }, async ($, e, next) => {
166    if (e.surface !== 'terminal') return next(e)
167    const el = $.ui.resolve(e)
168    const { Box, Text, Image } = el
169    const s = await read($, stats)
170    const cols = e.viewport?.columns ?? 120
171    const showScene = cols >= MIN_COLS_FOR_SCENE
172    site = showScene ? e.requestId : undefined
173    const room = cols - MODE_LABEL_COLS - (showScene ? SCENE_COLS + 2 : 0)
174
175    return (
176      <Box flexDirection="row" gap={2}>
177        <Box flexDirection="column" flexGrow={1} flexShrink={1}>
178          {statsGrid(el, s, columnsFor(room), Date.now())}
179          <Text dimColor wrap="truncate">{e.props.hint}</Text>
180        </Box>
181        {showScene ? (
182          <Image key="scene" source={frame()} columns={SCENE_COLS} rows={SCENE_ROWS} alt={`clawd ${mood}`} />
183        ) : null}
184      </Box>
185    )
186  })
187}
188
hooks/dash.tsx 144 lines
1// The stats grid drawn under the prompt: limits, session, and repo columns.
2import type { Elements } from 'claude-code'
3
4import type { Limit, Stats } from '../types'
5import { COLOR, effortColor, elapsed, gauge, level, modelName, tokens, until, usd } from './format'
6
7type El = Elements['terminal']
8
9// Narrowest each column may get; the columns share any extra width evenly.
10const LIMITS_W = 40
11const SESSION_W = 32
12const REPO_W = 30
13const SEP_W = 3
14const BAR_CELLS = 14
15const BRANCH_MAX = 22
16
17// How many stat columns fit in `cols` terminal columns.
18export function columnsFor(cols: number) {
19  if (cols >= LIMITS_W + SESSION_W + REPO_W + SEP_W * 2) return 3
20  if (cols >= LIMITS_W + SESSION_W + SEP_W) return 2
21  return 1
22}
23
24export function statsGrid({ Box, Text }: El, s: Stats, columns: number, now: number) {
25  const row = (key: string, ...parts: (JSX.Element | null)[]) => (
26    <Box key={key} gap={1} flexWrap="nowrap" overflow="hidden">{parts}</Box>
27  )
28  const sep = (key: string) => (
29    <Box key={key} flexDirection="column" width={SEP_W} alignItems="center">
30      <Text color={COLOR.faint}>│</Text>
31      <Text color={COLOR.faint}>│</Text>
32      <Text color={COLOR.faint}>│</Text>
33    </Box>
34  )
35  const dot = <Text color={COLOR.faint}>·</Text>
36
37  const meter = (label: string, labelColor: string, p: number, extra: JSX.Element | null) => {
38    const g = gauge(p, BAR_CELLS)
39    const c = level(p)
40    return row(
41      label,
42      <Text bold color={labelColor}>{label.padEnd(7)}</Text>,
43      <Box>
44        {g.full ? <Text color={c}>{g.full}</Text> : null}
45        {g.part ? <Text color={c} backgroundColor={COLOR.track}>{g.part}</Text> : null}
46        {g.rest ? <Text backgroundColor={COLOR.track}>{g.rest}</Text> : null}
47      </Box>,
48      <Text bold color={COLOR.text}>{`${Math.round(p)}%`.padStart(4)}</Text>,
49      extra,
50    )
51  }
52
53  const limit = (label: string, labelColor: string, l: Limit | undefined) =>
54    l
55      ? meter(label, labelColor, l.percent, l.resetsAt ? (
56          <Box gap={1}>
57            <Text color={COLOR.gold}>↻</Text>
58            <Text color={COLOR.soft}>{until(l.resetsAt, now)}</Text>
59          </Box>
60        ) : null)
61      : row(label, <Text bold color={labelColor}>{label.padEnd(7)}</Text>, <Text color={COLOR.dim}>waiting for first reading</Text>)
62
63  const ctx = s.contextPercent ?? 0
64  const limits = (
65    <Box key="limits" flexDirection="column" minWidth={LIMITS_W} flexGrow={1} flexShrink={0}>
66      {limit('5h', COLOR.sky, s.fiveHour)}
67      {limit('7d', COLOR.violet, s.sevenDay)}
68      {meter('context', COLOR.teal, ctx, s.contextTokens !== undefined && s.contextWindow ? (
69        <Box>
70          <Text color={COLOR.teal}>{tokens(s.contextTokens)}</Text>
71          <Text color={COLOR.dim}>{`/${tokens(s.contextWindow)}`}</Text>
72        </Box>
73      ) : null)}
74    </Box>
75  )
76
77  const session = (
78    <Box key="session" flexDirection="column" minWidth={SESSION_W} flexGrow={1} flexShrink={0}>
79      {row(
80        'model',
81        <Text bold color={COLOR.accent}>{s.model ? modelName(s.model) : '…'}</Text>,
82        s.effort ? dot : null,
83        s.effort ? <Text bold color={effortColor(s.effort)}>{s.effort}</Text> : null,
84      )}
85      {row(
86        'time',
87        <Text bold color={COLOR.gold}>{usd(s.costUsd ?? 0)}</Text>,
88        dot,
89        <Text color={COLOR.dim}>up</Text>,
90        <Text color={COLOR.sky}>{s.startedAt ? elapsed(now - s.startedAt) : '…'}</Text>,
91        s.lastTurnMs !== undefined ? dot : null,
92        s.lastTurnMs !== undefined ? <Text color={COLOR.dim}>last</Text> : null,
93        s.lastTurnMs !== undefined ? <Text color={COLOR.rose}>{elapsed(s.lastTurnMs)}</Text> : null,
94      )}
95      {row(
96        'counts',
97        <Text bold color={COLOR.violet}>{String(s.prompts ?? 0)}</Text>,
98        <Text color={COLOR.dim}>{s.prompts === 1 ? 'prompt' : 'prompts'}</Text>,
99        dot,
100        <Text bold color={COLOR.teal}>{String(s.tools)}</Text>,
101        <Text color={COLOR.dim}>{s.tools === 1 ? 'tool' : 'tools'}</Text>,
102      )}
103    </Box>
104  )
105
106  const git = s.git
107  const branch = git && git.branch.length > BRANCH_MAX ? `${git.branch.slice(0, BRANCH_MAX - 1)}…` : git?.branch
108  const fileCount = s.files.length
109  const repo = (
110    <Box key="repo" flexDirection="column" minWidth={REPO_W} flexGrow={1} flexShrink={0}>
111      {git
112        ? row(
113            'git',
114            <Text color={COLOR.violet}>⎇</Text>,
115            <Text bold color={COLOR.violet}>{branch}</Text>,
116            git.ahead ? <Text color={COLOR.ok}>{`↑${git.ahead}`}</Text> : null,
117            git.behind ? <Text color={COLOR.warn}>{`↓${git.behind}`}</Text> : null,
118            git.dirty
119              ? <Text color={COLOR.warn}>{`●${git.dirty}`}</Text>
120              : <Text color={COLOR.ok}>✓</Text>,
121          )
122        : row('git', <Text color={COLOR.dim}>not a git repo</Text>)}
123      {row(
124        'edits',
125        <Text bold color={COLOR.ok}>{`+${s.added}`}</Text>,
126        <Text bold color={COLOR.bad}>{`−${s.removed}`}</Text>,
127        dot,
128        <Text color={COLOR.soft}>{`${fileCount} ${fileCount === 1 ? 'file' : 'files'}`}</Text>,
129      )}
130      {row('version', <Text color={COLOR.dim}>{s.version ? `claude code v${s.version}` : ''}</Text>)}
131    </Box>
132  )
133
134  return (
135    <Box flexDirection="row" flexGrow={1} flexShrink={1}>
136      {limits}
137      {columns >= 2 ? sep('sep1') : null}
138      {columns >= 2 ? session : null}
139      {columns >= 3 ? sep('sep2') : null}
140      {columns >= 3 ? repo : null}
141    </Box>
142  )
143}
144
hooks/git.ts 28 lines
1// Branch, ahead/behind and dirty count from one `git status --porcelain --branch`.
2import type { Git } from '../types'
3
4export function parseStatus(out: string): Git {
5  const [head = '', ...rest] = out.split('\n')
6  const line = head.replace(/^## /, '')
7  const branch = line.match(/^(?:No commits yet|Initial commit) on (.+)$/)?.[1]
8    ?? (line.startsWith('HEAD (no branch)') ? 'detached' : line.split('...')[0]!.split(' ')[0]!)
9  return {
10    branch,
11    ahead: Number(line.match(/ahead (\d+)/)?.[1] ?? 0),
12    behind: Number(line.match(/behind (\d+)/)?.[1] ?? 0),
13    dirty: rest.filter(l => l.trim()).length,
14  }
15}
16
17type Run = (argv: string[]) => Promise<{ exitCode: number | null; stdout: string }>
18
19// null outside a repository, or when git can't run here.
20export async function readGit(run: Run): Promise<Git | null> {
21  try {
22    const r = await run(['git', 'status', '--porcelain=v1', '--branch'])
23    return r.exitCode === 0 ? parseStatus(r.stdout) : null
24  } catch {
25    return null
26  }
27}
28
hooks/raster.ts 91 lines
1// Turns one frame from scene.ts (an SVG of <g>/<rect> only) into RGBA pixels,
2// since the terminal draws an Image, not an Svg. Supports exactly what the scene
3// emits: translate/scale transforms, group opacity, and solid #hex fills.
4
5type Mat = { a: number; d: number; e: number; f: number }
6type Ctx = { m: Mat; o: number }
7
8const TAG = /<(\/?)(\w+)([^>]*?)\/?>/g
9const ATTR = /([\w-]+)="([^"]*)"/g
10const STEP = /(translate|scale)\(([^)]*)\)/g
11
12function attrs(src: string): Record<string, string> {
13  const out: Record<string, string> = {}
14  for (const [, k, v] of src.matchAll(ATTR)) out[k!] = v!
15  return out
16}
17
18function compose(m: Mat, transform: string | undefined): Mat {
19  if (!transform) return m
20  let r = m
21  for (const [, kind, args] of transform.matchAll(STEP)) {
22    const [x = 0, y] = args!.trim().split(/[\s,]+/).map(Number)
23    if (kind === 'translate') r = { ...r, e: r.a * x + r.e, f: r.d * (y ?? 0) + r.f }
24    else r = { ...r, a: r.a * x, d: r.d * (y ?? x) }
25  }
26  return r
27}
28
29function hex(c: string): [number, number, number] {
30  const s = c.replace('#', '')
31  const full = s.length === 3 ? s.split('').map(ch => ch + ch).join('') : s
32  const n = parseInt(full, 16)
33  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
34}
35
36/**
37 * Rasterizes `svg` (viewBox 0 0 w h) into RGBA, keeping columns [cropX, w),
38 * each art pixel blown up to `scale` x `scale` so the terminal scales crisply.
39 */
40export function rasterize(svg: string, w: number, h: number, cropX: number, scale: number) {
41  const rgb = new Float32Array(w * h * 3)
42  const stack: Ctx[] = [{ m: { a: 1, d: 1, e: 0, f: 0 }, o: 1 }]
43
44  for (const [, close, name, rest] of svg.matchAll(TAG)) {
45    const top = stack[stack.length - 1]!
46    if (name === 'g') {
47      if (close) stack.pop()
48      else {
49        const at = attrs(rest!)
50        stack.push({ m: compose(top.m, at.transform), o: top.o * (at.opacity ? Number(at.opacity) : 1) })
51      }
52      continue
53    }
54    if (name !== 'rect') continue
55
56    const at = attrs(rest!)
57    const x = Number(at.x), y = Number(at.y), rw = Number(at.width), rh = Number(at.height)
58    const { a, d, e, f } = top.m
59    const xs = [a * x + e, a * (x + rw) + e], ys = [d * y + f, d * (y + rh) + f]
60    const x0 = Math.max(0, Math.round(Math.min(...xs))), x1 = Math.min(w, Math.round(Math.max(...xs)))
61    const y0 = Math.max(0, Math.round(Math.min(...ys))), y1 = Math.min(h, Math.round(Math.max(...ys)))
62    if (x0 >= x1 || y0 >= y1 || top.o <= 0) continue
63
64    const [r, g, b] = hex(at.fill ?? '#000')
65    const o = Math.min(1, top.o)
66    for (let py = y0; py < y1; py++) {
67      for (let px = x0; px < x1; px++) {
68        const i = (py * w + px) * 3
69        rgb[i] = r * o + rgb[i]! * (1 - o)
70        rgb[i + 1] = g * o + rgb[i + 1]! * (1 - o)
71        rgb[i + 2] = b * o + rgb[i + 2]! * (1 - o)
72      }
73    }
74  }
75
76  const ow = (w - cropX) * scale
77  const oh = h * scale
78  const out = new Uint8Array(ow * oh * 4)
79  for (let oy = 0; oy < oh; oy++) {
80    for (let ox = 0; ox < ow; ox++) {
81      const i = (Math.floor(oy / scale) * w + cropX + Math.floor(ox / scale)) * 3
82      const j = (oy * ow + ox) * 4
83      out[j] = rgb[i]!
84      out[j + 1] = rgb[i + 1]!
85      out[j + 2] = rgb[i + 2]!
86      out[j + 3] = 255
87    }
88  }
89  return { rgba: out, width: ow, height: oh }
90}
91
hooks/scene.ts 598 lines
1// Pixel-art scenes for the mascot. Each call draws ONE still frame of a mood at
2// time `t` (seconds since the mood began): every pixel is a <rect> in a 100x18 grid
3// drawn at 3x, and the motion comes from keyframe tracks evaluated here, so a
4// redraw of the band can never restart the animation.
5
6import type { Mood } from '../types'
7
8// The band's own color, painted behind the scene.
9export const BAND_BG = '#212121'
10
11const W = 100
12const H = 18
13
14const PAL: Record<string, string> = {
15  o: '#d97757', // claude orange
16  O: '#b65a3c', // orange shade
17  e: '#1a1a1a', // eyes
18  K: '#262626',
19  k: '#121212',
20  w: '#f4efe4',
21  y: '#f7dc6f',
22  Y: '#e8a33d',
23  t: '#c9a46a',
24  b: '#8a5a2b',
25  B: '#5b3a1c',
26  g: '#8a8a8a',
27  G: '#4a4a4a',
28  c: '#7fd6e8',
29  C: '#9fd8f0',
30  n: '#5fbf6a',
31  N: '#2f7d3a',
32  r: '#e5534b',
33  R: '#a8322c',
34  p: '#f08fb0',
35  u: '#5a8fe6',
36  U: '#2d4f9e',
37  V: '#1b2c5c',
38  l: '#c3c7cf',
39  L: '#7d838c',
40  P: '#efe8d6',
41  s: '#3a2a52',
42  m: '#7d3b63',
43  d: '#d9673f',
44  f: '#f2a14a',
45  h: '#ffd27a',
46  v: '#9b6be0',
47}
48
49const fill = (c: string) => PAL[c] ?? c
50const rect = (x: number, y: number, w: number, h: number, c: string) =>
51  `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill(c)}"/>`
52
53// An ASCII sprite: one char per pixel, '.' is transparent; runs merge into one rect.
54function spr(rows: string[], x = 0, y = 0): string {
55  let out = ''
56  rows.forEach((row, j) => {
57    let i = 0
58    while (i < row.length) {
59      const c = row[i]
60      if (c === '.' || c === ' ') {
61        i++
62        continue
63      }
64      let k = i
65      while (k < row.length && row[k] === c) k++
66      out += rect(x + i, y + j, k - i, 1, c)
67      i = k
68    }
69  })
70  return out
71}
72
73// Deterministic noise so the art is the same on every frame.
74const rnd = (n: number) => {
75  const s = Math.sin(n * 127.1 + 311.7) * 43758.5453
76  return s - Math.floor(s)
77}
78
79// ---------- keyframe tracks ----------
80
81type Pose = { tx?: number; ty?: number; o?: number; sx?: number; sy?: number }
82type Track = [number, Pose][]
83const BASE: Required<Pose> = { tx: 0, ty: 0, o: 1, sx: 1, sy: 1 }
84
85const KF: Record<string, Track> = {
86  bob: [[0, { ty: 0 }], [50, { ty: -1 }], [100, { ty: 0 }]],
87  hop: [[0, { ty: 0 }], [50, { ty: -3 }], [100, { ty: 0 }]],
88  wave: [[0, { ty: 0 }], [50, { ty: -2 }], [100, { ty: 0 }]],
89  blink: [[0, { o: 1 }], [92, { o: 1 }], [94, { o: 0 }], [97, { o: 0 }], [100, { o: 1 }]],
90  sweep: [[0, { tx: 0 }], [50, { tx: -2 }], [100, { tx: 0 }]],
91  dust: [[0, { tx: 0, ty: 0, o: 1 }], [100, { tx: -9, ty: -3, o: 0 }]],
92  tw: [[0, { o: 0 }], [30, { o: 1 }], [60, { o: 1 }], [100, { o: 0 }]],
93  rise: [[0, { tx: 0, ty: 0, o: 0 }], [15, { o: 1 }], [100, { tx: 0, ty: -10, o: 0 }]],
94  riseR: [[0, { tx: 0, ty: 0, o: 0 }], [15, { o: 1 }], [100, { tx: 4, ty: -9, o: 0 }]],
95  walk: [[0, { tx: -36 }], [45, { tx: 0 }], [50, { tx: 0 }], [95, { tx: -36 }], [100, { tx: -36 }]],
96  look: [[0, { tx: 1 }], [50, { tx: -1 }], [100, { tx: -1 }]],
97  scan: [[0, { tx: -1 }], [50, { tx: 1 }], [100, { tx: -1 }]],
98  grow: [[0, { sx: 0 }], [70, { sx: 1 }], [100, { sx: 1 }]],
99  growY: [[0, { sy: 0 }], [60, { sy: 1 }], [100, { sy: 1 }]],
100  pull: [[0, { ty: 9 }], [12, { ty: 0 }], [100, { ty: 0 }]],
101  fly: [[0, { tx: 0, ty: 0, o: 1 }], [100, { tx: -14, ty: -12, o: 0 }]],
102  dispatch: [[0, { tx: 0, o: 0 }], [6, { o: 1 }], [88, { o: 1 }], [100, { tx: -48, o: 0 }]],
103  fall: [[0, { tx: 0, ty: -4 }], [50, { tx: 2, ty: 10 }], [100, { tx: -1, ty: 24 }]],
104  dance: [
105    [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 }],
106    [62, { tx: -2, ty: -1 }], [75, { tx: 2, ty: 0 }], [87, { tx: -2, ty: -1 }], [100, { tx: 0, ty: 0 }],
107  ],
108  sink: [[0, { ty: 0 }], [100, { ty: 3 }]],
109  swing: [[0, { tx: -1 }], [50, { tx: 1 }], [100, { tx: -1 }]],
110  on: [[0, { o: 1 }], [50, { o: 0 }], [100, { o: 0 }]],
111  off: [[0, { o: 0 }], [50, { o: 1 }], [100, { o: 1 }]],
112  leap: [
113    [0, { tx: 0, ty: 0, o: 0 }], [78, { tx: 0, ty: 0, o: 0 }], [82, { tx: -2, ty: -4, o: 1 }],
114    [88, { tx: -4, ty: -5, o: 1 }], [94, { tx: -6, ty: 0, o: 0 }], [100, { tx: 0, ty: 0, o: 0 }],
115  ],
116  drift: [[0, { tx: 0, ty: 0 }], [100, { tx: -70, ty: -2 }]],
117  zoom: [[0, { tx: 0 }], [100, { tx: -72 }]],
118  flip: [[0, { sx: 1 }], [60, { sx: 1 }], [70, { sx: 0.5 }], [80, { sx: -0.5 }], [90, { sx: -1 }], [100, { sx: -1 }]],
119  carry: [[0, { tx: 0 }], [8, { tx: 0 }], [45, { tx: -22 }], [55, { tx: -22 }], [92, { tx: 0 }], [100, { tx: 0 }]],
120  pace: [[0, { tx: 0 }], [50, { tx: -6 }], [100, { tx: 0 }]],
121  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 }]],
122  dash: [[0, { tx: 0 }], [100, { tx: -8 }]],
123  flipfood: [[0, { ty: 0 }], [70, { ty: 0 }], [80, { ty: -3 }], [90, { ty: -2 }], [100, { ty: 0 }]],
124  vis2: [[0, { o: 1 }], [50, { o: 0 }], [100, { o: 0 }]],
125}
126const visTrack = (n: number): Track => [[0, { o: 1 }], [100 / n, { o: 0 }], [100, { o: 0 }]]
127
128type Timing = { kind: 'step' } | { kind: 'linear' } | { kind: 'steps'; n: number }
129type Anim = { track: Track; dur: number; delay: number; timing: Timing; count: number; fwd: boolean; back: boolean }
130
131const animCache = new Map<string, Anim>()
132// Reads a CSS-style shorthand: "bob 1s step-end infinite -0.5s", "fall 2s steps(12) 0.4s 2 both".
133function parseAnim(def: string): Anim {
134  const hit = animCache.get(def)
135  if (hit) return hit
136  const parts = def.trim().split(/\s+/)
137  const name = parts[0]
138  const track = KF[name] ?? (name.startsWith('vis') ? visTrack(Number(name.slice(3))) : [[0, {}], [100, {}]] as Track)
139  const times: number[] = []
140  let timing: Timing = { kind: 'linear' }
141  let count = 1
142  let fwd = false
143  let back = false
144  for (const p of parts.slice(1)) {
145    if (/^-?[\d.]+s$/.test(p)) times.push(parseFloat(p))
146    else if (p === 'step-end') timing = { kind: 'step' }
147    else if (p === 'linear') timing = { kind: 'linear' }
148    else if (p.startsWith('steps(')) timing = { kind: 'steps', n: parseInt(p.slice(6), 10) }
149    else if (p === 'infinite') count = Infinity
150    else if (/^\d+$/.test(p)) count = Number(p)
151    else if (p === 'forwards') fwd = true
152    else if (p === 'backwards') back = true
153    else if (p === 'both') fwd = back = true
154  }
155  const a: Anim = { track, dur: times[0] ?? 1, delay: times[1] ?? 0, timing, count, fwd, back }
156  animCache.set(def, a)
157  return a
158}
159
160function sampleProp(track: Track, key: keyof Pose, pct: number, timing: Timing): number {
161  const pts = track.filter(([, p]) => p[key] !== undefined).map(([at, p]) => [at, p[key] as number] as const)
162  if (!pts.length) return BASE[key]
163  if (pts[0][0] > 0) pts.unshift([0, BASE[key]])
164  if (pts[pts.length - 1][0] < 100) pts.push([100, BASE[key]])
165  for (let i = 0; i < pts.length - 1; i++) {
166    const [a, va] = pts[i]
167    const [b, vb] = pts[i + 1]
168    if (pct >= a && pct < b) {
169      let f = (pct - a) / (b - a)
170      if (timing.kind === 'step') f = 0
171      else if (timing.kind === 'steps') f = Math.floor(f * timing.n) / timing.n
172      return va + (vb - va) * f
173    }
174  }
175  return pts[pts.length - 1][1]
176}
177
178// The pose of an animation at time t, or null where it has no effect (before or after its run).
179function poseAt(def: string, t: number): Required<Pose> | null {
180  const a = parseAnim(def)
181  const el = t - a.delay
182  let pct: number
183  if (el < 0) {
184    if (!a.back) return null
185    pct = 0
186  } else if (el >= a.dur * a.count) {
187    if (!a.fwd) return null
188    pct = 100
189  } else {
190    pct = ((el % a.dur) / a.dur) * 100
191  }
192  const keys: (keyof Pose)[] = ['tx', 'ty', 'o', 'sx', 'sy']
193  const out = { ...BASE }
194  for (const k of keys) out[k] = pct === 100 ? lastValue(a.track, k) : sampleProp(a.track, k, pct, a.timing)
195  return out
196}
197function lastValue(track: Track, key: keyof Pose): number {
198  for (let i = track.length - 1; i >= 0; i--) if (track[i][1][key] !== undefined) return track[i][1][key] as number
199  return BASE[key]
200}
201
202const n2 = (v: number) => Math.round(v * 100) / 100
203
204class Frame {
205  readonly t: number
206  constructor(t: number) {
207    this.t = t
208  }
209  // A group posed by `anim` at this frame, placed at (x, y); `origin` is where it scales from.
210  g(anim: string | null, inner: string, x = 0, y = 0, origin: [number, number] = [0, 0]): string {
211    let body = inner
212    if (anim) {
213      const p = poseAt(anim, this.t)
214      if (p) {
215        if (p.o <= 0.02) return ''
216        const tf: string[] = []
217        if (p.tx || p.ty) tf.push(`translate(${n2(p.tx)} ${n2(p.ty)})`)
218        if (p.sx !== 1 || p.sy !== 1) {
219          tf.push(`translate(${origin[0]} ${origin[1]}) scale(${n2(p.sx) || 0.001} ${n2(p.sy) || 0.001}) translate(${-origin[0]} ${-origin[1]})`)
220        }
221        const attrs = (tf.length ? ` transform="${tf.join(' ')}"` : '') + (p.o < 1 ? ` opacity="${n2(p.o)}"` : '')
222        if (attrs) body = `<g${attrs}>${inner}</g>`
223      }
224    }
225    return x || y ? `<g transform="translate(${x} ${y})">${body}</g>` : body
226  }
227  // Shows sub-scene i of n for its slice of a T-second cycle, building only the one on screen.
228  cycle(scenes: (() => string)[], T: number): string {
229    const n = scenes.length
230    const i = Math.floor((((this.t % T) + T) % T) / (T / n))
231    return scenes[i]()
232  }
233}
234
235// ---------- the mascot ----------
236
237type Eyes = 'normal' | 'look' | 'lookL' | 'lookR' | 'down' | 'focus' | 'happy' | 'shades' | 'closed' | 'scan'
238type ClawdOpts = {
239  eyes?: Eyes
240  bob?: string | null
241  armL?: string | null
242  armR?: string | null
243  armLUp?: boolean
244  armRUp?: boolean
245  walk?: boolean
246  sit?: boolean
247  extra?: string
248}
249
250function eyes(f: Frame, kind: Eyes): string {
251  const pair = rect(4, 1, 1, 2, 'e') + rect(11, 1, 1, 2, 'e')
252  switch (kind) {
253    case 'normal':
254      return f.g('blink 4s step-end infinite', pair)
255    case 'look':
256      return f.g('look 8s step-end infinite', pair)
257    case 'scan':
258      return f.g('scan .9s step-end infinite', pair)
259    case 'lookL':
260      return f.g('blink 3.5s step-end infinite', rect(3, 1, 1, 2, 'e') + rect(10, 1, 1, 2, 'e'))
261    case 'lookR':
262      return f.g('blink 3.1s step-end infinite', rect(5, 1, 1, 2, 'e') + rect(12, 1, 1, 2, 'e'))
263    case 'down':
264      return rect(3, 2, 1, 2, 'e') + rect(10, 2, 1, 2, 'e')
265    case 'focus':
266      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')
267    case 'happy':
268      return spr(['.e......e.', 'e.e....e.e'], 3, 1)
269    case 'closed':
270      return rect(3, 2, 2, 1, 'e') + rect(10, 2, 2, 1, 'e')
271    case 'shades':
272      return spr(['kkkkk.kkkkk', 'kkkkkkkkkkk', '.kkk...kkk.'], 2, 1) + rect(3, 1, 1, 1, 'w') + rect(9, 1, 1, 1, 'w')
273  }
274}
275
276// 16x8: body 12x6, arms 2x2 at the sides, four 1x2 legs.
277function clawd(f: Frame, x: number, y: number, o: ClawdOpts = {}): string {
278  const body = rect(2, 0, 12, 6, 'o') + rect(2, 5, 12, 1, 'O')
279  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'))
280  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'))
281  let legs = ''
282  if (!o.sit) {
283    const a = rect(3, 6, 1, 2, 'o') + rect(10, 6, 1, 2, 'o')
284    const c = rect(5, 6, 1, 2, 'o') + rect(12, 6, 1, 2, 'o')
285    legs = o.walk ? f.g('bob .3s step-end infinite', a) + f.g('bob .3s step-end infinite -.15s', c) : a + c
286  }
287  return f.g(o.bob ?? null, legs + body + armL + armR + eyes(f, o.eyes ?? 'normal') + (o.extra ?? ''), x, y)
288}
289
290// 8x5 helper mascot.
291function mini(f: Frame, x: number, y: number, carry = '', walk = true): string {
292  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')
293  const la = rect(2, 3, 1, 2, 'o')
294  const lb = rect(5, 3, 1, 2, 'o')
295  const legs = walk ? f.g('bob .3s step-end infinite', la) + f.g('bob .3s step-end infinite -.15s', lb) : la + lb
296  return `<g transform="translate(${x} ${y})">${legs}${body}${carry}</g>`
297}
298
299const SPARK = ['.y.', 'yyy', '.y.']
300const 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)
301
302const BANG = ['.wwww.', 'ww.www', 'ww.www', 'wwwwww', 'ww.www', '.wwww.', '.w....']
303const BOX = ['BbbbB', 'bbYbb', 'bbbbb', 'BbbbB']
304const SUN = ['..hhhh..', '.hhhhhh.', 'hhyyyyhh', 'hyyyyyyh', 'hyyyyyyh']
305
306// A horizontally banded backdrop for the right of the band, its left edge dithered.
307function backdrop(bands: [string, number][], x0 = 30): string {
308  let out = ''
309  let y = 0
310  for (const [c, h] of bands) {
311    for (let j = 0; j < h; j++, y++) {
312      out += rect(x0 + 4, y, W - x0 - 4, 1, c)
313      for (let dx = 0; dx < 4; dx++) {
314        const xx = x0 + dx
315        const on = dx === 3 ? (xx + y) % 4 !== 1 : dx === 2 ? (xx + y) % 2 === 0 : dx === 1 ? (xx + y) % 4 === 0 : false
316        if (on) out += rect(xx, y, 1, 1, c)
317      }
318    }
319  }
320  return out
321}
322
323// The dotted field that fades in from the left, as in the band's art.
324function dots(f: Frame): string {
325  let out = ''
326  for (let x = 0; x < W; x += 2) {
327    for (let y = 1; y < H; y += 2) {
328      const r = rnd(x * 31 + y)
329      const p = Math.pow(x / W, 1.6) * 0.6
330      if (r < p) {
331        out += r < p * 0.12
332          ? 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'))
333          : rect(x, y, 1, 1, r < p * 0.35 ? '#3c3c3c' : '#2c2c2c')
334      }
335    }
336  }
337  return out
338}
339
340const stars = (f: Frame, n: number, seed: number) =>
341  Array.from({ length: n }, (_, i) => {
342    const x = 36 + Math.floor(rnd(seed + i * 4.3) * 62)
343    const y = Math.floor(rnd(seed + i * 8.9) * 8)
344    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'))
345  }).join('')
346
347const palm = (x: number) =>
348  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)
349
350// ---------- idle: sweeping, walking, phone, chatting, reading ----------
351
352function idle(f: Frame): string {
353  const sweeping = () =>
354    clawd(f, 60, 10, { bob: 'bob 1.2s step-end infinite' }) +
355    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)) +
356    [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('') +
357    sparkle(f, 86, 3, 0) + sparkle(f, 92, 9, 0.6) + sparkle(f, 83, 12, 1.1) +
358    f.g('tw 1.2s step-end infinite -.3s', spr(['yyy', '.y.']), 90, 1)
359
360  const walking = () => {
361    const butterfly = f.g('on .3s step-end infinite', spr(['v.v', '.k.'])) + f.g('off .3s step-end infinite', spr(['...', 'vkv']))
362    return (
363      f.g('walk 8s steps(18) infinite', clawd(f, 70, 10, { walk: true, eyes: 'look' })) +
364      f.g('walk 8s steps(18) infinite -.5s', f.g('bob .9s step-end infinite', butterfly), 82, 4)
365    )
366  }
367
368  const phone = () =>
369    clawd(f, 66, 10, { eyes: 'down', bob: 'bob 2.4s step-end infinite' }) +
370    spr(['KKKK', 'KccK', 'KccK', 'KccK', 'KKKK'], 62, 9) +
371    f.g('on .6s step-end infinite', rect(63, 10, 2, 1, 'w') + rect(63, 12, 1, 1, 'w')) +
372    f.g('off .6s step-end infinite', rect(63, 11, 2, 1, 'w') + rect(64, 13, 1, 1, 'w')) +
373    f.g('rise 2.4s linear infinite', spr(['p.p', 'ppp', '.p.']), 60, 6) +
374    f.g('rise 2.4s linear infinite -1.2s', spr(['y', 'y', '.', 'y']), 64, 5) +
375    f.g('rise 2.4s linear infinite -.6s', spr(['nnn', 'n.n', 'nnn']), 56, 7)
376
377  const chatting = () =>
378    clawd(f, 40, 10, { eyes: 'lookR', bob: 'bob .6s step-end infinite', armR: 'wave 1.2s step-end infinite' }) +
379    clawd(f, 64, 10, { eyes: 'lookL', bob: 'bob .6s step-end infinite -.3s', armL: 'wave 1.2s step-end infinite -.6s' }) +
380    f.g('on 2.4s step-end infinite', spr(['.wwwwwww.', 'wwwwwwwww', 'wwKwKwKww', 'wwwwwwwww', '.wwwwwww.', '..ww.....', '..w......'], 48, 1)) +
381    f.g('off 2.4s step-end infinite', spr(['.wwwwwww.', 'wwwpwpwww', 'wwwpppwww', 'wwwwpwwww', '.wwwwwww.', '.....ww..', '......w..'], 60, 1)) +
382    f.g('tw 4.8s step-end infinite -2s', spr(['y.y', '.y.', 'y.y']), 58, 9)
383
384  const reading = () =>
385    clawd(f, 64, 10, { eyes: 'down', bob: 'bob 3s step-end infinite' }) +
386    spr(['UwwwwUwwwwU', 'UwgggUwgggU', 'UwwwwUwwwwU', 'UwgggUwggwU', 'UUUUUUUUUUU'], 55, 11) +
387    f.g('flip 3s step-end infinite', spr(['wwww', 'wggw', 'wwww', 'wgww']), 61, 11) +
388    f.g('tw 3s step-end infinite -1s', spr(['.y.', 'yyy', 'yyy', '.w.']), 80, 3)
389
390  return f.cycle([sweeping, walking, phone, chatting, reading], 40)
391}
392
393// ---------- working: laptop, papers, delegating, boxes, office, analysis ----------
394
395function working(f: Frame): string {
396  const typing = () => {
397    const lines = [4, 6, 3, 5]
398      .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]))
399      .join('')
400    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'))
401    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('')
402    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' })
403  }
404
405  const papers = () => {
406    const sheet = spr(['PPP', 'PgP', 'PPP', 'PgP'])
407    return (
408      spr(['PPPPPP', 'PggggP', 'PPPPPP', 'PgggPP', 'PPPPPP', 'PggggP'], 50, 12) +
409      [0, 0.5, 1].map(d => f.g(`fly 1.5s steps(7) infinite ${-d}s`, sheet, 52, 10)).join('') +
410      clawd(f, 66, 10, { eyes: 'scan' }) +
411      f.g('bob 1.2s step-end infinite', spr(['PPPP', 'PggP', 'PPPP', 'PgPP', 'PPPP']), 63, 11) +
412      f.g('tw 2s step-end infinite', spr(['y', 'y', '.', 'y']), 72, 5)
413    )
414  }
415
416  const delegate = () => {
417    const box = rect(2, -2, 4, 2, 'b') + rect(3, -2, 2, 1, 'Y')
418    const doc = rect(2, -3, 3, 3, 'P') + rect(3, -2, 1, 1, 'g')
419    return (
420      clawd(f, 74, 10, { armLUp: true, armL: 'wave .6s step-end infinite' }) +
421      spr(BANG, 66, 2) +
422      [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('')
423    )
424  }
425
426  const boxes = () =>
427    spr(BOX, 32, 14) + spr(BOX, 38, 14) + spr(BOX, 35, 10) + spr(BOX, 86, 14) + spr(BOX, 92, 14) +
428    f.g('off 6s step-end infinite', spr(BOX, 41, 10)) +
429    f.g('carry 6s steps(24) infinite',
430      clawd(f, 66, 10, { walk: true, armLUp: true, armRUp: true, extra: f.g('on 6s step-end infinite', spr(BOX, 5, -4)) }))
431
432  const office = () =>
433    [28, 43, 58].map((x, i) =>
434      f.g(`bob .3s step-end infinite ${-i * 0.15}s`, mini(f, x, 13, '', false)) +
435      spr(['LLLLL', 'LkkkL', 'LkkkL', 'LLLLL', '..L..'], x + 8, 9) +
436      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')) +
437      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')) +
438      rect(x + 7, 14, 7, 1, 'b') + rect(x + 8, 15, 1, 3, 'B') + rect(x + 12, 15, 1, 3, 'B'),
439    ).join('') +
440    f.g('pace 4s steps(6) infinite', clawd(f, 80, 10, { walk: true, armLUp: true, armL: 'wave .6s step-end infinite' })) +
441    f.g('tw 2s step-end infinite', spr(BANG), 86, 1)
442
443  const analyze = () =>
444    rect(36, 2, 22, 13, 'L') + rect(37, 3, 20, 11, 'k') + rect(45, 15, 4, 2, 'L') + rect(42, 17, 10, 1, 'L') +
445    [2, 5, 3, 7, 4, 8]
446      .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]))
447      .join('') +
448    clawd(f, 68, 10, { eyes: 'focus', armLUp: true }) +
449    f.g('magscan 5s steps(10) infinite', spr(['.lll...', 'lcccl..', 'lcccl..', 'lcccl..', '.lllb..', '....bb.', '.....bb']), 58, 5) +
450    f.g('tw 5s step-end infinite', spr(['www', '..w', '.ww', '...', '.w.']), 78, 1)
451
452  return f.cycle([typing, papers, delegate, boxes, office, analyze], 36)
453}
454
455// ---------- task complete: one burst of confetti, jumping and dancing ----------
456
457export const DONE_SECONDS = 6.5
458
459function done(f: Frame): string {
460  const colors = ['r', 'y', 'c', 'n', 'p', 'v', 'f', 'u']
461  let confetti = ''
462  for (let i = 0; i < 34; i++) {
463    const x = 26 + Math.floor(rnd(i * 3.7) * 72)
464    const dur = (1.6 + rnd(i * 5.1) * 1.2).toFixed(2)
465    const d = (rnd(i * 9.3) * 1.2).toFixed(2)
466    const c = colors[i % colors.length]
467    const piece =
468      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)) +
469      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]))
470    confetti += f.g(`fall ${dur}s steps(12) ${d}s 2 both`, piece, x, 0)
471  }
472  return (
473    confetti +
474    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 })) +
475    f.g('hop .6s step-end 0s 9', mini(f, 52, 13, '', false)) +
476    f.g('hop .6s step-end -.3s 9', mini(f, 86, 13, '', false)) +
477    sparkle(f, 60, 2, 0, 0.9) + sparkle(f, 88, 4, 0.45, 0.9) + sparkle(f, 46, 6, 0.3, 0.9)
478  )
479}
480
481// ---------- relaxing: sunset chair, fishing, hammock, campfire, barbecue, sunset drive ----------
482
483function relax(f: Frame): string {
484  const sunset = () =>
485    backdrop([['s', 3], ['m', 3], ['d', 3], ['f', 3], ['U', 3], ['t', 3]]) +
486    f.g('sink 8s steps(3) infinite', spr(SUN), 76, 7) +
487    rect(34, 12, 66, 3, 'U') +
488    [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('') +
489    f.g('drift 16s linear infinite', spr(['K.K', '.K.']) + spr(['K.K', '.K.'], 5, 2), 96, 2) +
490    spr(['r......', 'wr.....', 'rwr....', 'wrwrwrw', 'b.....b', 'b.....b'], 46, 10) +
491    clawd(f, 46, 8, { eyes: 'shades', bob: 'bob 3s step-end infinite' }) +
492    spr(['..p..', '.ppp.', '..t..', 'cccc.', 'cccc.', '.cc..'], 63, 10) +
493    f.g('riseR 3s linear infinite', spr(['.ww', '.w.', 'ww.']), 58, 5) +
494    f.g('riseR 3s linear infinite -1.5s', spr(['ww', 'w.', 'w.']), 52, 5)
495
496  const fishing = () =>
497    backdrop([['V', 4], ['s', 4], ['V', 3], ['U', 7]]) +
498    stars(f, 14, 3) +
499    spr(['.ww.', 'wwww', 'wwww', '.ww.'], 90, 1) +
500    [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('') +
501    rect(38, 10, 26, 2, 'b') + rect(38, 10, 26, 1, 't') + rect(40, 12, 1, 6, 'B') + rect(60, 12, 1, 6, 'B') +
502    clawd(f, 46, 4, { eyes: 'closed', bob: 'bob 3s step-end infinite' }) +
503    spr(['.......t', '......t.', '.....t..', '....t...', '...t....', '..t.....', '.t......'], 60, 0) +
504    rect(68, 1, 1, 12, 'g') +
505    f.g('bob 1.2s step-end infinite', rect(67, 12, 3, 1, 'r') + rect(67, 13, 3, 1, 'w')) +
506    f.g('leap 8s steps(10) infinite', spr(['.uu.u', 'uuuuu', '.uu.u']), 86, 13)
507
508  const hammockScene = () => {
509    // The hammock sags between the palms: tied high at the trunks, lowest in the middle.
510    let hammock = ''
511    for (let x = 48; x <= 82; x++) {
512      const t = (x - 65) / 17
513      hammock += rect(x, Math.round(9 + 4 * (1 - t * t)), 1, 1, x % 3 ? 'w' : 'r')
514    }
515    return (
516      backdrop([['C', 11], ['c', 2], ['t', 5]]) +
517      palm(46) + palm(83) +
518      f.g('swing 3s step-end infinite', clawd(f, 57, 7, { eyes: 'closed', sit: true }) + hammock) +
519      f.g('riseR 3s linear infinite', spr(['www', '.w.', 'www']), 70, 2) +
520      f.g('riseR 3s linear infinite -1.5s', spr(['ww', 'ww']), 74, 3)
521    )
522  }
523
524  const campfire = () =>
525    backdrop([['V', 5], ['s', 5], ['m', 2], ['N', 6]]) +
526    stars(f, 12, 11) +
527    f.g('on .3s step-end infinite', spr(['..r...', '.rfr..', '.rfyr.', 'rfyyfr']), 70, 10) +
528    f.g('off .3s step-end infinite', spr(['...r..', '..rfr.', '.ryfr.', 'rfyyfr']), 70, 10) +
529    spr(['BB..BB', '.BBBB.', 'BB..BB'], 70, 14) +
530    [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('') +
531    f.g('on .6s step-end infinite', rect(64, 16, 18, 1, 'O')) +
532    rect(46, 15, 14, 3, 'B') +
533    clawd(f, 47, 9, { eyes: 'closed', bob: 'bob 3.6s step-end infinite' }) +
534    spr(['www.', 'wwwP', 'www.'], 61, 12) +
535    f.g('rise 2.4s linear infinite', spr(['g', '.', 'g']), 62, 9) +
536    f.g('rise 2.4s linear infinite -1.2s', spr(['g', 'g']), 63, 9)
537
538  const barbecue = () => {
539    let fence = ''
540    for (let x = 35; x < W; x += 3) fence += rect(x, 7, 2, 6, 'w')
541    return (
542      backdrop([['C', 13], ['n', 2], ['N', 3]]) +
543      fence + rect(34, 9, 66, 1, 'w') +
544      spr(['KKKKKKKKKKKK', '.GGGGGGGGGG.', '..GGGGGGGG..', '...G....G...', '...G....G...', '..GG....GG..'], 46, 11) +
545      f.g('on .6s step-end infinite', rect(49, 12, 6, 1, 'd') + rect(56, 12, 1, 1, 'f')) +
546      f.g('off .6s step-end infinite', rect(48, 12, 2, 1, 'f') + rect(51, 12, 6, 1, 'd')) +
547      f.g('flipfood 2s steps(4) infinite', rect(47, 10, 3, 1, 'B')) +
548      rect(51, 10, 3, 1, 'r') +
549      f.g('flipfood 2s steps(4) infinite -1s', rect(55, 10, 2, 1, 'B')) +
550      [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('') +
551      clawd(f, 64, 10, { eyes: 'happy', extra: spr(['.wwwwww.', 'wwwwwwww', '.wwwwww.'], 4, -3) }) +
552      f.g('wave .6s step-end infinite', rect(60, 11, 4, 1, 't') + rect(58, 10, 2, 2, 'l'))
553    )
554  }
555
556  const drive = () => {
557    const palmSmall = rect(0, 4, 1, 8, 'B') + spr(['.N.N.', 'NNNNN', 'N.N.N'], -2, 2)
558    let dashes = ''
559    for (let x = 40; x < W + 8; x += 8) dashes += rect(x, 16, 4, 1, 'y')
560    const car = spr([
561      '.................c....',
562      '.................c....',
563      'rrrrrrrrrrrrrrrrrrrrrr',
564      'rrrrrrrrrrrrrrrrrrrrhh',
565      'RRRRRRRRRRRRRRRRRRRRRR',
566    ])
567    const wheels =
568      spr(['.KKK............KKK...', '.KKK............KKK...'], 0, 5) +
569      f.g('on .3s step-end infinite', rect(2, 5, 1, 1, 'g') + rect(18, 5, 1, 1, 'g')) +
570      f.g('off .3s step-end infinite', rect(2, 6, 1, 1, 'g') + rect(18, 6, 1, 1, 'g'))
571    return (
572      backdrop([['s', 3], ['m', 3], ['d', 3], ['f', 3], ['N', 2], ['G', 4]]) +
573      spr(SUN, 74, 8) + rect(34, 12, 66, 2, 'N') + rect(34, 14, 66, 4, 'G') +
574      f.g('dash .6s steps(4) infinite', dashes) +
575      f.g('zoom 2.4s steps(24) infinite', palmSmall, 100, 0) +
576      f.g('zoom 2.4s steps(24) infinite -1.2s', palmSmall, 100, 0) +
577      f.g('drift 16s linear infinite', spr(['K.K', '.K.']), 96, 2) +
578      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>`) +
579      [0, 0.3].map(d => f.g(`dust .6s linear infinite ${-d}s`, rect(0, 0, 2, 1, 'g'), 45, 13)).join('')
580    )
581  }
582
583  return f.cycle([sunset, fishing, hammockScene, campfire, barbecue, drive], 48)
584}
585
586// One still frame of `mood`, `t` seconds after it began.
587export function sceneSvg(mood: Mood, t: number): string {
588  const f = new Frame(t)
589  const body = mood === 'working' ? working(f) : mood === 'done' ? done(f) : mood === 'relax' ? relax(f) : idle(f)
590  return (
591    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W * 3}" height="${H * 3}" shape-rendering="crispEdges">` +
592    `<rect x="-2" y="-2" width="${W + 4}" height="${H + 4}" fill="${BAND_BG}"/>${dots(f)}${body}</svg>`
593  )
594}
595
596export const SCENE_WIDTH = W * 3
597export const SCENE_HEIGHT = H * 3
598
hooks/format.ts 69 lines
1// Small formatters and color tokens for the dashboard text.
2
3export const COLOR = {
4  text: '#ededed',
5  soft: '#c4c4c4',
6  dim: '#8a8a8a',
7  faint: '#4a4a4a',
8  track: '#333333',
9  accent: '#d97757',
10  ok: '#5fbf6a',
11  warn: '#e8a33d',
12  bad: '#e5534b',
13  sky: '#6cb6ff',
14  violet: '#b392f0',
15  teal: '#4fd1c5',
16  gold: '#e3c06a',
17  rose: '#f0809a',
18}
19
20export const level = (p: number) => (p >= 80 ? COLOR.bad : p >= 50 ? COLOR.warn : COLOR.ok)
21
22const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
23
24// A smooth bar in eighth-cell steps: `full` solid cells, one partial cell, then `rest` empty track cells.
25export function gauge(p: number, cells: number) {
26  const units = Math.min(cells * 8, Math.max(0, Math.round((p / 100) * cells * 8)))
27  const full = Math.floor(units / 8)
28  const part = EIGHTHS[units % 8]!
29  return { full: '█'.repeat(full), part, rest: ' '.repeat(cells - full - (part ? 1 : 0)) }
30}
31
32const EFFORT: Record<string, string> = { low: COLOR.sky, medium: COLOR.teal, high: COLOR.gold }
33
34export const effortColor = (effort: string) => EFFORT[effort] ?? COLOR.accent
35
36export function tokens(n: number) {
37  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
38  if (n >= 1000) return `${Math.round(n / 1000)}k`
39  return String(n)
40}
41
42function span(mins: number) {
43  const d = Math.floor(mins / 1440), h = Math.floor((mins % 1440) / 60), m = mins % 60
44  return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : `${m}m`
45}
46
47export function until(iso: string | undefined, now: number) {
48  if (!iso) return ''
49  return span(Math.max(0, Math.round((Date.parse(iso) - now) / 60000)))
50}
51
52// Elapsed time: seconds under a minute, then the same shape as `until`.
53export function elapsed(ms: number) {
54  const s = Math.max(0, Math.round(ms / 1000))
55  if (s < 60) return `${s}s`
56  if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`
57  return span(Math.floor(s / 60))
58}
59
60export const usd = (n: number) => `$${n < 10 ? n.toFixed(2) : n.toFixed(1)}`
61
62// "claude-opus-5-5[1m]" -> "Opus 5.5"; anything else passes through.
63export function modelName(id: string) {
64  const m = id.replace(/\[.*\]$/, '').match(/^claude-([a-z]+)-(\d+)(?:-(\d+))?/)
65  if (!m) return id
66  const family = m[1]!.charAt(0).toUpperCase() + m[1]!.slice(1)
67  return m[3] && m[3].length <= 2 ? `${family} ${m[2]}.${m[3]}` : `${family} ${m[2]}`
68}
69
types/index.d.ts 32 lines
1export type Mood = 'idle' | 'working' | 'done' | 'relax'
2
3export type Limit = { percent: number; resetsAt?: string }
4
5export type Git = { branch: string; ahead: number; behind: number; dirty: number }
6
7export type Stats = {
8  fiveHour?: Limit
9  sevenDay?: Limit
10  contextPercent?: number
11  contextTokens?: number
12  contextWindow?: number
13  costUsd?: number
14  model?: string
15  effort?: string
16  added: number
17  removed: number
18  files: string[]
19  startedAt?: number
20  prompts?: number
21  tools: number
22  lastTurnMs?: number
23  version?: string
24  git?: Git | null
25}
26
27declare module 'claude-code' {
28  interface PluginState {
29    'clawd-dash': { stats: Stats }
30  }
31}
32