SLOPSHOPPER

context-weather

A band above the prompt: context usage as a weather forecast, a bar chart of tokens per turn, and session totals.

newbandcommand
v0.1.0Apache-2.0updated 2026-10-08marco-quicula/claude-mods/mods/context-weather
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-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 › /context-weather ⎿ context-weather: Context weather band hidden. Run /context-weather to show it again. ⛅ Cloudy ██████████░░░░░░░░░░ 49% · 97.4k / 200.0k tokens in context [ Hide ] Tokens per turn █ · last: 7.9k (+91.0k read from cache) · session: 7.9k ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⛅ Cloudy ██████████░░░░░░░░░░ 49% · 97.4k / 200.0k tokens in context [ Hide ] Tokens per turn █ · last: 7.9k (+91.0k read from cache) · session: 7.9k
README

claude-mods

Mods for Claude Code, distributed as a plugin marketplace.

ModWhat it does
context-weatherA band above the prompt showing context usage as a weather forecast, a bar chart of tokens per turn and session totals.

Installation

Add the marketplace once, then install the mods you want. At the Claude Code prompt:

/plugin marketplace add marco-quicula/claude-mods
/plugin install context-weather@marco-quicula

To update later:

/plugin marketplace update marco-quicula
/plugin update context-weather@marco-quicula

To remove a mod: /plugin uninstall context-weather@marco-quicula.

New mods show up in the catalog once it is updated (/plugin marketplace update marco-quicula). They are never installed on their own: install each one you want with /plugin install <mod>@marco-quicula. See the releases for what is new.

context-weather

A band above the prompt that tells you how full the context window is, the way a forecast would:

⛅ Cloudy  ████████░░░░░░░░░░░░ 41% · 82.4k / 200.0k tokens in context  [Hide]
Tokens per turn ▂▃▁▅█▄▂  · last: 3.1k (+78.2k read from cache) · session: 41.7k

Forecast

Context usedWeather
up to 25%☀️ Clear skies
up to 50%⛅ Cloudy
up to 75%🌧️ Rainy
up to 90%🌩️ Thunder
above 90%⛈️ Storm

What the band shows

  • Gauge: how much of the context window is in use, in percent and tokens.
  • Tokens per turn: one bar per turn (the last 24), scaled to the largest. A turn's tokens are the new ones it spent: input, cache written and output.
  • last: the tokens of the last turn. Context read back from the prompt cache is shown apart, in parentheses, and is not counted.
  • session: the sum over every turn of the session.

Subagent turns are not counted. The band appears once the session reports context usage, usually after the first turn.

Commands

CommandWhat it does
/context-weatherShows or hides the band. The Hide button on the band hides it too.
/clima-contextoThe same command, an alias for Portuguese speakers.

Language

The band is available in English (en) and Brazilian Portuguese (pt-BR). The mod has a Language option:

  • auto (default): detects the language from the Claude Code language setting, then from the LANG environment variable. When neither names a supported language, the band is in English.
  • en or pt-BR: always that language.

You choose it when you install the mod, and you can change it any time in /config. The band switches language as soon as you change it.

Development

Each mod lives in mods/<mod>/ as a plugin of function hooks: a .claude-plugin/plugin.json manifest, a hooks module under hooks/, its state contract under types/ and tests under tests/.

  • Checks: claude plugin validate . for the marketplace, claude plugin validate mods/<mod> and claude plugin test mods/<mod> for a mod.
  • Running from a local folder: claude plugin marketplace add <folder> and install from it. Claude Code reads the mod straight from the folder; after editing, run /reload-plugins in the session. claude --plugin-dir mods/<mod> also loads a single mod for one session.
  • New version: bump version in the mod's plugin.json (see Versioning).

Versioning

Each mod is versioned on its own, following semantic versioning:

  • A new mod starts at 0.1.0.
  • A mod's version goes up only when the mod itself changes: patch for fixes, minor for new features, major for breaking changes.
  • Changes to the repository alone (this README, the license, the marketplace catalog) don't bump any mod.
  • Each version is tagged and published as a release named <mod>-v<version>, for example context-weather-v0.1.0.

License

Apache License 2.0.

