SLOPSHOPPER

cache-band

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

newbandcommandtoasttimer
v0.6.2MITupdated 2026-10-12CTeitzel/claude-code-cache-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-band
› 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 ● cache – □□□□□□□□□□□□□□□□□□□□ – hit · ⛅ Context 49% [ Compact ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● cache – □□□□□□□□□□□□□□□□□□□□ – hit · ⛅ Context 49% [ Compact ] ⟨Claude Code's own drawing⟩
README

cache-band

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.

What the row shows

PartMeaningDisplay
● cache 42mTime 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) ÷ inputColour of the countdown; grey and hollow when cold; shortens (down to 6) so the row stays on one line
93% hitHit rate of the last request: cache_read ÷ inputKept 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 transcriptReads 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.

Install

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).

Options

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.)

OptionValuesDefault
languageauto, 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
cacheTtlauto, 1h, 5mauto: CLAUDE_CODE_PROMPT_CACHE_TTL, then promptCacheTtl, else 1h
rtlInTerminaltrue, falsefalse: 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.

Limits

  • The countdown is an estimate from the clock, not a server check: a change at the start of the prompt can invalidate the cache earlier.
  • Only the main conversation counts; subagents and the engine's own forks are left out.
  • A change of the per-model auto-compact window through /autocompact may only show in the next session.

Development

FileContent
hooks/register.tsxthe hooks: readings, countdown restarts, the Compact button, the band
hooks/model.tspure maths: countdown, lifetime, bar, weather, auto-compact label
hooks/i18n.tsthe 24 languages, their number locales, right-to-left, and how auto picks one
types/index.d.tsthe $.state contract
tests/band.test.tsx27 tests against the engine (claude plugin test)
.claude/CLAUDE.mdnotes 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 ..

License

MIT © 2026 Dr. Christian Teitzel

Source 4 files
hooks/register.tsx 344 lines
1import { 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}
344
hooks/i18n.ts 350 lines
1/** 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}
350
hooks/model.ts 148 lines
1import 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}
148
types/index.d.ts 40 lines
1/** 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