Band above the prompt showing running subagents, progress and tokens; hides itself 10 min after the last one finishes.

Part of Watcher. A band above the prompt shows each running subagent with a pixel crab, its model and tokens. Press a for the panel. The band hides 10 minutes after the last agent finishes.
Watcher draws on screen only. It sends nothing anywhere, starts no programs, reads no files, and adds nothing to the model's context.
| Hook | What it does |
|---|---|
agent.spawn | Notes the new subagent's name, type and model for the band. The call passes through unchanged; its prompt, settings and tools are never edited. |
tool.call, turn.complete | Read progress and token counts from subagent turns. Nothing is changed. |
prompt.submit | A new prompt lifts the pause key (h). The prompt passes through unchanged. |
session.start, ui.* | Restore the band's own state and draw the band and panel. |
Settings: lingerMinutes (10) and bandRows (2).
hooks/register.tsx 524 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { AgentRow } from '../types'
5
6// How long the band and panel linger after the last agent finishes.
7// Set from the plugin's settings when it loads (see plugin.json userConfig).
8let LINGER_MS = 10 * 60 * 1000
9let BAND_ROWS = 2
10const PANE = 'agent-watcher-list'
11// The ⤢ chip's column beside the rows.
12const CHIP_COLS = 8
13const CHIP_PX = CHIP_COLS * 8 + 16
14
15const rows = atom({ plugin: 'agent-watcher', key: 'rows' } as const, [] as AgentRow[])
16const tick = atom({ plugin: 'agent-watcher', key: 'tick' } as const, 0)
17const isPanelOpen = atom({ plugin: 'agent-watcher', key: 'isPanelOpen' } as const, false)
18const isPaused = atom({ plugin: 'agent-watcher', key: 'isPaused' } as const, false)
19
20// Each agent wears its own color (a bandana on the crab, its name, its bar).
21// Each agent type gets a stable accent from this palette, picked by its name.
22const ACCENTS = ['#3FA7D6', '#E0A526', '#D65DB1', '#8F8CF4', '#3B9C5F', '#E0703A', '#5B8DEF']
23const accentOf = (type: string) => {
24 let hash = 0
25 for (const c of type) hash = (hash * 31 + c.charCodeAt(0)) >>> 0
26 return ACCENTS[hash % ACCENTS.length]
27}
28const DONE = '#3B9C5F'
29const FAILED = '#D0453F'
30const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
31
32const shortModel = (model: string) =>
33 /opus/i.test(model) ? 'opus' : /sonnet/i.test(model) ? 'sonnet' : /haiku/i.test(model) ? 'haiku' : model
34
35const formatTokens = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`)
36
37const formatElapsed = (ms: number) => {
38 const s = Math.max(0, Math.round(ms / 1000))
39 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
40}
41
42// Stats read model · time · tools · tokens; a narrow band keeps only the most useful few,
43// dropping the model first, then tokens, then tools (elapsed time always stays).
44const stats = (row: AgentRow, now: number, keep = 4) => {
45 const parts = [
46 { rank: 3, text: row.model },
47 { rank: 0, text: formatElapsed((row.endedAt ?? now) - row.startedAt) },
48 { rank: 1, text: `${row.tools} tools` },
49 { rank: 2, text: row.tokens > 0 ? formatTokens(row.tokens) : '' },
50 ]
51 return parts.filter(p => p.text && p.rank < keep).map(p => p.text).join(' · ')
52}
53
54// Running agents first, newest first within each group.
55const ordered = (all: AgentRow[]) =>
56 [...all].sort((a, b) => {
57 const aRunning = a.status === 'running'
58 const bRunning = b.status === 'running'
59 if (aRunning !== bRunning) {
60 return aRunning ? -1 : 1
61 }
62 return b.startedAt - a.startedAt
63 })
64
65const xml = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
66
67// Rough advance of system UI text, in em; enough to keep text out of the stats.
68const textWidth = (s: string, size: number) =>
69 [...s].reduce((w, ch) => w + (/[\s.,:;'|!il1()]/.test(ch) ? 0.3 : /[A-Zmw@%]/.test(ch) ? 0.72 : 0.56) * size, 0)
70
71const fitText = (s: string, size: number, maxW: number) => {
72 if (textWidth(s, size) <= maxW) {
73 return s
74 }
75 let out = ''
76 for (const ch of s) {
77 if (textWidth(out + ch + '…', size) > maxW) {
78 break
79 }
80 out += ch
81 }
82 return out + '…'
83}
84
85// ---------------------------------------------------------------------------
86// Pixel Clawd, adapted from savvy-progress by JohnnyVizz (MIT,
87// github.com/JohnnyVizz/claude-kit): a clay crab on a 30×28 grid whose legs step
88// while it works. Here every crab wears a bandana in its agent's color.
89
90const CLAY = '#D97757'
91const INK = '#1F1E1D'
92const CRAB_BOX = 30
93
94const CRAB_CSS = `<style>
95.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
96.run .bd{animation:bob .5s steps(1) infinite -.125s}
97.run g{transform-box:fill-box}
98@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
99@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}
100</style>`
101
102const crab = (x: number, y: number, accent: string, isWalking: boolean, scale: number) => {
103 const px = (cx: number, cy: number, w: number, ht: number, c: string) =>
104 `<rect x="${cx}" y="${cy}" width="${w}" height="${ht}" fill="${c}"/>`
105 const body = [
106 px(7, 10, 16, 12, CLAY),
107 px(3, 14, 4, 4, CLAY),
108 px(23, 14, 4, 4, CLAY),
109 px(9, 13, 2, 2, INK),
110 px(19, 13, 2, 2, INK),
111 px(6, 9, 18, 3, accent),
112 px(3, 10, 3, 2, accent),
113 ].join('')
114 const legsA = px(7, 22, 2, 4, CLAY) + px(17, 22, 2, 4, CLAY)
115 const legsB = px(11, 22, 2, 4, CLAY) + px(21, 22, 2, 4, CLAY)
116 return `<g transform="translate(${x},${y}) scale(${scale})" shape-rendering="crispEdges"><g class="${isWalking ? 'run' : ''}"><g class="bd">${body}</g><g class="la">${legsA}</g><g class="lb">${legsB}</g></g></g>`
117}
118
119// The Watcher track: a thin pixel bar adapted from savvy-progress by JohnnyVizz (MIT,
120// github.com/JohnnyVizz/claude-kit). Cells sit on a fixed noise pattern and only twinkle in
121// opacity, in four out-of-phase groups: nothing moves, nothing is blurred, so it never jitters,
122// and its source changes only with the status. The band never shows progress: running twinkles
123// across the whole width, done settles to a slow glow, failed is a still red bar. Only an expanded
124// panel passes a real percentage (2% or more), and the pixels then fill up to it.
125const noise = (x: number, y: number) => {
126 const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
127 return s - Math.floor(s)
128}
129const CELL = 3
130
131const glowTrack = (id: string, x: number, y: number, w: number, color: string, status: 'running' | 'done' | 'failed', percent: number | null = null) => {
132 const tint = status === 'failed' ? FAILED : color
133 const cols = Math.floor(w / CELL)
134 const isProgress = percent !== null && percent >= 2 && status === 'running'
135 const shown = isProgress ? Math.round((cols * Math.min(100, percent as number)) / 100) : cols
136 // Running: dots on a fixed noise pattern; finished, failed or real progress: every dot lit.
137 const density = status === 'running' && !isProgress ? 0.62 : 1
138 const cells: string[] = []
139 for (let c = 0; c < shown; c++) {
140 for (let r = 0; r < 2; r++) {
141 if (noise(c, r) < density) {
142 cells.push(`<rect class="t${Math.floor(noise(r, c) * 4)}" x="${x + c * CELL}" y="${y - 1 + r * CELL}" width="2" height="2"/>`)
143 }
144 }
145 }
146 const rest = shown < cols ? `<rect class="k" x="${x + shown * CELL}" y="${y + 0.5}" width="${w - shown * CELL}" height="2" rx="1"/>` : ''
147 const mode = status === 'running' ? (isProgress ? 'pg' : 'tw') : status === 'done' ? 'tw-done' : 'still'
148 return `${rest}<g class="${mode}" fill="${tint}" data-id="${id}">${cells.join('')}</g>`
149}
150
151// Opacity only, on one image whose source never changes while it runs, so nothing jitters. The
152// dots breathe slowly in four out-of-phase groups (3.6–5.2 s, never below 45%), calm rather than
153// flickery. Done: every dot lit with a slow glow. Failed: every dot lit red, still.
154// Panel bars: the same dots over a soft, still glow in the bar's color. The glow covers the lit part
155// (all of it, or up to the real percentage), never moves, so it reads as light, not flicker.
156const halo = (id: string, x: number, y: number, w: number, color: string, status: 'running' | 'done' | 'failed', percent: number | null) => {
157 const tint = status === 'failed' ? FAILED : color
158 const lit = percent !== null && percent >= 2 && status === 'running' ? (w * Math.min(100, percent)) / 100 : w
159 const strength = status === 'running' ? 0.45 : status === 'done' ? 0.55 : 0.35
160 return `<defs><filter id="h${id}" x="-5%" y="-150%" width="110%" height="400%"><feGaussianBlur stdDeviation="2.2"/></filter></defs>
161<rect x="${x}" y="${y - 1}" width="${lit}" height="5" rx="2.5" fill="${tint}" opacity="${strength}" filter="url(#h${id})"/>`
162}
163
164const TWINKLE_CSS = `.tw .t0,.tw .t1,.tw .t2,.tw .t3{animation:tw 4.2s ease-in-out infinite}
165.tw .t1{animation-duration:5.2s;animation-delay:-1.3s}.tw .t2{animation-duration:3.6s;animation-delay:-2.4s}.tw .t3{animation-duration:4.8s;animation-delay:-.8s}
166.tw-done{animation:twd 4.8s ease-in-out infinite}
167@keyframes tw{0%,100%{opacity:1}50%{opacity:.45}}@keyframes twd{0%,100%{opacity:1}50%{opacity:.78}}
168@media (prefers-reduced-motion: reduce){.tw rect,.tw-done{animation:none}}`
169
170const BASE_CSS = `<style>
171.t{fill:#1f1f1f}.s{fill:#6b6b68}.k{fill:#ecebe8}.ln{stroke:#e4e4e1}.tile{fill:#f4f3f0}
172@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.k{fill:#2c2c2b}.ln{stroke:#333331}.tile{fill:#262625}}
173.sweep{animation:sw 2s ease-in-out infinite;animation-delay:var(--sweep-delay,0s)}@keyframes sw{0%{transform:translateX(-30%)}100%{transform:translateX(130%)}}
174.live{animation:p 1s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
175.fill{transform-box:fill-box;transform-origin:left;animation:gr .6s ease-out 1}@keyframes gr{from{transform:scaleX(0)}}
176.glow{animation:gw 2.4s ease-in-out infinite}@keyframes gw{50%{opacity:.25}}
177.fail{animation:fl .45s ease-in-out 3}@keyframes fl{50%{opacity:.25}}
178@media (prefers-reduced-motion: reduce){.sweep,.live,.fill,.fail,.glow{animation:none}}
179${TWINKLE_CSS}
180</style>`
181
182// A band row is three images: the crab, the text line and the track. Only the text changes as the
183// clock ticks; the crab and the track keep the same source while an agent runs, so their animations
184// (walking legs, the sweep) are never restarted by redraws, ours or other mods'.
185const svg = (W: number, H: number, body: string) =>
186 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${BASE_CSS}${CRAB_CSS}${body}</svg>`
187
188
189const track = (id: string, x: number, y: number, w: number, row: AgentRow) => glowTrack(id, x, y, w, accentOf(row.type), row.status)
190
191const mark = (x: number, y: number, row: AgentRow) => {
192 if (row.status === 'running') {
193 return `<circle class="live" cx="${x}" cy="${y}" r="3" fill="${accentOf(row.type)}"/>`
194 }
195 if (row.status === 'failed') {
196 return `<path d="M${x - 3.5} ${y - 3.5}l7 7M${x + 3.5} ${y - 3.5}l-7 7" stroke="${FAILED}" stroke-width="1.6" stroke-linecap="round"/>`
197 }
198 return `<path d="M${x - 4.5} ${y}l3 3 6-6.5" fill="none" stroke="${DONE}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>`
199}
200
201// One band row: crab, name, task, stats on the right, a thin track beneath. The row respaces to
202// its width: stats drop their least useful parts first, then the task, then the name truncates.
203const bandRowSvg = (W: number, row: AgentRow, now: number, more: number) => {
204 const extra = more > 0 ? ` +${more}` : ''
205 const nameFull = textWidth(row.type, 12.5)
206 let keep = 4
207 let right = stats(row, now, keep) + extra
208 // Keep the name and at least ~90px of task whenever there is room for them.
209 while (keep > 1 && W - 34 - nameFull - 8 - textWidth(right, 11.5) - 28 < 90) {
210 keep--
211 right = stats(row, now, keep) + extra
212 }
213 const rightW = textWidth(right, 11.5)
214 const name = fitText(row.type, 12.5, Math.max(40, W - 34 - rightW - 28))
215 const nameW = textWidth(name, 12.5) + 8
216 const descW = W - 34 - nameW - rightW - 28
217 const desc = row.description && descW >= 48 ? fitText(row.description, 12, descW) : ''
218 return svg(
219 W - 32,
220 18,
221 `<text x="0" y="13" font-family="${FONT}" font-size="12.5" font-weight="600" fill="${accentOf(row.type)}">${xml(name)}</text>
222<text class="s" x="${nameW}" y="13" font-family="${FONT}" font-size="12">${xml(desc)}</text>
223<text class="s" x="${W - 46}" y="13" text-anchor="end" font-family="${FONT}" font-size="11.5" font-variant-numeric="tabular-nums">${xml(right)}</text>
224${mark(W - 37, 9, row)}`,
225 )
226}
227
228const bandTrackSvg = (W: number, row: AgentRow) => svg(W - 32, 8, track(row.id, 0, 2, W - 32, row))
229const bandCrabSvg = (row: AgentRow) => svg(28, 28, crab(0, 2, accentOf(row.type), row.status === 'running', 0.8))
230
231const headerSvg = (W: number, all: AgentRow[]) => {
232 const running = all.filter(row => row.status === 'running').length
233 const tokens = all.reduce((sum, row) => sum + row.tokens, 0)
234 const gap = 6
235 const tw = (W - gap * 2) / 3
236 const tile = (i: number, k: string, v: string) =>
237 `<rect class="tile" x="${i * (tw + gap)}" y="0" width="${tw}" height="40" rx="8"/>
238<text class="s" x="${i * (tw + gap) + 9}" y="16" font-family="${FONT}" font-size="11">${k}</text>
239<text class="t" x="${i * (tw + gap) + 9}" y="33" font-family="${FONT}" font-size="15" font-weight="600" font-variant-numeric="tabular-nums">${v}</text>`
240 return svg(W, 44, tile(0, 'Running', `${running}`) + tile(1, 'Finished', `${all.length - running}`) + tile(2, 'Tokens', formatTokens(tokens)))
241}
242
243// A panel card is three images like a band row: crab and track keep their source while running,
244// only the text line ticks, so the panel's animations never restart either.
245const paneTextSvg = (W: number, row: AgentRow, now: number) => {
246 const textW = W - 44 - 18
247 return svg(
248 W - 44,
249 40,
250 `<text class="t" x="0" y="18" font-family="${FONT}" font-size="13" font-weight="600">${xml(fitText(row.description || row.type, 13, textW))}</text>
251<text x="0" y="34" font-family="${FONT}" font-size="11"><tspan fill="${accentOf(row.type)}">${xml(row.type)}</tspan><tspan class="s"> ${xml(stats(row, now))}</tspan></text>
252${mark(W - 52, 14, row)}`,
253 )
254}
255const paneTrackSvg = (W: number, row: AgentRow) =>
256 svg(W - 44, 22, `${halo(`p${row.id}`, 0, 5, W - 44 - 8, accentOf(row.type), row.status, null)}${track(`p${row.id}`, 0, 5, W - 44 - 8, row)}<line class="ln" x1="0" y1="21.5" x2="${W - 44}" y2="21.5"/>`)
257const paneCrabSvg = (row: AgentRow) => svg(44, 62, crab(0, 12, accentOf(row.type), row.status === 'running', 1.1))
258
259// Terminal fallback: a colored dot, a block bar and plain text.
260const barText = (row: AgentRow, t: number, width: number) => {
261 if (row.status !== 'running') {
262 return '█'.repeat(width)
263 }
264 const span = width * 2 - 2
265 const pos = t % span
266 const head = pos < width ? pos : span - pos
267 return Array.from({ length: width }, (_, i) => {
268 const d = Math.abs(i - head)
269 return d === 0 ? '█' : d === 1 ? '▓' : d === 2 ? '▒' : '░'
270 }).join('')
271}
272
273export const register: Register = (on, options) => {
274 LINGER_MS = Math.max(1, Number(options.lingerMinutes ?? 10)) * 60 * 1000
275 BAND_ROWS = Math.max(1, Math.min(4, Number(options.bandRows ?? 2)))
276 let pulse: { cancel: () => void } | null = null
277 let clear: { cancel: () => void } | null = null
278
279 // A reload keeps the rows but drops the timers: restart the pulse or the hide timer.
280 on('session.start', async ($, e, next) => {
281 const result = await next(e)
282 try {
283 const all = await read($, rows)
284 if (all.some(row => row.status === 'running')) {
285 pulse = $.clock.every(1000, () => {
286 void update($, tick, t => t + 1).catch(() => {})
287 })
288 } else if (all.length > 0) {
289 clear = $.clock.after(LINGER_MS, () => {
290 clear = null
291 void update($, rows, list => list.filter(row => row.status === 'running')).catch(() => {})
292 void update($, isPanelOpen, () => false).catch(() => {})
293 void $.ui.close({ id: PANE }).catch(() => {})
294 })
295 }
296 } catch {}
297 return result
298 })
299
300 on('agent.spawn', async ($, e, next) => {
301 const result = await next(e)
302 if (!result.agentId) {
303 return result
304 }
305
306 // The band is cosmetic: a failure here must never affect the spawn.
307 try {
308 clear?.cancel()
309 clear = null
310 const row: AgentRow = {
311 id: result.agentId,
312 type: e.subagentType || 'general-purpose',
313 description: e.description ?? '',
314 model: shortModel(result.model ?? ''),
315 startedAt: await $.clock.now(),
316 endedAt: null,
317 tools: 0,
318 tokens: 0,
319 status: 'running',
320 }
321 await update($, rows, all => [...all, row])
322
323 // The crabs and sweeps animate in CSS; this tick only refreshes the elapsed time.
324 if (!pulse) {
325 pulse = $.clock.every(1000, () => {
326 void update($, tick, t => t + 1).catch(() => {})
327 })
328 }
329 } catch {}
330 return result
331 }).catch(($, e, next) => next(e)) // display only: a failure never blocks Claude Code
332
333 on('tool.call', async ($, e, next) => {
334 if (e.agentId) {
335 const id = e.agentId
336 // Display bookkeeping must never hold up or fail the tool call.
337 void update($, rows, all => all.map(row => (row.id === id ? { ...row, tools: row.tools + 1 } : row))).catch(() => {})
338 }
339 return next(e)
340 }).catch(($, e, next) => next(e)) // display only: a failure never blocks Claude Code
341
342 on('turn.complete', async ($, e, next) => {
343 const result = await next(e)
344 if (!e.agentId) {
345 return result
346 }
347
348 try {
349 const usage = result.usage ?? e.usage
350 const spent = usage
351 ? usage.input_tokens + usage.output_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
352 : 0
353 const now = await $.clock.now()
354 const id = e.agentId
355 // Anything but a normal answer (an error, an abort, a refusal) ends the sweep as a failure.
356 const status: AgentRow['status'] = e.reason !== 'answer' || e.isAborted ? 'failed' : 'done'
357 await update($, rows, all =>
358 all.map(row => (row.id === id ? { ...row, tokens: row.tokens + spent, status, endedAt: now } : row)),
359 )
360
361 const isAnyRunning = (await read($, rows)).some(row => row.status === 'running')
362 if (!isAnyRunning) {
363 pulse?.cancel()
364 pulse = null
365 clear?.cancel()
366 clear = $.clock.after(LINGER_MS, () => {
367 clear = null
368 void update($, rows, all => all.filter(row => row.status === 'running')).catch(() => {})
369 void update($, isPanelOpen, () => false).catch(() => {})
370 void $.ui.close({ id: PANE }).catch(() => {})
371 })
372 }
373 } catch {}
374 return result
375 })
376
377 // The pause key (h, in any Watcher band) hides every band until the person's next prompt.
378 on('ui.press', { element: 'watcher-pause' }, async ($, e, next) => {
379 await update($, isPaused, () => true).catch(() => {})
380 return next(e)
381 }).catch(($, e, next) => next(e))
382
383 on('prompt.submit', async ($, e, next) => {
384 if (e.origin?.kind !== 'task-notification') {
385 await update($, isPaused, () => false).catch(() => {})
386 }
387 return next(e)
388 }).catch(($, e, next) => next(e))
389
390 // The band steps aside while the panel is open and comes back when it closes.
391 on('ui.close', async ($, e, next) => {
392 if (e.id === PANE) {
393 await update($, isPanelOpen, () => false).catch(() => {})
394 }
395 return next(e)
396 }).catch(($, e, next) => next(e)) // display only: a failure never blocks Claude Code
397
398 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
399 const all = await read($, rows)
400 if (e.props.hasSurvey || all.length === 0 || (await read($, isPanelOpen)) || (await read($, isPaused))) {
401 return next(e)
402 }
403 // Other bands (context-watcher) draw beneath these rows; the engine's own placeholder is never nested.
404 const drawn = await Promise.resolve(next(e)).catch(() => null)
405 const below = drawn && typeof drawn === 'object' && (drawn as { type?: string }).type !== 'engine' ? drawn : null
406
407 const t = await read($, tick)
408 // Whole seconds: within a second the images come out identical, so other mods' redraws don't rebuild them.
409 const now = Math.floor((await $.clock.now()) / 1000) * 1000
410 const ui = $.ui.resolve(e)
411 const { Box, Button, Text } = ui
412 const shown = ordered(all).slice(0, BAND_ROWS)
413 const more = all.length - shown.length
414 const openPanel = async () => {
415 await update($, isPanelOpen, () => true)
416 await $.ui.open({ id: PANE, title: 'Agents' })
417 }
418
419 // The terminal's element table carries an Svg that draws nothing: test the surface too.
420 // The pause key shows only when another Watcher band is up too, once, in this band's second
421 // row's free chip slot, so it never crowds the open button.
422 const showPause = below !== null && !hasKey(below, 'watcher-pause')
423
424 if (e.surface !== 'terminal' && 'Svg' in ui) {
425 const { Svg } = ui
426 // Desktop cells are ~8px. Every row reserves the chip's column, so the rows' right edges line up.
427 const W = Math.max(200, Math.min(1400, (e.props.bodyColumns || 100) * 8 - CHIP_PX))
428 return (
429 <Box flexDirection="column" paddingTop={1}>
430 {shown.map((row, i) => (
431 <Box key={`band-${row.id}`} flexDirection="row" alignItems="center" gap={1}>
432 <Svg key={`crab-${row.id}`} source={bandCrabSvg(row)} alt={row.type} width={28} height={28} />
433 <Box key={`body-${row.id}`} flexDirection="column" flexShrink={0}>
434 <Svg
435 key={`text-${row.id}`}
436 source={bandRowSvg(W, row, now, i === shown.length - 1 ? more : 0)}
437 alt={`${row.type}: ${row.status}, ${stats(row, now)}`}
438 width={W - 32}
439 height={18}
440 />
441 <Svg key={`track-${row.id}`} source={bandTrackSvg(W, row)} alt={`${row.type} progress`} width={W - 32} height={8} />
442 </Box>
443 <Box key={`chip-${row.id}`} width={CHIP_COLS} flexShrink={0}>
444 {i === 0 && <Button key="agent-watcher" label={`⤢ ×${all.length}`} hotkey="a" plain onPress={openPanel} />}
445 {i === 1 && showPause ? <Button key="watcher-pause" label="–" hotkey="h" plain onPress={() => {}} /> : null}
446 </Box>
447 </Box>
448 ))}
449 {below && <Box key="agent-watcher-gap" height={1} />}
450 {below}
451 </Box>
452 )
453 }
454
455 return (
456 <Box flexDirection="column">
457 {shown.map((row, i) => (
458 <Button key={i === 0 ? 'agent-watcher' : `band-${row.id}`} plain onPress={openPanel}>
459 <Text color={accentOf(row.type)}>{`${row.status === 'failed' ? '✗' : '●'} ${barText(row, t, 12)} `}</Text>
460 <Text bold color={accentOf(row.type)}>
461 {row.type}
462 </Text>
463 <Text dimColor wrap="truncate-end">{` ${stats(row, now, (e.props.bodyColumns || 100) < 70 ? 1 : (e.props.bodyColumns || 100) < 90 ? 3 : 4)}${i === shown.length - 1 && more > 0 ? ` +${more}` : ''}`}</Text>
464 </Button>
465 ))}
466 {below}
467 </Box>
468 )
469 })
470
471 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
472 const all = ordered(await read($, rows))
473 const t = await read($, tick)
474 // Whole seconds: within a second the images come out identical, so other mods' redraws don't rebuild them.
475 const now = Math.floor((await $.clock.now()) / 1000) * 1000
476 const ui = $.ui.resolve(e)
477 const { Box, Text } = ui
478
479 if (e.surface !== 'terminal' && 'Svg' in ui) {
480 const { Svg } = ui
481 const W = Math.max(240, Math.min(900, (e.props.bodyColumns || 40) * 8 - 8))
482 return (
483 <Box flexDirection="column">
484 <Svg key="pane-header" source={headerSvg(W, all)} alt={`${all.length} agents`} width={W} height={44} />
485 {all.map(row => (
486 <Box key={`pane-${row.id}`} flexDirection="row">
487 <Svg key={`pcrab-${row.id}`} source={paneCrabSvg(row)} alt={row.type} width={44} height={62} />
488 <Box key={`pbody-${row.id}`} flexDirection="column" flexShrink={0}>
489 <Svg key={`ptext-${row.id}`} source={paneTextSvg(W, row, now)} alt={`${row.type}: ${row.status}, ${stats(row, now)}`} width={W - 44} height={40} />
490 <Svg key={`ptrack-${row.id}`} source={paneTrackSvg(W, row)} alt={`${row.type} progress`} width={W - 44} height={22} />
491 </Box>
492 </Box>
493 ))}
494 </Box>
495 )
496 }
497
498 return (
499 <Box flexDirection="column" gap={1}>
500 {all.length === 0 && <Text dimColor>No agents right now.</Text>}
501 {all.map(row => (
502 <Box key={`pane-${row.id}`} flexDirection="column">
503 <Text bold color={accentOf(row.type)}>{`● ${row.type}`}</Text>
504 <Text color={accentOf(row.type)}>{barText(row, t, 20)}</Text>
505 <Text dimColor>{[row.description, stats(row, now)].filter(Boolean).join(' · ')}</Text>
506 </Box>
507 ))}
508 </Box>
509 )
510 })
511}
512
513// Whether a drawn tree already holds an element with this key (another band's pause button).
514function hasKey(node: unknown, key: string): boolean {
515 if (!node || typeof node !== 'object') {
516 return false
517 }
518 if (Array.isArray(node)) {
519 return node.some(n => hasKey(n, key))
520 }
521 const el = node as { key?: unknown; props?: { key?: unknown; children?: unknown }; children?: unknown }
522 return el.key === key || el.props?.key === key || hasKey(el.props?.children, key) || hasKey(el.children, key)
523}
524types/index.d.ts 18 lines1export type AgentRow = {
2 id: string
3 type: string
4 description: string
5 model: string
6 startedAt: number
7 endedAt: number | null
8 tools: number
9 tokens: number
10 status: 'running' | 'done' | 'failed'
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'agent-watcher': { rows: AgentRow[]; tick: number; isPanelOpen: boolean; isPaused: boolean }
16 }
17}
18