SLOPSHOPPER

aurora

KOZMOS Aurora: northern lights above the prompt while the model thinks. Shifting curtains whose intensity and speed follow the effort level (low is gentle, max…

newbandspinnercommandtimer
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/aurora
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · aurora
› 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 › /aurora ⎿ aurora: Aurora hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

KOZMOS Aurora

Northern lights above the prompt while the model thinks.

  • Curtains of light hang from the sky with a bright lower edge, green low and violet/rose high, with shimmering rays. Their height, fold and speed follow the effort level of the request: low is a gentle glow, max is wild.
  • While the model responds: a calmer single cyan ribbon.
  • A label: ✦ thinking · xhigh · 12s with an intensity gauge.
  • Nothing while idle or while tools run.

Terminal: a Client module, 2–3 rows at 30 fps, drawing curtains with block characters (▀ ▔ for the lower edge, ▁▂▃… for the top, ▓▒░ for the rays). Desktop: one SVG night sky with blurred gradient curtain bands that drift, flare and breathe by CSS (speed from effort, phase kept across redraws), and twinkling stars.

How it knows the model is thinking. A ui.render hook may not write state, so the band does not rely on the Spinner alone. The primary signal is the turn.step stream of the main loop: a thinking chunk means thinking, a text chunk responding, a tool/input chunk tool input; the step's start is requesting and its end tool-use or idle. turn.step also carries the effort. A ui.render hook on the Spinner reads e.props.mode into a module variable only (it writes nothing), and a 500 ms timer adopts that mode when it changed during a turn, covering what the stream does not show.

✕ hides it; /aurora toggles it.

Türkçe

Model düşünürken istem satırının üstünde kuzey ışıkları.

  • Parlak alt kenarlı ışık perdeleri gökten sarkar; altta yeşil, üstte mor/pembe, titreşen ışınlarla. Yükseklikleri, kıvrımları ve hızları isteğin çaba düzeyini izler: low yumuşak bir parıltı, max çılgın.
  • Model yanıt verirken: daha sakin, tek bir camgöbeği şerit.
  • Etiket: ✦ thinking · xhigh · 12s ve bir yoğunluk göstergesi.
  • Boştayken ya da araçlar çalışırken hiçbir şey çizmez.

Terminal: blok karakterlerle perde çizen 2–3 satırlık, 30 fps Client modülü. Masaüstü: CSS ile süzülen, parlayan ve nefes alan bulanık perdelerle tek bir SVG gece göğü.

Düşündüğünü nasıl anlar? Asıl sinyal ana döngünün turn.step akışıdır: thinking parçası düşünme, text yanıt, tool/input araç girdisi demektir; çaba da turn.stepten okunur. Spinner üzerindeki ui.render kancası e.props.mode değerini yalnızca bir modül değişkenine okur (durum yazmaz); 500 ms'lik zamanlayıcı bu modu tur sırasında değiştiyse benimser.

✕ gizler; /aurora açar/kapatır.

Source 5 files
hooks/register.tsx 182 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AuroraMode, AuroraSnap } from '../types'
5import { pxOf } from './lib/kz.ts'
6import { auroraAlt, auroraSvg, isLit, levelOf } from './lights.ts'
7
8const snapAtom = atom({ plugin: 'aurora', key: 'snap' } as const, null)
9const hiddenAtom = atom({ plugin: 'aurora', key: 'isHidden' } as const, false)
10
11// What the model is doing, from two sources:
12//  1. turn.step's stream on the main loop: a `thinking` chunk means thinking,
13//     a `text` chunk responding, a `tool`/`input` chunk tool input. This is
14//     the primary signal: a non-render event, so it may publish to $.state.
15//  2. The Spinner's `mode` prop, READ in a ui.render hook that writes nothing
16//     but these module variables (render hooks never write state); the timer
17//     below adopts it when it changed, which covers what the stream does not
18//     show (e.g. a request waiting before its first chunk).
19let mode: AuroraMode = 'idle'
20let since = 0
21let effort: string | null = null
22let level = 0.5
23let isWorking = false
24let spinnerMode: AuroraMode | null = null
25let spinnerSeq = 0
26let seenSpinnerSeq = 0
27let lastKey = ''
28
29async function setHidden($: EngineInterface, isHidden: boolean): Promise<void> {
30  await update($, hiddenAtom, () => isHidden)
31  try {
32    await $.store.set('hidden', isHidden)
33  } catch {
34    // Not persisted; the session still honours it.
35  }
36}
37
38async function setMode($: EngineInterface, next: AuroraMode): Promise<void> {
39  if (next === mode) return
40  mode = next
41  since = await $.clock.now()
42  await publish($)
43}
44
45async function publish($: EngineInterface): Promise<void> {
46  const now = await $.clock.now()
47  const snap: AuroraSnap = { now, mode, since, effort, level }
48  // While lit the desktop label counts seconds; otherwise nothing moves.
49  const key = JSON.stringify({ ...snap, now: isLit(mode) ? Math.floor(now / 1000) : 0 })
50  if (key === lastKey) return
51  await update($, snapAtom, () => snap)
52  // Noted once written, so a refused write is tried again on the next tick.
53  lastKey = key
54}
55
56async function tick($: EngineInterface): Promise<void> {
57  if (spinnerSeq !== seenSpinnerSeq) {
58    seenSpinnerSeq = spinnerSeq
59    if (isWorking && spinnerMode) await setMode($, spinnerMode)
60  }
61  await publish($)
62}
63
64function chunkMode(kind: string): AuroraMode | undefined {
65  if (kind === 'thinking') return 'thinking'
66  if (kind === 'text') return 'responding'
67  if (kind === 'tool' || kind === 'input') return 'tool-input'
68  return undefined
69}
70
71export const register: Register = on => {
72  on('session.start', async ($, e, next) => {
73    const started = await next(e)
74    mode = 'idle'
75    since = await $.clock.now()
76    isWorking = false
77    spinnerMode = null
78    seenSpinnerSeq = spinnerSeq
79    lastKey = ''
80    await $.command.register({ name: 'aurora', description: 'KOZMOS: show or hide the Aurora that glows above the prompt while the model thinks', immediate: true })
81    try {
82      const stored = await $.store.get('hidden')
83      if (typeof stored === 'boolean') await update($, hiddenAtom, () => stored)
84    } catch {
85      // Fresh store.
86    }
87    $.clock.every(500, () => void tick($).catch(() => undefined))
88    return started
89  })
90
91  on('command.run', { command: 'aurora' }, async $ => {
92    const isHidden = !(await read($, hiddenAtom))
93    await setHidden($, isHidden)
94    return { text: isHidden ? 'Aurora hidden.' : 'Aurora shown.' }
95  })
96
97  on('turn.start', async ($, e, next) => {
98    isWorking = true
99    return next(e)
100  })
101
102  on('turn.complete', async ($, e, next) => {
103    if (e.agentId === undefined) {
104      isWorking = false
105      await setMode($, 'idle').catch(() => undefined)
106    }
107    return next(e)
108  })
109
110  // The main loop's stream says when the model thinks, writes or calls a tool.
111  on('turn.step', async function* ($, e, next) {
112    if (e.agentId !== undefined) return yield* next(e)
113    effort = e.effort === undefined ? effort : String(e.effort)
114    level = levelOf(e.effort ?? effort)
115    isWorking = true
116    await setMode($, 'requesting').catch(() => undefined)
117    const stream = next(e)
118    for await (const chunk of stream) {
119      const m = chunkMode(chunk.kind)
120      if (m !== undefined && m !== mode) await setMode($, m).catch(() => undefined)
121      yield chunk
122    }
123    const r = await stream.result
124    await setMode($, r.toolUses.length > 0 ? 'tool-use' : 'idle').catch(() => undefined)
125    return r
126  })
127
128  // Read-only: note the spinner's mode for the timer; never write state here.
129  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
130    if (e.props.mode !== spinnerMode) {
131      spinnerMode = e.props.mode
132      spinnerSeq++
133    }
134    return next(e)
135  })
136
137  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
138    const drawn = await next(e)
139    if (e.props.hasSurvey || (await read($, hiddenAtom))) return drawn
140    const snap = await read($, snapAtom)
141    if (!snap || !isLit(snap.mode)) return drawn
142
143    const { Box, Button } = $.ui.resolve(e)
144    const hide = <Button key="aurora-hide" label="✕" plain dimColor role="dismiss" onPress={() => void setHidden($, true)} />
145    const secs = (snap.now - snap.since) / 1000
146
147    if (e.surface !== 'terminal') {
148      const { Svg } = $.ui.resolve(e)
149      const W = pxOf(e.props.bodyColumns)
150      const H = e.props.maxRows >= 10 ? 72 : 56
151      return (
152        <Box flexDirection="column">
153          {drawn}
154          <Box flexDirection="row">
155            <Svg source={auroraSvg(snap.mode, snap.effort, snap.level, secs, snap.now, W, H)} alt={auroraAlt(snap.mode, snap.effort, secs)} width={W} height={H} />
156            {hide}
157          </Box>
158        </Box>
159      )
160    }
161
162    const { Client } = $.ui.resolve(e)
163    const cols = Math.max(30, e.props.bodyColumns || 80)
164    const rows = e.props.maxRows >= 10 ? 3 : 2
165    return (
166      <Box flexDirection="column">
167        {drawn}
168        <Box flexDirection="row">
169          <Client
170            key="aurora"
171            module="./scene.tsx"
172            width={cols - 2}
173            height={rows}
174            props={{ mode: snap.mode, effort: snap.effort, level: snap.level, now: snap.now, since: snap.since, width: cols - 2, rows }}
175          />
176          {hide}
177        </Box>
178      </Box>
179    )
180  })
181}
182
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
hooks/lights.ts 216 lines
1// Aurora's pure drawing: curtains of light as a cell frame for the terminal
2// and as one blurred, animated SVG for the desktop. No `$` here.
3import { hue, mix, noise, pips, svg } from './lib/kz.ts'
4
5export type Seg = { s: string; c?: string; b?: boolean; d?: boolean }
6export type Mode = 'idle' | 'requesting' | 'thinking' | 'responding' | 'tool-input' | 'tool-use'
7
8export const FADE = '#4b5563'
9
10/** Effort as intensity: low is gentle, max is wild; a numeric budget on a log scale. */
11export function levelOf(effort: string | number | null | undefined): number {
12  if (typeof effort === 'number') return Math.max(0.2, Math.min(1, Math.log10(Math.max(1, effort)) / 5))
13  switch (effort) {
14    case 'low': return 0.25
15    case 'medium': return 0.45
16    case 'high': return 0.65
17    case 'xhigh': return 0.85
18    case 'max': return 1
19    default: return 0.5
20  }
21}
22
23/** Whether the band has something to show in this mode. */
24export function isLit(mode: Mode): boolean {
25  return mode === 'thinking' || mode === 'responding'
26}
27
28/**
29 * The curtain at column `x`: it hangs from `top` down to its bright lower edge
30 * `edge` (both 0..1 of the band's height, from the bottom), with a shimmer
31 * `ray` (0..1) and a hue at its edge.
32 */
33export function curtain(x: number, t: number, level: number, mode: Mode): { edge: number; top: number; ray: number; hu: number } {
34  if (mode !== 'thinking') {
35    // Responding: one slow, calm ribbon in cyan.
36    const edge = 0.3 + 0.14 * Math.sin(x * 0.09 + t * 0.7) + 0.04 * Math.sin(x * 0.23 - t * 0.4)
37    return { edge, top: edge + 0.32, ray: 0.5, hu: 0.52 + 0.02 * Math.sin(x * 0.05 + t * 0.2) }
38  }
39  const sp = 0.45 + level * 1.9
40  const a = 0.1 + level * 0.18
41  const wave = 0.55 * Math.sin(x * 0.11 + t * 0.9 * sp)
42    + 0.3 * Math.sin(x * 0.27 - t * 1.7 * sp + 1.3)
43    + 0.15 * Math.sin(x * 0.53 + t * 3.1 * sp + 4.1)
44  const edge = 0.24 - level * 0.12 + a * wave
45  const ray = 0.5 + 0.5 * Math.sin(x * 1.31 + t * 4 * sp + noise(x, 3) * 6)
46  const top = edge + 0.4 + level * 0.45 + 0.18 * Math.sin(x * 0.19 + t * 0.6 * sp) + 0.12 * level * (ray - 0.5)
47  const hu = 0.35 + 0.07 * Math.sin(x * 0.035 + t * 0.3) + level * 0.06 * Math.sin(x * 0.12 - t * 0.8)
48  return { edge: Math.max(0.02, edge), top: Math.min(1.1, top), ray, hu }
49}
50
51const LOWER = ' ▁▂▃▄▅▆▇█'
52
53/** The cell's glyph for the part of [lo, hi] (eighths of this cell, 0..8) the curtain covers. */
54function glyphFor(lo: number, hi: number, depth: number, ray: number, isWild: boolean): string {
55  if (hi - lo >= 8) {
56    // Wholly inside: solid near the edge, thinning upward into rays.
57    if (depth < 0.3) return '█'
58    if (depth < 0.55) return isWild && ray < 0.25 ? '▒' : '▓'
59    if (depth < 0.8) return ray < 0.35 ? '░' : '▒'
60    return ray < 0.5 ? ' ' : '░'
61  }
62  if (lo <= 0) return LOWER.charAt(Math.max(1, Math.round(hi))) // the top of the curtain, from the cell's bottom
63  if (hi >= 8) return lo >= 5 ? '▔' : lo >= 2 ? '▀' : '█' // the lower edge, from the cell's top
64  return '━'
65}
66
67/** The lights: `rows` lines of `cols` cells at `t` seconds. */
68export function auroraFrame(t: number, cols: number, rows: number, level: number, mode: Mode): Seg[][] {
69  const W = Math.max(1, Math.floor(cols))
70  const H = Math.max(1, Math.floor(rows))
71  const total = H * 8
72  const lines: Seg[][] = []
73  const at = Array.from({ length: W }, (_, x) => curtain(x, t, level, mode))
74  for (let y = 0; y < H; y++) {
75    const fromBottom = H - 1 - y
76    const segs: Seg[] = []
77    for (let x = 0; x < W; x++) {
78      const { edge, top, ray, hu } = at[x]!
79      const e = edge * total - fromBottom * 8
80      const tp = top * total - fromBottom * 8
81      const lo = Math.max(0, Math.round(e))
82      const hi = Math.min(8, Math.round(tp))
83      let ch = ' '
84      let c: string | undefined
85      if (hi > lo) {
86        const mid = ((lo + hi) / 2 + fromBottom * 8) / total
87        const depth = Math.max(0, Math.min(1, (mid - edge) / Math.max(0.05, top - edge)))
88        ch = glyphFor(lo, hi, depth, ray, mode === 'thinking' && level > 0.6)
89        // Green at the edge, violet and rose above it, as the real thing.
90        const hh = mode === 'thinking' ? hu + depth * (0.28 + level * 0.24) : hu + depth * 0.05
91        const lum = 0.55 + 0.12 * (1 - depth) + 0.08 * ray * (mode === 'thinking' ? level : 0.2)
92        c = mix(hue(hh, 0.85, lum), FADE, depth * 0.45)
93      } else if (e > 8 && noise(x, y + 17) > 0.965 && Math.sin(t * 2.2 + x * 1.7) > -0.2) {
94        // Sky under the curtain: a few stars.
95        ch = noise(x, y + 3) > 0.5 ? '·' : '✦'
96        c = mix('#cbd5e1', FADE, 0.35)
97      } else if (hi <= 0 && noise(x, y + 29) > 0.975 && Math.sin(t * 1.7 + x) > 0) {
98        ch = '·'
99        c = mix('#cbd5e1', FADE, 0.45)
100      }
101      if (ch === ' ') c = undefined
102      const last = segs[segs.length - 1]
103      if (last && last.c === c) last.s += ch
104      else segs.push({ s: ch, c })
105    }
106    lines.push(segs)
107  }
108  return lines
109}
110
111/** The label beside the lights: `thinking · xhigh · 12s` over two or three lines. */
112export function labelLines(mode: Mode, effort: string | null, secs: number, level: number, rows: number, t: number): Seg[][] {
113  const color = mode === 'thinking' ? hue(0.36 + 0.1 * Math.sin(t * 0.8), 0.8, 0.58) : hue(0.5, 0.75, 0.55)
114  const star = '✧✦✶✦'.charAt(Math.floor(t * (2 + level * 4)) % 4)
115  const word = mode === 'thinking' ? 'thinking' : 'responding'
116  const time = `${Math.max(0, Math.floor(secs))}s`
117  const bar: Seg = { s: pips(level, 5), c: color }
118  if (rows <= 2) {
119    return [
120      [{ s: `${star} `, c: color, b: true }, { s: word, c: color, b: true }, { s: ` · ${time}`, d: true }],
121      [{ s: `  ${effort ?? 'effort —'} `, d: true }, bar],
122    ]
123  }
124  return [
125    [{ s: `${star} `, c: color, b: true }, { s: word, c: color, b: true }],
126    [{ s: `  ${effort ?? '—'} · ${time}`, d: true }],
127    [{ s: '  ', d: true }, bar],
128  ]
129}
130
131/** The desktop drawing: a night sky with blurred curtains drifting, flaring and breathing. */
132export function auroraSvg(mode: Mode, effort: string | null, level: number, secs: number, now: number, W: number, H: number): string {
133  const LW = 150
134  const SW = W - LW - 8
135  const isThink = mode === 'thinking'
136  const layers = isThink
137    ? [
138        { hu: 0.36, amp: 0.18, wl: 260, dur: 26, y: 0.42, op: 0.9 },
139        { hu: 0.46, amp: 0.22, wl: 340, dur: 34, y: 0.5, op: 0.7 },
140        { hu: 0.74, amp: 0.2, wl: 220, dur: 21, y: 0.32, op: 0.55 },
141        { hu: 0.88, amp: 0.16, wl: 300, dur: 41, y: 0.26, op: 0.4 },
142      ].slice(0, level >= 0.6 ? 4 : 3)
143    : [{ hu: 0.53, amp: 0.12, wl: 420, dur: 40, y: 0.5, op: 0.8 }]
144  const sp = isThink ? 0.45 + level * 1.9 : 0.6
145  const parts: string[] = []
146  const css: string[] = [
147    `.ausky{fill:url(#auSky)}`,
148    `@keyframes audrift{to{transform:translateX(var(--d))}}`,
149    `@keyframes auflare{0%,100%{transform:scaleY(1)}50%{transform:scaleY(var(--f))}}`,
150    `@keyframes aubreath{0%,100%{opacity:var(--o)}50%{opacity:calc(var(--o) * .55)}}`,
151    `.auflare{transform-box:fill-box;transform-origin:50% 100%}`,
152    `.autw{animation:autw 2.6s ease-in-out infinite}@keyframes autw{50%{opacity:.1}}`,
153    `.austar{transform-box:fill-box;transform-origin:center;animation:austar ${Math.max(0.6, 3 - level * 2).toFixed(2)}s linear infinite}@keyframes austar{to{transform:rotate(360deg)}}`,
154  ]
155  parts.push(`<defs>`,
156    `<linearGradient id="auSky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#020617"/><stop offset="1" stop-color="#0b1a2a"/></linearGradient>`,
157    `<filter id="auBlur" x="-10%" y="-40%" width="120%" height="180%"><feGaussianBlur stdDeviation="${isThink ? (4 + level * 3).toFixed(1) : 6}"/></filter>`,
158    `<clipPath id="auClip"><rect x="0" y="0" width="${SW}" height="${H}" rx="12"/></clipPath>`)
159  layers.forEach((l, i) => {
160    const c = hue(l.hu, 0.85, 0.58)
161    parts.push(`<linearGradient id="auL${i}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${hue(l.hu + 0.3, 0.8, 0.6)}" stop-opacity="0"/><stop offset=".45" stop-color="${hue(l.hu + 0.12, 0.85, 0.6)}" stop-opacity=".22"/><stop offset=".82" stop-color="${c}" stop-opacity=".7"/><stop offset=".95" stop-color="${hue(l.hu - 0.03, 0.9, 0.66)}" stop-opacity="1"/><stop offset="1" stop-color="${hue(l.hu - 0.05, 0.9, 0.72)}" stop-opacity=".6"/></linearGradient>`)
162  })
163  parts.push(`</defs>`)
164  parts.push(`<rect class="ausky" x="0" y="0" width="${SW}" height="${H}" rx="12"/>`)
165  parts.push(`<g clip-path="url(#auClip)">`)
166  for (let i = 0; i < Math.round(SW / 14); i++) {
167    const sx = noise(i, 11) * SW
168    const sy = noise(i, 12) * H * 0.7
169    parts.push(`<circle cx="${sx.toFixed(1)}" cy="${sy.toFixed(1)}" r="${(0.4 + noise(i, 13) * 0.7).toFixed(2)}" fill="#e2e8f0" opacity="${(0.3 + noise(i, 14) * 0.5).toFixed(2)}"${noise(i, 15) > 0.6 ? ` class="autw" style="animation-delay:-${(noise(i, 16) * 2.6).toFixed(2)}s"` : ''}/>`)
170  }
171  // Each layer: a band whose lower edge is a sum of sines, one wavelength wider
172  // than the sky so a drift by one wavelength loops without a seam.
173  layers.forEach((l, i) => {
174    const wl = l.wl
175    // A band: its lower edge a sum of sines, its upper edge the same lifted by
176    // a height that swells along it (curtains are taller where they fold).
177    const lower: string[] = []
178    const upper: string[] = []
179    const yBase = H * (1 - l.y)
180    const band = H * (isThink ? 0.45 + level * 0.3 : 0.4)
181    for (let x = -wl; x <= SW + wl; x += 8) {
182      const k = (x / wl) * Math.PI * 2
183      const y = yBase - H * l.amp * (Math.sin(k) + 0.35 * Math.sin(2 * k + i) + 0.2 * Math.sin(3 * k + 2 * i))
184      lower.push(`${x},${y.toFixed(1)}`)
185      upper.unshift(`${x},${(y - band * (0.75 + 0.25 * Math.sin(2 * k + 1.7 * i))).toFixed(1)}`)
186    }
187    const top = `M${lower.join(' L')} L${upper.join(' L')} Z`
188    const drift = Math.max(4, l.dur / sp)
189    const flare = Math.max(1.6, 5.2 - level * 3) / (isThink ? 1 : 0.5)
190    const breath = Math.max(1.4, 4 - level * 2.2)
191    const phase = (s: number) => `-${((now / 1000) % s).toFixed(2)}s`
192    const dir = i % 2 === 0 ? -wl : wl
193    parts.push(
194      `<g style="--d:${dir}px;animation:audrift ${drift.toFixed(2)}s linear infinite;animation-delay:${phase(drift)}">`,
195      `<g class="auflare" style="--f:${(1 + (isThink ? 0.12 + level * 0.3 : 0.06)).toFixed(2)};animation:auflare ${flare.toFixed(2)}s ease-in-out infinite;animation-delay:${phase(flare)}">`,
196      `<path d="${top}" transform="translate(0 ${(H * 0.15).toFixed(1)})" fill="url(#auL${i})" filter="url(#auBlur)" style="--o:${l.op};animation:aubreath ${breath.toFixed(2)}s ease-in-out infinite;animation-delay:${phase(breath + i)}"/>`,
197      `</g></g>`,
198    )
199  })
200  parts.push(`</g>`)
201
202  // Label, on the theme's panel.
203  const LX = SW + 8
204  const c = isThink ? hue(0.38, 0.8, 0.5) : hue(0.53, 0.8, 0.45)
205  parts.push(`<rect class="p" x="${LX}" y="0" width="${LW}" height="${H}" rx="12"/>`)
206  parts.push(`<text x="${LX + 18}" y="${H / 2 - 4}" font-size="14" fill="${c}" class="austar" font-family="sans-serif">✦</text>`)
207  parts.push(`<text x="${LX + 34}" y="${H / 2 - 4}" font-size="13" font-weight="700" fill="${c}" font-family="-apple-system,'Segoe UI',sans-serif">${isThink ? 'thinking' : 'responding'}</text>`)
208  parts.push(`<text class="s" x="${LX + 34}" y="${H / 2 + 13}" font-size="11" font-family="-apple-system,'Segoe UI',sans-serif" font-variant-numeric="tabular-nums">${effort ?? 'effort —'} · ${Math.max(0, Math.floor(secs))}s</text>`)
209  for (let i = 0; i < 5; i++) parts.push(`<rect x="${LX + 34 + i * 11}" y="${H / 2 + 20}" width="8" height="3" rx="1.5" fill="${c}" opacity="${i < Math.round(level * 5) ? 0.95 : 0.2}"/>`)
210  return svg(W, H, parts.join(''), css.join('\n'))
211}
212
213export function auroraAlt(mode: Mode, effort: string | null, secs: number): string {
214  return `Aurora: the model is ${mode === 'thinking' ? 'thinking' : 'responding'}${effort ? ` at ${effort} effort` : ''}, ${Math.floor(secs)} seconds.`
215}
216
hooks/scene.tsx 54 lines
1// Aurora's terminal scene: curtains of light at 30 fps with their label.
2// Runs in the drawing surface; no `$` here.
3import type { ClientModule } from 'claude-code'
4
5import { auroraFrame, labelLines } from './lights.ts'
6import type { Mode, Seg } from './lights.ts'
7
8type P = { mode: Mode; effort: string | null; level: number; now: number; since: number; width: number; rows: number }
9type S = { t: number; sig: number; at: number }
10
11const FRAME = 33
12const LABEL = 20
13
14const Scene: ClientModule<P, S> = (props, surface) => {
15  const s = surface.state
16  if (s === undefined) {
17    surface.setState({ t: 0, sig: props.now, at: 0 })
18    surface.every(FRAME, () => {
19      const c = surface.state
20      if (c) surface.setState({ ...c, t: c.t + 1 })
21    })
22  } else if (s.sig !== props.now) {
23    surface.setState({ ...s, sig: props.now, at: s.t })
24  }
25  const t = s?.t ?? 0
26  const at = s && s.sig === props.now ? s.at : t
27  const now = props.now + (t - at) * FRAME
28
29  const { Box, Text } = surface.elements
30  const width = Math.max(20, surface.columns > 0 ? surface.columns : props.width)
31  const rows = Math.max(2, Math.min(3, props.rows))
32  const skyW = width - LABEL
33  const secs = (now - props.since) / 1000
34  const line = (segs: readonly Seg[], key: string) => (
35    <Text key={key} wrap="truncate-end">
36      {segs.map((g, i) => (
37        <Text key={`s${i}`} color={g.c} bold={g.b} dimColor={g.d}>{g.s}</Text>
38      ))}
39    </Text>
40  )
41  return (
42    <Box flexDirection="row">
43      <Box flexDirection="column" width={skyW} flexShrink={0}>
44        {auroraFrame(now / 1000, skyW, rows, props.level, props.mode).map((segs, y) => line(segs, `a${y}`))}
45      </Box>
46      <Box flexDirection="column" width={LABEL} flexShrink={0}>
47        {labelLines(props.mode, props.effort, secs, props.level, rows, now / 1000).map((segs, y) => line(segs, `l${y}`))}
48      </Box>
49    </Box>
50  )
51}
52
53export default Scene
54
types/index.d.ts 19 lines
1export type AuroraMode = 'idle' | 'requesting' | 'thinking' | 'responding' | 'tool-input' | 'tool-use'
2
3export type AuroraSnap = {
4  now: number
5  mode: AuroraMode
6  /** When the current mode began: the label counts from it. */
7  since: number
8  /** The effort the main loop asks for: low, medium, high, xhigh, max, a number, or null. */
9  effort: string | null
10  /** 0..1 intensity from the effort: how tall, fast and wild the curtains are. */
11  level: number
12}
13
14declare module 'claude-code' {
15  interface PluginState {
16    aurora: { snap: AuroraSnap | null; isHidden: boolean }
17  }
18}
19