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

Mods for Claude Code by Gaurav Lahoti. Each one is a plugin, and this repo is the marketplace that lists them.
| Mod | What it does |
|---|---|
token-weather | A 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-meter | A band above the prompt showing how long the prompt cache stays warm, the last turn's hit rate, and a Compact button. |
gauravs-panel | A footer button that opens a row of models and a row of effort levels. One click to switch, no /model or /effort. |
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.
/plugin marketplace update gauravlahoti
.claude-plugin/marketplace.json the list of mods and where each one lives
plugins/<mod>/ one folder per mod, each a normal pluginhooks/register.tsx 90 lines1import { 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}
90hooks/meter.ts 33 lines1// 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}
33types/index.d.ts 13 lines1export 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