Your plan's 5-hour and 7-day usage as small bars above the prompt, with the reset countdown and the session's cost.

A private reference collection of public Claude Code mods, with a security-oriented analysis.
mods/Nothing here is installed or executed. All code is by its upstream authors under their own licences (MIT, one Apache-2.0); each folder keeps its original LICENSE.
hooks/register.tsx 218 lines1// Usage Meter: the plan's rate-limit windows and the session's cost, above the prompt.
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, Register, Timer } from 'claude-code'
4
5import type { UsageMeterSnapshot, UsageMeterWindow } from '../types'
6import { bandsFile, parseOrder, stackBands } from './bands'
7
8const CELLS = 5
9const BLOCKS = '▁▂▃▄▅▆▇█'
10const TICK_MS = 60_000
11const WARN_AT = 90
12const LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
13const ORDER = ['five_hour', 'seven_day', 'spend_limit']
14
15// Held by the host, so they survive a hot reload of this file.
16const snapshot = atom({ plugin: 'usage-meter', key: 'snapshot' } as const, null as UsageMeterSnapshot | null)
17const tick = atom({ plugin: 'usage-meter', key: 'tick' } as const, 0)
18const warned = atom({ plugin: 'usage-meter', key: 'warned' } as const, {} as Record<string, number>)
19
20type Segment = { text: string; color?: string; dim?: boolean }
21type Part = { kind: 'window' | 'cost'; isPrimary: boolean; segments: Segment[] }
22
23let bandOrder: Promise<string[]> | undefined // read once per session: restart to apply
24
25// The band order file, as the claude-code-mods checklist saves it; [] on any error.
26async function readBandOrder($: EngineInterface): Promise<string[]> {
27 const file = bandsFile(await $.env.get('CLAUDE_CONFIG_DIR'), await $.env.get('HOME'))
28 return file && (await $.fs.exists(file)) ? parseOrder(await $.fs.read(file)) : []
29}
30
31// Forgets the cached band order, for tests.
32export function resetBandOrder() {
33 bandOrder = undefined
34}
35
36export const register: Register = on => {
37 let timer: Timer | undefined // one countdown timer, even if session.start fires again
38
39 on('session.start', async ($, e, next) => {
40 const result = await next(e)
41 // Moves the reset countdown on while a window has a reset time.
42 timer?.cancel()
43 timer = $.clock.every(TICK_MS, () => {
44 void (async () => {
45 const snap = await read($, snapshot)
46 if (snap?.rateLimits.some(w => w.resetsAt)) await update($, tick, n => n + 1)
47 })().catch(() => {})
48 })
49 const usage = await $.session.usage().catch(() => null)
50 if (usage) await take($, usage.rateLimits, usage.cost?.usd).catch(() => {})
51 return result
52 })
53
54 on('session.measure', async ($, e, next) => {
55 const result = await next(e)
56 await take($, e.rateLimits, e.cost?.usd).catch(() => {})
57 return result
58 })
59
60 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
61 const rest = await next(e) // what other mods and Claude Code draw here stays
62 await read($, tick) // redraws on each tick of the countdown
63 const snap = await read($, snapshot)
64 if (e.props.hasSurvey || !snap) return rest
65
66 const parts = fit(build(snap, await $.clock.now()), e.props.bodyColumns - 2)
67 if (parts.length === 0) return rest
68
69 const { Box, Text } = $.ui.resolve(e)
70 const segments: Segment[] = [{ text: '⏱ ', dim: true }]
71 parts.forEach((p, i) => {
72 if (i > 0) segments.push({ text: ' · ', dim: true })
73 segments.push(...p.segments)
74 })
75
76 const mine = (
77 <Box flexDirection="row" paddingX={1}>
78 {segments.map((s, i) => (
79 <Text key={String(i)} color={s.color} dimColor={s.dim} wrap="truncate">
80 {s.text}
81 </Text>
82 ))}
83 </Box>
84 )
85 bandOrder ??= readBandOrder($).catch(() => [])
86 return stackBands(rest, 'usage-meter', mine, 'above', await bandOrder) // above what other mods draw, unless the band order says otherwise
87 })
88}
89
90async function take($: EngineInterface, rateLimits: readonly UsageMeterWindow[], costUsd: number | undefined) {
91 const windows = rateLimits
92 .filter(w => Number.isFinite(w.percentUsed))
93 .map(w => ({ kind: w.kind, percentUsed: w.percentUsed, resetsAt: w.resetsAt }))
94 await update($, snapshot, () => ({ rateLimits: windows, costUsd }))
95 await warn($, windows)
96}
97
98// One toast per window per reset period, once it passes WARN_AT.
99async function warn($: EngineInterface, windows: UsageMeterWindow[]) {
100 const done = await read($, warned)
101 const now = await $.clock.now()
102 const fresh: Record<string, number> = {}
103 const rearm: string[] = []
104 for (const w of windows) {
105 const resetMs = resetTime(w)
106 if (w.percentUsed < WARN_AT) {
107 // With no reset time, dropping back under the line is the only sign of a new period.
108 if (resetMs === 0 && done[w.kind] !== undefined) rearm.push(w.kind)
109 continue
110 }
111 const last = done[w.kind]
112 // A reset time that moved on by more than half an hour is a new period.
113 if (last !== undefined && resetMs <= last + 30 * 60_000) continue
114 fresh[w.kind] = resetMs
115 const when = resetMs > 0 ? `, resets in ${countdown(resetMs - now)}` : ''
116 const what = w.kind === 'spend_limit' ? 'Spend limit' : `${label(w.kind)} usage limit`
117 $.ui.toast(`${what} at ${Math.round(w.percentUsed)}%${when}`)
118 }
119 if (Object.keys(fresh).length > 0 || rearm.length > 0) {
120 await update($, warned, old => {
121 const next = { ...old, ...fresh }
122 for (const kind of rearm) delete next[kind]
123 return next
124 })
125 }
126}
127
128export function build(snap: UsageMeterSnapshot, now: number): Part[] {
129 const windows = snap.rateLimits
130 .filter(w => !isStale(w, now)) // a window past its reset waits for a fresh reading
131 .sort((a, b) => rank(a.kind) - rank(b.kind))
132 const parts: Part[] = windows.map((w, i) => {
133 const color = colorFor(w.percentUsed)
134 const segments: Segment[] = [
135 { text: `${label(w.kind)} `, dim: true },
136 { text: bar(w.percentUsed), color },
137 { text: ` ${Math.round(w.percentUsed)}%`, color },
138 ]
139 const resetMs = resetTime(w)
140 // The first window always says when it resets; the others only once they run high.
141 if (resetMs > 0 && (i === 0 || w.percentUsed >= 75)) {
142 segments.push({ text: ` · resets ${countdown(resetMs - now)}`, dim: true })
143 }
144 return { kind: 'window', isPrimary: i === 0, segments }
145 })
146 if (snap.costUsd !== undefined && snap.costUsd > 0) {
147 parts.push({ kind: 'cost', isPrimary: false, segments: [{ text: `$${snap.costUsd.toFixed(2)} session`, dim: true }] })
148 }
149 return parts
150}
151
152// Drops the secondary windows first, then the cost, until the line fits.
153export function fit(parts: Part[], columns: number): Part[] {
154 let kept = [...parts]
155 const tooWide = () => width(kept) > columns
156 while (tooWide()) {
157 const i = findLastIndex(kept, p => p.kind === 'window' && !p.isPrimary)
158 if (i < 0) break
159 kept.splice(i, 1)
160 }
161 if (tooWide() && kept.some(p => p.isPrimary)) kept = kept.filter(p => p.kind !== 'cost')
162 return kept
163}
164
165function width(parts: Part[]) {
166 const text = parts.map(p => p.segments.map(s => s.text).join('')).join(' · ')
167 return 3 + [...text].length // the clock and its space: the glyph can draw two cells wide
168}
169
170function findLastIndex<T>(list: T[], test: (item: T) => boolean) {
171 for (let i = list.length - 1; i >= 0; i--) if (test(list[i] as T)) return i
172 return -1
173}
174
175export function bar(percent: number) {
176 const filled = (Math.min(Math.max(percent, 0), 100) / 100) * CELLS
177 let out = ''
178 for (let i = 0; i < CELLS; i++) {
179 const cell = Math.min(Math.max(filled - i, 0), 1)
180 out += BLOCKS[Math.round(cell * (BLOCKS.length - 1))]
181 }
182 return out
183}
184
185export function colorFor(percent: number) {
186 if (percent >= 90) return 'red'
187 if (percent >= 75) return 'yellow'
188 return 'green'
189}
190
191export function countdown(ms: number) {
192 const minutes = Math.max(0, Math.ceil(ms / 60_000))
193 if (minutes < 1) return 'now'
194 if (minutes < 60) return `${minutes}m`
195 const hours = Math.floor(minutes / 60)
196 if (hours < 24) return `${hours}h${String(minutes % 60).padStart(2, '0')}`
197 return `${Math.floor(hours / 24)}d${hours % 24}h`
198}
199
200function resetTime(w: UsageMeterWindow) {
201 const ms = w.resetsAt ? Date.parse(w.resetsAt) : NaN
202 return Number.isFinite(ms) ? ms : 0
203}
204
205function isStale(w: UsageMeterWindow, now: number) {
206 const resetMs = resetTime(w)
207 return resetMs > 0 && resetMs <= now
208}
209
210function label(kind: string) {
211 return LABELS[kind] ?? kind.replace(/_/g, ' ')
212}
213
214function rank(kind: string) {
215 const i = ORDER.indexOf(kind)
216 return i < 0 ? ORDER.length : i
217}
218hooks/bands.ts 73 lines1// Band order: every band mod ships this same file (canonical copy: shared/bands.ts in the repo).
2// The order file, <configDir>/claude-code-mods/bands.json, lists mod names top to bottom
3// (the last sits right above the prompt). Each band mod wraps its band in a Box keyed
4// `band:<name>` and returns one column keyed `bands`; the next band mod unpacks that
5// column, adds its own band and sorts again, so the last one to draw sorts them all.
6// Edit shared/bands.ts, then copy it over every mod's hooks/bands.ts (a repo test checks).
7// Reading the file needs $, which Claude Code follows only within one file, so each mod
8// reads it in its own register file with bandsFile and parseOrder, once per session.
9import type { RenderElement, RenderNode } from 'claude-code'
10
11const COLUMN = 'bands'
12const PREFIX = 'band:'
13
14// Where the checklist saves the order: CLAUDE_CONFIG_DIR, or ~/.claude. Undefined with neither.
15export function bandsFile(configDir: string | undefined, home: string | undefined): string | undefined {
16 const dir = configDir ? configDir.replace(/\/+$/, '') : home ? `${home}/.claude` : ''
17 return dir ? `${dir}/claude-code-mods/bands.json` : undefined
18}
19
20// The names in an order file; [] when it is no JSON or not { order: string[] }.
21export function parseOrder(text: string): string[] {
22 try {
23 const order = (JSON.parse(text) as { order?: unknown } | null)?.order
24 return Array.isArray(order) && order.every(n => typeof n === 'string') ? order : []
25 } catch {
26 return []
27 }
28}
29
30/**
31 * One column of bands: `mine` wrapped in a Box keyed `band:<name>`, placed
32 * `at` (above or below) what `next(e)` returned, then sorted by `order`.
33 * With an order, items that are no band come first, then bands not in the
34 * order (in their current order), then the listed ones in file order. With
35 * no order nothing moves, so the screen looks as it did before.
36 */
37export function stackBands(rest: RenderElement | null | undefined, name: string, mine: RenderNode, at: 'above' | 'below', order: string[]): RenderElement {
38 const own: RenderElement = { type: 'Box', props: { key: PREFIX + name, flexDirection: 'column' }, children: [mine] }
39 const others = unpack(rest)
40 const items = at === 'above' ? [own, ...others] : [...others, own]
41 return { type: 'Box', props: { key: COLUMN, flexDirection: 'column' }, children: order.length ? sorted(items, order) : items }
42}
43
44// Another band mod's column gives up its items; anything else is one item.
45function unpack(rest: RenderElement | null | undefined): RenderNode[] {
46 if (!rest) return []
47 if (!isColumn(rest)) return [rest]
48 const kids = rest.children ?? []
49 const owner = rest.part?.plugin
50 // The engine stamped the column with its owner: that stamp belongs on the owner's own band.
51 return kids.map(k => (owner && typeof k !== 'string' && bandName(k) === owner && !('part' in k && k.part) ? { ...k, part: rest.part } : k))
52}
53
54function isColumn(el: RenderElement): el is RenderElement & { type: 'Box' } {
55 return el.type === 'Box' && el.props?.key === COLUMN
56}
57
58function bandName(node: RenderNode): string | undefined {
59 if (typeof node === 'string' || node.type !== 'Box') return undefined
60 const key = node.props?.key
61 return typeof key === 'string' && key.startsWith(PREFIX) ? key.slice(PREFIX.length) : undefined
62}
63
64function sorted(items: RenderNode[], order: string[]): RenderNode[] {
65 const rank = (n: RenderNode) => {
66 const name = bandName(n)
67 if (name === undefined) return -2 // not a band: first
68 const i = order.indexOf(name)
69 return i < 0 ? -1 : i // a band not in the order: on top of the listed ones
70 }
71 return items.map((n, i) => ({ n, i, r: rank(n) })).sort((a, b) => a.r - b.r || a.i - b.i).map(x => x.n)
72}
73types/index.d.ts 13 lines1export type UsageMeterWindow = { kind: string; percentUsed: number; resetsAt?: string }
2export type UsageMeterSnapshot = { rateLimits: UsageMeterWindow[]; costUsd?: number }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'usage-meter': {
7 snapshot: UsageMeterSnapshot | null
8 tick: number
9 warned: Record<string, number>
10 }
11 }
12}
13