SLOPSHOPPER

context-usage

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…

bandtoasttimer
★ 3v0.2.4MITupdated 2026-10-24T-AI-Labs/claude-code-mods/context-usage
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-usage
› 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 Context ▮▮▯▯▯ 5h ▮▮▯▯▯ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Context ▮▮▯▯▯ 5h ▮▮▯▯▯ ⟨Claude Code's own drawing⟩
README

<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>

Installation

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.

Mods

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&#8209;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&#8209;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&#8209;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&#8209;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&#8209;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-usage

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.

Usage

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.

Options

OptionValuesDefaultEffect
displaygauge, textgaugegauge draws a five-segment gauge before each percentage; text leaves it out.
detailstrue, falsetrueShows the figures in parentheses.
usagePositionbelow, abovebelowPuts 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.

context-meter

ElementWhat it shows
ContextFill 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 %.
AutoThe 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.
CompactRuns /compact now.
ClearRuns /clear.

Usage

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.

Options

OptionValuesDefaultEffect
displaygauge, textgaugegauge draws a five-segment gauge before the percentage; text leaves it out.
detailstrue, falsetrueShows 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:

How auto-compact works

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.

usage-limits

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 %.

Usage

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.

Options

OptionValuesDefaultEffect
displaygauge, textgaugegauge draws a five-segment gauge before each percentage; text leaves it out.
detailstrue, falsetrueShows 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.

cache-meter

ElementWhat it shows
CacheShare 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 / coldHow 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 warmOff 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.

Usage

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.

Options

OptionValuesDefaultEffect
displaygauge, textgaugegauge draws a five-segment gauge before the percentage; text leaves it out.
detailstrue, falsetrueShows the figures in parentheses.
keepWarmMinutes15, 30, 60, 12060How 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.

Why the hit rate matters

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.

What Keep warm costs

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.

catalog-pane

A pane with one tab per category: Skills, Commands, Agents, Mods, Plugins, Hooks (from the settings files), MCP servers and built-in Tools.

Usage

/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:

SortOrder
Most used, Recently used, NameBy usage, or alphabetically.
Clean-upCostly and unused entries first.
GitHub stars, Trending, GitHub forks, Latest GitHub pushBy the plugin's repository; Trending is stars gained per month.
Installs, Newest installedBy 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.

Options

OptionValuesDefaultEffect
languageany language nameEnglishThe language Explain with Haiku answers in.
githubStatstrue, falsetrueFetches 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.

Network and model requests

  • GitHub: once a day, the public API for each plugin repository's stars, forks, open issues and last push. Off with githubStats.
  • Haiku: one request of at most 400 output tokens per Explain with Haiku. The answer is kept, so each entry is explained once.
  • Claude Code CLI: 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.

License

MIT

Source 2 files
hooks/register.tsx 426 lines
1import { 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}
426
types/index.d.ts 21 lines
1export 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