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…

Northern lights above the prompt while the model thinks.
low is a gentle glow, max is wild.✦ thinking · xhigh · 12s with an intensity gauge.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.
Model düşünürken istem satırının üstünde kuzey ışıkları.
low yumuşak bir parıltı, max çılgın.✦ thinking · xhigh · 12s ve bir yoğunluk göstergesi.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.
hooks/register.tsx 182 lines1import { 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}
182hooks/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}
510hooks/lights.ts 216 lines1// 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}
216hooks/scene.tsx 54 lines1// 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
54types/index.d.ts 19 lines1export 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