Source 3 files
hooks/register.tsx 149 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionUsage } from 'claude-code'
3
4import type { Context, Stats } from '../types'
5import { MESSAGES, resolveLocale } from './i18n'
6import type { Locale, WeatherKey } from './i18n'
7
8const MAX_BARS = 24
9const BAR_CHARS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
10const GAUGE_WIDTH = 20
11
12const EMPTY: Stats = { history: [], last: 0, total: 0, lastCacheRead: 0, context: null }
13
14const stats = atom({ plugin: 'context-weather', key: 'stats' } as const, EMPTY)
15const isHidden = atom({ plugin: 'context-weather', key: 'isHidden' } as const, false)
16const locale = atom({ plugin: 'context-weather', key: 'locale' } as const, 'en' as Locale)
17
18type Weather = { icon: string; key: WeatherKey; color: string }
19
20/**
21 * The weather for the context usage: up to 25% clear skies, up to 50% cloudy,
22 * up to 75% rainy, up to 90% thunder (between rain and storm), above 90% storm.
23 */
24export function weatherFor(percent: number): Weather {
25  if (percent <= 25) return { icon: '☀️', key: 'clear', color: '#F5B700' }
26  if (percent <= 50) return { icon: '⛅', key: 'cloudy', color: '#8FA3B8' }
27  if (percent <= 75) return { icon: '🌧️', key: 'rainy', color: '#3B82F6' }
28  if (percent <= 90) return { icon: '🌩️', key: 'thunder', color: '#F59E0B' }
29  return { icon: '⛈️', key: 'storm', color: '#EF4444' }
30}
31
32/** 1234 -> "1.2k"; 1234567 -> "1.23M"; below a thousand, the whole number. */
33export function formatTokens(n: number, decimal = '.'): string {
34  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2).replace('.', decimal)}M`
35  if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace('.', decimal)}k`
36  return String(Math.round(n))
37}
38
39/** One vertical bar per turn, scaled to the largest in the series. */
40export function sparkline(values: readonly number[]): string {
41  const max = Math.max(0, ...values)
42  if (max === 0) return values.map(() => BAR_CHARS[0]).join('')
43  return values
44    .map(v => BAR_CHARS[Math.min(BAR_CHARS.length - 1, Math.round((v / max) * (BAR_CHARS.length - 1)))])
45    .join('')
46}
47
48/** The context gauge, filled up to the percentage. */
49export function gauge(percent: number): { filled: string; empty: string } {
50  const cells = Math.max(0, Math.min(GAUGE_WIDTH, Math.round((percent / 100) * GAUGE_WIDTH)))
51  return { filled: '█'.repeat(cells), empty: '░'.repeat(GAUGE_WIDTH - cells) }
52}
53
54function contextFrom(usage: SessionUsage): Context | null {
55  const { tokens, window, percent } = usage.context
56  if (tokens === undefined || percent === undefined) return null
57  return { tokens, window, percent }
58}
59
60/** Shows or hides the band; the reply names the command as it was typed. */
61async function toggle($: EngineInterface, command: string) {
62  const hidden = await update($, isHidden, h => !h)
63  const m = MESSAGES[await read($, locale)]
64
65  return { text: hidden ? m.hidden(command) : m.shown }
66}
67
68export const register: Register = (on, options) => {
69  on('session.start', async ($, e, next) => {
70    // Detection is best effort: a source that cannot be read counts as unset.
71    const settings = await $.settings.read().catch(() => ({}) as Record<string, unknown>)
72    const lang = await $.env.get('LANG').catch(() => undefined)
73    const resolved = resolveLocale(options.language, settings.language, lang)
74    await update($, locale, () => resolved)
75
76    await $.command.register({ name: 'context-weather', description: MESSAGES[resolved].commandDescription })
77    // Alias for Portuguese speakers, always described in Portuguese.
78    await $.command.register({ name: 'clima-contexto', description: MESSAGES['pt-BR'].commandDescription })
79
80    const context = contextFrom(await $.session.usage())
81    if (context) await update($, stats, s => ({ ...s, context }))
82
83    return next(e)
84  })
85
86  on('command.run', { command: 'context-weather' }, $ => toggle($, 'context-weather'))
87  on('command.run', { command: 'clima-contexto' }, $ => toggle($, 'clima-contexto'))
88
89  on('turn.complete', async ($, e, next) => {
90    const result = await next(e)
91    if (e.agentId !== undefined) return result
92
93    const usage = e.usage
94    const spent = usage ? usage.input_tokens + usage.cache_creation_input_tokens + usage.output_tokens : 0
95    const cacheRead = usage ? usage.cache_read_input_tokens : 0
96    const context = contextFrom(await $.session.usage())
97
98    await update($, stats, s => ({
99      history: [...s.history, spent].slice(-MAX_BARS),
100      last: spent,
101      total: s.total + spent,
102      lastCacheRead: cacheRead,
103      context: context ?? s.context,
104    }))
105
106    return result
107  })
108
109  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
110    if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
111
112    const s = await read($, stats)
113    if (s.context === null) return next(e)
114
115    const m = MESSAGES[await read($, locale)]
116    const fmt = (n: number) => formatTokens(n, m.decimal)
117    const { Box, Button, Text } = $.ui.resolve(e)
118    const weather = weatherFor(s.context.percent)
119    const bar = gauge(s.context.percent)
120    const bars = s.history.length > 0 ? sparkline(s.history) : '—'
121
122    return (
123      <Box flexDirection="column">
124        <Box>
125          <Text color={weather.color} bold={s.context.percent > 90}>
126            {weather.icon} {m.weather[weather.key]}{'  '}
127          </Text>
128          <Text color={weather.color}>{bar.filled}</Text>
129          <Text dimColor>{bar.empty}</Text>
130          <Text>
131            {' '}
132            {s.context.percent}% · {fmt(s.context.tokens)} / {fmt(s.context.window)} {m.tokensInContext}{'  '}
133          </Text>
134          <Button key="hide" label={m.hide} onPress={() => update($, isHidden, () => true)} />
135        </Box>
136        <Box>
137          <Text dimColor>{m.tokensPerTurn} </Text>
138          <Text color="#22D3EE">{bars}</Text>
139          <Text dimColor>
140            {'  '}· {m.last}: {fmt(s.last)}
141            {s.lastCacheRead > 0 ? ` (${m.cacheRead(fmt(s.lastCacheRead))})` : ''} · {m.session}:{' '}
142            {fmt(s.total)}
143          </Text>
144        </Box>
145      </Box>
146    )
147  })
148}
149
hooks/i18n.ts 80 lines
1export const LOCALES = ['en', 'pt-BR'] as const
2export type Locale = (typeof LOCALES)[number]
3
4export type WeatherKey = 'clear' | 'cloudy' | 'rainy' | 'thunder' | 'storm'
5
6export type Messages = {
7  /** Decimal separator used when abbreviating token counts. */
8  decimal: string
9  weather: Record<WeatherKey, string>
10  tokensInContext: string
11  tokensPerTurn: string
12  last: string
13  session: string
14  cacheRead: (tokens: string) => string
15  hide: string
16  commandDescription: string
17  hidden: (command: string) => string
18  shown: string
19}
20
21export const MESSAGES: Record<Locale, Messages> = {
22  en: {
23    decimal: '.',
24    weather: {
25      clear: 'Clear skies',
26      cloudy: 'Cloudy',
27      rainy: 'Rainy',
28      thunder: 'Thunder',
29      storm: 'Storm',
30    },
31    tokensInContext: 'tokens in context',
32    tokensPerTurn: 'Tokens per turn',
33    last: 'last',
34    session: 'session',
35    cacheRead: tokens => `+${tokens} read from cache`,
36    hide: 'Hide',
37    commandDescription: 'Show or hide the context weather band',
38    hidden: command => `Context weather band hidden. Run /${command} to show it again.`,
39    shown: 'Context weather band shown.',
40  },
41  'pt-BR': {
42    decimal: ',',
43    weather: {
44      clear: 'Céu aberto',
45      cloudy: 'Nublado',
46      rainy: 'Chuvoso',
47      thunder: 'Trovoadas',
48      storm: 'Tempestade',
49    },
50    tokensInContext: 'tokens no contexto',
51    tokensPerTurn: 'Tokens por interação',
52    last: 'última',
53    session: 'sessão',
54    cacheRead: tokens => `+${tokens} relidos do cache`,
55    hide: 'Ocultar',
56    commandDescription: 'Mostra ou oculta a faixa do clima do contexto',
57    hidden: command => `Faixa do clima oculta. /${command} mostra de novo.`,
58    shown: 'Faixa do clima visível.',
59  },
60}
61
62/** Maps free text ("pt_BR.UTF-8", "Portuguese", "português", "en-US") to a supported locale. */
63export function localeFromText(text: string | undefined): Locale | undefined {
64  if (!text) return undefined
65  const t = text.trim().toLowerCase()
66  if (/^pt([-_.]|$)/.test(t) || t.includes('portug')) return 'pt-BR'
67  if (/^en([-_.]|$)/.test(t) || t.includes('english') || t.includes('ingl')) return 'en'
68  return undefined
69}
70
71/**
72 * The locale the band is drawn in: the mod's `language` option when it names a
73 * locale; on `auto`, the first source that maps to one (the Claude Code
74 * `language` setting, then the `LANG` variable); English otherwise.
75 */
76export function resolveLocale(option: unknown, settingsLanguage: unknown, lang: string | undefined): Locale {
77  if (typeof option === 'string' && (LOCALES as readonly string[]).includes(option)) return option as Locale
78  return localeFromText(typeof settingsLanguage === 'string' ? settingsLanguage : undefined) ?? localeFromText(lang) ?? 'en'
79}
80
types/index.d.ts 21 lines
1export type Context = { tokens: number; window: number; percent: number }
2
3export type Stats = {
4  /** New tokens (input + cache written + output) of the latest turns, most recent last. */
5  history: number[]
6  /** New tokens of the last turn. */
7  last: number
8  /** Sum of the new tokens of every turn in the session. */
9  total: number
10  /** Cache reads of the last turn (context read again, not counted in last). */
11  lastCacheRead: number
12  /** The context at the end of the last turn. */
13  context: Context | null
14}
15
16declare module 'claude-code' {
17  interface PluginState {
18    'context-weather': { stats: Stats; isHidden: boolean; locale: 'en' | 'pt-BR' }
19  }
20}
21