SLOPSHOPPER

compact-usage-meter

Context, session and weekly usage meters with reset countdowns on a row under the prompt hint line

newspinnertoaststatustimer
★ 1v0.1.0MITupdated 2026-10-07dkoh0207/compact-usage-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · compact-usage-meter
› 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ Opus 5.5 │ context ▰▰▰▰▱▱▱▱ 49% │ session ▰▰▱▱▱▱▱▱ 31% (--)

Draws

Prompt hint
⟨Claude Code's own drawing⟩ Opus 5.5 │ context ▰▰▰▰▱▱▱▱ 49% │ session ▰▰▱▱▱▱▱▱ 31% (--)
README

compact-usage-meter

A Claude Code mod that shows, on a row under the prompt's hint line:

  • model: the model and its effort, e.g. Sonnet 5.5 (xhigh)
  • Cost: The total cost of the API billing rate. For subscription plans, this is hidden.
  • context: how full the conversation's context window is
  • session: your 5-hour usage limit, with the time until it resets
  • weekly: your 7-day usage limit, with the time until it resets

Example:


The meter under the prompt's hint line: context 12%, session 42% resetting in 3h34m, weekly 10% resetting in 4d23h

On narrow terminals and split panes, the row steps down to shorter forms. First the model line goes, then the bars shrink, then they're dropped, then the reset times, and finally only context is left.

Install

In Claude Code, run:

/plugin install compact-usage-meter --marketplace https://github.com/dkoh0207/compact-usage-meter.git

The short form --marketplace dkoh0207/compact-usage-meter works too.

To try it for one session without installing, clone the repository and start Claude Code with it:

git clone https://github.com/dkoh0207/compact-usage-meter.git
claude --plugin-dir compact-usage-meter

Settings

These constants are at the top of the files in hooks/:

ConstantFileWhat it does
GAPregister.tsxBlank rows between the hint line and the meter (default 1)
GLYPHSformat.ts'blocks' draws ▰▱; 'ascii' draws #- for fonts that render the blocks badly
DIVIDERformat.ts'bar' puts │ between the meters (default); 'dot' puts a center-dot ·
AMBIGUOUS_WIDTHformat.tsSet to 2 if your locale draws ▰▱ double-width (some East Asian setups)

Development

