SLOPSHOPPER

mosaic

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…

newpaneguardcommandtimer
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/mosaic
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mosaic
│ ┃ KOZMOS · Mosaic ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ MOSAIC · turns ▲ 1-day streak │ ┃ [ turns ] [ tools ] [ tokens ] [ $ ] ⏺ Read(src/auth.ts) │ ┃ Apr May Jun Jul Aug ⎿ Read 6 lines │ ┃ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ⏺ Update(src/auth.ts) │ ┃ M ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ⎿ Added 2 lines, removed 1 line │ ┃ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ⏺ Bash(bun test) │ ┃ W ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ less ■■■■■ more · 26 weeks ✻ Worked for 42s · done 4:20 PM │ ┃ streak 1d · longest 1d · best Oct 9 (1) │ ┃ Σ 1 turns · 9 tools · 0 tok · $0.42 · 1m · … › /mosaic │ ┃ today 1 turns · 9 tools · 0 tok · $0.42 · 1… ⎿ mosaic: Mosaic open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · KOZMOS · Mosaic
◆ MOSAIC · turns ▲ 1-day streak [ turns ] [ tools ] [ tokens ] [ $ ] Apr May Jun Jul Aug Sep ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ M ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ W ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ less ■■■■■ more · 26 weeks streak 1d · longest 1d · best Oct 9 (1) Σ 1 turns · 9 tools · 0 tok · $0.42 · 1m · 1 sessions today 1 turns · 9 tools · 0 tok · $0.42 · 1m · 1 sess.
README

KOZMOS Mosaic

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.

Türkçe

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ı.

