SLOPSHOPPER

Factory View

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

paneguardcommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · factory-view
│ ┃ Factory View ✕ › fix the failing auth test and add an audit log call │ ┃ ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ │ ┃ ⏺ Read(src/auth.ts) │ ┃ ▘▄▝ ⎿ Read 6 lines │ ┃ ▗▄▝▄ ▄▘▄▄ ▄ ▄▖ ⏺ Update(src/auth.ts) │ ┃ ▞ ▗▄▖ ▐ ▖ ▗ ▗▄▖ ▐ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 0 working · 1 waiting · 0 resting · pick an │ ┃ agent to dress it up ✻ Worked for 42s · done 4:20 PM │ ┃ agent-main │ ┃ [ Undressed agents: random looks ] [ Size: m › /factory-view │ ⎿ factory-view: Factory View is open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Factory View
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ▘▄▝ ▗▄▝▄ ▄▘▄▄ ▄ ▄▖ ▞ ▗▄▖ ▐ ▖ ▗ ▗▄▖ ▐ 0 working · 1 waiting · 0 resting · pick an agent to dress it up agent-main [ Undressed agents: random looks ] [ Size: medium ]
README

🏭 Factory View

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

Claude Code mod Terminal, desktop and mobile MIT license

<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.

📦 Install

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.

👀 What you see

On the floorMeans
⌨️ Arms typing, code scrolling on the monitor, a bubble over itworking: 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 dotswaiting: the manager waits for you; a subagent waits to start, or for a message
✅ Happy eyes, arms up, a green checkdone: the task came back
💤 Eyes closed, Zzz rising, screensaversleeping: idle for a while; a finished agent keeps its check
❗ X eyes, a red !, a red screenfailed

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.

📏 Three sizes

<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.

🎩 Dress them up

<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.

⌨️ Commands

CommandDoes
/factory-viewopen or close the pane (closing it keeps it closed in later sessions)
/factory-view demoadd 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 clearsend finished agents home
/factory-view optionslist the colors, hats and extras

🔒 Privacy

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.

❓ FAQ

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.

🔧 How it works

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.

FileHolds
hooks/register.tsxthe hooks: events, the pane, the /factory-view command, the animation ticker
hooks/model.tsagents, desks, status changes and styles, as plain functions
hooks/scene.tsthe floor plan and the pixel painter, for every size
hooks/sprites.tscolors, hats, extras and icons for the scaled SVG
hooks/minis.tsthe same for the large terminal desks
hooks/tinies.tsthe same for the medium and small desks, in quarter blocks
hooks/paint.tsthe picture as terminal cells (half or quarter blocks), and as an SVG with its frames
types/index.d.tsthe shape of the mod's state
tests/factory.test.tsthe floor plan, the agents, the session and the pane

🛠️ Developing

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/.

📄 License

MIT

Source 8 files
hooks/register.tsx 749 lines
1import { 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}
749
hooks/model.ts 329 lines
1import 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}
329
hooks/paint.ts 246 lines
1import { 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}
246
hooks/scene.ts 989 lines
1import 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}
989
hooks/sprites.ts 232 lines
1import 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']
232
hooks/minis.ts 97 lines
1import 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']
97
hooks/tinies.ts 138 lines
1import 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}
138
types/index.d.ts 107 lines
1/** 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