SLOPSHOPPER

glyphfall

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…

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

KOZMOS Glyphfall

Matrix rain of tool activity, above the prompt while Claude works.

  • Every tool call drops its name and a piece of its detail (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.
  • Quiet columns rain half-width katakana and symbols at low density, tinted by the latest tool.
  • The last line lists the last five tools as colored chips (a spinner while running, ✖ 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.

Türkçe

Claude çalışırken istem satırının üstünde araç etkinliğinden bir Matrix yağmuru.

  • Her araç çağrısı, adını ve ayrıntısından bir parçayı (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.
  • Sessiz sütunlara seyrek yarım genişlikte katakana ve simgeler yağar.
  • Son satır son beş aracı renkli etiketler olarak gösterir.

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.

Source 5 files
hooks/register.tsx 121 lines
1import { 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}
121
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/rain.ts 260 lines
1// 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}
260
hooks/scene.tsx 47 lines
1// 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
47
types/index.d.ts 24 lines
1/** 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