Counts down until the chat's prompt cache goes cold, above the prompt. When a big chat goes cold, offers a handoff to a fresh chat. Keep warm pings the cache…

Mods for Claude Code.
| Mod | What it does |
|---|---|
| cache-bar | Status-line bar under your existing status line showing how long the prompt cache stays warm, with a handoff countdown: cache ▰▰▰▱▱▱▱▱ 23m 182k · handoff in 18m. Turns yellow at a quarter of the TTL, red at the handoff threshold, and follows the drop to a 5-minute TTL. /cache-bar:setup installs it. |
| filetree | Fork of data-goblin/claude-code-filetree that shows only the files Claude touched this session; the header's ◆ button toggles to the whole tree. |
| helper-desk | Task list above the prompt that follows Claude's to-do list, plus a side pane with one live card per subagent. |
| helpin-per | Pane listing open Helpin PER-* tasks; pick one to have Claude start working on it. |
| cache-clock | Countdown above the prompt until the chat's cache goes cold, with a Keep warm button. |
| handoff | When a chat gets very long, writes a handoff prompt and starts a fresh chat with it (/refresh <focus>; the handoff tells the new chat to run /pickup). |
| reflect | When you correct Claude, offers to save the fix as a lasting rule in CLAUDE.md or a rules folder. |
| chat-inventory | Side pane, In this chat, listing the to-do list, connectors and calls per connector, files and skills this chat used. |
| screen-share | /screen-share hides names, emails, phone numbers and money amounts on screen while you record. |
| replay-theater | /replay steps through the file edits Claude made in the last turn, one diff at a time. |
| mods | /mods opens a pane to turn each mod on or off, including mods installed from other marketplaces, and to install mods a marketplace offers that are not installed yet. |
| effort-optimizer | Before Claude starts on a prompt, a quick Sonnet check picks the effort level it needs (Low to Max) and offers a one-click switch, or sets it by itself with /effort-optimizer auto. |
| prompt-refiner | A Refine button in the hint line under the prompt, any time in the chat. Press it and pick a rewrite style for the prompt in the box, like a phone keyboard's suggestions: Clearer, Shorter, Elaborate (fuller, same shape), Structured (the job, the why, the guardrails, done means), Ask me first (up to 3 questions on gaps), or My guidance. The rewrite shows above the prompt with what changed; Use it fills the box, Refine runs another pass. /refine <prompt> does the same. |
The eight mods other than replay-theater, from helper-desk to effort-optimizer, come from Ben AI's benai-mods (benai.co) and are redistributed with permission, with Windows fixes added here. replay-theater (one Ben AI change: /mods switch support) is Anthropic's sample mod from anthropics/claude-code-playground, under the Apache 2.0 license in its LICENSE.
Reflect saves a rule either to ~/.claude/CLAUDE.md (Save everywhere) or as its own note in a folder you choose (Save to vault). Set that folder as rules_folder when you install reflect, or later for reflect in /config. If it is empty, reflect asks for the folder the first time you press Save to vault.
At the prompt of a terminal session:
/plugin install helper-desk --marketplace jz-wilson/claude-mods
Answer y to add the marketplace, then pick a scope.
From a shell:
claude plugin install helper-desk --marketplace jz-wilson/claude-mods
claude plugin install cache-bar --marketplace jz-wilson/claude-mods
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with jz-wilson/claude-mods, then Add plugin.
cache-bar is a status-line script, so it takes over statusLine and runs the previous status line inside it. After installing, run /cache-bar:setup. It:
settings.json to settings.json.bak-cache-bar;statusLine.command (claude-hud's, for example) into inner in ~/.claude/plugins/cache-bar/config.json;statusLine.command at the newest installed cache-bar version, and sets refreshInterval: 60 so the countdown repaints while you are idle.The previous status line prints first, unchanged, and the bar goes on its own line underneath. If the inner command fails, the bar still prints. Before the first request of a session there is no cache yet, so the bar is hidden; after expiry it shows a dim cold.
The same config file sets the handoff threshold and the context floor:
{ "inner": "…", "handoffMinutes": 5, "floorTokens": 80000 }
The threshold is capped at a fifth of the TTL, so it fits the 5-minute TTL too. Under floorTokens of context the bar says no handoff (<floor).
To undo, copy settings.json.bak-cache-bar back over settings.json, or set statusLine.command back to the inner value.
Each mod is a folder with .claude-plugin/plugin.json. CI runs claude plugin validate on every mod for each push and pull request, then claude plugin test for mods with a hooks module and bun test for the rest. A merge to main that bumps a mod's version publishes a GitHub release tagged <mod>-v<version>.
claude plugin validate agent-tree
claude plugin test agent-tree
bun test cache-barhooks/register.tsx 476 lines1import type { EngineInterface, ModelForkResult, Register } from 'claude-code'
2
3import type { Clock, Warm } from '../types'
4
5const CLOCK = { plugin: 'cache-clock', key: 'clock' } as const
6const WARM = { plugin: 'cache-clock', key: 'warm' } as const
7
8const FIVE_MIN = 5 * 60_000
9const ONE_HOUR = 60 * 60_000
10const DEMO_MS = 60_000
11// A cold chat this big gets the handoff button: re-reading it costs the most.
12const BIG = 150_000
13const BAR = 20
14
15// The progress bar is the first thing to go when the band is narrow; the countdown and buttons stay.
16export const barColumns = (bodyColumns: number) => (bodyColumns >= 100 ? BAR : bodyColumns >= 60 ? 10 : 0)
17
18// Keep warm: one small background ping about 45 s before the cache would go cold.
19// $.model.fork repeats the main thread's last request (same model, system prompt,
20// tools, messages) with this one line after it, so the API reads the chat from its
21// cache and the cache's timer starts again. The fork never writes the transcript.
22const LEAD = 45_000
23const MAX_PINGS = 30
24const MAX_MS = 2 * 60 * 60_000
25const MAX_FAILS = 3
26const RETRY_MS = 20_000
27const PING_PROMPT = 'Reply with the single word: ok'
28const TIP = 'Each ping reads the chat from cache for a small part of the price (5 to 10%). Letting it go cold means writing it all again.'
29// Turns that are not the person typing: they do not count as "they are back".
30const BACKGROUND = new Set(['task-notification', 'scheduled-trigger', 'peer', 'peer-send-message', 'projects-relay', 'channel', 'coordinator', 'observer', 'observer-activity', 'auto-continuation'])
31
32// Module variables reset on a reload; the clock itself lives in $.state.
33let sid = ''
34let home = ''
35let demo = false
36let detected: 'unknown' | '5m' | '1h' = 'unknown'
37let working = false
38let pinging = false
39
40// The /mods switchboard turns this mod off in every chat with switches.json.
41// "cache-clock:hidden": true in the same file hides the clock in every chat.
42const NAME = 'cache-clock'
43const HIDE_KEY = `${NAME}:hidden`
44let off = false
45let hidden = false
46
47const switchesPath = ($: EngineInterface) => `${$.plugin.root.replace(/\\/g, '/').split('/.claude/')[0] ?? ''}/.claude/mods/switches.json`
48
49async function readSwitches($: EngineInterface): Promise<Record<string, unknown>> {
50 const path = switchesPath($)
51 if (!(await $.fs.exists(path))) return {}
52 const json = JSON.parse(String(await $.fs.read(path))) as unknown
53 return json && typeof json === 'object' && !Array.isArray(json) ? (json as Record<string, unknown>) : {}
54}
55
56async function readSwitch($: EngineInterface) {
57 let nowOff = off
58 let nowHidden = hidden
59 try {
60 const json = await readSwitches($)
61 nowOff = json[NAME] === false
62 nowHidden = json[HIDE_KEY] === true
63 } catch {
64 // A broken or missing file changes nothing.
65 }
66 if (nowOff === off && nowHidden === hidden) return
67 off = nowOff
68 hidden = nowHidden
69 if (off) await stopWarm($, 'Keep warm stopped: Cache Clock was turned off in /mods.')
70 else if (hidden) await stopWarm($, 'Keep warm stopped: Cache Clock is hidden.')
71 await redraw($)
72}
73
74async function redraw($: EngineInterface) {
75 try {
76 const c = await getClock($)
77 if (c) await setClock($, { ...c, now: await $.clock.now() })
78 $.ui.invalidate('ui.render')
79 } catch {
80 // The next tick redraws anyway.
81 }
82}
83
84// Read, change, write the whole file, so every chat sees the choice within 3 s.
85async function setHidden($: EngineInterface, value: boolean) {
86 try {
87 const json = await readSwitches($)
88 if (value) json[HIDE_KEY] = true
89 else delete json[HIDE_KEY]
90 await $.fs.write(switchesPath($), `${JSON.stringify(json, null, 2)}\n`)
91 } catch (err) {
92 await log($, `save hidden failed: ${String(err)}`)
93 }
94 hidden = value
95 if (value) await stopWarm($, 'Keep warm stopped: Cache Clock is hidden.')
96 await redraw($)
97}
98
99const short = (tokens: number) => `${Math.round(tokens / 1000)}k`
100const mmss = (ms: number) => {
101 const s = Math.max(0, Math.ceil(ms / 1000))
102 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
103}
104const ago = (ms: number) => {
105 const m = Math.floor(ms / 60_000)
106 return m < 1 ? 'just now' : m === 1 ? '1 min ago' : m < 60 ? `${m} min ago` : `${Math.floor(m / 60)} h ago`
107}
108
109/** Where the cache length comes from: Anthropic's own reply, the demo, or a guess before the first reply. */
110function lengthLabel(c: Clock) {
111 if (c.ttlSource === 'demo') return '60 s demo cache, this chat only'
112 if (c.ttlSource === 'detected') return `${c.ttlMs === ONE_HOUR ? '1 hour' : '5 min'} cache, as Anthropic's last reply set it`
113 return '5 min cache, a guess until the first reply'
114}
115
116function ttl(): { ttlMs: number; ttlSource: Clock['ttlSource'] } {
117 if (demo) return { ttlMs: DEMO_MS, ttlSource: 'demo' }
118 if (detected === '1h') return { ttlMs: ONE_HOUR, ttlSource: 'detected' }
119 if (detected === '5m') return { ttlMs: FIVE_MIN, ttlSource: 'detected' }
120 return { ttlMs: FIVE_MIN, ttlSource: 'default' }
121}
122
123async function getClock($: EngineInterface): Promise<Clock | null> {
124 const { value } = await $.state.get(CLOCK)
125 return value ?? null
126}
127
128async function setClock($: EngineInterface, c: Clock | null) {
129 await $.state.set(CLOCK, c)
130}
131
132async function getWarm($: EngineInterface): Promise<Warm | null> {
133 const { value } = await $.state.get(WARM)
134 return value ?? null
135}
136
137async function setWarm($: EngineInterface, w: Warm | null) {
138 await $.state.set(WARM, w)
139 try {
140 $.ui.invalidate('ui.render')
141 } catch {
142 // The next tick redraws anyway.
143 }
144}
145
146function toast($: EngineInterface, text: string) {
147 try {
148 $.ui.toast(text)
149 } catch {
150 // The toast is only a hint.
151 }
152}
153
154// Turns keep warm on. 'none' when there is no reply yet whose cache could be kept.
155async function startWarm($: EngineInterface): Promise<'on' | 'none'> {
156 const c = await getClock($)
157 if (!c || !c.lastEnd) return 'none'
158 // Keep warm never runs out of sight: it shows the clock first.
159 if (hidden) await setHidden($, false)
160 await setWarm($, { startedAt: await $.clock.now(), pings: 0, read: 0, write: 0, cold: false, error: '', sending: false, failures: 0, retryAt: 0 })
161 await log($, `keep warm on (${c.ttlMs / 1000} s cache, ${short(c.tokens)} tokens)`)
162 return 'on'
163}
164
165async function stopWarm($: EngineInterface, why: string, shout = true) {
166 const w = await getWarm($)
167 if (!w) return
168 await setWarm($, null)
169 await log($, `keep warm off after ${w.pings} pings: ${why}`)
170 if (shout) toast($, why)
171}
172
173// Once a second: sends the ping when the cache is 45 s from going cold, and keeps the caps.
174async function warmTick($: EngineInterface) {
175 const w = await getWarm($)
176 if (!w || pinging || working) return
177 if (off) return stopWarm($, 'Keep warm stopped: Cache Clock was turned off in /mods.')
178 const now = await $.clock.now()
179 if (now - w.startedAt >= MAX_MS) return stopWarm($, 'Keep warm stopped after 2 hours. Type /keepwarm to start it again.')
180 if (w.pings >= MAX_PINGS) return stopWarm($, `Keep warm stopped after ${MAX_PINGS} pings. Type /keepwarm to start it again.`)
181 if (now < w.retryAt) return
182 const c = await getClock($)
183 if (!c || !c.lastEnd) return
184 if (c.lastEnd + c.ttlMs - now > LEAD) return
185 pinging = true
186 void ping($, now).catch(async err => {
187 pinging = false
188 await log($, `ping failed: ${String(err)}`)
189 })
190}
191
192async function ping($: EngineInterface, start: number) {
193 const w0 = await getWarm($)
194 if (w0) await setWarm($, { ...w0, sending: true })
195 let r: ModelForkResult | null = null
196 let thrown = ''
197 try {
198 r = await $.model.fork({ prompt: PING_PROMPT })
199 } catch (err) {
200 thrown = String(err)
201 }
202 pinging = false
203 const n = (w0?.pings ?? 0) + 1
204 if (r && !r.isAnswered && r.reason === 'nothing-to-fork') {
205 await log($, `ping ${n}: nothing to fork yet`)
206 return stopWarm($, 'Keep warm stopped: there is no reply to keep warm yet.')
207 }
208 const usage = r && 'usage' in r ? r.usage : null
209 const read = usage?.cache_read_input_tokens ?? 0
210 const write = usage?.cache_creation_input_tokens ?? 0
211 // An answer, or a reply with no words, still read the chat: the cache timer restarted.
212 const ok = r !== null && (r.isAnswered || r.reason === 'empty-reply')
213 const error = ok ? '' : thrown ? 'error' : r && !r.isAnswered && r.reason === 'api-error' ? `${r.error}${r.status ? ` ${r.status}` : ''}` : r && !r.isAnswered ? r.reason : 'error'
214 await log($, `ping ${n}: cache read ${read}, cache write ${write}, error ${error || 'none'}${thrown ? ` (${thrown.slice(0, 200)})` : ''}`)
215 const w = await getWarm($)
216 // Stopped while the ping was out (a prompt, /keepwarm off): only the log keeps it.
217 if (!w) return
218 const now = await $.clock.now()
219 if (ok) {
220 await setWarm($, { ...w, pings: n, read, write, cold: write > read, error: '', sending: false, failures: 0, retryAt: 0 })
221 const c = await getClock($)
222 if (c) await setClock($, { ...c, lastEnd: Math.max(c.lastEnd, start), now })
223 } else {
224 const failures = w.failures + 1
225 await setWarm($, { ...w, pings: n, error, sending: false, failures, retryAt: now + RETRY_MS })
226 if (failures >= MAX_FAILS) return stopWarm($, `Keep warm stopped: ${MAX_FAILS} pings in a row failed (${error}).`)
227 }
228 if (n >= MAX_PINGS) await stopWarm($, `Keep warm stopped after ${MAX_PINGS} pings. Type /keepwarm to start it again.`)
229}
230
231function warmLine(w: Warm, left: number): string {
232 const parts = ['Keeping warm']
233 if (w.sending) parts.push(`sending ping ${w.pings + 1}`)
234 else if (w.pings === 0) parts.push(`first ping in ${mmss(left - LEAD)}`)
235 else parts.push(`ping ${w.pings}`, `next in ${mmss(left - LEAD)}`)
236 if (w.error) parts.push(`last ping failed (${w.error}), trying again`)
237 else if (w.pings > 0 && w.cold) parts.push(`the cache had gone cold, so ping ${w.pings} wrote ${short(w.write)} tokens to it again`)
238 else if (w.pings > 0) parts.push(`last ping read ${short(w.read)} cached tokens`)
239 return parts.join(' · ')
240}
241
242async function log($: EngineInterface, line: string) {
243 try {
244 const path = `${home}/.claude/mods-logs/cache-clock.log`
245 const old = (await $.fs.exists(path)) ? String(await $.fs.read(path)) : ''
246 const lines = old ? old.trimEnd().split('\n').slice(-200) : []
247 lines.push(`${new Date(await $.clock.now()).toISOString()} ${sid.slice(0, 8)} ${line}`)
248 await $.fs.write(path, `${lines.join('\n')}\n`)
249 } catch {
250 // The log must never break the clock.
251 }
252}
253
254// Reads which cache length this chat's replies were written with (5 min or 1 hour).
255async function detect($: EngineInterface) {
256 if (!sid) return
257 // Windows installs often have only `python`; a missing command throws rather than exiting non-zero.
258 const py = (bin: string) => $.process.run([bin, `${$.plugin.root}/bin/ttl.py`, sid]).catch(() => null)
259 let r = await py('python3')
260 if (!r || r.exitCode !== 0) r = (await py('python')) ?? r
261 if (!r) return log($, 'ttl check failed: no python3 or python')
262 const out = r.stdout.trim()
263 if (out === '5m' || out === '1h') detected = out
264 else if (r.exitCode !== 0) await log($, `ttl check failed: ${r.stderr.slice(0, 200)}`)
265}
266
267// A main-chat reply just ended: the cache is warm again from now.
268async function restart($: EngineInterface) {
269 const now = await $.clock.now()
270 const { context } = await $.session.usage()
271 await detect($).catch(() => undefined)
272 await setClock($, { lastEnd: now, ...ttl(), tokens: context.tokens ?? 0, now, note: '' })
273}
274
275// Once a second: moves the countdown while the band shows.
276async function tick($: EngineInterface) {
277 const c = await getClock($)
278 // While a turn runs the band is not drawn, so there is nothing to count.
279 if (!c || off || hidden || working || !c.lastEnd) return
280 const now = await $.clock.now()
281 // A cold clock only needs a redraw once a minute, for its "went cold" line.
282 const cold = now - c.lastEnd >= c.ttlMs
283 if (cold && Math.floor((now - c.lastEnd) / 60_000) === Math.floor((c.now - c.lastEnd) / 60_000) && c.now - c.lastEnd >= c.ttlMs) return
284 await setClock($, { ...c, now })
285}
286
287async function handoff($: EngineInterface) {
288 const c = await getClock($)
289 // The handoff mod picks this file up within 5 seconds.
290 await $.fs.write(`${home}/.claude/handoffs/.request-${sid}`, String(await $.clock.now()))
291 await log($, `asked for a handoff at ${short(c?.tokens ?? 0)}`)
292 if (c) await setClock($, { ...c, note: 'Handoff asked for. Watch the Refresh panel.' })
293}
294
295export const register: Register = on => {
296 on('session.start', async ($, e, next) => {
297 sid = await $.session.id()
298 home = $.plugin.root.replace(/\\/g, '/').split('/.claude/')[0] ?? ''
299 // The 60 s demo is for one chat only. Older versions saved it for every chat, so clear that.
300 demo = false
301 if ((await $.store.get('demo').catch(() => undefined)) === true) {
302 await $.store.set('demo', false).catch(() => undefined)
303 await log($, 'cleared a demo setting saved by an older version')
304 }
305 await readSwitch($).catch(() => undefined)
306 $.clock.every(3000, () => {
307 void readSwitch($).catch(() => undefined)
308 })
309 await $.command.register({
310 name: 'cacheclock',
311 description: 'Cache Clock: show or hide it. "/cacheclock hide" hides it, "/cacheclock demo" uses a 60 s cache for filming, "/cacheclock auto" goes back',
312 argumentHint: '[hide|demo|auto]',
313 immediate: true,
314 })
315 await $.command.register({
316 name: 'keepwarm',
317 description: 'Cache Clock: keep the cache warm with a small background ping 45 s before it goes cold. "/keepwarm off" stops it',
318 argumentHint: '[off]',
319 immediate: true,
320 })
321 $.clock.every(1000, () => {
322 void tick($).catch(() => undefined)
323 void warmTick($).catch(err => log($, `keep warm tick failed: ${String(err)}`))
324 })
325 void detect($).catch(() => undefined)
326 return next(e)
327 })
328
329 on('turn.start', async ($, e, next) => {
330 if (off) return next(e)
331 // Should the engine ever raise a turn for the ping's own fork, it is not the person's turn.
332 // A helper's turn does not stop the main chat's band, and only a main-loop complete clears this.
333 if (e.agentId === undefined && !(pinging && e.text === PING_PROMPT)) working = true
334 return next(e)
335 })
336
337 on('turn.complete', async ($, e, next) => {
338 // Clear before anything can return early or throw, so the countdown never stays frozen.
339 if (e.agentId === undefined) working = false
340 if (off) return next(e)
341 const result = await next(e)
342 // A helper's turn does not touch the main chat's cache.
343 if (e.agentId === undefined) void restart($).catch(err => log($, `restart failed: ${String(err)}`))
344 return result
345 })
346
347 // The person is back: a prompt of theirs ends keep warm. Its own commands do not.
348 on('prompt.submit', async ($, e, next) => {
349 if (off) return next(e)
350 const text = e.text.trim().toLowerCase()
351 const own = text.startsWith('/keepwarm') || text.startsWith('/cacheclock') || text === PING_PROMPT.toLowerCase()
352 if (!own && !BACKGROUND.has(e.origin?.kind ?? '')) {
353 void stopWarm($, 'Keep warm stopped: you sent a message.').catch(() => undefined)
354 }
355 return next(e)
356 })
357
358 on('command.run', { command: 'keepwarm' }, async ($, e) => {
359 if (off) return { text: 'Cache Clock is off. Turn it on with /mods.' }
360 const arg = e.args.trim().toLowerCase()
361 const w = await getWarm($)
362 if (arg === 'off' || (arg !== 'on' && w)) {
363 await stopWarm($, 'Keep warm is off.', false)
364 return { text: 'Keep warm is off. Type /keepwarm to start it again.' }
365 }
366 if (w) return { text: 'Keep warm is already on. Type /keepwarm off to stop it.' }
367 if ((await startWarm($)) === 'none') return { text: "Nothing to keep warm yet. Keep warm works after Claude's first reply." }
368 return { text: 'Keep warm is on: one small background ping about 45 s before the cache goes cold. It stops after 2 hours, 30 pings, or when you send a message. Type /keepwarm off to stop it.' }
369 })
370
371 on('ui.press', { plugin: 'cache-clock', element: 'keepwarm' }, async ($, e) => {
372 if (off) return { element: e.element }
373 if (await getWarm($)) await stopWarm($, 'Keep warm is off.')
374 else if ((await startWarm($)) === 'on') toast($, 'Keep warm is on. Type /keepwarm off to stop it.')
375 return { element: e.element }
376 })
377
378 on('command.run', { command: 'cacheclock' }, async ($, e) => {
379 if (off) return { text: 'Cache Clock is off. Turn it on with /mods.' }
380 const arg = e.args.trim().toLowerCase()
381 const hide = arg === 'hide' || (arg !== 'demo' && arg !== 'auto' && arg !== 'show' && !hidden)
382 if (arg === 'demo' || arg === 'auto') {
383 // This chat only: a new chat starts with the real cache length again.
384 demo = arg === 'demo'
385 }
386 await setHidden($, hide)
387 const c = await getClock($)
388 if (c) await setClock($, { ...c, ...ttl(), now: await $.clock.now() })
389 if (hide) return { text: 'Cache Clock hidden in every chat. Type /cacheclock or open /mods to show it.' }
390 const t = ttl()
391 const how = lengthLabel({ ttlMs: t.ttlMs, ttlSource: t.ttlSource } as Clock)
392 return { text: `Cache Clock is on: ${how}. Type /cacheclock hide or open /mods to hide it.` }
393 })
394
395 on('ui.press', { plugin: 'cache-clock', element: 'handoff' }, async ($, e) => {
396 if (off) return { element: e.element }
397 await handoff($)
398 return { element: e.element }
399 })
400 on('ui.press', { plugin: 'cache-clock', element: 'hide' }, async ($, e) => {
401 const wasWarm = (await getWarm($)) !== null
402 await stopWarm($, 'Keep warm stopped: Cache Clock is hidden.', false)
403 await setHidden($, true)
404 toast($, `Cache Clock hidden${wasWarm ? ' and keep warm stopped' : ''}. Type /cacheclock or open /mods to show it.`)
405 return { element: e.element }
406 })
407
408 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
409 if (off) return next(e)
410 const c = await getClock($)
411 if (hidden || !c || !c.lastEnd || working || e.props.isWorking || e.props.hasSurvey) return next(e)
412 const rest = await next(e)
413 const { Box, Text, Button } = $.ui.resolve(e)
414 const noop = () => undefined
415 const left = c.lastEnd + c.ttlMs - c.now
416 const bar = barColumns(e.props.bodyColumns)
417 const cost = `${short(c.tokens)} tokens`
418 const w = await getWarm($)
419 const warmButton = w ? <Button key="keepwarm" label="Stop keeping warm" onPress={noop} /> : <Button key="keepwarm" label="Keep warm" onPress={noop} />
420 const warmRow = w ? <Text color={w.cold || w.error ? 'error' : 'suggestion'} wrap="truncate-end">{warmLine(w, left)}</Text> : null
421 const tip = <Text dimColor>{TIP}</Text>
422
423 if (left > 0) {
424 const share = left / c.ttlMs
425 const filled = Math.max(1, Math.round(share * bar))
426 const color = left <= 60_000 ? 'error' : share < 0.5 ? 'warning' : 'success'
427 return (
428 <Box flexDirection="column">
429 {rest}
430 <Box flexDirection="row" alignItems="center" justifyContent="space-between" gap={1}>
431 <Text wrap="truncate-end">
432 <Text color={color}>◷ Cache warm </Text>
433 <Text weight="bold" color={color}>
434 {mmss(left)}
435 </Text>
436 <Text> </Text>
437 {bar > 0 ? <Text color={color}>{'━'.repeat(filled)}</Text> : null}
438 {bar > 0 ? <Text dimColor>{'━'.repeat(bar - filled)}</Text> : null}
439 <Text dimColor>{` ${lengthLabel(c)}.`}</Text>
440 <Text dimColor> Reply now and {cost} cost 5 to 10% as much.</Text>
441 </Text>
442 <Box flexDirection="row" gap={1}>
443 {warmButton}
444 <Button key="hide" label="Hide" role="dismiss" onPress={noop} />
445 </Box>
446 </Box>
447 {warmRow}
448 {tip}
449 </Box>
450 )
451 }
452
453 // The 60 s demo always shows the button, so it can be filmed in a small chat.
454 const big = c.tokens >= BIG || c.ttlSource === 'demo'
455 return (
456 <Box flexDirection="column">
457 {rest}
458 <Box flexDirection="row" alignItems="center" justifyContent="space-between" gap={1}>
459 <Text wrap="truncate-end">
460 <Text color="suggestion">❄ Cache cold</Text>
461 <Text dimColor> since {ago(-left)}. Your next message re-reads {cost} at full cost.</Text>
462 </Text>
463 <Box flexDirection="row" gap={1}>
464 {big ? <Button key="handoff" label="Hand off to a fresh chat" variant="primary" onPress={noop} /> : null}
465 {warmButton}
466 <Button key="hide" label="Hide" role="dismiss" onPress={noop} />
467 </Box>
468 </Box>
469 {warmRow}
470 {c.note ? <Text dimColor>{c.note}</Text> : null}
471 {tip}
472 </Box>
473 )
474 })
475}
476types/index.d.ts 42 lines1export type Clock = {
2 /** When the last main-chat reply ended, in ms. 0 before the first reply. */
3 lastEnd: number
4 /** How long the cache stays warm after a reply, in ms. */
5 ttlMs: number
6 /** detected: read from this chat's log. default: 5 min guess. demo: 60 s for filming. */
7 ttlSource: 'detected' | 'default' | 'demo'
8 /** Context tokens the next message re-sends. */
9 tokens: number
10 /** The clock's last tick, in ms, so the band redraws every second. */
11 now: number
12 /** A line under the band after a button press. */
13 note: string
14}
15
16export type Warm = {
17 /** When keep warm was turned on, in ms: it stops by itself 2 hours later. */
18 startedAt: number
19 /** Pings sent so far: it stops by itself after 30. */
20 pings: number
21 /** Cache read tokens of the last ping. */
22 read: number
23 /** Cache write tokens of the last ping. */
24 write: number
25 /** True when the last ping wrote the chat to the cache again: it had gone cold. */
26 cold: boolean
27 /** Why the last ping failed, or ''. */
28 error: string
29 /** True while a ping is out. */
30 sending: boolean
31 /** Failed pings in a row: it stops after 3. */
32 failures: number
33 /** No new ping before this time, in ms (after a failure). */
34 retryAt: number
35}
36
37declare module 'claude-code' {
38 interface PluginState {
39 'cache-clock': { clock: Clock | null; warm: Warm | null }
40 }
41}
42