Context, cost, tool calls and turn time above the prompt

A Claude Code mod that draws a live status band above the prompt: context usage, rate limits, session cost, tool calls, turn timing and 30-day token totals.
↻). Green; yellow above 80%; red above 90%.n/a if unavailable).12.3s, 2m03s, 1h02m). Shows no turns yet before the first turn./clear, as the API cost does.↑, output ↓) read from ~/.claude/stats-cache.json.weekly lines up with api cost.METER_CHARSET=ascii still forces ASCII there./meter command to toggle the meter.Wide, at 135 columns or more:
────────────────────────────────────────
context ███░░░░░░░░░░░ 24% 48k/ 200k │ api cost $1.23 │ tools 4 turn / 31 session │ turn 12.3s · avg 9.8s · total 2m03s
5h limit ██████░░░░░░░░ 41% ↻2h10m │ weekly ███░░░░░░░░░░░ 22% ↻4d3h │ 30d tokens 710M ↑ 6k ↓ 5.5M (est.) · to 10-10
Compact, below 135 columns (fits 74 columns):
──────────────────────────────────────────────────────────────────────────
context ████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 24% 48k/200k
5h limit █████░░░░░░░ 41% ↻2h10m │ weekly ███░░░░░░░░░ 22% ↻4d3h
tools 4/31 │ turn 12.3s · avg 9.8s · total 2m03s
30d tokens 710M ↑ 6k ↓ 5.5M (est.) · to 10-10 │ api cost $1.23
Desktop app (Code tab):
<img src="docs/desktop.png" width="1000" alt="session-meter in the Claude desktop app">
| Field | Source |
|---|---|
context | Context window usage from $.session.usage(); green; yellow above 150k tokens; red above 50% (configurable) |
api cost | Session cost from $.session.usage(), n/a if unavailable |
tools | Tool calls this turn / this session. Main loop only; subagent calls are not counted |
turn | Elapsed time of the current turn (cyan), otherwise the last turn's duration, plus average and total (main loop only) |
5h limit, weekly | Rate limits with percent used and time until reset (↻); green, yellow above 80%, red above 90% |
30d tokens | Total, input (↑) and output (↓) tokens over the last 30 days, from ~/.claude/stats-cache.json |
dailyModelTokens (which includes cache tokens) for days on or after the cutoff date (30 days ago, by your local date).(est.) appears when the cache starts before the 30-day window or has no valid firstSessionDate.to MM-DD is the newest day in the cache. Claude Code refreshes that file, not this mod, so it can lag./.claude/ in the plugin path. If the plugin lives elsewhere, the 30d field shows n/a.The meter switches to plain ASCII when the locale is not UTF-8 or the session runs over ssh (SSH_CONNECTION or SSH_TTY is set), because fonts and locales on the remote side often lack █ ░ │ ↻. Every glyph is one cell wide in both sets, so the layout does not change.
--------------------------------------------------------------------------
context ############------------------------------------- 24% 48k/200k
5h limit #####------- 41% ~2h10m | weekly ###--------- 22% ~4d3h
tools 4/31 | turn 12.3s - avg 9.8s - total 2m03s
30d tokens 710M in 6k out 5.5M (est.) - to 10-10 | api cost $1.23
Set METER_CHARSET=unicode or METER_CHARSET=ascii to override the detection.
| Option | Default | Meaning |
|---|---|---|
contextYellowTokens | 150000 | The context bar turns yellow above this many tokens |
contextRedPercent | 50 | The context bar turns red above this percent of the window |
Set them in the /config menu (or under pluginConfigs in settings). A change reloads the mod.
/plugin install session-meter --marketplace plopaur/claude-mod-session-meter
/meter toggles the meter on and off.hooks/register.tsx: all logic and renderinghooks/hooks.json: module listtypes/index.d.ts: state typestests/: render, charset, and time and number formatting tests (claude plugin test .).claude-plugin/: plugin manifest and marketplace.json; Claude Code writes its type definitions to types/ there (git-ignored)hooks/register.tsx 469 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Stats, Tokens30d } from '../types'
5
6const EMPTY: Stats = {
7 turns: 0,
8 totalMs: 0,
9 lastMs: null,
10 turnTools: 0,
11 sessionTools: 0,
12 turnStartedAt: null,
13}
14const stats = atom({ plugin: 'session-meter', key: 'stats' } as const, EMPTY)
15const now = atom({ plugin: 'session-meter', key: 'now' } as const, 0)
16const isHidden = atom({ plugin: 'session-meter', key: 'isHidden' } as const, false)
17
18const tokens30d = atom({ plugin: 'session-meter', key: 'tokens30d' } as const, null as Tokens30d | null)
19
20const BAR = 14
21const DAY_MS = 86_400_000
22
23// Thresholds sit where rounding would carry into the next unit, so 999,999 reads 1.0M, never 1000k
24export function millions(tokens: number): string {
25 if (tokens >= 999_500_000) {
26 return `${(tokens / 1e9).toFixed(1)}B`
27 }
28 if (tokens >= 9_950_000) {
29 return `${Math.round(tokens / 1e6)}M`
30 }
31 if (tokens >= 999_500) {
32 return `${(tokens / 1e6).toFixed(1)}M`
33 }
34 if (tokens >= 1000) {
35 return `${Math.round(tokens / 1e3)}k`
36 }
37
38 return String(tokens)
39}
40const LIMIT_BAR = 14
41const WIDE_COLUMNS = 135
42// Compact: the 5h bar starts under the context bar and the weekly bar ends where it ends
43const COMPACT_LIMIT_BAR = 12
44const COMPACT_COLUMNS = 74
45const LIMIT_SUFFIX = 13 // ' 100% ↻23h59m'
46
47// Every glyph is one cell wide in both sets, so the layout math is the same
48const UNICODE = { full: '█', empty: '░', sep: '│', rule: '─', reset: '↻', up: '↑ ', down: '↓ ', dot: '·' }
49const ASCII = { full: '#', empty: '-', sep: '|', rule: '-', reset: '~', up: 'in ', down: 'out ', dot: '-' }
50type Glyphs = typeof UNICODE
51let glyphs: Glyphs = UNICODE
52// METER_CHARSET as read at session start; the desktop draws Unicode unless it says ascii
53let forcedCharset: string | undefined
54const sep = (g: Glyphs) => ` ${g.sep} `
55// Wide layout: the first separator on both rows is tighter and sits in the same column
56const firstSep = (g: Glyphs) => ` ${g.sep} `
57const CONTEXT_BAR_COMPACT = 2 * COMPACT_LIMIT_BAR + LIMIT_SUFFIX + 5 + 'weekly '.length
58const LABEL = 'context '
59const TOKENS_WIDTH = 11 // ' 999k/1.5M', ' 1.2M/ 2M'
60// Wide layout: first column ends here on both rows, so api cost and weekly line up
61const WIDE_FIRST = LABEL.length + BAR + 5 + TOKENS_WIDTH
62// Desktop bar: stretches to its column, fill and track in the bar colors the terminal uses
63const STRIP_HEIGHT = 6
64const STRIP_COLORS: Record<string, string> = { green: '#4eba65', yellow: '#e5b84b', red: '#e5534b' }
65export function stripSvg(fraction: number, color: string): string {
66 const fill = Math.max(0, Math.min(100, fraction * 100))
67 const r = STRIP_HEIGHT / 2
68
69 return (
70 `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="${STRIP_HEIGHT}" preserveAspectRatio="none">` +
71 `<rect width="100%" height="${STRIP_HEIGHT}" rx="${r}" fill="#888" fill-opacity="0.3"/>` +
72 (fill > 0 ? `<rect width="${fill}%" height="${STRIP_HEIGHT}" rx="${r}" fill="${STRIP_COLORS[color] ?? color}"/>` : '') +
73 '</svg>'
74 )
75}
76
77const LIMITS = [
78 { kind: 'five_hour', label: '5h limit' },
79 { kind: 'seven_day', label: 'weekly' },
80] as const
81
82// Right-aligned in three columns so 5% and 100% take the same width
83function pct(percent: number): string {
84 return ` ${String(percent).padStart(3)}%`
85}
86
87function barColor(percent: number): string {
88 return percent > 90 ? 'red' : percent > 80 ? 'yellow' : 'green'
89}
90
91function until(resetsAt: string | undefined, time: number, g: Glyphs): string {
92 const at = resetsAt === undefined ? NaN : Date.parse(resetsAt)
93 if (Number.isNaN(at)) {
94 return ''
95 }
96 const minutes = Math.max(0, Math.round((at - time) / 60000))
97 const days = Math.floor(minutes / 1440)
98 const hours = Math.floor((minutes % 1440) / 60)
99
100 const mark = g.reset
101
102 return days > 0 ? ` ${mark}${days}d${hours}h` : hours > 0 ? ` ${mark}${hours}h${String(minutes % 60).padStart(2, '0')}m` : ` ${mark}${minutes}m`
103}
104
105const two = (n: number) => String(n).padStart(2, '0')
106
107// Rounds once, then splits, so 119.6s reads 2m00s, never 1m60s
108export function seconds(ms: number): string {
109 const tenths = Math.round(ms / 100)
110 if (tenths < 600) {
111 return `${(tenths / 10).toFixed(1)}s`
112 }
113 const total = Math.round(ms / 1000)
114 const hours = Math.floor(total / 3600)
115 const minutes = Math.floor((total % 3600) / 60)
116
117 return hours > 0 ? `${hours}h${two(minutes)}m` : `${minutes}m${two(total % 60)}s`
118}
119
120// At most four characters: 999k, then 1M, 1.5M
121export function kilo(tokens: number): string {
122 if (tokens >= 999_500) {
123 return `${Number((tokens / 1e6).toFixed(1))}M`
124 }
125
126 return tokens >= 1000 ? `${Math.round(tokens / 1000)}k` : String(tokens)
127}
128
129// Local calendar date, matching the dates Claude Code writes to stats-cache.json
130export function localDate(time: number): string {
131 const d = new Date(time)
132
133 return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`
134}
135
136export const register: Register = (on, options) => {
137 const yellowTokens = Number(options.contextYellowTokens)
138 const redPercent = Number(options.contextRedPercent)
139
140 on('session.start', async ($, e, next) => {
141 await $.command.register({
142 name: 'meter',
143 description: 'Hide or show the session meter above the prompt',
144 })
145 // Plain ASCII when the locale is not UTF-8 or the session is over ssh; METER_CHARSET=unicode|ascii overrides
146 const forced = await $.env.get('METER_CHARSET')
147 forcedCharset = forced
148 const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_CTYPE')) || (await $.env.get('LANG')) || ''
149 const isRemote = Boolean((await $.env.get('SSH_CONNECTION')) || (await $.env.get('SSH_TTY')))
150 const isUtf8 = /utf-?8/i.test(locale)
151 glyphs = forced === 'unicode' || (forced !== 'ascii' && isUtf8 && !isRemote) ? UNICODE : ASCII
152 // Needs the plugin to live under a `.claude/` directory; otherwise the 30d readout shows n/a
153 const marker = $.plugin.root.indexOf('/.claude/')
154 const home = marker === -1 ? null : $.plugin.root.slice(0, marker)
155 // Claude Code's own daily usage cache; it is refreshed by Claude Code, not by this mod
156 const refreshTokens = async () => {
157 try {
158 if (home === null) {
159 throw new Error('no .claude directory above the plugin root')
160 }
161 const cache = JSON.parse(await $.fs.read(`${home}/.claude/stats-cache.json`)) as {
162 dailyModelTokens?: { date: string; tokensByModel: Record<string, number> }[]
163 modelUsage?: Record<string, Record<string, number>>
164 firstSessionDate?: string
165 }
166 const since = (await $.clock.now()) - 30 * DAY_MS
167 // Days are plain local YYYY-MM-DD strings; compare them as strings instead of parsing to UTC midnight
168 const cutoff = localDate(since)
169 let total = 0
170 let asOf = ''
171 for (const day of cache.dailyModelTokens ?? []) {
172 asOf = day.date > asOf ? day.date : asOf
173 if (day.date >= cutoff) {
174 total += Object.values(day.tokensByModel).reduce((a, b) => a + b, 0)
175 }
176 }
177 // modelUsage splits input/output but covers every day the cache holds; scale it to the 30 days
178 let allInput = 0
179 let allOutput = 0
180 let allTotal = 0
181 for (const m of Object.values(cache.modelUsage ?? {})) {
182 allInput += m.inputTokens ?? 0
183 allOutput += m.outputTokens ?? 0
184 allTotal += (m.inputTokens ?? 0) + (m.outputTokens ?? 0) + (m.cacheReadInputTokens ?? 0) + (m.cacheCreationInputTokens ?? 0)
185 }
186 const share = allTotal > 0 ? Math.min(1, total / allTotal) : 0
187 const input = Math.round(allInput * share)
188 const output = Math.round(allOutput * share)
189 // A missing or unparsable first-session date counts as an estimate
190 const isEstimate = !(Date.parse(cache.firstSessionDate ?? '') >= since)
191 await update($, tokens30d, () => ({ total, input, output, isEstimate, asOf }))
192 } catch {
193 await update($, tokens30d, () => null)
194 }
195 }
196 void refreshTokens()
197 let ticks = 0
198 $.clock.every(1000, async () => {
199 try {
200 ticks += 1
201 const current = await read($, stats)
202 if (current.turnStartedAt !== null || ticks % 60 === 0) {
203 const time = await $.clock.now()
204 await update($, now, () => time)
205 }
206 if (ticks % 60 === 0) {
207 await refreshTokens()
208 }
209 } catch {
210 // a failed tick is retried on the next one
211 }
212 })
213
214 return next(e)
215 })
216
217 on('command.run', { command: 'meter' }, async $ => {
218 const hidden = await update($, isHidden, h => !h)
219
220 return { text: hidden ? 'Session meter hidden.' : 'Session meter shown.' }
221 })
222
223 // /clear starts the session's cost over without a new session.start; start the counters over with it
224 on('session.end', async ($, e, next) => {
225 if (e.reason === 'clear') {
226 await update($, stats, () => EMPTY)
227 }
228
229 return next(e)
230 })
231
232 on('turn.start', async ($, e, next) => {
233 const time = await $.clock.now()
234 await update($, now, () => time)
235 await update($, stats, s => ({ ...s, turnTools: 0, turnStartedAt: time }))
236
237 return next(e)
238 })
239
240 on('tool.call', async ($, e, next) => {
241 // Main loop only, matching turn.complete; subagent calls are not counted
242 if (e.agentId === undefined) {
243 void update($, stats, s => ({
244 ...s,
245 turnTools: s.turnTools + 1,
246 sessionTools: s.sessionTools + 1,
247 })).catch(() => undefined)
248 }
249
250 return next(e)
251 })
252
253 on('turn.complete', async ($, e, next) => {
254 if (e.agentId === undefined) {
255 await update($, stats, s => ({
256 ...s,
257 turns: s.turns + 1,
258 totalMs: s.totalMs + e.durationMs,
259 lastMs: e.durationMs,
260 turnStartedAt: null,
261 }))
262 }
263
264 return next(e)
265 })
266
267 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
268 if (e.props.hasSurvey || (await read($, isHidden))) {
269 return next(e)
270 }
271
272 const elements = $.ui.resolve(e)
273 const { Box, Text } = elements
274 const Svg = 'Svg' in elements ? elements.Svg : null
275 const s = await read($, stats)
276 const time = await read($, now)
277 const { context, cost, rateLimits } = await $.session.usage()
278 const clockNow = await $.clock.now()
279
280 // The desktop draws in a proportional font: columns are fixed-width boxes there, not padding,
281 // and its own process carries no UTF-8 locale, so it draws Unicode unless METER_CHARSET=ascii
282 const isDesktop = e.surface === 'desktop'
283 const g = isDesktop && forcedCharset !== 'ascii' ? UNICODE : glyphs
284 type TextStyle = { color?: string; dimColor?: boolean }
285 const cell = (width: number, text: string, style: TextStyle, align: 'flex-start' | 'center' | 'flex-end' = 'flex-start') =>
286 isDesktop ? (
287 <Box width={width} flexShrink={0} justifyContent={align}>
288 <Text {...style}>{text.trim()}</Text>
289 </Box>
290 ) : (
291 <Text {...style}>{text}</Text>
292 )
293 // Desktop: a thin SVG strip in place of █ and ░, whose widths in a proportional font are not a cell's
294 const bar = (width: number, filled: number, color: string) =>
295 isDesktop && Svg !== null ? (
296 <Box width={width} flexShrink={0} flexDirection="column" justifyContent="center">
297 <Svg source={stripSvg(filled / width, color)} alt={`${Math.round((filled / width) * 100)}%`} height={STRIP_HEIGHT} />
298 </Box>
299 ) : (
300 <Box flexDirection="row" flexShrink={0}>
301 <Text color={color}>{g.full.repeat(filled)}</Text>
302 <Text dimColor>{g.empty.repeat(width - filled)}</Text>
303 </Box>
304 )
305
306 const percent = Math.round(context.percent ?? 0)
307 const meterColor = percent > redPercent ? 'red' : (context.tokens ?? 0) > yellowTokens ? 'yellow' : 'green'
308 const isCompact = e.props.bodyColumns < WIDE_COLUMNS
309 // Wide: right-align both numbers so ' 48k/ 200k' and '999k/1000k' take the same width
310 const tokens =
311 context.tokens === undefined
312 ? ''
313 : isCompact || isDesktop
314 ? ` ${kilo(context.tokens)}/${kilo(context.window)}`
315 : ` ${kilo(context.tokens).padStart(4)}/${kilo(context.window).padStart(5)}`
316 const barCells = isCompact ? CONTEXT_BAR_COMPACT : BAR
317 const filled = Math.min(barCells, Math.round((percent / 100) * barCells))
318
319 const isRunning = s.turnStartedAt !== null
320 const turnText = isRunning
321 ? seconds(Math.max(0, time - (s.turnStartedAt ?? time)))
322 : s.lastMs === null
323 ? 'no turns yet'
324 : seconds(s.lastMs)
325 const runningMs = isRunning ? Math.max(0, time - (s.turnStartedAt ?? time)) : 0
326 const average = s.turns > 0 ? ` ${g.dot} avg ${seconds(s.totalMs / s.turns)}` : ''
327 const total = s.turns > 0 || isRunning ? ` ${g.dot} total ${seconds(s.totalMs + runningMs)}` : ''
328
329 const used30 = await read($, tokens30d)
330 const limits = LIMITS.map(({ kind, label }, i) => {
331 const limit = rateLimits.find(r => r.kind === kind)
332 const lead = i === 0 ? '' : isCompact ? sep(g) : firstSep(g)
333 const leadCell = lead === '' ? null : cell(lead.length, lead, { dimColor: true }, 'center')
334 const name = `${label} `
335 const length = isCompact ? COMPACT_LIMIT_BAR : LIMIT_BAR
336 // Pad the first limit's suffix so the weekly bar starts at a fixed column
337 const suffixWidth = isCompact ? LIMIT_SUFFIX : WIDE_FIRST - name.length - length
338 if (limit === undefined) {
339 if (isDesktop) {
340 return (
341 <Box key={kind} flexDirection="row">
342 {leadCell}
343 {cell(name.length, name, { dimColor: true })}
344 {bar(length, 0, 'green')}
345 {cell(i === 0 ? suffixWidth : ' n/a'.length, ' n/a', { dimColor: true })}
346 </Box>
347 )
348 }
349 return (
350 <Box key={kind} flexDirection="row">
351 <Text dimColor>{lead}{name}{g.empty.repeat(length)} n/a{i === 0 ? ' '.repeat(suffixWidth - ' n/a'.length) : ''}</Text>
352 </Box>
353 )
354 }
355 const used = Math.round(limit.percentUsed)
356 const cells = Math.min(length, Math.round((used / 100) * length))
357 const color = barColor(used)
358 const reset = until(limit.resetsAt, clockNow, g)
359
360 if (isDesktop) {
361 return (
362 <Box key={kind} flexDirection="row">
363 {leadCell}
364 {cell(name.length, name, { dimColor: true })}
365 {bar(length, cells, color)}
366 <Box flexDirection="row" flexShrink={0} width={i === 0 ? suffixWidth : undefined}>
367 {cell(pct(used).length, pct(used), { color }, 'flex-end')}
368 <Text dimColor>{reset}</Text>
369 </Box>
370 </Box>
371 )
372 }
373 const pad = i === 0 ? ' '.repeat(Math.max(0, suffixWidth - pct(used).length - reset.length)) : ''
374
375 return (
376 <Box key={kind} flexDirection="row">
377 <Text dimColor>{lead}{name}</Text>
378 {bar(length, cells, color)}
379 <Text color={color}>{pct(used)}</Text>
380 <Text dimColor>{reset}{pad}</Text>
381 </Box>
382 )
383 })
384
385 const contextBar = (
386 <Box flexDirection="row">
387 {cell(LABEL.length, LABEL, { dimColor: true })}
388 {bar(barCells, filled, meterColor)}
389 {cell(pct(percent).length, pct(percent), { color: meterColor }, 'flex-end')}
390 {isCompact ? <Text dimColor>{tokens}</Text> : cell(TOKENS_WIDTH, tokens.padEnd(TOKENS_WIDTH), { dimColor: true })}
391 </Box>
392 )
393 const apiCost = (
394 <Box flexDirection="row">
395 <Text dimColor>api cost </Text>
396 <Text>{cost === undefined ? 'n/a' : `$${cost.usd.toFixed(2)}`}</Text>
397 </Box>
398 )
399 const tokens30 = (
400 <Box flexDirection="row">
401 <Text dimColor>30d tokens </Text>
402 {used30 === null ? (
403 <Text dimColor>n/a</Text>
404 ) : (
405 <Box flexDirection="row">
406 <Text>{millions(used30.total)}</Text>
407 <Text dimColor> {g.up}</Text>
408 <Text>{millions(used30.input)}</Text>
409 <Text dimColor> {g.down}</Text>
410 <Text>{millions(used30.output)}</Text>
411 <Text dimColor>{used30.isEstimate ? ' (est.)' : ''}{used30.asOf === '' ? '' : ` ${g.dot} to ${used30.asOf.slice(5)}`}</Text>
412 </Box>
413 )}
414 </Box>
415 )
416 const dividerColumns = isCompact ? Math.min(COMPACT_COLUMNS, e.props.bodyColumns) : e.props.bodyColumns
417 // The desktop frames the band itself; a rule of glyphs would not match its width
418 const divider = isDesktop ? null : <Text dimColor wrap="truncate-end">{g.rule.repeat(Math.max(1, dividerColumns))}</Text>
419
420 if (isCompact) {
421 // Compact layout: four rows, each at most 74 columns
422 return (
423 <Box flexDirection="column">
424 {divider}
425 {contextBar}
426 <Box flexDirection="row">{limits}</Box>
427 <Box flexDirection="row">
428 <Text dimColor>tools </Text>
429 <Text>{s.turnTools}</Text>
430 <Text dimColor>/</Text>
431 <Text>{s.sessionTools}</Text>
432 <Text dimColor>{sep(g)}turn </Text>
433 <Text color={isRunning ? 'cyan' : undefined}>{turnText}</Text>
434 <Text dimColor>{average}{total}</Text>
435 </Box>
436 <Box flexDirection="row">
437 {tokens30}
438 <Text dimColor>{sep(g)}</Text>
439 {apiCost}
440 </Box>
441 </Box>
442 )
443 }
444
445 return (
446 <Box flexDirection="column">
447 {divider}
448 <Box flexDirection="row">
449 {contextBar}
450 {cell(firstSep(g).length, firstSep(g), { dimColor: true }, 'center')}
451 {apiCost}
452 <Text dimColor>{sep(g)}tools </Text>
453 <Text>{s.turnTools}</Text>
454 <Text dimColor> turn / </Text>
455 <Text>{s.sessionTools}</Text>
456 <Text dimColor> session{sep(g)}turn </Text>
457 <Text color={isRunning ? 'cyan' : undefined}>{turnText}</Text>
458 <Text dimColor>{average}{total}</Text>
459 </Box>
460 <Box flexDirection="row">
461 {limits}
462 <Text dimColor>{sep(g)}</Text>
463 {tokens30}
464 </Box>
465 </Box>
466 )
467 })
468}
469types/index.d.ts 17 lines1export type Stats = {
2 turns: number
3 totalMs: number
4 lastMs: number | null
5 turnTools: number
6 sessionTools: number
7 turnStartedAt: number | null
8}
9
10export type Tokens30d = { total: number; input: number; output: number; isEstimate: boolean; asOf: string }
11
12declare module 'claude-code' {
13 interface PluginState {
14 'session-meter': { stats: Stats; now: number; isHidden: boolean; tokens30d: Tokens30d | null }
15 }
16}
17