SLOPSHOPPER

usage-bar

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

newspinnercommandprocesstimer
★ 3v0.1.0MITupdated 2026-10-15omeerdvrn/o-mods/mods/usage-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-bar
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /usage-bar ⎿ usage-bar: Limits now show what you have left. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ▸ app 5h 69% left ⟨Claude Code's own drawing⟩ Opus 5.5 ctx 49% $0.42
README

usage-bar

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

A footer bar under the prompt shows the git branch, 5-hour and 7-day limit bars with percentages, and cost. /usage-bar switches them to what is left.

                     ⎇ feature/eng-1930 +1 ~2 ↑2   5h ████▏░░░░░ 42% ↻ 2h13m   7d ██░░░░░▏░░ 18% ↻ Sun 00:00
                                                        🔥 warm 42°   Opus 5.5   ctx 31%   $1.24

What it shows

  • 5h and 7d limits: how much of your 5-hour and weekly limits you have used, and when each resets.
  • The bar's cells go from green to red along its length, and the percentage turns amber at 70% and red at 90%.
  • The ┃ 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.
  • Git: the branch (⎇), 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.
  • Session: the model, how full the context window is, and the session's cost. Labels other mods add to the footer, such as the heat mod's tier and persona's role, show here too, in whatever order the mods were installed.

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.

Used or remaining

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.

Install

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.

Development

claude --plugin-dir /path/to/o-mods/mods/usage-bar
claude plugin validate .
claude plugin test .
FileContents
hooks/register.tsxReads usage and git; draws the footer
hooks/meter.tsBars, pace, reset times, model names, and git status parsing
types/index.d.tsThe mod's state contract
tests/usage-bar.test.tsTests with the session and git faked
Source 3 files
hooks/register.tsx 197 lines
1import { 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}
197
hooks/meter.ts 133 lines
1import 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}
133
types/index.d.ts 40 lines
1export 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