SLOPSHOPPER

visualize

Pixel-art view of what the agent (and each subagent) is looking at and thinking about, driven by tool calls and keywords.

newpaneguardcommandtoastprompt
v0.1.0no licenseupdated 2026-10-03calm-logic/visualize-claude-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · visualize
│ ┃ Agent eyes ✕ › fix the failing auth test and add an audit log call │ ┃ ● main THINKING │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Read(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀#debug▀#test▀#security▀▀▀▀▀▀▀▀▀▀ ⎿ 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 │ ┃ │ ┃ › /visualize │ ┃ ⎿ visualize: visualize opened (Esc closes it). │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent eyes
● main THINKING ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀#debug▀#test▀#security▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ reading your prompt #debug #test #security
README

visualize

A Claude Code mod that shows what the agent is looking at and thinking about as pixel art.

  • File access: a document with a scan-beam for reads, lines filling in for writes, red/green flips for edits, plus a log of recent files.
  • Test runs: a dot grid that fills green and turns red on failures (counts parsed from the output).
  • Browser flows: a pixelated browser window with a cursor and click ripples; a real screenshot feed when the tool returns a PNG (kitty / Ghostty).
  • Thinking: sprites and #topic chips chosen by keyword (debug, test, refactor, design, security, git, ...).
  • Subagents: one eye each, and a full-screen grid of every agent at once.

Use

/visualize   open the grid of main + all subagents (Esc closes)

Install

claude plugin marketplace add calm-logic/visualize-claude-mod
claude plugin install visualize@visualize-claude-mod

Or try it for one session without installing: claude --plugin-dir ./.

Develop

claude plugin validate .
claude plugin test .
npx -p typescript tsc -p .claude-plugin/types/tsconfig.json --noEmit
Source 3 files
hooks/register.tsx 284 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Eye } from '../types'
5import {
6  KIND,
7  blankEye,
8  canvas,
9  classifyTool,
10  clamp,
11  encode,
12  findPngBase64,
13  findPngPath,
14  hex,
15  parseTestCounts,
16  scene,
17  topicMeta,
18  topicsOf,
19  C,
20} from './lib'
21
22type D = EngineInterface
23
24const PANE = 'visualize'
25const OPEN = { id: PANE, title: 'Agent eyes', holdToasts: true, rows: 80, columns: 240 } as const
26
27const eyes = atom({ plugin: 'visualize', key: 'eyes' } as const, {} as Record<string, Eye>)
28const tick = atom({ plugin: 'visualize', key: 'tick' } as const, 0)
29
30// Bulk / transient values live at module scope (a reload just loses them).
31const shots = new Map<string, string>()
32const labels = new Map<string, string>()
33const bufs = new Map<string, string>()
34let toldAboutGrid = false
35
36async function labelFor($: D, id: string): Promise<string> {
37  if (id === 'main') return 'main'
38  let l = labels.get(id)
39  if (!l) {
40    try {
41      for (const a of await $.agent.list()) labels.set(a.id, `${a.type}: ${a.description}`.slice(0, 28))
42    } catch {
43      // fall through to the generic label
44    }
45    l = labels.get(id) ?? `agent ${id.slice(0, 6)}`
46  }
47  return l
48}
49
50type EyePatch = Partial<Eye> & { hist?: string }
51
52// Every scene stays up for at least DWELL ms, so a fast tool call is still readable.
53// Scene changes queue per agent; follow-up patches (result, topics, done) ride the newest queued scene.
54const DWELL = 1000
55const queues = new Map<string, EyePatch[]>()
56const shownAt = new Map<string, number>()
57const scheduled = new Set<string>()
58
59async function apply($: D, id: string, p: EyePatch) {
60  const label = await labelFor($, id)
61  const t = await read($, tick)
62  const { hist, ...rest } = p
63  if (rest.kind !== undefined) shownAt.set(id, await $.clock.now())
64  await update($, eyes, m => {
65    const cur = m[id] ?? blankEye(id, label)
66    const next: Eye = { ...cur, ...rest, label }
67    if (rest.kind !== undefined) next.since = t
68    if (rest.lastFile !== undefined) next.lastAt = t
69    if (hist) next.history = [...cur.history, hist].slice(-8)
70    return { ...m, [id]: next }
71  })
72  if (id !== 'main' && !toldAboutGrid) {
73    toldAboutGrid = true
74    $.ui.toast('visualize: subagents running. /visualize shows every agent at once.')
75  }
76}
77
78function schedule($: D, id: string, ms: number) {
79  if (scheduled.has(id)) return
80  scheduled.add(id)
81  $.clock.after(Math.max(0, ms), () => flush($, id))
82}
83
84async function flush($: D, id: string) {
85  scheduled.delete(id)
86  const q = queues.get(id) ?? []
87  const head = q.shift()
88  if (!head) return
89  await apply($, id, head)
90  if (q.length > 0) schedule($, id, DWELL)
91}
92
93async function patch($: D, id: string, p: EyePatch) {
94  const q = queues.get(id) ?? []
95  if (p.kind === undefined) {
96    if (q.length > 0) q[q.length - 1] = { ...q[q.length - 1], ...p }
97    else await apply($, id, p)
98    return
99  }
100  const wait = (shownAt.get(id) ?? 0) + DWELL - (await $.clock.now())
101  if (q.length === 0 && wait <= 0) {
102    await apply($, id, p)
103    return
104  }
105  q.push(p)
106  queues.set(id, q)
107  schedule($, id, wait)
108}
109
110// header + detail + topics lines under/over each raster
111const CARD_TEXT_ROWS = 3
112
113type UI = ReturnType<D['ui']['resolve']> extends infer R ? any : never
114
115function card(UI: UI, eye: Eye, t: number, cols: number, rows: number, extra: boolean, key: string) {
116  const { Box, Text, Raster, Image } = UI
117  const meta = KIND[eye.kind]
118  const c = canvas(cols, rows)
119  scene(c, eye, t)
120  const shot = eye.hasShot ? shots.get(eye.id) : undefined
121  const live = !eye.done && eye.kind !== 'idle'
122  return (
123    <Box key={key} flexDirection="column" width={cols}>
124      <Box flexDirection="row" gap={1}>
125        <Text color={hex(live ? meta.color : C.dim)}>{live ? '●' : '○'}</Text>
126        <Text bold>{eye.label}</Text>
127        <Text color={hex(meta.color)}>{eye.done ? `${meta.label} · done` : meta.label}</Text>
128      </Box>
129      {shot ? (
130        <Image key={`shot-${eye.id}`} source={{ png: shot }} columns={cols} rows={rows} alt=" " />
131      ) : (
132        <Raster key={`eye-${eye.id}`} columns={cols} rows={rows} cells={encode(c)} />
133      )}
134      <Text dimColor wrap="end">{eye.detail || ' '}</Text>
135      <Box flexDirection="row" gap={1}>
136        {eye.topics.length === 0 ? <Text dimColor> </Text> : eye.topics.map(n => <Text key={`tp-${n}`} color={hex(topicMeta(n).color)}>#{n}</Text>)}
137      </Box>
138      {extra &&
139        eye.history.slice(-3).map((entry, k) => (
140          <Text key={`h-${k}`} dimColor wrap="end">
141            {entry}
142          </Text>
143        ))}
144    </Box>
145  )
146}
147
148
149export const register: Register = on => {
150  on('session.start', async ($, e, next) => {
151    await $.command.register({ name: 'visualize', description: 'Open the visualize grid: what the main agent and every subagent is doing' })
152    await update($, eyes, m => (m.main ? m : { ...m, main: blankEye('main', 'main') }))
153    void $.ui.open(OPEN)
154    $.clock.every(125, async () => {
155      const m = await read($, eyes)
156      if (Object.values(m).some(x => !x.done && x.kind !== 'idle')) await update($, tick, n => n + 1)
157    })
158
159    return next(e)
160  })
161
162  on('command.run', { command: 'visualize' }, async $ => {
163    await $.ui.open({ ...OPEN, focus: true, closeOnEscape: true })
164    return { text: 'visualize opened (Esc closes it).' }
165  })
166
167  on('prompt.submit', async ($, e, next) => {
168    bufs.set('main', e.text)
169    queues.clear()
170    shots.clear()
171    await update($, eyes, m => {
172      const kept: Record<string, Eye> = {}
173      for (const [id, x] of Object.entries(m)) if (id === 'main' || !x.done) kept[id] = x
174      return kept
175    })
176    await patch($, 'main', { topics: topicsOf(e.text), kind: 'think', detail: 'reading your prompt', done: false, hasShot: false, result: '' })
177    return next(e)
178  })
179
180  // The model is generating: thinking / answer text drives the topic chips and the "think" scene.
181  on('turn.step', async function* ($, e, next) {
182    const id = e.agentId ?? 'main'
183    await patch($, id, { kind: 'think', detail: 'thinking', done: false, result: '', hasShot: false })
184    shots.delete(id)
185    let since = 0
186    for await (const chunk of next(e)) {
187      if (chunk.kind === 'thinking' || chunk.kind === 'text') {
188        const buf = ((bufs.get(id) ?? '') + chunk.text).slice(-700)
189        bufs.set(id, buf)
190        since += chunk.text.length
191        if (since > 400) {
192          since = 0
193          await patch($, id, { topics: topicsOf(buf) })
194        }
195      }
196      yield chunk
197    }
198  })
199
200  on('tool.call', async ($, e, next) => {
201    const id = e.agentId ?? 'main'
202    const input = e as unknown as Record<string, any>
203    const p = classifyTool(e.tool, input)
204    if (p.kind !== 'browser') shots.delete(id)
205    await patch($, id, { ...p, result: '', done: false, hasShot: p.kind === 'browser' ? (shots.has(id)) : false })
206
207    const ran = await next(e)
208    if (ran.deny !== undefined) return ran
209
210    const text = typeof ran.text === 'string' ? ran.text : ''
211    const fail = ran.isError === true
212    const after: Partial<Eye> = { result: fail ? 'fail' : 'ok' }
213    if (p.kind === 'test') {
214      const counts = parseTestCounts(text)
215      if (counts) Object.assign(after, counts)
216    }
217    if (p.kind === 'browser' || p.kind === 'bash') {
218      let b64 = findPngBase64((ran as { result?: unknown }).result)
219      if (!b64) {
220        const path = findPngPath(text) ?? findPngPath(JSON.stringify(input).slice(0, 2000))
221        if (path) {
222          try {
223            const got = (await $.fs.read(path, { as: 'bytes' })) as any
224            const raw: string | undefined = got?.base64 ?? got?.bytes?.toBase64?.()
225            if (raw && raw.length < 2_700_000) b64 = raw
226          } catch {
227            // no screenshot file; keep the drawn feed
228          }
229        }
230      }
231      if (b64) {
232        shots.set(id, b64)
233        after.hasShot = true
234      }
235    }
236    await patch($, id, after)
237    return ran
238  })
239
240  on('turn.complete', async ($, e, next) => {
241    const id = e.agentId ?? 'main'
242    // Keep the last scene on screen; just stop animating it.
243    await patch($, id, { done: true })
244    return next(e)
245  })
246
247  // ------------------------------------------------------------- drawing
248
249  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
250    if (e.surface !== 'terminal') {
251      const { Text } = $.ui.resolve(e)
252      return <Text>visualize draws on the terminal surface.</Text>
253    }
254    const UI = $.ui.resolve(e)
255    const { Box } = UI
256    const map = await read($, eyes)
257    const t = await read($, tick)
258    const list = [map.main ?? blankEye('main', 'main'), ...Object.values(map).filter(x => x.id !== 'main')]
259    const n = list.length
260
261    // One agent fills the pane; more tile it as evenly as possible (1, 2x1, 2x2, 3x2, 3x3, ...).
262    const gc = Math.ceil(Math.sqrt(n))
263    const gr = Math.ceil(n / gc)
264    const props = e.props as { bodyColumns?: number; scroll?: { bodyRows?: number } }
265    const bodyCols = props.bodyColumns ?? 120
266    const bodyRows = props.scroll?.bodyRows ?? Math.max(8, (e.viewport?.rows ?? 40) - 4)
267    const gap = 1
268    const cellW = clamp(Math.floor((bodyCols - (gc - 1) * gap) / gc), 8, 500)
269    const cellRows = clamp(Math.floor(bodyRows / gr) - CARD_TEXT_ROWS, 2, 250)
270    const rowsOfCards: Eye[][] = []
271    for (let i = 0; i < n; i += gc) rowsOfCards.push(list.slice(i, i + gc))
272
273    return (
274      <Box flexDirection="column">
275        {rowsOfCards.map((r, ri) => (
276          <Box key={`row-${ri}`} flexDirection="row" gap={gap}>
277            {r.map(x => card(UI, x, t, cellW, cellRows, false, `g-${x.id}`))}
278          </Box>
279        ))}
280      </Box>
281    )
282  })
283}
284
hooks/lib.ts 756 lines
1import type { Eye, EyeKind, Todo } from '../types'
2
3// ---------------------------------------------------------------- palette
4
5export const C = {
6  bg: 0x0d1117,
7  panel: 0x161b22,
8  panel2: 0x21262d,
9  line: 0x30363d,
10  dim: 0x484f58,
11  ink: 0x8b949e,
12  paper: 0xc9d1d9,
13  white: 0xf0f6fc,
14  cyan: 0x56d4dd,
15  green: 0x3fb950,
16  amber: 0xd29922,
17  red: 0xf85149,
18  blue: 0x58a6ff,
19  purple: 0xbc8cff,
20  pink: 0xff7b72,
21}
22
23export const hex = (n: number) => '#' + n.toString(16).padStart(6, '0')
24
25export const KIND: Record<EyeKind, { label: string; color: number; glyph: string }> = {
26  idle: { label: 'IDLE', color: C.ink, glyph: '-' },
27  think: { label: 'THINKING', color: C.amber, glyph: '?' },
28  read: { label: 'READING', color: C.cyan, glyph: 'R' },
29  write: { label: 'WRITING', color: C.green, glyph: 'W' },
30  edit: { label: 'EDITING', color: C.amber, glyph: 'E' },
31  search: { label: 'SEARCHING', color: C.purple, glyph: 'S' },
32  bash: { label: 'RUNNING', color: C.green, glyph: '$' },
33  test: { label: 'TESTING', color: C.pink, glyph: 'T' },
34  browser: { label: 'BROWSING', color: C.blue, glyph: 'B' },
35  web: { label: 'WEB', color: C.blue, glyph: '@' },
36  agent: { label: 'DELEGATING', color: C.purple, glyph: 'A' },
37  plan: { label: 'PLANNING', color: C.amber, glyph: 'P' },
38  tool: { label: 'TOOL', color: C.ink, glyph: '*' },
39}
40
41// ---------------------------------------------------------------- small utils
42
43export function hash(s: string | number): number {
44  const str = String(s)
45  let h = 2166136261
46  for (let i = 0; i < str.length; i++) {
47    h ^= str.charCodeAt(i)
48    h = Math.imul(h, 16777619)
49  }
50  return h >>> 0
51}
52
53export const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n))
54
55export const fname = (p: string) => p.split('/').filter(Boolean).pop() ?? p
56
57export function basename(p: string): string {
58  const parts = p.split('/').filter(Boolean)
59  return parts.slice(-2).join('/') || p
60}
61
62export function blankEye(id: string, label: string): Eye {
63  return {
64    id,
65    label,
66    kind: 'idle',
67    detail: id === 'main' ? 'waiting for you' : 'starting',
68    path: '',
69    since: 0,
70    topics: [],
71    history: [],
72    result: '',
73    passed: 0,
74    failed: 0,
75    x: -1,
76    y: -1,
77    progress: 0,
78    done: false,
79    hasShot: false,
80    todos: [],
81    action: '',
82    lastOp: '',
83    lastFile: '',
84    lastAt: 0,
85  }
86}
87
88// ---------------------------------------------------------------- keywords -> topics
89
90type Topic = { name: string; re: RegExp; color: number; sprite: string[] }
91
92const SPRITES: Record<string, string[]> = {
93  debug: ['#..##..#', '..####..', '.######.', '########', '#.####.#', '..####..', '.#.##.#.', '#..##..#'],
94  test: ['........', '......##', '.....##.', '#...##..', '##.##...', '.###....', '..#.....', '........'],
95  refactor: ['..#.....', '..##....', '#######.', '..##....', '..#..##.', '.....##.', '..#######', '.....##.'].map(r => r.slice(0, 8)),
96  design: ['##..##..', '##..##..', '........', '..##..##', '..##..##', '........', '##..##..', '##..##..'],
97  plan: ['#.#####.', '........', '#.#####.', '........', '#.####..', '........', '#.#####.', '........'],
98  perf: ['....##..', '...##...', '..###...', '.#######', '...###..', '..##....', '.##.....', '##......'],
99  security: ['..####..', '.#....#.', '.#....#.', '########', '##....##', '##.##.##', '##.##.##', '########'],
100  git: ['#.......', '#..###..', '#.#...#.', '#.#...#.', '##....#.', '#...###.', '#.......', '#.......'],
101  db: ['.######.', '########', '.######.', '#......#', '########', '#......#', '########', '.######.'],
102  api: ['..#..#..', '..#..#..', '########', '#......#', '#......#', '########', '...##...', '...##...'],
103  docs: ['######..', '#....##.', '#.##..#.', '#....###', '#.###..#', '#......#', '#.###..#', '########'],
104  build: ['..####..', '.#....#.', '########', '#..##..#', '#..##..#', '########', '#......#', '########'],
105  idea: ['..####..', '.#....#.', '#......#', '#......#', '.#....#.', '..####..', '..####..', '...##...'],
106}
107
108export const TOPICS: Topic[] = [
109  { name: 'debug', color: C.red, sprite: SPRITES.debug!, re: /\b(bug|errors?|fail(?:s|ed|ing|ures?)?|crash\w*|stack ?trace|exception|broken|root cause|undefined|regress\w*)\b/gi },
110  { name: 'test', color: C.pink, sprite: SPRITES.test!, re: /\b(tests?|specs?|assert\w*|expect\w*|coverage|vitest|jest|pytest|e2e|verif(?:y|ied|ication)|passing)\b/gi },
111  { name: 'refactor', color: C.cyan, sprite: SPRITES.refactor!, re: /\b(refactor\w*|rename\w*|extract\w*|clean ?up|simplif\w*|dedupe|restructur\w*)\b/gi },
112  { name: 'design', color: C.purple, sprite: SPRITES.design!, re: /\b(ui|ux|css|layout|styles?|colou?rs?|theme|components?|visual\w*|pixel\w*|animat\w*)\b/gi },
113  { name: 'plan', color: C.amber, sprite: SPRITES.plan!, re: /\b(plan|steps?|approach|first|then|strategy|architecture|outline)\b/gi },
114  { name: 'perf', color: C.amber, sprite: SPRITES.perf!, re: /\b(perf\w*|slow|latency|cach\w+|optimi[sz]\w*|memory|throughput)\b/gi },
115  { name: 'security', color: C.red, sprite: SPRITES.security!, re: /\b(auth\w*|tokens?|secrets?|permissions?|security|credentials?|password|csrf|xss|sandbox\w*|encrypt\w*)\b/gi },
116  { name: 'git', color: C.pink, sprite: SPRITES.git!, re: /\b(git|commits?|branch\w*|merge\w*|rebase|pull request|diff)\b/gi },
117  { name: 'data', color: C.blue, sprite: SPRITES.db!, re: /\b(sql\w*|database|schema|migrations?|quer(?:y|ies)|postgres|table|json)\b/gi },
118  { name: 'api', color: C.blue, sprite: SPRITES.api!, re: /\b(api|endpoints?|http\w*|requests?|responses?|server|websocket|rest|graphql|routes?)\b/gi },
119  { name: 'docs', color: C.green, sprite: SPRITES.docs!, re: /\b(docs?|readme|comments?|document\w*|changelog|explain\w*)\b/gi },
120  { name: 'build', color: C.green, sprite: SPRITES.build!, re: /\b(build\w*|compil\w+|install\w*|dependenc\w+|packages?|npm|cargo|bundl\w+|webpack|vite|deploy\w*)\b/gi },
121]
122
123export const IDEA = { name: 'idea', color: C.amber, sprite: SPRITES.idea! }
124
125export function topicsOf(text: string): string[] {
126  const scored: { name: string; n: number }[] = []
127  for (const t of TOPICS) {
128    const n = (text.match(t.re) ?? []).length
129    if (n > 0) scored.push({ name: t.name, n })
130  }
131  scored.sort((a, b) => b.n - a.n)
132  return scored.slice(0, 3).map(s => s.name)
133}
134
135export const topicMeta = (name: string) => TOPICS.find(t => t.name === name) ?? IDEA
136
137// ---------------------------------------------------------------- tool call -> eye patch
138
139export const TEST_RE = /\b(vitest|jest|pytest|mocha|playwright test|cypress run|cargo (?:test|nextest)|go test|npm (?:run )?test|pnpm (?:run )?test|yarn test|bun test|rspec|phpunit|claude plugin test)\b/
140export const BROWSER_BASH_RE = /\b(playwright|puppeteer|chromium|chrome|selenium|--headless)\b/
141export const BROWSER_TOOL_RE = /(browser|chrome|playwright|puppeteer|selenium|screenshot|navigate)/i
142
143const lineCount = (s: unknown) => (typeof s === 'string' ? s.split('\n').length : 0)
144
145export type Patch = Partial<Eye> & { kind: EyeKind; hist?: string }
146
147export function classifyTool(tool: string, i: Record<string, any>): Patch {
148  const short = (s: unknown) => String(s ?? '').replace(/\s+/g, ' ').slice(0, 80)
149  switch (tool) {
150    case 'Read':
151      return { kind: 'read', path: i.file_path ?? '', detail: basename(i.file_path ?? '') + (i.offset ? ` @${i.offset}` : ''), progress: i.limit ?? 0, lastOp: 'R', lastFile: fname(i.file_path ?? ''), hist: `R ${basename(i.file_path ?? '')}` }
152    case 'Write':
153      return { kind: 'write', path: i.file_path ?? '', detail: basename(i.file_path ?? ''), progress: lineCount(i.content), lastOp: 'W', lastFile: fname(i.file_path ?? ''), hist: `W ${basename(i.file_path ?? '')}` }
154    case 'Edit':
155    case 'NotebookEdit':
156      return { kind: 'edit', path: i.file_path ?? i.notebook_path ?? '', detail: basename(i.file_path ?? i.notebook_path ?? ''), progress: lineCount(i.new_string), lastOp: 'E', lastFile: fname(i.file_path ?? i.notebook_path ?? ''), hist: `E ${basename(i.file_path ?? i.notebook_path ?? '')}` }
157    case 'Grep':
158    case 'Glob':
159      return { kind: 'search', path: String(i.pattern ?? ''), detail: short(i.pattern) + (i.path ? ` in ${basename(i.path)}` : ''), hist: `S ${short(i.pattern).slice(0, 24)}` }
160    case 'WebFetch':
161    case 'WebSearch':
162      return { kind: 'web', path: String(i.url ?? i.query ?? ''), detail: short(i.url ?? i.query), hist: `@ ${short(i.url ?? i.query).slice(0, 24)}` }
163    case 'Agent':
164      return { kind: 'agent', detail: short(i.description ?? i.subagent_type ?? 'subagent'), hist: `A ${short(i.description).slice(0, 24)}` }
165    case 'TodoWrite': {
166      const todos: Todo[] = Array.isArray(i.todos) ? i.todos.map((t: any) => ({ t: String(t.content ?? t.subject ?? ''), s: String(t.status ?? 'pending') })) : []
167      return { kind: 'plan', detail: `${todos.filter(t => t.s === 'completed').length}/${todos.length} done`, todos, hist: 'P todo list' }
168    }
169    case 'Bash': {
170      const cmd = String(i.command ?? '')
171      if (TEST_RE.test(cmd)) return { kind: 'test', path: cmd, detail: short(cmd), passed: 0, failed: 0, hist: `T ${short(cmd).slice(0, 24)}` }
172      if (BROWSER_BASH_RE.test(cmd)) {
173        const url = (cmd.match(/https?:\/\/[^\s"'`]+/) ?? [''])[0]
174        return { kind: 'browser', path: url, detail: url || short(cmd), action: 'script', x: -1, y: -1, hist: `B ${(url || short(cmd)).slice(0, 24)}` }
175      }
176      return { kind: 'bash', path: cmd, detail: short(i.description ?? cmd), hist: `$ ${short(cmd).slice(0, 24)}` }
177    }
178    default: {
179      if (tool.startsWith('mcp__') && BROWSER_TOOL_RE.test(tool + JSON.stringify(i).slice(0, 200))) {
180        const blob = JSON.stringify(i)
181        const url = String(i.url ?? (blob.match(/https?:\/\/[^\s"'`\\]+/) ?? [''])[0])
182        const coord: unknown = i.coordinate ?? i.coordinates
183        let x = typeof i.x === 'number' ? i.x : -1
184        let y = typeof i.y === 'number' ? i.y : -1
185        if (Array.isArray(coord) && coord.length >= 2) {
186          x = Number(coord[0])
187          y = Number(coord[1])
188        }
189        const action = String(i.action ?? tool.split('__').pop() ?? 'browse')
190        return { kind: 'browser', path: url, detail: `${action} ${url}`.trim(), action, x: x < 0 ? -1 : (x % 1600) / 1600, y: y < 0 ? -1 : (y % 1000) / 1000, hist: `B ${action}`.slice(0, 28) }
191      }
192      if (tool.startsWith('Task') || tool === 'ToolSearch') return { kind: 'plan', detail: tool, hist: `P ${tool}` }
193      return { kind: 'tool', detail: tool.replace(/^mcp__/, '').replace(/__/g, ':'), hist: `* ${tool.replace(/^mcp__/, '').slice(0, 24)}` }
194    }
195  }
196}
197
198export function parseTestCounts(text: string): { passed: number; failed: number } | undefined {
199  const num = (re: RegExp) => {
200    const m = text.match(re)
201    return m ? Number(m[1]) : undefined
202  }
203  const passed = num(/(\d+)\s+(?:passed|passing|pass)\b/i) ?? num(/passed[:\s]+(\d+)/i)
204  const failed = num(/(\d+)\s+(?:failed|failing|fail)\b/i) ?? num(/failed[:\s]+(\d+)/i)
205  if (passed === undefined && failed === undefined) return undefined
206  return { passed: passed ?? 0, failed: failed ?? 0 }
207}
208
209export function findPngPath(text: string): string | undefined {
210  const m = text.match(/(\/[^\s"'`<>|]+\.png)\b/)
211  return m ? m[1] : undefined
212}
213
214export function findPngBase64(v: unknown, depth = 0): string | undefined {
215  if (depth > 5 || v === null || v === undefined) return undefined
216  if (typeof v === 'string') return v.startsWith('iVBORw0KGgo') && v.length < 2_700_000 ? v : undefined
217  if (Array.isArray(v)) {
218    for (const x of v) {
219      const r = findPngBase64(x, depth + 1)
220      if (r) return r
221    }
222    return undefined
223  }
224  if (typeof v === 'object') {
225    for (const x of Object.values(v as Record<string, unknown>)) {
226      const r = findPngBase64(x, depth + 1)
227      if (r) return r
228    }
229  }
230  return undefined
231}
232
233// ---------------------------------------------------------------- canvas (half-block pixels + text cells)
234
235export type Canvas = { w: number; h: number; rows: number; ch: Uint32Array; fg: Uint32Array; bg: Uint32Array }
236const HALF = 0x2580
237
238export function canvas(w: number, rows: number): Canvas {
239  const n = w * rows
240  return { w, h: rows * 2, rows, ch: new Uint32Array(n).fill(HALF), fg: new Uint32Array(n).fill(C.bg), bg: new Uint32Array(n).fill(C.bg) }
241}
242
243export function px(c: Canvas, x: number, y: number, col: number) {
244  x = Math.round(x)
245  y = Math.round(y)
246  if (x < 0 || y < 0 || x >= c.w || y >= c.h) return
247  const i = (y >> 1) * c.w + x
248  if (c.ch[i] !== HALF) {
249    c.ch[i] = HALF
250    c.fg[i] = C.bg
251    c.bg[i] = C.bg
252  }
253  if (y & 1) c.bg[i] = col
254  else c.fg[i] = col
255}
256
257export function rect(c: Canvas, x: number, y: number, w: number, h: number, col: number) {
258  for (let yy = 0; yy < h; yy++) for (let xx = 0; xx < w; xx++) px(c, x + xx, y + yy, col)
259}
260
261export function hline(c: Canvas, x: number, y: number, len: number, col: number) {
262  for (let k = 0; k < len; k++) px(c, x + k, y, col)
263}
264
265export function line(c: Canvas, x0: number, y0: number, x1: number, y1: number, col: number) {
266  const steps = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0), 1)
267  for (let s = 0; s <= steps; s++) px(c, x0 + ((x1 - x0) * s) / steps, y0 + ((y1 - y0) * s) / steps, col)
268}
269
270export function circle(c: Canvas, cx: number, cy: number, r: number, col: number, fill = false) {
271  for (let y = -r; y <= r; y++)
272    for (let x = -r; x <= r; x++) {
273      const d = Math.sqrt(x * x + y * y)
274      if (fill ? d <= r : Math.abs(d - r) < 0.7) px(c, cx + x, cy + y, col)
275    }
276}
277
278/** Text in cell coordinates (row, not pixel y). */
279export function text(c: Canvas, x: number, row: number, s: string, fg: number, bg = C.bg) {
280  if (row < 0 || row >= c.rows) return
281  for (let k = 0; k < s.length; k++) {
282    const xx = x + k
283    if (xx < 0 || xx >= c.w) continue
284    let code = s.charCodeAt(k)
285    if (code < 32 || code > 126) code = 63
286    const i = row * c.w + xx
287    c.ch[i] = code
288    c.fg[i] = fg
289    c.bg[i] = bg
290  }
291}
292
293export function sprite(c: Canvas, x: number, y: number, rowsArr: string[], col: number, scale = 1) {
294  rowsArr.forEach((r, ry) => {
295    for (let rx = 0; rx < r.length; rx++) if (r[rx] === '#') rect(c, x + rx * scale, y + ry * scale, scale, scale, col)
296  })
297}
298
299export function encode(c: Canvas): string {
300  const words = new Uint32Array(c.w * c.rows * 3)
301  for (let i = 0; i < c.w * c.rows; i++) {
302    words[i * 3] = c.ch[i]!
303    words[i * 3 + 1] = c.fg[i]!
304    words[i * 3 + 2] = c.bg[i]!
305  }
306  // toBase64 exists in the hooks runtime but not yet in every TypeScript lib.
307  return (new Uint8Array(words.buffer) as Uint8Array & { toBase64(): string }).toBase64()
308}
309
310// ---------------------------------------------------------------- scenes
311
312// ---------------------------------------------------------------- colour helpers
313
314const rgb = (n: number): [number, number, number] => [(n >> 16) & 255, (n >> 8) & 255, n & 255]
315
316export function mix(a: number, b: number, t: number): number {
317  t = clamp(t, 0, 1)
318  const [ar, ag, ab] = rgb(a)
319  const [br, bg, bb] = rgb(b)
320  return (Math.round(ar + (br - ar) * t) << 16) | (Math.round(ag + (bg - ag) * t) << 8) | Math.round(ab + (bb - ab) * t)
321}
322
323const shade = (col: number, f: number) => mix(C.bg, col, f)
324
325function ramp(stops: number[], v: number): number {
326  const x = clamp(v, 0, 0.9999) * (stops.length - 1)
327  const i = Math.floor(x)
328  return mix(stops[i]!, stops[i + 1]!, x - i)
329}
330
331// ---------------------------------------------------------------- thinking animations
332// Each fills the whole canvas; `age` is in ticks (~125ms), `col` is the topic colour.
333
334type Anim = (c: Canvas, age: number, col: number, words: string) => void
335
336const plasma: Anim = (c, age, col) => {
337  const W = c.w
338  const H = c.h
339  const cx = W / 2
340  const cy = H / 2
341  const stops = [C.bg, shade(C.purple, 0.4), col, mix(col, C.cyan, 0.5), mix(col, C.white, 0.75)]
342  for (let y = 0; y < H; y += 2)
343    for (let x = 0; x < W; x += 2) {
344      const v = (Math.sin(x / 7 + age / 9) + Math.sin(y / 5 - age / 7) + Math.sin((x + y) / 9 + age / 11) + Math.sin(Math.hypot(x - cx, y - cy) / 6 - age / 6)) / 4
345      const col2 = ramp(stops, Math.pow((v + 1) / 2, 1.5))
346      px(c, x, y, col2)
347      px(c, x + 1, y, col2)
348      px(c, x, y + 1, col2)
349      px(c, x + 1, y + 1, col2)
350    }
351}
352
353const neural: Anim = (c, age, col) => {
354  const W = c.w
355  const H = c.h
356  const layers = W < 40 ? [3, 4, 3] : [3, 5, 5, 3]
357  const pos = layers.map((n, l) =>
358    Array.from({ length: n }, (_, i) => ({ x: 4 + (l * (W - 8)) / (layers.length - 1), y: 4 + ((i + 0.5) * (H - 8)) / n })),
359  )
360  const glow = new Map<string, number>()
361  let e = 0
362  for (let l = 0; l < pos.length - 1; l++)
363    for (let i = 0; i < pos[l]!.length; i++)
364      for (let j = 0; j < pos[l + 1]!.length; j++) {
365        const a = pos[l]![i]!
366        const b = pos[l + 1]![j]!
367        line(c, a.x, a.y, b.x, b.y, shade(col, 0.16))
368        const p = (age * 0.04 + (hash(e) % 100) / 100) % 1
369        for (let s = 0; s < 4; s++) {
370          const q = Math.max(0, p - s * 0.03)
371          px(c, a.x + (b.x - a.x) * q, a.y + (b.y - a.y) * q, mix(shade(col, 0.4), C.white, (1 - s / 4) * 0.9))
372        }
373        if (p > 0.92) glow.set(`${l + 1}:${j}`, 1)
374        e++
375      }
376  pos.forEach((layer, l) =>
377    layer.forEach((n, i) => {
378      const g = glow.get(`${l}:${i}`) ?? 0
379      const base = 0.45 + 0.25 * Math.sin(age / 6 + hash(l * 9 + i) % 7)
380      rect(c, n.x - 1, n.y - 1, 3, 3, mix(shade(col, base), C.white, g * 0.8))
381    }),
382  )
383}
384
385const orbit: Anim = (c, age, col) => {
386  const W = c.w
387  const H = c.h
388  const cx = W / 2
389  const cy = H / 2
390  const pulse = 2 + (Math.sin(age / 5) + 1) * 0.8
391  circle(c, cx, cy, Math.round(pulse), mix(col, C.white, 0.5), true)
392  const R = Math.min(W / 2.2, H * 0.9)
393  for (let k = 0; k < 3; k++) {
394    const tilt = (k * Math.PI) / 3 + age / 140
395    const rx = R * (1 - k * 0.12)
396    const ry = rx * 0.4
397    const at = (a: number): [number, number] => {
398      const x = rx * Math.cos(a)
399      const y = ry * Math.sin(a)
400      return [cx + x * Math.cos(tilt) - y * Math.sin(tilt), cy + x * Math.sin(tilt) + y * Math.cos(tilt)]
401    }
402    for (let s = 0; s < 120; s++) {
403      const [x, y] = at((s / 120) * Math.PI * 2)
404      px(c, x, y, shade(col, 0.2))
405    }
406    for (let j = 0; j < 2; j++) {
407      const head = age * 0.07 * (1 + k * 0.35) + j * Math.PI
408      for (let s = 0; s < 12; s++) {
409        const [x, y] = at(head - s * 0.1)
410        px(c, x, y, mix(shade(col, 0.3), k === 1 ? C.cyan : C.white, 1 - s / 12))
411      }
412    }
413  }
414}
415
416const rain: Anim = (c, age, col, words) => {
417  const chars = (words.replace(/[^a-z]/gi, '') || 'think') + '01{}<>/;=+*#'
418  for (let x = 0; x < c.w; x += 2) {
419    const h = hash(x * 7919)
420    const speed = 0.22 + (h % 50) / 110
421    const trail = 4 + (h % 7)
422    const head = Math.floor(age * speed + (h % 97)) % (c.rows + trail)
423    for (let k = 0; k < trail; k++) {
424      const row = head - k
425      if (row < 0 || row >= c.rows) continue
426      const ch = chars[hash(x * 31 + row * 17 + (k === 0 ? age >> 2 : 0)) % chars.length]!
427      text(c, x, row, ch, k === 0 ? C.white : shade(col, (1 - k / trail) * 0.9))
428    }
429  }
430}
431
432const helix: Anim = (c, age, col) => {
433  const W = c.w
434  const H = c.h
435  const A = H * 0.28
436  const strand2 = C.cyan
437  for (let x = 1; x < W - 1; x++) {
438    const ph = x / 5 + age / 6
439    const y1 = H / 2 + A * Math.sin(ph)
440    const y2 = H / 2 - A * Math.sin(ph)
441    if (x % 4 === 0) line(c, x, y1, x, y2, shade(mix(col, strand2, 0.5), 0.3))
442  }
443  for (let x = 1; x < W - 1; x++) {
444    const ph = x / 5 + age / 6
445    const z = Math.cos(ph)
446    const y1 = H / 2 + A * Math.sin(ph)
447    const y2 = H / 2 - A * Math.sin(ph)
448    const a = mix(shade(col, 0.35), col, (z + 1) / 2)
449    const b = mix(shade(strand2, 0.35), strand2, (1 - z) / 2)
450    if (z > 0) {
451      rect(c, x, y2 - 1, 1, 2, b)
452      rect(c, x, y1 - 1, 1, 2, a)
453    } else {
454      rect(c, x, y1 - 1, 1, 2, a)
455      rect(c, x, y2 - 1, 1, 2, b)
456    }
457  }
458}
459
460const warp: Anim = (c, age, col) => {
461  const cx = c.w / 2
462  const cy = c.h / 2
463  const maxR = Math.hypot(c.w, c.h) / 2
464  for (let i = 0; i < 80; i++) {
465    const h = hash(i * 40503)
466    const ang = ((h % 3600) / 3600) * Math.PI * 2
467    const sp = 0.006 + ((h >> 8) % 100) / 14000
468    const p = (age * sp * 8 + ((h >> 4) % 1000) / 1000) % 1
469    for (let s = 0; s < 4; s++) {
470      const q = Math.max(0, p - s * 0.025)
471      const r = q * q * maxR
472      px(c, cx + Math.cos(ang) * r, cy + Math.sin(ang) * r, mix(C.bg, mix(col, C.white, p), (1 - s / 4) * clamp(p * 1.6, 0, 1)))
473    }
474  }
475}
476
477const lissajous: Anim = (c, age, col) => {
478  const cx = c.w / 2
479  const cy = c.h / 2
480  const A = c.w * 0.4
481  const B = c.h * 0.4
482  const phi = age / 40
483  for (let s = 0; s < 160; s++) {
484    const th = age * 0.09 - s * 0.045
485    const x = cx + A * Math.sin(3 * th + phi)
486    const y = cy + B * Math.sin(2 * th)
487    const f = 1 - s / 160
488    const color = mix(C.bg, mix(col, C.cyan, s / 160), f)
489    px(c, x, y, color)
490    if (f > 0.75) px(c, x + 1, y, color)
491  }
492}
493
494export const ANIMS: Anim[] = [plasma, neural, orbit, rain, helix, warp, lissajous]
495
496// ---------------------------------------------------------------- last-touched file badge
497
498const OP_WORD = { R: 'read', W: 'wrote', E: 'edited' } as const
499const OP_COLOR = { R: C.cyan, W: C.green, E: C.amber } as const
500
501function fileBadge(c: Canvas, eye: Eye, t: number) {
502  if (!eye.lastOp || !eye.lastFile || c.w < 16 || c.rows < 6) return
503  const col = OP_COLOR[eye.lastOp]
504  const recent = t - eye.lastAt < 24
505  const y = (c.rows - 3) * 2
506  const label = `${OP_WORD[eye.lastOp]} ${eye.lastFile}`.slice(0, c.w - 11)
507  rect(c, 1, y, Math.min(c.w - 2, 10 + label.length), 6, C.bg)
508  const icon = recent && (t >> 1) % 2 === 0 ? C.white : col
509  rect(c, 3, y, 5, 6, icon)
510  px(c, 7, y, C.bg)
511  hline(c, 4, y + 2, 3, C.bg)
512  hline(c, 4, y + 4, 3, C.bg)
513  text(c, 10, c.rows - 2, label, col)
514  text(c, 10, c.rows - 1, recent ? 'just now' : `${Math.round((t - eye.lastAt) / 8)}s ago`, C.dim)
515}
516
517function doc(c: Canvas, x: number, y: number, w: number, h: number, seed: number, colorOf: (i: number) => number | undefined) {
518  rect(c, x, y, w, h, C.panel2)
519  rect(c, x, y, w, 1, C.line)
520  const n = Math.floor((h - 3) / 2)
521  for (let i = 0; i < n; i++) {
522    const ly = y + 2 + i * 2
523    const indent = hash(seed * 31 + i) % 3 === 0 ? 3 : 0
524    const len = 3 + (hash(seed + i * 13) % Math.max(1, w - 6 - indent))
525    const hot = colorOf(i)
526    if (hot !== undefined) rect(c, x + 1, ly - (ly > y ? 0 : 0), w - 2, 1, hot === C.dim ? C.panel2 : (hot & 0xfefefe) >> 2)
527    hline(c, x + 2 + indent, ly, len, hot ?? C.dim)
528  }
529  return n
530}
531
532function fileLog(c: Canvas, x: number, eye: Eye, fromRow: number) {
533  const room = c.w - x - 1
534  if (room < 8) return
535  const files = eye.history.filter(h => 'RWE'.includes(h[0]!)).slice(-(c.rows - fromRow - 1))
536  files.forEach((h, k) => {
537    const col = h[0] === 'R' ? C.cyan : h[0] === 'W' ? C.green : C.amber
538    const last = k === files.length - 1
539    text(c, x, fromRow + k, h.slice(0, room), last ? col : C.dim)
540  })
541}
542
543export function scene(c: Canvas, eye: Eye, t: number) {
544  const age = Math.max(0, t - eye.since)
545  const W = c.w
546  const H = c.h
547  const meta = KIND[eye.kind]
548  const seedP = hash(eye.path || eye.detail)
549  // faint star field
550  for (let y = 1; y < H; y += 5) for (let x = (y * 3) % 7; x < W; x += 7) px(c, x, y, C.line)
551
552  switch (eye.kind) {
553    case 'read': {
554      const dw = clamp(Math.floor(W * 0.42), 12, 30)
555      const dh = H - 4
556      const nLines = Math.floor((dh - 3) / 2)
557      const hot = nLines > 0 ? Math.floor(age / 2) % nLines : 0
558      doc(c, 2, 2, dw, dh, seedP, i => (i === hot ? C.cyan : i === hot - 1 || i === hot + 1 ? C.blue : undefined))
559            fileLog(c, dw + 5, eye, 1)
560      break
561    }
562    case 'write': {
563      const dw = clamp(Math.floor(W * 0.42), 12, 30)
564      const dh = H - 4
565      const nLines = Math.floor((dh - 3) / 2)
566      const fill = eye.result ? nLines : Math.min(nLines, Math.floor(age / 2))
567      doc(c, 2, 2, dw, dh, seedP, i => (i < fill ? C.green : undefined))
568      if (!eye.result && fill < nLines && age % 6 < 3) rect(c, 4 + (hash(seedP + fill) % 10), 4 + fill * 2, 2, 1, C.amber)
569      if (eye.result) circle(c, 2 + dw - 3, 5, 2, C.green)
570      fileLog(c, dw + 5, eye, 1)
571      break
572    }
573    case 'edit': {
574      const dw = clamp(Math.floor(W * 0.42), 12, 30)
575      const dh = H - 4
576      const nLines = Math.floor((dh - 3) / 2)
577      const target = nLines > 0 ? seedP % nLines : 0
578      const showNew = eye.result === 'ok' || age % 40 >= 20
579      doc(c, 2, 2, dw, dh, seedP, i => (i === target ? (showNew ? C.green : C.red) : undefined))
580      if (!showNew) hline(c, 3, 2 + 2 + target * 2, dw - 4, C.red)
581      fileLog(c, dw + 5, eye, 1)
582      break
583    }
584    case 'search': {
585      const tw = 5
586      const th = 3
587      const cx = W / 2 + Math.sin(age / 9) * W * 0.32
588      const cy = H / 2 + Math.sin(age / 5 + 1) * H * 0.26
589      for (let ty = 2; ty < H - 3; ty += th + 1)
590        for (let tx = 2; tx < W - 3; tx += tw + 1) {
591          const d = Math.hypot(tx + tw / 2 - cx, ty + th / 2 - cy)
592          const lit = d < 9
593          rect(c, tx, ty, tw, th, lit ? (hash(tx * 7 + ty) % 4 === 0 ? C.amber : C.purple) : C.panel2)
594        }
595      circle(c, Math.round(cx), Math.round(cy), 6, C.white)
596      line(c, Math.round(cx) + 4, Math.round(cy) + 4, Math.round(cx) + 9, Math.round(cy) + 9, C.white)
597      text(c, 1, c.rows - 1, ('/' + eye.path).slice(0, W - 2), C.purple)
598      break
599    }
600    case 'bash': {
601      rect(c, 1, 1, W - 2, H - 2, 0x0b0e14)
602      rect(c, 1, 1, W - 2, 2, C.panel2)
603      px(c, 3, 1, C.red); px(c, 5, 1, C.amber); px(c, 7, 1, C.green)
604      text(c, 2, 1, ('$ ' + eye.path.split('\n')[0]!).slice(0, W - 14), C.green, 0x0b0e14)
605      const bars = c.rows - 4
606      for (let r = 0; r < bars; r++) {
607        const k = eye.result ? r : r + Math.floor(age / 3)
608        const len = 3 + (hash(eye.path + k) % Math.max(1, W - 12))
609        const col = hash(eye.path + k * 3) % 11 === 0 ? C.amber : eye.result === 'fail' && r === bars - 1 ? C.red : C.dim
610        hline(c, 3, 6 + r * 2, len, col)
611      }
612      const status = eye.result === 'ok' ? 'exit ok' : eye.result === 'fail' ? 'exit error' : age % 8 < 4 ? 'running_' : 'running '
613      text(c, 2, c.rows - 1, status, eye.result === 'fail' ? C.red : eye.result === 'ok' ? C.green : C.amber, 0x0b0e14)
614      break
615    }
616    case 'test': {
617      text(c, 1, 0, 'TESTS', C.pink)
618      const known = eye.passed + eye.failed
619      const perRow = Math.max(4, Math.floor((W - 4) / 3))
620      const rowsAvail = Math.max(1, Math.floor((H - 12) / 3))
621      const cap = perRow * rowsAvail
622      const total = Math.min(cap, known > 0 ? known : 60)
623      const filled = eye.result ? total : Math.min(total, Math.floor(age * 1.2))
624      const failedDots = eye.result && known > 0 ? Math.round((eye.failed / known) * total) : eye.result === 'fail' && known === 0 ? 2 : 0
625      const failSet = new Set<number>()
626      for (let k = 0; k < failedDots; k++) failSet.add(hash(eye.path + k) % total)
627      for (let k = 0; k < total; k++) {
628        const x = 2 + (k % perRow) * 3
629        const y = 5 + Math.floor(k / perRow) * 3
630        const col = k >= filled ? C.panel2 : failSet.has(k) ? C.red : k >= filled - 2 && !eye.result ? C.cyan : C.green
631        rect(c, x, y, 2, 2, col)
632      }
633      const summary = known > 0 ? `${eye.passed} passed  ${eye.failed} failed` : eye.result ? (eye.result === 'ok' ? 'passed' : 'failed') : 'running...'
634      text(c, 1, c.rows - 2, summary, eye.failed > 0 || eye.result === 'fail' ? C.red : C.green)
635      text(c, 1, c.rows - 1, eye.path.slice(0, W - 2), C.dim)
636      break
637    }
638    case 'browser': {
639      rect(c, 1, 0, W - 2, H, C.panel)
640      rect(c, 1, 0, W - 2, 2, C.panel2)
641      px(c, 3, 0, C.red); px(c, 5, 0, C.amber); px(c, 7, 0, C.green)
642      rect(c, 10, 2, W - 14, 2, C.bg)
643      text(c, 11, 1, (eye.path || 'about:blank').replace(/^https?:\/\//, '').slice(0, W - 17), C.ink, C.bg)
644      const loading = !eye.result && age < 30
645      if (loading) hline(c, 10, 4, Math.floor(((W - 14) * age) / 30), C.blue)
646      const py = 5
647      const pw = W - 4
648      const ph = H - py - 1
649      const hue = (n: number) => [C.blue, C.purple, C.cyan, C.green, C.amber, C.pink][n % 6]!
650      const s = seedP
651      rect(c, 2, py, pw, 3, C.panel2)
652      rect(c, 4, py + 1, 6, 1, hue(s))
653      if (!loading || age > 10) {
654        rect(c, 3, py + 5, Math.floor(pw * 0.7), Math.max(3, Math.floor(ph * 0.3)), (hue(s >> 3) & 0xfcfcfc) >> 2)
655        const cols = 2 + (s % 3)
656        const cw = Math.floor((pw - 2 - cols) / cols)
657        const cy = py + 7 + Math.floor(ph * 0.3)
658        for (let k = 0; k < cols; k++) {
659          rect(c, 3 + k * (cw + 1), cy, cw, Math.max(2, py + ph - cy - 1), C.panel2)
660          hline(c, 4 + k * (cw + 1), cy + 1, Math.max(1, cw - 3), hue(s + k))
661          hline(c, 4 + k * (cw + 1), cy + 3, Math.max(1, cw - 5), C.dim)
662        }
663      }
664      const fx = eye.x >= 0 ? eye.x : 0.5 + 0.35 * Math.sin(age / 12)
665      const fy = eye.y >= 0 ? eye.y : 0.5 + 0.3 * Math.sin(age / 7 + 2)
666      const mx = Math.round(2 + fx * (pw - 3))
667      const my = Math.round(py + fy * (ph - 3))
668      if (/click|press|tap/i.test(eye.action)) circle(c, mx, my, 1 + (age % 8), C.amber)
669      if (/type|fill|key/i.test(eye.action)) rect(c, mx, my, 1, 3, age % 6 < 3 ? C.white : C.panel2)
670      sprite(c, mx, my, ['#..', '##.', '###', '##.', '#..'], C.white)
671      text(c, 1, c.rows - 1, eye.action.slice(0, W - 2), C.blue, C.panel)
672      break
673    }
674    case 'web': {
675      const r = clamp(Math.min(Math.floor(H / 2) - 3, Math.floor(W / 4)), 4, 20)
676      const cx = Math.floor(W / 2)
677      const cy = Math.floor(H / 2) - 1
678      for (let lat = -75; lat <= 75; lat += 25)
679        for (let lon = 0; lon < 360; lon += 20) {
680          const la = (lat * Math.PI) / 180
681          const lo = ((lon + age * 4) * Math.PI) / 180
682          const z = Math.cos(la) * Math.cos(lo)
683          if (z > 0) px(c, cx + r * Math.cos(la) * Math.sin(lo), cy - r * Math.sin(la), z > 0.6 ? C.cyan : C.blue)
684        }
685      const a = age / 6
686      px(c, cx + (r + 4) * Math.cos(a), cy + (r + 4) * Math.sin(a) * 0.35, C.amber)
687      text(c, 1, c.rows - 1, eye.detail.slice(0, W - 2), C.blue)
688      break
689    }
690    case 'agent': {
691      const rx = Math.floor(W / 2)
692      const pulse = age % 12 < 6 ? 3 : 2
693      circle(c, rx, 5, pulse, C.purple, true)
694      const kids = 3
695      for (let k = 0; k < kids; k++) {
696        const kx = Math.floor((W * (k + 1)) / (kids + 1))
697        const ky = Math.floor(H * 0.68)
698        line(c, rx, 8, kx, ky - 3, C.dim)
699        const p = ((age + k * 5) % 20) / 20
700        px(c, rx + (kx - rx) * p, 8 + (ky - 11) * p, C.amber)
701        circle(c, kx, ky, 2 + ((age + k * 4) % 12 < 6 ? 1 : 0), C.purple, true)
702      }
703      text(c, 1, c.rows - 1, eye.detail.slice(0, W - 2), C.purple)
704      break
705    }
706    case 'plan': {
707      text(c, 1, 0, 'PLAN', C.amber)
708      const room = c.rows - 2
709      if (eye.todos.length === 0) text(c, 1, 2, eye.detail.slice(0, W - 2), C.ink)
710      eye.todos.slice(0, room).forEach((td, k) => {
711        const done = td.s === 'completed'
712        const doing = td.s === 'in_progress'
713        const mark = done ? '[x] ' : doing ? (age % 8 < 4 ? '[>] ' : '[ ] ') : '[ ] '
714        text(c, 1, 2 + k, (mark + td.t).slice(0, W - 2), done ? C.green : doing ? C.amber : C.dim)
715      })
716      break
717    }
718    case 'think': {
719      const top = eye.topics[0]
720      const m = top ? topicMeta(top) : IDEA
721      const idx = (hash(eye.id + ':' + eye.since) + Math.floor(age / 200)) % ANIMS.length
722      ANIMS[idx]!(c, age, m.color, eye.topics.join(''))
723      if (W >= 26) {
724        rect(c, 1, 1, 10, 10, C.bg)
725        sprite(c, 2, 2, m.sprite, m.color, 1)
726        let x = 12
727        const dots = '.'.repeat(1 + ((age >> 2) % 3))
728        if (eye.topics.length === 0) text(c, x, 1, ('thinking' + dots).slice(0, W - x - 1), C.amber)
729        eye.topics.forEach(name => {
730          const label = '#' + name
731          if (x + label.length < W) text(c, x, 1, label, topicMeta(name).color)
732          x += label.length + 1
733        })
734      }
735      fileBadge(c, eye, t)
736      break
737    }
738    case 'tool': {
739      const cx = Math.floor(W / 2)
740      const cy = Math.floor(H / 2) - 2
741      const a = age / 5
742      for (let k = 0; k < 4; k++) line(c, cx, cy, cx + Math.cos(a + (k * Math.PI) / 2) * 6, cy + Math.sin(a + (k * Math.PI) / 2) * 6, C.ink)
743      circle(c, cx, cy, 2, C.paper, true)
744      text(c, 1, c.rows - 1, eye.detail.slice(0, W - 2), C.ink)
745      break
746    }
747    default: {
748      const mid = Math.floor(c.rows / 2)
749      const dots = '.'.repeat(1 + ((t >> 2) % 3))
750      text(c, Math.max(1, Math.floor((W - 20) / 2)), mid, ('waiting for the agent' + dots).slice(0, W - 2), C.ink)
751      fileBadge(c, eye, t)
752      break
753    }
754  }
755}
756
types/index.d.ts 51 lines
1export type EyeKind =
2  | 'idle'
3  | 'think'
4  | 'read'
5  | 'write'
6  | 'edit'
7  | 'search'
8  | 'bash'
9  | 'test'
10  | 'browser'
11  | 'web'
12  | 'agent'
13  | 'plan'
14  | 'tool'
15
16export type Todo = { t: string; s: string }
17
18export type Eye = {
19  id: string
20  label: string
21  kind: EyeKind
22  detail: string
23  path: string
24  since: number
25  topics: string[]
26  history: string[]
27  result: '' | 'ok' | 'fail'
28  passed: number
29  failed: number
30  x: number
31  y: number
32  progress: number
33  done: boolean
34  hasShot: boolean
35  todos: Todo[]
36  action: string
37  /** Last file read/written/edited: survives scene changes. */
38  lastOp: '' | 'R' | 'W' | 'E'
39  lastFile: string
40  lastAt: number
41}
42
43declare module 'claude-code' {
44  interface PluginState {
45    'visualize': {
46      eyes: Record<string, Eye>
47      tick: number
48    }
49  }
50}
51