Playful live view of Claude and its subagents: an animated dashboard with usage, model and effort per agent, a stats band, an activity feed and toasts

A Claude Code mod that gives you a playful, live view of Claude and its subagents while they work.
Clone the repo:
git clone https://github.com/timoranjes/agent-arcade.git
Then point Claude Code at the folder. You can do this for one session:
claude --plugin-dir /path/to/agent-arcade
Or set it permanently in ~/.claude/settings.json:
{
"env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/agent-arcade" }
}
The panes open on their own when a session starts.
| Command | What it does |
|---|---|
/arcade | Show or hide the Dashboard and Activity panes |
/arcade reset | Clear the feed and finished agents (agents still running are kept) |
.claude-plugin/plugin.json mod manifest
hooks/hooks.json loads register.tsx
hooks/register.tsx event handlers, the /arcade command, panes, toasts
hooks/model.ts pure state and formatting logic
hooks/views.tsx band, dashboard and feed rendering
hooks/stage.tsx animated agent stage
hooks/*.test.ts(x) tests
types/index.d.ts shared types
hooks/register.tsx 346 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { AgentNode, FeedEntry, LimitSample, TurnStats } from '../types'
5import {
6 MAIN,
7 PARK_MS,
8 addSample,
9 addToTurn,
10 addUsage,
11 crossedThresholds,
12 describeTarget,
13 duration,
14 finishFeed,
15 mainNode,
16 patchAgent,
17 pushFeed,
18 runningCount,
19 shortModel,
20 spawnAgent,
21 stageView,
22 totalTokens,
23} from './model'
24import { Band, DashPane, FeedPane, StaticStage } from './views'
25
26const DASH = 'arcade-dash'
27const FEED = 'arcade-feed'
28
29const agents = atom({ plugin: 'agent-arcade', key: 'agents' } as const, {} as Record<string, AgentNode>)
30const feed = atom({ plugin: 'agent-arcade', key: 'feed' } as const, [] as FeedEntry[])
31const turn = atom({ plugin: 'agent-arcade', key: 'turn' } as const, null as TurnStats | null)
32const tick = atom({ plugin: 'agent-arcade', key: 'tick' } as const, 0)
33const stageDown = atom({ plugin: 'agent-arcade', key: 'stageDown' } as const, false)
34const samples = atom({ plugin: 'agent-arcade', key: 'samples' } as const, {} as Record<string, LimitSample[]>)
35const warned = atom({ plugin: 'agent-arcade', key: 'warned' } as const, [] as string[])
36
37/** Bookkeeping must never break Claude's own work: swallow its failures. */
38async function quietly(work: () => Promise<unknown>): Promise<void> {
39 try {
40 await work()
41 } catch {
42 // the view is best-effort
43 }
44}
45
46async function openPanes($: EngineInterface) {
47 await $.ui.open({ id: DASH, title: '📊 Dashboard' })
48 await $.ui.open({ id: FEED, title: '📜 Activity' })
49}
50
51// Redraws spinners and timers while something runs; stopped once all is idle.
52let ticker: Timer | undefined
53let seq = 0
54
55function startTicker($: EngineInterface) {
56 ticker ??= $.clock.every(300, () => void update($, tick, n => n + 1))
57}
58
59async function stopTickerIfIdle($: EngineInterface) {
60 const list = await read($, agents)
61 const isMainBusy = list[MAIN]?.status === 'running'
62 if (!isMainBusy && runningCount(list) === 0) {
63 ticker?.cancel()
64 ticker = undefined
65 }
66}
67
68export const register: Register = on => {
69 ticker = undefined
70
71 on('session.start', async ($, e, next) => {
72 await quietly(async () => {
73 await $.command.register({
74 name: 'arcade',
75 description: 'Agent arcade: /arcade (toggle panes) or /arcade reset',
76 })
77 await update($, stageDown, () => false)
78 void openPanes($)
79 })
80
81 return next(e)
82 })
83
84 on('command.run', { command: 'arcade' }, async ($, e) => {
85 if (e.args.trim() === 'reset') {
86 await update($, feed, () => [])
87 await update($, agents, list =>
88 Object.fromEntries(Object.entries(list).filter(([, n]) => n.status === 'running')),
89 )
90 await update($, warned, () => [])
91 return { text: '🧹 Arcade reset.' }
92 }
93 const panes = await $.ui.panes()
94 const isOpen = panes.some(p => (p.id === DASH || p.id === FEED) && p.isShown)
95 if (isOpen) {
96 await $.ui.close({ id: DASH })
97 await $.ui.close({ id: FEED })
98 return { text: '🕹️ Arcade panes closed. /arcade brings them back.' }
99 }
100 await openPanes($)
101 return { text: '🕹️ Arcade panes open.' }
102 })
103
104 on('turn.start', async ($, e, next) => {
105 await quietly(async () => {
106 const now = await $.clock.now()
107 if (e.agentId === undefined) {
108 const prev = (await read($, agents))[MAIN]
109 await update($, turn, () => ({ startedAt: now, tools: 0, files: [], agents: 0, tokens: 0, cacheRead: 0 }))
110 // A new prompt: keep agents still working in the background, drop finished ones.
111 await update($, agents, list => ({
112 ...Object.fromEntries(Object.entries(list).filter(([id, n]) => id !== MAIN && n.status === 'running')),
113 [MAIN]: { ...mainNode(now), model: prev?.model, effort: prev?.effort },
114 }))
115 } else {
116 const id = e.agentId
117 await update($, agents, list => patchAgent(list, id, () => ({ status: 'running', endedAt: undefined })))
118 }
119 startTicker($)
120 })
121
122 return next(e)
123 })
124
125 // Each request names the model and effort it is really sent with: record them, change nothing.
126 on('turn.step', async function* ($, e, next) {
127 await quietly(async () => {
128 const who = e.agentId ?? MAIN
129 const effort = e.effort === undefined ? undefined : String(e.effort)
130 await update($, agents, list => {
131 const node = list[who]
132 if (node && node.model === e.model && node.effort === effort) return list
133 return patchAgent(list, who, () => ({ model: e.model, effort }))
134 })
135 })
136
137 return yield* next(e)
138 })
139
140 on('agent.spawn', async ($, e, next) => {
141 const spawned = await next(e)
142 await quietly(async () => {
143 if (spawned.deny !== undefined || !spawned.agentId) return
144 const id = spawned.agentId
145 const now = await $.clock.now()
146 await update($, agents, list =>
147 patchAgent(
148 spawnAgent(list, { id, type: e.subagentType, description: e.description, parentId: e.parentAgentId, fork: e.fork }, now),
149 id,
150 () => ({ model: spawned.model }),
151 ),
152 )
153 await update($, turn, t => (t ? { ...t, agents: t.agents + 1 } : t))
154 startTicker($)
155 })
156
157 return spawned
158 }).catch(($, e, next) => next(e)) // an observer: never block a spawn
159
160 // The engine is about to ask the person; the ask clears when that call ends.
161 on('classic.PermissionRequest', async ($, e, next) => {
162 await quietly(async () => {
163 const who = e.agent_id ?? MAIN
164 const target = describeTarget((e.tool_input ?? {}) as Record<string, unknown>)
165 const label = target ? `${e.tool_name} ${target}` : e.tool_name
166 await update($, agents, list => patchAgent(list, who, () => ({ waiting: label })))
167 })
168
169 return next(e)
170 }).catch(($, e, next) => next(e))
171
172 on('tool.call', async ($, e, next) => {
173 const who = e.agentId ?? MAIN
174 const args = e as unknown as Record<string, unknown>
175 const target = describeTarget(args)
176 const callId = `${e.tool_use_id ?? 'call'}-${(seq += 1)}`
177 let startedAt = 0
178
179 await quietly(async () => {
180 startedAt = await $.clock.now()
181 const list = await read($, agents)
182 const node = list[who] ?? list[MAIN]
183 const icon = node?.icon ?? '🧠'
184 const color = node?.color ?? 'claude'
185 const label = `${e.tool}${target ? ` ${target}` : ''}`
186 await update($, agents, all => patchAgent(all, who, () => ({ tool: label })))
187 await update($, feed, f => pushFeed(f, { id: callId, agentId: who, icon, color, tool: e.tool, target, ok: null }))
188 $.ui.status(`${icon} ${node?.type ?? 'Claude'} → ${label}`)
189 })
190
191 const ran = await next(e)
192
193 await quietly(async () => {
194 const ms = Math.round((await $.clock.now()) - startedAt)
195 const isOk = ran.deny === undefined && ran.isError !== true
196 const file = typeof args.file_path === 'string' ? args.file_path : undefined
197 await update($, feed, f => finishFeed(f, callId, isOk, ms))
198 await update($, agents, all =>
199 patchAgent(all, who, n => ({
200 tools: n.tools + 1,
201 failures: (n.failures ?? 0) + (isOk ? 0 : 1),
202 tool: undefined,
203 waiting: undefined,
204 })),
205 )
206 await update($, turn, t => addToTurn(t, e.tool, file))
207 if (ran.deny !== undefined) $.ui.toast(`🚫 ${e.tool} was denied`)
208 else if (!isOk) $.ui.toast(`⚠️ ${e.tool} failed${target ? `: ${target}` : ''}`)
209 })
210
211 return ran
212 }).catch(($, e, next) => next(e)) // an observer: never block a tool
213
214 on('turn.complete', async ($, e, next) => {
215 await quietly(async () => {
216 const now = await $.clock.now()
217 const isOk = e.reason === 'answer'
218 const who = e.agentId ?? MAIN
219 const used = e.usage
220 if (used) {
221 await update($, agents, list => patchAgent(list, who, n => addUsage(n, used)))
222 await update($, turn, t =>
223 t ? { ...t, tokens: (t.tokens ?? 0) + totalTokens(used), cacheRead: (t.cacheRead ?? 0) + used.cache_read_input_tokens } : t,
224 )
225 }
226 if (e.agentId !== undefined) {
227 const id = e.agentId
228 const node = (await read($, agents))[id]
229 await update($, agents, list =>
230 patchAgent(list, id, () => ({ status: isOk ? 'done' : 'failed', endedAt: now, tool: undefined, waiting: undefined })),
231 )
232 // Fold the parked lane into the summary once its time is up.
233 $.clock.after(PARK_MS + 500, () => void update($, tick, n => n + 1))
234 if (node) {
235 const took = duration(now - node.startedAt)
236 $.ui.toast(
237 isOk
238 ? `🎉 ${node.icon} ${node.type} finished · ${took} · ${node.tools} tools`
239 : `💥 ${node.icon} ${node.type} stopped (${e.reason}) after ${took}`,
240 )
241 }
242 } else {
243 await update($, agents, list =>
244 patchAgent(list, MAIN, () => ({ status: isOk ? 'done' : 'failed', endedAt: now, tool: undefined, waiting: undefined })),
245 )
246 const t = await read($, turn)
247 await update($, turn, s => (s ? { ...s, endedAt: now } : s))
248 $.ui.status(undefined)
249 if (t && t.tools > 0) {
250 const files = t.files.length
251 $.ui.toast(
252 `🏁 Turn done · ${duration(now - t.startedAt)} · ${t.tools} tools` +
253 (files ? ` · ${files} file${files === 1 ? '' : 's'}` : '') +
254 (t.agents ? ` · ${t.agents} agent${t.agents === 1 ? '' : 's'}` : ''),
255 )
256 }
257 }
258 await stopTickerIfIdle($)
259 })
260
261 return next(e)
262 })
263
264 // Usage pushed by the engine: keep rate-limit history for the burn rate, warn once past thresholds.
265 on('session.measure', async ($, e, next) => {
266 await quietly(async () => {
267 const now = await $.clock.now()
268 if (e.changed.includes('rateLimits')) {
269 await update($, samples, all => {
270 const out = { ...all }
271 for (const l of e.rateLimits) out[l.kind] = addSample(all[l.kind], { at: now, percent: l.percentUsed })
272 return out
273 })
274 }
275 const fresh = crossedThresholds(e.context.percent, e.rateLimits, await read($, warned))
276 if (fresh.length > 0) {
277 await update($, warned, list => [...list, ...fresh.map(f => f.key)])
278 for (const f of fresh) $.ui.toast(f.text)
279 }
280 await update($, tick, n => n + 1)
281 })
282
283 return next(e)
284 })
285
286 // A stage that failed to load or draw: fall back to the still one.
287 on('ui.fault', async ($, e, next) => {
288 await quietly(() => update($, stageDown, () => true))
289 return next(e)
290 })
291
292 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
293 if (e.props.hasSurvey) return next(e)
294 const elements = $.ui.resolve(e)
295 const list = await read($, agents)
296 const usage = await $.session.usage()
297 const isDashUp = (await $.ui.panes()).some(p => p.id === DASH && p.isShown)
298
299 return Band(elements, {
300 model: list[MAIN]?.model ?? (await $.session.model()),
301 effort: list[MAIN]?.effort,
302 isCompact: isDashUp,
303 percent: usage.context.percent,
304 contextTokens: usage.context.tokens,
305 usd: usage.cost?.usd,
306 running: runningCount(list),
307 turn: await read($, turn),
308 isBusy: list[MAIN]?.status === 'running' || runningCount(list) > 0,
309 now: await $.clock.now(),
310 tick: await read($, tick),
311 })
312 })
313
314 on('ui.render', { component: 'Pane', requestId: DASH }, async ($, e) => {
315 const elements = $.ui.resolve(e)
316 const { Text } = elements
317 const Client = 'Client' in elements ? elements.Client : undefined // terminal and desktop only
318 const list = await read($, agents)
319 const now = await $.clock.now()
320 await read($, tick) // redraw with the clock while busy
321 const rows = e.viewport?.rows ?? 30
322 const view = stageView(list, now, Math.max(1, Math.floor((rows - 12) / 2)))
323 const isBusy = list[MAIN]?.status === 'running' || runningCount(list) > 0
324 const main = view.lanes.find(l => l.id === MAIN)
325 if (main && !isBusy) {
326 main.stats = [shortModel(list[MAIN]?.model), list[MAIN]?.effort ?? '-', ' ready for your next prompt'].join(' ')
327 }
328
329 const canAnimate = Client !== undefined && !(await read($, stageDown))
330 const empty = <Text dimColor>No agents yet. Send a prompt to start the show ✨</Text>
331 const stage =
332 view.lanes.length === 0 ? empty
333 : canAnimate && Client ? <Client key="stage" module="./stage.tsx" width="100%" props={{ lanes: view.lanes }} />
334 : StaticStage(elements, view)
335 const usage = await $.session.usage()
336 const data = { view, usage, samples: await read($, samples), turn: await read($, turn), isBusy, now, columns: e.props.bodyColumns }
337
338 return DashPane(elements, data, stage)
339 })
340
341 on('ui.render', { component: 'Pane', requestId: FEED }, async ($, e) => {
342 const room = Math.max(1, (e.viewport?.rows ?? 24) - 2)
343 return FeedPane($.ui.resolve(e), await read($, feed), room, await read($, tick))
344 })
345}
346hooks/model.ts 306 lines1import type { AgentNode, FeedEntry, LimitSample, TurnStats } from '../types'
2
3export const MAIN = 'main'
4export const FEED_CAP = 50
5export const SPINNER = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷']
6
7const STYLES: Record<string, { icon: string; color: string }> = {
8 main: { icon: '🧠', color: 'claude' },
9 Explore: { icon: '🔭', color: 'cyan' },
10 Plan: { icon: '🗺️', color: 'magenta' },
11 'general-purpose': { icon: '🤖', color: 'blue' },
12 fork: { icon: '🧬', color: 'green' },
13 'claude-code-guide': { icon: '📚', color: 'yellow' },
14}
15const FALLBACK_ICONS = ['👾', '🦊', '🐙', '🦉', '🐝', '🚀']
16const FALLBACK_COLORS = ['yellow', 'green', 'cyan', 'magenta', 'blue']
17
18export function styleFor(type: string): { icon: string; color: string } {
19 const known = STYLES[type]
20 if (known) return known
21 let hash = 0
22 for (const ch of type) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0
23 return {
24 icon: FALLBACK_ICONS[hash % FALLBACK_ICONS.length] ?? '👾',
25 color: FALLBACK_COLORS[hash % FALLBACK_COLORS.length] ?? 'yellow',
26 }
27}
28
29export function mainNode(now: number): AgentNode {
30 return {
31 id: MAIN,
32 type: 'Claude',
33 description: 'main loop',
34 icon: styleFor('main').icon,
35 color: styleFor('main').color,
36 startedAt: now,
37 status: 'running',
38 tools: 0,
39 }
40}
41
42export function spawnAgent(
43 agents: Record<string, AgentNode>,
44 spawn: { id: string; type: string; description: string; parentId?: string; fork?: boolean },
45 now: number,
46): Record<string, AgentNode> {
47 const style = styleFor(spawn.fork ? 'fork' : spawn.type)
48 return {
49 ...agents,
50 [spawn.id]: {
51 id: spawn.id,
52 type: spawn.fork ? 'fork' : spawn.type,
53 description: spawn.description,
54 parentId: spawn.parentId ?? MAIN,
55 icon: style.icon,
56 color: style.color,
57 startedAt: now,
58 status: 'running',
59 tools: 0,
60 },
61 }
62}
63
64export function patchAgent(
65 agents: Record<string, AgentNode>,
66 id: string,
67 patch: (node: AgentNode) => Partial<AgentNode>,
68): Record<string, AgentNode> {
69 const node = agents[id]
70 return node ? { ...agents, [id]: { ...node, ...patch(node) } } : agents
71}
72
73/** Agents in tree order, each with its depth below the main loop. */
74export function treeOrder(agents: Record<string, AgentNode>): { node: AgentNode; depth: number }[] {
75 const out: { node: AgentNode; depth: number }[] = []
76 const all = Object.values(agents)
77 const seen = new Set<string>()
78 const walk = (node: AgentNode, depth: number) => {
79 if (seen.has(node.id)) return
80 seen.add(node.id)
81 out.push({ node, depth })
82 all
83 .filter(child => child.parentId === node.id)
84 .sort((a, b) => a.startedAt - b.startedAt)
85 .forEach(child => walk(child, depth + 1))
86 }
87 const roots = all.filter(n => n.parentId === undefined || !agents[n.parentId])
88 roots.sort((a, b) => (a.id === MAIN ? -1 : b.id === MAIN ? 1 : a.startedAt - b.startedAt))
89 roots.forEach(root => walk(root, 0))
90 return out
91}
92
93export function runningCount(agents: Record<string, AgentNode>): number {
94 return Object.values(agents).filter(n => n.id !== MAIN && n.status === 'running').length
95}
96
97function short(text: string, max = 48): string {
98 const one = text.replace(/\s+/g, ' ').trim()
99 return one.length > max ? `${one.slice(0, max - 1)}…` : one
100}
101
102function baseName(path: string): string {
103 return path.split(/[\\/]/).filter(Boolean).pop() ?? path
104}
105
106/** A few words for what a tool call is aimed at, from its arguments. */
107export function describeTarget(args: Record<string, unknown>): string {
108 const str = (key: string) => (typeof args[key] === 'string' ? (args[key] as string) : undefined)
109 const file = str('file_path') ?? str('notebook_path')
110 if (file) return baseName(file)
111 const pattern = str('pattern')
112 if (pattern) return `"${short(pattern, 32)}"`
113 const command = str('command')
114 if (command) return short(command, 40)
115 const desc = str('description')
116 if (desc) return short(desc, 40)
117 const url = str('url') ?? str('query') ?? str('skill') ?? str('prompt')
118 return url ? short(url, 40) : ''
119}
120
121export function pushFeed(feed: FeedEntry[], entry: FeedEntry): FeedEntry[] {
122 return [...feed, entry].slice(-FEED_CAP)
123}
124
125export function finishFeed(feed: FeedEntry[], id: string, ok: boolean, ms: number): FeedEntry[] {
126 return feed.map(one => (one.id === id ? { ...one, ok, ms } : one))
127}
128
129export function addToTurn(turn: TurnStats | null, tool: string, file?: string): TurnStats | null {
130 if (!turn) return turn
131 const isWrite = tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit'
132 const files = isWrite && file && !turn.files.includes(file) ? [...turn.files, file] : turn.files
133 return { ...turn, tools: turn.tools + 1, files }
134}
135
136export function duration(ms: number): string {
137 const s = Math.max(0, Math.round(ms / 1000))
138 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
139}
140
141export function bar(percent: number, width = 8): string {
142 const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)
143 return '▓'.repeat(filled) + '░'.repeat(width - filled)
144}
145
146export function tokens(n: number): string {
147 return n >= 1000 ? `${Math.round(n / 100) / 10}k` : String(n)
148}
149
150type Usage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
151
152/** All the tokens a call was answered over and generated. */
153export function totalTokens(u: Usage): number {
154 return u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
155}
156
157/** Folds one turn's usage into an agent's running totals. */
158export function addUsage(node: AgentNode, u: Usage): Partial<AgentNode> {
159 return {
160 tokensIn: (node.tokensIn ?? 0) + u.input_tokens + u.cache_creation_input_tokens,
161 tokensOut: (node.tokensOut ?? 0) + u.output_tokens,
162 cacheRead: (node.cacheRead ?? 0) + u.cache_read_input_tokens,
163 }
164}
165
166export function agentTokens(node: AgentNode): number {
167 return (node.tokensIn ?? 0) + (node.tokensOut ?? 0) + (node.cacheRead ?? 0)
168}
169
170/** The share of input the prompt cache served, as a whole percent; undefined before any input. */
171export function cacheRate(cacheRead: number, input: number): number | undefined {
172 const all = cacheRead + input
173 return all > 0 ? Math.round((cacheRead / all) * 100) : undefined
174}
175
176/** `claude-opus-5-5` → `opus-5-5`; a dated suffix dropped. */
177export function shortModel(model: string | undefined): string {
178 if (!model) return '–'
179 return model.replace(/^claude-/, '').replace(/-\d{8}$/, '').replace(/\[1m\]$/, '')
180}
181
182const WINDOW_NAMES: Record<string, string> = { five_hour: '5-hour', seven_day: '7-day', spend_limit: 'spend' }
183const WINDOW_SHORT: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: '$lim' }
184
185export function windowName(kind: string, isShort = false): string {
186 return (isShort ? WINDOW_SHORT[kind] : WINDOW_NAMES[kind]) ?? kind.replace(/_/g, ' ')
187}
188
189function span(ms: number): string {
190 const mins = Math.max(1, Math.round(ms / 60_000))
191 if (mins < 60) return `${mins}m`
192 const hours = Math.floor(mins / 60)
193 if (hours < 24) return `${hours}h ${mins % 60}m`
194 return `${Math.floor(hours / 24)}d ${hours % 24}h`
195}
196
197/** "resets in 2h 10m" for a reset time ahead of now; empty when unknown or past. */
198export function resetsIn(resetsAt: string | undefined, now: number): string {
199 const at = resetsAt ? Date.parse(resetsAt) : NaN
200 return Number.isFinite(at) && at > now ? `resets in ${span(at - now)}` : ''
201}
202
203const SAMPLE_CAP = 30
204const MIN_SPAN_MS = 2 * 60_000
205
206/** Adds a reading; a drop in percent means the window reset, so the history starts over. */
207export function addSample(list: LimitSample[] | undefined, sample: LimitSample): LimitSample[] {
208 const prev = list ?? []
209 const last = prev[prev.length - 1]
210 if (last && sample.percent < last.percent) return [sample]
211 return [...prev, sample].slice(-SAMPLE_CAP)
212}
213
214/**
215 * When the window fills at the pace of its readings: "limit in ~1h 20m".
216 * Empty with too little history, no climb, or a reset that comes first.
217 */
218export function burnRate(list: LimitSample[] | undefined, now: number, resetsAt?: string): string {
219 const first = list?.[0]
220 const last = list?.[list.length - 1]
221 if (!first || !last || last.at - first.at < MIN_SPAN_MS || last.percent <= first.percent) return ''
222 if (last.percent >= 100) return 'limit reached'
223 const perMs = (last.percent - first.percent) / (last.at - first.at)
224 const eta = (100 - last.percent) / perMs - (now - last.at)
225 const reset = resetsAt ? Date.parse(resetsAt) : NaN
226 if (Number.isFinite(reset) && now + eta >= reset) return ''
227 return `limit in ~${span(Math.max(0, eta))}`
228}
229
230/** Thresholds that toast once each: context at 80%, a rate-limit window at 90%. */
231export function crossedThresholds(
232 context: number | undefined,
233 limits: readonly { kind: string; percentUsed: number }[],
234 warned: readonly string[],
235): { key: string; text: string }[] {
236 const out: { key: string; text: string }[] = []
237 if ((context ?? 0) >= 80 && !warned.includes('context')) {
238 out.push({ key: 'context', text: `🟥 Context is ${Math.round(context ?? 0)}% full: /compact soon` })
239 }
240 for (const l of limits) {
241 if (l.percentUsed >= 90 && !warned.includes(l.kind)) {
242 out.push({ key: l.kind, text: `🟥 ${windowName(l.kind)} limit is at ${Math.round(l.percentUsed)}%` })
243 }
244 }
245 return out
246}
247
248/** Bar color by fill: calm, then warning at 60%, error at 80%. */
249export function levelColor(percent: number): 'success' | 'warning' | 'error' {
250 return percent >= 80 ? 'error' : percent >= 60 ? 'warning' : 'success'
251}
252
253/** How long a finished agent stays parked on the stage before it folds into the summary. */
254export const PARK_MS = 30_000
255
256export type Lane = {
257 id: string
258 icon: string
259 color: string
260 name: string
261 status: AgentNode['status']
262 bubble: string
263 waiting: string
264 stats: string
265}
266
267export type StageView = { lanes: Lane[]; folded: { icon: string; name: string; took: string }[] }
268
269/** The stage: Claude first, then running agents, then the ones parked under PARK_MS; the rest folded. */
270export function stageView(agents: Record<string, AgentNode>, now: number, max: number): StageView {
271 const all = Object.values(agents)
272 const main = all.filter(n => n.id === MAIN)
273 const running = all.filter(n => n.id !== MAIN && n.status === 'running').sort((a, b) => a.startedAt - b.startedAt)
274 const ended = all
275 .filter(n => n.id !== MAIN && n.status !== 'running')
276 .sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
277 const parked = ended.filter(n => now - (n.endedAt ?? now) < PARK_MS)
278 const shown = [...main, ...running, ...parked].slice(0, Math.max(1, max))
279 const shownIds = new Set(shown.map(n => n.id))
280
281 return {
282 lanes: shown.map(n => ({
283 id: n.id,
284 icon: n.icon,
285 color: n.color,
286 name: n.type,
287 status: n.status,
288 bubble: n.status === 'running' ? (n.tool ?? '') : '',
289 waiting: n.status === 'running' ? (n.waiting ?? '') : '',
290 stats: laneStats(n, now),
291 })),
292 folded: ended
293 .filter(n => !shownIds.has(n.id))
294 .map(n => ({ icon: n.icon, name: n.type, took: duration((n.endedAt ?? now) - n.startedAt) })),
295 }
296}
297
298function laneStats(n: AgentNode, now: number): string {
299 const parts = [`${shortModel(n.model)} ${n.effort ?? '–'}`, duration((n.endedAt ?? now) - n.startedAt), `🔧 ${n.tools}${n.failures ? ` ✘${n.failures}` : ''}`]
300 const used = agentTokens(n)
301 if (used > 0) parts.push(tokens(used))
302 const rate = cacheRate(n.cacheRead ?? 0, n.tokensIn ?? 0)
303 if (rate !== undefined) parts.push(`cache ${rate}%`)
304 return parts.join(' ')
305}
306hooks/views.tsx 208 lines1import type { ElementTable, RenderElement, SessionUsage } from 'claude-code'
2
3import type { FeedEntry, LimitSample, TurnStats } from '../types'
4import type { StageView } from './model'
5import {
6 MAIN,
7 SPINNER,
8 bar,
9 burnRate,
10 cacheRate,
11 duration,
12 levelColor,
13 resetsIn,
14 shortModel,
15 tokens,
16 windowName,
17} from './model'
18
19type El = Pick<ElementTable, 'Box' | 'Text'>
20
21export type BandData = {
22 model: string
23 percent?: number
24 contextTokens?: number
25 usd?: number
26 running: number
27 turn: TurnStats | null
28 isBusy: boolean
29 now: number
30 tick: number
31 /** The main loop's latest effort, from turn.step. */
32 effort?: string
33 /** While the dashboard shows usage, the band keeps to model, time and agents. */
34 isCompact?: boolean
35}
36
37export function Band({ Box, Text }: El, d: BandData) {
38 const spin = SPINNER[d.tick % SPINNER.length]
39 const elapsed = d.turn ? duration((d.turn.endedAt ?? d.now) - d.turn.startedAt) : undefined
40 const pct = d.percent ?? 0
41 const ctxColor = pct >= 80 ? 'error' : pct >= 60 ? 'warning' : 'success'
42
43 return (
44 <Box flexDirection="row" gap={1}>
45 <Text color="claude" weight="bold">🕹️ arcade</Text>
46 <Text dimColor>│</Text>
47 <Text>{shortModel(d.model)}{d.effort ? ` ${d.effort}` : ''}</Text>
48 {d.isCompact ? null : <Text dimColor>│</Text>}
49 {d.isCompact ? null : <Text>
50 ctx <Text color={ctxColor}>{bar(pct)}</Text> {Math.round(pct)}%
51 {d.contextTokens !== undefined ? <Text dimColor> ({tokens(d.contextTokens)})</Text> : null}
52 </Text>}
53 {d.usd !== undefined && !d.isCompact ? <Text dimColor>│</Text> : null}
54 {d.usd !== undefined && !d.isCompact ? <Text>💰 ${d.usd.toFixed(2)}</Text> : null}
55 <Text dimColor>│</Text>
56 <Text color={d.running > 0 ? 'cyan' : undefined} dimColor={d.running === 0}>
57 🏃 {d.running} agent{d.running === 1 ? '' : 's'}
58 </Text>
59 {elapsed ? <Text dimColor>│</Text> : null}
60 {elapsed ? (
61 <Text>
62 ⏱ {elapsed} {d.isBusy ? <Text color="claude">{spin}</Text> : <Text color="success">✔</Text>}
63 </Text>
64 ) : null}
65 {d.turn && d.turn.tools > 0 ? <Text dimColor>│ 🔧 {d.turn.tools}</Text> : null}
66 </Box>
67 )
68}
69
70export function FeedPane({ Box, Text }: El, feed: FeedEntry[], room: number, tick: number) {
71 const spin = SPINNER[tick % SPINNER.length]
72 const shown = feed.slice(-room).reverse()
73
74 return (
75 <Box flexDirection="column">
76 {shown.length === 0 ? <Text dimColor>Quiet in here… tool calls will scroll by 🎮</Text> : null}
77 {shown.map(entry => (
78 <Text key={entry.id} wrap="truncate-end">
79 {entry.ok === null ? <Text color={entry.color}>{spin}</Text>
80 : entry.ok ? <Text color="success">✔</Text>
81 : <Text color="error">✘</Text>}{' '}
82 {entry.icon} <Text color={entry.color} weight="bold">{entry.tool}</Text>
83 {entry.target ? <Text> {entry.target}</Text> : null}
84 {entry.ms !== undefined ? <Text dimColor> {entry.ms < 1000 ? `${entry.ms}ms` : duration(entry.ms)}</Text> : null}
85 </Text>
86 ))}
87 </Box>
88 )
89}
90
91const NARROW = 60
92
93function baseName(path: string): string {
94 return path.split(/[\/]/).filter(Boolean).pop() ?? path
95}
96
97/** The stage drawn once, without motion: for surfaces with no Client, or after the stage failed. */
98export function StaticStage({ Box, Text }: El, view: StageView) {
99 return (
100 <Box flexDirection="column">
101 {view.lanes.map(lane => (
102 <Box key={lane.id} flexDirection="column">
103 <Text wrap="truncate-end">
104 {lane.status === 'failed' ? '💥' : lane.id === MAIN && lane.status !== 'running' ? '💤' : lane.icon}{' '}
105 <Text color={lane.color} weight="bold">{lane.name}</Text>
106 {lane.status === 'done' && lane.id !== MAIN ? <Text color="success"> ✔</Text> : null}
107 {lane.waiting ? <Text color="warning"> ⏸ asked you to approve {lane.waiting}</Text>
108 : lane.bubble ? <Text dimColor> ⟨{lane.bubble}⟩</Text> : null}
109 </Text>
110 <Text wrap="truncate-end" dimColor> {lane.stats}</Text>
111 </Box>
112 ))}
113 </Box>
114 )
115}
116
117export type DashData = {
118 view: StageView
119 usage: SessionUsage
120 samples: Record<string, LimitSample[]>
121 turn: TurnStats | null
122 isBusy: boolean
123 now: number
124 columns: number
125}
126
127function Meter({ Text }: El, name: string, percent: number, detail: string) {
128 const pct = Math.round(percent)
129 return (
130 <Text key={name} wrap="truncate-end">
131 {name.padEnd(9)}<Text color={levelColor(percent)}>{bar(percent, 10)}</Text> {String(pct).padStart(3)}% <Text dimColor>{detail}</Text>
132 </Text>
133 )
134}
135
136/** The dashboard: the stage (handed in, animated or not), then usage, then what changed. */
137export function DashPane(el: El, d: DashData, stage: RenderElement) {
138 const { Box, Text } = el
139 const isNarrow = d.columns < NARROW
140 const ctx = d.usage.context
141 const pct = ctx.percent ?? 0
142 const limits = d.usage.rateLimits
143 const turn = d.turn
144 const turnCache = turn ? cacheRate(turn.cacheRead ?? 0, Math.max(0, (turn.tokens ?? 0) - (turn.cacheRead ?? 0))) : undefined
145 const burns = limits
146 .map(l => ({ kind: l.kind, text: burnRate(d.samples[l.kind], d.now, l.resetsAt) }))
147 .filter(b => b.text)
148 const files = turn?.files ?? []
149 const folded = d.view.folded
150
151 return (
152 <Box flexDirection="column">
153 {stage}
154 {folded.length > 0 ? (
155 <Text wrap="truncate-end" dimColor>
156 <Text color="success">✔</Text> {folded.length} finished
157 {isNarrow ? '' : `: ${folded.slice(0, 4).map(f => `${f.icon} ${f.name} ${f.took}`).join(', ')}${folded.length > 4 ? ', …' : ''}`}
158 </Text>
159 ) : null}
160 <Text> </Text>
161 {!d.isBusy && turn?.endedAt !== undefined ? (
162 <Box flexDirection="column">
163 <Text wrap="truncate-end" dimColor>
164 last turn {duration(turn.endedAt - turn.startedAt)} 🔧 {turn.tools}
165 {turn.agents ? ` ${turn.agents} agent${turn.agents === 1 ? '' : 's'}` : ''}
166 {turn.tokens ? ` ${tokens(turn.tokens)} tokens` : ''}
167 {files.length ? ` ${files.length} file${files.length === 1 ? '' : 's'}` : ''}
168 </Text>
169 <Text> </Text>
170 </Box>
171 ) : null}
172 {isNarrow ? (
173 <Box flexDirection="column">
174 <Text wrap="truncate-end">
175 ctx <Text color={levelColor(pct)}>{Math.round(pct)}%</Text>
176 {limits.map(l => (
177 <Text key={l.kind}>
178 <Text dimColor> │ </Text>{windowName(l.kind, true)} <Text color={levelColor(l.percentUsed)}>{Math.round(l.percentUsed)}%</Text>
179 </Text>
180 ))}
181 {d.usage.cost ? <Text><Text dimColor> │ </Text>${d.usage.cost.usd.toFixed(2)}</Text> : null}
182 </Text>
183 {burns.map(b => <Text key={b.kind} dimColor wrap="truncate-end">{windowName(b.kind, true)} {b.text}</Text>)}
184 </Box>
185 ) : (
186 <Box flexDirection="column">
187 {Meter(el, 'context', pct, ctx.tokens !== undefined ? `${tokens(ctx.tokens)} of ${tokens(ctx.window)}` : `window ${tokens(ctx.window)}`)}
188 {limits.length === 0 ? <Text dimColor>{'limits'.padEnd(9)}no reading yet: they show after the first reply</Text> : null}
189 {limits.map(l => {
190 const parts = [resetsIn(l.resetsAt, d.now), burns.find(b => b.kind === l.kind)?.text ?? ''].filter(Boolean)
191 return Meter(el, windowName(l.kind), l.percentUsed, parts.join(', '))
192 })}
193 <Text wrap="truncate-end">
194 {'spent'.padEnd(9)}{d.usage.cost ? `$${d.usage.cost.usd.toFixed(2)}` : '–'}
195 {d.isBusy && turn?.tokens ? <Text dimColor> this turn {tokens(turn.tokens)} tokens{turnCache !== undefined ? `, cache ${turnCache}%` : ''}</Text> : null}
196 </Text>
197 {files.length > 0 ? <Text> </Text> : null}
198 {files.length > 0 ? (
199 <Text wrap="truncate-end">
200 {'changed'.padEnd(9)}<Text dimColor>{files.map(baseName).join(' ')}</Text>
201 </Text>
202 ) : null}
203 </Box>
204 )}
205 </Box>
206 )
207}
208hooks/stage.tsx 175 lines1import type { ClientModule, JsonValue } from 'claude-code'
2
3import type { Lane } from './model'
4
5// The stage: one lane per agent, its sprite running while it works.
6// Runs on the surface's own frame clock, so it never waits on the hooks module.
7
8export type StageProps = { lanes: Lane[] }
9
10type Shared = { props: StageProps; end: number }
11type StageState = { box: Shared; pos: Record<string, number>; dir: Record<string, number>; frame: number }
12
13const FRAME_MS = 120
14const LABEL = 13
15const SPRITE = 2 // an emoji takes two cells
16
17function seed(id: string, end: number): number {
18 let hash = 0
19 for (const ch of id) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0
20 return end > 0 ? hash % end : 0
21}
22
23function cells(text: string): number {
24 return [...text].length
25}
26
27function clip(text: string, room: number): string {
28 if (room <= 0) return ''
29 const chars = [...text]
30 return chars.length <= room ? text : `${chars.slice(0, Math.max(0, room - 1)).join('')}…`
31}
32
33function startAt(lane: Lane, end: number): number {
34 if (lane.status === 'running') return seed(lane.id, end)
35 return lane.id === 'main' ? 0 : end
36}
37
38/** One frame on: moves what moves; `moved` false when nothing did, so an idle stage costs no redraw. */
39export function step(state: StageState): { next: StageState; moved: boolean } {
40 const { lanes } = state.box.props
41 const end = state.box.end
42 const pos: Record<string, number> = {}
43 const dir: Record<string, number> = {}
44 let moved = Object.keys(state.pos).length !== lanes.length
45
46 for (const lane of lanes) {
47 const was = state.pos[lane.id] ?? startAt(lane, end)
48 let p = Math.min(was, end)
49 let d = state.dir[lane.id] ?? 1
50 const isMainAsleep = lane.id === 'main' && lane.status !== 'running'
51 if (lane.status === 'running' && !lane.waiting) {
52 // Each lane keeps its own pace: a slow one moves every other frame.
53 const isSlow = seed(lane.id, 2) === 1
54 if (!isSlow || state.frame % 2 === 0) {
55 if (p + d > end || p + d < 0) d = -d
56 p += d
57 }
58 } else if (isMainAsleep && p > 0) {
59 p = Math.max(0, p - 2)
60 d = -1
61 } else if (lane.status === 'done' && !isMainAsleep && p < end) {
62 p = Math.min(end, p + 2)
63 d = 1
64 }
65 pos[lane.id] = p
66 dir[lane.id] = d
67 if (p !== was || d !== state.dir[lane.id]) moved = true
68 }
69
70 return { next: { box: state.box, pos, dir, frame: state.frame + 1 }, moved }
71}
72
73type Seg = { at: number; text: string; width: number; color?: string; dim?: boolean }
74
75/** The track's segments left to right; the gaps between are spaces. */
76export function trackSegments(lane: Lane, p: number, d: number, track: number): Seg[] {
77 const isMoving = lane.status === 'running' && !lane.waiting
78 const isMainAsleep = lane.id === 'main' && lane.status !== 'running'
79 const sprite = lane.status === 'failed' ? '💥' : isMainAsleep ? '💤' : lane.icon
80 const segs: Seg[] = [{ at: p, text: sprite, width: SPRITE }]
81 let right = p + SPRITE
82 let left = p
83
84 if (isMoving && d > 0 && p >= 2) {
85 segs.unshift({ at: p - 2, text: '·∙', width: 2, dim: true })
86 left = p - 2
87 }
88 if (isMoving && d < 0 && right + 2 <= track) {
89 segs.push({ at: right, text: '∙·', width: 2, dim: true })
90 right += 2
91 }
92
93 const words = lane.waiting ? `⏸ asked you to approve ${lane.waiting}` : lane.bubble
94 if (words) {
95 const bubble = `⟨${words}⟩`
96 const color = lane.waiting ? 'warning' : undefined
97 const roomRight = track - right - 1
98 const roomLeft = left - 1
99 if (cells(bubble) <= roomRight || roomRight >= roomLeft) {
100 const text = clip(bubble, roomRight)
101 if (roomRight >= 6) segs.push({ at: right + 1, text, width: cells(text), color, dim: !color })
102 } else if (roomLeft >= 6) {
103 const text = clip(bubble, roomLeft)
104 segs.unshift({ at: left - 1 - cells(text), text, width: cells(text), color, dim: !color })
105 }
106 }
107
108 return segs
109}
110
111const Stage: ClientModule<JsonValue, StageState> = (raw, surface) => {
112 const { Box, Text } = surface.elements
113 const props = raw as unknown as StageProps
114 const width = Math.max(24, surface.columns || 60)
115 const track = Math.max(10, width - LABEL - 2)
116 const end = track - SPRITE
117
118 let now = surface.state
119 if (now === undefined) {
120 // First draw: one setState, and the one timer. The timer steps from its own
121 // copy (several ticks can pass between redraws) and hands each frame over
122 // as a new object, which is what makes the surface draw again.
123 let cur: StageState = { box: { props, end }, pos: {}, dir: {}, frame: 0 }
124 now = cur
125 surface.setState(cur)
126 surface.every(FRAME_MS, () => {
127 const { next, moved } = step(cur)
128 cur = next
129 if (moved) surface.setState(next)
130 })
131 }
132 // New props and width reach the running timer through the box every frame shares.
133 now.box.props = props
134 now.box.end = end
135 const state = now
136
137 return Box({
138 flexDirection: 'column',
139 children: props.lanes.map(lane => {
140 const p = Math.min(state.pos[lane.id] ?? startAt(lane, end), end)
141 const segs = trackSegments(lane, p, state.dir[lane.id] ?? 1, track)
142 const parts = []
143 let at = 0
144 for (const seg of segs) {
145 if (seg.at > at) parts.push(Text({ children: ' '.repeat(seg.at - at) }))
146 parts.push(Text({ children: seg.text, color: seg.color, dimColor: seg.dim }))
147 at = Math.max(at, seg.at) + seg.width
148 }
149 if (track > at) parts.push(Text({ children: ' '.repeat(track - at) }))
150 const isFinished = lane.status === 'done' && lane.id !== 'main'
151 const name = clip(lane.name, LABEL - 4).padEnd(LABEL - 4)
152
153 return Box({
154 key: lane.id,
155 flexDirection: 'column',
156 children: [
157 Text({
158 wrap: 'truncate-end',
159 children: [
160 Text({ children: `${lane.icon} ` }),
161 Text({ children: `${name} `, color: lane.color, weight: 'bold' }),
162 Text({ children: '┆', dimColor: true }),
163 ...parts,
164 isFinished ? Text({ children: '✔', color: 'success' }) : Text({ children: '┆', dimColor: true }),
165 ],
166 }),
167 Text({ wrap: 'truncate-end', dimColor: true, children: `${' '.repeat(LABEL + 1)}${lane.stats}` }),
168 ],
169 })
170 }),
171 })
172}
173
174export default Stage
175types/index.d.ts 65 lines1export type AgentStatus = 'running' | 'done' | 'failed'
2
3export type AgentNode = {
4 id: string
5 type: string
6 description: string
7 parentId?: string
8 icon: string
9 color: string
10 startedAt: number
11 endedAt?: number
12 status: AgentStatus
13 tool?: string
14 tools: number
15 /** Tokens its turns cost, summed from turn.complete usage. */
16 tokensIn?: number
17 tokensOut?: number
18 cacheRead?: number
19 /** The model and effort of its latest request, from turn.step. */
20 model?: string
21 effort?: string
22 /** Tool calls that were denied or errored. */
23 failures?: number
24 /** Set while a permission dialog it raised has not resolved its call. */
25 waiting?: string
26}
27
28/** One reading of a rate-limit window, for the burn rate. */
29export type LimitSample = { at: number; percent: number }
30
31export type FeedEntry = {
32 id: string
33 agentId: string
34 icon: string
35 color: string
36 tool: string
37 target: string
38 ok: boolean | null
39 ms?: number
40}
41
42export type TurnStats = {
43 startedAt: number
44 endedAt?: number
45 tools: number
46 files: string[]
47 agents: number
48 tokens?: number
49 cacheRead?: number
50}
51
52declare module 'claude-code' {
53 interface PluginState {
54 'agent-arcade': {
55 agents: Record<string, AgentNode>
56 feed: FeedEntry[]
57 turn: TurnStats | null
58 tick: number
59 stageDown: boolean
60 samples: Record<string, LimitSample[]>
61 warned: string[]
62 }
63 }
64}
65