SLOPSHOPPER

token-weather

A live dashboard of your context window and prompt cache above the prompt, in the analogy and language you pick

newbandcommandtimer
v0.4.0MITupdated 2026-10-14pstahel/token-weather-claude-code-mod
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 › /token-weather ⎿ token-weather: token-weather settings (change with /token-weather <setting> <value>): ⎿ token-weather: analogy ›weather moon hearts fuel pot tide daytime volcano ⎿ token-weather: cache ›full compact off ⎿ token-weather: ttl ›1h 5m ⎿ token-weather: lang ›en de es it fr ⎿ token-weather: ☁ Cloudy 48% 97.4k / 200k ▄▄ ▲ +0 last turn ~? turns ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
☁ Cloudy 48% 97.4k / 200k ▄▄ ▲ +0 last turn ~? turns
README

token-weather — a Claude Code mod

A live dashboard of your context window and prompt cache, shown in the band above the prompt and updated after every turn. Pick the analogy it speaks in; the default is weather, here is the motorbike:

▰▰▱▱▱ Reserve  20% left  40k / 200k  ▇▆▅▃▂  ▼ −22.1k last trip  ~2 trips
♨ engine warm  C ▰▰▰▰▰▰▰▱▱▱ H  128.1k (92% hit)  42 of 60 min left

Row 1, the context window

PieceMotorbikeShows
▰▰▱▱▱ Reservefuel gaugethe step the fill is in (five steps, below)
20% left · 40k / 200khow much of the window is left (or used, by analogy)
▇▆▅▃▂trip logthe last 12 turns
▼ −22.1k last tripconsumptionwhat the last turn added (▲ back up after a /compact)
~2 tripsrangehow many more turns fit at the pace of the last five (a /compact left out); ~? until two turns grew

Row 2, the prompt cache

PieceMotorbikeShows
♨ engine warmengine temperaturewarm (more than half the lifetime left, green), cooling (yellow), cold (red)
C ▰▰▰▰▰▰▰▱▱▱ Htemperature gaugelifetime left
128.1k (92% hit)tokens in the cache after the last request, and the share of the prompt it served
42 of 60 min leftuntil the entry lapses; every request refreshes it, and the row redraws every 15 seconds

A cold cache reads ♨ cold start C ▱▱▱▱▱▱▱▱▱▱ H next turn rewrites 134.4k: the next prompt writes the whole context to the cache again, which costs more, so a /compact first can be worth it. Subagents keep caches of their own and are not counted. The cache row appears after the first response.

Claude Code does not tell a mod how long its cache lives, so the lifetime is a setting (ttl, 1h by default, or 5m).

The band is drawn on Claude Code's terminal and desktop surfaces.

Analogies

Every analogy has five steps over the same thresholds (under 25%, 25–49, 50–74, 75–89, 90% and up of the window used), a gauge for the cache, and its own word for a turn. fuel and hearts count what is left, like a fuel gauge; the others count what is used.

AnalogyStepsCache: warm → cooling → coldA turn is
weather☀ Clear · ☁ Cloudy · ☂ Showers · ☇ Storm · ↯ Compact soon° mild → chilly → frostturn
moon○ New moon · ◔ Waxing · ◑ Half moon · ◕ Gibbous · ● Full moon✧ lantern lit → dim → outnight
hearts♥♥♥♥♥ Full health · ♥♥♥♥♡ Scratched · ♥♥♥♡♡ Wounded · ♥♥♡♡♡ Critical · ♥♡♡♡♡ Low HP✦ buff active → fading → gonehit
fuel▰▰▰▰▰ Full tank · ▰▰▰▰▱ Three quarters · ▰▰▰▱▱ Half tank · ▰▰▱▱▱ Reserve · ▰▱▱▱▱ Refuel♨ engine warm → cooling → cold starttrip
pot· Cold · ~ Warm · ≈ Simmering · ≋ Boiling · ‼ Boiling over♨ stove hot → cooling → coldladle
tide_ Low tide · ~ Calm · ≈ Swell · ≋ Surf · ↟ Storm surge☼ water warm → cooling → coldwave
daytime☼ Morning · ☀ Noon · ◐ Afternoon · ☾ Evening · ✦ Midnight♨ coffee hot → cooling → coldhour
volcano▲ Dormant · ▲ Rumbling · ▲ Smoking · ▲ Glowing · ✸ Eruption◉ lava glowing → cooling → rock coldquake

moon charts past turns as moon phases (○◔◑◕●) instead of bars.

Languages

The band speaks English (en), German (de), Spanish (es), Italian (it) and French (fr). The fuel dashboard in German:

▰▰▱▱▱ Reserve  20% übrig  40k / 200k  ▇▆▅▃▂  ▼ −22.1k letzte Fahrt  ~2 Fahrten
♨ Motor warm  C ▰▰▰▰▰▰▰▱▱▱ H  128.1k (92% Treffer)  noch 42 von 60 min

/token-weather itself answers in English.

Settings

Change them at the prompt, in the terminal or the desktop app:

/token-weather                 # show every setting, current values marked ›
/token-weather fuel            # analogy (or: /token-weather analogy fuel)
/token-weather de              # language: en, de, es, it, fr (or: /token-weather lang de)
/token-weather cache compact   # cache row: full, compact or off (or: /token-weather compact)
/token-weather ttl 5m          # cache lifetime: 1h or 5m (or: /token-weather 5m)

The band reloads with the new value right away, and it is kept for later sessions.

SettingOptionValuesDefault
analogyanalogyweather moon hearts fuel pot tide daytime volcanoweather
langlanguageen de es it fren
cachecachefull (its own row), compact (one segment on the forecast row: ♨ engine warm 42′), offfull
ttlcacheTtl1h 5m1h

