A pixel-art office for Claude Code: watch Claude and its subagents work at their desks, and dress them up.

A pixel-art office for Claude Code: watch Claude and its subagents work at their desks.
<img src="docs/factory.gif" alt="Claude hands out work: subagents take the desks around it, type away with their tools in thought bubbles, finish with a green check and doze off" width="564">
Install · What you see · Three sizes · Dress them up · Commands · FAQ
Factory View is a Claude Code mod that visualizes multi-agent orchestration. When Claude works with subagents, a lot happens at once and most of it is out of sight; Factory View turns it into a little office in a pane beside the conversation. Claude, the main agent, is the manager at the gilded desk in the middle of the floor; every subagent it starts takes a desk of its own around it. Agents at work type away while their monitor scrolls and a thought bubble shows the tool they run; agents with nothing to do look around, doze off in a stream of Zzz, or cheer with a green check when their task comes back.
Every agent is the Claude Code mascot, and every one of them can be dressed up.
At the prompt of a Claude Code terminal session:
/plugin install factory-view --marketplace metetik/claude-code-agent-factory-view-mode
Answer y to add the marketplace, pick a scope, and the pane opens. From a shell:
claude plugin install factory-view --marketplace metetik/claude-code-agent-factory-view-mode
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with metetik/claude-code-agent-factory-view-mode, then Add plugin.
Then type /factory-view demo to fill the floor with six pretend agents, or give Claude a task worth a few subagents and watch them get to work.
Factory View is a mod, built on Claude Code's function hooks (early access). It was made and tested on Claude Code 2.1.302.
| On the floor | Means |
|---|---|
| ⌨️ Arms typing, code scrolling on the monitor, a bubble over it | working: the bubble shows the tool: a magnifier for reading and searching, a pencil for edits, a terminal for Bash, a globe for the web, a little mascot for handing out work, a checklist for todos, … for thinking (colors stand for them on the smaller desks) |
| 🫧 Eyes looking around, a bubble of dots | waiting: the manager waits for you; a subagent waits to start, or for a message |
| ✅ Happy eyes, arms up, a green check | done: the task came back |
| 💤 Eyes closed, Zzz rising, screensaver | sleeping: idle for a while; a finished agent keeps its check |
❗ X eyes, a red !, a red screen | failed |
The manager dozes off 90 seconds after the last turn ends; a finished subagent cheers for 20 seconds, then naps at its desk. Desks fill nearest the manager first, ringing outward; when 24 are taken, the agent that finished longest ago goes home to make room.
Each desk wears a name tag: the agent's name when Claude gave it one, else its type when it is one of your own agents (code-reviewer, story), else the first words of its task. Under the floor, a legend lists every agent with the tool it runs, or ran last, and what it works on (Grep: TODO, Edit: parser.ts). In the desktop and mobile apps, hover a desk for the same.
<img src="docs/sizes.gif" alt="The same floor at large, medium and small" width="846">
A terminal cell is the smallest thing a terminal can draw, so each size has pictures of its own. Large draws in half blocks, two square pixels a cell, the mascot 8 cells wide. Medium (the default) and small draw in quarter blocks, 2x2 pixels a cell, the mascot 6 and 4 cells wide, with every hat and extra redrawn for them. A 3x3 floor takes 66, 54 or 42 columns. A name tag can't shrink with its desk (a letter is a cell), so it borrows the floor beside it: 21, 17 and 13 letters.
Claude Code Desktop and the mobile app draw the same floor as an animated SVG, scaled by the same size. Switch with Size under the legend, or /factory-view size.
<img src="docs/wardrobe.gif" alt="Three agents trying on hats, colors and extras" width="684">
14 colors · 18 hats: crown, top hat, cap, beanie, wizard hat, party hat, hard hat, chef's hat, cowboy hat, safari hat, propeller cap, headphones, bow, halo, antenna, grad cap, viking helmet, sprout · 8 extras: glasses, shades, monocle, tie, bow tie, mustache, scarf, blush.
Pick an agent in the legend and choose its color, hat and extra from the lists (the mobile app steps through them with buttons), for that agent alone or for every agent of its type. Random draws a new look, Reset takes the choice back, Transcript opens that subagent's conversation.
Agents nobody dressed draw a look of their own by lot. Prefer uniforms? Switch Undressed agents to looks by type: Explore agents in safari hats, general-purpose ones in hard hats, Plan in wizard hats. The manager keeps its crown and tie until you change them, and nobody else draws the crown by chance. Looks chosen for the manager and for types are remembered across sessions.
| Command | Does | ||
|---|---|---|---|
/factory-view | open or close the pane (closing it keeps it closed in later sessions) | ||
/factory-view demo | add or remove six pretend agents, to see a busy floor | ||
/factory-view style <who> color=<c> hat=<h> extra=<e> | dress the manager (main), every agent of a type (Explore, Plan, …), or one agent by its name tag | ||
/factory-view reset [who] | back to the default looks | ||
| `/factory-view looks random\ | type` | how agents nobody dressed look | |
| `/factory-view size small\ | medium\ | large` | how big the agents are drawn |
/factory-view clear | send finished agents home | ||
/factory-view options | list the colors, hats and extras |
Factory View runs entirely inside Claude Code. It makes no network requests, calls no model, runs no commands and sends no telemetry. It reads the session's own agent and tool events (which agent runs which tool, and a few words of that tool's input, such as a file name or the start of a command) to draw them, keeps them in the session's memory, and saves only the looks and settings you choose, in Claude Code's plugin store on your machine.
How do I see what my Claude Code subagents are doing? Install Factory View and the pane opens beside the conversation. Every subagent gets a desk, its thought bubble shows the kind of tool it runs, and the legend under the floor names the tool and what it works on, for every agent at once.
Does it work in Claude Code Desktop and the mobile app? Yes. The terminal draws the floor in block characters; Claude Code Desktop and the mobile app draw it as an animated SVG, with hover text on each desk.
Does it slow Claude down or cost tokens? No. It adds nothing to the prompt and makes no model calls; it only watches the events the session already has.
Does it work with agent teams and background agents? Yes. Teammates and background agents get desks like any subagent; a teammate waiting for a message looks around, then dozes off.
Which Claude Code version do I need? Factory View is a mod, built on function hooks, which are early access; it was made and tested on Claude Code 2.1.302. Installing with /plugin install … --marketplace in a session needs 2.1.275 or later, and from the shell 2.1.292 or later.
Is this an official Anthropic project? No. Factory View is a community mod and is not affiliated with Anthropic.
hooks/register.tsx follows the session: turn.start and turn.complete for every loop, the main one and each subagent's, tool.call for the tool each agent runs, and $.agent.list() every two seconds to catch anything missed. What it learns lives in $.state, so the pane redraws on every change and survives a reload. The terminal's picture is a Raster repainted four times a second with $.ui.blit; the remote surfaces get an SVG whose frames play by SMIL, so it animates with no redraws at all.
| File | Holds |
|---|---|
hooks/register.tsx | the hooks: events, the pane, the /factory-view command, the animation ticker |
hooks/model.ts | agents, desks, status changes and styles, as plain functions |
hooks/scene.ts | the floor plan and the pixel painter, for every size |
hooks/sprites.ts | colors, hats, extras and icons for the scaled SVG |
hooks/minis.ts | the same for the large terminal desks |
hooks/tinies.ts | the same for the medium and small desks, in quarter blocks |
hooks/paint.ts | the picture as terminal cells (half or quarter blocks), and as an SVG with its frames |
types/index.d.ts | the shape of the mod's state |
tests/factory.test.ts | the floor plan, the agents, the session and the pane |
claude plugin validate .
claude plugin test .
claude --plugin-dir . # a session with the mod loaded from this folder
bun scripts/preview.ts out 8 5 # the floor with 8 agents as SVG and ANSI, outside a session
tsc -p . type-checks the mod once Claude Code has loaded it from this folder, which lays the API's declarations in .claude-plugin/types/.
hooks/register.tsx 749 lines1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, Elements, EngineInterface, Register, ToolCallInput } from 'claude-code'
3
4import type { AccessoryName, AgentSize, AgentStatus, AgentStyle, ColorName, FactoryAgent, HatName, LooksMode } from '../types'
5import {
6 MAIN_ID,
7 STYLE_CHOICES,
8 addAgent,
9 baseStyle,
10 clearFinished,
11 cycle,
12 describeAgent,
13 endTool,
14 labelFor,
15 mainAgent,
16 parseStyle,
17 patchAgent,
18 lastingStyles,
19 randomStyle,
20 setStatus,
21 settle,
22 startTool,
23 styleKey,
24 styleOf,
25 toolDetail,
26 withMain,
27 type NewAgent,
28 type StyleScope,
29} from './model'
30import { rasterCells, sceneSvg } from './paint'
31import {
32 FRAMES,
33 clip,
34 colsFor,
35 drawScene,
36 layout,
37 sceneHotspots,
38 labelChars,
39 sceneLabels,
40 seatsNeeded,
41 type SceneAgent,
42} from './scene'
43import { ACCESSORY_NAMES, COLORS, COLOR_NAMES, HAT_NAMES, hex, prettyName } from './sprites'
44
45const PANE = 'factory-view'
46const TITLE = 'Factory View'
47const RASTER = 'floor'
48/** One animation frame; the SVG plays its frames at the same pace. */
49const TICK_MS = 250
50const RECONCILE_MS = 2000
51/** Desks across on the remote surfaces, which scale the picture to fit. */
52const REMOTE_COLS = 7
53/** CSS pixels per picture pixel on the remote surfaces, by agent size. */
54const REMOTE_SCALE: Record<AgentSize, number> = { small: 1, medium: 1.5, large: 2 }
55const SIZES: AgentSize[] = ['small', 'medium', 'large']
56/** Letters in a name tag on the remote surfaces: what fits under a desk and the floor beside it. */
57const REMOTE_LABEL_CHARS = 14
58
59const AGENTS = { plugin: 'factory-view', key: 'agents' } as const
60const agentsAtom = atom(AGENTS, [])
61const stylesAtom = atom({ plugin: 'factory-view', key: 'styles' } as const, {})
62const looksAtom = atom({ plugin: 'factory-view', key: 'looks' } as const, 'random')
63const sizeAtom = atom({ plugin: 'factory-view', key: 'size' } as const, 'medium')
64const selectedAtom = atom({ plugin: 'factory-view', key: 'selected' } as const, null)
65const scopeAtom = atom({ plugin: 'factory-view', key: 'scope' } as const, 'agent')
66const demoAtom = atom({ plugin: 'factory-view', key: 'isDemo' } as const, false)
67
68type Engine = EngineInterface
69/** What every surface draws with; `Select` where the surface has one (not mobile yet). */
70type Table = Pick<Elements['mobile'], 'Box' | 'Text' | 'Button'> & { Select?: Elements['desktop']['Select'] }
71/** The choices that decide how every agent looks. */
72type Wardrobe = { styles: Record<string, AgentStyle>; looks: LooksMode }
73
74/** The terminal picture's frame; a reload starts it over, which nobody sees. */
75let frame = 0
76/** The terminal Raster as last drawn, which the ticker repaints in place. */
77let mounted: { columns: number; rows: number; maxCols: number; isFlat: boolean; size: AgentSize } | null = null
78let isShown = true
79let isTicking = false
80/** The selected agent's id as last drawn, so the ticker highlights the same desk. */
81let selectedId: string | null = null
82/** Loop ids no agent list named lately (the engine's own forks), by when to ask again. */
83const unknownUntil = new Map<string, number>()
84
85async function quietly($: Engine, what: string, work: () => Promise<unknown>): Promise<void> {
86 try {
87 await work()
88 } catch (error) {
89 $.ui.log(`factory-view: ${what} failed: ${String(error)}`, { to: 'debug' })
90 }
91}
92
93/** Changes the agents in one write, or none when nothing changed. */
94async function mutate($: Engine, change: (agents: FactoryAgent[], now: number) => FactoryAgent[]): Promise<void> {
95 for (let attempt = 0; attempt < 5; attempt++) {
96 const now = await $.clock.now()
97 const held = await $.state.get(AGENTS)
98 const before = held.value ?? []
99 const after = change(withMain(before, now), now)
100 if (JSON.stringify(after) === JSON.stringify(before)) return
101 const { isSet } = await $.state.set(AGENTS, after, { ifVersion: held.version })
102 if (isSet) return
103 }
104}
105
106function statusFromList(status: AgentInfo['status']): AgentStatus {
107 switch (status) {
108 case 'running':
109 return 'working'
110 case 'completed':
111 return 'done'
112 case 'failed':
113 case 'killed':
114 return 'failed'
115 default:
116 return 'waiting'
117 }
118}
119
120function newAgentFrom(info: AgentInfo): NewAgent {
121 return {
122 id: info.id,
123 type: info.type,
124 label: labelFor(info.name, info.description, info.type),
125 description: info.description,
126 status: statusFromList(info.status),
127 isTeammate: info.teammateId !== undefined,
128 }
129}
130
131/** Seats the agent a loop id belongs to, if the session lists one; false for the engine's own forks. */
132async function ensureAgent($: Engine, id: string): Promise<boolean> {
133 if (id === MAIN_ID) return true
134 if ((await read($, agentsAtom)).some(a => a.id === id)) return true
135 const now = await $.clock.now()
136 if ((unknownUntil.get(id) ?? 0) > now) return false
137 const info = (await $.agent.list()).find(one => one.id === id)
138 if (info === undefined) {
139 unknownUntil.set(id, now + 5000)
140 return false
141 }
142 await mutate($, (agents, at) => addAgent(agents, newAgentFrom(info), at))
143
144 return true
145}
146
147/** Follows the session's agent list: new agents get desks, ended ones put their feet up. */
148async function reconcile($: Engine): Promise<void> {
149 await mutate($, (agents, now) => settle(agents, now) ?? agents)
150
151 const listed = await $.agent.list()
152 await mutate($, (agents, now) => {
153 let next = agents
154 for (const info of listed) {
155 const mine = next.find(a => a.id === info.id)
156 const status = statusFromList(info.status)
157 if (mine === undefined) {
158 if (status === 'working' || status === 'waiting') next = addAgent(next, newAgentFrom(info), now)
159 continue
160 }
161 const label = labelFor(info.name, info.description, info.type)
162 if (mine.label !== label || mine.type !== info.type || mine.description !== info.description) {
163 next = patchAgent(next, mine.id, a => ({ ...a, label, type: info.type, description: info.description }))
164 }
165 const isFresh = now - mine.since < 5000
166 const isEnded = status === 'done' || status === 'failed'
167 const isMineEnded = mine.status === 'done' || mine.status === 'failed' || mine.status === 'sleeping'
168 if ((isEnded && !isMineEnded) || (status === 'working' && mine.status !== 'working' && !isFresh && !isMineEnded)) {
169 next = patchAgent(next, mine.id, a => setStatus(a, status, now))
170 }
171 }
172 const ids = new Set(listed.map(info => info.id))
173 for (const mine of next) {
174 const isGone = !mine.isMain && !mine.isDemo && !ids.has(mine.id) && now - mine.since > 10_000
175 if (isGone && (mine.status === 'working' || mine.status === 'waiting')) {
176 next = patchAgent(next, mine.id, a => setStatus(a, 'done', now))
177 }
178 }
179
180 return next
181 })
182
183 const pane = (await $.ui.panes()).find(one => one.id === PANE)
184 isShown = pane?.isShown === true
185 if (await read($, demoAtom)) await demoStep($)
186}
187
188/** Repaints the terminal's Raster with the next frame, no render pass. */
189async function tick($: Engine): Promise<void> {
190 const at = mounted
191 if (at === null || !isShown || isTicking) return
192 isTicking = true
193 try {
194 frame = (frame + 1) % (FRAMES * 1000)
195 const agents = withMain(await read($, agentsAtom), 0)
196 const scene = sceneOf(agents, await wardrobeOf($))
197 const selected = agents.find(a => a.id === selectedId)
198 const lay = layout(seatsNeeded(scene), at.maxCols, { isFlat: at.isFlat, spacing: 'snug', art: at.size })
199 const picture = rasterCells(drawScene(lay, scene, frame, selected?.seat), sceneLabels(lay, scene, labelChars(lay)), lay.isQuad)
200 if (picture.columns !== at.columns || picture.rows !== at.rows) return
201 await $.ui.blit({ requestId: PANE, key: RASTER, cells: picture.cells, columns: picture.columns, rows: picture.rows })
202 } finally {
203 isTicking = false
204 }
205}
206
207async function wardrobeOf($: Engine): Promise<Wardrobe> {
208 return { styles: await read($, stylesAtom), looks: await read($, looksAtom) }
209}
210
211function sceneOf(agents: readonly FactoryAgent[], { styles, looks }: Wardrobe): SceneAgent[] {
212 return agents.map(agent => ({ agent, style: styleOf(agent, styles, looks) }))
213}
214
215const DEMO_TYPES = ['Explore', 'general-purpose', 'Plan', 'code-reviewer', 'test-runner', 'docs-writer']
216const DEMO_TASKS = ['Map the repo', 'Fix the parser', 'Plan the API', 'Review the diff', 'Run the tests', 'Write the docs']
217const DEMO_TOOLS = ['Read', 'Grep', 'Edit', 'Bash', 'WebFetch', 'TodoWrite', 'Glob', 'Write']
218
219function pick<T>(list: readonly T[]): T {
220 return list[Math.floor(Math.random() * list.length)]!
221}
222
223async function startDemo($: Engine): Promise<void> {
224 await update($, demoAtom, () => true)
225 await mutate($, (agents, now) =>
226 DEMO_TYPES.reduce(
227 (list, type, i) =>
228 addAgent(
229 list,
230 {
231 id: `demo-${i + 1}`,
232 type,
233 label: labelFor(undefined, DEMO_TASKS[i]!, type),
234 description: DEMO_TASKS[i]!,
235 status: i < 3 ? 'working' : i === 3 ? 'waiting' : 'done',
236 isDemo: true,
237 },
238 now,
239 ),
240 agents,
241 ).map(a => (a.isDemo && a.status === 'working' ? { ...a, tool: pick(DEMO_TOOLS), detail: 'demo' } : a)),
242 )
243}
244
245async function stopDemo($: Engine): Promise<void> {
246 await update($, demoAtom, () => false)
247 await mutate($, agents => agents.filter(a => !a.isDemo))
248}
249
250/** Moves one or two pretend agents along: a new tool, a finished task, a fresh one. */
251async function demoStep($: Engine): Promise<void> {
252 await mutate($, (agents, now) => {
253 const demo = agents.filter(a => a.isDemo)
254 if (demo.length === 0) return agents
255 let next = agents
256 for (let i = 0; i < 2; i++) {
257 const one = pick(demo)
258 const roll = Math.random()
259 next = patchAgent(next, one.id, a => {
260 if (a.status !== 'working') return roll < 0.5 ? setStatus(a, 'working', now) : a
261 if (roll < 0.15) return setStatus(a, 'done', now)
262 if (roll < 0.2) return setStatus(a, 'failed', now)
263 if (roll < 0.3) return { ...a, tool: null, toolId: null, detail: null }
264
265 return startTool(a, pick(DEMO_TOOLS), `demo-${now}-${i}`, 'demo', now)
266 })
267 }
268
269 return next
270 })
271}
272
273async function select($: Engine, id: string | null): Promise<void> {
274 await update($, selectedAtom, current => (current === id ? null : id))
275}
276
277/**
278 * Changes the look of one agent, or of every agent of its type; a look for
279 * the type takes the place of the agent's own, so the picked one shows it.
280 * `null` takes the choice back. Choices for types and the manager are kept
281 * for later sessions.
282 */
283async function restyle(
284 $: Engine,
285 agent: Pick<FactoryAgent, 'id' | 'isMain' | 'type'>,
286 scope: StyleScope,
287 change: (style: AgentStyle) => AgentStyle | null,
288): Promise<void> {
289 const key = styleKey(agent, scope)
290 const looks = await read($, looksAtom)
291 const styles = await update($, stylesAtom, all => {
292 const next = { ...all }
293 const style = change(baseStyle(agent, scope, all, looks))
294 if (scope === 'type') delete next[styleKey(agent, 'agent')]
295 if (style === null) delete next[key]
296 else next[key] = style
297
298 return next
299 })
300 await $.store.set('styles', lastingStyles(styles))
301}
302
303async function setLooks($: Engine, looks: LooksMode): Promise<void> {
304 await update($, looksAtom, () => looks)
305 await $.store.set('looks', looks)
306}
307
308async function setSize($: Engine, size: AgentSize): Promise<void> {
309 await update($, sizeAtom, () => size)
310 await $.store.set('size', size)
311}
312
313const STATUS_THEME: Record<AgentStatus, string> = {
314 working: 'claude',
315 waiting: 'warning',
316 sleeping: 'inactive',
317 done: 'success',
318 failed: 'error',
319}
320
321function summary(agents: readonly FactoryAgent[]): string {
322 const count = (test: (a: FactoryAgent) => boolean) => agents.filter(test).length
323 const parts = [
324 `${count(a => a.status === 'working')} working`,
325 `${count(a => a.status === 'waiting')} waiting`,
326 `${count(a => a.status === 'sleeping' || a.status === 'done')} resting`,
327 ]
328 const failed = count(a => a.status === 'failed')
329 if (failed > 0) parts.push(`${failed} failed`)
330
331 return parts.join(' · ')
332}
333
334function altText(agents: readonly FactoryAgent[]): string {
335 return `${TITLE}: ${agents.map(describeAgent).join('; ')}`.slice(0, 1000)
336}
337
338const options = (names: readonly string[]) => names.map(value => ({ value, label: prettyName(value) }))
339
340/** Pickers for color, hat and extra: lists where the surface has them, else buttons that step through. */
341function pickers(
342 $: Engine,
343 { Box, Button, Select }: Table,
344 selected: FactoryAgent,
345 scope: StyleScope,
346 shown: AgentStyle,
347) {
348 const set = (change: Partial<AgentStyle>) => restyle($, selected, scope, style => ({ ...style, ...change }))
349 if (Select !== undefined) {
350 return (
351 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
352 <Select
353 key="pick-color"
354 label="Color"
355 options={options(COLOR_NAMES)}
356 value={shown.color}
357 onSelect={value => void set({ color: value as ColorName })}
358 />
359 <Select
360 key="pick-hat"
361 label="Hat"
362 options={options(HAT_NAMES)}
363 value={shown.hat}
364 onSelect={value => void set({ hat: value as HatName })}
365 />
366 <Select
367 key="pick-extra"
368 label="Extra"
369 options={options(ACCESSORY_NAMES)}
370 value={shown.accessory}
371 onSelect={value => void set({ accessory: value as AccessoryName })}
372 />
373 </Box>
374 )
375 }
376
377 return (
378 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
379 <Button
380 key="style-color"
381 label={`Color: ${prettyName(shown.color)}`}
382 onPress={() => set({ color: cycle(STYLE_CHOICES.color, shown.color) })}
383 />
384 <Button key="style-hat" label={`Hat: ${prettyName(shown.hat)}`} onPress={() => set({ hat: cycle(STYLE_CHOICES.hat, shown.hat) })} />
385 <Button
386 key="style-extra"
387 label={`Extra: ${prettyName(shown.accessory)}`}
388 onPress={() => set({ accessory: cycle(STYLE_CHOICES.accessory, shown.accessory) })}
389 />
390 </Box>
391 )
392}
393
394/** The legend under the picture, and the style controls of the agent picked from it. */
395function controls(
396 $: Engine,
397 ui: Table,
398 agents: readonly FactoryAgent[],
399 { styles, looks }: Wardrobe,
400 selected: FactoryAgent | undefined,
401 scope: StyleScope,
402 size: AgentSize,
403 width: number,
404) {
405 const { Box, Text, Button } = ui
406 const sorted = [...agents].sort((a, b) => a.seat - b.seat)
407 const hasFinished = agents.some(a => !a.isMain && (a.status === 'done' || a.status === 'failed' || a.status === 'sleeping'))
408 const reach: StyleScope = selected?.isMain ? 'type' : scope
409
410 return (
411 <Box flexDirection="column">
412 <Text dimColor>
413 {summary(agents)} · pick an agent to dress it up
414 </Text>
415 {sorted.map(agent => {
416 const doing = describeAgent(agent).split(' · ')[1] ?? agent.status
417 return (
418 <Button key={`agent-${agent.id}`} plain onPress={() => select($, agent.id)}>
419 <Text color={hex(COLORS[styleOf(agent, styles, looks).color])}>■ </Text>
420 {clip(agent.label, 16)}{' '}
421 <Text color={STATUS_THEME[agent.status]}>{clip(doing, Math.max(10, width - 22))}</Text>
422 </Button>
423 )
424 })}
425 {selected !== undefined && (
426 <Box flexDirection="column" marginTop={1} rowGap={1}>
427 <Text weight="bold">Dressing {selected.isMain ? 'the manager' : selected.label}</Text>
428 {!selected.isMain && (
429 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
430 <Text dimColor>For</Text>
431 <Button
432 key="scope-agent"
433 label="this agent"
434 variant={reach === 'agent' ? 'primary' : 'secondary'}
435 onPress={() => update($, scopeAtom, () => 'agent')}
436 />
437 <Button
438 key="scope-type"
439 label={`every ${selected.type} agent`}
440 variant={reach === 'type' ? 'primary' : 'secondary'}
441 onPress={() => update($, scopeAtom, () => 'type')}
442 />
443 </Box>
444 )}
445 {pickers($, ui, selected, reach, baseStyle(selected, reach, styles, looks))}
446 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
447 <Button
448 key="style-random"
449 label="Random"
450 onPress={async () => {
451 const seed = await $.clock.now()
452 await restyle($, selected, reach, () => randomStyle(seed))
453 }}
454 />
455 <Button key="style-reset" label="Reset" onPress={() => restyle($, selected, reach, () => null)} />
456 {!selected.isMain && !selected.isDemo && (
457 <Button key="style-view" label="Transcript" onPress={() => $.ui.view({ agentId: selected.id })} />
458 )}
459 <Button key="style-done" label="Done" variant="primary" onPress={() => select($, null)} />
460 </Box>
461 </Box>
462 )}
463 {selected === undefined && (
464 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
465 <Button
466 key="looks"
467 label={looks === 'random' ? 'Undressed agents: random looks' : 'Undressed agents: looks by type'}
468 onPress={() => setLooks($, looks === 'random' ? 'type' : 'random')}
469 />
470 <Button
471 key="size"
472 label={`Size: ${size}`}
473 onPress={() => setSize($, cycle(SIZES, size))}
474 />
475 {hasFinished && (
476 <Button key="clear-finished" label="Send finished agents home" onPress={() => mutate($, list => clearFinished(list))} />
477 )}
478 </Box>
479 )}
480 </Box>
481 )
482}
483
484const HELP = [
485 '/factory-view — open or close the Factory View pane',
486 '/factory-view style <who> color=<c> hat=<h> extra=<e> — dress the manager (main), every agent of a type, or one agent by its name tag',
487 '/factory-view reset [who] — back to the default looks',
488 '/factory-view looks random|type — how agents nobody dressed look: each its own by lot, or dressed for their type',
489 '/factory-view size small|medium|large — how big the agents are drawn',
490 '/factory-view clear — send finished agents home',
491 '/factory-view demo — add or remove pretend agents, to see the floor busy',
492 '/factory-view options — list the colors, hats and extras',
493].join('\n')
494
495type Target = { agent: Pick<FactoryAgent, 'id' | 'isMain' | 'type'>; scope: StyleScope; name: string }
496
497/** Who a `/factory-view style` names: the manager, a type on the floor, an agent by its name tag, or any type. */
498function targetOf(word: string, agents: readonly FactoryAgent[]): Target {
499 if (/^(main|manager|claude|boss)$/i.test(word)) return { agent: mainAgent(0), scope: 'type', name: 'The manager' }
500 const lower = word.toLowerCase()
501 const ofType = agents.find(a => !a.isMain && a.type.toLowerCase() === lower)
502 if (ofType !== undefined) return { agent: ofType, scope: 'type', name: `Every ${ofType.type} agent` }
503 const tagged = agents.find(a => !a.isMain && a.label.toLowerCase() === lower)
504 if (tagged !== undefined) return { agent: tagged, scope: 'agent', name: tagged.label }
505
506 return { agent: { id: '', isMain: false, type: word }, scope: 'type', name: `Every ${word} agent` }
507}
508
509async function runCommand($: Engine, args: string): Promise<string> {
510 const words = args.trim().split(/\s+/).filter(Boolean)
511 const verb = words[0]?.toLowerCase()
512
513 if (verb === undefined || verb === 'open' || verb === 'close') {
514 const pane = (await $.ui.panes()).find(one => one.id === PANE)
515 if (verb !== 'open' && pane?.isShown === true) {
516 await $.store.set('isClosed', true)
517 await $.ui.close({ id: PANE })
518 return 'Factory View closed; /factory-view opens it again.'
519 }
520 await $.store.set('isClosed', false)
521 const opened = await $.ui.open({ id: PANE, title: TITLE })
522 return opened.isPlaced ? 'Factory View is open.' : `Factory View could not open: ${opened.reason}`
523 }
524
525 const agents = withMain(await read($, agentsAtom), await $.clock.now())
526
527 switch (verb) {
528 case 'style': {
529 const who = words[1]
530 if (who === undefined || words.length < 3) return `Usage: /factory-view style <who> color=<c> hat=<h> extra=<e>\n\n${HELP}`
531 const parsed = parseStyle(words.slice(2))
532 if ('error' in parsed) return `Unknown style "${parsed.error}". /factory-view options lists them.`
533 const target = targetOf(who, agents)
534 await restyle($, target.agent, target.scope, style => ({ ...style, ...parsed.change }))
535 const { styles, looks } = await wardrobeOf($)
536 const style = baseStyle(target.agent, target.scope, styles, looks)
537 return `${target.name} now wears: ${prettyName(style.color)}, ${prettyName(style.hat)} hat, ${prettyName(style.accessory)}.`
538 }
539 case 'reset': {
540 const who = words[1]
541 if (who === undefined) {
542 await update($, stylesAtom, () => ({}))
543 await $.store.set('styles', {})
544 return 'Every agent is back to its default look.'
545 }
546 const target = targetOf(who, agents)
547 await restyle($, target.agent, target.scope, () => null)
548 return `${target.name}: back to the default look.`
549 }
550 case 'size': {
551 const size = SIZES.find(one => one === words[1]?.toLowerCase())
552 if (size === undefined) return 'Usage: /factory-view size small|medium|large'
553 await setSize($, size)
554 return `The agents are drawn ${size} now.`
555 }
556 case 'looks': {
557 const mode = words[1]?.toLowerCase()
558 if (mode !== 'random' && mode !== 'type') return 'Usage: /factory-view looks random|type'
559 await setLooks($, mode)
560 return mode === 'random'
561 ? 'Agents nobody dressed now each get a look by lot.'
562 : 'Agents nobody dressed now dress for their type.'
563 }
564 case 'clear':
565 await mutate($, list => clearFinished(list))
566 return 'Finished agents went home.'
567 case 'demo':
568 if (await read($, demoAtom)) {
569 await stopDemo($)
570 return 'The pretend agents went home.'
571 }
572 await startDemo($)
573 await $.ui.open({ id: PANE, title: TITLE })
574 return 'Six pretend agents joined the floor; /factory-view demo sends them home.'
575 case 'options':
576 return [
577 `Colors: ${COLOR_NAMES.join(', ')}`,
578 `Hats: ${HAT_NAMES.join(', ')}`,
579 `Extras: ${ACCESSORY_NAMES.join(', ')}`,
580 ].join('\n')
581 default:
582 return HELP
583 }
584}
585
586function isStyles(value: unknown): value is Record<string, AgentStyle> {
587 if (typeof value !== 'object' || value === null) return false
588
589 return Object.values(value).every(
590 style =>
591 typeof style === 'object' &&
592 style !== null &&
593 (COLOR_NAMES as string[]).includes((style as AgentStyle).color) &&
594 (HAT_NAMES as string[]).includes((style as AgentStyle).hat) &&
595 (ACCESSORY_NAMES as string[]).includes((style as AgentStyle).accessory),
596 )
597}
598
599/** Shows an agent at work with a tool, and back to thinking once the tool is done. */
600async function watchTool<E extends ToolCallInput, R>($: Engine, e: E, next: (e: E) => Promise<R>): Promise<R> {
601 const id = e.agentId ?? MAIN_ID
602 const toolId = e.tool_use_id ?? `${e.tool}-${Math.random()}`
603 let isSeated = false
604 await quietly($, 'tool start', async () => {
605 isSeated = await ensureAgent($, id)
606 if (!isSeated) return
607 const detail = toolDetail(e.tool, e as unknown as Record<string, unknown>)
608 await mutate($, (agents, now) => patchAgent(agents, id, a => startTool(a, e.tool, toolId, detail, now)))
609 })
610 const ran = await next(e)
611 if (isSeated) {
612 await quietly($, 'tool end', () => mutate($, agents => patchAgent(agents, id, a => endTool(a, toolId))))
613 }
614
615 return ran
616}
617
618export const register: Register = on => {
619 on('session.start', async ($, e, next) => {
620 $.clock.every(TICK_MS, () => void tick($).catch(() => undefined))
621 $.clock.every(RECONCILE_MS, () => void quietly($, 'reconcile', () => reconcile($)))
622 await quietly($, 'styles', async () => {
623 const stored = await $.store.get('styles')
624 if (isStyles(stored)) await update($, stylesAtom, current => ({ ...stored, ...current }))
625 const looks = await $.store.get('looks')
626 if (looks === 'random' || looks === 'type') await update($, looksAtom, () => looks)
627 const storedSize = await $.store.get('size')
628 const size = SIZES.find(one => one === storedSize)
629 if (size !== undefined) await update($, sizeAtom, () => size)
630 await mutate($, agents => agents)
631 })
632 await quietly($, 'command', () =>
633 $.command.register({
634 name: 'factory-view',
635 description: 'Factory View: watch your agents work at their desks, and dress them up',
636 argumentHint: '[style <who> color=… hat=… extra=… | reset | clear | demo | options]',
637 }),
638 )
639 await quietly($, 'open', async () => {
640 if ((await $.store.get('isClosed')) !== true) void $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
641 })
642
643 return next(e)
644 })
645
646 on('session.end', async ($, e, next) => {
647 if (e.reason === 'clear') {
648 await quietly($, 'clear', async () => {
649 const now = await $.clock.now()
650 await mutate($, () => [mainAgent(now)])
651 await update($, demoAtom, () => false)
652 await update($, stylesAtom, lastingStyles)
653 })
654 }
655
656 return next(e)
657 })
658
659 on('turn.start', async ($, e, next) => {
660 const id = e.agentId ?? MAIN_ID
661 await quietly($, 'turn start', async () => {
662 if (await ensureAgent($, id)) await mutate($, (agents, now) => patchAgent(agents, id, a => setStatus(a, 'working', now)))
663 })
664
665 return next(e)
666 })
667
668 on('turn.complete', async ($, e, next) => {
669 const id = e.agentId ?? MAIN_ID
670 await quietly($, 'turn end', () =>
671 mutate($, (agents, now) =>
672 patchAgent(agents, id, a => {
673 const isError = e.reason === 'error' || e.reason === 'refusal'
674 if (isError) return setStatus(a, 'failed', now)
675 if (a.isMain || a.isTeammate) return setStatus(a, 'waiting', now)
676 if (e.reason === 'aborted') return { ...setStatus(a, 'sleeping', now), outcome: null }
677
678 return setStatus(a, 'done', now)
679 }),
680 ),
681 )
682
683 return next(e)
684 })
685
686 // Two hooks, so a subagent's own tool calls, made while the Agent call that
687 // started it is still running, do not land in the hook that is waiting on it.
688 on('tool.call', { tool: 'Agent' }, ($, e, next) => watchTool($, e, next))
689 on('tool.call', { tool: /^(?!Agent$)/ }, ($, e, next) => watchTool($, e, next))
690
691 on('ui.close', { id: PANE }, async ($, e, next) => {
692 if (e.origin.kind === 'person') await quietly($, 'close', () => $.store.set('isClosed', true))
693 mounted = null
694
695 return next(e)
696 })
697
698 on('command.run', { command: 'factory-view' }, async ($, e) => ({ text: await runCommand($, e.args) }))
699
700 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
701 const agents = withMain(await read($, agentsAtom), 0)
702 const wardrobe = await wardrobeOf($)
703 const picked = await read($, selectedAtom)
704 const scope = await read($, scopeAtom)
705 const size = await read($, sizeAtom)
706 const selected = agents.find(a => a.id === picked)
707 selectedId = selected?.id ?? null
708 const scene = sceneOf(agents, wardrobe)
709 const seats = seatsNeeded(scene)
710 const width = e.props.bodyColumns
711
712 if (e.surface === 'terminal') {
713 const ui = $.ui.resolve(e)
714 const { Box, Raster } = ui
715 const isFlat = e.props.placement === 'inline'
716 const maxCols = colsFor(width, 'snug', size)
717 const lay = layout(seats, maxCols, { isFlat, spacing: 'snug', art: size })
718 const picture = rasterCells(drawScene(lay, scene, frame, selected?.seat), sceneLabels(lay, scene, labelChars(lay)), lay.isQuad)
719 mounted = { columns: picture.columns, rows: picture.rows, maxCols, isFlat, size }
720
721 return (
722 <Box flexDirection="column">
723 <Raster key={RASTER} columns={picture.columns} rows={picture.rows} cells={picture.cells} />
724 {controls($, ui, agents, wardrobe, selected, scope, size, width)}
725 </Box>
726 )
727 }
728
729 // The remote surfaces draw an SVG that plays its own frames.
730 const ui = $.ui.resolve(e)
731 const { Box, Svg } = ui
732 // The mobile app draws no lists yet: its pickers are buttons.
733 const table: Table = e.surface === 'mobile' ? { Box, Text: ui.Text, Button: ui.Button } : ui
734 const lay = layout(seats, REMOTE_COLS, { spacing: 'roomy' })
735 const frames = Array.from({ length: FRAMES }, (_, f) => drawScene(lay, scene, f, selected?.seat))
736 const source = sceneSvg(frames, sceneLabels(lay, scene, REMOTE_LABEL_CHARS), sceneHotspots(lay, scene, describeAgent), {
737 scale: REMOTE_SCALE[size],
738 secondsPerFrame: TICK_MS / 1000,
739 })
740
741 return (
742 <Box flexDirection="column" rowGap={1}>
743 <Svg source={source} alt={altText(agents)} isInteractive />
744 {controls($, table, agents, wardrobe, selected, scope, size, width)}
745 </Box>
746 )
747 })
748}
749hooks/model.ts 329 lines1import type { AccessoryName, AgentStatus, AgentStyle, ColorName, FactoryAgent, HatName, LooksMode } from '../types'
2import { ACCESSORY_NAMES, COLOR_NAMES, HAT_NAMES } from './sprites'
3
4export const MAIN_ID = 'main'
5/** Desks beyond the manager's before finished agents are sent home. */
6export const MAX_DESKS = 24
7/** How long the manager waits for the person before dozing off. */
8export const MAIN_DOZE_MS = 90_000
9/** How long a finished agent cheers before it dozes off. */
10export const DONE_DOZE_MS = 20_000
11/** How long a teammate waits for a message before dozing off. */
12export const IDLE_DOZE_MS = 60_000
13
14export function mainAgent(now: number): FactoryAgent {
15 return {
16 id: MAIN_ID,
17 isMain: true,
18 type: 'main',
19 label: 'Claude',
20 description: 'The main conversation: hands out the work.',
21 status: 'waiting',
22 outcome: null,
23 tool: null,
24 detail: null,
25 toolId: null,
26 since: now,
27 seat: 0,
28 toolCount: 0,
29 isTeammate: false,
30 isDemo: false,
31 }
32}
33
34export function withMain(agents: readonly FactoryAgent[], now: number): FactoryAgent[] {
35 return agents.some(a => a.id === MAIN_ID) ? [...agents] : [mainAgent(now), ...agents]
36}
37
38const isFinished = (a: FactoryAgent) => !a.isMain && (a.status === 'done' || a.status === 'failed' || a.status === 'sleeping')
39
40/** The lowest desk nobody sits at. */
41export function freeSeat(agents: readonly FactoryAgent[]): number {
42 const taken = new Set(agents.map(a => a.seat))
43 let seat = 1
44 while (taken.has(seat)) seat++
45
46 return seat
47}
48
49export type NewAgent = Pick<FactoryAgent, 'id' | 'type' | 'label' | 'description'> &
50 Partial<Pick<FactoryAgent, 'status' | 'isDemo' | 'isTeammate'>>
51
52/**
53 * Seats a new agent at the nearest free desk; when the floor is full, the
54 * agent that finished longest ago goes home to make room.
55 */
56export function addAgent(agents: readonly FactoryAgent[], init: NewAgent, now: number): FactoryAgent[] {
57 if (agents.some(a => a.id === init.id)) return [...agents]
58 let list = [...agents]
59 if (freeSeat(list) > MAX_DESKS) {
60 const oldest = list.filter(isFinished).sort((a, b) => a.since - b.since)[0]
61 if (oldest !== undefined) list = list.filter(a => a.id !== oldest.id)
62 }
63 const agent: FactoryAgent = {
64 ...mainAgent(now),
65 id: init.id,
66 isMain: false,
67 type: init.type,
68 label: init.label,
69 description: init.description,
70 status: init.status ?? 'working',
71 seat: freeSeat(list),
72 isTeammate: init.isTeammate ?? false,
73 isDemo: init.isDemo ?? false,
74 }
75
76 return [...list, agent]
77}
78
79export function patchAgent(
80 agents: readonly FactoryAgent[],
81 id: string,
82 change: (agent: FactoryAgent) => FactoryAgent,
83): FactoryAgent[] {
84 return agents.map(a => (a.id === id ? change(a) : a))
85}
86
87export function setStatus(agent: FactoryAgent, status: AgentStatus, now: number): FactoryAgent {
88 if (agent.status === status) return agent
89 const outcome = status === 'done' ? 'ok' : status === 'failed' ? 'failed' : status === 'working' ? null : agent.outcome
90
91 return { ...agent, status, outcome, since: now, ...(status === 'working' ? {} : { tool: null, detail: null, toolId: null }) }
92}
93
94export function startTool(agent: FactoryAgent, tool: string, toolId: string, detail: string | null, now: number): FactoryAgent {
95 return { ...setStatus(agent, 'working', now), tool, toolId, detail, toolCount: agent.toolCount + 1 }
96}
97
98/** A tool that ended stays on show as the agent's last one, until the next starts or the agent stops working. */
99export function endTool(agent: FactoryAgent, toolId: string): FactoryAgent {
100 return agent.toolId === toolId ? { ...agent, toolId: null } : agent
101}
102
103/** Moves on whoever has waited, cheered or idled long enough; null when nobody moved. */
104export function settle(agents: readonly FactoryAgent[], now: number): FactoryAgent[] | null {
105 let isChanged = false
106 const next = agents.map(a => {
107 const waited = now - a.since
108 const dozes =
109 (a.isMain && a.status === 'waiting' && waited > MAIN_DOZE_MS) ||
110 (!a.isMain && a.status === 'done' && waited > DONE_DOZE_MS) ||
111 (!a.isMain && a.status === 'waiting' && waited > IDLE_DOZE_MS)
112 if (!dozes) return a
113 isChanged = true
114
115 return { ...setStatus(a, 'sleeping', now), outcome: a.outcome }
116 })
117
118 return isChanged ? next : null
119}
120
121export function clearFinished(agents: readonly FactoryAgent[]): FactoryAgent[] {
122 return agents.filter(a => !isFinished(a))
123}
124
125/**
126 * Whom a style choice is for: this one agent, or every agent of its type (the
127 * manager is one of a kind either way).
128 */
129export type StyleScope = 'agent' | 'type'
130
131/** Where a style is kept: `agent:<id>` for one agent, `type:<type>` for a type, `main` for the manager. */
132export function styleKey(agent: Pick<FactoryAgent, 'id' | 'isMain' | 'type'>, scope: StyleScope): string {
133 if (agent.isMain) return 'main'
134
135 return scope === 'agent' ? `agent:${agent.id}` : `type:${agent.type}`
136}
137
138/** Choices worth keeping past the session: an agent's own id is not seen again. */
139export function lastingStyles(styles: Record<string, AgentStyle>): Record<string, AgentStyle> {
140 return Object.fromEntries(Object.entries(styles).filter(([key]) => !key.startsWith('agent:')))
141}
142
143const MANAGER: AgentStyle = { color: 'clay', hat: 'crown', accessory: 'tie' }
144
145const KNOWN: Record<string, AgentStyle> = {
146 Explore: { color: 'sky', hat: 'explorer', accessory: 'none' },
147 'general-purpose': { color: 'mint', hat: 'hardhat', accessory: 'none' },
148 Plan: { color: 'grape', hat: 'wizard', accessory: 'glasses' },
149 'claude-code-guide': { color: 'teal', hat: 'gradcap', accessory: 'glasses' },
150 'statusline-setup': { color: 'rose', hat: 'propeller', accessory: 'blush' },
151 fork: { color: 'clay', hat: 'cap', accessory: 'none' },
152}
153
154function hash(text: string): number {
155 let h = 2166136261
156 for (let i = 0; i < text.length; i++) {
157 h ^= text.charCodeAt(i)
158 h = Math.imul(h, 16777619) >>> 0
159 }
160
161 return h
162}
163
164/** The manager's color and crown stay the manager's: nobody else draws them by chance. */
165const CHANCE_COLORS = COLOR_NAMES.filter(c => c !== 'clay')
166const CHANCE_HATS = HAT_NAMES.filter(name => name !== 'crown')
167
168/** A look drawn by lot from `seed`: the same seed, the same look. */
169export function randomStyle(seed: string | number): AgentStyle {
170 const h = hash(String(seed))
171 const extras = ACCESSORY_NAMES.filter(name => name !== 'none')
172
173 return {
174 color: CHANCE_COLORS[h % CHANCE_COLORS.length]!,
175 hat: CHANCE_HATS[(h >>> 8) % CHANCE_HATS.length]!,
176 accessory: (h >>> 16) % 2 === 0 ? 'none' : extras[(h >>> 20) % extras.length]!,
177 }
178}
179
180/** A look of its own for every agent type, the same every session. */
181export function typeStyle(type: string): AgentStyle {
182 return KNOWN[type] ?? randomStyle(`type:${type}`)
183}
184
185/**
186 * How an agent looks before anyone picks: the manager as the manager; the
187 * others by lot, each its own (`random`), or dressed for their type (`type`).
188 */
189export function defaultStyle(agent: Pick<FactoryAgent, 'id' | 'isMain' | 'type'>, looks: LooksMode = 'random'): AgentStyle {
190 if (agent.isMain) return MANAGER
191
192 return looks === 'random' ? randomStyle(`agent:${agent.id}`) : typeStyle(agent.type)
193}
194
195/** The look chosen for this agent, else for its type, else its default. */
196export function styleOf(
197 agent: Pick<FactoryAgent, 'id' | 'isMain' | 'type'>,
198 styles: Record<string, AgentStyle>,
199 looks: LooksMode = 'random',
200): AgentStyle {
201 return styles[styleKey(agent, 'agent')] ?? styles[styleKey(agent, 'type')] ?? defaultStyle(agent, looks)
202}
203
204/** What a scope shows before a choice is made there: the type's look for a type, the agent's own for an agent. */
205export function baseStyle(
206 agent: Pick<FactoryAgent, 'id' | 'isMain' | 'type'>,
207 scope: StyleScope,
208 styles: Record<string, AgentStyle>,
209 looks: LooksMode,
210): AgentStyle {
211 return scope === 'agent' ? styleOf(agent, styles, looks) : (styles[styleKey(agent, 'type')] ?? defaultStyle(agent, looks))
212}
213
214export function cycle<T>(list: readonly T[], current: T, step = 1): T {
215 const at = list.indexOf(current)
216
217 return list[(at + step + list.length) % list.length]!
218}
219
220export type StyleField = 'color' | 'hat' | 'accessory'
221
222export const STYLE_CHOICES: { color: ColorName[]; hat: HatName[]; accessory: AccessoryName[] } = {
223 color: COLOR_NAMES,
224 hat: HAT_NAMES,
225 accessory: ACCESSORY_NAMES,
226}
227
228/**
229 * Reads `color=sky hat=crown extra=tie` (any order, `accessory` or `extra`)
230 * into a style change, or the first word it does not know.
231 */
232export function parseStyle(words: readonly string[]): { change: Partial<AgentStyle> } | { error: string } {
233 const change: Partial<AgentStyle> = {}
234 for (const word of words) {
235 const [rawKey, rawValue] = word.split('=')
236 const key = rawKey?.toLowerCase()
237 const value = rawValue?.toLowerCase() ?? ''
238 if (key === 'color' && (COLOR_NAMES as string[]).includes(value)) change.color = value as ColorName
239 else if (key === 'hat' && (HAT_NAMES as string[]).includes(value)) change.hat = value as HatName
240 else if ((key === 'extra' || key === 'accessory') && (ACCESSORY_NAMES as string[]).includes(value))
241 change.accessory = value as AccessoryName
242 else return { error: word }
243 }
244
245 return { change }
246}
247
248/** A few words on what a tool call works on, read off its input. */
249export function toolDetail(tool: string, input: Record<string, unknown>): string | null {
250 const text = (key: string) => (typeof input[key] === 'string' ? (input[key] as string) : undefined)
251 const base = (path: string | undefined) => path?.split(/[\\/]/).filter(Boolean).pop()
252 const detail =
253 text('description') ??
254 text('command') ??
255 base(text('file_path')) ??
256 base(text('notebook_path')) ??
257 text('pattern') ??
258 text('query') ??
259 hostOf(text('url')) ??
260 text('skill') ??
261 text('subject')
262 if (detail === undefined || detail.trim() === '') return tool === 'Agent' ? 'delegating' : null
263
264 return detail.replace(/\s+/g, ' ').trim().slice(0, 60)
265}
266
267function hostOf(url: string | undefined): string | undefined {
268 if (url === undefined) return undefined
269 try {
270 return new URL(url).host
271 } catch {
272 return url
273 }
274}
275
276/** The agent types Claude Code ships with: they say little about one agent, so its task names it instead. */
277const BUILT_IN_TYPES = new Set([
278 'general-purpose',
279 'Explore',
280 'Plan',
281 'fork',
282 'teammate',
283 'statusline-setup',
284 'claude-code-guide',
285 'output-style-setup',
286])
287
288/**
289 * The name on a subagent's desk: its call name, else its type when it is one
290 * of your own agents (a plugin's without the plugin), else the first words of
291 * its task that say something, else its type.
292 */
293export function labelFor(name: string | undefined, description: string, type: string): string {
294 if (name !== undefined && name !== '') return name
295 const own = type.slice(type.lastIndexOf(':') + 1)
296 if (own !== '' && !BUILT_IN_TYPES.has(type)) return own
297 const words = description
298 .trim()
299 .split(/\s+/)
300 .filter(word => word !== '' && !/^(the|a|an|to|of|for|and|in|on|at|with)$/i.test(word))
301 .slice(0, 2)
302 .join(' ')
303
304 return words !== '' ? words : type
305}
306
307const STATUS_WORDS: Record<AgentStatus, string> = {
308 working: 'working',
309 waiting: 'waiting',
310 sleeping: 'sleeping',
311 done: 'done',
312 failed: 'failed',
313}
314
315/** One line on what an agent is doing, for the legend and the hover text. */
316export function describeAgent(agent: FactoryAgent): string {
317 const role = agent.isMain ? 'manager' : agent.type
318 const doing =
319 agent.status === 'working' && agent.tool !== null
320 ? `${agent.tool}${agent.detail !== null ? `: ${agent.detail}` : ''}`
321 : agent.status === 'working'
322 ? 'thinking'
323 : agent.status === 'sleeping' && agent.outcome === 'ok'
324 ? 'done, dozing'
325 : STATUS_WORDS[agent.status]
326
327 return `${agent.label} (${role}) · ${doing}`
328}
329hooks/paint.ts 246 lines1import { hex } from './sprites'
2import type { Canvas, Hotspot, Label } from './scene'
3
4const UPPER_HALF = 0x2580
5const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
6
7export function base64(bytes: Uint8Array): string {
8 const out: string[] = []
9 let i = 0
10 for (; i + 2 < bytes.length; i += 3) {
11 const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
12 out.push(B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!)
13 }
14 const rest = bytes.length - i
15 if (rest === 1) {
16 const n = bytes[i]! << 16
17 out.push(`${B64[(n >> 18) & 63]!}${B64[(n >> 12) & 63]!}==`)
18 } else if (rest === 2) {
19 const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
20 out.push(`${B64[(n >> 18) & 63]!}${B64[(n >> 12) & 63]!}${B64[(n >> 6) & 63]!}=`)
21 }
22
23 return out.join('')
24}
25
26/** A label's text centered in its width, one glyph per column, `perColumn` pixels to a column. */
27function labelCells(labels: readonly Label[], columns: number, perColumn: number): Map<number, { ch: number; color: number }> {
28 const cells = new Map<number, { ch: number; color: number }>()
29 for (const label of labels) {
30 const chars = Array.from(label.text)
31 const left = Math.floor(label.x / perColumn) + Math.floor((label.width / perColumn - chars.length) / 2)
32 const row = label.y >> 1
33 chars.forEach((ch, i) => {
34 const code = ch.codePointAt(0) ?? 32
35 const isPrintable = code >= 32 && code <= 0xffff && code !== 0x7f
36 cells.set(row * columns + left + i, { ch: isPrintable ? code : 0x3f, color: label.color })
37 })
38 }
39
40 return cells
41}
42
43/** The quarter blocks by which of a cell's four pixels are foreground: 1 top left, 2 top right, 4 bottom left, 8 bottom right. */
44const QUADRANTS = [
45 0x20, 0x2598, 0x259d, 0x2580, 0x2596, 0x258c, 0x259e, 0x259b, 0x2597, 0x259a, 0x2590, 0x259c, 0x2584, 0x2599, 0x259f, 0x2588,
46]
47
48function distance(a: number, b: number): number {
49 const dr = ((a >> 16) & 0xff) - ((b >> 16) & 0xff)
50 const dg = ((a >> 8) & 0xff) - ((b >> 8) & 0xff)
51 const db = (a & 0xff) - (b & 0xff)
52
53 return dr * dr + dg * dg + db * db
54}
55
56/**
57 * One cell of 2x2 pixels as a quarter block: the two colors most of its
58 * pixels have, every pixel drawn in the nearer of them (a cell paints two).
59 */
60export function quadrantCell(pixels: readonly [number, number, number, number]): { ch: number; fg: number; bg: number } {
61 const counts = new Map<number, number>()
62 for (const p of pixels) counts.set(p, (counts.get(p) ?? 0) + 1)
63 const ranked = [...counts].sort((a, b) => b[1] - a[1])
64 const bg = ranked[0]![0]
65 const fg = ranked[1]?.[0] ?? bg
66 let mask = 0
67 pixels.forEach((p, i) => {
68 if (p !== bg && distance(p, fg) < distance(p, bg)) mask |= 1 << i
69 })
70
71 return { ch: QUADRANTS[mask]!, fg, bg }
72}
73
74/**
75 * The terminal's picture. Half blocks by default: every cell an upper half
76 * block, its foreground the pixel above and its background the pixel below,
77 * so pixels come out square. Quarter blocks with `isQuad`: 2x2 pixels a cell.
78 */
79export function rasterCells(
80 canvas: Canvas,
81 labels: readonly Label[],
82 isQuad = false,
83): { columns: number; rows: number; cells: string } {
84 const perColumn = isQuad ? 2 : 1
85 const columns = Math.ceil(canvas.width / perColumn)
86 const rows = Math.ceil(canvas.height / 2)
87 const bytes = new Uint8Array(columns * rows * 12)
88 const view = new DataView(bytes.buffer)
89 const text = labelCells(labels, columns, perColumn)
90 const at = (x: number, y: number) => canvas.get(Math.min(x, canvas.width - 1), Math.min(y, canvas.height - 1))
91 for (let row = 0; row < rows; row++) {
92 for (let col = 0; col < columns; col++) {
93 const index = row * columns + col
94 const glyph = text.get(index)
95 const offset = index * 12
96 let cell: { ch: number; fg: number; bg: number }
97 if (isQuad) {
98 const x = col * 2
99 const y = row * 2
100 cell = quadrantCell([at(x, y), at(x + 1, y), at(x, y + 1), at(x + 1, y + 1)])
101 } else {
102 const top = at(col, row * 2)
103 cell = { ch: UPPER_HALF, fg: top, bg: at(col, row * 2 + 1) }
104 }
105 if (glyph !== undefined) {
106 // A name tag sits on the floor: the color most of the cell has.
107 const floor = isQuad ? cell.bg : cell.fg
108 cell = { ch: glyph.ch, fg: glyph.color, bg: floor }
109 }
110 view.setUint32(offset, cell.ch, true)
111 view.setUint32(offset + 4, cell.fg, true)
112 view.setUint32(offset + 8, cell.bg, true)
113 }
114 }
115
116 return { columns, rows, cells: base64(bytes) }
117}
118
119/** Rectangles covering every pixel `colorAt` gives, runs merged down the rows, as one path per color. */
120function paths(width: number, height: number, colorAt: (x: number, y: number) => number | undefined): string {
121 type Run = { x: number; y: number; w: number; h: number; color: number }
122 const done: Run[] = []
123 let open = new Map<string, Run>()
124 for (let y = 0; y < height; y++) {
125 const next = new Map<string, Run>()
126 let x = 0
127 while (x < width) {
128 const color = colorAt(x, y)
129 if (color === undefined) {
130 x++
131 continue
132 }
133 let w = 1
134 while (x + w < width && colorAt(x + w, y) === color) w++
135 const key = `${x},${w},${color}`
136 const run = open.get(key)
137 if (run !== undefined) {
138 run.h++
139 open.delete(key)
140 next.set(key, run)
141 } else {
142 next.set(key, { x, y, w, h: 1, color })
143 }
144 x += w
145 }
146 for (const run of open.values()) done.push(run)
147 open = next
148 }
149 for (const run of open.values()) done.push(run)
150
151 const byColor = new Map<number, string[]>()
152 for (const run of done) {
153 const list = byColor.get(run.color) ?? []
154 list.push(`M${run.x} ${run.y}h${run.w}v${run.h}h-${run.w}z`)
155 byColor.set(run.color, list)
156 }
157
158 return [...byColor].map(([color, d]) => `<path fill="${hex(color)}" d="${d.join('')}"/>`).join('')
159}
160
161function escapeXml(text: string): string {
162 return text.replace(/[<>&"']/g, ch => `&#${ch.charCodeAt(0)};`)
163}
164
165/** The color most pixels have, painted once as the backdrop. */
166function commonest(canvas: Canvas): number {
167 const counts = new Map<number, number>()
168 let best = canvas.px[0] ?? 0
169 let bestCount = 0
170 for (const color of canvas.px) {
171 const n = (counts.get(color) ?? 0) + 1
172 counts.set(color, n)
173 if (n > bestCount) {
174 best = color
175 bestCount = n
176 }
177 }
178
179 return best
180}
181
182export const SVG_LIMIT = 131072
183
184/**
185 * The remote surfaces' picture: the first frame whole, and for each later
186 * frame only the pixels that change, shown in turn by SMIL so it plays with
187 * no redraw. Falls back to fewer frames, then one, past the size limit.
188 */
189export function sceneSvg(
190 frames: readonly Canvas[],
191 labels: readonly Label[],
192 hotspots: readonly Hotspot[],
193 options: { scale: number; secondsPerFrame: number },
194): string {
195 const first = frames[0]!
196 const { width, height } = first
197 const backdrop = commonest(first)
198 const base = paths(width, height, (x, y) => {
199 const color = first.get(x, y)
200 return color === backdrop ? undefined : color
201 })
202 const texts = labels
203 .map(label => {
204 const x = label.x + label.width / 2
205 return `<text x="${x}" y="${label.y + 1.6}" fill="${hex(label.color)}">${escapeXml(label.text)}</text>`
206 })
207 .join('')
208 const spots = hotspots
209 .map(
210 spot =>
211 `<rect x="${spot.x}" y="${spot.y}" width="${spot.width}" height="${spot.height}" fill="#000" fill-opacity="0" pointer-events="all"><title>${escapeXml(spot.title)}</title></rect>`,
212 )
213 .join('')
214 const head =
215 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width * options.scale}" height="${height * options.scale}">` +
216 `<rect width="${width}" height="${height}" fill="${hex(backdrop)}"/>` +
217 `<g shape-rendering="crispEdges">${base}</g>`
218 const tail =
219 `<g font-family="ui-monospace,SFMono-Regular,Menlo,Consolas,monospace" font-size="3.4" font-weight="700" text-anchor="middle" dominant-baseline="middle">${texts}</g>` +
220 `${spots}</svg>`
221
222 const build = (step: number) => {
223 const shown = frames.filter((_, i) => i % step === 0)
224 if (shown.length < 2) return head + tail
225 const dur = (frames.length * options.secondsPerFrame).toFixed(2)
226 const overlays = shown.slice(1).map((frame, k) => {
227 const diff = paths(width, height, (x, y) => {
228 const color = frame.get(x, y)
229 return color === first.get(x, y) ? undefined : color
230 })
231 if (diff === '') return ''
232 const values = shown.map((_, i) => (i === k + 1 ? 'visible' : 'hidden')).join(';')
233 return `<g visibility="hidden" shape-rendering="crispEdges"><animate attributeName="visibility" values="${values}" dur="${dur}s" calcMode="discrete" repeatCount="indefinite"/>${diff}</g>`
234 })
235
236 return head + overlays.join('') + tail
237 }
238
239 for (const step of [1, 2, 4, frames.length]) {
240 const svg = build(step)
241 if (svg.length <= SVG_LIMIT) return svg
242 }
243
244 return head + tail
245}
246hooks/scene.ts 989 lines1import type { AccessoryName, AgentStyle, FactoryAgent, HatName } from '../types'
2import {
3 ACCESSORIES,
4 BANG,
5 BIG_Z,
6 CHECK,
7 COLORS,
8 HATS,
9 ICONS,
10 INK,
11 SMALL_Z,
12 shade,
13 type Hat,
14 type IconName,
15 type Sprite,
16} from './sprites'
17import { MINI_ACCESSORIES, MINI_BANG, MINI_BIG_Z, MINI_CHECK, MINI_HATS, MINI_ICONS, MINI_SMALL_Z } from './minis'
18import { CHIPS, MEDIUM_ACCESSORIES, MEDIUM_HATS, SMALL_ACCESSORIES, SMALL_HATS } from './tinies'
19
20/** A grid of opaque 0xRRGGBB pixels. */
21export class Canvas {
22 readonly px: Uint32Array
23
24 constructor(
25 readonly width: number,
26 readonly height: number,
27 fill = 0,
28 ) {
29 this.px = new Uint32Array(width * height).fill(fill)
30 }
31
32 get(x: number, y: number): number {
33 return this.px[y * this.width + x] ?? 0
34 }
35
36 set(x: number, y: number, color: number): void {
37 if (x >= 0 && y >= 0 && x < this.width && y < this.height) {
38 this.px[y * this.width + x] = color
39 }
40 }
41
42 rect(x: number, y: number, w: number, h: number, color: number): void {
43 for (let dy = 0; dy < h; dy++) {
44 for (let dx = 0; dx < w; dx++) {
45 this.set(x + dx, y + dy, color)
46 }
47 }
48 }
49
50 /** Draws a sprite, each letter through `paint`; `.` and unknown letters are see-through. */
51 sprite(x: number, y: number, rows: Sprite, paint: (ch: string) => number | undefined = ch => INK[ch]): void {
52 rows.forEach((row, dy) => {
53 for (let dx = 0; dx < row.length; dx++) {
54 const ch = row[dx]!
55 const color = ch === '.' ? undefined : paint(ch)
56 if (color !== undefined) {
57 this.set(x + dx, y + dy, color)
58 }
59 }
60 })
61 }
62}
63
64/** Text laid over the picture: one glyph per pixel column, on an even pixel row. */
65export type Label = { x: number; y: number; width: number; text: string; color: number }
66
67/** A desk's rectangle, for the hover text the remote surfaces show. */
68export type Hotspot = { x: number; y: number; width: number; height: number; title: string }
69
70export type SceneAgent = { agent: FactoryAgent; style: AgentStyle }
71
72/**
73 * Which pictures: `full` for the scaled SVG; `large`, `medium` and `small`
74 * for the terminal, where a pixel is a half block (`large`) or a quarter
75 * block (`medium`, `small`: half a cell wide, so the pictures carry twice
76 * the detail across).
77 */
78export type Art = 'full' | 'large' | 'medium' | 'small'
79
80/** Each desk's box and the wall above them all, in pixels, per set of pictures. */
81const DESKS: Record<Art, { w: number; h: number; wall: number; isQuad: boolean }> = {
82 full: { w: 22, h: 22, wall: 10, isQuad: false },
83 large: { w: 16, h: 16, wall: 6, isQuad: false },
84 medium: { w: 24, h: 12, wall: 4, isQuad: true },
85 small: { w: 16, h: 10, wall: 4, isQuad: true },
86}
87const FLAT_WALL_H = 2
88
89/**
90 * Floor between the desks: snug on the terminal, where a pixel is a cell,
91 * roomy where the picture is scaled. Vertical gaps stay even, so the name
92 * tags keep to whole terminal rows.
93 */
94export type Spacing = 'snug' | 'roomy'
95
96function spacingOf(spacing: Spacing, art: Art): { gapX: number; gapY: number; margin: number; bottom: number } {
97 if (spacing === 'roomy') return { gapX: 10, gapY: 6, margin: 8, bottom: 6 }
98 if (DESKS[art].isQuad) return { gapX: 12, gapY: 6, margin: 6, bottom: 2 }
99
100 return art === 'large' ? { gapX: 6, gapY: 6, margin: 3, bottom: 2 } : { gapX: 4, gapY: 2, margin: 3, bottom: 2 }
101}
102/** Frames in one loop of every animation; the SVG plays them at 4 a second. */
103export const FRAMES = 8
104
105export type Layout = {
106 cols: number
107 rows: number
108 /** Pixel rows of wall above the desks. */
109 wall: number
110 spacing: Spacing
111 art: Art
112 /** Quarter-block pixels, two to a cell across: see Art. */
113 isQuad: boolean
114 /** Each desk's box, in pixels. */
115 deskW: number
116 deskH: number
117 /** Cell of each seat: seat 0, the manager's, sits in the middle. */
118 cells: { col: number; row: number }[]
119 width: number
120 height: number
121}
122
123/** How wide `cols` desks across are, in pixels. */
124export function sceneWidth(cols: number, spacing: Spacing = 'snug', art: Art = 'full'): number {
125 const { gapX, margin } = spacingOf(spacing, art)
126
127 return margin * 2 + cols * DESKS[art].w + (cols - 1) * gapX
128}
129
130/** How many desks fit across `columns` terminal columns. */
131export function colsFor(columns: number, spacing: Spacing = 'snug', art: Art = 'full'): number {
132 const { gapX, margin } = spacingOf(spacing, art)
133 const width = DESKS[art].isQuad ? columns * 2 : columns
134
135 return Math.max(1, Math.floor((width - margin * 2 + gapX) / (DESKS[art].w + gapX)))
136}
137
138/**
139 * The desks of `seatCount` seats: the manager's in the middle of an odd grid
140 * and the others ringed around it, nearest first; on a narrow floor, a column.
141 * `isFlat` spends width before height and lowers the wall, for a pane above
142 * the prompt.
143 */
144export function layout(
145 seatCount: number,
146 maxCols: number,
147 { isFlat = false, spacing = 'snug', art = 'full' }: { isFlat?: boolean; spacing?: Spacing; art?: Art } = {},
148): Layout {
149 let cols: number
150 let rows: number
151 let cells: { col: number; row: number }[]
152
153 if (maxCols < 3) {
154 cols = Math.max(1, maxCols)
155 rows = Math.max(1, Math.ceil(seatCount / cols))
156 cells = []
157 for (let i = 0; i < cols * rows; i++) {
158 cells.push({ col: i % cols, row: Math.floor(i / cols) })
159 }
160 } else {
161 const seats = Math.max(3, seatCount)
162 cols = 3
163 rows = 1
164 for (let r = 1; r < 64; r += 2) {
165 let c = Math.max(3, Math.ceil(seats / r))
166 if (c % 2 === 0) c++
167 if (c <= maxCols && (isFlat || c <= r + 2)) {
168 cols = c
169 rows = r
170 break
171 }
172 }
173 const cx = (cols - 1) / 2
174 const cy = (rows - 1) / 2
175 cells = []
176 for (let row = 0; row < rows; row++) {
177 for (let col = 0; col < cols; col++) {
178 cells.push({ col, row })
179 }
180 }
181 const rank = (cell: { col: number; row: number }) => {
182 const dx = cell.col - cx
183 const dy = cell.row - cy
184 return [Math.max(Math.abs(dx), Math.abs(dy)), Math.abs(dy), Math.abs(dx), dy, dx]
185 }
186 cells.sort((a, b) => {
187 const ra = rank(a)
188 const rb = rank(b)
189 for (let i = 0; i < ra.length; i++) {
190 if (ra[i] !== rb[i]) return ra[i]! - rb[i]!
191 }
192 return 0
193 })
194 }
195
196 const desk = DESKS[art]
197 const wall = isFlat ? FLAT_WALL_H : desk.wall
198 const { gapY, bottom } = spacingOf(spacing, art)
199
200 return {
201 cols,
202 rows,
203 wall,
204 spacing,
205 art,
206 isQuad: desk.isQuad,
207 deskW: desk.w,
208 deskH: desk.h,
209 cells,
210 width: sceneWidth(cols, spacing, art),
211 height: wall + rows * desk.h + (rows - 1) * gapY + bottom,
212 }
213}
214
215export function stationOrigin(lay: Layout, seat: number): { x: number; y: number } | undefined {
216 const cell = lay.cells[seat]
217 if (cell === undefined) return undefined
218
219 const { gapX, gapY, margin } = spacingOf(lay.spacing, lay.art)
220
221 return { x: margin + cell.col * (lay.deskW + gapX), y: lay.wall + cell.row * (lay.deskH + gapY) }
222}
223
224/** A small number per agent, so desks side by side do not move in step. */
225export function phaseOf(id: string): number {
226 let h = 7
227 for (let i = 0; i < id.length; i++) {
228 h = (h * 31 + id.charCodeAt(i)) >>> 0
229 }
230
231 return h % FRAMES
232}
233
234export function iconFor(tool: string | null): IconName {
235 if (tool === null) return 'think'
236 if (/^(Read|Grep|Glob|LS|LSP|NotebookRead|ToolSearch)$/.test(tool)) return 'search'
237 if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(tool)) return 'edit'
238 if (/^(Bash|BashOutput|KillShell|KillBash|PowerShell|Monitor|TaskOutput|TaskStop)$/.test(tool)) return 'shell'
239 if (/^(WebFetch|WebSearch)$/.test(tool) || /browser|chrome|fetch/i.test(tool)) return 'web'
240 if (/^(Agent|Task|SendMessage|Skill|TeamCreate)$/.test(tool)) return 'delegate'
241 if (/^(TodoWrite|TaskCreate|TaskUpdate|TaskList|TaskGet|EnterPlanMode|ExitPlanMode)$/.test(tool)) return 'todo'
242 if (tool === 'AskUserQuestion') return 'ask'
243
244 return 'tool'
245}
246
247const FLOOR_A = 0x353c4b
248const FLOOR_B = 0x3a4253
249const WALL = 0x4b5468
250const WALL_DARK = 0x404859
251const BASEBOARD = 0x2b303b
252const RUG = 0x7a2e3a
253const RUG_EDGE = 0xd4a73c
254const CHAIR = 0x2f343f
255const CHAIR_LIGHT = 0x454c59
256const SCREEN_OFF = 0x14171d
257const MONITOR = 0x23272f
258
259function drawFloor(c: Canvas, wall: number): void {
260 for (let y = wall; y < c.height; y++) {
261 for (let x = 0; x < c.width; x++) {
262 const isA = (Math.floor(x / 6) + Math.floor((y - wall) / 6)) % 2 === 0
263 c.set(x, y, isA ? FLOOR_A : FLOOR_B)
264 }
265 }
266}
267
268function drawWall(c: Canvas, wall: number): void {
269 c.rect(0, 0, c.width, wall, WALL)
270 c.rect(0, 0, c.width, 1, WALL_DARK)
271 c.rect(0, wall - 2, c.width, 2, BASEBOARD)
272 if (wall >= DESKS.full.wall) {
273 const windows = Math.max(1, Math.floor((c.width - 8) / 26))
274 const step = c.width / windows
275 for (let i = 0; i < windows; i++) {
276 const x = Math.round(step * i + step / 2) - 5
277 c.rect(x, 2, 10, 5, 0x8892a6)
278 c.rect(x + 1, 3, 8, 3, 0x8ccbea)
279 c.set(x + 2, 3, 0xd8f0ff)
280 c.set(x + 3, 3, 0xd8f0ff)
281 c.set(x + 2, 4, 0xd8f0ff)
282 c.rect(x + 5, 3, 1, 3, 0x8892a6)
283 }
284 } else if (wall >= DESKS.large.wall) {
285 const windows = Math.max(1, Math.floor((c.width - 6) / 20))
286 const step = c.width / windows
287 for (let i = 0; i < windows; i++) {
288 const x = Math.round(step * i + step / 2) - 3
289 c.rect(x, 1, 6, 3, 0x8892a6)
290 c.rect(x + 1, 2, 4, 1, 0x8ccbea)
291 c.set(x + 1, 2, 0xd8f0ff)
292 }
293 } else if (wall >= DESKS.small.wall) {
294 // One row of glass, its x even, so each window keeps to whole quarter-block cells.
295 const windows = Math.max(1, Math.floor(c.width / 18))
296 const step = c.width / windows
297 for (let i = 0; i < windows; i++) {
298 const x = 2 * Math.round((step * i + step / 2 - 3) / 2)
299 c.rect(x, 1, 6, 1, 0x8ccbea)
300 }
301 }
302}
303
304function drawRug(c: Canvas, x: number, y: number, w: number, h: number): void {
305 const top = 2 * Math.round(h / 10)
306 c.rect(x - 2, y + top, w + 4, h - top, RUG_EDGE)
307 c.rect(x - 1, y + top + 1, w + 2, h - top - 2, RUG)
308}
309
310type Look = 'open' | 'left' | 'right' | 'closed' | 'happy' | 'dead'
311
312const EYES: Record<Look, [number, number][]> = {
313 open: [[3, 2], [3, 3], [8, 2], [8, 3]],
314 left: [[2, 2], [2, 3], [7, 2], [7, 3]],
315 right: [[4, 2], [4, 3], [9, 2], [9, 3]],
316 closed: [[2, 3], [3, 3], [8, 3], [9, 3]],
317 happy: [[2, 3], [3, 2], [4, 3], [7, 3], [8, 2], [9, 3]],
318 dead: [[2, 1], [4, 1], [3, 2], [2, 3], [4, 3], [7, 1], [9, 1], [8, 2], [7, 3], [9, 3]],
319}
320
321/**
322 * The Claude Code mascot, its top-left at (x, y): a body 10 wide and 7 tall,
323 * eyes on rows 2 and 3, an arm each side on row 4, four legs under it.
324 */
325function drawCharacter(
326 c: Canvas,
327 x: number,
328 y: number,
329 style: AgentStyle,
330 look: Look,
331 arms: [number, number],
332 t: number,
333): void {
334 const body = COLORS[style.color]
335 const dark = shade(body, 0.72)
336 c.rect(x + 1, y, 10, 6, body)
337 c.rect(x + 1, y + 6, 10, 1, dark)
338 for (const lx of [2, 4, 7, 9]) c.set(x + lx, y + 7, dark)
339 c.set(x, y + 4 + arms[0], body)
340 c.set(x + 11, y + 4 + arms[1], body)
341
342 // Glasses and the like frame the eyes; shades hide them.
343 const isOverEyes = style.accessory === 'shades'
344 if (style.accessory !== 'none' && !isOverEyes) c.sprite(x, y, ACCESSORIES[style.accessory])
345 for (const [ex, ey] of EYES[look]) c.set(x + ex, y + ey, INK.E!)
346 if (isOverEyes) c.sprite(x, y, ACCESSORIES.shades)
347 if (style.hat !== 'none') {
348 const hat = HATS[style.hat]
349 const frame = hat.frames[Math.floor(t / 2) % hat.frames.length]!
350 c.sprite(x, y - frame.length + hat.overlap, frame)
351 }
352}
353
354function drawChair(c: Canvas, x: number, y: number): void {
355 c.rect(x + 1, y + 6, 12, 8, CHAIR)
356 c.rect(x + 2, y + 6, 10, 1, CHAIR_LIGHT)
357}
358
359function drawDesk(c: Canvas, x: number, y: number, isManager: boolean): void {
360 const top = isManager ? 0x9c4a2c : 0xc68e5c
361 const edge = isManager ? RUG_EDGE : 0xdba673
362 const front = isManager ? 0x6b2c1a : 0x8f5d38
363 const foot = isManager ? 0x4e1f12 : 0x6e4529
364 const w = DESKS.full.w
365 c.rect(x, y + 14, w, 2, top)
366 c.rect(x, y + 14, w, 1, edge)
367 c.rect(x, y + 16, w, 3, front)
368 c.rect(x, y + 18, w, 1, foot)
369 c.rect(x + 1, y + 19, w - 2, 1, shade(FLOOR_A, 0.75))
370 if (isManager) {
371 c.rect(x + 8, y + 16, 6, 2, RUG_EDGE)
372 c.rect(x + 9, y + 16, 4, 1, 0xf6e7a8)
373 } else {
374 c.rect(x + 10, y + 17, 2, 1, INK.G!)
375 }
376}
377
378function drawKeyboard(c: Canvas, x: number, y: number): void {
379 c.rect(x + 3, y + 14, 8, 1, 0xe6e8ec)
380 c.rect(x + 3, y + 15, 8, 1, 0xa3a9b3)
381 c.set(x + 12, y + 15, 0xe6e8ec)
382}
383
384const CODE_ROWS: [number, number, number][] = [
385 [0, 4, 0x7fd4ff],
386 [1, 3, 0xffcb6b],
387 [1, 4, 0xc792ea],
388 [2, 2, 0x89dd7f],
389 [0, 3, 0xf78c6c],
390 [1, 2, 0x7fd4ff],
391 [2, 3, 0xffcb6b],
392 [0, 2, 0xc792ea],
393]
394
395/** The monitor beside the desk: its screen says what the agent is up to. */
396function drawMonitor(c: Canvas, x: number, y: number, agent: FactoryAgent | undefined, t: number): void {
397 const mx = x + 14
398 const my = y + 7
399 c.rect(mx, my, 7, 6, MONITOR)
400 c.set(x + 17, y + 13, MONITOR)
401 c.rect(x + 16, y + 14, 3, 1, MONITOR)
402 const sx = mx + 1
403 const sy = my + 1
404 c.rect(sx, sy, 5, 4, SCREEN_OFF)
405 if (agent === undefined) return
406
407 switch (agent.status) {
408 case 'working': {
409 const icon = iconFor(agent.tool)
410 if (icon === 'web') {
411 c.rect(sx, sy, 5, 4, 0xf3f4f6)
412 c.rect(sx, sy, 5, 1, 0x3f6fd8)
413 c.rect(sx + 1, sy + 2 + (t % 2), 3, 1, 0xa3a9b3)
414 return
415 }
416 const isShell = icon === 'shell'
417 c.rect(sx, sy, 5, 4, isShell ? 0x0b0f14 : 0x1e2533)
418 for (let i = 0; i < 4; i++) {
419 const [indent, len, color] = CODE_ROWS[(t + i) % CODE_ROWS.length]!
420 c.rect(sx + indent, sy + i, Math.min(len, 5 - indent), 1, isShell ? 0x6cc24a : color)
421 }
422 if (icon === 'search') c.rect(sx, sy + 1, 5, 1, 0xf7d84a)
423 return
424 }
425 case 'waiting':
426 c.rect(sx, sy, 5, 4, 0x1f2b44)
427 if (t % 4 < 2) c.set(sx, sy, 0xd7e6ff)
428 return
429 case 'failed':
430 c.rect(sx, sy, 5, 4, 0x5a1f22)
431 c.rect(sx + 2, sy, 1, 2, INK.W!)
432 c.set(sx + 2, sy + 3, INK.W!)
433 return
434 case 'done':
435 c.sprite(sx, sy, CHECK)
436 return
437 case 'sleeping':
438 c.set(sx + (t < 4 ? t : 7 - t), sy + (t % 4 < 2 ? 1 : 2), 0x26324a)
439 return
440 }
441}
442
443/** A thought bubble over the monitor, its tail toward the agent's head. */
444function drawBubble(c: Canvas, x: number, y: number, icon: IconName, t: number): void {
445 const bx = x + 14
446 const by = y
447 c.rect(bx + 1, by, 5, 7, INK.W!)
448 c.rect(bx, by + 1, 7, 5, INK.W!)
449 c.set(bx, by + 6, INK.W!)
450 c.set(bx - 1, by + 7, INK.W!)
451 const frames = ICONS[icon]
452 c.sprite(bx + 1, by + 1, frames[t % frames.length]!)
453}
454
455function drawSleep(c: Canvas, x: number, y: number, t: number): void {
456 for (const offset of [0, 4]) {
457 const s = (t + offset) % FRAMES
458 if (s > 6) continue
459 const zx = x + 12 + Math.floor(s / 2)
460 const zy = y + 6 - s
461 c.sprite(zx, Math.max(y, zy), s >= 3 ? BIG_Z : SMALL_Z)
462 }
463}
464
465/** One desk: chair, agent, desk, monitor and what floats over the agent's head. */
466function drawStation(c: Canvas, x: number, y: number, seat: number, sceneAgent: SceneAgent | undefined, frame: number) {
467 const isManager = seat === 0
468 drawChair(c, x, y)
469 const agent = sceneAgent?.agent
470 const t = agent === undefined ? 0 : (frame + phaseOf(agent.id)) % FRAMES
471
472 if (sceneAgent !== undefined && agent !== undefined) {
473 let look: Look = 'open'
474 let arms: [number, number] = [0, 0]
475 let dy = 0
476 switch (agent.status) {
477 case 'working':
478 look = t === 5 ? 'closed' : 'right'
479 arms = t % 2 === 0 ? [1, 0] : [0, 1]
480 break
481 case 'waiting':
482 look = t < 3 ? 'open' : t < 5 ? 'left' : t < 6 ? 'open' : 'right'
483 break
484 case 'sleeping':
485 look = 'closed'
486 dy = t < 4 ? 0 : 1
487 break
488 case 'done':
489 look = 'happy'
490 arms = t % 4 < 2 ? [-1, -1] : [0, 0]
491 break
492 case 'failed':
493 look = 'dead'
494 arms = t % 2 === 0 ? [-1, 0] : [0, -1]
495 break
496 }
497 drawCharacter(c, x + 1, y + 8 + dy, sceneAgent.style, look, arms, t)
498 }
499
500 drawDesk(c, x, y, isManager)
501 drawKeyboard(c, x, y)
502 drawMonitor(c, x, y, agent, t)
503
504 if (agent === undefined) {
505 // An empty desk keeps a plant for company.
506 c.rect(x + 1, y + 12, 3, 2, 0x9c5a3c)
507 c.set(x + 1, y + 11, INK.L!)
508 c.set(x + 3, y + 10, INK.L!)
509 c.set(x + 2, y + 11, INK.l!)
510 return
511 }
512
513 if (agent.status === 'working') drawBubble(c, x, y, iconFor(agent.tool), t)
514 if (agent.status === 'waiting') drawBubble(c, x, y, 'think', Math.min(2, Math.floor(t / 2)))
515 if (agent.status === 'sleeping') drawSleep(c, x, y, t)
516 if (agent.outcome === 'ok' && agent.status !== 'working') c.sprite(x + 1, y + 1, CHECK)
517 if (agent.outcome === 'failed' && agent.status !== 'working') c.sprite(x + 2, y + 1, BANG)
518}
519
520const MINI_EYES: Record<Look, [number, number][]> = {
521 open: [[2, 1], [2, 2], [5, 1], [5, 2]],
522 left: [[1, 1], [1, 2], [4, 1], [4, 2]],
523 right: [[3, 1], [3, 2], [6, 1], [6, 2]],
524 closed: [[1, 2], [2, 2], [5, 2], [6, 2]],
525 happy: [[1, 2], [2, 1], [5, 1], [6, 2]],
526 dead: [[1, 1], [2, 2], [6, 1], [5, 2]],
527}
528
529/** The mascot for the small desks: a body 6 wide and 6 tall, eyes on rows 1 and 2, arms on row 3. */
530function drawMiniCharacter(
531 c: Canvas,
532 x: number,
533 y: number,
534 style: AgentStyle,
535 look: Look,
536 arms: [number, number],
537 t: number,
538): void {
539 const body = COLORS[style.color]
540 const dark = shade(body, 0.72)
541 c.rect(x + 1, y, 6, 5, body)
542 c.rect(x + 1, y + 5, 6, 1, dark)
543 for (const lx of [1, 3, 4, 6]) c.set(x + lx, y + 6, dark)
544 c.set(x, y + 3 + arms[0], body)
545 c.set(x + 7, y + 3 + arms[1], body)
546
547 const isOverEyes = style.accessory === 'shades'
548 if (style.accessory !== 'none' && !isOverEyes) c.sprite(x, y, MINI_ACCESSORIES[style.accessory])
549 for (const [ex, ey] of MINI_EYES[look]) c.set(x + ex, y + ey, INK.E!)
550 if (isOverEyes) c.sprite(x, y, MINI_ACCESSORIES.shades)
551 if (style.hat !== 'none') {
552 const hat = MINI_HATS[style.hat]
553 const frame = hat.frames[Math.floor(t / 2) % hat.frames.length]!
554 c.sprite(x, y - frame.length + hat.overlap, frame)
555 }
556}
557
558/** The small monitor's 3x2 screen, saying what the agent is up to. */
559function drawMiniScreen(c: Canvas, sx: number, sy: number, agent: FactoryAgent, t: number): void {
560 switch (agent.status) {
561 case 'working': {
562 const icon = iconFor(agent.tool)
563 if (icon === 'web') {
564 c.rect(sx, sy, 3, 2, 0xf3f4f6)
565 c.rect(sx, sy, 3, 1, 0x3f6fd8)
566 return
567 }
568 const isShell = icon === 'shell'
569 c.rect(sx, sy, 3, 2, isShell ? 0x0b0f14 : 0x1e2533)
570 for (let i = 0; i < 2; i++) {
571 const [indent, len, color] = CODE_ROWS[(t + i) % CODE_ROWS.length]!
572 const left = Math.min(indent, 1)
573 c.rect(sx + left, sy + i, Math.min(len, 3 - left), 1, isShell ? 0x6cc24a : color)
574 }
575 if (icon === 'search') c.rect(sx, sy + 1, 3, 1, 0xf7d84a)
576 return
577 }
578 case 'waiting':
579 c.rect(sx, sy, 3, 2, 0x1f2b44)
580 if (t % 4 < 2) c.set(sx, sy, 0xd7e6ff)
581 return
582 case 'failed':
583 c.rect(sx, sy, 3, 2, 0x5a1f22)
584 c.set(sx + 1, sy, INK.W!)
585 return
586 case 'done':
587 c.set(sx + 2, sy, INK.L!)
588 c.set(sx + 1, sy + 1, INK.L!)
589 return
590 case 'sleeping':
591 c.set(sx + (t % 3), sy + (t % 4 < 2 ? 0 : 1), 0x26324a)
592 return
593 }
594}
595
596/**
597 * One small desk, 16 by 16: the agent on rows 6 to 10 above the desk top,
598 * the monitor beside it, the bubble or the Zzz over the monitor, the name
599 * tag on the last two rows.
600 */
601function drawMiniStation(c: Canvas, x: number, y: number, seat: number, sceneAgent: SceneAgent | undefined, frame: number) {
602 const isManager = seat === 0
603 const w = DESKS.large.w
604 c.rect(x + 1, y + 5, 8, 6, CHAIR)
605 c.rect(x + 2, y + 5, 6, 1, CHAIR_LIGHT)
606 const agent = sceneAgent?.agent
607 const t = agent === undefined ? 0 : (frame + phaseOf(agent.id)) % FRAMES
608
609 if (sceneAgent !== undefined && agent !== undefined) {
610 let look: Look = 'open'
611 let arms: [number, number] = [0, 0]
612 let dy = 0
613 switch (agent.status) {
614 case 'working':
615 look = t === 5 ? 'closed' : 'right'
616 arms = t % 2 === 0 ? [1, 0] : [0, 1]
617 break
618 case 'waiting':
619 look = t < 3 ? 'open' : t < 5 ? 'left' : t < 6 ? 'open' : 'right'
620 break
621 case 'sleeping':
622 look = 'closed'
623 dy = t < 4 ? 0 : 1
624 break
625 case 'done':
626 look = 'happy'
627 arms = t % 4 < 2 ? [-1, -1] : [0, 0]
628 break
629 case 'failed':
630 look = 'dead'
631 arms = t % 2 === 0 ? [-1, 0] : [0, -1]
632 break
633 }
634 drawMiniCharacter(c, x + 1, y + 6 + dy, sceneAgent.style, look, arms, t)
635 }
636
637 // The desk, the keyboard on it, and the monitor at its right end.
638 c.rect(x, y + 11, w, 1, isManager ? 0x9c4a2c : 0xc68e5c)
639 c.rect(x, y + 12, w, 1, isManager ? 0x6b2c1a : 0x8f5d38)
640 c.rect(x, y + 13, w, 1, isManager ? 0x4e1f12 : 0x6e4529)
641 if (isManager) c.rect(x + 3, y + 12, 4, 1, RUG_EDGE)
642 c.rect(x + 2, y + 11, 6, 1, 0xe6e8ec)
643 c.rect(x + 10, y + 6, 5, 4, MONITOR)
644 c.set(x + 12, y + 10, MONITOR)
645 c.rect(x + 11, y + 11, 3, 1, MONITOR)
646 c.rect(x + 11, y + 7, 3, 2, SCREEN_OFF)
647
648 if (agent === undefined) {
649 // An empty desk keeps a plant for company.
650 c.rect(x + 1, y + 9, 2, 2, 0x9c5a3c)
651 c.set(x + 1, y + 8, INK.L!)
652 c.set(x + 2, y + 7, INK.l!)
653 return
654 }
655 drawMiniScreen(c, x + 11, y + 7, agent, t)
656
657 const bubble = (icon: IconName, step: number) => {
658 const bx = x + 10
659 c.rect(bx + 1, y, 3, 5, INK.W!)
660 c.rect(bx, y + 1, 5, 3, INK.W!)
661 c.set(bx - 1, y + 5, INK.W!)
662 const frames = MINI_ICONS[icon]
663 c.sprite(bx + 1, y + 1, frames[step % frames.length]!)
664 }
665 if (agent.status === 'working') bubble(iconFor(agent.tool), t)
666 if (agent.status === 'waiting') bubble('think', Math.floor(t / 2))
667 if (agent.status === 'sleeping') {
668 for (const offset of [0, 4]) {
669 const s = (t + offset) % FRAMES
670 if (s > 5) continue
671 c.sprite(x + 9 + Math.floor(s / 2), Math.max(y, y + 4 - s), s >= 3 ? MINI_BIG_Z : MINI_SMALL_Z)
672 }
673 }
674 if (agent.outcome === 'ok' && agent.status !== 'working') c.sprite(x, y + 1, MINI_CHECK)
675 if (agent.outcome === 'failed' && agent.status !== 'working') c.sprite(x + 1, y, MINI_BANG)
676}
677
678/** Where things sit on a medium or small desk, in its quarter-block pixels. */
679type Compact = {
680 /** The body's top-left, its width and rows (the last one dark), the arms' row, the legs under it. */
681 body: { x: number; y: number; w: number; rows: number; armRow: number; legs: number[] }
682 eyes: Record<Look, [number, number, string?][]>
683 hats: Record<Exclude<HatName, 'none'>, Hat>
684 accessories: Record<Exclude<AccessoryName, 'none'>, Sprite>
685 chair: [number, number, number, number]
686 deskY: number
687 monitor: [number, number, number, number]
688 bubble: [number, number, number, number]
689 chip: [number, number, number, number]
690 /** Where a rising Zzz starts, and where the check or the `!` stands. */
691 zzz: [number, number]
692 badge: [number, number]
693 label: number
694}
695
696const COMPACT: Record<'medium' | 'small', Compact> = {
697 medium: {
698 body: { x: 2, y: 4, w: 10, rows: 4, armRow: 2, legs: [] },
699 eyes: {
700 open: [[2, 1], [7, 1]],
701 left: [[1, 1], [6, 1]],
702 right: [[3, 1], [8, 1]],
703 closed: [[1, 1, 'd'], [2, 1, 'd'], [7, 1, 'd'], [8, 1, 'd']],
704 happy: [[1, 1], [2, 1], [7, 1], [8, 1]],
705 dead: [[2, 1, 'R'], [7, 1, 'R']],
706 },
707 hats: MEDIUM_HATS,
708 accessories: MEDIUM_ACCESSORIES,
709 chair: [0, 4, 14, 4],
710 deskY: 8,
711 monitor: [16, 4, 6, 4],
712 bubble: [16, 0, 6, 4],
713 chip: [18, 1, 2, 2],
714 zzz: [14, 5],
715 badge: [17, 0],
716 label: 10,
717 },
718 small: {
719 body: { x: 2, y: 2, w: 6, rows: 3, armRow: 1, legs: [1, 4] },
720 eyes: {
721 open: [[1, 1], [4, 1]],
722 left: [[0, 1], [3, 1]],
723 right: [[2, 1], [5, 1]],
724 closed: [[1, 1, 'd'], [4, 1, 'd']],
725 happy: [[1, 1, 'd'], [4, 1, 'd']],
726 dead: [[1, 1, 'R'], [4, 1, 'R']],
727 },
728 hats: SMALL_HATS,
729 accessories: SMALL_ACCESSORIES,
730 chair: [0, 2, 10, 4],
731 deskY: 6,
732 monitor: [10, 2, 4, 4],
733 bubble: [10, 0, 4, 2],
734 chip: [12, 0, 2, 2],
735 zzz: [9, 1],
736 badge: [10, 0],
737 label: 8,
738 },
739}
740
741/** A medium or small desk: the same scene as the others, told in fewer, half-width pixels. */
742function drawCompactStation(
743 k: Compact,
744 c: Canvas,
745 x: number,
746 y: number,
747 seat: number,
748 sceneAgent: SceneAgent | undefined,
749 frame: number,
750) {
751 const isManager = seat === 0
752 const w = k.monitor[0] + k.monitor[2] + 2
753 const { body } = k
754 c.rect(x + k.chair[0], y + k.chair[1], k.chair[2], k.chair[3], CHAIR)
755 const agent = sceneAgent?.agent
756 const t = agent === undefined ? 0 : (frame + phaseOf(agent.id)) % FRAMES
757
758 if (sceneAgent !== undefined && agent !== undefined) {
759 const { style } = sceneAgent
760 let look: Look = 'open'
761 let arms: [number, number] = [0, 0]
762 let dy = 0
763 switch (agent.status) {
764 case 'working':
765 look = t === 5 ? 'closed' : 'right'
766 arms = t % 2 === 0 ? [-1, 0] : [0, -1]
767 break
768 case 'waiting':
769 look = t < 3 ? 'open' : t < 5 ? 'left' : t < 6 ? 'open' : 'right'
770 break
771 case 'sleeping':
772 look = 'closed'
773 dy = t < 4 ? 0 : 1
774 break
775 case 'done':
776 look = 'happy'
777 arms = t % 4 < 2 ? [-1, -1] : [0, 0]
778 break
779 case 'failed':
780 look = 'dead'
781 arms = t % 2 === 0 ? [-1, 0] : [0, -1]
782 break
783 }
784 const bx = x + body.x
785 const by = y + body.y + dy
786 const color = COLORS[style.color]
787 const dark = shade(color, 0.72)
788 c.rect(bx, by, body.w, body.rows - 1, color)
789 c.rect(bx, by + body.rows - 1, body.w, 1, dark)
790 for (const leg of body.legs) c.set(bx + leg, by + body.rows, dark)
791 c.set(bx - 1, by + body.armRow + arms[0], color)
792 c.set(bx + body.w, by + body.armRow + arms[1], color)
793 const isOverEyes = style.accessory === 'shades'
794 if (style.accessory !== 'none' && !isOverEyes) c.sprite(bx - 1, by, k.accessories[style.accessory])
795 for (const [ex, ey, ink] of k.eyes[look]) {
796 c.set(bx + ex, by + ey, ink === 'd' ? shade(color, 0.55) : INK[ink ?? 'E']!)
797 }
798 if (isOverEyes) c.sprite(bx - 1, by, k.accessories.shades)
799 if (style.hat !== 'none') {
800 const hat = k.hats[style.hat]
801 const hatFrame = hat.frames[Math.floor(t / 2) % hat.frames.length]!
802 c.sprite(bx - 1, by - hatFrame.length + hat.overlap, hatFrame)
803 }
804 }
805
806 // The desk across the whole box, a keyboard on it, the monitor at its right.
807 c.rect(x, y + k.deskY, w, 1, isManager ? 0x9c4a2c : 0xc68e5c)
808 c.rect(x, y + k.deskY + 1, w, 1, isManager ? 0x6b2c1a : 0x8f5d38)
809 if (isManager) c.rect(x + body.x + 2, y + k.deskY + 1, body.w - 4, 1, RUG_EDGE)
810 c.rect(x + body.x, y + k.deskY, body.w, 1, 0xe6e8ec)
811 const [mx, my, mw, mh] = k.monitor
812 c.rect(x + mx, y + my, mw, mh, MONITOR)
813 const sx = x + mx + 1
814 const sy = y + my + 1
815 const sw = mw - 2
816 const sh = mh - 2
817 c.rect(sx, sy, sw, sh, SCREEN_OFF)
818
819 if (agent === undefined) {
820 // An empty desk keeps a plant for company.
821 c.rect(x + body.x, y + k.deskY - 2, 2, 2, 0x9c5a3c)
822 c.rect(x + body.x, y + k.deskY - 3, 1, 1, INK.L!)
823 c.rect(x + body.x + 1, y + k.deskY - 4, 1, 1, INK.l!)
824 return
825 }
826
827 // The screen, a whole row of one color at a time: a quarter-block cell paints two.
828 switch (agent.status) {
829 case 'working': {
830 const icon = iconFor(agent.tool)
831 for (let i = 0; i < sh; i++) {
832 const code = CODE_ROWS[(t + i) % CODE_ROWS.length]![2]
833 const row =
834 icon === 'web' ? (i === 0 ? 0x3f6fd8 : 0xf3f4f6) : icon === 'shell' ? (i % 2 === t % 2 ? 0x6cc24a : 0x0b0f14) : code
835 c.rect(sx, sy + i, sw, 1, row)
836 }
837 break
838 }
839 case 'waiting':
840 c.rect(sx, sy, sw, sh, t % 4 < 2 ? 0x2a3a5c : 0x1f2b44)
841 break
842 case 'failed':
843 c.rect(sx, sy, sw, sh, 0x8a2a30)
844 break
845 case 'done':
846 c.rect(sx, sy, sw, sh, 0x2f6b3a)
847 break
848 case 'sleeping':
849 c.rect(sx, sy + (t % 4 < 2 ? 0 : sh - 1), sw, 1, 0x1a2130)
850 break
851 }
852
853 const [bx0, by0, bw0, bh0] = k.bubble
854 const [cx0, cy0, cw0, ch0] = k.chip
855 if (agent.status === 'working' || agent.status === 'waiting') {
856 c.rect(x + bx0, y + by0, bw0, bh0, INK.W!)
857 c.set(x + bx0, y + by0, FLOOR_A)
858 c.set(x + bx0 + bw0 - 1, y + by0, FLOOR_A)
859 const icon = agent.status === 'working' ? iconFor(agent.tool) : 'think'
860 if (icon !== 'think' || t % 2 === 0) c.rect(x + cx0, y + cy0, cw0, ch0, INK[CHIPS[icon]]!)
861 }
862 if (agent.status === 'sleeping') {
863 for (const offset of [0, 4]) {
864 const s = (t + offset) % FRAMES
865 if (s > 4) continue
866 c.set(x + k.zzz[0] + (s >> 1), y + k.zzz[1] - s, INK.Z!)
867 }
868 }
869 const [gx, gy] = k.badge
870 if (agent.outcome === 'ok' && agent.status !== 'working') {
871 c.set(x + gx, y + gy + 1, INK.L!)
872 c.set(x + gx + 1, y + gy + 2, INK.L!)
873 c.set(x + gx + 2, y + gy + 1, INK.L!)
874 c.set(x + gx + 3, y + gy, INK.L!)
875 }
876 if (agent.outcome === 'failed' && agent.status !== 'working') {
877 c.rect(x + gx + 1, y + gy, 1, 2, INK.R!)
878 c.set(x + gx + 1, y + gy + 3, INK.R!)
879 }
880}
881
882/** Seats sorted by number, each with the agent at it. */
883function seatMap(agents: readonly SceneAgent[]): Map<number, SceneAgent> {
884 return new Map(agents.map(one => [one.agent.seat, one]))
885}
886
887/** How many seats the floor needs for these agents. */
888export function seatsNeeded(agents: readonly SceneAgent[]): number {
889 return agents.reduce((most, one) => Math.max(most, one.agent.seat + 1), 1)
890}
891
892/** Corner brackets around the desk being styled. */
893function drawHighlight(c: Canvas, x: number, y: number, w: number, h: number, t: number): void {
894 const color = t % 4 < 2 ? INK.Y! : INK.y!
895 const right = x + w - 1
896 const bottom = y + h - 1
897 for (const [cx, cy, dx, dy] of [
898 [x, y, 1, 1],
899 [right, y, -1, 1],
900 [x, bottom, 1, -1],
901 [right, bottom, -1, -1],
902 ] as const) {
903 c.rect(Math.min(cx, cx + dx * 2), cy, 3, 1, color)
904 c.rect(cx, Math.min(cy, cy + dy * 2), 1, 3, color)
905 }
906}
907
908export function drawScene(lay: Layout, agents: readonly SceneAgent[], frame: number, highlightSeat?: number): Canvas {
909 const c = new Canvas(lay.width, lay.height, FLOOR_A)
910 drawFloor(c, lay.wall)
911 drawWall(c, lay.wall)
912 const bySeat = seatMap(agents)
913 const manager = stationOrigin(lay, 0)
914 if (manager !== undefined) drawRug(c, manager.x, manager.y, lay.deskW, lay.deskH)
915 const draw =
916 lay.art === 'full'
917 ? drawStation
918 : lay.art === 'large'
919 ? drawMiniStation
920 : (c: Canvas, x: number, y: number, seat: number, one: SceneAgent | undefined, f: number) =>
921 drawCompactStation(COMPACT[lay.art as 'medium' | 'small'], c, x, y, seat, one, f)
922 for (let seat = 0; seat < lay.cells.length; seat++) {
923 const origin = stationOrigin(lay, seat)
924 if (origin !== undefined) draw(c, origin.x, origin.y, seat, bySeat.get(seat), frame)
925 }
926 const highlight = highlightSeat === undefined ? undefined : stationOrigin(lay, highlightSeat)
927 if (highlight !== undefined) drawHighlight(c, highlight.x, highlight.y, lay.deskW, lay.deskH, frame % FRAMES)
928
929 return c
930}
931
932const STATUS_COLORS: Record<FactoryAgent['status'], number> = {
933 working: 0xffffff,
934 waiting: 0xf7e08a,
935 sleeping: 0x9aa3b5,
936 done: 0x8fe08a,
937 failed: 0xff8a8a,
938}
939
940export function statusColor(status: FactoryAgent['status']): number {
941 return STATUS_COLORS[status]
942}
943
944/**
945 * How many letters a desk's name tag holds, a letter to a column: the desk's
946 * width and the floor beside it, less one column, so the tags of desks side
947 * by side never touch. A letter cannot be drawn smaller than its cell, so
948 * the small desks' tags spill into the floor to stay readable.
949 */
950export function labelChars(lay: Layout): number {
951 const perColumn = lay.isQuad ? 2 : 1
952 const { gapX } = spacingOf(lay.spacing, lay.art)
953
954 return Math.floor((lay.deskW + gapX) / perColumn) - 1
955}
956
957/** Each desk's name tag, `maxChars` wide at most, centered under its desk. */
958export function sceneLabels(lay: Layout, agents: readonly SceneAgent[], maxChars: number): Label[] {
959 const perColumn = lay.isQuad ? 2 : 1
960 const columns = labelChars(lay)
961 const labels: Label[] = []
962 for (const { agent } of agents) {
963 const origin = stationOrigin(lay, agent.seat)
964 if (origin === undefined) continue
965 const text = clip(agent.label, maxChars)
966 const y = lay.isQuad ? origin.y + COMPACT[lay.art as 'medium' | 'small'].label : origin.y + lay.deskH - 2
967 const x = origin.x + perColumn * Math.floor((lay.deskW / perColumn - columns) / 2)
968 labels.push({ x, y, width: columns * perColumn, text, color: statusColor(agent.status) })
969 }
970
971 return labels
972}
973
974export function sceneHotspots(lay: Layout, agents: readonly SceneAgent[], describe: (a: FactoryAgent) => string): Hotspot[] {
975 const spots: Hotspot[] = []
976 for (const { agent } of agents) {
977 const origin = stationOrigin(lay, agent.seat)
978 if (origin === undefined) continue
979 spots.push({ ...origin, width: lay.deskW, height: lay.deskH, title: describe(agent) })
980 }
981
982 return spots
983}
984
985export function clip(text: string, max: number): string {
986 const chars = Array.from(text)
987 return chars.length <= max ? text : `${chars.slice(0, Math.max(1, max - 1)).join('')}…`
988}
989hooks/sprites.ts 232 lines1import type { AccessoryName, ColorName, HatName } from '../types'
2
3/** A small picture: one string per row, one character per pixel, `.` see-through. */
4export type Sprite = readonly string[]
5
6export const COLORS: Record<ColorName, number> = {
7 clay: 0xd97757,
8 tangerine: 0xf0a04b,
9 sun: 0xe8c547,
10 lime: 0x9bcb4b,
11 mint: 0x5fc9a0,
12 teal: 0x3aafa9,
13 sky: 0x63a9e8,
14 ocean: 0x4a72d9,
15 grape: 0x8e6ad8,
16 berry: 0xc45ab3,
17 rose: 0xee7e9b,
18 snow: 0xe8e6e3,
19 slate: 0x7c8794,
20 cocoa: 0x8b5e3c,
21}
22
23export const COLOR_NAMES = Object.keys(COLORS) as ColorName[]
24
25/** The fixed colors sprites name by letter. */
26export const INK: Record<string, number> = {
27 K: 0x22252b, // black
28 k: 0x4a4f5a, // charcoal
29 E: 0x1d1a17, // eyes
30 W: 0xf6f4f0, // white
31 w: 0xc9ccd2, // light gray
32 S: 0xb8c0c8, // silver
33 s: 0x7d8691, // dark silver
34 G: 0xf2c037, // gold
35 g: 0xc28f1c, // dark gold
36 R: 0xe0484d, // red
37 r: 0x9e2f35, // dark red
38 Y: 0xf7d84a, // yellow
39 y: 0xd6a51c, // dark yellow
40 B: 0x3f6fd8, // blue
41 b: 0x2b4b9c, // dark blue
42 C: 0x4fc3d9, // cyan
43 P: 0x7a4fc9, // purple
44 p: 0x543196, // dark purple
45 M: 0xec6aa8, // pink
46 m: 0xb8407c, // dark pink
47 N: 0x8b5a2b, // brown
48 n: 0x5e3b1c, // dark brown
49 T: 0xd8bb8a, // tan
50 t: 0xa98a5a, // dark tan
51 L: 0x6cc24a, // green
52 l: 0x3f8a2e, // dark green
53 O: 0xf08a24, // orange
54 Z: 0xd7e6ff, // sleepy blue-white
55 F: 0xff9fb6, // blush
56}
57
58/** A color `f` of the way toward black (f < 1) or white (f > 1). */
59export function shade(color: number, f: number): number {
60 const channel = (shift: number) => {
61 const c = (color >> shift) & 0xff
62 const v = f < 1 ? c * f : c + (255 - c) * (f - 1)
63 return Math.max(0, Math.min(255, Math.round(v)))
64 }
65
66 return (channel(16) << 16) | (channel(8) << 8) | channel(0)
67}
68
69/** Relative brightness, 0 to 255, for picking a readable eye color. */
70export function luma(color: number): number {
71 return 0.299 * ((color >> 16) & 0xff) + 0.587 * ((color >> 8) & 0xff) + 0.114 * (color & 0xff)
72}
73
74export const hex = (color: number) => `#${color.toString(16).padStart(6, '0')}`
75
76const PRETTY: Record<string, string> = {
77 tophat: 'top hat',
78 hardhat: 'hard hat',
79 gradcap: 'grad cap',
80 bowtie: 'bow tie',
81}
82
83/** A color, hat or extra's name as people read it. */
84export function prettyName(name: string): string {
85 return PRETTY[name] ?? name
86}
87
88/**
89 * Hats sit on a 12-pixel-wide character: the column of each string is the
90 * character's column. `overlap` rows of the hat are drawn over the body's top
91 * rows; the rest stand above it. A hat with frames swaps them as time goes on.
92 */
93export type Hat = { frames: readonly Sprite[]; overlap: number }
94
95export const HATS: Record<Exclude<HatName, 'none'>, Hat> = {
96 crown: {
97 overlap: 0,
98 frames: [['..G..GG..G..', '..GGGGGGGG..', '..gGRGGRGg..']],
99 },
100 tophat: {
101 overlap: 0,
102 frames: [['...KKKKKK...', '...KKKKKK...', '...RRRRRR...', '.KKKKKKKKKK.']],
103 },
104 cap: {
105 overlap: 0,
106 frames: [['....BBBB....', '..BBBWBBBB..', '..bbbbbbbbbb']],
107 },
108 beanie: {
109 overlap: 1,
110 frames: [['.....WW.....', '...RRRRRR...', '..RRRRRRRR..', '.rWrWrWrWrW.']],
111 },
112 wizard: {
113 overlap: 0,
114 frames: [
115 ['.......P....', '......PP....', '.....PPY....', '....PYPPP...', '...PPPPPP...', '.pppppppppp.'],
116 ['.......P....', '......PP....', '.....PPP....', '....PPPYP...', '...PPPPPP...', '.pppppppppp.'],
117 ],
118 },
119 party: {
120 overlap: 0,
121 frames: [
122 ['.....YY.....', '.....MM.....', '....MCMM....', '...MMMCMM...', '..MCMMMMCM..'],
123 ['.....WW.....', '.....MM.....', '....MCMM....', '...MMMCMM...', '..MCMMMMCM..'],
124 ],
125 },
126 hardhat: {
127 overlap: 0,
128 frames: [['....YYYY....', '..YYYWYYYY..', '..YYYYYYYY..', 'yyyyyyyyyyyy']],
129 },
130 chef: {
131 overlap: 0,
132 frames: [['...WW.WW....', '..WWWWWWWW..', '..WWWWWWWW..', '...WWWWWW...', '...wwwwww...']],
133 },
134 cowboy: {
135 overlap: 0,
136 frames: [['....NNNN....', '...NNnNNN...', 'N..nnnnnn..N', '.NNNNNNNNNN.']],
137 },
138 explorer: {
139 overlap: 0,
140 frames: [['....TTTT....', '...TTTTTT...', '..tttttttt..', 'TTTTTTTTTTTT']],
141 },
142 propeller: {
143 overlap: 0,
144 frames: [
145 ['..RRRRkBBBB.', '......k.....', '...RYYBBR...', '..RRYYBBRR..'],
146 ['.....RkB....', '......k.....', '...RYYBBR...', '..RRYYBBRR..'],
147 ],
148 },
149 headphones: {
150 overlap: 4,
151 frames: [['...kkkkkk...', '..k......k..', '.k........k.', '.k........k.', 'CC........CC', 'CC........CC']],
152 },
153 bow: {
154 overlap: 0,
155 frames: [['..MMM..MMM..', '..MMMmmMMM..', '..MMM..MMM..']],
156 },
157 halo: {
158 overlap: 0,
159 frames: [
160 ['...GGGGGG...', '..G......G..', '...GGGGGG...', '............'],
161 ['............', '...GGGGGG...', '..G......G..', '...GGGGGG...'],
162 ],
163 },
164 antenna: {
165 overlap: 0,
166 frames: [
167 ['..L......L..', '...k....k...', '....k..k....'],
168 ['..Y......Y..', '...k....k...', '....k..k....'],
169 ],
170 },
171 gradcap: {
172 overlap: 1,
173 frames: [['.KKKKKKKKKK.', 'KKKKKKKKKKKK', '...kkkkkk.Y.', '..........Y.']],
174 },
175 viking: {
176 overlap: 0,
177 frames: [['W..........W', 'W...SSSS...W', 'WW.SSSSSS.WW', '.WSSSSSSSSW.']],
178 },
179 sprout: {
180 overlap: 0,
181 frames: [['...LL..LL...', '....LllL....', '.....ll.....']],
182 },
183}
184
185export const HAT_NAMES: HatName[] = ['none', ...(Object.keys(HATS) as Exclude<HatName, 'none'>[])]
186
187/** Accessories drawn over the 12-wide character, top row on the body's top row. */
188export const ACCESSORIES: Record<Exclude<AccessoryName, 'none'>, Sprite> = {
189 glasses: ['............', '..KKK..KKK..', '..K.KKKK.K..', '..KKK..KKK..'],
190 shades: ['............', '............', '..KWKKKWKK..', '..KKK..KKK..'],
191 monocle: ['............', '........G...', '.......G.G..', '.......G.G..', '........G.G.', '..........G.'],
192 tie: ['............', '............', '............', '............', '.....RR.....', '.....rr.....', '.....RR.....'],
193 bowtie: ['............', '............', '............', '............', '...RRrrRR...', '...R....R...'],
194 mustache: ['............', '............', '............', '............', '...nnnnnn...', '..n......n..'],
195 scarf: ['............', '............', '............', '............', '............', '.RWRWRWRWRW.', '.R.......W..'],
196 blush: ['............', '............', '............', '............', '..F......F..'],
197}
198
199export const ACCESSORY_NAMES: AccessoryName[] = [
200 'none',
201 ...(Object.keys(ACCESSORIES) as Exclude<AccessoryName, 'none'>[]),
202]
203
204/** What a working agent's thought bubble shows: the kind of tool it runs. */
205export type IconName = 'search' | 'edit' | 'shell' | 'web' | 'delegate' | 'todo' | 'ask' | 'think' | 'tool'
206
207/** 5x5 pictures drawn inside the bubble. */
208export const ICONS: Record<IconName, readonly Sprite[]> = {
209 search: [['KKK..', 'KCK..', 'KKK..', '...K.', '....K']],
210 edit: [['...MM', '..YYM', '.YYY.', 'kYY..', 'k....']],
211 shell: [
212 ['KKKKK', 'KLKKK', 'KKLKK', 'KLKLL', 'KKKKK'],
213 ['KKKKK', 'KLKKK', 'KKLKK', 'KLKKK', 'KKKKK'],
214 ],
215 web: [['.BBB.', 'BLLBB', 'BBLLB', 'BLBBB', '.BBB.']],
216 delegate: [['.....', 'OOOOO', 'OEOEO', 'OOOOO', 'O.O.O']],
217 todo: [['L.kkk', '.....', 'L.kkk', '.....', 'w.kkk']],
218 ask: [['.BBB.', '....B', '..BB.', '.....', '..B..']],
219 think: [
220 ['.....', '.....', 'k....', '.....', '.....'],
221 ['.....', '.....', 'k.k..', '.....', '.....'],
222 ['.....', '.....', 'k.k.k', '.....', '.....'],
223 ['.....', '.....', '.....', '.....', '.....'],
224 ],
225 tool: [['..s..', '.sSs.', 'sS.Ss', '.sSs.', '..s..']],
226}
227
228export const CHECK: Sprite = ['....L', '...L.', 'L.L..', '.L...']
229export const BANG: Sprite = ['R', 'R', 'R', '.', 'R']
230export const BIG_Z: Sprite = ['ZZZZ', '..Z.', '.Z..', 'ZZZZ']
231export const SMALL_Z: Sprite = ['ZZZ', '.Z.', 'ZZZ']
232hooks/minis.ts 97 lines1import type { AccessoryName, HatName } from '../types'
2import type { Hat, IconName, Sprite } from './sprites'
3
4/**
5 * The small desks' pictures: the same hats, extras and icons drawn for a
6 * character 8 pixels wide (body 6 wide, eyes on rows 1 and 2, arms on row 3),
7 * columns lined up with the character's.
8 */
9export const MINI_HATS: Record<Exclude<HatName, 'none'>, Hat> = {
10 crown: { overlap: 0, frames: [['.G.GG.G.', '.GRGGRG.']] },
11 tophat: { overlap: 0, frames: [['..KKKK..', '..RRRR..', '.KKKKKK.']] },
12 cap: { overlap: 0, frames: [['..BBBB..', '.BBBBbbb']] },
13 beanie: { overlap: 1, frames: [['...WW...', '..RRRR..', '.rWrWrW.']] },
14 wizard: {
15 overlap: 0,
16 frames: [
17 ['....P...', '...PYP..', '..PPPP..', '.pppppp.'],
18 ['....P...', '...PPP..', '..PYPP..', '.pppppp.'],
19 ],
20 },
21 party: {
22 overlap: 0,
23 frames: [
24 ['...YY...', '..MCMM..', '.MMMCMM.'],
25 ['...WW...', '..MCMM..', '.MMMCMM.'],
26 ],
27 },
28 hardhat: { overlap: 0, frames: [['..YYYY..', '.YYWYYY.', 'yyyyyyyy']] },
29 chef: { overlap: 0, frames: [['.WWWWWW.', '..WWWW..', '..wwww..']] },
30 cowboy: { overlap: 0, frames: [['..NnnN..', 'N.nnnn.N', '.NNNNNN.']] },
31 explorer: { overlap: 0, frames: [['..TTTT..', '.tttttt.', 'TTTTTTTT']] },
32 propeller: {
33 overlap: 0,
34 frames: [
35 ['RRRkBBB.', '...k....', '..RYBR..'],
36 ['..RkB...', '...k....', '..RYBR..'],
37 ],
38 },
39 headphones: { overlap: 3, frames: [['..kkkk..', '.k....k.', 'k......k', 'C......C', 'C......C']] },
40 bow: { overlap: 0, frames: [['.MM..MM.', '..MmmM..', '.MM..MM.']] },
41 halo: {
42 overlap: 0,
43 frames: [
44 ['.GGGGGG.', '........', '........'],
45 ['........', '.GGGGGG.', '........'],
46 ],
47 },
48 antenna: {
49 overlap: 0,
50 frames: [
51 ['.L....L.', '..k..k..'],
52 ['.Y....Y.', '..k..k..'],
53 ],
54 },
55 gradcap: { overlap: 1, frames: [['KKKKKKKK', '..kkkk.Y', '.......Y']] },
56 viking: { overlap: 0, frames: [['W......W', 'W.SSSS.W', '.WSSSSW.']] },
57 sprout: { overlap: 0, frames: [['..LL.LL.', '....l...']] },
58}
59
60/** Extras over the small character, top row on its top row. */
61export const MINI_ACCESSORIES: Record<Exclude<AccessoryName, 'none'>, Sprite> = {
62 glasses: ['........', '.SSSSSS.', '.S.SS.S.'],
63 shades: ['........', '.KKKKKK.', '.KK..KK.'],
64 monocle: ['.....G..', '....G.G.', '....G.G.', '.....G.G'],
65 tie: ['........', '........', '........', '...RR...', '...rr...'],
66 bowtie: ['........', '........', '........', '..RrrR..'],
67 mustache: ['........', '........', '........', '..nnnn..', '.n....n.'],
68 scarf: ['........', '........', '........', '........', '.RWRWRW.'],
69 blush: ['........', '........', '........', '.F....F.'],
70}
71
72/** 3x3 pictures for the small thought bubble. */
73export const MINI_ICONS: Record<IconName, readonly Sprite[]> = {
74 search: [['KK.', 'KK.', '..K']],
75 edit: [['..M', '.Y.', 'k..']],
76 shell: [
77 ['KKK', 'LKK', 'KLL'],
78 ['KKK', 'LKK', 'KKK'],
79 ],
80 web: [['.B.', 'BLB', '.B.']],
81 delegate: [['OOO', 'EOE', 'O.O']],
82 todo: [['L.k', '...', 'L.k']],
83 ask: [['BBB', '..B', '.B.']],
84 think: [
85 ['...', '...', 'k..'],
86 ['...', '.k.', 'k..'],
87 ['..k', '.k.', 'k..'],
88 ['..k', '.k.', 'k..'],
89 ],
90 tool: [['.s.', 'sSs', '.s.']],
91}
92
93export const MINI_CHECK: Sprite = ['...L', 'L.L.', '.L..']
94export const MINI_BANG: Sprite = ['R', 'R', '.', 'R']
95export const MINI_BIG_Z: Sprite = ['ZZ.', '.Z.', '.ZZ']
96export const MINI_SMALL_Z: Sprite = ['Z.', '.Z']
97hooks/tinies.ts 138 lines1import type { AccessoryName, HatName } from '../types'
2import type { Hat, IconName, Sprite } from './sprites'
3
4/**
5 * The medium and small desks' pictures, drawn with quarter blocks: every
6 * character cell holds 2x2 pixels, each half a cell wide and half a cell
7 * tall. A cell paints only two colors, so the pictures keep to two colors
8 * in each 2x2 square where they can.
9 *
10 * Column 0 of a hat or an extra is the column left of the body, so the
11 * medium ones are 12 wide around a body 10 wide, the small ones 8 wide
12 * around a body 6 wide.
13 */
14export const MEDIUM_HATS: Record<Exclude<HatName, 'none'>, Hat> = {
15 crown: { overlap: 0, frames: [['.G...GG...G.', '.GGGGGGGGGG.']] },
16 tophat: { overlap: 0, frames: [['...KKKKKK...', '...KKKKKK...', '...RRRRRR...', 'KKKKKKKKKKKK']] },
17 cap: { overlap: 0, frames: [['...BBBBB....', '..BBBBBBBbbb']] },
18 beanie: { overlap: 1, frames: [['.....WW.....', '...RRRRRR...', '.rrrrrrrrrr.']] },
19 wizard: { overlap: 0, frames: [['.....PP.....', '....PPPP....', '...PPYYPP...', '..pppppppp..']] },
20 party: {
21 overlap: 0,
22 frames: [
23 ['.....YY.....', '....MCCM....', '...MMMMMM...'],
24 ['.....WW.....', '....MCCM....', '...MMMMMM...'],
25 ],
26 },
27 hardhat: { overlap: 0, frames: [['....YYYY....', '...YYYYYY...', 'yyyyyyyyyyyy']] },
28 chef: { overlap: 0, frames: [['..WWWWWWWW..', '..WWWWWWWW..', '...wwwwww...']] },
29 cowboy: { overlap: 0, frames: [['....NNNN....', 'N...nnnn...N', '.NNNNNNNNNN.']] },
30 explorer: { overlap: 0, frames: [['....TTTT....', '...tttttt...', 'TTTTTTTTTTTT']] },
31 propeller: {
32 overlap: 0,
33 frames: [
34 ['RRRRRkBBBBB.', '.....k......', '...RRYYBB...'],
35 ['....RkB.....', '.....k......', '...RRYYBB...'],
36 ],
37 },
38 headphones: { overlap: 2, frames: [['..kkkkkkkk..', '.k........k.', 'C..........C', 'C..........C']] },
39 bow: { overlap: 0, frames: [['...MM..MM...', '....MmmM....', '...MM..MM...']] },
40 halo: {
41 overlap: 0,
42 frames: [
43 ['...GGGGGG...', '............'],
44 ['............', '...GGGGGG...'],
45 ],
46 },
47 antenna: {
48 overlap: 0,
49 frames: [
50 ['..L......L..', '...k....k...'],
51 ['..Y......Y..', '...k....k...'],
52 ],
53 },
54 gradcap: { overlap: 1, frames: [['KKKKKKKKKKKK', '...kkkkkk.Y.', '..........Y.']] },
55 viking: { overlap: 0, frames: [['W..........W', 'W...SSSS...W', '.WSSSSSSSSW.']] },
56 sprout: { overlap: 0, frames: [['....LL.LL...', '......l.....']] },
57}
58
59export const MEDIUM_ACCESSORIES: Record<Exclude<AccessoryName, 'none'>, Sprite> = {
60 glasses: ['............', '..SSSSSSSS..'],
61 shades: ['............', '..KKKKKKKK..'],
62 monocle: ['............', '.......GGG..', '.........G..'],
63 tie: ['............', '............', '.....RR.....'],
64 bowtie: ['............', '............', '....RrrR....'],
65 mustache: ['............', '............', '....nnnn....'],
66 scarf: ['............', '............', '.RWRWRWRWRW.'],
67 blush: ['............', '............', '..F......F..'],
68}
69
70export const SMALL_HATS: Record<Exclude<HatName, 'none'>, Hat> = {
71 crown: { overlap: 0, frames: [['.G.GG.G.', '.GGGGGG.']] },
72 tophat: { overlap: 0, frames: [['..KKKK..', 'KKKKKKKK']] },
73 cap: { overlap: 0, frames: [['..BBBB..', '.BBBBbbb']] },
74 beanie: { overlap: 0, frames: [['...WW...', '.RRRRRR.']] },
75 wizard: { overlap: 0, frames: [['...PY...', '.pppppp.']] },
76 party: {
77 overlap: 0,
78 frames: [
79 ['...YY...', '..MCCM..'],
80 ['...WW...', '..MCCM..'],
81 ],
82 },
83 hardhat: { overlap: 0, frames: [['..YYYY..', 'yyyyyyyy']] },
84 chef: { overlap: 0, frames: [['.WWWWWW.', '..wwww..']] },
85 cowboy: { overlap: 0, frames: [['..NNNN..', 'NNNNNNNN']] },
86 explorer: { overlap: 0, frames: [['..TTTT..', 'TTTTTTTT']] },
87 propeller: {
88 overlap: 0,
89 frames: [
90 ['RRRkBBB.', '..RYYB..'],
91 ['..RkB...', '..RYYB..'],
92 ],
93 },
94 headphones: { overlap: 2, frames: [['.kkkkkk.', 'k......k', 'C......C']] },
95 bow: { overlap: 0, frames: [['.MM..MM.', '..MmmM..']] },
96 halo: {
97 overlap: 0,
98 frames: [
99 ['..GGGG..', '........'],
100 ['........', '..GGGG..'],
101 ],
102 },
103 antenna: {
104 overlap: 0,
105 frames: [
106 ['.L....L.', '..k..k..'],
107 ['.Y....Y.', '..k..k..'],
108 ],
109 },
110 gradcap: { overlap: 0, frames: [['KKKKKKKK', '...kk.Y.']] },
111 viking: { overlap: 0, frames: [['W......W', 'W.SSSS.W']] },
112 sprout: { overlap: 0, frames: [['..LL.LL.', '....l...']] },
113}
114
115export const SMALL_ACCESSORIES: Record<Exclude<AccessoryName, 'none'>, Sprite> = {
116 glasses: ['........', '.SSSSSS.'],
117 shades: ['........', '.KKKKKK.'],
118 monocle: ['........', '....GGG.'],
119 tie: ['........', '........', '...RR...'],
120 bowtie: ['........', '........', '..RrrR..'],
121 mustache: ['........', '........', '..nnnn..'],
122 scarf: ['........', '........', '.RWRWRW.'],
123 blush: ['........', '........', '.F....F.'],
124}
125
126/** Too small for a picture: the thought bubble shows the tool by its color. */
127export const CHIPS: Record<IconName, string> = {
128 search: 'Y',
129 edit: 'M',
130 shell: 'L',
131 web: 'B',
132 delegate: 'O',
133 todo: 'l',
134 ask: 'C',
135 think: 'k',
136 tool: 'S',
137}
138types/index.d.ts 107 lines1/** What an agent at a desk is doing right now. */
2export type AgentStatus = 'working' | 'waiting' | 'sleeping' | 'done' | 'failed'
3
4/** One agent on the factory floor: the main loop (`id: 'main'`) or a subagent. */
5export type FactoryAgent = {
6 id: string
7 isMain: boolean
8 /** The subagent type (`Explore`, `general-purpose`, ...), `main` for the main loop. */
9 type: string
10 /** The short name drawn under the desk. */
11 label: string
12 /** The task the agent was given, as the Agent call described it. */
13 description: string
14 status: AgentStatus
15 /** How its last task ended, kept after it dozes off: a badge over its head. */
16 outcome: 'ok' | 'failed' | null
17 /** The tool the agent runs, or ran last while it keeps working; null before its first. */
18 tool: string | null
19 /** A few words on what the tool works on (a file, a command, a query). */
20 detail: string | null
21 /** The `tool_use_id` of the tool while it runs, so only its own end marks it ended. */
22 toolId: string | null
23 /** Epoch milliseconds of the last status change. */
24 since: number
25 /** The desk: 0 is the manager's, the rest ring around it. */
26 seat: number
27 toolCount: number
28 /** A named member of the session's team: it waits for messages between turns. */
29 isTeammate: boolean
30 /** One of `/factory-view demo`'s pretend agents. */
31 isDemo: boolean
32}
33
34export type ColorName =
35 | 'clay'
36 | 'tangerine'
37 | 'sun'
38 | 'lime'
39 | 'mint'
40 | 'teal'
41 | 'sky'
42 | 'ocean'
43 | 'grape'
44 | 'berry'
45 | 'rose'
46 | 'snow'
47 | 'slate'
48 | 'cocoa'
49
50export type HatName =
51 | 'none'
52 | 'crown'
53 | 'tophat'
54 | 'cap'
55 | 'beanie'
56 | 'wizard'
57 | 'party'
58 | 'hardhat'
59 | 'chef'
60 | 'cowboy'
61 | 'explorer'
62 | 'propeller'
63 | 'headphones'
64 | 'bow'
65 | 'halo'
66 | 'antenna'
67 | 'gradcap'
68 | 'viking'
69 | 'sprout'
70
71export type AccessoryName =
72 | 'none'
73 | 'glasses'
74 | 'shades'
75 | 'monocle'
76 | 'tie'
77 | 'bowtie'
78 | 'mustache'
79 | 'scarf'
80 | 'blush'
81
82/** How one agent (or every agent of one type) looks. */
83export type AgentStyle = { color: ColorName; hat: HatName; accessory: AccessoryName }
84
85/** How agents nobody styled look: each its own by lot, or dressed for their type. */
86export type LooksMode = 'random' | 'type'
87
88/** How big the agents are drawn. */
89export type AgentSize = 'small' | 'medium' | 'large'
90
91declare module 'claude-code' {
92 interface PluginState {
93 'factory-view': {
94 agents: FactoryAgent[]
95 /** Keyed by `main`, `type:<subagent type>` or `agent:<agent id>`. */
96 styles: Record<string, AgentStyle>
97 looks: LooksMode
98 size: AgentSize
99 /** The agent whose style the pane is editing. */
100 selected: string | null
101 /** Whether the pane's style controls dress the picked agent alone or its whole type. */
102 scope: 'agent' | 'type'
103 isDemo: boolean
104 }
105 }
106}
107