Rate-limit, context, model, effort and advisor readout above the prompt, with toasts when a window runs high

A Claude Code mod that keeps your usage in view, right-aligned in the band above the prompt:
5h 42% (2h 30m) | wk 12% | ctx 85% | Opus 5.5 | high | Advisor: off
off when none is set)A figure at or past the warning threshold turns bold red, and a toast fires once when a figure crosses each threshold. It fires again only after the figure drops back below that threshold, for example after a window resets or a compaction.
/plugin install usage-line --marketplace wolves/usage-line
Answer y to add the marketplace, then pick a scope. The user scope loads it in every session.
Set these in /config:
| Option | Default | What it does |
|---|---|---|
| Warn at (%) | 80 | Turns a figure red and sends the first toast |
| Critical at (%) | 95 | Sends a second toast |
The band redraws only when something it shows changes:
effortLevel and advisorModel in settings, checked every 10 seconds and on each prompt and turnclaude --plugin-dir . # run a session with the mod loaded and hot-reloading
claude plugin validate . # check the manifest and hooks module
claude plugin test . # run hooks/*.test.ts
When the mod loads from this folder, Claude Code writes the API's type declarations to .claude-plugin/types/ (gitignored). After that, tsc -p . type-checks it.
Two renderer details to know about when changing the colors:
ansi256(n) (0–15 is the terminal's own palette), #hex, rgb() or a theme key. A bare name like "yellow" passes validation but is drawn uncolored.dimColor draws the theme's grey and replaces any color set beside it. It is not ANSI dim.MIT
hooks/register.tsx 217 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit, SessionUsage } from 'claude-code'
3
4import type { Usage, Window } from '../types'
5
6// The figures from session.measure, the model and effort the last main-thread
7// request carried, the effort and advisor as /effort and /advisor keep them in
8// settings, the 5h window's countdown as drawn, and the alerts already shown.
9const usage = atom({ plugin: 'usage-line', key: 'usage' } as const, null)
10const request = atom({ plugin: 'usage-line', key: 'request' } as const, {})
11const settings = atom({ plugin: 'usage-line', key: 'settings' } as const, {})
12const countdown = atom({ plugin: 'usage-line', key: 'countdown' } as const, '')
13const alerted = atom({ plugin: 'usage-line', key: 'alerted' } as const, [])
14
15const LABELS = { fiveHour: '5h', week: 'wk', context: 'ctx' } as const
16
17// The terminal's own palette, as the old status line's ANSI codes drew it.
18// A bare name like 'yellow' validates but is not drawn, and `dimColor` is the
19// theme's grey rather than ANSI dim, replacing any color it is set beside.
20const YELLOW = 'ansi256(3)'
21const WHITE = 'ansi256(7)'
22const RED = 'ansi256(1)'
23
24// Effort warms as it rises; low is left in the theme's grey. A level not
25// listed here (a numeric budget) is drawn white.
26const EFFORT_COLORS: Readonly<Record<string, string | undefined>> = {
27 low: undefined,
28 medium: WHITE,
29 high: 'ansi256(6)',
30 xhigh: 'ansi256(4)',
31 max: 'ansi256(5)',
32}
33
34const toWindow = (limit: SessionRateLimit | undefined): Window | undefined =>
35 limit && {
36 percent: limit.percentUsed,
37 resetsAt: limit.resetsAt ? Date.parse(limit.resetsAt) : undefined,
38 }
39
40const toUsage = (e: Pick<SessionUsage, 'context' | 'rateLimits'>): Usage => ({
41 fiveHour: toWindow(e.rateLimits.find(l => l.kind === 'five_hour')),
42 week: toWindow(e.rateLimits.find(l => l.kind === 'seven_day')),
43 context: e.context.percent,
44})
45
46const percentOf = (figures: Usage, key: keyof typeof LABELS) =>
47 key === 'context' ? figures.context : figures[key]?.percent
48
49const asText = (value: unknown): string | undefined =>
50 typeof value === 'string' && value !== '' ? value : undefined
51
52// "claude-opus-5-5[1m]" -> "Opus 5.5 [1m]"; anything else passes through.
53export const prettyModel = (id: string): string => {
54 const match = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(\[.+\])?$/.exec(id)
55 if (!match) return id
56 const [, family = '', major, minor, suffix] = match
57 const name = family.charAt(0).toUpperCase() + family.slice(1)
58 return `${name} ${major}${minor ? `.${minor}` : ''}${suffix ? ` ${suffix}` : ''}`
59}
60
61export const formatRemaining = (ms: number): string => {
62 const minutes = Math.max(0, Math.floor(ms / 60_000))
63 return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
64}
65
66// Values a timer or every request writes again are compared first, so an
67// unchanged one redraws nothing.
68const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
69
70// Toasts each figure once per threshold it crosses (once in all when a jump
71// crosses several), and forgets a threshold when the figure drops back under
72// it (a window reset, a compaction) so it can fire again.
73async function alert($: EngineInterface, figures: Usage, thresholds: readonly number[]) {
74 const fired = new Set(await read($, alerted))
75
76 for (const key of Object.keys(LABELS) as (keyof typeof LABELS)[]) {
77 const value = percentOf(figures, key) ?? 0
78 const crossed = thresholds.filter(t => value >= t && !fired.has(`${key}:${t}`))
79
80 for (const t of thresholds) {
81 if (value < t) fired.delete(`${key}:${t}`)
82 }
83 for (const t of crossed) fired.add(`${key}:${t}`)
84
85 if (crossed.length > 0) $.ui.toast(`${LABELS[key]} usage at ${Math.round(value)}%`)
86 }
87
88 await update($, alerted, () => [...fired])
89}
90
91// Rereads the settings /effort and /advisor write, and moves the countdown
92// on; the band redraws only when one of them reads differently.
93async function refresh($: EngineInterface) {
94 const [stored, figures, time, shown, left] = await Promise.all([
95 $.settings.read(),
96 read($, usage),
97 $.clock.now(),
98 read($, settings),
99 read($, countdown),
100 ])
101 const chosen = { effort: asText(stored.effortLevel), advisor: asText(stored.advisorModel) }
102 const resetsAt = figures?.fiveHour?.resetsAt
103 const remaining = resetsAt ? formatRemaining(resetsAt - time) : ''
104
105 if (!same(shown, chosen)) await update($, settings, () => chosen)
106 if (remaining !== left) await update($, countdown, () => remaining)
107}
108
109export const register: Register = (on, options) => {
110 const warnAt = Number(options.warnAt)
111 const thresholds = [warnAt, Number(options.criticalAt)]
112
113 on('session.start', async ($, e, next) => {
114 const [measured, model] = await Promise.all([$.session.usage(), $.session.model()])
115 const figures = toUsage(measured)
116
117 await update($, usage, () => figures)
118 await update($, request, r => ({ ...r, model: r.model ?? model }))
119 await refresh($)
120 await alert($, figures, thresholds)
121
122 $.clock.every(10_000, () => refresh($))
123
124 return next(e)
125 })
126
127 on('session.measure', async ($, e, next) => {
128 const figures = toUsage(e)
129
130 await update($, usage, () => figures)
131 await refresh($)
132 await alert($, figures, thresholds)
133
134 return next(e)
135 })
136
137 on('turn.step', async function* ($, e, next) {
138 if (e.agentId === undefined) {
139 const effort = e.effort === undefined ? undefined : String(e.effort)
140 const carried = { model: e.model, effort }
141 if (!same(await read($, request), carried)) await update($, request, () => carried)
142 }
143
144 return yield* next(e)
145 })
146
147 on('prompt.submit', async ($, e, next) => {
148 await refresh($)
149 return next(e)
150 })
151
152 on('turn.complete', async ($, e, next) => {
153 await refresh($)
154 return next(e)
155 })
156
157 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
158 const [figures, last, chosen, remaining] = await Promise.all([
159 read($, usage),
160 read($, request),
161 read($, settings),
162 read($, countdown),
163 ])
164
165 if (e.props.hasSurvey || (figures === null && last.model === undefined)) {
166 return next(e)
167 }
168
169 // `muted` follows `text` in the theme's grey: a value that is only a default.
170 const parts: { key: string; text: string; muted?: string; color?: string; bold?: boolean }[] = []
171
172 for (const key of Object.keys(LABELS) as (keyof typeof LABELS)[]) {
173 const value = figures ? percentOf(figures, key) : undefined
174 if (value === undefined) continue
175
176 const rounded = Math.round(value)
177 const suffix = key === 'fiveHour' && remaining ? ` (${remaining})` : ''
178 const isHot = rounded >= warnAt
179
180 parts.push({
181 key,
182 text: `${LABELS[key]} ${rounded}%${suffix}`,
183 color: isHot ? RED : undefined,
184 bold: isHot,
185 })
186 }
187
188 const effort = chosen.effort ?? last.effort
189 if (last.model) parts.push({ key: 'model', text: prettyModel(last.model), color: YELLOW })
190 if (effort) {
191 const color = effort in EFFORT_COLORS ? EFFORT_COLORS[effort] : WHITE
192 parts.push({ key: 'effort', text: effort, color })
193 }
194 parts.push(
195 chosen.advisor
196 ? { key: 'advisor', text: `Advisor: ${prettyModel(chosen.advisor)}`, color: WHITE }
197 : { key: 'advisor', text: 'Advisor: ', muted: 'off', color: WHITE },
198 )
199
200 const { Box, Text } = $.ui.resolve(e)
201
202 return (
203 <Box key="usage-line" justifyContent="flex-end" flexGrow={1}>
204 {parts.map((part, i) => (
205 <Text key={part.key} wrap="truncate">
206 {i > 0 ? <Text dimColor> | </Text> : null}
207 <Text color={part.color} dimColor={part.color === undefined} bold={part.bold}>
208 {part.text}
209 </Text>
210 {part.muted ? <Text dimColor>{part.muted}</Text> : null}
211 </Text>
212 ))}
213 </Box>
214 )
215 })
216}
217types/index.d.ts 27 lines1export type Window = { percent: number; resetsAt?: number }
2
3export type Usage = {
4 fiveHour?: Window
5 week?: Window
6 context?: number
7}
8
9// What the last main-thread request carried.
10export type Request = { model?: string; effort?: string }
11
12// What /effort and /advisor keep in settings.
13export type Chosen = { effort?: string; advisor?: string }
14
15declare module 'claude-code' {
16 interface PluginState {
17 'usage-line': {
18 usage: Usage | null
19 request: Request
20 settings: Chosen
21 // The 5h window's time left as drawn ('2h 30m'); '' when it has none.
22 countdown: string
23 alerted: readonly string[]
24 }
25 }
26}
27