Agent deck side pane: a live graph of your agents and what each is doing (search, crawl, edits, builds, tests, computer use), every running session, and your…

<img src="assets/logo.svg" width="140" alt="agentdeck logo: three connected agent nodes in a rounded frame">
<h1 align="center">agentdeck</h1>
<em>Watch your Claude agents work together, live, in a side pane.</em>
<img src="https://img.shields.io/badge/Claude%20Code-mod-a882ff?style=flat-square" alt="Claude Code mod"> <img src="https://img.shields.io/badge/dependencies-0-a9c6cc?style=flat-square" alt="Zero dependencies"> <img src="https://img.shields.io/github/license/XyndoX/agentdeck?style=flat-square&color=3ee6b4" alt="MIT license">
agentdeck is a Claude Code mod: a side pane, like repograph, that shows how your agents work together. The main agent and its subagents are painted as a live graph. Packets run along the links of the agents that are working, each node is colored by the work it is doing right now, and the ring around it shows what kind of work it has done. Next to the graph you get the agents as rows, a live tool feed, every Claude Code session running on the machine, and your routines.
| Tab | Shows |
|---|---|
Agents (1) | The agent graph (pixels, with terminal-text labels), a detail line for the selected agent, one row per agent with its current tool call and how long it has run, and the live feed of tool calls. |
Sessions (2) | Every live Claude Code session (interactive and --bg): busy or idle, working/total agents, current tool, and a 10-minute sparkline colored by kind of work. Enter (or two clicks) shows that session's agents. |
Routines (3) | Claude cloud routines (r loads them through RemoteTrigger), this session's loops and crons, background jobs, scheduled tasks, and your own launchd jobs with schedule and last exit code. |
Colors by kind of work: search · crawl (WebFetch, browser navigation) · edit · read · build · test · git · shell · computer use (clicks, screenshots, simulators) · agents · mcp.
claude plugin marketplace add XyndoX/agentdeck
claude plugin install agentdeck@agentdeck --scope user
Then, in a session:
/tui fullscreen
/agentdeck # or /agentdeck sessions, /agentdeck routines
Click the pane to give it the keys: 1 2 3 tabs, ↑↓ select an agent or session, enter open a session, esc back to this session, r reload routines. Click a node in the graph to select an agent.
/tui fullscreenPATH (for the collector)collector/feed.mjs is a small node process that the mod starts while the pane is open. It reads only what Claude Code already writes: ~/.claude/sessions (live sessions), the session transcripts and their subagents/ transcripts (incrementally, from the last 4 MiB), ~/.claude/jobs, ~/.claude/scheduled-tasks, and ~/Library/LaunchAgents + launchctl. It prints one JSON snapshot per second when something changed. A subagent is linked to the agent whose Agent call started it.hooks/flow.ts lays out and paints the graph into RGBA pixels (the painter is repograph's), which goes to the terminal as an Image. hooks/deck.ts turns the snapshot into colored text rows.CronList. Cloud routines come from RemoteTrigger, and only when you press r, so you get no permission prompt you didn't ask for. Nothing leaves your machine.claude --plugin-dir .
claude plugin validate .
claude plugin test . # layout, rows, the pane end to end
node --test tests/collect.test.mjs # the collector
node collector/feed.mjs <session-id> # the raw feed
MIT
hooks/register.tsx 329 lines1import type { EngineInterface, Register, RenderChildren } from 'claude-code'
2import { agentName, agentRows, ago, C, feedRows, line, routineRows, sessionRows, short, type Cloud, type Cron, type Seg, type Snapshot } from './deck'
3import { flow, type Flow } from './flow'
4import type { CanvasProps } from './canvas'
5import type { OverlayProps } from './overlay'
6
7// A side pane in three tabs. Agents: the agent graph painted in pixels, the
8// agents as rows and the live tool feed. Sessions: every live session on the
9// machine. Routines: cloud routines, loops, background jobs, scheduled tasks,
10// launchd. The data comes from collector/feed.mjs, which reads ~/.claude.
11
12const PANE = 'agentdeck'
13const ANIM_MS = 350 // packets move while an agent works
14type Tab = 'agents' | 'sessions' | 'routines'
15
16const S = {
17 open: false,
18 tab: 'agents' as Tab,
19 home: '', // this session
20 shown: '', // the session the Agents tab shows
21 snap: null as Snapshot | null,
22 error: '',
23 feed: null as AsyncIterator<unknown> | null,
24 feedFor: '',
25 sel: '', // an agent
26 hover: '',
27 cursor: 0, // Sessions tab
28 top: 0, // Sessions / Routines scroll
29 crons: [] as Cron[],
30 cloud: null as Cloud | null,
31 drawn: null as Flow | null,
32 listed: [] as string[], // the agent rows on screen, top down
33 box: { cols: 60, rows: 12 },
34 anim: null as { cancel: () => void } | null,
35 watch: null as { cancel: () => void } | null,
36}
37const redraw = ($: EngineInterface) => $.ui.invalidate('ui.render')
38
39// ── data ───────────────────────────────────────────────────────────────────
40
41function follow($: EngineInterface) {
42 if (S.feed && S.feedFor === S.shown) return
43 void S.feed?.return?.(undefined)
44 const session = S.shown
45 const feed = $.process.spawn({ argv: ['node', `${$.plugin.root}/collector/feed.mjs`, session] })
46 S.feed = feed
47 S.feedFor = session
48 void (async () => {
49 let buf = ''
50 try {
51 for await (const { stream, text } of feed) {
52 if (stream === 'stderr') { $.ui.log(`agentdeck feed: ${text.trim()}`, { to: 'debug' }); continue }
53 buf += text
54 let i
55 while ((i = buf.indexOf('\n')) >= 0) {
56 try { S.snap = JSON.parse(buf.slice(0, i)) as Snapshot; S.error = '' } catch { /* a cut line */ }
57 buf = buf.slice(i + 1)
58 }
59 animate($)
60 redraw($)
61 }
62 } catch (err) {
63 S.error = `the collector did not start (needs node ≥ 22 on PATH): ${err}`
64 redraw($)
65 }
66 if (S.feed === feed) S.feed = null
67 })()
68}
69
70// Packets only move while someone works and the graph is on screen.
71function animate($: EngineInterface) {
72 const busy = S.open && S.tab === 'agents' && !!S.snap?.session?.agentList.some(a => a.status === 'working')
73 if (busy && !S.anim) S.anim = $.clock.every(ANIM_MS, () => redraw($))
74 else if (!busy && S.anim) { S.anim.cancel(); S.anim = null }
75}
76
77async function loadCrons($: EngineInterface) {
78 try {
79 const r = await $.tool.call({ tool: 'CronList' })
80 if ('result' in r && r.result && !r.isError) S.crons = r.result.jobs.map(j => ({ id: j.id, prompt: j.prompt, schedule: j.humanSchedule || j.cron, recurring: j.recurring }))
81 } catch { /* not offered in this session */ }
82}
83
84type Trigger = Record<string, unknown>
85export function cloudItems(json: string) {
86 const data = JSON.parse(json) as unknown
87 const list = (Array.isArray(data) ? data : [data].flatMap(d => Object.values((d ?? {}) as Record<string, unknown>)).find(Array.isArray) ?? []) as Trigger[]
88 return list.map(t => ({
89 name: String(t.name ?? t.title ?? t.id ?? 'routine'),
90 schedule: String(t.cron_expression ?? t.schedule ?? t.cron ?? ''),
91 enabled: t.enabled !== false,
92 detail: [t.next_run_at && `next ${t.next_run_at}`, t.last_run_at && `last ${t.last_run_at}`, t.description].filter(Boolean).join(' · ').slice(0, 160),
93 }))
94}
95
96// Only on `r`: RemoteTrigger may ask for permission, so it runs when asked.
97async function loadCloud($: EngineInterface) {
98 S.cloud = { ...(S.cloud ?? {}), error: undefined }
99 try {
100 const r = await $.tool.call({ tool: 'RemoteTrigger', action: 'list' })
101 if ('deny' in r && r.deny) S.cloud = { at: Date.now(), error: `not allowed: ${r.deny}` }
102 else if (!r.result || r.isError) S.cloud = { at: Date.now(), error: 'RemoteTrigger failed (logged in to claude.ai?)' }
103 else S.cloud = r.result.status >= 400 ? { at: Date.now(), error: `claude.ai answered ${r.result.status}` } : { at: Date.now(), items: cloudItems(r.result.json) }
104 } catch (err) {
105 S.cloud = { at: Date.now(), error: `RemoteTrigger is not available here: ${err}` }
106 }
107 redraw($)
108}
109
110function show($: EngineInterface, session: string) {
111 S.shown = session
112 S.sel = ''
113 S.tab = 'agents'
114 follow($)
115}
116
117async function key($: EngineInterface, k: string) {
118 const ss = S.snap?.sessions ?? []
119 if (k === '1') S.tab = 'agents'
120 else if (k === '2') S.tab = 'sessions'
121 else if (k === '3') S.tab = 'routines'
122 else if (k === 'tab') S.tab = S.tab === 'agents' ? 'sessions' : S.tab === 'sessions' ? 'routines' : 'agents'
123 else if (k === 'escape') { if (S.sel) S.sel = ''; else show($, S.home) }
124 else if (k === 'r' && S.tab === 'routines') { void loadCrons($); void loadCloud($) }
125 else if (S.tab === 'sessions' && (k === 'up' || k === 'k')) S.cursor = Math.max(0, S.cursor - 1)
126 else if (S.tab === 'sessions' && (k === 'down' || k === 'j')) S.cursor = Math.min(ss.length - 1, S.cursor + 1)
127 else if (S.tab === 'sessions' && k === 'return' && ss[S.cursor]) show($, ss[S.cursor]!.id)
128 else if (S.tab === 'agents' && (k === 'up' || k === 'down')) {
129 const list = S.snap?.session?.agentList ?? []
130 const i = list.findIndex(a => a.id === S.sel)
131 S.sel = list[Math.max(0, Math.min(list.length - 1, i + (k === 'up' ? -1 : 1)))]?.id ?? ''
132 }
133 animate($)
134 redraw($)
135}
136
137async function openPane($: EngineInterface, tab?: Tab) {
138 S.home ||= await $.session.id()
139 S.shown ||= S.home
140 if (tab) S.tab = tab
141 S.open = true
142 await $.ui.open({ id: PANE, title: 'Agent deck', focus: true, rows: 40, columns: 80 })
143 follow($)
144 void loadCrons($)
145 S.watch ??= $.clock.every(5000, () => void (async () => {
146 if ((await $.ui.panes()).some(p => p.id === PANE)) return
147 // Closed: stop reading and painting until it opens again.
148 S.open = false
149 S.watch?.cancel()
150 S.watch = null
151 animate($)
152 const feed = S.feed
153 S.feed = null
154 await feed?.return?.(undefined)
155 })())
156}
157
158// The picture goes to the terminal as a file it reads itself (two take turns),
159// as in repograph; without a shell it goes inline.
160const RUN_ID = Math.random().toString(36).slice(2, 8)
161const pic = { key: '', ready: null as null | { file: string; width: number; height: number; generation: number }, gen: 0, inline: false, writing: false }
162async function ensurePicture($: EngineInterface, img: Flow['image']) {
163 if (pic.key === img.key || pic.inline || pic.writing) return
164 pic.writing = true
165 const gen = pic.gen + 1
166 try {
167 const tmp = ((await $.env.get('TMPDIR')) || '/tmp').replace(/\/$/, '')
168 const file = `${tmp}/agentdeck-${RUN_ID}-${gen % 2}.rgba`
169 const r = await $.process.run(['/bin/sh', '-c', 'base64 -d > "$1"', 'sh', file], { stdin: img.rgba, timeoutMs: 10_000 })
170 if (r.exitCode !== 0) throw new Error(r.stderr)
171 pic.key = img.key
172 pic.gen = gen
173 pic.ready = { file, width: img.width, height: img.height, generation: gen }
174 } catch {
175 pic.inline = true
176 } finally {
177 pic.writing = false
178 }
179 redraw($)
180}
181
182// ── hooks ──────────────────────────────────────────────────────────────────
183
184export const register: Register = on => {
185 on('session.start', async ($, e, next) => {
186 const r = await next(e)
187 await $.command.register({ name: 'agentdeck', description: 'Agent deck: your agents, all sessions and routines in a side pane; args: [agents|sessions|routines]' })
188 return r
189 })
190
191 on('command.run', { command: 'agentdeck' }, async ($, e) => {
192 const arg = (e.args ?? '').trim().toLowerCase()
193 await openPane($, arg === 'sessions' || arg === 'routines' || arg === 'agents' ? arg : undefined)
194 return { text: 'agentdeck: click the pane, then 1 agents · 2 sessions · 3 routines' }
195 })
196
197 on('ui.message', async ($, e, next) => {
198 if (e.requestId !== PANE || !['graph', 'agents', 'feed', 'list'].includes(String(e.element)) || !e.data || typeof e.data !== 'object') return next(e)
199 const data = e.data as { key?: string; press?: [number, number]; click?: [number, number]; hover?: [number, number] | null }
200 if (typeof data.key === 'string') await key($, data.key)
201 else if (data.press && e.element === 'graph') {
202 const id = S.drawn?.hit(data.press[0], data.press[1]) ?? ''
203 S.sel = id && id !== S.sel ? id : ''
204 redraw($)
205 } else if ('hover' in data && e.element === 'graph') {
206 const id = data.hover ? (S.drawn?.hit(data.hover[0], data.hover[1]) ?? '') : ''
207 if (id !== S.hover) { S.hover = id; redraw($) }
208 } else if (data.click && e.element === 'agents') {
209 const row = Math.floor(data.click[1])
210 const a = S.snap?.session?.agentList.find(x => x.id === S.listed[row])
211 S.sel = a && a.id !== S.sel ? a.id : ''
212 redraw($)
213 } else if (data.click && e.element === 'list' && S.tab === 'sessions') {
214 const i = Math.floor((S.top + data.click[1]) / 2)
215 const s = S.snap?.sessions[i]
216 if (s && i === S.cursor) show($, s.id)
217 else if (s) S.cursor = i
218 redraw($)
219 }
220 return {}
221 })
222
223 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e) => {
224 if (S.tab !== 'agents') { S.top = Math.max(0, S.top + Math.sign(e.by) * 2); redraw($) }
225 return {}
226 })
227
228 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
229 if (e.surface !== 'terminal') return next(e)
230 const { Box, Text, Button, Client, Image } = $.ui.resolve(e)
231 const cols = Math.max(30, (e.props?.bodyColumns ?? 80) - 1)
232 const height = Math.max(14, e.props?.scroll?.bodyRows ?? 36)
233 const snap = S.snap
234 const now = snap?.now ?? Date.now()
235 const working = snap?.session?.agentList.filter(a => a.status === 'working').length ?? 0
236 const busy = snap?.sessions.filter(s => s.status === 'busy').length ?? 0
237 const tab = (id: Tab, label: string, k: string) => (
238 <Button key={`tab-${id}`} plain dimColor={S.tab !== id} label={S.tab === id ? `[ ${label} ]` : ` ${label} `} onPress={() => void key($, k)} />
239 )
240 const header = (
241 <Box flexDirection="row">
242 {tab('agents', working ? `Agents ${working}` : 'Agents', '1')}
243 {tab('sessions', snap ? `Sessions ${busy}/${snap.sessions.length}` : 'Sessions', '2')}
244 {tab('routines', 'Routines', '3')}
245 <Box flexGrow={1} />
246 {S.tab === 'routines' ? <Button key="reload" plain dimColor label="↻" onPress={() => void key($, 'r')} /> : <Text color={snap ? C.ok : C.muted}>{snap ? '●' : '○'}</Text>}
247 </Box>
248 )
249 const frame = (...body: RenderChildren[]) => (
250 <Box flexDirection="column" minHeight={height}>
251 {header}
252 {body}
253 </Box>
254 )
255 const rowsOf = (key: string, rows: Seg[][]) => <Client key={key} module="./canvas.tsx" props={{ rows } satisfies CanvasProps} />
256 const hint = (t: string) => <Text color={C.muted} wrap="truncate-end">{t}</Text>
257
258 if (!snap) return frame(<Text color={S.error ? C.err : C.muted}>{S.error || 'Reading your sessions…'}</Text>)
259
260 if (S.tab === 'sessions') {
261 const view = Math.max(2, height - 2)
262 const all = sessionRows(snap.sessions, now, cols, S.shown, S.home, S.cursor)
263 S.top = Math.max(0, Math.min(S.top, Math.max(0, all.length - view)))
264 if (S.cursor * 2 < S.top) S.top = S.cursor * 2
265 if (S.cursor * 2 + 2 > S.top + view) S.top = S.cursor * 2 + 2 - view
266 return frame(rowsOf('list', all.slice(S.top, S.top + view)), <Box flexGrow={1} />, hint('↑↓ pick · enter or click twice: show its agents · esc: this session'))
267 }
268
269 if (S.tab === 'routines') {
270 const view = Math.max(2, height - 2)
271 const all = routineRows(snap.routines, S.crons, S.cloud, now, cols)
272 S.top = Math.max(0, Math.min(S.top, Math.max(0, all.length - view)))
273 return frame(rowsOf('list', all.slice(S.top, S.top + view)), <Box flexGrow={1} />, hint('r: reload cloud routines and loops · wheel: scroll'))
274 }
275
276 // Agents tab.
277 const d = snap.session
278 if (!d) return frame(<Text color={C.muted}>{S.shown === S.home ? 'This session shows up after its first prompt.' : 'That session has ended.'}</Text>, <Box flexGrow={1} />, hint('2: sessions · esc: this session'))
279 const busyNow = d.status === 'busy'
280 const title = (
281 <Box flexDirection="row">
282 <Text color={busyNow ? C.hot : C.muted}>{busyNow ? '● ' : '○ '}</Text>
283 <Text bold color={C.white} wrap="truncate-end">{d.name}</Text>
284 <Text color={C.muted} wrap="truncate-start">{` ${d.cwd}`}</Text>
285 <Box flexGrow={1} />
286 <Text color={C.muted}>{`${d.agentList.length} agents · ${d.agentList.reduce((n, a) => n + a.calls, 0)} calls`}</Text>
287 {S.shown !== S.home ? <Button key="home" plain dimColor label=" ⌂" onPress={() => void key($, 'escape')} /> : null}
288 </Box>
289 )
290 const agentsN = Math.min(d.agentList.length, Math.max(2, Math.floor(height / 6)))
291 const graphRows = Math.max(8, Math.min(28, Math.floor((height - 6 - agentsN) * 0.6)))
292 const feedN = Math.max(2, height - 6 - agentsN - graphRows)
293 S.box = { cols, rows: graphRows }
294 if (S.sel && !d.agentList.some(a => a.id === S.sel)) S.sel = ''
295 const f = flow(d, Date.now(), cols, graphRows, S.sel, S.hover)
296 S.drawn = f
297 void ensurePicture($, f.image)
298 const source = pic.inline ? { rgba: f.image.rgba, width: f.image.width, height: f.image.height } : pic.ready ? { ...pic.ready, format: 'rgba' as const } : null
299 const sel = d.agentList.find(a => a.id === S.sel)
300 const detail = sel
301 ? line([{ t: '▸ ', c: C.hot }, { t: agentName(sel) + ' ', c: C.white, b: true }, { t: sel.description ? sel.description + ' · ' : '', c: C.fg }, { t: sel.lastText, c: C.dim }], cols, { t: `${Math.round(sel.tokensOut / 100) / 10}k tok · ${ago(sel.firstAt, now)}`, c: C.muted })
302 : line([{ t: f.hidden ? `+${f.hidden} older agents not drawn · ` : '', c: C.muted }, { t: 'click a node or row for details', c: C.muted }], cols)
303 // The agent list keeps the selected and the working ones in view.
304 const rows = agentRows(d, now, cols, S.sel)
305 const order = d.agentList.map((a, i) => ({ a, i })).sort((x, y) => Number(y.a.id === S.sel) - Number(x.a.id === S.sel) || Number(y.a.status === 'working') - Number(x.a.status === 'working') || x.i - y.i).slice(0, agentsN).sort((x, y) => x.i - y.i)
306 S.listed = order.map(o => o.a.id)
307 return frame(
308 title,
309 <Box key="stage" position="relative" width={cols} height={graphRows}>
310 {source ? <Image key="flow-image" source={source} columns={cols} rows={graphRows} alt="agent graph" /> : <Box width={cols} height={graphRows} />}
311 {f.labels.map((l, i) => (
312 <Box key={`l${i}`} position="absolute" top={l.y} left={l.x}>
313 <Text color={l.color} bold={l.bold}>{l.text}</Text>
314 </Box>
315 ))}
316 <Box key="hit" position="absolute" top={0} left={0}>
317 <Client key="graph" module="./overlay.tsx" props={{ cols, rows: graphRows } satisfies OverlayProps} />
318 </Box>
319 </Box>,
320 rowsOf('detail', [detail]),
321 rowsOf('agents', order.map(o => rows[o.i]!)),
322 <Text color={C.muted} wrap="truncate-end">{`─ live ${'─'.repeat(Math.max(0, cols - 7))}`}</Text>,
323 rowsOf('feed', feedRows(d, now, cols, feedN)),
324 <Box flexGrow={1} />,
325 hint(`1 agents · 2 sessions · 3 routines · ↑↓ agent · esc ${S.shown !== S.home ? 'this session' : 'clear'} · ${short(d.id, 8)}`),
326 )
327 })
328}
329hooks/deck.ts 136 lines1// What collector/feed.mjs sends, and the coloured text rows the pane draws
2// from it (the same row format repograph's canvas draws).
3
4export type Seg = { t: string; c?: string; b?: boolean; bg?: string }
5
6export type Call = { id: string; name: string; tool: string; cat: string; text: string; start: number; end: number; err: boolean; agentId: string }
7export type Agent = {
8 id: string; type: string; description: string; model: string; parentId: string | null; background: boolean
9 status: 'working' | 'waiting' | 'idle' | 'done'; tokensOut: number; firstAt: number; lastAt: number; lastText: string
10 calls: number; byCat: Record<string, number>; current: Call | null; last: Call | null
11}
12export type Session = {
13 id: string; pid: number; name: string; cwd: string; kind: string; status: string; since: number; startedAt: number
14 agents: number; active: number; current: Call | null; spark: Record<string, number>[]; tokensOut: number; lastAt: number
15}
16export type Detail = Session & { agentList: Agent[]; events: Call[]; files: { path: string; n: number; at: number; agentId: string }[]; counts: Record<string, number> }
17export type Cron = { id: string; prompt: string; schedule: string; recurring?: boolean; session?: string }
18export type Cloud = { at?: number; items?: { name: string; schedule: string; enabled: boolean; detail: string }[]; error?: string }
19export type Routines = {
20 launchd: { label: string; schedule: string; program: string; loaded: boolean; running: boolean; lastExit: number | null; log: string }[]
21 scheduled: { name: string; description: string }[]
22 jobs: { id: string; state: string; detail: string; needs: string; tokens: number; name: string; live: boolean; at: number }[]
23}
24export type Snapshot = { now: number; sessionId: string; sessions: Session[]; session: Detail | null; routines: Routines }
25
26export const C = { fg: '#dadada', muted: '#6b6b76', dim: '#8a8a99', hot: '#a882ff', ok: '#98c379', warn: '#e5c07b', err: '#e06c75', sel: '#2e2a3d', white: '#ffffff' }
27export const CAT: Record<string, string> = {
28 search: '#61afef', crawl: '#53dfdd', edit: '#e5c07b', read: '#8a8fa6', build: '#d19a66', test: '#98c379',
29 git: '#e06c75', shell: '#c3a6ff', computer: '#ff79c6', agent: '#a882ff', mcp: '#56b6c2', other: '#6e6e7c',
30}
31export const STATUS: Record<string, string> = { working: C.hot, busy: C.hot, waiting: C.warn, idle: C.muted, done: C.ok }
32
33export const short = (s: string, n: number) => (n <= 0 ? '' : s.length > n ? s.slice(0, Math.max(0, n - 1)) + '…' : s)
34export const ago = (t: number, now: number) => {
35 const s = Math.max(0, Math.round((now - t) / 1000))
36 return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : s < 86400 ? `${Math.floor(s / 3600)}h` : `${Math.floor(s / 86400)}d`
37}
38export const dur = (ms: number) => (ms < 1000 ? `${Math.max(0, Math.round(ms))}ms` : ms < 60e3 ? `${(ms / 1000).toFixed(ms < 10e3 ? 1 : 0)}s` : `${Math.floor(ms / 60e3)}m${String(Math.round((ms % 60e3) / 1000)).padStart(2, '0')}`)
39export const model = (m: string) => m.replace(/^claude-/, '').replace(/-(\d+)-(\d+)$/, ' $1.$2').replace(/-(\d+)$/, ' $1')
40export const agentName = (a: Pick<Agent, 'type'>) => (a.type === 'main' ? 'main' : a.type.replace(/^.*:/, ''))
41
42/** One row `cols` wide: the left segments cut to fit, `right` flush right. */
43export function line(left: Seg[], cols: number, right?: Seg, bg?: string): Seg[] {
44 const room = cols - (right ? right.t.length + 1 : 0)
45 const out: Seg[] = []
46 let used = 0
47 for (const s of left) {
48 if (used >= room) break
49 const t = short(s.t, room - used)
50 out.push({ ...s, t })
51 used += t.length
52 }
53 const pad = cols - used - (right?.t.length ?? 0)
54 if (pad > 0) out.push({ t: ' '.repeat(pad) })
55 if (right) out.push(right)
56 return bg ? out.map(s => ({ ...s, bg })) : out
57}
58
59const BARS = '▁▂▃▄▅▆▇█'
60export function spark(bins: Record<string, number>[]): Seg[] {
61 const totals = bins.map(b => Object.values(b).reduce((x, y) => x + y, 0))
62 const max = Math.max(1, ...totals)
63 return bins.map((b, i) => {
64 const n = totals[i]!
65 if (!n) return { t: '·', c: '#3a3a44' }
66 const top = Object.entries(b).sort((x, y) => y[1] - x[1])[0]![0]
67 return { t: BARS[Math.min(7, Math.ceil((n / max) * 8) - 1)]!, c: CAT[top] ?? C.dim }
68 })
69}
70
71export function agentRows(d: Detail, now: number, cols: number, sel: string): Seg[][] {
72 return d.agentList.map(a => {
73 const call = a.current ?? a.last
74 const right = a.current ? { t: dur(now - a.current.start), c: C.hot } : { t: a.lastAt ? ago(a.lastAt, now) : '', c: C.muted }
75 return line([
76 { t: a.status === 'working' ? '● ' : a.status === 'done' ? '✓ ' : '○ ', c: STATUS[a.status] },
77 { t: short(agentName(a), 14).padEnd(15), c: a.status === 'done' ? C.dim : C.fg, b: a.id === sel || a.status === 'working' },
78 ...(call ? [{ t: call.tool + ' ', c: CAT[call.cat], b: !!a.current }, { t: call.text, c: a.current ? C.fg : C.muted }] : [{ t: 'no tool calls yet', c: C.muted }]),
79 ], cols, right, a.id === sel ? C.sel : undefined)
80 })
81}
82
83export function feedRows(d: Detail, now: number, cols: number, n: number): Seg[][] {
84 const names = new Map(d.agentList.map(a => [a.id, agentName(a)]))
85 return d.events.slice(0, n).map(e => line([
86 { t: '▍', c: CAT[e.cat] },
87 { t: short(e.tool, 9).padEnd(10), c: CAT[e.cat], b: !e.end },
88 { t: short(names.get(e.agentId) ?? '', 10) + ' ', c: C.muted },
89 { t: e.text, c: e.err ? C.err : e.end ? C.dim : C.fg },
90 ], cols, e.end ? { t: dur(e.end - e.start), c: C.muted } : { t: '● ' + dur(now - e.start), c: C.hot }))
91}
92
93/** Two rows per session; the one shown in the Agents tab is marked. */
94export function sessionRows(ss: Session[], now: number, cols: number, shown: string, home: string, cursor: number): Seg[][] {
95 return ss.flatMap((s, i) => {
96 const bg = i === cursor ? C.sel : undefined
97 const busy = s.status === 'busy'
98 const sp = spark(cols < 60 ? s.spark.slice(-12) : s.spark)
99 const head = line([
100 { t: busy ? '● ' : '○ ', c: busy ? C.hot : C.muted },
101 { t: s.name, c: s.id === shown ? C.white : C.fg, b: s.id === shown || busy },
102 { t: s.id === home ? ' (this)' : '', c: C.hot },
103 { t: s.kind === 'bg' ? ' bg' : '', c: C.muted },
104 ], Math.max(10, cols - sp.length - 1), { t: `${s.active}/${s.agents} `, c: s.active ? C.hot : C.muted })
105 const now_ = s.current
106 ? [{ t: ' ' + s.current.tool + ' ', c: CAT[s.current.cat], b: true }, { t: s.current.text, c: C.fg }]
107 : [{ t: ` ${s.cwd}`, c: C.muted }, { t: s.lastAt ? ` · ${ago(s.lastAt, now)} ago` : '', c: C.muted }]
108 return [[...head, { t: ' ' }, ...sp].map(x => (bg ? { ...x, bg } : x)), line(now_, cols, undefined, bg)]
109 })
110}
111
112export function routineRows(r: Routines, crons: Cron[], cloud: Cloud | null, now: number, cols: number): Seg[][] {
113 const rows: Seg[][] = []
114 const head = (t: string, hint = '') => { rows.push([]); rows.push(line([{ t, c: C.hot, b: true }], cols, hint ? { t: hint, c: C.muted } : undefined)) }
115 const row = (dot: string, color: string, name: string, when: string, sub: string) => {
116 rows.push(line([{ t: dot + ' ', c: color }, { t: name, c: C.fg, b: true }], cols, when ? { t: when, c: C.dim } : undefined))
117 if (sub) rows.push(line([{ t: ' ' + sub, c: C.muted }], cols))
118 }
119 head('Cloud routines', cloud?.at ? `${ago(cloud.at, now)} ago · r refresh` : 'r to load')
120 if (cloud?.items?.length) for (const t of cloud.items) row(t.enabled ? '●' : '○', t.enabled ? C.ok : C.muted, t.name, t.schedule, t.detail)
121 else rows.push(line([{ t: cloud?.error ?? (cloud ? 'none' : 'not loaded yet: press r (asks RemoteTrigger)'), c: cloud?.error ? C.err : C.muted }], cols))
122 head('Loops & crons', 'this session')
123 if (crons.length) for (const c of crons) row('●', C.ok, short(c.prompt || c.id, 40), c.schedule, c.recurring === false ? 'once' : '')
124 else rows.push(line([{ t: 'none (/loop, CronCreate)', c: C.muted }], cols))
125 head('Background jobs', 'claude --bg')
126 if (r.jobs.length) for (const j of r.jobs.slice(0, 10)) row(j.state === 'blocked' ? '●' : j.live ? '●' : '○', j.state === 'blocked' ? C.err : j.state === 'working' || j.state === 'running' ? C.hot : j.live ? C.ok : C.muted, j.name, j.state, `${j.needs ? 'needs: ' + j.needs : j.detail} · ${ago(j.at, now)} ago`)
127 else rows.push(line([{ t: 'none', c: C.muted }], cols))
128 head('Scheduled tasks', '~/.claude/scheduled-tasks')
129 if (r.scheduled.length) for (const t of r.scheduled) row('●', C.ok, t.name, '', t.description)
130 else rows.push(line([{ t: 'none', c: C.muted }], cols))
131 head('launchd', '~/Library/LaunchAgents')
132 if (r.launchd.length) for (const j of r.launchd) row('●', j.running ? C.hot : !j.loaded ? C.muted : j.lastExit === 0 ? C.ok : C.err, j.label, j.schedule, `${j.loaded ? (j.running ? 'running' : `last exit ${j.lastExit}`) : 'not loaded'}${j.log ? ' · ' + j.log : ''}`)
133 else rows.push(line([{ t: 'none of your own', c: C.muted }], cols))
134 return rows.slice(1)
135}
136hooks/flow.ts 158 lines1// Paints the Agents tab's graph: the main agent in the middle, the agents it
2// spawned around it, theirs further out. A node's colour is the work it is
3// doing now, its ring the mix of work it has done, and packets run along the
4// links of agents at work. Labels are terminal text laid over the picture.
5import { agentName, C, CAT, short, type Agent, type Detail } from './deck'
6import { Canvas, cellSize, hex } from './paint'
7
8export type Label = { x: number; y: number; text: string; color: string; bold: boolean }
9export type Flow = {
10 image: { rgba: string; width: number; height: number; key: string }
11 labels: Label[]
12 hidden: number
13 hit: (x: number, y: number) => string // cell coordinates, fractions allowed
14}
15
16const MAX_NODES = 16
17let picture = { key: '', rgba: '' }
18
19/** Which agents get a node: every working one, then the most recent. */
20export function pickAgents(list: Agent[]) {
21 const main = list.find(a => a.type === 'main')
22 const rest = list.filter(a => a !== main).sort((a, b) => Number(b.status === 'working') - Number(a.status === 'working') || b.lastAt - a.lastAt)
23 const kept = rest.slice(0, MAX_NODES - 1)
24 const ids = new Set(kept.map(a => a.id))
25 // A kept agent whose parent was cut hangs off main.
26 return { nodes: [...(main ? [main] : []), ...kept.map(a => ({ ...a, parentId: a.parentId && (ids.has(a.parentId) || a.parentId === 'main') ? a.parentId : 'main' }))], hidden: rest.length - kept.length }
27}
28
29/** Positions as fractions of the picture: main centred, rings by depth. */
30export function place(nodes: Agent[]) {
31 const pos = new Map<string, { x: number; y: number; angle: number }>()
32 const main = nodes.find(a => a.type === 'main')
33 if (main) pos.set(main.id, { x: 0.5, y: 0.5, angle: 0 })
34 const kids = (id: string) => nodes.filter(a => a.type !== 'main' && (a.parentId ?? 'main') === id)
35 const first = kids(main?.id ?? 'main')
36 first.forEach((a, i) => {
37 const angle = -Math.PI / 2 + (i / Math.max(1, first.length)) * Math.PI * 2 + (first.length === 2 ? Math.PI / 2 : 0)
38 pos.set(a.id, { x: 0.5 + Math.cos(angle) * 0.3, y: 0.5 + Math.sin(angle) * 0.34, angle })
39 })
40 // Deeper agents fan out beyond their parent.
41 const queue = [...first]
42 while (queue.length) {
43 const p = queue.shift()!
44 const pp = pos.get(p.id)!
45 const ks = kids(p.id)
46 ks.forEach((a, i) => {
47 const angle = pp.angle + (i - (ks.length - 1) / 2) * 0.55
48 pos.set(a.id, { x: pp.x + Math.cos(angle) * 0.17, y: pp.y + Math.sin(angle) * 0.2, angle })
49 queue.push(a)
50 })
51 }
52 for (const p of pos.values()) {
53 p.x = Math.min(0.95, Math.max(0.05, p.x))
54 p.y = Math.min(0.93, Math.max(0.07, p.y))
55 }
56 return pos
57}
58
59export function flow(d: Detail, now: number, cols: number, rows: number, sel: string, hover: string): Flow {
60 const { nodes, hidden } = pickAgents(d.agentList)
61 const pos = place(nodes)
62 const { cw, ch } = cellSize(cols, rows, 1 << 18) // small enough to repaint a few times a second
63 const W = cols * cw
64 const H = rows * ch
65 const at = (id: string) => { const p = pos.get(id); return p ? [p.x * W, p.y * H] as const : undefined }
66 const maxCalls = Math.max(1, ...nodes.map(a => a.calls))
67 const radius = (a: Agent) => cw * (0.55 + 0.75 * Math.sqrt(a.calls / maxCalls)) * (a.type === 'main' ? 1.35 : 1)
68 const colour = (a: Agent) => a.status === 'working' ? CAT[a.current?.cat ?? 'agent'] ?? C.hot : a.status === 'done' ? '#5a6b5a' : a.status === 'waiting' ? C.warn : '#55556a'
69 const working = nodes.some(a => a.status === 'working')
70 const phase = working ? Math.floor(now / 120) % 1000 : 0
71
72 const key = `${nodes.map(a => `${a.id}:${a.status}:${a.current?.cat}:${a.calls}`).join(',')}|${W}x${H}|${sel}|${hover}|${phase}`
73 if (picture.key !== key) {
74 const c = new Canvas(W, H)
75 const lw = Math.max(1, cw / 7)
76 // Links, then packets on the busy ones.
77 for (const a of nodes) {
78 if (a.type === 'main') continue
79 const pa = at(a.parentId ?? 'main'), pb = at(a.id)
80 if (!pa || !pb) continue
81 const busy = a.status === 'working'
82 c.line({ x0: pa[0], y0: pa[1], x1: pb[0], y1: pb[1], c: hex(busy ? C.hot : '#8a8a99'), a: busy ? 0.75 : a.status === 'done' ? 0.18 : 0.3, w: busy ? lw * 1.8 : lw })
83 if (!busy) continue
84 for (const k of [0, 0.5]) {
85 const t = ((now / 1600 + k + a.id.length * 0.13) % 1)
86 // Out along the link is the task, back is the result.
87 const [x, y] = k === 0 ? [pa[0] + (pb[0] - pa[0]) * t, pa[1] + (pb[1] - pa[1]) * t] : [pb[0] + (pa[0] - pb[0]) * t, pb[1] + (pa[1] - pb[1]) * t]
88 c.dot({ x, y, r: lw * 1.6, c: hex(k === 0 ? C.hot : CAT[a.current?.cat ?? 'agent'] ?? C.fg), a: 0.95, halo: true })
89 }
90 }
91 for (const a of nodes) {
92 const p = at(a.id)
93 if (!p) continue
94 const r = radius(a)
95 // The ring: what kind of work this agent has done, in proportion.
96 const total = Object.values(a.byCat).reduce((x, y) => x + y, 0)
97 if (total) {
98 const ring = r + cw * 0.35
99 const steps = Math.max(24, Math.round(ring * 1.6))
100 const parts = Object.entries(a.byCat).sort((x, y) => y[1] - x[1])
101 for (let s = 0; s < steps; s++) {
102 const f = s / steps
103 let acc = 0
104 let cat = parts[0]![0]
105 for (const [k, n] of parts) { acc += n / total; if (f < acc) { cat = k; break } }
106 const ang = -Math.PI / 2 + f * Math.PI * 2
107 c.dot({ x: p[0] + Math.cos(ang) * ring, y: p[1] + Math.sin(ang) * ring, r: lw * 0.9, c: hex(CAT[cat] ?? C.dim), a: a.status === 'done' ? 0.45 : 0.9 })
108 }
109 }
110 c.dot({ x: p[0], y: p[1], r, c: hex(colour(a)), a: a.status === 'done' ? 0.6 : 1, halo: a.status === 'working' })
111 if (a.id === sel || a.id === hover) c.dot({ x: p[0], y: p[1], r: r + cw * 0.85, c: hex(C.white), a: 0.85, ring: true })
112 }
113 picture = { key, rgba: c.base64() }
114 }
115
116 // Labels beside each node: its name, and for one at work what it runs now.
117 const taken = new Set<string>()
118 const labels: Label[] = []
119 const free = (x: number, y: number, n: number) => y >= 0 && y < rows && x >= 0 && x + n <= cols && ![...Array(n).keys()].some(i => taken.has(`${x + i},${y}`))
120 const take = (x: number, y: number, n: number) => { for (let i = 0; i < n; i++) taken.add(`${x + i},${y}`) }
121 const order = [...nodes].sort((a, b) => Number(b.id === sel) - Number(a.id === sel) || Number(b.status === 'working') - Number(a.status === 'working'))
122 for (const a of order) {
123 const p = at(a.id)
124 if (!p) continue
125 const cx = Math.floor(p[0] / cw)
126 const cy = Math.floor(p[1] / ch)
127 const gap = Math.ceil((radius(a) + cw * 0.6) / cw) + 1
128 const name = short(agentName(a), 18)
129 const what = short(a.current ? `${a.current.tool} ${a.current.text}` : a.type === 'main' ? '' : a.description, Math.max(8, Math.floor(cols / 2.4)))
130 const right = p[0] < W * 0.62
131 for (const side of right ? [1, -1] : [-1, 1]) {
132 const width = Math.max(name.length, what.length)
133 const x = side > 0 ? cx + gap : cx - gap - width + 1
134 if (!free(x, cy, name.length) || (what && !free(x, cy + 1, what.length))) continue
135 take(x, cy, name.length)
136 labels.push({ x, y: cy, text: name, color: a.status === 'working' || a.id === sel ? C.white : a.status === 'done' ? C.dim : C.fg, bold: a.type === 'main' || a.id === sel })
137 if (what) {
138 take(x, cy + 1, what.length)
139 labels.push({ x, y: cy + 1, text: what, color: a.current ? CAT[a.current.cat] ?? C.fg : C.muted, bold: false })
140 }
141 break
142 }
143 }
144
145 const hit = (x: number, y: number) => {
146 let best = ''
147 let bestD = Infinity
148 for (const a of nodes) {
149 const p = at(a.id)
150 if (!p) continue
151 const dist = Math.hypot(p[0] - x * cw, p[1] - y * ch)
152 if (dist < bestD && dist < Math.max(radius(a) + cw, cw * 2.5)) { best = a.id; bestD = dist }
153 }
154 return best
155 }
156 return { image: { rgba: picture.rgba, width: W, height: H, key: picture.key }, labels, hidden, hit }
157}
158hooks/canvas.tsx 43 lines1import type { ClientModule } from 'claude-code'
2import type { Seg } from './deck'
3
4// Draws rows of colour runs and reports keys, clicks and drags to the mod.
5export type CanvasProps = { rows: Seg[][] }
6type Local = { down: [number, number] | null; last: [number, number] | null; moved: boolean }
7
8const Canvas: ClientModule<CanvasProps, Local> = (props, surface) => {
9 const { Box, Text } = surface.elements
10 if (surface.state === undefined) surface.setState({ down: null, last: null, moved: false })
11 surface.onPointer(e => {
12 const cur = surface.state ?? { down: null, last: null, moved: false }
13 if (e.type === 'down' && (e.button ?? 'left') === 'left') {
14 surface.setState({ down: [e.x, e.y], last: [e.x, e.y], moved: false })
15 } else if (e.type === 'move' && e.button && cur.last) {
16 const [lx, ly] = cur.last
17 if (e.x !== lx || e.y !== ly) {
18 surface.post({ drag: [e.x - lx, e.y - ly] })
19 surface.setState({ ...cur, last: [e.x, e.y], moved: true })
20 }
21 } else if (e.type === 'up' || e.type === 'leave') {
22 if (e.type === 'up' && cur.down && !cur.moved) surface.post({ click: cur.down })
23 surface.setState({ down: null, last: null, moved: false })
24 }
25 })
26 surface.onKey(e => surface.post({ key: e.key, shift: Boolean(e.shift) }))
27 return (
28 <Box flexDirection="column">
29 {props.rows.map(row => (
30 <Box flexDirection="row" height={1} overflow="hidden">
31 {row.map(s => (
32 <Text color={s.c} backgroundColor={s.bg} bold={s.b}>
33 {s.t}
34 </Text>
35 ))}
36 </Box>
37 ))}
38 </Box>
39 )
40}
41
42export default Canvas
43hooks/overlay.tsx 41 lines1import type { ClientModule } from 'claude-code'
2
3// An empty box laid over the agent graph: it draws nothing and reports the
4// pointer (with sub-cell precision where the terminal gives it) and the keys.
5export type OverlayProps = { cols: number; rows: number }
6type Local = { last: [number, number] | null; hover: string }
7
8const Overlay: ClientModule<OverlayProps, Local> = (props, surface) => {
9 const { Box } = surface.elements
10 if (surface.state === undefined) surface.setState({ last: null, hover: '' })
11 // Presses, drags and releases go to the mod as they happen; it decides what a
12 // click is, so nothing here depends on state surviving between events.
13 surface.onPointer(e => {
14 const cur = surface.state ?? { last: null, hover: '' }
15 const at: [number, number] = [e.fine?.x ?? e.x + 0.5, e.fine?.y ?? e.y + 0.5]
16 if (e.type === 'down' && (e.button ?? 'left') === 'left') {
17 surface.post({ press: at })
18 surface.setState({ ...cur, last: at })
19 } else if (e.type === 'move' && e.button) {
20 const [lx, ly] = cur.last ?? at
21 if (Math.abs(at[0] - lx) + Math.abs(at[1] - ly) >= 0.5) {
22 surface.post({ drag: [at[0] - lx, at[1] - ly] })
23 surface.setState({ ...cur, last: at })
24 }
25 } else if (e.type === 'move') {
26 const cell = `${e.x},${e.y}`
27 if (cell !== cur.hover) {
28 surface.post({ hover: at })
29 surface.setState({ ...cur, hover: cell })
30 }
31 } else if (e.type === 'up') {
32 surface.post({ release: true })
33 surface.setState({ ...cur, last: null })
34 } else if (e.type === 'leave') surface.post({ hover: null })
35 })
36 surface.onKey(e => surface.post({ key: e.key }))
37 return <Box width={props.cols} height={props.rows} />
38}
39
40export default Overlay
41hooks/paint.ts 80 lines1// Paints the graph into RGBA pixels for an `Image` (kitty graphics: Ghostty, cmux,
2// kitty). Transparent background, so the terminal's own theme shows through.
3
4export type RGB = [number, number, number]
5export type Line = { x0: number; y0: number; x1: number; y1: number; c: RGB; a: number; w: number }
6export type Dot = { x: number; y: number; r: number; c: RGB; a: number; ring?: boolean; halo?: boolean }
7
8export function hex(h: string): RGB {
9 const v = parseInt(h.slice(1), 16)
10 return [(v >> 16) & 255, (v >> 8) & 255, v & 255]
11}
12
13export class Canvas {
14 W: number
15 H: number
16 px: Uint8ClampedArray
17 constructor(W: number, H: number) {
18 this.W = W
19 this.H = H
20 this.px = new Uint8ClampedArray(W * H * 4)
21 }
22
23 // "Over" compositing on straight alpha.
24 blend(x: number, y: number, c: RGB, a: number) {
25 if (a <= 0 || x < 0 || y < 0 || x >= this.W || y >= this.H) return
26 const i = (y * this.W + x) * 4
27 const da = this.px[i + 3]! / 255
28 const oa = a + da * (1 - a)
29 for (let k = 0; k < 3; k++) this.px[i + k] = (c[k]! * a + this.px[i + k]! * da * (1 - a)) / oa
30 this.px[i + 3] = oa * 255
31 }
32
33 // A line `w` pixels wide with soft edges. It walks the longer axis and covers a
34 // short run across it, so each pixel is touched once and long lines stay cheap.
35 line(l: Line) {
36 const steep = Math.abs(l.y1 - l.y0) > Math.abs(l.x1 - l.x0)
37 // Work as if the line were flat: swap x and y for a steep one.
38 let [a0, b0, a1, b1] = steep ? [l.y0, l.x0, l.y1, l.x1] : [l.x0, l.y0, l.x1, l.y1]
39 if (a0 > a1) [a0, b0, a1, b1] = [a1, b1, a0, b0]
40 const slope = a1 - a0 < 1e-6 ? 0 : (b1 - b0) / (a1 - a0)
41 const cos = 1 / Math.sqrt(1 + slope * slope)
42 const half = l.w / 2
43 const lim = (steep ? this.H : this.W) - 1
44 for (let a = Math.max(0, Math.floor(a0)); a <= Math.min(lim, Math.ceil(a1)); a++) {
45 const end = Math.min(1, Math.max(0, Math.min(a + 1 - a0, a1 - a + 0.5))) // soft caps
46 const bc = b0 + (Math.max(a0, Math.min(a1, a + 0.5)) - a0) * slope
47 for (let b = Math.floor(bc - half - 1); b <= Math.ceil(bc + half + 1); b++) {
48 const d = Math.abs(b + 0.5 - bc) * cos
49 const cover = Math.max(0, Math.min(1, half + 0.5 - d)) * end
50 if (cover > 0) steep ? this.blend(b, a, l.c, cover * l.a) : this.blend(a, b, l.c, cover * l.a)
51 }
52 }
53 }
54
55 dot(d: Dot) {
56 const R = d.r + (d.halo ? d.r * 1.2 : 0) + 1
57 for (let y = Math.floor(d.y - R); y <= Math.ceil(d.y + R); y++) {
58 for (let x = Math.floor(d.x - R); x <= Math.ceil(d.x + R); x++) {
59 const dist = Math.hypot(x + 0.5 - d.x, y + 0.5 - d.y)
60 if (d.halo) this.blend(x, y, d.c, Math.max(0, Math.min(1, d.r * 2.2 - dist)) * 0.18 * d.a)
61 const cover = d.ring
62 ? Math.max(0, Math.min(1, 1.6 - Math.abs(dist - d.r)))
63 : Math.max(0, Math.min(1, d.r + 0.5 - dist))
64 this.blend(x, y, d.c, cover * d.a)
65 }
66 }
67 }
68
69 base64(): string {
70 return (new Uint8Array(this.px.buffer) as Uint8Array & { toBase64(): string }).toBase64()
71 }
72}
73
74// Pixels per cell for a box of `cols`×`rows` cells under the inline image limit
75// (2 MiB of RGBA); a cell is about twice as tall as wide.
76export function cellSize(cols: number, rows: number, budget = 2 * 1024 * 1024 / 4) {
77 const cw = Math.max(3, Math.min(16, Math.floor(Math.sqrt(budget / (cols * rows * 2)))))
78 return { cw, ch: cw * 2 }
79}
80