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

A Claude Code mod with two features: one row of usage pills above the prompt, and prompt snippets. /meter hides or shows the row.
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.
0% │ reset. Only on a Claude subscription; an API key has no such limits.~ (as in ~8%), until the next reply.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)./cost)./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./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.
Type ;name anywhere in a prompt and it expands into a saved snippet before it's sent: ;review the auth module ;tests.
~/.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.)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.; inside a word (a;b) are left as typed.| File | What it holds |
|---|---|
hooks/register.tsx | Every hook and every engine ($) call, grouped by feature |
hooks/band.tsx | The band's drawing: the context and cache pills, the row, the buttons |
hooks/account.ts | The limits and cost pills |
hooks/pill.tsx | One pill: an SVG chip, or colored text on the terminal |
hooks/cache.ts | Pure prompt-cache logic: the lifetime and the time left |
hooks/usage.ts | Pure usage math: the engine's figures into readings |
hooks/limits.ts | Pure limits logic: the engine's windows into readings, the shared file |
hooks/snippets.ts | Pure snippet logic: parsing files, expanding ;name, the listing |
hooks/format.ts | Pure helpers: tones, labels, number, dollar and time formats |
types/index.d.ts | The $.state contract: every value the mod keeps |
tests/register.test.tsx | Tests on the terminal and desktop surfaces |
The engine has two rules that shape this layout:
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.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:
$.state: declare them in types/index.d.ts and add an atom in register.tsx.register.tsx, and put the logic in a pure file under hooks/.claude plugin validate context-meter
claude plugin test context-meter
npx tsc -p context-meter
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.
hooks/register.tsx 366 lines1// 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}
366hooks/band.tsx 106 lines1// 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}
106hooks/cache.ts 65 lines1// 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}
65hooks/limits.ts 52 lines1// 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}
52hooks/snippets.ts 81 lines1// 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)
81hooks/usage.ts 24 lines1// 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)
24hooks/account.ts 59 lines1// 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}
59hooks/format.ts 52 lines1// 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}
52hooks/pill.tsx 211 lines1// 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, '&').replace(/</g, '<').replace(/>/g, '>')
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}
211types/index.d.ts 28 lines1// 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