Shows in the footer how long the prompt cache stays warm, so you ask your next question before it expires

A Claude Code mod that shows how long your prompt cache stays warm, right in the footer next to the model name.

Every message you send, Claude Code re-sends the whole conversation. The prompt cache lets the model skip re-reading everything before, so replies come faster and use less of your plan. But the cache expires, and the first message after that reloads the whole conversation. Claude Code doesn't show how much time is left. Cache Timer does.
| Footer | Meaning |
|---|---|
⬤ 60 min (green) | Fresh. Stays at full time while Claude is answering |
⬤ 8 min (amber) → ⬤ 2 min (red) | Cooling down. Amber under 10 min, red under 3 |
⬤ 1:45 (red) | Last two minutes, counted in seconds. Ask now |
⬤ cache cold (grey) | Expired. Your next message reloads the conversation |
Requires Claude Code 2.1.287 or later (mods). Works in the terminal and in the Code tab of the Claude desktop app.
claude plugin marketplace add artemvoidd/cache-timer
claude plugin install cache-timer@artemvoidd
Start a new session. The timer appears after the first answer.
In the desktop app you can also ask Claude in the Code tab: “Install the plugin cache-timer from the marketplace artemvoidd/cache-timer”, then open a new chat.
/cache-ttl 5 or /cache-ttl 60; the choice is remembered.en (default) or ru, in /config or in ~/.claude/settings.json: { "pluginConfigs": { "cache-timer@artemvoidd": { "options": { "language": "ru" } } } }
The mod hooks turn.start and turn.complete to note when the main conversation last talked to the model, and redraws the footer (SessionMode) every 5 seconds. It makes no network requests, reads no files and runs no commands — claude plugin validate lists its calls:
hooks: session.start, turn.start, turn.complete, classic.PostModelSwitch, command.run{command=cache-ttl}, ui.render{component=SessionMode}
calls: $.clock.every, $.clock.now, $.command.register, $.store.get, $.store.set, $.ui.invalidate, $.ui.resolve
What each hook does:
| Hook | What it does | Changes anything? |
|---|---|---|
session.start | Reads the saved TTL, registers /cache-ttl, starts a 5-second redraw timer | No |
turn.start, turn.complete | Notes the time of the main conversation's last request (subagent turns are ignored) | No, passes the event on unchanged |
classic.PostModelSwitch | Reads cache_ttl (5m or 1h) that Claude Code reports on a model switch | No, passes the event on unchanged |
command.run (cache-ttl only) | Answers /cache-ttl 5 and /cache-ttl 60 and saves the choice in the mod's own store | Only its own command; other commands are not touched |
ui.render (SessionMode only) | Adds the dot and the time in front of the footer's mode labels, which stay as they were | Only the footer labels |
The countdown starts from the end of the last answer, so it can be a few seconds off from the exact API request.
Мод для Claude Code: в футере рядом с моделью показывает, сколько ещё живёт кэш промпта. Кружок меняет цвет по мере остывания: зелёный → жёлтый → красный, рядом минуты, в последние две — секунды. Когда «кэш остыл» (серый кружок), следующее сообщение заново прогоняет весь диалог — успей задать вопрос до этого.
Установка — две команды выше. Русские подписи: "language": "ru" в настройках плагина (пример выше). Время жизни кэша: /cache-ttl 5 или /cache-ttl 60.
Cache Timer collects and sends nothing. See PRIVACY.md.
MIT
hooks/register.tsx 107 lines1// Prompt cache countdown in the footer, next to the model label:
2// a dot whose color cools from green to red, and the minutes left beside it.
3import type { Register } from 'claude-code'
4
5const MINUTE = 60_000
6
7let ttlMs = 60 * MINUTE // 1h by default; /cache-ttl 5 switches to 5 minutes
8let lastActivity = 0 // ms of the last main-loop request (0 = no turn yet)
9let working = false // while Claude answers, the cache stays fresh
10
11// Muted, so the footer stays quiet; only the glyph carries the color
12const GREEN = '#6a9a78'
13const AMBER = '#b39552'
14const RED = '#b8645c'
15const GREY = '#7d8590'
16
17const WORDS = {
18 en: { min: 'min', cold: 'cache cold' },
19 ru: { min: 'мин', cold: 'кэш остыл' },
20}
21let words = WORDS.en
22
23export const register: Register = (on, options) => {
24 words = options?.language === 'ru' ? WORDS.ru : WORDS.en
25
26 on('session.start', async ($, e, next) => {
27 const saved = await $.store.get('ttlMinutes')
28 if (saved === 5 || saved === 60) ttlMs = saved * MINUTE
29 await $.command.register({
30 name: 'cache-ttl',
31 description: 'Prompt cache timer: /cache-ttl 5 or /cache-ttl 60 (minutes)',
32 })
33 // Redraw the footer so the minutes tick down
34 $.clock.every(5000, () => $.ui.invalidate('ui.render'))
35 return next(e)
36 })
37
38 // Only the main loop raises turn.start; subagents don't
39 on('turn.start', async ($, e, next) => {
40 working = true
41 lastActivity = await $.clock.now()
42 $.ui.invalidate('ui.render')
43 return next(e)
44 })
45
46 on('turn.complete', async ($, e, next) => {
47 if (!e.agentId) {
48 working = false
49 lastActivity = await $.clock.now()
50 $.ui.invalidate('ui.render')
51 }
52 return next(e)
53 })
54
55 // A model switch reports the TTL Claude Code actually uses
56 on('classic.PostModelSwitch', async ($, e, next) => {
57 ttlMs = (e.cache_ttl === '5m' ? 5 : 60) * MINUTE
58 $.ui.invalidate('ui.render')
59 return next(e)
60 })
61
62 on('command.run', { command: 'cache-ttl' }, async ($, e) => {
63 const minutes = parseInt(String(e.args || '').trim(), 10)
64 if (minutes !== 5 && minutes !== 60) {
65 return { text: 'Use /cache-ttl 5 or /cache-ttl 60. Now: ' + ttlMs / MINUTE + ' min' }
66 }
67 ttlMs = minutes * MINUTE
68 await $.store.set('ttlMinutes', minutes)
69 $.ui.invalidate('ui.render')
70 return { text: 'Cache timer: ' + minutes + ' min' }
71 })
72
73 // The labels at the right of the prompt footer, beside the model.
74 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
75 if (!lastActivity) return next(e)
76 const left = working ? ttlMs : lastActivity + ttlMs - (await $.clock.now())
77 const rest = e.props.modes.join(' & ')
78
79 // The desktop footer draws text only (an Svg there shows nothing), so the
80 // progress is a large dot whose color tracks the time left
81 const { Box, Text } = $.ui.resolve(e)
82 return (
83 <Box flexDirection="row" columnGap={1}>
84 <Text color={color(left)}>{'⬤'}</Text>
85 <Text dimColor>{label(left)}</Text>
86 {rest ? <Text dimColor>{' & ' + rest}</Text> : null}
87 </Box>
88 )
89 })
90}
91
92function label(left: number): string {
93 if (left <= 0) return words.cold
94 if (left < 2 * MINUTE) {
95 const s = Math.ceil(left / 1000)
96 return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0')
97 }
98 return Math.ceil(left / MINUTE) + ' ' + words.min
99}
100
101function color(left: number): string {
102 if (left <= 0) return GREY
103 if (left > 10 * MINUTE) return GREEN
104 return left > 3 * MINUTE ? AMBER : RED
105}
106
107