SLOPSHOPPER

brain-hud

A Jarvis-style Claude Brain pane: a 3D sphere and a vector HUD that shift colour, motion and overlay with what Claude is doing; a live draggable text sphere on…

newpaneguardcommandtoaststatus
v0.3.0MITupdated 2026-10-23Bello-online/my-mods/plugins/brain-hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · brain-hud
│ ┃ Claude Brain ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./brain3d.tsx │ ┃ 3: 3D h: HUD p: ⏸ pause s: speed c: clear ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ recent · hover a ◉ node for details, click ⏺ Update(src/auth.ts) │ ┃ to pin a note ⎿ Added 2 lines, removed 1 line │ ┃ ✓ Bash Show environment file 20ms ⏺ Bash(bun test) │ ┃ ✓ Bash Clean build output and push 20ms ⎿ 3 pass, 1 fail │ ┃ ✓ Bash Show working tree status 20ms │ ┃ ✓ Bash Run the test suite 20ms ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /brain │ ⎿ brain-hud: Claude Brain is open. Drag the sphere to spin it; 3/h │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Claude Brain
▣ client module ./brain3d.tsx 3: 3D h: HUD p: ⏸ pause s: speed c: clear recent · hover a ◉ node for details, click to pin a note ✓ Bash Show environment file 20ms ✓ Bash Clean build output and push 20ms ✓ Bash Show working tree status 20ms ✓ Bash Run the test suite 20ms
README

my-mods

Olase's Claude Code mods: changes to Claude's own interface, written as function-hooks plugins.

brain-hud

A Jarvis-style Claude Brain pane that animates while Claude works and changes colour, motion and overlay with the task: thinking, responding, reading, editing, running a command, searching, fetching the web, delegating to subagents, done, error.

  • 3D view: a sphere of points turning in depth, with gyroscope rings, a pulsing core and sparks. Each recent tool call rides the sphere as a node; hover one for the tool, file and duration. On a terminal the sphere is a live text render you can drag to spin, with arrow keys, +/- zoom, space to pause and r to reset.
  • HUD view: vector rings with a radar sweep, scan lines, ripples and a waveform per phase.
  • Buttons for 3D/HUD, pause, speed and clear; hotkeys 3, h, p, s, c while the pane has focus.
  • The status line shows the phase and what Claude is touching.

The pane docks beside the transcript on the desktop app; on a terminal, /brain opens it.

Install

In Claude Code:

/plugin marketplace add Bello-online/my-mods
/plugin install brain-hud@my-mods

Or from a shell:

claude plugin install brain-hud --marketplace Bello-online/my-mods

It loads in every session from the next start.

Develop

claude plugin validate plugins/brain-hud
claude plugin test plugins/brain-hud

Source: plugins/brain-hud/hooks (the hooks module register.tsx, the vector views in brain-svg.ts, the terminal 3D client brain3d.tsx, the tool-to-phase map phases.ts). After a change, bump the version in plugins/brain-hud/.claude-plugin/plugin.json and .claude-plugin/marketplace.json, then update the marketplace and reinstall.

License

MIT

