SLOPSHOPPER

usage

Band above prompt: ctx %, prompt-cache countdown and hit rate, 5h/7d quota used

newbandprocesstimer
v0.1.8no licenseupdated 2026-10-07Hsiang-LinC/usage-band
A shopper browsing a rack in a slop shop
README

usage-band

Band above the prompt: ctx %, cache countdown (1h TTL) and hit rate, 5h/7d quota used, colored by level (quota green <50%, yellow <80%, red after; cache green >5m, yellow ≤5m, red cold; ctx same, and from 80% suggests /compact or a handoff)

claude plugin marketplace add Hsiang-LinC/usage-band
claude plugin install usage@usage-band

Private repo: needs gh auth login (or any git credential for github.com) on the machine.

Test: claude plugin test .

Appearance

The band uses a Wada-inspired green / ochre / vermilion palette, with light/dark text colours and no background fill, so the terminal background shows through. Labels keep the terminal's normal monospace font; percentages and countdowns are bold. Separators use a neutral colour. The working star cycles ochre → blue-grey → vermilion: a shape changes every 200ms, and each colour holds for 1.2s, without opacity blinking.

Built-in light/dark variants follow Claude Code's selected theme. For auto and custom themes, this version follows macOS system appearance, checked at most once every 5 seconds; it does not infer a custom theme's background. Automatic appearance detection currently requires macOS. Use an explicit built-in light/dark theme on other hosts.

