SLOPSHOPPER

usage-dashboard

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

newpaneguardcommandprompttimer
v0.2.0MITupdated 2026-10-08pieroprUnap/usage-dashboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-dashboard
│ ┃ Dashboard ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ○ fix the failing auth test and add an aud ⏺ Read(src/auth.ts) │ ┃ dir /work/app ⎿ Read 6 lines │ ┃ model claude-opus-5-5 effort — mode ⏺ Update(src/auth.ts) │ ┃ ────────────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /dashboard │ ┃ ⎿ usage-dashboard: Usage dashboard opened. │ ┃ │ ┃ idle │ ┃ p: pet │ ┃ │ ┃ USAGE │ ┃ ────────────────────────────────────────── │ ┃ tokens 98.9k │ ┃ cost $0.4200 │ ┃ tools 0 │ ┃ session 0:30:00 │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Dashboard
○ fix the failing auth test and add an audit log … dir /work/app model claude-opus-5-5 effort — mode — ──────────────────────────────────────────────────── idle p: pet USAGE ──────────────────────────────────────────────────── tokens 98.9k cost $0.4200 tools 0 session 0:30:00 CONTEXT 97.4k / 200.0k ──────────────────────────────────────────────────── ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 49% LIMITS ──────────────────────────────────────────────────── 5 hours ━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 31% resets TASKS 0 ──────────────────────────────────────────────────── no tasks BACKGROUND 0 ──────────────────────────────────────────────────── nothing running TOOLS 0 ──────────────────────────────────────────────────── no calls yet ROOM 0 active ──────────────────────────────────────────────────── ╭──────────────────────────────────────────────────╮ │ AGENT MAP · 0 active │ │ · · · · · · · · · · · · │ │ │ │ · · · · · · · · · · · · │ │ │ │ · · · · · · · · · · · · │ │ │ │ · · · · · · · · · · · · │ │ │ │ · · · · · · · · · · · · │ │ │ │ · · · · · · · · · · · │ │ │ │ · · · · · · · · · · · · │ │ main │ │ · · · · · · · · · · · · │ │ │ │ · · · · · · · · · · · · │ │ │ │ · · · · · · · · · · · · │ │ │ ╰──────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────╮ │ PARTICIPANTS (1) │ │ main idle │ │ └ lead · 98.9k tokens · 0 tools · $0.42 │ ╰──────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────╮ │ LIVE INTERACTION STREAM │ │ quiet · main is alone in the room │ ╰──────────────────────────────────────────────────╯
README

usage-dashboard

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.

What it shows

SectionContents
Headersession title, working directory, model, effort and permission mode
Peta 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)
Usagetokens, cost, tool calls, session time
Contextcontext window fill, amber from 60 %, red from 85 %
Limits5-hour and weekly rate-limit windows with time to reset
Tasksthe session's task list: running, pending, done
Backgroundbackground shells and monitors, removed when their task-notification arrives (clear drops any leftover)
Toolscalls grouped by kind (built-in, mcp, plugins, subagents, skills); the tool in flight goes first with a pulsing dot
Rooman 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.

Install

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

Many subagents

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"

Develop

claude plugin validate .
claude plugin test .

Files:

  • .claude-plugin/plugin.json: manifest
  • hooks/hooks.json: points at the hooks module
  • hooks/register.tsx: the hooks and the pane
  • hooks/dashboard.test.ts: tests run by claude plugin test
  • types/index.d.ts: the shape of the state the pane keeps

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

Known limits

  • Mode only appears when Claude Code passes the permission mode to mods; otherwise the header shows the configured default, or —.
  • Font size cannot be changed per section: everything uses the terminal's font.

License

MIT

Source 2 files
hooks/register.tsx 1266 lines
1import { 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 lines
1export 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