Always-visible plan usage limits in the prompt footer

A small Claude Code mod, made for the desktop app's Code tab (it also works in the terminal).
| Mod | What it shows |
|---|---|
usage-band | Your plan limits in the prompt footer: 5h ◔ 23% ↻2h13 │ 7j ◕ 91% ↻3j (orange from 75 %, red from 90 %). |
Run this in a terminal claude session (the desktop Code tab does not accept /plugin, but mods installed at the user scope load there too):
/plugin install usage-band --marketplace Lingfar/claude-code-mods
Answer y to add the marketplace, then pick the user scope. New sessions, desktop ones included, load the mod.
To update later:
/plugin marketplace update claude-code-mods
To edit a mod and see the change, load it from its folder instead of installing it.
git clone git@github.com:Lingfar/claude-code-mods.git ~/.claude/mods
Then list the mod folders in ~/.claude/settings.json (: separates paths on macOS and Linux, ; on Windows):
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/usage-band"
}
}
For a single terminal session, claude --plugin-dir ~/.claude/mods/usage-band does the same.
Before committing a change:
claude plugin validate usage-band
claude plugin test usage-band
hooks/register.tsx 82 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { UsageSnapshot } from '../types'
5
6import { gaugesOf, ringGlyph, snapshotOf } from './format'
7
8const usage = atom({ plugin: 'usage-band', key: 'usage' } as const, null)
9const now = atom({ plugin: 'usage-band', key: 'now' } as const, 0)
10
11const PERCENT_COLORS = { normal: undefined, warning: 'warning', error: 'error' } as const
12const RING_COLORS = { normal: 'success', warning: 'warning', error: 'error' } as const
13const SEPARATOR = ' │ '
14
15async function refreshNow($: EngineInterface): Promise<void> {
16 const nowMs = await $.clock.now()
17
18 await update($, now, () => nowMs)
19}
20
21async function storeUsage($: EngineInterface, latest: UsageSnapshot | null): Promise<void> {
22 if (latest === null || JSON.stringify(await read($, usage)) === JSON.stringify(latest)) {
23 return
24 }
25
26 await update($, usage, () => latest)
27}
28
29export const register: Register = on => {
30 on('session.start', async ($, e, next) => {
31 await storeUsage($, snapshotOf(await $.session.usage()))
32 await refreshNow($)
33
34 $.clock.every(10_000, () => {
35 $.session
36 .usage()
37 .then(latest => storeUsage($, snapshotOf(latest)))
38 .catch(() => undefined)
39 })
40 $.clock.every(60_000, () => {
41 refreshNow($).catch(() => undefined)
42 })
43
44 return next(e)
45 })
46
47 on('session.measure', async ($, e, next) => {
48 await storeUsage($, snapshotOf(e))
49 await refreshNow($)
50
51 return next(e)
52 })
53
54 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
55 const snapshot = await read($, usage)
56
57 if (snapshot === null) {
58 return next(e)
59 }
60
61 const gauges = gaugesOf(snapshot, await read($, now))
62 const { Box, Text } = $.ui.resolve(e)
63
64 const segments = gauges.map(gauge => [
65 <Text>{`${gauge.label} `}</Text>,
66 <Text color={RING_COLORS[gauge.level]}>{`${ringGlyph(gauge.percent)} `}</Text>,
67 <Text bold color={PERCENT_COLORS[gauge.level]}>{`${gauge.percent}%`}</Text>,
68 ...(gauge.detail === undefined ? [] : [<Text dimColor>{` ${gauge.detail}`}</Text>]),
69 ])
70
71 if (e.props.modes.length > 0) {
72 segments.push([<Text dimColor>{e.props.modes.join(' & ')}</Text>])
73 }
74
75 return (
76 <Box key="usage" flexDirection="row" flexShrink={0}>
77 <Text wrap="end">{segments.flatMap((segment, index) => (index === 0 ? segment : [<Text dimColor>{SEPARATOR}</Text>, ...segment]))}</Text>
78 </Box>
79 )
80 })
81}
82hooks/format.ts 72 lines1import type { SessionRateLimit } from 'claude-code'
2
3import type { UsageSnapshot } from '../types'
4
5const LIMIT_LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7j', spend_limit: 'Budget' }
6
7const RING_GLYPHS = ['○', '◔', '◑', '◕', '●']
8
9export type Level = 'normal' | 'warning' | 'error'
10
11export type Gauge = { label: string; percent: number; detail?: string; level: Level }
12
13export function snapshotOf(measure: { rateLimits: SessionRateLimit[] }): UsageSnapshot | null {
14 if (measure.rateLimits.length === 0) {
15 return null
16 }
17
18 return { limits: measure.rateLimits.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt })) }
19}
20
21export function formatCountdown(resetsAt: string | undefined, nowMs: number): string | undefined {
22 const resetMs = resetsAt === undefined ? NaN : Date.parse(resetsAt)
23
24 if (Number.isNaN(resetMs) || nowMs === 0) {
25 return undefined
26 }
27
28 const minutes = Math.max(0, Math.round((resetMs - nowMs) / 60_000))
29 const days = Math.floor(minutes / 1440)
30 const hours = Math.floor((minutes % 1440) / 60)
31 const mins = minutes % 60
32
33 if (days > 0) {
34 return `${days}j`
35 }
36
37 if (hours > 0) {
38 return `${hours}h${String(mins).padStart(2, '0')}`
39 }
40
41 return `${mins}min`
42}
43
44function levelOf(percent: number): Level {
45 if (percent >= 90) {
46 return 'error'
47 }
48
49 return percent >= 75 ? 'warning' : 'normal'
50}
51
52export function gaugesOf(usage: UsageSnapshot, nowMs: number): Gauge[] {
53 return usage.limits.map(limit => {
54 const countdown = formatCountdown(limit.resetsAt, nowMs)
55
56 return {
57 label: LIMIT_LABELS[limit.kind] ?? limit.kind,
58 percent: Math.round(limit.percentUsed),
59 detail: countdown === undefined ? undefined : `↻${countdown}`,
60 level: levelOf(limit.percentUsed),
61 }
62 })
63}
64
65export function ringGlyph(percent: number): string {
66 if (percent <= 0 || percent >= 100) {
67 return percent <= 0 ? '○' : '●'
68 }
69
70 return RING_GLYPHS[Math.min(3, Math.max(1, Math.round(percent / 25)))] ?? '◑'
71}
72types/index.d.ts 10 lines1export type UsageLimit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type UsageSnapshot = { limits: UsageLimit[] }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'usage-band': { usage: UsageSnapshot | null; now: number }
8 }
9}
10