SLOPSHOPPER

usage-stats

Your Claude Code usage on this machine as a receipt: total tokens, prompts, sessions, issues closed, daily activity, and tokens by project and model. Opens…

newpanecommandprocess
★ 3v0.1.0MITupdated 2026-10-15omeerdvrn/o-mods/mods/usage-stats
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-stats
│ ┃ Usage stats ✕ › fix the failing auth test and add an audit log call │ ┃ Usage 7d 3 │ ┃ ⏺ Read(src/auth.ts) │ ┃ Couldn't read the session logs: JSON Parse ⎿ Read 6 lines │ ┃ error: Unexpected identifier "dev" ⏺ 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-stats │ ⎿ usage-stats: Usage stats opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Usage stats
Usage 7d 30d All … Couldn't read the session logs: JSON Parse error: Unexpected identifier "dev"
README

usage-stats

A Claude Code mod that shows your Claude Code usage on this machine. Type /usage-stats to open it. It never opens on its own. It opens as a side pane, brought to the front; where no pane can be shown, the same receipt is printed into the conversation.

Typing /usage-stats opens a side pane with 371.8M tokens in 30 days, a daily chart, and totals by project and by model.

Usage                                        7d  30d  All  ⟳

▀█  ▀▀█   █▀█ █▀▄
 █    █   █▀█ █▀▄
▀▀▀   ▀ ▀ ▀▀▀ ▀▀
tokens in the last 30 days

816 prompts   104 sessions   71 issues closed

          ▄▄▄▄  ▃▃                          ▅▅          ██▅▅
▄▄▃▃▃▃  ▃▃████▅▅██▆▆    ▇▇██▂▂▆▆▁▁  ▁▁██▄▄████▅▅    ▁▁▆▆████
Sep 9                                                  Oct 8

Projects
webapp ······································  10.8B    61%
shop-api ····································   3.8B    21%

Models
████████████████████████████████████████████████████████████
■ Opus 5 45%   ■ Opus 5.5 42%   ■ Sonnet 5 7%   ■ other 7%

Busiest day ········································  Oct 7
Busiest hour ···································· Thu 15:00
Top issue ··········································· ENG-4

It reads like a receipt:

  • The total: tokens in the range, in large block digits, then prompts, sessions and Linear issues closed.
  • The range: tokens per day across the range.
  • Projects: tokens and share per repository. A worktree counts toward its repository.
  • Models: each model's share of tokens.
  • Busiest moments: the busiest day, the busiest hour of the week, the issue with the most tokens, and the points you closed.

Press 7d, 30d or All to change the range; the choice is kept. Press ⟳ to read the logs again.

Where the numbers come from

  • Tokens, prompts, sessions: Claude Code's session logs in ~/.claude/projects, read by scanner/scan.py. Each API response is counted once, and subagents' logs are included.
  • The first scan reads every log, which takes a few seconds. After that, only new or changed logs are read.
  • The scanner caches its results in ~/.claude/usage-stats/cache.json.
  • Issues closed: your Linear connection (claude.ai Linear by default). Set linearServer in /config to use another server, or leave it empty to hide the section.
  • Claude Code removes old session logs, so "All time" goes back as far as the logs on this machine.

Nothing leaves your machine except the Linear request.

Requirements

  • Python 3, as python3
  • Linear connected to Claude, for the issues closed section

Install

claude plugin marketplace add omeerdvrn/o-mods
claude plugin install usage-stats@o-mods

Development

