Orchestrate lean headless Claude workers (Haiku scouts, Sonnet coders) with the cw CLI, and watch them live in a pixel-art side pane

Claude Code plugin. The main session (Opus) leads; cheap headless workers do the reading and the coding.
orchestrate skill — the playbook: scout → spec → coder → verify, fleets, permissions, cost rules.cw CLI (bin/, on the Bash PATH) — starts one claude -p worker with a ~3k-token cold start.scout = Haiku, read-only (+ web with -w). coder = Sonnet, edits and runs tests (auto mode, risky calls come back as requests).cwgo (background start), cwwait (notify on each finish), cwls, cwopen, cwwt (one git worktree per writer), cwkill./crew opens it; it also opens itself when a worker starts. Click a card's details › for its task, latest notes, recent steps and report; ‹ back (or b) returns./plugin install cw-crew --marketplace memoksin/cw-crew
Answer y to add the marketplace, then pick a scope.
winget install -e --id Git.Git
winget install -e --id jqlang.jq
winget install -e --id Anthropic.ClaudeCode
winget install -e --id BurntSushi.ripgrep.MSVC # optional, faster scout searches
winget install -e --id sharkdp.fd # optional
Git for Windows brings Git Bash (Claude Code's Bash tool), perl and git.
claude and the /plugin install line above.%USERPROFILE%\.claude\settings.json: { "env": { "CLAUDE_CODE_GIT_BASH_PATH": "C:\\Program Files\\Git\\bin\\bash.exe" } }
cw scout <<< "Reply ok. No tools.". The last line must contain rc=0.Update later with winget upgrade --all and claude plugin update cw-crew@cw-crew.
jq, see Windows.claude, jq, perl, git on PATH. rg and fd help scouts but are optional.CLAUDE_CODE_NO_FLICKER=1, outside tmux) and ≥110 columns. Otherwise it sits above the prompt. Sprites need a terminal with an image protocol; others get half-blocks.details › button per worker sits under it. Install the plugin from a terminal at user scope; the desktop session loads it.Ask Claude to delegate ("use workers", /orchestrate). By hand:
cw scout -f src/app.ts <<< "List every caller of parseConfig with file:line."
cwgo api coder -W api -T 'npm test' -- specs/common.md specs/api.md
cwwait # run in the background; fires when a worker finishes
| Var | Default | What |
|---|---|---|
CW_SCOUT_MODEL / CW_CODER_MODEL | claude-haiku-5-5 / claude-sonnet-5-5 | worker models |
CW_MAX_TURNS | 80 | coder turn limit |
CW_TIMEOUT | 3600 coder / 1200 scout | seconds |
CW_LOG_DIR | ~/.claude/cw-runs | run logs (the pane reads them too) |
CW_FLEET | $CW_LOG_DIR/fleet/<repo> | fleet dir |
CW_WT_ROOT | ../<repo>-wt | worktree root |
CW_JEV | 0 | 1 = Jev (OpenRouter) picks worker effort per task. Sends the first 20k characters of each task to OpenRouter. Off: role default (scout low, coder medium) |
OPENROUTER_API_KEY / CW_JEV_KEYCHAIN | — | key for CW_JEV=1; on macOS a keychain service name works instead of the env key |
CW_KILL_ALLOW | — | colon-separated command prefixes cwkill may also stop |
Set them in ~/.claude/settings.json under env.
claude --plugin-dir . # run from this folder
claude plugin validate .
claude plugin test .
tools/gen_frames.sh # rebuild frames/ after changing hooks/creatures.ts (needs bun + Pillow)
MIT
hooks/register.tsx 321 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { FRAME_MS, frameFile, imageSource, lookKey } from './creatures'
5import {
6 buildWorkers, clock, DETAILS, drawScene, encode, isActive, isVisible, LABEL, msysToWin, parseEvents, sceneRows, sceneSvg, SPRITE_COLS, SPRITE_ROWS,
7 type Meta, type Run, type State, type Worker,
8} from './scene'
9
10const PANE = 'cw-crew'
11const DAY = 86_400_000
12const slots = atom({ plugin: 'cw-crew', key: 'slots' } as const, 0)
13const selected = atom({ plugin: 'cw-crew', key: 'selected' } as const, '')
14const NOTES = 4, STEPS = 10, REPORT_CHARS = 2400
15type Tracked = Run & { size: number; final: boolean }
16
17// Module state: a reload starts it over and the next poll rebuilds it from the run files.
18let dir = ''
19let session = ''
20let metas = new Map<string, Meta | null>()
21let runs = new Map<string, Tracked>()
22let workers: Worker[] = []
23let mounted: { cols: number; rows: number; maxRows: number; images: boolean; keys: Set<string> } | undefined
24// Sprites are Images until the terminal shows it draws their alt instead (no image protocol,
25// tmux): a blit of a mounted Image is then denied, and the pane falls back to half-blocks.
26let images = true
27let refused = 0
28let shown = new Map<string, string>()
29let seen = new Map<string, State>()
30let status: string | undefined
31let opened = false
32let polling = false
33let lastKick = 0
34// Surfaces with no Raster (desktop, editor, mobile) get the scene as an Svg, redrawn by poll() while shown.
35let svgAt = 0
36let svgKick = 0
37// PNG frames as base64, read once each; a session sees a few dozen of the 650.
38const pngs = new Map<string, string>()
39// Detail view: the open worker's live notes (its assistant text) and final report, refreshed by poll().
40let selKey = ''
41let detail: { key: string; size: number; notes: string[]; report?: string } = { key: '', size: -1, notes: [] }
42
43function readText($: EngineInterface, name: string) {
44 return $.fs.read(`${dir}/${name}`).catch(() => undefined)
45}
46
47async function poll($: EngineInterface) {
48 if (polling) return
49 polling = true
50 try {
51 const sid = await $.session.id()
52 if (sid !== session) {
53 session = sid
54 metas = new Map(); runs = new Map(); seen = new Map()
55 }
56 const now = await $.clock.now()
57 const entries = await $.fs.list(dir).catch(() => [])
58 const byName = new Map(entries.map(en => [en.name, en]))
59
60 for (const en of entries) {
61 if (!en.name.endsWith('.meta')) continue
62 const id = en.name.slice(0, -'.meta'.length)
63 if (!metas.has(id)) {
64 if (now - en.mtimeMs > DAY) { metas.set(id, null); continue }
65 const text = await readText($, en.name)
66 let meta: Meta | undefined
67 try { meta = text ? (JSON.parse(text) as Meta) : undefined } catch { meta = undefined }
68 if (!meta) continue
69 // A resumed/restarted lead gets a new session id: keep showing the workers still running from before.
70 const live = !byName.has(`${id}.done`) && now / 1000 - meta.start < (meta.tmo ?? 3600) + 2400 // + cw's acceptance run
71 metas.set(id, meta.session === session || live ? meta : null)
72 }
73 const meta = metas.get(id)
74 if (!meta) continue
75 const run = runs.get(id) ?? { id, meta, events: [], size: -1, final: false }
76 if (run.final) {
77 // The lead closed the request without a resume (cwopen -x): lower the hand.
78 if (run.question !== undefined && !byName.has(`${id}.ask`)) runs = new Map(runs).set(id, { ...run, question: undefined })
79 continue
80 }
81 const ev = byName.get(`${id}.events`)
82 const done = byName.get(`${id}.done`)
83 let next: Tracked = run
84 // The events file is complete once .done exists, so read it one last time then.
85 if (ev && (ev.size !== run.size || done)) {
86 next = { ...next, events: parseEvents((await readText($, ev.name)) ?? ''), size: ev.size }
87 }
88 if (done) {
89 const rc = Number.parseInt((await readText($, done.name)) ?? '', 10)
90 const question = byName.has(`${id}.ask`) ? (await readText($, `${id}.ask`))?.trim() : undefined
91 next = { ...next, rc: Number.isNaN(rc) ? 1 : rc, doneAt: done.mtimeMs, question, final: true }
92 }
93 if (next !== run || !runs.has(id)) runs = new Map(runs).set(id, next)
94 }
95
96 const all = buildWorkers([...runs.values()], now)
97 announce($, all)
98 const visible = all.filter(w => isVisible(w, now))
99 const grew = visible.length !== workers.length
100 workers = visible
101 if (grew) await update($, slots, () => visible.length)
102
103 const active = all.filter(isActive).length
104 const asking = all.filter(w => w.state === 'ask').length
105 const text = active ? `⚒ ${active} working${asking ? ` · ${asking} asking` : ''}`
106 : asking ? `? ${asking} waiting for an answer` : undefined
107 if (text !== status) { status = text; $.ui.status(text) }
108
109 if (active && !opened) {
110 opened = true
111 const res = await $.ui.open(paneArgs())
112 if (!res.isPlaced) $.ui.toast('cw-crew: workers running, open the pane with /crew')
113 }
114 // A pane hidden behind a tab drops its Raster; ask for a redraw now and then so the animation resumes.
115 if (!mounted && opened && now - lastKick > 2000) { lastKick = now; $.ui.invalidate('ui.render') }
116 // ponytail: whole-Svg redraw per poll, no per-frame blit there; fine for a handful of workers.
117 else if (!selKey && now - svgAt < 5000 && now - svgKick >= (active ? 400 : 2000)) { svgKick = now; $.ui.invalidate('ui.render') }
118 if (selKey) await refreshDetail($, all, byName)
119 } finally {
120 polling = false
121 }
122}
123
124// The open worker's newest run: its stream (.jsonl) holds the notes, its result (.json) the report.
125async function refreshDetail($: EngineInterface, all: Worker[], byName: Map<string, { size: number }>) {
126 const w = all.find(x => x.key === selKey)
127 const id = w?.runIds.at(-1)
128 if (!w || !id) return
129 const size = byName.get(`${id}.jsonl`)?.size ?? 0
130 if (detail.key === w.key && detail.size === size && (w.end === undefined || detail.report !== undefined)) return
131 const notes: string[] = []
132 for (const line of ((await readText($, `${id}.jsonl`)) ?? '').split('\n')) {
133 if (!line.includes('"type":"assistant"')) continue
134 try {
135 const m = JSON.parse(line) as { message?: { content?: { type: string; text?: string }[] } }
136 for (const c of m.message?.content ?? []) if (c.type === 'text' && c.text?.trim()) notes.push(c.text.trim())
137 } catch { /* a line still being written */ }
138 }
139 let report: string | undefined
140 if (w.end !== undefined) {
141 try { report = ((JSON.parse((await readText($, `${id}.json`)) ?? '{}') as { result?: string }).result ?? '').trim() } catch { report = '' }
142 }
143 detail = { key: w.key, size, notes: notes.slice(-NOTES), report }
144 $.ui.invalidate('ui.render')
145}
146
147function openDetail($: EngineInterface, key: string) {
148 selKey = key
149 detail = { key: '', size: -1, notes: [] }
150 mounted = undefined
151 void update($, selected, () => key)
152}
153
154// Ask for the rows the scene needs (room for two workers once any run), not the default third of the screen.
155const paneArgs = () => ({ id: PANE, title: 'Crew', rows: sceneRows(workers.length && Math.max(2, workers.length), 60), columns: 60 })
156
157function announce($: EngineInterface, all: Worker[]) {
158 // One state per key (the last run wins): two entries with one key would flip-flop and toast every poll.
159 for (const w of new Map(all.map(w => [w.key, w])).values()) {
160 const before = seen.get(w.key)
161 seen.set(w.key, w.state)
162 if (before === w.state || (before === undefined && w.end !== undefined)) continue
163 if (w.state === 'ask') $.ui.toast(`? ${w.name} asks: ${w.question ?? ''}`, { timeoutMs: 8000 })
164 else if (w.state === 'blocked') $.ui.toast(`! ${w.name} blocked: ${w.arg}`)
165 else if (w.state === 'done') $.ui.toast(`✓ ${w.name} (${w.role}) done`)
166 else if (w.state === 'failed') $.ui.toast(`✗ ${w.name} (${w.role}) failed`)
167 }
168}
169
170async function paint($: EngineInterface) {
171 const at = mounted
172 const root = $.plugin.root
173 if (!at || sceneRows(workers.length, at.cols, at.maxRows) !== at.rows) return
174 const { words, spots } = drawScene(workers, at.cols, await $.clock.now(), at.maxRows, at.images)
175 const res = await $.ui.blit({ requestId: PANE, key: 'crew', cells: encode(words) })
176 if (res.deny) { mounted = undefined; return }
177 // The cells went in, so the pane is mounted: an Image refused now is one drawing its alt.
178 for (const s of spots) {
179 const key = lookKey(s.look)
180 if (!at.keys.has(s.key) || shown.get(s.key) === key) continue
181 const img = await $.ui.blit({ requestId: PANE, key: s.key, source: imageSource(s.look, root) })
182 if (img.deny) {
183 if (/alt|placeholder|unsupported/i.test(img.deny) || ++refused >= 6) {
184 images = false
185 mounted = undefined
186 $.ui.log(`cw-crew: no images here (${img.deny}); half-block sprites`)
187 $.ui.invalidate('ui.render')
188 }
189 return
190 }
191 refused = 0
192 shown.set(s.key, key)
193 }
194}
195
196export const register: Register = on => {
197 on('session.start', async ($, e, next) => {
198 session = await $.session.id()
199 const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || ''
200 dir = msysToWin((await $.env.get('CW_LOG_DIR')) || `${home}/.claude/cw-runs`).replace(/\\/g, '/')
201 await $.command.register({ name: 'crew', description: 'Open the cw worker pane (Haiku scouts / Sonnet coders)' })
202 $.clock.every(400, () => void poll($).catch(err => $.ui.log(`cw-crew poll: ${String(err)}`)))
203 $.clock.every(FRAME_MS, () => void paint($).catch(() => { mounted = undefined }))
204 return next(e)
205 })
206
207 // The engine docks a pane on the right only in the fullscreen layout from 110 columns; else it sits above the prompt.
208 on('command.run', { command: 'crew' }, async ($, e) => {
209 opened = true
210 // A reload with the pane open can refuse the first blits before the pane mounts; /crew retries PNG sprites.
211 images = true
212 refused = 0
213 mounted = undefined
214 await $.ui.open(paneArgs())
215 const { isFullscreen, columns } = e.presentation
216 // The layout hints are the terminal's; the desktop places the pane itself.
217 const where = (await $.env.get('CLAUDE_CODE_ENTRYPOINT')) === 'claude-desktop' ? ''
218 : !isFullscreen
219 ? ' It sits above the prompt: the right-side pane needs the fullscreen layout (CLAUDE_CODE_NO_FLICKER=1, outside tmux).'
220 : columns < 110 ? ` It sits above the prompt: the right-side pane needs a terminal of at least 110 columns (now ${columns}).` : ''
221 return { text: `Crew pane opened.${where}` }
222 })
223
224 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
225 await read($, slots)
226 const sel = await read($, selected)
227 const open = sel ? workers.find(w => w.key === sel) : undefined
228 if (open) {
229 mounted = undefined // paint() must not blit the scene over the detail view
230 const { Box, Text, Button } = $.ui.resolve(e)
231 const now = await $.clock.now()
232 const steps = (open.runIds.flatMap(id => runs.get(id)?.events ?? [])).filter(ev => ev.tool).slice(-STEPS)
233 const ago = (t: number) => clock(Math.max(0, now - t))
234 return (
235 <Box flexDirection="column" paddingX={1}>
236 <Box flexDirection="row" gap={1}>
237 <Button key="back" hotkey="b" onPress={() => { selKey = ''; mounted = undefined; void update($, selected, () => '') }}>‹ back</Button>
238 <Text bold>{open.name}</Text>
239 <Text dimColor>{open.model} · {LABEL[open.state]} · {clock((open.end ?? now) - open.start)} · {open.turns} turns</Text>
240 </Box>
241 {open.state === 'ask' && open.question && <Text color="warning" wrap="wrap">? {open.question}</Text>}
242 <Text bold>Task</Text>
243 <Text dimColor wrap="wrap">{open.task.slice(0, 600)}</Text>
244 <Text bold>Latest notes</Text>
245 {detail.key !== open.key ? <Text dimColor>reading…</Text>
246 : detail.notes.length === 0 ? <Text dimColor>no notes yet</Text>
247 : detail.notes.map(n => <Text wrap="wrap">· {n.slice(0, 400)}</Text>)}
248 <Text bold>Recent steps</Text>
249 {steps.length === 0 ? <Text dimColor>none yet</Text>
250 : steps.map(ev => <Text wrap="truncate-end"><Text dimColor>{ago(ev.t)} ago </Text>{ev.tool} {ev.arg ?? ''}</Text>)}
251 {detail.key === open.key && detail.report !== undefined && <Text bold>Report</Text>}
252 {detail.key === open.key && detail.report !== undefined &&
253 <Text wrap="wrap">{detail.report ? detail.report.slice(0, REPORT_CHARS) : 'no report (timed out or killed)'}</Text>}
254 </Box>
255 )
256 }
257 if (sel) { selKey = ''; void update($, selected, () => '') } // the worker left the crew
258 if (e.surface !== 'terminal') {
259 const { Box, Text, Button, Svg } = $.ui.resolve(e)
260 const now = await $.clock.now()
261 svgAt = now
262 const cols = Math.max(46, Math.min(138, e.props.bodyColumns))
263 let { rows, words, spots } = drawScene(workers, cols, now, 256, true)
264 const sprites = []
265 for (const s of spots) {
266 const file = frameFile(s.look)
267 let base64 = pngs.get(file)
268 if (base64 === undefined) {
269 base64 = await $.fs.read(`${$.plugin.root}/frames/${file}`, { as: 'bytes' }).then(r => r.base64, () => '')
270 pngs.set(file, base64)
271 }
272 if (base64) sprites.push({ x: s.x, y: s.y, base64 })
273 }
274 let svg = sceneSvg(words, cols, rows, sprites)
275 // A frame that would not read, or too many sprites for one Svg: half-block pixels instead.
276 if (sprites.length < spots.length || svg.length > 131_072) { ({ rows, words } = drawScene(workers, cols, now, 256, false)); svg = sceneSvg(words, cols, rows) }
277 const alt = workers.length ? workers.map(w => `${w.name} ${w.role} ${LABEL[w.state]}`).join(', ') : 'no workers'
278 return (
279 <Box flexDirection="column" gap={1}>
280 {svg.length <= 131_072 ? <Svg key="crew" source={svg} alt={alt} />
281 : workers.map(w => <Text>{w.name} · {w.role} · {LABEL[w.state]}</Text>)}
282 {workers.length > 0 && (
283 <Box flexDirection="row" gap={1} flexWrap="wrap">
284 {workers.map(w => <Button key={`open-${w.key}`} onPress={() => openDetail($, w.key)}>{w.name} {DETAILS}</Button>)}
285 </Box>
286 )}
287 </Box>
288 )
289 }
290 const { Box, Button, Image, Raster } = $.ui.resolve(e)
291 const cols = Math.max(24, Math.min(512, e.props.bodyColumns))
292 // Draw every card, even past the pane's height: the engine scrolls a body taller than its window.
293 // Only the 256-row hard cap still folds workers into one-line rows.
294 const maxRows = 256
295 const { rows, words, spots, hits } = drawScene(workers, cols, await $.clock.now(), maxRows, images)
296 mounted = { cols, rows, maxRows, images, keys: new Set(spots.map(s => s.key)) }
297 const raster = <Raster key="crew" columns={cols} rows={rows} cells={encode(words)} />
298 // A "details ›" Button on each card's bottom edge opens its detail view.
299 // Not `h`: JSX compiles to the global h(), which a parameter of that name would shadow.
300 const buttons = hits.map(hit => (
301 <Box position="absolute" top={hit.y} left={hit.x}>
302 <Button key={`open-${hit.key}`} plain dimColor onPress={() => openDetail($, hit.key)}>{DETAILS}</Button>
303 </Box>
304 ))
305 if (!images) return <Box width={cols} height={rows}>{raster}{buttons}</Box>
306 // Each sprite an Image over the blank cells the scene left for it; paint() swaps its frames.
307 shown = new Map(spots.map(s => [s.key, lookKey(s.look)]))
308 return (
309 <Box width={cols} height={rows}>
310 {raster}
311 {spots.map(s => (
312 <Box position="absolute" top={s.y} left={s.x}>
313 <Image key={s.key} source={imageSource(s.look, $.plugin.root)} columns={SPRITE_COLS} rows={SPRITE_ROWS} alt={s.alt} />
314 </Box>
315 ))}
316 {buttons}
317 </Box>
318 )
319 })
320}
321hooks/creatures.ts 954 lines1// The crew's creatures: a round owl per scout, a baby dragon per coder (purple and crowned on Opus).
2// Drawn in code at 60x32 pixels, 4x the old 15x8 half-block sprites each way, for terminals that
3// show images; the same frame downsampled to 15x8 half-blocks for the ones that draw cells only.
4// Frames step every FRAME_MS; motion is eased over long loops so nothing jumps more than a pixel or two.
5
6import type { Role, State } from './scene'
7
8export type Pose = State | 'sleep'
9export const CLEAR = 0x02000000
10export const BIG_W = 60, BIG_H = 32
11// Hi-res pixels per logical pixel in the image frames.
12export const BIG_S = 2
13export const SMALL_W = 15, SMALL_H = 8
14export const FRAME_MS = 125
15// A see-through black (gen_frames.py writes it with alpha) so the shadow suits any terminal background.
16export const SHADOW = 0x03000000
17
18const OUT = 0x2a2140
19const EYE = 0x1f1833, IRIS = 0x4a4fa8, SHINE = 0xffffff, BLUSH = 0xff9cc2, TONGUE = 0xff6f91
20const TEAR = 0x8fd3ff
21const CONFETTI = [0xff6b6b, 0xffd166, 0x06d6a0, 0x4dabf7, 0xcc5de8, 0xffffff]
22
23// Drawn in logical pixels; `s` hi-res pixels per logical one, so curves and outlines come out finer
24// while every shape keeps its logical size and place.
25export class Canvas {
26 px: Uint32Array
27 readonly pw: number; readonly ph: number
28 constructor(readonly w: number, readonly h: number, readonly s = 1) {
29 this.pw = w * s; this.ph = h * s
30 this.px = new Uint32Array(this.pw * this.ph).fill(CLEAR)
31 }
32 at(x: number, y: number) {
33 const i = Math.floor(x * this.s + 1e-6), j = Math.floor(y * this.s + 1e-6)
34 return i >= 0 && j >= 0 && i < this.pw && j < this.ph ? this.px[j * this.pw + i] ?? CLEAR : CLEAR
35 }
36 dot(i: number, j: number, c: number, on?: number) {
37 if (i < 0 || j < 0 || i >= this.pw || j >= this.ph) return
38 if (on !== undefined && this.px[j * this.pw + i] !== on) return
39 this.px[j * this.pw + i] = c
40 }
41 // One logical pixel: an s x s block, placed to the hi-res pixel.
42 put(x: number, y: number, c: number, on?: number) {
43 const i0 = Math.floor(x * this.s + 1e-6), j0 = Math.floor(y * this.s + 1e-6)
44 for (let j = 0; j < this.s; j++) for (let i = 0; i < this.s; i++) this.dot(i0 + i, j0 + j, c, on)
45 }
46 rect(x: number, y: number, w: number, h: number, c: number) {
47 const i0 = Math.floor(x * this.s + 1e-6), j0 = Math.floor(y * this.s + 1e-6)
48 for (let j = 0; j < Math.round(h * this.s); j++) for (let i = 0; i < Math.round(w * this.s); i++) this.dot(i0 + i, j0 + j, c)
49 }
50 // Every hi-res pixel whose centre lies in the ellipse; `keep` (in logical units) limits it to some of them.
51 oval(cx: number, cy: number, rx: number, ry: number, c: number, keep?: (x: number, y: number) => boolean) {
52 const s = this.s
53 for (let j = Math.floor((cy - ry) * s); j <= Math.ceil((cy + ry) * s); j++) {
54 for (let i = Math.floor((cx - rx) * s); i <= Math.ceil((cx + rx) * s); i++) {
55 const dx = ((i + 0.5) / s - cx) / rx, dy = ((j + 0.5) / s - cy) / ry
56 if (dx * dx + dy * dy <= 1 && (!keep || keep(i / s, j / s))) this.dot(i, j, c)
57 }
58 }
59 }
60 // An oval with a dark rim of its own, so it reads over a body of a near colour.
61 rim(cx: number, cy: number, rx: number, ry: number, c: number) {
62 this.oval(cx, cy, rx + 1, ry + 1, OUT)
63 this.oval(cx, cy, rx, ry, c)
64 }
65 tri(ax: number, ay: number, bx: number, by: number, cx: number, cy: number, c: number) {
66 const side = (px: number, py: number, x0: number, y0: number, x1: number, y1: number) =>
67 (px - x1) * (y0 - y1) - (x0 - x1) * (py - y1)
68 const s = this.s
69 for (let j = Math.floor(Math.min(ay, by, cy) * s); j <= Math.ceil(Math.max(ay, by, cy) * s); j++) {
70 for (let i = Math.floor(Math.min(ax, bx, cx) * s); i <= Math.ceil(Math.max(ax, bx, cx) * s); i++) {
71 const px = (i + 0.5) / s, py = (j + 0.5) / s
72 const d1 = side(px, py, ax, ay, bx, by), d2 = side(px, py, bx, by, cx, cy), d3 = side(px, py, cx, cy, ax, ay)
73 if (!((d1 < 0 || d2 < 0 || d3 < 0) && (d1 > 0 || d2 > 0 || d3 > 0))) this.dot(i, j, c)
74 }
75 }
76 }
77 line(x0: number, y0: number, x1: number, y1: number, c: number, thick = 1) {
78 const n = Math.max(1, Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0)) * this.s))
79 for (let k = 0; k <= n; k++) {
80 const x = x0 + ((x1 - x0) * k) / n, y = y0 + ((y1 - y0) * k) / n
81 this.rect(x, y, thick, thick, c)
82 }
83 }
84 stamp(rows: string[], pal: Record<string, number>, ox: number, oy: number, k = 1) {
85 rows.forEach((row, y) => {
86 for (let x = 0; x < row.length; x++) {
87 const c = pal[row[x] ?? '.']
88 if (c !== undefined) this.rect(ox + x * k, oy + y * k, k, k, c)
89 }
90 })
91 }
92 // A one hi-res pixel dark edge around everything drawn so far: the sticker look.
93 outline(c: number) {
94 const src = this.px.slice(), W = this.pw, H = this.ph
95 const at = (x: number, y: number) => (x < 0 || y < 0 || x >= W || y >= H ? CLEAR : src[y * W + x])
96 for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
97 if (src[y * W + x] !== CLEAR) continue
98 if (at(x - 1, y) !== CLEAR || at(x + 1, y) !== CLEAR || at(x, y - 1) !== CLEAR || at(x, y + 1) !== CLEAR) {
99 this.px[y * W + x] = c
100 }
101 }
102 }
103 over(o: Canvas) {
104 for (let i = 0; i < this.px.length; i++) if ((o.px[i] ?? CLEAR) !== CLEAR) this.px[i] = o.px[i] ?? CLEAR
105 }
106}
107
108const mix = (a: number, b: number, k: number) => {
109 const ch = (s: number) => Math.round(((a >> s) & 255) * (1 - k) + ((b >> s) & 255) * k)
110 return (ch(16) << 16) | (ch(8) << 8) | ch(0)
111}
112// One smooth cycle over `n` frames: 0 at frame 0, 1 halfway, back to 0.
113const cyc = (f: number, n: number) => 0.5 - 0.5 * Math.cos((2 * Math.PI * f) / n)
114const ease = (t: number) => (t <= 0 ? 0 : t >= 1 ? 1 : t * t * (3 - 2 * t))
115const dim = (c: number) => mix(c, 0x000000, 0.3)
116const rnd = (a: number, b: number) => {
117 const x = Math.sin(a * 12.9898 + b * 78.233) * 43758.5453
118 return x - Math.floor(x)
119}
120
121type Pal = {
122 body: number; shade: number; light: number; belly: number; mark: number
123 wing: number; face: number; beak: number; horn: number
124}
125const OWL: Pal = {
126 body: 0x63c9cf, shade: 0x3d9cad, light: 0xb4f0ea, belly: 0xfff3da, mark: 0xe8cf9f,
127 wing: 0x4aa3bd, face: 0xeafaf6, beak: 0xffa938, horn: 0x3d9cad,
128}
129const DRAGON: Pal = {
130 body: 0xffab52, shade: 0xe27c2f, light: 0xffd79c, belly: 0xfff0c8, mark: 0xf0c084,
131 wing: 0xff6f5e, face: 0xffab52, beak: 0, horn: 0xfff4dc,
132}
133const DRAGON_OPUS: Pal = {
134 body: 0xb59cff, shade: 0x8a6fe3, light: 0xe0d6ff, belly: 0xfff0fa, mark: 0xe6c6ef,
135 wing: 0xff8fcf, face: 0xb59cff, beak: 0, horn: 0xfff4dc,
136}
137
138type Arm = 'down' | 'up' | 'wave' | 'hold' | 'type' | 'chin' | 'point'
139type Eyes = 'open' | 'wide' | 'closed' | 'happy' | 'dizzy' | 'squeeze' | 'sad'
140type Mouth = 'smile' | 'open' | 'o' | 'frown' | 'flat'
141type Body = {
142 x: number; y: number; squash: number; look: [number, number]
143 eyes: Eyes; mouth: Mouth; left: Arm; right: Arm; phase: number
144 hat?: 'crown' | 'nightcap' | 'shell'; glasses?: boolean; flap: number
145}
146type Frame = { cx: number; cy: number; top: number; bottom: number; rx: number; ry: number; eyY: number }
147
148function frameOf(b: Body): Frame {
149 const top = 8 + b.y + b.squash, bottom = 30.5 + b.y
150 const cy = (top + bottom) / 2, ry = (bottom - top) / 2
151 return { cx: 16 + b.x, cy, top, bottom, rx: 12.5 + b.squash * 0.5, ry, eyY: cy - 3.5 }
152}
153
154function eye(c: Canvas, x: number, y: number, kind: Eyes, look: [number, number], phase: number, side: number, lid: number) {
155 const ix = Math.floor(x), iy = Math.floor(y)
156 switch (kind) {
157 case 'open':
158 case 'wide':
159 case 'sad': {
160 const r = kind === 'wide' ? 3.5 : 3.1
161 const lx = x + look[0], ly = y + look[1]
162 c.oval(lx, ly, r, r + 0.6, EYE)
163 c.oval(lx, ly + 1.8, r - 0.9, 1.6, IRIS, (px, py) => c.at(px, py) === EYE)
164 c.rect(Math.floor(lx - 1.7), Math.floor(ly - 2.3), 2, 2, SHINE)
165 c.put(lx + 1.1, ly + 1.2, SHINE)
166 if (kind === 'wide') c.put(lx + 1.4, ly - 2.2, SHINE)
167 if (kind === 'sad') {
168 // a heavy lid slanting down to the outside
169 for (let yy = iy - 5; yy <= iy; yy++) for (let xx = ix - 4; xx <= ix + 4; xx++) {
170 const cut = iy - 1 + (xx - ix) * side * 0.4
171 if (yy < cut) for (const on of [EYE, IRIS, SHINE]) c.put(xx, yy, lid, on)
172 }
173 c.line(ix - 3, iy - 1 - 3 * side * 0.4, ix + 3, iy - 1 + 3 * side * 0.4, EYE)
174 }
175 break
176 }
177 case 'closed':
178 for (const [dx, dy] of [[-2, 0], [-1, 1], [0, 1], [1, 1], [2, 0]] as const) c.put(ix + dx, iy + dy, EYE)
179 break
180 case 'happy':
181 for (const [dx, dy] of [[-2, 1], [-2, 2], [-1, 0], [0, -1], [1, 0], [2, 1], [2, 2]] as const) c.put(ix + dx, iy + dy, EYE)
182 break
183 case 'squeeze':
184 for (const [dx, dy] of [[-2, -2], [-1, -1], [0, 0], [-1, 1], [-2, 2], [-1, -2], [0, -1], [1, 0], [0, 1], [-1, 2]] as const) {
185 c.put(ix + dx * -side, iy + dy, EYE)
186 }
187 break
188 case 'dizzy':
189 for (let t = 0; t < 4 * Math.PI; t += 0.12) {
190 const r = 0.3 + t * 0.26
191 c.put(x + r * Math.cos(t * side + phase), y + 0.3 + r * Math.sin(t * side + phase), EYE)
192 }
193 break
194 }
195}
196
197function body(c: Canvas, role: Role, pal: Pal, b: Body) {
198 const { cx, cy, top, bottom, rx, ry, eyY } = frameOf(b)
199 if (role === 'coder') {
200 // the tail curls out behind, to the right
201 const wag = Math.sin((b.phase * Math.PI) / 8) * 0.8
202 for (let t = 0; t <= 1.0001; t += 0.05) {
203 const r = 3 - 1.7 * t
204 c.oval(cx + 8 + 8.5 * t, bottom - 3 - 9 * t * t + wag * t, r, r, pal.body)
205 }
206 c.tri(cx + 14.5, bottom - 13 + wag, cx + 18.5, bottom - 15 + wag, cx + 17, bottom - 10 + wag, pal.wing)
207 // bat wings behind the shoulders
208 for (const s of [-1, 1]) {
209 c.tri(cx + s * 7, cy - 6, cx + s * 15.6, cy - 12 - b.flap, cx + s * 10, cy + 1, pal.wing)
210 c.tri(cx + s * 11, cy - 4, cx + s * 15.6, cy - 12 - b.flap, cx + s * 15, cy - 1, pal.wing)
211 }
212 } else {
213 for (const s of [-1, 1]) c.tri(cx + s * 4.5, top + 3, cx + s * 10.5, top - 4.5, cx + s * 11.5, top + 6, pal.horn)
214 }
215
216 c.oval(cx, cy, rx, ry, pal.shade)
217 c.oval(cx - 0.6, cy - 0.9, rx - 1.1, ry - 0.9, pal.body)
218 c.oval(cx - 6, top + 3.6, 3.6, 1.7, pal.light)
219 c.oval(cx, bottom - 5.8, 7.6, 5.4, pal.belly)
220 if (role === 'scout') {
221 for (const [mx, my] of [[-4, -5], [1, -5], [-2, -2.5], [3, -2.5]] as const) {
222 const x = Math.floor(cx + mx), y = Math.floor(bottom + my)
223 c.put(x, y, pal.mark); c.put(x + 1, y + 1, pal.mark); c.put(x + 2, y, pal.mark)
224 }
225 for (const s of [-1, 1]) c.oval(cx + s * 5.5, eyY + 0.4, 5.9, 5.3, pal.face)
226 } else {
227 for (const dy of [-6.5, -3.5]) c.line(cx - 6, bottom + dy, cx + 5, bottom + dy, pal.mark)
228 for (const y of [bottom - 6.5, bottom - 3.5]) for (let x = cx - 8; x <= cx + 8; x++) {
229 if (c.at(x, y) === pal.mark && (c.at(x, y - 1) !== pal.belly || c.at(x, y + 1) !== pal.belly)) c.put(x, y, pal.belly)
230 }
231 for (const s of [-1, 1]) c.tri(cx + s * 4.5, top + 2.2, cx + s * 8.5, top - 5, cx + s * 8.6, top + 2.8, pal.horn)
232 }
233
234 // feet
235 for (const s of [-1, 1]) {
236 if (role === 'scout') c.oval(cx + s * 5, bottom, 2.6, 1.3, pal.beak)
237 else c.oval(cx + s * 6, bottom - 0.3, 3, 1.6, pal.shade)
238 }
239
240 for (const s of [-1, 1]) c.oval(cx + s * 8.6, eyY + 4.2, 1.8, 1, BLUSH)
241 for (const [s, x] of [[-1, cx - 5.5], [1, cx + 5.5]] as const) eye(c, x, eyY, b.eyes, b.look, (b.phase * Math.PI) / 8, s, pal.face)
242 if (b.glasses) {
243 for (const s of [-1, 1]) {
244 const gx = cx + s * 5.5
245 c.oval(gx, eyY, 4.9, 4.9, OUT, (x, y) => {
246 const dx = x + 0.5 - gx, dy = y + 0.5 - eyY
247 return dx * dx + dy * dy > 3.9 * 3.9
248 })
249 }
250 c.line(cx - 1, eyY - 1, cx, eyY - 1, OUT)
251 }
252
253 const my = Math.floor(eyY + 3)
254 const mx = Math.floor(cx)
255 if (role === 'scout') {
256 if (b.mouth === 'open' || b.mouth === 'o') {
257 c.rect(mx - 2, my, 4, 1, pal.beak); c.rect(mx - 2, my + 1, 4, 2, EYE)
258 c.put(mx - 2, my + 1, pal.beak); c.put(mx + 1, my + 1, pal.beak); c.rect(mx - 1, my + 3, 2, 1, pal.beak)
259 if (b.mouth === 'open') c.rect(mx - 1, my + 2, 2, 1, TONGUE)
260 } else {
261 c.rect(mx - 2, my, 4, 1, pal.beak); c.rect(mx - 1, my + 1, 2, 1, pal.beak)
262 if (b.mouth === 'frown') c.rect(mx - 1, my + 2, 2, 1, mix(pal.beak, EYE, 0.3))
263 }
264 } else {
265 c.put(mx - 2, my - 1, pal.shade); c.put(mx + 1, my - 1, pal.shade)
266 const y = my + 1
267 if (b.mouth === 'smile') {
268 for (const [dx, dy] of [[-3, 0], [-2, 1], [-1, 0], [0, 0], [1, 1], [2, 0]] as const) c.put(mx + dx, y + dy, EYE)
269 } else if (b.mouth === 'open') {
270 c.oval(cx, y + 1.2, 2.4, 1.9, EYE)
271 c.oval(cx, y + 2.4, 1.6, 0.8, TONGUE, (px, py) => c.at(px, py) === EYE)
272 c.put(mx - 2, y, SHINE)
273 } else if (b.mouth === 'o') {
274 c.oval(cx, y + 0.8, 1.3, 1.4, EYE)
275 } else if (b.mouth === 'frown') {
276 for (const [dx, dy] of [[-2, 1], [-1, 0], [0, 0], [1, 1]] as const) c.put(mx + dx, y + dy, EYE)
277 } else {
278 c.rect(mx - 1, y, 2, 1, EYE)
279 }
280 }
281
282 if (b.hat === 'crown') {
283 c.stamp([
284 'Y...YY...Y',
285 'YY.YYYY.YY',
286 'YYYYPPYYYY',
287 'yyyyyyyyyy',
288 ], { Y: 0xffd447, y: 0xe0a422, P: 0xff5fa2 }, mx - 5, Math.floor(top) - 4)
289 } else if (b.hat === 'nightcap') {
290 const t = Math.floor(top)
291 c.tri(mx - 8, t + 2, mx + 8, t + 2, mx + 11, t - 6, 0x6c7bff)
292 c.tri(mx - 2, t - 1, mx + 6, t - 1, mx + 11, t - 6, 0x8f9bff)
293 c.rect(mx - 8, t + 1, 17, 2, 0xf4f1ff)
294 c.oval(mx + 12, t - 6.5, 2.2, 2.2, 0xf4f1ff)
295 } else if (b.hat === 'shell') {
296 const t = Math.floor(top)
297 // the top of the eggshell, its broken edge jagged
298 c.oval(cx, t + 2, 7, 5, 0xfff6e6, (x, y) => y < t + 1 || (y === t + 1 && x % 2 === 0))
299 c.oval(cx - 3, t - 1, 1.2, 0.8, pal.body)
300 }
301}
302
303function arm(c: Canvas, role: Role, pal: Pal, b: Body, side: number, kind: Arm) {
304 const { cx, cy, eyY } = frameOf(b)
305 // owls have wings, dragons stubby paws: smaller, in the body's shade
306 const col = role === 'scout' ? pal.wing : pal.shade
307 const k = role === 'scout' ? 1 : 0.75
308 const s = side
309 const limb = (x: number, y: number, rx: number, ry: number) => c.rim(x, y, rx * k, ry * k, col)
310 switch (kind) {
311 case 'down': limb(cx + s * (role === 'scout' ? 11.2 : 10), cy + 3, 2.5, 4.8); break
312 case 'up': limb(cx + s * 12.4, cy - 8.5, 2.3, 4.5); break
313 case 'wave':
314 if (b.phase % 8 < 4) limb(cx + s * 12.4, cy - 8.5, 2.3, 4.5)
315 else limb(cx + s * 13.8, cy - 7, 2.3, 4.2)
316 break
317 case 'hold': limb(cx + s * 12.6, cy + 3, 4.6, 2.3); break
318 case 'type': limb(cx + s * 12.2, cy + 4.5 - (Math.floor(b.phase / 2) % 2 ? 1.5 : 0), 4.2, 2.2); break
319 case 'chin': c.rim(cx + s * 4.5, eyY + 7, 2.2, 1.6, col); break
320 case 'point':
321 limb(cx + s * 11.5, cy - 2, 2.4, 3.2)
322 limb(cx + s * 13.8, cy - 6.5, 2.2, 2.4)
323 c.oval(cx + s * 11.5, cy - 2, 2.4 * k, 3.2 * k, col)
324 break
325 }
326}
327
328function sparkle(c: Canvas, x: number, y: number, size: number, col = 0xffffff) {
329 if (size <= 0) return
330 c.put(x, y, col)
331 for (let k = 1; k <= size; k++) {
332 const t = k === size ? mix(col, 0x8888aa, 0.35) : col
333 c.put(x + k, y, t); c.put(x - k, y, t); c.put(x, y + k, t); c.put(x, y - k, t)
334 }
335}
336
337function star(c: Canvas, x: number, y: number) {
338 c.stamp(['..y..', '.yyy.', 'yyWyy', '.yyy.', '.y.y.'], { y: 0xffe066, W: 0xfffbe0 }, Math.floor(x) - 2, Math.floor(y) - 2)
339}
340
341const Z5 = ['#####', '...#.', '..#..', '.#...', '#####']
342const Z4 = ['####', '..#.', '.#..', '####']
343const QUESTION = ['.###.', '#...#', '...#.', '..#..', '..#..', '.....', '..#..']
344
345// Which frame a state shows at `now`: `seed` puts each worker's loop at its own phase.
346// Counts are frames of FRAME_MS. An intro plays once from the state's start; then the loop runs,
347// or, with no loop, the intro's last frame holds.
348export const LOOP: Record<Pose, number> = {
349 boot: 0, resumed: 0, think: 32, read: 48, write: 48, bash: 32, web: 32, test: 32,
350 blocked: 16, error: 32, ask: 32, done: 1, failed: 1, sleep: 32,
351}
352export const INTRO: Partial<Record<Pose, number>> = { boot: 12, resumed: 12, blocked: 8, done: 16, failed: 8 }
353export const BLINKS: Pose[] = ['think', 'read', 'write', 'bash', 'web', 'test', 'ask']
354// Working creatures breathe on a 2 s cycle; every loop above is a multiple of it.
355const BREATHE: Pose[] = ['think', 'read', 'write', 'bash', 'web', 'test', 'ask', 'error', 'blocked']
356export type Look = { role: Role; opus: boolean; state: Pose; frame: number; blink: boolean }
357
358export function lookAt(role: Role, model: string, state: Pose, now: number, stateAt: number, seed: number): Look {
359 const opus = /opus/i.test(model)
360 const tick = Math.floor(now / FRAME_MS) + seed
361 const intro = INTRO[state] ?? 0, loop = LOOP[state]
362 const step = Math.floor(Math.max(0, now - stateAt) / FRAME_MS)
363 const frame = step < intro ? step : loop ? intro + (loop > 1 ? tick % loop : 0) : intro - 1
364 const blink = BLINKS.includes(state) && tick % 31 === 0
365 return { role, opus, state, frame, blink }
366}
367export const lookKey = (l: Look) => `${l.role}${l.opus ? '+' : ''}:${l.state}:${l.frame}${l.blink ? 'b' : ''}`
368export const frameCount = (state: Pose) => (INTRO[state] ?? 0) + LOOP[state]
369
370export function drawLook({ role, opus, state, frame: f, blink }: Look): Canvas {
371 const pal = role === 'scout' ? OWL : opus ? DRAGON_OPUS : DRAGON
372 const c = new Canvas(BIG_W, BIG_H, BIG_S)
373 const fx = new Canvas(BIG_W, BIG_H, BIG_S)
374 const b: Body = {
375 x: 0, y: 0, squash: 0, look: [0, 0], eyes: 'open', mouth: 'smile', left: 'down', right: 'down',
376 phase: f, hat: opus ? 'crown' : undefined, flap: 0,
377 }
378 let creature = true
379 let mid: () => void = () => {}
380 let front: () => void = () => {}
381
382 switch (state) {
383 case 'boot': {
384 if (f <= 6) {
385 creature = false
386 const ox = [0, 0, -1, 0, 1, 0, -1][f] ?? 0
387 c.oval(16 + ox, 19.5, 10.5, 12.5, 0xfff6e6)
388 c.oval(17.5 + ox, 21, 9, 11, 0xf1e2c8, (x, y) => (x - 16 - ox) * 0.6 + (y - 19.5) > 6)
389 for (const [x, y, rx, ry] of [[11.5, 14, 2.2, 1.6], [20.5, 23, 2.8, 2.1], [12.5, 26, 1.6, 1.3], [21.5, 12, 1.4, 1.1]] as const) {
390 c.oval(x + ox, y, rx, ry, pal.body)
391 }
392 c.oval(12.5 + ox, 11, 1.4, 2.6, 0xffffff)
393 const crack: [number, number][] = [[6, 17], [8, 20], [10, 16], [13, 21], [15, 17], [18, 21], [21, 16], [24, 20], [26, 17]]
394 const n = Math.max(0, Math.floor((f - 1) * 1.6))
395 for (let i = 0; i < Math.min(n, crack.length - 1); i++) {
396 const p = crack[i], q = crack[i + 1]
397 if (p && q) c.line(p[0] + ox, p[1], q[0] + ox, q[1], OUT)
398 }
399 if (f >= 3) for (let i = 0; i < 3; i++) sparkle(fx, 36 + rnd(i, f) * 20, 4 + rnd(f, i) * 22, 1, i % 2 ? pal.body : 0xffffff)
400 } else {
401 const out = f >= 10
402 b.eyes = out ? 'happy' : 'wide'
403 b.mouth = out ? 'open' : 'o'
404 b.left = out ? 'up' : 'down'; b.right = out ? 'up' : 'down'
405 b.y = f === 10 ? -1.5 : f === 11 ? -0.5 : 0
406 if (out) b.hat = 'shell'
407 front = () => {
408 if (!out) {
409 // the lower half of the shell still around the belly, the cap flying off up and to the right
410 c.oval(16, 23, 12.5, 9, 0xfff6e6, (x, y) => y >= 26 + (x % 2))
411 for (let x = 4; x <= 28; x++) c.put(x, 25 + (x % 2), OUT)
412 const t = ease((f - 6) / 3)
413 const tx = 30 + 16 * t, ty = 9 - 7 * t
414 c.oval(tx, ty + 3, 6, 4, 0xfff6e6, (_, y) => y < ty + 4)
415 c.oval(tx - 2, ty + 1.5, 1.5, 1, pal.body)
416 } else {
417 c.oval(40, 29.5, 4, 2.5, 0xfff6e6, (_, y) => y >= 29)
418 c.oval(50, 30, 3, 2, 0xfff6e6, (_, y) => y >= 30)
419 }
420 for (let i = 0; i < 5; i++) sparkle(fx, 34 + rnd(i, f) * 24, 3 + rnd(f, i) * 24, 1 + ((i + f) % 2), [0xffffff, 0xffe066, pal.body][i % 3])
421 }
422 }
423 break
424 }
425
426 case 'think': {
427 b.look = [0.6 + 0.5 * Math.sin((f * Math.PI) / 16), -1]; b.right = 'chin'; b.mouth = 'flat'
428 mid = () => {
429 const dy = Math.round(cyc(f, 32))
430 for (const [x, y, rx, ry] of [[41.5, 8.5, 5, 4.5], [48, 6.5, 6, 5.2], [54.5, 8.5, 4.6, 4.2], [45, 12, 5.5, 3.2], [51.5, 12, 5.5, 3.2]] as const) {
431 c.oval(x, y + dy, rx, ry, 0xf4f1ff)
432 }
433 c.oval(34.5, 18.5, 1.6, 1.6, 0xf4f1ff)
434 c.oval(37.5, 15 + dy * 0.5, 2.2, 2.1, 0xf4f1ff)
435 // the dots light one a second, each fading in
436 for (let i = 0; i < 3; i++) c.rect(42 + i * 5, 8 + dy, 3, 3, mix(0xd5d0ea, 0x6a5acd, ease((f - 8 * i - 5) / 3)))
437 }
438 break
439 }
440
441 case 'read': {
442 // the eyes follow the highlighted line down the left page, then the right, then the page turns
443 const line = Math.min(7, Math.floor(f / 5))
444 b.look = [line < 4 ? 0.2 : 1.4, 0.4 + (line % 4) * 0.35]; b.glasses = true; b.right = 'hold'; b.mouth = 'flat'
445 mid = () => {
446 c.rect(31, 16, 27, 14, OUT)
447 c.rect(32, 27, 25, 2, 0xc4664d)
448 c.rect(32, 17, 12, 10, 0xfffaf0); c.rect(45, 17, 11, 10, 0xfffaf0)
449 c.rect(44, 17, 1, 10, 0xd9cbb2); c.rect(43, 17, 1, 10, 0xece2cf)
450 for (let i = 0; i < 4; i++) {
451 for (const [x0, w0, k] of [[33, 9, 0], [46, 9, 1]] as const) {
452 const len = 5 + Math.floor(rnd(i + k * 4, 0) * (w0 - 4))
453 c.rect(x0, 18 + i * 2 + 1, len, 1, f < 40 && line === i + k * 4 ? 0x4d9bf0 : 0xa7adbf)
454 }
455 }
456 const turn = f >= 41 && f <= 46 ? Math.floor((f - 41) / 2) : -1
457 if (turn >= 0) {
458 const x = [46, 43, 35][turn] ?? 46, w = turn === 1 ? 2 : 9, y = turn === 1 ? 10 : 14
459 c.rect(x - 1, y - 1, w + 2, 27 - y + 1, OUT)
460 c.rect(x, y, w, 27 - y, 0xfff3dd)
461 }
462 }
463 break
464 }
465
466 case 'write':
467 case 'bash': {
468 const code: [number, number, number][][] = [
469 [[0, 3, 0xff7aa2], [4, 5, 0x7cc7ff], [10, 2, 0xc9c9e6]],
470 [[2, 4, 0x7ee0a1], [7, 6, 0xffd166]],
471 [[2, 3, 0xff7aa2], [6, 7, 0x7cc7ff]],
472 [[4, 8, 0xc9c9e6], [13, 2, 0xffd166]],
473 [[0, 1, 0xc9c9e6]],
474 ]
475 if (state === 'write') {
476 // types the snippet out over ~4 s, then rests with the cursor blinking
477 const budget0 = Math.floor(f * 1.25)
478 const typing = budget0 < 41
479 b.right = typing ? 'type' : 'hold'
480 mid = () => {
481 c.rect(34, 6, 22, 18, OUT)
482 c.rect(35, 7, 20, 16, 0x3b4252)
483 c.rect(36, 8, 18, 14, 0x1e2230)
484 let budget = budget0
485 code.forEach((segs, row) => {
486 for (const [x, w, col] of segs) {
487 const shown = Math.max(0, Math.min(w, budget))
488 budget -= w
489 c.rect(37 + x, 10 + row * 2, shown, 1, col)
490 if (shown > 0 && shown < w) c.rect(37 + x + shown, 9 + row * 2, 1, 2, 0xffffff)
491 }
492 })
493 if (!typing && f % 8 < 4) c.rect(39, 17, 1, 2, 0xffffff)
494 c.rect(31, 24, 28, 5, OUT)
495 c.rect(32, 25, 26, 3, 0xc5ccd8)
496 for (let x = 33; x < 57; x += 2) c.put(x, 26, 0x8f99aa)
497 c.rect(36, 24, 18, 1, 0x8f99aa)
498 if (typing) for (let i = 0; i < 3; i++) {
499 const ph = (f + i * 5) % 16
500 fx.rect(38 + i * 7, 5 - ph * 0.35, 2, 1, [0xff7aa2, 0x7ee0a1, 0x7cc7ff][i] ?? 0xffffff)
501 }
502 }
503 b.look = [1, 0.2 + Math.min(4, Math.floor(budget0 / 9)) * 0.25]
504 } else {
505 // a line every second scrolls up; the prompt types between them
506 const s = Math.floor(f / 8), inLine = f % 8
507 const typed = Math.min(3, Math.floor(inLine / 2))
508 b.look = [1, 0.6]; b.right = inLine < 6 ? 'type' : 'hold'
509 mid = () => {
510 c.rect(33, 2, 26, 22, OUT)
511 c.rect(34, 3, 24, 3, 0x4c566a)
512 c.put(36, 4, 0xff5f56); c.put(38, 4, 0xffbd2e); c.put(40, 4, 0x27c93f)
513 c.rect(34, 6, 24, 17, 0x0d1117)
514 for (let row = 0; row < 4; row++) {
515 const k = (row + s) % 4
516 const len = 6 + Math.floor(rnd(k, 3) * 14)
517 c.rect(36, 8 + row * 3, len, 1, k % 3 === 0 ? 0x81c784 : 0x8b949e)
518 }
519 c.stamp(['#..', '.#.', '#..'], { '#': 0x81c784 }, 36, 19)
520 c.rect(40, 20, typed * 2, 1, 0xe6edf3)
521 if (f % 8 < 4) c.rect(40 + typed * 2, 19, 2, 3, 0xe6edf3)
522 c.rect(31, 25, 24, 5, OUT)
523 c.rect(32, 26, 22, 3, 0x9aa5b1)
524 for (let x = 33; x < 53; x += 2) c.put(x, 27, 0x6b7584)
525 }
526 }
527 break
528 }
529
530 case 'web': {
531 b.look = [1, -1]; b.right = 'point'; b.mouth = 'o'
532 mid = () => {
533 const gx = 47, gy = 13, r = 8.5
534 c.oval(gx, gy, r + 1, r + 1, OUT)
535 c.oval(gx, gy, r, r, 0x3a86ff, (x, y) => {
536 const nx = (x + 0.5 - gx) / r, ny = (y + 0.5 - gy) / r
537 const lon = Math.asin(Math.max(-1, Math.min(1, nx / Math.sqrt(Math.max(0.0001, 1 - ny * ny))))) + (f * Math.PI) / 16
538 const lat = Math.asin(ny)
539 const land = Math.sin(lon * 2 + 1) * Math.cos(lat * 2.5) + Math.sin(lon * 3 + lat * 3) * 0.6 > 0.45
540 if (land) c.put(x, y, nx > 0.55 ? 0x3a9e5c : 0x52d17c)
541 return !land
542 })
543 c.oval(gx, gy, r, r, 0x2a64c8, (x, y) => c.at(x, y) === 0x3a86ff && (x + 0.5 - gx) / r > 0.55)
544 c.rect(gx - 4, gy - 6, 2, 2, 0xd8ecff)
545 c.rect(gx - 1, gy + 10, 2, 3, 0x9aa5b1)
546 c.rect(gx - 5, gy + 13, 10, 2, 0x9aa5b1)
547 const arcs = 1 + Math.min(2, Math.floor((f % 16) / 5))
548 for (let k = 0; k < arcs; k++) {
549 const rr = 3 + k * 2.5
550 const col = k === arcs - 1 ? dim(0x4dd0e1) : 0x4dd0e1
551 for (let a = -0.2; a <= Math.PI / 2 + 0.2; a += 0.08) fx.put(gx + 4 + rr * Math.cos(a - Math.PI / 2), gy - 6 + rr * Math.sin(a - Math.PI / 2), col)
552 }
553 }
554 break
555 }
556
557 case 'test': {
558 // a clipboard of checks ticking green one by one; the row being run pulses yellow
559 const row = Math.min(4, Math.floor((f + 1) / 7))
560 b.look = [1, -0.4 + Math.min(3, row) * 0.45]; b.glasses = true; b.right = 'hold'; b.mouth = row >= 4 ? 'smile' : 'flat'
561 mid = () => {
562 c.rect(34, 3, 23, 28, OUT)
563 c.rect(35, 4, 21, 26, 0xc68a4f)
564 c.rect(37, 7, 17, 21, 0xfffaf0)
565 c.rect(41, 1, 9, 4, OUT)
566 c.rect(42, 2, 7, 2, 0x9aa5b1)
567 for (let i = 0; i < 4; i++) {
568 const y = 8 + i * 5
569 const done = i < row, now = i === row
570 c.rect(38, y, 5, 5, OUT)
571 c.rect(39, y + 1, 3, 3, done ? 0xd7f5df : now ? mix(0xfff3c4, 0xffd166, cyc(f, 4)) : 0xffffff)
572 if (done) c.stamp(['..#', '#.#', '.#.'], { '#': 0x2e9e4f }, 39, y + 1)
573 c.rect(44, y + 2, 5 + Math.floor(rnd(i, 1) * 5), 1, done ? 0x6b7584 : 0xa7adbf)
574 }
575 if (row >= 4) for (let k = 0; k < 3; k++) sparkle(fx, 36 + rnd(k, 9) * 20, 2 + rnd(9, k) * 6, (f + k) % 3 ? 1 : 2, k % 2 ? 0x7bd88f : 0xffffff)
576 }
577 break
578 }
579
580 case 'resumed': {
581 // the answer lands: two little hops under a lighting bulb
582 b.eyes = 'wide'; b.mouth = 'open'; b.left = 'up'; b.right = 'up'
583 b.y = -2.5 * Math.sin((Math.PI * (f % 6)) / 6)
584 b.flap = f % 4 < 2 ? 1 : 0
585 mid = () => {
586 const gx = 47, gy = 11
587 const lit = ease(f / 4)
588 c.oval(gx, gy, 7.2, 7.4, mix(0xd8dce6, 0xffe066, lit))
589 c.oval(gx + 1.5, gy + 1.5, 5.5, 5.5, mix(0xb8bdca, 0xffd23f, lit), (x, y) => (x - gx) + (y - gy) > 3)
590 c.oval(gx - 2.5, gy - 3, 1.4, 2.2, 0xfffbe0)
591 c.line(gx - 2, gy + 2, gx - 1, gy + 4, 0xff9f1c); c.line(gx - 1, gy + 4, gx, gy + 2, 0xff9f1c)
592 c.line(gx, gy + 2, gx + 1, gy + 4, 0xff9f1c)
593 c.rect(gx - 4, gy + 7, 8, 6, OUT)
594 c.rect(gx - 3, gy + 7, 6, 5, 0xb0b8c4)
595 c.rect(gx - 3, gy + 8, 6, 1, 0x7f889a); c.rect(gx - 3, gy + 10, 6, 1, 0x7f889a)
596 const len = f < 3 ? 0 : 1 + Math.min(2, Math.floor((f - 3) / 2))
597 for (let k = 0; k < 8; k++) {
598 if (k === 2) continue
599 const a = (k * Math.PI) / 4 - Math.PI / 2
600 for (let d = 0; d < len; d++) fx.put(gx + Math.cos(a) * (10 + d), gy + Math.sin(a) * (10 + d), 0xfff3b0)
601 }
602 }
603 break
604 }
605
606 case 'ask': {
607 b.eyes = 'wide'; b.look = [1, -1]; b.mouth = 'open'; b.right = 'wave'
608 mid = () => {
609 const dy = Math.round(cyc(f, 32))
610 c.oval(47, 11 + dy, 10, 9.5, 0xfffaf0)
611 c.tri(36.5, 13 + dy, 40, 17 + dy, 33, 21 + dy, 0xfffaf0)
612 c.stamp(QUESTION, { '#': 0xff9f1c }, 42, 4 + dy, 2)
613 const tw = f % 16
614 if (tw >= 2 && tw <= 5) sparkle(fx, 57, 2, tw === 3 || tw === 4 ? 2 : 1, 0xffd166)
615 if (tw >= 10 && tw <= 12) sparkle(fx, 36, 3, 1, 0xffd166)
616 }
617 break
618 }
619
620 case 'blocked': {
621 b.eyes = 'squeeze'; b.mouth = 'frown'
622 if (f < 8) b.x = [-1, 1, -1, 1, -0.6, 0.6, -0.3, 0][f] ?? 0
623 const g = Math.max(0, f - 8)
624 mid = () => {
625 const gx = 47, gy = 13
626 c.oval(gx, gy, 9, 9, OUT)
627 c.oval(gx, gy, 8, 8, f < 8 ? 0xff4d5e : mix(0xd93a4b, 0xff4d5e, cyc(g, 16)))
628 c.rect(gx - 6, gy - 1, 12, 3, 0xffffff)
629 const fr = frameOf(b)
630 const ph = (g + 10) % 16
631 fx.oval(fr.cx + 10, fr.top + 3 + ph * 0.35, 1.2, 1.7, TEAR)
632 fx.put(fr.cx + 9.6, fr.top + 2 + ph * 0.35, TEAR)
633 }
634 break
635 }
636
637 case 'error': {
638 // a slow dizzy spin: half the usual phase rate
639 b.eyes = 'dizzy'; b.mouth = 'flat'; b.phase = f / 2
640 b.x = 0.8 * Math.sin((2 * Math.PI * f) / 32)
641 mid = () => {
642 c.tri(47, 3, 36, 22.5, 58, 22.5, OUT)
643 c.tri(47, 5.5, 38, 21.5, 56, 21.5, mix(0xff9f43, 0xffc078, cyc(f, 32)))
644 c.rect(46, 10, 2, 6, OUT); c.rect(46, 17, 2, 2, OUT)
645 const fr = frameOf(b)
646 for (let k = 0; k < 3; k++) {
647 const a = (f * Math.PI) / 16 + (k * 2 * Math.PI) / 3
648 star(fx, fr.cx + 11 * Math.cos(a), fr.top - 0.5 + 2.2 * Math.sin(a))
649 }
650 }
651 break
652 }
653
654 case 'done': {
655 if (f < 16) {
656 // two hops, a squash on each landing, confetti drifting down
657 const h = f % 8
658 b.eyes = 'happy'; b.mouth = 'open'; b.left = 'up'; b.right = 'wave'
659 b.y = -3 * Math.sin((Math.PI * h) / 8)
660 b.squash = h === 0 ? 1 : 0
661 b.flap = f % 4 < 2 ? 2 : 0
662 front = () => {
663 for (let i = 0; i < 16; i++) {
664 const x = rnd(i, 7) * 60 + Math.sin((f + i) * 0.6) * 1.2, y = (rnd(i, 3) * 32 + f) % 32
665 const col = CONFETTI[i % CONFETTI.length] ?? 0xffffff
666 if ((i + Math.floor(f / 2)) % 2) fx.rect(x, y, 2, 1, col)
667 else fx.rect(x, y, 1, 2, col)
668 }
669 }
670 } else {
671 b.eyes = 'happy'; b.mouth = 'smile'
672 mid = () => {
673 const gx = 46, gy = 15
674 c.oval(gx, gy, 8, 8, 0x5cd67a)
675 c.oval(gx + 1, gy + 1, 6.5, 6.5, 0x43bd62, (x, y) => (x - gx) + (y - gy) > 4)
676 c.line(41, 15, 44, 18, 0xffffff, 2); c.line(44, 18, 50, 11, 0xffffff, 2)
677 sparkle(fx, 56, 4, 2, 0xffffff); sparkle(fx, 36, 5, 1, 0xfff3b0)
678 }
679 }
680 break
681 }
682
683 case 'failed': {
684 // sinks down over a second, then holds
685 b.eyes = 'sad'; b.mouth = 'frown'; b.squash = ease(Math.min(f, 7) / 7)
686 mid = () => {
687 const gx = 46, gy = 15
688 c.oval(gx, gy, 8, 8, 0xff5c6c)
689 c.oval(gx + 1, gy + 1, 6.5, 6.5, 0xe5485a, (x, y) => (x - gx) + (y - gy) > 4)
690 c.line(42, 11, 49, 18, 0xffffff, 2); c.line(49, 11, 42, 18, 0xffffff, 2)
691 const fr = frameOf(b)
692 for (const [s, off] of [[-1, 0], [1, 3]] as const) {
693 const ph = Math.min(f, 7 + off) - off
694 if (ph >= 0) fx.rect(fr.cx + s * 5.5 + (s < 0 ? -3 : 2), fr.eyY + 3 + Math.min(ph, 4) * 0.8, 1, 2, TEAR)
695 }
696 }
697 break
698 }
699
700 case 'sleep': {
701 b.eyes = 'closed'; b.mouth = 'o'; b.hat = 'nightcap'
702 b.squash = 0.2 + 0.9 * cyc(f, 32)
703 front = () => {
704 const fr = frameOf(b)
705 const r = 0.9 + 2.2 * cyc(f, 32)
706 fx.oval(fr.cx + 4.5, fr.eyY + 6.5 - r * 0.3, r, r, 0xbfe9ff)
707 fx.put(fr.cx + 4 - r * 0.4, fr.eyY + 5.5 - r * 0.6, 0xffffff)
708 for (let k = 0; k < 3; k++) {
709 const p = ((f + k * 11) % 32) / 32
710 const col = mix(0xc6b3ff, 0x2a2d3d, p * 0.9)
711 fx.stamp(k === 0 ? Z4 : Z5, { '#': col }, Math.floor(35 + k * 7 + p * 4), Math.floor(21 - k * 7 - p * 5))
712 }
713 }
714 break
715 }
716 }
717
718 if (BREATHE.includes(state)) b.squash += 0.7 * cyc(f, 16)
719 if (blink && (b.eyes === 'open' || b.eyes === 'wide')) b.eyes = 'closed'
720 if (creature) {
721 body(c, role, pal, b)
722 mid()
723 arm(c, role, pal, b, -1, b.left)
724 arm(c, role, pal, b, 1, b.right)
725 front()
726 }
727 c.outline(OUT)
728 c.over(fx)
729 // A soft shadow that shrinks as the creature leaves the ground.
730 const out = new Canvas(BIG_W, BIG_H, BIG_S)
731 const lift = Math.max(0, -b.y)
732 out.oval(creature ? frameOf(b).cx : 16, 31.2, 11.5 - lift * 1.4, 1.6, SHADOW)
733 out.over(c)
734 return out
735}
736
737// ---------- the half-block fallback: 15x8, a small hand-made creature and the props as icons ----------
738
739const SMALL_BODY: Record<Role, string[]> = {
740 scout: [
741 '.h....h.',
742 '.hBBBBh.',
743 'BFFBBFFB',
744 'BFFBBFFB',
745 'BBByyBBB',
746 'WBccccBW',
747 'WBccccBW',
748 '..o..o..',
749 ],
750 coder: [
751 'H......H',
752 'wHBBBBHw',
753 'wBBBBBBw',
754 '.BBBBBB.',
755 '.BBBBBB.',
756 '.BccccBt',
757 '.BccccBt',
758 '..d..d..',
759 ],
760}
761
762const ICON = {
763 ask: ['.##.', '#..#', '..#.', '.#..', '....', '.#..'],
764 bang: ['#', '#', '#', '.', '#'],
765 check: ['....#', '...#.', '#.#..', '.#...'],
766 cross: ['#...#', '.#.#.', '..#..', '.#.#.', '#...#'],
767 bulb: ['.###.', '#####', '#####', '.###.', '.###.'],
768 globe: ['.###.', '#####', '#####', '#####', '.###.'],
769 bubble: ['.#####.', '#######', '.#####.'],
770 stop: ['.###.', '#####', '#...#', '#####', '.###.'],
771 warn: ['..#..', '.###.', '.#.#.', '#####'],
772 z: ['###', '..#', '.#.', '###'],
773}
774const LAND = ['01100011', '11110011', '01110001', '00110110', '00011100']
775
776export function drawSmall({ role, opus, state, frame: big, blink }: Look): Canvas {
777 // The icons keep their own short cycles at half the big frames' rate.
778 const f = state === 'boot' ? (big < 7 ? Math.floor((big * 5) / 7) : big < 10 ? 5 : 7)
779 : state === 'done' ? (big < 16 ? big >> 1 : 8)
780 : big >> 1
781 const p = new Canvas(SMALL_W, SMALL_H)
782 const pal = role === 'scout' ? OWL : opus ? DRAGON_OPUS : DRAGON
783 const theme = role === 'scout' ? 0x4fd1c5 : opus ? 0xb59cff : 0xffb347
784 const colors: Record<string, number> = {
785 h: pal.horn, H: pal.horn, B: pal.body, F: pal.face, y: pal.beak, c: pal.belly, o: pal.beak,
786 w: pal.wing, t: pal.body, d: pal.shade,
787 }
788 const celebrating = state === 'done' && f < 8
789 const ox = state === 'blocked' && f < 2 ? f : 0
790 const oy = celebrating && f % 4 >= 1 && f % 4 <= 3 ? -1 : 0
791
792 if (state === 'boot' && f < 5) {
793 // the egg, cracking
794 p.stamp(['..##..', '.####.', '######', '######', '######', '.####.'], { '#': 0xfff6e6 }, 1 + (f === 1 ? -1 : f === 3 ? 1 : 0), 1)
795 p.put(2, 3, pal.body); p.put(5, 5, pal.body)
796 if (f >= 2) for (let x = 1; x < 1 + (f - 1) * 2 && x < 7; x++) p.put(x, 3 + (x % 2), OUT)
797 if (f >= 2) p.put(9 + (f * 3) % 5, (f * 5) % 7, 0xffffff)
798 return p
799 }
800
801 p.stamp(SMALL_BODY[role], colors, ox, oy)
802 if (opus) { p.put(ox + 3, oy, 0xffd447); p.put(ox + 4, oy, 0xffd447) }
803 if (state === 'sleep') { p.put(ox + 2, oy, 0x6c7bff); p.put(ox + 3, oy, 0x6c7bff); p.put(ox + 4, oy, 0x6c7bff); p.put(ox + 5, oy, 0xf4f1ff) }
804 if (state === 'boot') { p.put(ox + 2, oy, 0xfff6e6); p.put(ox + 3, oy, 0xfff6e6); p.put(ox + 4, oy, 0xfff6e6); p.put(ox + 5, oy, 0xfff6e6) }
805
806 // eyes: one dark pixel each, a lid line when shut
807 const eyeRow = oy + 3
808 const shut = blink || state === 'sleep' || state === 'blocked' || (state === 'done') || (state === 'boot' && f === 7)
809 const up = state === 'think' || state === 'ask' || state === 'web' || state === 'resumed'
810 const dx = state === 'read' || state === 'write' || state === 'bash' || state === 'test' ? 1 : 0
811 for (const x of [2, 5]) {
812 if (state === 'error') p.put(ox + x, eyeRow, f % 2 ? 0xff8a65 : EYE)
813 else if (shut) { p.put(ox + x - 1, eyeRow, EYE); p.put(ox + x, eyeRow, EYE) }
814 else p.put(ox + x + (x === 2 ? dx : 0) - (x === 5 && dx ? 0 : 0), eyeRow - (up ? 1 : 0), EYE)
815 if (state === 'failed') p.put(ox + x, eyeRow + 1 + (f % 2), TEAR)
816 }
817 if (state !== 'failed' && role === 'coder') { p.put(ox + 1, oy + 4, BLUSH); p.put(ox + 6, oy + 4, BLUSH) }
818 if (role === 'coder' && (state === 'done' || state === 'ask' || state === 'resumed')) { p.put(ox + 3, oy + 4, EYE); p.put(ox + 4, oy + 4, EYE) }
819
820 const limb = role === 'scout' ? pal.wing : pal.shade
821 const armUp = (x: number) => { p.put(ox + x, oy + 2, limb); p.put(ox + x, oy + 3, limb) }
822 const armDown = (x: number) => { p.put(ox + x, oy + 5, limb); p.put(ox + x, oy + 6, limb) }
823 armDown(0)
824
825 switch (state) {
826 case 'write':
827 case 'bash': {
828 p.put(7, 5 - (f % 2), limb)
829 if (state === 'bash') {
830 for (let y = 0; y < 6; y++) for (let x = 9; x < 15; x++) p.put(x, y, y === 0 ? 0x4c566a : 0x0d1117)
831 p.put(10, 2, 0x81c784); p.put(11, 3, 0x81c784); p.put(10, 4, 0x81c784)
832 if (f % 2 === 0) p.put(13, 4, 0xe6edf3)
833 for (let x = 9; x < 14; x++) p.put(x, 7, 0x9aa5b1)
834 } else {
835 for (let y = 1; y < 6; y++) for (let x = 9; x < 15; x++) p.put(x, y, y === 1 || x === 9 || x === 14 ? 0x3b4252 : 0x1e2230)
836 for (let i = 0; i < 3; i++) p.put(10 + i, 2 + i, [0xff7aa2, 0x7ee0a1, 0x7cc7ff][(i + f) % 3] ?? theme)
837 for (let x = 8; x < 15; x++) p.put(x, 6, 0xc5ccd8)
838 p.put(10 + (f % 4), 0, theme)
839 }
840 break
841 }
842 case 'read':
843 p.put(7, 5, limb)
844 for (let y = 3; y <= 7; y++) for (let x = 8; x <= 14; x++) p.put(x, y, x === 11 ? 0xd9cbb2 : 0xfffaf0)
845 for (const y of [4, 6]) {
846 const hot = Math.floor(f / 4) % 2
847 p.put(9, y, hot && y === 4 ? 0x4d9bf0 : 0xa7adbf); p.put(12, y, 0xa7adbf); p.put(13, y, y === 6 && !hot ? 0x4d9bf0 : 0xa7adbf)
848 }
849 break
850 case 'test':
851 p.put(7, 5, limb)
852 for (let y = 1; y < 8; y++) for (let x = 9; x < 15; x++) p.put(x, y, x === 9 || x === 14 || y === 1 || y === 7 ? 0xc68a4f : 0xfffaf0)
853 p.put(11, 0, 0x9aa5b1); p.put(12, 0, 0x9aa5b1)
854 for (let i = 0; i < 3; i++) {
855 const y = 2 + i * 2
856 p.put(10, y, f % 4 > i ? 0x43bd62 : f % 4 === i ? 0xffd166 : 0xa7adbf)
857 p.put(11, y, 0xa7adbf); p.put(12, y, 0xa7adbf)
858 }
859 break
860 case 'web':
861 armUp(7)
862 ICON.globe.forEach((row, y) => {
863 for (let x = 0; x < row.length; x++) if (row[x] === '#') {
864 const land = LAND[y]?.[(x + f) % 8] === '1'
865 p.put(9 + x, 1 + y, land ? 0x52d17c : 0x3a86ff)
866 }
867 })
868 p.put(f % 4 < 2 ? 14 : 13, 0, 0x4dd0e1)
869 break
870 case 'ask':
871 if (f % 4 < 2) armUp(7); else { p.put(7, 1, limb); p.put(7, 2, limb) }
872 p.stamp(ICON.ask, { '#': 0xffd166 }, 10, f % 4 < 2 ? 0 : 1)
873 break
874 case 'resumed':
875 armUp(7); armUp(0)
876 p.stamp(ICON.bulb.slice(0, 3), { '#': 0xffe066 }, 10, 0)
877 p.stamp(ICON.bulb.slice(3), { '#': 0x9aa5b1 }, 10, 3)
878 if (f % 2) { p.put(9, 0, 0xfff3b0); p.put(14, 1, 0xfff3b0); p.put(9, 2, 0xfff3b0) }
879 break
880 case 'blocked':
881 armDown(7)
882 p.stamp(ICON.stop, { '#': f < 2 || f % 4 < 2 ? 0xff4d5e : 0xd93a4b }, 10, 1)
883 p.put(12, 3, 0xffffff); p.put(11, 3, 0xffffff); p.put(13, 3, 0xffffff)
884 p.put(ox + 7, 1 + (f % 3), TEAR)
885 break
886 case 'error':
887 armDown(7)
888 p.stamp(ICON.warn, { '#': 0xff9f43 }, 10, 2)
889 p.put(12, 3, OUT)
890 p.put(ox + 1 + (f % 6), 0, 0xffe066)
891 break
892 case 'done':
893 if (celebrating) {
894 armUp(0); armUp(7)
895 for (let i = 0; i < 10; i++) {
896 const x = Math.floor(rnd(i, 7) * 15), y = Math.floor((rnd(i, 3) * 8 + f) % 8)
897 if (p.at(x, y) === CLEAR) p.put(x, y, CONFETTI[i % CONFETTI.length] ?? 0xffffff)
898 }
899 } else {
900 armDown(7)
901 p.stamp(ICON.check, { '#': 0x7bd88f }, 10, 2)
902 if (f % 16 < 2) p.put(14, 0, 0xffffff)
903 }
904 break
905 case 'failed':
906 armDown(7)
907 p.stamp(ICON.cross, { '#': 0xff5c6c }, 10, 1)
908 break
909 case 'sleep': {
910 armDown(7)
911 p.stamp(ICON.z, { '#': 0xb39ddb }, 9, 4)
912 if (f % 8 >= 3) p.stamp(ICON.z, { '#': 0x8f7fbf }, 12, 0)
913 break
914 }
915 case 'boot':
916 armUp(0); armUp(7)
917 p.put(10, 6, 0xfff6e6); p.put(11, 7, 0xfff6e6); p.put(12, 7, 0xfff6e6)
918 p.put(9 + (f % 3) * 2, 1, 0xffffff)
919 break
920 default: {
921 // think: paw at the chin, a thought bubble filling dot by dot
922 p.put(6, 4, limb)
923 p.put(8, 3, 0xe6e6f0)
924 p.stamp(ICON.bubble, { '#': 0xf4f1ff }, 8, 0)
925 const lit = Math.floor(f / 4)
926 for (let i = 0; i < 3; i++) p.put(9 + i * 2, 1, i < lit ? 0x6a5acd : 0xc8c8d8)
927 }
928 }
929 return p
930}
931
932// Frames are pure in their Look, so each is drawn once; a long session sees a few hundred.
933const BIG = new Map<string, Canvas>()
934const SMALL = new Map<string, Canvas>()
935const cached = <T>(m: Map<string, T>, key: string, make: () => T): T => {
936 const hit = m.get(key)
937 if (hit) return hit
938 if (m.size > 800) m.clear()
939 const v = make()
940 m.set(key, v)
941 return v
942}
943export const bigSprite = (l: Look) => cached(BIG, lookKey(l), () => drawLook(l))
944export const smallSprite = (l: Look) => cached(SMALL, lookKey(l), () => drawSmall(l))
945
946// Every distinct frame is a small PNG under frames/ (made by tools/gen_frames.sh); the terminal reads the
947// file itself and a source equal to the last one sends nothing, so a blit carries only a path.
948export const frameFile = (l: Look) => `${l.role}${l.opus ? '_opus' : ''}-${l.state}-${l.frame}${l.blink ? 'b' : ''}.png`
949export const imageSource = (l: Look, root: string) => ({ file: `${root}/frames/${frameFile(l)}`, format: 'png' as const })
950
951export const ALL_POSES: Pose[] = [
952 'boot', 'resumed', 'think', 'read', 'write', 'bash', 'web', 'test', 'blocked', 'error', 'ask', 'done', 'failed', 'sleep',
953]
954hooks/scene.ts 608 lines1// Pure drawing: cw run files -> worker states -> one Raster of text and half-block pixel art,
2// or of text alone with each sprite listed for an Image laid over it.
3
4import { CLEAR, FRAME_MS, SMALL_H, SMALL_W, lookAt, smallSprite, type Canvas, type Look } from './creatures'
5
6export type Role = 'scout' | 'coder'
7export type Kind = 'read' | 'write' | 'bash' | 'web'
8export type State =
9 | 'boot' | 'resumed' | 'think' | Kind | 'test' | 'blocked' | 'error' | 'ask' | 'done' | 'failed'
10
11export type Ev = {
12 e: string; t: number; tool?: string; arg?: string
13 ok?: boolean; sub?: string; sid?: string; turns?: number; denied?: number; out?: number
14}
15export type Meta = {
16 role: Role; model: string; session: string; resume: string; task: string
17 start: number; tmo: number; web: boolean; pid: number; effort?: string; effort_src?: string
18 name?: string // fleet name from cwgo (CW_NAME): shown instead of a generated one
19}
20export type Run = { id: string; meta: Meta; events: Ev[]; rc?: number; doneAt?: number; question?: string }
21
22export type Worker = {
23 key: string; role: Role; model: string; name: string; task: string; effort: string
24 start: number; runStart: number; resumed: boolean
25 state: State; stateAt: number; arg: string; trail: Kind[]; times: number[]; lastAt: number; tmo: number
26 blocked: number; question?: string; end?: number; turns: number; out: number
27 runIds: string[] // the chain's runs, oldest first: the detail view reads their files
28}
29
30const NAMES: Record<Role, string[]> = {
31 scout: ['KIRI', 'SORA', 'MOCHI', 'YUZU', 'HOSHI', 'NORI', 'TOFU', 'MISO', 'KOJI', 'UME'],
32 coder: ['BOLT', 'RIVET', 'PIXEL', 'FORGE', 'GIZMO', 'BYTE', 'FLUX', 'SPARK', 'COG', 'WELD'],
33}
34
35const READ_CMD = /^\s*(rg|fd|eza|ls|wc|head|tail|cat|bat|grep|find|sed -n|git (log|show|diff|status|blame))\b/
36
37export function kindOf(tool: string, arg: string): Kind {
38 if (tool === 'Read' || tool === 'Grep' || tool === 'Glob') return 'read'
39 if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit' || tool === 'NotebookEdit') return 'write'
40 if (tool === 'WebFetch' || tool === 'WebSearch') return 'web'
41 return READ_CMD.test(arg) ? 'read' : 'bash'
42}
43
44export function parseEvents(text: string): Ev[] {
45 const out: Ev[] = []
46 for (const line of text.split('\n')) {
47 if (!line.trim()) continue
48 try { out.push(JSON.parse(line)) } catch { /* a half-written last line; read again next poll */ }
49 }
50 return out
51}
52
53type RunView = Pick<Worker, 'state' | 'stateAt' | 'arg' | 'blocked' | 'end' | 'turns' | 'out' | 'lastAt'> & { trail: Kind[]; times: number[] }
54
55const ACCEPT_MS = 1_830_000 // cw's CW_ACCEPT_TMO default (1800 s) plus slack
56
57export function runState(run: Run, now: number): RunView {
58 const start = run.meta.start * 1000
59 const trail: Kind[] = [], times: number[] = []
60 let arg = '', lastTool: Ev | undefined, lastDenied: Ev | undefined, lastError: Ev | undefined
61 let result: Ev | undefined, accept: Ev | undefined, accepted: Ev | undefined, blocked = 0
62 for (const ev of run.events) {
63 if (ev.e === 'tool') {
64 lastTool = ev
65 arg = ev.arg ?? ''
66 trail.push(kindOf(ev.tool ?? '', arg))
67 times.push(ev.t)
68 } else if (ev.e === 'denied') { lastDenied = ev; blocked++ }
69 else if (ev.e === 'error') lastError = ev
70 else if (ev.e === 'result') result = ev
71 else if (ev.e === 'accept') accept = ev
72 else if (ev.e === 'accept_end') accepted = ev
73 }
74 const lastAt = run.events[run.events.length - 1]?.t ?? start
75 const base = { arg, trail, times, blocked, lastAt, turns: result?.turns ?? 0, out: result?.out ?? 0 }
76 // cw auto-continues a turn-limited run without writing .done: that result is not the end
77 const continuing = result?.sub === 'error_max_turns' && run.rc === undefined
78 // cw runs the acceptance command after the result and writes .done only when it ends
79 if (accept && !accepted && run.rc === undefined && now - accept.t < ACCEPT_MS) {
80 return { ...base, state: 'test', stateAt: accept.t }
81 }
82 if ((result && !continuing) || run.rc !== undefined) {
83 const end = accepted?.t ?? result?.t ?? run.doneAt ?? now
84 const ok = !!result?.ok && (run.rc ?? 0) === 0 && accepted?.ok !== false
85 const state: State = run.question && ok ? 'ask' : ok ? 'done' : 'failed'
86 return { ...base, state, stateAt: end, end }
87 }
88 if (now > start + (run.meta.tmo + 30) * 1000) return { ...base, state: 'failed', stateAt: now, end: now }
89 const last = run.events[run.events.length - 1]
90 if (run.meta.resume && now - start < 1600) return { ...base, state: 'resumed', stateAt: start }
91 if (!last || now - start < 1500) return { ...base, state: 'boot', stateAt: start }
92 if (lastDenied && now - lastDenied.t < 4000) return { ...base, state: 'blocked', stateAt: lastDenied.t }
93 if (lastError && now - lastError.t < 2500) return { ...base, state: 'error', stateAt: lastError.t }
94 if (lastTool && (last === lastTool || now - lastTool.t < 1500)) {
95 return { ...base, state: trail[trail.length - 1] ?? 'bash', stateAt: lastTool.t }
96 }
97 return { ...base, state: 'think', stateAt: last.t }
98}
99
100// One worker per chain of runs: a run started with `-r <sid>` continues the worker whose run ended with that sid.
101export function buildWorkers(runs: Run[], now: number): Worker[] {
102 const sorted = [...runs].sort((a, b) => a.meta.start - b.meta.start || a.id.localeCompare(b.id))
103 const bySid = new Map<string, Worker>()
104 const workers: Worker[] = []
105 const used: Record<Role, number> = { scout: 0, coder: 0 }
106 for (const run of sorted) {
107 const view = runState(run, now)
108 const prev = run.meta.resume ? bySid.get(run.meta.resume) : undefined
109 const role = run.meta.role === 'coder' ? 'coder' : 'scout'
110 // A resume started under its own fleet name (cwgo NAME -r sid) is a new job on the same creature:
111 // show that name, task and clock, as cwls does.
112 const renamed = !!prev && !!run.meta.name && run.meta.name !== prev.name
113 const w: Worker = {
114 key: prev?.key ?? run.id,
115 role,
116 model: run.meta.model,
117 effort: run.meta.effort ?? '',
118 name: renamed ? run.meta.name! : prev?.name ?? (run.meta.name || NAMES[role][used[role]++ % NAMES[role].length] + (used[role] > 10 ? String(Math.ceil(used[role] / 10)) : '')),
119 task: renamed ? run.meta.task : prev?.task ?? run.meta.task,
120 start: renamed ? run.meta.start * 1000 : prev?.start ?? run.meta.start * 1000,
121 runStart: run.meta.start * 1000,
122 tmo: run.meta.tmo,
123 resumed: !!prev,
124 ...view,
125 trail: [...(prev?.trail ?? []), ...view.trail],
126 times: [...(prev?.times ?? []), ...view.times],
127 blocked: (prev?.blocked ?? 0) + view.blocked,
128 turns: (prev?.turns ?? 0) + view.turns,
129 out: (prev?.out ?? 0) + view.out,
130 question: view.state === 'ask' ? run.question : undefined,
131 runIds: [...(prev?.runIds ?? []), run.id],
132 }
133 const at = prev ? workers.indexOf(prev) : -1
134 if (at >= 0) workers[at] = w
135 else workers.push(w)
136 // A run that dies before its result (killed, timeout) never maps its sid, so the next resume
137 // of the same sid still finds `prev`; point it at the newest run or two workers share a key.
138 if (prev) for (const [k, v] of bySid) if (v === prev) bySid.set(k, w)
139 // init carries the sid too, so a run that timed out before its result still chains its resume.
140 const sid = [...run.events].reverse().find(ev => ev.sid)?.sid
141 if (sid) bySid.set(sid, w)
142 }
143 return workers
144}
145
146export const KEEP_MS = 120_000
147export const isVisible = (w: Worker, now: number) =>
148 w.end === undefined || w.state === 'ask' || now - w.end < KEEP_MS
149export const isActive = (w: Worker) => w.end === undefined
150
151// ---------- pixels ----------
152
153const DEF = 0x01000000
154// A sprite fills MW x MH cells: one 60x32 Image where the terminal shows pictures, else
155// 15x8 half-block pixels (one pixel = one half-block, no scaling). Creature left, props right.
156const MW = SMALL_W, MH = SMALL_H / 2
157
158export const THEME: Record<Role, number> = { scout: 0x4fd1c5, coder: 0xffb347 }
159const OPUS_THEME = 0xb59cff
160
161const hash = (s: string) => {
162 let h = 7
163 for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0
164 return h % 997
165}
166const dim = (c: number, k: number) =>
167 (Math.round(((c >> 16) & 255) * k) << 16) | (Math.round(((c >> 8) & 255) * k) << 8) | Math.round((c & 255) * k)
168const mix = (a: number, b: number, k: number) => {
169 const ch = (s: number) => Math.round(((a >> s) & 255) * (1 - k) + ((b >> s) & 255) * k)
170 return (ch(16) << 16) | (ch(8) << 8) | ch(0)
171}
172// 0..1..0 once every `ms`: colours breathe instead of blinking.
173const pulse = (now: number, ms: number) => 0.5 - 0.5 * Math.cos((2 * Math.PI * (now % ms)) / ms)
174
175// Where a sprite goes in the scene: `key` names its Image, `look` the frame it shows.
176export type Spot = { key: string; x: number; y: number; look: Look; alt: string }
177export const SPRITE_COLS = MW, SPRITE_ROWS = MH
178
179// ---------- cells ----------
180
181export class Grid {
182 glyph: Uint32Array; fg: Uint32Array; bg: Uint32Array
183 constructor(public w: number, public h: number) {
184 this.glyph = new Uint32Array(w * h).fill(0x20)
185 this.fg = new Uint32Array(w * h).fill(DEF)
186 this.bg = new Uint32Array(w * h).fill(DEF)
187 }
188 // No `bg`: the cell keeps the one it has, so text and pixels sit on a filled panel.
189 put(x: number, y: number, ch: string, fg: number, bg?: number) {
190 if (x < 0 || y < 0 || x >= this.w || y >= this.h) return
191 const i = y * this.w + x
192 this.glyph[i] = ch.codePointAt(0) ?? 0x20
193 this.fg[i] = fg
194 if (bg !== undefined) this.bg[i] = bg
195 }
196 fill(x: number, y: number, w: number, h: number, bg: number) {
197 for (let j = y; j < y + h; j++) for (let i = x; i < x + w; i++) this.put(i, j, ' ', DEF, bg)
198 }
199 text(x: number, y: number, s: string, fg: number, max = this.w - x, bg?: number) {
200 const chars = [...s]
201 for (let i = 0; i < Math.min(chars.length, max); i++) this.put(x + i, y, chars[i] ?? ' ', fg, bg)
202 return Math.max(0, Math.min(chars.length, max))
203 }
204 // A rounded chip: half-circle caps around the text, the text on the chip's colour.
205 pill(x: number, y: number, s: string, fg: number, bg: number, max = this.w - x) {
206 if (max < 3) return 0
207 this.put(x, y, '▐', bg)
208 const n = this.text(x + 1, y, s, fg, max - 2, bg)
209 this.put(x + 1 + n, y, '▌', bg)
210 return n + 2
211 }
212 pixels(p: Canvas, x0: number, y0: number) {
213 for (let r = 0; r < p.h / 2; r++) for (let x = 0; x < p.w; x++) {
214 const top = p.px[r * 2 * p.w + x] ?? CLEAR, bot = p.px[(r * 2 + 1) * p.w + x] ?? CLEAR
215 if (top === CLEAR && bot === CLEAR) continue
216 if (top === CLEAR) this.put(x0 + x, y0 + r, '▄', bot)
217 else if (bot === CLEAR) this.put(x0 + x, y0 + r, '▀', top)
218 else if (top === bot) this.put(x0 + x, y0 + r, '█', top)
219 else this.put(x0 + x, y0 + r, '▀', top, bot)
220 }
221 }
222 words(): Uint32Array {
223 const out = new Uint32Array(this.w * this.h * 3)
224 for (let i = 0; i < this.w * this.h; i++) {
225 out[i * 3] = this.glyph[i] ?? 0x20; out[i * 3 + 1] = this.fg[i] ?? DEF; out[i * 3 + 2] = this.bg[i] ?? DEF
226 }
227 return out
228 }
229}
230
231export const encode = (words: Uint32Array): string =>
232 (new Uint8Array(words.buffer) as Uint8Array & { toBase64(): string }).toBase64()
233
234export const LABEL: Record<State, string> = {
235 boot: 'waking up', resumed: 'answered', think: 'thinking', read: 'reading', write: 'writing',
236 bash: 'command', web: 'on the web', test: 'testing', blocked: 'blocked', error: 'error', ask: 'asking',
237 done: 'done', failed: 'failed',
238}
239export const STATE_COLOR: Record<State, number> = {
240 boot: 0xffffff, resumed: 0xffe082, think: 0xb39ddb, read: 0x64b5f6, write: 0xffb347, bash: 0x81c784,
241 web: 0x4dd0e1, test: 0x9be7ff, blocked: 0xff6b6b, error: 0xff8a65, ask: 0xffd166, done: 0x7bd88f, failed: 0xff5c5c,
242}
243const KIND_COLOR: Record<Kind, number> = { read: 0x64b5f6, write: 0xffb347, bash: 0x81c784, web: 0x4dd0e1 }
244const EFFORT: Record<string, [number, number]> = { low: [1, 0x81c784], medium: [2, 0xffd166], high: [3, 0xff8a65] }
245const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
246const TEXT = 0xe6e6f0, DIM = 0x8a8fa3, FAINT = 0x4a4e5c
247// Everything sits on the terminal's own background (an Image's cells always show it); this is only
248// what chips and fades mix toward, a neutral dark grey close to most dark themes.
249const BASE = 0x262626
250const EDGE = 0x3c3f4c
251const BUCKET_MS = 4000
252const QUIET_MS = 45_000
253
254export const CARD_H = 6
255const HEADER_H = 2
256const EMPTY_H = 8
257
258export const perRow = (cols: number) => Math.max(1, Math.min(3, Math.floor(cols / 46)))
259
260// The engine scrolls a pane body taller than its window; past 256 rows the scene folds:
261// as many full cards as fit, the rest as one-line rows, then a "+N" row.
262export type Layout = { rows: number; cards: number; lines: number; more: number }
263export function layout(n: number, cols: number, maxRows = 256): Layout {
264 const cap = Math.max(1, Math.min(256, maxRows))
265 const per = perRow(cols)
266 const full = HEADER_H + (n === 0 ? EMPTY_H : Math.ceil(n / per) * CARD_H)
267 if (full <= cap || n === 0) return { rows: Math.min(full, cap), cards: n, lines: 0, more: 0 }
268 for (let k = Math.ceil(n / per) - 1; k >= 0; k--) {
269 const cards = Math.min(n, k * per)
270 const lines = Math.ceil((n - cards) / per)
271 const rows = HEADER_H + k * CARD_H + lines
272 if (rows <= cap) return { rows, cards, lines, more: 0 }
273 }
274 const lines = Math.max(0, cap - HEADER_H - 1)
275 return { rows: cap, cards: 0, lines, more: n - lines * per }
276}
277export const sceneRows = (n: number, cols: number, maxRows?: number) => layout(n, cols, maxRows).rows
278
279const RANK = (w: Worker) => (w.state === 'ask' ? 0 : !w.end ? 1 : w.state === 'failed' ? 2 : 3)
280
281export const clock = (ms: number) => {
282 const s = Math.max(0, Math.floor(ms / 1000))
283 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
284}
285const kilo = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k` : String(n))
286const shorten = (s: string, n: number) => {
287 const one = s.replace(/\s+/g, ' ').trim()
288 return [...one].length <= n ? one : '…' + [...one].slice(-(n - 1)).join('')
289}
290function wrap(s: string, width: number, lines: number): string[] {
291 const out: string[] = []
292 let cur = ''
293 for (const word of s.replace(/^[#>*\s\d.]*(goal:)?\s*/i, '').split(/\s+/)) {
294 if (!word) continue
295 const next = cur ? `${cur} ${word}` : word
296 if ([...next].length <= width) { cur = next; continue }
297 if (cur) out.push(cur)
298 cur = [...word].slice(0, width).join('')
299 if (out.length === lines) break
300 }
301 if (cur && out.length < lines) out.push(cur)
302 if (out.length === lines && [...out.join(' ')].length < [...s].length - 2) {
303 const last = [...(out[lines - 1] ?? '')]
304 out[lines - 1] = last.slice(0, Math.max(0, width - 1)).join('') + '…'
305 }
306 return out.slice(0, lines)
307}
308
309// MSYS path (/c/Users/x) -> C:/Users/x so $.fs can read it; other paths pass through.
310export const msysToWin = (p: string) => p.replace(/^\/([a-zA-Z])(?=\/|$)/, (_, d: string) => `${d.toUpperCase()}:`)
311
312// A path reads by its file name: the folders dim, the name bright. A command shows whole.
313function target(g: Grid, x: number, y: number, arg: string, max: number, bright: number, faint: number) {
314 if (max < 2 || !arg) return 0
315 const s = shorten(arg, max)
316 const cut = /\s/.test(arg.trim()) ? 0 : Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\')) + 1
317 const n = g.text(x, y, s.slice(0, cut), faint, max)
318 return n + g.text(x + n, y, s.slice(cut), bright, max - n)
319}
320
321const BARS = '▂▃▅▆▇'
322// Tools per BUCKET_MS bucket, newest on the right, each bar in the colour of its last tool's kind:
323// the worker's rhythm at a glance, and a flat line when it has gone quiet.
324function activity(g: Grid, w: Worker, x: number, y: number, n: number, now: number, fade: number) {
325 if (n <= 0) return
326 const anchor = Math.ceil((w.end ?? now) / BUCKET_MS) * BUCKET_MS
327 const from = anchor - n * BUCKET_MS
328 const count = new Array<number>(n).fill(0), kind = new Array<Kind | undefined>(n)
329 w.times.forEach((t, i) => {
330 const b = Math.floor((t - from) / BUCKET_MS)
331 if (b < 0 || b >= n) return
332 count[b] = (count[b] ?? 0) + 1
333 kind[b] = w.trail[i]
334 })
335 for (let i = 0; i < n; i++) {
336 const t0 = from + i * BUCKET_MS
337 if (t0 + BUCKET_MS < w.start) continue
338 const k = count[i] ?? 0, kd = kind[i]
339 if (!k || !kd) g.put(x + i, y, '▁', mix(FAINT, BASE, 0.35))
340 else g.put(x + i, y, BARS[Math.min(BARS.length - 1, k - 1)] ?? '▇', mix(KIND_COLOR[kd], BASE, fade))
341 }
342}
343
344type Place = (look: Look, x: number, y: number, alt: string) => void
345
346function card(g: Grid, w: Worker, x0: number, y0: number, W: number, now: number, place: Place) {
347 const f = Math.floor(now / FRAME_MS)
348 const opus = /opus/i.test(w.model)
349 const theme = opus ? OPUS_THEME : THEME[w.role]
350 const sc = STATE_COLOR[w.state]
351 const over = w.end !== undefined && w.state !== 'ask'
352 // A finished card steps back: its words sink toward the panel.
353 const fade = over ? 0.4 : 0
354 const ink = (c: number) => mix(c, BASE, fade)
355 const accent =
356 w.state === 'ask' ? mix(dim(sc, 0.5), sc, pulse(now, 1600))
357 : w.state === 'blocked' || w.state === 'error' ? mix(dim(sc, 0.45), sc, pulse(now, 700))
358 : over ? dim(sc, 0.7)
359 : mix(dim(theme, 0.75), theme, pulse(now + hash(w.key) * 37, 2400))
360
361 // A rounded frame on the terminal's background; its left side in the state's colour.
362 const R = x0 + W - 1, B = y0 + CARD_H - 1
363 const edge = over ? mix(EDGE, BASE, 0.4) : EDGE
364 for (let x = x0 + 1; x < R; x++) g.put(x, y0, '─', edge)
365 for (let y = y0 + 1; y < B; y++) { g.put(x0, y, '┃', accent); g.put(R, y, '│', edge) }
366 g.put(x0, y0, '┎', accent); g.put(R, y0, '╮', edge)
367 g.put(x0, B, '┖', accent); g.put(R, B, '╯', edge)
368
369 // The bottom edge is a bar: the run's time budget while it works (amber, then red, as it runs
370 // out), the countdown until a finished card leaves, full and breathing while a question waits.
371 const inner = W - 2
372 let frac = 0, bar = theme
373 if (w.state === 'ask') { frac = 1; bar = mix(dim(sc, 0.55), sc, pulse(now, 1600)) }
374 else if (over) { frac = 1 - (now - (w.end ?? now)) / KEEP_MS; bar = dim(sc, 0.6) }
375 else {
376 frac = (now - w.runStart) / Math.max(1, w.tmo * 1000)
377 bar = frac > 0.9 ? 0xff5c5c : frac > 0.75 ? 0xffb347 : dim(theme, 0.8)
378 }
379 const fill = Math.round(Math.max(0, Math.min(1, frac)) * inner)
380 for (let i = 0; i < inner; i++) g.put(x0 + 1 + i, B, i < fill ? '━' : '─', i < fill ? bar : edge)
381
382 place(lookAt(w.role, w.model, w.state, now, w.stateAt, hash(w.key)), x0 + 2, y0 + 1, `${w.name} ${LABEL[w.state]}`)
383
384 const X = x0 + MW + 3, TW = R - X - 1
385 if (TW < 8) return
386 const row = (i: number) => y0 + 1 + i
387
388 // 1: who — name, model chip, effort pips; the clock on the right
389 const elapsed = clock((w.end ?? now) - w.start)
390 const clockX = R - 1 - elapsed.length
391 g.text(clockX, row(0), elapsed, over ? ink(DIM) : TEXT)
392 let x = X + g.text(X, row(0), w.name, ink(theme), clockX - X - 1)
393 const room = () => clockX - x - 2
394 if (room() > 4) x += 1 + g.pill(x + 1, row(0), w.model.toUpperCase(), ink(theme), mix(BASE, theme, over ? 0.12 : 0.22), room())
395 const eff = EFFORT[w.effort]
396 if (eff && room() > 4) {
397 x += 1
398 for (let i = 0; i < 3; i++) g.put(x + i, row(0), i < eff[0] ? '●' : '○', i < eff[0] ? ink(eff[1]) : mix(FAINT, BASE, 0.3))
399 x += 3
400 }
401 if (w.resumed && room() > 2) g.put(x + 1, row(0), '↻', ink(0xffe082))
402
403 // 2: what it is doing now, and on what
404 const icon =
405 w.state === 'done' ? '✓' : w.state === 'failed' ? '✗' : w.state === 'ask' ? '?'
406 : w.state === 'blocked' || w.state === 'error' ? '!' : SPIN[f % SPIN.length] ?? ' '
407 g.put(X, row(1), icon, ink(sc))
408 let lx = X + 2 + g.text(X + 2, row(1), LABEL[w.state], ink(sc), TW - 2)
409 const quiet = !w.end ? now - w.lastAt : 0
410 if (quiet > QUIET_MS && w.state === 'think') {
411 lx += g.text(lx, row(1), ` · ${clock(quiet)} sessiz`, mix(0xffb347, BASE, 0.25 * pulse(now, 2000)), X + TW - lx)
412 } else if (w.arg && w.state !== 'ask' && X + TW - lx > 6) {
413 lx += 1 + g.text(lx + 1, row(1), '·', FAINT)
414 target(g, lx + 1, row(1), w.arg, X + TW - lx - 1, ink(0xc9d6e6), ink(0x6d7891))
415 }
416
417 // 3–4: the question while one waits; else the task and the activity strip
418 if (w.state === 'ask' && w.question) {
419 wrap(w.question, TW, 2).forEach((line, i) => g.text(X, row(2 + i), line, 0xffe8a3))
420 return
421 }
422 wrap(w.task, TW, 1).forEach(line => g.text(X, row(2), line, ink(DIM)))
423 const stats = over ? `${w.turns} turns · ${kilo(w.out)} tok`
424 : `${w.trail.length} tools${w.blocked ? ` · ${w.blocked} blocked` : ''}`
425 const sw = [...stats].length
426 const strip = TW - sw - 2
427 if (strip >= 6) activity(g, w, X, row(3), strip, now, fade)
428 g.text(X + TW - sw, row(3), stats, w.blocked && !w.end ? 0xff9e9e : ink(DIM))
429}
430
431function line(g: Grid, w: Worker, x0: number, y: number, W: number, now: number) {
432 const f = Math.floor(now / FRAME_MS)
433 const sc = STATE_COLOR[w.state]
434 const icon =
435 w.state === 'done' ? '✓' : w.state === 'failed' ? '✗' : w.state === 'ask' ? '?'
436 : w.state === 'blocked' || w.state === 'error' ? '!' : SPIN[f % SPIN.length] ?? ' '
437 const R = x0 + W - 1
438 const elapsed = clock((w.end ?? now) - w.start)
439 g.text(R - elapsed.length, y, elapsed, w.end ? DIM : TEXT)
440 const end = R - elapsed.length - 1
441 let x = x0 + 1
442 g.put(x, y, icon, sc); x += 2
443 x += g.text(x, y, w.name, /opus/i.test(w.model) ? OPUS_THEME : THEME[w.role], end - x)
444 x += 1 + g.text(x + 1, y, LABEL[w.state], sc, end - x - 1)
445 const rest = w.state === 'ask' ? (w.question ?? '') : w.arg || w.task
446 if (end - x > 4) g.text(x + 1, y, shorten(rest, end - x - 2), DIM)
447}
448
449function header(g: Grid, ws: Worker[], now: number) {
450 // the crew's colours drift through the title's diamond and the rule's travelling glow
451 const hues = [0x4fd1c5, 0x64b5f6, 0xb39ddb, 0xffb347, 0xffd166]
452 const t = (now / 1600) % hues.length
453 const hue = mix(hues[Math.floor(t)] ?? 0x4fd1c5, hues[(Math.floor(t) + 1) % hues.length] ?? 0x4fd1c5, t % 1)
454 g.put(1, 0, '◆', hue)
455 let x = 3 + g.text(3, 0, 'CREW', TEXT)
456 const active = ws.filter(w => !w.end).length
457 const asking = ws.filter(w => w.state === 'ask').length
458 const done = ws.filter(w => w.state === 'done').length
459 const failed = ws.filter(w => w.state === 'failed').length
460 const chips: [string, number, boolean][] = [
461 active ? [`● ${active} working`, 0x81c784, true] : ['○ idle', DIM, true], [`? ${asking} asking`, 0xffd166, asking > 0],
462 [`✓ ${done}`, 0x7bd88f, done > 0], [`✗ ${failed}`, 0xff5c5c, failed > 0],
463 ]
464 // the activity of the whole crew over the last two minutes, and its rate
465 const SPAN = 12, B = 10_000
466 const from = Math.ceil(now / B) * B - SPAN * B
467 const per = new Array<number>(SPAN).fill(0)
468 let minute = 0
469 for (const w of ws) for (const tt of w.times) {
470 const b = Math.floor((tt - from) / B)
471 if (b >= 0 && b < SPAN) per[b] = (per[b] ?? 0) + 1
472 if (now - tt < 60_000) minute++
473 }
474 const rate = `${minute}/dk`
475 const sparkW = SPAN + 1 + rate.length
476 const right = g.w - 1 - sparkW
477 for (const [s, c, on] of chips) {
478 if (!on) continue
479 const wdt = [...s].length + 2
480 if (x + 1 + wdt >= right - 1) break
481 x += 1 + g.pill(x + 1, 0, s, c === 0xffd166 ? mix(c, 0xffffff, 0.2 * pulse(now, 1600)) : c, mix(BASE, c, 0.18))
482 }
483 if (right > x + 1 && ws.length) {
484 const top = Math.max(1, ...per)
485 per.forEach((k, i) => g.put(right + i, 0, k ? (BARS[Math.min(BARS.length - 1, Math.ceil((k / top) * BARS.length) - 1)] ?? '▇') : '▁',
486 k ? mix(0x4fd1c5, 0xb39ddb, i / SPAN) : FAINT))
487 g.text(right + SPAN + 1, 0, rate, minute ? DIM : FAINT)
488 }
489 const head = ((now / 18) % (g.w + 30)) - 15
490 for (let i = 0; i < g.w; i++) {
491 const d = Math.abs(i - head)
492 g.put(i, 1, '─', d < 10 ? mix(0x2c2f3a, hue, (1 - d / 10) ** 2) : 0x2c2f3a)
493 }
494}
495
496// `images`: leave each sprite's cells blank and list it in `spots`, for an Image laid over them.
497// `hits`: where each card's "details" button goes (its bottom edge, right side), for a Button laid over it.
498export const DETAILS = 'details ›'
499export type Hit = { key: string; x: number; y: number }
500export function drawScene(
501 ws: Worker[], cols: number, now: number, maxRows?: number, images = false,
502): { rows: number; words: Uint32Array; spots: Spot[]; hits: Hit[] } {
503 const fit = layout(ws.length, cols, maxRows)
504 const rows = fit.rows
505 const g = new Grid(cols, rows)
506 const spots: Spot[] = []
507 const hits: Hit[] = []
508 const place: Place = (look, x, y, alt) => {
509 if (images) spots.push({ key: `s${spots.length}`, x, y, look, alt })
510 else g.pixels(smallSprite(look), x, y)
511 }
512 header(g, ws, now)
513 if (ws.length === 0) {
514 const half = Math.floor(cols / 2)
515 for (const [i, role] of (['scout', 'coder'] as Role[]).entries()) {
516 const cx = Math.max(0, (i ? half : 0) + Math.floor((half - MW) / 2))
517 place(lookAt(role, role === 'scout' ? 'haiku' : 'sonnet', 'sleep', now, 0, i * 5), cx, HEADER_H + 1, `${role} uyuyor`)
518 const label = role === 'scout' ? 'scout · haiku' : 'coder · sonnet'
519 g.text(Math.max(0, (i ? half : 0) + Math.floor((half - label.length) / 2)), HEADER_H + 2 + MH, label, dim(THEME[role], 0.8))
520 }
521 const msg = 'no workers · /orchestrate wakes them'
522 g.text(Math.max(0, Math.floor((cols - [...msg].length) / 2)), HEADER_H + MH + 3, msg, FAINT)
523 return { rows, words: g.words(), spots, hits }
524 }
525 const per = perRow(cols)
526 const W = Math.floor(cols / per)
527 const cw = per > 1 ? W - 1 : W
528 // Overflowing: questions and live workers keep the cards, finished ones drop to lines first.
529 const list = fit.cards < ws.length ? [...ws].sort((a, b) => RANK(a) - RANK(b)) : ws
530 list.slice(0, fit.cards).forEach((w, i) => {
531 const x0 = (i % per) * W, y0 = HEADER_H + Math.floor(i / per) * CARD_H
532 card(g, w, x0, y0, cw, now, place)
533 hits.push({ key: w.key, x: Math.max(x0 + 2, x0 + cw - DETAILS.length - 3), y: y0 + CARD_H - 1 })
534 })
535 const top = HEADER_H + Math.ceil(fit.cards / per) * CARD_H
536 list.slice(fit.cards, fit.cards + fit.lines * per)
537 .forEach((w, i) => line(g, w, (i % per) * W, top + Math.floor(i / per), W, now))
538 if (fit.more > 0) {
539 const msg = `+${fit.more} more · enlarge the pane`
540 g.text(Math.max(0, Math.floor((cols - [...msg].length) / 2)), rows - 1, msg, DIM)
541 }
542 return { rows, words: g.words(), spots, hits }
543}
544
545// ---------- svg (surfaces with no Raster: desktop, editor, mobile) ----------
546
547// One cell is CW x CH; each half-block pixel (CW x CH/2) comes out square. Every cell splits into
548// 2x2 quarters so ▀▄█▌▐ map to rects, merged into runs and one path per colour; other glyphs are text.
549const CW = 6, CH = 12
550const hex = (c: number) => `#${(c & 0xffffff).toString(16).padStart(6, '0')}`
551const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
552const QUARTERS: Record<number, number> = { 0x2580: 0b1100, 0x2584: 0b0011, 0x2588: 0b1111, 0x258c: 0b1010, 0x2590: 0b0101 }
553
554// `sprites`: PNG frames laid over the blank cells drawScene(images = true) left, as data URIs.
555export type SvgSprite = { x: number; y: number; base64: string }
556export function sceneSvg(words: Uint32Array, cols: number, rows: number, sprites: SvgSprite[] = [], scale = 1.5): string {
557 const q = new Int32Array(cols * 2 * rows * 2).fill(-1)
558 const texts: string[] = []
559 for (let y = 0; y < rows; y++) {
560 // Runs end at spaces: a surface may collapse them, and textLength would then stretch the rest.
561 let run = '', runX = 0, runFg = -1
562 const flush = () => {
563 if (run) {
564 const n = [...run].length
565 texts.push(`<text x="${runX * CW}" y="${y * CH + 9.5}" fill="${hex(runFg)}" textLength="${n * CW}" lengthAdjust="spacingAndGlyphs">${esc(run)}</text>`)
566 }
567 run = ''; runFg = -1
568 }
569 for (let x = 0; x < cols; x++) {
570 const i = (y * cols + x) * 3
571 const g = words[i] ?? 0x20, fg = words[i + 1] ?? DEF, bg = words[i + 2] ?? DEF
572 const fill = (mask: number, c: number) => {
573 if (c === DEF) return
574 for (let k = 0; k < 4; k++) if (mask & (8 >> k)) q[(y * 2 + (k >> 1)) * cols * 2 + x * 2 + (k & 1)] = c & 0xffffff
575 }
576 fill(0b1111, bg)
577 const mask = QUARTERS[g]
578 if (mask !== undefined) { fill(mask, fg === DEF ? TEXT : fg); flush(); continue }
579 const c = fg === DEF ? TEXT : fg & 0xffffff
580 if (g === 0x20) { flush(); continue }
581 if (run && c !== runFg) flush()
582 if (!run) { runX = x; runFg = c }
583 run += String.fromCodePoint(g)
584 }
585 flush()
586 }
587 const paths = new Map<number, string>()
588 const W = cols * 2
589 for (let j = 0; j < rows * 2; j++) {
590 for (let i = 0; i < W;) {
591 const c = q[j * W + i] ?? -1
592 let k = i + 1
593 while (k < W && q[j * W + k] === c) k++
594 if (c >= 0) {
595 const w = ((k - i) * CW) / 2
596 paths.set(c, `${paths.get(c) ?? ''}M${(i * CW) / 2} ${(j * CH) / 2}h${w}v${CH / 2}h-${w}z`)
597 }
598 i = k
599 }
600 }
601 const vw = cols * CW, vh = rows * CH
602 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${vw} ${vh}" width="${vw * scale}" height="${vh * scale}">` +
603 `<rect width="${vw}" height="${vh}" rx="6" fill="${hex(0x1b1c22)}"/>` +
604 [...paths].map(([c, d]) => `<path fill="${hex(c)}" d="${d}"/>`).join('') +
605 sprites.map(s => `<image x="${s.x * CW}" y="${s.y * CH}" width="${MW * CW}" height="${MH * CH}" style="image-rendering:pixelated" href="data:image/png;base64,${s.base64}"/>`).join('') +
606 `<g font-family="ui-monospace,Menlo,Consolas,monospace" font-size="10">${texts.join('')}</g></svg>`
607}
608types/index.d.ts 10 lines1export type Slots = number
2/** The worker key whose detail view is open; '' = the crew scene. */
3export type Selected = string
4
5declare module 'claude-code' {
6 interface PluginState {
7 'cw-crew': { slots: Slots; selected: Selected }
8 }
9}
10