Live session/weekly usage meters, reset times and a per-turn token sparkline above the prompt

<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section --> <!-- ALL-CONTRIBUTORS-BADGE:END -->
Live view of your Claude plan usage in the band above the prompt, updated after every turn:
◔ session [ 42% ] ↻ 15:30 weekly [ 18% ] ↻ Thu 09:00 ▂▃▅█▁▂ ▲ +98.3k last turn
Meters show only on a Claude subscription; with an API key you get the sparkline and turn total.
Claude Code v2.1.287 or later (tested with 2.1.292).
In Claude Code:
/plugin install usage --marketplace technologiestiftung/claude-code-usage-mod
Answer y to add the marketplace, then pick the user scope.
Load the working copy instead of the installed one; it hot-reloads on save:
claude --plugin-dir .
The code is in hooks/register.tsx. Check it with:
claude plugin validate .
claude plugin test .
Before you create a pull request, write an issue so we can discuss your changes.
Thanks goes to these wonderful people (emoji key):
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section --> <!-- prettier-ignore-start --> <!-- markdownlint-disable --> <table> <tbody> <tr> <td align="center" valign="top" width="14.28%"><a href="https://github.com/raphael-arce"><img src="https://avatars.githubusercontent.com/u/8709861?v=4?s=64" width="64px;" alt="Rapha"/><br /><sub><b>Rapha</b></sub></a><br /><a href="https://github.com/technologiestiftung/claude-code-usage-mod/commits?author=raphael-arce" title="Code">💻</a> <a href="https://github.com/technologiestiftung/claude-code-usage-mod/commits?author=raphael-arce" title="Documentation">📖</a> <a href="https://github.com/technologiestiftung/claude-code-usage-mod/commits?author=raphael-arce" title="Tests">⚠️</a> <a href="#ideas-raphael-arce" title="Ideas, Planning, & Feedback">🤔</a></td> </tr> </tbody> </table>
<!-- markdownlint-restore --> <!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
This project follows the all-contributors specification. Contributions of any kind welcome!
Texts and content available as CC BY.
<table> <tr> <td> Made by <a href="https://citylab-berlin.org/de/start/"> <img width="140" src="https://logos.citylab-berlin.org/logo-citylab-color.svg" alt="Link to the CityLAB Berlin website" /> </a> </td> <td> A project by <a href="https://www.technologiestiftung-berlin.de/"> <img width="150" src="https://logos.citylab-berlin.org/logo-technologiestiftung-berlin-de.svg" alt="Link to the Technologiestiftung Berlin website" /> </a> </td> <td> Supported by <a href="https://www.berlin.de/rbmskzl/"> <img width="80" src="https://logos.citylab-berlin.org/logo-berlin-senatskanzelei-de.svg" alt="Link to the Senate Chancellery of Berlin"/> </a> </td> </tr> </table>
hooks/register.tsx 147 lines1import { atom, read, update } from 'claude-code'
2import type { Register, SessionRateLimit, TurnUsage } from 'claude-code'
3import type { TurnTokens } from '../types'
4
5const HISTORY = 12
6const METER_WIDTH = 10
7const BARS = '▁▂▃▄▅▆▇█'
8const TRACK = '#4a4a4a'
9
10// Token totals of the last turns, oldest first; kept across reloads
11const turns = atom({ plugin: 'usage', key: 'turns' } as const, [] as TurnTokens)
12
13// Tokens a turn spent: everything but cache reads, which re-count the whole context every step
14export const tokensOf = (u: TurnUsage | undefined) =>
15 u ? u.input_tokens + u.cache_creation_input_tokens + u.output_tokens : 0
16
17type Rgb = [number, number, number]
18const STOPS: Rgb[] = [
19 [34, 197, 94], // green
20 [234, 179, 8], // yellow
21 [239, 68, 68], // red
22]
23
24export function meterColor(pct: number): Rgb {
25 const t = Math.min(Math.max(pct, 0), 100) / 50
26 const i = Math.min(Math.floor(t), 1)
27 const f = t - i
28 const [a, b] = [STOPS[i], STOPS[i + 1]]
29 return [0, 1, 2].map(k => Math.round(a[k] + (b[k] - a[k]) * f)) as Rgb
30}
31
32const hex = (c: Rgb) => '#' + c.map(v => v.toString(16).padStart(2, '0')).join('')
33
34const luminance = (c: Rgb) => {
35 const [r, g, b] = c.map(v => {
36 const s = v / 255
37 return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
38 })
39 return 0.2126 * r + 0.7152 * g + 0.0722 * b
40}
41
42// Black or white, whichever contrasts more with the background (WCAG ratio)
43export function textOn(bg: Rgb) {
44 const l = luminance(bg)
45 return (l + 0.05) / 0.05 >= 1.05 / (l + 0.05) ? '#000000' : '#ffffff'
46}
47
48const rgbOf = (h: string): Rgb => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16)) as Rgb
49
50// One run of cells per background, the percentage centred over the bar
51export function meterRuns(pct: number, width = METER_WIDTH) {
52 const label = `${Math.round(pct)}%`
53 const start = Math.floor((width - label.length) / 2)
54 const cells = Array.from({ length: width }, (_, i) => label[i - start] ?? ' ')
55 const filled = Math.min(width, Math.round((Math.min(pct, 100) / 100) * width))
56 const fill = hex(meterColor(pct))
57 const runs: { text: string; bg: string; fg: string }[] = []
58 cells.forEach((ch, i) => {
59 const bg = i < filled ? fill : TRACK
60 const last = runs[runs.length - 1]
61 if (last && last.bg === bg) last.text += ch
62 else runs.push({ text: ch, bg, fg: textOn(rgbOf(bg)) })
63 })
64 return runs
65}
66
67export function sparkline(values: number[]) {
68 const max = Math.max(...values, 1)
69 return values.map(v => BARS[Math.min(BARS.length - 1, Math.floor((v / max) * (BARS.length - 1)))]).join('')
70}
71
72export function compact(n: number) {
73 if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
74 if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k'
75 return String(n)
76}
77
78const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
79
80// Local reset time: "15:30" for the session window, "Thu 15:30" for the week
81export function resetAt(resetsAt: string, withDay: boolean) {
82 const d = new Date(resetsAt)
83 const time = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
84 return withDay ? `${DAYS[d.getDay()]} ${time}` : time
85}
86
87export const register: Register = on => {
88 // Subagent tokens spent since the last main-loop turn ended
89 let pending = 0
90
91 on('turn.complete', async ($, e, next) => {
92 pending += tokensOf(e.usage)
93 if (!e.agentId) {
94 const spent = pending
95 pending = 0
96 await update($, turns, list => [...list, spent].slice(-HISTORY))
97 }
98 return next(e)
99 })
100
101 on('session.measure', ($, e, next) => {
102 $.ui.invalidate('ui.render')
103 return next(e)
104 })
105
106 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
107 if (e.props.hasSurvey) return next(e)
108
109 const history = await read($, turns)
110 const { rateLimits } = await $.session.usage()
111 const find = (kind: string): SessionRateLimit | undefined => rateLimits.find(r => r.kind === kind)
112 const meters = [
113 { label: 'session', limit: find('five_hour'), withDay: false },
114 { label: 'weekly', limit: find('seven_day'), withDay: true },
115 ].filter(m => m.limit)
116
117 if (meters.length === 0 && history.length === 0) return next(e)
118
119 const { Box, Text } = $.ui.resolve(e)
120 const lastTurn = history[history.length - 1]
121
122 return (
123 <Box flexDirection="row">
124 <Text color="claude">◔ </Text>
125 {meters.map(({ label, limit, withDay }) => (
126 <Box key={label} flexDirection="row">
127 <Text dimColor>{label} </Text>
128 {meterRuns(limit!.percentUsed).map((r, i) => (
129 <Text key={String(i)} backgroundColor={r.bg} color={r.fg} bold>
130 {r.text}
131 </Text>
132 ))}
133 {limit!.resetsAt ? <Text dimColor> ↻ {resetAt(limit!.resetsAt, withDay)}</Text> : null}
134 <Text>{' '}</Text>
135 </Box>
136 ))}
137 {history.length > 0 ? (
138 <Text>
139 <Text color="suggestion">{sparkline(history)}</Text>
140 <Text dimColor>{' '}▲ +{compact(lastTurn)} last turn</Text>
141 </Text>
142 ) : null}
143 </Box>
144 )
145 })
146}
147types/index.d.ts 8 lines1export type TurnTokens = number[]
2
3declare module 'claude-code' {
4 interface PluginState {
5 usage: { turns: TurnTokens }
6 }
7}
8