SLOPSHOPPER

usage-line

Rate-limit, context, model, effort and advisor readout above the prompt, with toasts when a window runs high

newbandtoastprompttimer
v0.1.1MITupdated 2026-10-10wolves/usage-line
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-line
› 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% | ctx 49% | Opus 5.5 | Advisor: off ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5h 31% | ctx 49% | Opus 5.5 | Advisor: off
README

usage-line

A Claude Code mod that keeps your usage in view, right-aligned in the band above the prompt:

5h 42% (2h 30m) | wk 12% | ctx 85% | Opus 5.5 | high | Advisor: off
  • 5h: the five-hour rate-limit window, with the time until it resets
  • wk: the seven-day window
  • ctx: how full the context window is
  • the model, the effort level (colored by level: grey for low, then white, cyan, blue and magenta up to max), and the advisor model (off when none is set)

A figure at or past the warning threshold turns bold red, and a toast fires once when a figure crosses each threshold. It fires again only after the figure drops back below that threshold, for example after a window resets or a compaction.

Install

/plugin install usage-line --marketplace wolves/usage-line

Answer y to add the marketplace, then pick a scope. The user scope loads it in every session.

Options

Set these in /config:

OptionDefaultWhat it does
Warn at (%)80Turns a figure red and sends the first toast
Critical at (%)95Sends a second toast

How it updates

The band redraws only when something it shows changes:

  • Usage figures: after each turn, and whenever a rate-limit window moves a whole point
  • Model: on each model request in the main conversation
  • Effort and advisor: read from effortLevel and advisorModel in settings, checked every 10 seconds and on each prompt and turn
  • Reset countdown: when the minute changes

Development

claude --plugin-dir .          # run a session with the mod loaded and hot-reloading
claude plugin validate .       # check the manifest and hooks module
claude plugin test .           # run hooks/*.test.ts

When the mod loads from this folder, Claude Code writes the API's type declarations to .claude-plugin/types/ (gitignored). After that, tsc -p . type-checks it.