claude plugin validate .
claude plugin test .
Source 3 files
hooks/register.tsx 196 lines
1import { atom, read, update } from 'claude-code'
2import type {
3  Color,
4  EngineInterface,
5  Register,
6  SessionCost,
7  SessionRateLimit,
8  Timer,
9} from 'claude-code'
10
11import { BAR_GAP, fitParts, room, SEPARATOR } from './format'
12import type { Level, Part } from './format'
13import type { Reading } from '../types'
14
15const reading = atom({ plugin: 'compact-usage-meter', key: 'reading' } as const, null)
16
17const THRESHOLDS = [95, 80]
18
19const EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max'])
20
21// Blank rows between the engine's hint line and the meter.
22const GAP = 1
23
24const NAMES: Record<string, string> = {
25  five_hour: 'Session usage',
26  seven_day: 'Weekly usage',
27}
28
29// Each meter's own hue, from the theme so it suits light and dark.
30const HUES: Record<string, Color> = {
31  context: 'suggestion',
32  session: 'planMode',
33  weekly: 'merged',
34}
35
36// The percentage leaves the meter's hue only to warn.
37const WARNINGS: Record<Level, Color | undefined> = {
38  low: undefined,
39  mid: 'warning',
40  high: 'error',
41}
42
43// What a usage reading carries; the model is read at draw time, the effort from turn.step.
44type Figures = Omit<Reading, 'nowMs' | 'model' | 'effort'>
45
46let tick: Timer | undefined
47const warned = new Set<string>()
48
49function toFigures(usage: {
50  context: { percent?: number }
51  rateLimits: SessionRateLimit[]
52  cost?: SessionCost
53}): Figures {
54  return {
55    contextPercent: usage.context.percent,
56    rateLimits: usage.rateLimits,
57    cost: usage.cost?.usd,
58  }
59}
60
61async function record($: EngineInterface, figures?: Figures) {
62  const latest = figures ?? toFigures(await $.session.usage())
63  const nowMs = await $.clock.now()
64  await update($, reading, previous => ({ ...latest, effort: previous?.effort, nowMs }))
65}
66
67// The effort of the latest main-loop request; kept across readings until the next one.
68async function noteEffort($: EngineInterface, effort: string | number | undefined) {
69  const label = effort === undefined ? undefined : String(effort)
70  await update($, reading, previous =>
71    previous === null ? previous : { ...previous, effort: label },
72  )
73}
74
75// One toast per window per threshold; re-armed once usage drops below it.
76function warn($: EngineInterface, limits: SessionRateLimit[]) {
77  for (const { kind, percentUsed } of limits) {
78    const name = NAMES[kind]
79
80    if (name === undefined) {
81      continue
82    }
83
84    for (const threshold of THRESHOLDS) {
85      if (percentUsed < threshold) {
86        warned.delete(`${kind}@${threshold}`)
87      }
88    }
89
90    const crossed = THRESHOLDS.find(t => percentUsed >= t)
91
92    if (crossed !== undefined && !warned.has(`${kind}@${crossed}`)) {
93      THRESHOLDS.filter(t => t <= crossed).forEach(t => warned.add(`${kind}@${t}`))
94      $.ui.toast(`${name} at ${percentUsed}%`)
95    }
96  }
97}
98
99export const register: Register = on => {
100  on('session.start', async ($, e, next) => {
101    // v0.1 pinned a status line; take it down.
102    $.ui.status(undefined)
103    tick?.cancel()
104    tick = $.clock.every(60_000, () => void record($))
105    await record($)
106
107    return next(e)
108  })
109
110  on('session.measure', async ($, e, next) => {
111    await record($, toFigures(e))
112    warn($, e.rateLimits)
113
114    return next(e)
115  })
116
117  // Effort rides on each model request. Subagents have their own; the stream passes untouched.
118  on('turn.step', async function* ($, e, next) {
119    if (e.agentId === undefined) {
120      await noteEffort($, e.effort)
121    }
122
123    return yield* next(e)
124  })
125
126  // A switch from anywhere (/model, its picker, /config, a fallback) redraws now, not at the
127  // next reading; the drawing reads the model itself.
128  on('classic.PostModelSwitch', async ($, e, next) => {
129    await record($)
130
131    return next(e)
132  })
133
134  // `/effort <level>` shows at once. Its picker passes no level, so that one waits for the
135  // next request.
136  on('command.run', { command: 'effort' }, async ($, e, next) => {
137    const result = await next(e)
138    const level = e.args.trim().toLowerCase()
139
140    if (EFFORTS.has(level)) {
141      await noteEffort($, level)
142    }
143
144    return result
145  })
146
147  // A row of its own, under the engine's hint line ("⏵⏵ auto mode on …").
148  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
149    const engine = await next(e)
150    const current = await read($, reading)
151    // Read per draw, so a /model switch shows at the next redraw, not the next reading.
152    const model = current === null ? undefined : await $.session.model()
153    const columns = e.viewport?.columns ?? 80
154    const parts = current === null ? undefined : fitParts({ ...current, model }, room(columns))
155
156    if (parts === undefined) {
157      return engine
158    }
159
160    const { Box, Text } = $.ui.resolve(e)
161    const drawPart = (part: Part, index: number) => {
162      const hue = HUES[part.label]
163      const separator = index > 0 ? <Text dimColor>{SEPARATOR}</Text> : null
164
165      // The info part is plain text: no hue, bar or percentage.
166      if (part.pct === undefined) {
167        return (
168          <Text key={`part-${index}`} wrap="truncate">
169            {separator}
170            <Text dimColor>{part.label}</Text>
171          </Text>
172        )
173      }
174
175      return (
176        <Text key={`part-${index}`} wrap="truncate">
177          {separator}
178          <Text color={hue}>{part.label} </Text>
179          {part.bar === undefined ? null : <Text color={hue}>{part.bar}{BAR_GAP} </Text>}
180          <Text color={WARNINGS[part.level] ?? hue} bold={part.level !== 'low'}>{part.pct}</Text>
181          {part.reset === undefined ? null : <Text dimColor> {part.reset}</Text>}
182        </Text>
183      )
184    }
185
186    // The terminal draws the engine's line ahead of anything else in the tree,
187    // whatever its place, so the meter goes under it, GAP blank rows between.
188    return (
189      <Box flexDirection="column">
190        {engine}
191        <Box flexDirection="row" marginTop={GAP}>{parts.map(drawPart)}</Box>
192      </Box>
193    )
194  })
195}
196
hooks/format.ts 237 lines
1import type { Reading } from '../types'
2
3// The hint line's left gutter, and one cell kept free at the right edge.
4const GUTTER = 2
5const EDGE = 1
6// 'ascii' draws bars as #/- for terminals that render ▰▱ double-width.
7export const GLYPHS: Glyphs = 'blocks'
8// Cells per ▰/▱; set 2 where an East-Asian locale draws ambiguous glyphs wide.
9export const AMBIGUOUS_WIDTH = 1
10
11// 'bar' puts │ between the meters, 'dot' puts · there; both take one cell.
12export const DIVIDER: Divider = 'bar'
13export const DIVIDERS = { bar: '│', dot: '·' } as const
14
15export const SEPARATOR = ` ${DIVIDERS[DIVIDER]} `
16const INFO_SEPARATOR = ' · '
17
18export type Divider = keyof typeof DIVIDERS
19export type Glyphs = 'blocks' | 'ascii'
20export type Level = 'low' | 'mid' | 'high'
21
22// The info part (model, effort, cost) is label-only: no bar, no percentage.
23export type Part = {
24  label: string
25  bar?: string
26  pct?: string
27  reset?: string
28  level: Level
29}
30
31export type FitOptions = {
32  glyphs?: Glyphs
33  ambiguousWidth?: number
34}
35
36type Tier = { barCells: number; hasResets: boolean; hasLimits: boolean; hasInfo: boolean }
37
38// Richest first; the first that fits the room is drawn. Only the richest carries
39// the info part, so it is the first thing to go when the room narrows.
40const TIERS: Tier[] = [
41  { barCells: 8, hasResets: true, hasLimits: true, hasInfo: true },
42  { barCells: 8, hasResets: true, hasLimits: true, hasInfo: false },
43  { barCells: 4, hasResets: true, hasLimits: true, hasInfo: false },
44  { barCells: 0, hasResets: true, hasLimits: true, hasInfo: false },
45  { barCells: 0, hasResets: false, hasLimits: true, hasInfo: false },
46  { barCells: 0, hasResets: false, hasLimits: false, hasInfo: false },
47]
48
49const LIMITS = [
50  { kind: 'five_hour', label: 'session' },
51  { kind: 'seven_day', label: 'weekly' },
52] as const
53
54const BAR = {
55  blocks: { full: '▰', empty: '▱' },
56  ascii: { full: '#', empty: '-' },
57} as const
58
59const AMBIGUOUS = new Set(['▰', '▱'])
60
61export const cellWidth = (text: string, ambiguousWidth = AMBIGUOUS_WIDTH) => {
62  let cells = 0
63
64  for (const char of text) {
65    cells += AMBIGUOUS.has(char) ? ambiguousWidth : 1
66  }
67
68  return cells
69}
70
71const clamp = (n: number, low: number, high: number) =>
72  Math.min(high, Math.max(low, n))
73
74export const level = (pct: number | undefined): Level =>
75  pct === undefined || pct < 50 ? 'low' : pct <= 80 ? 'mid' : 'high'
76
77export const bar = (pct: number | undefined, cells: number, glyphs: Glyphs = GLYPHS) => {
78  const filled = pct === undefined ? 0 : Math.round((clamp(pct, 0, 100) / 100) * cells)
79  const { full, empty } = BAR[glyphs]
80
81  return full.repeat(filled) + empty.repeat(cells - filled)
82}
83
84// Unpadded, so no gap opens between the bar and the number: '--%', '6%', '13%', '100%'.
85export const pctText = (pct: number | undefined) =>
86  pct === undefined
87    ? '--%'
88    : `${String(clamp(Math.round(pct), 0, 999))}%`
89
90// '45m', '3h12m', '14h', '2d05h', 'now', '--'.
91export const countdown = (resetsAt: string | undefined, nowMs: number) => {
92  const at = resetsAt === undefined ? NaN : Date.parse(resetsAt)
93
94  if (Number.isNaN(at)) {
95    return '--'
96  }
97
98  const minutes = Math.ceil((at - nowMs) / 60_000)
99
100  if (minutes <= 0) {
101    return 'now'
102  }
103
104  if (minutes < 60) {
105    return `${minutes}m`
106  }
107
108  const hours = Math.floor(minutes / 60)
109
110  if (hours < 10) {
111    return `${hours}h${String(minutes % 60).padStart(2, '0')}m`
112  }
113
114  if (hours < 24) {
115    return `${hours}h`
116  }
117
118  const days = Math.floor(hours / 24)
119
120  return days < 10
121    ? `${days}d${String(hours % 24).padStart(2, '0')}h`
122    : `${days}d`
123}
124
125const title = (family: string, major?: string, minor?: string) => {
126  const name = family.charAt(0).toUpperCase() + family.slice(1)
127
128  return major === undefined ? name : `${name} ${major}${minor === undefined ? '' : `.${minor}`}`
129}
130
131// 'claude-sonnet-5-5' -> 'Sonnet 5.5', 'claude-haiku-4-5-20251001' -> 'Haiku 4.5',
132// 'claude-3-5-sonnet-20241022' -> 'Sonnet 3.5', 'opus[1m]' -> 'Opus'; anything else as given.
133export const modelName = (id: string) => {
134  const bare = id.replace(/\[[^\]]*\]$/, '')
135  const named = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?$/.exec(bare)
136  const numbered = /^claude-(\d+)(?:-(\d{1,2}))?-([a-z]+)(?:-\d{8})?$/.exec(bare)
137
138  if (named !== null) {
139    return title(named[1], named[2], named[3])
140  }
141
142  if (numbered !== null) {
143    return title(numbered[3], numbered[1], numbered[2])
144  }
145
146  return /^[a-z]+$/.test(bare) ? title(bare) : bare
147}
148
149// '$0.00', '$1.23', '$142.50'.
150export const costText = (usd: number) => `$${usd.toFixed(2)}`
151
152// Only a subscription reports 5-hour and 7-day windows; its cost is a list-price estimate,
153// not a bill, so it is left out. Nothing spent yet shows nothing either way.
154const isBilled = ({ rateLimits, cost }: Reading) =>
155  cost !== undefined && cost > 0 && !rateLimits.some(r => LIMITS.some(l => l.kind === r.kind))
156
157const info = (reading: Reading): Part | undefined => {
158  const { model, effort, cost = 0 } = reading
159
160  return model === undefined
161    ? undefined
162    : {
163        label: [
164          `${modelName(model)}${effort === undefined ? '' : ` (${effort})`}`,
165          isBilled(reading) ? costText(cost) : undefined,
166        ]
167          .filter(piece => piece !== undefined)
168          .join(INFO_SEPARATOR),
169        level: 'low',
170      }
171}
172
173const part = (
174  label: string,
175  pct: number | undefined,
176  tier: Tier,
177  glyphs: Glyphs,
178  reset?: string,
179): Part => ({
180  label,
181  bar: tier.barCells > 0 ? bar(pct, tier.barCells, glyphs) : undefined,
182  pct: pctText(pct),
183  reset: reset === undefined ? undefined : `(${reset})`,
184  level: level(pct),
185})
186
187export const segments = (reading: Reading, tier: Tier, glyphs: Glyphs = GLYPHS) => {
188  const lead = tier.hasInfo ? info(reading) : undefined
189  const parts: Part[] = lead === undefined ? [] : [lead]
190
191  parts.push(part('context', reading.contextPercent, tier, glyphs))
192
193  if (!tier.hasLimits) {
194    return parts
195  }
196
197  for (const { kind, label } of LIMITS) {
198    const limit = reading.rateLimits.find(r => r.kind === kind)
199
200    if (limit !== undefined) {
201      const reset = tier.hasResets ? countdown(limit.resetsAt, reading.nowMs) : undefined
202      parts.push(part(label, limit.percentUsed, tier, glyphs, reset))
203    }
204  }
205
206  return parts
207}
208
209// One space beyond the usual, after a bar, so the number does not crowd the glyphs.
210export const BAR_GAP = ' '
211
212// One part as drawn: the register's tree puts exactly these spaces between its Texts.
213export const partText = ({ label, bar, pct, reset }: Part) =>
214  [label, bar === undefined ? undefined : `${bar}${BAR_GAP}`, pct, reset]
215    .filter(piece => piece !== undefined)
216    .join(' ')
217
218export const plainText = (parts: Part[]) => parts.map(partText).join(SEPARATOR)
219
220// Cells for the meter on its own row, `columns` being the conversation column's width.
221export const room = (columns: number) => columns - GUTTER - EDGE
222
223// The richest parts that fit `roomCells`, or undefined when none do.
224export const fitParts = (reading: Reading, roomCells: number, options: FitOptions = {}) => {
225  const { glyphs = GLYPHS, ambiguousWidth = AMBIGUOUS_WIDTH } = options
226
227  for (const tier of TIERS) {
228    const parts = segments(reading, tier, glyphs)
229
230    if (cellWidth(plainText(parts), ambiguousWidth) <= roomCells) {
231      return parts
232    }
233  }
234
235  return undefined
236}
237
types/index.d.ts 18 lines
1export type RateLimit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Reading = {
4  contextPercent?: number
5  rateLimits: RateLimit[]
6  nowMs: number
7  // As `/model` shows it, the last turn's effort, and the session's cost in US dollars.
8  model?: string
9  effort?: string
10  cost?: number
11}
12
13declare module 'claude-code' {
14  interface PluginState {
15    'compact-usage-meter': { reading: Reading | null }
16  }
17}
18