Source 2 files
hooks/register.tsx 197 lines
1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3import { formatSegments, type Level, type LineInput } from './format'
4
5type CacheUsage = NonNullable<NonNullable<LineInput['cache']>['usage']>
6// newest main-thread request whose response reported usage
7let answered: { sentAt: number; usage: CacheUsage } | undefined
8// main-thread requests in flight, by identity (two may share a millisecond);
9// the icon grows only while this is non-empty
10const pending = new Set<{ sentAt: number }>()
11let contextPercent: number | undefined
12let rateLimits: LineInput['rateLimits'] = []
13let tick: Timer | undefined
14let poll: Timer | undefined
15let frame = 0
16let anim: Timer | undefined
17
18const POLL_MS = 5_000
19const ANIM_MS = 200
20const STARS = ['✶', '✴', '✷', '✦', '✧', '✦']
21// One full shape cycle (1.2s) per colour; no opacity blinking.
22const FRAME_COUNT = STARS.length * 3
23const PALETTES = {
24  light: {
25    neutral: '#61675F',
26    colors: { ok: '#286044', warn: '#755812', bad: '#983E32', none: '#61675F' },
27    stars: ['#755812', '#416579', '#983E32'],
28  },
29  dark: {
30    neutral: '#A2A99F',
31    colors: { ok: '#82B58B', warn: '#C5A35B', bad: '#DC9180', none: '#A2A99F' },
32    stars: ['#C5A35B', '#8BAABD', '#DC9180'],
33  },
34} satisfies Record<'light' | 'dark', {
35  neutral: string; colors: Record<Level, string>; stars: string[]
36}>
37
38// The mod API exposes the selected theme, not auto's resolved appearance.
39// On macOS, auto/custom themes follow system appearance, cached for 5s.
40let systemAppearance: Promise<'light' | 'dark'> | undefined
41let appearanceExpires = 0
42async function palette($: EngineInterface) {
43  const theme = (await $.config.list()).find(row => row.key === 'theme')?.value
44  if (typeof theme !== 'string') throw new Error('usage-band: missing theme config')
45  if (/^light(?:-|$)/.test(theme)) return PALETTES.light
46  if (/^dark(?:-|$)/.test(theme)) return PALETTES.dark
47  if (theme !== 'auto' && !theme.startsWith('custom:')) {
48    throw new Error('usage-band: unsupported theme selection')
49  }
50  const now = await $.clock.now()
51  if (!systemAppearance || now >= appearanceExpires) {
52    appearanceExpires = now + POLL_MS
53    systemAppearance = $.process.run(
54      ['/usr/bin/defaults', 'read', '-g', 'AppleInterfaceStyle'],
55      { timeoutMs: 1000 },
56    ).then(result => {
57      if (result.exitCode === 0 && result.stdout.trim() === 'Dark') return 'dark'
58      if (result.exitCode === 1 && /AppleInterfaceStyle.*does not exist/.test(result.stderr)) return 'light'
59      throw new Error('usage-band: cannot read macOS system appearance')
60    })
61  }
62  return PALETTES[await systemAppearance]
63}
64
65const redraw = ($: EngineInterface) => $.ui.invalidate('ui.render')
66
67// The countdown runs from the newest request sent, answered or still in
68// flight; the hit rate is the newest answered one's.
69function cacheState(): LineInput['cache'] {
70  let sentAt = answered?.sentAt
71  for (const request of pending) {
72    if (sentAt === undefined || request.sentAt > sentAt) sentAt = request.sentAt
73  }
74  return sentAt === undefined ? undefined : { sentAt, usage: answered?.usage }
75}
76
77// A minute ticker phased on the countdown's start, so the band redraws
78// exactly when a whole minute crosses.
79function phaseTicker($: EngineInterface, now: number) {
80  tick?.cancel()
81  tick = undefined
82  const cache = cacheState()
83  if (cache === undefined) return
84  tick = $.clock.after(60_000 - ((now - cache.sentAt) % 60_000), () => {
85    redraw($)
86    tick = $.clock.every(60_000, () => redraw($))
87  })
88}
89
90async function refreshUsage($: EngineInterface) {
91  const u = await $.session.usage()
92  contextPercent = u.context.percent
93  rateLimits = u.rateLimits
94  redraw($)
95}
96
97export const register: Register = on => {
98  on('session.start', async ($, e, next) => {
99    await refreshUsage($)
100    // keeps 5h/7d fresh in idle sessions, where no measure event fires
101    poll?.cancel()
102    poll = $.clock.every(POLL_MS, () => refreshUsage($))
103    return next(e)
104  })
105
106  on('session.measure', ($, e, next) => {
107    contextPercent = e.context.percent
108    rateLimits = e.rateLimits
109    redraw($)
110    return next(e)
111  })
112
113  // per request, not per turn: a turn's usage sums every request of its tool
114  // loop, and only the latest request says how warm the cache is now
115  on('turn.step', async function* ($, e, next) {
116    // sub-agents send other prefixes; they neither read nor refresh this cache
117    if (e.agentId !== undefined) return yield* next(e)
118    const request = { sentAt: await $.clock.now() }
119    // the TTL restarts as the request is sent, so count down from now at once,
120    // keeping the last answered hit rate until this response reports its own
121    pending.add(request)
122    phaseTicker($, request.sentAt)
123    if (pending.size === 1) {
124      frame = 0
125      anim = $.clock.every(ANIM_MS, () => {
126        frame = (frame + 1) % FRAME_COUNT
127        redraw($)
128      })
129    }
130    redraw($)
131    try {
132      const r = yield* next(e)
133      if (r.usage && (answered === undefined || request.sentAt >= answered.sentAt)) {
134        answered = {
135          sentAt: request.sentAt,
136          usage: {
137            input: r.usage.input_tokens,
138            cacheRead: r.usage.cache_read_input_tokens,
139            cacheWrite: r.usage.cache_creation_input_tokens,
140          },
141        }
142      }
143      return r
144    } finally {
145      // a request that failed or was cut off without usage confirms nothing
146      // about the cache: dropping it rolls the countdown back
147      pending.delete(request)
148      if (pending.size === 0) {
149        anim?.cancel()
150        anim = undefined
151        frame = 0
152      }
153      phaseTicker($, await $.clock.now())
154      redraw($)
155    }
156  })
157
158  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
159    if (e.props.hasSurvey) return next(e)
160    const segments = formatSegments({
161      contextPercent,
162      rateLimits,
163      cache: cacheState(),
164      now: await $.clock.now(),
165    })
166    if (segments.length === 0) return next(e)
167
168    const { Box, Text } = $.ui.resolve(e)
169    const theme = await palette($)
170    return (
171      <Box paddingX={1}>
172        {/* the frames' glyphs differ in width where the font falls back (desktop), so a fixed cell keeps the text after still */}
173        <Box width={2} flexShrink={0}>
174          <Text color={theme.stars[Math.floor(frame / STARS.length)]}>{STARS[frame % STARS.length]}</Text>
175        </Box>
176        {/* one inline run: the desktop draws Box as a flex row and trims the
177            whitespace at each flex item's edges, which would eat the separators' spaces */}
178        <Text>
179          {segments.map((s, i) => (
180            <Text key={String(i)}>
181              {i > 0 ? (
182                <Text color={theme.neutral}>{segments[i - 1].group === s.group ? ' · ' : ' │ '}</Text>
183              ) : null}
184              <Text color={theme.colors[s.level]}>
185                <Text>{s.text.slice(0, s.text.indexOf(' ') + 1)}</Text>
186                {s.text.slice(s.text.indexOf(' ') + 1).split(/(<?\d+(?:h\d+)?[hm%])/).filter(text => text !== '').map((text, j) => (
187                  <Text key={String(j)} bold={/^(<?\d+(?:h\d+)?[hm%])$/.test(text)}>{text}</Text>
188                ))}
189              </Text>
190            </Text>
191          ))}
192        </Text>
193      </Box>
194    )
195  })
196}
197
hooks/format.ts 85 lines
1export const CACHE_TTL_MS = 60 * 60 * 1000
2
3export type Level = 'ok' | 'warn' | 'bad' | 'none'
4// session: ctx and cache; quota: 5h and 7d. The band separates the two groups.
5export type Segment = { text: string; level: Level; group: 'session' | 'quota' }
6
7export type LineInput = {
8  contextPercent?: number
9  rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[]
10  // Absent until a main-thread request has been sent.
11  cache?: {
12    // When the newest counted request was sent (ms): the TTL restarts when a
13    // request reads or writes the cache, not when its response ends.
14    sentAt: number
15    // Figures of the newest request that answered; absent while none has.
16    usage?: { input: number; cacheRead: number; cacheWrite: number }
17  }
18  now: number
19}
20
21// Green <50, yellow <80, red after; shared by ctx and quota.
22const byUsed = (pct: number): Level => (pct >= 80 ? 'bad' : pct >= 50 ? 'warn' : 'ok')
23
24// "2h13m" / "45m"; undefined once the window has already reset.
25function untilReset(resetsAt: string | undefined, now: number): string | undefined {
26  if (resetsAt === undefined) return undefined
27  const ms = Date.parse(resetsAt) - now
28  if (!(ms > 0)) return undefined
29  const mins = Math.max(1, Math.floor(ms / 60_000))
30  return mins >= 60 ? `${Math.floor(mins / 60)}h${String(mins % 60).padStart(2, '0')}m` : `${mins}m`
31}
32
33export function formatSegments(i: LineInput): Segment[] {
34  const parts: Segment[] = []
35
36  if (i.contextPercent !== undefined) {
37    const pct = Math.floor(i.contextPercent)
38    const level = byUsed(pct)
39    const hint = level === 'bad' ? ' → /compact or hand off' : ''
40    parts.push({ text: `ctx ${pct}%${hint}`, level, group: 'session' })
41  }
42
43  const c = i.cache
44  if (c === undefined) {
45    parts.push({ text: 'cache --', level: 'none', group: 'session' })
46  } else {
47    const u = c.usage
48    const total = u ? u.input + u.cacheRead + u.cacheWrite : 0
49    const remaining = c.sentAt + CACHE_TTL_MS - i.now
50    const hit = u && total > 0 ? ` · hit ${Math.floor((u.cacheRead / total) * 100)}%` : ''
51    if (remaining <= 0) {
52      parts.push({ text: 'cache cold', level: 'bad', group: 'session' })
53    } else {
54      const text =
55        remaining < 60_000 ? 'cache <1m' : `cache ${Math.floor(remaining / 60_000)}m`
56      const level: Level = remaining > 5 * 60_000 ? 'ok' : 'warn'
57      parts.push({ text: text + hit, level, group: 'session' })
58    }
59  }
60
61  for (const [kind, label] of [
62    ['five_hour', '5h'],
63    ['seven_day', '7d'],
64  ] as const) {
65    const w = i.rateLimits.find(r => r.kind === kind)
66    if (w) {
67      const pct = Math.floor(w.percentUsed)
68      const reset = kind === 'five_hour' ? untilReset(w.resetsAt, i.now) : undefined
69      parts.push({
70        text: `${label} ${pct}%${reset ? ` ↻ ${reset}` : ''}`,
71        level: byUsed(pct),
72        group: 'quota',
73      })
74    }
75  }
76
77  return parts
78}
79
80export function formatLine(i: LineInput): string {
81  return formatSegments(i)
82    .map(s => s.text)
83    .join(' · ')
84}
85