SLOPSHOPPER

PixBar

A pixel creature above the prompt or in its own pane: the whale in a sea or clawd skating on land. Uncommitted work leaves plants and litter on the floor…

newpanebandspinnerguardcommand
v0.5.6MITupdated 2026-10-11ke11/claude-mods/pixbar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pixbar
│ ┃ whale ✕ › 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 │ ┃ │ ┃ /work/app M src/auth.ts ✘ claude-opus-5-5 › /pixbar │ ┃ ctx ▰▰▰▰▰▱▱▱▱▱ 49% 97k / 200k ⎿ pixbar: PixBar · whale · band │ ┃ 5h ▰▰▰▱▱▱▱▱▱▱ 31% 09:53 · in NaNm ⎿ pixbar: │ ⎿ pixbar: theme ● whale ○ clawd /pixbar theme clawd │ ⎿ pixbar: layout ● band ○ pane /pixbar layout pane │ ⎿ pixbar: c show or hide the context window │ │ ✻ Sounding… ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ /work/app M src/auth.ts ✘ claude-opus-5-5 ctx ▰▰▰▰▰▱▱▱▱▱ 49% 97k / 200k 5h ▰▰▰▱▱▱▱▱▱▱ 31% 09:53 · in NaNm ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ /work/app M src/auth.ts ✘ claude-opus-5-5 ctx ▰▰▰▰▰▱▱▱▱▱ 49% 97k / 200k 5h ▰▰▰▱▱▱▱▱▱▱ 31% 09:53 · in NaNm
Pane · whale
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ /work/app M src/auth.ts ✘ claude-opus-5-5 ctx ▰▰▰▰▰▱▱▱▱▱ 49% 97k / 200k 5h ▰▰▰▱▱▱▱▱▱▱ 31% 09:53 · in NaNm
README

PixBar

A pixel creature above your Claude Code prompt, or in a pane of its own: the whale in a sea, or clawd skating on land. It moves with the turn, fills with your context, rises with your five-hour usage window, and leaves litter on the floor until you commit.

Install, commands, skins and troubleshooting: the main README and pixbar.dev.

What it reads, writes and runs

PixBar makes no network calls and sends nothing anywhere.

Reads

  • What Claude Code passes a mod: the working folder, the model name, context and rate-limit usage, and when a turn or a tool call starts and ends. It does not read your prompts, the replies or tool results.
  • The HOME environment variable, to find ~/.claude/pixbar. No other variable, and no credential.
  • Your own skin files in ~/.claude/pixbar/skins/. They are read as text for their pixel rows and colours, and never run. The shipped creatures are text inside the mod (hooks/skins.ts): it reads no file of its own.

Writes no file. Your theme and the theme setting last seen in /plugin configure are kept in the mod's own store, which Claude Code holds for it. Versions before 0.5.4 wrote two one-line files, ~/.claude/pixbar/active and configured; they are still read when the store holds nothing, and never written.

Runs one program, every five seconds, in the session's working folder:

git status --porcelain=v1 -b

Its output gives the branch name and whether there is uncommitted work. It stays in the mod.

Hooks

  • command.run for its own /pixbar command only.
  • tool.call, to add a bubble when a tool starts. It never changes or blocks a call.
  • ui.render, to draw the band above the prompt, the pane and the spinner row.
  • ui.close, to move the creature back to the band when its pane is closed.
  • session.start, session.measure, turn.start, turn.complete, to follow the turn.
