SLOPSHOPPER

limits-meter

Session (5 h) and weekly usage limits as small progress bars, centred under the prompt; /limits hides or shows them

newspinnercommand
v0.2.0MITupdated 2026-10-09marcelmatula/claude-mods/limits-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · limits-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 › /limits ⎿ limits-meter: Limits meter hidden. /limits brings it back. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

limits-meter

A Claude Code mod that shows your usage limits for the current session (5 h) and the week as small progress bars with a percentage. They sit on a centred row at the bottom of the screen, under the prompt's hint line. /limits hides or shows them, and the choice is remembered.

Part of claude-mods, Marcel's Claude Code marketplace (marcel-mods).

limits-meter under the Claude Code prompt: Session 38 % in green and Week 82 % in red, centred on the row below the hint line

Each bar is green below 50 %, yellow from 50 % and red from 80 %, in your theme's colours. The screenshot is the mod running in a 120-column terminal with sample readings.

Install

Type this at the prompt of a Claude Code terminal session:

/plugin install limits-meter --marketplace marcelmatula/claude-mods

Answer y to add the marketplace, then pick a scope (user scope loads it in every session). The mod is active in that session at once; the meters show as soon as it has a reading, at the latest after the next response.

Or in two steps:

/plugin marketplace add marcelmatula/claude-mods
/plugin install limits-meter@marcel-mods

Showing and hiding

TypeWhat happens
/limitshides the meter row if it's showing, shows it if it's hidden
/limits offhides it
/limits onshows it

Claude Code's own hint line stays as it is either way. The choice is remembered across sessions, so a hidden meter stays hidden until you turn it back on.

What to expect

  • The figures are the ones Claude Code receives with each response, so the mod makes no requests of its own. They update whenever a window moves by a whole point.
  • On a Claude subscription only: with an API key there are no rate-limit windows to show. In a new session the row appears after the first response.
  • It adds one line at the bottom. While Claude Code shows a notice on the hint line (for example "Context left until auto-compact"), the notice moves to its own line under the meters.
  • On a narrow terminal the labels shorten to 5h and 7d and the bars get shorter. Below about 20 columns the row is hidden.

What it can access

A mod runs inside Claude Code's plugin sandbox and can reach the outside only through $ calls. On Claude Code 2.1.294 the sandbox has no fetch, require, process or eval, and the validator refuses disguised $ access, so its list of hooks and calls covers everything a mod can do. For limits-meter that list is:

HooksWhen they run
session.startonce when a session starts
session.measurewhen Claude Code's usage figures change
ui.render on PromptHintwhen the line under the prompt is drawn
command.run on limitswhen you run /limits
$ callsWhat for
$.session.usagereads the rate-limit figures
$.state.get, $.state.setkeeps those figures for the session, in the mod's own state
$.ui.resolvegets the elements it draws with
$.command.registeradds the /limits command
$.store.get, $.store.setkeeps the shown or hidden choice between sessions

Its one saved setting, shown or hidden, lives in the mod's own small store, which Claude Code keeps in your Claude Code configuration folder. Beyond that it reads and writes no files, runs no shell commands and makes no network requests. It has no hooks on your prompts or on Claude's tool calls; its one command hook answers /limits.

Check this yourself from a clone of the repo, in the hooks: and calls: lines:

claude plugin validate limits-meter

capabilities.json holds the same list. CI fails if the mod's hooks or $ calls ever go beyond it, so any new kind of access has to show up as a change to that file.

How it works

hooks/register.tsx is a plugin of function hooks:

  • session.start reads the current windows with $.session.usage().
  • session.measure keeps them up to date as responses arrive.
  • A ui.render hook on the PromptHint site draws Claude Code's own hint line unchanged and adds the meter row below it, unless the meter is hidden.
  • /limits is registered in session.start and answered by a command.run hook. It flips the hidden setting in the session's state, which redraws the row at once, and saves it with $.store. The next session.start reads it back.

Built and tested on Claude Code 2.1.294. The function-hooks plugin API is early access and may change between releases.

Development

From this folder:

claude plugin validate .
claude plugin test .
claude --plugin-dir .

License

MIT. See LICENSE.

