SLOPSHOPPER

halo

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…

newbandspinnerguardcommandtimer
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/halo
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · halo
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /halo ⎿ halo: Halo hidden. /halo brings it back. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

KOZMOS Halo

A one-row mood line across the band whose color and motion say what Claude is doing:

StateLine
idlecalm blue, slow breathing
requesting / writinga violet gradient flowing right
thinkinga magenta shimmer with sparks
a tool runningthe tool family's color racing left → right
a tool faileda 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.

Türkçe

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ştasakin mavi, yavaş nefes
istek / yazmasağa akan mor bir degrade
düşünmekıvılcımlı macenta bir parıltı
araç çalışıyoraraç ailesinin rengi soldan sağa koşar
araç başarısız4 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.

Source 6 files
hooks/register.tsx 187 lines
1import { 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}
187
hooks/line.tsx 100 lines
1// 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
100
hooks/mood.ts 45 lines
1// 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}
45
hooks/svg.ts 78 lines
1// 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}
78
hooks/lib/kz.ts 510 lines
1// 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> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
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}
510
types/index.d.ts 19 lines
1/** 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