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

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
| Piece | Motorbike | Shows |
|---|---|---|
▰▰▱▱▱ Reserve | fuel gauge | the step the fill is in (five steps, below) |
20% left · 40k / 200k | how much of the window is left (or used, by analogy) | |
▇▆▅▃▂ | trip log | the last 12 turns |
▼ −22.1k last trip | consumption | what the last turn added (▲ back up after a /compact) |
~2 trips | range | how many more turns fit at the pace of the last five (a /compact left out); ~? until two turns grew |
Row 2, the prompt cache
| Piece | Motorbike | Shows |
|---|---|---|
♨ engine warm | engine temperature | warm (more than half the lifetime left, green), cooling (yellow), cold (red) |
C ▰▰▰▰▰▰▰▱▱▱ H | temperature gauge | lifetime 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 left | until 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.
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.
| Analogy | Steps | Cache: warm → cooling → cold | A turn is |
|---|---|---|---|
weather | ☀ Clear · ☁ Cloudy · ☂ Showers · ☇ Storm · ↯ Compact soon | ° mild → chilly → frost | turn |
moon | ○ New moon · ◔ Waxing · ◑ Half moon · ◕ Gibbous · ● Full moon | ✧ lantern lit → dim → out | night |
hearts | ♥♥♥♥♥ Full health · ♥♥♥♥♡ Scratched · ♥♥♥♡♡ Wounded · ♥♥♡♡♡ Critical · ♥♡♡♡♡ Low HP | ✦ buff active → fading → gone | hit |
fuel | ▰▰▰▰▰ Full tank · ▰▰▰▰▱ Three quarters · ▰▰▰▱▱ Half tank · ▰▰▱▱▱ Reserve · ▰▱▱▱▱ Refuel | ♨ engine warm → cooling → cold start | trip |
pot | · Cold · ~ Warm · ≈ Simmering · ≋ Boiling · ‼ Boiling over | ♨ stove hot → cooling → cold | ladle |
tide | _ Low tide · ~ Calm · ≈ Swell · ≋ Surf · ↟ Storm surge | ☼ water warm → cooling → cold | wave |
daytime | ☼ Morning · ☀ Noon · ◐ Afternoon · ☾ Evening · ✦ Midnight | ♨ coffee hot → cooling → cold | hour |
volcano | ▲ Dormant · ▲ Rumbling · ▲ Smoking · ▲ Glowing · ✸ Eruption | ◉ lava glowing → cooling → rock cold | quake |
moon charts past turns as moon phases (○◔◑◕●) instead of bars.
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.
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.
| Setting | Option | Values | Default |
|---|---|---|---|
| analogy | analogy | weather moon hearts fuel pot tide daytime volcano | weather |
| lang | language | en de es it fr | en |
| cache | cache | full (its own row), compact (one segment on the forecast row: ♨ engine warm 42′), off | full |
| ttl | cacheTtl | 1h 5m | 1h |
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" }
}
}
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.
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:
| File | What |
|---|---|
hooks/forecast.ts | each analogy's look (ANALOGIES: icons, colors, counting direction, cache gauge), thresholds, delta and range |
hooks/i18n.ts | every word, per language and analogy (WORDS, PHRASES) |
hooks/cache.ts | the cache state over its lifetime |
hooks/band.ts | both rows put together from the above |
hooks/command.ts | the /token-weather arguments |
hooks/register.tsx | the 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.
MIT, see LICENSE.
hooks/register.tsx 136 lines1import { 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}
136hooks/forecast.ts 153 lines1import 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}
153hooks/command.ts 77 lines1import { 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}
77hooks/cache.ts 64 lines1import 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}
64hooks/band.ts 53 lines1import 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}
53hooks/i18n.ts 322 lines1import 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}
322types/index.d.ts 11 lines1export 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