SLOPSHOPPER

context-meter

One row of usage pills above the prompt (session and weekly limits, context, prompt cache, cost), a Compact button, and ;name prompt snippets

newbandcommandtoastprompttimer
v4.3.0MITupdated 2026-10-03ayush-905/claude-code-mods/context-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-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 › /meter ⎿ context-meter: Usage pills hidden. /meter brings them back. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

context-meter

A Claude Code mod with two features: one row of usage pills above the prompt, and prompt snippets. /meter hides or shows the row.

The pills: 5h 7% with 3h 58m left, 7d 43% with 14h 7m left, Context 41%, Cache 51m of 1h, $61.25

claude plugin install context-meter@claude-code-mods

(After claude plugin marketplace add ayush-905/claude-code-mods; see the repo README.) The desktop app's Code tab draws the pills as small pictures, following its light or dark appearance; the terminal draws them as colored text. The desktop is where it has been used; the terminal is covered by the tests.

Usage pills

  • 5h and 7d: your account's session (5-hour) and weekly limits: the share used, a meter, and the time until it resets. The meter's mark is how far through the window the clock is, so a fill past the mark means you're spending faster than the window lasts. A window past its reset reads 0% │ reset. Only on a Claude subscription; an API key has no such limits.
  • Context: how full the context window is; the pie and the meter fill with it. Right after a compaction, and in a fresh session before its first reply, no response has reported the window's size yet; the pill then shows Claude Code's own estimate, marked ~ (as in ~8%), until the next reply.
  • Cache: time left on the prompt cache, then its lifetime (42m │ 1h). Each main-thread request reads the cached conversation and restarts the timer; once it runs out the cache is cold, and the next request writes the whole context again at the cache-write price (shown as re-cache 152k). The lifetime follows Claude Code's own choice: CLAUDE_CODE_PROMPT_CACHE_TTL, then the promptCacheTtl setting, then automatic: 1 hour on a Claude subscription within its usage limits, 5 minutes past them or on an API key. It shows once the session's first reply says which applies. Compacting, with the button or a typed /compact, starts the timer over. A resumed session shows it from its first new request (an organization's plugin policy may keep installed plugins off the resume details that would date it sooner).
  • $: what the session has cost, from Claude Code's own ledger (/cost).
  • Compact: a button that runs Claude Code's own /compact, just as typing it does. It shows once the context passes 70% and while Claude isn't working. (A plugin can't compact under the press itself, and in an SDK session such as the desktop's Code tab only the /compact command compacts, so the press starts /compact a moment after.) Change the threshold with the plugin's "Show Compact at" option in /config.
  • Hide or /meter hides the row; /meter brings it back.

The limits across sessions. They're your account's, but a session only hears them on its own replies. A reading counts only when it arrives with a reply, stamped with the reply's time; what an idle session still holds from its last reply is never shared, so reloading many open sessions at once (as a plugin update does) can't overwrite a fresh reading with a stale one. Each session writes its newest reading to ~/.claude/plugins/data/context-meter/limits.json, and every session checks it every 30 seconds and shows the newest. A new session shows the limits before its first reply. Usage on other machines or on claude.ai shows up once any session here gets a reply.

On the desktop (and every surface but the terminal) each pill is a small SVG picture: a tinted, rounded chip with a line icon, a mini meter and monospace figures, its colors following the app's light or dark appearance. The terminal draws the same pills as colored text. A figure that should worry turns amber from 70% and red from 90% (the cache: under a quarter and a tenth of its lifetime left). The row never wraps: all the pills fit a band about 900px wide with Compact showing (a test checks it); on a narrower window the rightmost pills are clipped, and Compact and Hide stay.

Prompt snippets

Type ;name anywhere in a prompt and it expands into a saved snippet before it's sent: ;review the auth module ;tests.

  • Snippets are Markdown files: ~/.claude/snippets/<name>.md for every project, and a project's .claude/snippets/<name>.md for that project. The project's version wins when both exist. (With CLAUDE_CONFIG_DIR set, yours live in its snippets folder, and the shared limits in its plugins/data/context-meter.)
  • An optional front matter description: line labels it in the listing.
  • /snippets lists them. /snippets add <name> <text> saves one to ~/.claude/snippets. Delete the file to remove one.
  • Unknown names, slash commands, and ; inside a word (a;b) are left as typed.

Files

FileWhat it holds
hooks/register.tsxEvery hook and every engine ($) call, grouped by feature
hooks/band.tsxThe band's drawing: the context and cache pills, the row, the buttons
hooks/account.tsThe limits and cost pills
hooks/pill.tsxOne pill: an SVG chip, or colored text on the terminal
hooks/cache.tsPure prompt-cache logic: the lifetime and the time left
hooks/usage.tsPure usage math: the engine's figures into readings
hooks/limits.tsPure limits logic: the engine's windows into readings, the shared file
hooks/snippets.tsPure snippet logic: parsing files, expanding ;name, the listing
hooks/format.tsPure helpers: tones, labels, number, dollar and time formats
types/index.d.tsThe $.state contract: every value the mod keeps
tests/register.test.tsxTests on the terminal and desktop surfaces

Adding a feature

The engine has two rules that shape this layout:

  • Each event once per plugin. on('prompt.submit', ...) can be registered only once (registrations with different matchers, like command.run for meter and snippets, count as different). When a new feature needs an event that's already hooked, add its work to that hook.
  • $ stays in register.tsx. The engine follows $ into functions in the same file only, never across an import. Put engine calls in register.tsx (top-level helpers there are fine) and keep other files pure.
  • The engine's own drawing stays out of clipped Boxes. In a ui.render hook, what next(e) returns is the engine's own drawing; under a Box with overflow the engine refuses the whole tree and draws its own instead. The test kit's stand-in for it is an ordinary Box, so a test checks this rule.

Steps:

  1. Keep new values in $.state: declare them in types/index.d.ts and add an atom in register.tsx.
  2. Hook the events in register.tsx, and put the logic in a pure file under hooks/.
  3. Add a test, then check it, from the repo root:
claude plugin validate context-meter
claude plugin test context-meter
npx tsc -p context-meter
  1. Bump version in .claude-plugin/plugin.json and in the repo's .claude-plugin/marketplace.json, and push. Installs pick the update up with:
claude plugin marketplace update claude-code-mods && claude plugin update context-meter@claude-code-mods

New sessions run the update.

Source 10 files
hooks/register.tsx 366 lines
1// context-meter: one row of pills above the prompt (the account's session and
2// weekly limits, the context window, the prompt cache, this session's cost),
3// and ;name prompt snippets. /meter hides or shows the row.
4//
5// The engine takes each event once per plugin and follows `$` only into
6// functions in this file, so this file holds every hook and every engine call,
7// grouped by feature. The other files are pure: band.tsx, account.ts and
8// pill.tsx draw, format.ts formats, usage.ts, cache.ts, limits.ts and
9// snippets.ts turn engine figures and files into values.
10//
11// Band: the pills, and a Compact button once the context passes `compactAt`.
12// Limits: a session only hears them on its own replies, so each session leaves
13// its newest reading in one file and every session shows the newest of them,
14// checking every 30 seconds. A reading counts only when it arrives with a
15// reply, stamped with the reply's time: what an idle session still holds from
16// its last reply is never shared, so a reload of many sessions at once can't
17// overwrite a fresh reading.
18// Snippets: ~/.claude/snippets/<name>.md and the project's .claude/snippets
19// (the project's win), expanded wherever ;name appears in a prompt.
20// Paths under ~/.claude follow CLAUDE_CONFIG_DIR when it is set.
21
22import { atom, read, update } from 'claude-code'
23import type { EngineInterface, Register, SessionContextUsage, SessionRateLimit } from 'claude-code'
24
25import type { Reading } from '../types'
26import { band, bandPills } from './band'
27import type { TtlConfig } from './cache'
28import { cacheState, cacheTtl, settingTtl } from './cache'
29import type { SharedLimits } from './limits'
30import { SHARED_FILE, limitsOf, parseShared } from './limits'
31import type { Snippet } from './snippets'
32import { USAGE, expand, listing, mayExpand, parseAdd, parseSnippet, snippetName } from './snippets'
33import { readingOf } from './usage'
34
35const COMPACT_AT = 70
36// How often the countdowns redraw and a session looks for a newer limits reading.
37const TICK_EVERY = 30_000
38
39const context = atom({ plugin: 'context-meter', key: 'context' } as const, null)
40const limits = atom({ plugin: 'context-meter', key: 'limits' } as const, null)
41const accountLimits = atom({ plugin: 'context-meter', key: 'accountLimits' } as const, [])
42const accountLimitsAt = atom({ plugin: 'context-meter', key: 'accountLimitsAt' } as const, null)
43const cost = atom({ plugin: 'context-meter', key: 'cost' } as const, null)
44const isHidden = atom({ plugin: 'context-meter', key: 'isHidden' } as const, false)
45const isCompacting = atom({ plugin: 'context-meter', key: 'isCompacting' } as const, false)
46const lastRequestAt = atom({ plugin: 'context-meter', key: 'lastRequestAt' } as const, null)
47
48export const register: Register = (on, options) => {
49  const compactAt = typeof options.compactAt === 'number' ? options.compactAt : COMPACT_AT
50  // What decides the prompt cache's lifetime; read at session start.
51  let ttlConfig: TtlConfig = {}
52  // When the latest model reply of this load arrived; the limits a measurement
53  // carries are that reply's. Null until one arrives.
54  let lastReplyAt: number | null = null
55
56  on('session.start', async ($, e, next) => {
57    await $.command.register({ name: 'meter', description: 'Show or hide the usage pills above the prompt' })
58    await $.command.register({
59      name: 'snippets',
60      description: 'List prompt snippets (;name), or save one: /snippets add <name> <text>',
61    })
62    ttlConfig = await readTtlConfig($)
63    const reading = await readingNow($)
64    await update($, context, () => reading)
65    // Another session's reading shows the limits before this one's first reply.
66    // This session's own figures wait for its next reply: the ones it holds now
67    // may be hours old.
68    await syncLimits($)
69    const spent = (await $.session.usage()).cost?.usd ?? null
70    await update($, cost, () => spent)
71    $.clock.every(TICK_EVERY, () => void tick($))
72
73    return next(e)
74  })
75
76  // Each main-thread request reads the cached conversation and restarts its
77  // timer. Every reply, a subagent's too, brings the limits.
78  on('turn.step', async function* ($, e, next) {
79    if (e.agentId === undefined) {
80      const at = await $.clock.now()
81      await update($, lastRequestAt, () => at)
82    }
83    const reply = yield* next(e)
84    lastReplyAt = await $.clock.now()
85
86    return reply
87  })
88
89  on('session.measure', async ($, e, next) => {
90    const reading = await readingNow($, e.context)
91    await update($, context, () => reading)
92    // This session's own limits say which cache lifetime applies: none at all
93    // after the first reply means an API key.
94    if (e.changed.includes('rateLimits') || (await read($, limits)) === null) {
95      await update($, limits, () => limitsOf(e.rateLimits))
96    }
97    if (lastReplyAt !== null) {
98      await record($, e.rateLimits, lastReplyAt)
99    }
100    if (e.cost !== undefined) {
101      const spent = e.cost.usd
102      await update($, cost, () => spent)
103    }
104
105    return next(e)
106  })
107
108  // Any compaction of the main conversation, typed or the Compact button's:
109  // the engine puts the compacted window in place once this dispatch ends.
110  on('session.compact', async ($, e, next) => {
111    const result = await next(e)
112    if (e.agentId === undefined && e.trigger !== 'precompute' && result.skip === undefined) {
113      $.clock.after(0, () => void afterCompaction($))
114    }
115
116    return result
117  })
118
119  on('prompt.submit', async ($, e, next) => {
120    if (!mayExpand(e.text, e.origin.kind)) {
121      return next(e)
122    }
123    const expanded = expand(e.text, await loadSnippets($))
124    if (expanded.used.length === 0) {
125      return next(e)
126    }
127    $.ui.toast(`Expanded ${expanded.used.map(name => `;${name}`).join(', ')}`)
128
129    return next({ ...e, text: expanded.text })
130  })
131
132  on('command.run', { command: 'meter' }, async $ => {
133    const wasHidden = await read($, isHidden)
134    await update($, isHidden, () => !wasHidden)
135
136    return { text: wasHidden ? 'Usage pills shown.' : 'Usage pills hidden. /meter brings them back.' }
137  })
138
139  on('command.run', { command: 'snippets' }, async ($, e) => {
140    const args = e.args.trim()
141    if (args === '' || args === 'list') {
142      return { text: listing([...(await loadSnippets($)).values()]) }
143    }
144    const added = parseAdd(args)
145
146    return { text: added === null ? USAGE : await saveSnippet($, added.name, added.body) }
147  })
148
149  // The engine's own band drawing (`beneath`) stays in the row, after the pills.
150  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
151    if (e.props.hasSurvey) {
152      return next(e)
153    }
154    const beneath = await next(e)
155    if (await read($, isHidden)) {
156      return beneath
157    }
158    const reading = await read($, context)
159    const now = await $.clock.now()
160    const ttl = cacheTtl(ttlConfig, await read($, limits), now)
161    const view = {
162      surface: e.surface,
163      context: reading,
164      cache: ttl === null ? null : cacheState(await read($, lastRequestAt), ttl, now, reading?.tokens ?? 0),
165      account: { limits: await read($, accountLimits), cost: await read($, cost), now },
166      onHide: () => update($, isHidden, () => true),
167      compact: (await read($, isCompacting))
168        ? ('busy' as const)
169        : !e.props.isWorking && reading !== null && reading.percent >= compactAt
170          ? () => compactNow($)
171          : null,
172    }
173    if (bandPills(view).length === 0) {
174      return beneath
175    }
176
177    return band($.ui.resolve(e), view, beneath)
178  })
179}
180
181// Band
182
183// Claude Code's configuration folder: CLAUDE_CONFIG_DIR, else ~/.claude.
184async function configFolder($: EngineInterface): Promise<string | null> {
185  const configured = await $.env.get('CLAUDE_CONFIG_DIR')
186  if (configured !== undefined && configured !== '') {
187    return configured
188  }
189  const home = await $.env.get('HOME')
190
191  return home === undefined ? null : `${home}/.claude`
192}
193
194// The prompt cache's lifetime settings: the environment, then the most
195// specific settings file that sets promptCacheTtl (local, project, yours).
196async function readTtlConfig($: EngineInterface): Promise<TtlConfig> {
197  const config = await configFolder($)
198  const files = ['.claude/settings.local.json', '.claude/settings.json', ...(config === null ? [] : [`${config}/settings.json`])]
199  let setting: unknown
200  for (const file of files) {
201    try {
202      setting = (await $.fs.exists(file)) ? settingTtl(await $.fs.read(file)) : undefined
203    } catch {
204      setting = undefined
205    }
206    if (setting !== undefined) {
207      break
208    }
209  }
210
211  return {
212    env: await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'),
213    force5m: await $.env.get('FORCE_PROMPT_CACHING_5M'),
214    enable1h: await $.env.get('ENABLE_PROMPT_CACHING_1H'),
215    setting,
216  }
217}
218
219// The window's fill: the last response's figures, or, until the live window
220// has a response (a fresh session, or one just compacted), the engine's own
221// estimate, which it makes locally without a request.
222async function readingNow($: EngineInterface, live?: SessionContextUsage): Promise<Reading | null> {
223  const usage = live ?? (await $.session.usage()).context
224  if (usage.tokens !== undefined) {
225    return readingOf(usage)
226  }
227  try {
228    return readingOf(usage, (await $.session.usage({ breakdown: 'summary' })).context.breakdown?.totalTokens)
229  } catch {
230    return readingOf(usage)
231  }
232}
233
234async function afterCompaction($: EngineInterface) {
235  // The compacted conversation is a new prefix: its cache starts with the next request.
236  await update($, lastRequestAt, () => null)
237  const reading = await readingNow($)
238  await update($, context, () => reading)
239}
240
241async function tick($: EngineInterface) {
242  await syncLimits($)
243  $.ui.invalidate('ui.render')
244}
245
246// The Compact button runs Claude Code's own /compact. A plugin can't compact
247// under the press that asked (the engine holds that dispatch), and in an SDK
248// session, which the desktop's Code tab is, only the /compact command compacts.
249// So the press only marks the band busy and starts /compact just after; the
250// session.compact hook reads the compacted window.
251async function compactNow($: EngineInterface) {
252  if (await read($, isCompacting)) {
253    return
254  }
255  await update($, isCompacting, () => true)
256  $.clock.after(0, () => void runCompact($))
257}
258
259async function runCompact($: EngineInterface) {
260  try {
261    await $.command.run({ command: 'compact' })
262  } catch (error) {
263    const reason = error instanceof Error ? error.message.replace(/^[\w-]+: /, '') : String(error)
264    $.ui.toast(`Compaction failed: ${reason}`)
265  } finally {
266    await update($, isCompacting, () => false)
267  }
268}
269
270// Limits
271
272async function record($: EngineInterface, windows: readonly SessionRateLimit[], observedAt: number) {
273  if (windows.length > 0) {
274    await adoptLimits($, { observedAt, limits: limitsOf(windows) }, true)
275  }
276}
277
278// Takes a reading when it is newer than the one shown; this session's own
279// readings are also left for the other sessions.
280async function adoptLimits($: EngineInterface, reading: SharedLimits, isOwn: boolean) {
281  const shownAt = await read($, accountLimitsAt)
282  if (shownAt !== null && shownAt >= reading.observedAt) {
283    return
284  }
285  await update($, accountLimits, () => reading.limits)
286  await update($, accountLimitsAt, () => reading.observedAt)
287  if (!isOwn) {
288    return
289  }
290  const path = await sharedPath($)
291  const shared = path === null ? null : await readShared($, path)
292  if (path !== null && (shared === null || shared.observedAt < reading.observedAt)) {
293    try {
294      await $.fs.write(path, JSON.stringify(reading))
295    } catch {
296      // Sharing is best effort; this session still shows its own reading.
297    }
298  }
299}
300
301async function syncLimits($: EngineInterface) {
302  const path = await sharedPath($)
303  const shared = path === null ? null : await readShared($, path)
304  if (shared !== null) {
305    await adoptLimits($, shared, false)
306  }
307}
308
309async function sharedPath($: EngineInterface): Promise<string | null> {
310  const config = await configFolder($)
311
312  return config === null ? null : `${config}/${SHARED_FILE}`
313}
314
315async function readShared($: EngineInterface, path: string): Promise<SharedLimits | null> {
316  try {
317    return (await $.fs.exists(path)) ? parseShared(await $.fs.read(path)) : null
318  } catch {
319    return null
320  }
321}
322
323// Snippets
324
325async function snippetFolders($: EngineInterface): Promise<string[]> {
326  const config = await configFolder($)
327
328  // Later folders win: the project's snippets override yours.
329  return [...(config === null ? [] : [`${config}/snippets`]), '.claude/snippets']
330}
331
332async function loadSnippets($: EngineInterface): Promise<Map<string, Snippet>> {
333  const found = new Map<string, Snippet>()
334  for (const folder of await snippetFolders($)) {
335    if (!(await $.fs.exists(folder))) {
336      continue
337    }
338    for (const entry of await $.fs.list(folder)) {
339      const name = entry.kind === 'file' ? snippetName(entry.name) : null
340      if (name === null) {
341        continue
342      }
343      const path = `${folder}/${entry.name}`
344      try {
345        found.set(name, { name, path, ...parseSnippet(await $.fs.read(path)) })
346      } catch {
347        // An unreadable file is left out; the rest still expand.
348      }
349    }
350  }
351
352  return found
353}
354
355async function saveSnippet($: EngineInterface, name: string, body: string): Promise<string> {
356  const config = await configFolder($)
357  if (config === null) {
358    return 'Could not find your Claude Code folder to save the snippet in.'
359  }
360  const path = `${config}/snippets/${name}.md`
361  const existed = await $.fs.exists(path)
362  await $.fs.write(path, `${body}\n`)
363
364  return `${existed ? 'Updated' : 'Saved'} ;${name} in ${path}`
365}
366
hooks/band.tsx 106 lines
1// The band above the prompt: a plain function of what the hooks recorded.
2// One row of pills (the account's limits, the context, the prompt cache, the
3// session's cost), then Compact and Hide on the right:
4//
5//   (◷ 5h ━┃━ 20% │ 2h 40m) (▦ 7d ━━┃ 58% │ 1d 7h) (◔ Context ━━ 8%) (⧗ Cache 42m │ 1h) ($54.21)              Hide
6//
7// Until a fresh or just-compacted window has a response, the context figure is
8// the engine's estimate: `~8%`.
9
10import type { ElementTable, RenderElement, RenderSurface } from 'claude-code'
11
12import type { Reading } from '../types'
13import type { AccountView } from './account'
14import { costPills, limitPills } from './account'
15import type { CacheState } from './cache'
16import { TTL_MS } from './cache'
17import { percentText, short, toneFor, until } from './format'
18import type { Pill } from './pill'
19import { pills } from './pill'
20
21type BandView = {
22  surface: RenderSurface
23  context: Reading | null
24  cache: CacheState | null
25  account: AccountView
26  onHide: () => void
27  // A press handler while the Compact button shows, 'busy' while compacting.
28  compact: (() => void) | 'busy' | null
29}
30
31export function band(t: ElementTable, view: BandView, beneath: RenderElement) {
32  const { Box, Text, Button } = t
33  const own = bandPills(view)
34
35  // One line, never wrapped: the pills on the left (clipped, not wrapped, when
36  // the window is too narrow), what draws beneath, the buttons on the right.
37  // What draws beneath stays outside the clipped Box: the engine refuses its
38  // own drawing under a Box with `overflow`, and then draws nothing of ours.
39  return (
40    <Box flexDirection="row" flexWrap="nowrap" alignItems="center" columnGap={1} flexGrow={1}>
41      <Box flexDirection="row" flexWrap="nowrap" alignItems="center" columnGap={1} flexShrink={1} overflow="hidden">
42        {pills(t, view.surface, own)}
43      </Box>
44      {beneath}
45      <Box flexGrow={1} />
46      <Box flexDirection="row" flexWrap="nowrap" alignItems="center" columnGap={2} flexShrink={0}>
47        {view.compact === 'busy' && <Text dimColor>Compacting…</Text>}
48        {typeof view.compact === 'function' && (
49          <Button key="compact" label="Compact" variant="primary" onPress={view.compact} />
50        )}
51        <Button key="hide" label="Hide" plain dimColor onPress={view.onHide} />
52      </Box>
53    </Box>
54  )
55}
56
57// Every pill the band has figures for; empty before any.
58export const bandPills = (view: Pick<BandView, 'context' | 'cache' | 'account'>): Pill[] => [
59  ...limitPills(view.account),
60  ...(view.context === null ? [] : [contextPill(view.context)]),
61  ...(view.cache === null ? [] : [cachePill(view.cache)]),
62  ...costPills(view.account),
63]
64
65function contextPill(reading: Reading): Pill {
66  const tone = toneFor(reading.percent)
67  const about = reading.isEstimate === true ? '~' : ''
68
69  return {
70    key: 'context',
71    hue: 'sky',
72    icon: 'pie',
73    label: 'Context',
74    meter: { fill: reading.percent },
75    value: `${about}${percentText(reading.percent)}`,
76    ...(tone === 'success' ? {} : { loud: tone }),
77    alt:
78      `Context ${about}${percentText(reading.percent)} full: ${about}${short(reading.tokens)} of ${short(reading.window)} tokens` +
79      (reading.isEstimate === true ? ", the engine's estimate until the next reply" : ''),
80  }
81}
82
83// The prompt cache: the time left on its lifetime, louder as it runs out; once
84// cold, the next request writes the whole context again.
85function cachePill(cache: CacheState): Pill {
86  const base = { key: 'cache', hue: 'pink', icon: 'hourglass', label: 'Cache' } as const
87  if (cache.remaining <= 0) {
88    return {
89      ...base,
90      value: 'cold',
91      loud: 'warning',
92      after: { text: `re-cache ${short(cache.tokens)}` },
93      alt: `Prompt cache cold: the next request writes ${short(cache.tokens)} tokens to it again`,
94    }
95  }
96  const left = cache.remaining / TTL_MS[cache.ttl]
97
98  return {
99    ...base,
100    value: until(cache.remaining),
101    ...(left > 0.25 ? {} : { loud: left > 0.1 ? 'warning' : 'error' }),
102    after: { text: cache.ttl },
103    alt: `Prompt cache: ${until(cache.remaining)} left of its ${cache.ttl} lifetime`,
104  }
105}
106
hooks/cache.ts 65 lines
1// Pure prompt-cache logic: which lifetime the session's cache has, and how
2// much of it is left.
3//
4// Every main-thread request reads the cached conversation and restarts its
5// timer, measured from the request's start. Once the timer runs out the next
6// request writes the whole context to the cache again, at the write price.
7
8import type { Limit } from '../types'
9
10export type CacheTtl = '5m' | '1h'
11
12export const TTL_MS: Record<CacheTtl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
13
14// What decides the lifetime, read once at session start.
15export type TtlConfig = {
16  // CLAUDE_CODE_PROMPT_CACHE_TTL, FORCE_PROMPT_CACHING_5M, ENABLE_PROMPT_CACHING_1H
17  env?: string
18  force5m?: string
19  enable1h?: string
20  // The promptCacheTtl setting, from the most specific settings file that sets it.
21  setting?: unknown
22}
23
24const isTtl = (value: unknown): value is CacheTtl => value === '5m' || value === '1h'
25
26const isOn = (value: string | undefined) =>
27  value !== undefined && value !== '' && value !== '0' && value.toLowerCase() !== 'false'
28
29// The lifetime as Claude Code chooses it: the environment variable, then the
30// setting, then automatic: 1 hour on a Claude subscription within its usage
31// limits, 5 minutes past them or on an API key. `limits` is null until the
32// session's first reply says which; the lifetime is unknown until then.
33export function cacheTtl(config: TtlConfig, limits: readonly Limit[] | null, now: number): CacheTtl | null {
34  if (isTtl(config.env)) return config.env
35  if (isOn(config.force5m)) return '5m'
36  if (isTtl(config.setting)) return config.setting
37  if (isOn(config.enable1h)) return '1h'
38  if (limits === null) return null
39  const live = limits.filter(limit => limit.resetsAt === undefined || limit.resetsAt > now)
40  if (live.length === 0) return '5m'
41
42  return live.some(limit => limit.percent >= 100) ? '5m' : '1h'
43}
44
45// The promptCacheTtl a settings file sets, or undefined.
46export function settingTtl(text: string): unknown {
47  try {
48    const data: unknown = JSON.parse(text)
49    return typeof data === 'object' && data !== null ? (data as { promptCacheTtl?: unknown }).promptCacheTtl : undefined
50  } catch {
51    return undefined
52  }
53}
54
55// The cache's state for the band, or null before the first request.
56export type CacheState = { ttl: CacheTtl; remaining: number; tokens: number }
57
58export function cacheState(lastRequestAt: number | null, ttl: CacheTtl, now: number, tokens: number): CacheState | null {
59  if (lastRequestAt === null) {
60    return null
61  }
62
63  return { ttl, remaining: lastRequestAt + TTL_MS[ttl] - now, tokens }
64}
65
hooks/limits.ts 52 lines
1// Pure limits logic: the engine's rate-limit windows into readings, and the
2// file the sessions share them through.
3
4import type { SessionRateLimit } from 'claude-code'
5
6import type { Limit } from '../types'
7
8export function limitsOf(windows: readonly SessionRateLimit[]): Limit[] {
9  return windows.map(one => {
10    const resetsAt = one.resetsAt === undefined ? NaN : Date.parse(one.resetsAt)
11
12    return Number.isNaN(resetsAt)
13      ? { kind: one.kind, percent: one.percentUsed }
14      : { kind: one.kind, percent: one.percentUsed, resetsAt }
15  })
16}
17
18// The limits are the account's, shared by every session, but each session
19// only hears them on its own replies. So each session leaves its newest
20// reading in one file, and every session shows the newest of them.
21export type SharedLimits = { observedAt: number; limits: Limit[] }
22
23// Under Claude Code's configuration folder, whatever the marketplace is named.
24export const SHARED_FILE = 'plugins/data/context-meter/limits.json'
25
26// The file's reading, or null for a missing, partial or foreign file.
27export function parseShared(text: string): SharedLimits | null {
28  try {
29    const data: unknown = JSON.parse(text)
30    if (typeof data !== 'object' || data === null) {
31      return null
32    }
33    const { observedAt, limits } = data as { observedAt?: unknown; limits?: unknown }
34    if (typeof observedAt !== 'number' || !Array.isArray(limits) || !limits.every(isLimit)) {
35      return null
36    }
37
38    return { observedAt, limits }
39  } catch {
40    return null
41  }
42}
43
44const isLimit = (value: unknown): value is Limit => {
45  if (typeof value !== 'object' || value === null) {
46    return false
47  }
48  const { kind, percent, resetsAt } = value as { kind?: unknown; percent?: unknown; resetsAt?: unknown }
49
50  return typeof kind === 'string' && typeof percent === 'number' && (resetsAt === undefined || typeof resetsAt === 'number')
51}
52
hooks/snippets.ts 81 lines
1// Pure snippet logic: parsing snippet files, expanding ;name tokens in a
2// prompt, and the /snippets listing. register.tsx reads and writes the files.
3//
4// A snippet is a Markdown file, `review.md` for ;review, with an optional
5// front matter `description:` line that labels it in /snippets.
6
7export type Snippet = { name: string; body: string; description: string; path: string }
8
9const NAME = /^[a-z0-9][a-z0-9_-]*$/
10
11// `;name` at the start or after whitespace, ending at whitespace, punctuation or the end.
12const TOKEN = /(^|\s);([a-z0-9][a-z0-9_-]*)(?=$|[\s.,!?:)])/gi
13
14// Whether a prompt could hold a snippet: not a slash command, not a task notification.
15export const mayExpand = (text: string, origin: string): boolean =>
16  origin !== 'task-notification' && !text.trimStart().startsWith('/') && text.includes(';')
17
18// `review.md` → `review`, or null for a file that is not a snippet.
19export function snippetName(fileName: string): string | null {
20  if (!fileName.endsWith('.md')) {
21    return null
22  }
23  const name = fileName.slice(0, -'.md'.length).toLowerCase()
24
25  return NAME.test(name) ? name : null
26}
27
28export function parseSnippet(source: string): { body: string; description: string } {
29  const text = source.replace(/\r\n/g, '\n')
30  const front = /^---\n([\s\S]*?)\n---\n?/.exec(text)
31  const body = (front === null ? text : text.slice(front[0].length)).trim()
32  const described = front === null ? undefined : /^description:\s*(.+)$/m.exec(front[1] ?? '')?.[1]
33
34  return { body, description: (described ?? body.split('\n')[0] ?? '').trim() }
35}
36
37export function expand(text: string, known: ReadonlyMap<string, Snippet>): { text: string; used: string[] } {
38  const used = new Set<string>()
39  const expanded = text.replace(TOKEN, (whole: string, lead: string, name: string) => {
40    const snippet = known.get(name.toLowerCase())
41    if (snippet === undefined || snippet.body === '') {
42      return whole
43    }
44    used.add(snippet.name)
45
46    return `${lead}${snippet.body}`
47  })
48
49  return { text: expanded, used: [...used] }
50}
51
52// `/snippets add <name> <text>` → the name and text, or null when either is missing.
53export function parseAdd(args: string): { name: string; body: string } | null {
54  const match = /^add\s+(\S+)\s+([\s\S]+)$/.exec(args.trim())
55  const name = match?.[1]?.toLowerCase()
56  const body = match?.[2]?.trim()
57
58  return name !== undefined && body !== undefined && NAME.test(name) && body !== '' ? { name, body } : null
59}
60
61export const USAGE = 'Usage: /snippets lists them; /snippets add <name> <text> saves one (a name is letters, digits, - or _).'
62
63export function listing(all: readonly Snippet[]): string {
64  if (all.length === 0) {
65    return [
66      'No snippets yet. Save one with:',
67      '  /snippets add review Review my uncommitted changes for bugs',
68      "or put Markdown files in ~/.claude/snippets (or a project's .claude/snippets).",
69      'Then type ;review anywhere in a prompt.',
70    ].join('\n')
71  }
72  const width = Math.max(...all.map(snippet => snippet.name.length)) + 1
73  const rows = [...all]
74    .sort((a, b) => a.name.localeCompare(b.name))
75    .map(snippet => `  ;${snippet.name.padEnd(width)} ${clip(snippet.description, 70)}`)
76
77  return ['Snippets (type ;name anywhere in a prompt):', ...rows, '', 'Save one: /snippets add <name> <text>'].join('\n')
78}
79
80const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
81
hooks/usage.ts 24 lines
1// Pure usage math: the engine's figures turned into what the band keeps.
2
3import type { SessionContextUsage } from 'claude-code'
4
5import type { Reading } from '../types'
6
7// The window's fill as the last response reported it. A fresh session, or one
8// just compacted, has no response yet: then `estimate`, the engine's estimate
9// of what the window holds, when there is one.
10export function readingOf(window: SessionContextUsage, estimate?: number): Reading | null {
11  if (!window.window) {
12    return null
13  }
14  if (window.tokens !== undefined) {
15    return { tokens: window.tokens, window: window.window, percent: window.percent ?? percentOf(window.tokens, window.window) }
16  }
17
18  return estimate === undefined
19    ? null
20    : { tokens: estimate, window: window.window, percent: percentOf(estimate, window.window), isEstimate: true }
21}
22
23const percentOf = (tokens: number, window: number) => Math.round((tokens / window) * 100)
24
hooks/account.ts 59 lines
1// The account's pills: the session (5-hour) and weekly limits, and this
2// session's cost.
3//
4//   (◷ 5h ━┃━━ 20% │ 2h 40m) (▦ 7d ━━━┃ 58% │ 1d 7h)  …  ($54.21)
5//
6// Each limit's meter is the share used, and its mark how far through the
7// window the clock is: a fill past the mark is spending faster than the window
8// lasts. After the rule, the time until the window resets. $ is the session's
9// cost, from Claude Code's ledger.
10
11import type { Limit } from '../types'
12import { dollars, limitLabel, limitOrder, percentText, toneFor, until } from './format'
13import type { Hue, Icon, Pill } from './pill'
14
15export type AccountView = { limits: readonly Limit[]; cost: number | null; now: number }
16
17const HOUR = 3_600_000
18
19// The windows the account has, as the pills name them.
20const WINDOWS: Record<string, { label: string; hue: Hue; icon: Icon; length: number }> = {
21  five_hour: { label: '5h', hue: 'teal', icon: 'gauge', length: 5 * HOUR },
22  seven_day: { label: '7d', hue: 'violet', icon: 'calendar', length: 7 * 24 * HOUR },
23}
24
25const ordered = (limits: readonly Limit[]) => [...limits].sort((a, b) => limitOrder(a.kind) - limitOrder(b.kind))
26
27// The limits' pills, session first, and the cost's.
28export const limitPills = (view: AccountView): Pill[] => ordered(view.limits).map(limit => limitPill(limit, view.now))
29
30export const costPills = (view: AccountView): Pill[] =>
31  view.cost === null || view.cost <= 0
32    ? []
33    : [{ key: 'cost', hue: 'amber', icon: 'coin', value: dollars(view.cost), alt: `This session has cost ${dollars(view.cost)}` }]
34
35function limitPill(limit: Limit, now: number): Pill {
36  const known = WINDOWS[limit.kind]
37  const name = limitLabel(limit.kind)
38  const reset = limit.resetsAt !== undefined && limit.resetsAt <= now
39  const percent = reset ? 0 : limit.percent
40  const tone = toneFor(percent)
41  const left = reset || limit.resetsAt === undefined ? null : limit.resetsAt - now
42  const mark = left === null || known === undefined ? null : Math.max(0, 100 - (left / known.length) * 100)
43  const said = reset ? ', reset' : left === null ? '' : `, resets in ${until(left)}`
44  const passed = mark === null ? '' : `; ${Math.round(mark)}% of the window has passed`
45
46  return {
47    key: `limit-${limit.kind}`,
48    hue: known?.hue ?? 'amber',
49    icon: known?.icon ?? 'gauge',
50    label: known?.label ?? name,
51    meter: mark === null ? { fill: percent } : { fill: percent, mark },
52    // Whole percents in the pill, to keep the row narrow; the words keep the exact figure.
53    value: `${Math.round(percent)}%`,
54    ...(tone === 'success' ? {} : { loud: tone }),
55    ...(reset ? { after: { text: 'reset' } } : left === null ? {} : { after: { text: until(left) } }),
56    alt: `${name} limit: ${percentText(percent)} used${said}${passed}`,
57  }
58}
59
hooks/format.ts 52 lines
1// Pure helpers the band draws with: no `$`, no state.
2
3// How a figure looks: the app's theme keys.
4export type Tone = 'success' | 'warning' | 'error'
5
6// How worried a usage percentage should look.
7export const toneFor = (percent: number): Tone =>
8  percent >= 90 ? 'error' : percent >= 70 ? 'warning' : 'success'
9
10// 333000 → "333k", 23500 → "23.5k", 1000000 → "1M".
11export const short = (n: number): string => {
12  if (n >= 1_000_000) return `${scaled(n / 1_000_000)}M`
13  if (n >= 1_000) return `${scaled(n / 1_000)}k`
14  return String(n)
15}
16
17const scaled = (x: number) => (x >= 100 ? String(Math.round(x)) : x.toFixed(1).replace(/\.0$/, ''))
18
19// 4.321 → "$4.32", 0.004 → "<$0.01", 123.4 → "$123".
20export const dollars = (usd: number): string =>
21  usd > 0 && usd < 0.01 ? '<$0.01' : usd >= 100 ? `$${Math.round(usd)}` : `$${usd.toFixed(2)}`
22
23const LIMIT_LABELS: Record<string, string> = {
24  five_hour: 'Session',
25  seven_day: 'Week',
26  spend_limit: 'Spend',
27}
28
29export const limitLabel = (kind: string): string =>
30  LIMIT_LABELS[kind] ?? kind.replace(/_/g, ' ').replace(/^./, first => first.toUpperCase())
31
32// The session window first, then the week, then anything else.
33export const limitOrder = (kind: string): number => {
34  const at = Object.keys(LIMIT_LABELS).indexOf(kind)
35  return at === -1 ? Object.keys(LIMIT_LABELS).length : at
36}
37
38export const percentText = (percent: number): string =>
39  `${Number.isInteger(percent) ? percent : percent.toFixed(1)}%`
40
41// "2h 14m", "3d 4h", "45m", "<1m": how long until a window resets.
42export const until = (ms: number): string => {
43  const minutes = Math.floor(ms / 60_000)
44  if (minutes < 1) return '<1m'
45  const days = Math.floor(minutes / 1440)
46  const hours = Math.floor((minutes % 1440) / 60)
47  if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`
48  const rest = minutes % 60
49  if (hours > 0) return rest > 0 ? `${hours}h ${rest}m` : `${hours}h`
50  return `${rest}m`
51}
52
hooks/pill.tsx 211 lines
1// Pills: one figure each, the chips of the band above the prompt. The desktop
2// (and every surface but the terminal) draws each pill as a small picture: a
3// tinted, rounded chip with an icon, a mini meter and the figures. The terminal
4// draws a run of colored text:
5//
6//   ◷ 5h ━━┃─── 20% │ 2h 40m    ◔ Context ━───── 8%    $4.32
7
8import type { ElementTable, RenderElement, RenderSurface } from 'claude-code'
9
10export type Hue = 'sky' | 'pink' | 'teal' | 'violet' | 'amber'
11export type Icon = 'pie' | 'hourglass' | 'gauge' | 'calendar' | 'coin'
12// A figure that should worry: the app's theme keys on the terminal.
13type Loud = 'warning' | 'error'
14
15export type Pill = {
16  key: string
17  hue: Hue
18  icon: Icon
19  label?: string
20  // `fill` is the share used, 0 to 100; `mark`, how far through its window the clock is.
21  meter?: { fill: number; mark?: number }
22  value: string
23  loud?: Loud
24  // After a rule: `│ 2h 40m`.
25  after?: { text: string }
26  // What the pill says in words, for a reader that can't see it.
27  alt: string
28}
29
30type Table = Pick<ElementTable, 'Box' | 'Text'>
31type SvgTable = Extract<ElementTable, { Svg: unknown }>
32
33// Every surface but the terminal draws Svg. (The table can't be asked: an
34// element it lacks still answers `in`.)
35export function pills(t: ElementTable, surface: RenderSurface, list: readonly Pill[]): RenderElement[] {
36  return list.map(pill => (surface === 'terminal' ? line(t, pill) : picture(t as SvgTable, pill)))
37}
38
39// The picture
40
41// Each hue: `mid` reads on light and dark alike; `light` and `dark` are the
42// deeper and paler shades each theme gets where the surface says which it is.
43const HUES: Record<Hue, { mid: string; light: string; dark: string }> = {
44  sky: { mid: '#0ea5e9', light: '#0369a1', dark: '#7dd3fc' },
45  pink: { mid: '#ec4899', light: '#be185d', dark: '#f9a8d4' },
46  teal: { mid: '#14b8a6', light: '#0f766e', dark: '#5eead4' },
47  violet: { mid: '#8b5cf6', light: '#6d28d9', dark: '#c4b5fd' },
48  amber: { mid: '#f59e0b', light: '#a16207', dark: '#fcd34d' },
49}
50
51// Sizes in CSS pixels. Text is monospace, so its width follows its length.
52const HEIGHT = 22
53const FONT = 11
54const CHAR = 6.6
55const ICON = 12
56const METER = 24
57const BASELINE = 15
58
59function picture({ Box, Svg }: SvgTable, pill: Pill) {
60  const { source, width } = drawPill(pill)
61
62  return (
63    <Box key={pill.key} flexShrink={0}>
64      <Svg source={source} alt={pill.alt} width={width} height={HEIGHT} />
65    </Box>
66  )
67}
68
69// The pill as SVG markup, and its width.
70function drawPill(pill: Pill): { source: string; width: number } {
71  const parts: string[] = []
72  let x = 6
73  parts.push(icon(pill.icon, x, pill.meter?.fill))
74  x += ICON + 3
75  if (pill.label !== undefined) {
76    parts.push(text(pill.label, x, 'fg'))
77    x += textWidth(pill.label) + 4
78  }
79  if (pill.meter !== undefined) {
80    parts.push(meterBar(pill.meter, x, pill.loud))
81    x += METER + 4
82  }
83  parts.push(text(pill.value, x, `b ${pill.loud ?? 'num'}`))
84  x += textWidth(pill.value)
85  if (pill.after !== undefined) {
86    x += 5
87    parts.push(`<rect class="rule" x="${n(x)}" y="6" width="1" height="10"/>`)
88    x += 6
89    parts.push(text(pill.after.text, x, 'fg'))
90    x += textWidth(pill.after.text)
91  }
92  const width = Math.ceil(x + 7)
93
94  return {
95    width,
96    source:
97      `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${HEIGHT}" viewBox="0 0 ${width} ${HEIGHT}">` +
98      `<style>${style(HUES[pill.hue])}</style>` +
99      `<rect class="bg" width="${width}" height="${HEIGHT}" rx="${HEIGHT / 2}"/>${parts.join('')}</svg>`,
100  }
101}
102
103function style(hue: { mid: string; light: string; dark: string }): string {
104  const { mid, light, dark } = hue
105
106  return (
107    `text{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:${FONT}px}.b{font-weight:700}` +
108    `.bg{fill:${mid};fill-opacity:.14}.fg,.fill{fill:${mid}}.num{fill:${mid}}` +
109    `.ln{fill:none;stroke:${mid};stroke-width:1.3;stroke-linecap:round;stroke-linejoin:round}` +
110    `.track{fill:${mid};fill-opacity:.28}.rule{fill:${mid};fill-opacity:.4}.mark{fill:#71717a}` +
111    `.warning{fill:#d97706}.error{fill:#dc2626}` +
112    `@media (prefers-color-scheme:light){.fg,.fill{fill:${light}}.ln{stroke:${light}}.num{fill:#3f3f46}` +
113    `.mark{fill:#52525b}.warning{fill:#b45309}.error{fill:#b91c1c}}` +
114    `@media (prefers-color-scheme:dark){.bg{fill-opacity:.2}.fg,.fill{fill:${dark}}.ln{stroke:${dark}}.num{fill:#e4e4e7}` +
115    `.mark{fill:#d4d4d8}.warning{fill:#fbbf24}.error{fill:#f87171}}`
116  )
117}
118
119function meterBar(meter: { fill: number; mark?: number }, x: number, loud: Loud | undefined): string {
120  const lit = (clamp(meter.fill) / 100) * METER
121  let bar = `<rect class="track" x="${n(x)}" y="9" width="${METER}" height="4" rx="2"/>`
122  if (lit > 0) {
123    bar += `<rect class="fill${loud === undefined ? '' : ` ${loud}`}" x="${n(x)}" y="9" width="${n(Math.max(lit, 2))}" height="4" rx="2"/>`
124  }
125  if (meter.mark !== undefined) {
126    bar += `<rect class="mark" x="${n(x + (clamp(meter.mark) / 100) * METER - 0.75)}" y="6" width="1.5" height="10" rx=".75"/>`
127  }
128
129  return bar
130}
131
132const text = (value: string, x: number, className: string) =>
133  `<text class="${className}" x="${n(x)}" y="${BASELINE}">${escape(value)}</text>`
134
135const textWidth = (value: string) => value.length * CHAR
136
137// 12 by 12 line icons. The pie fills to `fill` (the context's share used).
138function icon(name: Icon, x: number, fill = 0): string {
139  const shapes: Record<Icon, string> = {
140    pie: `<circle class="ln" cx="6" cy="6" r="4.5"/>${pie(fill)}`,
141    hourglass:
142      '<path class="ln" d="M3 1.5h6M3 10.5h6M3.6 1.5C3.6 4.2 8.4 4.2 8.4 6S3.6 7.8 3.6 10.5M8.4 1.5C8.4 4.2 3.6 4.2 3.6 6S8.4 7.8 8.4 10.5"/>',
143    gauge: '<path class="ln" d="M1.5 9a4.5 4.5 0 0 1 9 0M6 9l2.3-2.9"/>',
144    calendar: '<rect class="ln" x="1.5" y="2.5" width="9" height="8" rx="1.5"/><path class="ln" d="M1.5 5.2h9M4 1.3v2.4M8 1.3v2.4"/>',
145    coin:
146      '<circle class="ln" cx="6" cy="6" r="4.8"/><path class="ln" d="M7.6 4.5c-.3-.5-.9-.8-1.6-.8-.9 0-1.6.5-1.6 1.1 0 .7.7.9 1.6 1.1.9.2 1.7.5 1.7 1.2s-.8 1.2-1.7 1.2c-.7 0-1.4-.3-1.7-.9M6 2.6v6.8"/>',
147  }
148
149  return `<g transform="translate(${n(x)} 5)">${shapes[name]}</g>`
150}
151
152function pie(fill: number): string {
153  const share = clamp(fill) / 100
154  if (share <= 0) return ''
155  if (share >= 0.995) return '<circle class="fill" cx="6" cy="6" r="2.8"/>'
156  const angle = share * 2 * Math.PI
157  const x = 6 + 2.8 * Math.sin(angle)
158  const y = 6 - 2.8 * Math.cos(angle)
159
160  return `<path class="fill" d="M6 6V3.2A2.8 2.8 0 ${share > 0.5 ? 1 : 0} 1 ${n(x)} ${n(y)}z"/>`
161}
162
163const clamp = (percent: number) => Math.max(0, Math.min(100, percent))
164const n = (value: number) => String(Math.round(value * 10) / 10)
165const escape = (value: string) => value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
166
167// The line, for a surface without Svg
168
169const GLYPHS: Record<Icon, string> = {
170  pie: '◔',
171  hourglass: '⧗',
172  gauge: '◷',
173  calendar: '▦',
174  coin: '',
175}
176
177const CELLS = 6
178
179function line({ Box, Text }: Table, pill: Pill) {
180  const color = HUES[pill.hue].mid
181  const head = [GLYPHS[pill.icon], pill.label ?? ''].filter(part => part !== '').join(' ')
182  // A Text takes text and Text children alone, so the parts are listed, not toggled.
183  const parts = [
184    ...(head === '' ? [] : [<Text color={color}>{`${head} `}</Text>]),
185    ...(pill.meter === undefined ? [] : cells(Text, pill.meter, pill.loud ?? color)),
186    <Text bold {...(pill.loud === undefined ? {} : { color: pill.loud })}>
187      {pill.value}
188    </Text>,
189    ...(pill.after === undefined ? [] : [<Text dimColor>{` │ ${pill.after.text}`}</Text>]),
190  ]
191
192  return (
193    <Box key={pill.key} flexShrink={0}>
194      <Text>{parts}</Text>
195    </Box>
196  )
197}
198
199// The meter in cells: lit in the pill's color, the clock's mark where it is.
200function cells(Text: Table['Text'], meter: { fill: number; mark?: number }, color: string) {
201  const lit = Math.round((clamp(meter.fill) / 100) * CELLS)
202  const mark = meter.mark === undefined ? -1 : Math.min(CELLS - 1, Math.floor((clamp(meter.mark) / 100) * CELLS))
203
204  return [
205    ...Array.from({ length: CELLS }, (_, at) =>
206      at === mark ? <Text dimColor>┃</Text> : at < lit ? <Text color={color}>━</Text> : <Text dimColor>─</Text>,
207    ),
208    <Text> </Text>,
209  ]
210}
211
types/index.d.ts 28 lines
1// The context window as the last response left it, or, until the live window
2// has a response (a fresh session, or one just compacted), as the engine
3// estimates it.
4export type Reading = { tokens: number; window: number; percent: number; isEstimate?: boolean }
5
6// One rate-limit window: `five_hour` (session), `seven_day` (week), ...
7export type Limit = { kind: string; percent: number; resetsAt?: number }
8
9declare module 'claude-code' {
10  interface PluginState {
11    'context-meter': {
12      context: Reading | null
13      // This session's own limits, which decide the cache's lifetime; null before its first reply.
14      limits: Limit[] | null
15      // The account's limits as the band shows them: the newest reading of any session here.
16      accountLimits: Limit[]
17      // When `accountLimits` was read off a reply; null before any.
18      accountLimitsAt: number | null
19      // What the session has cost, in US dollars; null where the host keeps no ledger.
20      cost: number | null
21      isHidden: boolean
22      isCompacting: boolean
23      // When the last main-thread request started: the prompt cache's timer counts from it.
24      lastRequestAt: number | null
25    }
26  }
27}
28