A band above the prompt: prompt-cache countdown, cache fill, hit rate, context fill, auto-compact threshold and a Compact button. 24 languages.

A Claude Code mod that keeps the prompt cache and the context window in view: one row above the prompt, in the Desktop Code tab and in the terminal.
● cache 42m ■■■■■■■■■■■■■■■■■■□□ 93% hit · ☀️ Context 9% (Auto-Compact 97%) [Compact]
Why: when the prompt cache lapses, the next message re-writes the whole context at the cache-write price. The band shows how long the cache stays warm, how much of the last request came from it, how full the context is, and puts /compact one click away.
| Part | Meaning | Display |
|---|---|---|
● cache 42m | Time left on the prompt cache, counted from the last model response of the main conversation. While a turn runs it stays at the full lifetime. A keepwarm ping (e.g. from cache-tax) that reads from the cache restarts it too. | Green above half the lifetime, yellow above a sixth, red below (31–60 / 11–30 / 0–10 minutes of an hour). cold once expired, – before the first response. |
| Bar (up to 20 cells) | How much of the last request sits in the cache: (cache_read + cache_creation) ÷ input | Colour of the countdown; grey and hollow when cold; shortens (down to 6) so the row stays on one line |
93% hit | Hit rate of the last request: cache_read ÷ input | Kept when cold — it describes the last request |
| ☀️ / ⛅ / 🌧️ | Context weather | ☀️ below 40 %, ⛅ 40–69 %, 🌧️ from 70 % (time to /compact) |
Context 9% | Fill of the context window, the status line's figure | – before the first response and right after a compaction |
(Auto-Compact 97%) | Where auto-compaction triggers, as a share of the window | (Auto-Compact off) when disabled |
[Compact] | Runs /compact as if typed; its progress and result show in the transcript | Reads Compacting… until done; dimmed while a turn runs, a press then says it has to wait; a failure is named in the transcript |
Every word is translated, Compact and Auto-Compact included (German: komprimieren, Auto-Komprimierung). Numbers follow the language's locale: 42m and 93% in English, 42 Min. and 93 % in German, 42 分 in Japanese, Arabic-Indic digits (٤٢ د, ٩٣٪) in Arabic. Arabic and Hebrew mirror the row in the Desktop app: it reads from the right, the bar fills from the right, the button sits on the left, and each piece is set right-to-left, numbers and parentheses included.
Other mods may draw into the same band; their rows appear beneath this one.
The bar gives way when the band is narrow, so the row stays on one line. In the terminal the fit is exact; in the Desktop app, which sets a proportional font, it is estimated with some room to spare.
Needs Claude Code 2.1.287 or later (mods are on by default).
claude plugin marketplace add CTeitzel/claude-code-cache-band
claude plugin install cache-band@cache-band
The repository is claude-code-cache-band; inside Claude Code the plugin and its marketplace are both cache-band, hence cache-band@cache-band.
Restart Claude Code or run /reload-plugins. The band appears once the session has started — in the Desktop app, after the first message is sent.
To work on a checkout instead, load it for one session with claude --plugin-dir <folder>, or for every session with CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json (do not install it from the marketplace as well, or it loads twice).
Both are rows in /config, or set them in ~/.claude/settings.json:
"pluginConfigs": {
"cache-band@cache-band": { "options": { "language": "de", "cacheTtl": "auto" } }
}
(For a --plugin-dir / CLAUDE_CODE_PLUGIN_DIRS checkout the key is cache-band@inline.)
| Option | Values | Default |
|---|---|---|
language | auto, en, de, fr, es, it, pt, nl, pl, sv, cs, el, tr, ru, uk, ar, he, hi, th, id, vi, ja, ko, zh (simplified), zh-TW (traditional) | auto: Claude Code's language setting, then the system locale (zh-Hant, zh-TW, zh-HK, zh-MO → traditional), then English |
cacheTtl | auto, 1h, 5m | auto: CLAUDE_CODE_PROMPT_CACHE_TTL, then promptCacheTtl, else 1h |
rtlInTerminal | true, false | false: in the terminal, Arabic and Hebrew show in English, because many terminals draw right-to-left script reversed. Turn it on if yours handles it; the Desktop app always mirrors. |
The cache lifetime is one hour on a Claude subscription and five minutes on an API key, Bedrock, Vertex or Foundry unless promptCacheTtl says otherwise. Nothing the mod can read tells the two apart, so auto assumes one hour: on an API key without promptCacheTtl, set cacheTtl to 5m.
/autocompact may only show in the next session.| File | Content |
|---|---|
hooks/register.tsx | the hooks: readings, countdown restarts, the Compact button, the band |
hooks/model.ts | pure maths: countdown, lifetime, bar, weather, auto-compact label |
hooks/i18n.ts | the 24 languages, their number locales, right-to-left, and how auto picks one |
types/index.d.ts | the $.state contract |
tests/band.test.tsx | 27 tests against the engine (claude plugin test) |
.claude/CLAUDE.md | notes for AI sessions working on the mod: invariants, where each figure comes from |
docs/dev-log/ | development log (German) |
claude plugin validate .
claude plugin test .
CI runs both on every push. To see what the bar is fitted to, start Claude Code with CACHE_BAND_DEBUG=1: the transcript gets one line per surface and width (cache-band debug: language=de surface=desktop bodyColumns=… bar=…), and the command /cache-band-language <code> draws the band in another language for that session, without touching the settings. A strict type-check needs the declarations Claude Code writes into .claude-plugin/types/ when it loads the mod; load the checkout once (--plugin-dir or CLAUDE_CODE_PLUGIN_DIRS), then npx -p typescript tsc -p ..
MIT © 2026 Dr. Christian Teitzel
hooks/register.tsx 344 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register, SessionContextUsage } from 'claude-code'
3
4import type { AutoCompact, Preferences } from '../types'
5import { LANGUAGES, bidiFor, packForSurface, resolveLanguage } from './i18n'
6import {
7 DESKTOP_WIDTHS,
8 HOUR_MS,
9 TERMINAL_WIDTHS,
10 autoCompactLabel,
11 barCells,
12 cacheLabel,
13 cacheView,
14 filledCells,
15 readingFrom,
16 resolveTtl,
17 weather,
18} from './model'
19
20const preferences = atom({ plugin: 'cache-band', key: 'preferences' } as const, {
21 language: 'en',
22 ttlMs: HOUR_MS,
23 rtlInTerminal: false,
24})
25const cache = atom({ plugin: 'cache-band', key: 'cache' } as const, null)
26const context = atom({ plugin: 'cache-band', key: 'context' } as const, null)
27const autoCompact = atom({ plugin: 'cache-band', key: 'autoCompact' } as const, null)
28const isCompacting = atom({ plugin: 'cache-band', key: 'isCompacting' } as const, false)
29
30const REDRAW_MS = 30_000
31
32function text(value: unknown): string | undefined {
33 return typeof value === 'string' ? value : undefined
34}
35
36/** The options (`/config`) over Claude Code's settings and environment: at start and when the settings change. */
37async function readPreferences($: EngineInterface, options: PluginOptions): Promise<void> {
38 const settings: Record<string, unknown> = await $.settings.read().catch(() => ({}))
39 const locale = (await $.env.get('LC_ALL')) ?? (await $.env.get('LANG'))
40 const value: Preferences = {
41 language: resolveLanguage(text(options.language) ?? 'auto', text(settings.language), locale),
42 ttlMs: resolveTtl(
43 text(options.cacheTtl) ?? 'auto',
44 await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'),
45 text(settings.promptCacheTtl),
46 ),
47 rtlInTerminal: options.rtlInTerminal === true,
48 }
49 await update($, preferences, () => value)
50}
51
52async function storeFill($: EngineInterface, live: SessionContextUsage): Promise<void> {
53 const fill = live.percent ?? null
54 await update($, context, () => fill)
55}
56
57/** Counts the window locally to read the threshold: at start, and when the model or the settings change. */
58async function measureAutoCompact($: EngineInterface): Promise<void> {
59 try {
60 const { context: counted } = await $.session.usage({ breakdown: 'summary' })
61 const breakdown = counted.breakdown
62
63 if (breakdown === undefined) {
64 return
65 }
66
67 const threshold = breakdown.autoCompactThreshold
68 const value: AutoCompact = {
69 isEnabled: breakdown.isAutoCompactEnabled,
70 percent: threshold === undefined ? null : Math.round((threshold / counted.window) * 100),
71 }
72 await update($, autoCompact, () => value)
73 } catch {
74 // No breakdown without a bound session; the band leaves the figure out.
75 }
76}
77
78/** After a compaction nothing is cached and the fill is unknown until the next response. */
79async function forgetWindow($: EngineInterface): Promise<void> {
80 await update($, cache, () => null)
81 await update($, context, () => null)
82}
83
84/**
85 * Runs `/compact` the way the person typing it does (`$.command.run`): queued for the engine, its
86 * progress and result in the transcript. `$.session.compact()` from the press left no compaction
87 * behind in the Desktop app (2026-10-11).
88 */
89async function runCompact($: EngineInterface, isWorking: boolean, surface: string): Promise<void> {
90 const { language, rtlInTerminal } = await read($, preferences)
91 const { words } = packForSurface(language, surface, rtlInTerminal)
92
93 if (isWorking) {
94 $.ui.toast(words.busy)
95
96 return
97 }
98
99 if (await read($, isCompacting)) {
100 return
101 }
102
103 await update($, isCompacting, () => true)
104
105 try {
106 await $.command.run({ command: 'compact' })
107 // A compaction leaves the window without a reading until the next response; a vetoed one keeps
108 // its fill. Whose session.compact hook ran is the engine's business: read the result instead.
109 const { context: live } = await $.session.usage()
110 await storeFill($, live)
111
112 if (live.percent === undefined) {
113 await update($, cache, () => null)
114 }
115 } catch (error) {
116 const message = words.failed(error instanceof Error ? error.message : String(error))
117 $.ui.toast(message)
118 $.ui.log(`cache-band: ${message}`)
119 } finally {
120 await update($, isCompacting, () => false)
121 }
122}
123
124// With CACHE_BAND_DEBUG=1, one transcript line per surface and width: what the bar was fitted to.
125let lastWidthNote = ''
126
127async function noteWidth($: EngineInterface, note: string): Promise<void> {
128 if (note === lastWidthNote || (await $.env.get('CACHE_BAND_DEBUG')) !== '1') {
129 return
130 }
131
132 lastWidthNote = note
133 $.ui.log(`cache-band debug: ${note}`)
134}
135
136export const register: Register = (on, options) => {
137 on('session.start', async ($, e, next) => {
138 $.clock.every(REDRAW_MS, () => $.ui.invalidate('ui.render'))
139 const result = await next(e)
140 await readPreferences($, options)
141
142 try {
143 await storeFill($, (await $.session.usage()).context)
144 } catch {
145 // Measured after the first turn instead.
146 }
147 await measureAutoCompact($)
148
149 if ((await $.env.get('CACHE_BAND_DEBUG')) === '1') {
150 await $.command.register({
151 name: 'cache-band-language',
152 description: 'cache-band debug: draw the band in another language for this session',
153 argumentHint: '<code>',
154 })
155 }
156
157 return result
158 })
159
160 // Debug only (registered with CACHE_BAND_DEBUG=1): try a language without touching the settings.
161 on('command.run', { command: 'cache-band-language' }, async ($, e) => {
162 const code = e.args.trim()
163
164 if (!(LANGUAGES as readonly string[]).includes(code)) {
165 // Claude Code already names the plugin before a command's output: no own prefix.
166 return { text: `unknown language "${code}"; one of ${LANGUAGES.join(', ')}` }
167 }
168
169 await update($, preferences, current => ({ ...current, language: code }))
170
171 return { text: `language ${code} for this session` }
172 })
173
174 on('classic.PostModelSwitch', async ($, e, next) => {
175 const result = await next(e)
176 await measureAutoCompact($)
177
178 return result
179 })
180
181 on('classic.ConfigChange', async ($, e, next) => {
182 const result = await next(e)
183 await readPreferences($, options)
184 await measureAutoCompact($)
185
186 return result
187 })
188
189 on('config.set', { key: 'autoCompact' }, async ($, e, next) => {
190 const result = await next(e)
191 await measureAutoCompact($)
192
193 return result
194 })
195
196 on('session.end', async ($, e, next) => {
197 if (e.reason === 'clear') {
198 await forgetWindow($)
199 }
200
201 return next(e)
202 })
203
204 // Pushed after each main-thread turn.
205 on('session.measure', async ($, e, next) => {
206 if (e.changed.includes('context')) {
207 await storeFill($, e.context)
208 }
209
210 return next(e)
211 })
212
213 // Every model request of the main thread refreshes the cache: restart the countdown there.
214 on('turn.step', async function* ($, e, next) {
215 const result = yield* next(e)
216
217 if (e.agentId === undefined && result.usage !== null) {
218 const reading = readingFrom(result.usage, await $.clock.now())
219
220 if (reading !== null) {
221 await update($, cache, () => reading)
222 }
223 }
224
225 return result
226 })
227
228 // A fork reads the main thread's cached prefix (cache-tax's keepwarm pings): a read refreshes it.
229 // Bar and hit rate stay those of the last real message.
230 on('model.fork', async ($, e, next) => {
231 const result = await next(e)
232
233 const reply = 'value' in result ? result.value : undefined
234
235 if (reply !== undefined && 'usage' in reply && reply.usage.cache_read_input_tokens > 0) {
236 const at = await $.clock.now()
237 await update($, cache, reading => (reading === null ? null : { ...reading, at }))
238 }
239
240 return result
241 })
242
243 on('session.compact', async ($, e, next) => {
244 const result = await next(e)
245
246 if (e.agentId === undefined && e.trigger !== 'precompute' && result.skip === undefined) {
247 await forgetWindow($)
248 }
249
250 return result
251 })
252
253 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
254 if (e.props.hasSurvey) {
255 return next(e)
256 }
257
258 const { Box, Text, Button } = $.ui.resolve(e)
259 const { language, ttlMs, rtlInTerminal } = await read($, preferences)
260 const pack = packForSurface(language, e.surface, rtlInTerminal)
261 const { words } = pack
262 // Right-to-left mirrors the row, each group in it and the bar, which then fills from the right.
263 const direction = pack.isRtl ? 'row-reverse' : 'row'
264 const bidi = bidiFor(pack, e.surface)
265 const reading = await read($, cache)
266 const fill = await read($, context)
267 const autoCompactText = autoCompactLabel(await read($, autoCompact), pack)
268 const compacting = await read($, isCompacting)
269 const isBusy = e.props.isWorking || compacting
270 const now = await $.clock.now()
271
272 const view = cacheView(reading, now, e.props.isWorking, ttlMs)
273 const countdownText = bidi(cacheLabel(view, pack))
274 const hitText = reading === null ? '–' : bidi(pack.percent(reading.hit))
275 const contextText = fill === null ? '–' : bidi(pack.percent(fill / 100))
276 const buttonLabel = bidi(compacting ? words.compacting : words.compact)
277 const texts = ['●', words.cache, countdownText, hitText, words.hit, '·', words.context, contextText, buttonLabel]
278 // The bar gives way so the row stays on one line: four gaps between the row's five parts, two
279 // in each group of three, one more beside the auto-compact label.
280 const cells = barCells(
281 e.props.bodyColumns,
282 autoCompactText === null ? texts : [...texts, autoCompactText],
283 autoCompactText === null ? 10 : 11,
284 e.surface === 'terminal' ? TERMINAL_WIDTHS : DESKTOP_WIDTHS,
285 )
286 const filled = filledCells(reading?.stored ?? 0, view.state !== 'warm', cells)
287 await noteWidth($, `language=${pack.language} surface=${e.surface} bodyColumns=${e.props.bodyColumns} bar=${cells}`)
288 // Other mods share the band (cache-tax adds its keepwarm row there): ours on top, theirs beneath.
289 const rest = await next(e)
290
291 const band = (
292 <Box key="row" flexDirection={direction} flexWrap="wrap" alignItems="center" columnGap={1}>
293 <Box flexDirection={direction} columnGap={1}>
294 <Text color={view.tone}>●</Text>
295 <Text dimColor>{bidi(words.cache)}</Text>
296 <Box key="countdown">
297 <Text color={view.tone} bold>
298 {countdownText}
299 </Text>
300 </Box>
301 </Box>
302 <Box key="bar" flexDirection={direction}>
303 {filled > 0 && <Text color={view.tone}>{'■'.repeat(filled)}</Text>}
304 {filled < cells && <Text color="gray">{'□'.repeat(cells - filled)}</Text>}
305 </Box>
306 <Box flexDirection={direction} columnGap={1}>
307 <Box key="hit">
308 <Text bold>{hitText}</Text>
309 </Box>
310 <Text dimColor>{bidi(words.hit)}</Text>
311 <Text dimColor>·</Text>
312 </Box>
313 <Box flexDirection={direction} columnGap={1}>
314 <Box key="weather">
315 <Text>{weather(fill)}</Text>
316 </Box>
317 <Text dimColor>{bidi(words.context)}</Text>
318 <Box key="context">
319 <Text bold>{contextText}</Text>
320 </Box>
321 {autoCompactText !== null && (
322 <Box key="autoCompact">
323 <Text dimColor>{bidi(autoCompactText)}</Text>
324 </Box>
325 )}
326 </Box>
327 <Button
328 key="compact"
329 label={buttonLabel}
330 dimColor={isBusy}
331 onPress={press => void runCompact($, e.props.isWorking, press.surface)}
332 />
333 </Box>
334 )
335
336 return (
337 <Box flexDirection="column">
338 {band}
339 {rest}
340 </Box>
341 )
342 })
343}
344hooks/i18n.ts 350 lines1/** The band's words, every one translated — `Compact` and `Auto-Compact` too (author's decision, 2026-10-11). */
2export type Strings = {
3 cache: string
4 cold: string
5 hit: string
6 context: string
7 on: string
8 off: string
9 /** The button's label. */
10 compact: string
11 /** Auto-compaction's name in the parenthesis. */
12 autoCompact: string
13 compacting: string
14 busy: string
15 failed: (reason: string) => string
16}
17
18export const LANGUAGES = [
19 'en', 'de', 'fr', 'es', 'it', 'pt', 'nl', 'pl', 'sv', 'cs', 'el', 'tr',
20 'ru', 'uk', 'ar', 'he', 'hi', 'th', 'id', 'vi', 'ja', 'ko', 'zh', 'zh-TW',
21] as const
22
23export type Language = (typeof LANGUAGES)[number]
24
25export const STRINGS: Record<Language, Strings> = {
26 en: {
27 cache: 'cache', cold: 'cold', hit: 'hit', context: 'Context', on: 'on', off: 'off',
28 compact: 'Compact', autoCompact: 'Auto-Compact',
29 compacting: 'Compacting…',
30 busy: 'Compact is possible once the running turn has finished.',
31 failed: r => `Compact failed: ${r}`,
32 },
33 de: {
34 cache: 'Cache', cold: 'kalt', hit: 'Treffer', context: 'Kontext', on: 'an', off: 'aus',
35 compact: 'komprimieren', autoCompact: 'Auto-Komprimierung',
36 compacting: 'Komprimiere…',
37 busy: 'Komprimieren geht erst, wenn der laufende Turn fertig ist.',
38 failed: r => `Komprimieren fehlgeschlagen: ${r}`,
39 },
40 fr: {
41 cache: 'cache', cold: 'froid', hit: 'succès', context: 'Contexte', on: 'activé', off: 'désactivé',
42 compact: 'Compacter', autoCompact: 'Compactage auto',
43 compacting: 'Compactage…',
44 busy: 'Le compactage est possible une fois le tour en cours terminé.',
45 failed: r => `Échec du compactage : ${r}`,
46 },
47 es: {
48 cache: 'caché', cold: 'frío', hit: 'acierto', context: 'Contexto', on: 'activado', off: 'desactivado',
49 compact: 'Compactar', autoCompact: 'Compactación automática',
50 compacting: 'Compactando…',
51 busy: 'La compactación solo es posible cuando termine el turno en curso.',
52 failed: r => `La compactación falló: ${r}`,
53 },
54 it: {
55 cache: 'cache', cold: 'freddo', hit: 'hit', context: 'Contesto', on: 'attivo', off: 'disattivo',
56 compact: 'Compatta', autoCompact: 'Compattazione automatica',
57 compacting: 'Compattazione…',
58 busy: 'La compattazione è possibile solo al termine del turno in corso.',
59 failed: r => `Compattazione non riuscita: ${r}`,
60 },
61 pt: {
62 cache: 'cache', cold: 'frio', hit: 'acerto', context: 'Contexto', on: 'ativado', off: 'desativado',
63 compact: 'Compactar', autoCompact: 'Compactação automática',
64 compacting: 'Compactando…',
65 busy: 'A compactação só é possível quando o turno atual terminar.',
66 failed: r => `A compactação falhou: ${r}`,
67 },
68 nl: {
69 cache: 'cache', cold: 'koud', hit: 'hit', context: 'Context', on: 'aan', off: 'uit',
70 compact: 'Compacteren', autoCompact: 'Auto-compacteren',
71 compacting: 'Compacteren…',
72 busy: 'Compacteren kan pas als de lopende beurt klaar is.',
73 failed: r => `Compacteren mislukt: ${r}`,
74 },
75 pl: {
76 cache: 'cache', cold: 'zimny', hit: 'trafień', context: 'Kontekst', on: 'włączony', off: 'wyłączony',
77 compact: 'Kompaktuj', autoCompact: 'Autokompaktowanie',
78 compacting: 'Kompaktowanie…',
79 busy: 'Kompaktowanie będzie możliwe po zakończeniu bieżącej tury.',
80 failed: r => `Kompaktowanie nie powiodło się: ${r}`,
81 },
82 sv: {
83 cache: 'cache', cold: 'kall', hit: 'träff', context: 'Kontext', on: 'på', off: 'av',
84 compact: 'Komprimera', autoCompact: 'Autokomprimering',
85 compacting: 'Komprimerar…',
86 busy: 'Komprimering går först när den pågående turen är klar.',
87 failed: r => `Komprimeringen misslyckades: ${r}`,
88 },
89 cs: {
90 cache: 'cache', cold: 'studená', hit: 'zásah', context: 'Kontext', on: 'zapnuto', off: 'vypnuto',
91 compact: 'Kompaktovat', autoCompact: 'Automatické kompaktování',
92 compacting: 'Kompaktuji…',
93 busy: 'Kompaktování je možné až po dokončení probíhajícího kola.',
94 failed: r => `Kompaktování selhalo: ${r}`,
95 },
96 ru: {
97 cache: 'кэш', cold: 'остыл', hit: 'попаданий', context: 'Контекст', on: 'вкл.', off: 'выкл.',
98 compact: 'Сжать', autoCompact: 'Автосжатие',
99 compacting: 'Сжатие…',
100 busy: 'Сжатие возможно только после завершения текущего хода.',
101 failed: r => `Сжатие не удалось: ${r}`,
102 },
103 uk: {
104 cache: 'кеш', cold: 'охолов', hit: 'влучань', context: 'Контекст', on: 'увімк.', off: 'вимк.',
105 compact: 'Стиснути', autoCompact: 'Автостиснення',
106 compacting: 'Стиснення…',
107 busy: 'Стиснення можливе лише після завершення поточного ходу.',
108 failed: r => `Стиснення не вдалося: ${r}`,
109 },
110 el: {
111 cache: 'cache', cold: 'κρύα', hit: 'ευστοχία', context: 'Πλαίσιο', on: 'ενεργό', off: 'ανενεργό',
112 compact: 'Συμπύκνωση', autoCompact: 'Αυτόματη συμπύκνωση',
113 compacting: 'Συμπύκνωση…',
114 busy: 'Η συμπύκνωση είναι δυνατή μόλις ολοκληρωθεί ο τρέχων γύρος.',
115 failed: r => `Η συμπύκνωση απέτυχε: ${r}`,
116 },
117 tr: {
118 cache: 'önbellek', cold: 'soğuk', hit: 'isabet', context: 'Bağlam', on: 'açık', off: 'kapalı',
119 compact: 'Sıkıştır', autoCompact: 'Otomatik sıkıştırma',
120 compacting: 'Sıkıştırılıyor…',
121 busy: 'Sıkıştırma, devam eden tur bittikten sonra yapılabilir.',
122 failed: r => `Sıkıştırma başarısız: ${r}`,
123 },
124 ar: {
125 cache: 'الكاش', cold: 'بارد', hit: 'إصابة', context: 'السياق', on: 'مفعّل', off: 'معطّل',
126 compact: 'ضغط', autoCompact: 'ضغط تلقائي',
127 compacting: 'جارٍ الضغط…',
128 busy: 'يمكن الضغط بعد انتهاء الدور الجاري.',
129 failed: r => `فشل الضغط: ${r}`,
130 },
131 he: {
132 cache: 'מטמון', cold: 'קר', hit: 'פגיעות', context: 'הקשר', on: 'פעיל', off: 'כבוי',
133 compact: 'דחיסה', autoCompact: 'דחיסה אוטומטית',
134 compacting: 'מבצע דחיסה…',
135 busy: 'דחיסה אפשרית רק לאחר שהתור הנוכחי מסתיים.',
136 failed: r => `הדחיסה נכשלה: ${r}`,
137 },
138 hi: {
139 cache: 'कैश', cold: 'ठंडा', hit: 'हिट', context: 'संदर्भ', on: 'चालू', off: 'बंद',
140 compact: 'कॉम्पैक्ट करें', autoCompact: 'ऑटो-कॉम्पैक्ट',
141 compacting: 'कॉम्पैक्ट हो रहा है…',
142 busy: 'कॉम्पैक्ट चल रहे टर्न के पूरा होने के बाद ही संभव है।',
143 failed: r => `कॉम्पैक्ट विफल: ${r}`,
144 },
145 th: {
146 cache: 'แคช', cold: 'หมดอายุ', hit: 'ฮิต', context: 'บริบท', on: 'เปิด', off: 'ปิด',
147 compact: 'บีบอัด', autoCompact: 'บีบอัดอัตโนมัติ',
148 compacting: 'กำลังบีบอัด…',
149 busy: 'บีบอัดได้เมื่อเทิร์นที่กำลังทำงานเสร็จแล้ว',
150 failed: r => `บีบอัดล้มเหลว: ${r}`,
151 },
152 id: {
153 cache: 'cache', cold: 'dingin', hit: 'hit', context: 'Konteks', on: 'aktif', off: 'nonaktif',
154 compact: 'Padatkan', autoCompact: 'Pemadatan otomatis',
155 compacting: 'Memadatkan…',
156 busy: 'Pemadatan baru bisa setelah giliran yang berjalan selesai.',
157 failed: r => `Pemadatan gagal: ${r}`,
158 },
159 vi: {
160 cache: 'cache', cold: 'nguội', hit: 'trúng', context: 'Ngữ cảnh', on: 'bật', off: 'tắt',
161 compact: 'Nén', autoCompact: 'Tự động nén',
162 compacting: 'Đang nén…',
163 busy: 'Chỉ có thể nén sau khi lượt hiện tại kết thúc.',
164 failed: r => `Nén thất bại: ${r}`,
165 },
166 ja: {
167 cache: 'キャッシュ', cold: '期限切れ', hit: 'ヒット', context: 'コンテキスト', on: 'オン', off: 'オフ',
168 compact: '圧縮', autoCompact: '自動圧縮',
169 compacting: '圧縮中…',
170 busy: '圧縮は実行中のターンが終わってから実行できます。',
171 failed: r => `圧縮に失敗しました: ${r}`,
172 },
173 ko: {
174 cache: '캐시', cold: '만료', hit: '적중', context: '컨텍스트', on: '켜짐', off: '꺼짐',
175 compact: '압축', autoCompact: '자동 압축',
176 compacting: '압축 중…',
177 busy: '압축은 진행 중인 턴이 끝난 후에 가능합니다.',
178 failed: r => `압축 실패: ${r}`,
179 },
180 zh: {
181 cache: '缓存', cold: '已过期', hit: '命中', context: '上下文', on: '开', off: '关',
182 compact: '压缩', autoCompact: '自动压缩',
183 compacting: '正在压缩…',
184 busy: '当前轮次结束后才能压缩。',
185 failed: r => `压缩失败:${r}`,
186 },
187 'zh-TW': {
188 cache: '快取', cold: '已過期', hit: '命中', context: '上下文', on: '開', off: '關',
189 compact: '壓縮', autoCompact: '自動壓縮',
190 compacting: '正在壓縮…',
191 busy: '目前輪次結束後才能壓縮。',
192 failed: r => `壓縮失敗:${r}`,
193 },
194}
195
196/** Claude Code's `language` setting is free text ("german", "日本語"): the names each language goes by. */
197const NAMES: Record<Language, readonly string[]> = {
198 en: ['english'],
199 de: ['german', 'deutsch'],
200 fr: ['french', 'français', 'francais'],
201 es: ['spanish', 'español', 'espanol', 'castellano'],
202 it: ['italian', 'italiano'],
203 pt: ['portuguese', 'português', 'portugues'],
204 nl: ['dutch', 'nederlands'],
205 pl: ['polish', 'polski'],
206 sv: ['swedish', 'svenska'],
207 cs: ['czech', 'čeština', 'cestina'],
208 el: ['greek', 'ελληνικά'],
209 tr: ['turkish', 'türkçe', 'turkce'],
210 ru: ['russian', 'русский'],
211 uk: ['ukrainian', 'українська'],
212 ar: ['arabic', 'العربية'],
213 he: ['hebrew', 'עברית'],
214 hi: ['hindi', 'हिन्दी', 'हिंदी'],
215 th: ['thai', 'ไทย'],
216 id: ['indonesian', 'bahasa indonesia'],
217 vi: ['vietnamese', 'tiếng việt', 'tieng viet'],
218 ja: ['japanese', '日本語'],
219 ko: ['korean', '한국어'],
220 zh: ['chinese', 'simplified chinese', '中文', '简体中文'],
221 'zh-TW': ['traditional chinese', '繁體中文', '繁体中文', '正體中文'],
222}
223
224function isLanguage(value: string): value is Language {
225 return (LANGUAGES as readonly string[]).includes(value)
226}
227
228/** A locale's leading code to a language; `iw` is Hebrew's old code. */
229function codeOf(locale: string): Language | null {
230 const parts = locale.split(/[-_.@]/)
231 const primary = parts[0] ?? ''
232
233 // Chinese as the Grok Depot dashboard reads it: Hant, TW, HK and MO are traditional.
234 if (primary === 'zh') {
235 return parts.some(part => ['hant', 'tw', 'hk', 'mo'].includes(part)) ? 'zh-TW' : 'zh'
236 }
237
238 if (primary === 'iw') {
239 return 'he'
240 }
241
242 return isLanguage(primary) ? primary : null
243}
244
245/** A setting's or a locale's spelling (`German`, `de-DE`, `de_DE.UTF-8`, `zh-Hant-HK`) to a language. */
246export function languageOf(text: string | undefined): Language | null {
247 const spelled = text?.trim().toLowerCase()
248
249 if (!spelled) {
250 return null
251 }
252
253 for (const language of LANGUAGES) {
254 if (NAMES[language].includes(spelled)) {
255 return language
256 }
257 }
258
259 return codeOf(spelled)
260}
261
262export function wordsFor(language: string): Strings {
263 return isLanguage(language) ? STRINGS[language] : STRINGS.en
264}
265
266/** Each language's number locale, as the Grok Depot dashboard has them (`ar-EG`: Arabic-Indic digits). */
267const LOCALES: Record<Language, string> = {
268 en: 'en-US', de: 'de-DE', fr: 'fr-FR', es: 'es-ES', it: 'it-IT', pt: 'pt-PT', nl: 'nl-NL',
269 pl: 'pl-PL', sv: 'sv-SE', cs: 'cs-CZ', el: 'el-GR', tr: 'tr-TR', ru: 'ru-RU', uk: 'uk-UA',
270 ar: 'ar-EG', he: 'he-IL', hi: 'hi-IN', th: 'th-TH', id: 'id-ID', vi: 'vi-VN', ja: 'ja-JP',
271 ko: 'ko-KR', zh: 'zh-CN', 'zh-TW': 'zh-TW',
272}
273
274const RIGHT_TO_LEFT: readonly Language[] = ['ar', 'he']
275
276/**
277 * The minute unit is the locale's narrow form, except where that reads oddly (author, 2026-10-11):
278 * Japanese narrow is a Latin `54m` (short: `54 分`), Greek narrow a bare `54 λ` (long: `54 λεπτά`).
279 */
280const MINUTE_DISPLAY: Partial<Record<Language, 'short' | 'long'>> = { ja: 'short', el: 'long' }
281
282/** One language's words, reading direction and number formats. */
283export type LanguagePack = {
284 language: Language
285 words: Strings
286 isRtl: boolean
287 /** `42m`, `42 Min.`, `٤٢ د`, `42 分`: the locale's minute unit (MINUTE_DISPLAY). */
288 minutes: (count: number) => string
289 /** `93%`, `93 %`, `٩٣٪`: a share from 0 to 1, whole percent. */
290 percent: (fraction: number) => string
291}
292
293const packs = new Map<Language, LanguagePack>()
294
295export function packFor(language: string): LanguagePack {
296 const known: Language = isLanguage(language) ? language : 'en'
297 const cached = packs.get(known)
298
299 if (cached !== undefined) {
300 return cached
301 }
302
303 const locale = LOCALES[known]
304 const minutes = new Intl.NumberFormat(locale, {
305 style: 'unit',
306 unit: 'minute',
307 unitDisplay: MINUTE_DISPLAY[known] ?? 'narrow',
308 })
309 const percent = new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 0 })
310 const pack: LanguagePack = {
311 language: known,
312 words: STRINGS[known],
313 isRtl: RIGHT_TO_LEFT.includes(known),
314 minutes: count => minutes.format(count),
315 percent: fraction => percent.format(fraction),
316 }
317 packs.set(known, pack)
318
319 return pack
320}
321
322/**
323 * Many terminals draw right-to-left script reversed: there Arabic and Hebrew fall back to English,
324 * unless the person says their terminal can (`rtlInTerminal`). Web surfaces lay it out themselves.
325 */
326export function packForSurface(language: string, surface: string | undefined, rtlInTerminal: boolean): LanguagePack {
327 const pack = packFor(language)
328
329 return pack.isRtl && surface === 'terminal' && !rtlInTerminal ? packFor('en') : pack
330}
331
332/**
333 * The surface lays each `Text` out left-to-right, so a right-to-left piece that mixes in numbers,
334 * parentheses or Latin (`(ضغط تلقائي ٩٧٪)`, `60 דק׳`) reads in the wrong order. On web surfaces a
335 * right-to-left isolate (RLI … PDI) gives the piece its own direction; terminals get no control
336 * characters, they may draw them.
337 */
338export function bidiFor(pack: LanguagePack, surface: string | undefined): (text: string) => string {
339 return pack.isRtl && surface !== 'terminal' ? text => `\u2067${text}\u2069` : text => text
340}
341
342/** The option wins; `auto` follows Claude Code's `language` setting, then the locale, then English. */
343export function resolveLanguage(option: string, setting: string | undefined, locale: string | undefined): Language {
344 if (isLanguage(option)) {
345 return option
346 }
347
348 return languageOf(setting) ?? languageOf(locale) ?? 'en'
349}
350hooks/model.ts 148 lines1import type { ModelUsage } from 'claude-code'
2
3import type { AutoCompact, CacheReading } from '../types'
4import type { LanguagePack } from './i18n'
5
6export const HOUR_MS = 60 * 60 * 1000
7export const FIVE_MINUTES_MS = 5 * 60 * 1000
8export const BAR_CELLS = 20
9export const MIN_BAR_CELLS = 6
10
11export type Tone = 'green' | 'yellow' | 'red' | 'gray'
12
13export type CacheView = {
14 tone: Tone
15 /** `none`: never cached; `cold`: expired; `warm`: `minutes` left. */
16 state: 'none' | 'cold' | 'warm'
17 minutes: number
18}
19
20export function readingFrom(usage: ModelUsage, at: number): CacheReading | null {
21 const cached = usage.cache_read_input_tokens + usage.cache_creation_input_tokens
22 const total = usage.input_tokens + cached
23
24 if (total <= 0) {
25 return null
26 }
27
28 return { at, hit: usage.cache_read_input_tokens / total, stored: cached / total }
29}
30
31/**
32 * The option wins; `auto` follows `CLAUDE_CODE_PROMPT_CACHE_TTL`, then `promptCacheTtl`, then one
33 * hour (a Claude subscription's default; an API key's is five minutes, which only a setting says).
34 */
35export function resolveTtl(option: string, env: string | undefined, setting: string | undefined): number {
36 for (const value of [option, env, setting]) {
37 if (value === '5m') {
38 return FIVE_MINUTES_MS
39 }
40
41 if (value === '1h') {
42 return HOUR_MS
43 }
44 }
45
46 return HOUR_MS
47}
48
49/** Green above half the lifetime, yellow above a sixth, red below: 31–60 / 11–30 / 0–10 of an hour. */
50export function cacheView(reading: CacheReading | null, now: number, isWorking: boolean, ttlMs: number): CacheView {
51 if (reading === null) {
52 return { tone: 'gray', state: 'none', minutes: 0 }
53 }
54
55 const remaining = isWorking ? ttlMs : ttlMs - (now - reading.at)
56
57 if (remaining <= 0) {
58 return { tone: 'red', state: 'cold', minutes: 0 }
59 }
60
61 const minutes = Math.min(ttlMs / 60_000, Math.ceil(remaining / 60_000))
62 const tone = remaining > ttlMs / 2 ? 'green' : remaining > ttlMs / 6 ? 'yellow' : 'red'
63
64 return { tone, state: 'warm', minutes }
65}
66
67export function cacheLabel(view: CacheView, pack: LanguagePack): string {
68 if (view.state === 'none') {
69 return '–'
70 }
71
72 return view.state === 'cold' ? pack.words.cold : pack.minutes(view.minutes)
73}
74
75/** How many of the bar's `cells` are filled. */
76export function filledCells(stored: number, isEmpty: boolean, cells: number = BAR_CELLS): number {
77 if (isEmpty) {
78 return 0
79 }
80
81 return Math.round(Math.min(1, Math.max(0, stored)) * cells)
82}
83
84/**
85 * What one piece of the row takes, in the band's cells. The terminal is exact; the Desktop app sets
86 * a proportional font in a monospace cell metric, so its figures are estimates, on the generous
87 * side: a short bar beats a row that wraps. Checked 2026-10-11 at bodyColumns 94 (German row):
88 * everything but the bar estimated at 80.3 cells, measured about 80; a square measured 1.44 cells.
89 */
90export type Widths = { char: number; square: number; emoji: number; buttonChrome: number; gap: number }
91
92export const TERMINAL_WIDTHS: Widths = { char: 1, square: 1, emoji: 2, buttonChrome: 4, gap: 1 }
93export const DESKTOP_WIDTHS: Widths = { char: 0.9, square: 1.4, emoji: 2, buttonChrome: 1.5, gap: 1 }
94
95// Marks that sit on another character (Mn, Me) and format characters take no cell; spacing marks
96// (Mc, such as the Devanagari vowel sign ा) take one.
97const ZERO_WIDTH = /[\p{Mn}\p{Me}\p{Cf}]/u
98const WIDE =
99 /[\u1100-\u115F\u2E80-\u303E\u3041-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{20000}-\u{3FFFD}]/u
100
101/** Cells a text takes in a terminal: East Asian wide characters two, ZERO_WIDTH none, the rest one. */
102export function textCells(text: string): number {
103 let cells = 0
104
105 for (const char of text) {
106 cells += ZERO_WIDTH.test(char) ? 0 : WIDE.test(char) ? 2 : 1
107 }
108
109 return cells
110}
111
112/**
113 * How many squares the bar gets so the row fits `available` cells: what the other pieces, the
114 * weather emoji, the button's frame and the `gaps` leave, between MIN_BAR_CELLS and BAR_CELLS.
115 */
116export function barCells(available: number, texts: readonly string[], gaps: number, widths: Widths): number {
117 const textWidth = texts.reduce((sum, text) => sum + textCells(text) * widths.char, 0)
118 const fixed = textWidth + widths.emoji + widths.buttonChrome + gaps * widths.gap + 1
119 const room = Math.floor((available - fixed) / widths.square)
120
121 return Math.max(MIN_BAR_CELLS, Math.min(BAR_CELLS, room))
122}
123
124/** null (no reading yet) counts as an empty window. */
125export function weather(contextPercent: number | null): string {
126 const filled = contextPercent ?? 0
127
128 if (filled >= 70) {
129 return '🌧️'
130 }
131
132 return filled >= 40 ? '⛅' : '☀️'
133}
134
135export function autoCompactLabel(autoCompact: AutoCompact | null, pack: LanguagePack): string | null {
136 if (autoCompact === null) {
137 return null
138 }
139
140 const name = pack.words.autoCompact
141
142 if (!autoCompact.isEnabled) {
143 return `(${name} ${pack.words.off})`
144 }
145
146 return autoCompact.percent === null ? `(${name} ${pack.words.on})` : `(${name} ${pack.percent(autoCompact.percent / 100)})`
147}
148types/index.d.ts 40 lines1/** The last main-thread response's cache figures. */
2export type CacheReading = {
3 /** When the response came back, ms since the epoch. */
4 at: number
5 /** cache_read ÷ total input, 0 to 1. */
6 hit: number
7 /** (cache_read + cache_creation) ÷ total input, 0 to 1. */
8 stored: number
9}
10
11/** Auto-compaction as the context breakdown reports it. */
12export type AutoCompact = {
13 isEnabled: boolean
14 /** The threshold as a whole percentage of the model's window; null when unknown. */
15 percent: number | null
16}
17
18/** What the options and settings resolve to: read at start and when the settings change. */
19export type Preferences = {
20 /** One of the band's languages (`en`, `de`, …). */
21 language: string
22 /** The prompt cache's lifetime: one hour or five minutes. */
23 ttlMs: number
24 /** Draw Arabic and Hebrew right-to-left in the terminal too, instead of English there. */
25 rtlInTerminal: boolean
26}
27
28declare module 'claude-code' {
29 interface PluginState {
30 'cache-band': {
31 preferences: Preferences
32 cache: CacheReading | null
33 /** The context window's fill in whole percent; null until a response reported one. */
34 context: number | null
35 autoCompact: AutoCompact | null
36 isCompacting: boolean
37 }
38 }
39}
40