Source 6 files
hooks/register.tsx 407 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Brain3dMessage, Brain3dProps, BrainPhase, BrainState, BrainTool, BrainUi } from '../types'
5import { PHASES, ago, classify, clip } from './phases'
6import { brainSphereSvg, brainSvg } from './brain-svg'
7import { elapsedText, orb, overlayLine } from './brain-ascii'
8
9const PANE = 'brain-hud'
10const CLIENT = 'brain3d'
11const TICK_MS = 200
12const SETTLE_MS = 3000
13
14const INITIAL: BrainState = {
15  phase: 'idle',
16  prevPhase: 'idle',
17  detail: '',
18  since: 0,
19  turnStartedAt: null,
20  turnCount: 0,
21  toolCount: 0,
22  active: 0,
23  recent: [],
24}
25
26const INITIAL_UI: BrainUi = { view: '3d', paused: false, speed: 2 }
27
28const brain = atom({ plugin: 'brain-hud', key: 'brain' } as const, INITIAL)
29const tick = atom({ plugin: 'brain-hud', key: 'tick' } as const, 0)
30const ui = atom({ plugin: 'brain-hud', key: 'ui' } as const, INITIAL_UI)
31
32// Module-local handles: a hot reload drops them, and the next turn makes new ones.
33let ticker: Timer | null = null
34let settle: Timer | null = null
35
36function startTicker($: EngineInterface): void {
37  if (ticker) return
38  ticker = $.clock.every(TICK_MS, () => {
39    void update($, tick, n => ((n ?? 0) + 1) % 1_000_000)
40  })
41}
42
43function stopTicker(): void {
44  ticker?.cancel()
45  ticker = null
46}
47
48function showStatus($: EngineInterface, state: BrainState): void {
49  if (state.phase === 'idle') {
50    $.ui.status(undefined)
51    return
52  }
53  const label = PHASES[state.phase].label
54  $.ui.status(state.detail ? `◉ ${label} · ${clip(state.detail, 40)}` : `◉ ${label}`)
55}
56
57/** Move the brain to a phase, keeping the previous one so the drawing can morph from it. */
58async function setPhase($: EngineInterface, phase: BrainPhase, detail: string, now?: number): Promise<void> {
59  const current = await read($, brain)
60  if (current.phase === phase && current.detail === detail) return
61  const at = now ?? (await $.clock.now())
62  const next = await update(
63    $,
64    brain,
65    (s): BrainState => ({ ...s, prevPhase: s.phase, phase, detail, since: at }),
66  )
67  showStatus($, next)
68}
69
70const SPEED_LABEL: Record<BrainUi['speed'], string> = { 1: 'slow', 2: 'normal', 3: 'fast' }
71
72async function openPane($: EngineInterface): Promise<void> {
73  const opened = await $.ui.open({ id: PANE, title: 'Claude Brain' })
74  if (!opened.isPlaced) $.ui.log(`brain-hud: pane waits: ${opened.reason}`, { to: 'debug' })
75}
76
77export const register: Register = on => {
78  on('session.start', async ($, e, next) => {
79    await $.command.register({
80      name: 'brain',
81      description: 'Open the Claude Brain pane: a live 3D view of what Claude is doing',
82    })
83    const result = await next(e)
84    // A docked sidebar on the desktop and editor surfaces; on the terminal it would take a third
85    // of the screen inline, so there it waits for /brain. A session a program started has no
86    // surface yet: the pane is opened anyway and seated when the surface attaches.
87    if (e.surface !== 'terminal') void openPane($)
88    return result
89  })
90
91  // A surface attaching later (the desktop app to a session it started) seats the pane.
92  on('session.attach', async ($, e, next) => {
93    const result = await next(e)
94    if (e.surface !== 'terminal') void openPane($)
95    return result
96  })
97
98  on('command.run', { command: 'brain' }, async $ => {
99    const opened = await $.ui.open({ id: PANE, title: 'Claude Brain', focus: true, rows: 22 })
100    return {
101      text: opened.isPlaced
102        ? 'Claude Brain is open. Drag the sphere to spin it; 3/h switch views, p pauses, s changes speed.'
103        : 'Claude Brain could not be placed on this surface.',
104    }
105  })
106
107  on('turn.start', async ($, e, next) => {
108    settle?.cancel()
109    settle = null
110    const now = await $.clock.now()
111    const next_ = await update(
112      $,
113      brain,
114      (s): BrainState => ({
115        ...s,
116        prevPhase: s.phase,
117        phase: 'thinking',
118        detail: clip(e.text, 60) || 'continuing',
119        since: now,
120        turnStartedAt: now,
121        turnCount: s.turnCount + 1,
122        toolCount: 0,
123        active: 0,
124        recent: [],
125      }),
126    )
127    showStatus($, next_)
128    startTicker($)
129    return next(e)
130  })
131
132  // The model's response as it streams: thinking, prose, or a tool call being prepared.
133  on('turn.step', async function* ($, e, next) {
134    const stream = next(e)
135    let seen = ''
136    for await (const chunk of stream) {
137      if (!e.agentId) {
138        let want: [BrainPhase, string] | null = null
139        if (chunk.kind === 'thinking') want = ['thinking', 'reasoning']
140        else if (chunk.kind === 'text') want = ['responding', 'writing the answer']
141        else if (chunk.kind === 'tool') want = [classify(chunk.name, {}).phase, `preparing ${chunk.name}`]
142        if (want && want.join('|') !== seen) {
143          seen = want.join('|')
144          await setPhase($, want[0], want[1])
145        }
146      }
147      yield chunk
148    }
149    return await stream.result
150  })
151
152  on('tool.call', async ($, e, next) => {
153    const input = e as unknown as Record<string, unknown>
154    const { phase, label } = classify(e.tool, input)
155    const now = await $.clock.now()
156    const id = e.tool_use_id ?? `${e.tool}-${now}`
157    const inSubagent = Boolean(e.agentId)
158    const call: BrainTool = { id, tool: e.tool, label, phase, startedAt: now }
159
160    try {
161      const started = await update(
162        $,
163        brain,
164        (s): BrainState => ({
165          ...s,
166          prevPhase: s.phase,
167          phase: inSubagent ? 'delegating' : phase,
168          detail: inSubagent ? `↳ ${label}` : label,
169          since: now,
170          toolCount: s.toolCount + 1,
171          active: s.active + 1,
172          recent: [...s.recent, call].slice(-8),
173        }),
174      )
175      showStatus($, started)
176    } catch {
177      // bookkeeping only: never gate the call on it
178    }
179
180    const ran = await next(e)
181
182    try {
183      const end = await $.clock.now()
184      const finished = await update($, brain, (s): BrainState => {
185        const active = Math.max(0, s.active - 1)
186        const recent = s.recent.map(r =>
187          r.id === id ? { ...r, ms: end - r.startedAt, isError: Boolean(ran.isError) } : r,
188        )
189        const backToThinking = active === 0 && !inSubagent
190        return {
191          ...s,
192          active,
193          recent,
194          ...(backToThinking
195            ? { prevPhase: s.phase, phase: 'thinking' as const, detail: 'weighing the results', since: end }
196            : {}),
197        }
198      })
199      showStatus($, finished)
200    } catch {
201      // bookkeeping only
202    }
203    return ran
204  })
205
206  on('turn.complete', async ($, e, next) => {
207    const result = await next(e)
208    const now = await $.clock.now()
209    const phase: BrainPhase = e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'idle' : 'error'
210    const detail =
211      e.reason === 'answer'
212        ? 'answer delivered'
213        : e.reason === 'aborted'
214          ? ''
215          : e.reason === 'refusal'
216            ? 'refused'
217            : 'the turn errored'
218    await update(
219      $,
220      brain,
221      (s): BrainState => ({ ...s, prevPhase: s.phase, phase, detail, since: now, active: 0 }),
222    )
223    $.ui.status(undefined)
224    settle?.cancel()
225    settle = $.clock.after(SETTLE_MS, () => {
226      settle = null
227      stopTicker()
228      void update(
229        $,
230        brain,
231        (s): BrainState => ({ ...s, prevPhase: s.phase, phase: 'idle', detail: '', turnStartedAt: null }),
232      )
233    })
234    return result
235  })
236
237  // What the 3D view posts back: a click on a tool node, or the space bar.
238  on('ui.message', async ($, e, next) => {
239    if (e.requestId !== PANE || e.element !== CLIENT) return next(e)
240    const data = e.data as Brain3dMessage | null
241    if (data && typeof data === 'object' && data.kind === 'node') {
242      const state = await read($, brain)
243      const node = state.recent.find(r => r.id === data.id)
244      if (node) {
245        const took = node.ms === undefined ? 'still running' : `took ${ago(node.ms)}`
246        $.ui.toast(`${node.tool} · ${node.label} · ${took}${node.isError ? ' · errored' : ''}`, {
247          timeoutMs: 5000,
248        })
249      }
250    } else if (data && typeof data === 'object' && data.kind === 'toggle-pause') {
251      await update($, ui, (p): BrainUi => ({ ...p, paused: !p.paused }))
252    }
253    return {}
254  })
255
256  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
257    const state = await read($, brain)
258    const prefs = await read($, ui)
259    const now = await $.clock.now()
260    const cols = Math.max(20, e.props.bodyColumns)
261    const style = PHASES[state.phase]
262    const recent = state.recent.slice(-4).reverse()
263    const { Box, Text, Button } = $.ui.resolve(e)
264    const hasHud = e.surface !== 'terminal'
265    const view: BrainUi['view'] = prefs.view
266
267    const setView = (next: BrainUi['view']) => () =>
268      void update($, ui, (p): BrainUi => ({ ...p, view: next }))
269    const togglePause = () => void update($, ui, (p): BrainUi => ({ ...p, paused: !p.paused }))
270    const cycleSpeed = () =>
271      void update(
272        $,
273        ui,
274        (p): BrainUi => ({ ...p, speed: p.speed === 3 ? 1 : ((p.speed + 1) as BrainUi['speed']) }),
275      )
276    const clearHistory = () => void update($, brain, (s): BrainState => ({ ...s, recent: [], toolCount: 0 }))
277
278    const controls = (
279      <Box gap={1} flexWrap="wrap">
280        <Button key="view-3d" hotkey="3" plain dimColor={view !== '3d'} onPress={setView('3d')}>
281          3D
282        </Button>
283        <Button key="view-hud" hotkey="h" plain dimColor={view !== 'hud'} onPress={setView('hud')}>
284          HUD
285        </Button>
286        <Button key="pause" hotkey="p" plain onPress={togglePause}>
287          {prefs.paused ? '▶ play' : '⏸ pause'}
288        </Button>
289        <Button key="speed" hotkey="s" plain onPress={cycleSpeed}>
290          speed <Text dimColor>{SPEED_LABEL[prefs.speed]}</Text>
291        </Button>
292        <Button key="clear" hotkey="c" plain dimColor onPress={clearHistory}>
293          clear
294        </Button>
295      </Box>
296    )
297
298    const recentList =
299      recent.length > 0 ? (
300        <Box flexDirection="column" marginTop={1}>
301          <Text dimColor>recent · hover a ◉ node for details, click to pin a note</Text>
302          {recent.map(r => (
303            <Text wrap="truncate-end">
304              <Text color={r.isError ? 'error' : PHASES[r.phase].color}>
305                {r.ms === undefined ? '⋯' : '✓'}
306              </Text>{' '}
307              <Text color={PHASES[r.phase].color} bold>
308                {r.tool}
309              </Text>{' '}
310              <Text dimColor>{clip(r.label, Math.max(8, cols - r.tool.length - 10))}</Text>{' '}
311              <Text dimColor>{r.ms === undefined ? '' : ago(r.ms)}</Text>
312            </Text>
313          ))}
314        </Box>
315      ) : null
316
317    // ---- the terminal's interactive 3D sphere: a Client drawn in cells ----
318    // (The desktop app draws Svg but no Client today, so there the sphere is vector, below.)
319    if (view === '3d' && e.surface === 'terminal') {
320      const { Client } = $.ui.resolve(e)
321      const props: Brain3dProps = {
322        phase: state.phase,
323        color: style.color,
324        dim: style.dim,
325        label: style.label,
326        detail: state.detail,
327        tempo: style.tempo,
328        paused: prefs.paused,
329        speed: prefs.speed,
330        nodes: state.recent.map(r => ({
331          id: r.id,
332          tool: r.tool,
333          label: r.label,
334          phase: r.phase,
335          ...(r.ms === undefined ? {} : { ms: r.ms }),
336          ...(r.isError ? { isError: true } : {}),
337        })),
338        elapsedMs: state.turnStartedAt && state.phase !== 'idle' ? Math.max(0, now - state.turnStartedAt) : 0,
339        tools: state.toolCount,
340      }
341      const room = e.props.scroll?.bodyRows ?? 0
342      const height =
343        room > 0 ? Math.max(10, Math.min(24, room - 3 - (recent.length ? recent.length + 2 : 0))) : 16
344      return (
345        <Box flexDirection="column" paddingX={1}>
346          <Client key={CLIENT} module="./brain3d.tsx" props={props} width="100%" height={height} />
347          {controls}
348          {recentList}
349        </Box>
350      )
351    }
352
353    // ---- the vector views (desktop, editor, mobile): the 3D sphere or the HUD rings ----
354    if (hasHud) {
355      const { Svg } = $.ui.resolve(e)
356      const width = Math.min(440, Math.max(240, Math.round(cols * 7.2)))
357      const height = Math.round((width * 230) / 320)
358      const draw = view === '3d' ? brainSphereSvg : brainSvg
359      return (
360        <Box flexDirection="column" paddingX={1}>
361          <Box justifyContent="center">
362            <Svg
363              source={draw(state, now, { paused: prefs.paused, speed: prefs.speed })}
364              alt={`Claude is ${style.label}${state.detail ? `: ${state.detail}` : ''}`}
365              width={width}
366              height={height}
367              isInteractive
368            />
369          </Box>
370          {controls}
371          {recentList}
372          {state.phase === 'idle' && recent.length === 0 && (
373            <Text dimColor>Standing by. The brain lights up when a turn starts.</Text>
374          )}
375        </Box>
376      )
377    }
378
379    // ---- the terminal's text HUD ----
380    const t = await read($, tick)
381    const orbRows = orb(t, state.phase, cols)
382    const line = overlayLine(t, state.phase, cols)
383    const elapsed = elapsedText(state, now)
384    return (
385      <Box flexDirection="column" paddingX={1}>
386        <Box justifyContent="space-between">
387          <Text color={style.color} bold>
388            {style.label.toUpperCase()}
389          </Text>
390          <Text dimColor>{elapsed}</Text>
391        </Box>
392        <Text dimColor>{clip(state.detail, cols - 2) || ' '}</Text>
393        <Box flexDirection="column" alignItems="center" marginY={1}>
394          {orbRows.map(r => (
395            <Text color={style.color}>{r.text}</Text>
396          ))}
397          <Text color={style.color} bold>
398            {line.text}
399          </Text>
400        </Box>
401        {controls}
402        {recentList}
403      </Box>
404    )
405  })
406}
407
hooks/phases.ts 95 lines
1import type { BrainPhase } from '../types'
2
3/** Colour, motion tempo and label per phase. */
4export type PhaseStyle = {
5  color: string
6  dim: string
7  label: string
8  /** seconds per rotation of the outer ring; smaller is faster */
9  tempo: number
10}
11
12export const PHASES: Record<BrainPhase, PhaseStyle> = {
13  idle: { color: '#7c8aa5', dim: '#3b4453', label: 'standing by', tempo: 48 },
14  thinking: { color: '#a78bfa', dim: '#4c3d82', label: 'thinking', tempo: 9 },
15  responding: { color: '#22d3ee', dim: '#155e6b', label: 'responding', tempo: 14 },
16  reading: { color: '#38bdf8', dim: '#1a5f80', label: 'reading', tempo: 12 },
17  editing: { color: '#fbbf24', dim: '#7a5a12', label: 'editing', tempo: 7 },
18  running: { color: '#fb923c', dim: '#7d4418', label: 'running', tempo: 4 },
19  searching: { color: '#4ade80', dim: '#1f6b3b', label: 'searching', tempo: 5 },
20  web: { color: '#2dd4bf', dim: '#166a60', label: 'fetching', tempo: 10 },
21  delegating: { color: '#f472b6', dim: '#7a3a5c', label: 'delegating', tempo: 11 },
22  connecting: { color: '#818cf8', dim: '#3e4480', label: 'connecting', tempo: 8 },
23  working: { color: '#60a5fa', dim: '#2b4f80', label: 'working', tempo: 8 },
24  done: { color: '#34d399', dim: '#1a6b4d', label: 'done', tempo: 30 },
25  error: { color: '#f87171', dim: '#7a2e2e', label: 'error', tempo: 20 },
26}
27
28/** Map a tool call to the phase it belongs to and a short label for it. */
29export function classify(tool: string, input: Record<string, unknown>): { phase: BrainPhase; label: string } {
30  const str = (k: string, max = 60) => {
31    const v = input[k]
32    if (typeof v !== 'string' || v.length === 0) return ''
33    const one = v.replace(/\s+/g, ' ').trim()
34    return one.length > max ? one.slice(0, max - 1) + '…' : one
35  }
36  const BACKSLASH = String.fromCharCode(92)
37  const base = (p: string) =>
38    p
39      .split('/')
40      .flatMap(part => part.split(BACKSLASH))
41      .filter(Boolean)
42      .slice(-2)
43      .join('/')
44
45  switch (tool) {
46    case 'Read':
47    case 'NotebookRead':
48      return { phase: 'reading', label: base(str('file_path', 200)) || 'a file' }
49    case 'Edit':
50    case 'MultiEdit':
51    case 'Write':
52    case 'NotebookEdit':
53      return { phase: 'editing', label: base(str('file_path', 200) || str('notebook_path', 200)) || 'a file' }
54    case 'Bash':
55    case 'PowerShell':
56      return { phase: 'running', label: str('description') || str('command') || 'a command' }
57    case 'Grep':
58      return { phase: 'searching', label: str('pattern') ? `/${str('pattern', 40)}/` : 'files' }
59    case 'Glob':
60      return { phase: 'searching', label: str('pattern') || 'files' }
61    case 'WebSearch':
62      return { phase: 'searching', label: str('query') || 'the web' }
63    case 'WebFetch':
64      return { phase: 'web', label: str('url', 70).replace(/^https?:\/\//, '') || 'a page' }
65    case 'Agent':
66    case 'Workflow':
67      return { phase: 'delegating', label: str('description') || str('subagent_type') || 'a subagent' }
68    case 'Skill':
69      return { phase: 'working', label: `skill ${str('skill')}`.trim() }
70    case 'ToolSearch':
71      return { phase: 'searching', label: str('query') || 'tools' }
72    default:
73      if (tool.startsWith('mcp__')) {
74        const parts = tool.split('__')
75        const name = parts[parts.length - 1] ?? tool
76        return { phase: 'connecting', label: name.replace(/_/g, ' ') }
77      }
78      return { phase: 'working', label: tool }
79  }
80}
81
82export function clip(text: string, max: number): string {
83  if (max <= 1) return ''
84  const one = text.replace(/\s+/g, ' ').trim()
85  return one.length > max ? one.slice(0, max - 1) + '…' : one
86}
87
88export function ago(ms: number): string {
89  if (ms < 1000) return `${ms}ms`
90  const s = Math.round(ms / 1000)
91  if (s < 60) return `${s}s`
92  const m = Math.floor(s / 60)
93  return `${m}m ${s - m * 60}s`
94}
95
hooks/brain-svg.ts 466 lines
1import type { BrainPhase, BrainState } from '../types'
2import { PHASES, clip } from './phases'
3
4const W = 320
5const H = 230
6const CX = 160
7const CY = 104
8
9function esc(s: string): string {
10  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
11}
12
13function polar(r: number, deg: number): [number, number] {
14  const a = ((deg - 90) * Math.PI) / 180
15  return [CX + r * Math.cos(a), CY + r * Math.sin(a)]
16}
17
18function arc(r: number, from: number, to: number): string {
19  const [x1, y1] = polar(r, from)
20  const [x2, y2] = polar(r, to)
21  const large = to - from > 180 ? 1 : 0
22  return `M${x1.toFixed(1)} ${y1.toFixed(1)}A${r} ${r} 0 ${large} 1 ${x2.toFixed(1)} ${y2.toFixed(1)}`
23}
24
25function spin(seconds: number, reverse = false): string {
26  const from = reverse ? 360 : 0
27  const to = reverse ? 0 : 360
28  return `<animateTransform attributeName="transform" type="rotate" from="${from} ${CX} ${CY}" to="${to} ${CX} ${CY}" dur="${seconds}s" repeatCount="indefinite"/>`
29}
30
31/** Three tilted orbits that tumble at different rates: the gyroscope that gives the HUD its depth. */
32function gyro(color: string, dim: string, tempo: number): string {
33  return [0, 60, 120]
34    .map((tilt, i) => {
35      const dur = (tempo * (1.3 + i * 0.5)).toFixed(1)
36      const tumble = (tempo * (0.9 + i * 0.35)).toFixed(1)
37      return `<g transform="rotate(${tilt} ${CX} ${CY})">
38  <animateTransform attributeName="transform" type="rotate" from="${tilt} ${CX} ${CY}" to="${tilt + (i % 2 ? -360 : 360)} ${CX} ${CY}" dur="${dur}s" repeatCount="indefinite"/>
39  <ellipse cx="${CX}" cy="${CY}" rx="94" ry="30" fill="none" stroke="${i === 0 ? color : dim}" stroke-width="${i === 0 ? 1.1 : 0.8}" opacity="${i === 0 ? 0.7 : 0.55}" stroke-dasharray="${i === 2 ? '3 4' : 'none'}">
40    <animate attributeName="ry" values="30;94;30" dur="${tumble}s" repeatCount="indefinite" calcMode="spline" keySplines="0.45 0 0.55 1;0.45 0 0.55 1"/>
41    <set attributeName="stroke" to="#ffffff" begin="mouseover" end="mouseout"/>
42  </ellipse>
43  <circle r="3" fill="${color}">
44    <animateMotion dur="${(tempo * 0.8 + i).toFixed(1)}s" repeatCount="indefinite" path="M${CX + 94} ${CY} A94 30 0 1 1 ${CX - 94} ${CY} A94 30 0 1 1 ${CX + 94} ${CY}"/>
45    <animate attributeName="r" values="1.5;3.5;1.5" dur="${(tempo * 0.8 + i).toFixed(1)}s" repeatCount="indefinite"/>
46    <animate attributeName="opacity" values="0.3;1;0.3" dur="${(tempo * 0.8 + i).toFixed(1)}s" repeatCount="indefinite"/>
47  </circle>
48</g>`
49    })
50    .join('')
51}
52
53/** The HUD rings every phase shares. */
54function rings(color: string, dim: string, tempo: number, prev: string): string {
55  const ticks: string[] = []
56  for (let i = 0; i < 36; i++) {
57    const long = i % 3 === 0
58    const [x1, y1] = polar(long ? 56 : 59, i * 10)
59    const [x2, y2] = polar(63, i * 10)
60    ticks.push(
61      `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="${dim}" stroke-width="${long ? 1.4 : 0.8}"/>`,
62    )
63  }
64  const morph = `<animate attributeName="stroke" from="${prev}" to="${color}" dur="0.7s" fill="freeze"/>`
65  return `
66<g opacity="0.9">
67  ${gyro(color, dim, tempo)}
68  <g>${spin(tempo)}
69    <path d="${arc(88, 0, 110)}" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round">${morph}</path>
70    <path d="${arc(88, 150, 200)}" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round" opacity="0.6">${morph}</path>
71    <path d="${arc(88, 240, 330)}" fill="none" stroke="${color}" stroke-width="1.2" stroke-linecap="round" opacity="0.5">${morph}</path>
72  </g>
73  <g>${spin(tempo * 1.7, true)}
74    <path d="${arc(74, 20, 160)}" fill="none" stroke="${color}" stroke-width="1.2" opacity="0.55">${morph}</path>
75    <path d="${arc(74, 200, 300)}" fill="none" stroke="${color}" stroke-width="4" opacity="0.35" stroke-linecap="round">${morph}</path>
76  </g>
77  <g>${spin(tempo * 3)}${ticks.join('')}</g>
78</g>`
79}
80
81/** The neural core: a pulsing centre with nodes that fire. */
82function core(color: string, dim: string, phase: BrainPhase, prev: string, titles: string[]): string {
83  const nodes: string[] = []
84  const links: string[] = []
85  const fast = phase === 'thinking' || phase === 'running' || phase === 'editing'
86  const n = 8
87  for (let i = 0; i < n; i++) {
88    const [x, y] = polar(42, i * (360 / n) + 22)
89    const [nx, ny] = polar(42, ((i + 1) % n) * (360 / n) + 22)
90    const begin = ((i * 0.37) % 2).toFixed(2)
91    const dur = fast ? 1.1 : 2.6
92    const travel = (dur * 1.3).toFixed(2)
93    links.push(
94      `<line x1="${CX}" y1="${CY}" x2="${x.toFixed(1)}" y2="${y.toFixed(1)}" stroke="${dim}" stroke-width="0.8"/>`,
95    )
96    links.push(`<line x1="${x.toFixed(1)}" y1="${y.toFixed(1)}" x2="${nx.toFixed(1)}" y2="${ny.toFixed(1)}" stroke="${color}" stroke-width="0.9" opacity="0.15">
97      <animate attributeName="opacity" values="0.1;0.7;0.1" dur="${(dur * 1.4).toFixed(2)}s" begin="${begin}s" repeatCount="indefinite"/></line>`)
98    const title = titles[i]
99    nodes.push(`<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${title ? 3.4 : 2.6}" fill="${color}" stroke="${title ? '#ffffff' : 'none'}" stroke-width="0.8">${title ? `<title>${esc(title)}</title>` : ''}
100      <set attributeName="r" to="7" begin="mouseover" end="mouseout"/>
101      <set attributeName="fill" to="#ffffff" begin="mouseover" end="mouseout"/>
102      <animate attributeName="r" values="2.2;4.2;2.2" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/>
103      <animate attributeName="opacity" values="0.5;1;0.5" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/></circle>`)
104    nodes.push(`<circle r="1.4" fill="#ffffff" opacity="0.9">
105      <animateMotion dur="${travel}s" begin="${begin}s" repeatCount="indefinite" path="M${x.toFixed(1)} ${y.toFixed(1)} L${CX} ${CY}"/>
106      <animate attributeName="opacity" values="0;1;0" dur="${travel}s" begin="${begin}s" repeatCount="indefinite"/></circle>`)
107  }
108  const breath = phase === 'idle' ? 4.5 : fast ? 1.2 : 2.2
109  return `
110<g>
111  ${links.join('')}
112  <circle cx="${CX}" cy="${CY}" r="34" fill="url(#glow)" opacity="0.8">
113    <animate attributeName="r" values="30;38;30" dur="${breath}s" repeatCount="indefinite"/>
114  </circle>
115  <circle cx="${CX}" cy="${CY}" r="16" fill="url(#coreGrad)" stroke="${color}" stroke-width="1.5">
116    <animate attributeName="stroke" from="${prev}" to="${color}" dur="0.7s" fill="freeze"/>
117    <animate attributeName="r" values="15;18;15" dur="${breath}s" repeatCount="indefinite"/>
118  </circle>
119  <circle cx="${CX}" cy="${CY}" r="6" fill="#ffffff" opacity="0.9">
120    <animate attributeName="opacity" values="0.6;1;0.6" dur="${breath}s" repeatCount="indefinite"/>
121  </circle>
122  ${nodes.join('')}
123</g>`
124}
125
126function linesOfText(color: string, dim: string): string {
127  const rows: string[] = []
128  for (let i = 0; i < 6; i++) {
129    const w = 22 + ((i * 13) % 22)
130    rows.push(`<rect x="${CX + 76}" y="${CY - 40 + i * 12}" width="${w}" height="3" rx="1.5" fill="${i % 2 ? dim : color}" opacity="0.6">
131  <animate attributeName="opacity" values="0.2;0.8;0.2" dur="1.8s" begin="${(i * 0.3).toFixed(1)}s" repeatCount="indefinite"/>
132</rect>`)
133  }
134  return rows.join('')
135}
136
137/** What each phase adds on top of the brain. */
138function overlay(phase: BrainPhase, color: string, dim: string): string {
139  switch (phase) {
140    case 'reading':
141      return `
142<g clip-path="url(#disc)">
143  <rect x="${CX - 70}" y="${CY - 70}" width="140" height="10" fill="${color}" opacity="0.35">
144    <animate attributeName="y" from="${CY - 72}" to="${CY + 66}" dur="1.8s" repeatCount="indefinite"/>
145  </rect>
146  <line x1="${CX - 70}" x2="${CX + 70}" y1="${CY - 62}" y2="${CY - 62}" stroke="${color}" stroke-width="1.2" opacity="0.9">
147    <animate attributeName="y1" from="${CY - 72}" to="${CY + 66}" dur="1.8s" repeatCount="indefinite"/>
148    <animate attributeName="y2" from="${CY - 72}" to="${CY + 66}" dur="1.8s" repeatCount="indefinite"/>
149  </line>
150</g>
151${linesOfText(color, dim)}`
152
153    case 'editing':
154      return `
155<g transform="translate(${CX - 44} ${CY + 60})">
156  <path d="M0 0 Q 12 -10 24 0 T 48 0 T 72 0 T 88 0" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-dasharray="120" stroke-dashoffset="120">
157    <animate attributeName="stroke-dashoffset" from="120" to="0" dur="1.6s" repeatCount="indefinite"/>
158  </path>
159  <rect x="86" y="-8" width="2" height="12" fill="${color}">
160    <animate attributeName="opacity" values="1;0;1" dur="0.9s" repeatCount="indefinite"/>
161  </rect>
162</g>`
163
164    case 'running':
165      return [0, 0.7, 1.4]
166        .map(
167          b => `<circle cx="${CX}" cy="${CY}" r="30" fill="none" stroke="${color}" stroke-width="1.5" opacity="0">
168  <animate attributeName="r" from="28" to="96" dur="2.1s" begin="${b}s" repeatCount="indefinite"/>
169  <animate attributeName="opacity" values="0.8;0" dur="2.1s" begin="${b}s" repeatCount="indefinite"/>
170</circle>`,
171        )
172        .join('')
173
174    case 'searching': {
175      const [x, y] = polar(96, 0)
176      const [ex, ey] = polar(96, 40)
177      const blips = (
178        [
179          [62, 35],
180          [40, 150],
181          [75, 250],
182          [55, 310],
183        ] as const
184      )
185        .map(([r, a]) => {
186          const [bx, by] = polar(r, a)
187          const begin = ((a / 360) * 2.4).toFixed(2)
188          return `<circle cx="${bx.toFixed(1)}" cy="${by.toFixed(1)}" r="2.5" fill="${color}" opacity="0">
189  <animate attributeName="opacity" values="0;1;0" dur="2.4s" begin="${begin}s" repeatCount="indefinite"/>
190  <animate attributeName="r" values="1;4;1" dur="2.4s" begin="${begin}s" repeatCount="indefinite"/>
191</circle>`
192        })
193        .join('')
194      return `
195<g>${spin(2.4)}
196  <path d="M${CX} ${CY} L${x.toFixed(1)} ${y.toFixed(1)} A96 96 0 0 1 ${ex.toFixed(1)} ${ey.toFixed(1)} Z" fill="url(#sweep)" opacity="0.55"/>
197  <line x1="${CX}" y1="${CY}" x2="${x.toFixed(1)}" y2="${y.toFixed(1)}" stroke="${color}" stroke-width="1.5" opacity="0.9"/>
198</g>${blips}`
199    }
200
201    case 'web': {
202      const lat = [-50, -25, 0, 25, 50]
203        .map(
204          d =>
205            `<ellipse cx="${CX}" cy="${CY + d}" rx="${Math.sqrt(96 * 96 - d * d).toFixed(1)}" ry="${(Math.abs(d) / 6).toFixed(1)}" fill="none" stroke="${dim}" stroke-width="0.8"/>`,
206        )
207        .join('')
208      const lon = [0, 1, 2]
209        .map(
210          i => `<ellipse cx="${CX}" cy="${CY}" rx="96" ry="96" fill="none" stroke="${color}" stroke-width="0.9" opacity="0.5">
211  <animate attributeName="rx" values="96;4;96" dur="4.5s" begin="${(i * 1.5).toFixed(1)}s" repeatCount="indefinite"/>
212</ellipse>`,
213        )
214        .join('')
215      return `<g opacity="0.8">${lat}${lon}</g>`
216    }
217
218    case 'delegating':
219      return [0, 120, 240]
220        .map(
221          a => `<g>${spin(6)}
222  <g transform="rotate(${a} ${CX} ${CY})">
223    <line x1="${CX}" y1="${CY}" x2="${CX}" y2="${CY - 100}" stroke="${dim}" stroke-width="0.8" stroke-dasharray="3 3"/>
224    <circle cx="${CX}" cy="${CY - 100}" r="6" fill="none" stroke="${color}" stroke-width="1.5">
225      <animate attributeName="r" values="5;8;5" dur="1.6s" repeatCount="indefinite"/>
226    </circle>
227    <circle cx="${CX}" cy="${CY - 100}" r="2" fill="${color}"/>
228  </g>
229</g>`,
230        )
231        .join('')
232
233    case 'responding': {
234      const bars: string[] = []
235      for (let i = 0; i < 17; i++) {
236        const x = CX - 64 + i * 8
237        const h = 4 + ((i * 7) % 11)
238        const dur = (0.6 + (i % 4) * 0.15).toFixed(2)
239        bars.push(`<rect x="${x}" y="${CY + 58}" width="4" height="${h}" rx="1.5" fill="${color}" opacity="0.8">
240  <animate attributeName="height" values="${h};${h + 12};${h}" dur="${dur}s" repeatCount="indefinite"/>
241  <animate attributeName="y" values="${CY + 58};${CY + 52};${CY + 58}" dur="${dur}s" repeatCount="indefinite"/>
242</rect>`)
243      }
244      return bars.join('')
245    }
246
247    case 'done':
248      return `
249<path d="M${CX - 14} ${CY + 1} L${CX - 4} ${CY + 11} L${CX + 16} ${CY - 11}" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="60" stroke-dashoffset="60">
250  <animate attributeName="stroke-dashoffset" from="60" to="0" dur="0.6s" begin="0.2s" fill="freeze"/>
251</path>
252<circle cx="${CX}" cy="${CY}" r="20" fill="none" stroke="${color}" stroke-width="2" opacity="0">
253  <animate attributeName="r" from="20" to="100" dur="1.2s" begin="0.1s" fill="freeze"/>
254  <animate attributeName="opacity" values="0.9;0" dur="1.2s" begin="0.1s" fill="freeze"/>
255</circle>`
256
257    case 'error':
258      return `
259<path d="M${CX - 11} ${CY - 11} L${CX + 11} ${CY + 11} M${CX + 11} ${CY - 11} L${CX - 11} ${CY + 11}" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round"/>
260<circle cx="${CX}" cy="${CY}" r="60" fill="none" stroke="${color}" stroke-width="2" opacity="0.6">
261  <animate attributeName="opacity" values="0.6;0.1;0.6" dur="1s" repeatCount="indefinite"/>
262</circle>`
263
264    case 'thinking':
265      return [0, 1, 2, 3, 4, 5]
266        .map(i => {
267          const a = i * 60 + 10
268          const [x1, y1] = polar(42, a)
269          const [x2, y2] = polar(42, a + 120)
270          const b1 = (i * 0.45).toFixed(2)
271          const b2 = (i * 0.45 + 2.7).toFixed(2)
272          return `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="${color}" stroke-width="1" opacity="0">
273  <animate attributeName="opacity" values="0;0.9;0" dur="0.5s" begin="${b1}s;${b2}s" repeatCount="indefinite"/>
274</line>`
275        })
276        .join('')
277
278    default:
279      return ''
280  }
281}
282
283/** Build the whole brain as one SVG document for the given state. */
284export type BrainSvgOptions = { paused?: boolean; speed?: 1 | 2 | 3 }
285
286export function brainSvg(state: BrainState, now: number, options: BrainSvgOptions = {}): string {
287  const style = PHASES[state.phase]
288  const prevStyle = PHASES[state.prevPhase] ?? style
289  const { color, dim } = style
290  const tempo = style.tempo * (options.speed === 1 ? 1.8 : options.speed === 3 ? 0.5 : 1)
291  const titles = state.recent
292    .slice(-8)
293    .map(r => `${r.tool} · ${r.label}${r.ms === undefined ? '' : ` · ${Math.round(r.ms)}ms`}`)
294  const label = style.label.toUpperCase()
295  const detail = clip(state.detail, 44)
296  const elapsed = state.turnStartedAt && state.phase !== 'idle' ? Math.max(0, now - state.turnStartedAt) : 0
297  const secs = Math.round(elapsed / 1000)
298  const elapsedText =
299    state.phase === 'idle' ? '' : secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`
300  const tools = state.toolCount ? `${state.toolCount} tool${state.toolCount === 1 ? '' : 's'}` : ''
301  // the verdict marks sit on the core; every other overlay sits beneath it
302  const onTop = state.phase === 'done' || state.phase === 'error'
303  const layer = overlay(state.phase, color, dim)
304  const dx = (W * 0.05).toFixed(1)
305  const dy = (H * 0.05).toFixed(1)
306
307  const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">
308<defs>
309  <radialGradient id="glow"><stop offset="0%" stop-color="${color}" stop-opacity="0.55"/><stop offset="100%" stop-color="${color}" stop-opacity="0"/></radialGradient>
310  <radialGradient id="coreGrad"><stop offset="0%" stop-color="#ffffff" stop-opacity="0.9"/><stop offset="55%" stop-color="${color}" stop-opacity="0.9"/><stop offset="100%" stop-color="${dim}" stop-opacity="1"/></radialGradient>
311  <linearGradient id="sweep" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="${color}" stop-opacity="0"/><stop offset="100%" stop-color="${color}" stop-opacity="0.7"/></linearGradient>
312  <clipPath id="disc"><circle cx="${CX}" cy="${CY}" r="70"/></clipPath>
313</defs>
314<g>
315  <animateTransform attributeName="transform" type="scale" from="0.9" to="1" dur="0.5s" fill="freeze" additive="sum" calcMode="spline" keySplines="0.2 0.8 0.2 1"/>
316  <animateTransform attributeName="transform" type="translate" from="${dx} ${dy}" to="0 0" dur="0.5s" fill="freeze" additive="sum" calcMode="spline" keySplines="0.2 0.8 0.2 1"/>
317  <animate attributeName="opacity" from="0" to="1" dur="0.45s" fill="freeze"/>
318  ${rings(color, dim, tempo, prevStyle.color)}
319  ${onTop ? '' : layer}
320  ${core(color, dim, state.phase, prevStyle.color, titles)}
321  ${onTop ? layer : ''}
322  <text x="${CX}" y="${H - 22}" text-anchor="middle" font-size="11" letter-spacing="3" fill="${color}" font-weight="700">${esc(label)}<animate attributeName="fill" from="${prevStyle.color}" to="${color}" dur="0.7s" fill="freeze"/></text>
323  <text x="${CX}" y="${H - 8}" text-anchor="middle" font-size="9" fill="${dim}" opacity="0.95">${esc(detail)}</text>
324  <text x="${W - 8}" y="14" text-anchor="end" font-size="9" fill="${dim}">${esc(elapsedText)}</text>
325  <text x="8" y="14" font-size="9" fill="${dim}">${esc(tools)}</text>
326</g>
327</svg>`
328  // Paused: every animation is dropped, so the frame stands still (hover reactions stay).
329  return options.paused ? svg.replace(/<animate(?:Transform|Motion)?\s[^>]*\/>/g, '') : svg
330}
331
332// ---------------------------------------------------------------------------
333// The 3D sphere as SVG: for surfaces that draw vector graphics but no Client.
334//
335// A sphere turning about its vertical axis moves every point along an ellipse
336// on screen, so each point is one SMIL motion along its own ellipse, phased by
337// its longitude, with its radius and opacity swelling as it comes to the front.
338// ---------------------------------------------------------------------------
339
340const SPHERE_R = 78
341const TILT = 0.42 // how far the camera looks down on the sphere: ry = rx * TILT
342const NODE_SEATS = 8
343
344/** `n` points spread evenly over the unit sphere: [x, y, z] with y up. */
345function fibonacci(n: number): Array<[number, number, number]> {
346  const out: Array<[number, number, number]> = []
347  const golden = Math.PI * (3 - Math.sqrt(5))
348  for (let i = 0; i < n; i++) {
349    const y = 1 - (i / (n - 1)) * 2
350    const r = Math.sqrt(Math.max(0, 1 - y * y))
351    const a = golden * i
352    out.push([Math.cos(a) * r, y, Math.sin(a) * r])
353  }
354  return out
355}
356
357/** One point of the sphere: a circle riding its latitude's ellipse, swelling at the front. */
358function orbiter(
359  p: [number, number, number],
360  dur: number,
361  fill: string,
362  size: [number, number],
363  opacity: [number, number],
364  extra = '',
365): string {
366  const [x, y, z] = p
367  const rx = Math.max(0.5, SPHERE_R * Math.sqrt(x * x + z * z))
368  const ry = rx * TILT
369  const cy = CY - SPHERE_R * y * 0.92
370  // longitude phases the motion; the path starts at the back of the ring
371  const phase = (Math.atan2(z, x) + Math.PI) / (Math.PI * 2)
372  const begin = (-phase * dur).toFixed(2)
373  const top = (cy - ry).toFixed(1)
374  const bottom = (cy + ry).toFixed(1)
375  const path = `M${CX} ${top} A${rx.toFixed(1)} ${ry.toFixed(1)} 0 1 1 ${CX} ${bottom} A${rx.toFixed(1)} ${ry.toFixed(1)} 0 1 1 ${CX} ${top}`
376  return `<circle r="${size[0]}" fill="${fill}" opacity="${opacity[0]}">${extra}
377  <animateMotion dur="${dur}s" begin="${begin}s" repeatCount="indefinite" path="${path}"/>
378  <animate attributeName="r" values="${size[0]};${size[1]};${size[0]}" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/>
379  <animate attributeName="opacity" values="${opacity[0]};${opacity[1]};${opacity[0]}" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/>
380</circle>`
381}
382
383/** The sphere's points, its recent-tool nodes, and the tracer rings. */
384function sphere(color: string, dim: string, tempo: number, phase: BrainPhase, titles: string[]): string {
385  const turn = Math.max(4, tempo * 0.9)
386  const shell = fibonacci(96)
387    .map(p => orbiter(p, turn, color, [0.9, 2.4], [0.18, 0.95]))
388    .join('')
389  // a few latitude rings, drawn as thin ellipses, give the volume its edges
390  const bands = [-0.72, -0.36, 0, 0.36, 0.72]
391    .map(y => {
392      const rx = SPHERE_R * Math.sqrt(1 - y * y)
393      return `<ellipse cx="${CX}" cy="${(CY - SPHERE_R * y * 0.92).toFixed(1)}" rx="${rx.toFixed(1)}" ry="${(rx * TILT).toFixed(1)}" fill="none" stroke="${dim}" stroke-width="0.6" opacity="0.55"/>`
394    })
395    .join('')
396  // the recent tool calls ride the shell as larger ringed nodes with tooltips and hover flare
397  const nodes = fibonacci(NODE_SEATS)
398    .map(([x, y, z], i) => {
399      const title = titles[i]
400      if (!title) return ''
401      const extra = `<title>${esc(title)}</title><set attributeName="r" to="7" begin="mouseover" end="mouseout"/><set attributeName="fill" to="#ffffff" begin="mouseover" end="mouseout"/>`
402      return orbiter([x * 1.06, y * 1.06, z * 1.06], turn, color, [2.6, 4.4], [0.45, 1], extra)
403    })
404    .join('')
405  const spark =
406    phase === 'thinking' || phase === 'running'
407      ? fibonacci(14)
408          .map(p => orbiter(p, Math.max(2.5, turn * 0.35), '#ffffff', [0.6, 1.8], [0, 0.9]))
409          .join('')
410      : ''
411  return `${bands}${shell}${nodes}${spark}`
412}
413
414/** The 3D sphere view as one SVG document: the sphere, the core, the gyro and the phase overlay. */
415export function brainSphereSvg(state: BrainState, now: number, options: BrainSvgOptions = {}): string {
416  const style = PHASES[state.phase]
417  const prevStyle = PHASES[state.prevPhase] ?? style
418  const { color, dim } = style
419  const tempo = style.tempo * (options.speed === 1 ? 1.8 : options.speed === 3 ? 0.5 : 1)
420  const titles = state.recent
421    .slice(-NODE_SEATS)
422    .map(r => `${r.tool} · ${r.label}${r.ms === undefined ? '' : ` · ${Math.round(r.ms)}ms`}`)
423  const label = style.label.toUpperCase()
424  const detail = clip(state.detail, 44)
425  const elapsed = state.turnStartedAt && state.phase !== 'idle' ? Math.max(0, now - state.turnStartedAt) : 0
426  const secs = Math.round(elapsed / 1000)
427  const elapsedText =
428    state.phase === 'idle' ? '' : secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`
429  const tools = state.toolCount ? `${state.toolCount} tool${state.toolCount === 1 ? '' : 's'}` : ''
430  const onTop = state.phase === 'done' || state.phase === 'error'
431  const layer = overlay(state.phase, color, dim)
432  const breath = state.phase === 'idle' ? 4.5 : 1.8
433  const dx = (W * 0.05).toFixed(1)
434  const dy = (H * 0.05).toFixed(1)
435
436  const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">
437<defs>
438  <radialGradient id="glow"><stop offset="0%" stop-color="${color}" stop-opacity="0.6"/><stop offset="100%" stop-color="${color}" stop-opacity="0"/></radialGradient>
439  <radialGradient id="coreGrad"><stop offset="0%" stop-color="#ffffff" stop-opacity="0.95"/><stop offset="55%" stop-color="${color}" stop-opacity="0.9"/><stop offset="100%" stop-color="${dim}" stop-opacity="1"/></radialGradient>
440  <linearGradient id="sweep" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="${color}" stop-opacity="0"/><stop offset="100%" stop-color="${color}" stop-opacity="0.7"/></linearGradient>
441  <clipPath id="disc"><circle cx="${CX}" cy="${CY}" r="70"/></clipPath>
442</defs>
443<g>
444  <animateTransform attributeName="transform" type="scale" from="0.9" to="1" dur="0.5s" fill="freeze" additive="sum" calcMode="spline" keySplines="0.2 0.8 0.2 1"/>
445  <animateTransform attributeName="transform" type="translate" from="${dx} ${dy}" to="0 0" dur="0.5s" fill="freeze" additive="sum" calcMode="spline" keySplines="0.2 0.8 0.2 1"/>
446  <animate attributeName="opacity" from="0" to="1" dur="0.45s" fill="freeze"/>
447  ${gyro(color, dim, tempo)}
448  ${onTop ? '' : layer}
449  <circle cx="${CX}" cy="${CY}" r="30" fill="url(#glow)" opacity="0.9">
450    <animate attributeName="r" values="26;36;26" dur="${breath}s" repeatCount="indefinite"/>
451  </circle>
452  <circle cx="${CX}" cy="${CY}" r="11" fill="url(#coreGrad)" stroke="${color}" stroke-width="1.2">
453    <animate attributeName="stroke" from="${prevStyle.color}" to="${color}" dur="0.7s" fill="freeze"/>
454    <animate attributeName="r" values="10;13;10" dur="${breath}s" repeatCount="indefinite"/>
455  </circle>
456  ${sphere(color, dim, tempo, state.phase, titles)}
457  ${onTop ? layer : ''}
458  <text x="${CX}" y="${H - 22}" text-anchor="middle" font-size="11" letter-spacing="3" fill="${color}" font-weight="700">${esc(label)}<animate attributeName="fill" from="${prevStyle.color}" to="${color}" dur="0.7s" fill="freeze"/></text>
459  <text x="${CX}" y="${H - 8}" text-anchor="middle" font-size="9" fill="${dim}" opacity="0.95">${esc(detail)}</text>
460  <text x="${W - 8}" y="14" text-anchor="end" font-size="9" fill="${dim}">${esc(elapsedText)}</text>
461  <text x="8" y="14" font-size="9" fill="${dim}">${esc(tools)}</text>
462</g>
463</svg>`
464  return options.paused ? svg.replace(/<animate(?:Transform|Motion)?\s[^>]*\/>/g, '') : svg
465}
466
hooks/brain-ascii.ts 109 lines
1import type { BrainPhase, BrainState } from '../types'
2import { PHASES } from './phases'
3
4/** One row of the terminal drawing: text and whether it is the bright colour. */
5export type AsciiRow = { text: string; bright?: boolean; dim?: boolean }
6
7const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
8
9/** A ring of dots around a boxed core, the bright dots walking with the tick. */
10export function orb(tick: number, phase: BrainPhase, width: number): AsciiRow[] {
11  if (width < 27) return [{ text: `${SPINNER[tick % SPINNER.length]} ${PHASES[phase].label}`, bright: true }]
12  const cols = 27
13  const rows = 9
14  const grid: string[][] = Array.from({ length: rows }, () => Array.from({ length: cols }, () => ' '))
15  const cx = 13
16  const cy = 4
17  const n = 16
18  const speed = phase === 'idle' ? 0.25 : phase === 'running' || phase === 'editing' ? 2 : 1
19  const head = Math.floor(tick * speed) % n
20  for (let i = 0; i < n; i++) {
21    const a = (i / n) * Math.PI * 2
22    const x = Math.round(cx + 12 * Math.cos(a))
23    const y = Math.round(cy + 4 * Math.sin(a))
24    const dist = (i - head + n) % n
25    const glyph = dist === 0 ? '●' : dist === 1 || dist === n - 1 ? '•' : '·'
26    const row = grid[y]
27    if (row) row[x] = glyph
28  }
29  // core box
30  const box = ['╭─────╮', '│     │', '╰─────╯']
31  box.forEach((line, r) => {
32    const row = grid[cy - 1 + r]
33    if (!row) return
34    for (let c = 0; c < line.length; c++) row[cx - 3 + c] = line[c] ?? ' '
35  })
36  // nodes inside the core: one fires per tick
37  const inner: Array<[number, number]> = [
38    [cx - 2, cy],
39    [cx, cy],
40    [cx + 2, cy],
41  ]
42  inner.forEach(([x, y], i) => {
43    const row = grid[y]
44    if (!row) return
45    const firing = phase === 'idle' ? false : (tick + i) % 3 === 0
46    row[x] = firing ? '◉' : '○'
47  })
48  return grid.map(r => ({ text: r.join('') }))
49}
50
51/** The phase-specific line drawn under the orb. */
52export function overlayLine(tick: number, phase: BrainPhase, width: number): AsciiRow {
53  const w = Math.max(10, Math.min(27, width))
54  const t = tick
55  switch (phase) {
56    case 'reading': {
57      const pos = t % w
58      const cells = Array.from({ length: w }, (_, i) => (i === pos ? '▓' : Math.abs(i - pos) < 3 ? '▒' : '░'))
59      return { text: cells.join(''), bright: true }
60    }
61    case 'editing': {
62      const len = t % (w - 3)
63      return { text: '❯ ' + '─'.repeat(len) + ((t & 1) === 0 ? '▌' : ' '), bright: true }
64    }
65    case 'running': {
66      const depth = t % 4
67      const left = ['(', ' (', '  (', '   ('].slice(0, depth + 1).join('')
68      const right = [')', ') ', ')  ', ')   ']
69        .slice(0, depth + 1)
70        .reverse()
71        .join('')
72      return { text: `${left} ◉ ${right}`.trim(), bright: true }
73    }
74    case 'searching': {
75      const pos = t % (2 * (w - 2))
76      const i = pos < w - 2 ? pos : 2 * (w - 2) - pos
77      const cells = Array.from({ length: w - 2 }, (_, k) => (k === i ? '●' : '·'))
78      return { text: `[${cells.join('')}]`, bright: true }
79    }
80    case 'web': {
81      const frames = ['◐', '◓', '◑', '◒']
82      return { text: `${frames[t % 4]} fetching ${'.'.repeat(t % 4)}`, bright: true }
83    }
84    case 'delegating': {
85      const frames = ['◇ ◆ ◇', '◆ ◇ ◇', '◇ ◇ ◆', '◇ ◆ ◇']
86      return { text: `${frames[t % 4]} subagents`, bright: true }
87    }
88    case 'responding': {
89      const bars = '▁▂▃▅▆▇▆▅▃▂'
90      const out = Array.from({ length: w }, (_, i) => bars[(i + t) % bars.length] ?? '▁')
91      return { text: out.join(''), bright: true }
92    }
93    case 'thinking':
94      return { text: `${SPINNER[t % SPINNER.length]} ${'∿'.repeat(1 + (t % 6))}`, bright: true }
95    case 'done':
96      return { text: '✔ done', bright: true }
97    case 'error':
98      return { text: '✖ error', bright: true }
99    default:
100      return { text: `${SPINNER[t % SPINNER.length]} ${PHASES[phase].label}`, bright: true }
101  }
102}
103
104export function elapsedText(state: BrainState, now: number): string {
105  if (!state.turnStartedAt || state.phase === 'idle') return ''
106  const secs = Math.max(0, Math.round((now - state.turnStartedAt) / 1000))
107  return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`
108}
109
hooks/brain3d.tsx 455 lines
1// The 3D brain: a surface module the pane mounts as a `Client`.
2//
3// It runs on the drawing thread with its own frame clock, so the sphere turns at
4// frame rate without a round trip to the hooks module, and it owns the pointer
5// while a drag is on, so the person can spin the brain by hand.
6
7import type { ClientModule, ClientPointerEvent } from 'claude-code'
8
9import type { Brain3dProps, BrainPhase } from '../types'
10
11type Vec = [number, number, number]
12
13type Drag = { x: number; y: number; yaw: number; pitch: number; moved: boolean }
14
15type State = {
16  t: number
17  yaw: number
18  pitch: number
19  /** yaw velocity carried after a drag */
20  spin: number
21  zoom: number
22  drag: Drag | null
23  hover: string | null
24  pointer: { x: number; y: number } | null
25  /** 1 right after a phase change, decaying to 0: the burst transition */
26  pulse: number
27  lastPhase: BrainPhase
28  flashes: number[]
29  ticksSinceProps: number
30  /** the latest props, for the frame clock, which closes over the first call's */
31  live: Pick<Brain3dProps, 'phase' | 'tempo' | 'speed' | 'paused'>
32}
33
34type Cell = { ch: string; color: string; dim: boolean; bold: boolean; z: number }
35
36const FRAME_MS = 70
37
38const DEPTH_GLYPHS = ['·', '∘', '•', '●'] as const
39
40/** Points spread evenly over the unit sphere. */
41function fibonacciSphere(n: number): Vec[] {
42  const out: Vec[] = []
43  const golden = Math.PI * (3 - Math.sqrt(5))
44  for (let i = 0; i < n; i++) {
45    const y = 1 - (i / (n - 1)) * 2
46    const r = Math.sqrt(1 - y * y)
47    const a = golden * i
48    out.push([Math.cos(a) * r, y, Math.sin(a) * r])
49  }
50  return out
51}
52
53/** A great circle in a tilted plane, as `n` points. */
54function ring(n: number, tiltX: number, tiltZ: number): Vec[] {
55  const out: Vec[] = []
56  for (let i = 0; i < n; i++) {
57    const a = (i / n) * Math.PI * 2
58    out.push(rotate([Math.cos(a), 0, Math.sin(a)], tiltX, 0, tiltZ))
59  }
60  return out
61}
62
63function rotate([x, y, z]: Vec, ax: number, ay: number, az: number): Vec {
64  // around Y, then X, then Z
65  let x1 = x * Math.cos(ay) + z * Math.sin(ay)
66  let z1 = -x * Math.sin(ay) + z * Math.cos(ay)
67  let y1 = y
68  const y2 = y1 * Math.cos(ax) - z1 * Math.sin(ax)
69  const z2 = y1 * Math.sin(ax) + z1 * Math.cos(ax)
70  y1 = y2
71  z1 = z2
72  const x3 = x1 * Math.cos(az) - y1 * Math.sin(az)
73  const y3 = x1 * Math.sin(az) + y1 * Math.cos(az)
74  x1 = x3
75  y1 = y3
76  return [x1, y1, z1]
77}
78
79const SHELL = fibonacciSphere(84)
80const CORE = fibonacciSphere(26).map(([x, y, z]): Vec => [x * 0.3, y * 0.3, z * 0.3])
81const RINGS: Vec[][] = [ring(40, 0.35, 0.2), ring(40, 1.2, 0.9), ring(40, 2.1, -0.7)]
82/** Where the recent-tool nodes sit: eight spread points, a little outside the shell. */
83const NODE_SEATS = fibonacciSphere(8).map(([x, y, z]): Vec => [x * 1.08, y * 1.08, z * 1.08])
84
85function longitude([x, , z]: Vec): number {
86  return Math.atan2(z, x)
87}
88
89function initial(props: Brain3dProps): State {
90  return {
91    t: 0,
92    yaw: 0.6,
93    pitch: 0.35,
94    spin: 0,
95    zoom: 1,
96    drag: null,
97    hover: null,
98    pointer: null,
99    pulse: 1,
100    lastPhase: props.phase,
101    flashes: [],
102    ticksSinceProps: 0,
103    live: { phase: props.phase, tempo: props.tempo, speed: props.speed, paused: props.paused },
104  }
105}
106
107function clamp(n: number, lo: number, hi: number): number {
108  return Math.max(lo, Math.min(hi, n))
109}
110
111function fmtElapsed(ms: number): string {
112  const s = Math.max(0, Math.round(ms / 1000))
113  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`
114}
115
116/** Paint one frame into a cell grid: a depth-buffered projection of every point. */
117function paint(props: Brain3dProps, s: State, columns: number, rows: number) {
118  const grid: (Cell | null)[][] = Array.from({ length: rows }, () =>
119    Array.from({ length: columns }, () => null),
120  )
121  const cx = columns / 2
122  const cy = rows / 2
123  const radius = Math.max(3, Math.min(columns / 4.6, rows / 2.3)) * s.zoom
124  const cameraD = 3.2
125  const burst = 1 + 0.28 * s.pulse
126  const phase = props.phase
127  const t = s.t
128  const nodeHits: Array<{ id: string; x: number; y: number; z: number }> = []
129
130  const put = (
131    p: Vec,
132    kind: 'shell' | 'core' | 'ring' | 'node' | 'satellite',
133    extra?: { id?: string; hot?: boolean },
134  ) => {
135    const [x, y, z] = rotate(p, s.pitch, s.yaw, 0)
136    const scale = cameraD / (cameraD - z)
137    const sx = Math.round(cx + x * radius * 2.15 * scale)
138    const sy = Math.round(cy - y * radius * scale)
139    if (sx < 0 || sy < 0 || sx >= columns || sy >= rows) return
140    const row = grid[sy]
141    if (!row) return
142    const here = row[sx]
143    if (here && here.z > z) return
144    const depth = (z + 1) / 2 // 0 far, 1 near
145    let ch: string
146    let color = props.color
147    let dim = depth < 0.4
148    let bold = depth > 0.75
149    switch (kind) {
150      case 'core':
151        ch = depth > 0.5 ? '●' : '•'
152        color = props.color
153        dim = false
154        bold = true
155        break
156      case 'ring':
157        ch = depth > 0.6 ? '◦' : '·'
158        color = props.dim
159        dim = depth < 0.6
160        bold = false
161        break
162      case 'node':
163        ch = '◉'
164        color = extra?.hot ? '#ffffff' : props.color
165        dim = false
166        bold = true
167        if (extra?.id) nodeHits.push({ id: extra.id, x: sx, y: sy, z })
168        break
169      case 'satellite':
170        ch = '◆'
171        dim = false
172        bold = true
173        break
174      default:
175        ch = DEPTH_GLYPHS[Math.min(3, Math.floor(depth * 4))] ?? '·'
176        if (extra?.hot) {
177          ch = '✦'
178          color = '#ffffff'
179          dim = false
180          bold = true
181        }
182    }
183    row[sx] = { ch, color, dim, bold, z }
184  }
185
186  // --- the shell, with the phase's own signature on it -------------------
187  const scanLevel = Math.sin(t * 0.12)
188  const sweep = (t * 0.22) % (Math.PI * 2)
189  const fill = (t * 2) % (SHELL.length + 30)
190  SHELL.forEach((p, i) => {
191    let hot = false
192    switch (phase) {
193      case 'reading':
194        hot = Math.abs(p[1] - scanLevel) < 0.1
195        break
196      case 'searching': {
197        let d = Math.abs(longitude(p) + Math.PI - sweep)
198        d = Math.min(d, Math.PI * 2 - d)
199        hot = d < 0.28
200        break
201      }
202      case 'editing':
203        hot = i < fill
204        break
205      case 'responding':
206        hot = Math.sin(longitude(p) * 3 - t * 0.35) > 0.82
207        break
208      case 'web':
209        hot = Math.abs(p[1]) < 0.06 || Math.abs(Math.sin(longitude(p) - t * 0.1)) < 0.06
210        break
211      case 'thinking':
212        hot = s.flashes.includes(i)
213        break
214      case 'done':
215        hot = s.pulse > 0.3
216        break
217      default:
218        hot = false
219    }
220    const r = phase === 'running' ? burst * (1 + 0.12 * Math.sin(t * 0.45 + p[1] * 3)) : burst
221    put([p[0] * r, p[1] * r, p[2] * r], 'shell', { hot })
222  })
223
224  // --- the gyro rings, each turning at its own pace -----------------------
225  RINGS.forEach((pts, k) => {
226    const speed = (0.05 + k * 0.03) * (phase === 'idle' ? 0.3 : phase === 'running' ? 2.2 : 1)
227    const a = t * speed * (k % 2 ? -1 : 1)
228    pts.forEach(p => put(rotate(p, 0, a, 0), 'ring'))
229  })
230
231  // --- the core ------------------------------------------------------------
232  const beat = 1 + 0.18 * Math.sin(t * (phase === 'idle' ? 0.12 : 0.4)) + 0.2 * s.pulse
233  CORE.forEach(p => put(rotate([p[0] * beat, p[1] * beat, p[2] * beat], 0, -t * 0.15, 0), 'core'))
234
235  // --- the recent tool calls as nodes on the shell ------------------------
236  props.nodes.slice(-NODE_SEATS.length).forEach((node, i) => {
237    const seat = NODE_SEATS[i]
238    if (!seat) return
239    put([seat[0] * burst, seat[1] * burst, seat[2] * burst], 'node', {
240      id: node.id,
241      hot: s.hover === node.id,
242    })
243  })
244
245  // --- satellites while delegating ----------------------------------------
246  if (phase === 'delegating') {
247    for (let k = 0; k < 3; k++) {
248      const a = t * 0.2 + (k * Math.PI * 2) / 3
249      put(rotate([Math.cos(a) * 1.5, 0.25 * Math.sin(a * 2), Math.sin(a) * 1.5], 0.4, 0, 0), 'satellite')
250    }
251  }
252
253  // --- the verdict marks ---------------------------------------------------
254  if (phase === 'error') {
255    const row = grid[Math.round(cy)]
256    if (row) row[Math.round(cx)] = { ch: '✖', color: '#ffffff', dim: false, bold: true, z: 9 }
257  }
258  if (phase === 'done' && s.pulse < 0.6) {
259    const row = grid[Math.round(cy)]
260    if (row) row[Math.round(cx)] = { ch: '✔', color: '#ffffff', dim: false, bold: true, z: 9 }
261  }
262
263  return { grid, nodeHits }
264}
265
266const Brain3d: ClientModule<Brain3dProps, State> = (props, surface) => {
267  const { Box, Text } = surface.elements
268
269  if (surface.state === undefined) {
270    // First call: seed the state and start the clock, the pointer and the keys, once.
271    surface.setState(initial(props))
272    surface.every(FRAME_MS, () => {
273      const s = surface.state
274      if (!s) return
275      const live = s.live
276      const moving = !live.paused && !s.drag
277      const rate =
278        (0.012 + 0.03 / Math.max(2, live.tempo)) * (live.speed === 1 ? 0.5 : live.speed === 3 ? 2 : 1)
279      const flashes = s.flashes.filter(() => Math.random() > 0.25)
280      if (live.phase === 'thinking' && Math.random() < 0.35)
281        flashes.push(Math.floor(Math.random() * SHELL.length))
282      surface.setState({
283        ...s,
284        t: s.t + 1,
285        yaw: s.yaw + (moving ? rate : 0) + s.spin,
286        spin: s.spin * 0.9,
287        pulse: Math.max(0, s.pulse - 0.1),
288        flashes,
289        ticksSinceProps: s.ticksSinceProps + 1,
290      })
291    })
292    surface.onPointer((e: ClientPointerEvent) => {
293      const s = surface.state
294      if (!s) return
295      if (e.type === 'down' && e.button === 'left') {
296        surface.setState({
297          ...s,
298          drag: { x: e.x, y: e.y, yaw: s.yaw, pitch: s.pitch, moved: false },
299          spin: 0,
300        })
301        return
302      }
303      if (e.type === 'move') {
304        if (s.drag) {
305          const dx = e.x - s.drag.x
306          const dy = e.y - s.drag.y
307          surface.setState({
308            ...s,
309            yaw: s.drag.yaw + dx * 0.07,
310            pitch: clamp(s.drag.pitch + dy * 0.14, -1.4, 1.4),
311            spin: dx === 0 ? s.spin : clamp(dx * 0.004, -0.06, 0.06),
312            drag: { ...s.drag, moved: s.drag.moved || Math.abs(dx) + Math.abs(dy) > 0 },
313          })
314        } else {
315          surface.setState({ ...s, pointer: { x: e.x, y: e.y } })
316        }
317        return
318      }
319      if (e.type === 'up') {
320        if (s.drag && !s.drag.moved && s.hover) surface.post({ kind: 'node', id: s.hover })
321        surface.setState({ ...s, drag: null })
322        return
323      }
324      if (e.type === 'leave') surface.setState({ ...s, pointer: null, hover: null })
325    })
326    surface.onKey(e => {
327      const s = surface.state
328      if (!s) return
329      switch (e.key) {
330        case 'left':
331          surface.setState({ ...s, yaw: s.yaw - 0.25 })
332          break
333        case 'right':
334          surface.setState({ ...s, yaw: s.yaw + 0.25 })
335          break
336        case 'up':
337          surface.setState({ ...s, pitch: clamp(s.pitch - 0.2, -1.4, 1.4) })
338          break
339        case 'down':
340          surface.setState({ ...s, pitch: clamp(s.pitch + 0.2, -1.4, 1.4) })
341          break
342        case '+':
343        case '=':
344          surface.setState({ ...s, zoom: clamp(s.zoom * 1.15, 0.5, 2.2) })
345          break
346        case '-':
347          surface.setState({ ...s, zoom: clamp(s.zoom / 1.15, 0.5, 2.2) })
348          break
349        case ' ':
350          surface.post({ kind: 'toggle-pause' })
351          break
352        case 'r':
353          surface.setState({ ...initial(props), ...{ live: s.live }, t: s.t, pulse: 0 })
354          break
355        default:
356          break
357      }
358    })
359    return <Text dimColor>warming up…</Text>
360  }
361
362  const s = surface.state
363  const liveChanged =
364    s.live.phase !== props.phase ||
365    s.live.tempo !== props.tempo ||
366    s.live.speed !== props.speed ||
367    s.live.paused !== props.paused
368  if (liveChanged) {
369    // New props the clock must see; a phase change also bursts, then settles into the new colour.
370    const changed = s.lastPhase !== props.phase
371    surface.setState({
372      ...s,
373      live: { phase: props.phase, tempo: props.tempo, speed: props.speed, paused: props.paused },
374      lastPhase: props.phase,
375      pulse: changed ? 1 : s.pulse,
376      flashes: changed ? [] : s.flashes,
377      ticksSinceProps: changed ? 0 : s.ticksSinceProps,
378    })
379  }
380
381  const columns = surface.columns
382  const rows = surface.rows
383  if (columns < 12 || rows < 5) return <Text dimColor>{props.label}</Text>
384
385  const bodyRows = rows - 2
386  const { grid, nodeHits } = paint(props, s, columns, bodyRows)
387
388  // Which node is under the pointer, if any (nearest within a cell and a half).
389  let hover: string | null = null
390  if (s.pointer) {
391    let best = 2.5
392    for (const n of nodeHits) {
393      const d = Math.abs(n.x - s.pointer.x) + Math.abs(n.y - s.pointer.y) * 2
394      if (d < best) {
395        best = d
396        hover = n.id
397      }
398    }
399  }
400  if (hover !== s.hover) surface.setState({ ...s, hover })
401
402  const hovered = props.nodes.find(n => n.id === (hover ?? s.hover))
403  const elapsed = props.elapsedMs > 0 ? fmtElapsed(props.elapsedMs + s.ticksSinceProps * FRAME_MS) : ''
404  const paused = props.paused
405
406  const lines = grid.map(row => {
407    const runs: Array<{ text: string; color: string; dim: boolean; bold: boolean }> = []
408    for (const cell of row) {
409      const ch = cell?.ch ?? ' '
410      const color = cell?.color ?? props.dim
411      const dim = cell?.dim ?? true
412      const bold = cell?.bold ?? false
413      const last = runs[runs.length - 1]
414      if (last && last.color === color && last.dim === dim && last.bold === bold) last.text += ch
415      else runs.push({ text: ch, color, dim, bold })
416    }
417    return runs
418  })
419
420  const title = `${props.label.toUpperCase()}${props.detail ? '  ' + props.detail : ''}`
421  const titleMax = Math.max(4, columns - elapsed.length - 2)
422  const shownTitle = title.length > titleMax ? title.slice(0, titleMax - 1) + '…' : title
423
424  const footer = hovered
425    ? `◉ ${hovered.tool} ${hovered.label}${hovered.ms === undefined ? '' : ' ' + fmtElapsed(hovered.ms)}`
426    : paused
427      ? '⏸ paused · space resumes · drag to spin'
428      : `drag to spin · ←→↑↓ turn · +/- zoom · space pause${props.tools ? ` · ${props.tools} tools` : ''}`
429
430  return (
431    <Box flexDirection="column">
432      <Box justifyContent="space-between">
433        <Text color={props.color} bold>
434          {shownTitle}
435        </Text>
436        <Text dimColor>{elapsed}</Text>
437      </Box>
438      {lines.map(runs => (
439        <Text>
440          {runs.map(run => (
441            <Text color={run.color} dimColor={run.dim} bold={run.bold}>
442              {run.text}
443            </Text>
444          ))}
445        </Text>
446      ))}
447      <Text color={hovered ? props.color : undefined} dimColor={!hovered} wrap="truncate-end">
448        {footer.length > columns ? footer.slice(0, columns - 1) + '…' : footer}
449      </Text>
450    </Box>
451  )
452}
453
454export default Brain3d
455
types/index.d.ts 81 lines
1export type BrainPhase =
2  | 'idle'
3  | 'thinking'
4  | 'responding'
5  | 'reading'
6  | 'editing'
7  | 'running'
8  | 'searching'
9  | 'web'
10  | 'delegating'
11  | 'connecting'
12  | 'working'
13  | 'done'
14  | 'error'
15
16export type BrainTool = {
17  id: string
18  tool: string
19  label: string
20  phase: BrainPhase
21  startedAt: number
22  ms?: number
23  isError?: boolean
24}
25
26export type BrainState = {
27  phase: BrainPhase
28  prevPhase: BrainPhase
29  detail: string
30  since: number
31  turnStartedAt: number | null
32  turnCount: number
33  toolCount: number
34  active: number
35  recent: BrainTool[]
36}
37
38/** What the person set on the pane's controls. */
39export type BrainUi = {
40  /** `3d` is the interactive point-sphere; `hud` the vector rings (desktop and editor only). */
41  view: '3d' | 'hud'
42  paused: boolean
43  /** 1 slow, 2 normal, 3 fast */
44  speed: 1 | 2 | 3
45}
46
47/** One node the 3D view places on the sphere: a recent tool call. */
48export type BrainNode = {
49  id: string
50  tool: string
51  label: string
52  phase: BrainPhase
53  ms?: number
54  isError?: boolean
55}
56
57/** The props the hooks module hands the 3D surface module. */
58export type Brain3dProps = {
59  phase: BrainPhase
60  color: string
61  dim: string
62  label: string
63  detail: string
64  tempo: number
65  paused: boolean
66  speed: 1 | 2 | 3
67  nodes: BrainNode[]
68  /** Milliseconds the turn had run when these props were made; 0 when idle. */
69  elapsedMs: number
70  tools: number
71}
72
73/** What the 3D surface module posts back to the hooks module. */
74export type Brain3dMessage = { kind: 'node'; id: string } | { kind: 'toggle-pause' }
75
76declare module 'claude-code' {
77  interface PluginState {
78    'brain-hud': { brain: BrainState; tick: number; ui: BrainUi }
79  }
80}
81