Source 4 files
hooks/register.tsx 487 lines
1import { 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}
487
hooks/lib/kz.ts 510 lines
1// GENERATED by scripts/sync-shared.mjs from shared/kz.ts. Do not edit here.
2// KOZMOS shared kit. Source of truth: shared/kz.ts at the bundle root.
3// `node scripts/sync-shared.mjs` copies it into every mod as hooks/lib/kz.ts,
4// because a hooks module may only import files inside its own plugin.
5// Edit the root copy, never a mod's copy.
6
7// ---------------------------------------------------------------------------
8// Palette: one neon family across every KOZMOS mod.
9
10export const KZ = {
11  violet: '#a78bfa',
12  magenta: '#f472b6',
13  cyan: '#22d3ee',
14  teal: '#2dd4bf',
15  green: '#4ade80',
16  lime: '#a3e635',
17  yellow: '#facc15',
18  amber: '#fb923c',
19  red: '#f87171',
20  blue: '#60a5fa',
21  clay: '#d97757',
22  ink: '#1f1e1d',
23  mist: '#9ca3af',
24} as const
25
26/** Tool families, colored the same in every mod. */
27export function toolColor(tool: string): string {
28  const t = String(tool)
29  if (t === 'Bash' || t === 'PowerShell') return KZ.green
30  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return KZ.yellow
31  if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP') return KZ.blue
32  if (t === 'Agent' || t === 'Task' || t === 'Workflow') return KZ.violet
33  if (t.startsWith('Web')) return KZ.cyan
34  if (t.startsWith('Todo') || t.startsWith('Task')) return KZ.teal
35  if (t.startsWith('mcp__')) return KZ.magenta
36  return KZ.mist
37}
38
39/** A short glyph per tool family. */
40export function toolGlyph(tool: string): string {
41  const t = String(tool)
42  if (t === 'Bash' || t === 'PowerShell') return '$'
43  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit') return '✎'
44  if (t === 'Read') return '◉'
45  if (t === 'Glob' || t === 'Grep') return '⌕'
46  if (t === 'Agent') return '◈'
47  if (t.startsWith('Web')) return '◍'
48  if (t.startsWith('Todo') || t.startsWith('Task')) return '☑'
49  if (t.startsWith('mcp__')) return '⬡'
50  return '•'
51}
52
53// ---------------------------------------------------------------------------
54// Colors.
55
56export function hexToRgb(hex: string): [number, number, number] {
57  const h = hex.replace('#', '')
58  const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
59  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
60}
61
62export function rgbToHex(r: number, g: number, b: number): string {
63  const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
64  return `#${c(r)}${c(g)}${c(b)}`
65}
66
67export function mix(a: string, b: string, t: number): string {
68  const [r1, g1, b1] = hexToRgb(a)
69  const [r2, g2, b2] = hexToRgb(b)
70  const k = clamp01(t)
71  return rgbToHex(r1 + (r2 - r1) * k, g1 + (g2 - g1) * k, b1 + (b2 - b1) * k)
72}
73
74/** Green at 0, yellow at 0.6, red at 1: the heat of a gauge. */
75export function heat(t: number): string {
76  const k = clamp01(t)
77  return k < 0.6 ? mix(KZ.green, KZ.yellow, k / 0.6) : mix(KZ.yellow, KZ.red, (k - 0.6) / 0.4)
78}
79
80/** A smooth rainbow for hues 0..1 (for auroras, plasma and the like). */
81export function hue(h: number, s = 0.75, l = 0.6): string {
82  const k = ((h % 1) + 1) % 1
83  const a = s * Math.min(l, 1 - l)
84  const f = (n: number) => {
85    const x = (n + k * 12) % 12
86    return l - a * Math.max(-1, Math.min(x - 3, 9 - x, 1))
87  }
88  return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255)
89}
90
91export function hexToInt(hex: string): number {
92  const [r, g, b] = hexToRgb(hex)
93  return (r << 16) | (g << 8) | b
94}
95
96// ---------------------------------------------------------------------------
97// Numbers and text.
98
99export const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0)
100
101export function fmtTokens(n: number): string {
102  if (!Number.isFinite(n)) return '—'
103  // Each unit starts where the one below would round up to 1000 of itself.
104  if (n >= 999.5e6) return `${(n / 1e9).toFixed(1)}B`
105  if (n >= 999_500) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
106  if (n >= 999.5) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
107  return `${Math.round(n)}`
108}
109
110export function fmtUsd(usd: number): string {
111  if (!Number.isFinite(usd)) return '$—'
112  if (usd < 0.01 && usd > 0) return '<$0.01'
113  return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
114}
115
116/** 0:42, 3:07, 1:02:33. */
117export function fmtClock(ms: number): string {
118  const s = Math.max(0, Math.round(ms / 1000))
119  const h = Math.floor(s / 3600)
120  const m = Math.floor((s % 3600) / 60)
121  const ss = String(s % 60).padStart(2, '0')
122  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
123}
124
125/** 42s, 7m, 2h41m, 3d4h: compact durations for countdowns. */
126export function fmtSpan(ms: number): string {
127  const s = Math.max(0, Math.round(ms / 1000))
128  if (s < 60) return `${s}s`
129  const m = Math.floor(s / 60)
130  if (m < 60) return `${m}m`
131  const h = Math.floor(m / 60)
132  if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
133  return `${Math.floor(h / 24)}d${h % 24}h`
134}
135
136export function fmtPct(p: number | undefined): string {
137  return p === undefined || !Number.isFinite(p) ? '—' : `${Math.round(p)}%`
138}
139
140export function clip(s: string, max: number): string {
141  const one = s.replace(/\s+/g, ' ').trim()
142  return one.length > max ? one.slice(0, Math.max(1, max - 1)) + '…' : one
143}
144
145export function padEnd(s: string, n: number): string {
146  return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length)
147}
148
149export function padStart(s: string, n: number): string {
150  return s.length >= n ? s.slice(s.length - n) : ' '.repeat(n - s.length) + s
151}
152
153/** `claude-opus-5-5[1m]` → `Opus 5.5`. */
154export function modelName(id: string | undefined): string {
155  if (!id) return '—'
156  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
157  if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
158  const fam = m[1]!
159  return `${fam.charAt(0).toUpperCase()}${fam.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
160}
161
162/** Last path segment, either slash. */
163export function baseName(p: string): string {
164  const parts = p.split(/[\\/]/).filter(Boolean)
165  return parts[parts.length - 1] ?? p
166}
167
168/** A stable small hash for seeding. */
169export function hash(s: string): number {
170  let h = 2166136261
171  for (let i = 0; i < s.length; i++) {
172    h ^= s.charCodeAt(i)
173    h = Math.imul(h, 16777619)
174  }
175  return h >>> 0
176}
177
178/** Deterministic 0..1 noise from two ints. */
179export function noise(x: number, y: number): number {
180  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
181  return s - Math.floor(s)
182}
183
184/** A seeded PRNG (mulberry32). */
185export function rng(seed: number): () => number {
186  let a = seed >>> 0
187  return () => {
188    a = (a + 0x6d2b79f5) >>> 0
189    let t = a
190    t = Math.imul(t ^ (t >>> 15), t | 1)
191    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
192    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
193  }
194}
195
196// ---------------------------------------------------------------------------
197// Text gauges for the terminal.
198
199const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
200
201/** A smooth bar with eighth blocks: `█████▍    `. */
202export function bar(ratio: number, width: number, empty = '░'): string {
203  const w = Math.max(1, Math.floor(width))
204  const exact = clamp01(ratio) * w
205  const full = Math.floor(exact)
206  const part = EIGHTHS[Math.floor((exact - full) * 8)] ?? ''
207  const used = full + (part ? 1 : 0)
208  return '█'.repeat(full) + part + empty.repeat(Math.max(0, w - used))
209}
210
211/** A segmented gauge: `▰▰▰▱▱▱`. */
212export function pips(ratio: number, width: number, on = '▰', off = '▱'): string {
213  const w = Math.max(1, Math.floor(width))
214  const n = Math.round(clamp01(ratio) * w)
215  return on.repeat(n) + off.repeat(w - n)
216}
217
218const SPARK = '▁▂▃▄▅▆▇█'
219
220/** A sparkline of the last `width` values, scaled to their own max (or `max`). */
221export function sparkline(values: readonly number[], width: number, max?: number): string {
222  const tail = values.slice(-Math.max(1, width))
223  const top = max ?? Math.max(1e-9, ...tail)
224  const line = tail.map(v => SPARK[Math.min(7, Math.max(0, Math.round((v / top) * 7)))] ?? '▁').join('')
225  return line.padStart(width, ' ')
226}
227
228/** Braille line graph, 2 samples per cell, `rows` cells tall. */
229export function brailleGraph(values: readonly number[], width: number, rows: number, max?: number): string[] {
230  const samples = values.slice(-(width * 2))
231  const top = max ?? Math.max(1e-9, ...samples)
232  const dotsTall = rows * 4
233  const grid: number[][] = Array.from({ length: rows }, () => Array.from({ length: width }, () => 0))
234  const offset = width * 2 - samples.length
235  // Dot bits per column, from the bottom row of a cell up.
236  const LEFT = [0x40, 0x04, 0x02, 0x01]
237  const RIGHT = [0x80, 0x20, 0x10, 0x08]
238  samples.forEach((v, i) => {
239    const x = offset + i
240    const col = Math.floor(x / 2)
241    const isRight = x % 2 === 1
242    const h = Math.round(clamp01(v / top) * (dotsTall - 1))
243    for (let d = 0; d <= h; d++) {
244      const row = rows - 1 - Math.floor(d / 4)
245      const bits = isRight ? RIGHT : LEFT
246      const cellRow = grid[row]
247      if (cellRow && col >= 0) cellRow[col] = (cellRow[col] ?? 0) | bits[d % 4]!
248    }
249  })
250  return grid.map(r => r.map(b => String.fromCharCode(0x2800 + b)).join(''))
251}
252
253// ---------------------------------------------------------------------------
254// Raster: a grid of colored cells, packed as the engine wants it.
255
256const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
257
258export function toBase64(bytes: Uint8Array): string {
259  let out = ''
260  let i = 0
261  for (; i + 2 < bytes.length; i += 3) {
262    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
263    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
264  }
265  const rest = bytes.length - i
266  if (rest === 1) {
267    const n = bytes[i]! << 16
268    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
269  } else if (rest === 2) {
270    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
271    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
272  }
273  return out
274}
275
276/** The terminal's own default color, for a cell's foreground or background. */
277export const DEFAULT_COLOR = 0x01000000
278
279export class Canvas {
280  readonly cols: number
281  readonly rows: number
282  private readonly words: Uint32Array
283
284  constructor(cols: number, rows: number) {
285    this.cols = Math.max(1, Math.min(512, Math.floor(cols)))
286    this.rows = Math.max(1, Math.min(256, Math.floor(rows)))
287    this.words = new Uint32Array(this.cols * this.rows * 3)
288    this.clear()
289  }
290
291  clear(bg: number = DEFAULT_COLOR): void {
292    for (let i = 0; i < this.cols * this.rows; i++) {
293      this.words[i * 3] = 0x20
294      this.words[i * 3 + 1] = DEFAULT_COLOR
295      this.words[i * 3 + 2] = bg
296    }
297  }
298
299  /** Puts one width-1 character; colors are '#rrggbb' or a packed int. */
300  set(x: number, y: number, ch: string, fg?: string | number, bg?: string | number): void {
301    const cx = Math.floor(x)
302    const cy = Math.floor(y)
303    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
304    const i = (cy * this.cols + cx) * 3
305    const code = ch.codePointAt(0) ?? 0x20
306    this.words[i] = code > 0xffff || code < 0x20 ? 0x20 : code
307    if (fg !== undefined) this.words[i + 1] = typeof fg === 'number' ? fg : hexToInt(fg)
308    if (bg !== undefined) this.words[i + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
309  }
310
311  /** Writes a string left to right, clipped to the canvas. */
312  text(x: number, y: number, s: string, fg?: string | number, bg?: string | number): void {
313    let cx = x
314    for (const ch of s) {
315      this.set(cx, y, ch, fg, bg)
316      cx++
317    }
318  }
319
320  /** Paints a cell's background only, keeping its character. */
321  paint(x: number, y: number, bg: string | number): void {
322    const cx = Math.floor(x)
323    const cy = Math.floor(y)
324    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
325    this.words[(cy * this.cols + cx) * 3 + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
326  }
327
328  /** Two vertical pixels per cell with the upper-half block: `py` is in half-cells. */
329  pixel(x: number, py: number, color: string): void {
330    const cx = Math.floor(x)
331    const cy = Math.floor(py / 2)
332    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
333    const i = (cy * this.cols + cx) * 3
334    const c = hexToInt(color)
335    const isTop = Math.floor(py) % 2 === 0
336    if (this.words[i] !== 0x2580) {
337      this.words[i] = 0x2580
338      this.words[i + 1] = DEFAULT_COLOR
339      this.words[i + 2] = DEFAULT_COLOR
340    }
341    if (isTop) this.words[i + 1] = c
342    else this.words[i + 2] = c
343  }
344
345  /** The `cells` prop of a Raster. */
346  encode(): string {
347    return toBase64(new Uint8Array(this.words.buffer))
348  }
349}
350
351// ---------------------------------------------------------------------------
352// SVG for the desktop: one drawing per row (the desktop wraps siblings).
353
354export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif"
355export const MONO = "ui-monospace,'Cascadia Code','SF Mono',Consolas,monospace"
356
357const XML_ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
358
359export function xml(s: string): string {
360  return s.replace(/[&<>"']/g, c => XML_ESC[c]!)
361}
362
363/** Theme-aware classes every KOZMOS drawing shares: t text, s secondary, m muted, k track, p panel. */
364export const SVG_BASE_CSS = `
365.t{fill:#1f1f1f}.s{fill:#5f5f5c}.m{fill:#8e8e8a}.k{fill:#e7e5e0}.p{fill:#f5f4f1}.ln{stroke:#e1dfda}
366@media (prefers-color-scheme: dark){.t{fill:#ededed}.s{fill:#b4b4b0}.m{fill:#7c7c78}.k{fill:#2d2d2b}.p{fill:#232322}.ln{stroke:#363634}}
367.pulse{animation:kzp 1.6s ease-in-out infinite}@keyframes kzp{50%{opacity:.35}}
368.spin{transform-box:fill-box;transform-origin:center;animation:kzs 2s linear infinite}@keyframes kzs{to{transform:rotate(360deg)}}
369@media (prefers-reduced-motion: reduce){*{animation:none!important}}
370`
371
372export function svg(width: number, height: number, body: string, css = ''): string {
373  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${SVG_BASE_CSS}${css}</style>${body}</svg>`
374}
375
376/** Rough width of UI text in px, for fitting labels. */
377export function textWidth(s: string, size: number): number {
378  let w = 0
379  for (const ch of s) w += (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%MWmw]/.test(ch) ? 0.72 : 0.56) * size
380  return w
381}
382
383export function fitText(s: string, size: number, maxW: number): string {
384  if (textWidth(s, size) <= maxW) return s
385  let out = ''
386  for (const ch of s) {
387    if (textWidth(out + ch + '…', size) > maxW) break
388    out += ch
389  }
390  return out + '…'
391}
392
393/** A rounded progress bar as SVG markup. */
394export function svgBar(x: number, y: number, w: number, h: number, ratio: number, color: string): string {
395  const fw = Math.max(0, Math.min(w, w * clamp01(ratio)))
396  return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
397    (fw > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fw)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : '')
398}
399
400/** SVG text helper. */
401export function svgText(x: number, y: number, s: string, opts: { cls?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string; mono?: boolean } = {}): string {
402  const { cls = 't', size = 12, weight = 400, anchor = 'start', fill, mono = false } = opts
403  return `<text ${fill ? `fill="${fill}"` : `class="${cls}"`} x="${x}" y="${y}" font-family="${mono ? MONO : FONT}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-variant-numeric="tabular-nums">${xml(s)}</text>`
404}
405
406/** Pixel width a desktop pane or band gives a drawing for `columns` reported columns. */
407export function pxOf(columns: number | undefined, fallback = 60, slack = 8): number {
408  return Math.max(200, Math.min(1600, (columns || fallback) * 8 - slack))
409}
410
411// ---------------------------------------------------------------------------
412// Prices, USD per million tokens: input, output, cache read, cache write (5 min).
413// Anthropic first-party rates as of 2026-10. Used only where the engine reports
414// tokens and not money (a subagent's own spend); the session total comes from
415// $.session.usage().cost, which is the engine's.
416
417const PRICES: [RegExp, [number, number, number, number]][] = [
418  [/fable|mythos/i, [10, 50, 0.25, 12.5]],
419  [/opus-5-5/i, [4, 20, 0.2, 5]],
420  [/opus/i, [5, 25, 0.5, 6.25]],
421  [/sonnet-5/i, [2, 10, 0.2, 2.5]],
422  [/sonnet/i, [3, 15, 0.3, 3.75]],
423  [/haiku-5/i, [0.1, 0.5, 0.01, 0.125]],
424  [/haiku/i, [1, 5, 0.1, 1.25]],
425]
426
427export type Usage = {
428  input_tokens?: number
429  output_tokens?: number
430  cache_read_input_tokens?: number
431  cache_creation_input_tokens?: number
432}
433
434export function priceOf(model: string): [number, number, number, number] {
435  return PRICES.find(([re]) => re.test(model))?.[1] ?? [4, 20, 0.2, 5]
436}
437
438export function costOf(model: string, u: Usage | null | undefined): number {
439  if (!u) return 0
440  const [i, o, r, w] = priceOf(model)
441  return ((u.input_tokens ?? 0) * i + (u.output_tokens ?? 0) * o + (u.cache_read_input_tokens ?? 0) * r + (u.cache_creation_input_tokens ?? 0) * w) / 1e6
442}
443
444export function tokensOf(u: Usage | null | undefined): number {
445  if (!u) return 0
446  return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
447}
448
449/** The context a request filled: everything it read plus what it wrote. */
450export function contextOf(u: Usage | null | undefined): number {
451  return tokensOf(u)
452}
453
454export function windowOf(model: string): number {
455  return /haiku-4/i.test(model) ? 200_000 : 1_000_000
456}
457
458// ---------------------------------------------------------------------------
459// Rate limits.
460
461export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
462
463export function limitLabel(kind: string): string {
464  if (kind === 'five_hour') return '5h'
465  if (kind === 'seven_day') return '7d'
466  if (kind === 'spend_limit') return '$'
467  return kind.replace(/_/g, ' ')
468}
469
470/** Milliseconds until a window resets, or undefined. */
471export function untilReset(l: Limit, now: number): number | undefined {
472  if (!l.resetsAt) return undefined
473  const t = Date.parse(l.resetsAt)
474  return Number.isFinite(t) ? Math.max(0, t - now) : undefined
475}
476
477/** The length of a window in ms, for burn-rate math. */
478export function windowMs(kind: string): number | undefined {
479  if (kind === 'five_hour') return 5 * 3600_000
480  if (kind === 'seven_day') return 7 * 24 * 3600_000
481  return undefined
482}
483
484// ---------------------------------------------------------------------------
485// Tool calls, described in a few words.
486
487/** What a tool call is about: the command, the file, the pattern, the agent's task. */
488export function toolDetail(tool: string, input: unknown): string {
489  const e = (input ?? {}) as Record<string, unknown>
490  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
491  const t = String(tool)
492  if (t === 'Bash' || t === 'PowerShell') return clip(s('description') || s('command'), 60)
493  if (s('file_path')) return baseName(s('file_path'))
494  if (s('notebook_path')) return baseName(s('notebook_path'))
495  if (t === 'Agent') return clip(s('description') || s('subagent_type'), 60)
496  if (s('pattern')) return clip(s('pattern'), 60)
497  if (s('url')) return clip(s('url').replace(/^https?:\/\//, ''), 60)
498  if (s('query')) return clip(s('query'), 60)
499  if (s('subject')) return clip(s('subject'), 60)
500  if (Array.isArray(e.todos)) return `${(e.todos as unknown[]).length} todos`
501  if (t.startsWith('mcp__')) return t.split('__').slice(1).join(' · ')
502  return ''
503}
504
505/** `mcp__server__tool` → `server·tool`; built-ins unchanged. */
506export function toolName(tool: string): string {
507  const t = String(tool)
508  return t.startsWith('mcp__') ? t.split('__').slice(1).join('·') : t
509}
510
hooks/calc.ts 146 lines
1// 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)
146
types/index.d.ts 20 lines
1/** 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