KOZMOS Halo: a one-row mood line above the prompt whose color and motion tell what Claude is doing: calm blue breathing when idle, a violet flow while it…

A one-row mood line across the band whose color and motion say what Claude is doing:
| State | Line |
|---|---|
| idle | calm blue, slow breathing |
| requesting / writing | a violet gradient flowing right |
| thinking | a magenta shimmer with sparks |
| a tool running | the tool family's color racing left → right |
| a tool failed | a red flash for 4 s |
waiting for you (AskUserQuestion, ExitPlanMode in progress) | an amber blink |
How the state is read: turn.start / turn.complete say whether a turn runs; the main loop's turn.step chunks say requesting, thinking or writing as they stream; tool.call says which tool runs and whether it failed. The spinner's own mode comes from a ui.render hook on Spinner, but a render hook must not write state, so that hook only stores the mode in a module variable and passes the drawing on unchanged; a 400 ms timer and every tool and turn event compute the mood and publish it to $.state only when it changed.
Terminal: a Client painting ━ at ~30 fps, every cell its own color. Desktop: one SVG line with CSS-animated gradients, glow, a comet or a strobe. Always on, one row.
/halo shows or hides it; ✕ hides it. The choice is kept across sessions.
Bandın boyunca, rengi ve hareketiyle Claude'un ne yaptığını söyleyen tek satırlık bir ruh hâli çizgisi:
| Durum | Çizgi |
|---|---|
| boşta | sakin mavi, yavaş nefes |
| istek / yazma | sağa akan mor bir degrade |
| düşünme | kıvılcımlı macenta bir parıltı |
| araç çalışıyor | araç ailesinin rengi soldan sağa koşar |
| araç başarısız | 4 sn kırmızı flaş |
sizi bekliyor (AskUserQuestion, ExitPlanMode sürerken) | amber yanıp sönme |
Durum nasıl okunur: turn.start / turn.complete tur sürüyor mu söyler; ana döngünün turn.step parçaları akarken istek, düşünme ya da yazmayı söyler; tool.call hangi aracın çalıştığını ve başarısız olup olmadığını söyler. Spinner'ın kendi modu Spinner üzerindeki bir ui.render kancasından gelir; çizim kancası durum yazamadığı için modu yalnızca bir modül değişkenine koyar ve çizimi olduğu gibi geçirir; 400 ms'lik bir zamanlayıcı ile her araç ve tur olayı ruh hâlini hesaplar ve yalnızca değiştiğinde $.state'e yayımlar.
Terminal: ━ karakterlerini ~30 fps boyayan, her hücresi kendi renginde bir Client. Masaüstü: CSS ile canlanan degrade, ışıma, kuyruklu yıldız ya da flaşlı tek SVG çizgi. Her zaman açık, tek satır.
/halo gösterir veya gizler; ✕ gizler. Seçim oturumlar arasında saklanır.
hooks/register.tsx 187 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { HaloSnap } from '../types'
5import type { LineProps } from './line.tsx'
6import { moodOf, WAITING_TOOLS } from './mood.ts'
7import type { SpinnerMode, StreamPhase } from './mood.ts'
8import { haloSvg } from './svg.ts'
9
10const snapAtom = atom({ plugin: 'halo', key: 'snap' } as const, null)
11const hiddenAtom = atom({ plugin: 'halo', key: 'isHidden' } as const, false)
12
13// The facts the mood is computed from. Module variables start over on a reload.
14//
15// The spinner's mode comes from a `ui.render` hook on the Spinner, and a render
16// hook must not write state. So that hook only stores the mode in a module
17// variable (`spinner`) and passes the drawing on untouched; a 400 ms timer and
18// every tool and turn event recompute the mood and publish it to $.state when
19// it changed. The model's own stream (turn.step chunks) is the first source;
20// the spinner fills in where no stream is in flight (the desktop's own words
21// for a step, a request the engine retries).
22let isWorking = false
23let phase: StreamPhase | undefined
24let spinner: { mode: SpinnerMode; at: number } | undefined
25const running = new Map<number, { tool: string; at: number }>()
26let toolSeq = 0
27let failedAt: number | undefined
28let published = ''
29
30async function publish($: EngineInterface): Promise<void> {
31 const now = await $.clock.now()
32 const tools = [...running.values()].sort((a, b) => b.at - a.at)
33 const mood = moodOf({
34 now,
35 isWorking,
36 tool: tools.find(t => !WAITING_TOOLS.includes(t.tool))?.tool,
37 isWaiting: tools.some(t => WAITING_TOOLS.includes(t.tool)),
38 failedAt,
39 phase,
40 spinner,
41 })
42 const key = `${mood.mood}|${mood.color}|${mood.label}`
43 if (key === published) return
44 published = key
45 const snap: HaloSnap = { ...mood, since: now }
46 await update($, snapAtom, () => snap)
47}
48
49async function setHidden($: EngineInterface, isHidden: boolean): Promise<void> {
50 await update($, hiddenAtom, () => isHidden)
51 try {
52 await $.store.set('isHidden', isHidden)
53 } catch {
54 // Hidden for this session only.
55 }
56}
57
58async function loadHidden($: EngineInterface): Promise<void> {
59 try {
60 const v = await $.store.get('isHidden')
61 if (typeof v === 'boolean') await update($, hiddenAtom, () => v)
62 } catch {
63 // Nothing stored.
64 }
65}
66
67export const register: Register = on => {
68 on('session.start', async ($, e, next) => {
69 const started = await next(e)
70 isWorking = false
71 phase = undefined
72 spinner = undefined
73 running.clear()
74 failedAt = undefined
75 published = ''
76 await $.command.register({ name: 'halo', description: 'KOZMOS: show or hide the Halo mood line above the prompt', immediate: true })
77 await loadHidden($)
78 await publish($)
79 $.clock.every(400, () => void publish($).catch(() => undefined))
80 return started
81 })
82
83 on('command.run', { command: 'halo' }, async $ => {
84 const isHidden = !(await read($, hiddenAtom))
85 await setHidden($, isHidden)
86 return { text: isHidden ? 'Halo hidden. /halo brings it back.' : 'Halo shown.' }
87 })
88
89 on('turn.start', async ($, e, next) => {
90 isWorking = true
91 phase = undefined
92 await publish($)
93 return next(e)
94 })
95
96 on('turn.complete', async ($, e, next) => {
97 if (e.agentId === undefined) {
98 isWorking = false
99 phase = undefined
100 spinner = undefined
101 await publish($)
102 }
103 return next(e)
104 })
105
106 // The main loop's request as it streams: requesting until the first chunk,
107 // then thinking, writing or calling a tool by the chunk's kind.
108 on('turn.step', async function* ($, e, next) {
109 if (e.agentId !== undefined) return yield* next(e)
110 phase = 'requesting'
111 void publish($).catch(() => undefined)
112 const stream = next(e)
113 try {
114 let item = await stream.next()
115 while (!item.done) {
116 const c = item.value
117 const now: StreamPhase | undefined = c.kind === 'thinking' ? 'thinking' : c.kind === 'text' ? 'responding' : c.kind === 'tool' || c.kind === 'input' ? 'tool-input' : undefined
118 if (now && now !== phase) {
119 phase = now
120 void publish($).catch(() => undefined)
121 }
122 yield c
123 item = await stream.next()
124 }
125 return item.value
126 } finally {
127 phase = undefined
128 }
129 })
130
131 on('tool.call', async ($, e, next) => {
132 const id = ++toolSeq
133 running.set(id, { tool: String(e.tool), at: await $.clock.now() })
134 await publish($)
135 try {
136 const ran = await next(e)
137 if (ran.isError === true || ran.deny !== undefined) failedAt = await $.clock.now()
138 return ran
139 } finally {
140 running.delete(id)
141 // The 400 ms timer ends the red flash once ERROR_MS has passed.
142 void publish($).catch(() => undefined)
143 }
144 }).catch(($, e, next) => next(e))
145
146 // Reads the spinner's mode and draws nothing of its own.
147 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
148 spinner = { mode: e.props.mode, at: await $.clock.now() }
149 return next(e)
150 })
151
152 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
153 const drawn = await next(e)
154 if (e.props.hasSurvey || (await read($, hiddenAtom))) return drawn
155 const snap = await read($, snapAtom)
156 if (!snap) return drawn
157 const cols = Math.max(20, (e.props.bodyColumns || 80) - 3)
158
159 if (e.surface === 'terminal') {
160 const { Box, Button, Client } = $.ui.resolve(e)
161 const props: LineProps = { mood: snap.mood, color: snap.color, label: snap.label }
162 return (
163 <Box flexDirection="column">
164 {drawn}
165 <Box key="halo" flexDirection="row">
166 <Client key="halo-line" module="./line.tsx" width={cols} height={1} props={props} />
167 <Button key="halo-hide" label="✕" plain dimColor role="dismiss" onPress={() => void setHidden($, true)} />
168 </Box>
169 </Box>
170 )
171 }
172
173 // Every surface but the terminal draws an Svg.
174 const { Box, Button, Svg } = $.ui.resolve(e)
175 const pic = haloSvg(snap, cols * 8 - 8, await $.clock.now())
176 return (
177 <Box flexDirection="column">
178 {drawn}
179 <Box key="halo" flexDirection="row">
180 <Svg source={pic.source} alt={pic.alt} width={pic.width} height={pic.height} />
181 <Button key="halo-hide" label="✕" plain dimColor role="dismiss" onPress={() => void setHidden($, true)} />
182 </Box>
183 </Box>
184 )
185 })
186}
187hooks/line.tsx 100 lines1// Halo's terminal line: a Client surface module that paints one row of ━ at
2// ~30 fps, every cell its own color, in a pattern per mood.
3import type { ClientModule, RenderElement } from 'claude-code'
4
5import type { HaloMood } from '../types'
6import { mix, noise } from './lib/kz.ts'
7
8export type LineProps = { mood: HaloMood; color: string; label: string }
9
10type LineState = { t: number; moodT: number; mood: HaloMood }
11
12/** A gray that reads on dark and light terminals alike: the line's "off". */
13const OFF = '#4b5563'
14
15let latest: LineProps = { mood: 'idle', color: '#60a5fa', label: '' }
16
17/** The color of cell `x` of `w` at frame `t` (`m` frames into the mood). */
18export function cellColor(p: LineProps, x: number, w: number, t: number, m: number): string {
19 const c = p.color
20 switch (p.mood) {
21 case 'idle': {
22 // Slow breathing with a faint swell drifting along.
23 const breath = 0.45 + 0.3 * Math.sin(t * 0.045)
24 const swell = 0.12 * Math.sin(x * 0.06 - t * 0.02)
25 return mix(OFF, c, Math.max(0, Math.min(1, breath + swell)))
26 }
27 case 'flow': {
28 // Bands of violet streaming right.
29 const k = 0.5 + 0.5 * Math.sin((x - t * 0.9) * 0.11)
30 return mix(mix(c, '#5b21b6', 0.55), mix(c, '#f5f3ff', 0.45), k)
31 }
32 case 'think': {
33 // A magenta field with sparks that come and go.
34 const base = 0.55 + 0.15 * Math.sin(x * 0.2 + t * 0.07)
35 const spark = noise(x, Math.floor(t / 3)) > 0.9 ? 1 : 0
36 return spark ? mix(c, '#fff0fa', 0.7) : mix(OFF, c, base)
37 }
38 case 'tool': {
39 // A comet of the tool's color racing left to right.
40 const head = (t * 1.6) % (w + 30)
41 const d = head - x
42 const tail = d >= 0 ? Math.exp(-d / 10) : 0
43 return mix(mix(OFF, c, 0.3), mix(c, '#ffffff', 0.35), tail)
44 }
45 case 'error': {
46 // A hard red strobe that settles over four seconds.
47 const fade = Math.max(0, 1 - m / 120)
48 const strobe = Math.floor(m / 4) % 2 === 0 ? 1 : 0.35
49 return mix(mix(OFF, c, 0.45), c, fade * strobe + (1 - fade) * 0.4)
50 }
51 case 'wait': {
52 // An amber blink, once a second, with a soft edge.
53 const phase = (t % 30) / 30
54 const on = phase < 0.55 ? 1 : phase < 0.65 ? 1 - (phase - 0.55) * 10 : 0.15
55 return mix(OFF, c, on)
56 }
57 }
58}
59
60const Line: ClientModule<LineProps, LineState> = (props, surface) => {
61 if (surface.state === undefined) {
62 surface.setState({ t: 0, moodT: 0, mood: props.mood })
63 surface.every(33, () => {
64 const s = surface.state
65 if (!s) return
66 const isNew = latest.mood !== s.mood
67 surface.setState({ t: s.t + 1, moodT: isNew ? 0 : s.moodT + 1, mood: latest.mood })
68 })
69 }
70 latest = props
71 const st = surface.state ?? { t: 0, moodT: 0, mood: props.mood }
72 const { Text } = surface.elements
73 const w = Math.max(4, surface.columns || 80)
74 const label = props.label ? ` ${props.label} ` : ''
75 const lead = label ? 2 : 0
76 const parts: RenderElement[] = []
77 let run = ''
78 let runColor = ''
79 const flush = (key: string) => {
80 if (run) parts.push(<Text key={key} color={runColor}>{run}</Text>)
81 run = ''
82 }
83 for (let x = 0; x < w; x++) {
84 if (label && x === lead) {
85 flush(`r${x}`)
86 parts.push(<Text key="label" color={cellColor(props, x, w, st.t, st.moodT)} bold>{label}</Text>)
87 x += label.length - 1
88 continue
89 }
90 const color = cellColor(props, x, w, st.t, st.moodT)
91 if (color !== runColor) flush(`r${x}`)
92 runColor = color
93 run += '━'
94 }
95 flush('end')
96 return <Text wrap="truncate-end">{parts}</Text>
97}
98
99export default Line
100hooks/mood.ts 45 lines1// Halo's state machine: which mood the line shows, from plain facts. No `$`.
2import type { HaloMood } from '../types'
3import { KZ, toolColor } from './lib/kz.ts'
4
5export const ERROR_MS = 4000
6export const SPINNER_FRESH_MS = 2500
7
8/** Tools whose call means Claude is waiting on the person. */
9export const WAITING_TOOLS: readonly string[] = ['AskUserQuestion', 'ExitPlanMode']
10
11export type StreamPhase = 'requesting' | 'thinking' | 'responding' | 'tool-input'
12export type SpinnerMode = 'requesting' | 'responding' | 'thinking' | 'tool-input' | 'tool-use'
13
14export type MoodFacts = {
15 now: number
16 isWorking: boolean
17 /** The newest running tool, if any (main loop or agent). */
18 tool?: string
19 /** A tool that waits on the person is running. */
20 isWaiting: boolean
21 failedAt?: number
22 /** The main loop's model request as it streams, if one is in flight. */
23 phase?: StreamPhase
24 /** The spinner's mode as last drawn, and when. */
25 spinner?: { mode: SpinnerMode; at: number }
26}
27
28export type Mood = { mood: HaloMood; color: string; label: string }
29
30export const IDLE: Mood = { mood: 'idle', color: KZ.blue, label: '' }
31
32/** Waiting beats a failure beats a running tool beats the model's own phase. */
33export function moodOf(f: MoodFacts): Mood {
34 if (f.isWaiting) return { mood: 'wait', color: KZ.amber, label: 'waiting for you' }
35 if (f.failedAt !== undefined && f.now - f.failedAt < ERROR_MS) return { mood: 'error', color: KZ.red, label: 'tool failed' }
36 if (f.tool) return { mood: 'tool', color: toolColor(f.tool), label: f.tool.startsWith('mcp__') ? f.tool.split('__').slice(1).join('·') : f.tool }
37 if (!f.isWorking) return IDLE
38 const fresh = f.spinner && f.now - f.spinner.at < SPINNER_FRESH_MS ? f.spinner.mode : undefined
39 const phase = f.phase ?? fresh
40 if (phase === 'thinking') return { mood: 'think', color: KZ.magenta, label: 'thinking' }
41 if (phase === 'responding') return { mood: 'flow', color: KZ.violet, label: 'writing' }
42 if (phase === 'tool-input' || phase === 'tool-use') return { mood: 'flow', color: KZ.violet, label: 'calling a tool' }
43 return { mood: 'flow', color: KZ.violet, label: 'requesting' }
44}
45hooks/svg.ts 78 lines1// Halo's desktop line: one SVG, every motion a CSS animation.
2import type { HaloSnap } from '../types'
3import { mix, svg, svgText, textWidth } from './lib/kz.ts'
4
5const H = 16
6
7export function haloSvg(s: HaloSnap, width: number, now: number): { source: string; width: number; height: number; alt: string } {
8 const W = Math.max(120, width)
9 const c = s.color
10 const labelW = s.label ? textWidth(s.label, 10) + 14 : 0
11 const x0 = labelW
12 const lw = W - x0
13 const y = H / 2
14 const p: string[] = []
15 const css: string[] = []
16 p.push(`<defs><filter id="hg" x="-5%" y="-200%" width="110%" height="500%"><feGaussianBlur stdDeviation="2.4"/></filter>`)
17 p.push(`<clipPath id="hc"><rect x="${x0}" y="${y - 2}" width="${lw}" height="4" rx="2"/></clipPath>`)
18 p.push(`<clipPath id="hcg"><rect x="${x0 - 4}" y="0" width="${lw + 8}" height="${H}"/></clipPath>`)
19 const line = (fill: string, extra = '') => `<rect x="${x0}" y="${y - 1.5}" width="${lw}" height="3" rx="1.5" fill="${fill}" ${extra}/>`
20
21 switch (s.mood) {
22 case 'idle': {
23 css.push('.br{animation:hbr 5s ease-in-out infinite}@keyframes hbr{50%{opacity:.35}}')
24 p.push(`</defs><g class="br"><g clip-path="url(#hcg)">${line(c, 'filter="url(#hg)" opacity=".6"')}</g>${line(c)}</g>`)
25 break
26 }
27 case 'flow': {
28 // A repeating violet gradient three widths long, sliding right forever.
29 const deep = mix(c, '#4c1d95', 0.5)
30 const light = mix(c, '#f5f3ff', 0.5)
31 p.push(`<linearGradient id="hf" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="180" y2="0" spreadMethod="repeat"><stop offset="0" stop-color="${deep}"/><stop offset=".5" stop-color="${light}"/><stop offset="1" stop-color="${deep}"/></linearGradient></defs>`)
32 css.push('.fl{animation:hfl 1.6s linear infinite}@keyframes hfl{from{transform:translateX(-180px)}to{transform:translateX(0)}}')
33 const band = `<rect class="fl" x="${x0}" y="0" width="${lw + 180}" height="${H}" fill="url(#hf)"/>`
34 p.push(`<g clip-path="url(#hcg)" filter="url(#hg)" opacity=".55"><g clip-path="url(#hc)">${band}</g></g><g clip-path="url(#hc)">${band}</g>`)
35 break
36 }
37 case 'think': {
38 css.push('.sh{animation:hsh 1.1s ease-in-out infinite}@keyframes hsh{from{transform:translateX(-80px)}to{transform:translateX(' + (lw + 80) + 'px)}}')
39 css.push('.sp{animation:hsp 1.4s ease-in-out infinite;opacity:0}@keyframes hsp{40%{opacity:1}80%{opacity:0}}')
40 p.push(`<linearGradient id="hs" x1="0" x2="1"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".5" stop-color="#fff" stop-opacity=".9"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient></defs>`)
41 p.push(`<g clip-path="url(#hcg)">${line(c, 'filter="url(#hg)" opacity=".7"')}</g>${line(c)}`)
42 p.push(`<g clip-path="url(#hc)"><rect class="sh" x="${x0}" y="0" width="80" height="${H}" fill="url(#hs)"/></g>`)
43 for (let i = 0; i < Math.min(28, Math.floor(lw / 40)); i++) {
44 const sx = x0 + ((i * 97.13) % lw)
45 p.push(`<circle class="sp" cx="${sx.toFixed(1)}" cy="${y + ((i % 3) - 1) * 2.5}" r="1.5" fill="${mix(c, "#ffffff", 0.7)}" style="animation-delay:${((i * 0.37) % 1.4).toFixed(2)}s"/>`)
46 }
47 break
48 }
49 case 'tool': {
50 // The tool's color dim along the line, and a bright comet racing across.
51 p.push(`<linearGradient id="ht" x1="0" x2="1"><stop offset="0" stop-color="${c}" stop-opacity="0"/><stop offset=".85" stop-color="${mix(c, '#ffffff', 0.25)}"/><stop offset="1" stop-color="${mix(c, "#ffffff", 0.55)}"/></linearGradient></defs>`)
52 css.push(`.cm{animation:hcm 1.3s cubic-bezier(.5,0,.5,1) infinite}@keyframes hcm{from{transform:translateX(-140px)}to{transform:translateX(${lw + 20}px)}}`)
53 p.push(line(c, 'opacity=".35"'))
54 p.push(`<g clip-path="url(#hcg)"><rect class="cm" x="${x0}" y="${y - 3}" width="140" height="6" rx="3" fill="url(#ht)" filter="url(#hg)"/></g>`)
55 p.push(`<g clip-path="url(#hc)"><rect class="cm" x="${x0}" y="0" width="140" height="${H}" fill="url(#ht)"/></g>`)
56 break
57 }
58 case 'error': {
59 // A strobe that runs for what is left of the four seconds, then rests.
60 const left = Math.max(0.2, 4 - (now - s.since) / 1000)
61 css.push(`.er{animation:her .22s steps(2,jump-none) ${Math.round(left / 0.22)}}@keyframes her{50%{opacity:.25}}`)
62 p.push(`</defs><g class="er"><g clip-path="url(#hcg)">${line(c, 'filter="url(#hg)"')}</g>${line(c)}</g>`)
63 break
64 }
65 case 'wait': {
66 css.push('.wt{animation:hwt 1s ease-in-out infinite}@keyframes hwt{0%,50%{opacity:1}65%,100%{opacity:.15}}')
67 p.push(`</defs><g class="wt"><g clip-path="url(#hcg)">${line(c, 'filter="url(#hg)"')}</g>${line(c)}</g>`)
68 break
69 }
70 }
71 if (s.label) p.push(svgText(0, y + 3.5, s.label, { size: 10, weight: 700, fill: c }))
72 return { source: svg(W, H, p.join(''), css.join('')), width: W, height: H, alt: altOf(s) }
73}
74
75export function altOf(s: HaloSnap): string {
76 return `Halo: ${s.mood === 'idle' ? 'idle' : s.label}`
77}
78hooks/lib/kz.ts 510 lines1// GENERATED by scripts/sync-shared.mjs from shared/kz.ts. Do not edit here.
2// KOZMOS shared kit. Source of truth: shared/kz.ts at the bundle root.
3// `node scripts/sync-shared.mjs` copies it into every mod as hooks/lib/kz.ts,
4// because a hooks module may only import files inside its own plugin.
5// Edit the root copy, never a mod's copy.
6
7// ---------------------------------------------------------------------------
8// Palette: one neon family across every KOZMOS mod.
9
10export const KZ = {
11 violet: '#a78bfa',
12 magenta: '#f472b6',
13 cyan: '#22d3ee',
14 teal: '#2dd4bf',
15 green: '#4ade80',
16 lime: '#a3e635',
17 yellow: '#facc15',
18 amber: '#fb923c',
19 red: '#f87171',
20 blue: '#60a5fa',
21 clay: '#d97757',
22 ink: '#1f1e1d',
23 mist: '#9ca3af',
24} as const
25
26/** Tool families, colored the same in every mod. */
27export function toolColor(tool: string): string {
28 const t = String(tool)
29 if (t === 'Bash' || t === 'PowerShell') return KZ.green
30 if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return KZ.yellow
31 if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP') return KZ.blue
32 if (t === 'Agent' || t === 'Task' || t === 'Workflow') return KZ.violet
33 if (t.startsWith('Web')) return KZ.cyan
34 if (t.startsWith('Todo') || t.startsWith('Task')) return KZ.teal
35 if (t.startsWith('mcp__')) return KZ.magenta
36 return KZ.mist
37}
38
39/** A short glyph per tool family. */
40export function toolGlyph(tool: string): string {
41 const t = String(tool)
42 if (t === 'Bash' || t === 'PowerShell') return '$'
43 if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit') return '✎'
44 if (t === 'Read') return '◉'
45 if (t === 'Glob' || t === 'Grep') return '⌕'
46 if (t === 'Agent') return '◈'
47 if (t.startsWith('Web')) return '◍'
48 if (t.startsWith('Todo') || t.startsWith('Task')) return '☑'
49 if (t.startsWith('mcp__')) return '⬡'
50 return '•'
51}
52
53// ---------------------------------------------------------------------------
54// Colors.
55
56export function hexToRgb(hex: string): [number, number, number] {
57 const h = hex.replace('#', '')
58 const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
59 return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
60}
61
62export function rgbToHex(r: number, g: number, b: number): string {
63 const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
64 return `#${c(r)}${c(g)}${c(b)}`
65}
66
67export function mix(a: string, b: string, t: number): string {
68 const [r1, g1, b1] = hexToRgb(a)
69 const [r2, g2, b2] = hexToRgb(b)
70 const k = clamp01(t)
71 return rgbToHex(r1 + (r2 - r1) * k, g1 + (g2 - g1) * k, b1 + (b2 - b1) * k)
72}
73
74/** Green at 0, yellow at 0.6, red at 1: the heat of a gauge. */
75export function heat(t: number): string {
76 const k = clamp01(t)
77 return k < 0.6 ? mix(KZ.green, KZ.yellow, k / 0.6) : mix(KZ.yellow, KZ.red, (k - 0.6) / 0.4)
78}
79
80/** A smooth rainbow for hues 0..1 (for auroras, plasma and the like). */
81export function hue(h: number, s = 0.75, l = 0.6): string {
82 const k = ((h % 1) + 1) % 1
83 const a = s * Math.min(l, 1 - l)
84 const f = (n: number) => {
85 const x = (n + k * 12) % 12
86 return l - a * Math.max(-1, Math.min(x - 3, 9 - x, 1))
87 }
88 return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255)
89}
90
91export function hexToInt(hex: string): number {
92 const [r, g, b] = hexToRgb(hex)
93 return (r << 16) | (g << 8) | b
94}
95
96// ---------------------------------------------------------------------------
97// Numbers and text.
98
99export const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0)
100
101export function fmtTokens(n: number): string {
102 if (!Number.isFinite(n)) return '—'
103 // Each unit starts where the one below would round up to 1000 of itself.
104 if (n >= 999.5e6) return `${(n / 1e9).toFixed(1)}B`
105 if (n >= 999_500) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
106 if (n >= 999.5) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
107 return `${Math.round(n)}`
108}
109
110export function fmtUsd(usd: number): string {
111 if (!Number.isFinite(usd)) return '$—'
112 if (usd < 0.01 && usd > 0) return '<$0.01'
113 return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
114}
115
116/** 0:42, 3:07, 1:02:33. */
117export function fmtClock(ms: number): string {
118 const s = Math.max(0, Math.round(ms / 1000))
119 const h = Math.floor(s / 3600)
120 const m = Math.floor((s % 3600) / 60)
121 const ss = String(s % 60).padStart(2, '0')
122 return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
123}
124
125/** 42s, 7m, 2h41m, 3d4h: compact durations for countdowns. */
126export function fmtSpan(ms: number): string {
127 const s = Math.max(0, Math.round(ms / 1000))
128 if (s < 60) return `${s}s`
129 const m = Math.floor(s / 60)
130 if (m < 60) return `${m}m`
131 const h = Math.floor(m / 60)
132 if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
133 return `${Math.floor(h / 24)}d${h % 24}h`
134}
135
136export function fmtPct(p: number | undefined): string {
137 return p === undefined || !Number.isFinite(p) ? '—' : `${Math.round(p)}%`
138}
139
140export function clip(s: string, max: number): string {
141 const one = s.replace(/\s+/g, ' ').trim()
142 return one.length > max ? one.slice(0, Math.max(1, max - 1)) + '…' : one
143}
144
145export function padEnd(s: string, n: number): string {
146 return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length)
147}
148
149export function padStart(s: string, n: number): string {
150 return s.length >= n ? s.slice(s.length - n) : ' '.repeat(n - s.length) + s
151}
152
153/** `claude-opus-5-5[1m]` → `Opus 5.5`. */
154export function modelName(id: string | undefined): string {
155 if (!id) return '—'
156 const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
157 if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
158 const fam = m[1]!
159 return `${fam.charAt(0).toUpperCase()}${fam.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
160}
161
162/** Last path segment, either slash. */
163export function baseName(p: string): string {
164 const parts = p.split(/[\\/]/).filter(Boolean)
165 return parts[parts.length - 1] ?? p
166}
167
168/** A stable small hash for seeding. */
169export function hash(s: string): number {
170 let h = 2166136261
171 for (let i = 0; i < s.length; i++) {
172 h ^= s.charCodeAt(i)
173 h = Math.imul(h, 16777619)
174 }
175 return h >>> 0
176}
177
178/** Deterministic 0..1 noise from two ints. */
179export function noise(x: number, y: number): number {
180 const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
181 return s - Math.floor(s)
182}
183
184/** A seeded PRNG (mulberry32). */
185export function rng(seed: number): () => number {
186 let a = seed >>> 0
187 return () => {
188 a = (a + 0x6d2b79f5) >>> 0
189 let t = a
190 t = Math.imul(t ^ (t >>> 15), t | 1)
191 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
192 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
193 }
194}
195
196// ---------------------------------------------------------------------------
197// Text gauges for the terminal.
198
199const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
200
201/** A smooth bar with eighth blocks: `█████▍ `. */
202export function bar(ratio: number, width: number, empty = '░'): string {
203 const w = Math.max(1, Math.floor(width))
204 const exact = clamp01(ratio) * w
205 const full = Math.floor(exact)
206 const part = EIGHTHS[Math.floor((exact - full) * 8)] ?? ''
207 const used = full + (part ? 1 : 0)
208 return '█'.repeat(full) + part + empty.repeat(Math.max(0, w - used))
209}
210
211/** A segmented gauge: `▰▰▰▱▱▱`. */
212export function pips(ratio: number, width: number, on = '▰', off = '▱'): string {
213 const w = Math.max(1, Math.floor(width))
214 const n = Math.round(clamp01(ratio) * w)
215 return on.repeat(n) + off.repeat(w - n)
216}
217
218const SPARK = '▁▂▃▄▅▆▇█'
219
220/** A sparkline of the last `width` values, scaled to their own max (or `max`). */
221export function sparkline(values: readonly number[], width: number, max?: number): string {
222 const tail = values.slice(-Math.max(1, width))
223 const top = max ?? Math.max(1e-9, ...tail)
224 const line = tail.map(v => SPARK[Math.min(7, Math.max(0, Math.round((v / top) * 7)))] ?? '▁').join('')
225 return line.padStart(width, ' ')
226}
227
228/** Braille line graph, 2 samples per cell, `rows` cells tall. */
229export function brailleGraph(values: readonly number[], width: number, rows: number, max?: number): string[] {
230 const samples = values.slice(-(width * 2))
231 const top = max ?? Math.max(1e-9, ...samples)
232 const dotsTall = rows * 4
233 const grid: number[][] = Array.from({ length: rows }, () => Array.from({ length: width }, () => 0))
234 const offset = width * 2 - samples.length
235 // Dot bits per column, from the bottom row of a cell up.
236 const LEFT = [0x40, 0x04, 0x02, 0x01]
237 const RIGHT = [0x80, 0x20, 0x10, 0x08]
238 samples.forEach((v, i) => {
239 const x = offset + i
240 const col = Math.floor(x / 2)
241 const isRight = x % 2 === 1
242 const h = Math.round(clamp01(v / top) * (dotsTall - 1))
243 for (let d = 0; d <= h; d++) {
244 const row = rows - 1 - Math.floor(d / 4)
245 const bits = isRight ? RIGHT : LEFT
246 const cellRow = grid[row]
247 if (cellRow && col >= 0) cellRow[col] = (cellRow[col] ?? 0) | bits[d % 4]!
248 }
249 })
250 return grid.map(r => r.map(b => String.fromCharCode(0x2800 + b)).join(''))
251}
252
253// ---------------------------------------------------------------------------
254// Raster: a grid of colored cells, packed as the engine wants it.
255
256const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
257
258export function toBase64(bytes: Uint8Array): string {
259 let out = ''
260 let i = 0
261 for (; i + 2 < bytes.length; i += 3) {
262 const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
263 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
264 }
265 const rest = bytes.length - i
266 if (rest === 1) {
267 const n = bytes[i]! << 16
268 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
269 } else if (rest === 2) {
270 const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
271 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
272 }
273 return out
274}
275
276/** The terminal's own default color, for a cell's foreground or background. */
277export const DEFAULT_COLOR = 0x01000000
278
279export class Canvas {
280 readonly cols: number
281 readonly rows: number
282 private readonly words: Uint32Array
283
284 constructor(cols: number, rows: number) {
285 this.cols = Math.max(1, Math.min(512, Math.floor(cols)))
286 this.rows = Math.max(1, Math.min(256, Math.floor(rows)))
287 this.words = new Uint32Array(this.cols * this.rows * 3)
288 this.clear()
289 }
290
291 clear(bg: number = DEFAULT_COLOR): void {
292 for (let i = 0; i < this.cols * this.rows; i++) {
293 this.words[i * 3] = 0x20
294 this.words[i * 3 + 1] = DEFAULT_COLOR
295 this.words[i * 3 + 2] = bg
296 }
297 }
298
299 /** Puts one width-1 character; colors are '#rrggbb' or a packed int. */
300 set(x: number, y: number, ch: string, fg?: string | number, bg?: string | number): void {
301 const cx = Math.floor(x)
302 const cy = Math.floor(y)
303 if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
304 const i = (cy * this.cols + cx) * 3
305 const code = ch.codePointAt(0) ?? 0x20
306 this.words[i] = code > 0xffff || code < 0x20 ? 0x20 : code
307 if (fg !== undefined) this.words[i + 1] = typeof fg === 'number' ? fg : hexToInt(fg)
308 if (bg !== undefined) this.words[i + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
309 }
310
311 /** Writes a string left to right, clipped to the canvas. */
312 text(x: number, y: number, s: string, fg?: string | number, bg?: string | number): void {
313 let cx = x
314 for (const ch of s) {
315 this.set(cx, y, ch, fg, bg)
316 cx++
317 }
318 }
319
320 /** Paints a cell's background only, keeping its character. */
321 paint(x: number, y: number, bg: string | number): void {
322 const cx = Math.floor(x)
323 const cy = Math.floor(y)
324 if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
325 this.words[(cy * this.cols + cx) * 3 + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
326 }
327
328 /** Two vertical pixels per cell with the upper-half block: `py` is in half-cells. */
329 pixel(x: number, py: number, color: string): void {
330 const cx = Math.floor(x)
331 const cy = Math.floor(py / 2)
332 if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
333 const i = (cy * this.cols + cx) * 3
334 const c = hexToInt(color)
335 const isTop = Math.floor(py) % 2 === 0
336 if (this.words[i] !== 0x2580) {
337 this.words[i] = 0x2580
338 this.words[i + 1] = DEFAULT_COLOR
339 this.words[i + 2] = DEFAULT_COLOR
340 }
341 if (isTop) this.words[i + 1] = c
342 else this.words[i + 2] = c
343 }
344
345 /** The `cells` prop of a Raster. */
346 encode(): string {
347 return toBase64(new Uint8Array(this.words.buffer))
348 }
349}
350
351// ---------------------------------------------------------------------------
352// SVG for the desktop: one drawing per row (the desktop wraps siblings).
353
354export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif"
355export const MONO = "ui-monospace,'Cascadia Code','SF Mono',Consolas,monospace"
356
357const XML_ESC: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
358
359export function xml(s: string): string {
360 return s.replace(/[&<>"']/g, c => XML_ESC[c]!)
361}
362
363/** Theme-aware classes every KOZMOS drawing shares: t text, s secondary, m muted, k track, p panel. */
364export const SVG_BASE_CSS = `
365.t{fill:#1f1f1f}.s{fill:#5f5f5c}.m{fill:#8e8e8a}.k{fill:#e7e5e0}.p{fill:#f5f4f1}.ln{stroke:#e1dfda}
366@media (prefers-color-scheme: dark){.t{fill:#ededed}.s{fill:#b4b4b0}.m{fill:#7c7c78}.k{fill:#2d2d2b}.p{fill:#232322}.ln{stroke:#363634}}
367.pulse{animation:kzp 1.6s ease-in-out infinite}@keyframes kzp{50%{opacity:.35}}
368.spin{transform-box:fill-box;transform-origin:center;animation:kzs 2s linear infinite}@keyframes kzs{to{transform:rotate(360deg)}}
369@media (prefers-reduced-motion: reduce){*{animation:none!important}}
370`
371
372export function svg(width: number, height: number, body: string, css = ''): string {
373 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${SVG_BASE_CSS}${css}</style>${body}</svg>`
374}
375
376/** Rough width of UI text in px, for fitting labels. */
377export function textWidth(s: string, size: number): number {
378 let w = 0
379 for (const ch of s) w += (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%MWmw]/.test(ch) ? 0.72 : 0.56) * size
380 return w
381}
382
383export function fitText(s: string, size: number, maxW: number): string {
384 if (textWidth(s, size) <= maxW) return s
385 let out = ''
386 for (const ch of s) {
387 if (textWidth(out + ch + '…', size) > maxW) break
388 out += ch
389 }
390 return out + '…'
391}
392
393/** A rounded progress bar as SVG markup. */
394export function svgBar(x: number, y: number, w: number, h: number, ratio: number, color: string): string {
395 const fw = Math.max(0, Math.min(w, w * clamp01(ratio)))
396 return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
397 (fw > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fw)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : '')
398}
399
400/** SVG text helper. */
401export function svgText(x: number, y: number, s: string, opts: { cls?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string; mono?: boolean } = {}): string {
402 const { cls = 't', size = 12, weight = 400, anchor = 'start', fill, mono = false } = opts
403 return `<text ${fill ? `fill="${fill}"` : `class="${cls}"`} x="${x}" y="${y}" font-family="${mono ? MONO : FONT}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-variant-numeric="tabular-nums">${xml(s)}</text>`
404}
405
406/** Pixel width a desktop pane or band gives a drawing for `columns` reported columns. */
407export function pxOf(columns: number | undefined, fallback = 60, slack = 8): number {
408 return Math.max(200, Math.min(1600, (columns || fallback) * 8 - slack))
409}
410
411// ---------------------------------------------------------------------------
412// Prices, USD per million tokens: input, output, cache read, cache write (5 min).
413// Anthropic first-party rates as of 2026-10. Used only where the engine reports
414// tokens and not money (a subagent's own spend); the session total comes from
415// $.session.usage().cost, which is the engine's.
416
417const PRICES: [RegExp, [number, number, number, number]][] = [
418 [/fable|mythos/i, [10, 50, 0.25, 12.5]],
419 [/opus-5-5/i, [4, 20, 0.2, 5]],
420 [/opus/i, [5, 25, 0.5, 6.25]],
421 [/sonnet-5/i, [2, 10, 0.2, 2.5]],
422 [/sonnet/i, [3, 15, 0.3, 3.75]],
423 [/haiku-5/i, [0.1, 0.5, 0.01, 0.125]],
424 [/haiku/i, [1, 5, 0.1, 1.25]],
425]
426
427export type Usage = {
428 input_tokens?: number
429 output_tokens?: number
430 cache_read_input_tokens?: number
431 cache_creation_input_tokens?: number
432}
433
434export function priceOf(model: string): [number, number, number, number] {
435 return PRICES.find(([re]) => re.test(model))?.[1] ?? [4, 20, 0.2, 5]
436}
437
438export function costOf(model: string, u: Usage | null | undefined): number {
439 if (!u) return 0
440 const [i, o, r, w] = priceOf(model)
441 return ((u.input_tokens ?? 0) * i + (u.output_tokens ?? 0) * o + (u.cache_read_input_tokens ?? 0) * r + (u.cache_creation_input_tokens ?? 0) * w) / 1e6
442}
443
444export function tokensOf(u: Usage | null | undefined): number {
445 if (!u) return 0
446 return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
447}
448
449/** The context a request filled: everything it read plus what it wrote. */
450export function contextOf(u: Usage | null | undefined): number {
451 return tokensOf(u)
452}
453
454export function windowOf(model: string): number {
455 return /haiku-4/i.test(model) ? 200_000 : 1_000_000
456}
457
458// ---------------------------------------------------------------------------
459// Rate limits.
460
461export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
462
463export function limitLabel(kind: string): string {
464 if (kind === 'five_hour') return '5h'
465 if (kind === 'seven_day') return '7d'
466 if (kind === 'spend_limit') return '$'
467 return kind.replace(/_/g, ' ')
468}
469
470/** Milliseconds until a window resets, or undefined. */
471export function untilReset(l: Limit, now: number): number | undefined {
472 if (!l.resetsAt) return undefined
473 const t = Date.parse(l.resetsAt)
474 return Number.isFinite(t) ? Math.max(0, t - now) : undefined
475}
476
477/** The length of a window in ms, for burn-rate math. */
478export function windowMs(kind: string): number | undefined {
479 if (kind === 'five_hour') return 5 * 3600_000
480 if (kind === 'seven_day') return 7 * 24 * 3600_000
481 return undefined
482}
483
484// ---------------------------------------------------------------------------
485// Tool calls, described in a few words.
486
487/** What a tool call is about: the command, the file, the pattern, the agent's task. */
488export function toolDetail(tool: string, input: unknown): string {
489 const e = (input ?? {}) as Record<string, unknown>
490 const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
491 const t = String(tool)
492 if (t === 'Bash' || t === 'PowerShell') return clip(s('description') || s('command'), 60)
493 if (s('file_path')) return baseName(s('file_path'))
494 if (s('notebook_path')) return baseName(s('notebook_path'))
495 if (t === 'Agent') return clip(s('description') || s('subagent_type'), 60)
496 if (s('pattern')) return clip(s('pattern'), 60)
497 if (s('url')) return clip(s('url').replace(/^https?:\/\//, ''), 60)
498 if (s('query')) return clip(s('query'), 60)
499 if (s('subject')) return clip(s('subject'), 60)
500 if (Array.isArray(e.todos)) return `${(e.todos as unknown[]).length} todos`
501 if (t.startsWith('mcp__')) return t.split('__').slice(1).join(' · ')
502 return ''
503}
504
505/** `mcp__server__tool` → `server·tool`; built-ins unchanged. */
506export function toolName(tool: string): string {
507 const t = String(tool)
508 return t.startsWith('mcp__') ? t.split('__').slice(1).join('·') : t
509}
510types/index.d.ts 19 lines1/** What the halo says: idle, the model at work, thinking, a tool running, a failure, or waiting for the person. */
2export type HaloMood = 'idle' | 'flow' | 'think' | 'tool' | 'error' | 'wait'
3
4export type HaloSnap = {
5 mood: HaloMood
6 /** The mood's color (a tool's family color while one runs). */
7 color: string
8 /** A word or two for the mood: "thinking", "Bash", "waiting for you". */
9 label: string
10 /** When this mood began (ms). */
11 since: number
12}
13
14declare module 'claude-code' {
15 interface PluginState {
16 halo: { snap: HaloSnap | null; isHidden: boolean }
17 }
18}
19