Two-line status under the prompt: model, effort, context bar, prompt cache countdown, 5h and 7d limits with reset times.

A Claude Code mod that draws a two-line usage status under the prompt:
Bars turn yellow and red as they fill. The context bar stretches so both lines are equally wide.
/plugin install usage-bars --marketplace Mariator-pro/claude-mod-statusline
Answer y to add the marketplace, then pick a scope. Needs a Claude Code version with mod support. If statusLine is set in ~/.claude/settings.json (on Windows %USERPROFILE%\.claude\settings.json), remove it, or both show.
Run from a working copy (reloads on save):
claude --plugin-dir <path-to-clone>
Or for every session, in ~/.claude/settings.json:
"env": { "CLAUDE_CODE_PLUGIN_DIRS": "/absolute/path/to/claude-mod-statusline" }
Check and test:
claude plugin validate .
claude plugin test .hooks/register.tsx 189 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { Limit, Stats } from '../types'
5import { HOUR_MS, MIN5_MS, rows, weekday } from './format'
6
7const stats = atom({ plugin: 'usage-bars', key: 'stats' } as const, {
8 model: '?',
9 effort: null,
10 tokens: null,
11 window: 200_000,
12 limits: [],
13 cacheAt: null,
14 cacheTtl: null,
15} as Stats, { shape: 'v2' }) // bump when Stats changes, so a reload drops the old value
16const now = atom({ plugin: 'usage-bars', key: 'now' } as const, 0)
17
18const TICK_MS = 60_000 // the line shows whole minutes
19
20const set = ($: EngineInterface, patch: Partial<Stats>) => update($, stats, s => ({ ...s, ...patch }))
21
22// The 7d reset as the host's local time, through `date` so the host's time zone applies.
23// BSD/macOS takes `-r <epoch>`, GNU `-d @<epoch>`, Windows has PowerShell; all print "4 16:00" (1 = Monday).
24// Failures are not cached, so the next measure retries.
25const labels = new Map<string, string>()
26async function resetLabel($: EngineInterface, iso: string): Promise<string | null> {
27 if (labels.has(iso)) return labels.get(iso)!
28 const secs = String(Math.floor(Date.parse(iso) / 1000))
29 const ps = `$d = [DateTimeOffset]::FromUnixTimeSeconds(${secs}).LocalDateTime; `
30 + `'{0} {1}' -f ((([int]$d.DayOfWeek) + 6) % 7 + 1), $d.ToString('HH:mm', [cultureinfo]::InvariantCulture)`
31 for (const cmd of [
32 ['date', '-r', secs, '+%u %H:%M'],
33 ['date', '-d', `@${secs}`, '+%u %H:%M'],
34 ['powershell', '-NoProfile', '-NonInteractive', '-Command', ps],
35 ]) {
36 try {
37 const { exitCode, stdout } = await $.process.run(cmd)
38 const label = exitCode === 0 ? weekday(stdout) : null
39 if (label) {
40 labels.set(iso, label)
41 return label
42 }
43 } catch {
44 // Not on this host (or no host commands on this surface); try the next.
45 }
46 }
47 return null
48}
49
50async function limits($: EngineInterface, raw: readonly SessionRateLimit[]): Promise<Limit[]> {
51 return Promise.all(raw.map(async l => ({
52 kind: l.kind,
53 percent: l.percentUsed,
54 resetsAt: l.resetsAt ? Date.parse(l.resetsAt) : null,
55 resetLabel: l.kind === 'seven_day' && l.resetsAt ? await resetLabel($, l.resetsAt) : null,
56 })))
57}
58
59type Entry = {
60 type?: string
61 isSidechain?: boolean
62 message?: { usage?: { cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number } } }
63}
64
65// The transcript's last lines: `tail` where the host has it, else the engine's read (files up to 4 MiB).
66async function tail($: EngineInterface, path: string, lines: number): Promise<string[]> {
67 try {
68 const { exitCode, stdout } = await $.process.run(['tail', '-n', String(lines), path])
69 if (exitCode === 0) return stdout.split('\n')
70 } catch {
71 // No `tail` on this host (Windows).
72 }
73 try {
74 return (await $.fs.read(path)).split('\n').slice(-lines)
75 } catch {
76 return []
77 }
78}
79
80// The engine reports the cache's TTL to a mod only on a model switch; the transcript's usage has it
81// per response: the last main response that wrote to the cache, 1h unless it wrote 5m only.
82// No write in view keeps the TTL already known.
83async function ttlFromTranscript($: EngineInterface, path: string): Promise<void> {
84 const writes = (await tail($, path, 300)).flatMap(line => {
85 try {
86 const e = JSON.parse(line) as Entry
87 const c = e.type === 'assistant' && !e.isSidechain ? e.message?.usage?.cache_creation : undefined
88 return c && (c.ephemeral_5m_input_tokens ?? 0) + (c.ephemeral_1h_input_tokens ?? 0) > 0 ? [c] : []
89 } catch {
90 return []
91 }
92 })
93 const w = writes.at(-1)
94 if (w) await set($, { cacheTtl: (w.ephemeral_1h_input_tokens ?? 0) > 0 ? HOUR_MS : MIN5_MS })
95}
96
97export const register: Register = on => {
98 on('session.start', async ($, e, next) => {
99 const result = await next(e)
100 const [model, settings, usage, t] = await Promise.all([
101 $.session.model(),
102 $.settings.read(),
103 $.session.usage(),
104 $.clock.now(),
105 ])
106 const effort = (settings as { effortLevel?: string }).effortLevel ?? null
107 await set($, {
108 model,
109 effort,
110 tokens: usage.context.tokens ?? null,
111 window: usage.context.window,
112 })
113 await update($, now, () => t)
114 // The 7d label spawns `date`; start does not wait for it, and a measure that landed meanwhile wins.
115 void limits($, usage.rateLimits).then(l => update($, stats, s => (s.limits.length ? s : { ...s, limits: l })))
116 // `-p` and SDK runs draw nothing, so nothing to tick for.
117 if (e.isInteractive) $.clock.every(TICK_MS, () => void $.clock.now().then(t => update($, now, () => t)))
118 return result
119 })
120
121 on('session.end', async ($, e, next) => {
122 if (e.reason === 'clear') await set($, { cacheAt: null, cacheTtl: null, tokens: null })
123 return next(e)
124 })
125
126 on('session.measure', async ($, e, next) => {
127 await set($, {
128 tokens: e.context.tokens ?? null,
129 window: e.context.window,
130 limits: await limits($, e.rateLimits),
131 })
132 return next(e)
133 })
134
135 // Every main response re-arms the prompt cache; the effort is the one the request carried.
136 on('turn.step', async function* ($, e, next) {
137 const result = yield* next(e)
138 if (e.agentId === undefined && result.usage) {
139 const at = await $.clock.now()
140 await update($, stats, s => ({
141 ...s,
142 model: e.model,
143 // A level; a numeric thinking budget is not one to show.
144 effort: typeof e.effort === 'string' ? e.effort : null,
145 cacheAt: at,
146 }))
147 }
148 return result
149 })
150
151 // A resumed session's cache: when its last response landed, as the engine reports it, and the TTL it wrote.
152 on('classic.SessionStart', async ($, e, next) => {
153 const result = await next(e)
154 if (e.seconds_since_last_response !== undefined) {
155 await set($, { cacheAt: (await $.clock.now()) - e.seconds_since_last_response * 1000 })
156 }
157 await ttlFromTranscript($, e.transcript_path)
158 return result
159 }).catch(($, e, next) => next(e))
160
161 // The TTL the turn actually wrote; turn.step already re-armed the cache.
162 on('classic.Stop', async ($, e, next) => {
163 const result = await next(e)
164 await ttlFromTranscript($, e.transcript_path)
165 return result
166 }).catch(($, e, next) => next(e))
167
168 // Under the prompt, above the engine's own hint line, which stays.
169 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
170 const [hint, s, t] = await Promise.all([next(e), read($, stats), read($, now)])
171 const { Box, Text } = $.ui.resolve(e)
172 // A render can come before session.start has seeded `now`; render has no `$.clock`.
173 return (
174 <Box flexDirection="column">
175 {rows(s, t || Date.now()).map((row, r) => (
176 <Box key={`row-${r}`} marginTop={1}>
177 <Text wrap="truncate-end">
178 {row.map((run, i) => (
179 <Text key={i} color={run.color} backgroundColor={run.bg} dimColor={run.dim}>{run.text}</Text>
180 ))}
181 </Text>
182 </Box>
183 ))}
184 <Box marginTop={1}>{hint}</Box>
185 </Box>
186 )
187 })
188}
189hooks/format.ts 109 lines1import type { Stats } from '../types'
2
3// One piece of text in one style; a row is the runs side by side.
4type Run = { text: string; color?: string; bg?: string; dim?: boolean }
5type Row = Run[]
6
7const BLUE = 'blue'
8const EMPTY_BG = '#3a3a3a' // xterm 237
9const EIGHTHS = '▏▎▍▌▋▊▉'
10const CTX_WIDTH = 22
11const MIN_CTX_WIDTH = 5
12export const HOUR_MS = 60 * 60_000
13export const MIN5_MS = 5 * 60_000
14const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
15
16const level = (p: number, yellow = 60, red = 85) => (p >= red ? 'red' : p >= yellow ? 'yellow' : 'green')
17
18// Full blocks, an eighth block on grey for the started cell, grey cells for the rest.
19export function bar(percent: number, width: number, color: string): Run[] {
20 const eighths = Math.floor((Math.min(Math.max(percent, 0) || 0, 100) * width * 8) / 100)
21 const full = Math.floor(eighths / 8)
22 const part = eighths % 8 ? EIGHTHS[(eighths % 8) - 1] : ''
23 const rest = width - full - (part ? 1 : 0)
24 return [
25 { text: '█'.repeat(full), color },
26 { text: part, color, bg: EMPTY_BG },
27 { text: ' '.repeat(rest), bg: EMPTY_BG },
28 ]
29}
30
31// Remaining time as 2h54m.
32export function left(ms: number): string {
33 const min = Math.max(0, Math.floor(ms / 60_000))
34 return `${Math.floor(min / 60)}h${String(min % 60).padStart(2, '0')}m`
35}
36
37// `date +'%u %H:%M'` output (1 = Monday) as "Thu 16:00".
38export function weekday(dateOutput: string): string | null {
39 const m = /^([1-7]) (\d\d:\d\d)/.exec(dateOutput.trim())
40 return m ? `${WEEKDAYS[Number(m[1]) - 1]} ${m[2]}` : null
41}
42
43// "claude-opus-5-5[1m]" or "Claude Opus 5.5" as "Opus 5.5".
44export function modelName(model: string): string {
45 const [, name, major, minor] = /^claude-([a-z]+)-(\d+)-(\d+)/.exec(model) ?? []
46 if (name) return `${name.charAt(0).toUpperCase()}${name.slice(1)} ${major}.${minor}`
47 return model.replace(/^Claude /, '')
48}
49
50// Terminal cells; the fire emoji is two wide.
51export const width = (row: Row) => row.reduce((n, r) => n + [...r.text].length + (r.text.match(/🔥/gu)?.length ?? 0), 0)
52
53function cache(s: Stats, now: number): Row {
54 if (s.cacheAt === null) return []
55 // Unknown TTL: Claude Code writes 1h caches on a subscription login (the one with limits), 5m on an API key.
56 // Decided here, not when the cache was armed, since limits can arrive later (a resume).
57 const ms = s.cacheAt + (s.cacheTtl ?? (s.limits.length > 0 ? HOUR_MS : MIN5_MS)) - now
58 if (ms <= 0) return [{ text: ' | pc ' }, { text: '❄ cold', color: BLUE }]
59 return [{ text: ' | pc ' }, { text: `🔥 ${Math.floor(ms / 60_000)}m`, color: ms < MIN5_MS ? 'yellow' : 'green' }]
60}
61
62function context(s: Stats, now: number, barWidth: number): Row {
63 const pct = s.tokens === null || !(s.window > 0) ? 0 : Math.max(0, Math.floor((s.tokens * 100) / s.window))
64 const color = level(pct, 75, 90)
65 return [
66 { text: modelName(s.model), color: BLUE },
67 ...(s.effort ? [{ text: ` ${s.effort}`, color: BLUE, dim: true }] : []),
68 { text: ' | ctx [' },
69 ...bar(pct, barWidth, color),
70 { text: '] ' },
71 { text: `${pct}%`, color },
72 ...cache(s, now),
73 ]
74}
75
76// An unknown reset time shows no reset at all.
77function limit(label: string, percent: number, reset: string | null): Row {
78 const p = Math.floor(percent)
79 const color = level(p)
80 return [
81 { text: `${label} [` },
82 ...bar(p, 8, color),
83 { text: '] ' },
84 { text: `${p}%`, color },
85 ...(reset === null ? [] : [{ text: ` ↻ ${reset}`, dim: true }]),
86 ]
87}
88
89// Both rows; the context bar stretches so the first row is as wide as the second.
90export function rows(s: Stats, now: number): Row[] {
91 // The engine reports limits only with a response; one whose reset has passed is empty until the next.
92 const find = (kind: string) => {
93 const l = s.limits.find(l => l.kind === kind)
94 return l && l.resetsAt !== null && l.resetsAt <= now ? { ...l, percent: 0, resetsAt: null, resetLabel: null } : l
95 }
96 const five = find('five_hour')
97 const seven = find('seven_day')
98 const parts = [
99 ...(five ? [limit('5h', five.percent, five.resetsAt === null ? null : left(five.resetsAt - now))] : []),
100 ...(seven ? [limit('7d', seven.percent, seven.resetLabel)] : []),
101 ]
102 // Limits come only with a subscription login; without them the first row stands alone.
103 if (!parts.length) return [context(s, now, CTX_WIDTH)]
104 const second = parts.flatMap((p, i) => (i ? [{ text: ' | ' }, ...p] : p))
105 // The first row without its bar is the fixed part; the bar takes the rest.
106 const w = Math.max(MIN_CTX_WIDTH, width(second) - width(context(s, now, 0)))
107 return [context(s, now, w), second]
108}
109types/index.d.ts 19 lines1export type Limit = { kind: string; percent: number; resetsAt: number | null; resetLabel: string | null }
2
3export type Stats = {
4 model: string
5 effort: string | null
6 tokens: number | null
7 window: number
8 limits: Limit[]
9 // When the last main response re-armed the prompt cache (ms), and its TTL once a cache write showed it.
10 cacheAt: number | null
11 cacheTtl: number | null
12}
13
14declare module 'claude-code' {
15 interface PluginState {
16 'usage-bars': { stats: Shaped<Stats>; now: number }
17 }
18}
19