Shows your plan's session (5-hour) and weekly usage % above the message box, with a heads-up at 90%

A Claude Code mod that shows your plan's usage right above the message box:
Works in Claude Code in the terminal and in the Code tab of the Claude desktop app. It needs a Claude subscription (the usage numbers come from your plan's rate limits).
At the prompt of a Claude Code terminal session:
/plugin install usage-meter --marketplace marco-droyd/usage-meter
Answer y to add the marketplace, then pick the user scope.
From a shell:
claude plugin install usage-meter --marketplace marco-droyd/usage-meter
In the Claude desktop app (where /plugin doesn't run): click + next to the prompt box, then Plugins, Add marketplace with marco-droyd/usage-meter, then Add plugin.
The colours live in one line near the top of usage-meter/hooks/register.tsx:
const MIDNIGHT = { bg: '#0b0b0b', purple: '#9d7bff', yellow: '#ffd84d', red: '#ff5c5c', ... }
MIT, see LICENSE.
hooks/register.tsx 216 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Limit, StepStatus, TaskTiming } from '../types'
5
6// The plan's rate-limit windows, as the engine reports them with each response.
7const limits = atom({ plugin: 'usage-meter', key: 'limits' } as const, [] as Limit[])
8// Windows already warned about at 90%, so the toast shows once per window.
9const warned = atom({ plugin: 'usage-meter', key: 'warned' } as const, [] as string[])
10// When `limits` was read. Readings are shared through $.store, so every session on this
11// computer shows the newest one, even a session that has been idle for hours.
12const readAt = atom({ plugin: 'usage-meter', key: 'readAt' } as const, 0)
13const LATEST = 'latest'
14type Latest = { at: number; limits: Limit[] }
15// Claude's task list in the desktop app (update_plan), one status per step.
16const steps = atom({ plugin: 'usage-meter', key: 'steps' } as const, [] as StepStatus[])
17// When the task list started and when its latest step was checked off, for the time-left guess.
18const timing = atom({ plugin: 'usage-meter', key: 'timing' } as const, { startedAt: 0, lastDoneAt: 0, done: 0, base: 0 } as TaskTiming)
19
20const PLAN_TOOL = 'mcp__ccd_turn__update_plan'
21const WRAP_TOOL = 'mcp__ccd_turn__wrap_up'
22
23const NAMES: Record<string, string> = { five_hour: 'Session', seven_day: 'Week', spend_limit: 'Spend' }
24const ORDER = ['five_hour', 'seven_day', 'spend_limit']
25// "Midnight" style: black band, purple as the normal colour. Each label takes its bar's colour.
26const MIDNIGHT = { bg: '#0b0b0b', purple: '#9d7bff', yellow: '#ffd84d', red: '#ff5c5c', muted: '#777777', divider: '#555555' }
27
28function untilText(resetsAt: string | undefined, now: number): string {
29 if (!resetsAt) return ''
30 const ms = Date.parse(resetsAt) - now
31 if (!(ms > 0)) return 'resetting'
32 const mins = Math.round(ms / 60000)
33 const d = Math.floor(mins / 1440)
34 const h = Math.floor((mins % 1440) / 60)
35 const m = mins % 60
36 if (d > 0) return `${d}d ${h}h`
37 if (h > 0) return `${h}h ${m}m`
38 return `${m}m`
39}
40
41function bar(pct: number, cells = 10): string {
42 const filled = Math.max(0, Math.min(cells, Math.round((pct / 100) * cells)))
43 return '█'.repeat(filled) + '░'.repeat(cells - filled)
44}
45
46// Time left = average time per finished step so far x steps left, minus time already spent on
47// the current step. A guess: steps differ in size, so it firms up as more steps finish.
48function leftText(plan: StepStatus[], t: TaskTiming, now: number): string {
49 const left = plan.filter(s => s !== 'completed').length
50 if (left === 0) return ''
51 // Only steps finished since the list started count; ones already ticked when it arrived don't.
52 const finished = t.done - t.base
53 if (finished <= 0) return 'estimating'
54 const perStep = (t.lastDoneAt - t.startedAt) / finished
55 const ms = perStep * left - (now - t.lastDoneAt)
56 const mins = Math.round(ms / 60000)
57 if (mins < 1) return '1m left'
58 if (mins < 60) return `${mins}m left`
59 return `${Math.floor(mins / 60)}h ${mins % 60}m left`
60}
61
62type PlanCall = {
63 items?: { status: StepStatus }[]
64 advance?: boolean
65 done?: number[]
66 start?: number
67}
68
69// Applies one update_plan call to the step list, the way the app reads it.
70function applyPlan(prev: StepStatus[], call: PlanCall): StepStatus[] {
71 if (call.items) return call.items.map(i => i.status)
72 const next = [...prev]
73 if (call.advance) {
74 const cur = next.indexOf('in_progress')
75 if (cur >= 0) next[cur] = 'completed'
76 const pending = next.indexOf('pending')
77 if (pending >= 0) next[pending] = 'in_progress'
78 }
79 for (const n of call.done ?? []) if (next[n - 1]) next[n - 1] = 'completed'
80 if (call.start && next[call.start - 1]) {
81 const cur = next.indexOf('in_progress')
82 if (cur >= 0 && cur !== call.start - 1) next[cur] = 'pending'
83 next[call.start - 1] = 'in_progress'
84 }
85 return next
86}
87
88async function store($: EngineInterface, list: Limit[]): Promise<void> {
89 if (list.length === 0) return
90 const clean = list.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt }))
91 const at = await $.clock.now()
92 await update($, limits, () => clean)
93 await update($, readAt, () => at)
94 await $.store.set(LATEST, { at, limits: clean } as Latest)
95 const already = await read($, warned)
96 for (const l of clean) {
97 const tag = `${l.kind}@${l.resetsAt ?? ''}`
98 if (l.percentUsed >= 90 && !already.includes(tag)) {
99 $.ui.toast(`Heads up: ${NAMES[l.kind] ?? l.kind} usage is at ${l.percentUsed}%`)
100 await update($, warned, w => [...w, tag])
101 }
102 }
103}
104
105// Uses the reading in $.store when it is newer than this session's own.
106async function adoptNewest($: EngineInterface): Promise<void> {
107 const saved = (await $.store.get(LATEST)) as Latest | undefined
108 if (!saved || !Array.isArray(saved.limits)) return
109 if (saved.at > (await read($, readAt))) {
110 await update($, limits, () => saved.limits)
111 await update($, readAt, () => saved.at)
112 }
113}
114
115export const register: Register = on => {
116 on('session.start', async ($, e, next) => {
117 const r = await next(e)
118 try {
119 const u = await $.session.usage()
120 await store($, u.rateLimits)
121 } catch {
122 // no reading yet; session.measure fills it in after the first reply
123 }
124 await adoptNewest($)
125 // Every 30 s: take a newer reading another session saved, and redraw so the reset
126 // countdowns and the task's time left keep moving while you're away. Local only: no model calls.
127 $.clock.every(30000, () => {
128 void adoptNewest($).then(() => $.ui.invalidate('ui.render'))
129 })
130 return r
131 })
132
133 on('session.measure', async ($, e, next) => {
134 await store($, e.rateLimits)
135 return next(e)
136 })
137
138 on('tool.call', { tool: PLAN_TOOL }, async ($, e, next) => {
139 const r = await next(e)
140 if (r.deny === undefined && r.isError !== true) {
141 const prev = await read($, steps)
142 const now = await $.clock.now()
143 const fresh = prev.length === 0 || prev.every(s => s === 'completed')
144 const nextSteps = applyPlan(prev, e as unknown as PlanCall)
145 const doneNow = nextSteps.filter(s => s === 'completed').length
146 await update($, timing, t =>
147 fresh ? { startedAt: now, lastDoneAt: now, done: doneNow, base: doneNow }
148 : doneNow > t.done ? { ...t, lastDoneAt: now, done: doneNow }
149 : t)
150 await update($, steps, () => nextSteps)
151 }
152 return r
153 })
154
155 // Wrapping up checks off the whole list.
156 on('tool.call', { tool: WRAP_TOOL }, async ($, e, next) => {
157 const r = await next(e)
158 await update($, steps, prev => prev.map(() => 'completed' as StepStatus))
159 return r
160 })
161
162 // A finished list clears when you send the next message.
163 on('prompt.submit', async ($, e, next) => {
164 const list = await read($, steps)
165 if (list.length > 0 && list.every(s => s === 'completed')) await update($, steps, () => [])
166 return next(e)
167 })
168
169 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
170 const list = await read($, limits)
171 const plan = await read($, steps)
172 if (list.length === 0 && plan.length === 0) return next(e)
173 const { Box, Text } = $.ui.resolve(e)
174 const now = await $.clock.now()
175 const sorted = [...list].sort((a, b) => ORDER.indexOf(a.kind) - ORDER.indexOf(b.kind))
176 const taskPct = plan.length ? Math.round((plan.filter(s => s === 'completed').length / plan.length) * 100) : 0
177 const taskLeft = plan.length ? leftText(plan, await read($, timing), now) : ''
178 // Each segment gets an equal share of the band: halves without a task, thirds with one.
179 // Task goes last, so Session and Week keep their order when a task starts or ends.
180 const segs = sorted.map(l => {
181 // Past its reset time the window has started over, whatever the last reading said.
182 const isReset = l.resetsAt !== undefined && Date.parse(l.resetsAt) <= now
183 const pct = isReset ? 0 : l.percentUsed
184 const color = pct >= 90 ? MIDNIGHT.red : pct >= 80 ? MIDNIGHT.yellow : MIDNIGHT.purple
185 return { key: l.kind, label: NAMES[l.kind] ?? l.kind, pct, color, extra: isReset ? '' : untilText(l.resetsAt, now) }
186 })
187 if (plan.length > 0) segs.push({ key: 'task', label: 'Task', pct: taskPct, color: MIDNIGHT.purple, extra: taskLeft })
188 // Halves get bars twice as long as thirds, so two segments fill the band like three do.
189 const cells = segs.length <= 2 ? 20 : 10
190 // Equal-width segments (flex basis 0, grow 1), each centred between its dividers or the band's edge.
191 const ours = (
192 <Box key="usage" backgroundColor={MIDNIGHT.bg} paddingX={1} width="100%" flexDirection="row">
193 {segs.flatMap((s, i) => [
194 ...(i > 0 ? [<Text key={`div-${s.key}`} color={MIDNIGHT.divider}>|</Text>] : []),
195 <Box key={s.key} width={0} flexGrow={1} flexDirection="row" justifyContent="center">
196 <Text>
197 <Text weight="bold" color={s.color}>{s.label} </Text>
198 <Text color={s.color}>{bar(s.pct, cells)} {s.pct}%</Text>
199 {s.extra ? <Text color={MIDNIGHT.muted}>{' ' + s.extra}</Text> : null}
200 </Text>
201 </Box>,
202 ])}
203 </Box>
204 )
205 if (!e.props.hasSurvey) return ours
206 // A survey or question box holds the band: keep it, with the bar above it.
207 const held = await next(e)
208 return (
209 <Box key="usage-wrap" flexDirection="column" width="100%">
210 {ours}
211 {held}
212 </Box>
213 )
214 })
215}
216types/index.d.ts 10 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2export type StepStatus = 'pending' | 'in_progress' | 'completed'
3export type TaskTiming = { startedAt: number; lastDoneAt: number; done: number; base: number }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'usage-meter': { limits: Limit[]; warned: string[]; steps: StepStatus[]; timing: TaskTiming; readAt: number }
8 }
9}
10