Memory, plan and weekly limits as bars above the prompt; /tokens charts token usage by day, repo, branch and model.

Above the prompt, one bar per limit:
memory ████████░░ 78% ⚠ clear soon [Compact]
plan ███░░░░░░░ 31% resets 2h 10m
week ██░░░░░░░░ 22%
| Row | What it means | When to act |
|---|---|---|
memory | How full this conversation's context window is. | 60%: clear (/clear) when you next switch tasks. 80%: clear or press Compact soon. |
plan | Your plan's 5-hour usage limit. resets says when it refills. | 90%: ⚠ near limit, slow down or wait for the reset. |
week | Your plan's 7-day usage limit. Shows the reset day from 50%. | Near 100%, save heavy work for after the reset. |
| cache hits | Not in the band. /tokens shows this session's cache hit rate. | Nothing to do. Higher is better. |
memory 41% · plan 31% · week 22%. It opens to three rows when any crosses its threshold.0%. With an API key there are no plan limits, so only memory shows. A limit whose reset time has passed shows reset? until the next reading./compact. Mid-turn the button hides.· 4.2k tokens: uncached input plus output tokens this turn."tok" in /tokens means everything a request read or wrote: input, output and cache.
Figures are read once at start, then pushed by Claude Code's session.measure after each turn. meter never shows a dollar figure.
Opens a pane: tokens per day for the last 14 days, then the top repos, branches and models by tokens for 1, 7 or 30 days. The header shows this session's cache hits, the share of input tokens read from cache (higher is better).
Every finished turn that reports usage, subagents' included, books its tokens into $.store under meter:YYYY-MM-DD, by repo (the main checkout's folder name), git branch and model. Days recorded by 0.1.0 still load; the cost they carry is ignored.
Days older than 30 are deleted. The store is shared by every session on the machine and has no locking, so two sessions finishing a turn in the same instant can drop one of the two. A failed write shows at the top of the pane.
hooks/register.ts 287 lines1import type { EngineInterface, On, RenderElement, SessionRateLimit, SessionUsage } from 'claude-code'
2
3import { bar, barRows, cachePercent, levelColor, short, totalTokens, until } from './format'
4import type { CacheTotals } from './format'
5import { KEEP_DAYS, addTo, dayOf, isDay, isExpired, keyOf, merge, top, total } from './ledger'
6import type { Bucket, Day, Entry } from './ledger'
7
8const MEMORY_WARN = 60
9const LIMIT_WARN = 50
10const BAR_CELLS = 10
11const MIN_BAR_CELLS = 4
12const DAY = 86_400_000
13const CHART_DAYS = 14
14const CHART_ROWS = 6
15const TOP = 5
16const RANGES = [1, 7, 30] as const
17
18type Figures = Pick<SessionUsage, 'context' | 'rateLimits'>
19type Limit = { percent: number; resetAt: number | null; isPast: boolean }
20
21let figures: Figures | null = null
22const cache: CacheTotals = { read: 0, written: 0, uncached: 0 }
23const turn = { isRunning: false, tokens: 0 }
24let mainTurnId = ''
25const git = { turnId: '', branch: '' }
26let range: (typeof RANGES)[number] = 7
27let days: (Day | undefined)[] | null = null
28let ledgerFailure: string | null = null
29let writes: Promise<void> = Promise.resolve()
30
31export function register(on: On) {
32 on('session.start', async ($, e, next) => {
33 const result = await next(e)
34 await seed($)
35 await $.command.register({ name: 'tokens', description: 'Token usage by day, repo, branch and model' })
36 return result
37 })
38
39 on('command.run', { command: 'tokens' }, async ($, e) => {
40 days = null
41 await $.ui.open({ id: 'tokens', title: 'Tokens', focus: true, closeOnEscape: true })
42 return {}
43 })
44
45 // session.start doesn't fire again after /clear or /resume, so the next turn.start re-seeds.
46 on('session.end', { reason: ['clear', 'resume'] }, async ($, e, next) => {
47 figures = null
48 cache.read = cache.written = cache.uncached = 0
49 return next(e)
50 })
51
52 on('turn.start', async ($, e, next) => {
53 if (!figures) await seed($)
54 turn.isRunning = true
55 turn.tokens = 0
56 mainTurnId = e.turnId
57 return next(e)
58 })
59
60 on('turn.step', async function* ($, e, next) {
61 const result = yield* next(e)
62 const u = result.usage
63 if (!u) return result
64 cache.read += u.cache_read_input_tokens
65 cache.written += u.cache_creation_input_tokens
66 cache.uncached += u.input_tokens
67 turn.tokens += u.input_tokens + u.output_tokens
68 $.ui.invalidate('ui.render')
69 return result
70 })
71
72 on('turn.complete', async ($, e, next) => {
73 const result = await next(e)
74 if (!e.agentId) turn.isRunning = false
75 if (e.usage) await book($, { model: e.usage.model, tokens: totalTokens(e.usage) })
76 return result
77 })
78
79 on('session.measure', async ($, e, next) => {
80 figures = { context: e.context, rateLimits: e.rateLimits }
81 $.ui.invalidate('ui.render')
82 return next(e)
83 })
84
85 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
86 if (!turn.isRunning) return next(e)
87 const suffix = `${e.props.suffix} · ${short(turn.tokens)} tokens`
88 return next({ ...e, props: { ...e.props, suffix } })
89 })
90
91 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
92 if (e.props.hasSurvey || !figures) return next(e)
93 const now = await $.clock.now()
94 const memory = figures.context.percent
95 const plan = limitOf(figures.rateLimits, 'five_hour', now)
96 const week = limitOf(figures.rateLimits, 'seven_day', now)
97 if (memory === undefined && !plan && !week) return next(e)
98 const { Box, Text, Button } = $.ui.resolve(e)
99
100 if ((memory ?? 0) < MEMORY_WARN && isCalm(plan) && isCalm(week)) {
101 const figure = (label: string, l: Limit | null) => (l ? [`${label} ${l.isPast ? 'reset?' : `${l.percent}%`}`] : [])
102 const line = [...(memory === undefined ? [] : [`memory ${memory}%`]), ...figure('plan', plan), ...figure('week', week)]
103 return Box({ flexDirection: 'column', children: [Text({ dimColor: true, children: line.join(' · ') }), await next(e)] })
104 }
105
106 const cells = Math.min(BAR_CELLS, Math.max(MIN_BAR_CELLS, e.props.bodyColumns - 50))
107 const dim = (text: string) => Text({ dimColor: true, children: text })
108 const label = (name: string) => dim(name.padEnd(7))
109 const row = (name: string, percent: number, hints: RenderElement[]) => {
110 const color = levelColor(percent)
111 const [fill, track] = bar(percent, cells)
112 return Box({
113 flexDirection: 'row',
114 children: [
115 label(name),
116 ...(fill ? [Text({ color, children: fill })] : []),
117 ...(track ? [dim(track)] : []),
118 Text({ bold: true, color, children: ` ${percent}%`.padStart(5) }),
119 ...hints.flatMap(h => [Text({ children: ' ' }), h]),
120 ],
121 })
122 }
123 const stale = (name: string) => Box({ flexDirection: 'row', children: [label(name), dim('reset?')] })
124
125 const rows: RenderElement[] = []
126 if (memory !== undefined) {
127 const hints: RenderElement[] = []
128 if (memory >= 80) {
129 hints.push(Text({ color: 'red', children: '⚠ clear soon' }))
130 if (!e.props.isWorking) hints.push(Button({ key: 'compact', label: 'Compact', onPress: () => compact($) }))
131 } else if (memory >= MEMORY_WARN) {
132 hints.push(dim('clear when you switch tasks'))
133 }
134 rows.push(row('memory', memory, hints))
135 }
136 if (plan?.isPast) rows.push(stale('plan'))
137 else if (plan) {
138 const hints: RenderElement[] = []
139 if (plan.percent >= 90) hints.push(Text({ color: 'red', children: '⚠ near limit' }))
140 if (plan.resetAt !== null) hints.push(dim(`resets ${until(plan.resetAt, now)}`))
141 rows.push(row('plan', plan.percent, hints))
142 }
143 if (week?.isPast) rows.push(stale('week'))
144 else if (week) {
145 const isSoon = week.resetAt !== null && week.percent >= LIMIT_WARN
146 rows.push(row('week', week.percent, isSoon ? [dim(`resets ${resetDay(week.resetAt!, now)}`)] : []))
147 }
148
149 return Box({ flexDirection: 'column', children: [...rows, await next(e)] })
150 })
151
152 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
153 if (e.requestId !== 'tokens') return next(e)
154 const { Box, Text, Button } = $.ui.resolve(e)
155 const now = await $.clock.now()
156 days ??= await load($, now)
157
158 const recent = days.slice(0, CHART_DAYS)
159 const daily = recent.map(d => total(d?.repos ?? {})).reverse()
160 const chart = recent.some(d => d)
161 ? barRows(daily, CHART_ROWS).map(l => Text({ color: 'cyan', children: l }))
162 : [Text({ dimColor: true, children: `No tokens recorded in the last ${CHART_DAYS} days.` })]
163
164 const picked = merge(days.slice(0, range))
165 const section = (title: string, buckets: Record<string, Bucket>) => {
166 const more = Object.keys(buckets).length - TOP
167 return [
168 Text({ bold: true, children: title }),
169 ...top(buckets, TOP).map(([name, b]) =>
170 Text({ wrap: 'truncate-end', children: ` ${short(b.tokens).padStart(6)} tok ${name}` }),
171 ),
172 ...(more > 0 ? [Text({ dimColor: true, children: ` +${more} more` })] : []),
173 ]
174 }
175 const hit = cachePercent(cache)
176
177 return Box({
178 flexDirection: 'column',
179 children: [
180 ...(ledgerFailure ? [Text({ color: 'red', children: `ledger write failed: ${ledgerFailure}` })] : []),
181 Text({ bold: true, children: `Tokens per day, last ${CHART_DAYS} days (max ${short(Math.max(...daily))})` }),
182 Text({ dimColor: true, children: hit === null ? 'cache hits: no requests yet' : `cache hits ${hit}% (higher is better)` }),
183 ...chart,
184 Text({ dimColor: true, children: `${dayOf(now, CHART_DAYS - 1).slice(5)} … ${dayOf(now).slice(5)}` }),
185 Box({
186 flexDirection: 'row',
187 gap: 1,
188 children: RANGES.map(n =>
189 Button({
190 key: `${n}d`,
191 label: `${n}d`,
192 ...(n === range && { variant: 'primary' as const }),
193 onPress: () => {
194 range = n
195 $.ui.invalidate('ui.render')
196 },
197 }),
198 ),
199 }),
200 Text({ children: `${range}d total ${short(total(picked.repos))} tok` }),
201 ...section('Repos', picked.repos),
202 ...section('Branches', picked.branches),
203 ...section('Models', picked.models),
204 ],
205 })
206 })
207}
208
209function limitOf(limits: readonly SessionRateLimit[], kind: SessionRateLimit['kind'], now: number): Limit | null {
210 const l = limits.find(l => l.kind === kind && Number.isFinite(l.percentUsed))
211 if (!l) return null
212 const resetAt = Date.parse(l.resetsAt ?? '')
213 return { percent: Math.round(l.percentUsed), resetAt: Number.isFinite(resetAt) ? resetAt : null, isPast: resetAt <= now }
214}
215
216// A limit past its reset is stale, so it doesn't hold the band open.
217function isCalm(l: Limit | null): boolean {
218 return !l || l.isPast || l.percent < LIMIT_WARN
219}
220
221function resetDay(at: number, now: number): string {
222 return at - now < DAY ? until(at, now) : new Date(at).toLocaleDateString('en-US', { weekday: 'short' })
223}
224
225async function load($: EngineInterface, now: number): Promise<(Day | undefined)[]> {
226 const loaded: (Day | undefined)[] = []
227 for (let back = 0; back < KEEP_DAYS; back++) {
228 const stored = await $.store.get(keyOf(now, back))
229 loaded.push(isDay(stored) ? stored : undefined)
230 }
231 return loaded
232}
233
234// Serialized because interleaved read-modify-writes in this process drop a booking. Across sessions
235// $.store has no lock, so that race remains, rare and tolerated for a usage tally.
236function book($: EngineInterface, part: Omit<Entry, 'repo' | 'branch'>): Promise<void> {
237 writes = writes.then(() => write($, part))
238 return writes
239}
240
241async function write($: EngineInterface, part: Omit<Entry, 'repo' | 'branch'>) {
242 try {
243 const now = await $.clock.now()
244 const root = (await $.session.repo())?.root
245 const repo = (root ?? (await $.session.root())).split('/').filter(Boolean).at(-1) ?? '/'
246 const branch = root ? await branchOf($) : '(no git)'
247 const key = keyOf(now)
248 const stored = await $.store.get(key)
249 await $.store.set(key, addTo(isDay(stored) ? stored : undefined, { repo, branch, ...part }))
250 if (stored === undefined) {
251 for (const k of await $.store.keys()) if (isExpired(k, now)) await $.store.delete(k)
252 }
253 ledgerFailure = null
254 } catch (err) {
255 ledgerFailure = String(err)
256 $.ui.log(`meter: ledger write failed: ${ledgerFailure}`, { to: 'debug' })
257 }
258 days = null
259 $.ui.invalidate('ui.render')
260}
261
262// Runs in the session's cwd, not repo.root: for a worktree that root is the main checkout, on another branch.
263async function branchOf($: EngineInterface): Promise<string> {
264 if (git.turnId === mainTurnId && git.branch) return git.branch
265 const run = await $.process.run(['git', 'branch', '--show-current']).catch(() => null)
266 git.turnId = mainTurnId
267 git.branch = !run || run.exitCode !== 0 ? '(git failed)' : run.stdout.trim() || '(detached)'
268 return git.branch
269}
270
271async function seed($: EngineInterface) {
272 try {
273 figures = await $.session.usage()
274 } catch (err) {
275 $.ui.log(`meter: usage read failed, waiting for the next measure: ${String(err)}`, { to: 'debug' })
276 }
277 $.ui.invalidate('ui.render')
278}
279
280async function compact($: EngineInterface) {
281 const result = await $.session.compact().catch((err: unknown) => ({ skip: String(err) }))
282 $.ui.toast(result.skip === undefined ? 'meter: compacted' : `meter: compact skipped, ${result.skip}`)
283 // No measure follows a compaction until the next turn, so read the shrunken context now.
284 figures = await $.session.usage()
285 $.ui.invalidate('ui.render')
286}
287hooks/format.ts 56 lines1import type { ModelUsage } from 'claude-code'
2
3export function short(n: number): string {
4 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
5 if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 100_000 ? 0 : 1)}k`
6 return String(Math.round(n))
7}
8
9export function levelColor(percent: number): string {
10 if (percent >= 80) return 'red'
11 if (percent >= 50) return 'yellow'
12 return 'green'
13}
14
15const PARTIAL = ' ▏▎▍▌▋▊▉'
16
17// The filled run, ending in an eighth-cell partial, and the track after it.
18export function bar(percent: number, cells: number): [string, string] {
19 const eighths = Math.round((Math.min(100, Math.max(0, percent)) / 100) * cells * 8)
20 const full = Math.floor(eighths / 8)
21 const part = eighths % 8
22 return ['█'.repeat(full) + (part ? PARTIAL[part] : ''), '░'.repeat(cells - full - (part ? 1 : 0))]
23}
24
25export function until(at: number, now: number): string {
26 const minutes = Math.max(0, Math.round((at - now) / 60_000))
27 const h = Math.floor(minutes / 60)
28 return h > 0 ? `${h}h ${minutes % 60}m` : `${minutes}m`
29}
30
31// The one meaning of "tok" in the ledger: everything the request read or wrote, cache reads
32// included. The spinner counts only uncached input plus output, the part a turn adds.
33export function totalTokens(u: ModelUsage): number {
34 return u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens
35}
36
37export type CacheTotals = { read: number; written: number; uncached: number }
38
39export function cachePercent(c: CacheTotals): number | null {
40 const all = c.read + c.written + c.uncached
41 return all === 0 ? null : Math.round((c.read / all) * 100)
42}
43
44const EIGHTHS = ' ▁▂▃▄▅▆▇█'
45
46// Rows top to bottom, bars two cells wide with one between, in eighth-cell steps.
47// A nonzero day gets at least one eighth so it never draws as an empty day.
48export function barRows(values: readonly number[], rows: number): string[] {
49 const top = Math.max(...values)
50 const heights = values.map(v => (v > 0 ? Math.max(1, Math.round((v / top) * rows * 8)) : 0))
51 return Array.from({ length: rows }, (_, r) => {
52 const floor = (rows - 1 - r) * 8
53 return heights.map(h => EIGHTHS[Math.max(0, Math.min(8, h - floor))]!.repeat(2)).join(' ')
54 })
55}
56hooks/ledger.ts 73 lines1export type Bucket = { tokens: number }
2export type Day = { repos: Record<string, Bucket>; branches: Record<string, Bucket>; models: Record<string, Bucket> }
3export type Entry = { repo: string; branch: string; model: string; tokens: number }
4
5export const KEEP_DAYS = 30
6const PREFIX = 'meter:'
7
8// Calendar arithmetic, not ms arithmetic, so a DST change never skips or repeats a day.
9export function dayOf(now: number, back = 0): string {
10 const d = new Date(now)
11 d.setDate(d.getDate() - back)
12 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
13}
14
15export function keyOf(now: number, back = 0): string {
16 return PREFIX + dayOf(now, back)
17}
18
19export function isExpired(key: string, now: number): boolean {
20 return /^meter:\d{4}-\d{2}-\d{2}$/.test(key) && key < keyOf(now, KEEP_DAYS - 1)
21}
22
23// Days written before 0.2.0 also carry a `usd` per bucket; it is ignored, never shown.
24function isBuckets(x: unknown): boolean {
25 return typeof x === 'object' && x !== null && Object.values(x).every(b => typeof b?.tokens === 'number')
26}
27
28export function isDay(x: unknown): x is Day {
29 if (typeof x !== 'object' || x === null) return false
30 const d = x as Partial<Day>
31 return isBuckets(d.repos) && isBuckets(d.branches) && isBuckets(d.models)
32}
33
34function bump(buckets: Record<string, Bucket>, name: string, entry: Entry): Record<string, Bucket> {
35 return { ...buckets, [name]: { tokens: (buckets[name]?.tokens ?? 0) + entry.tokens } }
36}
37
38export function addTo(day: Day | undefined, entry: Entry): Day {
39 const d = day ?? { repos: {}, branches: {}, models: {} }
40 return {
41 repos: bump(d.repos, entry.repo, entry),
42 branches: bump(d.branches, `${entry.branch} (${entry.repo})`, entry),
43 models: bump(d.models, entry.model, entry),
44 }
45}
46
47function sum(into: Record<string, Bucket>, from: Record<string, Bucket>) {
48 for (const [name, b] of Object.entries(from)) {
49 into[name] = { tokens: (into[name]?.tokens ?? 0) + b.tokens }
50 }
51}
52
53export function merge(days: readonly (Day | undefined)[]): Day {
54 const all: Day = { repos: {}, branches: {}, models: {} }
55 for (const d of days) {
56 if (!d) continue
57 sum(all.repos, d.repos)
58 sum(all.branches, d.branches)
59 sum(all.models, d.models)
60 }
61 return all
62}
63
64export function total(buckets: Record<string, Bucket>): number {
65 return Object.values(buckets).reduce((t, b) => t + b.tokens, 0)
66}
67
68export function top(buckets: Record<string, Bucket>, n = 5): [string, Bucket][] {
69 return Object.entries(buckets)
70 .sort(([, a], [, b]) => b.tokens - a.tokens)
71 .slice(0, n)
72}
73