Context, session and weekly usage meters with reset countdowns on a row under the prompt hint line

A Claude Code mod that shows, on a row under the prompt's hint line:
Sonnet 5.5 (xhigh)
On narrow terminals and split panes, the row steps down to shorter forms. First the model line goes, then the bars shrink, then they're dropped, then the reset times, and finally only context is left.
In Claude Code, run:
/plugin install compact-usage-meter --marketplace https://github.com/dkoh0207/compact-usage-meter.git
The short form --marketplace dkoh0207/compact-usage-meter works too.
To try it for one session without installing, clone the repository and start Claude Code with it:
git clone https://github.com/dkoh0207/compact-usage-meter.git
claude --plugin-dir compact-usage-meter
These constants are at the top of the files in hooks/:
| Constant | File | What it does |
|---|---|---|
GAP | register.tsx | Blank rows between the hint line and the meter (default 1) |
GLYPHS | format.ts | 'blocks' draws ▰▱; 'ascii' draws #- for fonts that render the blocks badly |
DIVIDER | format.ts | 'bar' puts │ between the meters (default); 'dot' puts a center-dot · |
AMBIGUOUS_WIDTH | format.ts | Set to 2 if your locale draws ▰▱ double-width (some East Asian setups) |
claude plugin validate .
claude plugin test .hooks/register.tsx 196 lines1import { atom, read, update } from 'claude-code'
2import type {
3 Color,
4 EngineInterface,
5 Register,
6 SessionCost,
7 SessionRateLimit,
8 Timer,
9} from 'claude-code'
10
11import { BAR_GAP, fitParts, room, SEPARATOR } from './format'
12import type { Level, Part } from './format'
13import type { Reading } from '../types'
14
15const reading = atom({ plugin: 'compact-usage-meter', key: 'reading' } as const, null)
16
17const THRESHOLDS = [95, 80]
18
19const EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max'])
20
21// Blank rows between the engine's hint line and the meter.
22const GAP = 1
23
24const NAMES: Record<string, string> = {
25 five_hour: 'Session usage',
26 seven_day: 'Weekly usage',
27}
28
29// Each meter's own hue, from the theme so it suits light and dark.
30const HUES: Record<string, Color> = {
31 context: 'suggestion',
32 session: 'planMode',
33 weekly: 'merged',
34}
35
36// The percentage leaves the meter's hue only to warn.
37const WARNINGS: Record<Level, Color | undefined> = {
38 low: undefined,
39 mid: 'warning',
40 high: 'error',
41}
42
43// What a usage reading carries; the model is read at draw time, the effort from turn.step.
44type Figures = Omit<Reading, 'nowMs' | 'model' | 'effort'>
45
46let tick: Timer | undefined
47const warned = new Set<string>()
48
49function toFigures(usage: {
50 context: { percent?: number }
51 rateLimits: SessionRateLimit[]
52 cost?: SessionCost
53}): Figures {
54 return {
55 contextPercent: usage.context.percent,
56 rateLimits: usage.rateLimits,
57 cost: usage.cost?.usd,
58 }
59}
60
61async function record($: EngineInterface, figures?: Figures) {
62 const latest = figures ?? toFigures(await $.session.usage())
63 const nowMs = await $.clock.now()
64 await update($, reading, previous => ({ ...latest, effort: previous?.effort, nowMs }))
65}
66
67// The effort of the latest main-loop request; kept across readings until the next one.
68async function noteEffort($: EngineInterface, effort: string | number | undefined) {
69 const label = effort === undefined ? undefined : String(effort)
70 await update($, reading, previous =>
71 previous === null ? previous : { ...previous, effort: label },
72 )
73}
74
75// One toast per window per threshold; re-armed once usage drops below it.
76function warn($: EngineInterface, limits: SessionRateLimit[]) {
77 for (const { kind, percentUsed } of limits) {
78 const name = NAMES[kind]
79
80 if (name === undefined) {
81 continue
82 }
83
84 for (const threshold of THRESHOLDS) {
85 if (percentUsed < threshold) {
86 warned.delete(`${kind}@${threshold}`)
87 }
88 }
89
90 const crossed = THRESHOLDS.find(t => percentUsed >= t)
91
92 if (crossed !== undefined && !warned.has(`${kind}@${crossed}`)) {
93 THRESHOLDS.filter(t => t <= crossed).forEach(t => warned.add(`${kind}@${t}`))
94 $.ui.toast(`${name} at ${percentUsed}%`)
95 }
96 }
97}
98
99export const register: Register = on => {
100 on('session.start', async ($, e, next) => {
101 // v0.1 pinned a status line; take it down.
102 $.ui.status(undefined)
103 tick?.cancel()
104 tick = $.clock.every(60_000, () => void record($))
105 await record($)
106
107 return next(e)
108 })
109
110 on('session.measure', async ($, e, next) => {
111 await record($, toFigures(e))
112 warn($, e.rateLimits)
113
114 return next(e)
115 })
116
117 // Effort rides on each model request. Subagents have their own; the stream passes untouched.
118 on('turn.step', async function* ($, e, next) {
119 if (e.agentId === undefined) {
120 await noteEffort($, e.effort)
121 }
122
123 return yield* next(e)
124 })
125
126 // A switch from anywhere (/model, its picker, /config, a fallback) redraws now, not at the
127 // next reading; the drawing reads the model itself.
128 on('classic.PostModelSwitch', async ($, e, next) => {
129 await record($)
130
131 return next(e)
132 })
133
134 // `/effort <level>` shows at once. Its picker passes no level, so that one waits for the
135 // next request.
136 on('command.run', { command: 'effort' }, async ($, e, next) => {
137 const result = await next(e)
138 const level = e.args.trim().toLowerCase()
139
140 if (EFFORTS.has(level)) {
141 await noteEffort($, level)
142 }
143
144 return result
145 })
146
147 // A row of its own, under the engine's hint line ("⏵⏵ auto mode on …").
148 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
149 const engine = await next(e)
150 const current = await read($, reading)
151 // Read per draw, so a /model switch shows at the next redraw, not the next reading.
152 const model = current === null ? undefined : await $.session.model()
153 const columns = e.viewport?.columns ?? 80
154 const parts = current === null ? undefined : fitParts({ ...current, model }, room(columns))
155
156 if (parts === undefined) {
157 return engine
158 }
159
160 const { Box, Text } = $.ui.resolve(e)
161 const drawPart = (part: Part, index: number) => {
162 const hue = HUES[part.label]
163 const separator = index > 0 ? <Text dimColor>{SEPARATOR}</Text> : null
164
165 // The info part is plain text: no hue, bar or percentage.
166 if (part.pct === undefined) {
167 return (
168 <Text key={`part-${index}`} wrap="truncate">
169 {separator}
170 <Text dimColor>{part.label}</Text>
171 </Text>
172 )
173 }
174
175 return (
176 <Text key={`part-${index}`} wrap="truncate">
177 {separator}
178 <Text color={hue}>{part.label} </Text>
179 {part.bar === undefined ? null : <Text color={hue}>{part.bar}{BAR_GAP} </Text>}
180 <Text color={WARNINGS[part.level] ?? hue} bold={part.level !== 'low'}>{part.pct}</Text>
181 {part.reset === undefined ? null : <Text dimColor> {part.reset}</Text>}
182 </Text>
183 )
184 }
185
186 // The terminal draws the engine's line ahead of anything else in the tree,
187 // whatever its place, so the meter goes under it, GAP blank rows between.
188 return (
189 <Box flexDirection="column">
190 {engine}
191 <Box flexDirection="row" marginTop={GAP}>{parts.map(drawPart)}</Box>
192 </Box>
193 )
194 })
195}
196hooks/format.ts 237 lines1import type { Reading } from '../types'
2
3// The hint line's left gutter, and one cell kept free at the right edge.
4const GUTTER = 2
5const EDGE = 1
6// 'ascii' draws bars as #/- for terminals that render ▰▱ double-width.
7export const GLYPHS: Glyphs = 'blocks'
8// Cells per ▰/▱; set 2 where an East-Asian locale draws ambiguous glyphs wide.
9export const AMBIGUOUS_WIDTH = 1
10
11// 'bar' puts │ between the meters, 'dot' puts · there; both take one cell.
12export const DIVIDER: Divider = 'bar'
13export const DIVIDERS = { bar: '│', dot: '·' } as const
14
15export const SEPARATOR = ` ${DIVIDERS[DIVIDER]} `
16const INFO_SEPARATOR = ' · '
17
18export type Divider = keyof typeof DIVIDERS
19export type Glyphs = 'blocks' | 'ascii'
20export type Level = 'low' | 'mid' | 'high'
21
22// The info part (model, effort, cost) is label-only: no bar, no percentage.
23export type Part = {
24 label: string
25 bar?: string
26 pct?: string
27 reset?: string
28 level: Level
29}
30
31export type FitOptions = {
32 glyphs?: Glyphs
33 ambiguousWidth?: number
34}
35
36type Tier = { barCells: number; hasResets: boolean; hasLimits: boolean; hasInfo: boolean }
37
38// Richest first; the first that fits the room is drawn. Only the richest carries
39// the info part, so it is the first thing to go when the room narrows.
40const TIERS: Tier[] = [
41 { barCells: 8, hasResets: true, hasLimits: true, hasInfo: true },
42 { barCells: 8, hasResets: true, hasLimits: true, hasInfo: false },
43 { barCells: 4, hasResets: true, hasLimits: true, hasInfo: false },
44 { barCells: 0, hasResets: true, hasLimits: true, hasInfo: false },
45 { barCells: 0, hasResets: false, hasLimits: true, hasInfo: false },
46 { barCells: 0, hasResets: false, hasLimits: false, hasInfo: false },
47]
48
49const LIMITS = [
50 { kind: 'five_hour', label: 'session' },
51 { kind: 'seven_day', label: 'weekly' },
52] as const
53
54const BAR = {
55 blocks: { full: '▰', empty: '▱' },
56 ascii: { full: '#', empty: '-' },
57} as const
58
59const AMBIGUOUS = new Set(['▰', '▱'])
60
61export const cellWidth = (text: string, ambiguousWidth = AMBIGUOUS_WIDTH) => {
62 let cells = 0
63
64 for (const char of text) {
65 cells += AMBIGUOUS.has(char) ? ambiguousWidth : 1
66 }
67
68 return cells
69}
70
71const clamp = (n: number, low: number, high: number) =>
72 Math.min(high, Math.max(low, n))
73
74export const level = (pct: number | undefined): Level =>
75 pct === undefined || pct < 50 ? 'low' : pct <= 80 ? 'mid' : 'high'
76
77export const bar = (pct: number | undefined, cells: number, glyphs: Glyphs = GLYPHS) => {
78 const filled = pct === undefined ? 0 : Math.round((clamp(pct, 0, 100) / 100) * cells)
79 const { full, empty } = BAR[glyphs]
80
81 return full.repeat(filled) + empty.repeat(cells - filled)
82}
83
84// Unpadded, so no gap opens between the bar and the number: '--%', '6%', '13%', '100%'.
85export const pctText = (pct: number | undefined) =>
86 pct === undefined
87 ? '--%'
88 : `${String(clamp(Math.round(pct), 0, 999))}%`
89
90// '45m', '3h12m', '14h', '2d05h', 'now', '--'.
91export const countdown = (resetsAt: string | undefined, nowMs: number) => {
92 const at = resetsAt === undefined ? NaN : Date.parse(resetsAt)
93
94 if (Number.isNaN(at)) {
95 return '--'
96 }
97
98 const minutes = Math.ceil((at - nowMs) / 60_000)
99
100 if (minutes <= 0) {
101 return 'now'
102 }
103
104 if (minutes < 60) {
105 return `${minutes}m`
106 }
107
108 const hours = Math.floor(minutes / 60)
109
110 if (hours < 10) {
111 return `${hours}h${String(minutes % 60).padStart(2, '0')}m`
112 }
113
114 if (hours < 24) {
115 return `${hours}h`
116 }
117
118 const days = Math.floor(hours / 24)
119
120 return days < 10
121 ? `${days}d${String(hours % 24).padStart(2, '0')}h`
122 : `${days}d`
123}
124
125const title = (family: string, major?: string, minor?: string) => {
126 const name = family.charAt(0).toUpperCase() + family.slice(1)
127
128 return major === undefined ? name : `${name} ${major}${minor === undefined ? '' : `.${minor}`}`
129}
130
131// 'claude-sonnet-5-5' -> 'Sonnet 5.5', 'claude-haiku-4-5-20251001' -> 'Haiku 4.5',
132// 'claude-3-5-sonnet-20241022' -> 'Sonnet 3.5', 'opus[1m]' -> 'Opus'; anything else as given.
133export const modelName = (id: string) => {
134 const bare = id.replace(/\[[^\]]*\]$/, '')
135 const named = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?$/.exec(bare)
136 const numbered = /^claude-(\d+)(?:-(\d{1,2}))?-([a-z]+)(?:-\d{8})?$/.exec(bare)
137
138 if (named !== null) {
139 return title(named[1], named[2], named[3])
140 }
141
142 if (numbered !== null) {
143 return title(numbered[3], numbered[1], numbered[2])
144 }
145
146 return /^[a-z]+$/.test(bare) ? title(bare) : bare
147}
148
149// '$0.00', '$1.23', '$142.50'.
150export const costText = (usd: number) => `$${usd.toFixed(2)}`
151
152// Only a subscription reports 5-hour and 7-day windows; its cost is a list-price estimate,
153// not a bill, so it is left out. Nothing spent yet shows nothing either way.
154const isBilled = ({ rateLimits, cost }: Reading) =>
155 cost !== undefined && cost > 0 && !rateLimits.some(r => LIMITS.some(l => l.kind === r.kind))
156
157const info = (reading: Reading): Part | undefined => {
158 const { model, effort, cost = 0 } = reading
159
160 return model === undefined
161 ? undefined
162 : {
163 label: [
164 `${modelName(model)}${effort === undefined ? '' : ` (${effort})`}`,
165 isBilled(reading) ? costText(cost) : undefined,
166 ]
167 .filter(piece => piece !== undefined)
168 .join(INFO_SEPARATOR),
169 level: 'low',
170 }
171}
172
173const part = (
174 label: string,
175 pct: number | undefined,
176 tier: Tier,
177 glyphs: Glyphs,
178 reset?: string,
179): Part => ({
180 label,
181 bar: tier.barCells > 0 ? bar(pct, tier.barCells, glyphs) : undefined,
182 pct: pctText(pct),
183 reset: reset === undefined ? undefined : `(${reset})`,
184 level: level(pct),
185})
186
187export const segments = (reading: Reading, tier: Tier, glyphs: Glyphs = GLYPHS) => {
188 const lead = tier.hasInfo ? info(reading) : undefined
189 const parts: Part[] = lead === undefined ? [] : [lead]
190
191 parts.push(part('context', reading.contextPercent, tier, glyphs))
192
193 if (!tier.hasLimits) {
194 return parts
195 }
196
197 for (const { kind, label } of LIMITS) {
198 const limit = reading.rateLimits.find(r => r.kind === kind)
199
200 if (limit !== undefined) {
201 const reset = tier.hasResets ? countdown(limit.resetsAt, reading.nowMs) : undefined
202 parts.push(part(label, limit.percentUsed, tier, glyphs, reset))
203 }
204 }
205
206 return parts
207}
208
209// One space beyond the usual, after a bar, so the number does not crowd the glyphs.
210export const BAR_GAP = ' '
211
212// One part as drawn: the register's tree puts exactly these spaces between its Texts.
213export const partText = ({ label, bar, pct, reset }: Part) =>
214 [label, bar === undefined ? undefined : `${bar}${BAR_GAP}`, pct, reset]
215 .filter(piece => piece !== undefined)
216 .join(' ')
217
218export const plainText = (parts: Part[]) => parts.map(partText).join(SEPARATOR)
219
220// Cells for the meter on its own row, `columns` being the conversation column's width.
221export const room = (columns: number) => columns - GUTTER - EDGE
222
223// The richest parts that fit `roomCells`, or undefined when none do.
224export const fitParts = (reading: Reading, roomCells: number, options: FitOptions = {}) => {
225 const { glyphs = GLYPHS, ambiguousWidth = AMBIGUOUS_WIDTH } = options
226
227 for (const tier of TIERS) {
228 const parts = segments(reading, tier, glyphs)
229
230 if (cellWidth(plainText(parts), ambiguousWidth) <= roomCells) {
231 return parts
232 }
233 }
234
235 return undefined
236}
237types/index.d.ts 18 lines1export type RateLimit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Reading = {
4 contextPercent?: number
5 rateLimits: RateLimit[]
6 nowMs: number
7 // As `/model` shows it, the last turn's effort, and the session's cost in US dollars.
8 model?: string
9 effort?: string
10 cost?: number
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'compact-usage-meter': { reading: Reading | null }
16 }
17}
18