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

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).
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.
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
| Type | What happens |
|---|---|
/limits | hides the meter row if it's showing, shows it if it's hidden |
/limits off | hides it |
/limits on | shows 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.
5h and 7d and the bars get shorter. Below about 20 columns the row is hidden.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:
| Hooks | When they run |
|---|---|
session.start | once when a session starts |
session.measure | when Claude Code's usage figures change |
ui.render on PromptHint | when the line under the prompt is drawn |
command.run on limits | when you run /limits |
$ calls | What for |
|---|---|
$.session.usage | reads the rate-limit figures |
$.state.get, $.state.set | keeps those figures for the session, in the mod's own state |
$.ui.resolve | gets the elements it draws with |
$.command.register | adds the /limits command |
$.store.get, $.store.set | keeps 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.
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.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.
From this folder:
claude plugin validate .
claude plugin test .
claude --plugin-dir .
MIT. See LICENSE.
hooks/register.tsx 190 lines1import { 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}
190types/index.d.ts 8 lines1export 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