Prompt-cache hit rate and context fill in one row above the prompt, with a switchable auto-compact threshold and Compact and Clear buttons.

<h1 align="center">Claude Code Mods</h1>
<a href="https://github.com/T-AI-Labs/claude-code-mods/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/T-AI-Labs/claude-code-mods/ci.yml?branch=main&style=flat-square&label=ci" alt="CI"></a> <a href="https://github.com/T-AI-Labs/claude-code-mods/releases"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2FT-AI-Labs%2Fclaude-code-mods%2Fmain%2Fcache-meter%2F.claude-plugin%2Fplugin.json&query=%24.version&prefix=v&label=cache-meter&style=flat-square" alt="cache-meter version"></a> <a href="https://github.com/T-AI-Labs/claude-code-mods/releases"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2FT-AI-Labs%2Fclaude-code-mods%2Fmain%2Fusage-limits%2F.claude-plugin%2Fplugin.json&query=%24.version&prefix=v&label=usage-limits&style=flat-square" alt="usage-limits version"></a> <a href="LICENSE"><img src="https://img.shields.io/github/license/T-AI-Labs/claude-code-mods?style=flat-square" alt="License"></a> <img src="https://img.shields.io/badge/Claude%20Code-2.1.295-d97757?style=flat-square" alt="Claude Code 2.1.295"> <img src="https://img.shields.io/badge/Claude%20desktop%20app-2.31226.1-d97757?style=flat-square" alt="Claude desktop app 2.31226.1">
<a href="#install"><strong>Install</strong></a> · <a href="#cache-meter">cache-meter</a> · <a href="#usage-limits">usage-limits</a> · <a href="#token-cost">Token cost</a> · <a href="#configure">Configure</a> · <a href="#update-and-uninstall">Update</a> · <a href="#credits">Credits</a>
claude plugin marketplace add T-AI-Labs/claude-code-mods
claude plugin install cache-meter@T-AI-Labs
claude plugin install usage-limits@T-AI-Labs
Run the three commands above in any shell (cmd, PowerShell, bash). The mods load in every session started afterwards, in the terminal and in the desktop app.
From the prompt of a running Claude Code terminal session, the same install is:
/plugin install cache-meter --marketplace T-AI-Labs/claude-code-mods
/plugin install usage-limits --marketplace T-AI-Labs/claude-code-mods
The mods use Claude Code's function-hook plugin API, which is in early access and may change between releases. They are built and tested against Claude Code 2.1.295, the engine in the Claude desktop app 2.31226.1.
Cache ▮▮▮▮▮ 92% (92k/100k) Context ▮▯▯▯▯ 10% (100k/1M) Auto 97% ▾ [ Compact ] [ Clear ]
| Element | What it shows |
|---|---|
| Cache | Share of the last turn's input tokens that the prompt cache served, with tokens read and total input in parentheses. Turns yellow below 40 %. |
| Context | Fill of the model's context window, with tokens used and window size in parentheses. Turns yellow within 15 points of the auto-compact threshold and red at it. |
| Auto | The auto-compact threshold: the context fill, as shown under Context, at which the mod compacts on its own. A dropdown over 97, 90, 70, 60 and 50 %. The choice is kept across sessions. |
| Compact | Runs /compact now. |
| Clear | Runs /clear. |
Both figures read – until the first response of a session, and again after a clear.
After each turn the mod compares the context fill with the threshold and runs /compact once it is reached. It does not fire again until the fill has dropped below the threshold, so a compaction that lands above it does not loop.
Claude Code's built-in auto-compaction stays on and still runs at its own limit. A threshold above that limit therefore never fires, and a long turn can pass the threshold before the mod gets to compact.
Showing the rows costs nothing. The mods read figures Claude Code already has; they make no model requests of their own and add nothing to the conversation.
A compaction does cost tokens, whether you press Compact or the threshold triggers it: /compact is a model request that summarizes the conversation, and the next turn starts on a cold cache. The lower the threshold, the more often that happens. To keep usage down, leave the threshold high and compact by hand when it suits you.
5h ▮▮▯▯▯ 42% (2h 13m) Week ▮▮▮▮▯ 78% (3d 4h)
Each rate-limit window Claude Code reports gets a gauge, the percentage used and, in parentheses, the time left until it resets. 5h is the 5-hour limit, Week the weekly one. A figure turns yellow from 75 % and red from 90 %.
The readings arrive with the first response of a session and are only reported on a subscription plan. Until then the row shows the last reading of the previous session, marked with its age (as of 3h 20m ago) and without the windows that have reset since. With nothing to show it reads Usage limits: no reading yet.
Both mods take the same two options:
| Option | Values | Default | Effect |
|---|---|---|---|
display | gauge, text | gauge | gauge draws a five-segment fill level before each percentage; text leaves it out. |
details | true, false | true | Shows the figures in parentheses. |
Change them at any time from a shell, once per mod. In PowerShell:
'{"display":"text","details":"false"}' | claude plugin configure cache-meter@T-AI-Labs --values-stdin
In cmd:
echo {"display":"text","details":"false"} | claude plugin configure cache-meter@T-AI-Labs --values-stdin
Leave out an option to keep its value. claude plugin configure cache-meter@T-AI-Labs alone shows what is set. A new session picks up the change.
They can also be set while installing, with --config display=text, and in a terminal session they are rows of the /config menu. Give both mods the same settings to keep their columns aligned.
cache-meter needs about 92 columns with the defaults. On a narrower window its buttons wrap to a second line. Turning details off saves about 22 columns, display set to text about 12:
Cache ▮▮▮▮▮ 92% Context ▮▯▯▯▯ 10% Auto 97% ▾ [ Compact ] [ Clear ]
In the terminal, use the mouse, or move the focus into the row with ctrl+x tab: Tab walks the controls, Enter presses a button, and the arrow keys pick a threshold. To hide both rows there, collapse the band with its [-] mark or ctrl+x ctrl+a.
claude plugin marketplace update T-AI-Labs
claude plugin update cache-meter@T-AI-Labs
claude plugin update usage-limits@T-AI-Labs
claude plugin uninstall cache-meter@T-AI-Labs
claude plugin uninstall usage-limits@T-AI-Labs
A new session picks up the change.
The idea for these mods comes from Julian Ivanov and his video Diese 10 Claude Code Mods solltest du unbedingt kennen. The code in this repository was written independently.
hooks/register.tsx 287 lines1import { atom, read, update } from 'claude-code'
2import type {
3 Color,
4 Elements,
5 EngineInterface,
6 Register,
7 SessionContextUsage,
8} from 'claude-code'
9
10import type { ContextFill } from '../types'
11
12const STEPS = [97, 90, 70, 60, 50]
13const OPTIONS = STEPS.map(step => ({ value: String(step), label: `${step}%` }))
14const DEFAULT_THRESHOLD = 97
15const STORE_KEY = 'threshold'
16
17// Shared with usage-limits so the columns of both rows line up.
18const LABEL_WIDTHS = [5, 7]
19const SEGMENTS = 5
20const PERCENT_WIDTH = 4
21const DETAIL_WIDTH = 10
22
23type Tags = Pick<Elements['terminal'], 'Box' | 'Text'>
24type Layout = { hasGauge: boolean; hasDetails: boolean }
25type Cell = {
26 label: string
27 column: number
28 percent: number | null
29 tone: Color
30 detail: string
31}
32
33const cache = atom({ plugin: 'cache-meter', key: 'cache' } as const, null)
34const context = atom({ plugin: 'cache-meter', key: 'context' } as const, null)
35const threshold = atom(
36 { plugin: 'cache-meter', key: 'threshold' } as const,
37 DEFAULT_THRESHOLD,
38)
39
40const toFill = (live: SessionContextUsage): ContextFill | null =>
41 live.percent === undefined || live.tokens === undefined
42 ? null
43 : { percent: live.percent, tokens: live.tokens, window: live.window }
44
45const tokens = (count: number) => {
46 const thousands = Math.round(count / 1000)
47
48 if (count < 1000) {
49 return String(count)
50 }
51
52 return thousands < 1000
53 ? `${thousands}k`
54 : `${Number((count / 1_000_000).toFixed(1))}M`
55}
56
57const cacheTone = (percent: number): Color =>
58 percent < 40 ? 'warning' : 'text'
59
60const contextTone = (percent: number, limit: number): Color =>
61 percent >= limit ? 'error' : percent >= limit - 15 ? 'warning' : 'text'
62
63const meter = ({ Box, Text }: Tags, layout: Layout, one: Cell) => {
64 const filled =
65 one.percent === null
66 ? 0
67 : Math.max(
68 0,
69 Math.min(SEGMENTS, Math.round((one.percent / 100) * SEGMENTS)),
70 )
71
72 return (
73 <Box flexDirection="row" flexShrink={0}>
74 <Box
75 minWidth={LABEL_WIDTHS[one.column] ?? LABEL_WIDTHS[1]}
76 marginRight={1}
77 flexShrink={0}
78 >
79 <Text dimColor>{one.label}</Text>
80 </Box>
81 {layout.hasGauge && (
82 <Box minWidth={SEGMENTS} marginRight={1} flexShrink={0}>
83 <Text color={one.tone}>{'▮'.repeat(filled)}</Text>
84 <Text dimColor>{'▯'.repeat(SEGMENTS - filled)}</Text>
85 </Box>
86 )}
87 <Box
88 minWidth={PERCENT_WIDTH}
89 marginRight={1}
90 flexShrink={0}
91 justifyContent="flex-end"
92 >
93 <Text color={one.percent === null ? 'inactive' : one.tone}>
94 {one.percent === null ? '–' : `${Math.round(one.percent)}%`}
95 </Text>
96 </Box>
97 {layout.hasDetails && (
98 <Box minWidth={DETAIL_WIDTH} marginRight={1} flexShrink={0}>
99 <Text dimColor>{one.detail}</Text>
100 </Box>
101 )}
102 </Box>
103 )
104}
105
106const runCommand = ($: EngineInterface, command: string) => {
107 $.command
108 .run({ command })
109 .catch(() => $.ui.toast(`cache-meter: /${command} could not run`))
110}
111
112const cycleThreshold = async ($: EngineInterface) => {
113 const value = await update(
114 $,
115 threshold,
116 held => STEPS[(STEPS.indexOf(held) + 1) % STEPS.length] ?? DEFAULT_THRESHOLD,
117 )
118 await $.store.set(STORE_KEY, value)
119}
120
121const setThreshold = async ($: EngineInterface, value: number) => {
122 if (STEPS.includes(value)) {
123 await update($, threshold, () => value)
124 await $.store.set(STORE_KEY, value)
125 }
126}
127
128export const register: Register = (on, options) => {
129 const layout: Layout = {
130 hasGauge: options.display !== 'text',
131 hasDetails: options.details !== false,
132 }
133 // Re-armed only once the fill drops below the threshold, so a compaction that lands above it does not loop.
134 let isArmed = true
135
136 on('session.start', async ($, e, next) => {
137 const stored = await $.store.get(STORE_KEY)
138
139 if (typeof stored === 'number' && STEPS.includes(stored)) {
140 await update($, threshold, () => stored)
141 }
142
143 const usage = await $.session.usage()
144 await update($, context, () => toFill(usage.context))
145
146 return next(e)
147 })
148
149 on('session.end', async ($, e, next) => {
150 if (e.reason === 'clear') {
151 isArmed = true
152 await update($, cache, () => null)
153 await update($, context, () => null)
154 }
155
156 return next(e)
157 })
158
159 on('turn.complete', async ($, e, next) => {
160 if (e.agentId === undefined && e.usage !== undefined) {
161 const served = e.usage.cache_read_input_tokens
162 const total =
163 e.usage.input_tokens + served + e.usage.cache_creation_input_tokens
164
165 if (total > 0) {
166 await update($, cache, () => ({ read: served, total }))
167 }
168 }
169
170 return next(e)
171 })
172
173 on('session.measure', async ($, e, next) => {
174 if (e.changed.includes('context')) {
175 const fill = toFill(e.context)
176 const limit = await read($, threshold)
177 await update($, context, () => fill)
178
179 if (fill !== null && fill.percent < limit) {
180 isArmed = true
181 } else if (fill !== null && isArmed) {
182 isArmed = false
183 // A compaction is refused while the turn that raised this measurement still runs.
184 $.clock.after(250, () => runCommand($, 'compact'))
185 }
186 }
187
188 return next(e)
189 })
190
191 on('session.compact', async ($, e, next) => {
192 const compacted = await next(e)
193 const isLive = e.agentId === undefined && e.trigger !== 'precompute'
194
195 if (isLive && compacted.skip === undefined) {
196 const after = compacted.tokensAfter
197 await update($, context, held =>
198 held === null || after === undefined
199 ? null
200 : {
201 tokens: after,
202 window: held.window,
203 percent: Math.round((after / held.window) * 100),
204 },
205 )
206 }
207
208 return compacted
209 }).catch(($, e, next) => next(e))
210
211 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
212 const below = await next(e)
213
214 if (e.props.hasSurvey) {
215 return below
216 }
217
218 const { Box, Button, Text } = $.ui.resolve(e)
219 const hit = await read($, cache)
220 const fill = await read($, context)
221 const limit = await read($, threshold)
222 const hitPercent = hit === null ? null : (hit.read / hit.total) * 100
223 let picker
224
225 // The mobile surface draws no Select.
226 if (e.surface === 'mobile') {
227 picker = (
228 <Button
229 key="threshold"
230 label={`Auto ${limit}%`}
231 onPress={() => void cycleThreshold($)}
232 />
233 )
234 } else {
235 const { Select } = $.ui.resolve(e)
236 picker = (
237 <Select
238 key="threshold"
239 label="Auto"
240 options={OPTIONS}
241 value={String(limit)}
242 onSelect={value => void setThreshold($, Number(value))}
243 />
244 )
245 }
246
247 return (
248 <Box flexDirection="column">
249 <Box flexDirection="row" flexWrap="wrap">
250 {meter({ Box, Text }, layout, {
251 label: 'Cache',
252 column: 0,
253 percent: hitPercent,
254 tone: cacheTone(hitPercent ?? 100),
255 detail:
256 hit === null ? '' : `(${tokens(hit.read)}/${tokens(hit.total)})`,
257 })}
258 {meter({ Box, Text }, layout, {
259 label: 'Context',
260 column: 1,
261 percent: fill === null ? null : fill.percent,
262 tone: contextTone(fill === null ? 0 : fill.percent, limit),
263 detail:
264 fill === null
265 ? ''
266 : `(${tokens(fill.tokens)}/${tokens(fill.window)})`,
267 })}
268 <Box flexDirection="row" gap={1} flexShrink={0}>
269 {picker}
270 <Button
271 key="compact"
272 label="Compact"
273 onPress={() => runCommand($, 'compact')}
274 />
275 <Button
276 key="clear"
277 label="Clear"
278 onPress={() => runCommand($, 'clear')}
279 />
280 </Box>
281 </Box>
282 {below}
283 </Box>
284 )
285 })
286}
287types/index.d.ts 14 lines1export type CacheHit = { read: number; total: number }
2
3export type ContextFill = { percent: number; tokens: number; window: number }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'cache-meter': {
8 cache: CacheHit | null
9 context: ContextFill | null
10 threshold: number
11 }
12 }
13}
14