SLOPSHOPPER

usage-band

Band above the prompt: 5h/7d rate-limit usage with reset countdowns, session cache tokens and cost

newband
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-band
› 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 󰓅 5h 31% 󰔟 NaNm ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
󰓅 5h 31% 󰔟 NaNm
README

usage-band

A band above the Claude Code prompt showing how much of your plan you've used and what the session costs.

󰓅 5h 20%  󰔟 2h 40m   󰃭 7d 58%  󰔟 1d 7h   󰆼 954.2k   󰄔 $4.32
PillShows
󰓅 5hRolling 5-hour rate limit: % used, then time until it resets
󰃭 7dWeekly rate limit: % used, then time until it resets
󰆼Prompt-cache tokens (read + written) since the mod loaded
󰄔Session cost, the same figure as /cost

Notes

  • Rate-limit pills need a subscription login (Pro/Max). With an API key there are no windows to show, so only the cache and cost pills appear. They show up after the session's first API response.
  • Cache tokens count from when the mod loads; the cost is the whole session's, as Claude Code keeps it.
  • Icons are Nerd Font glyphs. Without a Nerd Font in your terminal they show as boxes.
  • Colours assume a dark terminal theme.

Development

claude plugin validate plugins/usage-band
claude plugin test plugins/usage-band

hooks/register.tsx holds the hooks (session.start, session.measure, turn.complete, and the ui.render hook for AbovePrompt); hooks/format.ts holds the pure formatting helpers.

Source 3 files
hooks/register.tsx 73 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Limit, Tokens, Usage } from '../types'
5import { formatRemaining, formatTokens } from './format'
6
7const EMPTY_USAGE: Usage = { limits: [], costUsd: null }
8const EMPTY_TOKENS: Tokens = { cache: 0 }
9const usage = atom({ plugin: 'usage-band', key: 'usage' } as const, EMPTY_USAGE)
10const tokens = atom({ plugin: 'usage-band', key: 'tokens' } as const, EMPTY_TOKENS)
11
12const LABEL: Record<string, string> = { five_hour: '5h', seven_day: '7d' }
13const ICON: Record<string, string> = { five_hour: '󰓅', seven_day: '󰃭' }
14const TINT: Record<string, { bg: string; fg: string }> = {
15  five_hour: { bg: '#24352c', fg: '#8fbf9f' },
16  seven_day: { bg: '#2e2a3d', fg: '#b4a7e6' },
17}
18
19export const register: Register = on => {
20  on('session.start', async ($, e, next) => {
21    const now = await $.session.usage()
22    await update($, usage, () => ({ limits: now.rateLimits, costUsd: now.cost?.usd ?? null }))
23    return next(e)
24  })
25
26  on('session.measure', async ($, e, next) => {
27    await update($, usage, () => ({ limits: e.rateLimits, costUsd: e.cost?.usd ?? null }))
28    return next(e)
29  })
30
31  on('turn.complete', async ($, e, next) => {
32    const u = e.usage
33    if (u) {
34      await update($, tokens, t => ({
35        cache: t.cache + u.cache_read_input_tokens + u.cache_creation_input_tokens,
36      }))
37    }
38    return next(e)
39  })
40
41  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
42    if (e.props.hasSurvey) return next(e)
43    const { Box, Text } = $.ui.resolve(e)
44    const [{ limits, costUsd }, t, now] = await Promise.all([
45      read($, usage),
46      read($, tokens),
47      $.clock.now(),
48    ])
49
50    const pill = (key: string, bg: string, fg: string, text: string) => (
51      <Box key={key} backgroundColor={bg} paddingX={1} marginRight={1}>
52        <Text color={fg}>{text}</Text>
53      </Box>
54    )
55
56    const limitPill = (l: Limit) => {
57      const remaining = l.resetsAt ? Date.parse(l.resetsAt) - now : null
58      const tint = TINT[l.kind] ?? { bg: '#33302a', fg: '#d8b56a' }
59      const reset = remaining === null ? '' : `  󰔟 ${formatRemaining(remaining)}`
60      const label = `${ICON[l.kind] ?? '󰾆'} ${LABEL[l.kind] ?? l.kind} ${Math.round(l.percentUsed)}%`
61      return pill(`limit-${l.kind}`, tint.bg, tint.fg, label + reset)
62    }
63
64    return (
65      <Box flexDirection="row" flexWrap="wrap">
66        {limits.map(limitPill)}
67        {pill('cache', '#262c40', '#8fa3e6', `󰆼 ${formatTokens(t.cache)}`)}
68        {costUsd === null ? null : pill('cost', '#36311f', '#d8b56a', `󰄔 $${costUsd.toFixed(2)}`)}
69      </Box>
70    )
71  })
72}
73
hooks/format.ts 18 lines
1/** 954213 -> "954.2k", 1234567 -> "1.2M", 950 -> "950" */
2export const formatTokens = (n: number): string => {
3  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
4  if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
5  return String(n)
6}
7
8/** Time left as its two largest units: "1d 7h", "2h 40m", "12m" */
9export const formatRemaining = (ms: number): string => {
10  const minutes = Math.max(0, Math.floor(ms / 60_000))
11  const d = Math.floor(minutes / 1440)
12  const h = Math.floor((minutes % 1440) / 60)
13  const m = minutes % 60
14  if (d > 0) return `${d}d ${h}h`
15  if (h > 0) return `${h}h ${m}m`
16  return `${m}m`
17}
18
types/index.d.ts 12 lines
1export type Tokens = { cache: number }
2
3export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
4
5export type Usage = { limits: Limit[]; costUsd: number | null }
6
7declare module 'claude-code' {
8  interface PluginState {
9    'usage-band': { usage: Usage; tokens: Tokens }
10  }
11}
12