SLOPSHOPPER

throttle

KOZMOS Throttle: a car dashboard above the prompt. Speedometer of output tokens per second, fuel gauge of the 5-hour limit, temperature of the context, an…

newbandguardcommandtimer
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/throttle
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · throttle
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /throttle ⎿ throttle: Throttle hidden. /throttle brings it back. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

KOZMOS Throttle

A car dashboard above the prompt, shown while Claude works and for a minute after.

  • Speedometer: output tokens per second over the last ~10 s, main loop and subagents together. Streamed text gives a live estimate; each response's usage.output_tokens replaces it when the response ends. The scale grows 100 → 200 → 400 … with the peak of the last minute.
  • Fuel: what is left of the 5-hour limit (hidden without rate limits).
  • Temperature: context fill.
  • Odometer: the session's dollars.
  • Lamps: check-engine (amber) for 30 s after a failed tool, fuel under 15 %, overheat over 85 % context.

Terminal: three braille dials whose needles ease to each reading and shiver while the engine runs, a counting odometer and lamp chips (a Client at ~30 fps). Desktop: one SVG with analog dials (needles and arcs sweep from the previous reading), rolling odometer wheels, glowing lamps and a little car on a road that scrolls with the speed.

/throttle shows or hides it; ✕ hides it. The choice is kept across sessions.

Türkçe

İstemin üstünde bir araba göstergesi; Claude çalışırken ve bittikten sonra bir dakika görünür.

  • Hız göstergesi: son ~10 saniyede saniye başına çıktı token'ı (ana döngü ve alt ajanlar birlikte). Akan metin canlı bir tahmin verir; yanıt bitince usage.output_tokens gerçek sayıyı yazar. Ölçek son dakikanın tepesine göre 100 → 200 → 400 … büyür.
  • Yakıt: 5 saatlik limitten kalan (oran limiti yoksa gizli).
  • Sıcaklık: bağlam doluluğu.
  • Kilometre sayacı: oturumun dolar tutarı.
  • Uyarı lambaları: başarısız bir araçtan sonra 30 sn motor arızası (amber), yakıt %15 altı, bağlam %85 üstü aşırı ısınma.

Terminal: iğneleri her okumaya yumuşakça giden ve motor çalışırken titreyen üç braille kadran, sayan kilometre sayacı ve lamba etiketleri (~30 fps Client). Masaüstü: tek SVG; analog kadranlar (iğneler önceki okumadan süpürülür), dönen sayaç çarkları, parlayan lambalar ve hıza göre kayan bir yolda küçük bir araba.

/throttle gösterir veya gizler; ✕ gizler. Seçim oturumlar arasında saklanır.