claude --plugin-dir /path/to/o-mods/mods/usage-stats
claude plugin validate .
claude plugin test .
python3 scanner/scan.py | head -c 400
FileContents
hooks/register.tsxThe /usage-stats command, the scan and Linear calls, and the view
hooks/stats.tsRanges, totals and rankings from the scan
hooks/charts.tsBlock digits, dotted leaders, the sparkline and the proportion bar
scanner/scan.pyReads the session logs and prints aggregates as JSON
types/index.d.tsThe mod's state contract
tests/usage-stats.test.tsTests with the scanner and Linear faked
Source 5 files
hooks/register.tsx 344 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Closed, Range, Scan } from '../types'
5import { ACCENT, LEADER, SHADES, bigText, bucket, leaderLine, proportions, sparkRows, stretch } from './charts'
6import { receiptText } from './receipt'
7import { compact, dayKey, rangeStart, summarize, topWithOther } from './stats'
8
9const PANE = 'usage-stats'
10const TITLE = 'Usage stats'
11const SCAN_TIMEOUT_MS = 600_000
12const RECEIPT_WIDTH = 60
13const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
14const RANGE_KEY = 'range'
15const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
16const LINEAR_PAGES = 6
17
18const scan = atom({ plugin: 'usage-stats', key: 'scan' } as const, null as Scan | null)
19const closed = atom({ plugin: 'usage-stats', key: 'closed' } as const, null as Closed[] | null)
20const isScanning = atom({ plugin: 'usage-stats', key: 'isScanning' } as const, false)
21const error = atom({ plugin: 'usage-stats', key: 'error' } as const, null as string | null)
22const linearError = atom({ plugin: 'usage-stats', key: 'linearError' } as const, null as string | null)
23const range = atom({ plugin: 'usage-stats', key: 'range' } as const, '30d' as Range)
24
25type Options = { linearServer: string }
26
27const RANGES: { range: Range; label: string }[] = [
28  { range: '7d', label: '7d' },
29  { range: '30d', label: '30d' },
30  { range: 'all', label: 'All' },
31]
32
33/** Runs the bundled scanner over ~/.claude/projects; it caches per log file, so a rescan is quick. */
34async function runScan($: EngineInterface) {
35  if (await read($, isScanning)) return
36  await update($, isScanning, () => true)
37  try {
38    const root = $.plugin.root.replace(/\/\.claude-plugin\/?$/, '')
39    const result = await $.process.run(['python3', '-I', `${root}/scanner/scan.py`], { timeoutMs: SCAN_TIMEOUT_MS })
40    if (result.exitCode !== 0) throw new Error(result.stderr.trim().split('\n').pop() || `scanner exited ${result.exitCode}`)
41    const data = JSON.parse(result.stdout) as Scan
42    await update($, scan, () => data)
43    await update($, error, () => null)
44  } catch (err) {
45    const message = err instanceof Error ? err.message : String(err)
46    await update($, error, () => `Couldn't read the session logs: ${message}`)
47  } finally {
48    await update($, isScanning, () => false)
49  }
50}
51
52/** The configured Linear server first, then the names it usually goes by (claude.ai connector, `claude mcp add`). */
53const SERVER_NAMES = ['claude.ai Linear', 'linear', 'Linear']
54let connected: string | null = null
55
56async function linearCall($: EngineInterface, o: Options, args: Record<string, unknown>) {
57  const tried = [...new Set([connected, o.linearServer, ...SERVER_NAMES].filter((name): name is string => Boolean(name)))]
58  for (const server of tried) {
59    try {
60      const result = await $.mcp.call(server, 'list_issues', args)
61      connected = server
62      return result
63    } catch (err) {
64      const message = err instanceof Error ? err.message : String(err)
65      if (!/no connected MCP/i.test(message)) throw err
66      if (connected === server) connected = null
67    }
68  }
69  throw new Error(`no connected MCP server has Linear (tried ${tried.join(', ')})`)
70}
71
72/** Issues assigned to you and completed in the last 120 days, from Linear. */
73async function loadClosed($: EngineInterface, o: Options) {
74  if (!o.linearServer) return
75  try {
76    const found: Closed[] = []
77    let cursor: string | undefined
78    for (let page = 0; page < LINEAR_PAGES; page++) {
79      const result = await linearCall($, o, {
80        assignee: 'me',
81        state: 'completed',
82        updatedAt: '-P120D',
83        limit: 50,
84        fields: ['id', 'completedAt', 'estimate'],
85        ...(cursor ? { cursor } : {}),
86      })
87      const text = result.content.map(block => block.text ?? '').join('')
88      if (result.isError) throw new Error(text)
89      const data = JSON.parse(text) as {
90        issues?: { id: string; completedAt?: string | null; estimate?: { value?: number } | null }[]
91        hasNextPage?: boolean
92        cursor?: string
93      }
94      for (const issue of data.issues ?? []) {
95        if (!issue.completedAt) continue
96        found.push({ id: issue.id, day: dayKey(Date.parse(issue.completedAt)), points: issue.estimate?.value ?? 0 })
97      }
98      if (!data.hasNextPage || !data.cursor) break
99      cursor = data.cursor
100    }
101    await update($, closed, () => found)
102    await update($, linearError, () => null)
103  } catch (err) {
104    const message = err instanceof Error ? err.message : String(err)
105    await update($, linearError, () => linearProblem(message))
106  }
107}
108
109/** Shows another range and remembers it for next time. */
110async function pickRange($: EngineInterface, next: Range) {
111  await update($, range, () => next)
112  await $.store.set(RANGE_KEY, next)
113}
114
115/** A Linear failure in words: not connected, unavailable for now, or its own message. */
116function linearProblem(message: string): string {
117  if (/no connected MCP/i.test(message)) return 'Linear is not connected. Connect it in /mcp to see issues closed.'
118  try {
119    const body = JSON.parse(message) as { message?: string; status?: number }
120    if (body.status !== undefined && body.status >= 500) return 'Linear is unavailable right now. Press Refresh to try again.'
121    if (body.message) return body.message
122  } catch {
123    // Not JSON: the server's own words.
124  }
125  return message.length > 160 ? `${message.slice(0, 159)}…` : message
126}
127
128async function refresh($: EngineInterface, o: Options) {
129  await Promise.all([runScan($), loadClosed($, o)])
130}
131
132export const register: Register = (on, rawOptions) => {
133  const options = rawOptions as unknown as Options
134
135  on('session.start', async ($, e, next) => {
136    // Not awaited: registering makes Claude Code re-describe every command (~0.7 s), which would hold startup.
137    // Typed as /usage-stats in the terminal; the command file is what the desktop app lists in its menu.
138    void $.command.register({
139      name: 'usage-stats',
140      description: "Show your Claude Code usage on this machine: tokens, prompts, sessions, issues closed",
141    }).catch(() => undefined)
142    const stored = await $.store.get(RANGE_KEY)
143    if (stored === '7d' || stored === '30d' || stored === 'all') await update($, range, () => stored)
144
145    return next(e)
146  })
147
148  // The view opens only when asked for.
149  on('command.run', { command: ['usage-stats', 'usage-stats:usage-stats'] }, async $ => {
150    // Focused, so it comes to the front where a surface shows one pane at a time (the desktop app).
151    const opened = await $.ui.open({ id: PANE, title: TITLE, focus: true })
152    if (opened.isPlaced) {
153      void refresh($, options)
154      return { text: 'Usage stats opened.' }
155    }
156    // No pane here (a desktop app that places none): print the receipt into the conversation instead.
157    await refresh($, options)
158    const data = await read($, scan)
159    if (data === null) return { text: `Couldn't open the usage view: ${opened.reason}. ${(await read($, error)) ?? ''}`.trim() }
160    const shown = await read($, range)
161    const sum = summarize(data, (await read($, closed)) ?? [], shown, Date.now())
162    const period = shown === 'all' ? 'all time' : shown === '7d' ? 'the last 7 days' : 'the last 30 days'
163    const linearFailed = await read($, linearError)
164    const footer = `${data.files.toLocaleString('en-US')} session logs · shown here because ${opened.reason}`
165
166    return { text: receiptText(sum, period, linearFailed === null ? String(sum.closed.length) : '–', footer) }
167  })
168
169  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
170    const { Box, Text, Button } = $.ui.resolve(e)
171    const data = await read($, scan)
172    const issues = (await read($, closed)) ?? []
173    const scanning = await read($, isScanning)
174    const failed = await read($, error)
175    const linearFailed = await read($, linearError)
176    const shown = await read($, range)
177    // A receipt is narrow: cap the width so lines stay easy to follow on a wide pane.
178    const width = Math.max(36, Math.min(RECEIPT_WIDTH, e.props.bodyColumns - 2))
179    const now = Date.now()
180
181    const title = (
182      <Box flexDirection="row" justifyContent="space-between" width={width}>
183        <Text bold>Usage</Text>
184        <Box flexDirection="row" columnGap={2}>
185          {RANGES.map(item => (
186            <Button
187              key={`range:${item.range}`}
188              label={item.label}
189              plain
190              variant={shown === item.range ? 'primary' : undefined}
191              dimColor={shown !== item.range}
192              onPress={() => pickRange($, item.range)}
193            />
194          ))}
195          <Button key="refresh" label={scanning ? '…' : '⟳'} plain dimColor onPress={() => refresh($, options)} />
196        </Box>
197      </Box>
198    )
199
200    if (data === null) {
201      return (
202        <Box flexDirection="column" rowGap={1}>
203          {title}
204          {failed !== null ? (
205            <Text color="error" wrap="wrap">
206              {failed}
207            </Text>
208          ) : (
209            <Text dimColor>Reading your session logs…</Text>
210          )}
211        </Box>
212      )
213    }
214
215    const sum = summarize(data, issues, shown, now)
216    const line = (name: string, columns: string[], isStrong = false) => {
217      const parts = leaderLine(name, columns, width)
218      return (
219        <Box key={`line:${name}`} flexDirection="row">
220          <Text>{parts.name}</Text>
221          <Text color={LEADER}>{parts.leader}</Text>
222          <Text bold={isStrong}>{parts.tail}</Text>
223        </Box>
224      )
225    }
226    const share = (value: number) => `${Math.round((value / Math.max(1, sum.tokens)) * 100)}%`
227    const dateLabel = (day: string) => {
228      const [, month, date] = day.split('-').map(Number)
229      return `${MONTHS[month! - 1]} ${date}`
230    }
231
232    // The total, in block digits: the one loud thing on the page.
233    const hero = bigText(compact(sum.tokens))
234    const period = shown === 'all' ? 'all time' : shown === '7d' ? 'the last 7 days' : 'the last 30 days'
235
236    // The range at a glance: tokens per day, squeezed to the receipt's width.
237    const spark = sparkRows(stretch(bucket(sum.daily.map(day => day.tokens), width), width), 2)
238    const firstDay = sum.daily[0]?.day ?? ''
239    const lastDay = sum.daily[sum.daily.length - 1]?.day ?? ''
240
241    // Projects: the top four, the rest as one line.
242    const projects = topWithOther(sum.byProject, 5)
243
244    // Models: one proportion bar in shades of the accent; the labels carry the names.
245    const models = topWithOther(sum.byModel, 4)
246    const cells = proportions(
247      models.map(slice => slice.value),
248      width,
249    )
250
251    // The busiest moments.
252    const busiestDay = sum.daily.reduce((best, day) => (day.tokens > best.tokens ? day : best), { day: '', tokens: 0, prompts: 0 })
253    let hotDay = 0
254    let hotHour = 0
255    sum.weekHours.forEach((hours, day) =>
256      hours.forEach((count, hour) => {
257        if (count > (sum.weekHours[hotDay]![hotHour] ?? 0)) {
258          hotDay = day
259          hotHour = hour
260        }
261      }),
262    )
263    const hasHours = (sum.weekHours[hotDay]![hotHour] ?? 0) > 0
264    const topIssue = sum.byIssue[0]
265
266    return (
267      <Box flexDirection="column">
268        {title}
269
270        <Box flexDirection="column" marginTop={1}>
271          {hero.map(row => (
272            <Text color={ACCENT} bold>
273              {row}
274            </Text>
275          ))}
276          <Text dimColor>{`tokens in ${period}`}</Text>
277        </Box>
278
279        <Box flexDirection="row" columnGap={3} marginTop={1}>
280          <Text>
281            <Text bold>{compact(sum.prompts)}</Text>
282            <Text dimColor> prompts</Text>
283          </Text>
284          <Text>
285            <Text bold>{compact(sum.sessions)}</Text>
286            <Text dimColor> sessions</Text>
287          </Text>
288          <Text>
289            <Text bold>{linearFailed === null ? String(sum.closed.length) : '–'}</Text>
290            <Text dimColor> issues closed</Text>
291          </Text>
292        </Box>
293
294        <Box flexDirection="column" marginTop={1}>
295          {spark.map(row => (
296            <Text color={ACCENT}>{row}</Text>
297          ))}
298          <Box flexDirection="row" justifyContent="space-between" width={Math.max(spark[0]?.length ?? 0, 20)}>
299            <Text dimColor>{firstDay === '' ? '' : dateLabel(firstDay)}</Text>
300            <Text dimColor>{lastDay === '' ? '' : dateLabel(lastDay)}</Text>
301          </Box>
302        </Box>
303
304        <Box flexDirection="column" marginTop={1}>
305          <Text bold>Projects</Text>
306          {projects.map(slice => line(slice.label, [compact(slice.value), share(slice.value)]))}
307        </Box>
308
309        <Box flexDirection="column" marginTop={1}>
310          <Text bold>Models</Text>
311          <Box flexDirection="row">
312            {models.map((slice, index) => (
313              <Text color={SHADES[slice.label === 'other' ? 3 : Math.min(index, 2)]}>{'█'.repeat(cells[index] ?? 0)}</Text>
314            ))}
315          </Box>
316          <Box flexDirection="row" flexWrap="wrap" columnGap={3}>
317            {models.map((slice, index) => (
318              <Text key={`model:${slice.label}`}>
319                <Text color={SHADES[slice.label === 'other' ? 3 : Math.min(index, 2)]}>■ </Text>
320                <Text>{slice.label}</Text>
321                <Text dimColor>{` ${share(slice.value)}`}</Text>
322              </Text>
323            ))}
324          </Box>
325        </Box>
326
327        <Box flexDirection="column" marginTop={1}>
328          {busiestDay.tokens > 0 && line('Busiest day', [dateLabel(busiestDay.day)])}
329          {hasHours && line('Busiest hour', [`${WEEKDAYS[hotDay]} ${String(hotHour).padStart(2, '0')}:00`])}
330          {topIssue !== undefined && line('Top issue', [topIssue.label])}
331          {linearFailed === null && sum.closedPoints > 0 && line('Points closed', [String(sum.closedPoints)])}
332        </Box>
333
334        <Box marginTop={1} width={width}>
335          <Text dimColor wrap="wrap">
336            {`${data.files.toLocaleString('en-US')} session logs since ${dateLabel(rangeStart('all', now, data))}` +
337              (linearFailed === null ? '' : ` · ${linearFailed}`)}
338          </Text>
339        </Box>
340      </Box>
341    )
342  })
343}
344
hooks/charts.ts 88 lines
1/**
2 * The receipt's type: block digits for the total, dotted leaders for line items, a two-row sparkline,
3 * and a proportion bar. Everything is text, so it draws the same on every surface.
4 */
5
6/** The one accent, and its darker shades for proportions. */
7export const ACCENT = '#8AB4F8'
8export const SHADES = ['#8AB4F8', '#5B7FC4', '#3A5590', '#4A4A46']
9/** Leaders and rules: present, never loud. */
10export const LEADER = '#4A4A46'
11
12/** A 3-row block font for the total: each glyph is 3 cells wide. */
13const FONT: Record<string, [string, string, string]> = {
14  '0': ['█▀█', '█ █', '▀▀▀'],
15  '1': ['▀█ ', ' █ ', '▀▀▀'],
16  '2': ['▀▀█', '█▀▀', '▀▀▀'],
17  '3': ['▀▀█', ' ▀█', '▀▀▀'],
18  '4': ['█ █', '▀▀█', '  ▀'],
19  '5': ['█▀▀', '▀▀█', '▀▀▀'],
20  '6': ['█▀▀', '█▀█', '▀▀▀'],
21  '7': ['▀▀█', '  █', '  ▀'],
22  '8': ['█▀█', '█▀█', '▀▀▀'],
23  '9': ['█▀█', '▀▀█', '▀▀▀'],
24  '.': [' ', ' ', '▀'],
25  ',': [' ', ' ', '▀'],
26  B: ['█▀▄', '█▀▄', '▀▀ '],
27  M: ['█▄█', '█ █', '▀ ▀'],
28  k: ['█  ', '█▄▀', '▀ ▀'],
29  ' ': ['   ', '   ', '   '],
30}
31
32/** `text` in block digits, three rows, one column between glyphs. */
33export function bigText(text: string): [string, string, string] {
34  const glyphs = [...text].map(char => FONT[char] ?? FONT[' ']!)
35  return [0, 1, 2].map(row => glyphs.map(glyph => glyph[row]).join(' ')) as [string, string, string]
36}
37
38/** A receipt line: name, dotted leader, then right-aligned columns, `width` cells in all. */
39export function leaderLine(name: string, columns: string[], width: number): { name: string; leader: string; tail: string } {
40  const tail = columns.map(column => column.padStart(6)).join(' ')
41  const room = Math.max(6, width - tail.length - 1)
42  const shown = name.length > room - 3 ? `${name.slice(0, room - 4)}…` : name
43  const dots = Math.max(1, room - shown.length - 2)
44  return { name: `${shown} `, leader: '·'.repeat(dots), tail: ` ${tail}` }
45}
46
47const RISE = ' ▁▂▃▄▅▆▇█'
48
49/** A sparkline `rows` tall, one column per value, eighth-block tops. Top row first. */
50export function sparkRows(values: number[], rows: number): string[] {
51  const max = Math.max(0, ...values)
52  const heights = values.map(value => (max > 0 && value > 0 ? Math.max(1, Math.round((value / max) * rows * 8)) : 0))
53  const out: string[] = []
54  for (let row = rows - 1; row >= 0; row--) {
55    out.push(heights.map(height => RISE[Math.max(0, Math.min(8, height - row * 8))]!).join(''))
56  }
57  return out
58}
59
60/** Repeats each value so the series spans `width` cells: a short range still fills the receipt. */
61export function stretch(values: number[], width: number): number[] {
62  if (values.length === 0 || values.length >= width) return values
63  const each = Math.floor(width / values.length)
64  return values.flatMap(value => Array.from({ length: each }, () => value))
65}
66
67/** Squeezes `values` into at most `width` buckets by summing neighbours, so a long range still fits. */
68export function bucket(values: number[], width: number): number[] {
69  if (values.length <= width) return values
70  const size = Math.ceil(values.length / width)
71  const out: number[] = []
72  for (let index = 0; index < values.length; index += size) {
73    out.push(values.slice(index, index + size).reduce((sum, value) => sum + value, 0))
74  }
75  return out
76}
77
78/** Cell counts for a proportion bar of `width` cells: every non-zero share gets at least one. */
79export function proportions(values: number[], width: number): number[] {
80  const total = values.reduce((sum, value) => sum + value, 0)
81  if (total <= 0) return values.map(() => 0)
82  const cells = values.map(value => (value > 0 ? Math.max(1, Math.round((value / total) * width)) : 0))
83  // Give or take from the largest share so the bar is exactly `width`.
84  const largest = cells.indexOf(Math.max(...cells))
85  cells[largest]! += width - cells.reduce((sum, count) => sum + count, 0)
86  return cells
87}
88
hooks/receipt.ts 48 lines
1import { bigText, bucket, leaderLine, sparkRows, stretch } from './charts'
2import type { Summary } from './stats'
3import { compact, topWithOther } from './stats'
4
5const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
6
7function dateLabel(day: string): string {
8  const [, month, date] = day.split('-').map(Number)
9  return `${MONTHS[month! - 1]} ${date}`
10}
11
12/**
13 * The receipt as plain text, for where no pane is drawn (a desktop app that places none): the same
14 * lines as the pane, in a code block so they keep their alignment.
15 */
16export function receiptText(sum: Summary, period: string, closedIssues: string, footer: string, width = 56): string {
17  const share = (value: number) => `${Math.round((value / Math.max(1, sum.tokens)) * 100)}%`
18  const line = (name: string, columns: string[]) => {
19    const parts = leaderLine(name, columns, width)
20    return `${parts.name}${parts.leader}${parts.tail}`
21  }
22  const spark = sparkRows(stretch(bucket(sum.daily.map(day => day.tokens), width), width), 2)
23  const first = sum.daily[0]?.day
24  const last = sum.daily[sum.daily.length - 1]?.day
25  const dates = first && last ? dateLabel(first).padEnd(Math.max(0, (spark[0]?.length ?? 0) - dateLabel(last).length)) + dateLabel(last) : ''
26  const models = topWithOther(sum.byModel, 4)
27
28  const lines = [
29    ...bigText(compact(sum.tokens)),
30    `tokens in ${period}`,
31    '',
32    `${compact(sum.prompts)} prompts   ${compact(sum.sessions)} sessions   ${closedIssues} issues closed`,
33    '',
34    ...spark,
35    dates,
36    '',
37    'Projects',
38    ...topWithOther(sum.byProject, 5).map(slice => line(slice.label, [compact(slice.value), share(slice.value)])),
39    '',
40    'Models',
41    ...models.map(slice => line(slice.label, [compact(slice.value), share(slice.value)])),
42    '',
43    footer,
44  ]
45
46  return ['```', ...lines, '```'].join('\n')
47}
48
hooks/stats.ts 187 lines
1import type { Closed, Range, Scan } from '../types'
2
3export type Slice = { label: string; value: number }
4
5export type Summary = {
6  tokens: number
7  input: number
8  output: number
9  cacheWrite: number
10  cacheRead: number
11  replies: number
12  prompts: number
13  sessions: number
14  byProject: Slice[]
15  byModel: Slice[]
16  byIssue: Slice[]
17  /** One entry per day of the range, oldest first, empty days included. */
18  daily: { day: string; tokens: number; prompts: number }[]
19  /** Tokens per hour of the day, 0 to 23. */
20  hourly: number[]
21  /** Prompts by weekday (0 = Monday) and hour: when you work. */
22  weekHours: number[][]
23  closed: Closed[]
24  closedPoints: number
25  /** Issues closed per ISO week, oldest first. */
26  weekly: Slice[]
27}
28
29const DAY_MS = 86_400_000
30
31export function dayKey(time: number): string {
32  const date = new Date(time)
33  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
34}
35
36/** The first day a range covers, as YYYY-MM-DD; for `all`, the earliest day in the scan. */
37export function rangeStart(range: Range, now: number, scan: Scan): string {
38  if (range === '7d') return dayKey(now - 6 * DAY_MS)
39  if (range === '30d') return dayKey(now - 29 * DAY_MS)
40  const days = scan.tokens.map(row => row[0]).concat(scan.prompts.map(row => row[0]))
41  return days.length > 0 ? days.reduce((low, day) => (day < low ? day : low)) : dayKey(now)
42}
43
44function add(map: Map<string, number>, key: string, value: number) {
45  map.set(key, (map.get(key) ?? 0) + value)
46}
47
48function ranked(map: Map<string, number>): Slice[] {
49  return [...map.entries()]
50    .filter(([, value]) => value > 0)
51    .map(([label, value]) => ({ label, value }))
52    .sort((a, b) => b.value - a.value)
53}
54
55/** "claude-opus-5-5" -> "Opus 5.5", "claude-haiku-4-5-20251001" -> "Haiku 4.5". */
56export function modelName(model: string): string {
57  const match = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?/i.exec(model)
58  if (!match) return model
59  const [, family, major, minor] = match
60  return `${family![0]!.toUpperCase()}${family!.slice(1)} ${major}${minor ? `.${minor}` : ''}`
61}
62
63/** Monday-first weekday of a day: 0 = Monday ... 6 = Sunday. */
64export function weekdayOf(day: string): number {
65  const [year, month, date] = day.split('-').map(Number)
66  return (new Date(year!, month! - 1, date!).getDay() + 6) % 7
67}
68
69/** The same-length period just before the range, for comparisons; none for `all`. */
70export function previousNow(range: Range, now: number): number | null {
71  if (range === '7d') return now - 7 * DAY_MS
72  if (range === '30d') return now - 30 * DAY_MS
73  return null
74}
75
76/** Change from `before` to `now` as a whole percentage; null when there is nothing to compare with. */
77export function change(now: number, before: number): number | null {
78  if (before <= 0) return null
79  return Math.round(((now - before) / before) * 100)
80}
81
82/** Every model ranked by its all-time tokens, so its color stays the same whatever range is shown. */
83export function modelOrder(scan: Scan): string[] {
84  const totals = new Map<string, number>()
85  for (const [, , model, , input, output, cacheWrite, cacheRead] of scan.tokens) {
86    add(totals, modelName(model), input + output + cacheWrite + cacheRead)
87  }
88  return ranked(totals).map(slice => slice.label)
89}
90
91/** ISO week label of a day: "W41". */
92export function weekOf(day: string): string {
93  const [year, month, date] = day.split('-').map(Number)
94  const time = Date.UTC(year!, month! - 1, date!)
95  const target = new Date(time)
96  const weekday = (target.getUTCDay() + 6) % 7
97  target.setUTCDate(target.getUTCDate() - weekday + 3)
98  const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4))
99  const week = 1 + Math.round(((target.getTime() - firstThursday.getTime()) / DAY_MS - 3 + ((firstThursday.getUTCDay() + 6) % 7)) / 7)
100  return `W${week}`
101}
102
103export function summarize(scan: Scan, closed: Closed[], range: Range, now: number): Summary {
104  const from = rangeStart(range, now, scan)
105  const to = dayKey(now)
106  const inRange = (day: string) => day >= from && day <= to
107  const byProject = new Map<string, number>()
108  const byModel = new Map<string, number>()
109  const byIssue = new Map<string, number>()
110  const perDay = new Map<string, { tokens: number; prompts: number }>()
111  const totals = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, replies: 0 }
112
113  for (const [day, project, model, issue, input, output, cacheWrite, cacheRead, replies] of scan.tokens) {
114    if (!inRange(day)) continue
115    const tokens = input + output + cacheWrite + cacheRead
116    totals.input += input
117    totals.output += output
118    totals.cacheWrite += cacheWrite
119    totals.cacheRead += cacheRead
120    totals.replies += replies
121    add(byProject, project, tokens)
122    add(byModel, modelName(model), tokens)
123    if (issue !== '') add(byIssue, issue, tokens)
124    const entry = perDay.get(day) ?? { tokens: 0, prompts: 0 }
125    entry.tokens += tokens
126    perDay.set(day, entry)
127  }
128  let prompts = 0
129  for (const [day, , , count] of scan.prompts) {
130    if (!inRange(day)) continue
131    prompts += count
132    const entry = perDay.get(day) ?? { tokens: 0, prompts: 0 }
133    entry.prompts += count
134    perDay.set(day, entry)
135  }
136  const hourly = Array.from({ length: 24 }, () => 0)
137  const weekHours = Array.from({ length: 7 }, () => Array.from({ length: 24 }, () => 0))
138  for (const [day, hour, tokens, count] of scan.hours) {
139    if (!inRange(day)) continue
140    hourly[hour] = (hourly[hour] ?? 0) + tokens
141    weekHours[weekdayOf(day)]![hour]! += count
142  }
143
144  const daily: Summary['daily'] = []
145  const [year, month, date] = from.split('-').map(Number)
146  for (let time = new Date(year!, month! - 1, date!).getTime(); dayKey(time) <= to; time += DAY_MS) {
147    const day = dayKey(time)
148    daily.push({ day, ...(perDay.get(day) ?? { tokens: 0, prompts: 0 }) })
149  }
150
151  const done = closed.filter(issue => inRange(issue.day))
152  const weekly = new Map<string, number>()
153  for (const day of daily.map(entry => entry.day)) if (!weekly.has(weekOf(day))) weekly.set(weekOf(day), 0)
154  for (const issue of done) add(weekly, weekOf(issue.day), 1)
155
156  return {
157    tokens: totals.input + totals.output + totals.cacheWrite + totals.cacheRead,
158    ...totals,
159    prompts,
160    sessions: scan.sessions.filter(([day]) => inRange(day)).length,
161    byProject: ranked(byProject),
162    byModel: ranked(byModel),
163    byIssue: ranked(byIssue),
164    daily,
165    hourly,
166    weekHours,
167    closed: done,
168    closedPoints: done.reduce((sum, issue) => sum + issue.points, 0),
169    weekly: [...weekly.entries()].map(([label, value]) => ({ label, value })),
170  }
171}
172
173/** 26_607_881_459 -> "26.6B", 22_499_414 -> "22.5M", 1_174 -> "1,174". */
174export function compact(value: number): string {
175  if (value >= 1e9) return `${(value / 1e9).toFixed(1)}B`
176  if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`
177  if (value >= 1e4) return `${(value / 1e3).toFixed(1)}k`
178  return value.toLocaleString('en-US')
179}
180
181/** The top `count` slices, the rest folded into one "other" slice. */
182export function topWithOther(slices: Slice[], count: number): Slice[] {
183  if (slices.length <= count) return slices
184  const rest = slices.slice(count - 1).reduce((sum, slice) => sum + slice.value, 0)
185  return [...slices.slice(0, count - 1), { label: 'other', value: rest }]
186}
187
types/index.d.ts 34 lines
1/** What scanner/scan.py prints: aggregates of every session log on this machine. */
2export type Scan = {
3  generatedAt: number
4  files: number
5  parsed: number
6  seconds: number
7  /** [day, project, model, issue, input, output, cacheWrite, cacheRead, replies] */
8  tokens: [string, string, string, string, number, number, number, number, number][]
9  /** [day, project, issue, prompts] */
10  prompts: [string, string, string, number][]
11  /** [day, hour, tokens, prompts] */
12  hours: [string, number, number, number][]
13  /** [day, project], one per session */
14  sessions: [string, string][]
15}
16
17/** Linear issues you completed: id, completion day, estimate in points. */
18export type Closed = { id: string; day: string; points: number }
19
20export type Range = '7d' | '30d' | 'all'
21
22declare module 'claude-code' {
23  interface PluginState {
24    'usage-stats': {
25      scan: Scan | null
26      closed: Closed[] | null
27      isScanning: boolean
28      error: string | null
29      linearError: string | null
30      range: Range
31    }
32  }
33}
34