Two-line status under the prompt: model, project, branch, cost, context and 5h/7d rate-limit bars

A Claude Code mod that draws two rows under the prompt, in place of the shortcuts hint:
Opus 5.5 (medium) · my-project · main · $2.11 · 23m · [CAVEMAN]
ctx ━╸━━━━━━━━ 12% 116k/1000k · 5h ━╸━━━━━━━━ 10% ↻4h36m · 7d ━━━╸━━━━━━ 36% @Sun 17:00
Model and effort · project · git branch · session cost · minutes elapsed · the caveman badge when installed. Then the context window and the 5-hour and 7-day rate limits, each bar green under 50%, yellow under 80%, red above. Fields Claude Code doesn't have (rate limits off a subscription, branch outside git) are left out. It refreshes after each turn, when usage moves, and every minute.
Type at the prompt of a Claude Code terminal session:
/plugin install usage-line --marketplace hassanhmd23/claude-plugins
Answer y to add the marketplace, then pick a scope.
To get updates automatically, run /plugin, open Marketplaces, select hassanhammoud-plugins and choose Enable auto-update. Otherwise update with claude plugin update usage-line@hassanhammoud-plugins.
hooks/register.tsx 118 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3import type { Snapshot } from '../types'
4import { bar, k, modelName, snapshot, tone } from './format'
5
6const current = atom({ plugin: 'usage-line', key: 'snapshot' } as const, null)
7
8// Set from each model request; the session API has no getter for it.
9let effort: string | undefined
10
11// The caveman plugin's badge, when it is installed; its colours are dropped for ours.
12async function badge($: EngineInterface) {
13 try {
14 const home = await $.env.get('HOME')
15 const ran = await $.process.run(['bash', `${home}/.claude/hooks/caveman-statusline.sh`], { timeoutMs: 2000 })
16 const text = ran.exitCode === 0 ? ran.stdout.replace(/\x1b\[[0-9;]*m/g, '').trim() : ''
17 return text || undefined
18 } catch {
19 return undefined
20 }
21}
22
23async function refresh($: EngineInterface) {
24 const [model, root, usage, now, git, caveman] = await Promise.all([
25 $.session.model(),
26 $.session.root(),
27 $.session.usage(),
28 $.clock.now(),
29 $.process.run(['git', 'branch', '--show-current'], { timeoutMs: 2000 }).catch(() => undefined),
30 badge($),
31 ])
32 const branch = git?.exitCode === 0 ? git.stdout.trim() || undefined : undefined
33 const next = snapshot({ model: modelName(model), effort, project: root.split('/').pop() ?? root, branch, badge: caveman, usage, now })
34 await update($, current, () => next)
35}
36
37export const register: Register = on => {
38 on('session.start', async ($, e, next) => {
39 const started = await next(e)
40 // Clears the notice 0.1.0 pinned with $.ui.status; the host keeps it across updates.
41 $.ui.status(undefined)
42 await refresh($)
43 // Keeps the reset countdowns moving between turns.
44 $.clock.every(60_000, () => refresh($))
45 return started
46 })
47
48 on('turn.step', async function* ($, e, next) {
49 effort = e.effort === undefined ? undefined : String(e.effort)
50 return yield* next(e)
51 })
52
53 on('session.measure', async ($, e, next) => {
54 await refresh($)
55 return next(e)
56 })
57
58 on('turn.complete', async ($, e, next) => {
59 await refresh($)
60 return next(e)
61 })
62
63 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
64 const s: Snapshot | null = await read($, current)
65 if (!s) return next(e)
66
67 const { Box, Text } = $.ui.resolve(e)
68 const sep = <Text dimColor> · </Text>
69 const gauge = (label: string, percent: number) => {
70 const [fill, track] = bar(percent)
71 return (
72 <Text>
73 {label} <Text color={tone(percent)}>{fill}</Text>
74 <Text dimColor>{track}</Text> <Text color={tone(percent)}>{Math.round(percent)}%</Text>
75 </Text>
76 )
77 }
78
79 const top = [
80 <Text>
81 {s.model}
82 {s.effort && <Text dimColor> ({s.effort})</Text>}
83 </Text>,
84 <Text>{s.project}</Text>,
85 s.branch && <Text>{s.branch}</Text>,
86 s.cost !== undefined && <Text>${s.cost.toFixed(2)}</Text>,
87 <Text>{s.minutes}m</Text>,
88 s.badge && <Text color="#d78700">{s.badge}</Text>,
89 ].filter(Boolean) as RenderChildren[]
90
91 const bottom = [
92 s.context && (
93 <Text>
94 {gauge('ctx', s.context.percent)}
95 <Text dimColor> {k(s.context.tokens)}/{k(s.context.window)}</Text>
96 </Text>
97 ),
98 ...s.limits.map(l => (
99 <Text>
100 {gauge(l.label, l.percent)}
101 {l.reset && <Text dimColor> {l.reset}</Text>}
102 </Text>
103 )),
104 ].filter(Boolean) as RenderChildren[]
105
106 const row = (parts: RenderChildren[]) => (
107 <Text wrap="truncate">{parts.flatMap((p, i) => (i ? [sep, p] : [p]))}</Text>
108 )
109
110 return (
111 <Box flexDirection="column">
112 {row(e.props.isWorking ? [...top, <Text dimColor>{e.props.hint}</Text>] : top)}
113 {bottom.length > 0 && row(bottom)}
114 </Box>
115 )
116 })
117}
118hooks/format.ts 62 lines1import type { SessionUsage } from 'claude-code'
2import type { Limit, Snapshot } from '../types'
3
4// 10-cell bar with half steps, split so the track can be drawn dim: 45 -> ["━━━━╸", "━━━━━"]
5export const bar = (percent: number): [fill: string, track: string] => {
6 const halves = Math.round(Math.min(Math.max(percent, 0), 100) / 5)
7 const full = Math.floor(halves / 2)
8 const half = halves % 2 ? '╸' : ''
9 return ['━'.repeat(full) + half, '━'.repeat(10 - full - half.length)]
10}
11
12// Green under 50, yellow under 80, red from 80.
13export const tone = (percent: number): 'success' | 'warning' | 'error' =>
14 percent >= 80 ? 'error' : percent >= 50 ? 'warning' : 'success'
15
16// 152340 -> "152k"
17export const k = (n: number): string => (n >= 1000 ? `${Math.floor(n / 1000)}k` : `${n}`)
18
19// ms until reset -> "2h10m" / "3d11h" / "now"
20export const until = (ms: number): string => {
21 const s = Math.floor(ms / 1000)
22 if (s <= 0) return 'now'
23 if (s >= 86400) return `${Math.floor(s / 86400)}d${Math.floor((s % 86400) / 3600)}h`
24 if (s >= 3600) return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
25 return `${Math.floor(s / 60)}m`
26}
27
28// "claude-opus-5-5[1m]" -> "Opus 5.5"; anything else as given.
29export const modelName = (id: string): string => {
30 const m = /^claude-([a-z]+)-(\d+)-(\d+)/.exec(id)
31 return m ? `${m[1]![0]!.toUpperCase()}${m[1]!.slice(1)} ${m[2]}.${m[3]}` : id
32}
33
34const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
35
36// Epoch ms -> "Sun 17:00", local time.
37export const clock = (ms: number): string => {
38 const d = new Date(ms)
39 return `${DAYS[d.getDay()]} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
40}
41
42// The 5h window counts down; the 7d one names when it resets, as the bash status line did.
43const limit = ({ kind, percentUsed, resetsAt }: SessionUsage['rateLimits'][number], now: number): Limit => {
44 const at = resetsAt ? Date.parse(resetsAt) : undefined
45 if (kind === 'five_hour') return { label: '5h', percent: percentUsed, reset: at === undefined ? undefined : `↻${until(at - now)}` }
46 if (kind === 'seven_day') return { label: '7d', percent: percentUsed, reset: at === undefined ? undefined : `@${clock(at)}` }
47 return { label: kind === 'spend_limit' ? '$' : kind, percent: percentUsed }
48}
49
50export type SnapshotInput = Omit<Snapshot, 'cost' | 'minutes' | 'context' | 'limits'> & { usage: SessionUsage; now: number }
51
52export const snapshot = ({ usage, now, ...rest }: SnapshotInput): Snapshot => {
53 const { percent, tokens, window } = usage.context
54 return {
55 ...rest,
56 cost: usage.cost?.usd,
57 minutes: Math.floor((now - usage.startedAt) / 60000),
58 context: percent === undefined ? undefined : { percent, tokens: tokens ?? 0, window },
59 limits: usage.rateLimits.map(l => limit(l, now)),
60 }
61}
62types/index.d.ts 20 lines1export type Limit = { label: string; percent: number; reset?: string }
2
3export type Snapshot = {
4 model: string
5 effort?: string
6 project: string
7 branch?: string
8 cost?: number
9 minutes: number
10 context?: { percent: number; tokens: number; window: number }
11 limits: Limit[]
12 badge?: string
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'usage-line': { snapshot: Snapshot | null }
18 }
19}
20