SLOPSHOPPER

grass-tracker

GitHub-style contribution grid of your Claude Code token usage: a 24-hour bar above the prompt and /grass for the calendar and weekday × hour heatmap

newpanebandcommand
v0.1.0MITupdated 2026-10-15nakamura226/grass-tracker
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · grass-tracker
│ ┃ grass ✕ › fix the failing auth test and add an audit log call │ ┃ 98.9k tokens in the last 6 months · current │ ┃ streak 1d · longest 1d ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ May Jun Jul Aug Sep ⏺ Update(src/auth.ts) │ ┃ Oct ⎿ Added 2 lines, removed 1 line │ ┃ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ⏺ Bash(bun test) │ ┃ ■ ⎿ 3 pass, 1 fail │ ┃ Mon ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ │ ┃ ■ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ │ ┃ ■ ✻ Worked for 42s · done 4:20 PM │ ┃ Wed ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ │ ┃ ■ › /grass │ ┃ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ │ ┃ ■ │ ┃ Fri ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ │ ┃ ■ │ ┃ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ │ ┃ ■ │ ┃ │ ┃ 0 3 6 9 12 15 18 │ ┃ 21 │ ┃ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ │ ┃ ■ ■ │ ┃ Mon ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■■■■■■■■■■■■■■■■■■■■■■■■ 98.9k tokens ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
■■■■■■■■■■■■■■■■■■■■■■■■ 98.9k tokens
Pane · grass
98.9k tokens in the last 6 months · current streak 1d · longest 1d May Jun Jul Aug Sep Oct ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ Mon ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ Wed ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ Fri ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ 0 3 6 9 12 15 18 21 ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ Mon ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ Wed ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ Fri ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ Less ■ ■ ■ ■ ■ More · Esc to close
README

grass-tracker

A Claude Code mod that shows your token usage as a GitHub-style contribution grid ("草").

  • Above the prompt: one row for the last 24 hours, one cell per hour, with the 24-hour total.
  • /grass: a pane with the calendar (whole months that fit the width, up to a year), a weekday × hour heatmap, the total, and current / longest streaks. Esc closes it.

Each finished turn adds its tokens (input, output, and prompt-cache reads and writes) to ~/.claude/grass-history.json, by date and hour. Only sessions with the mod installed are counted.

Levels follow GitHub's approach: quartiles of your own non-zero values, so one heavy day does not wash out the rest. Colors switch between GitHub's dark and light palettes with your Claude Code theme.

Install

At the prompt of a Claude Code terminal session:

/plugin install grass-tracker --marketplace nakamura226/grass-tracker

Answer y to add the marketplace, then pick a scope (user scope is first; press Enter).

From a shell:

claude plugin install grass-tracker --marketplace nakamura226/grass-tracker

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with nakamura226/grass-tracker, then Add plugin.

Notes

  • The VS Code extension does not draw mod UI yet; use Claude Code in a terminal (the VS Code integrated terminal works).
  • Data stays on your machine in ~/.claude/grass-history.json. Delete that file to reset.

License

MIT

