SLOPSHOPPER

mission-control

Your crew, live: an animated Clawd for Claude and every subagent above the prompt, and a pane with the context and the budget.

newpanebandguardcommandtimer
v1.5.0MITupdated 2026-10-07datvodinh/claude-mission-control
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mission-control
│ ┃ Mission control ✕ › fix the failing auth test and add an audit log call │ ┃ Claude is waiting for you [ Close ] │ ┃ ▣ client module ./clawd.tsx ⏺ Read(src/auth.ts) │ ┃ ╭─────────────────────────────────────────── ⎿ Read 6 lines │ ┃ │ ● Claude Idle 0s ⏺ Update(src/auth.ts) │ ┃ │ 9 tool calls this turn · Opus 5.5 ⎿ Added 2 lines, removed 1 line │ ┃ │ Bash cat .env 0s ago ⏺ Bash(bun test) │ ┃ │ Bash rm -rf build && git push --force o… ⎿ 3 pass, 1 fail │ ┃ │ Bash git status --porcelain 0s ago │ ┃ │ Bash bun test 0s ago ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ Write cache.ts 0s ago │ ┃ ╰─────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ Context │ ┃ 97k of 200k used › /mission │ ┃ Budget ⎿ mission-control: Mission control is open. │ ┃ $0.42 this session │ ┃ 5-hour ━━━━──────── 31% used │ ┃ Hide the band above the prompt │ ▣ client module ./clawd.tsx [ Mission control ] 97k of 200k used ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ client module ./clawd.tsx [ Mission control ] 97k of 200k used
Pane · Mission control
Claude is waiting for you [ Close ] ▣ client module ./clawd.tsx ╭─────────────────────────────────────────────────────────── │ ● Claude Idle 0s │ 9 tool calls this turn · Opus 5.5 │ Bash cat .env 0s ago │ Bash rm -rf build && git push --force origin main 0s ago │ Bash git status --porcelain 0s ago │ Bash bun test 0s ago │ Write cache.ts 0s ago ╰─────────────────────────────────────────────────────────── Context 97k of 200k used Budget $0.42 this session 5-hour ━━━━──────── 31% used Hide the band above the prompt
README

Mission control

A Claude Code mod that puts your crew on screen. Claude and every subagent it starts get their own Clawd, live in a band above the prompt. One click opens Mission control: every agent at a glance, the context and the budget.

The Clawds stand on the session's context: a bar of what fills it (messages, tools, the system prompt, skills and free space), with how much is used, when it will auto-compact, and each part's share.

It runs in the desktop app and the terminal. On the desktop the Clawds are pixel art; in a terminal they're drawn in block characters, like the logo, three rows tall.

Idle: Claude has the band to itself

When Claude is waiting on you and has no subagents, it wanders up and down the band. It stops to look around, hop, dance, stretch, twirl, duck into the floor and pop back up, or doze off for a bit.

DesktopTerminal
Claude wandering the desktop band, hopping, dancing and dozingClaude wandering the terminal band

Napping

After three minutes with nothing to do, it falls properly asleep and snores.

DesktopTerminal
Claude asleep on the desktop band, snoringClaude asleep on the terminal band

At work

When a turn starts, Claude scuttles back to its desk and types at its laptop. Each subagent it starts rises into the band, walks to a laptop of its own and gets typing too. The screen lights up while a tool runs.

DesktopTerminal
Claude typing, then two subagents joining and sitting at their laptopsThe same on the terminal band

Hover: who is doing what

Point at a Clawd and it turns from its laptop to look at you, waving hello. Beside it, it says who it is, what it's doing and what it was asked: in a pixel speech bubble on the desktop, in plain words in a terminal. Move away and it goes back to work. Click a Clawd to open Mission control on it.

DesktopTerminal
A subagent and then Claude turning to face the pointer, each with a speech bubbleThe same in a terminal, the words beside the Clawd

Done

A subagent that finishes cheers and hops, then sinks out of sight. One that fails droops and greys out first.

DesktopTerminal
A subagent cheering when it finishes, then sinking awayThe same on the terminal band

Mission control

Click Mission control in the band, click a Clawd, or type /mission. The pane shows:

  • the whole crew on stage
  • a card for the newest agent at work, or whoever you pick: their task, what they're doing now and their latest tool calls
  • the context: how much is used, what fills it and when it will auto-compact
  • the budget: what this session has spent, and your 5-hour and weekly limits, with a pace check against each
DesktopTerminal
The Mission control pane: the stage, the spotlight card, the crew, the context and the budgetThe pane in a terminal

Install

You need a Claude Code build that supports mods: a plugin.json plus a hooks/register.tsx module. It was built and tested on 2.1.289 and 2.1.292.

Clone it into your skills folder, and every new session loads it:

git clone https://github.com/datvodinh/claude-mission-control ~/.claude/skills/mission-control

To load it into a session that's already open, type /reload-plugins. To switch it off, delete the folder.

To try it for a single terminal session without installing:

claude --plugin-dir /path/to/claude-mission-control

Develop

FileWhat it does
hooks/register.tsxFollows the session's events (turns, tool calls, subagents, usage), keeps the crew's state, and draws the band and the pane
hooks/clawd.tsxThe animated Clawds: sprites, moods, the idle wandering and the hover bubble, drawn on its own frame clock
hooks/view.tsxThe band's and the pane's layout
hooks/budget.tsSpend, and pace checks against the rate-limit windows
hooks/tools.ts, hooks/format.tsHow tool calls and numbers read
tests/mission-control.test.tsxThe behaviour, tested on both surfaces
claude plugin validate .
claude plugin test .

License

MIT