They are also pickers in /config in a terminal session, and they live in ~/.claude/settings.json. The key is the plugin's id: token-weather@token-weather when installed from this marketplace, token-weather@inline when loaded from a folder with --plugin-dir:

"pluginConfigs": {
  "token-weather@token-weather": {
    "options": { "analogy": "fuel", "language": "de", "cache": "full", "cacheTtl": "1h" }
  }
}

Install

At the prompt of a terminal session:

/plugin install token-weather --marketplace pstahel/token-weather-claude-code-mod

Answer y to add the marketplace, then pick a scope.

From a shell:

claude plugin install token-weather --marketplace pstahel/token-weather-claude-code-mod

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with pstahel/token-weather-claude-code-mod, then Add plugin.

Develop

claude plugin validate .
claude plugin test .

To try changes live, load the folder as a dev plugin: claude --plugin-dir . in a terminal; the session reloads the mod whenever a file changes. Where things live:

FileWhat
hooks/forecast.tseach analogy's look (ANALOGIES: icons, colors, counting direction, cache gauge), thresholds, delta and range
hooks/i18n.tsevery word, per language and analogy (WORDS, PHRASES)
hooks/cache.tsthe cache state over its lifetime
hooks/band.tsboth rows put together from the above
hooks/command.tsthe /token-weather arguments
hooks/register.tsxthe hooks and the drawing

A new analogy goes into ANALOGIES, into WORDS for every language, and into userConfig.analogy.options in .claude-plugin/plugin.json; a new language into LANGS, PHRASES, WORDS and userConfig.language.options.

License

MIT, see LICENSE.

