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

hooks/register.tsx 390 lines1import { 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}
390hooks/format.ts 147 lines1// 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')
147hooks/model.ts 166 lines1// 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}
166hooks/svg.ts 45 lines1// 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>`
45hooks/ledger.ts 144 lines1// 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}
144hooks/state.ts 33 lines1import 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 }
33types/index.d.ts 78 lines1// 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