Band above the prompt: last two path segments, git branch, 5h and weekly rate-limit bars with percentages, model and session cost

A Claude Code mod that draws a status band directly above the prompt, framed in a rounded green border:
╭──────────────────────────────────────────────────────────────────────────────────────╮
│ ● my-project/api main │ 5h ██░░░░░░░░ 20% ↻ 3h 10m │ Week █░░░░░░░░░ 12% ↻ 6d 21h │ claude-fable-5-1 $2.52 │
╰──────────────────────────────────────────────────────────────────────────────────────╯
What it shows, left to right:
no git./model shows it, and the session's cost in US dollars.The rate-limit figures come from the API's own responses and update after every turn. They show – until the first response arrives, and stay empty on accounts without a subscription.
At the prompt of a terminal session:
/plugin install status-band --marketplace golfomania/claude-status-band
Answer y to add the marketplace, then pick a scope (user scope installs it for every session).
From a shell:
claude plugin install status-band --marketplace golfomania/claude-status-band
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with golfomania/claude-status-band, then Add plugin.
Run it straight from a checkout for one session:
claude --plugin-dir /path/to/claude-status-band
Check it with claude plugin validate . and type-check with tsc -p . once the engine has laid the types into .claude-plugin/types/.
MIT
hooks/register.tsx 162 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { Limit, Meta, Repo } from '../types'
5
6const repo = atom({ plugin: 'status-band', key: 'repo' } as const, null as Repo | null)
7const limits = atom({ plugin: 'status-band', key: 'limits' } as const, [] as Limit[])
8const meta = atom({ plugin: 'status-band', key: 'meta' } as const, { model: null, usd: null } as Meta)
9
10const BAR_WIDTH = 10
11const REFRESH_MS = 15_000
12
13const shortPath = (cwd: string): string =>
14 cwd.split('/').filter(Boolean).slice(-2).join('/') || '/'
15
16const toLimits = (rows: readonly SessionRateLimit[]): Limit[] =>
17 rows.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt }))
18
19const sameLimits = (a: Limit[], b: Limit[]): boolean =>
20 a.length === b.length &&
21 a.every((x, i) => {
22 const y = b[i]
23 return y !== undefined && x.kind === y.kind && x.percentUsed === y.percentUsed && x.resetsAt === y.resetsAt
24 })
25
26const colorFor = (pct: number): 'success' | 'warning' | 'error' =>
27 pct >= 90 ? 'error' : pct >= 70 ? 'warning' : 'success'
28
29const bar = (pct: number): { filled: string; empty: string } => {
30 const n = Math.max(0, Math.min(BAR_WIDTH, Math.round((pct / 100) * BAR_WIDTH)))
31 return { filled: '█'.repeat(n), empty: '░'.repeat(BAR_WIDTH - n) }
32}
33
34const resetsIn = (resetsAt: string | undefined, now: number): string | null => {
35 if (!resetsAt) return null
36 const ms = Date.parse(resetsAt) - now
37 if (!Number.isFinite(ms) || ms <= 0) return null
38 const minutes = Math.floor(ms / 60_000)
39 const days = Math.floor(minutes / 1440)
40 const hours = Math.floor((minutes % 1440) / 60)
41 const mins = minutes % 60
42 if (days > 0) return `${days}d ${hours}h`
43 if (hours > 0) return `${hours}h ${mins}m`
44 return `${mins}m`
45}
46
47async function refreshRepo($: EngineInterface, cwd: string): Promise<void> {
48 const result = await $.process
49 .run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd, timeoutMs: 5_000 })
50 .catch(() => null)
51 const branch = result && result.exitCode === 0 ? result.stdout.trim() || null : null
52 const next: Repo = { path: shortPath(cwd), branch }
53 const prev = await read($, repo)
54 if (prev && prev.path === next.path && prev.branch === next.branch) return
55 await update($, repo, () => next)
56}
57
58async function refreshLimits($: EngineInterface, rows: readonly SessionRateLimit[]): Promise<void> {
59 const next = toLimits(rows)
60 const prev = await read($, limits)
61 if (sameLimits(prev, next)) return
62 await update($, limits, () => next)
63}
64
65async function refreshMeta($: EngineInterface, usd: number | null | undefined): Promise<void> {
66 const model = await $.session.model().catch(() => null)
67 const prev = await read($, meta)
68 const next: Meta = { model, usd: usd ?? prev.usd }
69 if (prev.model === next.model && prev.usd === next.usd) return
70 await update($, meta, () => next)
71}
72
73const fmtUsd = (usd: number): string => (usd < 10 ? `$${usd.toFixed(2)}` : `$${usd.toFixed(1)}`)
74
75export const register: Register = on => {
76 on('session.start', async ($, e, next) => {
77 const started = await next(e)
78
79 await refreshRepo($, started.cwd)
80 const usage = await $.session.usage().catch(() => null)
81 if (usage) await refreshLimits($, usage.rateLimits)
82 await refreshMeta($, usage?.cost?.usd ?? null)
83
84 $.clock.every(REFRESH_MS, () => {
85 void (async () => {
86 const cwd = await $.session.cwd()
87 await refreshRepo($, cwd)
88 await refreshMeta($, undefined)
89 })()
90 })
91
92 return started
93 })
94
95 on('session.measure', async ($, e, next) => {
96 await refreshLimits($, e.rateLimits)
97 await refreshMeta($, e.cost?.usd ?? null)
98 return next(e)
99 })
100
101 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
102 if (e.props.hasSurvey) return next(e)
103
104 const [r, rows, m, now] = await Promise.all([read($, repo), read($, limits), read($, meta), $.clock.now()])
105 const { Box, Text } = $.ui.resolve(e)
106
107 const five = rows.find(l => l.kind === 'five_hour')
108 const week = rows.find(l => l.kind === 'seven_day')
109
110 const limit = (label: string, l: Limit | undefined) => {
111 if (!l) {
112 return (
113 <Box gap={1}>
114 <Text dimColor>{label}</Text>
115 <Text dimColor>–</Text>
116 </Box>
117 )
118 }
119 const pct = Math.round(l.percentUsed)
120 const color = colorFor(l.percentUsed)
121 const { filled, empty } = bar(l.percentUsed)
122 const reset = resetsIn(l.resetsAt, now)
123 return (
124 <Box gap={1}>
125 <Text weight="semibold">{label}</Text>
126 <Box>
127 <Text color={color}>{filled}</Text>
128 <Text dimColor>{empty}</Text>
129 </Box>
130 <Text color={color} tabular>
131 {pct}%
132 </Text>
133 {reset ? <Text dimColor>↻ {reset}</Text> : null}
134 </Box>
135 )
136 }
137
138 return (
139 <Box gap={2} borderStyle="round" borderColor="success" paddingX={1} alignSelf="flex-start">
140 <Box gap={1}>
141 <Text color={r?.branch ? 'success' : 'inactive'}>●</Text>
142 <Text dimColor>{r?.path ?? '…'}</Text>
143 {r?.branch ? (
144 <Text color="success">{r.branch}</Text>
145 ) : (
146 <Text dimColor>no git</Text>
147 )}
148 </Box>
149 <Text color="success">│</Text>
150 {limit('5h', five)}
151 <Text color="success">│</Text>
152 {limit('Week', week)}
153 <Text color="success">│</Text>
154 <Box gap={1}>
155 {m.model ? <Text color="claude">{m.model}</Text> : null}
156 {m.usd !== null ? <Text dimColor>{fmtUsd(m.usd)}</Text> : null}
157 </Box>
158 </Box>
159 )
160 })
161}
162types/index.d.ts 10 lines1export type Repo = { path: string; branch: string | null }
2export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
3export type Meta = { model: string | null; usd: number | null }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'status-band': { repo: Repo | null; limits: Limit[]; meta: Meta }
8 }
9}
10