Source 7 files
hooks/register.tsx 136 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, SessionUsage, StateDollar } from 'claude-code'
3
4import { analogyOf, push } from './forecast'
5import { SETTINGS, parseCommand, settingsListing } from './command'
6import { cacheModeOf, cacheReadingOf, cacheView, ttlOf } from './cache'
7import { cacheRow, cacheSegment, forecastRow } from './band'
8import { langOf } from './i18n'
9
10const readings = atom({ plugin: 'token-weather', key: 'readings' } as const, [])
11const cache = atom({ plugin: 'token-weather', key: 'cache' } as const, null)
12/** Bumped on a timer so the cache countdown redraws between turns. */
13const tick = atom({ plugin: 'token-weather', key: 'tick' } as const, 0)
14const TICK_MS = 15_000
15
16async function takeReading($: StateDollar & { session: { usage: () => Promise<SessionUsage> } }) {
17  const { context } = await $.session.usage()
18  if (context.tokens === undefined || context.window <= 0) return
19  const reading = { tokens: context.tokens, window: context.window }
20  await update($, readings, rs => push(rs, reading))
21}
22
23export const register: Register = (on, options) => {
24  const analogy = analogyOf(options.analogy)
25  const lang = langOf(options.language)
26  const cacheMode = cacheModeOf(options.cache)
27  const ttl = ttlOf(options.cacheTtl)
28
29  // On load (and every reload), register /token-weather and show the current fill right away.
30  on('session.start', async ($, e, next) => {
31    await $.command
32      .register({
33        name: 'token-weather',
34        description: 'token-weather settings: analogy, cache row, cache lifetime and language',
35        argumentHint: '[analogy | cache full|compact|off | ttl 1h|5m | lang en|de|es|it|fr]',
36      })
37      .catch(() => {}) // the band works without the command
38    if (cacheMode !== 'off') $.clock.every(TICK_MS, () => update($, tick, n => n + 1))
39    const result = await next(e)
40    if ((await read($, readings)).length === 0) await takeReading($)
41    return result
42  })
43
44  // After every turn, take a reading of the context window.
45  on('turn.complete', async ($, e, next) => {
46    const result = await next(e)
47    if (!e.agentId) await takeReading($) // skip subagents' turns
48    return result
49  })
50
51  // After every main-thread API request, note what the prompt cache held and when.
52  on('turn.step', async function* ($, e, next) {
53    const result = yield* next(e)
54    if (cacheMode !== 'off' && !e.agentId && result.usage) {
55      const reading = cacheReadingOf(result.usage, await $.clock.now())
56      await update($, cache, () => reading)
57    }
58    return result
59  })
60
61  // `/token-weather` lists the settings; `/token-weather moon`, `cache compact`, `ttl 5m`, `de` change one.
62  on('command.run', { command: 'token-weather' }, async ($, e) => {
63    const current = { analogy, cache: cacheMode, ttl, lang }
64    const parsed = parseCommand(e.args ?? '')
65    if (parsed.kind === 'list') return { text: settingsListing(current) }
66    if (parsed.kind === 'error') return { text: parsed.text }
67
68    const { setting, value } = parsed
69    if (current[setting] === value) return { text: `token-weather ${setting} is already ${value}.` }
70    const { field } = SETTINGS[setting]
71    const row = (await $.config.list()).find(
72      r => r.key.startsWith('token-weather') && r.key.endsWith(`.${field}`),
73    )
74    if (!row) return { text: `token-weather: the ${setting} setting was not found in /config.` }
75    const { deny } = await $.config.set({ key: row.key, value })
76    if (deny) return { text: `token-weather: could not set ${setting} to ${value}: ${deny}` }
77    return { text: `token-weather ${setting} is now ${value}.` }
78  })
79
80  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
81    if (e.props.hasSurvey) return next(e)
82
83    const rs = await read($, readings)
84    const now = rs[rs.length - 1]
85    if (!now) return next(e)
86
87    const { Box, Text } = $.ui.resolve(e)
88    const f = forecastRow(rs, analogy, lang)
89
90    const c = cacheMode === 'off' ? null : await read($, cache)
91    await read($, tick) // redraw on the timer, so the countdown moves
92    const view = c ? cacheView(c, await $.clock.now(), ttl) : null
93    const isFull = view && cacheMode === 'full' && e.props.maxRows >= 2
94
95    const forecast = (
96      <Box key="token-weather" flexDirection="row" gap={2}>
97        <Text color={f.color} bold>
98          {f.head}
99        </Text>
100        <Text bold>{f.pct}</Text>
101        <Text dimColor>{f.tokens}</Text>
102        <Text>
103          {f.bars.map((b, i) => (
104            <Text key={`bar-${i}`} color={b.color}>
105              {b.glyph}
106            </Text>
107          ))}
108        </Text>
109        <Text dimColor>{f.delta}</Text>
110        <Text dimColor>{f.range}</Text>
111        {view && !isFull ? (
112          <Text key="token-weather-cache" color={cacheRow(view, now.tokens, analogy, lang).color}>
113            {cacheSegment(view, analogy, lang)}
114          </Text>
115        ) : null}
116      </Box>
117    )
118    if (!view || !isFull) return forecast
119
120    const row = cacheRow(view, now.tokens, analogy, lang)
121    return (
122      <Box key="token-weather-band" flexDirection="column">
123        {forecast}
124        <Box key="token-weather-cache" flexDirection="row" gap={2}>
125          <Text color={row.color} bold>
126            {row.head}
127          </Text>
128          <Text color={row.color}>{row.meter}</Text>
129          <Text dimColor>{row.size}</Text>
130          {row.time ? <Text dimColor>{row.time}</Text> : null}
131        </Box>
132      </Box>
133    )
134  })
135}
136
hooks/forecast.ts 153 lines
1import type { Reading } from '../types'
2
3export const HISTORY = 12
4const BARS = '▁▂▃▄▅▆▇█'
5/** How many recent turns the range averages over. */
6const RANGE_TURNS = 5
7const RANGE_MAX = 99
8
9type Five<T> = readonly [T, T, T, T, T]
10
11/**
12 * An analogy's look, the same in every language (the words are in i18n.ts):
13 * five steps over the shared thresholds, whether it counts what is used or what
14 * is left, how a past turn is charted, and the gauge it gives the prompt cache.
15 */
16export type Analogy = {
17  icons: Five<string>
18  colors: Five<string>
19  /** `left` shows what remains (a fuel gauge, hit points); `used` what is taken. */
20  counts: 'used' | 'left'
21  chart: 'bars' | 'steps'
22  cacheIcon: string
23  /** Labels at the ends of the cache meter, cold first. */
24  scale?: readonly [string, string]
25}
26
27/** Upper bounds (in % of the window used) of the first four steps; the fifth takes the rest. */
28const THRESHOLDS = [25, 50, 75, 90]
29
30export const ANALOGIES = {
31  weather: {
32    icons: ['☀', '☁', '☂', '☇', '↯'],
33    colors: ['yellow', 'cyan', 'blue', 'magenta', 'red'],
34    counts: 'used',
35    chart: 'bars',
36    cacheIcon: '°',
37  },
38  moon: {
39    icons: ['○', '◔', '◑', '◕', '●'],
40    colors: ['gray', 'cyan', 'blue', 'magenta', 'red'],
41    counts: 'used',
42    chart: 'steps',
43    cacheIcon: '✧',
44  },
45  hearts: {
46    icons: ['♥♥♥♥♥', '♥♥♥♥♡', '♥♥♥♡♡', '♥♥♡♡♡', '♥♡♡♡♡'],
47    colors: ['green', 'green', 'yellow', 'magenta', 'red'],
48    counts: 'left',
49    chart: 'bars',
50    cacheIcon: '✦',
51  },
52  fuel: {
53    icons: ['▰▰▰▰▰', '▰▰▰▰▱', '▰▰▰▱▱', '▰▰▱▱▱', '▰▱▱▱▱'],
54    colors: ['green', 'green', 'yellow', 'magenta', 'red'],
55    counts: 'left',
56    chart: 'bars',
57    cacheIcon: '♨',
58    scale: ['C', 'H'],
59  },
60  pot: {
61    icons: ['·', '~', '≈', '≋', '‼'],
62    colors: ['blue', 'cyan', 'yellow', 'magenta', 'red'],
63    counts: 'used',
64    chart: 'bars',
65    cacheIcon: '♨',
66  },
67  tide: {
68    icons: ['_', '~', '≈', '≋', '↟'],
69    colors: ['cyan', 'cyan', 'blue', 'magenta', 'red'],
70    counts: 'used',
71    chart: 'bars',
72    cacheIcon: '☼',
73  },
74  daytime: {
75    icons: ['☼', '☀', '◐', '☾', '✦'],
76    colors: ['yellow', 'yellow', 'cyan', 'blue', 'red'],
77    counts: 'used',
78    chart: 'bars',
79    cacheIcon: '♨',
80  },
81  volcano: {
82    icons: ['▲', '▲', '▲', '▲', '✸'],
83    colors: ['green', 'yellow', 'yellow', 'magenta', 'red'],
84    counts: 'used',
85    chart: 'bars',
86    cacheIcon: '◉',
87  },
88} as const satisfies Record<string, Analogy>
89
90export type AnalogyName = keyof typeof ANALOGIES
91export const DEFAULT_ANALOGY: AnalogyName = 'weather'
92export const ANALOGY_NAMES = Object.keys(ANALOGIES) as AnalogyName[]
93
94export const isAnalogy = (name: unknown): name is AnalogyName =>
95  typeof name === 'string' && Object.hasOwn(ANALOGIES, name)
96
97export const analogyOf = (name: unknown): AnalogyName => (isAnalogy(name) ? name : DEFAULT_ANALOGY)
98
99/** Share of the window used, 0–100. */
100export const percentOf = (r: Reading) => Math.min(100, (r.tokens / r.window) * 100)
101
102/** Which of the five steps a fill (in % used) is in, 0–4. */
103export const stepOf = (pct: number) => {
104  const i = THRESHOLDS.findIndex(t => pct < t)
105  return i === -1 ? THRESHOLDS.length : i
106}
107
108export function fmt(n: number): string {
109  if (n >= 1e6) return `${+(n / 1e6).toFixed(1)}M`
110  if (n >= 1e3) return `${+(n / 1e3).toFixed(1)}k`
111  return String(n)
112}
113
114/** One past turn in the chart: a bar of what the analogy counts, or its step glyph (moon phases). */
115export function barFor(pct: number, a: Analogy): string {
116  if (a.chart === 'steps') return a.icons[stepOf(pct)]!
117  const shown = a.counts === 'left' ? 100 - pct : pct
118  return BARS[Math.min(7, Math.floor(shown / 12.5))]!
119}
120
121/** Keeps the last HISTORY turns plus one more, so the oldest bar still has a delta. */
122export const push = (readings: Reading[], r: Reading) => [...readings, r].slice(-(HISTORY + 1))
123
124/** Tokens the last turn added (negative after a /compact). */
125export function delta(readings: Reading[]): number {
126  const last = readings[readings.length - 1]
127  if (!last) return 0
128  const prev = readings[readings.length - 2]
129  return last.tokens - (prev ? prev.tokens : 0)
130}
131
132/** The last turn's delta, signed the way the analogy counts: a fuel gauge drops as tokens are used. */
133export function deltaText(d: number, a: Analogy, last: string): string {
134  const isRise = a.counts === 'left' ? d < 0 : d >= 0
135  return `${isRise ? '▲ +' : '▼ −'}${fmt(Math.abs(d))} ${last}`
136}
137
138/**
139 * How many more turns fit in the window at the recent pace: the room left over the
140 * average growth of the last few turns, /compacts left out. Undefined until two turns grew.
141 */
142export function rangeOf(readings: Reading[]): number | undefined {
143  const growth: number[] = []
144  for (let i = readings.length - 1; i > 0 && growth.length < RANGE_TURNS; i--) {
145    const d = readings[i]!.tokens - readings[i - 1]!.tokens
146    if (d > 0) growth.push(d)
147  }
148  const now = readings[readings.length - 1]
149  if (!now || growth.length < 2) return undefined
150  const pace = growth.reduce((s, d) => s + d, 0) / growth.length
151  return Math.min(RANGE_MAX, Math.floor(Math.max(0, now.window - now.tokens) / pace))
152}
153
hooks/command.ts 77 lines
1import { ANALOGIES, ANALOGY_NAMES } from './forecast'
2import { CACHE_MODES, TTLS } from './cache'
3import { LANGS } from './i18n'
4
5/** The settings `/token-weather` changes: its word for each, and the `userConfig` field behind it. */
6export const SETTINGS = {
7  analogy: { field: 'analogy', values: ANALOGY_NAMES as readonly string[] },
8  cache: { field: 'cache', values: CACHE_MODES as readonly string[] },
9  ttl: { field: 'cacheTtl', values: Object.keys(TTLS) as readonly string[] },
10  lang: { field: 'language', values: LANGS as readonly string[] },
11} as const
12export type Setting = keyof typeof SETTINGS
13
14export type Parsed =
15  | { kind: 'list' }
16  | { kind: 'set'; setting: Setting; value: string }
17  | { kind: 'error'; text: string }
18
19const ALIASES: Record<string, Setting> = {
20  analogy: 'analogy',
21  cache: 'cache',
22  ttl: 'ttl',
23  cachettl: 'ttl',
24  lang: 'lang',
25  language: 'lang',
26}
27const SETTING_NAMES = Object.keys(SETTINGS) as Setting[]
28
29/** The one setting a bare value belongs to; the value lists do not overlap. */
30const settingOf = (value: string) => SETTING_NAMES.find(s => SETTINGS[s].values.includes(value))
31
32/**
33 * Reads `/token-weather` arguments: nothing lists the settings, `<setting> <value>` sets one,
34 * and a bare value (`moon`, `compact`, `5m`, `de`) sets the setting it belongs to.
35 */
36export function parseCommand(args: string): Parsed {
37  const words = args.trim().toLowerCase().split(/\s+/).filter(Boolean)
38  if (words.length === 0) return { kind: 'list' }
39
40  const named = ALIASES[words[0]!]
41  if (named) {
42    const value = words[1]
43    const { values } = SETTINGS[named]
44    if (!value) return { kind: 'error', text: `/token-weather ${named} takes one of: ${values.join(', ')}` }
45    if (!values.includes(value)) {
46      return { kind: 'error', text: `Unknown ${named} "${value}". Pick one of: ${values.join(', ')}` }
47    }
48    return { kind: 'set', setting: named, value }
49  }
50
51  const setting = settingOf(words[0]!)
52  if (words.length === 1 && setting) return { kind: 'set', setting, value: words[0]! }
53  return {
54    kind: 'error',
55    text: `Unknown "${words.join(' ')}". Try /token-weather to list the settings, e.g. /token-weather cache compact`,
56  }
57}
58
59/** What `/token-weather` alone shows: every setting with its current value marked. */
60export function settingsListing(current: Record<Setting, string>): string {
61  const rows = SETTING_NAMES.map(s => {
62    const values = SETTINGS[s].values.map(v => (v === current[s] ? `›${v}` : v)).join('  ')
63    return `  ${s.padEnd(8)} ${values}`
64  })
65  const analogies = ANALOGY_NAMES.map(name => {
66    const steps = ANALOGIES[name].icons.join('  ')
67    return `  ${name === current.analogy ? '›' : ' '} ${name.padEnd(8)} ${steps}`
68  })
69  return [
70    'token-weather settings (change with /token-weather <setting> <value>):',
71    ...rows,
72    '',
73    'Analogies:',
74    ...analogies,
75  ].join('\n')
76}
77
hooks/cache.ts 64 lines
1import type { CacheReading } from '../types'
2
3export type CacheMode = 'off' | 'compact' | 'full'
4export const CACHE_MODES: readonly CacheMode[] = ['full', 'compact', 'off']
5export const TTLS = { '1h': 60, '5m': 5 } as const
6export type Ttl = keyof typeof TTLS
7
8const MINUTE = 60_000
9const METER = 10
10
11export const cacheModeOf = (v: unknown): CacheMode =>
12  CACHE_MODES.includes(v as CacheMode) ? (v as CacheMode) : 'full'
13
14export const ttlOf = (v: unknown): Ttl => (v === '5m' || v === '1h' ? v : '1h')
15
16/** The cache line of one API response's usage, taken at `at` (ms since the epoch). */
17export function cacheReadingOf(
18  usage: { input_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number },
19  at: number,
20): CacheReading {
21  return {
22    read: usage.cache_read_input_tokens,
23    written: usage.cache_creation_input_tokens,
24    uncached: usage.input_tokens,
25    at,
26  }
27}
28
29export type CacheView = {
30  state: 'warm' | 'cooling' | 'cold'
31  /** Tokens held in the cache: what the last request read plus what it wrote. */
32  cached: number
33  /** Share of the last request's prompt the cache served, 0–100. */
34  hitPct: number
35  /** Whole minutes until the entry lapses (rounded up), 0 once cold. */
36  minsLeft: number
37  ttlMins: number
38  /** Share of the TTL still left, 0–1. */
39  left: number
40}
41
42/** Where the cache stands `now`: each request refreshes the entry for a full TTL. */
43export function cacheView(c: CacheReading, now: number, ttl: Ttl): CacheView {
44  const ttlMins = TTLS[ttl]
45  const msLeft = Math.max(0, c.at + ttlMins * MINUTE - now)
46  const prompt = c.read + c.written + c.uncached
47  const left = msLeft / (ttlMins * MINUTE)
48  return {
49    state: msLeft === 0 ? 'cold' : left > 0.5 ? 'warm' : 'cooling',
50    cached: c.read + c.written,
51    hitPct: prompt > 0 ? Math.round((c.read / prompt) * 100) : 0,
52    minsLeft: Math.ceil(msLeft / MINUTE),
53    ttlMins,
54    left,
55  }
56}
57
58export const CACHE_COLORS = { warm: 'green', cooling: 'yellow', cold: 'red' } as const
59
60export const meterFor = (left: number) => {
61  const full = Math.round(left * METER)
62  return '▰'.repeat(full) + '▱'.repeat(METER - full)
63}
64
hooks/band.ts 53 lines
1import type { Reading } from '../types'
2import { ANALOGIES, HISTORY, barFor, delta, deltaText, fmt, percentOf, rangeOf, stepOf } from './forecast'
3import type { AnalogyName } from './forecast'
4import { CACHE_COLORS, meterFor } from './cache'
5import type { CacheView } from './cache'
6import { PHRASES, WORDS, rangeText } from './i18n'
7import type { Lang } from './i18n'
8
9const STATE = { warm: 0, cooling: 1, cold: 2 } as const
10
11/** The pieces of the forecast row, in the analogy's look and the language's words. */
12export function forecastRow(rs: Reading[], name: AnalogyName, lang: Lang) {
13  const a = ANALOGIES[name]
14  const w = WORDS[lang][name]
15  const now = rs[rs.length - 1]!
16  const pct = percentOf(now)
17  const i = stepOf(pct)
18  const isLeft = a.counts === 'left'
19  return {
20    head: `${a.icons[i]} ${w.steps[i]}`,
21    color: a.colors[i] as string,
22    pct: isLeft ? `${100 - Math.floor(pct)}% ${PHRASES[lang].left}` : `${Math.floor(pct)}%`,
23    tokens: `${fmt(isLeft ? Math.max(0, now.window - now.tokens) : now.tokens)} / ${fmt(now.window)}`,
24    bars: rs.slice(-HISTORY).map(r => {
25      const p = percentOf(r)
26      return { glyph: barFor(p, a), color: a.colors[stepOf(p)] as string }
27    }),
28    delta: deltaText(delta(rs), a, w.last),
29    range: rangeText(rangeOf(rs), w.unit),
30  }
31}
32
33/** The pieces of the cache row (variant full): the analogy's gauge, its meter, size and time. */
34export function cacheRow(v: CacheView, context: number, name: AnalogyName, lang: Lang) {
35  const a: { cacheIcon: string; scale?: readonly [string, string] } = ANALOGIES[name]
36  const p = PHRASES[lang]
37  const isCold = v.state === 'cold'
38  const meter = meterFor(isCold ? 0 : v.left)
39  return {
40    head: `${a.cacheIcon} ${WORDS[lang][name].cache[STATE[v.state]]}`,
41    color: CACHE_COLORS[v.state] as string,
42    meter: a.scale ? `${a.scale[0]} ${meter} ${a.scale[1]}` : meter,
43    size: isCold ? p.rewrites(fmt(context)) : `${fmt(v.cached)} (${v.hitPct}% ${p.hit})`,
44    time: isCold ? undefined : p.timeLeft(v.minsLeft, v.ttlMins),
45  }
46}
47
48/** The cache as one segment on the forecast row (variant compact): `♨ engine warm 42′`. */
49export function cacheSegment(v: CacheView, name: AnalogyName, lang: Lang): string {
50  const head = `${ANALOGIES[name].cacheIcon} ${WORDS[lang][name].cache[STATE[v.state]]}`
51  return v.state === 'cold' ? head : `${head} ${v.minsLeft}′`
52}
53
hooks/i18n.ts 322 lines
1import type { AnalogyName } from './forecast'
2
3export const LANGS = ['en', 'de', 'es', 'it', 'fr'] as const
4export type Lang = (typeof LANGS)[number]
5export const DEFAULT_LANG: Lang = 'en'
6
7export const langOf = (v: unknown): Lang => (LANGS.includes(v as Lang) ? (v as Lang) : DEFAULT_LANG)
8
9/** An analogy's words in one language. */
10export type AnalogyWords = {
11  /** The five steps, emptiest first. */
12  steps: readonly [string, string, string, string, string]
13  /** What a turn is called, one and many (`trip`, `trips`): the range counts them. */
14  unit: readonly [string, string]
15  /** How the last turn's delta is labelled (`last trip`). */
16  last: string
17  /** The cache gauge: warm, cooling, cold. */
18  cache: readonly [string, string, string]
19}
20
21/** The words every analogy shares in one language. */
22export type Phrases = {
23  /** After the percentage of an analogy that counts what is left (`33% left`). */
24  left: string
25  /** After the cache's hit rate (`92% hit`). */
26  hit: string
27  timeLeft: (mins: number, ttl: number) => string
28  rewrites: (tokens: string) => string
29}
30
31export const PHRASES: Record<Lang, Phrases> = {
32  en: {
33    left: 'left',
34    hit: 'hit',
35    timeLeft: (m, t) => `${m} of ${t} min left`,
36    rewrites: k => `next turn rewrites ${k}`,
37  },
38  de: {
39    left: 'übrig',
40    hit: 'Treffer',
41    timeLeft: (m, t) => `noch ${m} von ${t} min`,
42    rewrites: k => `nächster Turn schreibt ${k} neu`,
43  },
44  es: {
45    left: 'restante',
46    hit: 'acierto',
47    timeLeft: (m, t) => `quedan ${m} de ${t} min`,
48    rewrites: k => `el próximo turno reescribe ${k}`,
49  },
50  it: {
51    left: 'rimasto',
52    hit: 'hit',
53    timeLeft: (m, t) => `ancora ${m} di ${t} min`,
54    rewrites: k => `il prossimo turno riscrive ${k}`,
55  },
56  fr: {
57    left: 'restant',
58    hit: 'succès',
59    timeLeft: (m, t) => `encore ${m} sur ${t} min`,
60    rewrites: k => `le prochain tour réécrit ${k}`,
61  },
62}
63
64export const WORDS: Record<Lang, Record<AnalogyName, AnalogyWords>> = {
65  en: {
66    weather: {
67      steps: ['Clear', 'Cloudy', 'Showers', 'Storm', 'Compact soon'],
68      unit: ['turn', 'turns'],
69      last: 'last turn',
70      cache: ['mild', 'chilly', 'frost'],
71    },
72    moon: {
73      steps: ['New moon', 'Waxing', 'Half moon', 'Gibbous', 'Full moon, compact soon'],
74      unit: ['night', 'nights'],
75      last: 'last night',
76      cache: ['lantern lit', 'lantern dim', 'lantern out'],
77    },
78    hearts: {
79      steps: ['Full health', 'Scratched', 'Wounded', 'Critical', 'Low HP, /compact to heal'],
80      unit: ['hit', 'hits'],
81      last: 'last hit',
82      cache: ['buff active', 'buff fading', 'buff gone'],
83    },
84    fuel: {
85      steps: ['Full tank', 'Three quarters', 'Half tank', 'Reserve', 'Refuel, /compact'],
86      unit: ['trip', 'trips'],
87      last: 'last trip',
88      cache: ['engine warm', 'engine cooling', 'cold start'],
89    },
90    pot: {
91      steps: ['Cold', 'Warm', 'Simmering', 'Boiling', 'Boiling over'],
92      unit: ['ladle', 'ladles'],
93      last: 'last ladle',
94      cache: ['stove hot', 'stove cooling', 'stove cold'],
95    },
96    tide: {
97      steps: ['Low tide', 'Calm', 'Swell', 'Surf', 'Storm surge'],
98      unit: ['wave', 'waves'],
99      last: 'last wave',
100      cache: ['water warm', 'water cooling', 'water cold'],
101    },
102    daytime: {
103      steps: ['Morning', 'Noon', 'Afternoon', 'Evening', 'Midnight, time to compact'],
104      unit: ['hour', 'hours'],
105      last: 'last hour',
106      cache: ['coffee hot', 'coffee cooling', 'coffee cold'],
107    },
108    volcano: {
109      steps: ['Dormant', 'Rumbling', 'Smoking', 'Glowing', 'Eruption, compact now'],
110      unit: ['quake', 'quakes'],
111      last: 'last quake',
112      cache: ['lava glowing', 'lava cooling', 'rock cold'],
113    },
114  },
115  de: {
116    weather: {
117      steps: ['Klar', 'Bewölkt', 'Schauer', 'Gewitter', 'Bald compacten'],
118      unit: ['Turn', 'Turns'],
119      last: 'letzter Turn',
120      cache: ['mild', 'kühl', 'Frost'],
121    },
122    moon: {
123      steps: ['Neumond', 'Zunehmend', 'Halbmond', 'Fast voll', 'Vollmond, bald compacten'],
124      unit: ['Nacht', 'Nächte'],
125      last: 'letzte Nacht',
126      cache: ['Laterne an', 'Laterne schwach', 'Laterne aus'],
127    },
128    hearts: {
129      steps: ['Volle Gesundheit', 'Kratzer', 'Verwundet', 'Kritisch', 'Wenig HP, /compact heilt'],
130      unit: ['Treffer', 'Treffer'],
131      last: 'letzter Treffer',
132      cache: ['Buff aktiv', 'Buff schwindet', 'Buff weg'],
133    },
134    fuel: {
135      steps: ['Voller Tank', 'Dreiviertel', 'Halber Tank', 'Reserve', 'Tanken, /compact'],
136      unit: ['Fahrt', 'Fahrten'],
137      last: 'letzte Fahrt',
138      cache: ['Motor warm', 'Motor kühlt ab', 'Kaltstart'],
139    },
140    pot: {
141      steps: ['Kalt', 'Lauwarm', 'Köchelt', 'Kocht', 'Kocht über'],
142      unit: ['Kelle', 'Kellen'],
143      last: 'letzte Kelle',
144      cache: ['Herd heiß', 'Herd kühlt ab', 'Herd kalt'],
145    },
146    tide: {
147      steps: ['Ebbe', 'Ruhig', 'Dünung', 'Brandung', 'Sturmflut'],
148      unit: ['Welle', 'Wellen'],
149      last: 'letzte Welle',
150      cache: ['Wasser warm', 'Wasser kühlt ab', 'Wasser kalt'],
151    },
152    daytime: {
153      steps: ['Morgen', 'Mittag', 'Nachmittag', 'Abend', 'Mitternacht, Zeit zu compacten'],
154      unit: ['Stunde', 'Stunden'],
155      last: 'letzte Stunde',
156      cache: ['Kaffee heiß', 'Kaffee kühlt ab', 'Kaffee kalt'],
157    },
158    volcano: {
159      steps: ['Ruhend', 'Grollt', 'Raucht', 'Glüht', 'Ausbruch, jetzt compacten'],
160      unit: ['Beben', 'Beben'],
161      last: 'letztes Beben',
162      cache: ['Lava glüht', 'Lava kühlt ab', 'Gestein kalt'],
163    },
164  },
165  es: {
166    weather: {
167      steps: ['Despejado', 'Nublado', 'Chubascos', 'Tormenta', 'Compacta pronto'],
168      unit: ['turno', 'turnos'],
169      last: 'último turno',
170      cache: ['templado', 'fresco', 'helada'],
171    },
172    moon: {
173      steps: ['Luna nueva', 'Creciente', 'Media luna', 'Gibosa', 'Luna llena, compacta pronto'],
174      unit: ['noche', 'noches'],
175      last: 'última noche',
176      cache: ['farol encendido', 'farol tenue', 'farol apagado'],
177    },
178    hearts: {
179      steps: ['Salud completa', 'Rasguño', 'Herido', 'Crítico', 'Poca vida, /compact cura'],
180      unit: ['golpe', 'golpes'],
181      last: 'último golpe',
182      cache: ['buff activo', 'buff se agota', 'buff perdido'],
183    },
184    fuel: {
185      steps: ['Depósito lleno', 'Tres cuartos', 'Medio depósito', 'Reserva', 'Repostar, /compact'],
186      unit: ['viaje', 'viajes'],
187      last: 'último viaje',
188      cache: ['motor caliente', 'motor enfriándose', 'arranque en frío'],
189    },
190    pot: {
191      steps: ['Frío', 'Tibio', 'A fuego lento', 'Hirviendo', 'Se desborda'],
192      unit: ['cucharón', 'cucharones'],
193      last: 'último cucharón',
194      cache: ['fogón caliente', 'fogón enfriándose', 'fogón frío'],
195    },
196    tide: {
197      steps: ['Marea baja', 'Calma', 'Mar de fondo', 'Oleaje', 'Marejada'],
198      unit: ['ola', 'olas'],
199      last: 'última ola',
200      cache: ['agua templada', 'agua enfriándose', 'agua fría'],
201    },
202    daytime: {
203      steps: ['Mañana', 'Mediodía', 'Tarde', 'Anochecer', 'Medianoche, hora de compactar'],
204      unit: ['hora', 'horas'],
205      last: 'última hora',
206      cache: ['café caliente', 'café enfriándose', 'café frío'],
207    },
208    volcano: {
209      steps: ['Dormido', 'Retumba', 'Humea', 'Brilla', 'Erupción, compacta ya'],
210      unit: ['temblor', 'temblores'],
211      last: 'último temblor',
212      cache: ['lava ardiente', 'lava enfriándose', 'roca fría'],
213    },
214  },
215  it: {
216    weather: {
217      steps: ['Sereno', 'Nuvoloso', 'Rovesci', 'Temporale', 'Compatta presto'],
218      unit: ['turno', 'turni'],
219      last: 'ultimo turno',
220      cache: ['mite', 'fresco', 'gelo'],
221    },
222    moon: {
223      steps: ['Luna nuova', 'Crescente', 'Mezza luna', 'Gibbosa', 'Luna piena, compatta presto'],
224      unit: ['notte', 'notti'],
225      last: 'ultima notte',
226      cache: ['lanterna accesa', 'lanterna fioca', 'lanterna spenta'],
227    },
228    hearts: {
229      steps: ['Salute piena', 'Graffio', 'Ferito', 'Critico', 'Poca vita, /compact cura'],
230      unit: ['colpo', 'colpi'],
231      last: 'ultimo colpo',
232      cache: ['buff attivo', 'buff in calo', 'buff finito'],
233    },
234    fuel: {
235      steps: ['Pieno', 'Tre quarti', 'Mezzo serbatoio', 'Riserva', 'Fai benzina, /compact'],
236      unit: ['viaggio', 'viaggi'],
237      last: 'ultimo viaggio',
238      cache: ['motore caldo', 'motore si raffredda', 'avvio a freddo'],
239    },
240    pot: {
241      steps: ['Freddo', 'Tiepido', 'Sobbolle', 'Bolle', 'Trabocca'],
242      unit: ['mestolo', 'mestoli'],
243      last: 'ultimo mestolo',
244      cache: ['fornello caldo', 'fornello si raffredda', 'fornello freddo'],
245    },
246    tide: {
247      steps: ['Bassa marea', 'Calmo', 'Onda lunga', 'Risacca', 'Mareggiata'],
248      unit: ['onda', 'onde'],
249      last: 'ultima onda',
250      cache: ['acqua calda', 'acqua si raffredda', 'acqua fredda'],
251    },
252    daytime: {
253      steps: ['Mattina', 'Mezzogiorno', 'Pomeriggio', 'Sera', 'Mezzanotte, ora di compattare'],
254      unit: ['ora', 'ore'],
255      last: 'ultima ora',
256      cache: ['caffè caldo', 'caffè si raffredda', 'caffè freddo'],
257    },
258    volcano: {
259      steps: ['Quiescente', 'Brontola', 'Fuma', 'Arde', 'Eruzione, compatta ora'],
260      unit: ['scossa', 'scosse'],
261      last: 'ultima scossa',
262      cache: ['lava ardente', 'lava si raffredda', 'roccia fredda'],
263    },
264  },
265  fr: {
266    weather: {
267      steps: ['Dégagé', 'Nuageux', 'Averses', 'Orage', 'Compacter bientôt'],
268      unit: ['tour', 'tours'],
269      last: 'dernier tour',
270      cache: ['doux', 'frais', 'gel'],
271    },
272    moon: {
273      steps: ['Nouvelle lune', 'Croissant', 'Demi-lune', 'Gibbeuse', 'Pleine lune, compacter bientôt'],
274      unit: ['nuit', 'nuits'],
275      last: 'dernière nuit',
276      cache: ['lanterne allumée', 'lanterne faible', 'lanterne éteinte'],
277    },
278    hearts: {
279      steps: ['Pleine santé', 'Égratigné', 'Blessé', 'Critique', 'PV bas, /compact soigne'],
280      unit: ['coup', 'coups'],
281      last: 'dernier coup',
282      cache: ['buff actif', 'buff faiblit', 'buff perdu'],
283    },
284    fuel: {
285      steps: ['Plein', 'Trois quarts', 'Demi-réservoir', 'Réserve', 'Faire le plein, /compact'],
286      unit: ['trajet', 'trajets'],
287      last: 'dernier trajet',
288      cache: ['moteur chaud', 'moteur refroidit', 'démarrage à froid'],
289    },
290    pot: {
291      steps: ['Froid', 'Tiède', 'Mijote', 'Bout', 'Déborde'],
292      unit: ['louche', 'louches'],
293      last: 'dernière louche',
294      cache: ['feu chaud', 'feu refroidit', 'feu froid'],
295    },
296    tide: {
297      steps: ['Marée basse', 'Calme', 'Houle', 'Ressac', 'Submersion'],
298      unit: ['vague', 'vagues'],
299      last: 'dernière vague',
300      cache: ['eau chaude', 'eau refroidit', 'eau froide'],
301    },
302    daytime: {
303      steps: ['Matin', 'Midi', 'Après-midi', 'Soir', "Minuit, l'heure de compacter"],
304      unit: ['heure', 'heures'],
305      last: 'dernière heure',
306      cache: ['café chaud', 'café refroidit', 'café froid'],
307    },
308    volcano: {
309      steps: ['Endormi', 'Gronde', 'Fume', 'Rougeoie', 'Éruption, compacter maintenant'],
310      unit: ['secousse', 'secousses'],
311      last: 'dernière secousse',
312      cache: ['lave ardente', 'lave refroidit', 'roche froide'],
313    },
314  },
315}
316
317/** `~5 trips`; `~? trips` until the pace is known; `~99+` at the cap. */
318export function rangeText(n: number | undefined, unit: readonly [string, string]): string {
319  if (n === undefined) return `~? ${unit[1]}`
320  return `~${n >= 99 ? '99+' : n} ${n === 1 ? unit[0] : unit[1]}`
321}
322
types/index.d.ts 11 lines
1export type Reading = { tokens: number; window: number }
2
3/** The prompt cache as the last main-thread API response reported it, and when (ms since the epoch). */
4export type CacheReading = { read: number; written: number; uncached: number; at: number }
5
6declare module 'claude-code' {
7  interface PluginState {
8    'token-weather': { readings: Reading[]; cache: CacheReading | null; tick: number }
9  }
10}
11