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

A Claude Code mod that shows your token usage as a GitHub-style contribution grid ("草").
/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.
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.
~/.claude/grass-history.json. Delete that file to reset.MIT
hooks/register.tsx 194 lines1import { 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}
194hooks/grass.ts 60 lines1import 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)
60types/index.d.ts 9 lines1export 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