SLOPSHOPPER

agentdeck

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…

newpanecommandprocesstimer
v0.2.0MITupdated 2026-10-16XyndoX/agentdeck
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agentdeck
│ ┃ Agent deck ✕ › fix the failing auth test and add an audit log call │ ┃ [ Agents ] Sessions Routines ○ │ ┃ Reading your sessions… ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ 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 │ ┃ │ ┃ › /agentdeck │ ┃ ⎿ agentdeck: agentdeck: click the pane, then 1 agents · 2 sessions │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent deck
[ Agents ] Sessions Routines ○ Reading your sessions…
README

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

Tabs

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

Install

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.

Requirements

  • Claude Code ≥ 2.1.287 (mods), with /tui fullscreen
  • A terminal with the kitty graphics protocol for the graph (Ghostty, kitty, WezTerm, cmux). Elsewhere you still get the labels, rows and the other tabs.
  • Node ≥ 22 on PATH (for the collector)

How it works

  • 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.
  • Loops come from 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.

Develop

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

License

MIT

Source 6 files
hooks/register.tsx 329 lines
1import 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}
329
hooks/deck.ts 136 lines
1// 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}
136
hooks/flow.ts 158 lines
1// 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}
158
hooks/canvas.tsx 43 lines
1import 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
43
hooks/overlay.tsx 41 lines
1import 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
41
hooks/paint.ts 80 lines
1// 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