Live usage line under the prompt: model, context, branch, 5h/week/model limits, session time, folder, cache countdown and rewrite cost.

<h1>【 Claude Code Status Line 】</h1> <h3></h3>
A Claude Code mod that draws a live usage line under the prompt: your model, context usage, rate limits, session time, folder and git branch, plus how long the prompt cache stays warm and what re-caching would cost. The same on macOS, Windows and Linux, in the terminal and the desktop app. No Python, no scripts, no settings to edit.
OPUS 5.5 │ [▓░░░░░░░░░] 12% │ 5H 3% │ 7D 62% │ FABLE 38% │ 42M │ my-project │ main │ ● 58M │ ≈$0.69
One row when the terminal is wide enough; a narrower one breaks onto more rows between segments.
Looking for the original Python status line script? It now lives in
python/and is deprecated in favor of this mod.
Requirements: Claude Code 2.1.287 or later (claude --version; update with claude update). The 5h, week and model limits need you signed in with a Claude subscription; on an API key they are simply left out.
/plugin install usage-statusline --marketplace OctopiAI/claude-code-statusline
y to add the marketplace, then press Enter to keep the default user scope.You'll see Installed usage-statusline. Plugin is now active. and the line appears under the prompt right away, no restart needed. With the user scope it shows in every session from then on, in every project, and in the Claude Code desktop app too. The desktop app can't run /plugin install itself, so install once from a terminal.
Coming from the Python script? Remove the statusLine block from ~/.claude/settings.json (Windows: C:\Users\YOUR_USERNAME\.claude\settings.json), or you'll see both lines.
Update: claude plugin update usage-statusline, then /reload-plugins in a running session.<br> Uninstall: claude plugin uninstall usage-statusline.
| Segment | Description |
|---|---|
| Model | Active model, e.g. OPUS 5.5 |
| Context | Fill bar and percentage of the context window; red from 80% |
| 5H / 7D | Rate-limit windows; red from 80% |
| Model weekly | A model's own weekly limit (e.g. Fable), shown only if your plan has one. Claude Code doesn't hand it to mods, so the mod reads it from the same usage endpoint /usage uses, with your existing login, every 5 minutes. Dims when the reading is older than 30 minutes |
| Session | Time since the session started |
| Folder | Current folder |
| Branch | Current git branch (or short commit hash on a detached HEAD) |
| Cache | Time left before the prompt cache goes cold: green, amber under half, red in the last 20%, a hollow ○ once it has expired |
| Rewrite | What re-caching the current context would cost at the model's list cache-write price (1.25x input on a 5m cache, 2x on a 1h cache); amber once the cache is cold, since that is when the next prompt pays it |
The cache TTL is 1h on a Claude subscription and 5m otherwise; override it with the cacheTtl option in /config.
The mod is a Claude Code plugin of function hooks: hooks/register.tsx is the hooks module, hooks/format.ts the formatting, types/index.d.ts the state it keeps.
claude --plugin-dir /path/to/claude-code-statusline # run a session with your working copy; saving a file reloads it
claude plugin validate . # manifest, marketplace and hooks module
claude plugin test . # tests/*.test.tsx
claude-code-statusline/
├── .claude-plugin/
│ ├── plugin.json # the mod's manifest
│ └── marketplace.json # makes this repo installable with /plugin install
├── hooks/
│ ├── hooks.json
│ ├── register.tsx
│ └── format.ts
├── types/
│ └── index.d.ts
├── tests/
│ └── band.test.tsx
├── python/ # deprecated Python status line script
└── README.md
MIT
hooks/register.tsx 260 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextUsage, SessionRateLimit } from 'claude-code'
3
4import type { AccountLimit, AccountLimits, CacheMark, Session, Usage } from '../types'
5import {
6 bar,
7 basename,
8 cacheTone,
9 countdown,
10 duration,
11 modelName,
12 rewriteUsd,
13 ttlMs,
14 usd,
15} from './format'
16
17const usage = atom({ plugin: 'usage-statusline', key: 'usage' } as const, {
18 contextTokens: null,
19 window: null,
20 limits: [],
21} as Usage)
22const cache = atom({ plugin: 'usage-statusline', key: 'cache' } as const, null as CacheMark | null)
23const now = atom({ plugin: 'usage-statusline', key: 'now' } as const, 0)
24const session = atom({ plugin: 'usage-statusline', key: 'session' } as const, null as Session | null)
25const account = atom({ plugin: 'usage-statusline', key: 'account' } as const, null as AccountLimits | null)
26
27// Mods get five_hour and seven_day, never a model's own weekly limit, so that
28// one comes from the endpoint /usage reads, with the session's own login.
29const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
30const ACCOUNT_EVERY_MS = 5 * 60_000
31const ACCOUNT_STALE_MS = 30 * 60_000
32
33const SEP = ' │ '
34// The footer's indent on both sides; the width assumed before the surface
35// has measured.
36const GUTTER = 4
37const DEFAULT_COLUMNS = 120
38const ALERT_PERCENT = 80
39
40// One run of text in one style; a segment is the runs between separators.
41type Run = { text: string; color?: string; bold?: boolean; dim?: boolean }
42type Segment = Run[]
43
44const width = (seg: Segment) => seg.reduce((n, run) => n + [...run.text].length, 0)
45
46// Packs segments into as few rows as the band's width allows, breaking only
47// between segments.
48function rows(segments: Segment[], columns: number): Segment[][] {
49 const out: Segment[][] = []
50 let row: Segment[] = []
51 let used = 0
52 for (const seg of segments) {
53 const w = width(seg)
54 if (row.length > 0 && used + SEP.length + w > columns) {
55 out.push(row)
56 row = []
57 used = 0
58 }
59 used += (row.length > 0 ? SEP.length : 0) + w
60 row.push(seg)
61 }
62 if (row.length > 0) out.push(row)
63 return out
64}
65
66function toUsage(context: SessionContextUsage, limits: readonly SessionRateLimit[]): Usage {
67 return {
68 contextTokens: context.tokens ?? null,
69 window: context.window,
70 limits: limits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt ?? null })),
71 }
72}
73
74async function branchOf($: EngineInterface): Promise<string | null> {
75 for (const argv of [
76 ['git', 'symbolic-ref', '--short', 'HEAD'],
77 ['git', 'rev-parse', '--short', 'HEAD'],
78 ]) {
79 try {
80 const { exitCode, stdout } = await $.process.run(argv, { timeoutMs: 2000 })
81 if (exitCode === 0 && stdout.trim()) return stdout.trim()
82 } catch {
83 // No git, or no host commands on this surface.
84 }
85 }
86 return null
87}
88
89async function refreshSession($: EngineInterface): Promise<void> {
90 const [model, cwd, branch, { startedAt }] = await Promise.all([
91 $.session.model(),
92 $.session.cwd(),
93 branchOf($),
94 $.session.usage(),
95 ])
96 await update($, session, () => ({ model, branch, folder: basename(cwd), startedAt }))
97}
98
99async function refreshAccount($: EngineInterface): Promise<void> {
100 const auth = await $.session.authorize()
101 if (!auth) return
102 const res = await $.http.fetch(USAGE_URL, {
103 auth: auth.handle,
104 headers: { 'anthropic-beta': 'oauth-2025-04-20' },
105 })
106 if (!res.ok) return
107 const rows: unknown = JSON.parse(res.text)?.limits
108 if (!Array.isArray(rows)) return
109 const limits: AccountLimit[] = rows.flatMap(row => {
110 if (typeof row?.kind !== 'string' || typeof row.percent !== 'number') return []
111 return [{
112 kind: row.kind,
113 name: row.scope?.model?.display_name ?? null,
114 percent: row.percent,
115 resetsAt: typeof row.resets_at === 'string' ? row.resets_at : null,
116 }]
117 })
118 const fetchedAt = await $.clock.now()
119 await update($, account, () => ({ fetchedAt, limits }))
120}
121
122export const register: Register = (on, options) => {
123 on('session.start', async ($, e, next) => {
124 const result = await next(e)
125 const u = await $.session.usage()
126 await update($, usage, () => toUsage(u.context, u.rateLimits))
127 await update($, now, () => Date.now())
128 await refreshSession($)
129 $.clock.every(1000, () => void $.clock.now().then(t => update($, now, () => t)))
130 $.clock.every(ACCOUNT_EVERY_MS, () => void refreshAccount($).catch(() => {}))
131 void refreshAccount($).catch(() => {})
132 return result
133 })
134
135 on('session.end', async ($, e, next) => {
136 if (e.reason === 'clear') {
137 await update($, cache, () => null)
138 await refreshSession($)
139 }
140 return next(e)
141 })
142
143 on('session.measure', async ($, e, next) => {
144 await update($, usage, () => toUsage(e.context, e.rateLimits))
145 return next(e)
146 })
147
148 // Every main-thread response re-arms the prompt cache for its TTL.
149 on('turn.step', async function* ($, e, next) {
150 const result = yield* next(e)
151 if (e.agentId === undefined && result.usage) {
152 const u = result.usage
153 const at = await $.clock.now()
154 const sent = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens
155 await update($, cache, () => ({ at, model: u.model, tokens: sent }))
156 }
157 return result
158 })
159
160 on('turn.complete', async ($, e, next) => {
161 const result = await next(e)
162 await refreshSession($)
163 return result
164 })
165
166 // Drawn under the prompt, above the engine's own hint line (modes, "esc to
167 // interrupt"), which stays as the engine draws it. The Python status line's
168 // order, styles and colors, with the cache appended.
169 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
170 const [hint, u, mark, t, s, acct] = await Promise.all([
171 next(e),
172 read($, usage),
173 read($, cache),
174 read($, now),
175 read($, session),
176 read($, account),
177 ])
178 const at = t || Date.now()
179 const ttl = ttlMs(options.cacheTtl, u.limits.length > 0 || acct !== null)
180 const isStale = acct !== null && at - acct.fetchedAt > ACCOUNT_STALE_MS
181 const limitColor = (percent: number, color: string) => (percent >= ALERT_PERCENT ? 'red' : color)
182
183 const segments: Segment[] = []
184
185 if (s) segments.push([{ text: modelName(s.model), color: 'yellow', bold: true }])
186
187 const ctx = u.contextTokens ?? mark?.tokens ?? null
188 if (ctx !== null && u.window) {
189 const percent = (ctx / u.window) * 100
190 const { filled, empty } = bar(percent)
191 const color = limitColor(percent, 'green')
192 segments.push([
193 { text: '[', color },
194 { text: filled, color },
195 { text: empty, dim: true },
196 { text: ']', color },
197 { text: ` ${Math.round(percent)}%`, color: 'gray' },
198 ])
199 }
200
201 const windows: [string, string, string, string][] = [
202 ['five_hour', 'session', '5H', 'yellow'],
203 ['seven_day', 'weekly_all', '7D', 'green'],
204 ]
205 for (const [kind, accountKind, label, color] of windows) {
206 const percent = u.limits.find(l => l.kind === kind)?.percentUsed
207 ?? acct?.limits.find(l => l.kind === accountKind)?.percent
208 if (percent === undefined) continue
209 segments.push([{ text: `${label} ${Math.round(percent)}%`, color: limitColor(percent, color) }])
210 }
211
212 for (const l of acct?.limits ?? []) {
213 if (l.kind !== 'weekly_scoped' || (l.resetsAt && Date.parse(l.resetsAt) < at)) continue
214 const text = `${(l.name ?? 'MODEL').toUpperCase()} ${Math.round(l.percent)}%`
215 segments.push([isStale ? { text, dim: true } : { text, color: limitColor(l.percent, 'magenta') }])
216 }
217
218 if (s) {
219 segments.push([{ text: duration(at - s.startedAt), dim: true }])
220 segments.push([{ text: s.folder, color: 'cyan', bold: true }])
221 if (s.branch) segments.push([{ text: s.branch, color: 'green' }])
222 }
223
224 const remaining = mark ? mark.at + ttl - at : 0
225 segments.push(
226 remaining > 0
227 ? [{ text: `● ${countdown(remaining)}`, color: cacheTone(remaining, ttl) }]
228 : [{ text: '○', dim: true }],
229 )
230
231 if (ctx !== null) {
232 const cost = rewriteUsd(ctx, mark?.model ?? s?.model ?? '', ttl)
233 if (cost !== null) {
234 segments.push([remaining > 0
235 ? { text: `≈${usd(cost)}`, dim: true }
236 : { text: `≈${usd(cost)}`, color: 'warning' }])
237 }
238 }
239
240 const { Box, Text } = $.ui.resolve(e)
241 return (
242 <Box flexDirection="column" marginTop={1}>
243 {rows(segments, (e.viewport?.columns ?? DEFAULT_COLUMNS) - GUTTER).map((row, r) => (
244 <Box key={`row-${r}`}>
245 <Text wrap="truncate-end">
246 {row.flatMap((seg, i) => [
247 i > 0 ? <Text dimColor>{SEP}</Text> : null,
248 ...seg.map(run => (
249 <Text color={run.color} bold={run.bold} dimColor={run.dim}>{run.text}</Text>
250 )),
251 ])}
252 </Text>
253 </Box>
254 ))}
255 {hint}
256 </Box>
257 )
258 })
259}
260hooks/format.ts 92 lines1// List input prices in USD per million tokens, matched by model id prefix
2// (first match wins, so more specific prefixes come first).
3const INPUT_USD_PER_MTOK: readonly (readonly [string, number])[] = [
4 ['claude-fable', 10],
5 ['claude-mythos', 10],
6 ['claude-opus-5-5', 4],
7 ['claude-opus', 5],
8 ['claude-sonnet-5', 2],
9 ['claude-sonnet', 3],
10 ['claude-haiku-5-5', 0.1],
11 ['claude-haiku', 1],
12]
13
14const MINUTE = 60_000
15const HOUR = 60 * MINUTE
16
17export type Tone = 'success' | 'warning' | 'error' | 'inactive'
18
19// "/model" shows "Opus 5.5"; the API reports "claude-opus-5-5".
20export function modelId(name: string): string {
21 const id = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
22 return id.startsWith('claude-') ? id : `claude-${id}`
23}
24
25// Re-caching the whole context costs 1.25x input on the 5m TTL, 2x on 1h.
26export function rewriteUsd(tokens: number, model: string, ttlMs: number): number | null {
27 const id = modelId(model)
28 const price = INPUT_USD_PER_MTOK.find(([prefix]) => id.startsWith(prefix))?.[1]
29 if (price === undefined) return null
30 return (tokens / 1e6) * price * (ttlMs > 5 * MINUTE ? 2 : 1.25)
31}
32
33// "claude-opus-5-5[1m]", "Claude Opus 5.5 (1M context)" -> "OPUS 5.5"
34export function modelName(name: string): string {
35 const words = name
36 .toLowerCase()
37 .replace(/\[.*?\]|\(.*?\)/g, ' ')
38 .split(/[\s\-_]+/)
39 .filter(w => w && w !== 'claude')
40 const family = words.find(w => /^[a-z]+$/.test(w))
41 if (!family) return name.toUpperCase()
42 const version = words.filter(w => /^\d{1,2}(\.\d{1,2})?$/.test(w)).join('.')
43 return version ? `${family.toUpperCase()} ${version}` : family.toUpperCase()
44}
45
46export function bar(percent: number, cells = 10): { filled: string; empty: string } {
47 const n = Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)))
48 return { filled: '▓'.repeat(n), empty: '░'.repeat(cells - n) }
49}
50
51export function tokens(n: number): string {
52 if (n >= 1e6) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
53 if (n >= 1e3) return `${Math.round(n / 1e3)}K`
54 return String(n)
55}
56
57export function usd(n: number): string {
58 return `$${n.toFixed(2)}`
59}
60
61// 42M, 1H 05M, 3D 4H
62export function duration(ms: number): string {
63 const m = Math.max(0, Math.floor(ms / MINUTE))
64 if (m < 60) return `${m}M`
65 const h = Math.floor(m / 60)
66 if (h < 24) return `${h}H ${String(m % 60).padStart(2, '0')}M`
67 return `${Math.floor(h / 24)}D ${h % 24}H`
68}
69
70// Counts up to the next whole minute so a fresh 1h cache reads 60M; the last
71// minute counts seconds.
72export function countdown(ms: number): string {
73 if (ms < MINUTE) return `${Math.ceil(ms / 1000)}S`
74 return `${Math.ceil(ms / MINUTE)}M`
75}
76
77export function cacheTone(remainingMs: number, ttlMs: number): Tone {
78 if (remainingMs <= 0) return 'inactive'
79 const left = remainingMs / ttlMs
80 return left > 0.5 ? 'success' : left > 0.2 ? 'warning' : 'error'
81}
82
83export function ttlMs(setting: unknown, onSubscription: boolean): number {
84 if (setting === '5m') return 5 * MINUTE
85 if (setting === '1h') return HOUR
86 return onSubscription ? HOUR : 5 * MINUTE
87}
88
89export function basename(path: string): string {
90 return path.split(/[\\/]/).filter(Boolean).pop() ?? path
91}
92types/index.d.ts 31 lines1export type Limit = { kind: string; percentUsed: number; resetsAt: string | null }
2
3export type Usage = {
4 contextTokens: number | null
5 window: number | null
6 limits: Limit[]
7}
8
9// When the main thread last got a response, which re-arms the prompt cache.
10export type CacheMark = { at: number; model: string; tokens: number }
11
12// One row of the usage endpoint's `limits`: `session` (5h), `weekly_all`
13// (week) and `weekly_scoped` (a model's own weekly, such as Fable).
14export type AccountLimit = { kind: string; name: string | null; percent: number; resetsAt: string | null }
15
16export type AccountLimits = { fetchedAt: number; limits: AccountLimit[] }
17
18export type Session = { model: string; branch: string | null; folder: string; startedAt: number }
19
20declare module 'claude-code' {
21 interface PluginState {
22 'usage-statusline': {
23 usage: Usage
24 cache: CacheMark | null
25 now: number
26 session: Session | null
27 account: AccountLimits | null
28 }
29 }
30}
31