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…

Olase's Claude Code mods: changes to Claude's own interface, written as function-hooks plugins.
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.
+/- zoom, space to pause and r to reset.3, h, p, s, c while the pane has focus.The pane docks beside the transcript on the desktop app; on a terminal, /brain opens it.
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.
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.
MIT
hooks/register.tsx 407 lines1import { 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}
407hooks/phases.ts 95 lines1import 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}
95hooks/brain-svg.ts 466 lines1import 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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}
466hooks/brain-ascii.ts 109 lines1import 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}
109hooks/brain3d.tsx 455 lines1// 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
455types/index.d.ts 81 lines1export 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