Source 7 files
hooks/register.tsx 720 lines
1// Mission control: every agent of the session as a living Clawd, how full the
2// context window is, and the budget's pace. The band above the prompt keeps
3// the crew in view, nothing more; the pane (/mission) has the stage, the
4// crew, the context and the budget.
5//
6// The hooks keep the facts in `$.state` (who is doing what, the readings);
7// the band and the pane draw them, and the surface module (./clawd.tsx)
8// animates the Clawds between redraws.
9
10import { atom, read, update } from 'claude-code'
11import type { AgentInfo, AgentSpawnInput, EngineInterface, ModelUsage, Register, TurnCompleteInput } from 'claude-code'
12
13import type { Actor, Member, Mood, Reading, StageProps, StripProps, Walker, Where } from '../types'
14import { fillOf, paceOf, spendLine } from './budget'
15import type { Fill, Tone } from './budget'
16import { plural, prettyModel, shorten, span, tokens } from './format'
17import { ASKING, DELEGATING, badgeOf, captionOf, detailOf, verbOf } from './tools'
18import { Band, Pane } from './view'
19import type { ContextView, CrewRow, Kit, Spotlight } from './view'
20
21type Engine = EngineInterface
22
23const PANE = 'mission-control'
24const TITLE = 'Mission control'
25
26const crew = atom({ plugin: 'mission-control', key: 'crew' } as const, [])
27const selected = atom({ plugin: 'mission-control', key: 'selected' } as const, null)
28const reading = atom({ plugin: 'mission-control', key: 'reading' } as const, null)
29const turns = atom({ plugin: 'mission-control', key: 'turns' } as const, [])
30const samples = atom({ plugin: 'mission-control', key: 'samples' } as const, [])
31const isBandHidden = atom({ plugin: 'mission-control', key: 'isBandHidden' } as const, false)
32
33/** The app's own subagent colours, in the order new subagents take them. */
34const PALETTE = [
35  'blue_FOR_SUBAGENTS_ONLY',
36  'green_FOR_SUBAGENTS_ONLY',
37  'purple_FOR_SUBAGENTS_ONLY',
38  'pink_FOR_SUBAGENTS_ONLY',
39  'cyan_FOR_SUBAGENTS_ONLY',
40  'yellow_FOR_SUBAGENTS_ONLY',
41  'red_FOR_SUBAGENTS_ONLY',
42]
43
44const MAIN = 'main'
45const ENDED: readonly Mood[] = ['done', 'failed', 'stopped']
46const BUSY: readonly Mood[] = ['working', 'asking', 'delegating']
47const SLEEP_AFTER_MS = 3 * 60_000
48const OFF_STAGE_AFTER_MS = 5 * 60_000
49/** How long a subagent that ended stays on the band's strip, to play it out. */
50const OFF_STRIP_AFTER_MS = 20_000
51/** The band's Clawds on the desktop: as tall as a hover's bubble beside them, in lines, and Claude's own room, in ch. */
52const STRIP_LH = 1.5
53const STRIP_MAIN_CH = 6.3
54const KEEP_MEMBERS = 24
55const BEAT_MS = 15_000
56
57/** The engine's own plumbing, not work worth showing: a subagent handing its report back. */
58const INTERNAL = new Set(['SubagentHandback', 'StructuredOutput'])
59
60// Module state: rebuilt on every reload, and nothing is drawn from it.
61const inFlight = new Map<string, number>()
62/** Each subagent's latest response, in tokens: its context as it stands. */
63const lastSize = new Map<string, number>()
64let beats = 0
65
66const sizeOf = (usage: ModelUsage) => usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens + usage.output_tokens
67
68// ── The crew ──────────────────────────────────────────────────────────────
69
70function newMember(id: string, type: string, task: string, color: string, at: number, extra: Partial<Member> = {}): Member {
71  return {
72    id,
73    type,
74    task,
75    color,
76    mood: 'thinking',
77    since: at,
78    tool: null,
79    detail: null,
80    calls: 0,
81    tokens: null,
82    model: null,
83    joinedAt: at,
84    startedAt: at,
85    endedAt: null,
86    isBackground: false,
87    isTeammate: false,
88    recent: [],
89    ...extra,
90  }
91}
92
93const colorFor = (index: number) => PALETTE[index % PALETTE.length] ?? 'blue_FOR_SUBAGENTS_ONLY'
94
95/** Keeps Claude and the latest subagents, letting the oldest finished ones go first. */
96function trim(list: readonly Member[]): Member[] {
97  if (list.length <= KEEP_MEMBERS) return [...list]
98  const ended = list.filter(one => one.id !== MAIN && ENDED.includes(one.mood)).sort((a, b) => (a.endedAt ?? 0) - (b.endedAt ?? 0))
99  const drop = new Set(ended.slice(0, list.length - KEEP_MEMBERS).map(one => one.id))
100  return list.filter(one => !drop.has(one.id))
101}
102
103function moodOfStatus(status: AgentInfo['status'], now: Mood): Mood {
104  switch (status) {
105    case 'completed':
106      return ENDED.includes(now) ? now : 'done'
107    case 'failed':
108      return 'failed'
109    case 'killed':
110      return 'stopped'
111    case 'waiting':
112      return 'asking'
113    case 'idle':
114      return 'idle'
115    case 'pending':
116    case 'running':
117      return now === 'idle' || now === 'asking' ? 'thinking' : now
118  }
119}
120
121async function ensureMain($: Engine): Promise<void> {
122  const [at, model] = await Promise.all([$.clock.now(), $.session.model()])
123  await update($, crew, list =>
124    list.some(one => one.id === MAIN)
125      ? list.map(one => (one.id === MAIN ? { ...one, model } : one))
126      : [newMember(MAIN, 'Claude', '', 'clawd_body', at, { mood: 'idle', model }), ...list],
127  )
128}
129
130async function toolStarted($: Engine, who: string, tool: string, detail: string | null): Promise<void> {
131  try {
132    const at = await $.clock.now()
133    inFlight.set(who, (inFlight.get(who) ?? 0) + 1)
134    const mood: Mood = ASKING.has(tool) ? 'asking' : DELEGATING.has(tool) ? 'delegating' : 'working'
135    await update($, crew, list =>
136      list.map(one => {
137        if (one.id !== who) return one
138        return {
139          ...one,
140          mood,
141          since: one.mood === mood ? one.since : at,
142          tool,
143          detail,
144          calls: one.calls + 1,
145          recent: [...one.recent, { tool, detail, at }].slice(-6),
146        }
147      }),
148    )
149  } catch {
150    // The bookkeeping never stands in a tool's way.
151  }
152}
153
154async function toolEnded($: Engine, who: string): Promise<void> {
155  try {
156    const left = Math.max(0, (inFlight.get(who) ?? 1) - 1)
157    inFlight.set(who, left)
158    if (left > 0) return
159    const at = await $.clock.now()
160    await update($, crew, list =>
161      list.map((one): Member => (one.id === who && BUSY.includes(one.mood) ? { ...one, mood: 'thinking', since: at, tool: null } : one)),
162    )
163  } catch {
164    // As above.
165  }
166}
167
168/**
169 * A turn the engine began rather than you: a background task's notice, a
170 * message relayed from another session. Its text is markup, not an ask.
171 */
172const isEngineText = (text: string) => /^\s*<[a-z][\w-]*[\s/>]/i.test(text) || text.startsWith('Another Claude session sent a message')
173
174async function turnStarted($: Engine, text: string): Promise<void> {
175  try {
176    const at = await $.clock.now()
177    inFlight.set(MAIN, 0)
178    const prompt = isEngineText(text) ? '' : text.trim()
179    await update($, crew, list =>
180      list.map((one): Member =>
181        one.id === MAIN
182          ? { ...one, mood: 'thinking', since: at, task: prompt === '' ? one.task : shorten(prompt, 240), calls: 0, startedAt: at, endedAt: null, tool: null, detail: null }
183          : one,
184      ),
185    )
186  } catch {
187    // As above.
188  }
189}
190
191async function turnEnded($: Engine, e: TurnCompleteInput): Promise<void> {
192  try {
193    const at = await $.clock.now()
194    if (e.agentId === undefined) {
195      inFlight.set(MAIN, 0)
196      await update($, crew, list => list.map((one): Member => (one.id === MAIN ? { ...one, mood: 'idle', since: at, tool: null, endedAt: at } : one)))
197      await refresh($, true, true)
198      return
199    }
200    const id = e.agentId
201    const member = (await read($, crew)).find(one => one.id === id)
202    if (member === undefined) return
203    inFlight.delete(id)
204    // What the run took, as Claude Code counts it: the context its last
205    // response stood on. A turn's usage sums every response, re-reading the
206    // same context each time, so without a step seen it counts each token once.
207    const usage = e.usage
208    const used =
209      lastSize.get(id) ?? (usage === undefined ? null : usage.input_tokens + usage.cache_creation_input_tokens + usage.output_tokens)
210    lastSize.delete(id)
211    const mood: Mood = member.isTeammate ? 'idle' : e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'stopped' : 'failed'
212    await update($, crew, list =>
213      list.map((one): Member =>
214        one.id === id ? { ...one, mood, since: at, tool: null, endedAt: member.isTeammate ? null : at, tokens: used ?? one.tokens } : one,
215      ),
216    )
217  } catch {
218    // As above.
219  }
220}
221
222async function joined($: Engine, e: AgentSpawnInput, agentId: string, model: string): Promise<void> {
223  try {
224    const at = await $.clock.now()
225    await update($, crew, list => {
226      if (list.some(one => one.id === agentId)) return list
227      const subs = list.filter(one => one.id !== MAIN).length
228      const type = e.name ?? e.subagentType
229      return trim([
230        ...list,
231        newMember(agentId, type, e.description, colorFor(subs), at, { model, isBackground: e.background, isTeammate: e.isTeammate === true }),
232      ])
233    })
234  } catch {
235    // As above.
236  }
237}
238
239/** Squares the crew with the engine's own list: statuses, and agents started before this load. */
240async function reconcile($: Engine): Promise<void> {
241  try {
242    const [agents, at] = await Promise.all([$.agent.list(), $.clock.now()])
243    await update($, crew, list => {
244      const known = new Set(list.map(one => one.id))
245      const next = list.map(one => {
246        const info = agents.find(agent => agent.id === one.id)
247        if (info === undefined || one.id === MAIN) return one
248        const mood = moodOfStatus(info.status, one.mood)
249        if (mood === one.mood) return one
250        return { ...one, mood, since: at, endedAt: ENDED.includes(mood) ? (one.endedAt ?? at) : null }
251      })
252      const subs = next.filter(one => one.id !== MAIN).length
253      const fresh = agents
254        .filter(agent => !known.has(agent.id))
255        .map((agent, i) =>
256          newMember(agent.id, agent.name ?? agent.type, agent.description, colorFor(subs + i), at, {
257            mood: moodOfStatus(agent.status, 'thinking'),
258            isTeammate: agent.teammateId !== undefined,
259          }),
260        )
261      return fresh.length === 0 && next.every((one, i) => one === list[i]) ? list : trim([...next, ...fresh])
262    })
263  } catch {
264    // The list is a nicety; a failed read waits for the next beat.
265  }
266}
267
268// ── Readings ──────────────────────────────────────────────────────────────
269
270async function refresh($: Engine, withBreakdown: boolean, isTurnEnd = false): Promise<void> {
271  try {
272    const usage = await $.session.usage(withBreakdown ? { breakdown: 'summary' } : {})
273    const [model, at, before] = await Promise.all([$.session.model(), $.clock.now(), read($, reading)])
274    const live = usage.context
275    const breakdown = live.breakdown
276    // The window /context measures against: the model's, or a compaction
277    // window set smaller. Its breakdown comes now and then; until the next,
278    // the last one's window stands.
279    const window = breakdown?.rawMaxTokens ?? before?.context?.window ?? live.window
280    // Before the window's first response (a fresh session, a compaction just
281    // run) only /context's estimate has a figure: what the next one carries.
282    const used = live.tokens !== undefined && live.tokens > 0 ? live.tokens : (breakdown?.totalTokens ?? before?.context?.tokens ?? 0)
283    const context = used <= 0 || window <= 0 ? null : { tokens: used, window, percent: (used / window) * 100 }
284    const next: Reading = {
285      at,
286      model,
287      startedAt: usage.startedAt,
288      context,
289      compactAt:
290        breakdown === undefined
291          ? (before?.compactAt ?? null)
292          : breakdown.isAutoCompactEnabled
293            ? (breakdown.autoCompactThreshold ?? null)
294            : null,
295      categories:
296        breakdown === undefined
297          ? (before?.categories ?? [])
298          : breakdown.categories.flatMap(one =>
299              one.kind === 'deferred' || one.tokens <= 0 ? [] : [{ name: one.name, tokens: one.tokens, color: one.color, kind: one.kind }],
300            ),
301      limits: usage.rateLimits.map(one => ({ kind: one.kind, percent: one.percentUsed, resetsAt: one.resetsAt ?? null })),
302      costUsd: usage.cost?.usd ?? null,
303    }
304    await update($, reading, () => next)
305
306    if (isTurnEnd && context !== null) await update($, turns, list => [...list, context.tokens].slice(-40))
307
308    const fiveHour = next.limits.find(one => one.kind === 'five_hour')?.percent ?? null
309    await update($, samples, list => [...list.filter(one => at - one.at <= 50 * 60_000), { at, costUsd: next.costUsd, fiveHour }].slice(-240))
310  } catch {
311    // A reading that fails is retried on the next beat.
312  }
313}
314
315async function heartbeat($: Engine): Promise<void> {
316  beats += 1
317  await refresh($, beats % 4 === 0)
318  await reconcile($)
319}
320
321async function pick($: Engine, id: string): Promise<void> {
322  await update($, selected, now => (now === id ? null : id))
323}
324
325async function openPane($: Engine): Promise<boolean> {
326  void refresh($, true)
327  const opened = await $.ui.open({ id: PANE, title: TITLE })
328  return opened.isPlaced
329}
330
331// ── From facts to what is drawn ───────────────────────────────────────────
332
333function labelOf(member: Member): string {
334  if (member.type === 'general-purpose') return 'General'
335  return member.type
336}
337
338function moodNow(member: Member, now: number): Mood {
339  return member.mood === 'idle' && member.id === MAIN && now - member.since > SLEEP_AFTER_MS ? 'sleeping' : member.mood
340}
341
342function captionFor(member: Member, now: number, isNarrow = false): string {
343  const mood = moodNow(member, now)
344  switch (mood) {
345    case 'working':
346    case 'asking':
347    case 'delegating':
348      // The bubble names the tool; a subagent's narrow caption keeps the argument.
349      if (member.tool === null) return 'working'
350      return isNarrow && member.detail !== null ? member.detail : captionOf(member.tool, member.detail)
351    case 'thinking':
352      return 'thinking…'
353    case 'idle':
354      return member.id === MAIN ? 'waiting for you' : 'idle'
355    case 'sleeping':
356      return 'napping'
357    case 'done':
358      return `done · ${span((member.endedAt ?? now) - member.startedAt)}`
359    case 'failed':
360      return 'failed'
361    case 'stopped':
362      return 'stopped'
363  }
364}
365
366const MOOD_WORD: Record<Mood, string> = {
367  working: 'Working',
368  thinking: 'Thinking',
369  asking: 'Needs you',
370  delegating: 'Waiting on the crew',
371  idle: 'Idle',
372  sleeping: 'Napping',
373  done: 'Done',
374  failed: 'Failed',
375  stopped: 'Stopped',
376}
377
378const MOOD_TONE: Record<Mood, Tone> = {
379  working: 'ok',
380  thinking: 'ok',
381  asking: 'warn',
382  delegating: 'muted',
383  idle: 'muted',
384  sleeping: 'muted',
385  done: 'ok',
386  failed: 'bad',
387  stopped: 'muted',
388}
389
390/** Claude first, then the crew at work in the order they joined, then the ones that finished, latest first. */
391function lineUp(members: readonly Member[], now: number, isStage: boolean): Member[] {
392  const main = members.filter(one => one.id === MAIN)
393  const subs = members.filter(one => one.id !== MAIN)
394  const busy = subs.filter(one => !ENDED.includes(one.mood)).sort((a, b) => a.startedAt - b.startedAt)
395  const ended = subs
396    .filter(one => ENDED.includes(one.mood) && (!isStage || now - (one.endedAt ?? now) < OFF_STAGE_AFTER_MS))
397    .sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
398  return [...main, ...busy, ...ended]
399}
400
401/** How long since it joined; one kept from before join times were kept counts as long ago. */
402const joinedMs = (member: Member, now: number) => (typeof member.joinedAt === 'number' ? Math.max(0, now - member.joinedAt) : Number.MAX_SAFE_INTEGER)
403
404function actorOf(member: Member, now: number): Actor {
405  return {
406    id: member.id,
407    name: labelOf(member),
408    caption: captionFor(member, now, member.id !== MAIN),
409    color: member.color,
410    mood: moodNow(member, now),
411    ageMs: Math.max(0, now - member.since),
412    joinedMs: joinedMs(member, now),
413    tool: member.tool === null ? null : badgeOf(member.tool),
414    isMain: member.id === MAIN,
415  }
416}
417
418/** The band's Clawds: Claude, the crew at work in the order they joined, and those just ended, still playing it out. */
419function stripOf(members: readonly Member[], now: number, where: Where): StripProps {
420  const main = members.filter(one => one.id === MAIN)
421  const subs = members
422    .filter(one => one.id !== MAIN && (!ENDED.includes(one.mood) || now - (one.endedAt ?? now) < OFF_STRIP_AFTER_MS))
423    .sort((a, b) => a.startedAt - b.startedAt)
424  const walkers: Walker[] = [...main, ...subs].map(one => ({
425    id: one.id,
426    name: one.id === MAIN ? 'Claude' : labelOf(one),
427    // Claude's is your own last prompt: its bubble leaves it out.
428    task: one.id === MAIN ? '' : shorten(one.task, 120),
429    doing: captionFor(one, now),
430    color: one.color,
431    mood: moodNow(one, now),
432    ageMs: Math.max(0, now - one.since),
433    joinedMs: joinedMs(one, now),
434  }))
435  return { view: 'strip', surface: where, walkers }
436}
437
438/** What the stage says while Claude is alone on it: what became of the crew, if there was one. */
439function hintOf(members: readonly Member[]): string | null {
440  const done = members.filter(one => one.id !== MAIN && ENDED.includes(one.mood)).length
441  return done === 0 ? null : `${plural(done, 'subagent')} done and off the stage · see Crew below`
442}
443
444function spotlightOf(member: Member, now: number, isPinned: boolean): Spotlight {
445  const mood = moodNow(member, now)
446  const facts = [
447    member.id === MAIN ? plural(member.calls, 'tool call') + ' this turn' : plural(member.calls, 'tool call'),
448    member.tokens === null ? null : `${tokens(member.tokens)} tokens`,
449    member.model === null ? null : prettyModel(member.model),
450    member.isBackground ? 'in the background' : null,
451  ].filter((one): one is string => one !== null)
452  return {
453    id: member.id,
454    name: member.id === MAIN ? 'Claude' : labelOf(member),
455    color: member.color,
456    status: MOOD_WORD[mood],
457    tone: MOOD_TONE[mood],
458    elapsed: span((member.endedAt ?? now) - member.startedAt),
459    task: member.task === '' ? null : member.task,
460    doing: member.tool === null || !BUSY.includes(mood) ? null : { verb: verbOf(member.tool), detail: member.detail },
461    facts: facts.join(' · '),
462    recent: [...member.recent]
463      .reverse()
464      .slice(0, 5)
465      .map(step => ({ tool: step.tool, detail: step.detail, ago: `${span(now - step.at)} ago` })),
466    isPinned,
467  }
468}
469
470/** The pinned agent, else the newest subagent at work, else Claude. */
471function focusOf(members: readonly Member[], pinned: string | null): { member: Member | null; isPinned: boolean } {
472  const chosen = pinned === null ? undefined : members.find(one => one.id === pinned)
473  if (chosen !== undefined) return { member: chosen, isPinned: true }
474  const busy = members.filter(one => one.id !== MAIN && !ENDED.includes(one.mood)).sort((a, b) => b.startedAt - a.startedAt)[0]
475  return { member: busy ?? members.find(one => one.id === MAIN) ?? null, isPinned: false }
476}
477
478function headlineOf(members: readonly Member[], now: number): string {
479  const main = members.find(one => one.id === MAIN)
480  const subs = members.filter(one => one.id !== MAIN)
481  const busy = subs.filter(one => !ENDED.includes(one.mood)).length
482  const done = subs.filter(one => one.mood === 'done').length
483  const claude =
484    main === undefined
485      ? null
486      : moodNow(main, now) === 'idle' || moodNow(main, now) === 'sleeping'
487        ? 'Claude is waiting for you'
488        : main.mood === 'asking'
489          ? 'Claude needs you'
490          : main.mood === 'delegating'
491            ? 'Claude is waiting on the crew'
492            : 'Claude is working'
493  return [claude, busy === 0 ? null : `${plural(busy, 'subagent')} running`, done === 0 ? null : `${done} done`]
494    .filter((one): one is string => one !== null)
495    .join(' · ')
496}
497
498function compactNote(fill: Fill): { text: string; tone: Tone } | null {
499  if (fill.compactTokens === null) return null
500  const at = `auto-compact at ${tokens(fill.compactTokens)}`
501  if (fill.turnsLeft === null) return { text: at, tone: 'muted' }
502  const tone: Tone = fill.turnsLeft <= 2 ? 'bad' : fill.turnsLeft <= 5 ? 'warn' : 'muted'
503  return { text: `${at} · ${fill.turnsLeft === 0 ? 'compacts next turn' : `~${plural(fill.turnsLeft, 'turn')} to compact`}`, tone }
504}
505
506function contextOf(fill: Fill, current: Reading | null): ContextView {
507  const parts = current?.categories ?? []
508  const total = parts.reduce((sum, one) => sum + one.tokens, 0)
509  return {
510    used: fill.tokens === null ? null : tokens(fill.tokens),
511    window: fill.window === null ? null : tokens(fill.window),
512    compact: compactNote(fill),
513    parts:
514      total <= 0
515        ? []
516        : [
517            ...parts.filter(one => one.kind === 'used').sort((a, b) => b.tokens - a.tokens),
518            // The buffer auto-compact keeps is left out: the compact note says where it starts.
519            ...parts.filter(one => one.kind === 'free'),
520          ].map(one => ({ name: one.name, share: one.tokens / total, color: one.color, kind: one.kind })),
521  }
522}
523
524// ── The hooks ─────────────────────────────────────────────────────────────
525
526export const register: Register = on => {
527  // One Mission control a session: a second copy of this plugin, from
528  // another folder (a working copy beside the installed one), stays out
529  // rather than drawing a second band. A reload of this one comes from its
530  // own folder, and passes.
531  on('plugin.register', async ($, e, next) =>
532    e.name === $.plugin.name && e.root !== $.plugin.root
533      ? { refuse: `${$.plugin.name} is already loaded from ${$.plugin.root}` }
534      : next(e),
535  ).catch(($, e, next) => next(e))
536
537  on('session.start', async ($, e, next) => {
538    const started = await next(e)
539    await $.command.register({ name: 'mission', description: 'Open Mission control: every agent live, the context and the budget' })
540    // Every load starts with the band in view: once hidden it has no way back
541    // of its own (the switch is in the pane), so a hide never outlives the
542    // load it was made in, an older version's Hide button's included.
543    await update($, isBandHidden, () => false)
544    await ensureMain($)
545    await refresh($, true)
546    await reconcile($)
547    $.clock.every(BEAT_MS, () => {
548      void heartbeat($)
549    })
550    return started
551  })
552
553  on('turn.start', async ($, e, next) => {
554    await turnStarted($, e.text)
555    return next(e)
556  })
557
558  on('turn.complete', async ($, e, next) => {
559    const done = await next(e)
560    await turnEnded($, e)
561    return done
562  })
563
564  on('agent.spawn', async ($, e, next) => {
565    const spawned = await next(e)
566    if ('agentId' in spawned && typeof spawned.agentId === 'string') await joined($, e, spawned.agentId, spawned.model)
567    return spawned
568  }).catch(($, e, next) => next(e))
569
570  on('turn.step', async function* ($, e, next) {
571    const result = yield* next(e)
572    try {
573      if (e.agentId !== undefined && result.usage !== null) lastSize.set(e.agentId, sizeOf(result.usage))
574    } catch {
575      // A count missed is a count estimated at the run's end.
576    }
577    return result
578  })
579
580  on('session.compact', async ($, e, next) => {
581    const done = await next(e)
582    try {
583      if (e.agentId === undefined && e.trigger !== 'precompute' && done.skip === undefined) {
584        await update($, turns, () => [])
585        void refresh($, true)
586      }
587    } catch {
588      // As above.
589    }
590    return done
591  }).catch(($, e, next) => next(e))
592
593  on('tool.call', async ($, e, next) => {
594    const who = e.agentId ?? MAIN
595    const tool = String(e.tool)
596    if (INTERNAL.has(tool)) return next(e)
597    await toolStarted($, who, tool, detailOf(tool, e as unknown as Readonly<Record<string, unknown>>))
598    try {
599      return await next(e)
600    } finally {
601      await toolEnded($, who)
602    }
603  }).catch(($, e, next) => next(e))
604
605  on('ui.message', async ($, e, next) => {
606    const data = e.data
607    if (typeof data === 'object' && data !== null) {
608      const message = data as Record<string, unknown>
609      if (typeof message.select === 'string') await pick($, message.select)
610      if (typeof message.focus === 'string') await update($, selected, () => (message.focus === MAIN ? null : (message.focus as string)))
611      if (message.open === true) await openPane($)
612    }
613    return next(e)
614  })
615
616  on('command.run', { command: 'mission' }, async $ => {
617    const isPlaced = await openPane($)
618    return { text: isPlaced ? 'Mission control is open.' : 'Mission control is ready; widen the window to see it.' }
619  })
620
621  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
622    if (e.props.hasSurvey) return next(e)
623    // Each reading redraws the band: the context under the crew stays current,
624    // a long wait turns into a nap and a hover's figures stay fresh.
625    const [isHidden, members, current, history, now] = await Promise.all([read($, isBandHidden), read($, crew), read($, reading), read($, turns), $.clock.now()])
626    if (isHidden) return next(e)
627
628    const table = $.ui.resolve(e)
629    const kit: Kit = { surface: e.surface, Box: table.Box, Text: table.Text, Button: table.Button }
630    const Client = 'Client' in table ? table.Client : undefined
631    const where: Where | null = e.surface === 'desktop' || e.surface === 'terminal' ? e.surface : null
632    const { Box } = kit
633    const shown = contextOf(fillOf(current, history), current)
634
635    return Band(
636      kit,
637      {
638        strip:
639          Client === undefined || where === null ? null : (
640            <Box flexGrow={1} flexShrink={1} minWidth={where === 'desktop' ? STRIP_MAIN_CH : 11}>
641              <Client key="strip" module="./clawd.tsx" width="100%" height={where === 'desktop' ? STRIP_LH : 3} props={stripOf(members, now, where)} />
642            </Box>
643          ),
644        context: shown.used === null ? null : shown,
645        width: e.props.bodyColumns,
646        rows: e.props.maxRows,
647      },
648      { open: () => void openPane($) },
649    )
650  })
651
652  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
653    const [members, pinned, current, history, taken, isHidden, now] = await Promise.all([
654      read($, crew),
655      read($, selected),
656      read($, reading),
657      read($, turns),
658      read($, samples),
659      read($, isBandHidden),
660      $.clock.now(),
661    ])
662    const table = $.ui.resolve(e)
663    const kit: Kit = { surface: e.surface, Box: table.Box, Text: table.Text, Button: table.Button }
664    const Client = 'Client' in table ? table.Client : undefined
665    const where: Where | null = e.surface === 'desktop' || e.surface === 'terminal' ? e.surface : null
666    const width = Math.max(30, e.props.bodyColumns)
667    const fill = fillOf(current, history)
668    const focus = focusOf(members, pinned)
669    const isBig = where === 'desktop' || width >= 44
670
671    const stageProps: StageProps | null =
672      where === null
673        ? null
674        : {
675            view: 'stage',
676            surface: where,
677            big: isBig,
678            actors: lineUp(members, now, true).map(one => actorOf(one, now)),
679            selected: focus.member?.id ?? null,
680            hint: hintOf(members),
681          }
682
683    const crewRows: CrewRow[] = lineUp(members, now, false)
684      .slice(0, 10)
685      .map(one => ({
686        id: one.id,
687        name: one.id === MAIN ? 'Claude' : labelOf(one),
688        color: one.color,
689        caption: captionFor(one, now),
690        elapsed: span((one.endedAt ?? now) - one.startedAt),
691        isEnded: ENDED.includes(one.mood),
692        isPicked: one.id === focus.member?.id,
693      }))
694
695    return Pane(
696      kit,
697      {
698        headline: headlineOf(members, now),
699        stage:
700          Client === undefined || stageProps === null ? null : (
701            <Client key="stage" module="./clawd.tsx" width="100%" height={where === 'desktop' ? 7.5 : isBig ? 10 : 7} props={stageProps} />
702          ),
703        spotlight: focus.member === null ? null : spotlightOf(focus.member, now, focus.isPinned),
704        crew: crewRows,
705        context: contextOf(fill, current),
706        spend: spendLine(current, taken, now),
707        paces: (current?.limits ?? []).map(one => paceOf(one, taken, now)),
708        isBandHidden: isHidden,
709        width,
710      },
711      {
712        close: () => void $.ui.close({ id: PANE }),
713        pick: id => void pick($, id),
714        follow: () => void update($, selected, () => null),
715        toggleBand: () => void update($, isBandHidden, value => !value),
716      },
717    )
718  })
719}
720
hooks/budget.ts 138 lines
1// The arithmetic behind the band and the pane: how full the context is and
2// how many turns are left before auto-compact, what the session burns
3// per hour, and whether each rate-limit window is on pace to last.
4
5import type { Limit, Reading, Sample } from '../types'
6import { clock, limitInfo, money, span, when } from './format'
7
8export type Tone = 'ok' | 'warn' | 'bad' | 'muted'
9
10// ── Context ───────────────────────────────────────────────────────────────
11
12export type Fill = {
13  tokens: number | null
14  window: number | null
15  /** Shares of the window, 0 to 1: what is used, and where auto-compact runs. */
16  used: number | null
17  compactAt: number | null
18  compactTokens: number | null
19  /** Average growth per turn over the recent turns, in tokens. */
20  perTurn: number | null
21  /** Whole turns left before auto-compact at that pace. */
22  turnsLeft: number | null
23  tone: Tone
24}
25
26const RECENT_TURNS = 6
27
28export function fillOf(reading: Reading | null, turns: readonly number[]): Fill {
29  const context = reading?.context ?? null
30  const window = context?.window ?? null
31  const tokens = context?.tokens ?? null
32  const compactTokens = reading?.compactAt ?? null
33  const share = (count: number | null) => (count === null || window === null || window <= 0 ? null : Math.min(1, count / window))
34
35  const growth: number[] = []
36  for (let i = 1; i < turns.length; i++) {
37    const delta = (turns[i] ?? 0) - (turns[i - 1] ?? 0)
38    if (delta > 0) growth.push(delta)
39  }
40  const recent = growth.slice(-RECENT_TURNS)
41  const perTurn = recent.length === 0 ? null : recent.reduce((sum, one) => sum + one, 0) / recent.length
42  const turnsLeft =
43    tokens !== null && compactTokens !== null && perTurn !== null && perTurn > 0 ? Math.max(0, Math.floor((compactTokens - tokens) / perTurn)) : null
44
45  const ratio = tokens === null ? 0 : compactTokens !== null ? tokens / compactTokens : (share(tokens) ?? 0)
46  const tone: Tone = ratio >= 0.9 ? 'bad' : ratio >= 0.75 ? 'warn' : 'ok'
47
48  return { tokens, window, used: share(tokens), compactAt: share(compactTokens), compactTokens, perTurn, turnsLeft, tone }
49}
50
51// ── Spend ─────────────────────────────────────────────────────────────────
52
53/** Dollars per hour over the last half hour of samples, once there are five minutes of them. */
54export function burnOf(samples: readonly Sample[], now: number): number | null {
55  const priced = samples.filter(one => one.costUsd !== null && now - one.at <= 30 * 60_000)
56  const first = priced[0]
57  const last = priced[priced.length - 1]
58  if (first === undefined || last === undefined || first.costUsd === null || last.costUsd === null) return null
59  const hours = (last.at - first.at) / 3_600_000
60  if (hours < 5 / 60) return null
61  return Math.max(0, (last.costUsd - first.costUsd) / hours)
62}
63
64export function spendLine(reading: Reading | null, samples: readonly Sample[], now: number): string | null {
65  const cost = reading?.costUsd ?? null
66  if (cost === null) return null
67  const burn = burnOf(samples, now)
68  return burn === null ? money(cost) : `${money(cost)} · ${money(burn)}/h`
69}
70
71// ── Rate-limit windows ────────────────────────────────────────────────────
72
73export type Pace = {
74  kind: string
75  label: string
76  percent: number
77  /** How much of the window's time has gone, as a percent; null when not known. */
78  elapsed: number | null
79  /** Where it would stand at the reset at the recent rate. */
80  projected: number | null
81  verdict: string
82  tone: Tone
83  resets: string | null
84}
85
86export function paceOf(limit: Limit, samples: readonly Sample[], now: number): Pace {
87  const { label, hours } = limitInfo(limit.kind)
88  const resetAt = limit.resetsAt === null ? Number.NaN : Date.parse(limit.resetsAt)
89  const hasReset = !Number.isNaN(resetAt) && resetAt > now
90  const resets = hasReset ? when(resetAt, now) : null
91  const base = { kind: limit.kind, label, percent: limit.percent, resets }
92
93  if (limit.percent >= 100) {
94    return { ...base, elapsed: null, projected: null, verdict: resets === null ? 'limit reached' : `limit reached · back ${resets}`, tone: 'bad' }
95  }
96  if (hours === null || !hasReset) {
97    return { ...base, elapsed: null, projected: null, verdict: resets === null ? 'used' : `resets ${resets}`, tone: limit.percent >= 80 ? 'warn' : 'muted' }
98  }
99
100  const length = hours * 3_600_000
101  const start = resetAt - length
102  const gone = clampNumber((now - start) / length, 0, 1)
103  const left = resetAt - now
104
105  // The recent rate, from this session's samples of the 5-hour window; else
106  // the window's own average since it opened.
107  let rate: number | null = null
108  if (limit.kind === 'five_hour') {
109    const recent = samples.filter(one => one.fiveHour !== null && now - one.at <= 45 * 60_000 && one.at >= start)
110    const first = recent[0]
111    const last = recent[recent.length - 1]
112    if (first !== undefined && last !== undefined && first.fiveHour !== null && last.fiveHour !== null && last.at - first.at >= 10 * 60_000) {
113      rate = Math.max(0, (last.fiveHour - first.fiveHour) / (last.at - first.at))
114    }
115  }
116  if (rate === null && now > start) rate = limit.percent / (now - start)
117  const projected = rate === null ? null : limit.percent + rate * left
118
119  if (projected !== null && rate !== null && rate > 0 && projected >= 100) {
120    const outAt = now + (100 - limit.percent) / rate
121    return { ...base, elapsed: gone * 100, projected, verdict: `out ~${clock(outAt)} · resets ${resets}`, tone: 'bad' }
122  }
123  if (projected !== null && projected >= 85) {
124    return { ...base, elapsed: gone * 100, projected, verdict: `tight · ~${Math.round(projected)}% by reset`, tone: 'warn' }
125  }
126  return {
127    ...base,
128    elapsed: gone * 100,
129    projected,
130    verdict: `on pace · resets ${hours <= 24 ? `in ${span(left)}` : resets}`,
131    tone: 'ok',
132  }
133}
134
135function clampNumber(n: number, low: number, high: number): number {
136  return Math.max(low, Math.min(high, n))
137}
138
hooks/format.ts 78 lines
1// Plain formatting: numbers, money, durations, model names, short text.
2
3export function tokens(count: number): string {
4  if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(count % 1_000_000 === 0 ? 0 : 1)}M`
5  if (count >= 10_000) return `${Math.round(count / 1000)}k`
6  if (count >= 1000) return `${(count / 1000).toFixed(1)}k`
7  return String(count)
8}
9
10export function money(usd: number): string {
11  if (usd >= 100) return `$${Math.round(usd)}`
12  return `$${usd.toFixed(2)}`
13}
14
15/** `42s`, `3m`, `1h 5m`, `2d`. */
16export function span(ms: number): string {
17  const seconds = Math.max(0, Math.round(ms / 1000))
18  if (seconds < 60) return `${seconds}s`
19  const minutes = Math.round(seconds / 60)
20  if (minutes < 60) return `${minutes}m`
21  const hours = Math.floor(minutes / 60)
22  if (hours < 48) return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`
23  return `${Math.round(hours / 24)}d`
24}
25
26export function clock(at: number): string {
27  const date = new Date(at)
28  return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
29}
30
31const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
32
33/** `15:40` today, else `Mon 09:00`. */
34export function when(at: number, now: number): string {
35  const day = new Date(at)
36  const today = new Date(now)
37  const isToday = day.getFullYear() === today.getFullYear() && day.getMonth() === today.getMonth() && day.getDate() === today.getDate()
38  return isToday ? clock(at) : `${DAYS[day.getDay()] ?? ''} ${clock(at)}`
39}
40
41const LIMITS: Record<string, { label: string; hours: number | null }> = {
42  five_hour: { label: '5-hour', hours: 5 },
43  seven_day: { label: 'Weekly', hours: 7 * 24 },
44  seven_day_opus: { label: 'Weekly Opus', hours: 7 * 24 },
45  spend_limit: { label: 'Spend', hours: null },
46}
47
48export function limitInfo(kind: string): { label: string; hours: number | null } {
49  const known = LIMITS[kind]
50  if (known !== undefined) return known
51  const words = kind.replace(/_/g, ' ')
52  return { label: words.charAt(0).toUpperCase() + words.slice(1), hours: null }
53}
54
55/** `claude-opus-5-5[1m]` → `Opus 5.5`; anything else as given. */
56export function prettyModel(id: string): string {
57  const found = /(opus|sonnet|haiku|fable)[-_ ]?(\d+)(?:[-_.](\d+))?/i.exec(id)
58  if (found === null) return id
59  const family = found[1] ?? ''
60  const name = family.charAt(0).toUpperCase() + family.slice(1).toLowerCase()
61  const version = found[3] === undefined || found[3].length > 2 ? found[2] : `${found[2]}.${found[3]}`
62  return `${name} ${version}`
63}
64
65export function shorten(text: string, width: number): string {
66  const line = text.replace(/\s+/g, ' ').trim()
67  return line.length <= width ? line : `${line.slice(0, Math.max(1, width - 1))}…`
68}
69
70export function plural(count: number, one: string, many = `${one}s`): string {
71  return `${count} ${count === 1 ? one : many}`
72}
73
74export function baseName(path: string): string {
75  const trimmed = path.replace(/\/+$/, '')
76  return trimmed.slice(trimmed.lastIndexOf('/') + 1) || trimmed
77}
78
hooks/tools.ts 124 lines
1// What a tool call is doing, in a few words: "Reading app.tsx", "Running bun
2// test". The badge is what a Clawd's speech bubble says while it runs.
3
4import { baseName, shorten } from './format'
5
6type Input = Readonly<Record<string, unknown>>
7
8const text = (input: Input, key: string): string | null => {
9  const value = input[key]
10  return typeof value === 'string' && value.trim() !== '' ? value : null
11}
12
13const VERBS: Record<string, string> = {
14  Read: 'Reading',
15  Edit: 'Editing',
16  MultiEdit: 'Editing',
17  Write: 'Writing',
18  NotebookEdit: 'Editing',
19  Bash: 'Running',
20  BashOutput: 'Watching',
21  KillShell: 'Stopping',
22  Grep: 'Searching',
23  Glob: 'Finding',
24  WebFetch: 'Fetching',
25  WebSearch: 'Searching the web',
26  Agent: 'Delegating',
27  Task: 'Delegating',
28  TodoWrite: 'Planning',
29  AskUserQuestion: 'Asking you',
30  ExitPlanMode: 'Presenting a plan',
31  Skill: 'Using',
32  SendMessage: 'Messaging',
33  ToolSearch: 'Loading tools',
34}
35
36/** Tools that wait on the person while they run. */
37export const ASKING = new Set(['AskUserQuestion', 'ExitPlanMode'])
38
39/** Tools that wait on subagents while they run. */
40export const DELEGATING = new Set(['Agent', 'Task'])
41
42function mcpParts(tool: string): [string, string] | null {
43  const found = /^mcp__(.+?)__(.+)$/.exec(tool)
44  return found === null ? null : [found[1] ?? '', found[2] ?? '']
45}
46
47/** The argument worth showing: a file's name, a command, a pattern, a host. */
48export function detailOf(tool: string, input: Input): string | null {
49  switch (tool) {
50    case 'Read':
51    case 'Edit':
52    case 'MultiEdit':
53    case 'Write': {
54      const path = text(input, 'file_path')
55      return path === null ? null : baseName(path)
56    }
57    case 'NotebookEdit': {
58      const path = text(input, 'notebook_path')
59      return path === null ? null : baseName(path)
60    }
61    case 'Bash': {
62      const command = text(input, 'command')
63      if (command === null) return null
64      const line = (command.split('\n')[0] ?? '').replace(/^cd\s+\S+\s*&&\s*/, '')
65      return shorten(line, 48)
66    }
67    case 'Grep': {
68      const pattern = text(input, 'pattern')
69      return pattern === null ? null : `"${shorten(pattern, 32)}"`
70    }
71    case 'Glob':
72      return text(input, 'pattern')
73    case 'WebFetch': {
74      const url = text(input, 'url')
75      if (url === null) return null
76      const host = /^[a-z]+:\/\/([^/?#]+)/i.exec(url)
77      return host?.[1] ?? shorten(url, 32)
78    }
79    case 'WebSearch': {
80      const query = text(input, 'query')
81      return query === null ? null : `"${shorten(query, 32)}"`
82    }
83    case 'Agent':
84    case 'Task':
85      return text(input, 'description')
86    case 'TodoWrite': {
87      const todos = input.todos
88      return Array.isArray(todos) ? `${todos.length} todos` : null
89    }
90    case 'AskUserQuestion': {
91      const questions = input.questions
92      const first = Array.isArray(questions) ? (questions[0] as Input | undefined) : undefined
93      const question = first === undefined ? null : text(first, 'question')
94      return question === null ? null : shorten(question, 48)
95    }
96    case 'Skill':
97      return text(input, 'skill') ?? text(input, 'command')
98    case 'SendMessage':
99      return text(input, 'to') ?? text(input, 'recipient')
100    default: {
101      const mcp = mcpParts(tool)
102      return mcp === null ? null : mcp[1].replace(/_/g, ' ')
103    }
104  }
105}
106
107export function verbOf(tool: string): string {
108  const known = VERBS[tool]
109  if (known !== undefined) return known
110  const mcp = mcpParts(tool)
111  return mcp === null ? `Using ${tool}` : `Calling ${mcp[0]}`
112}
113
114/** What a tool call reads as: `Reading app.tsx`. */
115export function captionOf(tool: string, detail: string | null): string {
116  return detail === null ? verbOf(tool) : `${verbOf(tool)} ${detail}`
117}
118
119/** The speech-bubble word: the tool, or an MCP tool's server. */
120export function badgeOf(tool: string): string {
121  const mcp = mcpParts(tool)
122  return shorten(mcp === null ? tool : mcp[0], 12)
123}
124
hooks/view.tsx 458 lines
1// The band and the pane, as plain functions over a surface's element table.
2//
3// Every colour is a theme key, never a raw colour: the desktop maps each key
4// onto its own design token and the terminal onto the person's theme, so
5// both follow light, dark and custom themes. The animated parts (the stage
6// and the band's strip of Clawds) are `Client`s the hooks module builds and
7// hands in.
8
9import type { BoxProps, ButtonProps, ElementConstructor, RenderElement, RenderSurface, TextProps } from 'claude-code'
10
11import type { Pace, Tone } from './budget'
12
13export type Kit = {
14  surface: RenderSurface
15  Box: ElementConstructor<BoxProps>
16  Text: ElementConstructor<TextProps>
17  Button: ElementConstructor<ButtonProps>
18}
19
20export const INK: Record<Tone, string> = { ok: 'success', warn: 'warning', bad: 'error', muted: 'subtle' }
21
22const isTerminal = (kit: Kit) => kit.surface === 'terminal'
23
24/** Half a line between stacked parts on the desktop; none in a terminal. */
25const breath = (kit: Kit, rows = 1) => (isTerminal(kit) ? 0 : rows)
26
27function card(kit: Kit, ink: string | null): Partial<BoxProps> {
28  if (isTerminal(kit)) return { borderStyle: 'round', borderColor: ink ?? 'subtle', paddingX: 1 }
29  return ink === null ? { borderStyle: 'round' } : { borderStyle: 'round', borderColor: ink, borderDimColor: true }
30}
31
32function section(kit: Kit, title: string, children: RenderElement[], aside: RenderElement | null = null): RenderElement {
33  const { Box, Text } = kit
34  return (
35    <Box flexDirection="column" rowGap={breath(kit, 0.5)}>
36      <Box flexDirection="row" alignItems="center">
37        <Text bold color="inactive">
38          {title}
39        </Text>
40        <Box flexGrow={1} />
41        {aside}
42      </Box>
43      {children}
44    </Box>
45  )
46}
47
48/** Splits `columns` among shares by largest remainder, so the cells add up. */
49function cellsOf(shares: readonly number[], columns: number): number[] {
50  const total = shares.reduce((sum, one) => sum + one, 0)
51  if (total <= 0) return shares.map(() => 0)
52  const exact = shares.map(one => (one / total) * columns)
53  const cells = exact.map(Math.floor)
54  let left = columns - cells.reduce((sum, one) => sum + one, 0)
55  const order = exact.map((one, i) => [one - Math.floor(one), i] as const).sort((a, b) => b[0] - a[0])
56  for (const [, i] of order) {
57    if (left <= 0) break
58    cells[i] = (cells[i] ?? 0) + 1
59    left -= 1
60  }
61  return cells
62}
63
64/** A width the engine takes: a whole percent. */
65const percent = (n: number) => `${Math.round(Math.max(0, Math.min(100, n)))}%`
66
67// ── The band ──────────────────────────────────────────────────────────────
68//
69// The crew, alive, and the way in: the Clawds say who is at work and a hover
70// names one and says what it is doing. Under them, the context they work in;
71// the budget waits in the pane.
72
73export type BandView = {
74  /** The crew, animated: a Client the hooks build. */
75  strip: RenderElement | null
76  /** The context under the crew, as the pane shows it; null before the first reading. */
77  context: ContextView | null
78  /** Cells the context spans in a terminal. */
79  width: number
80  /** Rows the band may take before it scrolls, in a terminal. */
81  rows: number
82}
83
84export type BandActions = { open: () => void }
85
86export function Band(kit: Kit, view: BandView, act: BandActions): RenderElement {
87  const { Box, Button } = kit
88  const crew = (
89    <Box flexDirection="row" alignItems="center" width="100%" columnGap={2}>
90      {view.strip ?? <Box flexGrow={1} />}
91      <Box flexShrink={0}>
92        {isTerminal(kit) ? (
93          <Button key="open" label="Mission control" hotkey="m" onPress={act.open} />
94        ) : (
95          <Button key="open" plain label="Mission control" onPress={act.open} />
96        )}
97      </Box>
98    </Box>
99  )
100  const ctx = view.context
101  if (ctx === null) return crew
102  if (isTerminal(kit)) {
103    // Rows are dear here: the bar is the floor the crew stands on, and the
104    // figures and what fills it share the row under it. Short of rows, the
105    // legend goes first, then the figures, so the band never scrolls.
106    const room = view.rows - CREW_ROWS
107    const hasBar = ctx.parts.length > 0 && room >= 1
108    const lines = room - (hasBar ? 1 : 0)
109    const words = lines >= textRows(ctx, view.width) ? [contextLine(kit, ctx), ...legendOf(kit, ctx.parts)] : lines >= 1 ? [contextLine(kit, ctx)] : []
110    return (
111      <Box flexDirection="column" width="100%">
112        {crew}
113        {hasBar ? contextBar(kit, ctx.parts, view.width) : null}
114        {words.length === 0 ? null : (
115          <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
116            {words}
117          </Box>
118        )}
119      </Box>
120    )
121  }
122  return (
123    <Box flexDirection="column" width="100%" rowGap={0.5}>
124      {crew}
125      {contextBlock(kit, ctx, view.width)}
126    </Box>
127  )
128}
129
130// ── The pane ──────────────────────────────────────────────────────────────
131
132export type Spotlight = {
133  id: string
134  name: string
135  color: string
136  status: string
137  tone: Tone
138  elapsed: string
139  task: string | null
140  doing: { verb: string; detail: string | null } | null
141  facts: string
142  recent: { tool: string; detail: string | null; ago: string }[]
143  isPinned: boolean
144}
145
146export type CrewRow = {
147  id: string
148  name: string
149  color: string
150  caption: string
151  elapsed: string
152  isEnded: boolean
153  isPicked: boolean
154}
155
156export type ContextView = {
157  used: string | null
158  window: string | null
159  compact: { text: string; tone: Tone } | null
160  /** What fills the window, each a share of it, with its /context colour: what is used, then the free space. */
161  parts: { name: string; share: number; color: string; kind: 'used' | 'free' | 'buffer' }[]
162}
163
164
165export type PaneView = {
166  headline: string
167  stage: RenderElement | null
168  spotlight: Spotlight | null
169  crew: CrewRow[]
170  context: ContextView
171  spend: string | null
172  paces: Pace[]
173  isBandHidden: boolean
174  width: number
175}
176
177export type PaneActions = {
178  close: () => void
179  pick: (id: string) => void
180  follow: () => void
181  toggleBand: () => void
182}
183
184function spotlightCard(kit: Kit, spot: Spotlight, act: PaneActions): RenderElement {
185  const { Box, Text, Button } = kit
186  return (
187    <Box flexDirection="column" rowGap={breath(kit, 0.5)} {...card(kit, spot.color)}>
188      <Box flexDirection="row" alignItems="center" columnGap={1}>
189        <Text color={spot.color}>●</Text>
190        <Text bold wrap="truncate-end">
191          {spot.name}
192        </Text>
193        <Box flexGrow={1} />
194        <Text color={INK[spot.tone]}>{spot.status}</Text>
195        <Text color="subtle">{spot.elapsed}</Text>
196      </Box>
197      {spot.task === null ? null : (
198        <Text color="inactive" wrap="truncate-end">
199          {spot.task}
200        </Text>
201      )}
202      {spot.doing === null ? null : (
203        <Text wrap="truncate-end">
204          <Text color="permission">{spot.doing.verb}</Text>
205          {spot.doing.detail === null ? '' : ` ${spot.doing.detail}`}
206        </Text>
207      )}
208      <Text color="subtle" wrap="truncate-end">
209        {spot.facts}
210      </Text>
211      {spot.recent.length === 0 ? null : (
212        <Box flexDirection="column" marginTop={breath(kit, 0.5)}>
213          {spot.recent.map(one => (
214            <Box flexDirection="row" columnGap={1}>
215              <Text wrap="truncate-end">
216                <Text color="inactive">{one.tool}</Text>
217                {one.detail === null ? '' : <Text color="subtle">{` ${one.detail}`}</Text>}
218              </Text>
219              <Box flexGrow={1} />
220              <Text color="subtle">{one.ago}</Text>
221            </Box>
222          ))}
223        </Box>
224      )}
225      {spot.isPinned ? (
226        <Box flexDirection="row">
227          <Button key="follow" plain label="Follow the action" onPress={act.follow} />
228        </Box>
229      ) : null}
230    </Box>
231  )
232}
233
234function crewList(kit: Kit, rows: CrewRow[], act: PaneActions): RenderElement {
235  const { Box, Text, Button } = kit
236  return section(
237    kit,
238    'Crew',
239    rows.map(row => (
240      <Box flexDirection="row" alignItems="center" columnGap={1}>
241        <Text color={row.isEnded ? 'subtle' : row.color}>{row.isEnded ? '○' : '●'}</Text>
242        <Button key={`pick:${row.id}`} plain label={row.name} onPress={() => act.pick(row.id)} />
243        <Text color={row.isEnded ? 'subtle' : 'inactive'} wrap="truncate-end">
244          {row.caption}
245        </Text>
246        <Box flexGrow={1} />
247        <Text color="subtle">{row.elapsed}</Text>
248      </Box>
249    )),
250  )
251}
252
253/** A terminal's bar: what is used a heavy rule, the free space a light one. */
254const GLYPH = { used: '━', free: '─', buffer: '╌' }
255
256/** What fills the context, as one bar of its parts' colours, `width` cells wide in a terminal. */
257function contextBar(kit: Kit, parts: ContextView['parts'], width: number): RenderElement {
258  const { Box, Text } = kit
259  if (isTerminal(kit)) {
260    return (
261      <Text wrap="truncate">
262        {cellsOf(
263          parts.map(part => part.share),
264          Math.max(10, width),
265        ).map((cells, i) => {
266          const part = parts[i]
267          return part === undefined || cells === 0 ? null : <Text color={part.color}>{GLYPH[part.kind].repeat(cells)}</Text>
268        })}
269      </Text>
270    )
271  }
272  return (
273    <Box flexDirection="row" width="100%" height={0.4} overflow="hidden" flexShrink={0}>
274      {cellsOf(
275        parts.map(part => part.share),
276        100,
277      ).map((whole, i) => {
278        const part = parts[i]
279        return part === undefined || whole === 0 ? null : <Box width={percent(whole)} height={0.4} flexShrink={1} backgroundColor={part.color} />
280      })}
281    </Box>
282  )
283}
284
285function composition(kit: Kit, parts: ContextView['parts'], width: number): RenderElement[] {
286  const { Box, Text } = kit
287  if (parts.length === 0) return []
288  return [
289    contextBar(kit, parts, width),
290    <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
291      {legendOf(kit, parts)}
292    </Box>,
293  ]
294}
295
296/** The parts a legend names: the five biggest used, then the free space. */
297function legendNames(parts: ContextView['parts']): ContextView['parts'] {
298  return [...parts.filter(part => part.kind === 'used' && part.share >= 0.005).slice(0, 5), ...parts.filter(part => part.kind !== 'used')]
299}
300
301/** Each part that counts, by name and share: the five biggest used, then the free space. */
302function legendOf(kit: Kit, parts: ContextView['parts']): RenderElement[] {
303  const { Text } = kit
304  return legendNames(parts).map(part => (
305    <Text wrap="truncate-end">
306      <Text color={part.color}>■</Text>
307      <Text color="inactive">{` ${part.name} `}</Text>
308      <Text color="subtle">{`${Math.round(part.share * 100)}%`}</Text>
309    </Text>
310  ))
311}
312
313/** A terminal strip's rows: a whole Clawd. */
314const CREW_ROWS = 3
315
316/** Rows the figures and the legend wrap to in `width` cells, each item kept whole. */
317function textRows(ctx: ContextView, width: number): number {
318  const line = `${ctx.used ?? ''} of ${ctx.window ?? '?'} used${ctx.compact === null ? '' : ` · ${ctx.compact.text}`}`
319  const items = [line, ...legendNames(ctx.parts).map(part => `■ ${part.name} ${Math.round(part.share * 100)}%`)]
320  let rows = 1
321  let at = 0
322  for (const item of items) {
323    const n = [...item].length
324    if (at > 0 && at + 2 + n > width) {
325      rows += 1
326      at = n
327    } else at += (at > 0 ? 2 : 0) + n
328  }
329  return rows
330}
331
332/** How much is used of the window, and when it compacts. */
333function contextLine(kit: Kit, ctx: ContextView): RenderElement {
334  const { Text } = kit
335  return (
336    <Text wrap="truncate-end">
337      {ctx.used === null ? (
338        <Text color="subtle">No reading yet: the context shows after Claude's first reply.</Text>
339      ) : (
340        [<Text bold>{ctx.used}</Text>, <Text color="subtle">{` of ${ctx.window ?? '?'} used`}</Text>]
341      )}
342      {ctx.compact === null ? null : [<Text color="subtle"> · </Text>, <Text color={INK[ctx.compact.tone]}>{ctx.compact.text}</Text>]}
343    </Text>
344  )
345}
346
347/** How much is used, when it compacts, and what fills it: the pane's Context, and the band's. */
348function contextBlock(kit: Kit, ctx: ContextView, width: number): RenderElement[] {
349  return [contextLine(kit, ctx), ...composition(kit, ctx.parts, width)]
350}
351
352function paceBar(kit: Kit, pace: Pace, width: number): RenderElement {
353  const { Box, Text } = kit
354  const used = Math.max(0, Math.min(100, pace.percent))
355  const gone = pace.elapsed
356  const ink = INK[pace.tone === 'muted' ? 'ok' : pace.tone]
357  if (isTerminal(kit)) {
358    const cells = Math.round((used / 100) * width)
359    const mark = gone === null ? -1 : Math.min(width - 1, Math.round((gone / 100) * (width - 1)))
360    const glyphs: RenderElement[] = []
361    for (let i = 0; i < width; i++) {
362      if (i === mark) glyphs.push(<Text color="text">│</Text>)
363      else glyphs.push(<Text color={i < cells ? ink : 'promptBorder'}>{i < cells ? '━' : '─'}</Text>)
364    }
365    return <Text>{glyphs}</Text>
366  }
367  // Used, then the rest of the window; the time gone is a tick across the
368  // bar, and what is used past it (ahead of pace) is drawn as a warning.
369  const mark = gone === null ? null : Math.max(0, Math.min(100, gone))
370  const segments: { share: number; ink: string }[] =
371    mark === null
372      ? [
373          { share: used, ink },
374          { share: 100 - used, ink: 'promptBorder' },
375        ]
376      : used <= mark
377        ? [
378            { share: used, ink },
379            { share: mark - used, ink: 'promptBorder' },
380            { share: -1, ink: 'text' },
381            { share: 100 - mark, ink: 'promptBorder' },
382          ]
383        : [
384            { share: mark, ink },
385            { share: -1, ink: 'text' },
386            { share: used - mark, ink: 'warning' },
387            { share: 100 - used, ink: 'promptBorder' },
388          ]
389  const wholes = cellsOf(
390    segments.map(one => Math.max(0, one.share)),
391    100,
392  )
393  return (
394    <Box flexDirection="row" width={width} height={0.9} flexShrink={0} overflow="hidden" alignItems="center">
395      {segments.map((one, i) =>
396        one.share < 0 ? (
397          <Box width={0.25} height={0.9} flexShrink={0} backgroundColor={one.ink} />
398        ) : (wholes[i] ?? 0) === 0 ? null : (
399          <Box width={percent(wholes[i] ?? 0)} height={0.45} flexShrink={1} backgroundColor={one.ink} />
400        ),
401      )}
402    </Box>
403  )
404}
405
406export function Pane(kit: Kit, view: PaneView, act: PaneActions): RenderElement {
407  const { Box, Text, Button } = kit
408  const ctx = view.context
409  const barWidth = isTerminal(kit) ? 12 : 14
410
411  return (
412    <Box flexDirection="column" width="100%" rowGap={breath(kit, 2)}>
413      <Box flexDirection="row" alignItems="center" columnGap={1}>
414        <Text color="inactive" wrap="truncate-end">
415          {view.headline}
416        </Text>
417        <Box flexGrow={1} />
418        {isTerminal(kit) ? <Button key="close" label="Close" hotkey="q" onPress={act.close} /> : null}
419      </Box>
420
421      {view.stage}
422
423      {view.spotlight === null ? null : spotlightCard(kit, view.spotlight, act)}
424
425      {view.crew.length > 1 ? crewList(kit, view.crew, act) : null}
426
427      {section(kit, 'Context', contextBlock(kit, ctx, view.width - 4))}
428
429      {section(kit, 'Budget', [
430        <Text wrap="truncate-end">
431          {view.spend === null ? <Text color="subtle">No spend recorded yet</Text> : <Text bold>{view.spend}</Text>}
432          {view.spend === null ? null : <Text color="subtle"> this session</Text>}
433        </Text>,
434        ...view.paces.map(pace => (
435          <Box flexDirection="row" alignItems="center" columnGap={1}>
436            <Box width={isTerminal(kit) ? 7 : 9} flexShrink={0}>
437              <Text color="inactive" wrap="truncate-end">
438                {pace.label}
439              </Text>
440            </Box>
441            {paceBar(kit, pace, barWidth)}
442            <Box width={5} flexShrink={0} justifyContent="flex-end">
443              <Text bold>{`${Math.round(pace.percent)}%`}</Text>
444            </Box>
445            <Text color={INK[pace.tone]} wrap="truncate-end">
446              {pace.verdict}
447            </Text>
448          </Box>
449        )),
450      ])}
451
452      <Box flexDirection="row">
453        <Button key="band" plain label={view.isBandHidden ? 'Show the band above the prompt' : 'Hide the band above the prompt'} onPress={act.toggleBand} />
454      </Box>
455    </Box>
456  )
457}
458
hooks/clawd.tsx 1196 lines
1// The crew, drawn where it is seen: pixel Clawds on the desktop, block
2// characters in a terminal. The hooks hand it who is doing what; it keeps
3// the frames, so the Clawds go on moving between the hooks' redraws.
4//
5// Two views share the sprites: `stage` (the pane's: everyone side by side,
6// Claude twice the size, a speech bubble over each head) and `strip` (the
7// band's: Claude and the crew at work, small, in a row). An agent at work
8// sits side-on at a laptop, typing; one that waits on you, or is done, turns
9// to face you, and so does one under the pointer, its laptop left on the desk.
10//
11// A frame is drawn only when something in it changed: a calm scene costs a
12// blink every few seconds, a busy one eight frames a second.
13
14import type { ClientModule, ClientSurface, RenderElement } from 'claude-code'
15
16import type { Actor, ClawdProps, Mood, StageProps, StripProps, Walker } from '../types'
17
18const TICK_MS = 125
19
20type State = { frame: number; hover: string | null }
21type Surface = ClientSurface<State>
22type Elements = Surface['elements']
23
24// ── Pixels ────────────────────────────────────────────────────────────────
25//
26// A pixel is half a terminal cell wide and half a cell tall, as in the CLI
27// logo, so it stands twice as tall as it is wide. The desktop draws one `w`
28// ch wide and `tall(w)` lh tall: the same shape in its 13 px / 19 px line.
29
30type Canvas = { width: number; height: number; ink: (string | null)[] }
31
32const round3 = (n: number) => Math.round(n * 1000) / 1000
33const tall = (w: number) => round3((w * 2 * 7.4) / 19)
34const clamp = (n: number, low: number, high: number) => Math.max(low, Math.min(high, n))
35
36function blank(width: number, height: number): Canvas {
37  return { width, height, ink: new Array<string | null>(width * height).fill(null) }
38}
39
40/**
41 * Paints a block of a sprite's pixels: columns [col, col + w) of rows
42 * [row, row + h), each pixel `scale` canvas pixels a side, the sprite's
43 * corner at canvas (x, y). Halves land from scale 2 up and round down at
44 * scale 1. `null` cuts a hole.
45 */
46function paint(c: Canvas, x: number, y: number, scale: number, col: number, row: number, w: number, h: number, ink: string | null): void {
47  const x0 = Math.max(0, x + Math.floor(col * scale))
48  const x1 = Math.min(c.width, x + Math.floor((col + w) * scale))
49  const y0 = Math.max(0, y + Math.floor(row * scale))
50  const y1 = Math.min(c.height, y + Math.floor((row + h) * scale))
51  for (let py = y0; py < y1; py++) for (let px = x0; px < x1; px++) c.ink[py * c.width + px] = ink
52}
53
54// ── The Clawd ─────────────────────────────────────────────────────────────
55//
56// 18 × 5 pixels, the CLI logo exactly:   ▐▛███▜▌
57//                                        ▝▜█████▛▘
58//                                          ▘▘ ▝▝
59// The eyes are holes in the head; the arms move between rows; the legs
60// scuttle between three stances. Side-on it faces left, toward its laptop:
61// one eye, the brow's notch at the front of the face, the near arm reaching
62// for the keys.
63
64type View = 'front' | 'side'
65type Eyes = 'open' | 'shut' | 'left' | 'right' | 'closed'
66type Legs = 'stand' | 'out' | 'in' | 'none'
67
68type Pose = {
69  view: View
70  eyes: Eyes
71  legs: Legs
72  /**
73   * The row each arm is on: 2 at rest, 0 raised high, 1 waving, 3 lowered;
74   * halves show from scale 2. Side-on, `armL` is the arm at the keys.
75   */
76  armL: number
77  armR: number
78  /** Rows above the stand (1, a hop) or below it (a half: settled down to doze). */
79  lift: number
80  /** Rows drawn from the feet up: 5 is all of it, fewer while it appears. */
81  reveal: number
82}
83
84const LEGS: Record<Legs, readonly number[]> = {
85  stand: [4, 6, 11, 13],
86  out: [3, 5, 12, 14],
87  in: [5, 7, 10, 12],
88  none: [],
89}
90const SCUTTLE: readonly Legs[] = ['stand', 'out', 'stand', 'in']
91const SPRITE_W = 18
92const SPRITE_H = 5
93
94/** An eye hole in the head's second row: its column, its width, and how far up from the row's foot it reaches. */
95type Hole = readonly [number, number, number]
96
97function holesOf(view: View, eyes: Eyes, isFine: boolean): readonly Hole[] {
98  if (view === 'side') {
99    const notch: Hole = [3, 1, 1]
100    if (eyes === 'shut') return [notch]
101    return [notch, eyes === 'closed' && isFine ? [7.5, 2, 0.5] : [8, 1, 1]]
102  }
103  switch (eyes) {
104    case 'open':
105      return [
106        [5, 1, 1],
107        [12, 1, 1],
108      ]
109    case 'left':
110      return [
111        [4, 1, 1],
112        [11, 1, 1],
113      ]
114    case 'right':
115      return [
116        [6, 1, 1],
117        [13, 1, 1],
118      ]
119    case 'shut':
120      return []
121    case 'closed':
122      // Slits: half an eye tall where there is room for halves, else as wide
123      // as two eyes, which a terminal draws as `▀`.
124      return isFine
125        ? [
126            [4.5, 2, 0.5],
127            [11.5, 2, 0.5],
128          ]
129        : [
130            [4, 2, 1],
131            [12, 2, 1],
132          ]
133  }
134}
135
136function drawClawd(c: Canvas, x: number, y: number, pose: Pose, ink: string, scale = 1): void {
137  const hidden = SPRITE_H - pose.reveal
138  const fill = (col: number, row: number, w: number, h: number, what: string | null = ink) => {
139    if (row >= hidden) paint(c, x, y, scale, col, row, w, h, what)
140  }
141  for (let row = 0; row < 4; row++) fill(3, row, 12, 1)
142  for (const [col, w, h] of holesOf(pose.view, pose.eyes, scale >= 2)) fill(col, 2 - h, w, h, null)
143  if (pose.view === 'side') {
144    fill(0, pose.armL, 3, 1)
145  } else {
146    fill(1, pose.armL, 2, 1)
147    fill(15, pose.armR, 2, 1)
148  }
149  for (const col of LEGS[pose.legs]) fill(col, 4, 1, 1)
150}
151
152/** The laptop's greys: the lid's back and its screen, the keys. A terminal cell holds one colour, so it is one grey there. */
153const DESK_INKS = {
154  desktop: { back: 'subtle', face: 'inactive', keys: 'inactive' },
155  terminal: { back: 'inactive', face: 'inactive', keys: 'inactive' },
156}
157
158/** How far left of a Clawd's own columns its laptop reaches. */
159const LAPTOP_W = 6
160
161/**
162 * The laptop a Clawd types at, in the Clawd's own columns: to its left, the
163 * lid leaning back from the hinge, the keyboard under its near arm. While it
164 * works on the desktop, a line of the screen lights, scrolling down.
165 */
166function drawLaptop(c: Canvas, x: number, y: number, scale: number, glow: number | null, isDesktop: boolean): void {
167  const inks = DESK_INKS[isDesktop ? 'desktop' : 'terminal']
168  const fill = (col: number, row: number, w: number, h: number, ink: string) => paint(c, x, y, scale, col, row, w, h, ink)
169  if (scale >= 2 && isDesktop) {
170    // Half pixels: a two-tone lid, a slim keyboard.
171    for (let i = 0; i < 5; i++) {
172      fill(-5.5 + i / 2, 2 + i / 2, 0.5, 0.5, inks.back)
173      fill(-5 + i / 2, 2 + i / 2, 0.5, 0.5, glow === i ? 'text' : inks.face)
174    }
175    fill(-3.5, 4.5, 5.5, 0.5, inks.keys)
176    return
177  }
178  if (scale >= 2) {
179    // A terminal's quadrants: the lid one line of `▚`, the keyboard `▙▄▄▄`.
180    for (let i = 0; i < 5; i++) fill(-5 + i / 2, 2 + i / 2, 0.5, 0.5, inks.back)
181    fill(-3, 4.5, 5, 0.5, inks.keys)
182    return
183  }
184  for (let i = 0; i < 3; i++) fill(-5 + i, 1 + i, 1, 1, inks.back)
185  fill(-3, 4, 5, 1, inks.keys)
186}
187
188/**
189 * The row a Clawd's head starts on. The desktop keeps a row of headroom for
190 * hops. A terminal draws two pixel rows per cell, so a head that starts on an
191 * odd row comes out as a different set of block glyphs: at scale 1 it stands
192 * on even rows (the CLI logo's own), and hops by tucking its legs instead.
193 */
194function standY(isDesktop: boolean, pose: Pose, scale: number): { y: number; pose: Pose } {
195  if (isDesktop || scale > 1) return { y: Math.floor((1 - pose.lift) * scale), pose }
196  if (pose.lift > 0) return { y: 0, pose: { ...pose, legs: 'none' } }
197  return { y: 0, pose }
198}
199
200/** The row the floor puts a laptop's corner on: the Clawd's, standing. */
201const floorY = (isDesktop: boolean, scale: number) => (isDesktop || scale > 1 ? scale : 0)
202
203// ── Moods, as poses and speech bubbles ────────────────────────────────────
204
205const SPIN = ['·', '✢', '✳', '✶', '✻', '✽', '✻', '✶', '✳', '✢']
206const SPARK = ['·', '✦', '✶', '✻', '✦', '·']
207const ENDED: readonly Mood[] = ['done', 'failed', 'stopped']
208/** Keys pressed, a frame each: steadily on a tool's work, in bursts while it writes. */
209const TYPING: Record<'working' | 'thinking', string> = {
210  working: '1101011010110101',
211  thinking: '10101100000010110000',
212}
213/** A breath every three seconds while it dozes: in, then out with a snore. */
214const BREATH = 24
215const SNORE = ['z', 'z z', 'z z Z']
216/** Frames a Clawd under the pointer waves hello before it just looks at you: two waves. */
217const GREET = 8
218
219/** When the module first saw an agent, or saw it in its mood; `isFresh` when that was live, not a late mount. */
220type Mark = { mood: Mood; at: number; isFresh: boolean }
221
222type Look = {
223  pose: Pose
224  ink: string
225  bubble: string
226  bubbleInk: string | null
227  isPill: boolean
228  /** At the laptop: which line of its screen is lit, if any. */
229  desk: { glow: number | null } | null
230}
231
232type Who = { id: string; mood: Mood; color: string; tool: string | null }
233
234function seedOf(text: string): number {
235  let seed = 7
236  for (let i = 0; i < text.length; i++) seed = (seed * 31 + text.charCodeAt(i)) % 9973
237  return seed
238}
239
240/** Where an idle Clawd looks: ahead, then left, ahead, right, a few seconds each. */
241const glanceOf = (t: number): Eyes => (['open', 'left', 'open', 'right'] as const)[Math.floor(t / 22) % 4] ?? 'open'
242
243/** `hovered`: frames since the pointer came to this Clawd, null while it is elsewhere. */
244function lookOf(who: Who, frame: number, mark: Mark, born: Mark, hovered: number | null): Look {
245  const t = frame + seedOf(who.id)
246  const since = frame - mark.at
247  const isBlink = t % 41 === 0
248  const rest: Pose = { view: 'front', eyes: isBlink ? 'shut' : 'open', legs: 'stand', armL: 2, armR: 2, lift: 0, reveal: SPRITE_H }
249  const droop: Pose = { ...rest, armL: 3, armR: 3 }
250  let look: Look = { pose: rest, ink: who.color, bubble: ' ', bubbleInk: null, isPill: false, desk: null }
251
252  switch (who.mood) {
253    case 'working':
254    case 'thinking': {
255      const keys = TYPING[who.mood]
256      const isDown = keys[t % keys.length] === '1'
257      const isWorking = who.mood === 'working'
258      look = {
259        ...look,
260        pose: { ...rest, view: 'side', armL: isDown ? 3.5 : 2.5 },
261        desk: { glow: isWorking ? Math.floor(t / 2) % 7 : null },
262        bubble: isWorking ? (who.tool ?? '…') : (SPIN[t % SPIN.length] ?? '·'),
263        bubbleInk: isWorking ? null : who.color,
264        isPill: isWorking && who.tool !== null,
265      }
266      break
267    }
268    case 'asking':
269      look = { ...look, pose: { ...rest, armR: frame % 4 < 2 ? 0 : 1 }, bubble: '?', bubbleInk: 'warning' }
270      break
271    case 'delegating':
272      look = { ...look, pose: { ...rest, eyes: isBlink ? 'shut' : 'right' }, bubble: '⋯', bubbleInk: 'subtle' }
273      break
274    case 'idle':
275      look = { ...look, pose: { ...rest, eyes: isBlink ? 'shut' : glanceOf(t) } }
276      break
277    case 'sleeping':
278      look = dozing(look, t)
279      break
280    case 'done': {
281      const isCheering = mark.isFresh && since < 16
282      look = {
283        ...look,
284        pose: isCheering ? { ...rest, eyes: 'closed', armL: 0, armR: 0, lift: since % 8 < 4 ? 1 : 0 } : rest,
285        bubble: '✓',
286        bubbleInk: 'success',
287      }
288      break
289    }
290    case 'failed':
291      look = { ...look, pose: { ...droop, eyes: 'closed', legs: 'in' }, ink: 'inactive', bubble: '✕', bubbleInk: 'error' }
292      break
293    case 'stopped':
294      look = { ...look, pose: droop, ink: 'inactive', bubble: '–', bubbleInk: 'subtle' }
295      break
296  }
297
298  if (hovered !== null && !ENDED.includes(who.mood) && who.mood !== 'sleeping') {
299    // It looks at you: at work it turns from its laptop, which stays on the
300    // desk, still lit. It waves hello with the far arm, then holds your gaze.
301    const isAtDesk = look.desk !== null
302    look = {
303      ...look,
304      pose: {
305        ...look.pose,
306        view: 'front',
307        eyes: isBlink ? 'shut' : 'open',
308        armL: isAtDesk ? 2 : look.pose.armL,
309        armR: hovered < GREET ? (hovered % 4 < 2 ? 0 : 1) : look.pose.armR,
310      },
311    }
312  }
313  const age = frame - born.at
314  if (born.isFresh && age >= 0 && age < SPARK.length) {
315    // Appearing: it rises from the floor facing you, then goes to its desk.
316    look = {
317      ...look,
318      pose: { ...look.pose, view: 'front', armL: 2, armR: 2, reveal: Math.min(SPRITE_H, age + 1) },
319      desk: null,
320      bubble: SPARK[age] ?? '·',
321      bubbleInk: who.color,
322      isPill: false,
323    }
324  }
325  return look
326}
327
328/**
329 * Asleep: eyes closed, legs splayed. It rises as it breathes in, and settles,
330 * arms dropping, as it breathes out with a snore.
331 */
332function dozing(look: Look, t: number): Look {
333  const phase = t % BREATH
334  const isOut = phase >= BREATH / 2
335  const snore = Math.floor(((phase - BREATH / 2) / (BREATH / 2)) * SNORE.length)
336  return {
337    ...look,
338    pose: { ...look.pose, view: 'front', eyes: 'closed', legs: 'out', armL: isOut ? 3 : 2.5, armR: isOut ? 3 : 2.5, lift: isOut ? -0.5 : 0 },
339    bubble: isOut ? (SNORE[snore] ?? ' ') : ' ',
340    bubbleInk: 'subtle',
341  }
342}
343
344/** On its way somewhere: facing you, scuttling, looking where it goes. */
345function walking(look: Look, heading: number, frame: number): Look {
346  return {
347    ...look,
348    desk: null,
349    pose: { ...look.pose, view: 'front', legs: SCUTTLE[frame % 4] ?? 'stand', eyes: heading > 0 ? 'right' : 'left', armL: 2, armR: 2, lift: 0 },
350  }
351}
352
353const keyOf = (look: Look) => {
354  const p = look.pose
355  const desk = look.desk === null ? '-' : String(look.desk.glow)
356  return `${p.view}.${p.eyes}.${p.legs}.${p.armL}.${p.armR}.${p.lift}.${p.reveal}.${look.ink}.${look.bubble}.${desk}`
357}
358
359// ── What each instance remembers between frames ───────────────────────────
360
361type Memo = {
362  /** The frame clock: one tick every TICK_MS, drawn or not. */
363  ticks: number
364  props: ClawdProps | null
365  moods: Map<string, Mark>
366  born: Map<string, Mark>
367  /** Who is drawn, in order, each slot's columns for the pointer, and where in its slot each Clawd stands. */
368  cast: string[]
369  slots: { id: string; from: number; to: number }[]
370  at: Map<string, number>
371  /** The tick the pointer came to the Clawd it is on: its hello starts there. */
372  hoverAt: number
373  /** Claude idling alone on the band: where it is, and what it is up to. */
374  roam: Roam | null
375  /** What the frame on screen shows, so a still frame is not drawn again. */
376  shown: string
377}
378
379const memos = new WeakMap<object, Memo>()
380
381function memoOf(surface: Surface): Memo {
382  let memo = memos.get(surface)
383  if (memo === undefined) {
384    memo = {
385      ticks: 0,
386      props: null,
387      moods: new Map(),
388      born: new Map(),
389      cast: [],
390      slots: [],
391      at: new Map(),
392      hoverAt: 0,
393      roam: null,
394      shown: '',
395    }
396    memos.set(surface, memo)
397  }
398  return memo
399}
400
401/** Notes each agent's arrival and each change of mood, as the props bring them. */
402function remember(memo: Memo, who: readonly { id: string; mood: Mood; ageMs: number; joinedMs: number }[]): void {
403  for (const one of who) {
404    const isFresh = one.ageMs < 2500
405    if (!memo.born.has(one.id)) {
406      memo.born.set(one.id, { mood: one.mood, at: memo.ticks, isFresh: one.joinedMs < 2500 && !ENDED.includes(one.mood) })
407    }
408    if (memo.moods.get(one.id)?.mood !== one.mood) memo.moods.set(one.id, { mood: one.mood, at: memo.ticks, isFresh })
409  }
410}
411
412const LONG_AGO: Mark = { mood: 'idle', at: -1000, isFresh: false }
413
414function looksOf(memo: Memo, who: readonly Who[], frame: number, hover: string | null): Look[] {
415  return who.map(one => {
416    const look = lookOf(one, frame, memo.moods.get(one.id) ?? LONG_AGO, memo.born.get(one.id) ?? LONG_AGO, hover === one.id ? frame - memo.hoverAt : null)
417    return memo.roam?.id === one.id && hover !== one.id ? roamLook(look, memo.roam, frame, one.mood) : look
418  })
419}
420
421// ── Drawing pixels on each surface ────────────────────────────────────────
422
423/**
424 * Desktop: one row of pixels as Boxes, a Box per run of one colour. Each run
425 * reaches a hair into the row below, which paints over it: rows a fraction
426 * of a line tall would otherwise show hairline seams where they meet.
427 */
428function pixelRow(el: Elements, inks: readonly (string | null)[], w: number, rowLh: number): RenderElement {
429  const { Box } = el
430  const reach = round3(rowLh + Math.min(0.06, rowLh * 0.2))
431  const runs: RenderElement[] = []
432  let gap = 0
433  let x = 0
434  while (x < inks.length) {
435    const ink = inks[x] ?? null
436    if (ink === null) {
437      gap += 1
438      x += 1
439      continue
440    }
441    let n = 1
442    while (x + n < inks.length && inks[x + n] === ink) n += 1
443    runs.push(
444      <Box
445        width={round3(n * w)}
446        height={reach}
447        flexShrink={0}
448        backgroundColor={ink}
449        {...(gap > 0 ? { marginLeft: round3(gap * w) } : {})}
450      />,
451    )
452    gap = 0
453    x += n
454  }
455  return (
456    <Box flexDirection="row" height={rowLh} flexShrink={0}>
457      {runs}
458    </Box>
459  )
460}
461
462/** The canvas as rows of Boxes, rows alike drawn as one. */
463function pixelRows(el: Elements, c: Canvas, w: number): RenderElement[] {
464  const rows: RenderElement[] = []
465  const rowOf = (y: number) => c.ink.slice(y * c.width, (y + 1) * c.width)
466  const isSame = (a: number, b: number) => {
467    for (let x = 0; x < c.width; x++) if (c.ink[a * c.width + x] !== c.ink[b * c.width + x]) return false
468    return true
469  }
470  let y = 0
471  while (y < c.height) {
472    let n = 1
473    while (y + n < c.height && isSame(y, y + n)) n += 1
474    rows.push(pixelRow(el, rowOf(y), w, round3(tall(w) * n)))
475    y += n
476  }
477  return rows
478}
479
480const QUADS = ' ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█'
481const CORNERS: readonly (readonly [number, number, number])[] = [
482  [0, 0, 1],
483  [1, 0, 2],
484  [0, 1, 4],
485  [1, 1, 8],
486]
487
488type Run = { ink: string | null; text: string }
489
490/** Terminal: a cell holds 2 × 2 pixels as one quadrant block, in one colour. */
491function quadCells(c: Canvas, cy: number): Run[] {
492  const cells: Run[] = []
493  for (let cx = 0; cx < Math.ceil(c.width / 2); cx++) {
494    let bits = 0
495    let ink: string | null = null
496    for (const [dx, dy, bit] of CORNERS) {
497      const x = cx * 2 + dx
498      const y = cy * 2 + dy
499      if (x >= c.width || y >= c.height) continue
500      const here = c.ink[y * c.width + x] ?? null
501      if (here !== null && (ink === null || here === ink)) {
502        bits |= bit
503        ink = here
504      }
505    }
506    cells.push({ ink, text: QUADS[bits] ?? ' ' })
507  }
508  return cells
509}
510
511/** Joins cells into runs of one colour; a blank joins whatever is beside it. */
512function joinRuns(cells: readonly Run[]): Run[] {
513  const runs: Run[] = []
514  for (const cell of cells) {
515    const last = runs[runs.length - 1]
516    if (last !== undefined && (cell.ink === null || last.ink === null || last.ink === cell.ink)) {
517      last.text += cell.text
518      if (last.ink === null) last.ink = cell.ink
519    } else {
520      runs.push({ ...cell })
521    }
522  }
523  return runs
524}
525
526function textRow(el: Elements, runs: readonly Run[]): RenderElement {
527  const { Text } = el
528  return (
529    <Text wrap="truncate">
530      {runs.map(run => (run.ink === null ? run.text : <Text color={run.ink}>{run.text}</Text>))}
531    </Text>
532  )
533}
534
535function quadRows(el: Elements, c: Canvas): RenderElement[] {
536  const rows: RenderElement[] = []
537  for (let cy = 0; cy < Math.ceil(c.height / 2); cy++) rows.push(textRow(el, joinRuns(quadCells(c, cy))))
538  return rows
539}
540
541// ── The stage ─────────────────────────────────────────────────────────────
542//
543// Each Clawd stands in a slot of its own, on a canvas wide enough for it and
544// its laptop. Facing you it stands in the middle; at work it sits right of
545// the middle, the laptop at its left, and it walks between the two. Turned
546// from its laptop to look at you, it keeps its seat, a step to the left.
547
548const STAGE = {
549  desktop: { mainPixel: 0.4, crewPixel: 0.2, mainSlot: 18, crewSlot: 11 },
550  terminal: { mainPixel: 0.5, crewPixel: 0.5, mainSlot: 23, crewSlot: 12 },
551}
552
553/** A slot's canvas, in sprite pixels, and where in it the Clawd's own columns start, each way it faces. */
554const SCENE_W = 21
555const FRONT_AT = 2
556const SIDE_AT = 6
557
558/** Who is on the stage: Claude, then as many of the crew as fit, then a count of the rest. */
559function castOf(props: StageProps, columns: number): { cast: Actor[]; more: number; mainSlot: number } {
560  const size = STAGE[props.surface]
561  const mainSlot = props.surface === 'desktop' || props.big ? size.mainSlot : size.crewSlot
562  const main = props.actors.find(one => one.isMain)
563  const crew = props.actors.filter(one => !one.isMain)
564  const fit = Math.max(0, Math.floor((columns - mainSlot) / size.crewSlot))
565  const shown = crew.length > fit ? crew.slice(0, Math.max(0, fit - 1)) : crew
566  return { cast: [...(main === undefined ? [] : [main]), ...shown], more: crew.length - shown.length, mainSlot }
567}
568
569/** Canvas pixels per sprite pixel: twice for Claude, and on the desktop for everyone, drawn finer. */
570const scaleOf = (props: StageProps, actor: Actor) => (props.surface === 'desktop' || (actor.isMain && props.big) ? 2 : 1)
571
572type Figure<T extends Who = Actor> = { actor: T; look: Look; at: number }
573
574const homeOf = (look: Look) => (look.desk === null ? FRONT_AT : SIDE_AT)
575/** How far left of its seat a Clawd turned from its laptop stands: facing you, its far arm needs the room. */
576const TURN = 2
577
578/** A Clawd on its slot's canvas: its laptop at the seat, then the Clawd, side-on at the keys or turned to you. */
579function drawFigure(c: Canvas, look: Look, at: number, k: number, isDesktop: boolean, reveal = look.pose.reveal): void {
580  if (look.desk !== null) drawLaptop(c, at * k, floorY(isDesktop, k), k, look.desk.glow, isDesktop)
581  const x = look.desk !== null && look.pose.view === 'front' ? at - TURN : at
582  const stand = standY(isDesktop, look.pose, k)
583  drawClawd(c, x * k, stand.y, { ...stand.pose, reveal }, look.ink, k)
584}
585
586/** Each Clawd as it looks this frame, where it stands, walking if it is not home yet. */
587function figuresOf<T extends Who>(memo: Memo, cast: readonly T[], frame: number, hover: string | null): Figure<T>[] {
588  const looks = looksOf(memo, cast, frame, hover)
589  const out: Figure<T>[] = []
590  cast.forEach((actor, i) => {
591    const look = looks[i]
592    if (look === undefined) return
593    const home = homeOf(look)
594    const at = memo.at.get(actor.id) ?? home
595    if (!memo.at.has(actor.id)) memo.at.set(actor.id, at)
596    out.push({ actor, look: at === home ? look : walking(look, Math.sign(home - at), frame), at })
597  })
598  return out
599}
600
601/** One tick: each Clawd two pixels nearer where it belongs. */
602function shuffle(memo: Memo, cast: readonly Who[], frame: number, hover: string | null): void {
603  looksOf(memo, cast, frame, hover).forEach((look, i) => {
604    const id = cast[i]?.id
605    if (id === undefined) return
606    const home = homeOf(look)
607    const at = memo.at.get(id)
608    memo.at.set(id, at === undefined ? home : at + clamp(home - at, -2, 2))
609  })
610}
611
612const stageKey = (figures: readonly Figure<Who>[]) => figures.map(one => `${one.at}:${keyOf(one.look)}`).join('|')
613
614function stage(props: StageProps, memo: Memo, frame: number, hover: string | null, surface: Surface): RenderElement {
615  const el = surface.elements
616  const { Box, Text } = el
617  const isDesktop = props.surface === 'desktop'
618  const size = STAGE[props.surface]
619  const { cast, more, mainSlot } = castOf(props, surface.columns)
620  const figures = figuresOf(memo, cast, frame, hover)
621  const hasCrew = props.actors.some(one => !one.isMain)
622
623  type Slot = { figure: Figure | null; width: number }
624  const slots: Slot[] = figures.map(figure => ({ figure, width: figure.actor.isMain ? mainSlot : size.crewSlot }))
625  if (more > 0) slots.push({ figure: null, width: size.crewSlot })
626
627  let from = 0
628  memo.slots = []
629  for (const slot of slots) {
630    if (slot.figure !== null) memo.slots.push({ id: slot.figure.actor.id, from, to: from + slot.width })
631    from += slot.width
632  }
633  memo.cast = cast.map(one => one.id)
634  memo.shown = stageKey(figures)
635
636  const sprite = ({ actor, look, at }: Figure) => {
637    const k = scaleOf(props, actor)
638    const c = blank(SCENE_W * k, (SPRITE_H + 1) * k)
639    drawFigure(c, look, at, k, isDesktop)
640    if (!isDesktop) return <Box flexDirection="column">{quadRows(el, c)}</Box>
641    const pixel = actor.isMain ? size.mainPixel : size.crewPixel
642    return (
643      <Box flexDirection="column" width={round3(c.width * pixel)} flexShrink={0}>
644        {pixelRows(el, c, pixel)}
645      </Box>
646    )
647  }
648
649  const bubble = (look: Look | null) =>
650    look === null ? (
651      <Text> </Text>
652    ) : look.isPill && isDesktop ? (
653      <Box flexDirection="column" alignItems="center" width="100%">
654        <Text wrap="truncate-end" color="text" backgroundColor="promptBorder">
655          {` ${look.bubble} `}
656        </Text>
657        <Box width={0.8} height={0.22} backgroundColor="promptBorder" />
658      </Box>
659    ) : look.isPill ? (
660      <Text wrap="truncate-end" color="text" backgroundColor="userMessageBackgroundHover">
661        {` ${look.bubble} `}
662      </Text>
663    ) : (
664      <Text wrap="truncate-end" bold color={look.bubbleInk ?? 'subtle'}>
665        {look.bubble}
666      </Text>
667    )
668
669  const actorOf = (slot: Slot) => slot.figure?.actor ?? null
670  const floorInk = (slot: Slot) => {
671    const actor = actorOf(slot)
672    return actor !== null && actor.id === props.selected ? (slot.figure?.look.ink ?? 'promptBorder') : 'promptBorder'
673  }
674  const labelInk = (slot: Slot) => {
675    const actor = actorOf(slot)
676    return actor === null ? 'subtle' : ENDED.includes(actor.mood) ? 'inactive' : 'text'
677  }
678
679  return (
680    <Box flexDirection="column" width="100%">
681      <Box flexDirection="row" alignItems="flex-end">
682        {slots.map(slot => (
683          <Box flexDirection="column" alignItems="center" width={slot.width} flexShrink={0}>
684            {bubble(slot.figure?.look ?? null)}
685            {slot.figure !== null ? sprite(slot.figure) : <Text bold color="subtle">{`+${more}`}</Text>}
686          </Box>
687        ))}
688        {hasCrew ? null : (
689          <Box flexDirection="column" flexGrow={1} paddingLeft={2}>
690            <Text color="subtle" wrap="wrap">
691              {props.hint ?? 'Subagents join the stage as Claude starts them.'}
692            </Text>
693          </Box>
694        )}
695      </Box>
696      <Box flexDirection="row">
697        {slots.map(slot =>
698          isDesktop ? (
699            <Box width={slot.width} height={0.16} flexShrink={0} backgroundColor={floorInk(slot)} />
700          ) : (
701            <Text color={floorInk(slot)}>{'▔'.repeat(slot.width)}</Text>
702          ),
703        )}
704        {isDesktop ? <Box flexGrow={1} height={0.16} backgroundColor="promptBorder" /> : null}
705      </Box>
706      <Box flexDirection="row" marginTop={isDesktop ? 0.2 : 0}>
707        {slots.map(slot => {
708          const actor = actorOf(slot)
709          return (
710            <Box width={slot.width} flexShrink={0} justifyContent="center" paddingX={1}>
711              <Text
712                wrap="truncate-end"
713                bold={actor !== null && (actor.isMain || actor.id === props.selected)}
714                underline={actor !== null && actor.id === hover}
715                color={labelInk(slot)}
716              >
717                {actor?.name ?? 'more'}
718              </Text>
719            </Box>
720          )
721        })}
722      </Box>
723      <Box flexDirection="row">
724        {slots.map(slot => (
725          <Box width={slot.width} flexShrink={0} justifyContent="center" paddingX={1}>
726            <Text wrap="truncate-end" color="subtle">
727              {actorOf(slot)?.caption ?? ' '}
728            </Text>
729          </Box>
730        ))}
731      </Box>
732    </Box>
733  )
734}
735
736/** The stage's actors as cast at the last draw, in order. */
737function castNow(memo: Memo, props: StageProps): Actor[] {
738  return memo.cast.map(id => props.actors.find(one => one.id === id)).filter((one): one is Actor => one !== undefined)
739}
740
741// ── The strip ─────────────────────────────────────────────────────────────
742//
743// The band's: Claude, then the crew at work, each in a slot as wide as its
744// scene, packed from the left. A subagent that ends plays it out (a cheer,
745// or a droop) and sinks out of sight. A terminal gives it one row: the
746// heads, their eyes at work. A nap snores beside the head. A hover names
747// the Clawd under the pointer and says what it is doing; the Clawd turns to
748// look at you and waves hello.
749
750/** The desktop's: a canvas pixel in ch, drawn at twice the sprite's pixels for the fine details. */
751const STRIP = { pixel: 0.15, scale: 2, gap: 1 }
752/** Frames an ended subagent stays before it sinks: two hops' cheer. */
753const STAY = 16
754
755type OnStrip = { walker: Walker; who: Who; reveal: number; aside: string | null }
756
757// ── Idling on the band ────────────────────────────────────────────────────
758//
759// Alone and waiting on you, Claude has the band to itself: it wanders off
760// along it, looks about, hops, dances, stretches, twirls, ducks into the
761// floor and pops back up, and dozes off for a while. Back to work, it walks
762// home to its desk. Under the pointer it stops to look at you.
763
764type Act = 'walk' | 'look' | 'hop' | 'dance' | 'stretch' | 'twirl' | 'peek' | 'doze'
765/** Where it is and where it is headed, in the strip's cells; what it is doing since `at`, until `until`. */
766type Roam = { id: string; x: number; to: number; act: Act; at: number; until: number }
767
768/** Each act's odds, out of 100, and how many frames it lasts (a walk lasts until it gets there). */
769const ACTS: readonly (readonly [Act, number, number])[] = [
770  ['walk', 38, 0],
771  ['look', 20, 32],
772  ['hop', 8, 18],
773  ['dance', 8, 32],
774  ['stretch', 8, 24],
775  ['twirl', 6, 16],
776  ['peek', 4, 18],
777  ['doze', 8, 96],
778]
779/** How far it walks a frame: a sprite pixel on the desktop, half a cell in a terminal. */
780const STRIDE = { desktop: 0.3, terminal: 0.5 }
781/** Cells kept clear right of where it may wander, so a hover's bubble always has room. */
782const ROAM_CLEAR = 30
783
784/** A number from 0 to n - 1 that a frame always gives the same, frames apart no alike. */
785function roll(frame: number, n: number): number {
786  let h = Math.imul(frame ^ 0x9e3779b9, 0x85ebca6b)
787  h ^= h >>> 13
788  h = Math.imul(h, 0xc2b2ae35)
789  h ^= h >>> 16
790  return (h >>> 0) % n
791}
792
793/** One frame of Claude's idling: a step nearer where it is going, or its next act. */
794function roamTick(memo: Memo, main: Walker | undefined, isAlone: boolean, room: number, step: number, frame: number, isHovered: boolean): void {
795  if (main === undefined) {
796    memo.roam = null
797    return
798  }
799  const r: Roam = memo.roam?.id === main.id ? memo.roam : { id: main.id, x: 0, to: 0, act: 'look', at: frame, until: frame + 24 }
800  memo.roam = r
801  const toward = (to: number) => (Math.abs(to - r.x) <= step ? to : round3(r.x + Math.sign(to - r.x) * step))
802  if (!isAlone) {
803    // Company: back in its own slot at once, so no one stands on another.
804    Object.assign(r, { x: 0, to: 0, act: 'look', at: frame, until: frame + 24 })
805    return
806  }
807  if (main.mood === 'sleeping') {
808    // A nap stays where it falls.
809    Object.assign(r, { to: r.x, act: 'look', at: frame, until: frame + 24 })
810    return
811  }
812  if (main.mood !== 'idle') {
813    // To work: home to the desk.
814    Object.assign(r, { to: 0, act: 'walk', x: toward(0) })
815    return
816  }
817  if (isHovered) {
818    r.until += 1
819    return
820  }
821  const far = Math.max(0, room)
822  if (r.act === 'walk' && r.x !== r.to) {
823    r.x = toward(Math.min(r.to, far))
824    return
825  }
826  if (r.act !== 'walk' && frame < r.until) return
827  // Next: a walk to somewhere else on the band, or an act where it stands.
828  let pick = roll(frame, 100)
829  const found = ACTS.find(([, odds]) => (pick -= odds) < 0) ?? ACTS[1]
830  const [act, , lasts] = found ?? ['look', 0, 32]
831  if (act === 'walk' && far >= 4) {
832    let to = Math.round((roll(frame + 7, 1000) / 1000) * far / step) * step
833    if (Math.abs(to - r.x) < 4) to = r.x < far / 2 ? far : 0
834    Object.assign(r, { act, to: round3(to), at: frame, until: frame })
835    return
836  }
837  Object.assign(r, { act: act === 'walk' ? 'look' : act, to: r.x, at: frame, until: frame + (lasts || 32) })
838}
839
840/** How Claude looks at its act this frame, over the look its mood gave it. */
841function roamLook(look: Look, r: Roam, frame: number, mood: Mood): Look {
842  if (r.act === 'walk' && r.x !== r.to) return walking(look, Math.sign(r.to - r.x), frame)
843  if (mood !== 'idle') return look
844  const t = frame - r.at
845  const pose = look.pose
846  switch (r.act) {
847    case 'hop': {
848      const isUp = t % 6 < 3
849      return { ...look, pose: { ...pose, lift: isUp ? 1 : 0, armL: isUp ? 1 : 2, armR: isUp ? 1 : 2, eyes: 'open' } }
850    }
851    case 'dance': {
852      const isLeft = t % 4 < 2
853      return { ...look, pose: { ...pose, eyes: 'closed', legs: SCUTTLE[t % 4] ?? 'stand', armL: isLeft ? 0 : 2, armR: isLeft ? 2 : 0 } }
854    }
855    case 'stretch':
856      // Arms up, eyes squeezed shut, a long breath; then a shake.
857      return t < 14
858        ? { ...look, pose: { ...pose, eyes: 'closed', armL: 0, armR: 0, lift: t < 4 ? 0 : 0.5 } }
859        : { ...look, pose: { ...pose, armL: t % 2 === 0 ? 1 : 2, armR: t % 2 === 0 ? 2 : 1 } }
860    case 'twirl':
861      return t % 4 < 2 ? { ...look, pose: { ...pose, view: 'side', eyes: 'open' } } : { ...look, pose: { ...pose, eyes: t % 8 < 4 ? 'left' : 'right' } }
862    case 'peek': {
863      // Ducks into the floor and pops back up.
864      const down = Math.max(0, Math.min(SPRITE_H - 1, Math.floor(Math.min(t, 18 - t) / 2)))
865      return { ...look, pose: { ...pose, reveal: Math.min(pose.reveal, SPRITE_H - down), eyes: t < 9 ? 'open' : 'shut' } }
866    }
867    case 'doze':
868      return dozing(look, t)
869    default:
870      return look
871  }
872}
873
874/** What a Clawd says beside its head, for the moods that speak: a snore (room kept between snores), a question. */
875function asideOf(look: Look, mood: Mood): string | null {
876  if (mood === 'sleeping') return look.bubble === ' ' ? '' : look.bubble
877  if (mood === 'asking') return '?'
878  return null
879}
880
881/** Who is on the strip this frame, how much of each shows, and what each says: an ended one only while it plays out. */
882function onStrip(memo: Memo, walkers: readonly Walker[], frame: number, hover: string | null): OnStrip[] {
883  const out: OnStrip[] = []
884  for (const walker of walkers) {
885    const who: Who = { id: walker.id, mood: walker.mood, color: walker.color, tool: null }
886    let reveal = SPRITE_H
887    if (ENDED.includes(walker.mood)) {
888      const mark = memo.moods.get(walker.id)
889      if (mark === undefined || !mark.isFresh) continue
890      reveal = Math.min(SPRITE_H, SPRITE_H - (frame - mark.at - STAY))
891      if (reveal <= 0) continue
892    }
893    out.push({ walker, who, reveal, aside: null })
894  }
895  looksOf(memo, out.map(one => one.who), frame, hover).forEach((look, i) => {
896    const one = out[i]
897    if (one === undefined) return
898    const isDozing = memo.roam?.id === one.walker.id && memo.roam.act === 'doze' && one.walker.mood === 'idle' && one.walker.id !== hover
899    one.aside = asideOf(look, isDozing ? 'sleeping' : one.walker.mood)
900  })
901  return out
902}
903
904/** A frame of the strip as a key: who is on it, then each drawn Clawd's look and place. */
905function stripKey(all: readonly OnStrip[], drawn: readonly OnStrip[], figures: readonly Figure<Who>[], x: number): string {
906  const who = all.map(one => `${one.walker.id}^${one.reveal}`).join(',')
907  const looks = drawn.map((one, i) => {
908    const figure = figures[i]
909    return figure === undefined ? '' : `${one.aside ?? '-'}@${figure.at}:${keyOf(figure.look)}`
910  })
911  return `${who}~${x}#${looks.join('|')}`
912}
913
914const ASIDE_W = 6
915/** A terminal strip's slot: a scene's columns, two to a cell. */
916const STRIP_CELLS = Math.ceil(SCENE_W / 2)
917const MORE_W = 4
918/** The slot of the `+N` the strip has no room for. */
919const MORE = '+'
920
921function strip(props: StripProps, memo: Memo, frame: number, hover: string | null, surface: Surface): RenderElement {
922  const el = surface.elements
923  const { Box, Text } = el
924  const isDesktop = props.surface === 'desktop'
925  const all = onStrip(memo, props.walkers, frame, hover)
926
927  // As many as fit, Claude first, then a count of the rest.
928  const gap = isDesktop ? STRIP.gap : 1
929  const widthOf = (one: OnStrip, i: number) =>
930    (isDesktop ? round3(SCENE_W * STRIP.pixel * STRIP.scale) : STRIP_CELLS) + (one.aside === null ? 0 : ASIDE_W)
931  const room = all.map((one, i) => widthOf(one, i) + (i === 0 ? 0 : gap))
932  const across = (n: number) => room.slice(0, n).reduce((sum, one) => sum + one, 0) + (n < all.length ? gap + MORE_W : 0)
933  let fit = all.length
934  while (fit > 1 && across(fit) > surface.columns) fit -= 1
935  const drawn = all.slice(0, fit)
936  const more = across(fit) <= surface.columns ? all.length - fit : 0
937
938  // Claude, idling, stands where its wandering took it.
939  const roamX = roamAt(memo, drawn[0]?.walker.id, isDesktop)
940  memo.slots = []
941  let from = roamX
942  drawn.forEach((one, i) => {
943    if (i > 0) from += gap
944    const w = widthOf(one, i)
945    memo.slots.push({ id: one.walker.id, from, to: from + w })
946    from += w
947  })
948  if (more > 0) memo.slots.push({ id: MORE, from: from + gap, to: from + gap + MORE_W })
949  memo.cast = drawn.map(one => one.walker.id)
950  const figures = figuresOf(memo, drawn.map(one => one.who), frame, hover)
951  memo.shown = stripKey(all, drawn, figures, roamX)
952
953  const aside = (one: OnStrip) =>
954    one.aside === null ? null : (
955      <Box width={ASIDE_W} flexShrink={0} paddingLeft={1}>
956        <Text bold color={one.walker.mood === 'asking' ? 'warning' : 'subtle'}>
957          {one.aside}
958        </Text>
959      </Box>
960    )
961  const count =
962    more === 0 ? null : (
963      <Box flexShrink={0} marginLeft={gap} alignSelf="center">
964        <Text bold underline={hover === MORE} color="subtle">{`+${more}`}</Text>
965      </Box>
966    )
967  // The bubble floats over the strip from the hovered slot's right edge, so
968  // showing it moves no Clawd. Those past it step out of view behind it, or
969  // a neighbour's laptop would show through its stepped edges; the pointer
970  // still finds them where they stand.
971  const pointed = all.find(one => one.walker.id === hover)?.walker
972  const hidden = all.slice(drawn.length).map(one => one.walker.name)
973  const say: Say | null =
974    pointed !== undefined
975      ? { name: pointed.name, doing: pointed.doing, task: pointed.task }
976      : hover === MORE && hidden.length > 0
977        ? { name: `+${hidden.length}`, doing: hidden.join(', '), task: '' }
978        : null
979  const under = memo.slots.find(one => one.id === hover)
980  const left = under === undefined ? 0 : Math.ceil(under.to) + (isDesktop ? 0 : 1)
981  const speech =
982    say === null || under === undefined || surface.columns - left < BUBBLE_ROOM ? null : (
983      <Box position="absolute" top={isDesktop ? 0 : 1} left={left} right={0} flexDirection="row">
984        {bubble(el, isDesktop, fitSay(say, surface.columns - left - (isDesktop ? BUBBLE_EDGES : 0)))}
985      </Box>
986    )
987
988  /** The last slot drawn: the hovered one while its bubble shows. */
989  const last = speech === null || under === undefined ? memo.slots.length : memo.slots.indexOf(under)
990
991  const k = isDesktop ? STRIP.scale : 1
992  const slots = figures.map(({ look, at }, i) => {
993    const one = drawn[i]
994    if (one === undefined || i > last) return null
995    const reveal = Math.min(look.pose.reveal, one.reveal)
996    let sprite: RenderElement
997    if (isDesktop) {
998      const c = blank(SCENE_W * k, (SPRITE_H + 1) * k)
999      drawFigure(c, look, at, k, true, reveal)
1000      sprite = (
1001        <Box flexDirection="column" width={round3(c.width * STRIP.pixel)} flexShrink={0}>
1002          {pixelRows(el, c, STRIP.pixel)}
1003        </Box>
1004      )
1005    } else {
1006      // The whole Clawd, laptop and all, as the stage draws it: three rows.
1007      const c = blank(SCENE_W, SPRITE_H + 1)
1008      drawFigure(c, look, at, 1, false, reveal)
1009      sprite = (
1010        <Box flexDirection="column" width={STRIP_CELLS} flexShrink={0}>
1011          {quadRows(el, c)}
1012        </Box>
1013      )
1014    }
1015    return (
1016      <Box flexDirection="row" flexShrink={0} alignItems="flex-start" marginLeft={i === 0 ? roamX : gap}>
1017        {sprite}
1018        {aside(one)}
1019      </Box>
1020    )
1021  })
1022  return (
1023    <Box flexDirection="row" width="100%" alignItems="flex-end">
1024      {slots}
1025      {drawn.length > last ? null : count}
1026      {speech}
1027    </Box>
1028  )
1029}
1030
1031// ── The hover's bubble ────────────────────────────────────────────────────
1032//
1033// Paper in the app's text colour and words in its inverse: light on the dark
1034// theme, dark on the light one. On the desktop it is pixel art on the
1035// strip's grid, a pixel as wide as two canvas pixels and as tall as one: its
1036// corners step in twice, and a tail of steps points back at the Clawd. A
1037// terminal says it in plain words beside the Clawd: a font draws block
1038// glyphs shorter than the row a background fills, so paper there shows seams.
1039
1040/** What a hover's bubble says: the name, what it is doing, then what it was asked, cut first. */
1041type Say = { name: string; doing: string; task: string }
1042
1043const PAPER = 'text'
1044const WORDS = 'inverseText'
1045const BUBBLE = { px: round3(STRIP.pixel * 2), py: tall(STRIP.pixel) }
1046/** The tail's rows, top to bottom: how many pixels each reaches out from the bubble. */
1047const TAIL = [1, 2, 3, 2, 1]
1048/** The least room the strip must have right of a Clawd for its bubble, in cells. */
1049const BUBBLE_ROOM = 10
1050
1051/** A desktop bubble's cells besides its words: the tail, the padding either side, and a cell to spare. */
1052const BUBBLE_EDGES = Math.ceil(3 * BUBBLE.px) + 3
1053
1054/**
1055 * What a bubble says, cut to `room` cells so its paper hugs the words: the
1056 * task goes first, then what it is doing, each ending in an ellipsis. A
1057 * surface left to cut it drops whole runs, and the paper stays wide and blank.
1058 */
1059function fitSay(say: Say, room: number): Say {
1060  const cut = (text: string, n: number) => ([...text].length <= n ? text : n <= 1 ? '' : `${[...text].slice(0, n - 1).join('').trimEnd()}…`)
1061  const name = cut(say.name, room)
1062  let left = room - [...name].length
1063  const doing = say.doing === '' || left < 4 ? '' : cut(say.doing, left - 2)
1064  left -= doing === '' ? 0 : 2 + [...doing].length
1065  const task = say.task === '' || left < 8 ? '' : cut(say.task, left - 5)
1066  return { name, doing, task }
1067}
1068
1069function bubble(el: Elements, isDesktop: boolean, say: Say): RenderElement {
1070  const { Box, Text } = el
1071  const words = (pad: string) => (
1072    <Text wrap="truncate-end" color={WORDS}>
1073      {pad}
1074      <Text bold color={WORDS}>
1075        {say.name}
1076      </Text>
1077      {say.doing === '' ? '' : `  ${say.doing}`}
1078      {say.task === '' ? null : (
1079        <Text dimColor color={WORDS}>{`  ·  ${say.task}`}</Text>
1080      )}
1081      {pad}
1082    </Text>
1083  )
1084  if (!isDesktop) {
1085    return (
1086      <Box flexShrink={1} minWidth={0}>
1087        <Text wrap="truncate-end">
1088          <Text bold color="text">
1089            {say.name}
1090          </Text>
1091          {say.doing === '' ? null : <Text color="inactive">{`  ${say.doing}`}</Text>}
1092          {say.task === '' ? null : <Text color="subtle">{`  ·  ${say.task}`}</Text>}
1093        </Text>
1094      </Box>
1095    )
1096  }
1097  const { px, py } = BUBBLE
1098  /** One row of the stepped corners: the paper, `inset` pixels in from each end. */
1099  const edge = (inset: number) => (
1100    <Box height={py} flexShrink={0} marginLeft={round3(inset * px)} marginRight={round3(inset * px)} backgroundColor={PAPER} />
1101  )
1102  return (
1103    <Box flexDirection="row" alignItems="center" flexShrink={1} minWidth={0}>
1104      <Box flexDirection="column" width={round3(3 * px)} flexShrink={0}>
1105        {TAIL.map(n => (
1106          <Box height={py} width={round3(n * px)} marginLeft={round3((3 - n) * px)} flexShrink={0} backgroundColor={PAPER} />
1107        ))}
1108      </Box>
1109      <Box flexDirection="column" flexShrink={1} minWidth={0}>
1110        {edge(2)}
1111        {edge(1)}
1112        <Box height={1} paddingX={1} flexShrink={0} backgroundColor={PAPER}>
1113          {words('')}
1114        </Box>
1115        {edge(1)}
1116        {edge(2)}
1117      </Box>
1118    </Box>
1119  )
1120}
1121
1122/** One tick of the strip: the Clawds drawn last step nearer their places; the frame's key. */
1123function stripTick(memo: Memo, props: StripProps, frame: number, hover: string | null, columns: number): string {
1124  const isDesktop = props.surface === 'desktop'
1125  const main = props.walkers[0]
1126  const isAlone = onStrip(memo, props.walkers, frame, hover).length === 1
1127  const room = columns - (isDesktop ? round3(SCENE_W * STRIP.pixel * STRIP.scale) : STRIP_CELLS) - ASIDE_W - ROAM_CLEAR
1128  roamTick(memo, main, isAlone, room, STRIDE[isDesktop ? 'desktop' : 'terminal'], frame, main !== undefined && hover === main.id)
1129  const all = onStrip(memo, props.walkers, frame, hover)
1130  const drawn = memo.cast.flatMap(id => all.filter(one => one.walker.id === id))
1131  const who = drawn.map(one => one.who)
1132  shuffle(memo, who, frame, hover)
1133  return stripKey(all, drawn, figuresOf(memo, who, frame, hover), roamAt(memo, drawn[0]?.walker.id, isDesktop))
1134}
1135
1136/** Where Claude's slot starts while it wanders: in cells, whole ones in a terminal. */
1137function roamAt(memo: Memo, id: string | undefined, isDesktop: boolean): number {
1138  const r = memo.roam
1139  if (r === null || r.id !== id) return 0
1140  return isDesktop ? r.x : Math.round(r.x)
1141}
1142
1143// ── The module ────────────────────────────────────────────────────────────
1144
1145function tick(surface: Surface): void {
1146  const memo = memoOf(surface)
1147  memo.ticks += 1
1148  const props = memo.props
1149  if (props === null) return
1150  const hover = surface.state?.hover ?? null
1151  let next: string
1152  if (props.view === 'stage') {
1153    const cast = castNow(memo, props)
1154    shuffle(memo, cast, memo.ticks, hover)
1155    next = stageKey(figuresOf(memo, cast, memo.ticks, hover))
1156  } else {
1157    next = stripTick(memo, props, memo.ticks, hover, surface.columns)
1158  }
1159  if (next !== memo.shown) surface.setState({ frame: memo.ticks, hover })
1160}
1161
1162function point(surface: Surface, event: { type: string; x: number }): void {
1163  const memo = memoOf(surface)
1164  const view = memo.props?.view
1165  if (view === undefined) return
1166  const state = surface.state ?? { frame: memo.ticks, hover: null }
1167  const slot = memo.slots.find(one => event.x >= one.from && event.x < one.to)
1168  const hover = event.type === 'leave' ? null : (slot?.id ?? null)
1169  if (event.type === 'down' && slot !== undefined) {
1170    surface.post(view === 'stage' ? { select: slot.id } : slot.id === MORE ? { open: true } : { open: true, focus: slot.id })
1171  }
1172  if (hover !== state.hover) {
1173    memo.hoverAt = memo.ticks
1174    surface.setState({ ...state, hover })
1175  }
1176}
1177
1178const Clawd: ClientModule<ClawdProps, State> = (props, surface) => {
1179  const memo = memoOf(surface)
1180  memo.props = props
1181  if (surface.state === undefined) {
1182    surface.every(TICK_MS, () => tick(surface))
1183    surface.onPointer(event => point(surface, event))
1184    surface.setState({ frame: 0, hover: null })
1185  }
1186  remember(memo, props.view === 'stage' ? props.actors : props.walkers)
1187  const { Box } = surface.elements
1188  if (surface.columns <= 0) return <Box flexDirection="column" />
1189  const frame = memo.ticks
1190  const hover = surface.state?.hover ?? null
1191  if (props.view === 'stage') return stage(props, memo, frame, hover, surface)
1192  return strip(props, memo, frame, hover, surface)
1193}
1194
1195export default Clawd
1196
types/index.d.ts 134 lines
1// What Mission control keeps for the session, and what its surface module
2// (hooks/clawd.tsx) is handed to draw.
3
4/** What an agent is doing, as its Clawd shows it. */
5export type Mood =
6  | 'thinking' // the model is writing
7  | 'working' // a tool runs
8  | 'asking' // waiting on the person: a question, a plan to approve
9  | 'delegating' // waiting on subagents it started
10  | 'idle' // its turn ended
11  | 'sleeping' // idle for a good while
12  | 'done' // a subagent that finished
13  | 'failed' // a subagent that ended in an error
14  | 'stopped' // a subagent that was stopped
15
16export type Step = { tool: string; detail: string | null; at: number }
17
18export type Member = {
19  /** `main` for Claude itself, else the subagent's id. */
20  id: string
21  /** `Claude`, or the subagent type (`Explore`, `general-purpose`). */
22  type: string
23  /** What it was asked: the prompt, or the task's few words. */
24  task: string
25  /** A theme key: `clawd_body` for Claude, a subagent colour for the rest. */
26  color: string
27  mood: Mood
28  /** When the mood began. */
29  since: number
30  tool: string | null
31  detail: string | null
32  /** Tool calls so far (Claude's: this turn). */
33  calls: number
34  /** Tokens its run took, once it ended. */
35  tokens: number | null
36  model: string | null
37  /** When it joined the session: Claude's when the session began. */
38  joinedAt: number
39  /** When its run (Claude's: this turn) began. */
40  startedAt: number
41  endedAt: number | null
42  isBackground: boolean
43  /** A teammate goes idle between turns rather than finishing. */
44  isTeammate: boolean
45  /** The latest tool calls, newest last. */
46  recent: Step[]
47}
48
49
50export type Category = { name: string; tokens: number; color: string; kind: 'used' | 'free' | 'buffer' }
51
52export type Limit = { kind: string; percent: number; resetsAt: string | null }
53
54export type Reading = {
55  at: number
56  model: string
57  startedAt: number
58  /** The last response's context, or before one (a fresh or just compacted window) /context's estimate. */
59  context: { tokens: number; window: number; percent: number } | null
60  /** Tokens at which auto-compact runs; null when it is off or not known yet. */
61  compactAt: number | null
62  categories: Category[]
63  limits: Limit[]
64  costUsd: number | null
65}
66
67export type Sample = { at: number; costUsd: number | null; fiveHour: number | null }
68
69// ── What the surface module draws: plain data ─────────────────────────────
70
71export type Where = 'desktop' | 'terminal'
72
73export type Actor = {
74  id: string
75  name: string
76  caption: string
77  color: string
78  mood: Mood
79  /** How long it had been in this mood when the hooks drew. */
80  ageMs: number
81  /** How long since it joined: one that just did rises into view. */
82  joinedMs: number
83  /** The tool running, for its speech bubble. */
84  tool: string | null
85  isMain: boolean
86}
87
88export type StageProps = {
89  view: 'stage'
90  surface: Where
91  /** Claude drawn at twice the crew's size. */
92  big: boolean
93  actors: Actor[]
94  selected: string | null
95  /** What the stage says while Claude has it to itself. */
96  hint: string | null
97}
98
99export type Walker = {
100  id: string
101  /** `Claude`, or the subagent's type: what a hover names it. */
102  name: string
103  /** What it was asked, and what it is doing now: what a hover says of it. */
104  task: string
105  doing: string
106  color: string
107  mood: Mood
108  ageMs: number
109  joinedMs: number
110}
111
112/** The band's crew: Claude, then the subagents at work (and those just ended, to play it out), side by side. */
113export type StripProps = {
114  view: 'strip'
115  surface: Where
116  walkers: Walker[]
117}
118
119export type ClawdProps = StageProps | StripProps
120
121declare module 'claude-code' {
122  interface PluginState {
123    'mission-control': {
124      crew: Member[]
125      selected: string | null
126      reading: Reading | null
127      /** Context tokens after each of Claude's turns, oldest first. */
128      turns: number[]
129      samples: Sample[]
130      isBandHidden: boolean
131    }
132  }
133}
134