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…

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.
PixBar makes no network calls and sends nothing anywhere.
Reads
HOME environment variable, to find ~/.claude/pixbar. No other variable, and no credential.~/.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.hooks/register.tsx 510 lines1import 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
510hooks/scene.ts 527 lines1// 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}
527hooks/skins.ts 127 lines1// 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