Draws your plan usage limits as bars above the prompt, one colour per window, flagging when you are using a window faster than its clock. Toggle with…

Draws your plan usage limits as bars above the prompt, and flags a window you are using faster than its clock (illustrative output, not real figures):
Usage · amber = using a window faster than its clock · session $12.19
Session █████████████████████░░░░░ 79% used · 47% of time · 32 pts ahead of the clock · resets in 2h 40m
Week ███░░░░░░░░░░░░░░░░░░░░░░░ 10% used · 61% of time · resets in 2d 18h
Session (5 hours), Week (7 days), and Spend for a gateway spend limit. The colour says which window it is. The solid part is what you have used, the shaded part is what is left.% of time is how much of the window has already passed, worked out from its reset time.% used amber and adds N pts ahead of the clock. More than 25 points ahead, or 95% used or more, turns it red. A window with no known length (the spend limit) has no clock, so it is flagged only when nearly full./usage-bar toggles it. It starts on in every session.
$.session.usage(): the rate-limit windows from the last API response, and the session cost.AbovePrompt band, on top of whatever other band mods draw there. It steps aside while a survey is showing./usage.% of time is approximate.hooks/register.tsx 168 lines1import { atom, read, update } from 'claude-code'
2import type { Hook, Register } from 'claude-code'
3
4import type { Limit } from '../types'
5
6type Api = Parameters<Hook<'turn.complete'>>[0]
7
8const snapshot = atom({ plugin: 'usage-bar', key: 'snapshot' } as const, null)
9const isOn = atom({ plugin: 'usage-bar', key: 'isOn' } as const, true)
10
11const HOUR = 3_600_000
12const WINDOW_MS: Record<string, number> = { five_hour: 5 * HOUR, seven_day: 7 * 24 * HOUR }
13const LABEL: Record<string, string> = { five_hour: 'Session', seven_day: 'Week', spend_limit: 'Spend' }
14const COLOR: Record<string, string> = {
15 five_hour: 'permission',
16 seven_day: 'claude',
17 spend_limit: 'success',
18}
19const LABEL_WIDTH = 8
20const TEXT_WIDTH = 62
21const MIN_GAP_MS = 10_000
22const TICK_MS = 60_000
23
24let lastReadAt = 0
25
26const duration = (ms: number) => {
27 const minutes = Math.max(0, Math.round(ms / 60_000))
28 const days = Math.floor(minutes / 1440)
29 const hours = Math.floor((minutes % 1440) / 60)
30
31 if (days > 0) return `${days}d ${hours}h`
32 if (hours > 0) return `${hours}h ${minutes % 60}m`
33
34 return `${minutes}m`
35}
36
37// A bar's colour only ever says which window it is; the flag beside it says how it is going.
38const colorFor = (l: Limit) => COLOR[l.kind] ?? 'suggestion'
39
40// Percentage points of the window used beyond the share of its time that has passed.
41const SLACK_POINTS = 5
42const FAR_AHEAD_POINTS = 25
43
44// Ahead of the clock = using the window faster than it is passing, past a little slack.
45// A window with no known length (a spend limit) has no clock, so only a nearly full one is flagged.
46function pace(used: number, passed: number | null) {
47 // From the figures as shown, so the gap is exactly what subtracting the two printed numbers gives.
48 const ahead = passed === null ? null : Math.round(used) - Math.round(passed * 100)
49 const level =
50 used >= 95 || (ahead !== null && ahead > FAR_AHEAD_POINTS)
51 ? ('error' as const)
52 : ahead !== null && ahead > SLACK_POINTS
53 ? ('warning' as const)
54 : undefined
55
56 return { ahead, level }
57}
58
59// The bar: the used part solid, the rest shaded.
60function cells(width: number, used: number) {
61 const fill = Math.max(0, Math.min(width, Math.round((Math.min(used, 100) / 100) * width)))
62
63 return { fill: '█'.repeat(fill), rest: '░'.repeat(width - fill) }
64}
65
66async function refresh($: Api, force = false) {
67 const now = await $.clock.now()
68
69 if (!force && now - lastReadAt < MIN_GAP_MS) return
70 lastReadAt = now
71
72 try {
73 const usage = await $.session.usage()
74
75 await update($, snapshot, () => ({
76 limits: usage.rateLimits.map(r => ({
77 kind: r.kind,
78 percent: r.percentUsed,
79 resetsAt: r.resetsAt ? Date.parse(r.resetsAt) : null,
80 })),
81 costUsd: usage.cost ? usage.cost.usd : null,
82 }))
83 } catch {
84 // No session bound yet, or the read failed: keep the last bars.
85 }
86}
87
88export const register: Register = on => {
89 on('session.start', async ($, e, next) => {
90 await $.command.register({
91 name: 'usage-bar',
92 description: 'Toggle the plan usage bars above the prompt',
93 })
94 await refresh($, true)
95 // Countdowns and the time marker move even when nothing is being said.
96 void $.clock.every(TICK_MS, () => refresh($, true))
97
98 return next(e)
99 })
100
101 on('prompt.submit', async ($, e, next) => {
102 const result = await next(e)
103 await refresh($)
104
105 return result
106 })
107
108 on('turn.complete', async ($, e, next) => {
109 await refresh($, true)
110
111 return next(e)
112 })
113
114 on('command.run', { command: 'usage-bar' }, async $ => {
115 const next = !(await read($, isOn))
116 await update($, isOn, () => next)
117
118 if (next) await refresh($, true)
119
120 return { text: `Usage bar ${next ? 'on' : 'off'}.` }
121 })
122
123 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
124 // Whatever another band hook drew sits above ours: each hook draws on top of what is beneath it.
125 const below = await next(e)
126 const snap = await read($, snapshot)
127
128 if (e.props.hasSurvey || snap === null || !(await read($, isOn))) {
129 return below
130 }
131
132 const { Box, Text } = $.ui.resolve(e)
133 const now = await $.clock.now()
134 const barWidth = Math.max(10, Math.min(60, e.props.bodyColumns - LABEL_WIDTH - TEXT_WIDTH - 2))
135 const cost = snap.costUsd === null ? '' : ` · session $${snap.costUsd.toFixed(2)}`
136
137 return (
138 <Box flexDirection="column">
139 {below}
140 <Text dimColor>{`Usage · amber = using a window faster than its clock${cost}`}</Text>
141 {snap.limits.length === 0 && <Text dimColor>No plan limits reported for this account.</Text>}
142 {snap.limits.map(l => {
143 const span = WINDOW_MS[l.kind]
144 const left = l.resetsAt === null ? null : Math.max(0, l.resetsAt - now)
145 const passed = span === undefined || left === null ? null : Math.max(0, Math.min(1, 1 - left / span))
146 const color = colorFor(l)
147 const bar = cells(barWidth, l.percent)
148 const flag = pace(l.percent, passed)
149
150 return (
151 <Text key={l.kind}>
152 <Text color={color}>{(LABEL[l.kind] ?? l.kind).padEnd(LABEL_WIDTH)}</Text>
153 <Text color={color}>{bar.fill}</Text>
154 <Text dimColor>{bar.rest}</Text>
155 <Text color={flag.level} dimColor={flag.level === undefined}>{` ${Math.round(l.percent)}% used`}</Text>
156 <Text dimColor>{`${passed === null ? '' : ` · ${Math.round(passed * 100)}% of time`}`}</Text>
157 {flag.ahead !== null && flag.level !== undefined && (
158 <Text color={flag.level}>{` · ${flag.ahead} pts ahead of the clock`}</Text>
159 )}
160 <Text dimColor>{`${left === null ? '' : ` · resets in ${duration(left)}`}`}</Text>
161 </Text>
162 )
163 })}
164 </Box>
165 )
166 })
167}
168types/index.d.ts 21 lines1export type Limit = {
2 /** `five_hour`, `seven_day`, a gateway's `spend_limit`, or whatever else the engine reports. */
3 kind: string
4 /** 0 to 100, past it on an exceeded spend limit. */
5 percent: number
6 /** When the window resets, in epoch milliseconds; null when the engine gave none. */
7 resetsAt: number | null
8}
9
10export type Snapshot = {
11 limits: Limit[]
12 /** What the session has cost so far, in US dollars; null where there is no ledger. */
13 costUsd: number | null
14}
15
16declare module 'claude-code' {
17 interface PluginState {
18 'usage-bar': { snapshot: Snapshot | null; isOn: boolean }
19 }
20}
21