SLOPSHOPPER

cc-usage-hud

A chip bar above the prompt: cache hit rate, rate limits, cost (session/day/week/month) and TPS, with hover cards.

newbandguardtoolprocesstimer
v0.8.0no licenseupdated 2026-10-09Bing2000me/cc-usage-hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-usage-hud
› 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 ◷ 1 轮 0 步 · — ▦ 0 tok · 缓存命中 — ◔ 5h 31% · 7d — ◇ $0.42 · 今日 $0.00 · 本周 $0.00 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◷ 1 轮 0 步 · — ▦ 0 tok · 缓存命中 — ◔ 5h 31% · 7d — ◇ $0.42 · 今日 $0.00 · 本周 $0.00
Source 7 files
hooks/register.tsx 390 lines
1import { atom, read, update } from 'claude-code'
2import type { FsEntry, Register, RenderNode, TurnUsage } from 'claude-code'
3
4import type { HudCard, HudLimit } from '../types'
5import * as F from './format'
6import { buildView, ORDER, pickTier } from './model'
7import { iconSvg } from './svg'
8import { Ledger, parseJson, serial } from './ledger'
9import type { LedgerFile, LimitsCache } from './ledger'
10import { EMPTY_SNAP, EMPTY_STATS, EMPTY_TOTALS } from './state'
11
12const statsAtom = atom({ plugin: 'cc-usage-hud', key: 'stats' } as const, EMPTY_STATS)
13const snapAtom = atom({ plugin: 'cc-usage-hud', key: 'snap' } as const, EMPTY_SNAP)
14const totalsAtom = atom({ plugin: 'cc-usage-hud', key: 'totals' } as const, EMPTY_TOTALS)
15const ownAtom = atom({ plugin: 'cc-usage-hud', key: 'own' } as const, null)
16const pinnedAtom = atom({ plugin: 'cc-usage-hud', key: 'pinned' } as const, null)
17const tickAtom = atom({ plugin: 'cc-usage-hud', key: 'tick' } as const, 0)
18
19// Tools whose time is the person's, not the machine's.
20const USER_WAIT_TOOLS = new Set(['AskUserQuestion', 'ExitPlanMode'])
21const LIMITS_WRITE_EVERY_MS = 30_000
22const CARD_WIDTH = 40
23// The desktop's proportional text runs narrower than the cells cellWidth counts.
24const DESKTOP_TEXT_FIT = 0.88
25const CHIP_GAP = 2
26// Cells a desktop chip adds to its label: the icon, its gap and the padding.
27const CHIP_CHROME = 5
28const GLYPH: Record<string, string> = { gauge: '◷', database: '▦', ring: '◔', timer: '◔', coin: '◇' }
29
30export const register: Register = on => {
31  const ledger = new Ledger()
32  let refreshQueued = false
33  let lastLimitsWrite = 0
34  let lastLimitsJson = ''
35
36  on('session.start', async ($, e, next) => {
37    const r = await next(e)
38    // The dev tool exists only in a copy loaded from a session's hot-reload folder.
39    if ($.plugin.root.includes('/.claude/dev-mods/'))
40      await $.tool
41        .register({
42          name: 'hud_dev',
43          description: 'Dev only: reloads the cc-usage-hud mod and opens its detail for one chip (stats, tokens, limits, cost, or none).',
44          inputSchema: {
45            type: 'object',
46            properties: { pin: { type: 'string', enum: ['stats', 'tokens', 'limits', 'cost', 'none'] } },
47          },
48          isDeferred: false,
49        })
50        .catch(() => undefined)
51
52    // Reads the status line's figures and folds them into the band and the ledger.
53    const refresh = () =>
54      serial(async () => {
55        const usage = await $.session.usage()
56        const now = await $.clock.now()
57        const fresh: HudLimit[] = usage.rateLimits.map(l => ({
58          kind: l.kind,
59          percentUsed: l.percentUsed,
60          ...(l.resetsAt ? { resetsAt: l.resetsAt } : {}),
61        }))
62        await update($, snapAtom, s => ({
63          costUsd: usage.cost?.usd ?? s.costUsd,
64          limits: fresh.length > 0 ? fresh : s.limits,
65          limitsAt: fresh.length > 0 ? now : s.limitsAt,
66          limitsStale: fresh.length > 0 ? false : s.limitsStale,
67          ctxTokens: usage.context.tokens ?? null,
68          ctxWindow: usage.context.window ?? null,
69          ctxPercent: usage.context.percent ?? null,
70        }))
71        if (usage.cost) {
72          const id = await $.session.id()
73          for (const w of ledger.applyCost(usage.cost.usd, now, id)) await $.fs.write(w.path, w.text)
74          const own = ledger.own
75          if (own) await update($, ownAtom, () => ({ ...own, days: { ...own.days } }))
76        }
77        await update($, totalsAtom, () => ledger.totals(now))
78        if (fresh.length > 0 && ledger.base) {
79          const json = JSON.stringify(fresh)
80          if (json !== lastLimitsJson && now - lastLimitsWrite >= LIMITS_WRITE_EVERY_MS) {
81            lastLimitsJson = json
82            lastLimitsWrite = now
83            const cache: LimitsCache = { at: now, limits: fresh }
84            await $.fs.write(ledger.limitsPath(), JSON.stringify(cache))
85          }
86        }
87      })
88
89    // Re-reads the other sessions' ledgers, flushes this one, moves the countdowns.
90    const everyMinute = () =>
91      serial(async () => {
92        const now = await $.clock.now()
93        if (ledger.base) {
94          let entries: readonly FsEntry[] = []
95          try {
96            entries = await $.fs.list(ledger.dir())
97          } catch {
98            entries = []
99          }
100          for (const entry of ledger.toRead(entries, now)) {
101            let text: string | null = null
102            try {
103              text = String(await $.fs.read(`${ledger.dir()}/${entry.name}`))
104            } catch {
105              text = null
106            }
107            ledger.ingest(entry, text)
108          }
109          ledger.settle(entries, now)
110          const w = ledger.take(now, true)
111          if (w) await $.fs.write(w.path, w.text)
112        }
113        await update($, totalsAtom, () => ledger.totals(now))
114        await update($, tickAtom, () => now)
115      })
116
117    await serial(async () => {
118      const home = await $.env.get('HOME')
119      ledger.base = home ? `${home}/.claude/cc-usage-hud` : null
120      try {
121        const out = await $.process.run(['date', '+%z'])
122        const tz = F.parseTz(out.stdout)
123        if (tz !== null) ledger.tzMin = tz
124      } catch {
125        // Keep the environment's own offset.
126      }
127      const now = await $.clock.now()
128      const usage = await $.session.usage()
129      const id = await $.session.id()
130      const held = await read($, ownAtom)
131      let file: LedgerFile | null = null
132      let meta: { since?: string } | null = null
133      let cached: LimitsCache | null = null
134      if (ledger.base) {
135        file = parseJson<LedgerFile>(await $.fs.read(ledger.ownPath(id)).then(String, () => null))
136        meta = parseJson<{ since?: string }>(await $.fs.read(ledger.metaPath()).then(String, () => null))
137        cached = parseJson<LimitsCache>(await $.fs.read(ledger.limitsPath()).then(String, () => null))
138      }
139      ledger.start(id, held, file, usage.cost?.usd ?? null)
140      if (ledger.base) {
141        ledger.since = meta?.since ?? F.dayKey(now, ledger.tzMin)
142        if (!meta?.since) await $.fs.write(ledger.metaPath(), JSON.stringify({ since: ledger.since }))
143      }
144      const snap = await read($, snapAtom)
145      if (snap.limits.length === 0 && cached && cached.limits.length > 0) {
146        const { at, limits } = cached
147        await update($, snapAtom, s => ({ ...s, limits, limitsAt: at, limitsStale: true }))
148      }
149    })
150    await everyMinute()
151    await refresh()
152
153    $.clock.every(1_500, () => {
154      if (!refreshQueued) return
155      refreshQueued = false
156      refresh().catch(() => undefined)
157    })
158    $.clock.every(60_000, () => {
159      everyMinute().catch(() => undefined)
160    })
161    return r
162  })
163
164  on('turn.start', async ($, e, next) => {
165    await update($, statsAtom, s => ({ ...s, turns: s.turns + 1 })).catch(() => undefined)
166    return next(e)
167  })
168
169  on('turn.step', async function* ($, e, next) {
170    const isMain = e.agentId === undefined
171    const t0 = performance.now()
172    let tFirst: number | null = null
173    let tStop: number | null = null
174    let usage: TurnUsage | null = null
175    for await (const c of next(e)) {
176      if (tFirst === null && c.kind !== 'engine') tFirst = performance.now()
177      if (c.kind === 'stop') {
178        tStop = performance.now()
179        usage = c.usage
180      }
181      yield c
182    }
183    const end = tStop ?? performance.now()
184    const out = usage?.output_tokens ?? 0
185    const genMs = tFirst !== null && tStop !== null ? tStop - tFirst : 0
186    // Under a quarter second the clock says more about chunking than speed.
187    const isTimed = genMs >= 250 && out > 0
188    try {
189      await update($, statsAtom, s => {
190        const n = { ...s }
191        if (usage) {
192          n.input += usage.input_tokens
193          n.cacheWrite += usage.cache_creation_input_tokens
194          n.cacheRead += usage.cache_read_input_tokens
195          n.output += usage.output_tokens
196        }
197        if (isMain) {
198          n.steps += 1
199          n.modelMs += end - t0
200          if (tFirst !== null) {
201            n.ttftMs += tFirst - t0
202            n.ttftN += 1
203          }
204          if (isTimed) {
205            n.genOut += out
206            n.genMs += genMs
207            n.lastTps = out / (genMs / 1000)
208          }
209        } else {
210          n.subSteps += 1
211          if (isTimed) {
212            n.subGenOut += out
213            n.subGenMs += genMs
214          }
215        }
216        return n
217      })
218    } catch {
219      // A missed count is better than a stalled turn.
220    }
221    refreshQueued = true
222  })
223
224  on('tool.call', async ($, e, next) => {
225    if (e.agentId !== undefined || USER_WAIT_TOOLS.has(String(e.tool))) return next(e)
226    const t0 = performance.now()
227    const result = await next(e)
228    const dt = performance.now() - t0
229    await update($, statsAtom, s => ({ ...s, toolMs: s.toolMs + dt })).catch(() => undefined)
230    return result
231  }).catch(($, e, next) => next(e))
232
233  on('session.measure', async ($, e, next) => {
234    refreshQueued = true
235    return next(e)
236  })
237
238  on('turn.complete', async ($, e, next) => {
239    const r = await next(e)
240    refreshQueued = true
241    return r
242  })
243
244  on('session.end', async ($, e, next) => {
245    try {
246      const usage = await $.session.usage()
247      const now = await $.clock.now()
248      const id = await $.session.id()
249      const writes = usage.cost ? ledger.applyCost(usage.cost.usd, now, id) : []
250      const last = ledger.take(now, true)
251      if (last) writes.push(last)
252      for (const w of writes) await $.fs.write(w.path, w.text)
253    } catch {
254      // Exits stay fast; at worst the last few seconds go uncounted.
255    }
256    return next(e)
257  })
258
259  // Dev only: a tool that reloads this mod and pins a card open, for screenshots.
260  on('tool.call', { tool: 'mcp__cc-usage-hud__hud_dev' }, async ($, e) => {
261    const want = String((e as { pin?: unknown }).pin ?? 'none')
262    const pin = (ORDER as string[]).includes(want) ? (want as HudCard) : null
263    await update($, pinnedAtom, () => pin)
264    return { result: `cc-usage-hud reloaded; pinned: ${pin ?? 'none'}` }
265  })
266
267  // Desktop: a row of chips in the band above the prompt, in the band's own gray tray,
268  // as the app's own bars sit. Figures only: the band clips what is drawn outside it,
269  // and no element takes a tooltip, so nothing could float over the conversation.
270  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
271    if (e.props.hasSurvey || e.surface !== 'desktop') return next(e)
272    const { Box, Text, Svg } = $.ui.resolve(e)
273    const s = await read($, statsAtom)
274    const snap = await read($, snapAtom)
275    const totals = await read($, totalsAtom)
276    await read($, tickAtom)
277    const now = await $.clock.now()
278    const view = buildView(s, snap, totals, now, ledger.tzMin)
279
280    // The desktop shows the plan's limits itself, beside the model picker.
281    const chips = view.chips.filter(c => c.id !== 'limits')
282    const fit = (text: string) => F.cellWidth(text) * DESKTOP_TEXT_FIT
283    const tier = pickTier(chips, e.props.bodyColumns, label => fit(label) + CHIP_CHROME)
284
285    return (
286      <Box flexDirection="row" justifyContent="center" columnGap={1}>
287        {chips.map(chip => (
288          <Box key={`chip-${chip.id}`} flexDirection="row" alignItems="center" columnGap={1} paddingX={1}>
289            <Svg source={iconSvg(chip.icon, 14, chip.ring)} alt={chip.labels[tier]} width={14} height={14} />
290            <Text dimColor wrap="truncate-end">
291              {chip.labels[tier]}
292            </Text>
293          </Box>
294        ))}
295      </Box>
296    )
297  })
298
299  // Terminal: a band above the prompt; a chip shows its card on hover or when pressed.
300  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
301    if (e.props.hasSurvey || e.surface !== 'terminal') return next(e)
302    const { Box, Text, Button } = $.ui.resolve(e)
303    const s = await read($, statsAtom)
304    const snap = await read($, snapAtom)
305    const totals = await read($, totalsAtom)
306    const pinned = await read($, pinnedAtom)
307    await read($, tickAtom)
308    const now = await $.clock.now()
309    const view = buildView(s, snap, totals, now, ledger.tzMin)
310
311    const cols = Math.max(20, e.props.bodyColumns)
312    const width = Math.min(CARD_WIDTH, cols)
313    const label = (c: (typeof view.chips)[number], t: 0 | 1 | 2) => `${GLYPH[c.icon] ?? '·'} ${c.labels[t]}`
314    const tier = pickTier(view.chips, cols - CHIP_GAP * (view.chips.length - 1), l => F.cellWidth(l) + 2)
315
316    const offsets = new Map<HudCard, number>()
317    let x = 0
318    for (const c of view.chips) {
319      offsets.set(c.id, Math.max(0, Math.min(x, cols - width)))
320      x += F.cellWidth(label(c, tier)) + CHIP_GAP
321    }
322
323    const tone = (t: string | undefined) =>
324      t === 'bad' ? 'error' : t === 'warn' ? 'warning' : t === 'ok' ? 'success' : undefined
325    const row = (l: string, v: string, t?: string) => (
326      <Box flexDirection="row" justifyContent="space-between">
327        <Text dimColor>{l}</Text>
328        <Text color={tone(t)} dimColor={t === 'dim'}>
329          {v}
330        </Text>
331      </Box>
332    )
333
334    const cards = view.cards.map(card => {
335      const body: RenderNode[] = card.rows.map(r => row(r.label, r.value, r.tone))
336      for (const m of card.meters) {
337        body.push(row(m.label, m.value, m.tone))
338        body.push(<Text color={tone(m.tone)}>{F.bar(m.pct ?? 0, width - 4)}</Text>)
339        if (m.note) body.push(<Text dimColor>{m.note}</Text>)
340      }
341      if (body.length === 0 && card.empty) body.push(<Text dimColor>{card.empty}</Text>)
342      const isPinned = pinned === card.id
343      return (
344        <Box
345          key={`card-${card.id}`}
346          display={isPinned ? 'flex' : 'none'}
347          hover={isPinned ? { scope: `hud-${card.id}` } : { scope: `hud-${card.id}`, display: 'flex' }}
348          flexDirection="column"
349          marginLeft={offsets.get(card.id) ?? 0}
350          width={width}
351          borderStyle="round"
352          borderColor="subtle"
353          paddingX={1}
354        >
355          <Box flexDirection="row" justifyContent="space-between">
356            <Text bold>{card.title}</Text>
357            <Text bold>{card.right}</Text>
358          </Box>
359          {body}
360          {card.foot.map(line => (
361            <Text dimColor wrap="truncate-end">
362              {line}
363            </Text>
364          ))}
365        </Box>
366      )
367    })
368
369    const chips = view.chips.map(c => (
370      <Button
371        key={`chip-${c.id}`}
372        label={label(c, tier)}
373        plain
374        dimColor
375        hover={{ scope: `hud-${c.id}`, bold: true }}
376        onPress={() => update($, pinnedAtom, p => (p === c.id ? null : c.id))}
377      />
378    ))
379
380    return (
381      <Box flexDirection="column">
382        {cards}
383        <Box flexDirection="row" flexWrap="wrap" columnGap={CHIP_GAP}>
384          {chips}
385        </Box>
386      </Box>
387    )
388  })
389}
390
hooks/format.ts 147 lines
1// Pure formatting and date helpers; no `$`, so tests call them directly.
2
3const isWide = (c: number) =>
4  (c >= 0x1100 && c <= 0x115f) ||
5  (c >= 0x2e80 && c <= 0xa4cf) ||
6  (c >= 0xac00 && c <= 0xd7a3) ||
7  (c >= 0xf900 && c <= 0xfaff) ||
8  (c >= 0xfe30 && c <= 0xfe4f) ||
9  (c >= 0xff00 && c <= 0xff60) ||
10  (c >= 0xffe0 && c <= 0xffe6)
11
12/** Terminal cells a string takes: CJK and fullwidth forms count two. */
13export const cellWidth = (s: string): number => {
14  let w = 0
15  for (const ch of s) w += isWide(ch.codePointAt(0) ?? 0) ? 2 : 1
16  return w
17}
18
19/** 3678039 → "3,678,039". */
20export const fmtInt = (n: number): string =>
21  String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
22
23const trim0 = (s: string) => s.replace(/\.0$/, '')
24
25/** 3678039 → "3.7M", 164000 → "164K", 950 → "950". */
26export const fmtTok = (n: number): string => {
27  if (n >= 1e9) return `${trim0((n / 1e9).toFixed(1))}B`
28  if (n >= 1e6) return `${trim0((n / 1e6).toFixed(1))}M`
29  if (n >= 1e5) return `${Math.round(n / 1e3)}K`
30  if (n >= 1e3) return `${trim0((n / 1e3).toFixed(1))}K`
31  return String(Math.round(n))
32}
33
34/** 671000 → "11分11秒", 7300 → "7.3秒", 3780000 → "1小时3分". */
35export const fmtDur = (ms: number): string => {
36  if (ms < 60_000) return `${(ms / 1000).toFixed(1)}秒`
37  const s = Math.round(ms / 1000)
38  const h = Math.floor(s / 3600)
39  const m = Math.floor((s % 3600) / 60)
40  return h > 0 ? `${h}小时${m}分` : `${m}分${s % 60}秒`
41}
42
43export const fmtUsd = (u: number | null | undefined): string => {
44  if (u === null || u === undefined) return '—'
45  if (u > 0 && u < 0.005) return '<$0.01'
46  if (u >= 1000) return `$${fmtInt(u)}`
47  if (u >= 100) return `$${u.toFixed(0)}`
48  return `$${u.toFixed(2)}`
49}
50
51export const fmtPct = (p: number): string => `${Math.round(p)}%`
52
53/** Hit rate: what the cache served over every input token. */
54export const hitRate = (s: { input: number; cacheWrite: number; cacheRead: number }): number | null => {
55  const all = s.input + s.cacheWrite + s.cacheRead
56  return all > 0 ? (s.cacheRead / all) * 100 : null
57}
58
59export const tps = (out: number, ms: number): number | null => (ms > 0 && out > 0 ? out / (ms / 1000) : null)
60
61const pad = (n: number) => String(n).padStart(2, '0')
62
63/** The local calendar day of `ms` (YYYY-MM-DD), `tzMin` minutes east of UTC. */
64export const dayKey = (ms: number, tzMin: number): string =>
65  new Date(ms + tzMin * 60_000).toISOString().slice(0, 10)
66
67/** "+0800" → 480. */
68export const parseTz = (s: string): number | null => {
69  const m = /^([+-])(\d{2})(\d{2})$/.exec(s.trim())
70  if (!m) return null
71  const v = Number(m[2]) * 60 + Number(m[3])
72  return m[1] === '-' ? -v : v
73}
74
75export type Ranges = { today: string; weekStart: string; monthStart: string; earliestMs: number }
76
77/** Today, this week (from Monday) and this month, local. */
78export const ranges = (now: number, tzMin: number): Ranges => {
79  const today = dayKey(now, tzMin)
80  const midnight = Date.parse(`${today}T00:00:00Z`)
81  const dow = (new Date(midnight).getUTCDay() + 6) % 7
82  const weekStart = new Date(midnight - dow * 86_400_000).toISOString().slice(0, 10)
83  const monthStart = `${today.slice(0, 8)}01`
84  const earliest = weekStart < monthStart ? weekStart : monthStart
85  return { today, weekStart, monthStart, earliestMs: Date.parse(`${earliest}T00:00:00Z`) - tzMin * 60_000 }
86}
87
88export const sumDays = (days: Record<string, number>, r: Ranges) => {
89  let today = 0
90  let week = 0
91  let month = 0
92  for (const [k, v] of Object.entries(days)) {
93    if (k > r.today || typeof v !== 'number') continue
94    if (k === r.today) today += v
95    if (k >= r.weekStart) week += v
96    if (k >= r.monthStart) month += v
97  }
98  return { today, week, month }
99}
100
101const LIMIT_LABEL: Record<string, string> = {
102  five_hour: '5 小时',
103  seven_day: '7 天',
104  seven_day_opus: '7 天 · Opus',
105  seven_day_sonnet: '7 天 · Sonnet',
106  spend_limit: '消费上限',
107}
108export const limitLabel = (kind: string) => LIMIT_LABEL[kind] ?? kind
109
110const LIMIT_SHORT: Record<string, string> = { five_hour: '5h', seven_day: '7d' }
111export const limitShort = (kind: string) => LIMIT_SHORT[kind]
112
113const fmtLeft = (ms: number) => {
114  const m = Math.ceil(ms / 60_000)
115  if (m < 60) return `${m}分`
116  if (m < 1440) return m % 60 === 0 ? `${m / 60}小时` : `${Math.floor(m / 60)}小时${m % 60}分`
117  const h = Math.floor((m % 1440) / 60)
118  return h === 0 ? `${Math.floor(m / 1440)}天` : `${Math.floor(m / 1440)}天${h}小时`
119}
120
121/** "2小时13分后重置 · 04:50", or the date when it is not today. */
122export const fmtReset = (resetsAt: string | undefined, now: number, tzMin: number): string => {
123  const t = resetsAt ? Date.parse(resetsAt) : NaN
124  if (Number.isNaN(t)) return ''
125  if (t <= now) return '已重置'
126  const d = new Date(t + tzMin * 60_000)
127  const hm = `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`
128  const when = dayKey(t, tzMin) === dayKey(now, tzMin) ? hm : `${d.getUTCMonth() + 1}/${d.getUTCDate()} ${hm}`
129  return `${fmtLeft(t - now)}后重置 · ${when}`
130}
131
132export const isReset = (resetsAt: string | undefined, now: number) => {
133  const t = resetsAt ? Date.parse(resetsAt) : NaN
134  return !Number.isNaN(t) && t <= now
135}
136
137export const fmtAgo = (ms: number) => (ms < 60_000 ? '刚刚' : `${fmtLeft(ms)}前`)
138
139/** A bar of `width` cells, `pct` of them full. */
140export const bar = (pct: number, width: number): string => {
141  const full = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
142  return '█'.repeat(full) + '░'.repeat(width - full)
143}
144
145/** Theme color for how close a limit is. */
146export const limitColor = (pct: number) => (pct >= 90 ? 'error' : pct >= 70 ? 'warning' : 'success')
147
hooks/model.ts 166 lines
1// What the HUD shows, worked out once from state, drawn by either surface.
2
3import type { HudCard, HudSnap, HudStats, HudTotals } from '../types'
4import * as F from './format'
5
6export type Tone = 'normal' | 'ok' | 'warn' | 'bad' | 'dim'
7export type Icon = 'gauge' | 'database' | 'ring' | 'timer' | 'coin'
8
9export type Row = { label: string; value: string; tone?: Tone }
10export type Meter = { label: string; pct: number | null; value: string; note: string; tone: Tone }
11
12export type Card = {
13  id: HudCard
14  icon: Icon
15  title: string
16  right: string
17  /** Draws `right` as a quiet note rather than a figure. */
18  isRightQuiet?: boolean
19  rows: Row[]
20  meters: Meter[]
21  empty?: string
22  foot: string[]
23}
24
25/** A chip's label at three widths: full, shorter, shortest. */
26export type Chip = { id: HudCard; icon: Icon; labels: [string, string, string]; ring?: number | null }
27
28export type View = { chips: Chip[]; cards: Card[] }
29
30export const ORDER: HudCard[] = ['stats', 'tokens', 'limits', 'cost']
31
32const tpsText = (v: number | null) => (v === null ? '—' : `${Math.round(v)} tok/s`)
33
34/** The widest of a chip set's three label tiers whose total fits in `room`. */
35export const pickTier = (chips: Chip[], room: number, widthOf: (label: string) => number): 0 | 1 | 2 => {
36  for (const tier of [0, 1] as const) {
37    if (chips.reduce((w, c) => w + widthOf(c.labels[tier]), 0) <= room) return tier
38  }
39  return 2
40}
41
42const toneOf = (pct: number): Tone => (pct >= 90 ? 'bad' : pct >= 70 ? 'warn' : 'ok')
43
44export const buildView = (
45  s: HudStats,
46  snap: HudSnap,
47  totals: HudTotals,
48  now: number,
49  tzMin: number,
50): View => {
51  const mainTps = F.tps(s.genOut, s.genMs)
52  const subTps = F.tps(s.subGenOut, s.subGenMs)
53  const hit = F.hitRate(s)
54  const hitText = hit === null ? '—' : F.fmtPct(hit)
55  const totalTok = s.input + s.cacheWrite + s.cacheRead + s.output
56
57  const isOld = (resetsAt: string | undefined) => snap.limitsStale && F.isReset(resetsAt, now)
58  const five = snap.limits.find(l => l.kind === 'five_hour')
59  const seven = snap.limits.find(l => l.kind === 'seven_day')
60  const pctOf = (l: typeof five) => (!l || isOld(l.resetsAt) ? null : l.percentUsed)
61  const pctText = (p: number | null) => (p === null ? '—' : F.fmtPct(p))
62  const fivePct = pctOf(five)
63  const sevenPct = pctOf(seven)
64
65  const tpsShort = mainTps === null ? '—' : `${Math.round(mainTps)} tok/s`
66  const hasLimits = snap.limits.length > 0
67  const limitsText = `5h ${pctText(fivePct)} · 7d ${pctText(sevenPct)}`
68  const costShort = `${F.fmtUsd(snap.costUsd ?? 0)} · 今日 ${F.fmtUsd(totals.today)}`
69  const costText = `${costShort} · 本周 ${F.fmtUsd(totals.week)}`
70
71  const chips: Chip[] = [
72    {
73      id: 'stats',
74      icon: 'gauge',
75      labels: [`${s.turns} 轮 ${s.steps} 步 · ${tpsShort}`, `${s.steps} 步 · ${tpsShort}`, tpsShort],
76    },
77    {
78      id: 'tokens',
79      icon: 'database',
80      labels: [`${F.fmtTok(totalTok)} tok · 缓存命中 ${hitText}`, `${F.fmtTok(totalTok)} · 命中 ${hitText}`, `命中 ${hitText}`],
81    },
82    {
83      id: 'limits',
84      icon: 'ring',
85      ring: fivePct,
86      labels: hasLimits ? [limitsText, limitsText, `5h ${pctText(fivePct)}`] : ['限额 —', '限额 —', '—'],
87    },
88    {
89      id: 'cost',
90      icon: 'coin',
91      labels: [costText, costShort, F.fmtUsd(snap.costUsd ?? 0)],
92    },
93  ]
94
95  const cards: Card[] = [
96    {
97      id: 'stats',
98      icon: 'gauge',
99      title: '会话统计',
100      right: `${s.turns} 轮 · ${s.steps} 步`,
101      isRightQuiet: true,
102      rows: [
103        { label: '模型用时', value: F.fmtDur(s.modelMs) },
104        { label: '工具调用用时', value: F.fmtDur(s.toolMs) },
105        { label: '首 token 平均(TTFT)', value: s.ttftN > 0 ? F.fmtDur(s.ttftMs / s.ttftN) : '—' },
106        { label: '输出速度(TPS)', value: tpsText(mainTps) },
107        ...(s.subSteps > 0 ? [{ label: '子代理', value: `${s.subSteps} 步 · ${tpsText(subTps)}` }] : []),
108      ],
109      meters: [],
110      foot: ['主线程计时 · 输出 token 含 thinking'],
111    },
112    {
113      id: 'tokens',
114      icon: 'database',
115      title: 'Token 用量',
116      right: `${F.fmtTok(totalTok)} tok`,
117      isRightQuiet: true,
118      rows: [
119        { label: '未缓存输入', value: F.fmtInt(s.input) },
120        { label: '缓存写入', value: F.fmtInt(s.cacheWrite) },
121        { label: '缓存读取', value: F.fmtInt(s.cacheRead) },
122        { label: '输出', value: F.fmtInt(s.output) },
123      ],
124      meters: [{ label: '缓存命中', pct: hit, value: hitText, note: '', tone: 'ok' }],
125      foot: ['含子代理 · 命中率 = 缓存读取 ÷ 全部输入'],
126    },
127    {
128      id: 'limits',
129      icon: 'timer',
130      title: '用量限额',
131      right: snap.limitsStale && snap.limitsAt !== null ? `上次读数 · ${F.fmtAgo(now - snap.limitsAt)}` : '',
132      isRightQuiet: true,
133      rows: [],
134      meters: snap.limits.map(l => {
135        const old = isOld(l.resetsAt)
136        return {
137          label: F.limitLabel(l.kind),
138          pct: old ? null : l.percentUsed,
139          value: old ? '—' : `${l.percentUsed}%`,
140          note: F.fmtReset(l.resetsAt, now, tzMin),
141          tone: old ? 'dim' : toneOf(l.percentUsed),
142        }
143      }),
144      empty: '暂无读数 · 本会话首次响应后显示',
145      foot: ['账号级窗口,所有会话共用'],
146    },
147    {
148      id: 'cost',
149      icon: 'coin',
150      title: '费用',
151      right: `按 API 标价${totals.since ? ` · 自 ${totals.since.slice(5).replace('-', '/')} 起` : ''}`,
152      isRightQuiet: true,
153      rows: [
154        { label: '本会话', value: F.fmtUsd(snap.costUsd ?? 0) },
155        { label: '今日', value: F.fmtUsd(totals.today) },
156        { label: '本周', value: F.fmtUsd(totals.week) },
157        { label: '本月', value: F.fmtUsd(totals.month) },
158      ],
159      meters: [],
160      foot: ['与 /cost 同口径,订阅为等价费用', ...(totals.since ? [`累计自 ${totals.since.slice(5).replace('-', '/')} 起`] : [])],
161    },
162  ]
163
164  return { chips, cards }
165}
166
hooks/svg.ts 45 lines
1// The desktop's drawings: the chip icons, as SVG markup.
2// Colors follow the system appearance through prefers-color-scheme.
3
4import type { Icon } from './model'
5
6const PALETTE = `
7  :root { --icon:#9d9da3; --track:rgba(255,255,255,.10); --ok:#5b8def; --warn:#f0a43c; --bad:#ef5b55; }
8  @media (prefers-color-scheme: light) {
9    :root { --icon:#7a7a80; --track:rgba(0,0,0,.08); --ok:#3b73e0; --warn:#c77a12; --bad:#d6403a; }
10  }`
11
12/** One 24-unit icon's strokes (lucide-style geometry). */
13const glyph = (icon: Icon, ring: number | null | undefined): string => {
14  switch (icon) {
15    case 'gauge':
16      return `<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>`
17    case 'database':
18      return `<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14a9 3 0 0 0 18 0V5"/><path d="M3 12a9 3 0 0 0 18 0"/>`
19    case 'timer':
20      return `<path d="M10 2h4"/><path d="m12 14 3-3"/><circle cx="12" cy="14" r="8"/>`
21    case 'coin':
22      return `<circle cx="12" cy="12" r="10"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 18V6"/>`
23    case 'ring': {
24      const r = 9
25      const c = 2 * Math.PI * r
26      const p = Math.max(0, Math.min(100, ring ?? 0))
27      const tone = p >= 90 ? 'var(--bad)' : p >= 70 ? 'var(--warn)' : 'var(--ok)'
28      return (
29        `<circle cx="12" cy="12" r="${r}" stroke="var(--track)" stroke-width="3.2"/>` +
30        (p > 0
31          ? `<circle cx="12" cy="12" r="${r}" stroke="${tone}" stroke-width="3.2" stroke-linecap="round"` +
32            ` stroke-dasharray="${((p / 100) * c).toFixed(2)} ${c.toFixed(2)}" transform="rotate(-90 12 12)"/>`
33          : '')
34      )
35    }
36  }
37}
38
39/** A chip's icon at `size` CSS pixels. */
40export const iconSvg = (icon: Icon, size: number, ring?: number | null): string =>
41  `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24">` +
42  `<style>${PALETTE}</style>` +
43  `<g fill="none" stroke="var(--icon)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">` +
44  `${glyph(icon, ring)}</g></svg>`
45
hooks/ledger.ts 144 lines
1// The cross-session cost ledger: each session owns one small file,
2// ~/.claude/cc-usage-hud/ledger/<sessionId>.json, holding the USD it added
3// per local day. Only growth is recorded, at the price the engine charged
4// then, so a later price change never rewrites a past day.
5//
6// Pure bookkeeping: the hooks do the reading and writing it asks for.
7
8import type { FsEntry } from 'claude-code'
9
10import type { HudLimit, HudOwn, HudTotals } from '../types'
11import { dayKey, ranges, sumDays } from './format'
12
13export type Write = { path: string; text: string }
14export type LedgerFile = HudOwn & { v: 1; updatedAt: number }
15export type LimitsCache = { at: number; limits: HudLimit[] }
16
17const WRITE_EVERY_MS = 5_000
18
19export const parseJson = <T>(text: string | null | undefined): T | null => {
20  if (!text) return null
21  try {
22    return JSON.parse(text) as T
23  } catch {
24    return null
25  }
26}
27
28export class Ledger {
29  base: string | null = null
30  tzMin = -new Date().getTimezoneOffset()
31  own: HudOwn | null = null
32  since: string | null = null
33  private dirty = false
34  private lastWrite = 0
35  private others = { today: 0, week: 0, month: 0 }
36  // A session left by /clear, counted here until the next settle reads its file.
37  private retired = { today: 0, week: 0, month: 0 }
38  private files = new Map<string, { mtimeMs: number; days: Record<string, number> }>()
39
40  dir = () => `${this.base}/ledger`
41  ownPath = (id: string) => `${this.dir()}/${id}.json`
42  metaPath = () => `${this.base}/meta.json`
43  limitsPath = () => `${this.base}/ratelimits.json`
44
45  /**
46   * Picks this session's ledger: the one a hot reload kept, else its file,
47   * else a fresh one whose baseline is the cost the session started with (a
48   * resumed session's restored cost was counted when it was spent).
49   */
50  start(sessionId: string, held: HudOwn | null, file: LedgerFile | null, startCost: number | null) {
51    if (held && held.sessionId === sessionId) this.own = { ...held, days: { ...held.days } }
52    else if (file) this.own = { sessionId, lastTotal: file.lastTotal, days: { ...(file.days ?? {}) } }
53    else this.own = { sessionId, lastTotal: startCost ?? 0, days: {} }
54  }
55
56  /** Folds the session's cost total in; returns the files to write now. */
57  applyCost(total: number, now: number, sessionId: string): Write[] {
58    if (!this.own) return []
59    const writes: Write[] = []
60    if (sessionId !== this.own.sessionId) {
61      // /clear or a fork: the old file is done, the baseline carries on.
62      const last = this.take(now, true)
63      if (last) writes.push(last)
64      const gone = sumDays(this.own.days, ranges(now, this.tzMin))
65      this.retired = {
66        today: this.retired.today + gone.today,
67        week: this.retired.week + gone.week,
68        month: this.retired.month + gone.month,
69      }
70      this.own = { sessionId, lastTotal: this.own.lastTotal, days: {} }
71    }
72    let delta = total - this.own.lastTotal
73    if (delta < -1e-9) delta = total // the engine's total started over
74    this.own.lastTotal = total
75    if (delta > 1e-9) {
76      const k = dayKey(now, this.tzMin)
77      this.own.days[k] = (this.own.days[k] ?? 0) + delta
78      this.dirty = true
79    }
80    const w = this.take(now, false)
81    if (w) writes.push(w)
82    return writes
83  }
84
85  /** This session's file, when it changed and is due (or `force`). */
86  take(now: number, force: boolean): Write | null {
87    if (!this.base || !this.own || !this.dirty) return null
88    if (!force && now - this.lastWrite < WRITE_EVERY_MS) return null
89    this.dirty = false
90    this.lastWrite = now
91    const file: LedgerFile = { v: 1, ...this.own, updatedAt: now }
92    return { path: this.ownPath(this.own.sessionId), text: JSON.stringify(file) }
93  }
94
95  private inWindow(entries: readonly FsEntry[], now: number) {
96    const { earliestMs } = ranges(now, this.tzMin)
97    const mine = this.own ? `${this.own.sessionId}.json` : ''
98    return entries.filter(
99      e => e.kind === 'file' && e.name.endsWith('.json') && e.name !== mine && e.mtimeMs >= earliestMs,
100    )
101  }
102
103  /** The other sessions' files to read: in this month or week, changed since last read. */
104  toRead(entries: readonly FsEntry[], now: number): FsEntry[] {
105    return this.inWindow(entries, now).filter(e => this.files.get(e.name)?.mtimeMs !== e.mtimeMs)
106  }
107
108  ingest(entry: FsEntry, text: string | null) {
109    this.files.set(entry.name, { mtimeMs: entry.mtimeMs, days: parseJson<LedgerFile>(text)?.days ?? {} })
110  }
111
112  /** Sums the other sessions' days, once their changed files are ingested. */
113  settle(entries: readonly FsEntry[], now: number) {
114    const r = ranges(now, this.tzMin)
115    const sums = { today: 0, week: 0, month: 0 }
116    for (const e of this.inWindow(entries, now)) {
117      const s = sumDays(this.files.get(e.name)?.days ?? {}, r)
118      sums.today += s.today
119      sums.week += s.week
120      sums.month += s.month
121    }
122    this.others = sums
123    this.retired = { today: 0, week: 0, month: 0 }
124  }
125
126  totals(now: number): HudTotals {
127    const mine = this.own ? sumDays(this.own.days, ranges(now, this.tzMin)) : { today: 0, week: 0, month: 0 }
128    return {
129      today: this.others.today + this.retired.today + mine.today,
130      week: this.others.week + this.retired.week + mine.week,
131      month: this.others.month + this.retired.month + mine.month,
132      since: this.since,
133    }
134  }
135}
136
137// Ledger work runs one job at a time, so two cost readings never interleave.
138let chain: Promise<unknown> = Promise.resolve()
139export const serial = (job: () => Promise<void>): Promise<void> => {
140  const run = chain.then(job, job)
141  chain = run.catch(() => undefined)
142  return run.catch(() => undefined)
143}
144
hooks/state.ts 33 lines
1import type { HudSnap, HudStats, HudTotals } from '../types'
2
3export const EMPTY_STATS: HudStats = {
4  turns: 0,
5  steps: 0,
6  subSteps: 0,
7  modelMs: 0,
8  toolMs: 0,
9  ttftMs: 0,
10  ttftN: 0,
11  genOut: 0,
12  genMs: 0,
13  subGenOut: 0,
14  subGenMs: 0,
15  lastTps: null,
16  input: 0,
17  cacheWrite: 0,
18  cacheRead: 0,
19  output: 0,
20}
21
22export const EMPTY_SNAP: HudSnap = {
23  costUsd: null,
24  limits: [],
25  limitsAt: null,
26  limitsStale: false,
27  ctxTokens: null,
28  ctxWindow: null,
29  ctxPercent: null,
30}
31
32export const EMPTY_TOTALS: HudTotals = { today: 0, week: 0, month: 0, since: null }
33
types/index.d.ts 78 lines
1// The values cc-usage-hud keeps in $.state (they survive a hot reload).
2
3/** Session counters, summed from every turn.step the session made. */
4export type HudStats = {
5  /** Main-thread turns (one per prompt). */
6  turns: number
7  /** Main-thread model requests. */
8  steps: number
9  /** Subagent model requests. */
10  subSteps: number
11  /** Main thread: summed request time (send → stop), ms. */
12  modelMs: number
13  /** Main thread: summed tool-call time, ms (user-wait tools excluded). */
14  toolMs: number
15  /** Main thread: summed and counted time to first token, ms. */
16  ttftMs: number
17  ttftN: number
18  /** Main thread: output tokens and generation time (first token → stop) for TPS. */
19  genOut: number
20  genMs: number
21  /** Subagents: the same, for their TPS. */
22  subGenOut: number
23  subGenMs: number
24  /** The last main-thread response's TPS. */
25  lastTps: number | null
26  /** Token counts over every response, subagents included (as /cost counts them). */
27  input: number
28  cacheWrite: number
29  cacheRead: number
30  output: number
31}
32
33export type HudLimit = { kind: string; percentUsed: number; resetsAt?: string }
34
35/** What $.session.usage() last said, plus the cached rate-limit reading. */
36export type HudSnap = {
37  costUsd: number | null
38  limits: HudLimit[]
39  /** When `limits` was read, ms since epoch. */
40  limitsAt: number | null
41  /** True while `limits` is a reading cached from an earlier session. */
42  limitsStale: boolean
43  ctxTokens: number | null
44  ctxWindow: number | null
45  ctxPercent: number | null
46}
47
48/** Cost totals over every session's ledger, this one included (USD). */
49export type HudTotals = {
50  today: number
51  week: number
52  month: number
53  /** The first day the ledger holds (YYYY-MM-DD). */
54  since: string | null
55}
56
57/** This session's ledger, mirrored here so a hot reload keeps it. */
58export type HudOwn = {
59  sessionId: string
60  lastTotal: number
61  days: Record<string, number>
62}
63
64export type HudCard = 'stats' | 'tokens' | 'limits' | 'cost'
65
66declare module 'claude-code' {
67  interface PluginState {
68    'cc-usage-hud': {
69      stats: HudStats
70      snap: HudSnap
71      totals: HudTotals
72      own: HudOwn | null
73      pinned: HudCard | null
74      tick: number
75    }
76  }
77}
78