Band above prompt: ctx %, prompt-cache countdown and hit rate, 5h/7d quota used

Band above the prompt: ctx %, cache countdown (1h TTL) and hit rate, 5h/7d quota used, colored by level (quota green <50%, yellow <80%, red after; cache green >5m, yellow ≤5m, red cold; ctx same, and from 80% suggests /compact or a handoff)
claude plugin marketplace add Hsiang-LinC/usage-band
claude plugin install usage@usage-band
Private repo: needs gh auth login (or any git credential for github.com) on the machine.
Test: claude plugin test .
The band uses a Wada-inspired green / ochre / vermilion palette, with light/dark text colours and no background fill, so the terminal background shows through. Labels keep the terminal's normal monospace font; percentages and countdowns are bold. Separators use a neutral colour. The working star cycles ochre → blue-grey → vermilion: a shape changes every 200ms, and each colour holds for 1.2s, without opacity blinking.
Built-in light/dark variants follow Claude Code's selected theme. For auto and custom themes, this version follows macOS system appearance, checked at most once every 5 seconds; it does not infer a custom theme's background. Automatic appearance detection currently requires macOS. Use an explicit built-in light/dark theme on other hosts.
hooks/register.tsx 197 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3import { formatSegments, type Level, type LineInput } from './format'
4
5type CacheUsage = NonNullable<NonNullable<LineInput['cache']>['usage']>
6// newest main-thread request whose response reported usage
7let answered: { sentAt: number; usage: CacheUsage } | undefined
8// main-thread requests in flight, by identity (two may share a millisecond);
9// the icon grows only while this is non-empty
10const pending = new Set<{ sentAt: number }>()
11let contextPercent: number | undefined
12let rateLimits: LineInput['rateLimits'] = []
13let tick: Timer | undefined
14let poll: Timer | undefined
15let frame = 0
16let anim: Timer | undefined
17
18const POLL_MS = 5_000
19const ANIM_MS = 200
20const STARS = ['✶', '✴', '✷', '✦', '✧', '✦']
21// One full shape cycle (1.2s) per colour; no opacity blinking.
22const FRAME_COUNT = STARS.length * 3
23const PALETTES = {
24 light: {
25 neutral: '#61675F',
26 colors: { ok: '#286044', warn: '#755812', bad: '#983E32', none: '#61675F' },
27 stars: ['#755812', '#416579', '#983E32'],
28 },
29 dark: {
30 neutral: '#A2A99F',
31 colors: { ok: '#82B58B', warn: '#C5A35B', bad: '#DC9180', none: '#A2A99F' },
32 stars: ['#C5A35B', '#8BAABD', '#DC9180'],
33 },
34} satisfies Record<'light' | 'dark', {
35 neutral: string; colors: Record<Level, string>; stars: string[]
36}>
37
38// The mod API exposes the selected theme, not auto's resolved appearance.
39// On macOS, auto/custom themes follow system appearance, cached for 5s.
40let systemAppearance: Promise<'light' | 'dark'> | undefined
41let appearanceExpires = 0
42async function palette($: EngineInterface) {
43 const theme = (await $.config.list()).find(row => row.key === 'theme')?.value
44 if (typeof theme !== 'string') throw new Error('usage-band: missing theme config')
45 if (/^light(?:-|$)/.test(theme)) return PALETTES.light
46 if (/^dark(?:-|$)/.test(theme)) return PALETTES.dark
47 if (theme !== 'auto' && !theme.startsWith('custom:')) {
48 throw new Error('usage-band: unsupported theme selection')
49 }
50 const now = await $.clock.now()
51 if (!systemAppearance || now >= appearanceExpires) {
52 appearanceExpires = now + POLL_MS
53 systemAppearance = $.process.run(
54 ['/usr/bin/defaults', 'read', '-g', 'AppleInterfaceStyle'],
55 { timeoutMs: 1000 },
56 ).then(result => {
57 if (result.exitCode === 0 && result.stdout.trim() === 'Dark') return 'dark'
58 if (result.exitCode === 1 && /AppleInterfaceStyle.*does not exist/.test(result.stderr)) return 'light'
59 throw new Error('usage-band: cannot read macOS system appearance')
60 })
61 }
62 return PALETTES[await systemAppearance]
63}
64
65const redraw = ($: EngineInterface) => $.ui.invalidate('ui.render')
66
67// The countdown runs from the newest request sent, answered or still in
68// flight; the hit rate is the newest answered one's.
69function cacheState(): LineInput['cache'] {
70 let sentAt = answered?.sentAt
71 for (const request of pending) {
72 if (sentAt === undefined || request.sentAt > sentAt) sentAt = request.sentAt
73 }
74 return sentAt === undefined ? undefined : { sentAt, usage: answered?.usage }
75}
76
77// A minute ticker phased on the countdown's start, so the band redraws
78// exactly when a whole minute crosses.
79function phaseTicker($: EngineInterface, now: number) {
80 tick?.cancel()
81 tick = undefined
82 const cache = cacheState()
83 if (cache === undefined) return
84 tick = $.clock.after(60_000 - ((now - cache.sentAt) % 60_000), () => {
85 redraw($)
86 tick = $.clock.every(60_000, () => redraw($))
87 })
88}
89
90async function refreshUsage($: EngineInterface) {
91 const u = await $.session.usage()
92 contextPercent = u.context.percent
93 rateLimits = u.rateLimits
94 redraw($)
95}
96
97export const register: Register = on => {
98 on('session.start', async ($, e, next) => {
99 await refreshUsage($)
100 // keeps 5h/7d fresh in idle sessions, where no measure event fires
101 poll?.cancel()
102 poll = $.clock.every(POLL_MS, () => refreshUsage($))
103 return next(e)
104 })
105
106 on('session.measure', ($, e, next) => {
107 contextPercent = e.context.percent
108 rateLimits = e.rateLimits
109 redraw($)
110 return next(e)
111 })
112
113 // per request, not per turn: a turn's usage sums every request of its tool
114 // loop, and only the latest request says how warm the cache is now
115 on('turn.step', async function* ($, e, next) {
116 // sub-agents send other prefixes; they neither read nor refresh this cache
117 if (e.agentId !== undefined) return yield* next(e)
118 const request = { sentAt: await $.clock.now() }
119 // the TTL restarts as the request is sent, so count down from now at once,
120 // keeping the last answered hit rate until this response reports its own
121 pending.add(request)
122 phaseTicker($, request.sentAt)
123 if (pending.size === 1) {
124 frame = 0
125 anim = $.clock.every(ANIM_MS, () => {
126 frame = (frame + 1) % FRAME_COUNT
127 redraw($)
128 })
129 }
130 redraw($)
131 try {
132 const r = yield* next(e)
133 if (r.usage && (answered === undefined || request.sentAt >= answered.sentAt)) {
134 answered = {
135 sentAt: request.sentAt,
136 usage: {
137 input: r.usage.input_tokens,
138 cacheRead: r.usage.cache_read_input_tokens,
139 cacheWrite: r.usage.cache_creation_input_tokens,
140 },
141 }
142 }
143 return r
144 } finally {
145 // a request that failed or was cut off without usage confirms nothing
146 // about the cache: dropping it rolls the countdown back
147 pending.delete(request)
148 if (pending.size === 0) {
149 anim?.cancel()
150 anim = undefined
151 frame = 0
152 }
153 phaseTicker($, await $.clock.now())
154 redraw($)
155 }
156 })
157
158 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
159 if (e.props.hasSurvey) return next(e)
160 const segments = formatSegments({
161 contextPercent,
162 rateLimits,
163 cache: cacheState(),
164 now: await $.clock.now(),
165 })
166 if (segments.length === 0) return next(e)
167
168 const { Box, Text } = $.ui.resolve(e)
169 const theme = await palette($)
170 return (
171 <Box paddingX={1}>
172 {/* the frames' glyphs differ in width where the font falls back (desktop), so a fixed cell keeps the text after still */}
173 <Box width={2} flexShrink={0}>
174 <Text color={theme.stars[Math.floor(frame / STARS.length)]}>{STARS[frame % STARS.length]}</Text>
175 </Box>
176 {/* one inline run: the desktop draws Box as a flex row and trims the
177 whitespace at each flex item's edges, which would eat the separators' spaces */}
178 <Text>
179 {segments.map((s, i) => (
180 <Text key={String(i)}>
181 {i > 0 ? (
182 <Text color={theme.neutral}>{segments[i - 1].group === s.group ? ' · ' : ' │ '}</Text>
183 ) : null}
184 <Text color={theme.colors[s.level]}>
185 <Text>{s.text.slice(0, s.text.indexOf(' ') + 1)}</Text>
186 {s.text.slice(s.text.indexOf(' ') + 1).split(/(<?\d+(?:h\d+)?[hm%])/).filter(text => text !== '').map((text, j) => (
187 <Text key={String(j)} bold={/^(<?\d+(?:h\d+)?[hm%])$/.test(text)}>{text}</Text>
188 ))}
189 </Text>
190 </Text>
191 ))}
192 </Text>
193 </Box>
194 )
195 })
196}
197hooks/format.ts 85 lines1export const CACHE_TTL_MS = 60 * 60 * 1000
2
3export type Level = 'ok' | 'warn' | 'bad' | 'none'
4// session: ctx and cache; quota: 5h and 7d. The band separates the two groups.
5export type Segment = { text: string; level: Level; group: 'session' | 'quota' }
6
7export type LineInput = {
8 contextPercent?: number
9 rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[]
10 // Absent until a main-thread request has been sent.
11 cache?: {
12 // When the newest counted request was sent (ms): the TTL restarts when a
13 // request reads or writes the cache, not when its response ends.
14 sentAt: number
15 // Figures of the newest request that answered; absent while none has.
16 usage?: { input: number; cacheRead: number; cacheWrite: number }
17 }
18 now: number
19}
20
21// Green <50, yellow <80, red after; shared by ctx and quota.
22const byUsed = (pct: number): Level => (pct >= 80 ? 'bad' : pct >= 50 ? 'warn' : 'ok')
23
24// "2h13m" / "45m"; undefined once the window has already reset.
25function untilReset(resetsAt: string | undefined, now: number): string | undefined {
26 if (resetsAt === undefined) return undefined
27 const ms = Date.parse(resetsAt) - now
28 if (!(ms > 0)) return undefined
29 const mins = Math.max(1, Math.floor(ms / 60_000))
30 return mins >= 60 ? `${Math.floor(mins / 60)}h${String(mins % 60).padStart(2, '0')}m` : `${mins}m`
31}
32
33export function formatSegments(i: LineInput): Segment[] {
34 const parts: Segment[] = []
35
36 if (i.contextPercent !== undefined) {
37 const pct = Math.floor(i.contextPercent)
38 const level = byUsed(pct)
39 const hint = level === 'bad' ? ' → /compact or hand off' : ''
40 parts.push({ text: `ctx ${pct}%${hint}`, level, group: 'session' })
41 }
42
43 const c = i.cache
44 if (c === undefined) {
45 parts.push({ text: 'cache --', level: 'none', group: 'session' })
46 } else {
47 const u = c.usage
48 const total = u ? u.input + u.cacheRead + u.cacheWrite : 0
49 const remaining = c.sentAt + CACHE_TTL_MS - i.now
50 const hit = u && total > 0 ? ` · hit ${Math.floor((u.cacheRead / total) * 100)}%` : ''
51 if (remaining <= 0) {
52 parts.push({ text: 'cache cold', level: 'bad', group: 'session' })
53 } else {
54 const text =
55 remaining < 60_000 ? 'cache <1m' : `cache ${Math.floor(remaining / 60_000)}m`
56 const level: Level = remaining > 5 * 60_000 ? 'ok' : 'warn'
57 parts.push({ text: text + hit, level, group: 'session' })
58 }
59 }
60
61 for (const [kind, label] of [
62 ['five_hour', '5h'],
63 ['seven_day', '7d'],
64 ] as const) {
65 const w = i.rateLimits.find(r => r.kind === kind)
66 if (w) {
67 const pct = Math.floor(w.percentUsed)
68 const reset = kind === 'five_hour' ? untilReset(w.resetsAt, i.now) : undefined
69 parts.push({
70 text: `${label} ${pct}%${reset ? ` ↻ ${reset}` : ''}`,
71 level: byUsed(pct),
72 group: 'quota',
73 })
74 }
75 }
76
77 return parts
78}
79
80export function formatLine(i: LineInput): string {
81 return formatSegments(i)
82 .map(s => s.text)
83 .join(' · ')
84}
85