context-meter and usage-limits in one band above the prompt: the context fill with its auto-compact threshold, Compact and Clear, and the 5-hour and weekly…

<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%2Fcontext-usage%2F.claude-plugin%2Fplugin.json&query=%24.version&prefix=v&label=context-usage&style=flat-square" alt="context-usage 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%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%2Fcatalog-pane%2F.claude-plugin%2Fplugin.json&query=%24.version&prefix=v&label=catalog-pane&style=flat-square" alt="catalog-pane 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%2Fcontext-meter%2F.claude-plugin%2Fplugin.json&query=%24.version&prefix=v&label=context-meter&style=flat-square" alt="context-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>
Add the marketplace once, then install the mods you want with the first command in their row of the Mods table. To update, run the second lines: the marketplace first, then each mod. The commands work in any shell, or inside Claude Code with /plugin in place of claude plugin. New sessions load the changes.
claude plugin marketplace add T-AI-Labs/claude-code-mods
claude plugin marketplace update T-AI-Labs
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.
context-usage already contains context-meter and usage-limits: install either it or those two, not both.
<table> <tr><th>Mod</th><th>Description</th><th width="35%">Preview</th></tr> <tr><td><a href="#context-usage">context‑usage</a></td><td>
Start here. The context fill with the auto-compact threshold, Compact and Clear, and the 5-hour and weekly limits beneath it.
claude plugin install context-usage@T-AI-Labs
claude plugin update context-usage@T-AI-Labs
</td><td><img src=".docs/context-usage.svg" alt="The context-usage rows" width="320"></td></tr> <tr><td><a href="#context-meter">context‑meter</a></td><td>
The context row of context-usage on its own.
claude plugin install context-meter@T-AI-Labs
claude plugin update context-meter@T-AI-Labs
</td><td><img src=".docs/context-meter.svg" alt="The context-meter row" width="320"></td></tr> <tr><td><a href="#usage-limits">usage‑limits</a></td><td>
The usage row of context-usage on its own.
claude plugin install usage-limits@T-AI-Labs
claude plugin update usage-limits@T-AI-Labs
</td><td><img src=".docs/usage-limits.svg" alt="The usage-limits row" width="320"></td></tr> <tr><td><a href="#cache-meter">cache‑meter</a></td><td>
How much of each turn the prompt cache served and how long it stays warm, with a switch that keeps it warm while you are away.
claude plugin install cache-meter@T-AI-Labs
claude plugin update cache-meter@T-AI-Labs
</td><td><img src=".docs/cache-meter.svg" alt="The cache-meter row" width="320"></td></tr> <tr><td><a href="#catalog-pane">catalog‑pane</a></td><td>
A pane listing every skill, command, agent, mod, plugin, settings hook, MCP server and tool, with usage, token cost and buttons to use or manage each one.
claude plugin install catalog-pane@T-AI-Labs
claude plugin update catalog-pane@T-AI-Labs
</td><td><img src=".docs/catalog-pane.svg" alt="The catalog pane" width="320"></td></tr> </table>
context-meter and usage-limits in one mod: the context row and the usage row beneath it. Both work as described under context-meter and usage-limits. To switch from the two separate mods, install context-usage, uninstall context-meter and usage-limits, and pick the threshold again.
The rows sit in the band above the prompt and cost no tokens: they only read figures Claude Code already has. In the terminal, ctrl+x tab moves into the band: Tab walks the controls, Enter presses a button, the arrow keys pick a threshold. ctrl+x ctrl+a or the band's [-] mark collapses it.
| Option | Values | Default | Effect |
|---|---|---|---|
display | gauge, text | gauge | gauge draws a five-segment gauge before each percentage; text leaves it out. |
details | true, false | true | Shows the figures in parentheses. |
usagePosition | below, above | below | Puts the usage row under or on top of the context row. |
'{"display":"text","usagePosition":"above"}' | claude plugin configure context-usage@T-AI-Labs --values-stdin
That is PowerShell; in cmd, use echo {"display":"text"} | …. Options you leave out keep their value, the command without --values-stdin shows what is set, and a new session picks up the change. --config display=text sets an option while installing, and in a terminal session the options are rows of the /config menu.
| Element | What it shows |
|---|---|
| Context | Fill of the context window, with tokens used and window size. Yellow within 15 points of the threshold, red at it; with Auto off, the same against 100 %. |
| Auto | The fill at which the mod compacts on its own: Off, 97, 90, 70, 60 or 50 %. Off leaves it to you and to Claude Code's built-in auto-compaction. Kept across sessions. |
| Compact | Runs /compact now. |
| Clear | Runs /clear. |
The row sits in the band above the prompt and costs no tokens: it only reads figures Claude Code already has. In the desktop app it is two lines high so the app's taller buttons fit. In the terminal, ctrl+x tab moves into the band: Tab walks the controls, Enter presses a button, the arrow keys pick a threshold. ctrl+x ctrl+a or the band's [-] mark collapses it.
Until the first response of a session, and again after a clear, the fill reads 0%: the engine counts the system prompt and tools only once a response arrives.
| Option | Values | Default | Effect |
|---|---|---|---|
display | gauge, text | gauge | gauge draws a five-segment gauge before the percentage; text leaves it out. |
details | true, false | true | Shows the figures in parentheses. |
'{"display":"text","details":"false"}' | claude plugin configure context-meter@T-AI-Labs --values-stdin
Set them as for context-usage. The row needs about 62 columns with the defaults; on a narrower window the buttons wrap to a second line. details off saves about 11 columns, display set to text about 6:
After each turn the mod compares the fill with the threshold and runs /compact once it is reached. It fires again only after the fill has dropped below the threshold, so a compaction that lands above it does not loop. Claude Code's built-in auto-compaction still runs at its own limit, so a threshold above that limit never fires.
Every compaction costs 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. To keep usage down, leave the threshold high and compact by hand when it suits you.
A gauge for each rate-limit window, with the percentage used and the time until it resets: 5h for the 5-hour limit, Week for the weekly one. Yellow from 75 %, red from 90 %.
The row sits in the band above the prompt and costs no tokens. ctrl+x ctrl+a or the band's [-] mark collapses it in the terminal.
Claude Code reports the limits only on a subscription plan, with each response. Until the first one, and right after a /clear, the row shows the last reading, with a window that has reset since at 0 %. A window that never had a reading shows –.
Sessions running side by side share their readings through a file on your machine, once a minute and at no token cost, so an idle session still shows what the others used. context-usage and usage-limits keep separate stores and do not share with each other.
| Option | Values | Default | Effect |
|---|---|---|---|
display | gauge, text | gauge | gauge draws a five-segment gauge before each percentage; text leaves it out. |
details | true, false | true | Shows the reset times in parentheses. |
'{"display":"text"}' | claude plugin configure usage-limits@T-AI-Labs --values-stdin
Set them as for context-usage. Give the row mods the same display and details to keep their columns aligned.
| Element | What it shows |
|---|---|
| Cache | Share of the last turn's input tokens served from the prompt cache, with tokens read and total input. Yellow below 40 %; – until the first response and after a clear. |
| warm / cold | How long the cache stays valid: five minutes after the last request starts, or an hour once the session has used the hour-long cache. cold means the next turn writes the whole context again at full price, the cheapest moment to compact or clear. |
| Keep warm | Off by default. Refreshes the cache shortly before it lapses, for up to keepWarmMinutes after the last turn, and counts the refreshes. Stops when a refresh finds the cache already gone. |
The row sits in the band above the prompt and costs no tokens; only Keep warm's refreshes are model requests. Keep warm is a text link in the desktop app and a button elsewhere. In the terminal, ctrl+x tab moves into the band, Tab walks the controls and Enter presses one. ctrl+x ctrl+a or the band's [-] mark collapses it.
Before 0.4.0 cache-meter also showed the context fill, threshold, Compact and Clear. They moved to context-meter, which an update does not install: install it and pick the threshold again.
| Option | Values | Default | Effect |
|---|---|---|---|
display | gauge, text | gauge | gauge draws a five-segment gauge before the percentage; text leaves it out. |
details | true, false | true | Shows the figures in parentheses. |
keepWarmMinutes | 15, 30, 60, 120 | 60 | How long after the last turn Keep warm goes on refreshing. |
'{"keepWarmMinutes":"120"}' | claude plugin configure cache-meter@T-AI-Labs --values-stdin
Set them as for context-usage.
Every turn sends the whole conversation again. Cached tokens cost about a tenth of the input price, a twentieth on Claude Opus 5.5; writing them to the cache costs 1.25 times the input price. The cache lapses after a pause, on a model switch, and when something early in the context changes, such as CLAUDE.md or the tools.
Each refresh reads the cached context once at the cache price, plus a one-word reply. With 200k tokens on Claude Opus 5.5 that is about $0.04, against about $1.00 for writing the context again after it went cold. At one refresh every four and a half minutes, an hour costs about half a rewrite: worth it if you come back, lost if you don't. On a subscription the refreshes count against the usage limits.
A pane with one tab per category: Skills, Commands, Agents, Mods, Plugins, Hooks (from the settings files), MCP servers and built-in Tools.
/catalog opens the pane, in the desktop app and in the terminal; /catalog mods opens a category, /catalog <text> a search. Each row shows the entry's origin, how often it was used in total and in the last 30 days, and for plugin parts the tokens it adds to every session. Usage is counted from the install of the mod on. On the desktop you can filter by text and origin and sort:
| Sort | Order |
|---|---|
| Most used, Recently used, Name | By usage, or alphabetically. |
| Clean-up | Costly and unused entries first. |
| GitHub stars, Trending, GitHub forks, Latest GitHub push | By the plugin's repository; Trending is stars gained per month. |
| Installs, Newest installed | By the official directory's install count, or by install date. |
Pressing an entry opens its detail: usage over 7 and 30 days, token cost, version and install dates, what a mod hooks and calls, the repository and the full description. Explain with Haiku sums up in three sentences what the entry is for. The buttons depend on the kind: insert or run a skill or command, insert an agent, MCP server or tool into the prompt, update, disable or uninstall a plugin or mod, remove a local MCP server. Uninstalling and removing ask first.
| Option | Values | Default | Effect |
|---|---|---|---|
language | any language name | English | The language Explain with Haiku answers in. |
githubStats | true, false | true | Fetches the GitHub stats of each plugin's repository once a day. |
'{"language":"German"}' | claude plugin configure catalog-pane@T-AI-Labs --values-stdin
Set them as for context-usage.
githubStats.claude plugin details once per plugin version for its token cost, claude plugin validate once per mod version, and update, disable, enable or uninstall when you press those buttons.Everything else comes from Claude Code and the configuration files, and is kept in the mod's own store.
hooks/register.tsx 426 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, Reading, UsageWindow } from '../types'
11
12const OFF = 0
13const STEPS = [OFF, 97, 90, 70, 60, 50]
14const stepLabel = (step: number) => (step === OFF ? 'Off' : `${step}%`)
15const OPTIONS = STEPS.map(step => ({ value: String(step), label: stepLabel(step) }))
16const DEFAULT_THRESHOLD = 97
17const THRESHOLD_KEY = 'threshold'
18const READING_KEY = 'reading'
19
20const LABELS: Record<string, string> = {
21 // Padded so the hours line up with a two-digit window such as 24h.
22 five_hour: ' 5h',
23 seven_day: 'Week',
24 spend_limit: 'Spend',
25}
26const ORDER = ['five_hour', 'seven_day', 'spend_limit']
27const ALWAYS = ['five_hour', 'seven_day']
28const MINUTE = 60_000
29const HOUR = 60 * MINUTE
30const DAY = 24 * HOUR
31
32// Shared with cache-meter, context-meter and usage-limits so the gauges of all rows line up.
33const LABEL_WIDTH = 7
34const SEGMENTS = 5
35const PERCENT_WIDTH = 4
36const DETAIL_WIDTH = 10
37
38type Tags = Pick<Elements['terminal'], 'Box' | 'Text'>
39type Layout = { hasGauge: boolean; hasDetails: boolean; isUsageAbove: boolean }
40
41const DESKTOP_BUTTON_ROWS = 2
42
43type Cell = {
44 label: string
45 percent: number | null
46 tone: Color | undefined
47 detail: string
48}
49
50const context = atom({ plugin: 'context-usage', key: 'context' } as const, null)
51const threshold = atom(
52 { plugin: 'context-usage', key: 'threshold' } as const,
53 DEFAULT_THRESHOLD,
54)
55const windows = atom({ plugin: 'context-usage', key: 'windows' } as const, [])
56const now = atom({ plugin: 'context-usage', key: 'now' } as const, 0)
57
58const toFill = (live: SessionContextUsage): ContextFill | null =>
59 live.percent === undefined || live.tokens === undefined
60 ? null
61 : { percent: live.percent, tokens: live.tokens, window: live.window }
62
63const tokens = (count: number) => {
64 const thousands = Math.round(count / 1000)
65
66 if (count < 1000) {
67 return String(count)
68 }
69
70 return thousands < 1000
71 ? `${thousands}k`
72 : `${Number((count / 1_000_000).toFixed(1))}M`
73}
74
75const contextTone = (percent: number, threshold: number): Color | undefined => {
76 const limit = threshold === OFF ? 100 : threshold
77
78 return percent >= limit ? 'error' : percent >= limit - 15 ? 'warning' : undefined
79}
80
81const usageTone = (percent: number): Color | undefined =>
82 percent >= 90 ? 'error' : percent >= 75 ? 'warning' : undefined
83
84const labelOf = (kind: string) => LABELS[kind] ?? kind.replaceAll('_', ' ')
85
86const rankOf = (kind: string) => {
87 const rank = ORDER.indexOf(kind)
88
89 return rank === -1 ? ORDER.length : rank
90}
91
92const span = (duration: number) => {
93 const days = Math.floor(duration / DAY)
94 const hours = Math.floor((duration % DAY) / HOUR)
95 const minutes = Math.max(1, Math.floor((duration % HOUR) / MINUTE))
96
97 if (days > 0) {
98 return `${days}d ${hours}h`
99 }
100
101 return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`
102}
103
104const untilReset = (resetsAt: string | undefined, at: number) => {
105 const left = resetsAt === undefined ? Number.NaN : Date.parse(resetsAt) - at
106
107 return Number.isNaN(left) ? '' : `(${span(left)})`
108}
109
110const hasReset = (one: UsageWindow, at: number) =>
111 one.resetsAt !== undefined && Date.parse(one.resetsAt) <= at
112
113const cellsOf = (list: readonly UsageWindow[], at: number): Cell[] =>
114 [...new Set([...ALWAYS, ...list.map(one => one.kind)])]
115 .sort((a, b) => rankOf(a) - rankOf(b))
116 .map(kind => {
117 const one = list.find(held => held.kind === kind)
118
119 if (one === undefined) {
120 return { label: labelOf(kind), percent: null, tone: undefined, detail: '' }
121 }
122
123 // A window past its reset has started over, but the engine reports that only with the next response.
124 if (hasReset(one, at)) {
125 return { label: labelOf(kind), percent: 0, tone: undefined, detail: '' }
126 }
127
128 return {
129 label: labelOf(kind),
130 percent: one.percentUsed,
131 tone: usageTone(one.percentUsed),
132 detail: untilReset(one.resetsAt, at),
133 }
134 })
135
136const meter = ({ Box, Text }: Tags, layout: Layout, one: Cell) => {
137 const filled =
138 one.percent === null
139 ? 0
140 : Math.max(
141 0,
142 Math.min(SEGMENTS, Math.round((one.percent / 100) * SEGMENTS)),
143 )
144
145 return (
146 <Box flexDirection="row" flexShrink={0}>
147 <Box minWidth={LABEL_WIDTH} marginRight={1} flexShrink={0}>
148 <Text dimColor>{one.label}</Text>
149 </Box>
150 {layout.hasGauge && (
151 <Box minWidth={SEGMENTS} marginRight={1} flexShrink={0}>
152 <Text color={one.tone}>{'▮'.repeat(filled)}</Text>
153 <Text dimColor>{'▯'.repeat(SEGMENTS - filled)}</Text>
154 </Box>
155 )}
156 <Box
157 minWidth={PERCENT_WIDTH}
158 marginRight={1}
159 flexShrink={0}
160 justifyContent="flex-end"
161 >
162 <Text color={one.percent === null ? 'inactive' : one.tone}>
163 {one.percent === null ? '–' : `${Math.round(one.percent)}%`}
164 </Text>
165 </Box>
166 {layout.hasDetails && (
167 <Box minWidth={DETAIL_WIDTH} marginRight={1} flexShrink={0}>
168 <Text dimColor>{one.detail}</Text>
169 </Box>
170 )}
171 </Box>
172 )
173}
174
175const runCommand = ($: EngineInterface, command: string) => {
176 $.command
177 .run({ command })
178 .catch(() => $.ui.toast(`context-usage: /${command} could not run`))
179}
180
181const cycleThreshold = async ($: EngineInterface) => {
182 const value = await update(
183 $,
184 threshold,
185 held => STEPS[(STEPS.indexOf(held) + 1) % STEPS.length] ?? DEFAULT_THRESHOLD,
186 )
187 await $.store.set(THRESHOLD_KEY, value)
188}
189
190const setThreshold = async ($: EngineInterface, value: number) => {
191 if (STEPS.includes(value)) {
192 await update($, threshold, () => value)
193 await $.store.set(THRESHOLD_KEY, value)
194 }
195}
196
197const restoreContext = async ($: EngineInterface) => {
198 const stored = await $.store.get(THRESHOLD_KEY)
199
200 if (typeof stored === 'number' && STEPS.includes(stored)) {
201 await update($, threshold, () => stored)
202 }
203
204 // The engine has no reading before the first response of a session, so an empty one counts as nothing sent yet.
205 const usage = await $.session.usage()
206 await update(
207 $,
208 context,
209 () => toFill(usage.context) ?? { percent: 0, tokens: 0, window: usage.context.window },
210 )
211}
212
213const restoreUsage = async ($: EngineInterface) => {
214 const at = await $.clock.now()
215 const stored = (await $.store.get(READING_KEY)) as Reading | undefined
216 await update($, now, () => at)
217
218 if (stored !== undefined) {
219 await update($, windows, () => stored.windows)
220 }
221}
222
223// The next window starts no earlier than this one resets, so it resets at least a whole window later.
224const SAME_WINDOW = HOUR
225
226const newer = (first: UsageWindow, second: UsageWindow) => {
227 const gap = Date.parse(first.resetsAt ?? '') - Date.parse(second.resetsAt ?? '')
228
229 // Without a reset time there is no telling which is newer, so the session's own reading stays.
230 if (Number.isNaN(gap)) {
231 return first
232 }
233
234 if (Math.abs(gap) > SAME_WINDOW) {
235 return gap > 0 ? first : second
236 }
237
238 return second.percentUsed > first.percentUsed ? second : first
239}
240
241// Every session writes the same store, so the readings of all of them meet there.
242const merge = (...lists: (readonly UsageWindow[])[]) => {
243 const byKind = new Map<string, UsageWindow>()
244
245 for (const one of lists.flat()) {
246 const held = byKind.get(one.kind)
247 byKind.set(one.kind, held === undefined ? one : newer(held, one))
248 }
249
250 return [...byKind.values()].sort((a, b) => rankOf(a.kind) - rankOf(b.kind))
251}
252
253const refreshUsage = async ($: EngineInterface, list?: readonly UsageWindow[]) => {
254 // The engine has no reading before the first response of a session.
255 if (list !== undefined && list.length === 0) {
256 return restoreUsage($)
257 }
258
259 const at = await $.clock.now()
260 const stored = (await $.store.get(READING_KEY)) as Reading | undefined
261 await update($, now, () => at)
262
263 // Without a measurement this only takes in what other sessions stored since.
264 if (list === undefined) {
265 await update($, windows, held => merge(held, stored?.windows ?? []))
266
267 return
268 }
269
270 // A measurement can leave a window out, which then keeps its last reading.
271 const merged = merge(list, stored?.windows ?? [])
272 await update($, windows, () => merged)
273 await $.store.set(READING_KEY, { windows: merged })
274}
275
276export const register: Register = (on, options) => {
277 const layout: Layout = {
278 hasGauge: options.display !== 'text',
279 hasDetails: options.details !== false,
280 isUsageAbove: options.usagePosition === 'above',
281 }
282 // Re-armed only once the fill drops below the threshold, so a compaction that lands above it does not loop.
283 let isArmed = true
284
285 on('session.start', async ($, e, next) => {
286 await restoreContext($)
287 const usage = await $.session.usage()
288 await refreshUsage($, usage.rateLimits)
289
290 $.clock.every(MINUTE, () => void refreshUsage($))
291
292 return next(e)
293 })
294
295 // A /clear wipes every plugin's state after session.end and raises no session.start, only this.
296 on('classic.SessionStart', async ($, e, next) => {
297 if (e.source === 'clear') {
298 await restoreContext($).catch(() => undefined)
299 await restoreUsage($).catch(() => undefined)
300 }
301
302 return next(e)
303 })
304
305 on('session.end', async ($, e, next) => {
306 if (e.reason === 'clear') {
307 isArmed = true
308 await update($, context, () => null)
309 }
310
311 return next(e)
312 })
313
314 on('session.measure', async ($, e, next) => {
315 await refreshUsage($, e.rateLimits)
316
317 if (e.changed.includes('context')) {
318 const fill = toFill(e.context)
319 const limit = await read($, threshold)
320 await update($, context, () => fill)
321
322 if (fill !== null && (limit === OFF || fill.percent < limit)) {
323 isArmed = true
324 } else if (fill !== null && isArmed) {
325 isArmed = false
326 // A compaction is refused while the turn that raised this measurement still runs.
327 $.clock.after(250, () => runCommand($, 'compact'))
328 }
329 }
330
331 return next(e)
332 })
333
334 on('session.compact', async ($, e, next) => {
335 const compacted = await next(e)
336 const isLive = e.agentId === undefined && e.trigger !== 'precompute'
337
338 if (isLive && compacted.skip === undefined) {
339 const after = compacted.tokensAfter
340 await update($, context, held =>
341 held === null || after === undefined
342 ? null
343 : {
344 tokens: after,
345 window: held.window,
346 percent: Math.round((after / held.window) * 100),
347 },
348 )
349 }
350
351 return compacted
352 }).catch(($, e, next) => next(e))
353
354 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
355 const below = await next(e)
356
357 if (e.props.hasSurvey) {
358 return below
359 }
360
361 const { Box, Button, Text } = $.ui.resolve(e)
362 const fill = await read($, context)
363 const limit = await read($, threshold)
364 const list = await read($, windows)
365 const at = await read($, now)
366 let picker
367
368 // The mobile surface draws no Select.
369 if (e.surface === 'mobile') {
370 picker = (
371 <Button
372 key="threshold"
373 label={`Auto ${stepLabel(limit)}`}
374 onPress={() => void cycleThreshold($)}
375 />
376 )
377 } else {
378 const { Select } = $.ui.resolve(e)
379 picker = (
380 <Select
381 key="threshold"
382 label="Auto"
383 options={OPTIONS}
384 value={String(limit)}
385 onSelect={value => void setThreshold($, Number(value))}
386 />
387 )
388 }
389
390 const contextRow = (
391 <Box
392 key="context"
393 flexDirection="row"
394 flexWrap="wrap"
395 alignItems="center"
396 // The desktop's native Button is taller than one text row and spills onto the row below.
397 minHeight={e.surface === 'desktop' ? DESKTOP_BUTTON_ROWS : undefined}
398 >
399 {meter({ Box, Text }, layout, {
400 label: 'Context',
401 percent: fill === null ? null : fill.percent,
402 tone: contextTone(fill === null ? 0 : fill.percent, limit),
403 detail: fill === null ? '' : `(${tokens(fill.tokens)}/${tokens(fill.window)})`,
404 })}
405 <Box flexDirection="row" gap={1} flexShrink={0}>
406 {picker}
407 <Button key="compact" label="Compact" onPress={() => runCommand($, 'compact')} />
408 <Button key="clear" label="Clear" onPress={() => runCommand($, 'clear')} />
409 </Box>
410 </Box>
411 )
412 const usageRow = (
413 <Box key="usage" flexDirection="row" flexWrap="wrap">
414 {cellsOf(list, at).map(one => meter({ Box, Text }, layout, one))}
415 </Box>
416 )
417
418 return (
419 <Box flexDirection="column">
420 {layout.isUsageAbove ? [usageRow, contextRow] : [contextRow, usageRow]}
421 {below}
422 </Box>
423 )
424 })
425}
426types/index.d.ts 21 lines1export type ContextFill = { percent: number; tokens: number; window: number }
2
3export type UsageWindow = {
4 kind: string
5 percentUsed: number
6 resetsAt?: string
7}
8
9export type Reading = { windows: UsageWindow[] }
10
11declare module 'claude-code' {
12 interface PluginState {
13 'context-usage': {
14 context: ContextFill | null
15 threshold: number
16 windows: UsageWindow[]
17 now: number
18 }
19 }
20}
21