Source 2 files
hooks/register.tsx 190 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, SessionRateLimit } from 'claude-code'
3
4import type { Limit } from '../types'
5
6const limits = atom({ plugin: 'limits-meter', key: 'limits' } as const, [])
7const isHidden = atom({ plugin: 'limits-meter', key: 'isHidden' } as const, false)
8
9// $.store key that keeps the shown/hidden choice across sessions.
10const HIDDEN_KEY = 'isHidden'
11
12const WINDOWS = [
13  { kind: 'five_hour', label: 'Session', short: '5h' },
14  { kind: 'seven_day', label: 'Week', short: '7d' },
15] as const
16
17const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
18const SEPARATOR = '   '
19const TRACK = 'subtle'
20// The hint line's rows start two columns in from the terminal's edge.
21const INDENT = 2
22
23type Tier = { name: 'full' | 'compact' | 'minimal'; bar: number }
24
25const TIERS: Tier[] = [
26  { name: 'full', bar: 8 },
27  { name: 'compact', bar: 5 },
28  { name: 'minimal', bar: 0 },
29]
30
31const known = (list: readonly SessionRateLimit[]): Limit[] =>
32  list
33    .filter(one => WINDOWS.some(w => w.kind === one.kind))
34    .map(({ kind, percentUsed, resetsAt }) =>
35      resetsAt === undefined ? { kind, percentUsed } : { kind, percentUsed, resetsAt },
36    )
37
38const colorFor = (percent: number) =>
39  percent >= 80 ? 'error' : percent >= 50 ? 'warning' : 'success'
40
41const percentText = (percent: number) => `${Math.round(percent)}%`.padStart(4)
42
43// Cells one meter takes: "label ", the bar and a space, then "nn%" padded to 4.
44const meterWidth = (tier: Tier, label: string) =>
45  label.length + 1 + (tier.bar > 0 ? tier.bar + 1 : 0) + 4
46
47// What `/limits <args>` asks for: show, hide, flip, or undefined for anything else.
48const wanted = (args: string, hidden: boolean): boolean | undefined => {
49  const word = args.trim().toLowerCase()
50  if (word === '') return !hidden
51  if (word === 'off') return true
52  if (word === 'on') return false
53  return undefined
54}
55
56export const register: Register = on => {
57  on('session.start', async ($, e, next) => {
58    try {
59      const stored = await $.store.get(HIDDEN_KEY)
60      await update($, isHidden, () => stored === true)
61    } catch {
62      // No stored choice: the meters show.
63    }
64
65    try {
66      await $.command.register({
67        name: 'limits',
68        description: 'Show or hide the usage limits meter',
69        argumentHint: 'on|off',
70      })
71    } catch {
72      // Without the command the meters still work; they just can't be hidden.
73    }
74
75    try {
76      const { rateLimits } = await $.session.usage()
77      await update($, limits, () => known(rateLimits))
78    } catch {
79      // No reading yet; session.measure fills it in after the next response.
80    }
81
82    return next(e)
83  })
84
85  on('session.measure', async ($, e, next) => {
86    if (e.changed.includes('rateLimits')) {
87      await update($, limits, () => known(e.rateLimits))
88    }
89
90    return next(e)
91  })
92
93  on('command.run', { command: 'limits' }, async ($, e) => {
94    const hidden = await read($, isHidden)
95    // A plugin's own $.command.run can leave args out; typed commands carry "".
96    const hide = wanted(e.args ?? '', hidden)
97    if (hide === undefined) {
98      return { text: 'Usage: /limits shows or hides the limits meter; /limits on and /limits off set it.' }
99    }
100
101    await update($, isHidden, () => hide)
102    try {
103      await $.store.set(HIDDEN_KEY, hide)
104    } catch {
105      return { text: `Limits meter ${hide ? 'hidden' : 'shown'} for this session; the choice could not be saved for later ones.` }
106    }
107
108    return { text: hide ? 'Limits meter hidden. /limits brings it back.' : 'Limits meter shown.' }
109  })
110
111  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
112    const engine = await next(e)
113    if (await read($, isHidden)) {
114      return engine
115    }
116
117    const list = await read($, limits)
118    const readings = WINDOWS.flatMap(w => {
119      const one = list.find(l => l.kind === w.kind)
120
121      return one === undefined ? [] : [{ ...w, percent: one.percentUsed }]
122    })
123
124    if (readings.length === 0) {
125      return engine
126    }
127
128    // The most detailed tier that fits between the indents.
129    const columns = e.viewport?.columns ?? 80
130    const layout = TIERS.map(tier => {
131      const meters = readings.map(r => ({
132        label: tier.name === 'full' ? r.label : r.short,
133        percent: r.percent,
134      }))
135      const width =
136        meters.reduce((sum, m) => sum + meterWidth(tier, m.label), 0) +
137        SEPARATOR.length * (meters.length - 1)
138
139      return { tier, meters, width }
140    }).find(l => l.width <= columns - 2 * INDENT)
141
142    if (layout === undefined) {
143      return engine
144    }
145
146    const { Box, Text } = $.ui.resolve(e)
147
148    const bar = (percent: number, cells: number) => {
149      const eighths = Math.round((Math.min(100, Math.max(0, percent)) / 100) * cells * 8)
150      const full = Math.floor(eighths / 8)
151      const partial = EIGHTHS[eighths % 8] ?? ''
152      const empty = cells - full - (partial === '' ? 0 : 1)
153      const color = colorFor(percent)
154
155      return (
156        <Box flexDirection="row" marginRight={1}>
157          {[
158            ...(full > 0 ? [<Text color={color}>{'█'.repeat(full)}</Text>] : []),
159            ...(partial !== '' ? [<Text color={color} backgroundColor={TRACK}>{partial}</Text>] : []),
160            ...(empty > 0 ? [<Text backgroundColor={TRACK}>{' '.repeat(empty)}</Text>] : []),
161          ]}
162        </Box>
163      )
164    }
165
166    // The engine's line is a full-width row (hint left, notices right) that paints
167    // over anything placed on it, and a Box around it may not size it. So it stays
168    // as it is, and the meters take the row under it, centred on the terminal.
169    return (
170      <Box flexDirection="column">
171        {engine}
172        <Box flexDirection="row" marginLeft={Math.max(0, Math.floor((columns - layout.width) / 2) - INDENT)}>
173          {layout.meters.map((meter, index) => (
174            <Box flexDirection="row">
175              {[
176                ...(index > 0 ? [<Text dimColor>{SEPARATOR}</Text>] : []),
177                <Text dimColor>{`${meter.label} `}</Text>,
178                ...(layout.tier.bar > 0 ? [bar(meter.percent, layout.tier.bar)] : []),
179                <Text color={colorFor(meter.percent)} bold>
180                  {percentText(meter.percent)}
181                </Text>,
182              ]}
183            </Box>
184          ))}
185        </Box>
186      </Box>
187    )
188  })
189}
190
types/index.d.ts 8 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'limits-meter': { limits: Limit[]; isHidden: boolean }
6  }
7}
8