Live session dashboard: tokens, cost, context, limits, agents, tasks, background jobs, tools and an animated pet

A live side panel for Claude Code that shows what a session is doing while it works: usage, limits, tools, background jobs, tasks, an animated pet, and an agent map for sessions that run many subagents at once.
It is a Claude Code mod: a plugin of function hooks that draws a Pane beside the conversation.
| Section | Contents |
|---|---|
| Header | session title, working directory, model, effort and permission mode |
| Pet | a pixel-art slime whose mood follows the session: idle, thinking, coding (laptop), searching (magnifier), reading (book), delegating (mini agents), puzzled (a tool failed), sleeping (90 s idle), happy (press p to pet it) |
| Usage | tokens, cost, tool calls, session time |
| Context | context window fill, amber from 60 %, red from 85 % |
| Limits | 5-hour and weekly rate-limit windows with time to reset |
| Tasks | the session's task list: running, pending, done |
| Background | background shells and monitors, removed when their task-notification arrives (clear drops any leftover) |
| Tools | calls grouped by kind (built-in, mcp, plugins, subagents, skills); the tool in flight goes first with a pulsing dot |
| Room | an agent map with main in the center and every running subagent around it, linked by dotted lines that carry packets; a participants list; and a live interaction stream with timestamps |
The map packs agents on a grid around main, nearest first, growing up to 10 rows; past capacity the rest gather as a +N chip. Agents pop in when they start, bob and blink while they work, open their mouth when they talk, and burst into sparks when they finish.
The palette is neutral greys on a dark background; color is reserved for the pet, agent types and alerts.
Clone the repo anywhere, then point Claude Code at the folder.
git clone https://github.com/pieroprUnap/claude-code-usage-dashboard.git ~/.claude/mods/usage-dashboard
Load it in every session through ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/usage-dashboard"
}
}
Or for a single session:
claude --plugin-dir ~/.claude/mods/usage-dashboard
The pane opens by itself on terminals 144 columns or wider. Otherwise run:
/dashboard
Claude Code runs up to 20 subagents at once by default. To watch larger rooms, raise the limit in the same env block (it is read when Claude Code starts):
"CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS": "50"
claude plugin validate .
claude plugin test .
Files:
.claude-plugin/plugin.json: manifesthooks/hooks.json: points at the hooks modulehooks/register.tsx: the hooks and the panehooks/dashboard.test.ts: tests run by claude plugin testtypes/index.d.ts: the shape of the state the pane keepsWhen the mod loads, Claude Code writes its own typings to .claude-plugin/types/ (ignored by git); tsconfig.json extends them so tsc -p . type-checks the mod.
—.MIT
hooks/register.tsx 1266 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { LogLine, Stats } from '../types'
5
6const PANE = 'dashboard'
7const EMPTY: Stats = {
8 tokens: 0, usd: 0, contextPct: 0, contextTokens: 0, contextWindow: 0,
9 toolCalls: 0, startedAt: 0, now: 0, byKind: {}, limits: [], agents: [], seen: {}, seenTick: {}, leaving: [], finished: [], log: [],
10 background: [], tasks: [], chats: [], busy: 0, active: {}, lastUsed: {}, current: '',
11 turnActive: false, lastActivity: 0, happyUntil: 0, puzzledUntil: 0, puzzledBy: '', pets: 0,
12 title: '', cwd: '', model: '', effort: '', mode: '', defaultMode: '',
13}
14const stats = atom({ plugin: 'usage-dashboard', key: 'stats' } as const, EMPTY)
15const frame = atom({ plugin: 'usage-dashboard', key: 'frame' } as const, 0)
16
17// Neutral palette: greys on a dark background; color only on the pet and alerts
18const C = {
19 bg: '#141414',
20 rule: '#2c2c2c',
21 label: '#8c8c8c',
22 dim: '#5f5f5f',
23 text: '#c8c8c8',
24 value: '#ececec',
25 track: '#2c2c2c',
26 fill: '#bdbdbd',
27 warn: '#d7af5f',
28 bad: '#d75f5f',
29 ok: '#87af87',
30}
31
32function set($: EngineInterface, change: (s: Stats) => Partial<Stats>) {
33 return update($, stats, s => {
34 const cur = { ...EMPTY, ...s }
35 return { ...cur, ...change(cur) }
36 }).catch(() => EMPTY)
37}
38
39// ───────────────────────── pet ─────────────────────────
40
41const PET_COLORS: Record<string, string> = {
42 a: '#1f7fd0', // antennae
43 B: '#2fa8f0', // body
44 H: '#7fd0ff', // highlight
45 D: '#1a6fb0', // shade
46 E: '#0a0a0a', // eyes
47 P: '#f08fb0', // cheeks
48 // props
49 L: '#7a7a7a', // laptop shell
50 k: '#4a4a4a', // shadow, text lines
51 K: '#101820', // screen
52 g: '#87af87', // green code
53 y: '#d7af5f', // amber code, moon
54 c: '#7fd0ff', // cyan code
55 w: '#d8d8d8', // paper, cloud, cursor
56 M: '#bdbdbd', // lens rim
57 G: '#3a5a7a', // lens glass
58 W: '#8a5a2a', // handle
59 R: '#a05050', // book cover
60 m: '#87af87', // green mini agent
61 n: '#d7af5f', // amber mini agent
62}
63
64type Eyes = 'open' | 'closed' | 'left' | 'right' | 'happy'
65type Antennae = 'up' | 'left' | 'right' | 'droop'
66
67// Every character is one terminal cell painted with a background color.
68// Cells are twice as tall as wide, so 14×7 cells read as roughly square.
69// Glyphs drawn as text on the panel background: 1 = Z, 2 = z, 3 = ♥, 4 = •
70const SPRITE_W = 14
71const BLANK = '.'.repeat(SPRITE_W)
72
73const ANTENNAE: Record<Antennae, string[]> = {
74 up: ['..a........a..', '...a......a...'],
75 left: ['.a........a...', '..a......a....'],
76 right: ['...a........a.', '....a......a..'],
77 droop: ['..............', '.aa........aa.'],
78}
79
80function body(eyes: Eyes): string[] {
81 const shift = eyes === 'left' ? -1 : eyes === 'right' ? 1 : 0
82 const face = [...'..HBBBBBBBBB..']
83 const cheeks = [...'.DBBBBBBBBBBB.']
84 const eye = eyes === 'closed' ? 'D' : 'E'
85 face[5 + shift] = eye
86 face[9 + shift] = eye
87 if (eyes === 'happy') {
88 cheeks[3] = 'P'
89 cheeks[11] = 'P'
90 }
91 return ['.....BBBB.....', '...HBBBBBBB...', face.join(''), cheeks.join(''), 'DDBBBBBBBBBBBB']
92}
93
94type Mood = 'sleeping' | 'idle' | 'thinking' | 'coding' | 'searching' | 'reading' | 'delegating' | 'puzzled' | 'happy'
95
96const SEARCH_TOOLS = new Set(['Grep', 'Glob', 'WebSearch', 'ToolSearch', 'LS'])
97const READ_TOOLS = new Set(['Read', 'WebFetch', 'NotebookRead'])
98
99function moodOf(s: Stats): Mood {
100 if (s.happyUntil > s.now) return 'happy'
101 if (s.puzzledUntil > s.now) return 'puzzled'
102 if (s.busy > 0) {
103 const t = s.current
104 if (READ_TOOLS.has(t) || /read|fetch|get_|docs/i.test(t)) return 'reading'
105 if (SEARCH_TOOLS.has(t) || /search|query|find|list|resolve/i.test(t)) return 'searching'
106 return 'coding'
107 }
108 if (s.agents.length > 0) return 'delegating'
109 if (s.turnActive) return 'thinking'
110 if (s.lastActivity && s.now - s.lastActivity > 90_000) return 'sleeping'
111 return 'idle'
112}
113
114/** jump phases: crouch, rise, fall crouched, back to normal */
115type Pose = 'normal' | 'crouch' | 'up'
116const HOP: Pose[] = ['crouch', 'up', 'up', 'crouch']
117const hopAt = (f: number, every: number): Pose => HOP[f % every] ?? 'normal'
118const SWAY: Antennae[] = ['up', 'right', 'up', 'left']
119const sway = (f: number, ticks: number): Antennae => SWAY[Math.floor(f / ticks) % 4]
120
121/**
122 * The pet in 8 rows. One tick is 125 ms: jumps spread over several frames
123 * (crouch, rise, fall) and the antennae pass through the middle.
124 */
125function petPixels(mood: Mood, f: number): string[] {
126 let eyes: Eyes = 'open'
127 let ant: Antennae = 'up'
128 let pose: Pose = 'normal'
129 const blink = (every: number) => f % every >= every - 2
130 switch (mood) {
131 case 'sleeping':
132 eyes = 'closed'; ant = 'droop'; pose = Math.floor(f / 10) % 2 ? 'crouch' : 'normal'; break
133 case 'idle':
134 eyes = blink(32) ? 'closed' : 'open'; ant = sway(f, 8); pose = hopAt(f, 48); break
135 case 'thinking':
136 eyes = blink(40) ? 'closed' : 'right'; ant = sway(f, 5); break
137 case 'coding':
138 eyes = blink(36) ? 'closed' : 'right'; ant = sway(f, 2); pose = hopAt(f, 16); break
139 case 'searching':
140 eyes = blink(30) ? 'closed' : 'right'; ant = sway(f, 4); break
141 case 'reading':
142 eyes = blink(24) ? 'closed' : 'right'; pose = f % 24 === 0 ? 'crouch' : 'normal'; break
143 case 'delegating':
144 eyes = blink(28) ? 'closed' : 'right'; ant = sway(f, 3); pose = hopAt(f, 20); break
145 case 'puzzled':
146 eyes = (['left', 'left', 'right', 'right', 'closed'] as Eyes[])[Math.floor(f / 4) % 5]; ant = Math.floor(f / 6) % 2 ? 'left' : 'droop'; break
147 case 'happy':
148 eyes = 'happy'; ant = sway(f, 2); pose = hopAt(f, 6); break
149 }
150 const b = body(eyes)
151 const a = ANTENNAE[ant]
152 if (pose === 'up') return [...a, ...b, BLANK]
153 if (pose === 'crouch') return [BLANK, BLANK, ...a, ...b.slice(1)]
154 return [BLANK, ...a, ...b]
155}
156
157// ── props beside the pet: 14×8 cells on its right
158
159const PROP_H = 8
160const grid = () => Array.from({ length: PROP_H }, () => [...BLANK])
161function paint(g: string[][], x: number, y: number, rows: string[]) {
162 rows.forEach((r, dy) =>
163 [...r].forEach((ch, dx) => {
164 const gx = x + dx
165 const gy = y + dy
166 if (ch !== '.' && gy >= 0 && gy < PROP_H && gx >= 0 && gx < SPRITE_W) g[gy][gx] = ch
167 }),
168 )
169}
170const toRows = (g: string[][]) => g.map(r => r.join(''))
171
172/** laptop whose code is typed and scrolls line by line */
173function laptop(f: number): string[] {
174 const g = grid()
175 paint(g, 0, 1, ['.LLLLLLLLLLLL.', '.LKKKKKKKKKKL.', '.LKKKKKKKKKKL.', '.LKKKKKKKKKKL.', '.LLLLLLLLLLLL.', 'LkLkLkLkLkLkLL', '.kkkkkkkkkkkk.'])
176 const base = Math.floor(f / 6)
177 for (let i = 0; i < 3; i++) {
178 const n = base + i
179 const indent = [0, 2, 4, 2][n % 4]
180 const len = Math.min(10 - indent, 3 + ((n * 5) % 5))
181 const isTyping = i === 2
182 const shown = isTyping ? Math.min(len, (f % 6) + 1) : len
183 const color = 'gyc'[n % 3]
184 for (let x = 0; x < shown; x++) g[2 + i][2 + indent + x] = x === 0 && n % 4 === 0 ? 'c' : color
185 if (isTyping && f % 2 === 0 && 2 + indent + shown < 12) g[2 + i][2 + indent + shown] = 'w'
186 }
187 return toRows(g)
188}
189
190/** magnifier gliding over a document */
191function magnifier(f: number): string[] {
192 const g = grid()
193 paint(g, 3, 0, ['wwwwwwwwww', 'wkkkkkkkkw', 'wwwwwwwwww', 'wkkkkkwwww', 'wwwwwwwwww', 'wkkkkkkkww', 'wwwwwwwwww'])
194 const path = [[2, 0], [3, 0], [4, 0], [5, 0], [6, 0], [7, 1], [8, 1], [8, 2], [7, 3], [6, 3], [5, 3], [4, 3], [3, 3], [2, 2], [2, 1]]
195 const [x, y] = path[Math.floor(f / 2) % path.length]
196 paint(g, x, y, ['.MMM.', 'MGGGM', '.MMM.', '....W', '.....W'])
197 return toRows(g)
198}
199
200/** open book turning pages */
201function book(f: number): string[] {
202 const g = grid()
203 paint(g, 0, 3, ['..wwwwwRwwwww.', '..wkkkwRwkkkw.', '..wwwwwRwwwww.', '..wkkwwRwkkww.', '.RRRRRRRRRRRR.'])
204 const flip = f % 3
205 if (flip === 0) paint(g, 8, 2, ['wwww'])
206 if (flip === 1) paint(g, 7, 1, ['.ww', 'w..'])
207 if (flip === 2) paint(g, 3, 2, ['wwww'])
208 return toRows(g)
209}
210
211/** thought cloud with dots */
212function cloud(f: number): string[] {
213 const g = grid()
214 paint(g, 1, 0, ['...wwwwww...', '.wwwwwwwwww.', '.wwwwwwwwww.', '...wwwwww...'])
215 for (let i = 0; i < f % 4; i++) g[1][4 + i * 2] = 'k'
216 paint(g, 1, 5, ['w'])
217 paint(g, 0, 6, ['w'])
218 return toRows(g)
219}
220
221/** rising Zs and a moon */
222function snore(f: number): string[] {
223 const g = grid()
224 paint(g, 10, 0, ['.yy', 'y..', '.yy'])
225 g[Math.max(0, 6 - (f % 6))][2] = '1'
226 g[Math.max(0, 6 - ((f + 3) % 6))][5] = '2'
227 g[Math.max(0, 5 - ((f + 1) % 5))][7] = '1'
228 return toRows(g)
229}
230
231/** mini agents bouncing while a message travels */
232function minis(f: number, count: number): string[] {
233 const g = grid()
234 const mini = (c: string) => [`.${c}${c}${c}.`, `${c}E${c}E${c}`, `${c}${c}${c}${c}${c}`]
235 paint(g, 2, f % 2 ? 4 : 5, mini('m'))
236 if (count !== 1) paint(g, 8, f % 2 ? 5 : 4, mini('n'))
237 const dot = f % 7
238 if (dot < 6) g[2][dot * 2] = '4'
239 return toRows(g)
240}
241
242/** floating hearts */
243function hearts(f: number): string[] {
244 const g = grid()
245 g[Math.max(0, 6 - (f % 6))][2] = '3'
246 g[Math.max(0, 6 - ((f + 2) % 6))][6] = '3'
247 g[Math.max(0, 6 - ((f + 4) % 6))][10] = '3'
248 return toRows(g)
249}
250
251/** question marks popping around the head */
252function questions(f: number): string[] {
253 const g = grid()
254 const spots = [[1, 1], [5, 0], [9, 2], [3, 3], [11, 1]]
255 spots.forEach(([x, y], i) => {
256 const phase = (f + i * 3) % 10
257 if (phase < 6) g[Math.max(0, y + (phase < 3 ? 1 : 0))][x] = i % 2 ? '6' : '5'
258 })
259 return toRows(g)
260}
261
262function prop(mood: Mood, f: number, agents: number): string[] | null {
263 const at = (ticks: number) => Math.floor(f / ticks)
264 switch (mood) {
265 case 'coding': return laptop(at(2))
266 case 'searching': return magnifier(f)
267 case 'reading': return book(at(3))
268 case 'thinking': return cloud(at(4))
269 case 'sleeping': return snore(at(4))
270 case 'delegating': return minis(at(2), agents)
271 case 'puzzled': return questions(at(2))
272 case 'happy': return hearts(at(2))
273 default: return null
274 }
275}
276
277function scene(mood: Mood, f: number, agents: number): string[] {
278 const pet = petPixels(mood, f)
279 const p = prop(mood, f, agents) ?? Array.from({ length: PROP_H }, () => BLANK)
280 return pet.map((r, i) => `${r}.${p[i] ?? BLANK}`)
281}
282
283const GLYPH: Record<string, [string, string]> = {
284 '1': ['Z', '#d8d8d8'],
285 '2': ['z', '#a8a8a8'],
286 '3': ['♥', '#f08fb0'],
287 '4': ['•', '#d8d8d8'],
288 '5': ['?', '#d7af5f'],
289 '6': ['?', '#8c8c8c'],
290}
291
292type Run = [text: string, bg: string | undefined, fg: string | undefined]
293
294/** merges equal adjacent cells into runs */
295function runs(line: string): Run[] {
296 const out: Run[] = []
297 for (const ch of line) {
298 const glyph = GLYPH[ch]
299 const cell: Run = glyph ? [glyph[0], undefined, glyph[1]] : [' ', PET_COLORS[ch], undefined]
300 const last = out[out.length - 1]
301 if (last && last[1] === cell[1] && last[2] === cell[2]) last[0] += cell[0]
302 else out.push(cell)
303 }
304 return out
305}
306
307function bubble(mood: Mood, s: Stats, f: number): string {
308 const dots = '.'.repeat((f % 3) + 1).padEnd(3)
309 switch (mood) {
310 case 'sleeping': return ['z', 'z Z', 'z Z z'][f % 3]
311 case 'thinking': return `hmm${dots}`
312 case 'coding': return `⚙ ${s.current}`
313 case 'searching': return `⌕ ${s.current}`
314 case 'reading': return `▤ ${s.current}`
315 case 'delegating': return `${s.agents.length} agent${s.agents.length > 1 ? 's' : ''} in the room`
316 case 'puzzled': return `? ${s.puzzledBy} failed`
317 case 'happy': return ['♥', '♥ ♥', '♥ ♥ ♥'][f % 3]
318 default: return ''
319 }
320}
321
322// ───────────────────────── room scene ─────────────────────────
323//
324// The room is an agent map drawn on a grid of terminal cells, over a faint
325// dotted floor. `main` is the session's own pet, in the center. Every agent
326// is a tiny cousin of the pet (antennae, two rows of shaded body, eyes and
327// mouth as glyphs) placed on a grid around `main`, nearest first, with its
328// name under it and a dotted link to `main`. Packets travel the links both ways;
329// a talking agent shows a speech chip. Agents pop in when they start, bob,
330// sway and blink while they work, and burst into sparks when they finish.
331// The grid grows rows with the count; past the last row the rest gather as
332// a `+N` chip.
333
334type Cell = { ch: string; bg?: string; fg?: string }
335type Canvas = Cell[][]
336
337const AGENT_PALETTE = ['#87af87', '#d7af5f', '#af87d7', '#5fafd7', '#d78787', '#87d7d7', '#d7d787', '#afafaf']
338
339function agentColor(type: string): string {
340 let h = 0
341 for (const ch of type) h = (h * 31 + ch.charCodeAt(0)) >>> 0
342 return AGENT_PALETTE[h % AGENT_PALETTE.length]
343}
344
345/** scales a #rrggbb color: < 1 darkens, > 1 lightens */
346function shade(hex: string, k: number): string {
347 const n = parseInt(hex.slice(1), 16)
348 const ch = (v: number) => Math.max(0, Math.min(255, Math.round(k > 1 ? v + (255 - v) * (k - 1) : v * k)))
349 const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map(ch)
350 return `#${((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1)}`
351}
352
353const SPRITE = 5
354const INK = '#141414'
355const FLOOR = '#262626'
356const LINK = '#4a4a4a'
357
358const blankCanvas = (w: number, h: number): Canvas =>
359 Array.from({ length: h }, () => Array.from({ length: w }, () => ({ ch: ' ' })))
360
361function put(c: Canvas, x: number, y: number, cell: Cell) {
362 const at = c[y]?.[x]
363 if (at) Object.assign(at, cell)
364}
365
366/** a glyph on the floor; never drawn over a body or a label */
367function glyph(c: Canvas, x: number, y: number, ch: string, fg: string, over = false) {
368 const cell = c[Math.round(y)]?.[Math.round(x)]
369 if (!cell || cell.bg) return
370 if (!over && cell.ch !== ' ' && cell.fg !== FLOOR && cell.fg !== LINK) return
371 cell.ch = ch
372 cell.fg = fg
373}
374
375function text(c: Canvas, x: number, y: number, s: string, fg: string) {
376 ;[...s].forEach((ch, dx) => glyph(c, x + dx, y, ch, fg, true))
377}
378
379type Look = { eyes: string; mouth: string; antennae: [string, string] }
380
381function look(tick: number, i: number, isTalking: boolean): Look {
382 const blink = (tick + i * 7) % 48 >= 46
383 const antennae = ([['╲', '╱'], ['│', '╱'], ['╲', '╱'], ['╲', '│']] as [string, string][])[Math.floor((tick + i * 3) / 4) % 4]
384 return { eyes: blink ? '‿' : '•', mouth: isTalking ? ((tick + i) % 4 < 2 ? 'o' : '‿') : ' ', antennae }
385}
386
387/** a tiny slime: antennae row, face row (eyes), body row (mouth) */
388function drawSlime(c: Canvas, x: number, y: number, color: string, l: Look) {
389 const light = shade(color, 1.35)
390 const dark = shade(color, 0.7)
391 glyph(c, x + 1, y, l.antennae[0], dark, true)
392 glyph(c, x + SPRITE - 2, y, l.antennae[1], dark, true)
393 for (let dx = 0; dx < SPRITE; dx++) {
394 const isEdge = dx === 0 || dx === SPRITE - 1
395 const isEye = dx === 1 || dx === SPRITE - 2
396 put(c, x + dx, y + 1, { ch: isEye ? l.eyes : ' ', bg: dx === 0 ? light : color, fg: INK })
397 put(c, x + dx, y + 2, { ch: dx === 2 ? l.mouth : ' ', bg: isEdge ? dark : color, fg: INK })
398 }
399}
400
401/** the pet at map scale: 9×5 cells plus a row to hop into */
402function miniPetPixels(mood: Mood, tick: number): string[] {
403 const sleepy = mood === 'sleeping'
404 const blink = sleepy || tick % 32 >= 30
405 const busy = mood === 'coding' || mood === 'delegating' || mood === 'happy'
406 const hop = busy ? tick % 12 < 2 : mood === 'idle' && tick % 48 < 2
407 const sway = Math.floor(tick / (busy ? 2 : 8)) % 4
408 const antennae = sleepy
409 ? ['.........', '.aa...aa.']
410 : [['.a.....a.', '..a...a..'], ['a.....a..', '.a...a...'], ['.a.....a.', '..a...a..'], ['..a.....a', '...a...a.']][sway]
411 const eye = blink ? 'D' : 'E'
412 const cheek = mood === 'happy' ? 'P' : 'B'
413 const body = ['..HBBBB..', `.HB${eye}BB${eye}B.`, `D${cheek}BBBBB${cheek}D`]
414 const blank = '.........'
415 return hop ? [...antennae, ...body, blank] : [blank, ...antennae, ...body]
416}
417
418/** the session's pet, painted from its pixel rows */
419function drawPet(c: Canvas, x: number, y: number, rows: string[]) {
420 rows.forEach((r, dy) =>
421 [...r].forEach((k, dx) => {
422 const bg = PET_COLORS[k]
423 if (k !== '.' && bg) put(c, x + dx, y + dy, { ch: ' ', bg })
424 }),
425 )
426}
427
428/** a faint dotted floor, like a map grid */
429function drawFloor(c: Canvas) {
430 c.forEach((row, y) => row.forEach((cell, x) => {
431 if (y % 2 === 0 && x % 4 === 0) {
432 cell.ch = '·'
433 cell.fg = FLOOR
434 }
435 }))
436}
437
438/** a dotted link between two points */
439function drawLink(c: Canvas, from: { x: number; y: number }, to: { x: number; y: number }) {
440 const steps = Math.max(Math.abs(to.x - from.x), Math.abs(to.y - from.y) * 2)
441 for (let k = 1; k < steps; k += 2) {
442 const t = k / steps
443 glyph(c, from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t, '·', LINK)
444 }
445}
446
447/** a packet flying along a link, with a fading trail */
448function drawPacket(c: Canvas, from: { x: number; y: number }, to: { x: number; y: number }, p: number, color: string) {
449 for (const [dt, ch, fg] of [[0.12, '·', '#5f5f5f'], [0.06, '•', '#8c8c8c'], [0, '●', color]] as const) {
450 const t = p - dt
451 if (t < 0 || t > 1) continue
452 glyph(c, from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t, ch, fg)
453 }
454}
455
456type RoomAgent = { id: string; name: string; type: string; since: number }
457type Leaving = { id: string; type: string; tick: number }
458
459const SLOT_W = 9
460const SLOT_H = 5
461const MIN_ROWS = 4
462const MAX_ROWS = 10
463
464type Spot = { x: number; y: number }
465
466/** slot centers around main, nearest first; main's own box stays clear */
467function slotsFor(width: number, rows: number) {
468 const cols = Math.max(1, Math.floor(width / SLOT_W))
469 const height = rows * SLOT_H
470 const cx = Math.floor(width / 2)
471 const cy = Math.floor(height / 2)
472 const spots: Spot[] = []
473 for (let r = 0; r < rows; r++) {
474 for (let k = 0; k < cols; k++) {
475 const x = k * SLOT_W + Math.floor(SLOT_W / 2) + Math.floor((width - cols * SLOT_W) / 2)
476 const y = r * SLOT_H + 2
477 const isMain = Math.abs(x - cx) < 8 && y >= cy - 4 && y <= cy + 6
478 if (!isMain) spots.push({ x, y })
479 }
480 }
481 spots.sort((p, q) => Math.hypot((p.x - cx) / 2, p.y - cy) - Math.hypot((q.x - cx) / 2, q.y - cy))
482 return { spots, height, cx, cy }
483}
484
485function roomScene(
486 agents: RoomAgent[],
487 leaving: Leaving[],
488 seenTick: Record<string, number>,
489 talking: Set<string>,
490 tick: number,
491 width: number,
492 pet: string[],
493) {
494 const fleeting = leaving.filter(l => !agents.some(a => a.id === l.id))
495 const everyone = [...agents.map(a => ({ kind: 'agent' as const, a })), ...fleeting.map(l => ({ kind: 'puff' as const, l }))]
496 // grow rows until everyone fits (or the room is full)
497 let rows = MIN_ROWS
498 let layout = slotsFor(width, rows)
499 while (layout.spots.length < everyone.length && rows < MAX_ROWS) layout = slotsFor(width, ++rows)
500 const { height, cx, cy } = layout
501 const capacity = layout.spots.length
502 const overflow = Math.max(0, everyone.length - capacity)
503 const shown = overflow ? everyone.slice(0, capacity - 1) : everyone
504 const spots = layout.spots
505 const c = blankCanvas(width, height)
506 drawFloor(c)
507
508 const petX = cx - 4
509 const petY = cy - 3
510 const mainAt = { x: cx, y: cy + 1 }
511
512 // links first, so bodies and labels sit on top of them
513 const isCrowded = shown.length > 12
514 shown.forEach((entry, i) => {
515 const isBusy = entry.kind === 'agent' && (talking.has(entry.a.name) || Math.floor((tick + i * 5) / 16) % 3 === 0)
516 if (!isCrowded || isBusy) drawLink(c, mainAt, { x: spots[i].x, y: spots[i].y })
517 })
518 drawPet(c, petX, petY, pet)
519 text(c, cx - 2, petY + pet.length, 'main', '#ececec')
520
521 shown.forEach((entry, i) => {
522 const at = spots[i]
523 const x = at.x - 2
524 const y = at.y - 2
525 if (entry.kind === 'puff') {
526 const age = tick - entry.l.tick
527 const color = agentColor(entry.l.type)
528 glyph(c, at.x, at.y, ['✺', '✦', '*', '·'][Math.min(3, Math.floor(age / 3))], color, true)
529 if (age < 6) {
530 glyph(c, at.x - 2, at.y - 1, '·', color, true)
531 glyph(c, at.x + 2, at.y - 1, '·', color, true)
532 glyph(c, at.x - 1, at.y + 1, '˙', color, true)
533 glyph(c, at.x + 1, at.y + 1, '˙', color, true)
534 }
535 return
536 }
537 const { a } = entry
538 const color = agentColor(a.type)
539 const age = tick - (seenTick[a.id] ?? tick - 99)
540 const isTalking = talking.has(a.name) || talking.has(a.type)
541 if (age < 2) {
542 glyph(c, at.x, at.y, age === 0 ? '·' : '✦', color, true)
543 return
544 }
545 if (age < 4) {
546 for (let dx = 1; dx < SPRITE - 1; dx++) put(c, x + dx, y + 2, { ch: ' ', bg: color })
547 return
548 }
549 const bob = (tick + i * 5) % 16 < 2 ? -1 : 0
550 drawSlime(c, x, y + bob, color, look(tick, i, isTalking))
551 text(c, at.x - Math.floor(Math.min(8, a.name.length) / 2), y + 3, clip(a.name, 8), color)
552 if (isTalking) {
553 ;[...' … '].forEach((ch, dx) => put(c, x + SPRITE + dx - 1, y - 1 + bob, { ch, bg: '#3a3a3a', fg: '#ececec' }))
554 }
555
556 // traffic: talking agents always, the rest in waves; odd agents reply
557 const period = 16
558 const wave = Math.floor((tick + i * 5) / period) % 3 === 0
559 if (isTalking || wave) {
560 const p = ((tick + i * 5) % period) / (period - 4)
561 if (p <= 1) drawPacket(c, i % 2 ? at : mainAt, i % 2 ? mainAt : at, p, color)
562 }
563 })
564
565 if (overflow) {
566 ;[...` +${overflow + 1} `].forEach((ch, dx) => put(c, width - 8 + dx, height - 2, { ch, bg: '#3a3a3a', fg: '#ececec' }))
567 }
568 return c
569}
570
571/** merges equal adjacent cells into runs of [text, bg, fg] */
572function canvasRuns(row: Cell[]): Run[] {
573 const out: Run[] = []
574 for (const cell of row) {
575 const last = out[out.length - 1]
576 if (last && last[1] === cell.bg && last[2] === cell.fg) last[0] += cell.ch
577 else out.push([cell.ch, cell.bg, cell.fg])
578 }
579 return out
580}
581
582// ───────────────────────── helpers ─────────────────────────
583
584const KINDS: { key: string; label: string }[] = [
585 { key: 'builtin', label: 'built-in' },
586 { key: 'mcp', label: 'mcp' },
587 { key: 'plugin', label: 'plugins' },
588 { key: 'agent', label: 'subagents' },
589 { key: 'skill', label: 'skills' },
590]
591
592function classify(tool: string, input: Record<string, unknown> | undefined): [kind: string, name: string] {
593 if (tool === 'Skill') return ['skill', String(input?.skill ?? '?')]
594 if (tool === 'Agent' || tool === 'Task') return ['agent', String(input?.subagent_type ?? 'general-purpose')]
595 if (tool.startsWith('mcp__plugin_')) {
596 const [plugin = '?', ...rest] = tool.slice('mcp__plugin_'.length).split('__')
597 return ['plugin', `${plugin.split('_')[0]} › ${rest.join('__') || plugin}`]
598 }
599 if (tool.startsWith('mcp__')) {
600 const [server = '?', ...rest] = tool.slice(5).split('__')
601 return ['mcp', `${server.replace(/^claude_ai_/, '')} › ${rest.join('__')}`]
602 }
603 return ['builtin', tool]
604}
605
606const HIDDEN_TOOLS = new Set(['SubagentHandback'])
607
608const shortTool = (tool: string) => (tool.startsWith('mcp__') ? tool.split('__').slice(-1)[0] ?? tool : tool)
609
610const fmtTokens = (n: number) =>
611 n >= 1e6 ? `${(n / 1e6).toFixed(2)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : `${n}`
612
613function fmtClock(ms: number) {
614 const s = Math.max(0, Math.floor(ms / 1000))
615 const pad = (x: number) => String(x).padStart(2, '0')
616 return `${Math.floor(s / 3600)}:${pad(Math.floor((s % 3600) / 60))}:${pad(s % 60)}`
617}
618
619function fmtAgo(ms: number) {
620 const s = Math.max(0, Math.floor(ms / 1000))
621 if (s < 60) return `${s}s`
622 if (s < 3600) return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
623 return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
624}
625
626const LIMIT_LABEL: Record<string, string> = { five_hour: '5 hours', seven_day: 'weekly' }
627
628function fmtReset(iso: string | undefined, now: number): string {
629 if (!iso) return ''
630 const t = Date.parse(iso)
631 if (Number.isNaN(t)) return ''
632 const mins = Math.max(0, Math.round((t - now) / 60000))
633 if (mins < 60) return `in ${mins}m`
634 if (mins < 48 * 60) return `in ${Math.floor(mins / 60)}h ${mins % 60}m`
635 return `in ${Math.floor(mins / 1440)}d ${Math.floor((mins % 1440) / 60)}h`
636}
637
638const MODE_LABEL: Record<string, string> = {
639 default: 'manual',
640 auto: 'auto',
641 acceptEdits: 'accept edits',
642 plan: 'plan',
643 bypassPermissions: 'bypass',
644 dontAsk: "don't ask",
645}
646
647const tidyPath = (p: string) => p.replace(/^[A-Za-z]:[\\/]Users[\\/][^\\/]+/, '~').replace(/\\/g, '/')
648const clip = (t: string, n: number) => (n <= 1 ? '' : t.length > n ? `${t.slice(0, n - 1)}…` : t)
649const oneLine = (t: string) => t.replace(/\s+/g, ' ').trim()
650
651// ───────────────────────── data ─────────────────────────
652
653async function refresh($: EngineInterface) {
654 const [usage, now, agentList, cwd, model, envEffort, settings] = await Promise.all([
655 $.session.usage(),
656 $.clock.now(),
657 $.agent.list().catch(() => []),
658 $.session.cwd().catch(() => ''),
659 $.session.model().catch(() => ''),
660 $.env.get('CLAUDE_EFFORT').catch(() => undefined),
661 $.settings.read().catch(() => undefined),
662 ])
663 const tick = await read($, frame)
664 await set($, s => {
665 const seen = { ...s.seen }
666 const seenTick = { ...s.seenTick }
667 const agents = agentList
668 .filter(a => a.status === 'running')
669 .map(a => {
670 seen[a.id] ??= now
671 seenTick[a.id] ??= tick
672 return { id: a.id, name: a.name ?? a.type, type: a.type, description: a.description, parentId: a.parentId, since: seen[a.id] }
673 })
674 const gone = s.agents.filter(a => !agents.some(b => b.id === a.id)).map(a => ({ id: a.id, type: a.type, tick }))
675 const leaving = [...s.leaving, ...gone].filter(l => tick - l.tick < 14)
676 const done = s.agents
677 .filter(a => !agents.some(b => b.id === a.id))
678 .map(a => ({ id: a.id, name: a.name, type: a.type, description: a.description, tookMs: now - a.since, at: now }))
679 const finished = [...s.finished, ...done].filter(x => now - x.at < 3 * 60_000).slice(-8)
680 const log = done.reduce((acc, x) => [...acc, { at: now, who: x.name, color: agentColor(x.type), text: `done · took ${fmtAgo(x.tookMs)}` }], s.log).slice(-40)
681 return {
682 seenTick,
683 leaving,
684 finished,
685 log,
686 usd: usage.cost?.usd ?? s.usd,
687 contextPct: usage.context.percent ?? s.contextPct,
688 contextTokens: usage.context.tokens ?? s.contextTokens,
689 contextWindow: usage.context.window,
690 startedAt: usage.startedAt,
691 now,
692 limits: usage.rateLimits.length ? usage.rateLimits.map(l => ({ ...l })) : s.limits,
693 agents,
694 seen,
695 cwd: cwd || s.cwd,
696 model: model || s.model,
697 effort: s.effort || envEffort || '',
698 defaultMode: String((settings as { permissions?: { defaultMode?: string } } | undefined)?.permissions?.defaultMode ?? s.defaultMode ?? ''),
699 lastActivity: s.lastActivity || now,
700 }
701 })
702}
703
704const pushLog = (s: Stats, line: Omit<LogLine, 'at'>) => [...s.log, { ...line, at: s.now }].slice(-40)
705
706const pushChat = (s: Stats, from: string, to: string, text: string, at: number, kind: 'spawn' | 'message' = 'message') =>
707 [...s.chats, { from, to, text: clip(oneLine(text), 120), at, kind }].slice(-8)
708
709/** classic hook inputs carry the turn's effort and the permission mode */
710function readTurnInfo($: EngineInterface, e: unknown) {
711 const info = e as { effort?: { level?: string }; permission_mode?: string }
712 const effort = info.effort?.level
713 const mode = info.permission_mode
714 if (effort || mode) {
715 void set($, s => ({ effort: effort || s.effort, mode: mode || s.mode }))
716 }
717}
718
719/** a task-notification names the job by its task id or the call that started it */
720const finished = (b: { id: string; taskId?: string }, text: string) => text.includes(b.id) || (!!b.taskId && text.includes(b.taskId))
721
722type TaskRow = { id?: string; subject?: string; status?: string; activeForm?: string; content?: string }
723
724function trackTasks($: EngineInterface, tool: string, input: Record<string, unknown> | undefined, result: unknown) {
725 const res = (result ?? {}) as { task?: TaskRow; tasks?: TaskRow[] }
726 if (tool === 'TaskCreate' && res.task?.id) {
727 const task = { id: String(res.task.id), subject: String(input?.subject ?? res.task.subject ?? ''), status: 'pending', activeForm: input?.activeForm as string | undefined }
728 void set($, s => ({ tasks: [...s.tasks.filter(x => x.id !== task.id), task] }))
729 } else if (tool === 'TaskUpdate' && input?.taskId) {
730 const id = String(input.taskId)
731 void set($, s => ({
732 tasks: input.status === 'deleted'
733 ? s.tasks.filter(x => x.id !== id)
734 : s.tasks.map(x => (x.id === id
735 ? { ...x, status: String(input.status ?? x.status), subject: String(input.subject ?? x.subject), activeForm: (input.activeForm as string | undefined) ?? x.activeForm }
736 : x)),
737 }))
738 } else if (tool === 'TaskList' && Array.isArray(res.tasks)) {
739 const fresh = res.tasks.map(x => ({ id: String(x.id), subject: String(x.subject ?? ''), status: String(x.status ?? 'pending') }))
740 void set($, s => ({ tasks: fresh.map(x => ({ ...s.tasks.find(o => o.id === x.id), ...x })) }))
741 } else if (tool === 'TodoWrite' && Array.isArray(input?.todos)) {
742 const todos = (input.todos as TaskRow[]).map((x, i) => ({ id: `todo-${i}`, subject: String(x.content ?? ''), status: String(x.status ?? 'pending'), activeForm: x.activeForm }))
743 void set($, () => ({ tasks: todos }))
744 }
745}
746
747export const register: Register = on => {
748 on('session.start', async ($, e, next) => {
749 await $.command.register({ name: 'dashboard', description: 'Open the usage dashboard' })
750 void $.ui.open({ id: PANE, title: 'Dashboard' })
751 await set($, () => ({ cwd: e.cwd, background: [], leaving: [] }))
752 await refresh($)
753 $.clock.every(1000, () => void refresh($))
754 $.clock.every(125, () => void update($, frame, f => (f + 1) % 100_000).catch(() => 0))
755 return next(e)
756 })
757
758 on('command.run', { command: 'dashboard' }, async $ => {
759 await $.ui.open({ id: PANE, title: 'Dashboard' })
760 return { text: 'Usage dashboard opened.' }
761 })
762
763 on('classic.UserPromptSubmit', async ($, e, next) => {
764 const input = e as { session_title?: string; prompt?: string }
765 readTurnInfo($, e)
766 void set($, s => ({
767 title: input.session_title || s.title || clip(oneLine(input.prompt ?? ''), 48),
768 lastActivity: s.now,
769 }))
770 return next(e)
771 })
772
773 on('prompt.submit', async ($, e, next) => {
774 const origin = (e as { origin?: { kind?: string } }).origin?.kind
775 const raw = e.text ?? ''
776 if (origin === 'task-notification' || raw.includes('<task-notification>')) {
777 void set($, s => {
778 const ended = s.background.filter(b => finished(b, raw))
779 return {
780 background: s.background.filter(b => !finished(b, raw)),
781 log: ended.reduce((acc, b) => [...acc, { at: s.now, who: b.kind, text: `background job finished · ${clip(b.label, 60)}` }], s.log).slice(-40),
782 lastActivity: s.now,
783 }
784 })
785 return next(e)
786 }
787 const text = oneLine(raw)
788 if (text && !text.startsWith('/') && (!origin || origin === 'user')) void set($, s => ({ title: s.title || clip(text, 48), lastActivity: s.now }))
789 return next(e)
790 })
791
792 on('classic.PostToolUse', async ($, e, next) => {
793 readTurnInfo($, e)
794 return next(e)
795 })
796
797 on('classic.Stop', async ($, e, next) => {
798 readTurnInfo($, e)
799 const inFlight = (e as { background_tasks?: { id: string }[] }).background_tasks
800 if (Array.isArray(inFlight)) {
801 const ids = new Set(inFlight.map(x => x.id))
802 void set($, s => ({ background: s.background.filter(b => (b.taskId ? ids.has(b.taskId) : true)) }))
803 }
804 return next(e)
805 })
806
807 on('classic.SessionStart', async ($, e, next) => {
808 const title = (e as { session_title?: string }).session_title
809 readTurnInfo($, e)
810 if (title) void set($, () => ({ title }))
811 return next(e)
812 })
813
814 on('turn.step', async function* ($, e, next) {
815 if (!e.agentId && e.effort !== undefined) {
816 const effort = String(e.effort)
817 void set($, s => (s.effort === effort ? {} : { effort }))
818 }
819 return yield* next(e)
820 })
821
822 on('turn.start', async ($, e, next) => {
823 if (!(e as { agentId?: string }).agentId) void set($, s => ({ turnActive: true, lastActivity: s.now }))
824 return next(e)
825 })
826
827 on('tool.call', async ($, e, next) => {
828 if (HIDDEN_TOOLS.has(e.tool)) return next(e)
829 const input = e as unknown as Record<string, unknown>
830 const [kind, name] = classify(e.tool, input)
831 const key = `${kind}|${name}`
832 const isBackground = input?.run_in_background === true || e.tool === 'Monitor'
833 const isAgent = e.tool === 'Agent' || e.tool === 'Task'
834 void set($, s => {
835 const byKind = { ...s.byKind }
836 byKind[kind] = { ...(byKind[kind] ?? {}), [name]: (byKind[kind]?.[name] ?? 0) + 1 }
837 const from = (e as { agentId?: string }).agentId ? 'agent' : 'main'
838 const chats = isAgent
839 ? pushChat(s, from, String(input?.name ?? input?.subagent_type ?? 'agent'), `▸ ${String(input?.description ?? 'new task')}`, s.now, 'spawn')
840 : s.chats
841 const log = isAgent
842 ? pushLog(s, { who: from, color: PET_COLORS.B, to: String(input?.subagent_type ?? 'agent'), toColor: agentColor(String(input?.subagent_type ?? 'agent')), text: String(input?.description ?? 'new task') })
843 : s.log
844 return {
845 log,
846 toolCalls: s.toolCalls + 1,
847 busy: s.busy + 1,
848 active: { ...s.active, [key]: (s.active[key] ?? 0) + 1 },
849 lastUsed: { ...s.lastUsed, [key]: s.now },
850 current: shortTool(e.tool),
851 lastActivity: s.now,
852 byKind,
853 chats,
854 }
855 })
856 try {
857 const r = await next(e)
858 if (isBackground && !isAgent) {
859 const text = String((r as { text?: unknown }).text ?? '')
860 const taskId = /(?:ID|id|task[_ ]?id)[:=]?\s*"?([A-Za-z0-9_-]{5,})/.exec(text)?.[1]
861 const label = String(input?.description ?? input?.command ?? e.tool)
862 void set($, s => ({
863 background: [...s.background, { id: e.tool_use_id, taskId, kind: e.tool, label: clip(oneLine(label), 80), since: s.now }].slice(-12),
864 log: pushLog(s, { who: 'main', color: PET_COLORS.B, text: `started ${e.tool} in background · ${clip(oneLine(label), 60)}` }),
865 }))
866 }
867 trackTasks($, e.tool, input, (r as { result?: unknown }).result)
868 if ((r as { isError?: boolean }).isError) void set($, x => ({ puzzledUntil: x.now + 5000, puzzledBy: shortTool(e.tool) }))
869 return r
870 } finally {
871 void set($, s => ({
872 busy: Math.max(0, s.busy - 1),
873 active: { ...s.active, [key]: Math.max(0, (s.active[key] ?? 0) - 1) },
874 lastUsed: { ...s.lastUsed, [key]: s.now },
875 }))
876 }
877 })
878
879 on('session.send', async ($, e, next) => {
880 void set($, s => {
881 const from = e.agentId ? (s.agents.find(a => a.id === e.agentId)?.name ?? 'agent') : 'main'
882 return { chats: pushChat(s, from, e.to, e.text, s.now), log: pushLog(s, { who: from, to: e.to, text: e.text }), lastActivity: s.now }
883 })
884 return next(e)
885 })
886
887 on('session.receive', async ($, e, next) => {
888 const origin = e.origin as { kind: string; name?: string; from?: string }
889 void set($, s => {
890 if (origin.kind === 'task-notification') {
891 const background = s.background.filter(b => !finished(b, e.text))
892 return { background, lastActivity: s.now }
893 }
894 return { chats: pushChat(s, origin.name ?? origin.from ?? origin.kind, 'main', e.text, s.now), lastActivity: s.now }
895 })
896 return next(e)
897 })
898
899 on('turn.complete', async ($, e, next) => {
900 const r = await next(e)
901 const u = r.usage
902 const isMain = !(e as { agentId?: string }).agentId
903 await set($, s => ({
904 tokens: u ? s.tokens + u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens : s.tokens,
905 turnActive: isMain ? false : s.turnActive,
906 lastActivity: s.now,
907 }))
908 await refresh($)
909 return r
910 })
911
912 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
913 const { Box, Text, Button } = $.ui.resolve(e)
914 const s = { ...EMPTY, ...(await read($, stats)) }
915 const tick = await read($, frame)
916 const f = Math.floor(tick / 3)
917 const total = Math.max(24, ((e.props as { bodyColumns?: number }).bodyColumns ?? e.viewport?.columns ?? 40) - 4)
918 const rows = e.viewport?.rows
919 const twoCols = total >= 76
920 const gap = 4
921 const colW = twoCols ? Math.floor((total - gap) / 2) : total
922 const labelW = 10
923
924 const heading = (title: string, right = '', width = colW) => (
925 <Box marginTop={1} flexDirection="column">
926 <Text>
927 <Text color={C.label} bold>{title.toUpperCase()}</Text>
928 {right ? <Text color={C.dim}> {right}</Text> : null}
929 </Text>
930 <Text color={C.rule}>{'─'.repeat(width)}</Text>
931 </Box>
932 )
933 const row = (label: string, value: string, color = C.value) => (
934 <Text>
935 <Text color={C.label}>{label.padEnd(labelW)}</Text>
936 <Text color={color} bold>{value}</Text>
937 </Text>
938 )
939 const meter = (pctRaw: number, w: number) => {
940 const p = Math.round(pctRaw)
941 const col = p >= 85 ? C.bad : p >= 60 ? C.warn : C.fill
942 const n = Math.round((Math.min(100, Math.max(0, p)) / 100) * w)
943 return (
944 <Text>
945 <Text color={col}>{'━'.repeat(n)}</Text>
946 <Text color={C.track}>{'━'.repeat(Math.max(0, w - n))}</Text>
947 <Text color={col} bold> {String(p).padStart(3)}%</Text>
948 </Text>
949 )
950 }
951 const more = (n: number) => (n > 0 ? <Text color={C.dim}> +{n} more</Text> : null)
952
953 // ── header: session, path, model, effort, mode
954 const isLive = s.turnActive || s.busy > 0
955 const liveMode = s.mode || s.defaultMode
956 const mode = MODE_LABEL[liveMode] ?? liveMode
957 const modeColor = liveMode === 'plan' ? C.warn : liveMode === 'bypassPermissions' ? C.bad : C.value
958 const header = (
959 <Box flexDirection="column" marginTop={1}>
960 <Text wrap="truncate">
961 <Text color={isLive ? C.ok : C.dim}>{isLive ? '●' : '○'} </Text>
962 <Text color={C.value} bold>{clip(s.title || 'untitled session', total - 2)}</Text>
963 </Text>
964 <Text wrap="truncate">
965 <Text color={C.dim}> dir </Text>
966 <Text color={C.text}>{clip(tidyPath(s.cwd || '—'), total - 9)}</Text>
967 </Text>
968 <Text wrap="truncate">
969 <Text color={C.dim}> model </Text>
970 <Text color={C.text}>{s.model || '—'}</Text>
971 <Text color={C.dim}> effort </Text>
972 <Text color={s.effort ? C.value : C.dim} bold={!!s.effort}>{s.effort || '—'}</Text>
973 <Text color={C.dim}> mode </Text>
974 <Text color={liveMode ? modeColor : C.dim} bold={!!s.mode}>{mode || '—'}</Text>
975 {!s.mode && s.defaultMode ? <Text color={C.dim}> (default)</Text> : null}
976 </Text>
977 </Box>
978 )
979
980 // ── pet
981 const mood = moodOf(s)
982 const petLines = scene(mood, tick, s.agents.length).map(line => (
983 <Text wrap="truncate">
984 {runs(line).map(([t, bg, fg]) => (bg ? <Text backgroundColor={bg}>{t}</Text> : <Text color={fg}>{t}</Text>)) as never}
985 </Text>
986 ))
987 const say = bubble(mood, s, f)
988 const pet = (
989 <Box flexDirection="column" marginTop={1}>
990 <Box flexDirection="column" flexShrink={0} width={SPRITE_W * 2 + 1}>{petLines as never}</Box>
991 <Box flexDirection="row" marginTop={1} gap={2}>
992 <Text color={C.value} bold>{mood}</Text>
993 {say ? <Text color={C.text} wrap="truncate">{clip(say, Math.max(6, colW - 14))}</Text> : null}
994 </Box>
995 <Box flexDirection="row" gap={2}>
996 <Button
997 key="pet"
998 plain
999 dimColor
1000 hotkey="p"
1001 onPress={async () => {
1002 await set($, x => ({ happyUntil: x.now + 3500, pets: x.pets + 1, lastActivity: x.now }))
1003 }}
1004 >
1005 pet
1006 </Button>
1007 <Text color={C.dim}>{s.pets ? `♥ ${s.pets}` : ''}</Text>
1008 </Box>
1009 </Box>
1010 )
1011
1012 // ── usage, context, limits
1013 const barW = Math.max(8, Math.min(28, colW - labelW - 6))
1014 const usage = (
1015 <Box flexDirection="column">
1016 {heading('usage')}
1017 {row('tokens', fmtTokens(s.tokens))}
1018 {row('cost', `$${s.usd.toFixed(4)}`)}
1019 {row('tools', String(s.toolCalls))}
1020 {row('session', s.startedAt ? fmtClock(s.now - s.startedAt) : '—')}
1021
1022 {heading('context', `${fmtTokens(s.contextTokens)} / ${fmtTokens(s.contextWindow)}`)}
1023 {meter(s.contextPct, Math.max(8, Math.min(36, colW - 6)))}
1024
1025 {heading('limits')}
1026 {s.limits.length === 0 ? <Text color={C.dim}>no reading yet</Text> : null}
1027 {s.limits.map(l => (
1028 <Box flexDirection="column">
1029 <Text>
1030 <Text color={C.label}>{(LIMIT_LABEL[l.kind] ?? l.kind).padEnd(labelW)}</Text>
1031 {meter(l.percentUsed, barW)}
1032 </Text>
1033 {l.resetsAt ? <Text color={C.dim}>{' '.repeat(labelW)}resets {fmtReset(l.resetsAt, s.now)}</Text> : null}
1034 </Box>
1035 ))}
1036 </Box>
1037 )
1038
1039 // ── room: agent map, participants, and the room log
1040 const talking = new Set(s.chats.filter(c => s.now - c.at < 6000).flatMap(c => [c.from, c.to]))
1041 const roomW = total
1042 const isStacked = roomW < 72
1043 const mapW = isStacked ? roomW : Math.max(40, Math.floor(roomW * 0.7))
1044 const sideW = isStacked ? roomW : roomW - mapW - 2
1045 const canvas = roomScene(s.agents, s.leaving, s.seenTick, talking, tick, mapW - 4, miniPetPixels(mood, tick))
1046 const newest = [...s.agents].sort((a, b) => b.since - a.since)
1047 const clockOf = (ms: number) => new Date(ms).toTimeString().slice(0, 8)
1048 const statusOf = (name: string, type: string) => (talking.has(name) || talking.has(type) ? 'TALKING' : 'ACTIVE')
1049 const sideRows = Math.max(4, canvas.length - 1)
1050 const panel = (title: string, w: number, body: unknown) => (
1051 <Box flexDirection="column" width={w} borderStyle="round" borderColor={C.rule} paddingX={1}>
1052 <Text color={C.label} bold>{title}</Text>
1053 {body as never}
1054 </Box>
1055 )
1056 const map = panel(
1057 `AGENT MAP · ${s.agents.length} active${s.leaving.length ? ` · ${s.leaving.length} leaving` : ''}`,
1058 mapW,
1059 canvas.map(line => (
1060 <Text wrap="truncate">
1061 {canvasRuns(line).map(([t, bg, fg]) => (bg ? <Text backgroundColor={bg} color={fg}>{t}</Text> : <Text color={fg}>{t}</Text>)) as never}
1062 </Text>
1063 )),
1064 )
1065 // two lines per participant: who and how, then what (the summary gets the full width)
1066 const detailW = Math.max(8, sideW - 6)
1067 const entry = (head: unknown, detail: string, detailColor: string) => (
1068 <Box flexDirection="column">
1069 <Text wrap="truncate">{head as never}</Text>
1070 <Text wrap="truncate" color={detailColor}>{' └ '}{clip(oneLine(detail), detailW)}</Text>
1071 </Box>
1072 )
1073 const slots = Math.max(1, Math.floor((sideRows - 1) / 2))
1074 const runningShown = newest.slice(0, Math.max(0, slots - 1))
1075 const finishedShown = [...s.finished].reverse().slice(0, Math.max(0, slots - 1 - runningShown.length))
1076 const participantRows = [
1077 entry(
1078 [
1079 <Text backgroundColor={PET_COLORS.B}>{' '}</Text>,
1080 <Text color={C.value} bold> main</Text>,
1081 <Text color={C.text}> {mood}</Text>,
1082 ],
1083 `lead · ${fmtTokens(s.tokens)} tokens · ${s.toolCalls} tools · $${s.usd.toFixed(2)}`,
1084 C.dim,
1085 ),
1086 ...runningShown.map(a => {
1087 const isTalking = statusOf(a.name, a.type) === 'TALKING'
1088 return entry(
1089 [
1090 <Text backgroundColor={agentColor(a.type)}>{' '}</Text>,
1091 <Text color={agentColor(a.type)} bold> {clip(a.name, 16)}</Text>,
1092 <Text color={isTalking ? C.warn : C.ok}> {isTalking ? '◆ talking' : '● active'}</Text>,
1093 <Text color={C.value}> {fmtAgo(s.now - a.since)}</Text>,
1094 ],
1095 a.description,
1096 C.label,
1097 )
1098 }),
1099 ...finishedShown.map(x =>
1100 entry(
1101 [
1102 <Text color={C.ok}>✓</Text>,
1103 <Text color={C.label}> {clip(x.name, 16)}</Text>,
1104 <Text color={C.dim}> done · {fmtAgo(x.tookMs)}</Text>,
1105 ],
1106 x.description,
1107 C.dim,
1108 ),
1109 ),
1110 ]
1111 const hidden = newest.length - runningShown.length
1112 const participants = panel(
1113 `PARTICIPANTS (${s.agents.length + 1})`,
1114 sideW,
1115 [...participantRows, hidden > 0 ? <Text color={C.dim}> +{hidden} more running</Text> : null],
1116 )
1117 const logLines = s.log.slice(-6)
1118 const log = panel(
1119 'LIVE INTERACTION STREAM',
1120 roomW,
1121 logLines.length === 0
1122 ? <Text color={C.dim}>quiet · main is alone in the room</Text>
1123 : logLines.map(l => (
1124 <Text wrap="truncate">
1125 <Text color={C.dim}>[{clockOf(l.at)}] </Text>
1126 <Text color={l.color ?? C.text} bold>{clip(l.who, 16)}</Text>
1127 {l.to ? <Text color={C.dim}> → </Text> : null}
1128 {l.to ? <Text color={l.toColor ?? C.text} bold>{clip(l.to, 16)}</Text> : null}
1129 <Text color={C.label}>: {oneLine(l.text)}</Text>
1130 </Text>
1131 )),
1132 )
1133 const room = (
1134 <Box flexDirection="column" flexGrow={1} marginBottom={1}>
1135 {heading('room', `${s.agents.length} active`, roomW)}
1136 <Box flexDirection={isStacked ? 'column' : 'row'} gap={isStacked ? 0 : 2}>
1137 {map}
1138 {participants}
1139 </Box>
1140 {log}
1141 </Box>
1142 )
1143
1144 // ── tasks
1145 const taskOrder: Record<string, number> = { in_progress: 0, pending: 1, completed: 2 }
1146 const taskList = [...s.tasks].sort((a, b) => (taskOrder[a.status] ?? 1) - (taskOrder[b.status] ?? 1))
1147 const doneCount = s.tasks.filter(x => x.status === 'completed').length
1148 const tasks = (
1149 <Box flexDirection="column">
1150 {heading('tasks', s.tasks.length ? `${doneCount}/${s.tasks.length}` : '0')}
1151 {s.tasks.length === 0 ? <Text color={C.dim}>no tasks</Text> : null}
1152 {taskList.slice(0, 5).map(x => {
1153 const isRunning = x.status === 'in_progress'
1154 const isDone = x.status === 'completed'
1155 const mark = isRunning ? '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'[tick % 10] : isDone ? '✓' : '○'
1156 const label = isRunning && x.activeForm ? x.activeForm : x.subject
1157 return (
1158 <Text wrap="truncate">
1159 <Text color={isRunning ? C.value : isDone ? C.ok : C.dim}>{mark} </Text>
1160 <Text color={isRunning ? C.value : isDone ? C.dim : C.text} bold={isRunning} strikethrough={isDone}>{clip(oneLine(label), colW - 3)}</Text>
1161 </Text>
1162 )
1163 })}
1164 {more(taskList.length - 5)}
1165 </Box>
1166 )
1167
1168 // ── background jobs
1169 const background = (
1170 <Box flexDirection="column">
1171 {heading('background', String(s.background.length))}
1172 {s.background.length > 0 ? (
1173 <Button key="clear-background" plain dimColor onPress={async () => { await set($, () => ({ background: [] })) }}>
1174 clear
1175 </Button>
1176 ) : null}
1177 {s.background.length === 0 ? <Text color={C.dim}>nothing running</Text> : null}
1178 {s.background.slice(-4).map(b => (
1179 <Text wrap="truncate">
1180 <Text color={C.fill}>{'⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'[tick % 10]} </Text>
1181 <Text color={C.text} bold>{b.kind.padEnd(10)}</Text>
1182 <Text color={C.dim}>{fmtAgo(s.now - b.since).padStart(6)} </Text>
1183 <Text color={C.label}>{clip(b.label, Math.max(0, colW - 20))}</Text>
1184 </Text>
1185 ))}
1186 {more(s.background.length - 4)}
1187 </Box>
1188 )
1189
1190 // ── tools: a full-width strip at the bottom, one column per group.
1191 // In-flight first (white dot), then just finished (grey dot), then by calls.
1192 const isActive = (kind: string, name: string) => (s.active[`${kind}|${name}`] ?? 0) > 0
1193 const isRecent = (kind: string, name: string) => s.now - (s.lastUsed[`${kind}|${name}`] ?? 0) < 3000
1194 const rank = (kind: string, name: string) => (isActive(kind, name) ? 2 : isRecent(kind, name) ? 1 : 0)
1195 const groups = KINDS.map(k => {
1196 const items = Object.entries(s.byKind[k.key] ?? {}).sort(
1197 (a, b) => rank(k.key, b[0]) - rank(k.key, a[0]) || b[1] - a[1] || a[0].localeCompare(b[0]),
1198 )
1199 return { ...k, items, total: items.reduce((acc, [, c]) => acc + c, 0), top: items.length ? rank(k.key, items[0][0]) : 0 }
1200 })types/index.d.ts 65 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2export type RunningAgent = { id: string; name: string; type: string; description: string; parentId?: string; since: number }
3export type BackgroundJob = { id: string; taskId?: string; kind: string; label: string; since: number }
4export type Task = { id: string; subject: string; status: string; activeForm?: string }
5export type Chat = { from: string; to: string; text: string; at: number; kind?: 'spawn' | 'message' }
6export type LogLine = { at: number; who: string; to?: string; text: string; color?: string; toColor?: string }
7export type FinishedAgent = { id: string; name: string; type: string; description: string; tookMs: number; at: number }
8
9export type Stats = {
10 tokens: number
11 usd: number
12 contextPct: number
13 contextTokens: number
14 contextWindow: number
15 toolCalls: number
16 startedAt: number
17 now: number
18 /** kind -> name -> calls */
19 byKind: Record<string, Record<string, number>>
20 limits: Limit[]
21 agents: RunningAgent[]
22 /** when each agent id was first seen */
23 seen: Record<string, number>
24 /** animation tick when each agent id was first seen */
25 seenTick: Record<string, number>
26 /** agents that just finished, kept for their exit animation */
27 leaving: { id: string; type: string; tick: number }[]
28 /** agents that finished recently, with how long they took */
29 finished: FinishedAgent[]
30 /** the room log: spawns, finishes, messages, background jobs */
31 log: LogLine[]
32 background: BackgroundJob[]
33 tasks: Task[]
34 chats: Chat[]
35 /** tool calls in flight */
36 busy: number
37 /** "kind|name" -> calls in flight */
38 active: Record<string, number>
39 /** "kind|name" -> last time it ran */
40 lastUsed: Record<string, number>
41 /** short name of the last tool that started */
42 current: string
43 turnActive: boolean
44 lastActivity: number
45 happyUntil: number
46 /** a tool failed: the pet is puzzled until then */
47 puzzledUntil: number
48 /** what failed, for the bubble */
49 puzzledBy: string
50 pets: number
51 title: string
52 cwd: string
53 model: string
54 effort: string
55 mode: string
56 /** permissions.defaultMode from settings, until a turn reports the live mode */
57 defaultMode: string
58}
59
60declare module 'claude-code' {
61 interface PluginState {
62 'usage-dashboard': { stats: Stats; frame: number }
63 }
64}
65