A live forecast of the context window, with a prompt-cache countdown, drawn above the prompt.

<h1 align="center">Claude Code mods</h1>
<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>
Add the marketplace once, then install any mod by name:
claude plugin marketplace add hamzafer/claude-code-mods
claude plugin install context-bar@claude-code-mods
Or install the general-purpose set in one go:
for m in context-bar token-weather usage-meter where-am-i next-steps agent-radar review-watch replay-theater md-preview blast-radius mission-control; do
claude plugin install "$m@claude-code-mods"
done
Restart Claude Code after installing. 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 | 1 2 3, 0 hides |
| 💰 | usage-meter | 5-hour and 7-day plan usage, the reset countdown and the session's cost | |
| 💳 | 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 |
| 📡 | 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 |
<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 268 lines1// Token Weather: a live forecast of the context window, above the prompt,
2// and how long the prompt cache stays warm after the last request.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register, Timer } from 'claude-code'
5
6import type { CacheTtl, TokenWeatherReading } from '../types'
7
8const HISTORY = 12
9const BARS = '▁▂▃▄▅▆▇█'
10const FORECAST = [
11 { upTo: 25, icon: '☀', word: 'Clear', color: 'yellow' },
12 { upTo: 50, icon: '☁', word: 'Cloudy', color: 'cyan' },
13 { upTo: 75, icon: '☂', word: 'Showers', color: 'blue' },
14 { upTo: 90, icon: '☇', word: 'Storm', color: 'magenta' },
15 { upTo: Infinity, icon: '↯', word: 'Compact soon', color: 'red' },
16] as const
17
18// The cache countdown ticks every second only in its last two minutes;
19// before that it moves in 15 s steps (30 s with over ten minutes left),
20// so the band is not redrawn every second.
21const TTL_MS = { '5m': 5 * 60_000, '1h': 60 * 60_000 } as const
22const FAST_BELOW_MS = 2 * 60_000
23const SLOW_STEP_MS = 15_000
24const SLOWER_ABOVE_MS = 10 * 60_000
25const SLOWER_STEP_MS = 30_000
26const WARN_BELOW_MS = 60_000
27// How much of the transcript's end to read for the last response's usage.
28const TAIL_BYTES = 1024 * 1024
29
30// Held by the host, so the history survives a hot reload of this file.
31const readings = atom({ plugin: 'token-weather', key: 'readings' } as const, [] as TokenWeatherReading[])
32const lastRequestAt = atom({ plugin: 'token-weather', key: 'lastRequestAt' } as const, null as number | null)
33// The cache lifetime Claude's responses last showed; null until one wrote to the cache.
34const detectedTtl = atom({ plugin: 'token-weather', key: 'detectedTtl' } as const, null as CacheTtl | null)
35// Whether the cache-clock mod draws the cache on the status line, from Claude Code's own figures.
36const cacheClockOn = atom({ plugin: 'token-weather', key: 'cacheClockOn' } as const, false)
37
38export const register: Register = (on, options) => {
39 // `5m` or `1h` set by hand wins; `auto` (the default) goes by what was detected.
40 const override: CacheTtl | null = options.cacheTtl === '5m' || options.cacheTtl === '1h' ? options.cacheTtl : null
41
42 on('session.start', async ($, e, next) => {
43 const result = await next(e)
44 await takeReading($)
45 await checkCacheClock($)
46 if ((await read($, detectedTtl)) === null) {
47 const known = await $.store.get('detectedTtl').catch(() => undefined) // the last session's, until this one's first response says
48 if (known === '5m' || known === '1h') await update($, detectedTtl, () => known)
49 }
50 const since = await read($, lastRequestAt) // kept across a hot reload: pick the countdown up again
51 if (since !== null) restart($, since, await $.clock.now(), await ttlMsFor($, override))
52 return result
53 })
54
55
56 // A /clear or a resume leaves the old conversation, and its cache, behind.
57 on('session.end', async ($, e, next) => {
58 if (e.reason === 'clear' || e.reason === 'resume') {
59 stop()
60 await update($, lastRequestAt, () => null)
61 }
62 return next(e)
63 })
64
65 // One model request of a turn. The cache's clock restarts when each one on the
66 // main thread finishes, tool-loop requests inside a turn included.
67 on('turn.step', async function* ($, e, next) {
68 const result = yield* next(e)
69 if (!e.agentId && result.stopReason !== null) {
70 const now = await $.clock.now()
71 await update($, lastRequestAt, () => now)
72 restart($, now, now, await ttlMsFor($, override))
73 }
74 return result
75 })
76
77 on('turn.complete', async ($, e, next) => {
78 const result = await next(e)
79 if (!e.agentId) {
80 await takeReading($) // main-loop turns only, not subagents
81 await checkCacheClock($) // set up or removed mid-session
82 if (override === null) {
83 // The last response's usage in the transcript says which lifetime it wrote to the cache with.
84 const found = ttlFromTranscript(await readTail($))
85 if (found !== null) await detect($, found)
86 }
87 }
88 return result
89 })
90
91 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
92 const rest = await next(e) // what other mods and Claude Code draw here stays
93 const history = await read($, readings)
94 const now = history.at(-1)
95 if (e.props.hasSurvey || !now) {
96 return rest
97 }
98
99 const { Box, Text } = $.ui.resolve(e)
100 const f = FORECAST.find(b => now.percent < b.upTo) ?? FORECAST[4]
101 const isWide = e.props.bodyColumns >= 60
102
103 // The cache part goes first when the line runs out of room.
104 const since = await read($, lastRequestAt)
105 const ttlMs = await ttlMsFor($, override)
106 let cache: ReturnType<typeof cachePart> | null = null
107 if (since !== null && !(await read($, cacheClockOn))) {
108 const clockNow = await $.clock.now()
109 if (armedFor !== since) restart($, since, clockNow, ttlMs) // its timer was lost (a reload): pick it up
110 cache = cachePart(since + ttlMs - clockNow)
111 }
112 const lineWidth =
113 2 + // paddingX
114 `${f.icon} ${f.word} ${now.percent}% of context ${short(now.tokens)} / ${short(now.window)}`.length +
115 (isWide ? ` last turns ${sparkline(history)}${history.length > 1 ? trend(history) : ''}`.length : 0)
116 const showCache = isWide && cache !== null && lineWidth + cache.text.length <= e.props.bodyColumns
117
118 return (
119 <Box flexDirection="column">
120 <Box flexDirection="row" paddingX={1}>
121 <Text color={f.color} bold>{`${f.icon} ${f.word}`}</Text>
122 <Text>{` ${now.percent}% of context`}</Text>
123 <Text dimColor>{` ${short(now.tokens)} / ${short(now.window)}`}</Text>
124 {isWide && <Text dimColor>{' last turns '}</Text>}
125 {isWide && <Text color={f.color}>{sparkline(history)}</Text>}
126 {isWide && history.length > 1 && <Text dimColor>{trend(history)}</Text>}
127 {showCache && cache && <Text color={cache.color} dimColor={cache.isCold}>{cache.text}</Text>}
128 </Box>
129 {rest}
130 </Box>
131 )
132 })
133}
134
135// cache-clock's status line shows the exact countdown, so this one steps aside.
136async function checkCacheClock($: EngineInterface) {
137 const settings = await $.settings.read().catch(() => ({}) as Record<string, unknown>)
138 const line = settings.statusLine as { command?: unknown } | undefined
139 const isOn = typeof line?.command === 'string' && line.command.includes('cache-clock/cache-clock.mjs')
140 if ((await read($, cacheClockOn)) !== isOn) await update($, cacheClockOn, () => isOn)
141}
142
143async function ttlMsFor($: EngineInterface, override: CacheTtl | null) {
144 return TTL_MS[override ?? (await read($, detectedTtl)) ?? '5m']
145}
146
147// Keeps a newly detected lifetime, here and for the next session, and moves the countdown
148// onto it. Only called with no override set.
149async function detect($: EngineInterface, ttl: CacheTtl) {
150 if ((await read($, detectedTtl)) === ttl) return
151 await update($, detectedTtl, () => ttl)
152 await $.store.set('detectedTtl', ttl).catch(() => {}) // remembered for the next session when the store allows
153 const now = await $.clock.now()
154 const since = await read($, lastRequestAt) // read last, so a request that just finished is the one re-armed
155 if (since !== null) restart($, since, now, TTL_MS[ttl])
156}
157
158// The end of this session's transcript, where the last response is: whole if small,
159// else its last bytes. '' when it cannot be found or read.
160async function readTail($: EngineInterface) {
161 try {
162 const path = await transcriptPath($)
163 if (path === null) return ''
164 const { size } = await $.fs.stat(path)
165 if (size <= TAIL_BYTES) return await $.fs.read(path)
166 const { exitCode, stdout } = await $.process.run(['tail', '-c', String(TAIL_BYTES), path])
167 return exitCode === 0 ? stdout : ''
168 } catch {
169 return '' // unreadable: keep the last detected value
170 }
171}
172
173// <config dir>/projects/<project root, each other character a dash>/<session id>.jsonl
174// A very long project path, or a root moved during the session, gives a path that does
175// not exist: then nothing is detected and the last value stays.
176async function transcriptPath($: EngineInterface) {
177 const home = await $.env.get('HOME')
178 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (home ? `${home}/.claude` : undefined)
179 if (!configDir) return null
180 const project = (await $.session.root()).replace(/[^a-zA-Z0-9]/g, '-')
181 return `${configDir}/projects/${project}/${await $.session.id()}.jsonl`
182}
183
184// The lifetime the latest main-thread response wrote to the cache with; null when it
185// wrote nothing (a pure cache hit) or no response was found.
186function ttlFromTranscript(text: string): CacheTtl | null {
187 const lines = text.split('\n')
188 for (let i = lines.length - 1; i >= 0; i--) {
189 let entry: any
190 try {
191 entry = JSON.parse(lines[i] ?? '')
192 } catch {
193 continue // blank, or cut by the tail
194 }
195 if (entry?.type !== 'assistant' || entry.isSidechain || !entry.message?.usage) continue
196 const written = entry.message.usage.cache_creation
197 if (Number(written?.ephemeral_1h_input_tokens) > 0) return '1h'
198 if (Number(written?.ephemeral_5m_input_tokens) > 0) return '5m'
199 return null
200 }
201 return null
202}
203
204// The countdown's redraw timer, one at a time; a reload of this file starts it over.
205let tick: Timer | undefined
206let armedFor: number | null = null
207
208function stop() {
209 tick?.cancel()
210 tick = undefined
211 armedFor = null
212}
213
214function restart($: EngineInterface, since: number, now: number, ttlMs: number) {
215 armedFor = since
216 arm($, since, now, ttlMs)
217}
218
219// Redraws the band when the countdown's text next changes, until it reads cold.
220function arm($: EngineInterface, since: number, now: number, ttlMs: number) {
221 if (armedFor !== since) return // a newer request took over
222 tick?.cancel()
223 const left = since + ttlMs - now
224 if (left <= 0) return
225 const isFast = left <= FAST_BELOW_MS
226 const step = isFast ? 1000 : left > SLOWER_ABOVE_MS ? SLOWER_STEP_MS : SLOW_STEP_MS
227 const wait = Math.min(left % step || step, isFast ? step : left - (left > SLOWER_ABOVE_MS ? SLOWER_ABOVE_MS : FAST_BELOW_MS))
228 tick = $.clock.after(wait, () => {
229 $.ui.invalidate('ui.render')
230 $.clock.now().then(t => arm($, since, t, ttlMs)).catch(() => {})
231 })
232}
233
234async function takeReading($: EngineInterface) {
235 const { context } = await $.session.usage()
236 if (!context?.window) return
237 const tokens = context.tokens ?? 0
238 const percent = context.percent ?? Math.round((tokens / context.window) * 100)
239 await update($, readings, history => [...history, { tokens, window: context.window, percent }].slice(-HISTORY))
240}
241
242// What the cache part says with `leftMs` until the cache expires.
243function cachePart(leftMs: number) {
244 if (leftMs <= 0) {
245 return { text: ' ❄ cache cold', color: 'red', isCold: true }
246 }
247 const seconds = Math.ceil(leftMs / 1000)
248 const clock = `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
249 return { text: ` ❄ cache ${clock}`, color: seconds * 1000 < WARN_BELOW_MS ? 'yellow' : undefined, isCold: false }
250}
251
252function sparkline(history: TokenWeatherReading[]) {
253 const top = Math.max(...history.map(r => r.tokens), 1)
254 return history.map(r => BARS[Math.floor((r.tokens / top) * (BARS.length - 1))]).join('')
255}
256
257function trend(history: TokenWeatherReading[]) {
258 const delta = (history.at(-1)?.tokens ?? 0) - (history.at(-2)?.tokens ?? 0)
259 if (delta === 0) return ' steady'
260 return delta > 0 ? ` ▲ +${short(delta)} last turn` : ` ▼ ${short(-delta)} last turn`
261}
262
263function short(n: number) {
264 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
265 if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k`
266 return String(n)
267}
268types/index.d.ts 17 lines1export type CacheTtl = '5m' | '1h'
2export type TokenWeatherReading = { tokens: number; window: number; percent: number }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'token-weather': {
7 readings: TokenWeatherReading[]
8 // When the last main-thread model request finished, in ms since the epoch; null before the first.
9 lastRequestAt: number | null
10 // The cache lifetime Claude's responses last showed; null until one wrote to the cache.
11 detectedTtl: CacheTtl | null
12 // Whether the cache-clock mod's status line is set up; its countdown replaces this one.
13 cacheClockOn: boolean
14 }
15 }
16}
17