Source 6 files
hooks/register.tsx 246 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ThrottleSnap } from '../types'
5import type { DashProps } from './dash.tsx'
6import { CHARS_PER_TOKEN, fuelLeft, lamps, prune, speedScale, tokensPerSec } from './model.ts'
7import type { Span } from './model.ts'
8import { dashSvg } from './svg.ts'
9
10const snapAtom = atom({ plugin: 'throttle', key: 'snap' } as const, null)
11const hiddenAtom = atom({ plugin: 'throttle', key: 'isHidden' } as const, false)
12
13const SHOW_AFTER_MS = 60_000
14const AGENT_RECENT_MS = 5_000
15
16// ---------------------------------------------------------------------------
17// The live collector. Module variables start over on a reload; session.start
18// fills them again. The band draws only from the published snapshot.
19
20type Flight = { agentId?: string; first: number; chars: number }
21
22let spans: Span[] = []
23const flights = new Map<number, Flight>()
24let flightSeq = 0
25let isMainWorking = false
26let lastActiveAt = 0
27let failedAt: number | undefined
28let agentSeen = new Map<string, number>()
29let peaks: { at: number; v: number }[] = []
30let fuel: number | null = null
31let temp: number | null = null
32let odo: number | null = null
33let published: ThrottleSnap | null = null
34let publishedKey = ''
35
36function reset(): void {
37  spans = []
38  flights.clear()
39  isMainWorking = false
40  lastActiveAt = 0
41  failedAt = undefined
42  agentSeen = new Map()
43  peaks = []
44  fuel = temp = odo = null
45  published = null
46  publishedKey = ''
47}
48
49async function readUsage($: EngineInterface): Promise<void> {
50  try {
51    const u = await $.session.usage()
52    fuel = fuelLeft(u.rateLimits)
53    temp = u.context.percent ?? null
54    odo = u.cost?.usd ?? null
55  } catch {
56    // Keep the last reading.
57  }
58}
59
60async function publish($: EngineInterface): Promise<void> {
61  const now = await $.clock.now()
62  const live: Span[] = [...spans]
63  for (const f of flights.values()) if (f.first > 0 && f.chars > 0) live.push({ s: f.first, e: now, tok: f.chars / CHARS_PER_TOKEN })
64  const speed = Math.round(tokensPerSec(live, now))
65  peaks = [...peaks.filter(p => now - p.at < 60_000), { at: now, v: speed }]
66  const agents = [...agentSeen.values()].filter(at => now - at < AGENT_RECENT_MS).length
67  const inFlightAgents = new Set([...flights.values()].map(f => f.agentId).filter(Boolean)).size
68  const isWorking = isMainWorking || flights.size > 0 || agents > 0
69  if (isWorking) lastActiveAt = now
70  const isVisible = lastActiveAt > 0 && (isWorking || now - lastActiveAt < SHOW_AFTER_MS)
71  const next: ThrottleSnap = {
72    isVisible,
73    isWorking,
74    speed,
75    speedPrev: published?.speed ?? 0,
76    speedMax: speedScale(Math.max(...peaks.map(p => p.v))),
77    fuel: fuel === null ? null : Math.round(fuel),
78    fuelPrev: published?.fuel ?? null,
79    temp: temp === null ? null : Math.round(temp),
80    tempPrev: published?.temp ?? null,
81    odo: odo === null ? null : Math.round(odo * 100) / 100,
82    odoPrev: published?.odo ?? null,
83    lamps: lamps(now, failedAt, fuel, temp),
84    agents: Math.max(agents, inFlightAgents),
85    idleMs: isWorking ? 0 : Math.round((now - lastActiveAt) / 1000) * 1000,
86  }
87  // Compare without the previous readings, so a still dashboard never redraws.
88  const key = JSON.stringify({ ...next, speedPrev: 0, fuelPrev: 0, tempPrev: 0, odoPrev: 0 })
89  if (key === publishedKey) return
90  publishedKey = key
91  published = next
92  await update($, snapAtom, () => next)
93}
94
95async function tick($: EngineInterface): Promise<void> {
96  const now = await $.clock.now()
97  spans = prune(spans, now)
98  // The figures only matter while the band shows (or is about to).
99  if (published?.isVisible || isMainWorking || flights.size > 0) await readUsage($)
100  await publish($)
101}
102
103async function setHidden($: EngineInterface, isHidden: boolean): Promise<void> {
104  await update($, hiddenAtom, () => isHidden)
105  try {
106    await $.store.set('isHidden', isHidden)
107  } catch {
108    // The band still hides for this session.
109  }
110}
111
112async function loadHidden($: EngineInterface): Promise<void> {
113  try {
114    const v = await $.store.get('isHidden')
115    if (typeof v === 'boolean') await update($, hiddenAtom, () => v)
116  } catch {
117    // Nothing stored.
118  }
119}
120
121async function markActive($: EngineInterface): Promise<void> {
122  lastActiveAt = await $.clock.now()
123}
124
125export const register: Register = on => {
126  on('session.start', async ($, e, next) => {
127    const started = await next(e)
128    reset()
129    await $.command.register({ name: 'throttle', description: 'KOZMOS: show or hide the Throttle dashboard above the prompt', immediate: true })
130    await loadHidden($)
131    await readUsage($)
132    $.clock.every(1000, () => void tick($).catch(() => undefined))
133    return started
134  })
135
136  on('command.run', { command: 'throttle' }, async $ => {
137    const isHidden = !(await read($, hiddenAtom))
138    await setHidden($, isHidden)
139    return { text: isHidden ? 'Throttle hidden. /throttle brings it back.' : 'Throttle shown: it appears while Claude works and for a minute after.' }
140  })
141
142  on('turn.start', async ($, e, next) => {
143    isMainWorking = true
144    await markActive($)
145    void publish($).catch(() => undefined)
146    return next(e)
147  })
148
149  on('turn.complete', async ($, e, next) => {
150    if (e.agentId === undefined) {
151      isMainWorking = false
152      await markActive($)
153      void tick($).catch(() => undefined)
154    }
155    return next(e)
156  })
157
158  // Every model request, main loop and agents: the streamed characters give a
159  // live estimate, the response's usage the exact count once it ends.
160  on('turn.step', async function* ($, e, next) {
161    const id = ++flightSeq
162    const flight: Flight = { agentId: e.agentId, first: 0, chars: 0 }
163    flights.set(id, flight)
164    const stream = next(e)
165    try {
166      let item = await stream.next()
167      while (!item.done) {
168        const c = item.value
169        const n = c.kind === 'text' || c.kind === 'thinking' ? c.text.length : c.kind === 'input' ? c.json.length : 0
170        if (n > 0) {
171          if (flight.first === 0) flight.first = await $.clock.now()
172          flight.chars += n
173          if (e.agentId !== undefined) agentSeen.set(e.agentId, flight.first)
174        }
175        yield c
176        item = await stream.next()
177      }
178      const result = item.value
179      const end = await $.clock.now()
180      const tok = result.usage?.output_tokens ?? flight.chars / CHARS_PER_TOKEN
181      if (tok > 0) spans.push({ s: flight.first || end - 1000, e: end, tok })
182      if (e.agentId !== undefined) agentSeen.set(e.agentId, end)
183      lastActiveAt = end
184      return result
185    } finally {
186      flights.delete(id)
187    }
188  })
189
190  on('tool.call', async ($, e, next) => {
191    const ran = await next(e)
192    if (ran.isError === true || ran.deny !== undefined) {
193      failedAt = await $.clock.now()
194      void publish($).catch(() => undefined)
195    }
196    return ran
197  }).catch(($, e, next) => next(e))
198
199  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
200    const drawn = await next(e)
201    if (e.props.hasSurvey || (await read($, hiddenAtom))) return drawn
202    const snap = await read($, snapAtom)
203    if (!snap || !snap.isVisible) return drawn
204    const cols = Math.max(20, e.props.bodyColumns || 80)
205
206    if (e.surface === 'terminal') {
207      const { Box, Button, Client } = $.ui.resolve(e)
208      const props: DashProps = {
209        speed: snap.speed,
210        speedMax: snap.speedMax,
211        fuel: snap.fuel,
212        temp: snap.temp,
213        odo: snap.odo,
214        engine: snap.lamps.engine,
215        fuelLow: snap.lamps.fuel,
216        heat: snap.lamps.heat,
217        working: snap.isWorking,
218        agents: snap.agents,
219        idleSec: Math.round(snap.idleMs / 1000),
220      }
221      return (
222        <Box flexDirection="column">
223          {drawn}
224          <Box key="throttle" flexDirection="row">
225            <Client key="throttle-dash" module="./dash.tsx" width={cols - 3} height={2} props={props} />
226            <Button key="throttle-hide" label="✕" plain dimColor role="dismiss" onPress={() => void setHidden($, true)} />
227          </Box>
228        </Box>
229      )
230    }
231
232    // Every other surface draws SVG.
233    const { Box, Button, Svg } = $.ui.resolve(e)
234    const pic = dashSvg(snap, Math.max(240, (cols - 3) * 8 - 8))
235    return (
236      <Box flexDirection="column">
237        {drawn}
238        <Box key="throttle" flexDirection="row">
239          <Svg source={pic.source} alt={pic.alt} width={pic.width} height={pic.height} />
240          <Button key="throttle-hide" label="✕" plain dimColor role="dismiss" onPress={() => void setHidden($, true)} />
241        </Box>
242      </Box>
243    )
244  })
245}
246
hooks/dash.tsx 177 lines
1// Throttle's terminal dashboard: a Client surface module animating at ~30 fps.
2// Three braille dials (2 rows × 7 cells = 14 × 8 dots each) whose needles ease
3// toward the latest reading and shiver while the engine runs, a counting
4// odometer, warning lamps and a status word. No `$` here: the hooks module
5// sends plain numbers as props.
6import type { ClientModule, RenderElement } from 'claude-code'
7
8import { mix } from './lib/kz.ts'
9
10export type DashProps = {
11  speed: number
12  speedMax: number
13  fuel: number | null
14  temp: number | null
15  odo: number | null
16  engine: boolean
17  fuelLow: boolean
18  heat: boolean
19  working: boolean
20  agents: number
21  idleSec: number
22}
23
24type DashState = { t: number; sp: number; fu: number; te: number; od: number }
25
26type Run = { text: string; color?: string; bg?: string; bold?: boolean; dim?: boolean }
27
28const NEEDLE = '#ff5a4f'
29const DIM_ARC = '#4b5563'
30const LEFT = [0x01, 0x02, 0x04, 0x40]
31const RIGHT = [0x08, 0x10, 0x20, 0x80]
32
33const speedTint = (f: number): string => (f < 0.5 ? mix('#22d3ee', '#a78bfa', f * 2) : mix('#a78bfa', '#f472b6', (f - 0.5) * 2))
34const fuelTint = (f: number): string => (f < 0.35 ? mix('#f87171', '#fb923c', f / 0.35) : mix('#facc15', '#4ade80', (f - 0.35) / 0.65))
35const tempTint = (f: number): string => (f < 0.5 ? mix('#60a5fa', '#4ade80', f * 2) : f < 0.8 ? mix('#4ade80', '#fb923c', (f - 0.5) / 0.3) : mix('#fb923c', '#f87171', (f - 0.8) / 0.2))
36
37/** A semicircular gauge in braille: two rows of seven cells. */
38export function dialCells(v: number, tint: (f: number) => string): [Run[], Run[]] {
39  const cols = 7
40  const rows = 2
41  const bits: number[][] = Array.from({ length: rows }, () => Array.from({ length: cols }, () => 0))
42  const needle: boolean[][] = Array.from({ length: rows }, () => Array.from({ length: cols }, () => false))
43  const lit: number[][] = Array.from({ length: rows }, () => Array.from({ length: cols }, () => -1))
44  const k = Math.max(0, Math.min(1, Number.isFinite(v) ? v : 0))
45  const cx = 6.5
46  const cy = 7.4
47  const r = 6.6
48  // The arc spans x -0.1..13.1 and y 0.8..7.4, the needle less: every dot
49  // rounds into the 14 × 8 grid, so the cells below always exist.
50  const dot = (x: number, y: number, isNeedle: boolean, f: number) => {
51    const dx = Math.round(x)
52    const dy = Math.round(y)
53    const cell = Math.floor(dx / 2)
54    const row = Math.floor(dy / 4)
55    const line = bits[row]!
56    line[cell] = line[cell]! | (dx % 2 ? RIGHT : LEFT)[dy % 4]!
57    if (isNeedle) needle[row]![cell] = true
58    else if (f <= k + 1e-6) lit[row]![cell] = Math.max(lit[row]![cell]!, f)
59  }
60  for (let i = 0; i <= 48; i++) {
61    const f = i / 48
62    const a = Math.PI * (1 - f)
63    dot(cx + r * Math.cos(a), cy - r * Math.sin(a), false, f)
64  }
65  const a = Math.PI * (1 - k)
66  for (let t = 0; t <= 1.0001; t += 0.07) dot(cx + t * (r - 1.6) * Math.cos(a), cy - t * (r - 1.6) * Math.sin(a), true, 0)
67  const row = (y: number): Run[] =>
68    bits[y]!.map((b, x) => {
69      const f = lit[y]![x]!
70      return { text: b ? String.fromCharCode(0x2800 + b) : ' ', color: needle[y]![x] ? NEEDLE : f >= 0 ? tint(f) : DIM_ARC }
71    })
72  return [row(0), row(1)]
73}
74
75function clock(sec: number): string {
76  const s = Math.max(0, Math.round(sec))
77  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
78}
79
80function odo(usd: number): string {
81  const cents = Math.max(0, Math.round(usd * 100)) % 1_000_000
82  const s = String(cents).padStart(6, '0')
83  return `${s.slice(0, 4)}.${s.slice(4)}`
84}
85
86const ease = (from: number, to: number, k: number): number => (Math.abs(to - from) < 0.002 ? to : from + (to - from) * k)
87
88const Dash: ClientModule<DashProps, DashState> = (props, surface) => {
89  if (surface.state === undefined) {
90    surface.setState({ t: 0, sp: 0, fu: (props.fuel ?? 0) / 100, te: (props.temp ?? 0) / 100, od: props.odo ?? 0 })
91    surface.every(33, () => {
92      const s = surface.state
93      if (!s) return
94      const p = latest
95      surface.setState({
96        t: s.t + 1,
97        sp: ease(s.sp, Math.min(1, p.speed / Math.max(1, p.speedMax)), 0.16),
98        fu: ease(s.fu, (p.fuel ?? 0) / 100, 0.1),
99        te: ease(s.te, (p.temp ?? 0) / 100, 0.1),
100        od: Math.abs((p.odo ?? 0) - s.od) < 0.004 ? (p.odo ?? 0) : s.od + ((p.odo ?? 0) - s.od) * 0.12,
101      })
102    })
103  }
104  latest = props
105  const st = surface.state ?? { t: 0, sp: 0, fu: 0, te: 0, od: 0 }
106  const { Box, Text } = surface.elements
107  const width = surface.columns || 80
108  const t = st.t
109  const shiver = props.working && props.speed > 0.5 ? Math.sin(t * 0.9) * 0.012 + Math.sin(t * 2.7) * 0.006 : 0
110  const blink = Math.floor(t / 15) % 2 === 0
111
112  const top: Run[] = []
113  const bottom: Run[] = []
114  let used = 0
115  const add = (w: number, a: Run[], b: Run[]): boolean => {
116    if (used + w > width) return false
117    top.push(...a)
118    bottom.push(...b)
119    used += w
120    return true
121  }
122  const pad = (s: string, n: number) => (s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length))
123  const gauge = (v: number, tint: (f: number) => string, value: string, valueColor: string, label: string): void => {
124    const [r1, r2] = dialCells(v, tint)
125    add(15, [...r1, { text: ' ' }, { text: pad(value, 6), color: valueColor, bold: true }, { text: ' ' }], [...r2, { text: ' ' }, { text: pad(label, 6), dim: true }, { text: ' ' }])
126  }
127
128  const spd = Math.max(0, Math.min(1, st.sp + shiver))
129  gauge(spd, speedTint, String(Math.round(props.speed)), speedTint(st.sp), `tok/s`)
130  if (props.fuel !== null) gauge(st.fu, fuelTint, `${Math.round(props.fuel)}%`, props.fuelLow ? '#f87171' : fuelTint(st.fu), 'fuel')
131  if (props.temp !== null) gauge(st.te, tempTint, `${Math.round(props.temp)}%`, props.heat ? '#f87171' : tempTint(st.te), 'ctx')
132  if (props.odo !== null) {
133    const d = odo(st.od)
134    add(11, [
135      { text: '$', color: '#facc15', bold: true },
136      { text: d.slice(0, 4), color: '#f3f4f6', bg: '#1f2937', bold: true },
137      { text: '.', color: '#9ca3af', bg: '#1f2937' },
138      { text: d.slice(5), color: '#fff1f2', bg: '#9f1239', bold: true },
139      { text: '  ' },
140    ], [{ text: pad('odometer', 11), dim: true }])
141  }
142  const lamp = (label: string, on: boolean, color: string, flash: boolean): Run =>
143    on && (!flash || blink) ? { text: ` ${label} `, color: '#111111', bg: color, bold: true } : { text: ` ${label} `, color: on ? color : '#6b7280', dim: !on }
144  const lamps = [lamp('ENG', props.engine, '#fb923c', false), lamp('FUEL', props.fuelLow, '#fb923c', true), lamp('HOT', props.heat, '#f87171', true)]
145  // The status sits under the lamps, 17 cells: `◈ 12 on the road` fits, the desktop's longer words would not.
146  const status: Run = props.working
147    ? { text: props.agents > 0 ? `◈ ${props.agents} on the road` : '● engine running', color: '#4ade80' }
148    : { text: `○ coasting ${clock(props.idleSec)}`, dim: true }
149  add(17, [...lamps, { text: ' ' }], [{ ...status, text: pad(status.text, 17) }])
150
151  const line = (runs: Run[], key: string): RenderElement => {
152    const merged: Run[] = []
153    for (const r of runs) {
154      const last = merged[merged.length - 1]
155      if (last && last.color === r.color && last.bg === r.bg && last.bold === r.bold && last.dim === r.dim) last.text += r.text
156      else merged.push({ ...r })
157    }
158    return (
159      <Text key={key} wrap="truncate-end">
160        {merged.map((r, i) => (
161          <Text key={String(i)} color={r.color} backgroundColor={r.bg} bold={r.bold} dimColor={r.dim}>{r.text}</Text>
162        ))}
163      </Text>
164    )
165  }
166  return (
167    <Box flexDirection="column">
168      {line(top, 'a')}
169      {line(bottom, 'b')}
170    </Box>
171  )
172}
173
174let latest: DashProps = { speed: 0, speedMax: 100, fuel: null, temp: null, odo: null, engine: false, fuelLow: false, heat: false, working: false, agents: 0, idleSec: 0 }
175
176export default Dash
177
hooks/model.ts 75 lines
1// Throttle's arithmetic: no `$`, no engine, so the tests can drive it directly.
2
3/** Output tokens a model request produced between `s` and `e` (ms). */
4export type Span = { s: number; e: number; tok: number }
5
6/** How many characters of streamed text make one output token, roughly. */
7export const CHARS_PER_TOKEN = 4
8
9/** The window the speedometer averages over. */
10export const SPEED_WINDOW_MS = 10_000
11
12/**
13 * Output tokens per second over the last `windowMs`, every span spread evenly
14 * over its own length. The divisor is the part of the window that actually saw
15 * output (at least 2 s), so the needle reads the current pace from the first
16 * seconds of a stream instead of creeping up for ten seconds.
17 */
18export function tokensPerSec(spans: readonly Span[], now: number, windowMs = SPEED_WINDOW_MS): number {
19  const from = now - windowMs
20  let tok = 0
21  let earliest = now
22  for (const sp of spans) {
23    const end = Math.min(sp.e, now)
24    if (end <= from || sp.s >= now || sp.tok <= 0) continue
25    const start = Math.max(sp.s, from)
26    const len = sp.e - sp.s
27    const share = len <= 0 ? 1 : Math.max(0, (end - start) / len)
28    tok += sp.tok * share
29    if (start < earliest) earliest = start
30  }
31  if (tok <= 0) return 0
32  const seen = Math.min(windowMs, Math.max(2000, now - earliest))
33  return tok / (seen / 1000)
34}
35
36/** The dial's full scale: the smallest of 100, 200, 400 ... that holds `peak` with 10 % headroom. */
37export function speedScale(peak: number): number {
38  let top = 100
39  while (top < peak * 1.1 && top < 102_400) top *= 2
40  return top
41}
42
43/** Fuel left in the 5-hour window, 0..100, or null without a reading. */
44export function fuelLeft(limits: readonly { kind: string; percentUsed: number }[]): number | null {
45  const five = limits.find(l => l.kind === 'five_hour')
46  if (!five || !Number.isFinite(five.percentUsed)) return null
47  return Math.max(0, Math.min(100, 100 - five.percentUsed))
48}
49
50export type Lamps = { engine: boolean; fuel: boolean; heat: boolean }
51
52export const ENGINE_LAMP_MS = 30_000
53export const FUEL_LAMP_BELOW = 15
54export const HEAT_LAMP_ABOVE = 85
55
56export function lamps(now: number, failedAt: number | undefined, fuel: number | null, temp: number | null): Lamps {
57  return {
58    engine: failedAt !== undefined && now - failedAt < ENGINE_LAMP_MS,
59    fuel: fuel !== null && fuel < FUEL_LAMP_BELOW,
60    heat: temp !== null && temp > HEAT_LAMP_ABOVE,
61  }
62}
63
64/** Drops spans that ended before the window (keeping a minute for the peak). */
65export function prune(spans: Span[], now: number, keepMs = 60_000): Span[] {
66  return spans.filter(sp => sp.e > now - keepMs)
67}
68
69/** `$12.34` as odometer wheels: `0012.34` (wraps past 9999.99). */
70export function odoDigits(usd: number): string {
71  const cents = Math.max(0, Math.round(usd * 100)) % 1_000_000
72  const s = String(cents).padStart(6, '0')
73  return `${s.slice(0, 4)}.${s.slice(4)}`
74}
75
hooks/svg.ts 201 lines
1// Throttle's desktop drawing: one SVG row. Pure: snapshot in, markup out.
2// The SVG is redrawn on each published snapshot, so motion is keyframed from
3// the previous reading (`*Prev`) to the new one: the needles sweep, the arcs
4// fill, the odometer wheels roll, all in the compositor.
5import type { ThrottleSnap } from '../types'
6import { KZ, svg, svgText } from './lib/kz.ts'
7import { odoDigits } from './model.ts'
8
9const H = 80
10const MIN_W = 540
11const SWEEP = 240
12const START = 150
13
14const CSS = `
15.tk{stroke:#e2e0da}.tick{stroke:#b9b7b0}.well{fill:#ecebe6}.wtx{fill:#1f1f1f}
16@media (prefers-color-scheme: dark){.tk{stroke:#30302e}.tick{stroke:#5a5a56}.well{fill:#121212}.wtx{fill:#f2f2f2}}
17.blink{animation:tb 1s steps(2,jump-none) infinite}@keyframes tb{50%{opacity:.25}}
18.glow{filter:url(#glow)}
19`
20
21const rad = (deg: number) => (deg * Math.PI) / 180
22const pt = (cx: number, cy: number, r: number, deg: number) => [cx + r * Math.cos(rad(deg)), cy + r * Math.sin(rad(deg))] as const
23const f1 = (n: number) => n.toFixed(1)
24
25type DialSpec = {
26  id: string
27  cx: number
28  value: number
29  prev: number | null
30  max: number
31  text: string
32  unit: string
33  stops: [number, string][]
34  isAlarm: boolean
35  isShaking: boolean
36}
37
38function dial(d: DialSpec, cy: number, css: string[]): string {
39  const r = 29
40  const L = (r * rad(SWEEP))
41  const v = Math.max(0, Math.min(1, d.value / d.max))
42  const pv = d.prev === null ? v : Math.max(0, Math.min(1, d.prev / d.max))
43  const [x0, y0] = pt(d.cx, cy, r, START)
44  const [x1, y1] = pt(d.cx, cy, r, START + SWEEP)
45  const arc = `M${f1(x0)} ${f1(y0)}A${r} ${r} 0 1 1 ${f1(x1)} ${f1(y1)}`
46  const out: string[] = []
47  out.push(`<defs><linearGradient id="g${d.id}" x1="0" y1="0" x2="1" y2="0">${d.stops.map(([o, c]) => `<stop offset="${o}" stop-color="${c}"/>`).join('')}</linearGradient></defs>`)
48  out.push(`<circle class="p" cx="${d.cx}" cy="${cy}" r="${r + 7}"/>`)
49  out.push(`<path d="${arc}" class="tk" stroke-width="5" fill="none" stroke-linecap="round"/>`)
50  for (let i = 0; i <= 12; i++) {
51    const a = START + (SWEEP * i) / 12
52    const [ax, ay] = pt(d.cx, cy, r - 6, a)
53    const [bx, by] = pt(d.cx, cy, r - (i % 3 === 0 ? 11 : 8.5), a)
54    out.push(`<line class="tick" x1="${f1(ax)}" y1="${f1(ay)}" x2="${f1(bx)}" y2="${f1(by)}" stroke-width="${i % 3 === 0 ? 1.4 : 0.8}"/>`)
55  }
56  css.push(`@keyframes a${d.id}{from{stroke-dashoffset:${f1(L * (1 - pv))}}to{stroke-dashoffset:${f1(L * (1 - v))}}}`)
57  out.push(`<path d="${arc}" stroke="url(#g${d.id})" stroke-width="5" fill="none" stroke-linecap="round" stroke-dasharray="${f1(L)} ${f1(L + 10)}" stroke-dashoffset="${f1(L * (1 - v))}" class="glow" style="animation:a${d.id} .9s cubic-bezier(.2,.8,.2,1) both"/>`)
58  const from = START + SWEEP * pv
59  const to = START + SWEEP * v
60  css.push(`@keyframes n${d.id}{from{transform:rotate(${f1(from)}deg)}to{transform:rotate(${f1(to)}deg)}}`)
61  css.push(`@keyframes s${d.id}{from{transform:rotate(-1.4deg)}to{transform:rotate(1.4deg)}}`)
62  const origin = `transform-box:view-box;transform-origin:${d.cx}px ${cy}px`
63  out.push(`<g style="${origin};animation:n${d.id} .9s cubic-bezier(.25,1.4,.4,1) both"><g style="${origin}${d.isShaking ? `;animation:s${d.id} .11s ease-in-out infinite alternate` : ''}">`)
64  out.push(`<line x1="${d.cx - 6}" y1="${cy}" x2="${d.cx + r - 4}" y2="${cy}" stroke="${KZ.red}" stroke-width="2.2" stroke-linecap="round"/>`)
65  out.push(`</g></g>`)
66  out.push(`<circle cx="${d.cx}" cy="${cy}" r="3.6" fill="${KZ.red}"/><circle cx="${d.cx}" cy="${cy}" r="1.5" class="p"/>`)
67  out.push(svgText(d.cx, cy + 17, d.text, { size: 13, weight: 750, anchor: 'middle', fill: d.isAlarm ? KZ.red : undefined }))
68  out.push(svgText(d.cx, cy + 28, d.unit, { cls: 'm', size: 8.5, weight: 600, anchor: 'middle' }))
69  return out.join('')
70}
71
72/** The odometer: seven wheels; the ones that changed roll up from the old digit. */
73function odometer(x: number, y: number, usd: number, prevUsd: number | null, css: string[]): string {
74  const now = odoDigits(usd)
75  const was = odoDigits(prevUsd ?? usd)
76  const out: string[] = []
77  const wh = 22
78  const ww = 13
79  out.push(`<rect class="p" x="${x - 6}" y="${y - 8}" width="${7 * (ww + 2) + 20}" height="${wh + 30}" rx="9"/>`)
80  out.push(`<defs><clipPath id="odo"><rect x="${x}" y="${y}" width="${7 * (ww + 2) + 8}" height="${wh}" rx="4"/></clipPath></defs>`)
81  let cx = x
82  let wheel = 0
83  for (let i = 0; i < now.length; i++) {
84    const ch = now.charAt(i)
85    if (ch === '.') {
86      out.push(svgText(cx + 2, y + wh - 5, '.', { cls: 's', size: 14, weight: 700, anchor: 'middle' }))
87      cx += 6
88      continue
89    }
90    const isCents = i > 4
91    out.push(`<rect x="${cx}" y="${y}" width="${ww}" height="${wh}" rx="3" ${isCents ? `fill="${KZ.red}" opacity=".9"` : 'class="well"'}/>`)
92    const old = was.charAt(i)
93    const cls = isCents ? '' : 'wtx'
94    const fill = isCents ? '#fff' : undefined
95    const digit = (dy: number, s: string) =>
96      `<text ${fill ? `fill="${fill}"` : `class="${cls}"`} x="${cx + ww / 2}" y="${y + wh - 6 + dy}" font-family="ui-monospace,Consolas,monospace" font-size="14" font-weight="700" text-anchor="middle">${s}</text>`
97    if (old !== ch) {
98      css.push(`@keyframes w${wheel}{from{transform:translateY(${wh}px)}to{transform:translateY(0)}}`)
99      out.push(`<g clip-path="url(#odo)"><g style="animation:w${wheel} .7s cubic-bezier(.3,1.3,.5,1) both">${digit(-wh, old)}${digit(0, ch)}</g></g>`)
100    } else out.push(`<g clip-path="url(#odo)">${digit(0, ch)}</g>`)
101    cx += ww + 2
102    wheel++
103  }
104  out.push(svgText(x, y - 1 + wh + 16, 'ODOMETER · SESSION $', { cls: 'm', size: 8, weight: 650 }))
105  return out.join('')
106}
107
108function lamp(kind: 'engine' | 'fuel' | 'heat', x: number, y: number, isOn: boolean, color: string, isBlinking: boolean): string {
109  const paint = isOn ? `fill="${color}"` : 'class="m" opacity=".35"'
110  const stroke = isOn ? `stroke="${color}"` : 'class="m" stroke="#8e8e8a" opacity=".35"'
111  let icon = ''
112  if (kind === 'engine') {
113    icon = `<path ${paint} d="M${x - 9} ${y - 3}h3v-3h4v-2h6v2h3l2 3h2v-2h2v9h-2v-2h-2l-2 4h-11l-2-3h-3z"/>`
114  } else if (kind === 'fuel') {
115    icon = `<rect ${paint} x="${x - 7}" y="${y - 8}" width="9" height="15" rx="1.5"/><rect x="${x - 5}" y="${y - 6}" width="5" height="4" rx=".6" fill="${isOn ? '#111' : 'none'}" opacity=".55"/>` +
116      `<path ${stroke} fill="none" stroke-width="1.6" stroke-linecap="round" d="M${x + 2} ${y - 3}h3v7a1.5 1.5 0 0 0 3 0v-8l-3-3"/>`
117  } else {
118    icon = `<path ${stroke} fill="none" stroke-width="2.2" stroke-linecap="round" d="M${x} ${y - 9}v10"/><circle ${paint} cx="${x}" cy="${y + 3}" r="3.6"/>` +
119      `<path ${stroke} fill="none" stroke-width="1.3" stroke-linecap="round" d="M${x - 9} ${y + 8}q2-2 4 0t4 0M${x + 1} ${y + 8}q2-2 4 0t4 0"/>`
120  }
121  const label = kind === 'engine' ? 'ENG' : kind === 'fuel' ? 'FUEL' : 'HOT'
122  return `<g class="${isOn ? `glow${isBlinking ? ' blink' : ''}` : ''}">${icon}</g>` + svgText(x, y + 22, label, { size: 8, weight: 700, anchor: 'middle', ...(isOn ? { fill: color } : { cls: 'm' }) })
123}
124
125function road(x: number, w: number, s: ThrottleSnap, css: string[]): string {
126  const y = 16
127  const h = 54
128  const out: string[] = []
129  out.push(`<rect class="p" x="${x}" y="${y - 8}" width="${w}" height="${h + 12}" rx="12"/>`)
130  out.push(`<defs><clipPath id="rd"><rect x="${x}" y="${y - 8}" width="${w}" height="${h + 12}" rx="12"/></clipPath></defs>`)
131  const ly = y + h - 18
132  const moving = s.isWorking && s.speed > 0.5
133  const pps = 30 + Math.min(600, s.speed * 2.2)
134  const dur = (40 / pps).toFixed(2)
135  css.push(`@keyframes rd{to{stroke-dashoffset:-40}}@keyframes rk{to{transform:translateX(-120px)}}@keyframes bob{50%{transform:translateY(-1.2px)}}@keyframes wh{to{transform:rotate(360deg)}}`)
136  out.push(`<g clip-path="url(#rd)">`)
137  // Distant hills drift slower than the road: parallax.
138  const hills: string[] = []
139  for (let hx = x - 120; hx < x + w + 240; hx += 120) hills.push(`M${hx} ${ly - 6}q30-22 60 -4t60 0`)
140  out.push(`<path d="${hills.join('')}" class="tk" stroke-width="1.4" fill="none" style="${moving ? `animation:rk ${(120 / (pps * 0.18)).toFixed(1)}s linear infinite` : ''}"/>`)
141  out.push(`<line x1="${x}" y1="${ly + 11}" x2="${x + w}" y2="${ly + 11}" class="tk" stroke-width="5"/>`)
142  out.push(`<line x1="${x}" y1="${ly + 11}" x2="${x + w}" y2="${ly + 11}" stroke="${KZ.mist}" stroke-width="2" stroke-dasharray="22 18" style="${moving ? `animation:rd ${dur}s linear infinite` : ''}"/>`)
143  out.push(`</g>`)
144  // The car: a little coupe that bobs while it drives.
145  const carX = x + Math.min(w - 60, Math.max(18, 18 + (w - 90) * Math.min(1, s.speed / Math.max(1, s.speedMax))))
146  const body = s.lamps.engine ? KZ.amber : KZ.violet
147  out.push(`<g style="${moving ? 'animation:bob .35s ease-in-out infinite' : ''}">`)
148  out.push(`<path fill="${body}" d="M${carX} ${ly + 6}v-6q0-3 3-3h6l6-6h14l7 6h7q3 0 3 3v6z"/>`)
149  out.push(`<path fill="#bfe9ff" opacity=".85" d="M${carX + 11} ${ly - 4}l4-4h5v4zM${carX + 22} ${ly - 4}v-4h5l4 4z"/>`)
150  for (const wx of [carX + 9, carX + 32]) {
151    out.push(`<g style="transform-box:fill-box;transform-origin:center;${moving ? 'animation:wh .5s linear infinite' : ''}"><circle cx="${wx}" cy="${ly + 6}" r="4.2" fill="#2b2b2b"/><line x1="${wx - 3}" y1="${ly + 6}" x2="${wx + 3}" y2="${ly + 6}" stroke="#aaa" stroke-width="1"/></g>`)
152  }
153  if (moving) out.push(`<path d="M${carX - 4} ${ly + 3}h-10M${carX - 3} ${ly}h-6" stroke="${KZ.mist}" stroke-width="1.2" stroke-linecap="round" class="pulse"/>`)
154  out.push(`</g>`)
155  const status = s.isWorking
156    ? s.agents > 0 ? `◈ ${s.agents} agent${s.agents === 1 ? '' : 's'} on the road` : 'engine running'
157    : `coasting · ${Math.round(s.idleMs / 1000)}s`
158  out.push(svgText(x + 12, y + 6, status, { size: 10.5, weight: 650, ...(s.isWorking ? { fill: KZ.green } : { cls: 's' }) }))
159  return out.join('')
160}
161
162export function dashSvg(s: ThrottleSnap, width: number): { source: string; width: number; height: number; alt: string } {
163  const W = Math.max(MIN_W, width)
164  const css: string[] = [CSS]
165  const parts: string[] = []
166  parts.push(`<defs><filter id="glow" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="1.6" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs>`)
167  const cy = 38
168  const dials: DialSpec[] = [
169    { id: 'sp', cx: 40, value: s.speed, prev: s.speedPrev, max: s.speedMax, text: String(Math.round(s.speed)), unit: `TOK/S · ${s.speedMax}`, stops: [[0, KZ.cyan], [0.5, KZ.violet], [1, KZ.magenta]], isAlarm: false, isShaking: s.isWorking && s.speed > 0.5 },
170  ]
171  if (s.fuel !== null) dials.push({ id: 'fu', cx: 0, value: s.fuel, prev: s.fuelPrev, max: 100, text: `${Math.round(s.fuel)}%`, unit: '5H FUEL', stops: [[0, KZ.red], [0.3, KZ.amber], [0.6, KZ.yellow], [1, KZ.green]], isAlarm: s.lamps.fuel, isShaking: false })
172  if (s.temp !== null) dials.push({ id: 'te', cx: 0, value: s.temp, prev: s.tempPrev, max: 100, text: `${Math.round(s.temp)}%`, unit: 'CTX TEMP', stops: [[0, KZ.blue], [0.5, KZ.green], [0.8, KZ.amber], [1, KZ.red]], isAlarm: s.lamps.heat, isShaking: false })
173  dials.forEach((d, i) => {
174    d.cx = 40 + i * 84
175    parts.push(dial(d, cy, css))
176  })
177  let x = 40 + dials.length * 84 - 30
178  if (s.odo !== null) {
179    parts.push(odometer(x + 6, 22, s.odo, s.odoPrev, css))
180    x += 7 * 15 + 30
181  }
182  const lx = x + 14
183  parts.push(lamp('engine', lx, 34, s.lamps.engine, KZ.amber, false))
184  parts.push(lamp('fuel', lx + 34, 34, s.lamps.fuel, KZ.amber, true))
185  parts.push(lamp('heat', lx + 64, 34, s.lamps.heat, KZ.red, true))
186  x = lx + 84
187  if (W - x > 110) parts.push(road(x, W - x - 2, s, css))
188  const scale = Math.min(1, width / W)
189  return { source: svg(W, H, parts.join(''), css.join('')), width: Math.round(W * scale), height: Math.round(H * scale), alt: altOf(s) }
190}
191
192export function altOf(s: ThrottleSnap): string {
193  const bits = [`${Math.round(s.speed)} output tokens per second`]
194  if (s.fuel !== null) bits.push(`${Math.round(s.fuel)}% of the 5-hour limit left`)
195  if (s.temp !== null) bits.push(`context ${Math.round(s.temp)}%`)
196  if (s.odo !== null) bits.push(`session $${s.odo.toFixed(2)}`)
197  const lit = [s.lamps.engine && 'check engine (a tool failed)', s.lamps.fuel && 'low fuel', s.lamps.heat && 'overheating'].filter(Boolean)
198  if (lit.length) bits.push(`warnings: ${lit.join(', ')}`)
199  return `Throttle: ${bits.join('; ')}`
200}
201
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
types/index.d.ts 31 lines
1export type ThrottleSnap = {
2  /** Shown while something runs and for a minute after. */
3  isVisible: boolean
4  isWorking: boolean
5  /** Output tokens per second over the last ~10 s, main loop and agents together. */
6  speed: number
7  speedPrev: number
8  /** The dial's full scale (100, 200, 400, ...). */
9  speedMax: number
10  /** Percent of the 5-hour window left; null without a rate-limit reading. */
11  fuel: number | null
12  fuelPrev: number | null
13  /** Context fill in percent. */
14  temp: number | null
15  tempPrev: number | null
16  /** Session cost in USD. */
17  odo: number | null
18  odoPrev: number | null
19  lamps: { engine: boolean; fuel: boolean; heat: boolean }
20  /** Subagent loops that streamed in the last few seconds. */
21  agents: number
22  /** Milliseconds since the last activity (0 while working). */
23  idleMs: number
24}
25
26declare module 'claude-code' {
27  interface PluginState {
28    throttle: { snap: ThrottleSnap | null; isHidden: boolean }
29  }
30}
31