A live forecast of the context window, drawn above the prompt.

Mods for Claude Code (2.1.287+): plugins of function hooks that observe, rewrite or answer what Claude Code does and draw their own UI. Based on Getting started with Claude Code mods.
Landing page: https://cskwork.github.io/claude-code-mods/
| Mod | What it does |
|---|---|
token-weather | A one-line forecast of the context window above the prompt: ☀ Clear → ↯ Compact soon, tokens used, a sparkline of the last 12 turns, and how much the last turn added. |
blast-radius | Before a risky Bash command runs (recursive rm, git reset --hard, git clean -f, force push, git branch -D, git stash drop/clear, kubectl delete, SQL DROP/TRUNCATE, migrations), dry-runs what it would touch and asks Proceed or Cancel. Cancel or dismiss denies the call. |
template | Starter mod, not installed: a slash command, a status line entry, a band, and a $.state contract with a test. |
Inside Claude Code:
/plugin marketplace add cskwork/claude-code-mods
/plugin install token-weather@claude-code-mods
/plugin install blast-radius@claude-code-mods
/reload-plugins
scripts/new-mod.sh my-mod "One line about what it does"
claude --plugin-dir "$PWD/my-mod" # hot-reloads on every save
The script copies template/, renames it, adds it to .claude-plugin/marketplace.json, then runs claude plugin validate and claude plugin test on it.
A mod is three files plus an optional state contract:
my-mod/
├── .claude-plugin/plugin.json name, version, description, "types"
├── hooks/hooks.json { "modules": ["./register.tsx"] }
├── hooks/register.tsx export const register: Register = on => { ... }
├── types/index.d.ts PluginState contract for $.state values
└── tests/my-mod.test.ts claude plugin test my-mod
Rules that bite:
($, e, next). Call next(e) to pass on, next({ ...e, x }) to rewrite, return without it to answer.$.state (atom / read / update); module variables reset on every hot reload.$. UI elements come from $.ui.resolve(e).on('process.run', ($, e) => ({ value: ... }))..claude-plugin/types/ beside it, so tsc -p <mod> type-checks it (gitignored).for m in token-weather blast-radius template; do claude plugin validate $m && claude plugin test $m; donehooks/register.tsx 83 lines1// Token Weather: a live forecast of the context window, above the prompt.
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, Register } from 'claude-code'
4
5import type { TokenWeatherReading } from '../types'
6
7const HISTORY = 12
8const BARS = '▁▂▃▄▅▆▇█'
9const FORECAST = [
10 { upTo: 25, icon: '☀', word: 'Clear', color: 'yellow' },
11 { upTo: 50, icon: '☁', word: 'Cloudy', color: 'cyan' },
12 { upTo: 75, icon: '☂', word: 'Showers', color: 'blue' },
13 { upTo: 90, icon: '☇', word: 'Storm', color: 'magenta' },
14 { upTo: Infinity, icon: '↯', word: 'Compact soon', color: 'red' },
15] as const
16
17// Held by the host, so the history survives a hot reload of this file.
18const readings = atom({ plugin: 'token-weather', key: 'readings' } as const, [] as TokenWeatherReading[])
19
20export const register: Register = on => {
21 on('session.start', async ($, e, next) => {
22 const result = await next(e)
23 await takeReading($)
24 return result
25 })
26
27 on('turn.complete', async ($, e, next) => {
28 const result = await next(e)
29 if (!e.agentId) await takeReading($) // main-loop turns only, not subagents
30 return result
31 })
32
33 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
34 const history = await read($, readings)
35 const now = history[history.length - 1]
36 if (e.props.hasSurvey || !now) return next(e)
37
38 const { Box, Text } = $.ui.resolve(e)
39 const f = forecast(now.percent)
40 const isWide = (e.props.bodyColumns ?? 80) >= 60
41
42 return (
43 <Box flexDirection="row" paddingX={1}>
44 <Text color={f.color} bold>{`${f.icon} ${f.word}`}</Text>
45 <Text>{` ${now.percent}% of context`}</Text>
46 <Text dimColor>{` ${short(now.tokens)} / ${short(now.window)}`}</Text>
47 {isWide && <Text dimColor>{' last turns '}</Text>}
48 {isWide && <Text color={f.color}>{sparkline(history)}</Text>}
49 {isWide && history.length > 1 && <Text dimColor>{trend(history)}</Text>}
50 </Box>
51 )
52 })
53}
54
55async function takeReading($: EngineInterface) {
56 const { context } = await $.session.usage()
57 if (!context?.window) return
58 const tokens = context.tokens ?? 0
59 const percent = context.percent ?? Math.round((tokens / context.window) * 100)
60 await update($, readings, list => [...list, { tokens, window: context.window, percent }].slice(-HISTORY))
61}
62
63export function forecast(percent: number) {
64 return FORECAST.find(b => percent < b.upTo) ?? FORECAST[FORECAST.length - 1]!
65}
66
67export function sparkline(history: readonly TokenWeatherReading[]) {
68 const top = Math.max(...history.map(r => r.tokens), 1)
69 return history.map(r => BARS[Math.floor((r.tokens / top) * (BARS.length - 1))]).join('')
70}
71
72export function trend(history: readonly TokenWeatherReading[]) {
73 const delta = history[history.length - 1]!.tokens - history[history.length - 2]!.tokens
74 if (delta === 0) return ' steady'
75 return delta > 0 ? ` ▲ +${short(delta)} last turn` : ` ▼ ${short(-delta)} last turn`
76}
77
78export function short(n: number) {
79 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
80 if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k`
81 return String(n)
82}
83types/index.d.ts 8 lines1export type TokenWeatherReading = { tokens: number; window: number; percent: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'token-weather': { readings: TokenWeatherReading[] }
6 }
7}
8