Source 3 files
hooks/register.tsx 194 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GrassHistory } from '../types'
5
6import { addDays, asUsage, compact, dateKey, dayTotal, hourTotal, leveler, pad, streaks } from './grass'
7import type { Level } from './grass'
8
9const history = atom({ plugin: 'grass-tracker', key: 'history' } as const, {} as GrassHistory)
10const isLight = atom({ plugin: 'grass-tracker', key: 'isLight' } as const, false)
11
12const PANE = 'grass'
13
14const PALETTE = {
15  dark: ['subtle', '#0e4429', '#006d32', '#26a641', '#39d353'],
16  light: ['subtle', '#9be9a8', '#40c463', '#30a14e', '#216e39'],
17} as const
18
19// neighbouring cells of one color become one Text to keep the tree small
20const runs = (levels: Level[], colors: readonly string[], cell: string) => {
21  const out: { color: string; text: string }[] = []
22  for (const l of levels) {
23    const color = colors[l]!
24    const last = out[out.length - 1]
25    if (last && last.color === color) last.text += cell
26    else out.push({ color, text: cell })
27  }
28  return out
29}
30
31const WEEKDAY_LABELS = ['    ', 'Mon ', '    ', 'Wed ', '    ', 'Fri ', '    ']
32const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
33
34async function loadTheme($: EngineInterface) {
35  const row = (await $.config.list()).find(r => r.key === 'theme')
36  const light = /light/i.test(String(row?.value ?? ''))
37  await update($, isLight, () => light)
38}
39
40export const register: Register = on => {
41  on('session.start', async ($, e, next) => {
42    const path = `${await $.env.get('HOME')}/.claude/grass-history.json`
43    const saved = await $.fs.read(path).then(t => JSON.parse(t) as GrassHistory).catch(() => null)
44    if (saved) await update($, history, () => saved)
45    await loadTheme($).catch(() => {})
46    await $.command.register({ name: 'grass', description: 'Show token usage calendar and weekday × hour heatmap' })
47    return next(e)
48  })
49
50  on('config.set', { key: 'theme' }, async ($, e, next) => {
51    const result = await next(e)
52    await loadTheme($).catch(() => {})
53    return result
54  })
55
56  on('command.run', { command: 'grass' }, async $ => {
57    await $.ui.open({ id: PANE, title: 'grass' })
58    return {}
59  })
60
61  on('turn.complete', async ($, e, next) => {
62    const result = await next(e)
63    const usage = e.usage
64    if (!usage) return result
65
66    const io = usage.input_tokens + usage.output_tokens
67    const cache = (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0)
68    const now = new Date(await $.clock.now())
69    const day = dateKey(now)
70    const hour = pad(now.getHours())
71
72    const saved = await update($, history, h => {
73      const prev = asUsage(h[day]?.[hour] ?? { io: 0, cache: 0 })
74      return { ...h, [day]: { ...h[day], [hour]: { io: prev.io + io, cache: prev.cache + cache } } }
75    })
76    const path = `${await $.env.get('HOME')}/.claude/grass-history.json`
77    await $.fs.write(path, JSON.stringify(saved, null, 2)).catch(() => {})
78    return result
79  })
80
81  // one row above the prompt: the last 24 hours, one cell per hour, oldest left
82  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
83    if (e.props.hasSurvey) return next(e)
84
85    const { Box, Text } = $.ui.resolve(e)
86    const saved = await read($, history)
87    const colors = PALETTE[(await read($, isLight)) ? 'light' : 'dark']
88    const now = new Date(await $.clock.now())
89
90    const hours = Array.from({ length: 24 }, (_, i) => new Date(now.getTime() - (23 - i) * 3_600_000))
91    const values = hours.map(d => hourTotal(saved, d, d.getHours()))
92
93    // levels follow every recorded hour of the past year, so a quiet day reads as quiet
94    const yearHours: number[] = []
95    for (let d = addDays(now, -364); d <= now; d = addDays(d, 1)) {
96      for (let hr = 0; hr < 24; hr++) yearHours.push(hourTotal(saved, d, hr))
97    }
98    const level = leveler(yearHours)
99    const sum = values.reduce((a, b) => a + b, 0)
100
101    return (
102      <Box>
103        {runs(values.map(level), colors, '■').map((r, i) => (
104          <Text key={String(i)} color={r.color}>{r.text}</Text>
105        ))}
106        <Text dimColor> {compact(sum)} tokens</Text>
107      </Box>
108    )
109  })
110
111  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
112    const { Box, Text } = $.ui.resolve(e)
113    const saved = await read($, history)
114    const colors = PALETTE[(await read($, isLight)) ? 'light' : 'dark']
115    const today = new Date(await $.clock.now())
116    const width = e.props.bodyColumns
117
118    // whole months, newest last, as many as fit the width in one block (no wrapping), up to 12;
119    // every figure in the pane covers this same span
120    const maxWeeks = Math.max(1, Math.floor((width - 4) / 2))
121    const lastSunday = addDays(today, -today.getDay())
122    const weeksFrom = (start: Date) => Math.round((lastSunday.getTime() - addDays(start, -start.getDay()).getTime()) / (7 * 86_400_000)) + 1
123    let months = 12
124    while (months > 1 && weeksFrom(new Date(today.getFullYear(), today.getMonth() - (months - 1), 1)) > maxWeeks) months -= 1
125    const start = new Date(today.getFullYear(), today.getMonth() - (months - 1), 1)
126    const weeks = weeksFrom(start)
127    const firstSunday = addDays(start, -start.getDay())
128
129    const cal: (Date | null)[][] = Array.from({ length: 7 }, (_, wd) =>
130      Array.from({ length: weeks }, (_, w) => {
131        const d = addDays(firstSunday, w * 7 + wd)
132        return d < start || d > today ? null : d
133      }),
134    )
135    const calValues = cal.flat().filter((d): d is Date => d !== null).map(d => dayTotal(saved, d))
136    const calLevel = leveler(calValues)
137
138    let monthRow = '    '
139    for (let w = 0; w < weeks; w++) {
140      const d = addDays(firstSunday, w * 7 + 6)
141      const isNewMonth = w === 0 || addDays(d, -7).getMonth() !== d.getMonth()
142      if (monthRow.length <= 4 + w * 2 && isNewMonth) monthRow = monthRow.padEnd(4 + w * 2) + MONTHS[d.getMonth()]
143    }
144
145    const punch = Array.from({ length: 7 }, () => Array<number>(24).fill(0))
146    for (let d = start; d <= today; d = addDays(d, 1)) {
147      for (let hr = 0; hr < 24; hr++) punch[d.getDay()]![hr]! += hourTotal(saved, d, hr)
148    }
149    const punchLevel = leveler(punch.flat())
150    // always starts at 0:00; late hours are dropped first when the pane is narrow
151    const hoursShown = Math.max(1, Math.min(24, Math.floor((width - 4) / 2)))
152
153    const { current, longest } = streaks(saved, today, start)
154    const total = calValues.reduce((a, b) => a + b, 0)
155    const span = months === 12 ? 'the last year' : months === 1 ? 'this month' : `the last ${months} months`
156
157    // null cells before the span stay blank so the columns line up; nulls after today are dropped
158    const row = (label: string, cells: (Level | null)[], key: string) => {
159      const lead = cells.findIndex(c => c !== null)
160      const levels = (lead < 0 ? [] : cells.slice(lead)).filter((c): c is Level => c !== null)
161      return (
162        <Box key={key}>
163          <Text dimColor>{label}{'  '.repeat(Math.max(0, lead))}</Text>
164          {runs(levels, colors, '■ ').map((r, i) => (
165            <Text key={String(i)} color={r.color}>{r.text}</Text>
166          ))}
167        </Box>
168      )
169    }
170
171    return (
172      <Box flexDirection="column">
173        <Text>
174          {compact(total)} tokens in {span} · current streak {current}d · longest {longest}d
175        </Text>
176        <Text> </Text>
177        <Text dimColor>{monthRow}</Text>
178        {cal.map((week, wd) => row(WEEKDAY_LABELS[wd]!, week.map(d => (d ? calLevel(dayTotal(saved, d)) : null)), `c${wd}`))}
179        <Text> </Text>
180        <Text dimColor>{'    0     3     6     9     12    15    18    21'.slice(0, 4 + hoursShown * 2).trimEnd()}</Text>
181        {punch.map((hours, wd) => row(WEEKDAY_LABELS[wd]!, hours.slice(0, hoursShown).map(punchLevel), `p${wd}`))}
182        <Text> </Text>
183        <Box>
184          <Text dimColor>Less </Text>
185          {runs([0, 1, 2, 3, 4], colors, '■ ').map((r, i) => (
186            <Text key={String(i)} color={r.color}>{r.text}</Text>
187          ))}
188          <Text dimColor>More · Esc to close</Text>
189        </Box>
190      </Box>
191    )
192  })
193}
194
hooks/grass.ts 60 lines
1import type { GrassHistory, HourUsage } from '../types'
2
3export type Level = 0 | 1 | 2 | 3 | 4
4
5export const pad = (n: number) => String(n).padStart(2, '0')
6export const dateKey = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
7export const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n)
8
9// older files stored a bare number per hour
10export const asUsage = (v: HourUsage | number): HourUsage => (typeof v === 'number' ? { io: v, cache: 0 } : v)
11export const tokens = (v: HourUsage | number | undefined) => (v === undefined ? 0 : asUsage(v).io + asUsage(v).cache)
12
13export const hourTotal = (h: GrassHistory, d: Date, hour: number) => tokens(h[dateKey(d)]?.[pad(hour)])
14export const dayTotal = (h: GrassHistory, d: Date) =>
15  Object.values(h[dateKey(d)] ?? {}).reduce((s, v) => s + tokens(v), 0)
16
17export const quantile = (sorted: number[], q: number) => {
18  const pos = (sorted.length - 1) * q
19  const lo = Math.floor(pos)
20  return sorted[lo]! + (sorted[Math.ceil(pos)]! - sorted[lo]!) * (pos - lo)
21}
22
23// GitHub-style: thresholds are the quartiles of the non-zero values, so one huge day
24// does not wash the rest out; with fewer than four samples, fall back to a share of the max
25export const leveler = (values: number[]) => {
26  const nonZero = values.filter(v => v > 0).sort((a, b) => a - b)
27  if (nonZero.length < 4) {
28    const max = nonZero[nonZero.length - 1] ?? 0
29    return (v: number): Level => (v <= 0 ? 0 : (Math.min(4, Math.ceil((v / max) * 4)) as Level))
30  }
31  const [q1, q2, q3] = [0.25, 0.5, 0.75].map(q => quantile(nonZero, q)) as [number, number, number]
32  return (v: number): Level => (v <= 0 ? 0 : v <= q1 ? 1 : v <= q2 ? 2 : v <= q3 ? 3 : 4)
33}
34
35export const streaks = (h: GrassHistory, today: Date, from: Date) => {
36  const days = Object.keys(h)
37    .filter(k => k >= dateKey(from) && Object.values(h[k]!).some(v => tokens(v) > 0))
38    .sort()
39  const active = new Set(days)
40  let longest = 0
41  let run = 0
42  let prev: string | undefined
43  for (const k of days) {
44    const [y, m, d] = k.split('-').map(Number)
45    run = prev === dateKey(addDays(new Date(y!, m! - 1, d!), -1)) ? run + 1 : 1
46    longest = Math.max(longest, run)
47    prev = k
48  }
49  let current = 0
50  let d = active.has(dateKey(today)) ? today : addDays(today, -1)
51  while (active.has(dateKey(d))) {
52    current += 1
53    d = addDays(d, -1)
54  }
55  return { current, longest }
56}
57
58export const compact = (n: number) =>
59  n >= 1e9 ? `${(n / 1e9).toFixed(1)}B` : n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n)
60
types/index.d.ts 9 lines
1export type HourUsage = { io: number; cache: number }
2export type GrassHistory = Record<string, Record<string, HourUsage>>
3
4declare module 'claude-code' {
5  interface PluginState {
6    'grass-tracker': { history: GrassHistory; isLight: boolean }
7  }
8}
9