Status line with 5h/7d usage, reset countdowns, tokens, cost and tokens per second

A Claude Code status line showing your plan usage, tokens, cost and live speed. Works in the desktop app's Code tab and the CLI.
cc-usage ⏳ 5% | 2h 11m 📅 1% | 5d 6h ⬆️ 99.9k ⬇️ 6.2k 💵 10.6 ⚡ 𝟷𝟺𝟺.𝟶 t/s
| Field | Meaning |
|---|---|
| ⏳ | 5-hour limit used, and time until it resets |
| 📅 | 7-day limit used, and time until it resets |
| ⬆️ | Input tokens sent this session (fresh input plus prompt-cache reads and writes, summed over every request) |
| ⬇️ | Output tokens this session; counts up live while Claude replies |
| 💵 | Session cost in US dollars |
| ⚡ | Output tokens per second; live while Claude replies, then the exact figure for the last reply. Fixed width (monospace digits) so the line does not shift as it updates |
In Claude Code:
/plugin marketplace add leonardokidd/cc-usage
/plugin install cc-usage@cc-usage
Requires Claude Code 2.1.286 or newer (function-hook plugins, an early-access feature that may change between releases).
/usage-bar shows or hides the line./usage-bar raw prints the raw rate-limit and cost figures Claude Code hands the plugin.--.MIT. See LICENSE.
hooks/register.ts 172 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Limit, Speed } from '../types'
5
6const limits = atom({ plugin: 'cc-usage', key: 'limits' } as const, [] as Limit[])
7const now = atom({ plugin: 'cc-usage', key: 'now' } as const, 0)
8const cost = atom({ plugin: 'cc-usage', key: 'cost' } as const, 0)
9const isHidden = atom({ plugin: 'cc-usage', key: 'isHidden' } as const, false)
10const speed = atom({ plugin: 'cc-usage', key: 'speed' } as const, {
11 inTok: 0,
12 outTok: 0,
13 cacheTok: 0,
14 liveOut: 0,
15 tps: 0,
16 liveTps: 0,
17 isLive: false,
18} as Speed)
19
20// Space between an icon and its value; a Unicode space, so the app does not collapse it.
21const GAP = '\u2002'
22// ⏳ and ⚡ are drawn about 1/6 em narrower than their box on each side (measured in Apple Color
23// Emoji), so they get a 1/3 em space instead of 1/2 em to look the same as the other icons.
24const GAP_NARROW_ICON = '\u2004'
25// Space between fields. The em space keeps its width in the desktop app (which collapses
26// runs of ASCII spaces); the two ASCII spaces widen it in the terminal, where every space is one cell.
27const SEP = '\u2003 '
28// Pads t/s on the left with figure spaces (each as wide as a digit), so its changing value keeps
29// one width and the right-aligned line stops shifting while Claude replies.
30const pad = (s: string, width: number) => s.padStart(width, '\u2007')
31// The desktop font's digits differ in width (a 1 is narrower than an 8), so t/s uses the Unicode
32// monospace digits, which are all exactly one figure space wide.
33const monoDigits = (s: string) => s.replace(/[0-9]/g, d => String.fromCodePoint(0x1d7f6 + Number(d)))
34const num = (x: unknown) => (typeof x === 'number' && Number.isFinite(x) ? x : 0)
35// One decimal, halves rounded up (10.55 -> 10.6); the epsilon absorbs float error such as 10.549999.
36const r1 = (n: number) => (Math.round(n * 10 + 1e-9) / 10).toFixed(1)
37const fmt = (n: number) => (n >= 1e6 ? `${r1(n / 1e6)}M` : n >= 1000 ? `${r1(n / 1000)}k` : String(Math.round(n)))
38const left = (iso: string | number | undefined, t: number) => {
39 if (iso === undefined || iso === null || !t) return '--'
40 const n = Number(iso)
41 const at = Number.isFinite(n) ? (n < 1e12 ? n * 1000 : n) : Date.parse(String(iso))
42 if (!Number.isFinite(at)) return '--'
43 const s = Math.max(0, Math.floor((at - t) / 1000))
44 const d = Math.floor(s / 86400)
45 const hh = Math.floor((s % 86400) / 3600)
46 const m = Math.floor((s % 3600) / 60)
47 return d > 0 ? `${d}d\u2009${hh}h` : `${hh}h\u2009${m}m`
48}
49
50async function refresh($: EngineInterface) {
51 const u = await $.session.usage()
52 await update($, limits, () => (u.rateLimits ?? []).map(r => ({ ...r })))
53 await update($, cost, () => num(u.cost?.usd))
54 await pushStatus($)
55}
56
57// Background bookkeeping: never awaited by a hook, never allowed to throw into one.
58const quietly = (p: Promise<unknown>) => void p.catch(() => {})
59
60export const register: Register = on => {
61 on('session.start', async ($, e, next) => {
62 const r = await next(e)
63 quietly(
64 (async () => {
65 await $.command.register({ name: 'usage-bar', description: 'Show or hide the usage line under the prompt' })
66 await update($, now, () => Date.now())
67 await refresh($)
68 $.clock.every(5000, async () => {
69 const t = Date.now()
70 await update($, now, () => t)
71 if (Math.floor(t / 5000) % 6 === 0) await refresh($)
72 else await pushStatus($)
73 })
74 })(),
75 )
76 return r
77 })
78
79 on('command.run', { command: 'usage-bar' }, async ($, e) => {
80 if (e.args.trim() === 'raw') {
81 const u = await $.session.usage()
82 return { text: JSON.stringify({ rateLimits: u.rateLimits, cost: u.cost, at: new Date().toISOString() }, null, 2) }
83 }
84 const hidden = await update($, isHidden, v => !v)
85 await pushStatus($)
86 return { text: `Usage line ${hidden ? 'hidden' : 'shown'}.` }
87 })
88
89 on('session.measure', async ($, e, next) => {
90 const r = await next(e)
91 quietly(refresh($))
92 return r
93 })
94
95 on('turn.complete', async ($, e, next) => {
96 const r = await next(e)
97 quietly(refresh($))
98 return r
99 })
100
101 // Observe the main conversation's stream only; every chunk is passed on untouched and at once.
102 on('turn.step', async function* ($, e, next) {
103 if (e.agentId) return yield* next(e)
104 const t0 = Date.now()
105 let first = 0
106 let chars = 0
107 let lastWrite = 0
108 const stream = next(e)
109 for await (const c of stream) {
110 yield c
111 try {
112 const t = Date.now()
113 if (c.kind === 'text' || c.kind === 'thinking' || c.kind === 'input') {
114 if (!first) first = t
115 chars += (c.kind === 'input' ? c.json : c.text).length
116 if (t - lastWrite >= 150) {
117 lastWrite = t
118 const est = chars / 4
119 const tps = num(est / Math.max(0.2, (t - first) / 1000))
120 quietly(update($, speed, s => ({ ...s, isLive: true, liveOut: est, liveTps: tps })).then(() => pushStatus($)))
121 }
122 }
123 if (c.kind === 'stop') {
124 const u = c.usage
125 const out = u ? num(u.output_tokens) : chars / 4
126 const tps = num(out / Math.max(0.2, (t - (first || t0)) / 1000))
127 quietly(
128 update($, speed, s => ({
129 inTok: num(s?.inTok) + (u ? num(u.input_tokens) + num(u.cache_read_input_tokens) + num(u.cache_creation_input_tokens) : 0),
130 outTok: num(s?.outTok) + out,
131 cacheTok: num(s?.cacheTok) + (u ? num(u.cache_read_input_tokens) + num(u.cache_creation_input_tokens) : 0),
132 liveOut: 0,
133 tps,
134 liveTps: 0,
135 isLive: false,
136 })).then(() => pushStatus($)),
137 )
138 }
139 } catch {}
140 }
141 return await stream.result
142 })
143}
144
145async function pushStatus($: EngineInterface) {
146 if (await read($, isHidden)) {
147 $.ui.status(undefined)
148 return
149 }
150 const ls = (await read($, limits)) ?? []
151 const sp: Partial<Speed> = (await read($, speed)) ?? {}
152 const t = num(await read($, now))
153 const usd = num(await read($, cost))
154 const isLive = !!sp.isLive
155 const tps = num(isLive ? sp.liveTps : sp.tps)
156 const out = num(sp.outTok) + (isLive ? num(sp.liveOut) : 0)
157 const win = (kind: string) => {
158 const l = ls.find(x => x?.kind === kind)
159 return `${l ? Math.round(num(l.percentUsed)) + '%' : '--'} | ${left(l?.resetsAt, t)}`
160 }
161 $.ui.status(
162 [
163 `⏳${GAP_NARROW_ICON}${win('five_hour')}`,
164 `📅${GAP}${win('seven_day')}`,
165 `⬆️${GAP}${fmt(num(sp.inTok))}`,
166 `⬇️${GAP}${fmt(out)}`,
167 `💵${GAP}${r1(usd)}`,
168 `⚡${GAP_NARROW_ICON}${monoDigits(pad(r1(tps), 5))} t/s`,
169 ].join(SEP),
170 )
171}
172types/index.d.ts 17 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2export type Speed = {
3 inTok: number
4 outTok: number
5 cacheTok: number
6 liveOut: number
7 tps: number
8 liveTps: number
9 isLive: boolean
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'cc-usage': { limits: Limit[]; speed: Speed; now: number; cost: number; isHidden: boolean }
15 }
16}
17