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

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.
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.
| Desktop | Terminal |
|---|---|
![]() | ![]() |
After three minutes with nothing to do, it falls properly asleep and snores.
| Desktop | Terminal |
|---|---|
![]() | ![]() |
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.
| Desktop | Terminal |
|---|---|
![]() | ![]() |
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.
| Desktop | Terminal |
|---|---|
![]() | ![]() |
A subagent that finishes cheers and hops, then sinks out of sight. One that fails droops and greys out first.
| Desktop | Terminal |
|---|---|
![]() | ![]() |
Click Mission control in the band, click a Clawd, or type /mission. The pane shows:
| Desktop | Terminal |
|---|---|
![]() | ![]() |
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
| File | What it does |
|---|---|
hooks/register.tsx | Follows the session's events (turns, tool calls, subagents, usage), keeps the crew's state, and draws the band and the pane |
hooks/clawd.tsx | The animated Clawds: sprites, moods, the idle wandering and the hover bubble, drawn on its own frame clock |
hooks/view.tsx | The band's and the pane's layout |
hooks/budget.ts | Spend, and pace checks against the rate-limit windows |
hooks/tools.ts, hooks/format.ts | How tool calls and numbers read |
tests/mission-control.test.tsx | The behaviour, tested on both surfaces |
claude plugin validate .
claude plugin test .
hooks/register.tsx 720 lines1// 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}
720hooks/budget.ts 138 lines1// 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}
138hooks/format.ts 78 lines1// 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}
78hooks/tools.ts 124 lines1// 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}
124hooks/view.tsx 458 lines1// 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}
458hooks/clawd.tsx 1196 lines1// 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
1196types/index.d.ts 134 lines1// 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