A GitHub-style grid of your Claude Code time in a /heatmap pane, with streaks, peaks and top projects.

<h1 align="center">Claude Code mods</h1>
<a href="https://www.npmjs.com/package/claude-code-mods"><img src="https://img.shields.io/npm/v/claude-code-mods.svg" alt="npm"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT"></a> <a href="https://github.com/hamzafer/claude-code-mods/actions/workflows/ci.yml"><img src="https://github.com/hamzafer/claude-code-mods/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
<a href="#-install">Install</a> · <a href="#-the-mods">All mods</a> · <a href="docs/mods.md">Docs</a> · <a href="https://claude.dev/blog/getting-started-with-claude-code-mods/">What are mods?</a>
<table> <tr> <td align="center" width="33%"><a href="docs/mods.md#-context-bar"><img src="images/context-bar.png" alt="context-bar: Claude Code context window usage as a stacked bar, a color per category" width="260"></a><br>📊 <b>context-bar</b><br>what fills your context</td> <td align="center" width="33%"><a href="docs/mods.md#-review-watch"><img src="images/review-watch.png" alt="review-watch: live lines for running Codex and subagent code reviews in Claude Code" width="260"></a><br>🔍 <b>review-watch</b><br>running code reviews, live</td> <td align="center" width="33%"><a href="docs/mods.md#-md-preview"><img src="images/md-preview.png" alt="md-preview: Markdown that Claude Code edits, rendered like GitHub next to the diff" width="260"></a><br>📝 <b>md-preview</b><br>Markdown rendered like GitHub</td> </tr> <tr> <td align="center" width="33%"><a href="docs/mods.md#-blast-radius"><img src="images/gallery/blast-radius.png" alt="blast-radius: a Claude Code hook holds rm -rf and lists the files it would delete" width="260"></a><br>💥 <b>blast-radius</b><br>see what <code>rm -rf</code> would delete</td> <td align="center" width="33%"><a href="docs/mods.md#-now-playing"><img src="images/now-playing.png" alt="now-playing: Spotify track, progress bar and synced lyrics inside Claude Code" width="260"></a><br>🎵 <b>now-playing</b><br>Spotify and its lyrics, live</td> <td align="center" width="33%"><a href="docs/mods.md#-reels-and-snake"><img src="images/reels-demo.gif" alt="reels: YouTube Shorts in a Claude Code pane while it works" width="260"></a><br>📱 <b>reels</b><br>Shorts while Claude works</td> </tr> <tr> <td align="center" width="33%"><a href="docs/mods.md#-where-am-i"><img src="images/gallery/where-am-i.png" alt="where-am-i: the session goal, current step and what waits on you, above the Claude Code prompt" width="260"></a><br>📍 <b>where-am-i</b><br>goal, now, waiting on you</td> <td align="center" width="33%"><a href="docs/mods.md#-lines-above-the-prompt"><img src="images/gallery/lines.png" alt="token-weather, usage-meter and other Claude Code status lines stacked above the prompt" width="260"></a><br>🌦️ <b>token-weather and friends</b><br>lines above the prompt</td> <td align="center" width="33%"><a href="#mission-control"><img src="images/gallery/mission-control.png" alt="mission-control: Claude Code subagents, tool calls and the files they touch, live" width="260"></a><br>🛰️ <b>mission-control</b><br>agents and the code they touch</td> </tr> </table>
npx claude-code-mods@latest
Tick the mods you want in a checklist, see what each one adds, and press Enter to install. Untick a mod to disable it (its settings stay). Restart Claude Code after. @latest makes npx skip an old cached copy.
--project installs for this project only--dry-run prints the claude commands instead of running them--pick a,b turns on exactly these mods, no checklistIt edits the Claude profile that claude uses here, shown at the top. For another profile, set it first: CLAUDE_CONFIG_DIR=~/.claude-other npx claude-code-mods@latest.
Press o in the checklist to order your bands (the lines above the prompt). Move the cursor with up/down or j/k, move a band with Shift+up/down or K/J, and press Enter to save. Esc goes back without saving. The order goes to <config dir>/claude-code-mods/bands.json, where the config dir is CLAUDE_CONFIG_DIR or ~/.claude. Restart Claude Code to apply it. Bands not in the file sit on top.
By hand:
claude plugin marketplace add hamzafer/claude-code-mods
claude plugin install context-bar@claude-code-mods
To try one without installing:
git clone https://github.com/hamzafer/claude-code-mods && cd claude-code-mods
claude --plugin-dir mods/context-bar
Needs Claude Code 2.1.287+. A few mods need more (Chrome,
gh, a connector); the tables say which.
| Mod | What it does | Command | |
|---|---|---|---|
| 🛰️ | mission-control | Live map of agents, tool calls and the code they touch | /mission |
| 📊 | context-bar | Your context window as one stacked bar, a color per category, with token counts and where it compacts | /context-bar |
| 🌦️ | token-weather | Context fill from Clear to Compact soon, plus a prompt-cache countdown | |
| ⏱️ | cache-clock | A prompt-cache line under your status line, from Claude Code's own figures: time left, hit rate and misses, and the tokens your next message re-caches once it goes cold. Needs Node and Claude Code 2.1.251+ | /cache-clock setup |
| 📍 | where-am-i | Goal, doing now, waiting on you, next step | /where |
| ➡️ | next-steps | 2 or 3 likely next prompts after each turn, one key to draft one. language sets their language | 1 2 3, 0 hides |
| 💰 | usage-meter | 5-hour and 7-day plan usage, the reset countdown and the session's cost | |
| 🔥 | heatmap | A GitHub-style grid of your Claude Code time, with your streak, peak hours and top projects | /heatmap |
| 💳 | openai-balance | Your OpenAI API credit: an estimated balance with a gauge, today's spend, where the money mostly went, and the last call. Needs an OpenAI organization Admin key | /openai-balance |
| 🪙 | openrouter-balance | Your OpenRouter credit: the real balance with a gauge, today's spend and the model the money mostly went to. A regular API key shows the balance too when OpenRouter allows it, plus its own spend and limit | /openrouter-balance |
| 📡 | agent-radar | One live line per running subagent | /radar |
| 🔍 | review-watch | One live line per running code review (Codex or a review subagent) with the model, target, elapsed time and Codex's latest output. A toast lists the findings when it ends | |
| 🌐 | browser-lanes | Whether this session has a browser, and who holds it | /browser |
| 🕌 | prayer-times | The current prayer and how long is left, the next one, and zawal. Computed on your computer, Hanafi or standard Asr | /prayers |
| 🎬 | replay-theater | Steps through the last turn's edits, one diff at a time | /replay |
| 📝 | md-preview | Renders the Markdown files Claude edits like GitHub does, with before and after side by side. Needs Chrome and a terminal that shows images | /md |
| Mod | What it does | Command | |
|---|---|---|---|
| 💥 | blast-radius | Holds rm -r, force pushes and migrations, shows what they'd delete, cancels after 60 s with no answer |
| Mod | What it does | Command | |
|---|---|---|---|
| 🔀 | switchboard | Picks the model for each subagent that doesn't name one, with OpenAI's Decisions API or Jev, from its short label only. Shows what every subagent cost | /route |
These are built around my own tools and rules. Fork them and change the rules to yours.
| Mod | What it does | Command | |
|---|---|---|---|
| 👀 | glance | One line with what needs you: next meeting, PRs, Linear issues, Slack DMs. Needs gh and the Google Calendar, Linear and Slack connectors | /glance |
| 🚦 | merge-gate | Holds gh pr merge until CI is green and Codex reviewed once. Needs gh and the Codex CLI. Reviews run on one fixed model; change it to yours | /gate |
| 📏 | rulebook-guard | Enforces my writing and git rules: rewrites em dashes, asks before --amend, unformatted pushes, emails and phone numbers in notes | |
| 💾 | session-saver | Saves where you left off, shows it on resume. Needs unpause | /park [note] |
| Mod | What it does | Command | |
|---|---|---|---|
| 📱 | reels | YouTube Shorts while Claude works, pauses when it's done | /reels |
| 🐍 | snake | Snake while Claude works | /snake |
| 🎵 | now-playing | What Spotify is playing, with a progress bar, the lyric being sung, and ⏮ ⏸ ⏭ buttons. Needs macOS and the Spotify app | /music |
Mods by other people, in their own marketplaces. They're not maintained here, so read the code before you install. They also show up last in the npx claude-code-mods@latest checklist.
| Mod | What it does | Install | |
|---|---|---|---|
| 🖼️ | image-view by @jarrodwatts | See the images you paste into Claude Code: thumbnails above the prompt instead of bare [Image #1] tags | claude plugin marketplace add jarrodwatts/claude-image-view then claude plugin install image-view@claude-image-view |
| 🏷️ | last-prompt-title by @zhangbububu | Session titles that follow your latest prompt: cut for the CLI, written by Haiku for the desktop sidebar | claude plugin marketplace add zhangbububu/session-titles then claude plugin install last-prompt-title@session-titles |
| 🌏 | cc-zh by @jiangnanquan | Simplified Chinese UI localization via static dictionary: slash-command descriptions, /config labels, spinner, hints, notices, command output, tool summaries and turn duration. Zero tokens / network; verified on CC 2.1.296 | claude plugin marketplace add jiangnanquan/claude-code-zh then claude plugin install cc-zh@claude-code-zh |
| 🎛️ | subagent-tuner by @Kruhlikau | Picks the model and effort of every subagent: a Manual/Auto band, a pick pane, review → Opus and search → Haiku, and a saved default that overrides what the agent asked for | claude plugin marketplace add Kruhlikau/subagent-tuner then claude plugin install subagent-tuner@subagent-tuner |
| 🛒 | mod-store by @hellosverre | An app store in a pane: /mods browses, searches and installs about 2,700 community mods, with what each one can access shown before you install. Or ask Claude to find one | claude plugin marketplace add hellosverre/mod-store then claude plugin install mod-store@mod-store |
| 📈 | cache-meter by @T-AI-Labs | The share of each turn the prompt cache served, next to the context fill, with an auto-compact threshold and Compact and Clear buttons | claude plugin marketplace add T-AI-Labs/claude-code-mods then claude plugin install cache-meter@T-AI-Labs |
| ⏳ | usage-limits by @T-AI-Labs | 5-hour and weekly plan limits in percent, with the time left until each resets | claude plugin marketplace add T-AI-Labs/claude-code-mods then claude plugin install usage-limits@T-AI-Labs |
Built one? Open a PR that adds a row.
<a id="mission-control"></a>
Every subagent, every tool call and every file they touch, in a pane next to the chat. Shown at 4x: two subagents building a logout feature across four files.

Install it like any mod, restart, and type /mission (or /mission code to open the code map). q closes it.
w shows the agents and every tool call, livec shows the code map, with import arrowsThe Code view also needs macOS, Google Chrome and a terminal that shows images (Ghostty, kitty, iTerm2). The Who view works everywhere.
hooks/register.tsx 280 lines1// Heatmap: a GitHub-style grid of your Claude Code time, in a /heatmap pane.
2// It keeps its own daily ledger, so the grid outlives Claude Code's log cleanup.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register } from 'claude-code'
5
6import type { GridProps } from './grid'
7import {
8 MILESTONES,
9 addDays,
10 aggregate,
11 buildView,
12 dayOf,
13 duration,
14 formatLedger,
15 hourLabel,
16 localOffset,
17 mergeLedger,
18 monthLabels,
19 parseLedger,
20 parseTimes,
21 projectName,
22 rampFor,
23 thousands,
24 weekdayName,
25 weeksFor,
26} from './heat'
27import type { Ramp, Row, SessionLog } from './heat'
28import type { Scope } from '../types'
29
30const PANE = 'heatmap'
31const GRID = 'grid'
32const TOASTED_KEY = 'toastedDay'
33const GREP_BATCH = 8 // logs read at once
34const BAR_COLUMNS = 12
35
36// Held by the host, so a hot reload keeps the pane's scope.
37const scope = atom({ plugin: 'heatmap', key: 'scope' } as const, 'all' as Scope)
38const version = atom({ plugin: 'heatmap', key: 'version' } as const, 0) // bumped when the ledger changes
39const isLoading = atom({ plugin: 'heatmap', key: 'isLoading' } as const, false)
40
41// The ledger as last read or written; a hot reload starts over and reads it again.
42let rows: Row[] | null = null
43let refreshing: Promise<void> | null = null
44let ramp: Ramp | null = null // from the person's theme, read once
45let failure: string | null = null // why the last refresh failed, shown while there are no rows
46
47export const register: Register = on => {
48 let here = '' // this session's project folder
49
50 on('session.start', async ($, e, next) => {
51 const r = await next(e)
52 here = projectFolder(await $.session.root())
53 await $.command.register({ name: 'heatmap', description: 'A GitHub-style grid of your Claude Code time' }).catch(() => {}) // a name Claude Code already has is refused: start anyway
54 void (async () => {
55 await refresh($)
56 await milestoneToast($, rows ?? [])
57 })()
58 return r
59 })
60
61 on('session.end', async ($, e, next) => {
62 const r = await next(e)
63 await refresh($)
64 return r
65 })
66
67 on('command.run', { command: 'heatmap' }, async $ => {
68 await update($, isLoading, () => rows === null)
69 await $.ui.open({ id: PANE, title: 'Heatmap', focus: true })
70 void refresh($).then(() => update($, isLoading, () => false)) // today stays current mid-session
71 return { text: 'Opened the heatmap' }
72 })
73
74 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
75 const { Box, Text, Button, Client } = $.ui.resolve(e)
76 await read($, version) // redraw when the ledger changes
77 const which = await read($, scope)
78 const close = <Button key="close" label="Close" hotkey="q" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
79
80 if (rows === null) {
81 if (failure) {
82 return (
83 <Box flexDirection="column">
84 <Text color="error">{`Could not read your session history: ${failure}`}</Text>
85 {close}
86 </Box>
87 )
88 }
89 if (!(await read($, isLoading))) void refresh($) // a hot reload dropped the rows
90 return (
91 <Box flexDirection="column">
92 <Text dimColor>Reading your session history… the first time takes a few seconds.</Text>
93 {close}
94 </Box>
95 )
96 }
97 if (e.surface !== 'terminal' && e.surface !== 'desktop') {
98 return <Text dimColor>The heatmap draws in the terminal or desktop app.</Text>
99 }
100
101 const today = dayOf(Date.now(), localOffset)
102 const weeks = weeksFor(e.props.bodyColumns)
103 const isHere = which === 'here'
104 const v = buildView(rows, { today, weeks, project: isHere ? here : undefined })
105 const hereName = rows.find(r => r.p === here)?.n ?? 'this repo'
106 const grid: GridProps = {
107 weeks,
108 start: v.start,
109 cells: v.days.map(d => [d.min, d.s, d.top]),
110 months: monthLabels(v.start, weeks),
111 labels: v.days.map(d => d.d),
112 ramp: (ramp ??= await themeRamp($)),
113 }
114 const toggle = () => update($, scope, s => (s === 'all' ? 'here' : 'all'))
115
116 const stats = [`🔥 ${v.streak.current}-day streak · best ${v.streak.best}`, `${hours(v.totalMin)} active · ${thousands(v.sessions)} session${v.sessions === 1 ? '' : 's'}`]
117 const peak = [v.peakDay === null ? null : weekdayName(v.peakDay), v.peakHour === null ? null : hourLabel(v.peakHour)].filter(Boolean).join(' · ')
118 const nameWidth = Math.min(16, Math.max(0, ...v.top.map(t => t.name.length)))
119
120 return (
121 <Box flexDirection="column">
122 <Text>
123 <Text bold color="claude">{'Heatmap'}</Text>
124 <Text>{` · ${isHere ? hereName : 'all projects'}`}</Text>
125 <Text dimColor>{` [p] ${isHere ? 'all projects' : 'this repo'}`}</Text>
126 </Text>
127 <Text> </Text>
128 <Client key={GRID} module="./grid.tsx" props={grid} width={Math.max(1, e.props.bodyColumns)} height={12} />
129 <Text> </Text>
130 <Text wrap="truncate-end">{stats.join(' ')}</Text>
131 {peak ? <Text dimColor>{`peak: ${peak}`}</Text> : null}
132 {v.top.length > 0 ? <Text> </Text> : null}
133 {v.top.map(t => (
134 <Text wrap="truncate-end">
135 <Text>{fit(t.name, nameWidth).padEnd(nameWidth + 1)}</Text>
136 <Text color="claude">{'■'.repeat(Math.max(1, Math.round(t.share * BAR_COLUMNS)))}</Text>
137 <Text dimColor>{` ${Math.round(t.share * 100)}% · ${duration(t.min)}`}</Text>
138 </Text>
139 ))}
140 <Box flexDirection="row" gap={1}>
141 <Button key="scope" label={isHere ? 'All projects' : 'This repo'} hotkey="p" onPress={toggle} />
142 {close}
143 </Box>
144 </Box>
145 )
146 })
147
148 // The grid posts its keys: p switches the scope, q closes.
149 on('ui.message', async ($, e) => {
150 if (e.element !== GRID) return {}
151 const data = e.data as { scope?: unknown; close?: unknown }
152 if (data?.scope === 'toggle') await update($, scope, s => (s === 'all' ? 'here' : 'all'))
153 if (data?.close === true) await $.ui.close({ id: PANE })
154 return {}
155 })
156}
157
158/** One refresh at a time; a second caller waits on the first. */
159function refresh($: EngineInterface): Promise<void> {
160 refreshing ??= (async () => {
161 try {
162 rows = await refreshLedger($)
163 failure = null
164 } catch (err) {
165 failure = String(err instanceof Error ? err.message : err)
166 $.ui.log(`heatmap: could not read the session logs: ${failure}`, { to: 'debug' })
167 } finally {
168 refreshing = null
169 await update($, version, v => v + 1)
170 }
171 })()
172 return refreshing
173}
174
175/** The ramp for the saved theme; Claude Code keeps it in .claude.json. Auto, or unreadable: the ramp that reads on both. */
176async function themeRamp($: EngineInterface): Promise<Ramp> {
177 try {
178 const custom = await $.env.get('CLAUDE_CONFIG_DIR')
179 const file = custom ? `${custom.replace(/\/+$/, '')}/.claude.json` : `${await $.env.get('HOME')}/.claude.json`
180 return rampFor((JSON.parse(await $.fs.read(file)) as { theme?: unknown }).theme)
181 } catch {
182 return 'auto'
183 }
184}
185
186/** <projects folder>/<this>: the project root with each other character a dash, as Claude Code names it. */
187function projectFolder(root: string) {
188 return root.replace(/[^a-zA-Z0-9]/g, '-')
189}
190
191/** 412h past ten hours, 3h 12m below. */
192function hours(min: number) {
193 return min >= 600 ? `${thousands(Math.round(min / 60))}h` : duration(min)
194}
195
196function fit(name: string, width: number) {
197 return name.length <= width ? name : `${name.slice(0, Math.max(0, width - 1))}…`
198}
199
200async function configDir($: EngineInterface) {
201 const custom = await $.env.get('CLAUDE_CONFIG_DIR')
202 if (custom) return custom.replace(/\/+$/, '')
203 const home = await $.env.get('HOME')
204 if (!home) throw new Error('HOME is not set, so the Claude config folder is unknown.')
205 return `${home}/.claude`
206}
207
208/**
209 * Reads the ledger and brings it up to date from the session logs. With no ledger
210 * (or an empty one) it backfills every day the logs hold. After that it replaces
211 * the days from the ledger's last day (yesterday at the latest) on, reading only
212 * logs touched since, so older days stay as they were.
213 */
214async function refreshLedger($: EngineInterface): Promise<Row[]> {
215 const dir = await configDir($)
216 const file = `${dir}/heatmap/days.jsonl`
217 const parsed = (await $.fs.exists(file)) ? parseLedger(await $.fs.read(file)) : []
218 const old = parsed.length > 0 ? parsed : null // an empty or broken ledger backfills again
219 const yesterday = addDays(dayOf(Date.now(), localOffset), -1)
220 const last = old ? old[old.length - 1]!.d : '' // rows are sorted by day
221 const fromDay = old === null ? '' : last < yesterday ? last : yesterday
222 const since = old === null ? 0 : Date.parse(`${addDays(fromDay, -1)}T00:00:00`) // local midnight, a day early for any clock shift
223
224 const logs = await readLogs($, `${dir}/projects`, since)
225 const merged = mergeLedger(old ?? [], aggregate(logs, localOffset, fromDay), fromDay)
226 await $.fs.write(file, formatLedger(merged))
227 return merged
228}
229
230/** The session logs touched since `since`: each one's project and message times, read with grep (logs can pass the 4 MiB read limit). */
231async function readLogs($: EngineInterface, projects: string, since: number): Promise<SessionLog[]> {
232 if (!(await $.fs.exists(projects))) return []
233 const jobs: { folder: string; path: string }[] = []
234 for (const folder of await $.fs.list(projects)) {
235 if (folder.kind !== 'dir') continue
236 const files = await $.fs.list(`${projects}/${folder.name}`).catch(() => [])
237 for (const f of files) {
238 if (f.kind === 'file' && f.name.endsWith('.jsonl') && f.mtimeMs >= since) jobs.push({ folder: folder.name, path: `${projects}/${folder.name}/${f.name}` })
239 }
240 }
241
242 const names = new Map<string, string>()
243 const logs: SessionLog[] = []
244 for (let i = 0; i < jobs.length; i += GREP_BATCH) {
245 await Promise.all(
246 jobs.slice(i, i + GREP_BATCH).map(async job => {
247 if (!names.has(job.folder)) {
248 const cwd = await firstCwd($, job.path)
249 if (cwd) names.set(job.folder, projectName(cwd, job.folder)) // a log without one leaves the next log to say
250 }
251 const r = await $.process.run(['grep', '-oE', '"timestamp": *"[0-9]{4}-[^"]*"', '--', job.path], { timeoutMs: 60_000 }).catch(() => null)
252 if (!r) return
253 if (r.isStdoutTruncated) $.ui.log(`heatmap: ${job.path} has more messages than one read holds; its latest are left out`, { to: 'debug' })
254 const times = parseTimes(r.stdout)
255 if (times.length > 0) logs.push({ p: job.folder, n: names.get(job.folder) ?? job.folder, times })
256 }),
257 )
258 }
259 return logs
260}
261
262async function firstCwd($: EngineInterface, path: string): Promise<string | null> {
263 const r = await $.process.run(['grep', '-m1', '-oE', '"cwd": *"[^"]*"', '--', path], { timeoutMs: 30_000 }).catch(() => null)
264 const m = r?.stdout.match(/"cwd": *"([^"]*)"/)
265 return m ? m[1]!.replace(/\\\\/g, '\\') : null
266}
267
268/** One toast per streak as it reaches 7, 30 or 100 days. */
269async function milestoneToast($: EngineInterface, rows: readonly Row[]) {
270 const today = dayOf(Date.now(), localOffset)
271 const { streak } = buildView(rows, { today, weeks: 1 })
272 if (!MILESTONES.includes(streak.current)) return
273 // The streak and the milestone together: the next day, before any activity, it still reads the same.
274 const last = rows.some(r => r.d === today && (r.s > 0 || r.min > 0)) ? today : addDays(today, -1)
275 const mark = `${addDays(last, 1 - streak.current)}:${streak.current}`
276 if ((await $.store.get(TOASTED_KEY).catch(() => undefined)) === mark) return
277 await $.store.set(TOASTED_KEY, mark)
278 $.ui.toast(`🔥 ${streak.current}-day Claude streak · /heatmap`)
279}
280hooks/grid.tsx 118 lines1// The grid itself: draws the weeks and moves a day cursor with the arrow keys.
2import type { ClientModule } from 'claude-code'
3
4import { LABEL_COLUMNS, dayLabel, duration, level, thousands } from './heat'
5import type { Ramp } from './heat'
6
7/** One day: active minutes, sessions, top project. */
8export type Cell = [number, number, string | null]
9
10export type GridProps = {
11 weeks: number
12 start: string // the Monday of the first week
13 cells: Cell[] // from start to today
14 months: string // the month row, already laid out
15 labels: string[] // one per day in order, for the detail line
16 ramp: Ramp // from the person's saved theme
17}
18
19type GridText = Parameters<ClientModule<GridProps, Local>>[1]['elements']['Text']
20type Local = { cursor: string | null; live: { props: GridProps } } // the day under the cursor; null is today
21
22// Two sizes and a ramp of Claude orange: a level never depends on telling colors apart alone.
23export const GLYPHS = ['·', '▪', '▪', '■', '■']
24const RAMPS: Record<Ramp, string[]> = {
25 dark: ['#5c3426', '#8f4a33', '#c4613f', '#f08a63'], // brighter is busier
26 light: ['#f5c9b6', '#ec9c79', '#d97757', '#a8492b'], // darker is busier
27 // One lightness, so each sits as far from a light background as from a dark one;
28 // only the color strengthens (from near gray to full orange), so busier reads busier on both.
29 auto: ['#8c807b', '#a5796a', '#c26b4d', '#dc5a2e'],
30}
31const ROW_LABELS = ['Mon', '', 'Wed', '', 'Fri', '', '']
32
33const Grid: ClientModule<GridProps, Local> = (props, surface) => {
34 const { Box, Text } = surface.elements
35
36 if (surface.state === undefined) {
37 surface.onKey(e => {
38 const now = surface.state
39 if (!now) return
40 const key = e.key.toLowerCase()
41 if (key === 'p') return surface.post({ scope: 'toggle' })
42 if (key === 'q') return surface.post({ close: true })
43 const step = key === 'up' ? -1 : key === 'down' ? 1 : key === 'left' ? -7 : key === 'right' ? 7 : 0
44 if (step === 0) return
45 const { labels } = now.live.props
46 const end = labels.length - 1
47 const cursor = Math.max(0, Math.min(end, indexOf(labels, now.cursor) + step))
48 const day = cursor === end ? null : labels[cursor]!
49 if (day !== now.cursor) surface.setState({ ...now, cursor: day })
50 })
51 surface.setState({ cursor: null, live: { props } }) // starts on today
52 }
53
54 const local = surface.state ?? { cursor: null, live: { props } }
55 local.live.props = props // the keys read the latest props
56 const cursor = indexOf(props.labels, local.cursor)
57 const at = props.cells[cursor]
58
59 return (
60 <Box flexDirection="column">
61 <Text dimColor>{' '.repeat(LABEL_COLUMNS) + props.months}</Text>
62 {ROW_LABELS.map((label, row) => (
63 <Text>
64 <Text dimColor>{label.padEnd(LABEL_COLUMNS)}</Text>
65 {Array.from({ length: props.weeks }, (_, w) => {
66 const i = w * 7 + row
67 const cell = props.cells[i]
68 if (!cell) return <Text>{' '}</Text>
69 const lv = level(cell[0], cell[1])
70 return (
71 <Text>
72 {square(Text, lv, i === cursor, props.ramp)}
73 {' '}
74 </Text>
75 )
76 })}
77 </Text>
78 ))}
79 <Text>
80 <Text dimColor>{`${' '.repeat(LABEL_COLUMNS)}less `}</Text>
81 {GLYPHS.map((_, lv) => (
82 <Text>
83 {square(Text, lv, false, props.ramp)}{' '}
84 </Text>
85 ))}
86 <Text dimColor>more</Text>
87 </Text>
88 <Text> </Text>
89 <Text wrap="truncate-end">{at ? detail(props.labels[cursor] ?? '', at) : ''}</Text>
90 <Text dimColor wrap="truncate-end">click the grid, then ←↑↓→ move · p scope · q close · Esc</Text>
91 </Box>
92 )
93}
94
95export default Grid
96
97function square(Text: GridText, level: number, isCursor: boolean, ramp: Ramp) {
98 const color = level === 0 ? 'subtle' : RAMPS[ramp][level - 1]!
99 return (
100 <Text color={color} inverse={isCursor}>
101 {GLYPHS[level]}
102 </Text>
103 )
104}
105
106// The cursor's place in the grid: its day, or today when the day is gone or unset.
107function indexOf(labels: string[], day: string | null) {
108 const i = day === null ? -1 : labels.indexOf(day)
109 return i < 0 ? labels.length - 1 : i
110}
111
112function detail(day: string, [min, s, top]: Cell) {
113 if (s === 0 && min === 0) return `${dayLabel(day)} · no activity`
114 const parts = [dayLabel(day), duration(min), `${thousands(s)} session${s === 1 ? '' : 's'}`]
115 if (top) parts.push(`top: ${top}`)
116 return parts.join(' · ')
117}
118hooks/heat.ts 343 lines1// The heatmap's pure parts: active minutes, the ledger, and what the pane shows.
2// No `$` here, so every rule is tested without an engine.
3
4/** One ledger row: a project's activity on one local day. */
5export type Row = {
6 d: string // the local day, YYYY-MM-DD
7 p: string // the project's folder under projects/ (its root, each other character a dash)
8 n: string // the project's display name, the last part of its root
9 min: number // active minutes
10 s: number // sessions active that day
11 h: number[] // active minutes per local hour, 24 of them
12}
13
14/** One session log: its project and the times of its messages, in ms. */
15export type SessionLog = { p: string; n: string; times: number[] }
16
17/** Minutes UTC is ahead of local time at an instant, as Date#getTimezoneOffset says it. */
18export type Offset = (ms: number) => number
19
20export const GAP_MS = 5 * 60_000 // a pause longer than this adds nothing
21export const MAX_WEEKS = 52
22export const LABEL_COLUMNS = 4 // "Mon " before each grid row
23export const MILESTONES = [7, 30, 100]
24
25const DAY_MS = 86_400_000
26const HOUR_MS = 3_600_000
27const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
28const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
29
30export const localOffset: Offset = ms => new Date(ms).getTimezoneOffset()
31
32/** The local day of an instant, YYYY-MM-DD. */
33export function dayOf(ms: number, offset: Offset): string {
34 return new Date(ms - offset(ms) * 60_000).toISOString().slice(0, 10)
35}
36
37/** Adds whole days to a YYYY-MM-DD day. */
38export function addDays(day: string, n: number): string {
39 return new Date(Date.parse(`${day}T00:00:00Z`) + n * DAY_MS).toISOString().slice(0, 10)
40}
41
42/** Monday is 0, Sunday 6. */
43export function weekday(day: string): number {
44 return (new Date(`${day}T00:00:00Z`).getUTCDay() + 6) % 7
45}
46
47type DayTotals = { ms: number; hours: number[] }
48
49/**
50 * One session's activity per local day: the time between messages, where a pause
51 * over GAP_MS adds nothing, split at local midnight and at each local hour.
52 * A day with a message and no counted time is still there, with 0 ms.
53 */
54export function sessionDays(times: readonly number[], offset: Offset): Map<string, DayTotals> {
55 const sorted = [...times].sort((a, b) => a - b)
56 const days = new Map<string, DayTotals>()
57 const at = (day: string) => {
58 let t = days.get(day)
59 if (!t) days.set(day, (t = { ms: 0, hours: new Array<number>(24).fill(0) }))
60 return t
61 }
62 for (const ms of sorted) at(dayOf(ms, offset))
63 for (let i = 1; i < sorted.length; i++) {
64 const a = sorted[i - 1]!
65 const b = sorted[i]!
66 if (b - a > GAP_MS) continue
67 let cur = a
68 while (cur < b) {
69 const local = cur - offset(cur) * 60_000
70 const end = Math.min(b, cur + (Math.floor(local / HOUR_MS) + 1) * HOUR_MS - local) // the next local hour
71 const t = at(new Date(local).toISOString().slice(0, 10))
72 t.ms += end - cur
73 t.hours[new Date(local).getUTCHours()]! += end - cur
74 cur = end
75 }
76 }
77 return days
78}
79
80/**
81 * Ledger rows from session logs: one per day per project, from `fromDay` on.
82 * A resumed session's log copies the earlier messages, so within a project each
83 * message time counts once, for the log that has it first.
84 */
85export function aggregate(logs: readonly SessionLog[], offset: Offset, fromDay = ''): Row[] {
86 const acc = new Map<string, { p: string; n: string; d: string; ms: number; s: number; hours: number[] }>()
87 const seen = new Map<string, Set<number>>()
88 // Oldest log first; on a tie the shorter one, the original a resume copied from.
89 const order = logs.map(log => ({ log, first: earliest(log.times) })).sort((a, b) => a.first - b.first || a.log.times.length - b.log.times.length)
90 for (const { log } of order) {
91 let had = seen.get(log.p)
92 if (!had) seen.set(log.p, (had = new Set()))
93 const own = log.times.filter(ms => !had!.has(ms))
94 own.forEach(ms => had!.add(ms))
95 for (const [d, t] of sessionDays(own, offset)) {
96 if (d < fromDay) continue
97 const key = `${d}\u0000${log.p}`
98 let r = acc.get(key)
99 if (!r) acc.set(key, (r = { p: log.p, n: log.n, d, ms: 0, s: 0, hours: new Array<number>(24).fill(0) }))
100 r.ms += t.ms
101 r.s += 1
102 t.hours.forEach((ms, h) => (r!.hours[h]! += ms))
103 }
104 }
105 return sortRows(
106 [...acc.values()].map(r => ({ d: r.d, p: r.p, n: r.n, min: Math.round(r.ms / 60_000), s: r.s, h: r.hours.map(ms => Math.round(ms / 60_000)) })),
107 )
108}
109
110function earliest(times: readonly number[]): number {
111 let min = Infinity
112 for (const ms of times) if (ms < min) min = ms // no spread: a long log passes the argument limit
113 return min
114}
115
116/**
117 * The ledger brought up to date: from `fromDay` on, a fresh row replaces the old
118 * one for its day and project. Older days, and old rows nothing fresh replaces
119 * (their logs may be gone), stay as they were.
120 */
121export function mergeLedger(old: readonly Row[], fresh: readonly Row[], fromDay: string): Row[] {
122 const recent = fresh.filter(r => r.d >= fromDay)
123 const replaced = new Set(recent.map(r => `${r.d}\u0000${r.p}`))
124 return sortRows([...old.filter(r => !replaced.has(`${r.d}\u0000${r.p}`)), ...recent])
125}
126
127/** Reads the ledger file; a malformed line is skipped. */
128export function parseLedger(text: string): Row[] {
129 const rows: Row[] = []
130 for (const line of text.split('\n')) {
131 if (!line.trim()) continue
132 try {
133 const r = JSON.parse(line) as Partial<Row>
134 if (typeof r.d !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(r.d) || typeof r.p !== 'string') continue
135 if (typeof r.min !== 'number' || typeof r.s !== 'number') continue
136 const h = Array.isArray(r.h) && r.h.length === 24 && r.h.every(x => typeof x === 'number') ? r.h : new Array<number>(24).fill(0)
137 rows.push({ d: r.d, p: r.p, n: typeof r.n === 'string' && r.n ? r.n : r.p, min: r.min, s: r.s, h })
138 } catch {
139 // a broken line costs that line only
140 }
141 }
142 return rows
143}
144
145export function formatLedger(rows: readonly Row[]): string {
146 return rows.map(r => JSON.stringify(r)).join('\n') + (rows.length ? '\n' : '')
147}
148
149function sortRows(rows: Row[]): Row[] {
150 return rows.sort((a, b) => (a.d < b.d ? -1 : a.d > b.d ? 1 : a.p < b.p ? -1 : a.p > b.p ? 1 : 0))
151}
152
153/** How many weeks fit a pane this wide: two columns a week after the labels, up to MAX_WEEKS. */
154export function weeksFor(columns: number): number {
155 return Math.max(1, Math.min(MAX_WEEKS, Math.floor((columns - LABEL_COLUMNS) / 2)))
156}
157
158/** 0 for no activity, then 1 to 4 for under 15 minutes, under an hour, under 3 hours, 3 hours and more. */
159export function level(min: number, s: number): number {
160 if (s <= 0 && min <= 0) return 0
161 return min < 15 ? 1 : min < 60 ? 2 : min < 180 ? 3 : 4
162}
163
164export type Day = { d: string; min: number; s: number; top: string | null }
165
166export type View = {
167 start: string // the Monday the grid starts on
168 weeks: number
169 today: string
170 days: Day[] // from start to today, one per day
171 streak: { current: number; best: number }
172 totalMin: number
173 sessions: number
174 peakDay: number | null // Monday is 0
175 peakHour: number | null
176 top: { name: string; min: number; share: number }[] // all projects only
177}
178
179/**
180 * What the pane shows for one scope: `project` set keeps that project's rows alone.
181 * Streaks count over every day in the ledger; the rest over the weeks shown.
182 */
183export function buildView(rows: readonly Row[], opts: { today: string; weeks: number; project?: string }): View {
184 const scoped = opts.project === undefined ? rows : rows.filter(r => r.p === opts.project)
185 const start = addDays(opts.today, -weekday(opts.today) - (opts.weeks - 1) * 7)
186
187 const byDay = new Map<string, { min: number; s: number; projects: Map<string, number> }>()
188 for (const r of scoped) {
189 let t = byDay.get(r.d)
190 if (!t) byDay.set(r.d, (t = { min: 0, s: 0, projects: new Map() }))
191 t.min += r.min
192 t.s += r.s
193 t.projects.set(r.n, (t.projects.get(r.n) ?? 0) + r.min)
194 }
195
196 const days: Day[] = []
197 const weekdayMin = new Array<number>(7).fill(0)
198 const hourMin = new Array<number>(24).fill(0)
199 const projectMin = new Map<string, number>()
200 let totalMin = 0
201 let sessions = 0
202 for (let d = start; d <= opts.today; d = addDays(d, 1)) {
203 const t = byDay.get(d)
204 days.push({ d, min: t?.min ?? 0, s: t?.s ?? 0, top: t ? topOf(t.projects) : null })
205 if (!t) continue
206 totalMin += t.min
207 sessions += t.s
208 weekdayMin[weekday(d)]! += t.min
209 }
210 for (const r of scoped) {
211 if (r.d < start || r.d > opts.today) continue
212 r.h.forEach((m, h) => (hourMin[h]! += m))
213 projectMin.set(r.n, (projectMin.get(r.n) ?? 0) + r.min)
214 }
215
216 const top =
217 opts.project === undefined && totalMin > 0
218 ? [...projectMin.entries()]
219 .filter(([, min]) => min > 0)
220 .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
221 .slice(0, 3)
222 .map(([name, min]) => ({ name, min, share: min / totalMin }))
223 : []
224
225 return {
226 start,
227 weeks: opts.weeks,
228 today: opts.today,
229 days,
230 streak: streaks(new Set([...byDay.entries()].filter(([, t]) => t.s > 0 || t.min > 0).map(([d]) => d)), opts.today),
231 totalMin,
232 sessions,
233 peakDay: argmax(weekdayMin),
234 peakHour: argmax(hourMin),
235 top,
236 }
237}
238
239/**
240 * The current streak runs back from today, or from yesterday while today has no
241 * activity yet; the best is the longest run of active days anywhere.
242 */
243export function streaks(active: ReadonlySet<string>, today: string): { current: number; best: number } {
244 let current = 0
245 for (let d = active.has(today) ? today : addDays(today, -1); active.has(d); d = addDays(d, -1)) current++
246 let best = 0
247 for (const d of active) {
248 if (active.has(addDays(d, -1))) continue // not the start of a run
249 let n = 0
250 for (let x = d; active.has(x); x = addDays(x, 1)) n++
251 best = Math.max(best, n)
252 }
253 return { current, best: Math.max(best, current) }
254}
255
256function topOf(projects: Map<string, number>): string | null {
257 let best: string | null = null
258 let most = -1
259 for (const [name, min] of projects) {
260 if (min > most || (min === most && best !== null && name < best)) {
261 best = name
262 most = min
263 }
264 }
265 return best
266}
267
268function argmax(xs: readonly number[]): number | null {
269 let at: number | null = null
270 xs.forEach((x, i) => {
271 if (x > 0 && (at === null || x > xs[at]!)) at = i
272 })
273 return at
274}
275
276/** The month names over the grid: each sits over the first week holding the 1st of its month, when there is room. */
277export function monthLabels(start: string, weeks: number): string {
278 let out = ''
279 for (let w = 0; w < weeks; w++) {
280 const monday = addDays(start, w * 7)
281 const hasFirst = w === 0 ? false : Array.from({ length: 7 }, (_, i) => addDays(monday, i)).some(d => d.endsWith('-01'))
282 if (!hasFirst || out.length > w * 2) continue
283 const sunday = addDays(monday, 6)
284 const name = MONTHS[Number(sunday.slice(5, 7)) - 1]!
285 out = out.padEnd(w * 2) + name
286 }
287 return out.slice(0, weeks * 2)
288}
289
290export type Ramp = 'dark' | 'light' | 'auto'
291
292/**
293 * The color ramp for a saved theme (`dark…` or `light…`). Auto, or a theme we
294 * can't read, says nothing about the background, so it gets the ramp that reads
295 * the same on both.
296 */
297export function rampFor(theme: unknown): Ramp {
298 if (typeof theme !== 'string') return 'auto'
299 return theme.startsWith('light') ? 'light' : theme.startsWith('dark') ? 'dark' : 'auto'
300}
301
302/** 3h 12m, 47m, 0m. */
303export function duration(min: number): string {
304 const h = Math.floor(min / 60)
305 const m = min % 60
306 return h === 0 ? `${m}m` : m === 0 ? `${h}h` : `${h}h ${m}m`
307}
308
309/** Tue 14 Oct, from fixed names so every machine says the same. */
310export function dayLabel(day: string): string {
311 return `${WEEKDAYS[weekday(day)]} ${Number(day.slice(8, 10))} ${MONTHS[Number(day.slice(5, 7)) - 1]}`
312}
313
314export function weekdayName(i: number): string {
315 return WEEKDAYS[i]!
316}
317
318/** 22:00 */
319export function hourLabel(h: number): string {
320 return `${String(h).padStart(2, '0')}:00`
321}
322
323/** 1,204 */
324export function thousands(n: number): string {
325 return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
326}
327
328/** The last part of a project's root, or its folder name when there is no root. */
329export function projectName(cwd: string | null, folder: string): string {
330 const parts = (cwd ?? '').split(/[\\/]/).filter(Boolean)
331 return parts[parts.length - 1] ?? folder
332}
333
334/** Message times from `grep -o '"timestamp":"…"'` output. */
335export function parseTimes(grepOutput: string): number[] {
336 const out: number[] = []
337 for (const m of grepOutput.matchAll(/"timestamp": *"([^"]+)"/g)) {
338 const ms = Date.parse(m[1]!)
339 if (Number.isFinite(ms)) out.push(ms)
340 }
341 return out
342}
343types/index.d.ts 8 lines1export type Scope = 'all' | 'here'
2
3declare module 'claude-code' {
4 interface PluginState {
5 'heatmap': { scope: Scope; version: number; isLoading: boolean }
6 }
7}
8