Shows your usage-limit windows, context fullness and session totals in a pane and the status line, with warnings before you hit a limit.

A Claude Code mod that shows how much of your usage limits, context window and session you have used, so you can slow down, stop or start fresh before you hit a wall.
/meter: your 5-hour and 7-day usage windows with countdowns to their reset, how full the context window is, and this session's spend, tokens, turns, tool calls and running time.5h 62% · ctx 41%.In a terminal Claude Code session (Claude Code 2.1.295 or newer):
/plugin install session-meter --marketplace verastack-labs/session-meter
Answer y to add the marketplace, then pick a scope.
/plugin works only in a terminal session; the Claude desktop app's Code tab does not offer it. From any shell instead:
claude plugin marketplace add verastack-labs/session-meter
claude plugin install session-meter@session-meter
Installed at the user scope, it also loads in the desktop app's sessions, starting with the next one.
/meter
opens the pane, and running it again closes it. Bars turn yellow at 80% and red at 95%.
Usage windows appear only for accounts that report them, such as a Claude subscription; with an API key the pane says none are reported. Spend appears only when Claude Code reports a cost. Tokens, turns and tool calls count from when the mod loaded, and /clear starts them over. Turns are your own turns; tokens and tool calls include work done by subagents, since that work counts against your limits too.
claude plugin test .
claude plugin validate .
Apache 2.0. See LICENSE.
hooks/register.tsx 197 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, TurnUsage } from 'claude-code'
3
4import type { Counters, Reading, Snapshot } from '../types'
5import { INITIAL_ALERTS, dueAlerts } from './alerts'
6import type { Alert } from './alerts'
7import { statusText } from './status'
8import { toSnapshot } from './usage'
9import { meterView } from './view'
10
11const REFRESH_MS = 30_000
12
13const ZERO_COUNTERS: Counters = {
14 turns: 0,
15 toolCalls: 0,
16 inputTokens: 0,
17 outputTokens: 0,
18 cacheReadTokens: 0,
19 cacheWriteTokens: 0,
20}
21
22const reading = atom({ plugin: 'session-meter', key: 'reading' } as const, { snapshot: null, checkedAt: 0 } as Reading)
23const counters = atom({ plugin: 'session-meter', key: 'counters' } as const, ZERO_COUNTERS)
24const alerts = atom({ plugin: 'session-meter', key: 'alerts' } as const, INITIAL_ALERTS)
25const isPaneOpen = atom({ plugin: 'session-meter', key: 'isPaneOpen' } as const, false)
26const statusShown = atom({ plugin: 'session-meter', key: 'statusShown' } as const, null as string | null)
27const PANE_ID = 'session-meter'
28
29type Dollar = Pick<EngineInterface, 'state' | 'session' | 'ui' | 'clock'>
30
31/** Sets the status line, only when its text differs from what it shows. */
32async function showStatus($: Dollar, text: string | undefined): Promise<void> {
33 const shown = text ?? ''
34 if ((await read($, statusShown)) === shown) return
35 await update($, statusShown, () => shown)
36 $.ui.status(text)
37}
38
39/**
40 * Reads the usage figures, stores them, updates the status line and shows due
41 * toasts. A failed read keeps the last figures; `checkedAt` still moves, so the
42 * pane can say how old they are. What the pane draws is written once, so a
43 * refresh redraws it once, and a read that started earlier than the stored one
44 * (a slow read finishing after a newer one) is dropped.
45 */
46async function refresh($: Dollar): Promise<void> {
47 const now = await $.clock.now()
48 let fresh: Snapshot | undefined
49 try {
50 fresh = toSnapshot(await $.session.usage(), now)
51 } catch {
52 fresh = undefined
53 }
54 let isNewest = false
55 await update($, reading, current => {
56 isNewest = fresh !== undefined && (current.snapshot === null || current.snapshot.readAt <= fresh.readAt)
57 return { snapshot: isNewest ? (fresh ?? null) : current.snapshot, checkedAt: Math.max(current.checkedAt, now) }
58 })
59 if (fresh === undefined || !isNewest) return
60 const next = fresh
61 await showStatus($, statusText(next))
62 let due: Alert[] = []
63 await update($, alerts, current => {
64 const result = dueAlerts(next, current)
65 due = result.alerts
66 return result.next
67 })
68 for (const alert of due) $.ui.toast(alert.text)
69}
70
71/** State outlives a reload, so whether the pane is open comes from the engine's own record. */
72async function syncPaneOpen($: Dollar): Promise<void> {
73 try {
74 const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE_ID)
75 await update($, isPaneOpen, () => isOpen)
76 } catch {
77 // the meter never disturbs the session
78 }
79}
80
81async function addUsage($: Dollar, usage: TurnUsage): Promise<void> {
82 await update($, counters, current => ({
83 ...current,
84 inputTokens: current.inputTokens + usage.input_tokens,
85 outputTokens: current.outputTokens + usage.output_tokens,
86 cacheReadTokens: current.cacheReadTokens + usage.cache_read_input_tokens,
87 cacheWriteTokens: current.cacheWriteTokens + usage.cache_creation_input_tokens,
88 }))
89}
90
91async function addTurn($: Dollar): Promise<void> {
92 await update($, counters, current => ({ ...current, turns: current.turns + 1 }))
93}
94
95async function addToolCall($: Dollar): Promise<void> {
96 await update($, counters, current => ({ ...current, toolCalls: current.toolCalls + 1 }))
97}
98
99async function resetCounters($: Dollar): Promise<void> {
100 await update($, counters, () => ZERO_COUNTERS)
101}
102
103export const register: Register = on => {
104 on('session.start', async ($, e, next) => {
105 await $.command.register({ name: 'meter', description: 'Open or close the session-meter pane' })
106 await syncPaneOpen($)
107 $.clock.every(REFRESH_MS, () => {
108 void refresh($)
109 })
110 return next(e)
111 })
112
113 on('session.end', async ($, e, next) => {
114 if (e.reason === 'clear') {
115 try {
116 await resetCounters($)
117 } catch {
118 // counting never disturbs the session
119 }
120 }
121 return next(e)
122 })
123
124 on('turn.step', async function* ($, e, next) {
125 const result = yield* next(e)
126 try {
127 // only an in-memory count here: the usage read waits for session.measure,
128 // so nothing the meter does sits in the response path
129 if (result.usage !== null) await addUsage($, result.usage)
130 } catch {
131 // counting never disturbs the response
132 }
133 return result
134 })
135
136 // the engine measures after each turn and when a limit window moves a point
137 on('session.measure', async ($, e, next) => {
138 const result = await next(e)
139 try {
140 await refresh($)
141 } catch {
142 // the meter never disturbs the session
143 }
144 return result
145 })
146
147 on('turn.complete', async ($, e, next) => {
148 const result = await next(e)
149 try {
150 // a subagent's or the engine's own fork has an agentId; only your turns count
151 if (e.agentId === undefined) await addTurn($)
152 } catch {
153 // counting never disturbs the turn
154 }
155 return result
156 })
157
158 on('tool.call', async ($, e, next) => {
159 try {
160 await addToolCall($)
161 } catch {
162 // counting never blocks a tool call
163 }
164 return next(e)
165 })
166
167 on('command.run', { command: 'meter' }, async $ => {
168 if (await read($, isPaneOpen)) {
169 await $.ui.close({ id: PANE_ID })
170 await update($, isPaneOpen, () => false)
171 return { text: 'Pane closed.' }
172 }
173 await $.ui.open({ id: PANE_ID, title: 'Session meter' })
174 await update($, isPaneOpen, () => true)
175 return { text: 'Pane opened.' }
176 })
177
178 on('ui.close', async ($, e, next) => {
179 if (e.id === PANE_ID) await update($, isPaneOpen, () => false)
180 return next(e)
181 })
182
183 on('ui.render', { component: 'Pane', requestId: 'session-meter' }, async ($, e) => {
184 const els = $.ui.resolve(e)
185 const { snapshot, checkedAt } = await read($, reading)
186 return meterView(
187 { Box: els.Box, Text: els.Text, Svg: e.surface !== 'terminal' && 'Svg' in els ? els.Svg : undefined },
188 {
189 snapshot,
190 counters: await read($, counters),
191 now: checkedAt,
192 columns: e.props.bodyColumns,
193 },
194 )
195 })
196}
197hooks/alerts.ts 68 lines1import type { AlertState, Snapshot } from '../types'
2import { WINDOW_LABELS, formatCountdown, formatPercent } from './usage'
3
4/** A toast to show, and the key that stops it showing twice. */
5export type Alert = { key: string; text: string }
6
7export const INITIAL_ALERTS: AlertState = { fired: [], isContextArmed: true }
8
9const WARNED_KINDS: readonly string[] = ['five_hour', 'seven_day']
10const THRESHOLDS: readonly number[] = [80, 95]
11const CONTEXT_WARN = 80
12const CONTEXT_REARM = 70
13/** How far below a threshold a window must fall before that threshold may warn again. */
14const WINDOW_REARM_GAP = 10
15const MAX_FIRED = 50
16
17function resetPhrase(resetsAt: string | undefined, now: number): string {
18 const countdown = formatCountdown(resetsAt, now)
19 if (countdown === undefined) return ''
20 return countdown === 'now' ? ', resets now' : `, resets in ${countdown}`
21}
22
23/**
24 * The toasts a new snapshot calls for. Each window alerts once at 80% and
25 * once at 95% per window (keyed by its reset time); a jump past 95% fires only
26 * the 95% alert. Context alerts at 80% and re-arms below 70%. Thresholds
27 * are judged on the rounded percentage the toast and pane show.
28 */
29export function dueAlerts(snapshot: Snapshot, state: AlertState): { alerts: Alert[]; next: AlertState } {
30 const alerts: Alert[] = []
31 const fired = [...state.fired]
32 for (const window of snapshot.windows) {
33 if (!WARNED_KINDS.includes(window.kind)) continue
34 const shown = Math.round(window.percentUsed)
35 // a window whose usage fell well below a threshold has reset, reported or
36 // not, so that threshold may warn again; this is what re-arms a window that
37 // reports no reset time
38 const prefix = `${window.kind}:${window.resetsAt ?? ''}:`
39 for (let i = fired.length - 1; i >= 0; i -= 1) {
40 const key = fired[i] ?? ''
41 if (key.startsWith(prefix) && Number(key.slice(prefix.length)) - shown >= WINDOW_REARM_GAP) fired.splice(i, 1)
42 }
43 const keys = THRESHOLDS.filter(threshold => shown >= threshold).map(
44 threshold => `${window.kind}:${window.resetsAt ?? ''}:${threshold}`,
45 )
46 const topKey = keys[keys.length - 1]
47 if (topKey === undefined || fired.includes(topKey)) continue
48 const label = WINDOW_LABELS[window.kind] ?? window.kind
49 alerts.push({
50 key: topKey,
51 text: `${label} limit at ${formatPercent(window.percentUsed)}${resetPhrase(window.resetsAt, snapshot.readAt)}`,
52 })
53 for (const key of keys) if (!fired.includes(key)) fired.push(key)
54 }
55 let isContextArmed = state.isContextArmed
56 const percent = snapshot.context.percent
57 if (percent !== undefined) {
58 const shownPercent = Math.round(percent)
59 if (isContextArmed && shownPercent >= CONTEXT_WARN) {
60 alerts.push({ key: 'context', text: `Context ${formatPercent(percent)} full; consider /compact or /clear` })
61 isContextArmed = false
62 } else if (!isContextArmed && shownPercent < CONTEXT_REARM) {
63 isContextArmed = true
64 }
65 }
66 return { alerts, next: { fired: fired.slice(-MAX_FIRED), isContextArmed } }
67}
68hooks/status.ts 17 lines1import type { Snapshot } from '../types'
2import { formatPercent } from './usage'
3
4/**
5 * The status-line text: the 5-hour window and context, `5h 62% · ctx 41%`.
6 * Undefined, which clears the line, when neither is known. The engine shows
7 * the line under the mod's name, so the text leaves the name out.
8 */
9export function statusText(snapshot: Snapshot | null): string | undefined {
10 if (snapshot === null) return undefined
11 const parts: string[] = []
12 const five = snapshot.windows.find(window => window.kind === 'five_hour')
13 if (five !== undefined) parts.push(`5h ${formatPercent(five.percentUsed)}`)
14 if (snapshot.context.percent !== undefined) parts.push(`ctx ${formatPercent(snapshot.context.percent)}`)
15 return parts.length > 0 ? parts.join(' · ') : undefined
16}
17hooks/usage.ts 90 lines1import type { SessionUsage } from 'claude-code'
2
3import type { Snapshot } from '../types'
4
5const MINUTE = 60_000
6const HOUR = 60 * MINUTE
7const DAY = 24 * HOUR
8
9/** How each window kind is labelled; an unknown kind shows as itself. */
10export const WINDOW_LABELS: Readonly<Record<string, string>> = {
11 five_hour: '5-hour',
12 seven_day: '7-day',
13 spend_limit: 'Spend limit',
14}
15
16/** The order windows are listed in; unknown kinds come last. */
17export const WINDOW_ORDER: readonly string[] = ['five_hour', 'seven_day', 'spend_limit']
18
19export function formatPercent(percent: number): string {
20 return `${Math.round(percent)}%`
21}
22
23/** The largest two units of a span: `2h 14m`, `3d 4h`, `47m`, or `under 1m`. */
24export function formatDuration(ms: number): string {
25 if (ms < MINUTE) return 'under 1m'
26 const days = Math.floor(ms / DAY)
27 const hours = Math.floor((ms % DAY) / HOUR)
28 const minutes = Math.floor((ms % HOUR) / MINUTE)
29 if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`
30 if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`
31 return `${minutes}m`
32}
33
34/** Time left until an ISO reset time: a duration, `now` once passed, or nothing when unknown. */
35export function formatCountdown(resetsAt: string | undefined, now: number): string | undefined {
36 if (resetsAt === undefined) return undefined
37 const at = Date.parse(resetsAt)
38 if (Number.isNaN(at)) return undefined
39 return at <= now ? 'now' : formatDuration(at - now)
40}
41
42function oneDecimal(value: number): string {
43 return value.toFixed(1).replace(/\.0$/, '')
44}
45
46/** `999`, `1.2k`, `82k`, `2.1M`. */
47export function formatTokens(count: number): string {
48 if (count < 1000) return String(Math.round(count))
49 if (Number((count / 1000).toFixed(1)) < 1000) return `${oneDecimal(count / 1000)}k`
50 return `${oneDecimal(count / 1_000_000)}M`
51}
52
53export function formatUsd(usd: number): string {
54 return `$${usd.toFixed(2)}`
55}
56
57/** Filled cells of a bar `width` cells wide, clamped to the bar. */
58export function barCells(percent: number, width: number): number {
59 return Math.min(width, Math.max(0, Math.round((percent / 100) * width)))
60}
61
62export type Urgency = 'normal' | 'warning' | 'error'
63
64/** Yellow from 80%, red from 95%, judged on the rounded number the pane shows. */
65export function urgency(percent: number): Urgency {
66 const shown = Math.round(percent)
67 if (shown >= 95) return 'error'
68 if (shown >= 80) return 'warning'
69 return 'normal'
70}
71
72/** The figures the pane needs from one usage reading, with no context breakdown. */
73export function toSnapshot(usage: SessionUsage, now: number): Snapshot {
74 const context: Snapshot['context'] = { window: usage.context.window }
75 if (usage.context.tokens !== undefined) context.tokens = usage.context.tokens
76 if (usage.context.percent !== undefined) context.percent = usage.context.percent
77 const snapshot: Snapshot = {
78 readAt: now,
79 startedAt: usage.startedAt,
80 windows: usage.rateLimits.map(window =>
81 window.resetsAt === undefined
82 ? { kind: window.kind, percentUsed: window.percentUsed }
83 : { kind: window.kind, percentUsed: window.percentUsed, resetsAt: window.resetsAt },
84 ),
85 context,
86 }
87 if (usage.cost !== undefined) snapshot.usd = usage.cost.usd
88 return snapshot
89}
90hooks/view.tsx 226 lines1import type { Elements, RenderElement } from 'claude-code'
2
3import type { Counters, LimitWindow, Snapshot } from '../types'
4import {
5 WINDOW_LABELS,
6 WINDOW_ORDER,
7 barCells,
8 formatCountdown,
9 formatDuration,
10 formatPercent,
11 formatTokens,
12 formatUsd,
13 urgency,
14} from './usage'
15import type { Urgency } from './usage'
16
17/** `Svg` is there on the surfaces that draw it (desktop, VS Code, mobile) and absent in the terminal. */
18export type Els = Pick<Elements['terminal'], 'Box' | 'Text'> & { Svg?: Elements['desktop']['Svg'] }
19
20/** What the pane draws from. `now` is the last refresh attempt. */
21export type MeterInput = { snapshot: Snapshot | null; counters: Counters; now: number; columns: number }
22
23// Column widths in cells. A Box with a fixed width lines up in any font, which
24// padding with spaces does not on the desktop's proportional font.
25const LABEL = 12
26const USED = 5
27const GAP = 2
28const BAR_CELLS = 16
29/** The fewest cells the last column keeps, so a bar never pushes it off the row. */
30const END_MIN = 8
31const BAR_MIN = 6
32const STALE_MS = 120_000
33const FILLED = '█'
34const EMPTY = '░'
35const COLOURS = { normal: undefined, warning: 'warning', error: 'error' } as const
36
37// The SVG bar, in CSS pixels; a filled part above 0% is never thinner than the sliver.
38const SVG_WIDTH = 120
39const SVG_HEIGHT = 10
40const SLIVER = 3
41const SVG_FILLS: Record<Urgency, string> = { normal: '#4c9be8', warning: '#e0a526', error: '#e5484d' }
42
43/** The share of the SVG bar to fill, out of 100: 0 at 0%, at least the sliver above it. */
44export function svgFill(percent: number): number {
45 if (percent <= 0) return 0
46 return Math.min(100, Math.max(SLIVER, Math.round(percent)))
47}
48
49/** A rounded grey track with the filled part over it, coloured by urgency. */
50export function barSvg(percent: number): string {
51 const fill = svgFill(percent)
52 const r = SVG_HEIGHT / 2
53 const track = `<rect width="${SVG_WIDTH}" height="${SVG_HEIGHT}" rx="${r}" fill="#888888" fill-opacity="0.25"/>`
54 const filled =
55 fill === 0
56 ? ''
57 : `<rect data-fill="${fill}" data-urgency="${urgency(percent)}" width="${(fill / 100) * SVG_WIDTH}" height="${SVG_HEIGHT}" rx="${r}" fill="${SVG_FILLS[urgency(percent)]}"/>`
58 return `<svg xmlns="http://www.w3.org/2000/svg" width="${SVG_WIDTH}" height="${SVG_HEIGHT}" viewBox="0 0 ${SVG_WIDTH} ${SVG_HEIGHT}">${track}${filled}</svg>`
59}
60
61/**
62 * Bar cells for a pane `columns` wide: up to 16, what is left beside the label,
63 * the percent and the last column, or 0 to drop the bar.
64 */
65export function barCellsFor(columns: number): number {
66 const room = Math.min(BAR_CELLS, columns - LABEL - USED - 3 * GAP - END_MIN)
67 return room >= BAR_MIN ? room : 0
68}
69
70function rank(kind: string): number {
71 const index = WINDOW_ORDER.indexOf(kind)
72 return index === -1 ? WINDOW_ORDER.length : index
73}
74
75function resetText(window: LimitWindow, now: number): string {
76 const countdown = formatCountdown(window.resetsAt, now)
77 if (countdown === undefined) return ''
78 return countdown === 'now' ? 'now' : `in ${countdown}`
79}
80
81function bar(els: Els, key: string, label: string, percent: number, cells: number) {
82 const { Box, Text, Svg } = els
83 if (Svg !== undefined) {
84 return (
85 <Box key={`${key}-bar`} width={cells} flexShrink={0} alignItems="center">
86 <Svg source={barSvg(percent)} alt={`${label} ${formatPercent(percent)} used`} width={Math.round((cells * SVG_WIDTH) / BAR_CELLS)} height={SVG_HEIGHT} />
87 </Box>
88 )
89 }
90 const filled = barCells(percent, cells)
91 return (
92 <Box key={`${key}-bar`} width={cells} flexShrink={0}>
93 <Text>
94 <Text color={COLOURS[urgency(percent)]}>{FILLED.repeat(filled)}</Text>
95 <Text dimColor>{EMPTY.repeat(cells - filled)}</Text>
96 </Text>
97 </Box>
98 )
99}
100
101/** One row of the limits table: label, used percent, bar when there is room, and the last column. */
102function gaugeRow(els: Els, key: string, label: string, percent: number, end: string, cells: number) {
103 const { Box, Text } = els
104 return (
105 <Box flexDirection="row" columnGap={GAP}>
106 <Box key={`${key}-label`} width={LABEL} flexShrink={0}>
107 <Text wrap="truncate-end">{label}</Text>
108 </Box>
109 <Box key={`${key}-used`} width={USED} flexShrink={0} justifyContent="flex-end">
110 <Text>{formatPercent(percent)}</Text>
111 </Box>
112 {cells > 0 && bar(els, key, label, percent, cells)}
113 <Box key={`${key}-end`} flexGrow={1}>
114 <Text wrap="truncate-end">{end}</Text>
115 </Box>
116 </Box>
117 )
118}
119
120function headerRow(els: Els, cells: number) {
121 const { Box, Text } = els
122 return (
123 <Box flexDirection="row" columnGap={GAP}>
124 <Box width={LABEL} flexShrink={0} />
125 <Box key="header-used" width={USED} flexShrink={0} justifyContent="flex-end">
126 <Text dimColor>Used</Text>
127 </Box>
128 {cells > 0 && <Box width={cells} flexShrink={0} />}
129 <Box key="header-end" flexGrow={1}>
130 <Text dimColor>Resets</Text>
131 </Box>
132 </Box>
133 )
134}
135
136/** One row of the session list: a label and its value. */
137function figureRow(els: Els, key: string, label: string, value: string) {
138 const { Box, Text } = els
139 return (
140 <Box flexDirection="row" columnGap={GAP}>
141 <Box width={LABEL} flexShrink={0}>
142 <Text>{label}</Text>
143 </Box>
144 <Box key={key} flexGrow={1}>
145 <Text wrap="truncate-end">{value}</Text>
146 </Box>
147 </Box>
148 )
149}
150
151function tokensIn(counters: Counters): string {
152 const fresh = formatTokens(counters.inputTokens)
153 const cache: string[] = []
154 if (counters.cacheReadTokens > 0) cache.push(`+${formatTokens(counters.cacheReadTokens)} read from cache`)
155 if (counters.cacheWriteTokens > 0) cache.push(cache.length > 0 ? `+${formatTokens(counters.cacheWriteTokens)} written to it` : `+${formatTokens(counters.cacheWriteTokens)} written to cache`)
156 return cache.length > 0 ? `${fresh} (${cache.join(', ')})` : fresh
157}
158
159/** The pane's drawing, per the spec's layout and its 0.2.0 amendment. */
160export function meterView(els: Els, input: MeterInput): RenderElement {
161 const { Box, Text } = els
162 const { snapshot, counters, now, columns } = input
163 if (snapshot === null || (snapshot.context.percent === undefined && snapshot.windows.length === 0)) {
164 return (
165 <Box flexDirection="column">
166 <Box key="waiting">
167 <Text dimColor>Waiting for the first response</Text>
168 </Box>
169 </Box>
170 )
171 }
172 const cells = barCellsFor(columns)
173 const windows = [...snapshot.windows].sort((a, b) => rank(a.kind) - rank(b.kind))
174 const age = now - snapshot.readAt
175 const { context } = snapshot
176 return (
177 <Box flexDirection="column">
178 <Text bold>Usage limits</Text>
179 {age > STALE_MS && (
180 <Box key="stale">
181 <Text dimColor>last updated {formatDuration(age)} ago</Text>
182 </Box>
183 )}
184 {windows.length === 0 ? (
185 <Box key="no-limits">
186 <Text dimColor>No usage limits reported for this account</Text>
187 </Box>
188 ) : (
189 [
190 headerRow(els, cells),
191 ...windows.map(window =>
192 gaugeRow(els, `window-${window.kind}`, WINDOW_LABELS[window.kind] ?? window.kind, window.percentUsed, resetText(window, now), cells),
193 ),
194 ]
195 )}
196 {context.percent === undefined ? (
197 <Box flexDirection="row" columnGap={GAP}>
198 <Box key="context-label" width={LABEL} flexShrink={0}>
199 <Text>Context</Text>
200 </Box>
201 <Box key="context-waiting" flexGrow={1}>
202 <Text dimColor>waiting for the first response</Text>
203 </Box>
204 </Box>
205 ) : (
206 gaugeRow(
207 els,
208 'context',
209 'Context',
210 context.percent,
211 context.tokens === undefined ? '' : `${formatTokens(context.tokens)} of ${formatTokens(context.window)}`,
212 cells,
213 )
214 )}
215 <Text> </Text>
216 <Text bold>This session</Text>
217 {snapshot.usd !== undefined && figureRow(els, 'spend', 'Spend', formatUsd(snapshot.usd))}
218 {figureRow(els, 'tokens-in', 'Tokens in', tokensIn(counters))}
219 {figureRow(els, 'tokens-out', 'Tokens out', formatTokens(counters.outputTokens))}
220 {figureRow(els, 'turns', 'Turns', String(counters.turns))}
221 {figureRow(els, 'tool-calls', 'Tool calls', String(counters.toolCalls))}
222 {figureRow(els, 'running', 'Running', formatDuration(now - snapshot.startedAt))}
223 </Box>
224 )
225}
226types/index.d.ts 40 lines1/** One rate-limit window as the engine reports it. */
2export type LimitWindow = { kind: string; percentUsed: number; resetsAt?: string }
3
4/** One reading of `$.session.usage()`, with when it was read. */
5export type Snapshot = {
6 readAt: number
7 startedAt: number
8 windows: LimitWindow[]
9 context: { tokens?: number; window: number; percent?: number }
10 usd?: number
11}
12
13/** Session totals the mod counts itself. */
14export type Counters = {
15 turns: number
16 toolCalls: number
17 inputTokens: number
18 outputTokens: number
19 cacheReadTokens: number
20 cacheWriteTokens: number
21}
22
23/** The last usage reading and when a refresh last ran, written together so a refresh redraws the pane once. */
24export type Reading = { snapshot: Snapshot | null; checkedAt: number }
25
26/** Which warnings were shown, and whether the context warning may fire again. */
27export type AlertState = { fired: string[]; isContextArmed: boolean }
28
29declare module 'claude-code' {
30 interface PluginState {
31 'session-meter': {
32 reading: Reading
33 counters: Counters
34 alerts: AlertState
35 isPaneOpen: boolean
36 statusShown: string | null
37 }
38 }
39}
40