Shows what uses up your Claude limits: tokens by model, by main chat versus agents, and by project, across every chat on this Mac

See where your tokens go. Type /usage-breakdown and a pane opens with your last 5 hours and your last 7 days, across every chat on this Mac, broken down three ways:
For example:
Last 5 hours 3.1M tokens · 214 requests
By model
Opus 5.5 ████████████████████████████████▊ 82% 2.5M
Sonnet 5.5 ███████▎ 18% 560k
By source
Main chats █████████████████████████▋ 64% 2M
Agents ██████████████▍ 36% 1.1M
By project
api-server ██████████████████▊ 47% 1.5M
ios-app ████████████▍ 31% 960k
website █████▋ 14% 430k
Other ███▎ 8% 250k
The shares are of tokens (input, cache writes and output), not of your limits: Opus uses more of your limits per token than Sonnet. Cache reads are left out.
Claude Code. Nothing else.
UsageBreakdown installs with the other mods in this repository. See the main README.
claude plugin validate .
claude plugin test .
hooks/register.tsx 380 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register } from 'claude-code'
3
4import type { Report, Row, Section, Session } from '../types'
5import {
6 EMPTY,
7 FOOTNOTE,
8 KEY_PREFIX,
9 SAVE_EVERY_MS,
10 addRequest,
11 bar,
12 buildReport,
13 compact,
14 isEmpty,
15 isSession,
16 layoutOf,
17 percent,
18 projectOf,
19 pruneHours,
20 sweepPlan,
21 widths,
22} from './format'
23import type { Layout, Source } from './format'
24
25const sessionIdAtom = atom({ plugin: 'UsageBreakdown', key: 'sessionId' } as const, '')
26const projectAtom = atom({ plugin: 'UsageBreakdown', key: 'project' } as const, '')
27const hoursAtom = atom({ plugin: 'UsageBreakdown', key: 'hours' } as const, {})
28const changesAtom = atom({ plugin: 'UsageBreakdown', key: 'changes' } as const, 0)
29const savedAtom = atom({ plugin: 'UsageBreakdown', key: 'saved' } as const, 0)
30const savedAtAtom = atom({ plugin: 'UsageBreakdown', key: 'savedAt' } as const, 0)
31const sweptAtAtom = atom({ plugin: 'UsageBreakdown', key: 'sweptAt' } as const, 0)
32const reportAtom = atom({ plugin: 'UsageBreakdown', key: 'report' } as const, { at: 0, sections: [] })
33
34const PANE = 'usage-breakdown'
35const SWEEP_EVERY_MS = 3_600_000
36
37// The accent used for fills elsewhere, checked against light and dark surfaces alike.
38const BAR_COLOR = '#3987e5'
39
40// The module's own variables reset on a reload, as the timers do, so they stay in step.
41let isTicking = false
42let attaching: Promise<string> | undefined
43
44type Elements = Pick<ElementTable<'terminal'>, 'Box' | 'Text'>
45
46// Makes the chat on show the one the counts belong to: loads what it saved before
47// (a resumed chat), and saves the previous chat's counts first when the id has moved on.
48async function attachNow($: EngineInterface) {
49 const id = await $.session.id()
50
51 if ((await read($, sessionIdAtom)) === id) {
52 return id
53 }
54
55 await save($, true)
56
57 let stored: unknown
58
59 try {
60 stored = await $.store.get(`${KEY_PREFIX}${id}`)
61 } catch {
62 stored = undefined
63 }
64
65 const now = await $.clock.now()
66 const cwd = await $.session.cwd()
67 const repo = await $.session.repo()
68
69 await update($, hoursAtom, () => (isSession(stored) ? pruneHours(stored.hours, now) : {}))
70 await update($, projectAtom, () => projectOf(cwd, repo?.root))
71 await update($, changesAtom, () => 0)
72 await update($, savedAtom, () => 0)
73 await update($, savedAtAtom, () => 0)
74 await update($, sessionIdAtom, () => id)
75
76 return id
77}
78
79// One at a time, so two requests ending together do not both load over the other's count.
80function attach($: EngineInterface) {
81 attaching ??= attachNow($).finally(() => {
82 attaching = undefined
83 })
84
85 return attaching
86}
87
88// Writes this chat's counts when they changed, at most every 30 seconds unless forced.
89async function save($: EngineInterface, isForced: boolean) {
90 const id = await read($, sessionIdAtom)
91 const changes = await read($, changesAtom)
92
93 if (id === '' || changes === (await read($, savedAtom))) {
94 return
95 }
96
97 const now = await $.clock.now()
98
99 if (!isForced && now - (await read($, savedAtAtom)) < SAVE_EVERY_MS) {
100 return
101 }
102
103 const hours = pruneHours(await read($, hoursAtom), now)
104
105 try {
106 await $.store.set(`${KEY_PREFIX}${id}`, { project: await read($, projectAtom), updatedAt: now, hours })
107 } catch {
108 // The store is full or unwritable: keep counting, try again later.
109 return
110 }
111
112 await update($, hoursAtom, current => pruneHours(current, now))
113 await update($, savedAtom, () => changes)
114 await update($, savedAtAtom, () => now)
115}
116
117// Deletes the chats nobody has updated for 8 days, and the oldest ones if the store gets big.
118async function sweep($: EngineInterface) {
119 const now = await $.clock.now()
120 const own = `${KEY_PREFIX}${await read($, sessionIdAtom)}`
121 const entries = []
122
123 await update($, sweptAtAtom, () => now)
124
125 for (const key of await $.store.keys()) {
126 if (key.startsWith(KEY_PREFIX)) {
127 entries.push({ key, value: await $.store.get(key) })
128 }
129 }
130
131 for (const key of sweepPlan(entries, now, own)) {
132 await $.store.delete(key)
133 }
134}
135
136async function tick($: EngineInterface) {
137 await save($, false)
138
139 if ((await $.clock.now()) - (await read($, sweptAtAtom)) >= SWEEP_EVERY_MS) {
140 await sweep($)
141 }
142}
143
144// Every chat's saved counts, with this chat's own taken live rather than as last saved.
145async function sessionsOf($: EngineInterface): Promise<Session[]> {
146 const id = await read($, sessionIdAtom)
147 const own = `${KEY_PREFIX}${id}`
148 const sessions: Session[] = []
149
150 for (const key of await $.store.keys()) {
151 if (key.startsWith(KEY_PREFIX) && key !== own) {
152 const value = await $.store.get(key)
153
154 if (isSession(value)) {
155 sessions.push(value)
156 }
157 }
158 }
159
160 if (id !== '') {
161 sessions.push({
162 project: await read($, projectAtom),
163 updatedAt: await $.clock.now(),
164 hours: await read($, hoursAtom),
165 })
166 }
167
168 return sessions
169}
170
171async function refresh($: EngineInterface) {
172 const now = await $.clock.now()
173 const report = buildReport(await sessionsOf($), now)
174
175 await update($, reportAtom, () => report)
176}
177
178async function refreshIfOpen($: EngineInterface) {
179 if ((await $.ui.panes()).some(pane => pane.id === PANE)) {
180 await refresh($)
181 }
182}
183
184// Saves on a short timer so the last requests of a quiet chat are not left unwritten,
185// and refreshes the pane while it is open.
186function startTimers($: EngineInterface) {
187 if (isTicking) {
188 return
189 }
190
191 isTicking = true
192 $.clock.every(10_000, () => void tick($).catch(() => undefined))
193 $.clock.every(60_000, () => void refreshIfOpen($).catch(() => undefined))
194}
195
196type Step = { model: string; agentId?: string | undefined }
197type Used = { model: string; input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
198
199// Adds one model request to this chat's current hour.
200async function record($: EngineInterface, step: Step, used: Used | null) {
201 if (used === null) {
202 return
203 }
204
205 await attach($)
206
207 const now = await $.clock.now()
208 const model = used.model === '' ? step.model : used.model
209 const source: Source = step.agentId === undefined ? 'main' : 'agents'
210
211 await update($, hoursAtom, hours => addRequest(hours, now, model, source, used))
212 await update($, changesAtom, changes => changes + 1)
213 await save($, false)
214 startTimers($)
215}
216
217type RowProps = { elements: Elements; row: Row; layout: Layout }
218
219function BarRow({ elements, row, layout }: RowProps) {
220 const { Box, Text } = elements
221
222 return (
223 <Box columnGap={1}>
224 <Box width={layout.label}>
225 <Text wrap="truncate-end">{row.label}</Text>
226 </Box>
227 <Box width={layout.bar}>
228 <Text color={BAR_COLOR}>{bar(row.share, layout.bar)}</Text>
229 </Box>
230 <Box width={widths.percent} justifyContent="flex-end">
231 <Text>{percent(row.share)}</Text>
232 </Box>
233 <Box width={widths.tokens} justifyContent="flex-end">
234 <Text dimColor>{compact(row.tokens)}</Text>
235 </Box>
236 </Box>
237 )
238}
239
240type GroupProps = { elements: Elements; title: string; rows: readonly Row[]; layout: Layout }
241
242function Group({ elements, title, rows, layout }: GroupProps) {
243 const { Box, Text } = elements
244
245 return (
246 <Box flexDirection="column" marginTop={1}>
247 <Text dimColor>{title}</Text>
248 {rows.map(row => (
249 <BarRow elements={elements} row={row} layout={layout} />
250 ))}
251 </Box>
252 )
253}
254
255type SectionProps = { elements: Elements; section: Section; layout: Layout }
256
257function SectionView({ elements, section, layout }: SectionProps) {
258 const { Box, Text } = elements
259 const requests = `${section.requests} request${section.requests === 1 ? '' : 's'}`
260
261 return (
262 <Box flexDirection="column" marginTop={1}>
263 <Box columnGap={1}>
264 <Text bold>{section.title}</Text>
265 <Text dimColor>{`${compact(section.tokens)} tokens · ${requests}`}</Text>
266 </Box>
267 {section.tokens === 0 ? (
268 <Text dimColor>Nothing in this window.</Text>
269 ) : (
270 <Box flexDirection="column">
271 <Group elements={elements} title="By model" rows={section.models} layout={layout} />
272 <Group elements={elements} title="By source" rows={section.sources} layout={layout} />
273 <Group elements={elements} title="By project" rows={section.projects} layout={layout} />
274 </Box>
275 )}
276 </Box>
277 )
278}
279
280type PaneProps = { elements: Elements; report: Report; columns: number }
281
282function PaneView({ elements, report, columns }: PaneProps) {
283 const { Box, Text } = elements
284
285 if (isEmpty(report)) {
286 return (
287 <Box>
288 <Text dimColor>{EMPTY}</Text>
289 </Box>
290 )
291 }
292
293 // One layout for every group, so the columns line up down the pane.
294 const labels = report.sections.flatMap(section => [...section.models, ...section.sources, ...section.projects])
295 const layout = layoutOf(columns, Math.max(...labels.map(row => row.label.length)))
296
297 return (
298 <Box flexDirection="column">
299 {report.sections.map(section => (
300 <SectionView elements={elements} section={section} layout={layout} />
301 ))}
302 <Box marginTop={1}>
303 <Text dimColor>{FOOTNOTE}</Text>
304 </Box>
305 </Box>
306 )
307}
308
309export const register: Register = on => {
310 on('session.start', async ($, e, next) => {
311 await $.command.register({
312 name: 'usage-breakdown',
313 description: 'Show which models, agents and projects use your tokens',
314 })
315
316 try {
317 await attach($)
318 } catch {
319 // Counting begins with the first request instead.
320 }
321
322 startTimers($)
323
324 return next(e)
325 })
326
327 // A step is one model request, the main chat's or an agent's. The stream passes through
328 // untouched; the usage is read off its result once the response is whole.
329 on('turn.step', async function* ($, e, next) {
330 const result = yield* next(e)
331
332 try {
333 await record($, e, result.usage)
334 } catch {
335 // Counting must never get in the way of a turn.
336 }
337
338 return result
339 })
340
341 on('session.end', async ($, e, next) => {
342 try {
343 await save($, true)
344 } catch {
345 // Whatever was not saved is lost; the exit goes on.
346 }
347
348 return next(e)
349 })
350
351 on('command.run', { command: 'usage-breakdown' }, async $ => {
352 try {
353 await refresh($)
354 } catch {
355 // The pane shows what it last had.
356 }
357
358 startTimers($)
359 await $.ui.open({ id: PANE, title: 'Usage breakdown' })
360
361 return { text: 'Usage breakdown opened.' }
362 })
363
364 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
365 const { Box, Text } = $.ui.resolve(e)
366 let report = await read($, reportAtom)
367
368 if (report.at === 0) {
369 try {
370 await refresh($)
371 report = await read($, reportAtom)
372 } catch {
373 // Falls through to the empty state.
374 }
375 }
376
377 return <PaneView elements={{ Box, Text }} report={report} columns={e.props.bodyColumns} />
378 })
379}
380hooks/format.ts 314 lines1import type { Counts, Hours, Report, Row, Section, Session } from '../types'
2
3export const HOUR = 3_600_000
4const DAY = 24 * HOUR
5
6// How long counts are kept, and how often a chat saves at most.
7export const KEEP_MS = 8 * DAY
8export const SAVE_EVERY_MS = 30_000
9
10export const KEY_PREFIX = 'session:'
11
12// Well under the store's 4 MiB, which every chat's counts share.
13const MAX_BYTES = 2 * 1024 * 1024
14const TRIM_TO_BYTES = 1.5 * 1024 * 1024
15
16const WINDOWS = [
17 { title: 'Last 5 hours', ms: 5 * HOUR },
18 { title: 'Last 7 days', ms: 7 * DAY },
19]
20
21export const MODEL_LIMIT = 8
22export const PROJECT_LIMIT = 6
23
24export type Source = 'main' | 'agents'
25
26export type Usage = {
27 input_tokens: number
28 output_tokens: number
29 cache_read_input_tokens: number
30 cache_creation_input_tokens: number
31}
32
33const count = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0)
34
35export const hourOf = (now: number) => Math.floor(now / HOUR) * HOUR
36
37// Adds one request to the hour it was made in, without touching what was there.
38export function addRequest(hours: Hours, now: number, model: string, source: Source, usage: Usage): Hours {
39 const start = String(hourOf(now))
40 const key = `${model}|${source}`
41 const hour = hours[start] ?? {}
42 const was = hour[key] ?? [0, 0, 0, 0, 0]
43 const next: Counts = [
44 was[0] + 1,
45 was[1] + count(usage.input_tokens),
46 was[2] + count(usage.cache_creation_input_tokens),
47 was[3] + count(usage.cache_read_input_tokens),
48 was[4] + count(usage.output_tokens),
49 ]
50
51 return { ...hours, [start]: { ...hour, [key]: next } }
52}
53
54// Drops the hours too old to count.
55export function pruneHours(hours: Hours, now: number): Hours {
56 const kept: Hours = {}
57
58 for (const [start, hour] of Object.entries(hours)) {
59 if (Number(start) + HOUR > now - KEEP_MS) {
60 kept[start] = hour
61 }
62 }
63
64 return kept
65}
66
67const isCounts = (value: unknown): value is Counts =>
68 Array.isArray(value) && value.length === 5 && value.every(one => typeof one === 'number' && Number.isFinite(one))
69
70// Whether a value read back from the store is a chat this mod saved.
71export function isSession(value: unknown): value is Session {
72 if (typeof value !== 'object' || value === null) {
73 return false
74 }
75
76 const { project, updatedAt, hours } = value as Partial<Session>
77
78 if (typeof project !== 'string' || typeof updatedAt !== 'number' || typeof hours !== 'object' || hours === null) {
79 return false
80 }
81
82 return Object.values(hours).every(
83 hour => typeof hour === 'object' && hour !== null && Object.values(hour).every(isCounts),
84 )
85}
86
87export type Entry = { key: string; value: unknown }
88
89// Which saved chats to delete: those not updated for 8 days or unreadable, then the
90// least recently updated until the rest fit, never `own`.
91export function sweepPlan(entries: readonly Entry[], now: number, own: string): string[] {
92 const doomed: string[] = []
93 const kept: { key: string; updatedAt: number; bytes: number }[] = []
94
95 for (const { key, value } of entries) {
96 if (!key.startsWith(KEY_PREFIX) || key === own) {
97 continue
98 }
99
100 if (!isSession(value) || value.updatedAt < now - KEEP_MS) {
101 doomed.push(key)
102 } else {
103 kept.push({ key, updatedAt: value.updatedAt, bytes: JSON.stringify(value).length })
104 }
105 }
106
107 let total = kept.reduce((sum, one) => sum + one.bytes, 0)
108
109 if (total > MAX_BYTES) {
110 for (const one of kept.sort((a, b) => a.updatedAt - b.updatedAt)) {
111 if (total <= TRIM_TO_BYTES) {
112 break
113 }
114
115 doomed.push(one.key)
116 total -= one.bytes
117 }
118 }
119
120 return doomed
121}
122
123// The part of a path after its last slash, ignoring trailing slashes.
124export function basename(path: string) {
125 const parts = path.split('/').filter(part => part !== '')
126
127 return parts.at(-1) ?? path
128}
129
130// The project a chat belongs to: its git top level when there is one, else its working folder.
131export function projectOf(cwd: string, gitTop: string | undefined) {
132 const top = gitTop?.trim()
133
134 return basename(top !== undefined && top !== '' ? top : cwd) || 'unknown'
135}
136
137const FAMILY = /(opus|sonnet|haiku)/
138const NEW_NAME = /claude-(opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/
139const OLD_NAME = /claude-(\d+)(?:-(\d{1,2})(?!\d))?-(opus|sonnet|haiku)/
140
141const capitalize = (text: string) => text.charAt(0).toUpperCase() + text.slice(1)
142
143// A short name for a model id: claude-opus-4-5-20251101 reads Opus 4.5.
144export function modelLabel(model: string) {
145 const id = model.toLowerCase().replace(/\[.*\]$/, '')
146 const next = NEW_NAME.exec(id)
147
148 if (next?.[1] !== undefined && next[2] !== undefined) {
149 return `${capitalize(next[1])} ${next[2]}${next[3] === undefined ? '' : `.${next[3]}`}`
150 }
151
152 const old = OLD_NAME.exec(id)
153
154 if (old?.[1] !== undefined && old[3] !== undefined) {
155 return `${capitalize(old[3])} ${old[1]}${old[2] === undefined ? '' : `.${old[2]}`}`
156 }
157
158 const family = FAMILY.exec(id)?.[1]
159
160 if (family !== undefined) {
161 return capitalize(family)
162 }
163
164 return id.replace(/^claude-/, '') || 'unknown'
165}
166
167// What counts toward a share: everything but cache reads.
168export const tokensOf = (counts: Counts) => counts[1] + counts[2] + counts[4]
169
170type Tally = { tokens: number; requests: number }
171
172const bump = (map: Map<string, Tally>, label: string, counts: Counts) => {
173 const was = map.get(label) ?? { tokens: 0, requests: 0 }
174 map.set(label, { tokens: was.tokens + tokensOf(counts), requests: was.requests + counts[0] })
175}
176
177// The biggest `limit` rows, the rest folded into "Other"; each row's share is of `total`.
178export function rowsOf(map: Map<string, Tally>, total: number, limit: number): Row[] {
179 const rows = [...map.entries()]
180 .filter(([, tally]) => tally.tokens > 0)
181 .map(([label, tally]) => ({ label, tokens: tally.tokens }))
182 .sort((a, b) => b.tokens - a.tokens || a.label.localeCompare(b.label))
183 const top = rows.slice(0, limit)
184 const rest = rows.slice(limit).reduce((sum, row) => sum + row.tokens, 0)
185
186 if (rest > 0) {
187 top.push({ label: 'Other', tokens: rest })
188 }
189
190 return top.map(row => ({ ...row, share: total > 0 ? row.tokens / total : 0 }))
191}
192
193function sectionOf(sessions: readonly Session[], now: number, title: string, ms: number): Section {
194 const models = new Map<string, Tally>()
195 const sources = new Map<string, Tally>([
196 ['Main chats', { tokens: 0, requests: 0 }],
197 ['Agents', { tokens: 0, requests: 0 }],
198 ])
199 const projects = new Map<string, Tally>()
200 let tokens = 0
201 let requests = 0
202
203 for (const session of sessions) {
204 for (const [start, hour] of Object.entries(session.hours)) {
205 // An hour counts while any of it is inside the window.
206 if (Number(start) + HOUR <= now - ms) {
207 continue
208 }
209
210 for (const [key, counts] of Object.entries(hour)) {
211 const cut = key.lastIndexOf('|')
212 const model = modelLabel(key.slice(0, cut))
213 const source = key.slice(cut + 1) === 'agents' ? 'Agents' : 'Main chats'
214
215 tokens += tokensOf(counts)
216 requests += counts[0]
217 bump(models, model, counts)
218 bump(sources, source, counts)
219 bump(projects, session.project, counts)
220 }
221 }
222 }
223
224 return {
225 title,
226 tokens,
227 requests,
228 models: rowsOf(models, tokens, MODEL_LIMIT),
229 // Both sources always show, even when one is empty.
230 sources: [...sources.entries()].map(([label, tally]) => ({
231 label,
232 tokens: tally.tokens,
233 share: tokens > 0 ? tally.tokens / tokens : 0,
234 })),
235 projects: rowsOf(projects, tokens, PROJECT_LIMIT),
236 }
237}
238
239// Every window of the report, from every chat's saved counts.
240export function buildReport(sessions: readonly Session[], now: number): Report {
241 return { at: now, sections: WINDOWS.map(({ title, ms }) => sectionOf(sessions, now, title, ms)) }
242}
243
244// Whether there is nothing to show in any window.
245export const isEmpty = (report: Report) => report.sections.every(section => section.tokens === 0)
246
247// 1.2M, 340k, 950: three significant figures at most.
248export function compact(tokens: number) {
249 const n = Math.max(0, Math.round(tokens))
250
251 if (n < 1000) {
252 return String(n)
253 }
254
255 const one = (value: number) => (value < 10 ? value.toFixed(1).replace(/\.0$/, '') : String(Math.round(value)))
256
257 if (n < 999_500) {
258 return `${one(n / 1000)}k`
259 }
260
261 return n < 999_500_000 ? `${one(n / 1e6)}M` : `${one(n / 1e9)}B`
262}
263
264export function percent(share: number) {
265 if (share <= 0) {
266 return '0%'
267 }
268
269 return share < 0.01 ? '<1%' : `${Math.round(share * 100)}%`
270}
271
272const PARTS = ['▏', '▎', '▍', '▌', '▋', '▊', '▉']
273
274// A bar `width` cells wide for a share of 0 to 1, in eighths of a cell; any
275// share above nothing shows at least a sliver.
276export function bar(share: number, width: number) {
277 if (width <= 0 || share <= 0) {
278 return ''
279 }
280
281 const eighths = Math.min(width * 8, Math.max(1, Math.round(share * width * 8)))
282 const part = eighths % 8
283
284 return '█'.repeat(Math.floor(eighths / 8)) + (part > 0 ? (PARTS[part - 1] ?? '') : '')
285}
286
287export type Layout = { label: number; bar: number }
288
289const PCT_WIDTH = 4
290const TOKENS_WIDTH = 5
291const COLUMN_GAPS = 3
292const MIN_LABEL = 6
293const MAX_LABEL = 16
294
295// Splits a row's width into the label and the bar; the percentage and the tokens are fixed.
296export function layoutOf(columns: number, longest: number): Layout {
297 const fixed = PCT_WIDTH + TOKENS_WIDTH + COLUMN_GAPS
298 const label = Math.min(MAX_LABEL, Math.max(MIN_LABEL, longest))
299 const room = columns - fixed - label
300
301 if (room >= 6) {
302 return { label, bar: room }
303 }
304
305 return { label: MIN_LABEL, bar: Math.max(0, columns - fixed - MIN_LABEL) }
306}
307
308export const widths = { percent: PCT_WIDTH, tokens: TOKENS_WIDTH }
309
310export const FOOTNOTE =
311 'Shares of tokens, not of your limits: Opus uses more of your limits per token than Sonnet. Cache reads are left out.'
312
313export const EMPTY = 'Nothing recorded yet: counting starts now.'
314types/index.d.ts 48 lines1// Token counts of one model and source in one hour: requests, input, cache write, cache read, output.
2export type Counts = [number, number, number, number, number]
3
4// One hour's counts by "<model>|<source>", the source being `main` or `agents`.
5export type Hour = Record<string, Counts>
6
7// Hours by the hour's start in milliseconds, as a string key.
8export type Hours = Record<string, Hour>
9
10// What one chat saves under `session:<id>` in the mod's store.
11export type Session = { project: string; updatedAt: number; hours: Hours }
12
13// One bar of the report: a model, a source or a project, with its tokens and share of the window's.
14export type Row = { label: string; tokens: number; share: number }
15
16// One window of the report, such as the last 5 hours.
17export type Section = {
18 title: string
19 tokens: number
20 requests: number
21 models: Row[]
22 sources: Row[]
23 projects: Row[]
24}
25
26export type Report = { at: number; sections: Section[] }
27
28declare module 'claude-code' {
29 interface PluginState {
30 'UsageBreakdown': {
31 // The chat the counts below belong to; '' before the first request.
32 sessionId: string
33 // The basename of the chat's git top level, or of its working folder.
34 project: string
35 // This chat's counts, the same as its saved value.
36 hours: Hours
37 // How many requests were recorded, and how many of those the last save held.
38 changes: number
39 saved: number
40 // When the chat last saved and when old chats were last swept, in milliseconds.
41 savedAt: number
42 sweptAt: number
43 // What the pane shows; `at` is 0 until the first refresh.
44 report: Report
45 }
46 }
47}
48