Shows your 5-hour and weekly plan limits, used or remaining, and the session's git branch or worktree under the prompt.

A Claude Code mod that shows your plan limits and where the session is working, under the prompt.

⎇ feature/eng-1930 +1 ~2 ↑2 5h ████▏░░░░░ 42% ↻ 2h13m 7d ██░░░░░▏░░ 18% ↻ Sun 00:00
🔥 warm 42° Opus 5.5 ctx 31% $1.24
┃ tick marks how much of the window's time has passed. If the fill reaches past the tick, you are using the limit faster than it resets.⎇), or the worktree folder (⌂) when the session is in a linked worktree. Then staged (+), modified (~) and untracked (?) files, and commits ahead (↑) of and behind (↓) the upstream. Outside a repository, it shows the folder name.The limit readings come from Claude Code itself: each API response reports them, so they appear after the session's first reply. API-key and gateway sessions without plan limits show no bars. Git is read when the session starts, after each turn, and every 15 seconds.
Type /usage-bar to switch the limit bars between what you've used (42%) and what's left (58% left). /usage-bar used and /usage-bar remaining pick one. The choice is kept across sessions.
In remaining mode, the bar drains as you use the limit, in one color for how close you are to it. The tick marks how much of the window's time is left: a bar shorter than the tick is running out before the window resets.
claude plugin marketplace add omeerdvrn/o-mods
claude plugin install usage-bar@o-mods
It replaces a statusLine command for these figures: remove statusLine from ~/.claude/settings.json if you used one for them.
claude --plugin-dir /path/to/o-mods/mods/usage-bar
claude plugin validate .
claude plugin test .
| File | Contents |
|---|---|
hooks/register.tsx | Reads usage and git; draws the footer |
hooks/meter.ts | Bars, pace, reset times, model names, and git status parsing |
types/index.d.ts | The mod's state contract |
tests/usage-bar.test.ts | Tests with the session and git faked |
hooks/register.tsx 197 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Limit, Show, Snapshot } from '../types'
5import { barCells, currentLimits, elapsedShare, labelOf, levelColor, modelName, orderLimits, parseGitStatus, resetLabel } from './meter'
6
7const GIT_EVERY_MS = 60_000
8const CLOCK_EVERY_MS = 60_000
9const BAR_CELLS = 10
10
11const limits = atom({ plugin: 'usage-bar', key: 'limits' } as const, [] as Limit[])
12const snapshot = atom({ plugin: 'usage-bar', key: 'snapshot' } as const, null as Snapshot | null)
13const git = atom({ plugin: 'usage-bar', key: 'git' } as const, null as Snapshot['git'])
14const now = atom({ plugin: 'usage-bar', key: 'now' } as const, 0)
15const folder = atom({ plugin: 'usage-bar', key: 'folder' } as const, '')
16const show = atom({ plugin: 'usage-bar', key: 'show' } as const, 'used' as Show)
17const SHOW_KEY = 'show'
18
19async function readGit($: EngineInterface) {
20 const cwd = await $.session.cwd()
21 await update($, folder, () => cwd.split('/').pop() || cwd)
22 const place = await $.process.run(
23 ['git', 'rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir', '--show-toplevel'],
24 { cwd, timeoutMs: 5_000 },
25 )
26 if (place.exitCode !== 0) {
27 await update($, git, () => null)
28 return
29 }
30 const status = await $.process.run(['git', 'status', '--porcelain=v2', '--branch'], { cwd, timeoutMs: 5_000 })
31 const parsed = parseGitStatus(place.stdout, status.exitCode === 0 ? status.stdout : '')
32 await update($, git, () => parsed)
33}
34
35async function readUsage($: EngineInterface) {
36 const usage = await $.session.usage()
37 const model = await $.session.model()
38 const clock = await $.clock.now()
39 await update($, limits, () => usage.rateLimits.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt })))
40 await update($, snapshot, previous => ({
41 model,
42 contextPercent: usage.context.percent ?? null,
43 costUsd: usage.cost?.usd ?? null,
44 git: previous?.git ?? null,
45 }))
46 await update($, now, () => clock)
47}
48
49async function tick($: EngineInterface) {
50 const clock = await $.clock.now()
51 await update($, now, () => clock)
52}
53
54export const register: Register = on => {
55 on('session.start', async ($, e, next) => {
56 // Not awaited: registering makes Claude Code re-describe every command (~0.7 s), which would hold startup.
57 // Typed as /usage-bar in the terminal; the command file is what the desktop app lists in its menu.
58 void $.command.register({
59 name: 'usage-bar',
60 description: "Show plan limits as used or remaining",
61 argumentHint: '[used|remaining|debug]',
62 }).catch(() => undefined)
63 const stored = await $.store.get(SHOW_KEY)
64 if (stored === 'used' || stored === 'remaining') await update($, show, () => stored)
65 void readUsage($)
66 void readGit($)
67 $.clock.every(GIT_EVERY_MS, () => void readGit($))
68 $.clock.every(CLOCK_EVERY_MS, () => void tick($))
69
70 return next(e)
71 })
72
73 // `/usage-bar` flips between used and remaining; `/usage-bar used|remaining` picks one. The choice is kept.
74 on('command.run', { command: ['usage-bar', 'usage-bar:usage-bar'] }, async ($, e) => {
75 const asked = e.args.trim()
76 // The figures exactly as the engine reports them, to compare with /usage.
77 if (asked === 'debug') {
78 const usage = await $.session.usage()
79 return { text: JSON.stringify({ now: new Date(await $.clock.now()).toISOString(), rateLimits: usage.rateLimits }, null, 2) }
80 }
81 const current = await read($, show)
82 const next: Show = asked === 'used' || asked === 'remaining' ? asked : current === 'used' ? 'remaining' : 'used'
83 await update($, show, () => next)
84 await $.store.set(SHOW_KEY, next)
85
86 return { text: next === 'used' ? 'Limits now show what you have used.' : 'Limits now show what you have left.' }
87 })
88
89 // The engine pushes a measurement after each turn and whenever a limit moves a whole point.
90 on('session.measure', async ($, e, next) => {
91 await readUsage($)
92
93 return next(e)
94 })
95
96 // A turn can switch branches or enter a worktree: read git again as soon as it ends.
97 on('turn.complete', async ($, e, next) => {
98 void readGit($)
99
100 return next(e)
101 })
102
103 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
104 const { Box, Text } = $.ui.resolve(e)
105 // The mode labels as the rest of the chain and the engine draw them: labels other mods add (the heat mod's
106 // tier, persona's role) show whichever order the mods were installed in.
107 const labels = await next(e)
108 const mode = await read($, show)
109 const info = await read($, snapshot)
110 const place = await read($, git)
111 const where = await read($, folder)
112 const clock = (await read($, now)) || Date.now()
113 const windows = orderLimits(currentLimits(await read($, limits), clock))
114
115 const changes = place
116 ? [
117 place.staged > 0 && { text: `+${place.staged}`, color: 'success' },
118 place.modified > 0 && { text: `~${place.modified}`, color: 'warning' },
119 place.untracked > 0 && { text: `?${place.untracked}`, color: 'inactive' },
120 place.ahead > 0 && { text: `↑${place.ahead}`, color: 'suggestion' },
121 place.behind > 0 && { text: `↓${place.behind}`, color: 'suggestion' },
122 ].filter((part): part is { text: string; color: string } => Boolean(part))
123 : []
124
125 const limitRow = (
126 <Box flexDirection="row" columnGap={3}>
127 {windows.map(window => {
128 const color = levelColor(window.percentUsed)
129 const elapsed = elapsedShare(window.kind, window.resetsAt, clock)
130 // Remaining: the bar drains as you use it, in one color for how close you are, and the tick marks
131 // how much of the window's time is left, so a bar shorter than the tick is running out early.
132 const cells =
133 mode === 'remaining'
134 ? barCells(Math.max(0, 100 - window.percentUsed), BAR_CELLS, elapsed === null ? null : 1 - elapsed, color)
135 : barCells(window.percentUsed, BAR_CELLS, elapsed)
136 const reset = resetLabel(window.resetsAt, clock)
137
138 return (
139 <Box key={`limit:${window.kind}`} flexDirection="row" columnGap={1}>
140 <Text dimColor>{labelOf(window.kind)}</Text>
141 <Box flexDirection="row">
142 {cells.map(cell => (
143 <Text color={cell.color} backgroundColor={cell.background}>
144 {cell.glyph}
145 </Text>
146 ))}
147 </Box>
148 <Text color={color} bold>
149 {mode === 'remaining'
150 ? `${Math.max(0, Math.round(100 - window.percentUsed))}% left`
151 : `${Math.round(window.percentUsed)}%`}
152 </Text>
153 {reset !== null && <Text dimColor>{`↻ ${reset}`}</Text>}
154 </Box>
155 )
156 })}
157 </Box>
158 )
159
160 const gitPart =
161 place === null ? (
162 where !== '' && <Text dimColor>{`▸ ${where}`}</Text>
163 ) : (
164 <Box flexDirection="row" columnGap={1}>
165 <Text color={place.worktree ? 'permission' : 'suggestion'}>
166 {place.worktree ? `⌂ ${place.worktree}` : '⎇'}
167 </Text>
168 <Text color={place.worktree ? 'permission' : 'suggestion'}>{place.branch}</Text>
169 {changes.map(part => (
170 <Text color={part.color}>{part.text}</Text>
171 ))}
172 </Box>
173 )
174
175 return (
176 <Box flexDirection="column" alignItems="flex-end">
177 <Box flexDirection="row" columnGap={3}>
178 {gitPart}
179 {windows.length > 0 && limitRow}
180 </Box>
181 <Box flexDirection="row" columnGap={3}>
182 {labels}
183 {info !== null && <Text dimColor>{modelName(info.model)}</Text>}
184 {info?.contextPercent !== null && info?.contextPercent !== undefined && (
185 <Text color={levelColor(info.contextPercent)} dimColor={info.contextPercent < 70}>
186 {`ctx ${Math.round(info.contextPercent)}%`}
187 </Text>
188 )}
189 {info?.costUsd !== null && info?.costUsd !== undefined && (
190 <Text dimColor>{`$${info.costUsd.toFixed(2)}`}</Text>
191 )}
192 </Box>
193 </Box>
194 )
195 })
196}
197hooks/meter.ts 133 lines1import type { GitPlace, Limit } from '../types'
2
3const LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d' }
4const ORDER: Record<string, number> = { five_hour: 0, seven_day: 1 }
5const WINDOW_MS: Record<string, number> = { five_hour: 5 * 3_600_000, seven_day: 7 * 86_400_000 }
6const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
7
8/** Cell colors from an empty bar to a full one: green, through amber, to red. */
9const GRADIENT = ['#3FB950', '#56C24A', '#8CC63F', '#C9C33A', '#E3B341', '#F0883E', '#F06A3E', '#F85149']
10
11/**
12 * A window whose reset time has passed starts over at 0%, though the engine only reports the fresh figure
13 * after the next turn: the bar shows the reset as it happens instead of the old window's usage.
14 */
15export function currentLimits(limits: readonly Limit[], now: number): Limit[] {
16 return limits.map(limit => {
17 const at = limit.resetsAt ? Date.parse(limit.resetsAt) : Number.NaN
18 return !Number.isNaN(at) && at <= now ? { kind: limit.kind, percentUsed: 0 } : limit
19 })
20}
21
22/** The 5-hour window first, then the weekly one, then anything else the account reports. */
23export function orderLimits(limits: readonly Limit[]): Limit[] {
24 return [...limits].sort((a, b) => (ORDER[a.kind] ?? 9) - (ORDER[b.kind] ?? 9))
25}
26
27export function labelOf(kind: string): string {
28 return LABELS[kind] ?? kind.replace(/_/g, ' ')
29}
30
31/** Color for a figure by how close it is to its limit. */
32export function levelColor(percent: number): string {
33 if (percent >= 90) return '#F85149'
34 if (percent >= 70) return '#E3B341'
35 return '#3FB950'
36}
37
38/** Background of a cell the fill has not reached. */
39export const TRACK = '#30363D'
40/** The pace tick, drawn over whatever cell it falls in. */
41const TICK = '#E6EDF3'
42
43export type Cell = { glyph: string; color: string; background: string; isFilled: boolean }
44
45/**
46 * The bar's cells, drawn as solid blocks: filled cells take the gradient color of their position, the rest
47 * the track color. The cell where the window's elapsed time falls carries a pace tick, so a fill that runs
48 * past the tick is burning faster than the window.
49 */
50export function barCells(percent: number, cells: number, elapsed: number | null, solid?: string): Cell[] {
51 const share = Math.round((percent / 100) * cells)
52 // Any use at all lights the first cell, so 2% never reads as an empty bar.
53 const full = Math.max(percent > 0 ? 1 : 0, Math.min(cells, share))
54 const pace = elapsed === null ? -1 : Math.min(cells - 1, Math.max(0, Math.floor(elapsed * cells)))
55
56 return Array.from({ length: cells }, (_, index) => {
57 const isFilled = index < full
58 const background = isFilled
59 ? solid ?? GRADIENT[Math.min(GRADIENT.length - 1, Math.floor((index / cells) * GRADIENT.length))]!
60 : TRACK
61 return { glyph: index === pace ? '▏' : ' ', color: TICK, background, isFilled }
62 })
63}
64
65/** How far through its window the clock is, 0 to 1; null when the window's length or reset is unknown. */
66export function elapsedShare(kind: string, resetsAt: string | undefined, now: number): number | null {
67 const length = WINDOW_MS[kind]
68 if (!length || !resetsAt) return null
69 const at = Date.parse(resetsAt)
70 if (Number.isNaN(at)) return null
71 return Math.min(1, Math.max(0, 1 - (at - now) / length))
72}
73
74/**
75 * When a window resets, short: "47m" or "2h13m" within a day, otherwise the weekday and hour ("Thu 14:00").
76 * Times are local to the machine.
77 */
78export function resetLabel(resetsAt: string | undefined, now: number): string | null {
79 if (!resetsAt) return null
80 const at = Date.parse(resetsAt)
81 if (Number.isNaN(at)) return null
82 const minutes = Math.max(0, Math.round((at - now) / 60_000))
83 if (minutes < 60) return `${minutes}m`
84 if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
85 const date = new Date(at)
86 return `${DAYS[date.getDay()]} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
87}
88
89/** "claude-opus-5-5" -> "Opus 5.5"; "claude-sonnet-5-5[1m]" -> "Sonnet 5.5 1M"; other names unchanged. */
90export function modelName(model: string): string {
91 const match = /^claude-([a-z]+)-(\d+)(?:-(\d+))?(?:-\d{8})?(\[1m\])?$/i.exec(model)
92 if (!match) return model
93 const [, family, major, minor, wide] = match
94 const name = `${family![0]!.toUpperCase()}${family!.slice(1)} ${major}${minor ? `.${minor}` : ''}`
95 return wide ? `${name} 1M` : name
96}
97
98/**
99 * Reads `git rev-parse --path-format=absolute --git-dir --git-common-dir --show-toplevel` and
100 * `git status --porcelain=v2 --branch`. A linked worktree has its own git dir, apart from the common one.
101 */
102export function parseGitStatus(revParse: string, status: string): GitPlace | null {
103 const [gitDir, commonDir, top] = revParse.trim().split('\n')
104 if (!gitDir || !commonDir || !top) return null
105
106 let head = 'HEAD'
107 let oid = ''
108 let ahead = 0
109 let behind = 0
110 let staged = 0
111 let modified = 0
112 let untracked = 0
113 for (const line of status.split('\n')) {
114 if (line.startsWith('# branch.head ')) head = line.slice('# branch.head '.length)
115 else if (line.startsWith('# branch.oid ')) oid = line.slice('# branch.oid '.length)
116 else if (line.startsWith('# branch.ab ')) {
117 const [a, b] = line.slice('# branch.ab '.length).split(' ')
118 ahead = Math.abs(Number(a) || 0)
119 behind = Math.abs(Number(b) || 0)
120 } else if (line.startsWith('? ')) untracked += 1
121 else if (/^[12u] /.test(line)) {
122 const xy = line.split(' ')[1] ?? '..'
123 if (xy[0] !== '.') staged += 1
124 if (xy[1] !== '.') modified += 1
125 }
126 }
127 const branch = head === '(detached)' ? `detached ${oid.slice(0, 7)}` : head
128 const place: GitPlace = { branch, staged, modified, untracked, ahead, behind }
129 if (gitDir !== commonDir) place.worktree = top.split('/').pop() ?? top
130
131 return place
132}
133types/index.d.ts 40 lines1export type Limit = {
2 kind: string
3 percentUsed: number
4 resetsAt?: string
5}
6
7export type GitPlace = {
8 branch: string
9 /** The worktree's folder name when the session is in a linked worktree; absent in the main checkout. */
10 worktree?: string
11 staged: number
12 modified: number
13 untracked: number
14 ahead: number
15 behind: number
16}
17
18/** Whether the limit bars show what you've used or what's left. */
19export type Show = 'used' | 'remaining'
20
21export type Snapshot = {
22 model: string
23 contextPercent: number | null
24 costUsd: number | null
25 git: GitPlace | null
26}
27
28declare module 'claude-code' {
29 interface PluginState {
30 'usage-bar': {
31 limits: Limit[]
32 snapshot: Snapshot | null
33 git: GitPlace | null
34 folder: string
35 now: number
36 show: Show
37 }
38 }
39}
40