SLOPSHOPPER

token-weather

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

newband
v0.1.0no licenseupdated 2026-10-02cskwork/claude-code-mods/token-weather
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-weather
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ☁ Cloudy 49% of context 97.4k / 200k last turns ██ steady ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
☁ Cloudy 49% of context 97.4k / 200k last turns ██ steady
README

claude-code-mods

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/

ModWhat it does
token-weatherA 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-radiusBefore 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.
templateStarter mod, not installed: a slash command, a status line entry, a band, and a $.state contract with a test.

Install

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

Make a new mod

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:

  • Every hook is ($, e, next). Call next(e) to pass on, next({ ...e, x }) to rewrite, return without it to answer.
  • Keep values in $.state (atom / read / update); module variables reset on every hot reload.
  • No Node, no DOM: files, processes, clock and UI go through $. UI elements come from $.ui.resolve(e).
  • Test mocks are hooks too: on('process.run', ($, e) => ({ value: ... })).
  • After the engine loads a mod it writes .claude-plugin/types/ beside it, so tsc -p <mod> type-checks it (gitignored).

Check

for m in token-weather blast-radius template; do claude plugin validate $m && claude plugin test $m; done
Source 2 files
hooks/register.tsx 83 lines
1// 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}
83
types/index.d.ts 8 lines
1export type TokenWeatherReading = { tokens: number; window: number; percent: number }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'token-weather': { readings: TokenWeatherReading[] }
6  }
7}
8