A two-row band above the prompt: context, prompt cache countdown and hit rate, rate limits with reset times, git

A Claude Code mod that draws a two-row band above the prompt:
opus-5-5 ctx █░░░░░░░ 14% cache 98% hit · 54m left
5h █████░░░ 64% resets 2h30m 7d █░░░░░░░ 14% resets 4d16h main ⇡2 S:1 U:1 ?:3 +3 -1
cold after /model or /compact, and says on resume whether the cache survived. A toast warns about five minutes before it lapses.S:), unstaged (U:) and untracked (?:) files, lines added and removed.Bars turn yellow at 70% and red at 90%.
macOS and Linux:
curl -fsSL https://raw.githubusercontent.com/dip497/claude-statusband/main/install.sh | sh
Windows (PowerShell):
irm https://raw.githubusercontent.com/dip497/claude-statusband/main/install.ps1 | iex
Or without a script, the same on every platform, inside Claude Code:
/plugin install statusband --marketplace dip497/claude-statusband
It installs for your user, so every new Claude Code session loads it, in any project. Sessions already open pick it up after a restart or /reload-plugins.
Claude Code does not auto-update third-party plugins by default, so the install script turns auto-update on for statusband (the same flag as /plugin → Marketplaces → statusband → Enable auto-update). New versions then arrive by themselves, shortly after a session starts.
To install without it, set STATUSBAND_AUTO_UPDATE=0 before running the script. Installing with /plugin install leaves it off too. Either way you can update by hand:
claude plugin update statusband@statusband
An update applies to the next session, or to a running one after /reload-plugins.
statusband is a band above the prompt; it does not replace a custom status line (ccstatusline, a statusline.sh, ...) set as statusLine in ~/.claude/settings.json, so both would show. The installer tells you when it finds one. To remove it:
curl -fsSL https://raw.githubusercontent.com/dip497/claude-statusband/main/remove-statusline.sh | sh
irm https://raw.githubusercontent.com/dip497/claude-statusband/main/remove-statusline.ps1 | iex
It deletes only the statusLine entry and keeps the previous file beside it as settings.json.bak-<timestamp>. On macOS and Linux it needs jq, python3 or node.
claude plugin uninstall statusband@statusband
claude plugin marketplace remove statusband
Claude Code does not tell a mod when the cache expires, so the band works it out: the lifetime restarts at every response of the main conversation, and its length follows the documented rules. One hour on a subscription inside its plan's usage, five minutes otherwise, unless you set it yourself: FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL, the promptCacheTtl setting and ENABLE_PROMPT_CACHING_1H are read, in that order.
It is still an estimate. Anything else that shortens the lifetime on Anthropic's side is not seen, and the hit rate is the last turn's, not the session's.
Git counts refresh after each turn and after any tool that can change files. A change made outside the session shows at the next of those.
claude --plugin-dir . # load it from this folder
claude plugin validate .
claude plugin test .
sh tests/scripts-test.sh # the install and removal scripts
MIT
hooks/register.tsx 280 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMeasureInput, Timer } from 'claude-code'
3
4import type { View } from '../types'
5
6type Figures = Pick<SessionMeasureInput, 'context' | 'rateLimits'>
7
8const CACHE_WARN_MIN = 5
9const MINUTE = 60_000
10const LIMIT_LABEL: Record<string, string> = { five_hour: '5h', seven_day: '7d' }
11
12const view = atom({ plugin: 'statusband', key: 'view' } as const, {
13 now: 0,
14 model: '',
15 git: '',
16 context: null,
17 limits: [],
18 cacheTtlMin: 5,
19 cacheHit: null,
20 lastResponseAt: null,
21})
22const FILE_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'Bash'])
23const warned = new Set<string>()
24let ticker: Timer | undefined
25
26export const span = (ms: number): string => {
27 const m = Math.max(0, Math.round(ms / MINUTE))
28 if (m >= 1440) return `${Math.floor(m / 1440)}d${Math.floor((m % 1440) / 60)}h`
29 return m >= 60 ? `${Math.floor(m / 60)}h${m % 60}m` : `${m}m`
30}
31
32export const bar = (percent: number, cells = 8): string => {
33 const full = Math.min(cells, Math.max(0, Math.round((percent / 100) * cells)))
34 return '█'.repeat(full) + '░'.repeat(cells - full)
35}
36
37export const tone = (percent: number): string => (percent >= 90 ? 'error' : percent >= 70 ? 'warning' : 'success')
38
39// The main conversation caches for an hour on a subscription inside its plan's usage, five minutes otherwise.
40export const cacheTtlMin = (limits: readonly { percent: number }[]): number =>
41 limits.length > 0 && limits.every(l => l.percent < 100) ? 60 : 5
42
43export type TtlControls = { force5m?: string; envTtl?: string; settingTtl?: unknown; enable1h?: string }
44
45// What the person set wins over the default, in the order Claude Code reads its controls;
46// an hour asked for is not given while the plan is on usage credits.
47export const resolveTtlMin = (c: TtlControls, limits: readonly { percent: number }[]): number => {
48 const asked =
49 c.force5m === '1'
50 ? '5m'
51 : c.envTtl === '5m' || c.envTtl === '1h'
52 ? c.envTtl
53 : c.settingTtl === '5m' || c.settingTtl === '1h'
54 ? c.settingTtl
55 : c.enable1h === '1'
56 ? '1h'
57 : undefined
58 if (asked === undefined) return cacheTtlMin(limits)
59 return asked === '1h' && limits.every(l => l.percent < 100) ? 60 : 5
60}
61
62export const shortModel = (id: string): string => id.replace(/^claude-/, '').replace(/-\d{8}$/, '')
63
64export const cacheLeft = (v: View): number | null =>
65 v.lastResponseAt === null ? null : v.cacheTtlMin * MINUTE - (v.now - v.lastResponseAt)
66
67export const gitSummary = (status: string, shortstat: string): string => {
68 const lines = status.split('\n')
69 const head = lines.find(l => l.startsWith('# branch.head '))?.slice(14) ?? ''
70 const ab = /^# branch\.ab \+(\d+) -(\d+)/m.exec(status)
71 const changed = lines.filter(l => l.startsWith('1 ') || l.startsWith('2 '))
72 const count = (n: number, label: string) => (n > 0 ? `${label}${n}` : '')
73 return [
74 head,
75 count(Number(ab?.[1] ?? 0), '⇡'),
76 count(Number(ab?.[2] ?? 0), '⇣'),
77 count(changed.filter(l => l[2] !== '.').length, 'S:'),
78 count(changed.filter(l => l[3] !== '.').length, 'U:'),
79 count(lines.filter(l => l.startsWith('? ')).length, '?:'),
80 count(Number(/(\d+) insertion/.exec(shortstat)?.[1] ?? 0), '+'),
81 count(Number(/(\d+) deletion/.exec(shortstat)?.[1] ?? 0), '-'),
82 ]
83 .filter(Boolean)
84 .join(' ')
85}
86
87// A repository's own config can name programs git runs for it; none of them may run for a status read.
88const GIT = ['git', '-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false', '-c', 'diff.external=']
89
90// ponytail: re-read on a measure and after a tool that can change files, not watched; an edit made outside the session shows at the next of those
91async function readGit($: EngineInterface): Promise<string> {
92 const status = await $.process.run([...GIT, 'status', '--porcelain=v2', '--branch'])
93 if (status.exitCode !== 0) return ''
94 const stat = await $.process.run([...GIT, 'diff', '--no-ext-diff', '--no-textconv', '--shortstat', 'HEAD'])
95 return gitSummary(status.stdout, stat.stdout)
96}
97
98// Toasts once per crossing; dropping back under the line re-arms it.
99function warnOnce($: EngineInterface, key: string, isOver: boolean, text: string): void {
100 if (!isOver) warned.delete(key)
101 else if (!warned.has(key)) {
102 warned.add(key)
103 $.ui.toast(text, { timeoutMs: 8000 })
104 }
105}
106
107async function tick($: EngineInterface): Promise<void> {
108 const now = await $.clock.now()
109 const v = await update($, view, old => ({ ...old, now }))
110 const left = cacheLeft(v)
111 warnOnce(
112 $,
113 'cache',
114 left !== null && left > 0 && left <= CACHE_WARN_MIN * MINUTE,
115 `Prompt cache expires in about ${CACHE_WARN_MIN}m; the next prompt after that re-caches the whole context`,
116 )
117}
118
119async function measure($: EngineInterface, figures: Figures): Promise<void> {
120 const now = await $.clock.now()
121 const model = shortModel(await $.session.model())
122 const git = await readGit($)
123 // a window with no response yet has no reading; the local estimate, when asked for, stands in
124 const fill = figures.context.percent ?? figures.context.breakdown?.percentage
125 const context = fill === undefined ? null : Math.round(fill)
126 const limits = figures.rateLimits.map(r => ({
127 label: LIMIT_LABEL[r.kind] ?? r.kind,
128 percent: Math.round(r.percentUsed),
129 resetsAt: r.resetsAt === undefined ? null : Date.parse(r.resetsAt),
130 }))
131 const controls: TtlControls = {
132 force5m: await $.env.get('FORCE_PROMPT_CACHING_5M'),
133 envTtl: await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'),
134 settingTtl: ((await $.settings.read()) as Record<string, unknown>).promptCacheTtl,
135 enable1h: await $.env.get('ENABLE_PROMPT_CACHING_1H'),
136 }
137 const isSet = Object.values(controls).some(v => v !== undefined)
138 // with nothing set and no reading yet, nothing is known of the plan: the lifetime already known stands
139 await update($, view, old => ({
140 ...old,
141 now,
142 model,
143 git,
144 context,
145 limits,
146 cacheTtlMin: isSet || limits.length > 0 ? resolveTtlMin(controls, limits) : old.cacheTtlMin,
147 }))
148}
149
150async function refreshGit($: EngineInterface): Promise<void> {
151 const git = await readGit($)
152 await update($, view, old => (old.git === git ? old : { ...old, git }))
153}
154
155// Reads the figures now, for a moment the engine raises no measurement of its own.
156async function remeasure($: EngineInterface): Promise<void> {
157 await measure($, await $.session.usage({ breakdown: 'summary' }))
158}
159
160// A cache nothing can read any more: 0 is long enough ago to be past any lifetime.
161async function chillCache($: EngineInterface): Promise<void> {
162 const now = await $.clock.now()
163 await update($, view, old => ({ ...old, now, lastResponseAt: 0, cacheHit: null }))
164}
165
166async function touchCache($: EngineInterface, cacheHit?: number | null): Promise<void> {
167 const now = await $.clock.now()
168 await update($, view, old => ({ ...old, now, lastResponseAt: now, cacheHit: cacheHit === undefined ? old.cacheHit : cacheHit }))
169}
170
171export const register: Register = on => {
172 on('session.start', async ($, e, next) => {
173 // drops the line an earlier version of this mod pinned under the prompt
174 $.ui.status(undefined)
175 await remeasure($)
176 // keeps the countdowns moving between turns; a session that starts over keeps one timer
177 ticker?.cancel()
178 ticker = $.clock.every(MINUTE, () => void tick($))
179 return next(e)
180 })
181
182 // a resumed session says how long it sat idle and whether its cache outlived that
183 on('classic.SessionStart', async ($, e, next) => {
184 const idleMs = (e.seconds_since_last_response ?? 0) * 1000
185 // a cleared conversation has no turn left for the hit rate to describe
186 if (e.source === 'clear') {
187 await update($, view, old => ({ ...old, cacheHit: null }))
188 await remeasure($)
189 } else if (e.prompt_cache_likely_expired === true) await chillCache($)
190 else if (e.seconds_since_last_response !== undefined) {
191 const now = await $.clock.now()
192 await update($, view, old => ({
193 ...old,
194 now,
195 lastResponseAt: now - idleMs,
196 cacheHit: null,
197 cacheTtlMin: idleMs > 5 * MINUTE ? 60 : old.cacheTtlMin,
198 }))
199 }
200 return next(e)
201 })
202
203 // each model has a cache of its own, and a compacted history shares no prefix with the old one
204 // Neither is followed by a measurement until the next turn, and both move the context figure.
205 on('classic.PostModelSwitch', async ($, e, next) => {
206 await chillCache($)
207 await remeasure($)
208 return next(e)
209 })
210
211 on('classic.PostCompact', async ($, e, next) => {
212 await chillCache($)
213 await remeasure($)
214 return next(e)
215 })
216
217 on('session.measure', async ($, e, next) => {
218 await measure($, e)
219 return next(e)
220 })
221
222 // a tool call follows a response, so the cache was just touched; a subagent's requests touch only its own
223 on('tool.call', async ($, e, next) => {
224 if (e.agentId === undefined) await touchCache($)
225 const ran = await next(e)
226 if (FILE_TOOLS.has(e.tool)) await refreshGit($)
227 return ran
228 })
229
230 on('turn.complete', async ($, e, next) => {
231 if (e.agentId === undefined && e.usage !== undefined) {
232 const u = e.usage
233 const input = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
234 await touchCache($, input === 0 ? null : Math.round((u.cache_read_input_tokens / input) * 100))
235 }
236 return next(e)
237 })
238
239 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
240 const v = await read($, view)
241 if (e.props.hasSurvey || v.model === '') return next(e)
242
243 const { Box, Text } = $.ui.resolve(e)
244 const left = cacheLeft(v)
245 const cacheTone = left === null || left <= 0 ? 'error' : left <= CACHE_WARN_MIN * MINUTE ? 'warning' : 'success'
246
247 return (
248 <Box flexDirection="column" paddingLeft={2}>
249 <Box gap={2}>
250 <Text bold color="cyan">{v.model}</Text>
251 {v.context !== null && (
252 <Text>
253 <Text dimColor>ctx </Text>
254 <Text color={tone(v.context)}>{bar(v.context)} {v.context}%</Text>
255 </Text>
256 )}
257 {left !== null && (
258 <Text>
259 <Text dimColor>cache </Text>
260 <Text color={cacheTone}>
261 {left <= 0 ? 'cold' : `${v.cacheHit === null ? '' : `${v.cacheHit}% hit · `}${span(left)} left`}
262 </Text>
263 </Text>
264 )}
265 </Box>
266 <Box gap={2}>
267 {v.limits.map(l => (
268 <Text key={l.label}>
269 <Text dimColor>{l.label} </Text>
270 <Text color={tone(l.percent)}>{bar(l.percent)} {l.percent}%</Text>
271 {l.resetsAt !== null && l.resetsAt > v.now && <Text dimColor> resets {span(l.resetsAt - v.now)}</Text>}
272 </Text>
273 ))}
274 {v.git !== '' && <Text color="magenta" wrap="truncate-end">{v.git}</Text>}
275 </Box>
276 </Box>
277 )
278 })
279}
280types/index.d.ts 22 lines1export type Limit = { label: string; percent: number; resetsAt: number | null }
2
3export type View = {
4 now: number
5 model: string
6 git: string
7 context: number | null
8 limits: Limit[]
9 // prompt cache lifetime in minutes, derived from the plan's usage
10 cacheTtlMin: number
11 // share of the last turn's input the prompt cache served, 0 to 100
12 cacheHit: number | null
13 // when the cache was last touched; every response restarts its lifetime
14 lastResponseAt: number | null
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 'statusband': { view: View }
20 }
21}
22