KOZMOS Mosaic: a GitHub-style activity calendar across sessions. 26 weeks of days colored by turns, tool calls, tokens or dollars, with the current and longest…

A GitHub-style activity calendar across sessions, as a sidebar. /mosaic toggles it; autoOpen opens it on start.
Per local day it keeps in $.store: sessions started, main turns, tool calls (subagents included), tokens (every turn.step usage), dollars (the growth of each session's cost) and active minutes (a minute counts once when anything happened in it or Claude was working). Counts are buffered and written every 10 seconds; days older than 200 are pruned.
The pane draws 26 weeks × 7 days (Sunday first) colored by the selected metric in four quartile levels. Buttons (hotkeys 1 to 4) switch the metric: turns, tools, tokens, $. Below: the current streak (an empty today keeps yesterday's run alive), the longest streak, the best day, 26-week totals and today's line. Terminal: colored ■ cells in a Raster with month and weekday labels. Desktop: rounded SVG squares with tooltips (title elements), today ringed, the busiest days gently breathing, plus stat tiles and today's figures.
Oturumlar arası GitHub tarzı etkinlik takvimi, yan panel olarak. /mosaic paneli açar/kapatır; autoOpen oturum başında açar.
Her yerel gün için $.store içinde tutar: başlatılan oturumlar, ana turlar, araç çağrıları (alt ajanlar dahil), tokenlar (her turn.step kullanımı), dolar (her oturumun maliyet artışı) ve aktif dakikalar. Sayımlar biriktirilip 10 saniyede bir yazılır; 200 günden eski günler silinir.
Panel, seçili metriğe göre dört çeyreklik seviyede renklenen 26 hafta × 7 gün çizer. Butonlar (kısayol 1 ile 4) metriği değiştirir: turns, tools, tokens, $. Altında: güncel seri (bugün boşsa dünkü seri sürer), en uzun seri, en iyi gün, 26 haftalık toplamlar ve bugünün satırı. Terminal: Raster içinde ay ve gün etiketli renkli ■ hücreler. Masaüstü: ipuçlu yuvarlak SVG kareler, halkalı bugün, istatistik kutuları ve bugünün rakamları.
hooks/register.tsx 487 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { MoDay, MoMetric, MoSnap } from '../types'
5import { Canvas, KZ, fmtTokens, fmtUsd, mix, pxOf, svg, svgText, tokensOf } from './lib/kz.ts'
6import {
7 METRICS, WEEKS, addDays, addTo, bestDay, calendar, cutPoints, dayKey, isEmptyDelta, levelOf, minuteOf, monthOf, streaks, totals,
8 valueOf, zeroDay,
9} from './calc.ts'
10import type { Days, Level } from './calc.ts'
11
12const PANE = 'kz-mosaic'
13const TITLE = 'KOZMOS · Mosaic'
14const snapAtom = atom({ plugin: 'mosaic', key: 'snap' } as const, null)
15const metricAtom = atom({ plugin: 'mosaic', key: 'metric' } as const, 'turns')
16
17const DAYS_KEY = 'days'
18const SEEN_KEY = 'seen'
19const SESSIONS_KEY = 'sessions'
20const METRIC_KEY = 'metric'
21
22const METRIC_COLOR: Record<MoMetric, string> = { turns: KZ.green, tools: KZ.blue, tokens: KZ.violet, usd: KZ.yellow }
23const METRIC_LABEL: Record<MoMetric, string> = { turns: 'turns', tools: 'tools', tokens: 'tokens', usd: '$' }
24const EMPTY_TERM = '#30363d'
25const LEVELS = [0, 1, 2, 3, 4] as const
26const SHADE_MIX = [0, 0.35, 0.58, 0.8, 1] as const
27const OPACITY = [0, 0.32, 0.55, 0.78, 1] as const
28
29const blank = (now: number): MoSnap => ({ now, today: dayKey(now), days: {}, isWorking: false })
30
31function fmtMetric(v: number, metric: MoMetric): string {
32 if (metric === 'usd') return fmtUsd(v)
33 if (metric === 'tokens') return fmtTokens(v)
34 return v >= 10_000 ? fmtTokens(v) : String(Math.round(v))
35}
36
37function fmtMinutes(m: number): string {
38 if (m < 60) return `${m}m`
39 return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
40}
41
42const shade = (metric: MoMetric, level: Level): string =>
43 level <= 0 ? EMPTY_TERM : mix('#1f2a24', METRIC_COLOR[metric], SHADE_MIX[level])
44
45function niceDate(key: string): string {
46 return `${monthOf(key)} ${Number(key.slice(8))}`
47}
48
49// ---------------------------------------------------------------------------
50
51async function isOpen($: EngineInterface): Promise<boolean> {
52 return (await $.ui.panes()).some(p => p.id === PANE)
53}
54
55async function toggle($: EngineInterface): Promise<boolean> {
56 if (await isOpen($)) {
57 await $.ui.close({ id: PANE })
58 return false
59 }
60 await $.ui.open({ id: PANE, title: TITLE })
61 return true
62}
63
64async function setMetric($: EngineInterface, m: MoMetric): Promise<void> {
65 await update($, metricAtom, () => m)
66 await $.store.set(METRIC_KEY, m).catch(() => undefined)
67}
68
69// Counts not yet written, per local day; module state, flushed every 10 s.
70let pending: Record<string, Partial<MoDay>> = {}
71let lastMinute = -1
72let live: MoSnap = blank(0)
73let lastPublished = ''
74
75function addPending(key: string, delta: Partial<MoDay>): void {
76 const d = pending[key] ?? {}
77 for (const [k, v] of Object.entries(delta) as [keyof MoDay, number][]) d[k] = (d[k] ?? 0) + v
78 pending[key] = d
79}
80
81const bump = (now: number, delta: Partial<MoDay>): void => addPending(dayKey(now), delta)
82
83/** Counts the current minute once as active. */
84async function touch($: EngineInterface): Promise<void> {
85 const now = await $.clock.now()
86 const m = minuteOf(now)
87 if (m !== lastMinute) {
88 lastMinute = m
89 bump(now, { m: 1 })
90 }
91}
92
93async function getObj<T>($: EngineInterface, key: string, fallback: T): Promise<T> {
94 try {
95 const v = await $.store.get(key)
96 return v && typeof v === 'object' ? (v as T) : fallback
97 } catch {
98 return fallback
99 }
100}
101
102async function publish($: EngineInterface): Promise<void> {
103 const key = JSON.stringify({ ...live, now: 0 })
104 if (key === lastPublished) return
105 await update($, snapAtom, () => JSON.parse(JSON.stringify(live)) as MoSnap)
106 // Noted once written, so a refused write is tried again on the next flush.
107 lastPublished = key
108}
109
110/** Books the session's new spend, writes the pending counts, and republishes. */
111let isFlushing = false
112let isFlushAgain = false
113
114/** One flush at a time: a flush asked for while one runs waits and runs once
115 * after it, so two never read the stored days and write over each other. */
116async function flush($: EngineInterface): Promise<void> {
117 if (isFlushing) {
118 isFlushAgain = true
119 return
120 }
121 isFlushing = true
122 try {
123 do {
124 isFlushAgain = false
125 await flushOnce($)
126 } while (isFlushAgain)
127 } finally {
128 isFlushing = false
129 }
130}
131
132async function flushOnce($: EngineInterface): Promise<void> {
133 const now = await $.clock.now()
134 if (live.isWorking) await touch($)
135 try {
136 const u = await $.session.usage()
137 if (u.cost && u.cost.usd > 0) {
138 const seen = await getObj<Record<string, { usd: number; at: number }>>($, SEEN_KEY, {})
139 const id = String(u.startedAt)
140 const prev = seen[id]?.usd
141 const delta = prev === undefined || u.cost.usd < prev ? u.cost.usd : u.cost.usd - prev
142 if (delta > 0) {
143 const kept: Record<string, { usd: number; at: number }> = {}
144 for (const [k, v] of Object.entries(seen)) if (v && v.at >= now - 3 * 86_400_000) kept[k] = v
145 kept[id] = { usd: u.cost.usd, at: now }
146 await $.store.set(SEEN_KEY, kept)
147 // Booked once the store remembers it, so a refused write is not counted twice.
148 bump(now, { u: delta })
149 }
150 }
151 } catch {
152 // No cost this time.
153 }
154 let stored: Days | undefined
155 try {
156 const v = await $.store.get(DAYS_KEY)
157 stored = v && typeof v === 'object' ? (v as Days) : {}
158 } catch {
159 // Unread: nothing is written over the history this time.
160 }
161 const toWrite = Object.entries(pending).filter(([, d]) => !isEmptyDelta(d))
162 pending = {}
163 let days = stored ?? {}
164 for (const [k, d] of toWrite) days = addTo(days, k, d)
165 const isWritten = stored !== undefined && (toWrite.length === 0 || (await $.store.set(DAYS_KEY, days).then(() => true, () => false)))
166 // Counts not written wait for the next flush.
167 if (!isWritten) for (const [k, d] of toWrite) addPending(k, d)
168 const today = dayKey(now)
169 const first = addDays(today, -(WEEKS * 7 + 7))
170 const shown: Days = {}
171 for (const [k, v] of Object.entries(days)) if (k >= first) shown[k] = { ...zeroDay(), ...v }
172 live = { ...live, now, today, days: shown }
173 await publish($)
174}
175
176/** Counts this session once, across reloads, by its start time. */
177async function countSession($: EngineInterface): Promise<void> {
178 try {
179 const u = await $.session.usage()
180 const ids = await getObj<string[]>($, SESSIONS_KEY, [])
181 const id = String(u.startedAt)
182 if (Array.isArray(ids) && ids.includes(id)) return
183 await $.store.set(SESSIONS_KEY, [...(Array.isArray(ids) ? ids : []), id].slice(-100))
184 bump(await $.clock.now(), { s: 1 })
185 } catch {
186 // Not counted.
187 }
188}
189
190export const register: Register = (on, options) => {
191 on('session.start', async ($, e, next) => {
192 const started = await next(e)
193 pending = {}
194 lastMinute = -1
195 lastPublished = ''
196 live = blank(await $.clock.now())
197 const m = await $.store.get(METRIC_KEY).catch(() => undefined)
198 if (typeof m === 'string' && (METRICS as readonly string[]).includes(m)) await update($, metricAtom, () => m as MoMetric)
199 await $.command.register({ name: 'mosaic', description: 'KOZMOS: toggle the Mosaic activity calendar sidebar', immediate: true })
200 await countSession($)
201 await flush($).catch(() => undefined)
202 $.clock.every(10_000, () => void flush($).catch(() => undefined))
203 if (options.autoOpen === true) void $.ui.open({ id: PANE, title: TITLE })
204 return started
205 })
206
207 on('session.end', async ($, e, next) => {
208 await flush($).catch(() => undefined)
209 return next(e)
210 }).catch(($, e, next) => next(e))
211
212 on('command.run', { command: 'mosaic' }, async $ => {
213 const opened = await toggle($)
214 if (opened) await flush($).catch(() => undefined)
215 return { text: opened ? 'Mosaic open.' : 'Mosaic closed.' }
216 })
217
218 on('turn.start', async ($, e, next) => {
219 live.isWorking = true
220 await touch($)
221 return next(e)
222 }).catch(($, e, next) => next(e))
223
224 on('turn.step', async function* ($, e, next) {
225 const r = yield* next(e)
226 if (r.usage) {
227 bump(await $.clock.now(), { k: tokensOf(r.usage) })
228 await touch($)
229 }
230 return r
231 })
232
233 on('tool.call', async ($, e, next) => {
234 bump(await $.clock.now(), { c: 1 })
235 await touch($)
236 return next(e)
237 }).catch(($, e, next) => next(e))
238
239 on('turn.complete', async ($, e, next) => {
240 const done = await next(e)
241 if (e.agentId === undefined) {
242 bump(await $.clock.now(), { t: 1 })
243 await touch($)
244 live.isWorking = false
245 await flush($).catch(() => undefined)
246 }
247 return done
248 }).catch(($, e, next) => next(e))
249
250 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
251 const s = (await read($, snapAtom)) ?? blank(await $.clock.now())
252 const metric = await read($, metricAtom)
253 const { Box, Button } = $.ui.resolve(e)
254
255 const weeksAll = calendar(s.today, WEEKS)
256 const keys = weeksAll.flat().filter((k): k is string => k !== null)
257 const cuts = cutPoints(keys.map(k => valueOf(s.days[k], metric)))
258 const st = streaks(s.days, s.today)
259 const best = bestDay(Object.fromEntries(keys.map(k => [k, s.days[k] ?? zeroDay()])), metric)
260 const sum = totals(s.days, keys)
261 const today = s.days[s.today] ?? zeroDay()
262
263 const buttons = (
264 <Box key="metrics" flexDirection="row" gap={1}>
265 {METRICS.map((m, i) => (
266 <Button
267 key={`m-${m}`}
268 label={METRIC_LABEL[m]}
269 hotkey={String(i + 1)}
270 variant={m === metric ? 'primary' : 'secondary'}
271 dimColor={m !== metric}
272 onPress={() => void setMetric($, m)}
273 />
274 ))}
275 </Box>
276 )
277
278 if (e.surface !== 'terminal') {
279 const { Svg } = $.ui.resolve(e)
280 const W = pxOf(e.props.bodyColumns, 44)
281 const cards = [
282 calendarCard(s, weeksAll, metric, cuts, st.current, W),
283 statsCard(metric, st, best, sum, W),
284 todayCard(today, s.isWorking, W),
285 ]
286 return (
287 <Box flexDirection="column">
288 {buttons}
289 {cards.map((c, i) => <Svg key={`c${i}`} source={c.source} alt={c.alt} width={W} height={c.height} isInteractive={c.isInteractive} />)}
290 </Box>
291 )
292 }
293
294 // ---- terminal ----
295 const { Text, Raster } = $.ui.resolve(e)
296 const cols = Math.max(28, e.props.bodyColumns || 40)
297 const cell = 2 + WEEKS * 2 <= cols ? 2 : 1
298 const weeks = Math.min(WEEKS, Math.floor((cols - 2) / cell))
299 const shownWeeks = weeksAll.slice(-weeks)
300 const c = metricColor(metric)
301 return (
302 <Box flexDirection="column">
303 <Box flexDirection="row" justifyContent="space-between">
304 <Text bold color={c}>◆ MOSAIC · {METRIC_LABEL[metric]}</Text>
305 <Text color={st.current ? KZ.amber : KZ.mist}>{st.current ? `▲ ${st.current}-day streak` : 'no streak'}</Text>
306 </Box>
307 {buttons}
308 <Raster key="cal" columns={Math.min(cols, 2 + weeks * cell)} rows={8} cells={calendarRaster(s, shownWeeks, metric, cuts, cell, Math.min(cols, 2 + weeks * cell)).encode()} />
309 <Text wrap="truncate-end">
310 <Text dimColor>less </Text>
311 {LEVELS.map(l => <Text key={`l${l}`} color={shade(metric, l)}>■</Text>)}
312 <Text dimColor> more · {weeks} weeks</Text>
313 </Text>
314 <Text wrap="truncate-end">
315 <Text color={KZ.amber}>streak {st.current}d</Text>
316 <Text dimColor> · longest {st.longest}d · best {best ? `${niceDate(best.date)} (${fmtMetric(best.value, metric)})` : '—'}</Text>
317 </Text>
318 <Text wrap="truncate-end" dimColor>
319 Σ {fmtMetric(sum.t, 'turns')} turns · {fmtMetric(sum.c, 'tools')} tools · {fmtTokens(sum.k)} tok · {fmtUsd(sum.u)} · {fmtMinutes(sum.m)} · {sum.s} sessions
320 </Text>
321 <Text wrap="truncate-end">
322 <Text color={c} bold>today </Text>
323 <Text>{today.t} turns · {today.c} tools · {fmtTokens(today.k)} tok · {fmtUsd(today.u)} · {fmtMinutes(today.m)} · {today.s} sess.</Text>
324 </Text>
325 </Box>
326 )
327 })
328}
329
330const metricColor = (m: MoMetric): string => METRIC_COLOR[m]
331
332/** Month labels on row 0; weekday initials M W F in the first two columns; one square per day. */
333function calendarRaster(s: MoSnap, weeks: (string | null)[][], metric: MoMetric, cuts: [number, number, number], cell: number, cols: number): Canvas {
334 const c = new Canvas(cols, 8)
335 for (const [row, ch] of [[2, 'M'], [4, 'W'], [6, 'F']] as const) c.set(0, row, ch, KZ.mist)
336 let labelEnd = -1
337 let lastMonth = ''
338 weeks.forEach((week, w) => {
339 const x = 2 + w * cell
340 const mon = monthOf(week[0]!) // a week's Sunday is never after today
341 if (mon !== lastMonth) {
342 if (x > labelEnd && x + 3 <= cols) {
343 c.text(x, 0, mon, KZ.mist)
344 labelEnd = x + 3
345 }
346 lastMonth = mon
347 }
348 week.forEach((key, d) => {
349 if (key === null) return
350 const lvl = levelOf(valueOf(s.days[key], metric), cuts)
351 c.set(x, d + 1, key === s.today ? '◆' : '■', key === s.today && lvl === 0 ? KZ.mist : shade(metric, lvl))
352 })
353 })
354 return c
355}
356
357// ---------------------------------------------------------------------------
358// Desktop.
359
360type Card = { source: string; alt: string; height: number; isInteractive?: boolean }
361
362const MO_CSS = `
363.mo4{animation:mob 3.2s ease-in-out infinite}@keyframes mob{50%{opacity:.72}}
364.motd{animation:mot 1.4s ease-in-out infinite}@keyframes mot{50%{stroke-opacity:.2}}
365`
366
367function calendarCard(s: MoSnap, weeks: (string | null)[][], metric: MoMetric, cuts: [number, number, number], streak: number, W: number): Card {
368 const pad = 14
369 const labelW = 26
370 const gap = 3
371 const n = weeks.length
372 const sq = Math.max(6, Math.min(16, Math.floor((W - pad * 2 - labelW - gap * (n - 1)) / n)))
373 const gx = pad + labelW
374 const gy = 46
375 const H = gy + 7 * (sq + gap) + 30
376 const base = METRIC_COLOR[metric]
377 const p: string[] = [`<rect class="p" x="0" y="0" width="${W}" height="${H}" rx="14"/>`]
378 p.push(svgText(pad, 22, `ACTIVITY · ${METRIC_LABEL[metric].toUpperCase()}`, { cls: 's', size: 10.5, weight: 650 }))
379 if (streak > 0) {
380 const txt = `▲ ${streak}-day streak`
381 const cw = txt.length * 6.4 + 16
382 p.push(`<rect x="${W - pad - cw}" y="9" width="${cw}" height="18" rx="9" fill="${KZ.amber}" opacity=".18"/>`)
383 p.push(svgText(W - pad - cw / 2, 22, txt, { size: 10.5, weight: 700, anchor: 'middle', fill: KZ.amber }))
384 }
385 for (const [d, label] of [[1, 'Mon'], [3, 'Wed'], [5, 'Fri']] as const) {
386 p.push(svgText(pad, gy + d * (sq + gap) + sq - 1, label, { cls: 'm', size: 9 }))
387 }
388 let lastMonth = ''
389 let labelEnd = -1
390 weeks.forEach((week, w) => {
391 const x = gx + w * (sq + gap)
392 const mon = monthOf(week[0]!) // a week's Sunday is never after today
393 if (mon !== lastMonth) {
394 if (x > labelEnd) {
395 p.push(svgText(x, gy - 8, mon, { cls: 'm', size: 9.5 }))
396 labelEnd = x + 26
397 }
398 lastMonth = mon
399 }
400 week.forEach((key, d) => {
401 if (key === null) return
402 const y = gy + d * (sq + gap)
403 const v = valueOf(s.days[key], metric)
404 const lvl = levelOf(v, cuts)
405 const tip = `<title>${niceDate(key)} · ${fmtMetric(v, metric)} ${METRIC_LABEL[metric] === '$' ? '' : METRIC_LABEL[metric]}</title>`
406 const r = Math.min(3, sq / 4).toFixed(1)
407 if (lvl === 0) p.push(`<rect class="k" x="${x}" y="${y}" width="${sq}" height="${sq}" rx="${r}">${tip}</rect>`)
408 else {
409 const op = OPACITY[lvl]
410 const delay = ((w * 7 + d) % 11) * 0.29
411 p.push(`<rect x="${x}" y="${y}" width="${sq}" height="${sq}" rx="${r}" fill="${base}" fill-opacity="${op}" ${lvl === 4 ? `class="mo4" style="animation-delay:${delay.toFixed(2)}s"` : ''}>${tip}</rect>`)
412 }
413 if (key === s.today) p.push(`<rect x="${x - 1.5}" y="${y - 1.5}" width="${sq + 3}" height="${sq + 3}" rx="${(Number(r) + 1.5).toFixed(1)}" fill="none" stroke="${base}" stroke-width="1.5" class="motd"/>`)
414 })
415 })
416 // Legend.
417 const ly = H - 14
418 let lx = W - pad - 5 * (10 + 3) - 30
419 p.push(svgText(lx - 6, ly, 'less', { cls: 'm', size: 9, anchor: 'end' }))
420 for (let l = 0; l <= 4; l++) {
421 p.push(l === 0 ? `<rect class="k" x="${lx}" y="${ly - 9}" width="10" height="10" rx="2.5"/>` : `<rect x="${lx}" y="${ly - 9}" width="10" height="10" rx="2.5" fill="${base}" fill-opacity="${OPACITY[l]}"/>`)
422 lx += 13
423 }
424 p.push(svgText(lx + 3, ly, 'more', { cls: 'm', size: 9 }))
425 return {
426 source: svg(W, H, p.join(''), MO_CSS),
427 height: H,
428 isInteractive: true,
429 alt: `Activity calendar of the last ${weeks.length} weeks by ${METRIC_LABEL[metric]}`,
430 }
431}
432
433function statsCard(metric: MoMetric, st: { current: number; longest: number }, best: { date: string; value: number } | undefined, sum: MoDay, W: number): Card {
434 const tiles: [string, string, string, string][] = [
435 ['STREAK', `${st.current}d`, st.current ? 'keep it going' : 'start one today', KZ.amber],
436 ['LONGEST', `${st.longest}d`, 'in a row', KZ.magenta],
437 ['BEST DAY', best ? fmtMetric(best.value, metric) : '—', best ? niceDate(best.date) : 'no data yet', METRIC_COLOR[metric]],
438 ['26 WEEKS', fmtMetric(valueOf(sum, metric), metric), `${sum.s} sessions · ${fmtMinutes(sum.m)}`, KZ.cyan],
439 ]
440 const gap = 6
441 const tw = (W - gap) / 2
442 const th = 60
443 const H = th * 2 + gap
444 const p: string[] = []
445 tiles.forEach(([k, v, sub, c], i) => {
446 const x = (i % 2) * (tw + gap)
447 const y = Math.floor(i / 2) * (th + gap)
448 p.push(`<rect class="p" x="${x}" y="${y}" width="${tw}" height="${th}" rx="12"/>`)
449 p.push(`<rect x="${x + 10}" y="${y + 11}" width="3" height="38" rx="1.5" fill="${c}"/>`)
450 p.push(svgText(x + 20, y + 22, k, { cls: 's', size: 10, weight: 650 }))
451 p.push(svgText(x + 20, y + 41, v, { size: 16, weight: 740 }))
452 p.push(svgText(x + 20, y + 53, sub, { cls: 'm', size: 9.5 }))
453 })
454 return {
455 source: svg(W, H, p.join('')),
456 height: H,
457 alt: `Current streak ${st.current} days, longest ${st.longest} days, best day ${best ? `${niceDate(best.date)} with ${fmtMetric(best.value, metric)}` : 'none'}`,
458 }
459}
460
461function todayCard(d: MoDay, isWorking: boolean, W: number): Card {
462 const pad = 14
463 const H = 64
464 const chips: [string, string, string][] = [
465 ['turns', String(d.t), KZ.green],
466 ['tools', String(d.c), KZ.blue],
467 ['tokens', fmtTokens(d.k), KZ.violet],
468 ['spent', fmtUsd(d.u), KZ.yellow],
469 ['active', fmtMinutes(d.m), KZ.cyan],
470 ['sessions', String(d.s), KZ.magenta],
471 ]
472 const p: string[] = [`<rect class="p" x="0" y="0" width="${W}" height="${H}" rx="14"/>`]
473 p.push(`<circle cx="${pad + 4}" cy="18" r="4" fill="${isWorking ? KZ.green : KZ.mist}" class="${isWorking ? 'pulse' : ''}"/>`)
474 p.push(svgText(pad + 14, 22, 'TODAY', { cls: 's', size: 10.5, weight: 650 }))
475 const cw = (W - pad * 2) / chips.length
476 chips.forEach(([k, v, c], i) => {
477 const x = pad + i * cw
478 p.push(svgText(x, 44, v, { size: 13, weight: 720, fill: c }))
479 p.push(svgText(x, 56, k, { cls: 'm', size: 9 }))
480 })
481 return {
482 source: svg(W, H, p.join(''), MO_CSS),
483 height: H,
484 alt: `Today: ${d.t} turns, ${d.c} tool calls, ${fmtTokens(d.k)} tokens, ${fmtUsd(d.u)}, ${fmtMinutes(d.m)} active, ${d.s} sessions`,
485 }
486}
487hooks/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/calc.ts 146 lines1// Mosaic math: pure functions, no `$`.
2import type { MoDay, MoMetric } from '../types'
3
4export const METRICS: readonly MoMetric[] = ['turns', 'tools', 'tokens', 'usd']
5export const WEEKS = 26
6
7export const zeroDay = (): MoDay => ({ s: 0, t: 0, c: 0, k: 0, u: 0, m: 0 })
8
9// ---------------------------------------------------------------------------
10// Local calendar days.
11
12const pad2 = (n: number): string => String(n).padStart(2, '0')
13
14export function dayKey(ms: number): string {
15 const d = new Date(ms)
16 return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
17}
18
19/** Noon of a day key, so DST shifts never cross midnight. */
20export function noonOf(key: string): Date {
21 const [y, m, d] = key.split('-').map(Number)
22 return new Date(y!, (m ?? 1) - 1, d ?? 1, 12) // a split has at least one part
23}
24
25export function addDays(key: string, n: number): string {
26 const d = noonOf(key)
27 d.setDate(d.getDate() + n)
28 return dayKey(d.getTime())
29}
30
31/** 0 = Sunday. */
32export const weekdayOf = (key: string): number => noonOf(key).getDay()
33
34const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
35export const monthOf = (key: string): string => MONTHS[noonOf(key).getMonth()] ?? ''
36
37// ---------------------------------------------------------------------------
38// The ledger.
39
40export type Days = Record<string, MoDay>
41
42/** Adds `delta` to day `key` (a new object) and keeps only the last `keep` days. */
43export function addTo(days: Days, key: string, delta: Partial<MoDay>, keep = 200): Days {
44 const oldest = addDays(key, -(keep - 1))
45 const out: Days = {}
46 for (const [k, v] of Object.entries(days)) if (k >= oldest) out[k] = { ...zeroDay(), ...v }
47 const d = out[key] ?? zeroDay()
48 out[key] = {
49 s: d.s + (delta.s ?? 0),
50 t: d.t + (delta.t ?? 0),
51 c: d.c + (delta.c ?? 0),
52 k: d.k + (delta.k ?? 0),
53 u: d.u + (delta.u ?? 0),
54 m: d.m + (delta.m ?? 0),
55 }
56 return out
57}
58
59export const isEmptyDelta = (d: Partial<MoDay>): boolean => !d.s && !d.t && !d.c && !d.k && !d.u && !d.m
60
61export function valueOf(d: MoDay | undefined, metric: MoMetric): number {
62 if (!d) return 0
63 return metric === 'turns' ? d.t : metric === 'tools' ? d.c : metric === 'tokens' ? d.k : d.u
64}
65
66/** A day counts toward a streak when anything happened in it. */
67export const isActive = (d: MoDay | undefined): boolean => !!d && (d.t > 0 || d.c > 0 || d.s > 0 || d.m > 0)
68
69/**
70 * The calendar: `weeks` columns of 7 days (Sunday first), the last column
71 * holding today. Days after today are `null`.
72 */
73export function calendar(today: string, weeks = WEEKS): (string | null)[][] {
74 const lastSunday = addDays(today, -weekdayOf(today))
75 const firstSunday = addDays(lastSunday, -(weeks - 1) * 7)
76 return Array.from({ length: weeks }, (_, w) =>
77 Array.from({ length: 7 }, (_, d) => {
78 const key = addDays(firstSunday, w * 7 + d)
79 return key > today ? null : key
80 }))
81}
82
83/** Quartile cut points of the non-zero values: levels 1..4 above zero. */
84export function cutPoints(values: readonly number[]): [number, number, number] {
85 const nz = values.filter(v => v > 0).sort((a, b) => a - b)
86 if (nz.length === 0) return [0, 0, 0]
87 const q = (p: number) => nz[Math.min(nz.length - 1, Math.floor(p * nz.length))]! // nz is not empty
88 return [q(0.25), q(0.5), q(0.75)]
89}
90
91/** A cell's shade: 0 is empty, 1..4 above zero. */
92export type Level = 0 | 1 | 2 | 3 | 4
93
94export function levelOf(v: number, cuts: readonly [number, number, number]): Level {
95 if (v <= 0) return 0
96 if (v <= cuts[0]) return 1
97 if (v <= cuts[1]) return 2
98 if (v <= cuts[2]) return 3
99 return 4
100}
101
102/** Current streak (ending today, or yesterday while today is still empty) and the longest. */
103export function streaks(days: Days, today: string, span = 400): { current: number; longest: number } {
104 let longest = 0
105 let run = 0
106 for (let i = span - 1; i >= 0; i--) {
107 const key = addDays(today, -i)
108 run = isActive(days[key]) ? run + 1 : 0
109 longest = Math.max(longest, run)
110 }
111 let current = 0
112 let key = isActive(days[today]) ? today : addDays(today, -1)
113 while (isActive(days[key])) {
114 current++
115 key = addDays(key, -1)
116 }
117 return { current, longest }
118}
119
120export function bestDay(days: Days, metric: MoMetric): { date: string; value: number } | undefined {
121 let best: { date: string; value: number } | undefined
122 for (const [date, d] of Object.entries(days)) {
123 const v = valueOf(d, metric)
124 if (v > 0 && (!best || v > best.value)) best = { date, value: v }
125 }
126 return best
127}
128
129export function totals(days: Days, keys: readonly string[]): MoDay {
130 const t = zeroDay()
131 for (const k of keys) {
132 const d = days[k]
133 if (!d) continue
134 t.s += d.s
135 t.t += d.t
136 t.c += d.c
137 t.k += d.k
138 t.u += d.u
139 t.m += d.m
140 }
141 return t
142}
143
144/** The minute index a time falls in. */
145export const minuteOf = (ms: number): number => Math.floor(ms / 60_000)
146types/index.d.ts 20 lines1/** One local day: sessions started, turns, tool calls, tokens, dollars, active minutes. */
2export type MoDay = { s: number; t: number; c: number; k: number; u: number; m: number }
3
4export type MoMetric = 'turns' | 'tools' | 'tokens' | 'usd'
5
6export type MoSnap = {
7 now: number
8 /** Today's local date, YYYY-MM-DD. */
9 today: string
10 /** Days with any activity inside the drawn range, by local date. */
11 days: Record<string, MoDay>
12 isWorking: boolean
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 mosaic: { snap: MoSnap | null; metric: MoMetric }
18 }
19}
20