SLOPSHOPPER

cache-meter

A band above the prompt showing how long the prompt cache stays warm, the last turn's hit rate, and a Compact button

newbandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-meter
› 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 [ Compact ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● -- ▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫▫ cache --% hit [ Compact ] ⟨Claude Code's own drawing⟩
README

claude-mods

Mods for Claude Code by Gaurav Lahoti. Each one is a plugin, and this repo is the marketplace that lists them.

ModWhat it does
token-weatherA band above the prompt with a forecast of the context window: a weather word, percent used, tokens, a 12-turn chart and how much the last turn added.
cache-meterA band above the prompt showing how long the prompt cache stays warm, the last turn's hit rate, and a Compact button.
gauravs-panelA footer button that opens a row of models and a row of effort levels. One click to switch, no /model or /effort.

Install

In Claude Code:

/plugin marketplace add gauravlahoti/claude-mods
/plugin install token-weather@gauravlahoti
/reload-plugins

Swap token-weather for cache-meter or gauravs-panel to install the others.

Update

/plugin marketplace update gauravlahoti

Layout

.claude-plugin/marketplace.json   the list of mods and where each one lives
plugins/<mod>/                    one folder per mod, each a normal plugin
Source 3 files
hooks/register.tsx 90 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { cells, clock, hitPercent, minutesLeft, tone } from './meter'
5
6const reading = atom({ plugin: 'cache-meter', key: 'reading' } as const, null)
7// Bumped every 30s so the countdown redraws while nothing else is happening.
8const tick = atom({ plugin: 'cache-meter', key: 'tick' } as const, 0)
9
10// The session's last response carries the cache counts; `summary` estimates
11// locally and sends no request. `fresh` says the response just arrived.
12async function sample($: EngineInterface, fresh: boolean) {
13  const { context } = await $.session.usage({ breakdown: 'summary' })
14  const u = context.breakdown?.apiUsage
15  const hit = u ? hitPercent(u) : null
16  if (!u || hit === null || u.cache_read_input_tokens + u.cache_creation_input_tokens === 0) return
17  const at = fresh ? await $.clock.now() : null
18  // A seed never overwrites a reading that already knows its time.
19  await update($, reading, prev => (fresh || prev === null ? { at, hitPercent: hit } : prev))
20}
21
22export const register: Register = on => {
23  on('session.start', async ($, e, next) => {
24    const result = await next(e)
25    await sample($, false)
26    $.clock.every(30_000, () => update($, tick, n => n + 1))
27    return result
28  })
29
30  // Fires on every response that moves the context fill: a fresh cache reading.
31  on('session.measure', async ($, e, next) => {
32    if (e.changed.includes('context')) await sample($, true)
33    return next(e)
34  })
35
36  on('session.compact', async ($, e, next) => {
37    // The compacted transcript is a new prefix: the old cache no longer applies.
38    await update($, reading, () => null)
39    return next(e)
40  })
41
42  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
43    // Keep what a plugin beneath drew (context-weather) and stack ours on top, so both show.
44    const below = await next(e)
45    const r = await read($, reading)
46    await read($, tick)
47    if (e.props.hasSurvey) return below
48
49    const { Box, Text, Button } = $.ui.resolve(e)
50    const known = r !== null && r.at !== null
51    const mins = known ? minutesLeft(r.at as number, await $.clock.now()) : 0
52    const color = !known ? undefined : mins === 0 ? 'error' : tone(mins)
53    const width = Math.max(8, Math.min(30, e.props.bodyColumns - 48))
54    const [full, empty] = cells(known ? mins : 0, width)
55    const canCompact = !e.props.isWorking
56
57    const band = (
58      <Box flexDirection="row" justifyContent="space-between">
59        <Box flexDirection="row">
60          <Text color={color ?? 'success'} dimColor={!known}>
61            ●{' '}
62          </Text>
63          <Text color={color} dimColor={!known} bold>
64            {!known ? '--' : mins === 0 ? 'cold' : `${clock(mins)} left`}
65          </Text>
66          <Text color={color}> {full}</Text>
67          <Text dimColor>{empty}</Text>
68        </Box>
69        <Box flexDirection="row">
70          <Text dimColor>cache </Text>
71          <Text bold>{r === null ? '--' : r.hitPercent}%</Text>
72          <Text dimColor> hit </Text>
73          {canCompact ? (
74            <Button key="compact" label="Compact" hotkey="k" onPress={() => $.session.compact()} />
75          ) : null}
76        </Box>
77      </Box>
78    )
79
80    // A blank row above our line, so it clears the spinner, and one between
81    // our line and the band beneath, so the two don't crowd.
82    return (
83      <Box flexDirection="column" marginTop={1} gap={1}>
84        {band}
85        {below}
86      </Box>
87    )
88  })
89}
90
hooks/meter.ts 33 lines
1// The prompt cache lives this long after each response that touches it.
2export const TTL_MS = 60 * 60 * 1000
3
4export function hitPercent(u: {
5  input_tokens: number
6  cache_read_input_tokens: number
7  cache_creation_input_tokens: number
8}): number | null {
9  const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
10  return total === 0 ? null : Math.round((u.cache_read_input_tokens / total) * 100)
11}
12
13export function minutesLeft(at: number, now: number): number {
14  return Math.max(0, Math.ceil((at + TTL_MS - now) / 60_000))
15}
16
17export function clock(minutes: number): string {
18  if (minutes >= 60) return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}`
19  return `${minutes}m`
20}
21
22// One cell per slice of the TTL: filled while that slice of cache life remains.
23export function cells(minutes: number, width: number): [string, string] {
24  const filled = Math.max(0, Math.min(width, Math.ceil((minutes / (TTL_MS / 60_000)) * width)))
25  return ['▪'.repeat(filled), '▫'.repeat(width - filled)]
26}
27
28export function tone(minutes: number): 'success' | 'warning' | 'error' {
29  if (minutes <= 5) return 'error'
30  if (minutes <= 15) return 'warning'
31  return 'success'
32}
33
types/index.d.ts 13 lines
1export type CacheReading = {
2  /** Epoch ms when the response that produced this reading arrived; null when seeded from a past response. */
3  at: number | null
4  /** Share of the last response's prompt served from cache, 0 to 100. */
5  hitPercent: number
6}
7
8declare module 'claude-code' {
9  interface PluginState {
10    'cache-meter': { reading: CacheReading | null; tick: number }
11  }
12}
13