KOZMOS Glyphfall: matrix rain of tool activity above the prompt while Claude works. Every tool call drops its name and detail as a wave of letters, colored by…

Matrix rain of tool activity, above the prompt while Claude works.
Grep turn.step, Bash npm test) as a wave of letters across neighbouring columns, colored by tool family: each letter a bright head with a fading tail of glyphs above it. The word hangs mid-band, readable, for as long as the tool runs, then falls out. Words in flight together never overlap.✖ when failed).Terminal: a Client module, 3–4 rows full width at 30 fps. Desktop: one SVG: rain columns falling by CSS translateY, tool letters dropping in and out with a glow, chips on a theme-aware strip.
Idle (no turn running) it draws nothing. ✕ hides it; /glyphfall toggles it.
Claude çalışırken istem satırının üstünde araç etkinliğinden bir Matrix yağmuru.
Grep turn.step, Bash npm test) komşu sütunlara yayılan bir harf dalgası olarak düşürür; renk araç ailesinden gelir, her harfin parlak bir başı ve üstünde solan bir kuyruğu vardır. Kelime, araç çalıştığı sürece bandın ortasında okunur biçimde asılı kalır, sonra düşer. Aynı anda düşen kelimeler üst üste binmez.Terminal: 3–4 satır, tam genişlik, 30 fps Client modülü. Masaüstü: CSS ile düşen sütunlar ve parlayan harflerle tek bir SVG.
Boştayken hiçbir şey çizmez. ✕ gizler; /glyphfall açar/kapatır.
hooks/register.tsx 121 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { FallTool, GlyphSnap } from '../types'
5import { pxOf, toolColor, toolDetail } from './lib/kz.ts'
6import { rainAlt, rainSvg } from './rain.ts'
7
8const snapAtom = atom({ plugin: 'glyphfall', key: 'snap' } as const, null)
9const hiddenAtom = atom({ plugin: 'glyphfall', key: 'isHidden' } as const, false)
10
11const KEEP = 12
12
13// Live collector; the band draws from the snapshot published to $.state.
14let tools: FallTool[] = []
15let seq = 0
16let lastKey = ''
17
18async function setHidden($: EngineInterface, isHidden: boolean): Promise<void> {
19 await update($, hiddenAtom, () => isHidden)
20 try {
21 await $.store.set('hidden', isHidden)
22 } catch {
23 // Not persisted; the session still honours it.
24 }
25}
26
27async function publish($: EngineInterface): Promise<void> {
28 const now = await $.clock.now()
29 const snap: GlyphSnap = { now, tools: tools.map(t => ({ ...t })) }
30 // Words in flight need the desktop redrawn now and then; a quiet band does not.
31 const isFalling = tools.some(t => t.end === null || now - t.end < 4000)
32 const key = JSON.stringify({ ...snap, now: isFalling ? now : 0 })
33 if (key === lastKey) return
34 lastKey = key
35 await update($, snapAtom, () => snap)
36}
37
38export const register: Register = on => {
39 on('session.start', async ($, e, next) => {
40 const started = await next(e)
41 tools = []
42 seq = 0
43 lastKey = ''
44 await $.command.register({ name: 'glyphfall', description: 'KOZMOS: show or hide the Glyphfall tool rain above the prompt', immediate: true })
45 try {
46 const stored = await $.store.get('hidden')
47 if (typeof stored === 'boolean') await update($, hiddenAtom, () => stored)
48 } catch {
49 // Fresh store.
50 }
51 $.clock.every(1000, () => void publish($).catch(() => undefined))
52 return started
53 })
54
55 on('command.run', { command: 'glyphfall' }, async $ => {
56 const isHidden = !(await read($, hiddenAtom))
57 await setHidden($, isHidden)
58 return { text: isHidden ? 'Glyphfall hidden.' : 'Glyphfall shown.' }
59 })
60
61 // Every tool call, the main loop's and the subagents', drops a word.
62 on('tool.call', async ($, e, next) => {
63 const name = String(e.tool)
64 const rec: FallTool = {
65 id: e.tool_use_id || `kz-${++seq}`,
66 at: await $.clock.now(),
67 end: null,
68 name,
69 detail: toolDetail(name, e),
70 color: toolColor(name),
71 isError: false,
72 }
73 tools = [...tools, rec].slice(-KEEP)
74 void publish($).catch(() => undefined)
75 const ran = await next(e)
76 rec.end = await $.clock.now()
77 rec.isError = ran.isError === true || ran.deny !== undefined
78 void publish($).catch(() => undefined)
79 return ran
80 }).catch(($, e, next) => next(e))
81
82 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
83 const drawn = await next(e)
84 if (e.props.hasSurvey || !e.props.isWorking || (await read($, hiddenAtom))) return drawn
85 const snap = await read($, snapAtom)
86 if (!snap || snap.tools.length === 0) return drawn
87
88 const { Box, Button } = $.ui.resolve(e)
89 const hide = <Button key="glyphfall-hide" label="✕" plain dimColor role="dismiss" onPress={() => void setHidden($, true)} />
90
91 // Every surface but the terminal draws SVG; the terminal animates a Client.
92 if (e.surface !== 'terminal') {
93 const { Svg } = $.ui.resolve(e)
94 const W = pxOf(e.props.bodyColumns)
95 const H = e.props.maxRows >= 10 ? 96 : 76
96 return (
97 <Box flexDirection="column">
98 {drawn}
99 <Box flexDirection="row">
100 <Svg source={rainSvg(snap.tools, snap.now, W, H)} alt={rainAlt(snap.tools)} width={W} height={H} />
101 {hide}
102 </Box>
103 </Box>
104 )
105 }
106
107 const { Client } = $.ui.resolve(e)
108 const cols = Math.max(24, e.props.bodyColumns || 80)
109 const rows = e.props.maxRows >= 10 ? 4 : 3
110 return (
111 <Box flexDirection="column">
112 {drawn}
113 <Box flexDirection="row">
114 <Client key="glyphfall" module="./scene.tsx" width={cols - 2} height={rows} props={{ tools: snap.tools, now: snap.now, width: cols - 2, rows }} />
115 {hide}
116 </Box>
117 </Box>
118 )
119 })
120}
121hooks/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/rain.ts 260 lines1// Glyphfall's pure drawing: the rain as a cell frame for the terminal, the
2// chip row, and one SVG for the desktop. No `$` here.
3import { KZ, clip, fitText, hash, mix, noise, svg, svgText, textWidth, toolGlyph, toolName, xml } from './lib/kz.ts'
4
5export type Tool = { id: string; at: number; end: number | null; name: string; detail: string; color: string; isError: boolean }
6export type Seg = { s: string; c?: string; bg?: string; b?: boolean; d?: boolean }
7
8/** Half-width katakana and a few symbols: each one cell wide. */
9export const GLYPHS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン012345789:.=*+<>¦╌'
10export const FADE = '#4b5563'
11
12const IN_MS = 450
13const HOLD_MS = 1500
14const MAX_HOLD_MS = 30_000
15const FALL_ROWS_S = 9
16const STAGGER_MS = 34
17
18/** The text a tool's stream spells: its name, then a piece of its detail. */
19export function wordOf(t: Pick<Tool, 'name' | 'detail'>, max: number): string {
20 const name = toolName(t.name)
21 const detail = t.detail.replace(/\s+/g, ' ').trim()
22 const word = detail ? `${name} ${detail}` : name
23 return word.length > max ? word.slice(0, Math.max(1, max)) : word
24}
25
26/** A glyph that changes now and then, per cell. */
27export function glyphAt(x: number, y: number, now: number, salt = 0): string {
28 const rate = 260 + Math.floor(noise(x, y + salt) * 900)
29 // noise is below 1, so the index stays inside GLYPHS.
30 const i = Math.floor(noise(x * 7 + salt, y * 13 + Math.floor(now / rate)) * GLYPHS.length)
31 return GLYPHS.charAt(i)
32}
33
34/** Where a word's letter `j` hangs at `now`: rows from the top, -Infinity before it starts. */
35export function letterY(t: Tool, j: number, now: number, mid: number): number {
36 const age = now - t.at - j * STAGGER_MS
37 if (age < 0) return -Infinity
38 if (age < IN_MS) {
39 const k = age / IN_MS
40 return -1 + (mid + 1) * (1 - (1 - k) * (1 - k))
41 }
42 const holdUntil = t.end === null ? t.at + MAX_HOLD_MS : Math.max(t.at + IN_MS + HOLD_MS, t.end)
43 const fallFrom = holdUntil + j * STAGGER_MS
44 if (now < fallFrom) return mid
45 return mid + ((now - fallFrom) / 1000) * FALL_ROWS_S
46}
47
48/** When a tool's word has left the band for good (Infinity while it runs). */
49function goneAt(t: Tool): number {
50 return t.end === null ? Infinity : Math.max(t.end, t.at + IN_MS + HOLD_MS) + 2500
51}
52
53/**
54 * The first column of each tool's word, so words in flight together never
55 * overlap. A word's place depends only on the words already in flight when it
56 * started, so it never moves while it falls.
57 */
58export function placeWords(tools: readonly Tool[], cols: number, maxLen: number): Map<string, number> {
59 const placed = new Map<string, number>()
60 const spans: { t: Tool; x: number; len: number }[] = []
61 for (const t of [...tools].sort((a, b) => a.at - b.at)) {
62 const len = wordOf(t, maxLen).length
63 const room = Math.max(1, cols - len)
64 const busy = spans.filter(s => goneAt(s.t) >= t.at)
65 const free = (x: number) => busy.every(s => x + len + 1 < s.x || x > s.x + s.len + 1)
66 const want = hash(t.id) % room
67 let x = want
68 for (let k = 1; k < room && !free(x); k++) {
69 const right = (want + k) % room
70 if (free(right)) {
71 x = right
72 break
73 }
74 }
75 placed.set(t.id, x)
76 spans.push({ t, x, len })
77 }
78 return placed
79}
80
81type Cell = { ch: string; c: string; b?: boolean }
82
83/** The rain: `rows` lines of `cols` cells at `now`. */
84export function rainFrame(tools: readonly Tool[], now: number, cols: number, rows: number): Seg[][] {
85 const W = Math.max(4, Math.floor(cols))
86 const H = Math.max(1, Math.floor(rows))
87 const grid: (Cell | undefined)[][] = Array.from({ length: H }, () => Array.from({ length: W }, () => undefined))
88 const latest = tools[tools.length - 1]
89 const tint = latest ? mix(latest.color, KZ.green, 0.5) : KZ.green
90 const quiet = mix(tint, FADE, 0.5)
91
92 // The quiet rain: a stream now and then per column, its period and speed its own.
93 for (let x = 0; x < W; x++) {
94 const period = 1800 + noise(x, 1) * 3400
95 const shifted = now + noise(x, 2) * period
96 const cycle = Math.floor(shifted / period)
97 if (noise(x, cycle * 3 + 5) > 0.3) continue
98 const speed = 5 + noise(x, cycle) * 9
99 const head = ((shifted % period) / 1000) * speed - 1
100 const len = 2 + noise(x, cycle + 9) * 4
101 for (let y = 0; y < H; y++) {
102 const d = head - y
103 if (d < 0 || d >= len) continue
104 grid[y]![x] = d < 1
105 ? { ch: glyphAt(x, y, now), c: mix(tint, '#e5fff0', 0.25), b: true }
106 : { ch: glyphAt(x, y, now, 3), c: mix(quiet, FADE, (d / len) * 0.8) }
107 }
108 }
109
110 // The tools: each word falls as a wave across neighbouring columns, hangs
111 // mid-band (as long as the tool runs), then drops out.
112 const mid = Math.floor((H - 1) / 2)
113 const maxLen = Math.min(28, W - 2)
114 // Every tool has its place.
115 const places = placeWords(tools, W, maxLen)
116 for (const t of tools) {
117 const word = wordOf(t, maxLen)
118 const x0 = places.get(t.id)!
119 const color = t.isError ? KZ.red : t.color
120 for (let j = 0; j < word.length; j++) {
121 const ch = word.charAt(j)
122 const y = letterY(t, j, now, mid)
123 if (!Number.isFinite(y) || y - 4 > H) continue
124 const hy = Math.round(y)
125 const x = x0 + j
126 // The tail: glyphs above the head, fading.
127 for (let d = 1; d <= 3; d++) {
128 const line = grid[hy - d]
129 if (line && x < W) line[x] = { ch: glyphAt(x, hy - d, now, 11), c: mix(color, FADE, 0.25 + d * 0.2) }
130 }
131 const line = grid[hy]
132 if (line && x < W && ch !== ' ') line[x] = { ch, c: mix(color, '#ffffff', 0.18), b: true }
133 else if (line && x < W) line[x] = undefined
134 }
135 }
136
137 return grid.map(line => {
138 const segs: Seg[] = []
139 for (const cell of line) {
140 const ch = cell?.ch ?? ' '
141 const last = segs[segs.length - 1]
142 if (last && last.c === cell?.c && last.b === cell?.b) last.s += ch
143 else segs.push({ s: ch, c: cell?.c, b: cell?.b })
144 }
145 return segs
146 })
147}
148
149/** The newest tools, newest first, as colored chips fitted to `cols`. */
150export function chipLine(tools: readonly Tool[], now: number, cols: number): Seg[] {
151 const segs: Seg[] = []
152 let used = 0
153 for (const t of [...tools].reverse().slice(0, 5)) {
154 const name = toolName(t.name)
155 const mark = t.isError ? '✖' : t.end === null ? '◐◓◑◒'.charAt(Math.floor(now / 150) % 4) : toolGlyph(t.name)
156 const chip = ` ${mark} ${name} `
157 const detail = t.detail ? ` ${clip(t.detail, 18)}` : ''
158 if (used + chip.length + 1 > cols) break
159 segs.push({ s: chip, c: '#0b0f14', bg: t.isError ? KZ.red : t.color, b: true })
160 used += chip.length
161 const room = Math.min(detail.length, cols - used - 2)
162 if (room > 3) {
163 segs.push({ s: detail.slice(0, room), d: true })
164 used += room
165 }
166 segs.push({ s: ' ' })
167 used += 2
168 }
169 return segs
170}
171
172/** The desktop drawing: rain columns falling by CSS, tool words dropping in, chips below. */
173export function rainSvg(tools: readonly Tool[], now: number, W: number, H: number): string {
174 const CH = 11 // column pitch
175 const chipH = 26
176 const RH = H - chipH - 4
177 const cols = Math.floor((W - 8) / CH)
178 const parts: string[] = []
179 const latest = tools[tools.length - 1]
180 const tint = latest ? mix(latest.color, KZ.green, 0.5) : KZ.green
181 const css = [
182 `.gfscr{fill:#060a0d}@media (prefers-color-scheme: dark){.gfscr{fill:#030507}}`,
183 `.gfcol{animation:gffall linear infinite}@keyframes gffall{from{transform:translateY(-${RH + 20}px)}to{transform:translateY(${RH + 20}px)}}`,
184 `.gfin{animation:gfin .45s cubic-bezier(.2,.8,.3,1) both}@keyframes gfin{from{transform:translateY(-${RH / 2 + 14}px);opacity:0}to{transform:none;opacity:1}}`,
185 `.gfout{animation:gfout .9s ease-in both}@keyframes gfout{from{transform:none;opacity:1}to{transform:translateY(${RH}px);opacity:0}}`,
186 `.gfhead{animation:gfh 1.2s ease-in-out infinite}@keyframes gfh{50%{opacity:.7}}`,
187 ]
188 parts.push(`<defs><filter id="gfGlow" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="1.6" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>`,
189 `<clipPath id="gfClip"><rect x="0" y="0" width="${W}" height="${RH}" rx="10"/></clipPath></defs>`)
190 parts.push(`<rect class="gfscr" x="0" y="0" width="${W}" height="${RH}" rx="10"/>`)
191 parts.push(`<g clip-path="url(#gfClip)" font-family="ui-monospace,'Cascadia Code',Consolas,monospace" font-size="12">`)
192
193 // The quiet rain: a third of the columns, each a falling streak of glyphs.
194 for (let c = 0; c < cols; c++) {
195 if (noise(c, 41) > 0.5) continue
196 const n = 4 + Math.floor(noise(c, 42) * 6)
197 const dur = 2.4 + noise(c, 43) * 3.6
198 const x = 6 + c * CH
199 const spans: string[] = []
200 for (let i = 0; i < n; i++) {
201 const isHead = i === n - 1
202 const op = isHead ? 1 : 0.2 + (i / n) * 0.65
203 spans.push(`<tspan x="${x}" dy="13" fill="${isHead ? mix(tint, '#ffffff', 0.45) : tint}" fill-opacity="${op.toFixed(2)}">${xml(glyphAt(c, i, 0, 5))}</tspan>`)
204 }
205 parts.push(`<g class="gfcol" style="animation-duration:${dur.toFixed(2)}s;animation-delay:-${((now / 1000 + noise(c, 44) * dur) % dur).toFixed(2)}s"><text y="0">${spans.join('')}</text></g>`)
206 }
207
208 // The tools: letters drop in, hang while the tool runs, fall when it ends.
209 const midY = RH / 2 + 4
210 const maxLen = Math.min(30, cols - 2)
211 const places = placeWords(tools, cols, maxLen)
212 const recent = tools.filter(t => t.end === null || now - t.end < 2500)
213 // One falling-light gradient per tool color in flight.
214 const grads = [...new Set(recent.map(t => (t.isError ? KZ.red : t.color)))]
215 parts.push(`<defs>${grads.map((c, i) => `<linearGradient id="gfT${i}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${c}" stop-opacity="0"/><stop offset=".8" stop-color="${c}" stop-opacity=".28"/><stop offset="1" stop-color="${c}" stop-opacity=".05"/></linearGradient>`).join('')}</defs>`)
216 for (const t of recent) {
217 const word = wordOf(t, maxLen)
218 const x0 = 6 + places.get(t.id)! * CH
219 const color = t.isError ? KZ.red : t.color
220 const end = t.end
221 for (let j = 0; j < word.length; j++) {
222 const ch = word.charAt(j)
223 if (ch === ' ') continue
224 const isOut = end !== null && now - t.at > IN_MS + HOLD_MS
225 const delay = isOut ? (j * STAGGER_MS - Math.max(0, now - Math.max(end, t.at + IN_MS + HOLD_MS))) / 1000 : (j * STAGGER_MS - (now - t.at)) / 1000
226 const x = x0 + j * CH
227 parts.push(`<g class="${isOut ? 'gfout' : 'gfin'}" style="animation-delay:${delay.toFixed(2)}s">`,
228 `<rect x="${x - 1}" y="${midY - 46}" width="${CH - 2}" height="50" rx="2" fill="url(#gfT${grads.indexOf(color)})"/>`,
229 `<text x="${x}" y="${midY - 26}" fill="${color}" fill-opacity=".45">${xml(glyphAt(j, 1, now, 7))}</text>`,
230 `<text x="${x}" y="${midY - 13}" fill="${color}" fill-opacity=".7">${xml(glyphAt(j, 2, now, 7))}</text>`,
231 `<text x="${x}" y="${midY}" fill="${mix(color, '#ffffff', 0.35)}" font-size="13" font-weight="700" filter="url(#gfGlow)"${t.end === null ? ' class="gfhead"' : ''}>${xml(ch)}</text>`,
232 `</g>`)
233 }
234 }
235 parts.push(`</g>`)
236
237 // The chips: the last five tools, newest first, on the theme's panel.
238 const cy = RH + 4
239 parts.push(`<rect class="p" x="0" y="${cy}" width="${W}" height="${chipH}" rx="9"/>`)
240 let cx = 8
241 for (const t of [...tools].reverse().slice(0, 5)) {
242 const color = t.isError ? KZ.red : t.color
243 const label = `${toolName(t.name)}${t.detail ? ' · ' + t.detail : ''}`
244 const text = fitText(label, 11, 190)
245 const w = textWidth(text, 11) + 30
246 if (cx + w > W - 8) break
247 parts.push(`<rect x="${cx}" y="${cy + 4}" width="${w.toFixed(1)}" height="${chipH - 8}" rx="${(chipH - 8) / 2}" fill="${color}" fill-opacity=".16" stroke="${color}" stroke-opacity=".5"/>`)
248 parts.push(`<circle cx="${cx + 11}" cy="${cy + chipH / 2}" r="3.6" fill="${color}"${t.end === null ? ' class="pulse"' : ''}/>`)
249 if (t.isError) parts.push(svgText(cx + 11, cy + chipH / 2 + 3.5, '✖', { size: 9, anchor: 'middle', fill: '#fff', weight: 700 }))
250 parts.push(svgText(cx + 20, cy + chipH / 2 + 4, text, { size: 11, weight: 550 }))
251 cx += w + 6
252 }
253 return svg(W, H, parts.join(''), css.join('\n'))
254}
255
256export function rainAlt(tools: readonly Tool[]): string {
257 const last = [...tools].reverse().slice(0, 5).map(t => `${toolName(t.name)}${t.detail ? ' ' + t.detail : ''}${t.isError ? ' (failed)' : t.end === null ? ' (running)' : ''}`)
258 return last.length ? `Tool rain. Latest tools: ${last.join('; ')}.` : 'Tool rain.'
259}
260hooks/scene.tsx 47 lines1// Glyphfall's terminal scene: the rain at 30 fps over the chip row.
2// Runs in the drawing surface; no `$` here.
3import type { ClientModule } from 'claude-code'
4
5import { chipLine, rainFrame } from './rain.ts'
6import type { Seg, Tool } from './rain.ts'
7
8type P = { tools: Tool[]; now: number; width: number; rows: number }
9type S = { t: number; sig: number; at: number }
10
11const FRAME = 33
12
13const Scene: ClientModule<P, S> = (props, surface) => {
14 const s = surface.state
15 if (s === undefined) {
16 surface.setState({ t: 0, sig: props.now, at: 0 })
17 surface.every(FRAME, () => {
18 const c = surface.state
19 if (c) surface.setState({ ...c, t: c.t + 1 })
20 })
21 } else if (s.sig !== props.now) {
22 surface.setState({ ...s, sig: props.now, at: s.t })
23 }
24 const t = s?.t ?? 0
25 const at = s && s.sig === props.now ? s.at : t
26 const now = props.now + (t - at) * FRAME
27
28 const { Box, Text } = surface.elements
29 const cols = Math.max(10, surface.columns > 0 ? surface.columns : props.width)
30 const rainRows = Math.max(1, props.rows - 1)
31 const line = (segs: readonly Seg[], key: string) => (
32 <Text key={key} wrap="truncate-end">
33 {segs.map((g, i) => (
34 <Text key={`s${i}`} color={g.c} backgroundColor={g.bg} bold={g.b} dimColor={g.d}>{g.s}</Text>
35 ))}
36 </Text>
37 )
38 return (
39 <Box flexDirection="column">
40 {rainFrame(props.tools, now, cols, rainRows).map((segs, y) => line(segs, `r${y}`))}
41 {line(chipLine(props.tools, now, cols), 'chips')}
42 </Box>
43 )
44}
45
46export default Scene
47types/index.d.ts 24 lines1/** One tool call as a falling word. */
2export type FallTool = {
3 id: string
4 at: number
5 /** When it finished; null while it runs (its word hangs mid-band until then). */
6 end: number | null
7 name: string
8 detail: string
9 color: string
10 isError: boolean
11}
12
13export type GlyphSnap = {
14 now: number
15 /** The latest tool calls, oldest first. */
16 tools: FallTool[]
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 glyphfall: { snap: GlyphSnap | null; isHidden: boolean }
22 }
23}
24