Source 3 files
hooks/register.tsx 510 lines
1import type { Elements, Register } from 'claude-code'
2
3import {
4  WHALE, LAND_WORDS, WORDS, SEA_H, bar, ctxColor, encodeCells, fmtTokens, levelFor, modeFromSpinner, mouthOf,
5  paintScene, parseSkin, resetAt, seaWidth, stepBubbles, stepSwim, toCells, until, useColor, widthOf, windowLabel,
6} from './scene'
7import type { Mode, SceneState, Skin } from './scene'
8import { SHIPPED } from './skins'
9
10// PixBar: a pixel creature in the band above the prompt, or in a pane of its own.
11//   context used      → its colour, filling from the belly up
12//   five-hour window  → in the sea, how high it swims; on land, the time of day
13//   uncommitted work  → plants and litter on the floor; committed, the floor is clean
14//   turn state        → how it moves: parked when idle, slow in thought,
15//                       fast at work, a spout or a speech bubble while responding
16//
17// Creatures: the whale (built in), the ones this mod ships (skins.ts), and any the
18// person has under ~/.claude/pixbar/skins/. `/pixbar <name>` switches and remembers.
19
20const TICK = 100 // ms per frame: 10 fps
21const GIT_EVERY = 5000
22const BREAKDOWN_EVERY = 15000
23const SAFE_NAME = /^[A-Za-z0-9._-]+$/
24const PANE = 'whale' // the pane's request id; its title is the creature's name
25const WHALE_NAMES = ['whale', 'whaleagent'] // whaleagent: its old name, still in older ~/.claude/pixbar/active files
26
27type Placement = 'band' | 'pane'
28
29type Win = { kind: string; pct: number; resetsAt?: string }
30type Cat = { name: string; tokens: number; color: string; kind: string }
31
32// Which creature a session starts with. `configured` is theme in /plugin configure, `seen` is
33// the value it had when last looked at, `active` is what /pixbar theme kept. A configure value
34// that changed since it was seen is the later pick and wins; one never seen wins only where
35// nothing was picked with /pixbar, so an update never swaps a creature someone chose.
36export const startTheme = (configured: string, seen: string | undefined, active: string | undefined) => {
37  const changed = configured !== '' && configured !== seen
38  const takes = changed && (seen !== undefined || active === undefined)
39  return { name: takes ? configured : active, keep: changed }
40}
41
42export const register: Register = (on, options) => {
43  // `layout` in /plugin configure picks the default; /pixbar layout band|pane switches for
44  // the session. Before 0.5.6 the setting was called `placement`.
45  let placement: Placement = (options.layout ?? options.placement) === 'pane' ? 'pane' : 'band'
46  let skin: Skin = WHALE
47  // the band's scene leads; the pane's copies its state and swims in its own width
48  const scene: SceneState = { t: 0, mode: 'idle', ctxPct: 0, level: levelFor(WHALE, 0), isDirty: false, x: 20, dir: -1, bubbles: [] }
49  const paneScene: SceneState = { ...scene, x: 20, bubbles: [], w: 60 }
50  let fivePct = 0
51  let ctx = { tokens: 0, window: 0, pct: 0 }
52  let windows: Win[] = []
53  let cats: Cat[] = []
54  let breakdown = { total: 0, max: 0 }
55  let lastBreakdown = 0
56  let branch = ''
57  let model = ''
58  let dir = ''
59  let bandId: string | undefined
60  let wordIx = 0
61  let isBreakdownShown = true
62  let textKey = ''
63  // set up in session.start, where `$` and HOME are known; the other hooks call them
64  let applyUsage: (context: { tokens?: number; window: number; percent?: number }, limits: { kind: string; percentUsed: number; resetsAt?: string }[]) => void = () => undefined
65  let refreshBreakdown: () => Promise<void> = async () => undefined
66
67  // Context against the session's own limit: the compaction window the breakdown reports
68  // (what /context measures against), else the model's window. Tokens fall back to the
69  // breakdown's total before the first response reports a live count.
70  const ctxNow = () => {
71    const window = breakdown.max > 0 ? breakdown.max : ctx.window
72    const tokens = ctx.tokens > 0 ? ctx.tokens : breakdown.total
73    const pct = window > 0 ? Math.min(100, Math.round((tokens / window) * 100)) : ctx.pct
74    return { tokens, window, pct }
75  }
76
77  const cellsOf = (s: SceneState) => encodeCells(toCells(paintScene(skin, s), widthOf(skin, s), SEA_H))
78  const syncPane = () => {
79    paneScene.t = scene.t
80    paneScene.mode = scene.mode
81    paneScene.ctxPct = scene.ctxPct
82    paneScene.level = scene.level
83    paneScene.sun = scene.sun
84    paneScene.isDirty = scene.isDirty
85  }
86
87  // Set up in session.start, where `$` is known: where creatures live, and how to find one.
88  let home = ''
89  const userDirs = () => [`${home}/.claude/pixbar`, `${home}/.claude/pixerbar`] // pixerbar: before the rename
90  const loadSkin = async (read: (path: string) => Promise<string>, name: string): Promise<Skin | undefined> => {
91    if (WHALE_NAMES.includes(name)) return WHALE
92    if (!SAFE_NAME.test(name)) return undefined
93    const parseAt = async (dir: string) => {
94      try {
95        return parseSkin(await read(`${dir}/${name}.sh`))
96      } catch {
97        return undefined // not in this folder
98      }
99    }
100    // the shipped skins are text inside the mod (skins.ts): it reads none of its own files
101    const shipped = SHIPPED[name] ? parseSkin(SHIPPED[name]) : undefined
102    // your own skins first, then the shipped ones, then pixerbar-era copies: an old
103    // export must never stand in for a shipped creature (a 1 Sep clawd gave it a purple eye)
104    const parsed = (await parseAt(`${home}/.claude/pixbar/skins`)) ?? shipped ?? (await parseAt(`${home}/.claude/pixerbar/skins`))
105    if (!parsed) return undefined
106    // a pixerbar-era copy has no HABITAT: clawd would swim. The shipped skin knows.
107    if (!parsed.habitat) {
108      parsed.habitat = shipped?.habitat
109      parsed.ride ??= shipped?.ride
110    }
111    return parsed
112  }
113  const useSkin = (next: Skin) => {
114    skin = next
115    for (const s of [scene, paneScene]) {
116      s.level = levelFor(skin, fivePct)
117      s.x = Math.min(s.x, widthOf(skin, s) - skin.w - 3)
118      s.bubbles = []
119    }
120  }
121  const nameOf = (s: Skin) => (s === WHALE ? 'whale' : s.name)
122
123  on('session.start', async ($, e, next) => {
124    home = (await $.env.get('HOME')) ?? ''
125    const read = (path: string) => $.fs.read(path)
126
127    // The creature last chosen, with /pixbar theme or with theme in /plugin configure,
128    // whichever was set later; the whale otherwise. Both picks are kept in the mod's own
129    // store. Before 0.5.4 they were one-line files in ~/.claude/pixbar, still read here
130    // where the store holds nothing, and no longer written.
131    const kept = async (key: string, file: string) => {
132      const value = await $.store.get(key).catch(() => undefined)
133      if (typeof value === 'string') return value
134      for (const dir of userDirs()) {
135        try {
136          return (await $.fs.read(`${dir}/${file}`)).trim()
137        } catch {
138          // not in this folder: try the next
139        }
140      }
141      return undefined
142    }
143    const configured = typeof options.theme === 'string' ? options.theme : ''
144    const chosen = startTheme(configured, await kept('configured', 'configured'), await kept('theme', 'active'))
145    if (chosen.keep) {
146      await $.store.set('configured', configured).catch(() => undefined)
147      if (chosen.name === configured) await $.store.set('theme', configured).catch(() => undefined)
148    }
149    const found = chosen.name ? await loadSkin(read, chosen.name) : undefined
150    if (found) useSkin(found)
151    scene.level = levelFor(skin, fivePct)
152
153    // the hint shows dim after /pixbar as it is typed: every theme that can be picked, then the layouts
154    const themes = await creatures(path => $.fs.list(path))
155    await $.command.register({
156      name: 'pixbar',
157      description: 'Pick the creature and where it lives',
158      argumentHint: `theme ${themes.join('|')} · layout ${LAYOUTS.join('|')}`,
159    })
160    if (placement === 'pane') void $.ui.open({ id: PANE, title: nameOf(skin) })
161
162    // Re-checks the text beside the whale; redraws only when a visible value changed.
163    const refreshText = () => {
164      const now = Date.now()
165      const key = JSON.stringify([dir, branch, scene.isDirty, model, ctx, windows.map(w => [w.pct, w.resetsAt && until(now, Date.parse(w.resetsAt))]), cats, breakdown, isBreakdownShown])
166      if (key !== textKey) {
167        textKey = key
168        $.ui.invalidate('ui.render')
169      }
170    }
171
172    const refreshGit = async () => {
173      try {
174        const cwd = await $.session.cwd()
175        const home2 = home && cwd.startsWith(home) ? '~' + cwd.slice(home.length) : cwd
176        dir = home2
177        model = await $.session.model()
178        const r = await $.process.run(['git', 'status', '--porcelain=v1', '-b'], { timeoutMs: 3000 }) // runs in the session's folder
179        if (r.exitCode !== 0) {
180          branch = ''
181          scene.isDirty = false
182        } else {
183          const [head = '', ...rest] = r.stdout.split('\n')
184          branch = head.replace(/^## /, '').replace(/\.\.\..*$/, '').replace(/^No commits yet on /, '')
185          scene.isDirty = rest.some(l => l.trim() !== '')
186        }
187      } catch {
188        branch = ''
189      }
190      refreshText()
191    }
192
193    refreshBreakdown = async () => {
194      try {
195        const u = await $.session.usage({ breakdown: 'summary' })
196        const b = u.context.breakdown
197        if (b) {
198          cats = b.categories.filter(c => c.kind !== 'deferred').map(c => ({ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind }))
199          breakdown = { total: b.totalTokens, max: b.rawMaxTokens }
200          scene.ctxPct = ctxNow().pct
201        }
202        lastBreakdown = Date.now()
203      } catch {
204        // no breakdown on this host: the band shows the single ctx row only
205      }
206      refreshText()
207    }
208
209    applyUsage = (context, limits) => {
210      ctx = { tokens: context.tokens ?? 0, window: context.window, pct: context.percent ?? 0 }
211      scene.ctxPct = ctxNow().pct
212      windows = limits.map(l => ({ kind: l.kind, pct: l.percentUsed, resetsAt: l.resetsAt }))
213      fivePct = windows.find(w => w.kind === 'five_hour')?.pct ?? 0
214      refreshText()
215    }
216
217    try {
218      const u = await $.session.usage()
219      applyUsage(u.context, u.rateLimits)
220    } catch {
221      // keep the zeros
222    }
223    void refreshGit()
224    void refreshBreakdown()
225
226    // the frame clock
227    let sinceGit = 0
228    let sinceText = 0
229    $.clock.every(TICK, () => {
230      scene.t += TICK
231      const target = levelFor(skin, fivePct)
232      scene.level += (target - scene.level) * Math.min(1, TICK / 350)
233      scene.sun = (scene.sun ?? fivePct) + (fivePct - (scene.sun ?? fivePct)) * Math.min(1, TICK / 350)
234      if (scene.mode === 'tool-use' && Math.random() < TICK / 700) scene.bubbles.push(mouthOf(skin, scene))
235      stepSwim(skin, scene, TICK)
236      stepBubbles(scene, TICK)
237      if (placement === 'pane') {
238        syncPane()
239        if (scene.mode === 'tool-use' && Math.random() < TICK / 700) paneScene.bubbles.push(mouthOf(skin, paneScene))
240        stepSwim(skin, paneScene, TICK)
241        stepBubbles(paneScene, TICK)
242      }
243      sinceGit += TICK
244      sinceText += TICK
245      if (sinceGit >= GIT_EVERY) { sinceGit = 0; void refreshGit() }
246      if (sinceText >= 30000) { sinceText = 0; refreshText() } // the reset countdowns
247      if (placement === 'band' && bandId) void $.ui.blit({ requestId: bandId, key: 'sea', cells: cellsOf(scene) }).catch(() => undefined)
248      if (placement === 'pane') void $.ui.blit({ requestId: PANE, key: 'sea', cells: cellsOf(paneScene) }).catch(() => undefined)
249    })
250
251    return next(e)
252  })
253
254  on('session.measure', async ($, e, next) => {
255    applyUsage(e.context, e.rateLimits)
256    if (e.changed.includes('context') && Date.now() - lastBreakdown > BREAKDOWN_EVERY) void refreshBreakdown()
257    return next(e)
258  })
259
260  on('turn.complete', async ($, e, next) => {
261    scene.mode = 'idle'
262    void refreshBreakdown()
263    return next(e)
264  })
265
266  on('turn.start', async ($, e, next) => {
267    scene.mode = 'thinking'
268    return next(e)
269  })
270
271  on('tool.call', async ($, e, next) => {
272    try {
273      for (const s of [scene, paneScene]) {
274        const m = mouthOf(skin, s)
275        if (m.y0 !== undefined) s.bubbles.push({ ...m }, { ...m, x: m.x - s.dir })
276        else s.bubbles.push({ x: m.x, y: m.y }, { x: m.x, y: m.y + 2 }, { x: m.x, y: m.y + 4 })
277      }
278    } catch {
279      // a bubble is never worth blocking a tool
280    }
281    return next(e)
282  })
283
284  // Every creature this person can pick: the whale, the ones shipped here, their own.
285  const creatures = async (list: (path: string) => Promise<{ name: string }[]>) => {
286    const names = new Set(['whale', ...Object.keys(SHIPPED)])
287    for (const dir of userDirs().map(d => `${d}/skins`)) {
288      const entries = await list(dir).catch(() => [])
289      for (const f of entries) if (f.name.endsWith('.sh')) names.add(f.name.slice(0, -3))
290    }
291    return [...names]
292  }
293
294  // /pixbar theme <name> picks the creature; /pixbar layout <band|pane> moves it. A bare
295  // creature or layout word still works as a shortcut, and anything else gets the menu.
296  const LAYOUTS = ['band', 'pane'] as const
297  const pick = (names: readonly string[], current: string) =>
298    names.map(n => `${n === current ? '●' : '○'} ${n}`.padEnd(10, ' ')).join(' ')
299
300  on('command.run', { command: 'pixbar' }, async ($, e) => {
301    const [first = '', second = '', ...rest] = e.args.trim().toLowerCase().split(/\s+/)
302    const themes = await creatures(path => $.fs.list(path))
303    const theme = nameOf(skin)
304
305    const menu = () => [
306      `PixBar · ${theme} · ${placement}`,
307      '',
308      `theme    ${pick(themes, theme)}  /pixbar theme ${themes.find(t => t !== theme) ?? theme}`,
309      `layout   ${pick(LAYOUTS, placement)}  /pixbar layout ${placement === 'band' ? 'pane' : 'band'}`,
310      'c        show or hide the context window',
311    ].join('\n')
312
313    const setLayout = async (to: 'band' | 'pane') => {
314      placement = to
315      if (to === 'pane') {
316        const opened = await $.ui.open({ id: PANE, title: theme })
317        $.ui.invalidate('ui.render')
318        return { text: opened.isPlaced
319          ? '✓ Layout: pane, beside the transcript (this session). To keep it: /plugin configure pixbar@claude-mods.'
320          : '✓ Layout: pane. It opens once the terminal is wide enough (144 columns).' }
321      }
322      await $.ui.close({ id: PANE }).catch(() => undefined)
323      $.ui.invalidate('ui.render')
324      return { text: '✓ Layout: band, above the prompt.' }
325    }
326
327    const setTheme = async (name: string) => {
328      const found = await loadSkin(path => $.fs.read(path), name)
329      if (!found) return { text: `✗ No theme called ${name}. Themes: ${themes.join(', ')}.\n  Try /pixbar theme ${themes.find(t => t !== theme) ?? theme}` }
330      useSkin(found)
331      // kept in the mod's store, so the next session starts with it
332      await $.store.set('theme', name).catch(() => undefined)
333      $.ui.invalidate('ui.render')
334      return { text: `✓ Theme: ${nameOf(found)}. Kept for your next session.` }
335    }
336
337    if (rest.length > 0) return { text: `✗ Too many words. ${first === 'layout' ? 'Try /pixbar layout pane' : 'Try /pixbar theme clawd'}\n\n${menu()}` }
338    if (first === '') return { text: menu() }
339    if (first === 'theme') return second === '' ? { text: `Theme: ${theme}. Themes: ${themes.join(', ')}.\n  /pixbar theme <name> picks one.` } : setTheme(second)
340    if (first === 'layout') {
341      if (second === '') return { text: `Layout: ${placement}. /pixbar layout band or /pixbar layout pane.` }
342      return second === 'band' || second === 'pane' ? setLayout(second) : { text: `✗ No layout called ${second}. Layouts: band, pane.\n  Try /pixbar layout ${placement === 'band' ? 'pane' : 'band'}` }
343    }
344    // shortcuts: /pixbar pane, /pixbar clawd
345    if (second === '' && (first === 'band' || first === 'pane')) return setLayout(first)
346    if (second === '' && themes.includes(first)) return setTheme(first)
347    return { text: `✗ Not a command: /pixbar ${e.args.trim()}\n\n${menu()}` }
348  })
349
350  // Closing the pane by hand sends the whale back to the band rather than losing it.
351  on('ui.close', async ($, e, next) => {
352    if (e.id === PANE && e.origin.kind === 'person') {
353      placement = 'band'
354      $.ui.invalidate('ui.render')
355    }
356    return next(e)
357  })
358
359  // The spinner names what the whale is doing, and tells the band the turn's state.
360  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
361    const mode: Mode = modeFromSpinner(e.props.mode)
362    if (mode !== scene.mode) {
363      scene.mode = mode
364      wordIx += 1
365    }
366    if (e.props.message !== null) return next(e)
367    const words = (skin.habitat === 'land' ? LAND_WORDS : WORDS)[mode]
368    const word = words[wordIx % words.length]
369    return word ? next({ ...e, props: { ...e.props, word } }) : next(e)
370  })
371
372  // One drawing for both homes. The band puts the tank left and the text right;
373  // the pane stacks them, the tank as wide as the pane.
374  const view = (els: Elements['terminal'], onToggle: () => void, cols: number, seaW: number, cellsStr: string, isPane: boolean) => {
375    const { Box, Text, Raster, Button } = els
376    const now = Date.now()
377    const dim = '#7b8591'
378    const live = ctxNow()
379
380    const row = (key: string, label: string, pct: number, color: string, tail: [string, string?][]) => (
381      <Box key={key} flexDirection="row">
382        <Text color={dim}>{label.padEnd(6, ' ')}</Text>
383        <Text color={color}>{bar(pct)}</Text>
384        <Text color={color}>{' ' + String(Math.round(pct)).padStart(3, ' ') + '%'}</Text>
385        {tail.map(([t, c]) => <Text color={c ?? dim}>{t}</Text>)}
386      </Box>
387    )
388
389    const stats = (
390      <Box flexDirection="column">
391        <Box flexDirection="row">
392          <Text color="#6aa6ff">{dir}</Text>
393          {branch !== '' && <Text color="#e3a63f">{' ' + branch}</Text>}
394          {branch !== '' && <Text color={scene.isDirty ? '#ff3b30' : '#34c759'}>{scene.isDirty ? ' ✘' : ' ✔'}</Text>}
395          <Text color={dim}>{' ' + model}</Text>
396        </Box>
397        {row('ctx', 'ctx', live.pct, ctxColor(live.pct), [
398          ['   '],
399          [`${fmtTokens(live.tokens)} / ${fmtTokens(live.window)}`, '#d7dbe0'],
400        ])}
401        {windows.length === 0 && <Text color={dim}>5h    no rate-limit windows reported</Text>}
402        {windows.map(w => {
403          const at = w.resetsAt ? resetAt(now, w.resetsAt) : undefined
404          // the reset, roomy and plain: when, then how long
405          const tail: [string, string?][] = at ? [['   '], [at, '#d7dbe0'], [` · in ${until(now, Date.parse(w.resetsAt!))}`]] : []
406          return row(w.kind, windowLabel(w.kind), w.pct, useColor(w.pct), tail)
407        })}
408      </Box>
409    )
410
411    // Layout: the tank on the left, everything else in one column on the right that
412    // takes the rest of the width. Below ~50 spare columns the column drops under the tank.
413    const GAP = 2
414    const isWide = !isPane && cols - seaW - GAP >= 50
415    const rightW = isWide ? cols - seaW - GAP : cols
416
417    // the full context window, as /context breaks it down, sized to the right column
418    const used = cats.filter(c => c.kind !== 'free' && c.tokens > 0)
419    const free = cats.find(c => c.kind === 'free')
420    const max = Math.max(1, breakdown.max)
421    const barW = Math.max(10, rightW)
422    const segs = used.map(c => ({ c, n: Math.max(1, Math.round((c.tokens / max) * barW)) }))
423    const freeN = Math.max(0, barW - segs.reduce((a, s) => a + s.n, 0))
424
425    // the legend: a grid of equal columns, name and tokens only, each name cut to fit so no entry wraps
426    type Item = { key: string; color: string; name: string; tokens: string; pct: string; isFree: boolean }
427    const items: Item[] = used.map(c => ({ key: c.name, color: c.color, name: c.name, tokens: fmtTokens(c.tokens), pct: '', isFree: false }))
428    if (free) items.push({ key: 'free', color: dim, name: 'free', tokens: fmtTokens(free.tokens), pct: '', isFree: true })
429    const ncol = rightW >= 84 ? 3 : 2
430    const colW = Math.floor(rightW / ncol)
431    const fit = (it: Item) => {
432      const room = colW - 2 - (1 + it.tokens.length) - (it.pct ? 1 + it.pct.length : 0) - 2
433      return it.name.length <= room ? it.name : it.name.slice(0, Math.max(1, room - 1)) + '…'
434    }
435    const gridRows: Item[][] = []
436    for (let i = 0; i < items.length; i += ncol) gridRows.push(items.slice(i, i + ncol))
437
438    const contextBlock = cats.length > 0 && (
439      <Box flexDirection="column" marginTop={isWide ? 0 : 1} width={rightW}>
440        <Box flexDirection="row" justifyContent="space-between" width={rightW}>
441          <Box flexDirection="row">
442            <Text color="claude">◆ </Text>
443            <Text bold>context window   </Text>
444            <Text bold>{fmtTokens(breakdown.total)}</Text>
445            <Text color={dim}>{` / ${fmtTokens(breakdown.max)}`}</Text>
446          </Box>
447          <Button
448            key="toggle-context"
449            plain
450            hotkey="c"
451            label={isBreakdownShown ? 'hide' : 'show'}
452            onPress={onToggle}
453          />
454        </Box>
455        {isBreakdownShown && (
456          <Box flexDirection="row">
457            {segs.map(s => <Text color={s.c.color}>{'█'.repeat(s.n)}</Text>)}
458            <Text color={dim}>{'░'.repeat(freeN)}</Text>
459          </Box>
460        )}
461        {isBreakdownShown && gridRows.map((r, i) => (
462          <Box key={`legend-${i}`} flexDirection="row">
463            {r.map(it => (
464              <Box key={it.key} width={colW} flexDirection="row">
465                <Text color={it.color}>■ </Text>
466                <Text color={it.isFree ? dim : undefined}>{fit(it) + ' '}</Text>
467                <Text bold>{it.tokens}</Text>
468                {it.pct !== '' && <Text color={dim}>{' ' + it.pct}</Text>}
469              </Box>
470            ))}
471          </Box>
472        ))}
473      </Box>
474    )
475
476    return (
477      <Box flexDirection={isWide ? 'row' : 'column'} gap={GAP} alignItems="flex-start">
478        <Raster key="sea" columns={seaW} rows={SEA_H / 2} cells={cellsStr} />
479        {/* beside the tank, the column is the tank's height: stats flush with its top,
480            the context window flush with its bottom */}
481        <Box flexDirection="column" width={rightW} minHeight={isWide ? SEA_H / 2 : undefined} justifyContent={isWide ? 'space-between' : 'flex-start'}>
482          {stats}
483          {contextBlock}
484        </Box>
485      </Box>
486    )
487  }
488
489  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
490    if (placement !== 'band' || e.props.hasSurvey || e.surface !== 'terminal') return next(e)
491    bandId = e.requestId
492    return view($.ui.resolve(e), () => {
493      isBreakdownShown = !isBreakdownShown
494      $.ui.invalidate('ui.render')
495    }, e.props.bodyColumns, seaWidth(skin), cellsOf(scene), false)
496  })
497
498  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
499    if (e.surface !== 'terminal') return next(e)
500    const cols = Math.max(skin.w + 12, e.props.bodyColumns)
501    paneScene.w = cols
502    syncPane()
503    return view($.ui.resolve(e), () => {
504      isBreakdownShown = !isBreakdownShown
505      $.ui.invalidate('ui.render')
506    }, cols, cols, cellsOf(paneScene), true)
507  })
508}
509
510
hooks/scene.ts 527 lines
1// The whale's sea, drawn as pure data: a pixel grid in, Raster cells out.
2// No `$` and no imports in here, so the tests can run every state without an engine, and
3// pixbar.dev can import this file as it is to draw its preview.
4
5export type Mode = 'idle' | 'thinking' | 'tool-use' | 'responding'
6
7export type Skin = {
8  name: string
9  w: number
10  nframes: number
11  idle: number
12  frames: string[] // (nframes + 1) * 10 rows, each `w` digits
13  dirty: string[] // 10 rows of `w` 0/1 digits: what turns cream on uncommitted work
14  base: number
15  gauge: number
16  cream: number
17  inks: (number | undefined)[] // digits 2..4
18  habitat?: 'sea' | 'land' // land: walks on grass under a sky instead of swimming; unset is the sea
19  ride?: 'skateboard' // land: rides a skateboard while it answers (clawd); unset walks
20}
21
22// The whale, exactly as skins/whale.sh ships it (scripts/check-whale.ts keeps them equal).
23export const WHALE: Skin = {
24  name: 'whale',
25  w: 15,
26  nframes: 4,
27  idle: 4,
28  frames: [
29    '000000000000000', '000000000000000', '111111111110111', '111111111111111', '110111111111110',
30    '111111111111100', '111111111111100', '000001111110110', '111111111100011', '000001100000011',
31    '000000000000011', '111111111110111', '111111111111110', '110111111111100', '111111111111100',
32    '111111111111110', '000001111110111', '111111111100011', '000001100000000', '000000000000000',
33    '000000000000011', '000000000000011', '111111111110110', '111111111111100', '110111111111100',
34    '111111111111110', '111111111111111', '000001111110111', '111111111100000', '000001100000000',
35    '000000000000000', '000000000000011', '111111111110111', '111111111111110', '110111111111100',
36    '111111111111100', '111111111111110', '000001111110111', '111111111100011', '000001100000000',
37    '000000000000000', '000000000000011', '111111111110111', '111111111111110', '110111111111100',
38    '111111111111100', '111111111111110', '000001111110111', '111111111100011', '000001100000000',
39  ],
40  dirty: Array(10).fill('000000000001111'),
41  base: 0xf7931a,
42  gauge: 0xd8695e,
43  cream: 0xfaf9f5,
44  inks: [],
45}
46
47const rgbTriple = (v: string | undefined): number | undefined => {
48  const m = v?.match(/^(\d{1,3});(\d{1,3});(\d{1,3})$/)
49  if (!m) return undefined
50  const [r = 0, g = 0, b = 0] = [m[1], m[2], m[3]].map(Number)
51  if (r > 255 || g > 255 || b > 255) return undefined
52  return (r << 16) | (g << 8) | b
53}
54
55// Reads a pixerbar skin file (shell) without running it: the scalar assignments
56// and the two quoted-string arrays. Anything malformed returns undefined, and the
57// caller keeps the built-in whale.
58export function parseSkin(src: string): Skin | undefined {
59  const scalar = (name: string) => src.match(new RegExp(`^\\s*${name}=['"]?([^'"\\s#;]+(?:;[^'"\\s#;]+)*)['"]?`, 'm'))?.[1]
60  const array = (name: string) => {
61    const body = src.match(new RegExp(`^\\s*${name}=\\(([\\s\\S]*?)^\\s*\\)`, 'm'))?.[1]
62    return body ? [...body.replace(/#.*$/gm, '').matchAll(/"([0-9]+)"/g)].map(m => m[1] ?? '') : undefined
63  }
64  const w = Number(scalar('W'))
65  const nframes = Number(scalar('NFRAMES'))
66  const frames = array('FRAMES') as string[] | undefined
67  const dirty = array('DIRTY') as string[] | undefined
68  if (!Number.isInteger(w) || w < 8 || w > 40) return undefined
69  if (!Number.isInteger(nframes) || nframes < 2 || nframes > 6) return undefined
70  if (!frames || frames.length !== (nframes + 1) * 10 || frames.some(r => r.length !== w)) return undefined
71  const idle = Number(scalar('IDLE_FRAME') ?? nframes)
72  return {
73    name: scalar('SKIN_NAME') ?? 'skin',
74    w,
75    nframes,
76    idle: Number.isInteger(idle) && idle >= 0 && idle <= nframes ? idle : nframes,
77    frames,
78    dirty: dirty && dirty.length === 10 && dirty.every(r => r.length === w) ? dirty : Array(10).fill('0'.repeat(w)),
79    base: rgbTriple(scalar('SKIN_BASE')) ?? WHALE.base,
80    gauge: rgbTriple(scalar('SKIN_GAUGE')) ?? WHALE.gauge,
81    cream: rgbTriple(scalar('SKIN_CREAM')) ?? WHALE.cream,
82    inks: [undefined, undefined, rgbTriple(scalar('SKIN_INK2')), rgbTriple(scalar('SKIN_INK3')), rgbTriple(scalar('SKIN_INK4'))],
83    // unset, not 'sea', when the file says nothing: a copy saved before HABITAT existed
84    // takes the habitat from the mod's own skin of the same name
85    habitat: ({ land: 'land', sea: 'sea' } as const)[scalar('HABITAT') ?? ''],
86    ride: scalar('RIDE') === 'skateboard' ? 'skateboard' : undefined,
87  }
88}
89
90// ---------------------------------------------------------------- the sea ---
91
92export const SEA_H = 20 // pixels: 10 terminal rows of half-blocks
93export const SURF = 0 // the surface wave is the top row: no sky above it
94const AIR = -1
95// The floor, the same in both scenes: one row along the bottom, a sand crest at sea, grass on
96// land. The creature rests on the row above it.
97const GROUND = SEA_H - 1
98const SEABED = GROUND
99// The floor's top row at column x: smooth, the same everywhere and in both scenes.
100const floorTop = (_x: number) => GROUND
101
102const C = {
103  water: 0x0f2233,
104  deep: 0x0a1824,
105  surface: 0x4e6f8e,
106  spray: 0x9cc8ef,
107  dim: 0x7b8591,
108  sand: 0x3b3428,
109  crest: 0x52483a, // the lit edge along the top of the sand
110  weed: 0x2e6b57,
111  weedHi: 0x3f8a70,
112}
113
114// Both scenes fade over the six rows above their floor and keep their plants in the same places.
115const PLANTS = [0.06, 0.34, 0.64, 0.9]
116
117// Uncommitted work: plants come up and litter lies on the floor, the same in both scenes.
118// Committed: the floor is clean, nothing on it. Nothing changes colour.
119const JUNK = { paper: 0xd9d2c0, tin: 0x9aa3ad, tinLo: 0x6b737c, glass: 0x8a5a2b, glassHi: 0xb07a3e }
120function strew(put: (x: number, y: number, c: number) => void, w: number) {
121  const at = (fx: number) => Math.round(w * fx)
122  const lay = (x: number, up: number, c: number) => put(x, floorTop(x) - up, c) // on the floor, never in it: the floor stays smooth
123  lay(at(0.12), 1, JUNK.paper); lay(at(0.12) + 1, 1, JUNK.paper); lay(at(0.12) + 1, 2, JUNK.paper) // a crumpled sheet
124  lay(at(0.25), 1, JUNK.tin); lay(at(0.25) + 1, 1, JUNK.tinLo) // a can on its side
125  lay(at(0.73), 1, JUNK.glass); lay(at(0.73) + 1, 1, JUNK.glass); lay(at(0.73) + 2, 1, JUNK.glassHi) // a bottle
126}
127
128export const mix = (a: number, b: number, t: number) => {
129  const ch = (s: number) => Math.round(((a >> s) & 255) * (1 - t) + ((b >> s) & 255) * t)
130  return (ch(16) << 16) | (ch(8) << 8) | ch(0)
131}
132
133export type SceneState = {
134  t: number // ms since start
135  mode: Mode
136  ctxPct: number // colour
137  level: number // smoothed sprite top row, from the five-hour window
138  isDirty: boolean // uncommitted work: plants and litter on the floor; committed, a clean floor
139  x: number // the sprite's left column; it swims back and forth
140  dir: -1 | 1 // -1 swims left (head first, as drawn), 1 swims right (mirrored)
141  bubbles: { x: number; y: number; y0?: number }[] // on land, dust: y0 is where it rose from
142  sun?: number // land: the moon's place on its arc, 0..100, eased toward the five-hour window
143  w?: number // the sea's width in columns; absent, seaWidth(skin). A pane sizes it to itself.
144}
145
146// One width for every shipped creature (whale 15 wide, clawd 21), so they sit side by side at one size.
147export const seaWidth = (skin: Skin) => Math.max(54, skin.w + 33)
148export const widthOf = (skin: Skin, s: SceneState) => Math.max(skin.w + 12, s.w ?? seaWidth(skin))
149
150// px per ms, by state: parked when idle, slow in thought, fast at work
151const SPEED: Record<Mode, number> = { idle: 0, thinking: 0.003, 'tool-use': 0.016, responding: 0.008 }
152// On land it stays put to think and to work; only answering moves it, on its board.
153const speedOf = (skin: Skin, mode: Mode) => (skin.habitat === 'land' && mode !== 'responding' ? 0 : SPEED[mode])
154
155export function stepSwim(skin: Skin, s: SceneState, dt: number) {
156  const max = widthOf(skin, s) - skin.w - 3
157  s.x += s.dir * speedOf(skin, s.mode) * dt
158  if (s.x < 3) { s.x = 3; s.dir = 1 }
159  if (s.x > max) { s.x = max; s.dir = -1 }
160}
161
162function headTop(skin: Skin, f: number) {
163  for (let r = 0; r < 10; r++) if (skin.frames[f * 10 + r]?.[0] !== '0') return r
164  return 2
165}
166
167export function frameAt(skin: Skin, mode: Mode, t: number) {
168  if (mode === 'idle') return skin.idle
169  if (skin.habitat === 'land') {
170    // the wand is for work: thinking, it stands with its eyes open and blinks; answering, it skates
171    if (mode === 'thinking') return skin.idle
172    if (mode === 'responding') return 0
173  }
174  if (mode === 'thinking') return Math.min([3, 0, 3, 2][Math.floor(t / 700) % 4] ?? 0, skin.nframes - 1)
175  if (mode === 'responding') return (t % SPOUT_MS) < SPOUT_MS / 2 ? 1 % skin.nframes : Math.min(3, skin.nframes - 1)
176  return Math.floor(t / 150) % skin.nframes
177}
178
179// Five-hour window → the sprite's top row. 100%: the head at the surface. 0%: resting on the seabed.
180export function levelFor(skin: Skin, pct: number) {
181  const f = skin.idle
182  const top = SURF - headTop(skin, f) + 1
183  const bottom = SEABED - 1 - 9
184  return bottom - (Math.max(0, Math.min(100, pct)) / 100) * (bottom - top)
185}
186
187const SPOUT_MS = 1400
188const SPOUT: [number, number, 'c' | 's' | 'd'][][] = [
189  [[0, -1, 'c']],
190  [[0, -1, 'c'], [0, -2, 'c']],
191  [[0, -1, 'c'], [0, -2, 'c'], [0, -3, 'c']],
192  [[0, -1, 'c'], [0, -2, 'c'], [0, -3, 'c'], [-1, -4, 's'], [1, -4, 's']],
193  [[0, -1, 's'], [0, -2, 's'], [0, -3, 's'], [-1, -4, 's'], [1, -4, 's'], [-2, -4, 's'], [2, -4, 's']],
194  [[0, -1, 'd'], [0, -2, 'd'], [-2, -3, 's'], [2, -3, 's']],
195  [[-2, -2, 'd'], [2, -2, 'd']],
196  [], [], [],
197]
198const SONAR_MS = 1600
199
200// The hill behind the creature on land.
201const ridgeTop = (x: number) => GROUND - 1 - Math.round(1 + Math.sin(x / 6) + Math.sin(x / 2.3) * 0.5)
202
203export function paintScene(skin: Skin, s: SceneState): Int32Array {
204  if (skin.habitat === 'land') return paintLand(skin, s)
205  const w = widthOf(skin, s), h = SEA_H
206  const px = new Int32Array(w * h).fill(AIR)
207  const put = (x: number, y: number, c: number) => {
208    if (x >= 0 && x < w && y >= 0 && y < h) px[y * w + x] = c
209  }
210
211  // water, fading deeper over the six rows above the seabed; the surface wave on top
212  for (let y = 0; y < SEABED; y++) {
213    const c = mix(C.water, C.deep, Math.max(0, (y - (SEABED - 6)) / 6))
214    for (let x = 0; x < w; x++) put(x, y, c)
215  }
216  for (let x = 0; x < w; x++) if ((x + Math.floor(s.t / 350)) % 7 < 3) put(x, SURF, C.surface)
217  // the seabed: smooth sand, a lit crest along its top
218  for (let x = 0; x < w; x++) { const top = floorTop(x); put(x, top, C.crest); for (let y = top + 1; y < h; y++) put(x, y, C.sand) }
219  // uncommitted work: seagrass swaying and litter on the seabed; committed, nothing
220  if (s.isDirty) PLANTS.forEach((fx, i) => {
221    const x0 = Math.round(w * fx), tall = 3 + (i % 2), base = floorTop(x0) - 1
222    for (let k = 0; k < tall; k++) {
223      const sway = Math.round(Math.sin(s.t / 1300 + i * 1.7 + k * 0.5) * (k / tall) * 1.4)
224      put(x0 + sway, base - k, k % 2 ? C.weedHi : C.weed)
225    }
226  })
227  if (s.isDirty) strew(put, w)
228
229  // the whale
230  const f = frameAt(skin, s.mode, s.t)
231  const flip = s.dir > 0
232  const lunge = s.mode === 'tool-use' && (Math.floor(s.t / 150) % 4) % 2 ? s.dir : 0
233  const ox = Math.round(s.x) + lunge
234  const colX = (c: number) => ox + (flip ? skin.w - 1 - c : c)
235  const bob = s.mode === 'thinking' ? Math.round(Math.sin(s.t / 900)) : 0
236  const oy = Math.max(SURF - headTop(skin, f) + 1, Math.min(SEABED - 10, Math.round(s.level) + bob))
237  const firstRed = 10 - Math.round(s.ctxPct / 10)
238  const blink = s.mode === 'idle' && s.t % 4200 < 160
239  for (let r = 0; r < 10; r++) {
240    const row = skin.frames[f * 10 + r] ?? ''
241    for (let c = 0; c < skin.w; c++) {
242      const d = row[c] ?? '0'
243      const isEye = blink && d === '0' && c === 2 && row[0] !== '0' && row[3] !== '0'
244      if (d === '0' && !isEye) continue
245      const ink = d >= '2' ? skin.inks[Number(d)] : undefined
246      put(colX(c), oy + r, ink ?? (r >= firstRed ? skin.gauge : skin.base))
247    }
248  }
249
250  // thinking: sonar arcs leave the snout and fade as they spread
251  if (s.mode === 'thinking') {
252    const mid = oy + 5
253    for (const lag of [0, SONAR_MS / 2]) {
254      const d = 1 + Math.floor(((s.t + lag) % SONAR_MS) / (SONAR_MS / 5))
255      if (d > 4) continue
256      // two columns out, so even the first arc's tips stand a clear column off the snout
257      const x = colX(0) + s.dir * (d + 2), hh = Math.min(d, 2), tone = mix(C.spray, C.water, (d - 1) * 0.22)
258      for (let k = -hh + 1; k <= hh - 1; k++) put(x, mid + k, tone)
259      put(x + s.dir * -1, mid - hh, tone); put(x + s.dir * -1, mid + hh, tone)
260    }
261  }
262
263  // responding: the spout above the surface, a bubble column below it
264  if (s.mode === 'responding') {
265    const top = oy + headTop(skin, f), bx = colX(4)
266    if (top - SURF > 1) {
267      const k = Math.floor((s.t % SPOUT_MS) / 140)
268      for (let i = 0; i < 3; i++) { const y = top - 1 - ((k + i * 3) % 7); if (y > SURF) put(bx + (i % 2), y, C.spray) }
269    } else {
270      const phase = Math.floor((s.t % SPOUT_MS) / (SPOUT_MS / SPOUT.length))
271      for (const [dx, dy, k] of SPOUT[phase] ?? []) put(bx + dx, top + dy, k === 'c' ? skin.cream : k === 's' ? C.spray : C.dim)
272    }
273  }
274
275  for (const b of s.bubbles) if (b.y > SURF) put(Math.round(b.x), Math.round(b.y), C.spray)
276  return px
277}
278
279// Mouth position, where bubbles start. On land, the heels, where dust is kicked up.
280export function mouthOf(skin: Skin, s: SceneState): { x: number; y: number; y0?: number } {
281  if (skin.habitat === 'land') {
282    const y = GROUND - 1
283    return { x: s.dir > 0 ? Math.round(s.x) - 1 : Math.round(s.x) + skin.w, y, y0: y }
284  }
285  const snout = s.dir > 0 ? Math.round(s.x) + skin.w - 1 : Math.round(s.x)
286  return { x: snout + s.dir, y: Math.round(s.level) + 7 }
287}
288
289export function stepBubbles(s: SceneState, dt: number) {
290  for (const b of s.bubbles) {
291    if (b.y0 !== undefined) { b.y -= 0.003 * dt; b.x -= s.dir * 0.004 * dt }
292    else { b.y -= 0.009 * dt; b.x += 0.15 * Math.sin(b.y) }
293  }
294  s.bubbles = s.bubbles.filter(b => (b.y0 !== undefined ? b.y > b.y0 - 3 : b.y > SURF && b.x >= 0)).slice(-40)
295}
296
297// ---------------------------------------------------------------- the land ---
298
299const L = {
300  skyDay: 0x16304a, skyDusk: 0x3b2135, haze: 0x24465f, hazeDusk: 0x5a3040,
301  moon: 0xf4ecc8, moonset: 0xe8c47a, cloud: 0x2f4c6a, cloudDusk: 0x5b3a4c, star: 0x9aa3ad,
302  hill: 0x1d3a2c, grass: 0x3f8a4f, grassHi: 0x56a862, dirt: 0x4a3a28,
303  dust: 0x8a7a60,
304  deck: 0x8c3150, wheel: 0xf2b45a, // the sticker's board: maroon deck (lifted to read on dark grass), gold wheels, maroon hub
305}
306
307// The columns the feet span in the idle pose: the board sits under them, two wider each side.
308function feetOf(skin: Skin) {
309  const row = skin.frames[skin.idle * 10 + inkRows(skin, skin.idle).bottom] ?? ''
310  const l = row.search(/[1-4]/), r = Math.max(row.lastIndexOf('1'), row.lastIndexOf('2'), row.lastIndexOf('3'), row.lastIndexOf('4'))
311  return l < 0 ? { l: 0, r: skin.w - 1 } : { l: Math.max(0, l - 2), r: Math.min(skin.w - 1, r + 2) }
312}
313
314// First and last rows with anything in them, over the whole frame.
315function inkRows(skin: Skin, f: number) {
316  let top = -1, bottom = 9
317  for (let r = 0; r < 10; r++) if (/[1-4]/.test(skin.frames[f * 10 + r] ?? '')) { if (top < 0) top = r; bottom = r }
318  return { top: Math.max(0, top), bottom }
319}
320
321// The empty pixels the body closes round - the eyes - found by flooding in from the edge.
322// A blink fills them.
323function holesOf(skin: Skin, f: number) {
324  const open = new Set<number>(), q: number[] = []
325  const empty = (r: number, c: number) => (skin.frames[f * 10 + r]?.[c] ?? '0') === '0'
326  for (let r = 0; r < 10; r++) for (let c = 0; c < skin.w; c++)
327    if ((r === 0 || r === 9 || c === 0 || c === skin.w - 1) && empty(r, c)) { open.add(r * skin.w + c); q.push(r * skin.w + c) }
328  while (q.length) {
329    const i = q.pop()!, r = Math.floor(i / skin.w), c = i % skin.w
330    for (const [dr, dc] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) {
331      const rr = r + dr, cc = c + dc, j = rr * skin.w + cc
332      if (rr >= 0 && rr < 10 && cc >= 0 && cc < skin.w && !open.has(j) && empty(rr, cc)) { open.add(j); q.push(j) }
333    }
334  }
335  const holes = new Set<number>()
336  for (let r = 0; r < 10; r++) for (let c = 0; c < skin.w; c++) if (empty(r, c) && !open.has(r * skin.w + c)) holes.add(r * skin.w + c)
337  return holes
338}
339
340const JUMP_MS = 1400 // answering, it jumps once a beat: up three pixels and down again in the first 600ms
341
342// The same signals, read for something that walks. Context is still its colour and git
343// still litters the floor; the turn is still how it moves. The five-hour window can't be a
344// depth on land, so it is the time of night: the moon rises on a fresh window and sets at 100%.
345export function paintLand(skin: Skin, s: SceneState): Int32Array {
346  const w = widthOf(skin, s), h = SEA_H
347  const px = new Int32Array(w * h).fill(AIR)
348  const put = (x: number, y: number, c: number) => {
349    if (x >= 0 && x < w && y >= 0 && y < h) px[y * w + x] = c
350  }
351  const day = Math.max(0, Math.min(100, s.sun ?? 0)) / 100
352  const dusk = day * day
353
354  for (let y = 0; y < GROUND; y++) {
355    const haze = Math.max(0, (y - (GROUND - 6)) / 6)
356    const c = mix(mix(L.skyDay, L.skyDusk, dusk), mix(L.haze, L.hazeDusk, dusk), haze)
357    for (let x = 0; x < w; x++) put(x, y, c)
358  }
359  if (day > 0.8) {
360    for (const [fx, y] of [[0.08, 1], [0.27, 3], [0.46, 0], [0.71, 2], [0.93, 1]] as const) {
361      const x = Math.round(w * fx)
362      if ((Math.floor(s.t / 700) + x) % 5) put(x, y, mix(L.skyDusk, L.star, (day - 0.8) * 5))
363    }
364  }
365  const sx = Math.round(2 + day * (w - 5)), sy = Math.round(1 + 7 * (2 * day - 1) ** 2)
366  // a crescent moon on the arc, opening to the left; it warms as it sets
367  const moon = mix(L.moon, L.moonset, dusk)
368  for (const [dx, dy] of [[0, -1], [-1, -1], [1, 0], [0, 0], [1, 1], [0, 1], [0, 2], [-1, 2]] as const) put(sx + dx, sy + dy, moon)
369  ;[0, 1].forEach(i => {
370    const cx = Math.round(((s.t / (2600 + i * 1700) + i * 0.55) % 1) * (w + 8)) - 6, cy = 2 + i * 2
371    const c = mix(L.cloud, L.cloudDusk, dusk)
372    for (let k = 1; k < 4; k++) put(cx + k, cy, c)
373    for (let k = 0; k < 6; k++) put(cx + k, cy + 1, c)
374  })
375  for (let x = 0; x < w; x++) for (let y = ridgeTop(x); y < GROUND; y++) put(x, y, L.hill)
376  // the ground: smooth, a grass top over dirt
377  for (let x = 0; x < w; x++) { const top = floorTop(x); put(x, top, L.grass); for (let y = top + 1; y < h; y++) put(x, y, L.dirt) }
378  // uncommitted work: grass tufts and litter; committed, nothing
379  if (s.isDirty) PLANTS.forEach((fx, i) => {
380    const x0 = Math.round(w * fx), sway = Math.round(Math.sin(s.t / 1100 + i * 2.1) * 0.6), b = floorTop(x0) - 1, b3 = floorTop(x0 + 3) - 1
381    put(x0, b, L.grassHi); put(x0 + 1 + sway, b - 1, L.grassHi)
382    if (i % 2) { put(x0 + 3, b3, L.grass); put(x0 + 3 + sway, b3 - 1, L.grass) }
383  })
384  if (s.isDirty) strew(put, w)
385
386  // the creature: a rider skates while it answers; otherwise it stands, the board leaning beside it except at work
387  const f = frameAt(skin, s.mode, s.t)
388  const { bottom } = inkRows(skin, skin.idle)
389  const flip = s.dir > 0
390  const board = skin.ride === 'skateboard'
391  const riding = board && s.mode === 'responding'
392  const ph = (s.t % JUMP_MS) / 600
393  const lift = s.mode === 'responding' && ph < 1 ? Math.round(3 * Math.sin(Math.PI * ph)) : 0
394  const ox = Math.round(s.x), oy = GROUND - 1 - bottom - lift - (riding ? 2 : 0)
395  const colX = (c: number) => ox + (flip ? skin.w - 1 - c : c)
396  const feet = feetOf(skin)
397  if (riding) {
398    const deckY = GROUND - 2 - lift, spin = Math.floor(s.t / 120) % 2
399    for (let c = feet.l; c <= feet.r; c++) put(colX(c), deckY, L.deck)
400    put(colX(feet.r), deckY - 1, L.deck) // the tail kicks up at the back
401    // two-pixel wheels, the maroon hub swapping sides as they roll
402    for (const c of [feet.l + 1, feet.r - 2]) { put(colX(c), deckY + 1, spin ? L.deck : L.wheel); put(colX(c + 1), deckY + 1, spin ? L.wheel : L.deck) }
403  } else if (board && s.mode !== 'tool-use') {
404    // leaned on its tail behind the creature, wheels out
405    // in the first column clear of the idle pose, or just past the sprite
406    let c = feet.r + 3
407    while (c < skin.w && skin.frames.slice(skin.idle * 10, skin.idle * 10 + 10).some(r => r[c] !== '0')) c++
408    for (let y = GROUND - 6; y < GROUND; y++) put(colX(c), y, L.deck)
409    put(colX(c) + (flip ? -1 : 1), GROUND - 2, L.wheel); put(colX(c) + (flip ? -1 : 1), GROUND - 5, L.wheel)
410  }
411  const firstRed = 10 - Math.round(s.ctxPct / 10)
412  // thinking: a blink every 1.8s, the eyes filled for 180ms
413  const shut = s.mode === 'thinking' && s.t % 1800 < 180 ? holesOf(skin, f) : undefined
414  for (let r = 0; r < 10; r++) {
415    const row = skin.frames[f * 10 + r] ?? ''
416    for (let c = 0; c < skin.w; c++) {
417      const d = row[c] ?? '0'
418      if (d === '0' && !shut?.has(r * skin.w + c)) continue
419      const ink = d >= '2' ? skin.inks[Number(d)] : undefined
420      put(colX(c), oy + r, ink ?? (r >= firstRed ? skin.gauge : skin.base))
421    }
422  }
423  for (const b of s.bubbles) put(Math.round(b.x), Math.round(b.y), mix(L.dust, L.dirt, ((b.y0 ?? b.y) - b.y) / 3))
424  return px
425}
426
427// ------------------------------------------------------------- the cells ---
428
429const DEFAULT = 0x01000000
430const UPPER = 0x2580 // ▀
431const LOWER = 0x2584 // ▄
432
433// Two pixel rows per cell: the top pixel is the foreground of ▀, the bottom its background.
434export function toCells(px: Int32Array, w: number, h: number): Uint32Array {
435  const rows = Math.ceil(h / 2)
436  const out = new Uint32Array(w * rows * 3)
437  for (let r = 0; r < rows; r++) for (let x = 0; x < w; x++) {
438    const top = px[2 * r * w + x] ?? AIR, bot = 2 * r + 1 < h ? (px[(2 * r + 1) * w + x] ?? AIR) : AIR
439    const i = (r * w + x) * 3
440    if (top === AIR && bot === AIR) { out[i] = 0x20; out[i + 1] = DEFAULT; out[i + 2] = DEFAULT }
441    else if (top === AIR) { out[i] = LOWER; out[i + 1] = bot; out[i + 2] = DEFAULT }
442    else { out[i] = UPPER; out[i + 1] = top; out[i + 2] = bot === AIR ? DEFAULT : bot }
443  }
444  return out
445}
446
447const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
448export function base64(bytes: Uint8Array): string {
449  let out = ''
450  for (let i = 0; i < bytes.length; i += 3) {
451    const a = bytes[i] ?? 0, b = bytes[i + 1], c = bytes[i + 2]
452    const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
453    out += B64.charAt((n >> 18) & 63) + B64.charAt((n >> 12) & 63) + (b === undefined ? '=' : B64.charAt((n >> 6) & 63)) + (c === undefined ? '=' : B64.charAt(n & 63))
454  }
455  return out
456}
457
458export function encodeCells(cells: Uint32Array): string {
459  // little-endian u32 triplets, as RasterProps asks
460  const bytes = new Uint8Array(cells.length * 4)
461  const view = new DataView(bytes.buffer)
462  cells.forEach((v, i) => view.setUint32(i * 4, v, true))
463  return base64(bytes)
464}
465
466// --------------------------------------------------------------- the text ---
467
468export function fmtTokens(n: number) {
469  if (n >= 1e6) return (n / 1e6).toFixed(n % 1e6 ? 1 : 0).replace(/\.0$/, '') + 'M'
470  if (n >= 1e3) return (n >= 1e4 ? Math.round(n / 1e3) : Math.round(n / 100) / 10) + 'k'
471  return String(Math.round(n))
472}
473
474export const bar = (pct: number, n = 10) => {
475  const k = Math.max(0, Math.min(n, Math.round((pct / 100) * n)))
476  return '▰'.repeat(k) + '▱'.repeat(n - k)
477}
478
479export const useColor = (p: number) => (p >= 90 ? '#ff3b30' : p >= 80 ? '#e8672e' : p >= 50 ? '#e3a63f' : '#34c759')
480export const ctxColor = (p: number) => (p >= 80 ? '#ff3b30' : p >= 50 ? '#e3a63f' : '#7b8591')
481
482export function windowLabel(kind: string) {
483  if (kind === 'five_hour') return '5h'
484  if (kind === 'seven_day') return 'week'
485  const rest = kind.replace(/^seven_day_/, '').replace(/_/g, ' ')
486  return rest.charAt(0).toUpperCase() + rest.slice(1)
487}
488
489export function until(fromMs: number, toMs: number) {
490  let m = Math.max(0, Math.round((toMs - fromMs) / 60000))
491  const d = Math.floor(m / 1440); m -= d * 1440
492  const h = Math.floor(m / 60); m -= h * 60
493  return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : `${m}m`
494}
495
496const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
497const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
498const pad2 = (n: number) => String(n).padStart(2, '0')
499// Local time: under a day away, the clock; further, the day too.
500export function resetAt(nowMs: number, iso: string) {
501  const d = new Date(iso)
502  if (Number.isNaN(d.getTime())) return undefined
503  const clock = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
504  return d.getTime() - nowMs < 864e5 ? clock : `${DAYS[d.getDay()]} ${d.getDate()} ${MONTHS[d.getMonth()]} ${clock}`
505}
506
507export const WORDS: Record<Mode, string[]> = {
508  idle: [],
509  thinking: ['Diving', 'Sounding', 'Drifting'],
510  'tool-use': ['Breaching', 'Lunge-feeding', 'Fluking'],
511  responding: ['Surfacing', 'Spouting'],
512}
513
514// For a creature that walks: no diving, no breaching.
515export const LAND_WORDS: Record<Mode, string[]> = {
516  idle: [],
517  thinking: ['Pondering', 'Musing', 'Scheming'],
518  'tool-use': ['Conjuring', 'Tinkering', 'Scurrying'],
519  responding: ['Skating', 'Kickflipping', 'Rolling'],
520}
521
522export function modeFromSpinner(m: string): Mode {
523  if (m === 'tool-use' || m === 'tool-input') return 'tool-use'
524  if (m === 'responding') return 'responding'
525  return 'thinking'
526}
527
hooks/skins.ts 127 lines
1// Written by scripts/build-skins.ts from skins/*.sh. Do not edit: change the skin file and run it again.
2export const SHIPPED: Record<string, string> = {
3  clawd: `# clawd — pixbar's land creature. 21 x 10, four working poses + idle.
4#
5# The creature itself is 16 wide, traced by measurement rather than by eye:
6# module 26.375px, every silhouette edge on an integer multiple, symmetric about
7# column 7.5. Columns 16-20 are the wand and its sparkle, which exist only while
8# Claude is working.
9#
10# The eyes narrow to > < on the working poses and open to a plain bar when idle.
11# They are the one part of the body that moves while the rest is frozen, and they
12# get away with it because they sit inside the silhouette rather than on its edge:
13# nothing shifts, so nothing competes with the sparkle for attention. A state
14# change, not a motion.
15SKIN_NAME="clawd"
16NFRAMES=4                  # working poses; index 4 is the parked idle pose
17W=21
18SKIN_BASE='203;123;95'     # #CB7B5F  sampled off the source art
19SKIN_GAUGE='154;90;68'    # #9A5A44  clawd in shadow: it darkens from the feet up as context fills.
20                           #          Not red (read as bleeding), not grey: its own terracotta, shaded.
21SKIN_CREAM='250;249;245'   # #FAF9F5  uncommitted worktree
22SKIN_INK2='122;115;106'    # the wand. The sticker draws it BLACK, which is
23                           # invisible on a dark bar - the value has to invert
24                           # so the stick still reads as an object being held.
25SKIN_INK3='242;201;119'    # #F2C977  the sparkle, sampled off the sticker
26SKIN_INK4='251;234;184'    # #FBEAB8  its bright tone. The sticker mixes gold and
27                           # WHITE squares; pure white here would read as the
28                           # cream a dirty worktree paints, so this stays in the
29                           # gold family and only lifts the value.
30IDLE_FRAME=4
31FAST_A=0; FAST_B=2         # no wand <-> full burst: the largest change there is
32HABITAT='land'             # clawd walks: the mod draws it on land, not in the sea
33RIDE='skateboard'          # and skates while it answers; at rest the board leans beside it
34
35# The hands - the two nubs the wide bar puts outside the body, cols 0-1 and
36# 14-15. They take the role the whale's fluke has: what an uncommitted worktree
37# paints. Hands, not feet, because uncommitted work is something Clawd is
38# HOLDING; and being the outermost pixels they stay legible at a glance even
39# when the context gauge has flooded everything behind them.
40#
41# The wand and sparkle are accent ink, so git and the gauge never touch them.
42DIRTY=(
43  "000000000000000000000" "000000000000000000000" "000000000000000000000"
44  "000000000000000000000" "110000000000001100000" "110000000000001100000"
45  "000000000000000000000" "000000000000000000000" "000000000000000000000"
46  "000000000000000000000"
47)
48
49# The beat is the wand coming out and the particles building: empty hand, spark,
50# full burst, scattering. The particle field is cols 16-20 over rows 0-8 and every
51# frame scatters it differently, so it twinkles rather than pulses - at one frame
52# a second a field that only grew and shrank read as a throb. Two tones alternate
53# inside it for the same reason.
54#
55# THE BODY DOES NOT MOVE. Hands, feet and the arm bar are byte-identical in all
56# five frames; only the tail columns and, at idle, the eyes differ. An earlier
57# build walked the legs and swung the free arm underneath the wand, and the two
58# motions fought: at 1 fps the eye cannot follow a gait and a sparkle at once, and
59# the twitching feet stole attention from the thing that was actually changing.
60FRAMES=(
61  # 0 - default body: the hand is empty
62  "001111111111110000000" "001101111110110000000" "001110111101110000000" "001101111110110000000"
63  "111111111111111100000" "111111111111111100000" "001111111111110000000" "001111111111110000000"
64  "000101000010100000000" "000101000010100000000"
65  # 1 - the wand lights: a first pair of motes at the tip
66  "001111111111110000000" "001101111110110000000" "001110111101110000030" "001101111110110000400"
67  "111111111111111122340" "111111111111111100030" "001111111111110000000" "001111111111110000000"
68  "000101000010100000000" "000101000010100000000"
69  # 2 - full burst: 16 motes, both tones, gaps between every one
70  "001111111111110000030" "001101111110110000403" "001110111101110003040" "001101111110110000304"
71  "111111111111111122430" "111111111111111100340" "001111111111110003030" "001111111111110000403"
72  "000101000010100000030" "000101000010100000000"
73  # 3 - scattering: fewer motes, thrown further out
74  "001111111111110000003" "001101111110110000300" "001110111101110000040" "001101111110110000003"
75  "111111111111111122300" "111111111111111100040" "001111111111110000300" "001111111111110000003"
76  "000101000010100000030" "000101000010100000000"
77  # 4 - idle: no wand, and the eyes open back up to a plain bar
78  "001111111111110000000" "001111111111110000000" "001101111110110000000" "001101111110110000000"
79  "111111111111111100000" "111111111111111100000" "001111111111110000000" "001111111111110000000"
80  "000101000010100000000" "000101000010100000000"
81)
82`,
83  whale: `# whale — pixbar's default creature. 15 x 10, four beat frames + idle, traced off
84# a 128x128 GIF (4 frames at 5fps, one pixel every 6px — the module
85# size is the gcd of every ink run, measured rather than guessed).
86#
87# The whale is also built into the mod (WHALE in hooks/scene.ts), so it still swims
88# when no skin file can be read. This file is the same whale in skin form: an example
89# of the format, kept identical to the built-in one by a test. Change both together.
90SKIN_NAME="whale"
91HABITAT='sea'              # swims; clawd sets land
92NFRAMES=4                  # beat frames; index 4 is the parked idle pose
93W=15                       # sprite width in pixels; canvas is W+2 (gap + tick)
94SKIN_BASE='247;147;26'     # #F7931A
95SKIN_GAUGE='216;105;94'    # #D8695E  context window, fills bottom-up
96SKIN_CREAM='250;249;245'   # #FAF9F5  uncommitted worktree
97IDLE_FRAME=4               # the parked rest pose
98FAST_A=1; FAST_B=0         # 54px apart: the only pair that swings body AND fluke
99
100# The fluke. The one part that reads as "the animal's business end", so it is
101# what an uncommitted worktree paints.
102DIRTY=(
103  "000000000001111" "000000000001111" "000000000001111" "000000000001111" "000000000001111"
104  "000000000001111" "000000000001111" "000000000001111" "000000000001111" "000000000001111"
105)
106
107FRAMES=(
108  # 0 - tail down
109  "000000000000000" "000000000000000" "111111111110111" "111111111111111" "110111111111110"
110  "111111111111100" "111111111111100" "000001111110110" "111111111100011" "000001100000011"
111  # 1 - rest, body up
112  "000000000000011" "111111111110111" "111111111111110" "110111111111100" "111111111111100"
113  "111111111111110" "000001111110111" "111111111100011" "000001100000000" "000000000000000"
114  # 2 - tail up
115  "000000000000011" "000000000000011" "111111111110110" "111111111111100" "110111111111100"
116  "111111111111110" "111111111111111" "000001111110111" "111111111100000" "000001100000000"
117  # 3 - rest, body down
118  "000000000000000" "000000000000011" "111111111110111" "111111111111110" "110111111111100"
119  "111111111111100" "111111111111110" "000001111110111" "111111111100011" "000001100000000"
120  # 4 - idle: the rest pose, one blank row above and none below is the closest
121  #     a 9-row whale gets to centred in 10 rows
122  "000000000000000" "000000000000011" "111111111110111" "111111111111110" "110111111111100"
123  "111111111111100" "111111111111110" "000001111110111" "111111111100011" "000001100000000"
124)
125`,
126}
127