Two renderer details to know about when changing the colors:

  • Use ansi256(n) (0–15 is the terminal's own palette), #hex, rgb() or a theme key. A bare name like "yellow" passes validation but is drawn uncolored.
  • dimColor draws the theme's grey and replaces any color set beside it. It is not ANSI dim.

License

MIT

Source 2 files
hooks/register.tsx 217 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit, SessionUsage } from 'claude-code'
3
4import type { Usage, Window } from '../types'
5
6// The figures from session.measure, the model and effort the last main-thread
7// request carried, the effort and advisor as /effort and /advisor keep them in
8// settings, the 5h window's countdown as drawn, and the alerts already shown.
9const usage = atom({ plugin: 'usage-line', key: 'usage' } as const, null)
10const request = atom({ plugin: 'usage-line', key: 'request' } as const, {})
11const settings = atom({ plugin: 'usage-line', key: 'settings' } as const, {})
12const countdown = atom({ plugin: 'usage-line', key: 'countdown' } as const, '')
13const alerted = atom({ plugin: 'usage-line', key: 'alerted' } as const, [])
14
15const LABELS = { fiveHour: '5h', week: 'wk', context: 'ctx' } as const
16
17// The terminal's own palette, as the old status line's ANSI codes drew it.
18// A bare name like 'yellow' validates but is not drawn, and `dimColor` is the
19// theme's grey rather than ANSI dim, replacing any color it is set beside.
20const YELLOW = 'ansi256(3)'
21const WHITE = 'ansi256(7)'
22const RED = 'ansi256(1)'
23
24// Effort warms as it rises; low is left in the theme's grey. A level not
25// listed here (a numeric budget) is drawn white.
26const EFFORT_COLORS: Readonly<Record<string, string | undefined>> = {
27  low: undefined,
28  medium: WHITE,
29  high: 'ansi256(6)',
30  xhigh: 'ansi256(4)',
31  max: 'ansi256(5)',
32}
33
34const toWindow = (limit: SessionRateLimit | undefined): Window | undefined =>
35  limit && {
36    percent: limit.percentUsed,
37    resetsAt: limit.resetsAt ? Date.parse(limit.resetsAt) : undefined,
38  }
39
40const toUsage = (e: Pick<SessionUsage, 'context' | 'rateLimits'>): Usage => ({
41  fiveHour: toWindow(e.rateLimits.find(l => l.kind === 'five_hour')),
42  week: toWindow(e.rateLimits.find(l => l.kind === 'seven_day')),
43  context: e.context.percent,
44})
45
46const percentOf = (figures: Usage, key: keyof typeof LABELS) =>
47  key === 'context' ? figures.context : figures[key]?.percent
48
49const asText = (value: unknown): string | undefined =>
50  typeof value === 'string' && value !== '' ? value : undefined
51
52// "claude-opus-5-5[1m]" -> "Opus 5.5 [1m]"; anything else passes through.
53export const prettyModel = (id: string): string => {
54  const match = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(\[.+\])?$/.exec(id)
55  if (!match) return id
56  const [, family = '', major, minor, suffix] = match
57  const name = family.charAt(0).toUpperCase() + family.slice(1)
58  return `${name} ${major}${minor ? `.${minor}` : ''}${suffix ? ` ${suffix}` : ''}`
59}
60
61export const formatRemaining = (ms: number): string => {
62  const minutes = Math.max(0, Math.floor(ms / 60_000))
63  return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
64}
65
66// Values a timer or every request writes again are compared first, so an
67// unchanged one redraws nothing.
68const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
69
70// Toasts each figure once per threshold it crosses (once in all when a jump
71// crosses several), and forgets a threshold when the figure drops back under
72// it (a window reset, a compaction) so it can fire again.
73async function alert($: EngineInterface, figures: Usage, thresholds: readonly number[]) {
74  const fired = new Set(await read($, alerted))
75
76  for (const key of Object.keys(LABELS) as (keyof typeof LABELS)[]) {
77    const value = percentOf(figures, key) ?? 0
78    const crossed = thresholds.filter(t => value >= t && !fired.has(`${key}:${t}`))
79
80    for (const t of thresholds) {
81      if (value < t) fired.delete(`${key}:${t}`)
82    }
83    for (const t of crossed) fired.add(`${key}:${t}`)
84
85    if (crossed.length > 0) $.ui.toast(`${LABELS[key]} usage at ${Math.round(value)}%`)
86  }
87
88  await update($, alerted, () => [...fired])
89}
90
91// Rereads the settings /effort and /advisor write, and moves the countdown
92// on; the band redraws only when one of them reads differently.
93async function refresh($: EngineInterface) {
94  const [stored, figures, time, shown, left] = await Promise.all([
95    $.settings.read(),
96    read($, usage),
97    $.clock.now(),
98    read($, settings),
99    read($, countdown),
100  ])
101  const chosen = { effort: asText(stored.effortLevel), advisor: asText(stored.advisorModel) }
102  const resetsAt = figures?.fiveHour?.resetsAt
103  const remaining = resetsAt ? formatRemaining(resetsAt - time) : ''
104
105  if (!same(shown, chosen)) await update($, settings, () => chosen)
106  if (remaining !== left) await update($, countdown, () => remaining)
107}
108
109export const register: Register = (on, options) => {
110  const warnAt = Number(options.warnAt)
111  const thresholds = [warnAt, Number(options.criticalAt)]
112
113  on('session.start', async ($, e, next) => {
114    const [measured, model] = await Promise.all([$.session.usage(), $.session.model()])
115    const figures = toUsage(measured)
116
117    await update($, usage, () => figures)
118    await update($, request, r => ({ ...r, model: r.model ?? model }))
119    await refresh($)
120    await alert($, figures, thresholds)
121
122    $.clock.every(10_000, () => refresh($))
123
124    return next(e)
125  })
126
127  on('session.measure', async ($, e, next) => {
128    const figures = toUsage(e)
129
130    await update($, usage, () => figures)
131    await refresh($)
132    await alert($, figures, thresholds)
133
134    return next(e)
135  })
136
137  on('turn.step', async function* ($, e, next) {
138    if (e.agentId === undefined) {
139      const effort = e.effort === undefined ? undefined : String(e.effort)
140      const carried = { model: e.model, effort }
141      if (!same(await read($, request), carried)) await update($, request, () => carried)
142    }
143
144    return yield* next(e)
145  })
146
147  on('prompt.submit', async ($, e, next) => {
148    await refresh($)
149    return next(e)
150  })
151
152  on('turn.complete', async ($, e, next) => {
153    await refresh($)
154    return next(e)
155  })
156
157  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
158    const [figures, last, chosen, remaining] = await Promise.all([
159      read($, usage),
160      read($, request),
161      read($, settings),
162      read($, countdown),
163    ])
164
165    if (e.props.hasSurvey || (figures === null && last.model === undefined)) {
166      return next(e)
167    }
168
169    // `muted` follows `text` in the theme's grey: a value that is only a default.
170    const parts: { key: string; text: string; muted?: string; color?: string; bold?: boolean }[] = []
171
172    for (const key of Object.keys(LABELS) as (keyof typeof LABELS)[]) {
173      const value = figures ? percentOf(figures, key) : undefined
174      if (value === undefined) continue
175
176      const rounded = Math.round(value)
177      const suffix = key === 'fiveHour' && remaining ? ` (${remaining})` : ''
178      const isHot = rounded >= warnAt
179
180      parts.push({
181        key,
182        text: `${LABELS[key]} ${rounded}%${suffix}`,
183        color: isHot ? RED : undefined,
184        bold: isHot,
185      })
186    }
187
188    const effort = chosen.effort ?? last.effort
189    if (last.model) parts.push({ key: 'model', text: prettyModel(last.model), color: YELLOW })
190    if (effort) {
191      const color = effort in EFFORT_COLORS ? EFFORT_COLORS[effort] : WHITE
192      parts.push({ key: 'effort', text: effort, color })
193    }
194    parts.push(
195      chosen.advisor
196        ? { key: 'advisor', text: `Advisor: ${prettyModel(chosen.advisor)}`, color: WHITE }
197        : { key: 'advisor', text: 'Advisor: ', muted: 'off', color: WHITE },
198    )
199
200    const { Box, Text } = $.ui.resolve(e)
201
202    return (
203      <Box key="usage-line" justifyContent="flex-end" flexGrow={1}>
204        {parts.map((part, i) => (
205          <Text key={part.key} wrap="truncate">
206            {i > 0 ? <Text dimColor> | </Text> : null}
207            <Text color={part.color} dimColor={part.color === undefined} bold={part.bold}>
208              {part.text}
209            </Text>
210            {part.muted ? <Text dimColor>{part.muted}</Text> : null}
211          </Text>
212        ))}
213      </Box>
214    )
215  })
216}
217
types/index.d.ts 27 lines
1export type Window = { percent: number; resetsAt?: number }
2
3export type Usage = {
4  fiveHour?: Window
5  week?: Window
6  context?: number
7}
8
9// What the last main-thread request carried.
10export type Request = { model?: string; effort?: string }
11
12// What /effort and /advisor keep in settings.
13export type Chosen = { effort?: string; advisor?: string }
14
15declare module 'claude-code' {
16  interface PluginState {
17    'usage-line': {
18      usage: Usage | null
19      request: Request
20      settings: Chosen
21      // The 5h window's time left as drawn ('2h 30m'); '' when it has none.
22      countdown: string
23      alerted: readonly string[]
24    }
25  }
26}
27