SLOPSHOPPER

UsageBreakdown

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

newpanecommandtimer
★ 2v0.1.0MITupdated 2026-10-07Heuwzen/claude-runway/usage-breakdown
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · UsageBreakdown
│ ┃ Usage breakdown ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing recorded yet: counting starts now. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /usage-breakdown │ ⎿ UsageBreakdown: Usage breakdown opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Usage breakdown
Nothing recorded yet: counting starts now.
README

UsageBreakdown

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:

  • By model, such as Opus 5.5 and Sonnet 5.5
  • By source: your main chats, or the agents they start
  • By project: your six busiest, then everything else

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.

How it works

  • Every model request, from a chat or from an agent, passes through UsageBreakdown on its way back. It reads the token counts and passes the response on untouched.
  • Counts are kept per chat in hourly buckets and saved at most every 30 seconds. Anything older than 8 days is dropped.
  • A chat's project is its git repository's folder name, or its working folder outside a repository.
  • The pane adds up every chat's counts and refreshes every minute while it's open.
  • Counting starts when you install it. "Last 5 hours" counts every hour that overlaps those 5 hours, so it can reach a little further back.

Requirements

Claude Code. Nothing else.

Install

UsageBreakdown installs with the other mods in this repository. See the main README.

Development

claude plugin validate .
claude plugin test .

License

MIT

Source 3 files
hooks/register.tsx 380 lines
1import { 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}
380
hooks/format.ts 314 lines
1import 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.'
314
types/index.d.ts 48 lines
1// 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