KOZMOS Laurels: 32 achievements earned from what the session does (Hydra, Night Owl, Cache Wizard, Bug Squasher, Polyglot, Centurion, Committed and more), kept…

Achievements for what you and Claude get up to: 32 badges, each with a glyph, a description and a check over the session's events. Counters and unlock dates are kept across sessions.
A few of them: First Light (first turn), Hydra (five subagents running at once), Night Owl (a turn between 00:00 and 05:00), Marathon (a two-hour session), Big Spender ($10 session), Penny Pincher (ten tools in a turn under $0.05), Cache Wizard (cache reads over 90 % of a turn's input), Context Surfer (starting a turn past 90 % context), Bug Squasher (a failed shell command later passes, same command), Polyglot (five file extensions edited), Speed Demon, Centurion, Architect, Archaeologist, Committed, Shipwright, Test Pilot, Delegator, Taskmaster, Switchboard, Undaunted, Deep Thinker, Haiku, Clean Slate, Token Tsunami, Lab Notes, Devotee, Regular and more.
On an unlock: a toast (🏆 Hydra unlocked — five agents at once) and a six-second celebration band: a hopping trophy, the name in a travelling shimmer and drifting confetti on the terminal; a medal popping in over turning rays with falling confetti on the desktop.
/laurels opens the gallery: unlocked badges in color with their dates, locked ones gray with a hint and a progress bar for counter badges, and the total (17/32). On the desktop it is a grid of medals with ribbons and a wreath; recent unlocks glow and shine. /laurels hide / show (or ✕) controls the celebration band.
Oturumda yaptıklarınız için başarımlar: 32 rozet, her birinin simgesi, açıklaması ve olaylar üzerinden bir kontrolü var; sayaçlar ve açılma tarihleri oturumlar arasında saklanır (Hydra, Night Owl, Cache Wizard, Bug Squasher, Polyglot, Centurion, Committed ve dahası).
Açılınca bildirim ve 6 saniyelik kutlama bandı çıkar (terminalde zıplayan kupa ve konfeti, masaüstünde dönen ışınlar ve düşen konfeti). /laurels galeriyi açar: açılanlar renkli ve tarihli, kilitliler gri, ipucu ve ilerleme çubuğuyla; toplam 17/32. Masaüstünde kurdeleli madalya ızgarası, yeni açılanlar parlar.
hooks/register.tsx 578 lines1import { atom, read, update } from 'claude-code'
2import type { BoxProps, ElementConstructor, EngineInterface, Register, TextProps } from 'claude-code'
3
4import type { LaurelsBurst } from '../types'
5import { KZ, clamp01, costOf, fitText, mix, pips, pxOf, svg, svgBar, svgText, textWidth, tokensOf, xml } from './lib/kz.ts'
6import {
7 BADGES, badgeById, dayKey, emptyFacts, extOf, isGitCommit, isGitPush, isTestRun, mcpServerOf, newlyUnlocked,
8 normCommand, progressOf, sanitizeLife, todosDone, turnFlags,
9} from './badges.ts'
10import type { Badge, Facts, TurnFacts } from './badges.ts'
11
12const PANE = 'kz-laurels'
13const TITLE = 'KOZMOS · Laurels'
14const BURST_MS = 6000
15const RECENT_MS = 24 * 3600_000
16
17const unlockedAtom = atom({ plugin: 'laurels', key: 'unlocked' } as const, {})
18const progressAtom = atom({ plugin: 'laurels', key: 'progress' } as const, {})
19const burstAtom = atom({ plugin: 'laurels', key: 'burst' } as const, null)
20const hiddenAtom = atom({ plugin: 'laurels', key: 'isHidden' } as const, false)
21
22// Module memory; session.start fills it from $.store.
23let facts: Facts = emptyFacts()
24let unlocked: Record<string, number> = {}
25let turn: (TurnFacts & { files: number; paths: string[] }) | null = null
26let failedCommands = new Set<string>()
27let lastTodos: { content: string; status: string }[] = []
28let ctxPercent = 0
29let queue: string[] = []
30let isBursting = false
31let lastProgress = ''
32/** Badges an evaluation is unlocking right now, so a concurrent one leaves them be. */
33const claiming = new Set<string>()
34
35function sanitizeUnlocked(v: unknown): Record<string, number> {
36 const out: Record<string, number> = {}
37 if (v && typeof v === 'object') for (const [k, t] of Object.entries(v as Record<string, unknown>)) if (typeof t === 'number' && badgeById(k)) out[k] = t
38 return out
39}
40
41async function persist($: EngineInterface): Promise<void> {
42 try {
43 await $.store.set('unlocked', unlocked)
44 await $.store.set('life', facts.life)
45 } catch {
46 // Kept for this session.
47 }
48}
49
50async function showNext($: EngineInterface): Promise<void> {
51 const id = queue.shift()
52 if (id === undefined) {
53 isBursting = false
54 await update($, burstAtom, () => null)
55 return
56 }
57 isBursting = true
58 const burst: LaurelsBurst = { id, at: await $.clock.now(), more: queue.length }
59 await update($, burstAtom, () => burst)
60 $.clock.after(BURST_MS, () => void showNext($).catch(() => undefined))
61}
62
63async function evaluate($: EngineInterface): Promise<void> {
64 // Tool calls run in parallel: claim the fresh badges before the first wait,
65 // or two evaluations would both unlock (and toast) the same one.
66 const fresh = newlyUnlocked(facts, unlocked).filter(b => !claiming.has(b.id))
67 for (const b of fresh) claiming.add(b.id)
68 try {
69 const progress = progressOf(facts)
70 const key = JSON.stringify(progress)
71 if (key !== lastProgress) {
72 lastProgress = key
73 await update($, progressAtom, () => progress)
74 }
75 if (fresh.length === 0) return
76 const now = await $.clock.now()
77 for (const b of fresh) {
78 unlocked[b.id] = now
79 $.ui.toast(`🏆 ${b.name} unlocked — ${b.cheer}`, { timeoutMs: 7000 })
80 queue.push(b.id)
81 }
82 const snapshot = { ...unlocked }
83 await update($, unlockedAtom, () => snapshot)
84 await persist($)
85 if (!isBursting) await showNext($)
86 else await update($, burstAtom, b => (b ? { ...b, more: queue.length } : b))
87 } finally {
88 for (const b of fresh) claiming.delete(b.id)
89 }
90}
91
92async function poll($: EngineInterface): Promise<void> {
93 try {
94 const u = await $.session.usage()
95 facts.costUsd = u.cost?.usd ?? facts.costUsd
96 ctxPercent = u.context.percent ?? ctxPercent
97 if (u.startedAt > 0) facts.sessionMs = Math.max(0, (await $.clock.now()) - u.startedAt)
98 } catch {
99 // Figures the engine cannot give now stay as they were.
100 }
101 await countRunning($)
102 await evaluate($)
103}
104
105async function countRunning($: EngineInterface): Promise<void> {
106 try {
107 const agents = await $.agent.list()
108 const running = agents.filter(a => a.status === 'running' || a.status === 'pending' || a.status === 'waiting').length
109 facts.maxRunning = Math.max(facts.maxRunning, running)
110 } catch {
111 // No agent list here.
112 }
113}
114
115async function setHidden($: EngineInterface, value: boolean): Promise<void> {
116 await update($, hiddenAtom, () => value)
117 try {
118 await $.store.set('hidden', value)
119 } catch {
120 // Session only.
121 }
122}
123
124async function togglePane($: EngineInterface): Promise<boolean> {
125 if ((await $.ui.panes()).some(p => p.id === PANE)) {
126 await $.ui.close({ id: PANE })
127 return false
128 }
129 await $.ui.open({ id: PANE, title: TITLE })
130 return true
131}
132
133function str(e: unknown, k: string): string {
134 const v = (e as Record<string, unknown>)[k]
135 return typeof v === 'string' ? v : ''
136}
137
138function noteTool(tool: string, e: unknown, failed: boolean): void {
139 facts.tools++
140 facts.life.tools++
141 if (turn) turn.tools++
142 if (failed) {
143 facts.fails++
144 if (turn) turn.fails++
145 }
146 if (tool === 'Read') facts.reads++
147 if (tool === 'Grep' || tool === 'Glob') facts.searches++
148 if (tool === 'WebFetch' || tool === 'WebSearch') facts.web++
149 const server = mcpServerOf(tool)
150 if (server && !failed && !facts.mcpServers.includes(server)) facts.mcpServers.push(server)
151 if (!failed && (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit' || tool === 'MultiEdit')) {
152 const path = str(e, 'file_path') || str(e, 'notebook_path')
153 const ext = extOf(path)
154 if (ext && !facts.exts.includes(ext)) facts.exts.push(ext)
155 if (tool === 'NotebookEdit' || ext === '.ipynb') facts.flags.labNotes = true
156 if (turn && path && !turn.paths.includes(path)) {
157 turn.paths.push(path)
158 turn.files = turn.paths.length
159 facts.bestFilesInTurn = Math.max(facts.bestFilesInTurn, turn.files)
160 }
161 }
162 if (tool === 'Bash' || tool === 'PowerShell') {
163 const cmd = normCommand(str(e, 'command'))
164 if (cmd) {
165 if (failed) failedCommands.add(cmd)
166 else {
167 if (failedCommands.has(cmd)) facts.flags.bugSquasher = true
168 if (isGitCommit(cmd)) facts.flags.committed = true
169 if (isGitPush(cmd)) facts.flags.pushed = true
170 if (isTestRun(cmd)) facts.flags.testPilot = true
171 }
172 }
173 }
174 if (!failed && tool === 'TodoWrite') {
175 const todos = (e as Record<string, unknown>).todos
176 if (Array.isArray(todos)) {
177 const list = todos.map(t => ({ content: String((t as Record<string, unknown>).content ?? ''), status: String((t as Record<string, unknown>).status ?? '') }))
178 facts.life.tasksDone += todosDone(lastTodos, list)
179 lastTodos = list
180 }
181 }
182 if (!failed && tool === 'TaskUpdate' && str(e, 'status') === 'completed') facts.life.tasksDone++
183}
184
185export const register: Register = on => {
186 on('session.start', async ($, e, next) => {
187 const started = await next(e)
188 turn = null
189 failedCommands = new Set()
190 lastTodos = []
191 ctxPercent = 0
192 queue = []
193 isBursting = false
194 lastProgress = ''
195 try {
196 unlocked = sanitizeUnlocked(await $.store.get('unlocked'))
197 facts = emptyFacts(sanitizeLife(await $.store.get('life')))
198 const isHidden = (await $.store.get('hidden')) === true
199 await update($, hiddenAtom, () => isHidden)
200 } catch {
201 unlocked = {}
202 facts = emptyFacts()
203 }
204 const snapshot = { ...unlocked }
205 await update($, unlockedAtom, () => snapshot)
206 await update($, burstAtom, () => null)
207 await $.command.register({ name: 'laurels', description: 'KOZMOS: open the achievements gallery (hide / show the unlock band)', argumentHint: '[hide | show]', immediate: true })
208 await evaluate($).catch(() => undefined)
209 $.clock.every(5000, () => void poll($).catch(() => undefined))
210 return started
211 })
212
213 on('command.run', { command: 'laurels' }, async ($, e) => {
214 const verb = e.args.trim().toLowerCase()
215 if (verb === 'hide' || verb === 'show') {
216 await setHidden($, verb === 'hide')
217 return { text: verb === 'hide' ? 'Laurels celebrations hidden (toasts still show).' : 'Laurels celebrations shown.' }
218 }
219 const open = await togglePane($)
220 return { text: open ? `Laurels: ${Object.keys(unlocked).length}/${BADGES.length} unlocked.` : 'Laurels closed.' }
221 })
222
223 on('session.measure', async ($, e, next) => {
224 const r = await next(e)
225 ctxPercent = e.context.percent ?? ctxPercent
226 if (e.cost) facts.costUsd = e.cost.usd
227 if (e.changed.includes('cost')) await evaluate($)
228 return r
229 }).catch(($, e, next) => next(e))
230
231 on('turn.start', async ($, e, next) => {
232 const now = await $.clock.now()
233 turn = { startedAt: now, durationMs: 0, tools: 0, fails: 0, files: 0, paths: [], steps: 0, estUsd: 0, input: 0, cacheRead: 0, cacheWrite: 0, reason: '' }
234 if (ctxPercent >= 90) {
235 facts.flags.contextSurfer = true
236 await evaluate($)
237 }
238 return next(e)
239 }).catch(($, e, next) => next(e))
240
241 on('turn.step', async function* ($, e, next) {
242 const r = yield* next(e)
243 if (r.usage) {
244 facts.tokens += tokensOf(r.usage)
245 if (turn) {
246 turn.steps++
247 turn.estUsd += costOf(r.usage.model || e.model, r.usage)
248 if (e.agentId === undefined) {
249 turn.input += r.usage.input_tokens
250 turn.cacheRead += r.usage.cache_read_input_tokens
251 turn.cacheWrite += r.usage.cache_creation_input_tokens
252 }
253 }
254 }
255 return r
256 })
257
258 on('tool.call', async ($, e, next) => {
259 const ran = await next(e)
260 noteTool(String(e.tool), e, ran.isError === true || ran.deny !== undefined)
261 await evaluate($)
262 return ran
263 }).catch(($, e, next) => next(e))
264
265 on('agent.spawn', async ($, e, next) => {
266 const r = await next(e)
267 if ((r as { agentId?: string }).agentId) {
268 facts.agents++
269 await countRunning($)
270 await evaluate($)
271 }
272 return r
273 }).catch(($, e, next) => next(e))
274
275 on('turn.complete', async ($, e, next) => {
276 const done = await next(e)
277 if (e.agentId !== undefined || !turn) return done
278 const t = { ...turn, durationMs: e.durationMs, reason: e.reason }
279 turn = null
280 const d = new Date(t.startedAt)
281 for (const f of turnFlags(t, d.getHours(), d.getDay())) facts.flags[f] = true
282 facts.turns++
283 facts.life.turns++
284 const day = dayKey(t.startedAt)
285 if (!facts.life.days.includes(day)) facts.life.days = [...facts.life.days, day].slice(-60)
286 if (facts.tools >= 20 && facts.fails === 0) facts.flags.cleanSlate = true
287 await evaluate($)
288 await persist($)
289 return done
290 }).catch(($, e, next) => next(e))
291
292 // ---- the celebration band -------------------------------------------------
293 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
294 const drawn = await next(e)
295 const burst = await read($, burstAtom)
296 // A burst names a badge of this book (one renamed since a reload draws nothing).
297 const b = burst ? badgeById(burst.id) : undefined
298 if (!burst || !b || e.props.hasSurvey || (await read($, hiddenAtom))) return drawn
299 const cols = Math.max(40, e.props.bodyColumns || 80)
300 let mine
301 if (e.surface === 'terminal') {
302 const { Client } = $.ui.resolve(e)
303 mine = <Client key="laurels-burst" module="./burst.tsx" width={cols - 3} height={1} props={{ cols: cols - 3, name: b.name, glyph: b.glyph, color: b.color, cheer: b.cheer, more: burst.more }} />
304 } else {
305 // Every other surface draws Svg.
306 const { Svg } = $.ui.resolve(e)
307 const W = pxOf(cols)
308 mine = <Svg source={burstSvg(b, W, burst.more)} alt={`Achievement unlocked: ${b.name}. ${b.desc}`} width={W} height={44} />
309 }
310 const { Box, Button } = $.ui.resolve(e)
311 const hide = <Button key="laurels-hide" label="✕" plain dimColor role="dismiss" onPress={() => void setHidden($, true)} />
312 return (
313 <Box flexDirection="column">
314 {drawn}
315 <Box key="laurels" flexDirection="row">
316 {mine}
317 {hide}
318 </Box>
319 </Box>
320 )
321 })
322
323 // ---- the gallery ----------------------------------------------------------
324 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
325 const got = await read($, unlockedAtom)
326 const progress = await read($, progressAtom)
327 const now = await $.clock.now()
328 const ui = $.ui.resolve(e)
329 const { Box, Text } = ui
330 const cols = Math.max(36, e.props.bodyColumns || 60)
331 const ordered = orderBadges(got, progress)
332 const count = Object.keys(got).length
333
334 if ('Svg' in ui && e.surface !== 'terminal') {
335 const { Svg } = ui
336 const W = pxOf(cols, 60)
337 const head = galleryHeader(W, count, got)
338 const rows = medalRows(ordered, got, progress, now, W)
339 return (
340 <Box flexDirection="column">
341 <Svg key="lh" source={head} alt={`Laurels: ${count} of ${BADGES.length} unlocked`} width={W} height={64} />
342 {rows.map((r, i) => <Svg key={`lr${i}`} source={r.source} alt={r.alt} width={W} height={r.height} />)}
343 </Box>
344 )
345 }
346
347 const twoCols = cols >= 100
348 const colW = twoCols ? Math.floor((cols - 2) / 2) : cols
349 const lines = ordered.map(b => badgeLine(b, got[b.id], progress[b.id] ?? 0, colW, Box, Text))
350 const grid = twoCols
351 ? Array.from({ length: Math.ceil(lines.length / 2) }, (_, i) => (
352 <Box key={`g${i}`} flexDirection="row">
353 {lines.slice(i * 2, i * 2 + 2).map((line, j) => <Box key={`c${j}`} width={colW} marginRight={j === 0 ? 2 : 0}>{line}</Box>)}
354 </Box>
355 ))
356 : lines
357 return (
358 <Box flexDirection="column">
359 <Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
360 <Text bold color={KZ.yellow}>🏆 LAURELS</Text>
361 <Text>
362 <Text color={KZ.yellow}>{pips(count / BADGES.length, Math.min(20, Math.max(6, cols - 30)))}</Text>
363 <Text bold> {count}/{BADGES.length}</Text>
364 </Text>
365 </Box>
366 {grid}
367 </Box>
368 )
369 })
370}
371
372// ---------------------------------------------------------------------------
373// Drawing.
374
375const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
376
377function dateOf(ms: number): string {
378 const d = new Date(ms)
379 return `${MONTHS[d.getMonth()]} ${d.getDate()}`
380}
381
382/** Unlocked first (newest first), then locked by how close they are. */
383function orderBadges(got: Readonly<Record<string, number>>, progress: Readonly<Record<string, number>>): Badge[] {
384 const done = BADGES.filter(b => got[b.id] !== undefined).sort((a, b) => Number(got[b.id]) - Number(got[a.id]))
385 const left = BADGES.filter(b => got[b.id] === undefined).sort((a, b) => (progress[b.id] ?? 0) / b.goal - (progress[a.id] ?? 0) / a.goal)
386 return [...done, ...left]
387}
388
389function fmtGoal(b: Badge, v: number): string {
390 if (b.id === 'big-spender') return `$${v.toFixed(2)}/$${b.goal}`
391 if (b.id === 'marathon') return `${Math.floor(v / 60)}h${String(Math.floor(v % 60)).padStart(2, '0')}/2h`
392 if (b.goal >= 100_000) return `${Math.round((v / b.goal) * 100)}%`
393 return `${Math.floor(v)}/${b.goal}`
394}
395
396
397function badgeLine(b: Badge, at: number | undefined, value: number, width: number, Box: ElementConstructor<BoxProps>, Text: ElementConstructor<TextProps>) {
398 if (at !== undefined) {
399 return (
400 <Box key={b.id} flexDirection="row">
401 <Box width={3} flexShrink={0}><Text color={b.color}>{b.glyph}</Text></Box>
402 <Text wrap="truncate-end">
403 <Text bold color={b.color}>{b.name}</Text>
404 <Text dimColor> {dateOf(at)} · {b.desc}</Text>
405 </Text>
406 </Box>
407 )
408 }
409 const isCounter = b.goal > 1
410 const barW = Math.max(4, Math.min(10, width - b.name.length - 18))
411 return (
412 <Box key={b.id} flexDirection="row">
413 <Box width={3} flexShrink={0}><Text dimColor>○</Text></Box>
414 <Text wrap="truncate-end">
415 <Text dimColor bold>{b.name} </Text>
416 {isCounter ? <Text color="#6b7280">{pips(clamp01(value / b.goal), barW)} {fmtGoal(b, value)} </Text> : null}
417 <Text dimColor>· {b.hint}</Text>
418 </Text>
419 </Box>
420 )
421}
422
423function galleryHeader(W: number, count: number, got: Readonly<Record<string, number>>): string {
424 const latest = Object.entries(got).sort((a, b) => b[1] - a[1])[0]
425 const lb = latest ? badgeById(latest[0]) : undefined
426 const parts: string[] = []
427 parts.push(`<defs><linearGradient id="lhG" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#f59e0b"/><stop offset="1" stop-color="#fde047"/></linearGradient></defs>`)
428 parts.push(`<rect class="p" x="0" y="0" width="${W}" height="56" rx="14"/>`)
429 parts.push(`<text x="16" y="36" font-size="24">🏆</text>`)
430 parts.push(svgText(52, 25, 'LAURELS', { size: 11, weight: 800, fill: '#d97706' }))
431 parts.push(svgText(52, 44, `${count} of ${BADGES.length} unlocked`, { size: 15, weight: 700 }))
432 const bx = Math.max(220, W * 0.45)
433 const bw = W - bx - 16
434 if (bw > 60) {
435 parts.push(svgBar(bx, 20, bw, 8, count / BADGES.length, 'url(#lhG)'))
436 if (lb && latest) parts.push(svgText(W - 16, 45, fitText(`latest · ${lb.name} · ${dateOf(latest[1])}`, 10.5, bw), { cls: 'm', size: 10.5, anchor: 'end' }))
437 }
438 return svg(W, 64, parts.join(''))
439}
440
441/** Splits text into at most `max` lines of `width` px. */
442function wrapText(s: string, size: number, width: number, max: number): string[] {
443 const lines: string[] = []
444 let cur = ''
445 for (const w of s.split(' ')) {
446 const next = cur ? `${cur} ${w}` : w
447 if (textWidth(next, size) > width && cur) {
448 lines.push(cur)
449 cur = w
450 if (lines.length === max) break
451 } else cur = next
452 }
453 if (lines.length < max && cur) lines.push(cur)
454 if (lines.length === max && textWidth(lines.join(' '), size) < textWidth(s, size) - 1) lines[max - 1] = fitText(`${lines[max - 1]} …`, size, width)
455 return lines
456}
457
458const MEDAL_CSS = `
459.lr-lockg{filter:grayscale(1);opacity:.4}
460.lr-shn{animation:lsh 3.2s ease-in-out infinite}@keyframes lsh{0%{transform:translateX(-70px)}55%,100%{transform:translateX(70px)}}
461.lr-glow{transform-box:fill-box;transform-origin:center;animation:lgl 2.4s ease-in-out infinite}@keyframes lgl{50%{transform:scale(1.08);opacity:.15}}
462.lr-pop{transform-box:fill-box;transform-origin:center;animation:lpop .6s cubic-bezier(.2,1.6,.4,1) both}@keyframes lpop{from{transform:scale(.4);opacity:0}}
463`
464
465function medal(b: Badge, cx: number, cy: number, at: number | undefined, value: number, now: number, idx: string): string {
466 const R = 30
467 if (at === undefined) {
468 let s = `<circle class="k" cx="${cx}" cy="${cy}" r="${R}"/><circle cx="${cx}" cy="${cy}" r="${R - 6}" fill="none" class="ln" stroke-width="1.5" stroke-dasharray="3 4"/>`
469 if (b.goal > 1 && value > 0) {
470 const C = 2 * Math.PI * (R - 2)
471 const p = clamp01(value / b.goal)
472 s += `<circle cx="${cx}" cy="${cy}" r="${R - 2}" fill="none" stroke="${b.color}" stroke-width="3" stroke-linecap="round" stroke-dasharray="${(C * p).toFixed(1)} ${C.toFixed(1)}" transform="rotate(-90 ${cx} ${cy})" opacity=".8"/>`
473 }
474 s += `<text class="lr-lockg" x="${cx}" y="${cy + 8}" font-size="22" text-anchor="middle">${xml(b.glyph)}</text>`
475 return s
476 }
477 const isRecent = now - at < RECENT_MS
478 const light = mix(b.color, '#ffffff', 0.55)
479 const dark = mix(b.color, '#000000', 0.35)
480 let s = `<defs><radialGradient id="lmf-${idx}" cx=".38" cy=".32" r=".8"><stop offset="0" stop-color="${light}"/><stop offset=".55" stop-color="${b.color}"/><stop offset="1" stop-color="${dark}"/></radialGradient>` +
481 `<linearGradient id="lmr-${idx}" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fde68a"/><stop offset=".5" stop-color="#d97706"/><stop offset="1" stop-color="#fbbf24"/></linearGradient>` +
482 `<clipPath id="lmc-${idx}"><circle cx="${cx}" cy="${cy}" r="${R}"/></clipPath></defs>`
483 // Ribbon tails behind the medal.
484 s += `<path d="M${cx - 13} ${cy + 18}l-6 22 8-5 5 7 5-22z" fill="${dark}"/><path d="M${cx + 13} ${cy + 18}l6 22-8-5-5 7-5-22z" fill="${b.color}"/>`
485 if (isRecent) s += `<circle class="lr-glow" cx="${cx}" cy="${cy}" r="${R + 4}" fill="${b.color}" opacity=".3"/>`
486 s += `<g class="${isRecent ? 'lr-pop' : ''}"><circle cx="${cx}" cy="${cy}" r="${R}" fill="url(#lmr-${idx})"/><circle cx="${cx}" cy="${cy}" r="${R - 5}" fill="url(#lmf-${idx})"/>`
487 // A laurel wreath: five leaves up each side of the lower rim, tangent to it.
488 for (let i = 0; i < 5; i++) {
489 for (const deg of [105 + i * 15, 75 - i * 15]) {
490 const a = (deg * Math.PI) / 180
491 const x = (cx + Math.cos(a) * (R - 8)).toFixed(1)
492 const y = (cy + Math.sin(a) * (R - 8)).toFixed(1)
493 s += `<ellipse cx="${x}" cy="${y}" rx="1.9" ry="4.2" fill="#fef3c7" opacity=".6" transform="rotate(${deg} ${x} ${y})"/>`
494 }
495 }
496 s += `<text x="${cx}" y="${cy + 8}" font-size="22" text-anchor="middle">${xml(b.glyph)}</text>`
497 if (isRecent) s += `<g clip-path="url(#lmc-${idx})"><rect class="lr-shn" x="${cx - 12}" y="${cy - R}" width="14" height="${R * 2}" fill="#ffffff" opacity=".45" transform="skewX(-20)"/></g>`
498 s += `</g>`
499 return s
500}
501
502function medalRows(list: readonly Badge[], got: Readonly<Record<string, number>>, progress: Readonly<Record<string, number>>, now: number, W: number): { source: string; height: number; alt: string }[] {
503 const gap = 8
504 const per = Math.max(2, Math.floor((W + gap) / (128 + gap)))
505 const cw = (W - gap * (per - 1)) / per
506 const H = 150
507 const rows: { source: string; height: number; alt: string }[] = []
508 for (let r = 0; r * per < list.length; r++) {
509 const parts: string[] = []
510 const alts: string[] = []
511 list.slice(r * per, r * per + per).forEach((b, i) => {
512 const x = i * (cw + gap)
513 const at = got[b.id]
514 const v = progress[b.id] ?? 0
515 const cx = x + cw / 2
516 parts.push(`<g><title>${xml(`${b.name}: ${at !== undefined ? b.desc : b.hint}`)}</title>`)
517 parts.push(`<rect class="p" x="${x}" y="0" width="${cw}" height="${H - 6}" rx="14"/>`)
518 parts.push(medal(b, cx, 42, at, v, now, b.id))
519 parts.push(svgText(cx, 96, fitText(b.name, 12, cw - 12), { size: 12, weight: 700, anchor: 'middle', cls: at !== undefined ? 't' : 'm' }))
520 const body = wrapText(at !== undefined ? b.desc : b.hint, 9.5, cw - 16, 2)
521 body.forEach((line, j) => parts.push(svgText(cx, 110 + j * 12, line, { size: 9.5, anchor: 'middle', cls: at !== undefined ? 's' : 'm' })))
522 const foot = at !== undefined ? dateOf(at) : b.goal > 1 ? fmtGoal(b, v) : 'locked'
523 parts.push(svgText(cx, 138, foot, { size: 9.5, weight: 600, anchor: 'middle', fill: at !== undefined ? b.color : undefined, cls: 'm' }))
524 parts.push(`</g>`)
525 alts.push(`${b.name} ${at !== undefined ? `unlocked ${dateOf(at)}` : `locked${b.goal > 1 ? ` ${fmtGoal(b, v)}` : ''}`}`)
526 })
527 rows.push({ source: svg(W, H, parts.join(''), MEDAL_CSS), height: H, alt: alts.join('; ') })
528 }
529 return rows
530}
531
532/** The 1-row desktop celebration: a medal popping in, rays turning, confetti falling. */
533function burstSvg(b: Badge, W: number, more: number): string {
534 const css = `
535.lr-rays{transform-box:fill-box;transform-origin:center;animation:lrray 6s linear infinite}@keyframes lrray{to{transform:rotate(360deg)}}
536.lr-bpop{transform-box:fill-box;transform-origin:center;animation:lbpop .7s cubic-bezier(.2,1.7,.4,1) both}@keyframes lbpop{from{transform:scale(.2);opacity:0}}
537.lr-slide{animation:lsl .6s cubic-bezier(.2,.8,.2,1) .15s both}@keyframes lsl{from{opacity:0;transform:translateX(-10px)}}
538.lr-cf{animation:lcf 2.2s linear infinite}@keyframes lcf{from{transform:translateY(-14px) rotate(0)}to{transform:translateY(52px) rotate(260deg)}}
539.lr-cf{transform-box:fill-box;transform-origin:center}
540.lr-sweep{animation:lsw${W} 1.8s ease-out .3s both}@keyframes lsw${W}{from{transform:translateX(-200px)}to{transform:translateX(${W + 200}px)}}
541`
542 const parts: string[] = []
543 parts.push(`<defs><linearGradient id="lbg-${b.id}" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="${b.color}" stop-opacity=".28"/><stop offset=".6" stop-color="${b.color}" stop-opacity=".08"/><stop offset="1" stop-color="${b.color}" stop-opacity="0"/></linearGradient>` +
544 `<linearGradient id="lsw" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".5" stop-color="#fff" stop-opacity=".35"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>` +
545 `<clipPath id="lbc${W}"><rect x="0" y="0" width="${W}" height="44" rx="12"/></clipPath></defs>`)
546 parts.push(`<rect class="p" x="0" y="0" width="${W}" height="44" rx="12"/><rect x="0" y="0" width="${W}" height="44" rx="12" fill="url(#lbg-${b.id})"/>`)
547 parts.push(`<g clip-path="url(#lbc${W})">`)
548 // Confetti: seeded pieces falling at their own pace.
549 const hues = ['#f472b6', '#facc15', '#4ade80', '#22d3ee', '#a78bfa', '#fb923c']
550 for (let i = 0; i < 26; i++) {
551 const x = 220 + ((i * 97.3) % Math.max(60, W - 240))
552 const delay = ((i * 0.37) % 2.2).toFixed(2)
553 const dur = (1.8 + ((i * 0.53) % 1.4)).toFixed(2)
554 const c = hues[i % hues.length]
555 parts.push(i % 3 === 0
556 ? `<circle class="lr-cf" style="animation-delay:-${delay}s;animation-duration:${dur}s" cx="${x.toFixed(0)}" cy="0" r="1.8" fill="${c}"/>`
557 : `<rect class="lr-cf" style="animation-delay:-${delay}s;animation-duration:${dur}s" x="${x.toFixed(0)}" y="-2" width="3" height="6" rx="1" fill="${c}"/>`)
558 }
559 // Rays behind the medal.
560 const cx = 26
561 const cy = 22
562 let rays = ''
563 for (let i = 0; i < 12; i++) {
564 const a = (i * Math.PI) / 6
565 rays += `<path d="M${cx} ${cy}L${(cx + Math.cos(a - 0.12) * 26).toFixed(1)} ${(cy + Math.sin(a - 0.12) * 26).toFixed(1)}L${(cx + Math.cos(a + 0.12) * 26).toFixed(1)} ${(cy + Math.sin(a + 0.12) * 26).toFixed(1)}Z" fill="${b.color}" opacity=".22"/>`
566 }
567 parts.push(`<g class="lr-rays">${rays}</g>`)
568 parts.push(`<g class="lr-bpop"><circle cx="${cx}" cy="${cy}" r="16" fill="#f59e0b"/><circle cx="${cx}" cy="${cy}" r="13" fill="${b.color}"/><text x="${cx}" y="${cy + 6}" font-size="15" text-anchor="middle">${xml(b.glyph)}</text></g>`)
569 parts.push(`<g class="lr-slide">`)
570 parts.push(svgText(52, 17, more > 0 ? `ACHIEVEMENT UNLOCKED · +${more} MORE` : 'ACHIEVEMENT UNLOCKED', { size: 9.5, weight: 800, fill: '#d97706' }))
571 parts.push(svgText(52, 35, b.name, { size: 15, weight: 750 }))
572 parts.push(svgText(52 + textWidth(b.name, 15) + 10, 35, fitText(`— ${b.cheer}`, 12, Math.max(40, W - 120 - textWidth(b.name, 15))), { cls: 's', size: 12 }))
573 parts.push(`</g>`)
574 parts.push(`<rect class="lr-sweep" x="0" y="0" width="160" height="44" fill="url(#lsw)"/>`)
575 parts.push(`</g>`)
576 return svg(W, 44, parts.join(''), css)
577}
578hooks/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/badges.ts 198 lines1// Laurels' badge book, pure: the facts a session gathers, the badges read
2// over them, and the small classifiers the hooks feed them with.
3
4export type Flag =
5 | 'nightOwl' | 'earlyBird' | 'weekend' | 'pennyPincher' | 'cacheWizard' | 'contextSurfer'
6 | 'bugSquasher' | 'speedDemon' | 'committed' | 'pushed' | 'testPilot' | 'undaunted'
7 | 'deepThinker' | 'haiku' | 'cleanSlate' | 'labNotes'
8
9export type Facts = {
10 /** This session. */
11 turns: number
12 tools: number
13 fails: number
14 reads: number
15 searches: number
16 web: number
17 agents: number
18 maxRunning: number
19 costUsd: number
20 sessionMs: number
21 tokens: number
22 exts: string[]
23 mcpServers: string[]
24 bestFilesInTurn: number
25 flags: Partial<Record<Flag, true>>
26 /** Every session, kept in $.store. */
27 life: Lifetime
28}
29
30export type Lifetime = { turns: number; tools: number; tasksDone: number; days: string[] }
31
32export function emptyLife(): Lifetime {
33 return { turns: 0, tools: 0, tasksDone: 0, days: [] }
34}
35
36export function emptyFacts(life: Lifetime = emptyLife()): Facts {
37 return {
38 turns: 0, tools: 0, fails: 0, reads: 0, searches: 0, web: 0, agents: 0, maxRunning: 0,
39 costUsd: 0, sessionMs: 0, tokens: 0, exts: [], mcpServers: [], bestFilesInTurn: 0, flags: {}, life,
40 }
41}
42
43export type Badge = {
44 id: string
45 name: string
46 glyph: string
47 color: string
48 /** What it is for, shown once unlocked. */
49 desc: string
50 /** How to get it, shown while locked. */
51 hint: string
52 /** The toast's flourish: `🏆 Hydra unlocked — <cheer>`. */
53 cheer: string
54 /** Counter badges show a progress bar toward `goal`; flag badges have goal 1. */
55 goal: number
56 value: (f: Facts) => number
57}
58
59const flag = (k: Flag) => (f: Facts) => (f.flags[k] ? 1 : 0)
60
61export const BADGES: readonly Badge[] = [
62 { id: 'first-light', name: 'First Light', glyph: '✦', color: '#facc15', desc: 'Finished your very first turn.', hint: 'Finish a turn.', cheer: 'the first of many', goal: 1, value: f => Math.min(1, f.life.turns) },
63 { id: 'hydra', name: 'Hydra', glyph: '🐉', color: '#a78bfa', desc: 'Five subagents running at once.', hint: 'Have five subagents running at the same time.', cheer: 'five agents at once', goal: 5, value: f => f.maxRunning },
64 { id: 'delegator', name: 'Delegator', glyph: '◈', color: '#c084fc', desc: 'Ten subagents in one session.', hint: 'Spawn ten subagents in a session.', cheer: 'ten agents dispatched', goal: 10, value: f => f.agents },
65 { id: 'night-owl', name: 'Night Owl', glyph: '🦉', color: '#818cf8', desc: 'A turn between midnight and 5 a.m.', hint: 'Work between 00:00 and 05:00.', cheer: 'the moon approves', goal: 1, value: flag('nightOwl') },
66 { id: 'early-bird', name: 'Early Bird', glyph: '🌅', color: '#fb923c', desc: 'A turn between 5 and 7 a.m.', hint: 'Work between 05:00 and 07:00.', cheer: 'up before the sun', goal: 1, value: flag('earlyBird') },
67 { id: 'weekend-warrior', name: 'Weekend Warrior', glyph: '⚔', color: '#f472b6', desc: 'Shipped work on a weekend.', hint: 'Finish a turn on Saturday or Sunday.', cheer: 'no days off', goal: 1, value: flag('weekend') },
68 { id: 'marathon', name: 'Marathon', glyph: '🏃', color: '#34d399', desc: 'A session two hours long.', hint: 'Keep one session going for two hours.', cheer: 'two hours in the saddle', goal: 120, value: f => Math.floor(f.sessionMs / 60_000) },
69 { id: 'big-spender', name: 'Big Spender', glyph: '💎', color: '#22d3ee', desc: 'A ten-dollar session.', hint: 'Spend $10 in one session.', cheer: 'ten dollars of thought', goal: 10, value: f => Math.floor(f.costUsd * 100) / 100 },
70 { id: 'penny-pincher', name: 'Penny Pincher', glyph: '🪙', color: '#eab308', desc: 'Ten tools in one turn for under five cents.', hint: 'Run ten tools in a turn that costs under $0.05.', cheer: 'thrift, perfected', goal: 1, value: flag('pennyPincher') },
71 { id: 'cache-wizard', name: 'Cache Wizard', glyph: '🧙', color: '#60a5fa', desc: 'Cache reads over 90% of a turn’s input.', hint: 'Have a turn read over 90% of its input from cache.', cheer: 'the cache remembers', goal: 1, value: flag('cacheWizard') },
72 { id: 'context-surfer', name: 'Context Surfer', glyph: '🏄', color: '#2dd4bf', desc: 'Kept going past 90% context.', hint: 'Start a turn with the context over 90% full.', cheer: 'riding the edge of the window', goal: 1, value: flag('contextSurfer') },
73 { id: 'bug-squasher', name: 'Bug Squasher', glyph: '🐞', color: '#4ade80', desc: 'A failing command, fixed: the same command later passed.', hint: 'Make a failing shell command pass.', cheer: 'red to green', goal: 1, value: flag('bugSquasher') },
74 { id: 'polyglot', name: 'Polyglot', glyph: '🗺', color: '#f59e0b', desc: 'Edited five kinds of file in one session.', hint: 'Edit files with five different extensions.', cheer: 'five tongues spoken', goal: 5, value: f => f.exts.length },
75 { id: 'speed-demon', name: 'Speed Demon', glyph: '⚡', color: '#fde047', desc: 'Three tools in a turn under ten seconds.', hint: 'Finish a turn with 3+ tools in under 10 s.', cheer: 'blink and it’s done', goal: 1, value: flag('speedDemon') },
76 { id: 'centurion', name: 'Centurion', glyph: '🛡', color: '#f87171', desc: 'A hundred tool calls in one session.', hint: 'Make 100 tool calls in a session.', cheer: 'a hundred strikes', goal: 100, value: f => f.tools },
77 { id: 'architect', name: 'Architect', glyph: '🏛', color: '#fbbf24', desc: 'Wrote ten files in a single turn.', hint: 'Write or edit ten files in one turn.', cheer: 'ten files raised at once', goal: 10, value: f => f.bestFilesInTurn },
78 { id: 'archaeologist', name: 'Archaeologist', glyph: '🏺', color: '#d97757', desc: 'Fifty reads in one session.', hint: 'Read 50 files in a session.', cheer: 'fifty layers deep', goal: 50, value: f => f.reads },
79 { id: 'needle', name: 'Needle Finder', glyph: '⌕', color: '#93c5fd', desc: 'Twenty-five searches in one session.', hint: 'Run 25 Grep or Glob searches.', cheer: 'found it, every time', goal: 25, value: f => f.searches },
80 { id: 'committed', name: 'Committed', glyph: '⎇', color: '#22c55e', desc: 'A successful git commit.', hint: 'Commit with git from the shell.', cheer: 'history written', goal: 1, value: flag('committed') },
81 { id: 'shipwright', name: 'Shipwright', glyph: '⛵', color: '#38bdf8', desc: 'A successful git push.', hint: 'Push with git from the shell.', cheer: 'shipped', goal: 1, value: flag('pushed') },
82 { id: 'test-pilot', name: 'Test Pilot', glyph: '🧪', color: '#a3e635', desc: 'A test run that passed.', hint: 'Run the tests and see them pass.', cheer: 'all green', goal: 1, value: flag('testPilot') },
83 { id: 'taskmaster', name: 'Taskmaster', glyph: '☑', color: '#14b8a6', desc: 'Ten tasks finished.', hint: 'Complete ten todos or tasks.', cheer: 'ten boxes ticked', goal: 10, value: f => f.life.tasksDone },
84 { id: 'web-crawler', name: 'Web Crawler', glyph: '🕸', color: '#06b6d4', desc: 'Ten web searches or fetches in a session.', hint: 'Search or fetch the web ten times.', cheer: 'the web, combed', goal: 10, value: f => f.web },
85 { id: 'switchboard', name: 'Switchboard', glyph: '⬡', color: '#e879f9', desc: 'Tools from three MCP servers in one session.', hint: 'Use tools of three different MCP servers.', cheer: 'all lines connected', goal: 3, value: f => f.mcpServers.length },
86 { id: 'undaunted', name: 'Undaunted', glyph: '🔥', color: '#ef4444', desc: 'Five failures in a turn, and it still finished.', hint: 'Push through five failed tools in one turn.', cheer: 'nothing stops you', goal: 1, value: flag('undaunted') },
87 { id: 'deep-thinker', name: 'Deep Thinker', glyph: '🌀', color: '#8b5cf6', desc: 'A single turn of ten minutes or more.', hint: 'Let one turn run ten minutes.', cheer: 'ten minutes of focus', goal: 1, value: flag('deepThinker') },
88 { id: 'haiku', name: 'Haiku', glyph: '🍃', color: '#86efac', desc: 'An answer in under five seconds, no tools.', hint: 'Get a tool-free answer in under 5 s.', cheer: 'brevity is wit', goal: 1, value: flag('haiku') },
89 { id: 'clean-slate', name: 'Clean Slate', glyph: '✧', color: '#e5e7eb', desc: 'Twenty tool calls in a session, not one failed.', hint: 'Make 20 tool calls without a single failure.', cheer: 'flawless', goal: 1, value: flag('cleanSlate') },
90 { id: 'token-tsunami', name: 'Token Tsunami', glyph: '🌊', color: '#3b82f6', desc: 'A million tokens through one session.', hint: 'Move a million tokens in a session.', cheer: 'a million tokens', goal: 1_000_000, value: f => f.tokens },
91 { id: 'lab-notes', name: 'Lab Notes', glyph: '📓', color: '#fcd34d', desc: 'Edited a Jupyter notebook.', hint: 'Edit a notebook cell.', cheer: 'science, documented', goal: 1, value: flag('labNotes') },
92 { id: 'devotee', name: 'Devotee', glyph: '📅', color: '#f97316', desc: 'Worked on seven different days.', hint: 'Finish turns on seven different days.', cheer: 'a week of devotion', goal: 7, value: f => f.life.days.length },
93 { id: 'regular', name: 'Regular', glyph: '♛', color: '#eab308', desc: 'A hundred turns, all sessions together.', hint: 'Finish 100 turns in all.', cheer: 'one hundred turns', goal: 100, value: f => f.life.turns },
94]
95
96export function badgeById(id: string): Badge | undefined {
97 return BADGES.find(b => b.id === id)
98}
99
100/** Badges whose value reached the goal and are not yet in `unlocked`. */
101export function newlyUnlocked(f: Facts, unlocked: Readonly<Record<string, number>>): Badge[] {
102 return BADGES.filter(b => unlocked[b.id] === undefined && b.value(f) >= b.goal)
103}
104
105/** Each counter badge's value, capped at its goal (for progress bars). */
106export function progressOf(f: Facts): Record<string, number> {
107 const out: Record<string, number> = {}
108 for (const b of BADGES) out[b.id] = Math.min(b.goal, Math.max(0, b.value(f)))
109 return out
110}
111
112// ---------------------------------------------------------------------------
113// Turn-level checks.
114
115export type TurnFacts = {
116 startedAt: number
117 durationMs: number
118 tools: number
119 fails: number
120 files: number
121 steps: number
122 estUsd: number
123 input: number
124 cacheRead: number
125 cacheWrite: number
126 reason: string
127}
128
129/** The flags one finished main turn earns; `hour` and `weekday` are local. */
130export function turnFlags(t: TurnFacts, hour: number, weekday: number): Flag[] {
131 const out: Flag[] = []
132 const answered = t.reason === 'answer'
133 if (hour >= 0 && hour < 5) out.push('nightOwl')
134 if (hour >= 5 && hour < 7) out.push('earlyBird')
135 if (weekday === 0 || weekday === 6) out.push('weekend')
136 if (answered && t.tools >= 3 && t.durationMs < 10_000) out.push('speedDemon')
137 if (answered && t.tools >= 10 && t.steps > 0 && t.estUsd < 0.05) out.push('pennyPincher')
138 const inTotal = t.input + t.cacheRead + t.cacheWrite
139 if (inTotal >= 10_000 && t.cacheRead / inTotal > 0.9) out.push('cacheWizard')
140 if (t.durationMs >= 10 * 60_000) out.push('deepThinker')
141 if (answered && t.tools === 0 && t.durationMs < 5000) out.push('haiku')
142 if (answered && t.fails >= 5) out.push('undaunted')
143 return out
144}
145
146// ---------------------------------------------------------------------------
147// Classifiers for tool calls.
148
149export function normCommand(cmd: string): string {
150 return cmd.replace(/\s+/g, ' ').trim()
151}
152
153export function isGitCommit(cmd: string): boolean {
154 return /(^|[;&|]\s*)git(\s+-\S+(\s+\S+)?)*\s+commit\b/.test(normCommand(cmd))
155}
156
157export function isGitPush(cmd: string): boolean {
158 return /(^|[;&|]\s*)git(\s+-\S+(\s+\S+)?)*\s+push\b/.test(normCommand(cmd))
159}
160
161export function isTestRun(cmd: string): boolean {
162 return /\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b|\b(pytest|vitest|jest|mocha|rspec|phpunit)\b|\b(cargo|go|dotnet|mix|deno|bun)\s+test\b|\bclaude\s+plugin\s+test\b|\bmake\s+(test|check)\b|\bgradle\w*\s+test\b|\bmvn\s+test\b/.test(cmd)
163}
164
165/** `.tsx` from `src/app.tsx`; '' for none (Makefile). */
166export function extOf(path: string): string {
167 const base = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
168 const i = base.lastIndexOf('.')
169 return i > 0 ? base.slice(i).toLowerCase() : ''
170}
171
172export function mcpServerOf(tool: string): string | undefined {
173 if (!tool.startsWith('mcp__')) return undefined
174 return tool.split('__')[1] || undefined
175}
176
177/** Todos newly marked completed between two TodoWrite lists (by content). */
178export function todosDone(before: readonly { content: string; status: string }[], after: readonly { content: string; status: string }[]): number {
179 const was = new Set(before.filter(t => t.status === 'completed').map(t => t.content))
180 return after.filter(t => t.status === 'completed' && !was.has(t.content)).length
181}
182
183export function dayKey(ms: number): string {
184 const d = new Date(ms)
185 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
186}
187
188export function sanitizeLife(v: unknown): Lifetime {
189 const o = (v && typeof v === 'object' ? v : {}) as Record<string, unknown>
190 const n = (k: string) => (typeof o[k] === 'number' && Number.isFinite(o[k]) ? (o[k] as number) : 0)
191 return {
192 turns: n('turns'),
193 tools: n('tools'),
194 tasksDone: n('tasksDone'),
195 days: Array.isArray(o.days) ? (o.days as unknown[]).filter((d): d is string => typeof d === 'string').slice(-60) : [],
196 }
197}
198hooks/burst.tsx 82 lines1// Laurels' terminal burst: one row that celebrates an unlock for six seconds.
2// A trophy that hops, the badge glyph, its name in a travelling shimmer,
3// the cheer, and confetti drifting through the rest of the row.
4import type { ClientModule } from 'claude-code'
5
6type P = { cols: number; name: string; glyph: string; color: string; cheer: string; more: number }
7type S = { f: number }
8
9const CONFETTI = ['✦', '✧', '·', '*', '⋆', '˚', '•', '✶']
10const HUES = ['#f472b6', '#facc15', '#4ade80', '#22d3ee', '#a78bfa', '#fb923c']
11const HOP = ['🏆', '🏆', '✨', '🏆']
12
13function hash(n: number): number {
14 const s = Math.sin(n * 91.3458) * 47453.5453
15 return s - Math.floor(s)
16}
17
18/** Mixes two #rrggbb colors. */
19function mix(a: string, b: string, t: number): string {
20 const p = (h: string, i: number) => parseInt(h.slice(1 + i * 2, 3 + i * 2), 16)
21 const c = (i: number) => Math.round(p(a, i) + (p(b, i) - p(a, i)) * t).toString(16).padStart(2, '0')
22 return `#${c(0)}${c(1)}${c(2)}`
23}
24
25const Burst: ClientModule<P, S> = (props, surface) => {
26 if (surface.state === undefined) {
27 surface.setState({ f: 0 })
28 surface.every(60, () => surface.setState({ f: (surface.state?.f ?? 0) + 1 }))
29 }
30 const f = surface.state?.f ?? 0
31 const { Box, Text } = surface.elements
32 const base = /^#[0-9a-f]{6}$/i.test(props.color) ? props.color : '#facc15'
33
34 const head = `${HOP[Math.floor(f / 4) % HOP.length]} `
35 const label = ' UNLOCKED '
36 const name = props.name
37 const tail = ` — ${props.cheer}${props.more > 0 ? ` (+${props.more} more)` : ''} `
38 const used = 3 + label.length + 3 + name.length + tail.length
39 const room = Math.max(0, (props.cols || 60) - used)
40
41 // A shimmer crest walks along the name, wrapping.
42 const crest = (f * 0.6) % (name.length + 6)
43 const letters = Array.from(name).map((ch, i) => {
44 const d = Math.abs(i - crest)
45 const glow = d < 2.5 ? 1 - d / 2.5 : 0
46 return <Text key={`n${i}`} bold color={mix(base, '#ffffff', glow * 0.85)}>{ch}</Text>
47 })
48
49 // Confetti drifting right to left over the spare cells.
50 const field: { ch: string; color: string }[] = []
51 for (let x = 0; x < room; x++) {
52 const seed = Math.floor((x + f * 0.5) / 1) + 7
53 const r = hash(seed)
54 const on = r < 0.28
55 // hash() is in [0, 1): both picks are in range.
56 field.push(on ? { ch: CONFETTI[Math.floor(hash(seed + 3) * CONFETTI.length)]!, color: HUES[Math.floor(hash(seed + 5) * HUES.length)]! } : { ch: ' ', color: '#000000' })
57 }
58 const runs: { color: string; text: string }[] = []
59 for (const c of field) {
60 const last = runs[runs.length - 1]
61 if (last && (last.color === c.color || c.ch === ' ')) last.text += c.ch
62 else runs.push({ color: c.color, text: c.ch })
63 }
64
65 const sparkle = CONFETTI[f % CONFETTI.length]
66 return (
67 <Box flexDirection="row">
68 <Text wrap="truncate-end">
69 <Text>{head}</Text>
70 <Text backgroundColor={base} color="#111111" bold>{label}</Text>
71 <Text color={base}> {props.glyph} </Text>
72 {letters}
73 <Text color="#d1d5db">{tail}</Text>
74 {runs.map((r, i) => <Text key={`c${i}`} color={r.color}>{r.text}</Text>)}
75 <Text color={HUES[f % HUES.length]}>{room > 0 ? '' : sparkle}</Text>
76 </Text>
77 </Box>
78 )
79}
80
81export default Burst
82types/index.d.ts 15 lines1export type LaurelsBurst = { id: string; at: number; more: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 laurels: {
6 /** Badge id → unlock time (ms). */
7 unlocked: Record<string, number>
8 /** Badge id → current value toward its goal. */
9 progress: Record<string, number>
10 burst: LaurelsBurst | null
11 isHidden: boolean
12 }
13 }
14}
15