Keeps the prompt cache warm while a session sits idle: shortly before the cache would expire, a small background request re-reads it, without touching the…

Keep Claude Code's prompt cache warm while you're away.

When a Claude Code session sits idle, its prompt cache expires, and your next message has to rebuild it from scratch. That's slower, and it uses more of your usage limits. cachebeat gives the session a heartbeat: shortly before the cache would expire, it sends a tiny background request that reads the conversation and keeps the cache alive. Your transcript never sees it.
You come back from lunch, type your next message, and it picks up right where you left off.
claude --version and update with claude update.claude -p.In your terminal:
claude plugin marketplace add 404Mayank/cachebeat
claude plugin install cachebeat@cachebeat
Or from inside Claude Code:
/plugin marketplace add 404Mayank/cachebeat
/plugin install cachebeat@cachebeat
/reload-plugins
If /cachebeat isn't there afterwards, restart Claude Code.
Update:
claude plugin marketplace update cachebeat
claude plugin update cachebeat@cachebeat
Then restart Claude Code. To get new versions automatically, turn on auto-update under /plugin → Marketplaces → cachebeat.
Uninstall:
claude plugin uninstall cachebeat@cachebeat
claude plugin marketplace remove cachebeat
In any session:
/cachebeat on
The heart appears under the prompt, and after your next turn it starts beating. To have every new session start with it on:
/cachebeat global on
Or ask Claude: "keep the cache warm in this session", "make 30 minutes the default beat time".
Beats fit your cache by themselves: every 50 minutes on a Claude subscription's one-hour cache, and every 4 on the five-minute cache of an API key, a cloud provider or usage credits. To pick your own interval, use /cachebeat 30 for this session, or set Beat after idle in /cachebeat settings.
Each beat is one small request to the Anthropic API, made the way Claude Code makes its own requests: it carries the current conversation and asks for a one-character reply. The reply is thrown away, and nothing is added to your transcript.
promptCacheTtl setting, the variables FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL and ENABLE_PROMPT_CACHING_1H, and whether your usage limits are reported, which they are on a subscription./cachebeat and the two tools Claude uses, mcp__cachebeat__state and mcp__cachebeat__set, are answered by cachebeat itself. The tools read or change only cachebeat's own state and settings. Claude calls them without a permission prompt, and each call that goes through shows as one line in the transcript./model switches, without changing them, so beats can wait for your next message or warm the new model, as After /model says.| Command | What it does |
|---|---|
/cachebeat | Shows the status: on or off, the interval, beats so far, the next beat |
/cachebeat on | Turns it on for this session |
/cachebeat off | Turns it off for this session |
/cachebeat <minutes> | Turns it on with a different interval, e.g. /cachebeat 30 (1–55) |
/cachebeat now | Beats right away |
/cachebeat global on / off | Sets whether new sessions start on, and applies it here too |
/cachebeat settings | Opens the settings menu |
The heart sits at the end of the hint line under the prompt, with the beat count beside it. It beats while a beat is scheduled, bursts when one lands, and holds still while it waits for your next turn.
The turn line sits under your latest turn. Before the first beat it reads ♡ next beat in 50m, and after that ♥ cache kept warm ×3 · 184k cached · next in 42m. Its heart beats too, with an animation of its own under Turn line. While beats wait for your next message, after /model or /compact, it says so. When you send a new message, the line moves down to the new turn. In the desktop app it stays above the prompt instead.
✻ Cogitated for 12s · done 3:09 PM
♥ cache kept warm ×3 · next in 42m
❯
⏸ manual mode on · ⠀(♥)⠀ ×3
Open the menu with /cachebeat settings, or ask Claude to change a setting. Settings are saved once and apply to every session.
Settings with numbers also take a custom value at the bottom of their list, such as 35 minutes, 90m, or 35k tokens. A value out of range is refused, with what the setting takes. Your last custom value stays in the list after you pick another, so it's one Enter from coming back.
Everything in the menu applies to every session, except This session at the top of Beating.
| Setting | What it does | Default |
|---|---|---|
| This session | Turns beating on or off right here | off |
| New sessions start | Whether new sessions start with it on | off |
| Beat after idle | How long the session sits idle before a beat: auto fits your cache, or a number of minutes | auto: 50 minutes on a one-hour cache, 4 on a five-minute one |
| After /model | Wait for your next message, or have the next beat warm the new model's cache | wait |
/cachebeat 30 changes | Whether /cachebeat 30 changes this session only, or the default for all | this session |
Beat now sends a beat right away, and Reset all puts every setting back to its default.
| Setting | What it does | Default |
|---|---|---|
| Stop after idle | Stops beating after this long without a message from you | 8 hours |
| Stop at usage | Stops once any of your usage limits reaches this percentage | 90% |
| Skip small chats | Skips beating chats under a minimum size | off, 20k tokens |
With Skip small chats on, cachebeat tells you up front when a chat is under your minimum, once in the log and in the turn line: ♡ beats skip · this chat is 38k tokens, under your 50k minimum. It starts beating as soon as the chat grows past the minimum.
| Setting | What it does | Default |
|---|---|---|
| Placement | At the end of the hint line, drawn dim, or on its own line, in color | hint line |
| Animation | Which of the 19 animations the heart plays | Classic |
| Beat count | Shows the ×3 beside the heart | on |
| Color | On its own line: dim, a color from your theme, a preset, or any hex color | claude, your theme's accent |
| Animate | Turns its animation on or off | on |
| Effect | On its own line: steady; flash on each beat; flow, a shimmer sweeping across; or mixed, which switches between them | steady |
| Speed | Slow, normal, fast, or a custom frame time | normal |
| Timing | Lub-dub beats twice, then rests, and suits the hearts; linear plays evenly, and suits the line animations | lub-dub |
The Animation list plays every animation at once, so you can watch them side by side before you pick: Classic, Pulse, Triplet, Sparkle, Fleuron, Wave, ECG, Beam, Dash, Sine, Charge, Converge, Twins, Orbit, Static, Garland, Equalizer, Cupid and Bounce.
Classic ⋅ ♡ ⋅ ECG ─⎼⎺⎽────⎼⎺⎽───♥
Sparkle ✦ ♥ ✦ Sine ⠒⠉⠒⠤⣀⠤⠒⠉⠒⠤⣀⠤⠒⠉❤︎
Orbit •♥ Cupid ─➤ ♡
The desktop app draws no heart under the prompt.
| Setting | What it does | Default |
|---|---|---|
| Show | Under the turn after a blank line, directly under it, or off | after a blank line |
| Countdown | Shows the time to the next beat | on |
| Tokens kept | Shows how much the last beat kept warm, e.g. · 184k cached | on |
| Its heart | What the heart at the start of the line plays: one heart beating, a still one, or any of the 19 animations | one heart, beating |
| Color | Dim, a color from your theme, a preset, or any hex color | claude, your theme's accent |
| Animate · Effect · Speed · Timing | As for the prompt heart, for the line on its own | on · steady · normal · lub-dub |
Theme colors follow your Claude Code theme, so they change when you switch themes. In the desktop app the line sits above the prompt.
| Setting | What it does | Default |
|---|---|---|
| On a beat | Log, toast, both, or nothing when a beat lands | nothing |
| On stop | Log, toast, both, or nothing when beating stops by itself | log |
cachebeat turns itself off for the session when:
It tells you why, the way you chose under Alerts. On auto, a cache that's gone before a 50-minute beat lasts five minutes: instead of stopping, cachebeat says so and beats every 4 minutes from there.
/compact, and after /model unless After /model is set to warm the new model, beats wait for your next message: there's no cache for it yet./clear starts a fresh conversation: the count goes back to zero, and beats resume after your first message./cachebeat now needs at least one turn in the session, since before that there's nothing cached to keep warm.Contributions are welcome. Read the contributing guide to get started.
cachebeat began as a personal project and is shared publicly in good faith. It's actively maintained: report problems or ideas as a GitHub issue and they'll be looked at, though what gets fixed or added is up to the maintainer.
Every beat is a real request that counts toward your usage limits, or toward your API bill if you pay per token. You use cachebeat at your own risk, and its author isn't responsible for any usage, charges or other costs it causes.
hooks/register.tsx 894 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import type { Alert, BeatSettings, PanePage, Pulse, Saved, Ttl } from '../types'
4import { cells, loopIndex, slotText, slotWidths, statusFrame, variant } from './animations'
5import { PANE, paint, settingsPane, statusText } from './pane'
6import { AUTO, DEFAULTS, TABS, changed, frameMs, glyphs, isLit, lookOf, normalize, rowOf, spans, tokens } from './settings'
7import type { Span, Value } from './settings'
8import { SET, STATE, parseSet, summary } from './tools'
9
10const MIN = 60_000
11export const IDLE = AUTO['1h'] * MIN // the default silence before a beat on a subscription's one-hour cache
12export const DEADLINE = DEFAULTS.stopAfterHours * 60 * MIN // default stop this long after the last real turn
13export const RETRY = MIN // after a transient API error
14export const MAX_TTL = 60 * MIN // past this since the last cache read, the cache is gone whatever the TTL
15
16const pulse = atom({ plugin: 'cachebeat', key: 'pulse' } as const, 'hidden' as Pulse)
17const saved = atom({ plugin: 'cachebeat', key: 'saved' } as const, null as Saved | null)
18const frameAtom = atom({ plugin: 'cachebeat', key: 'frame' } as const, 0)
19const lineFrameAtom = atom({ plugin: 'cachebeat', key: 'lineFrame' } as const, 0)
20const lineAtom = atom({ plugin: 'cachebeat', key: 'line' } as const, '')
21const settingsAtom = atom({ plugin: 'cachebeat', key: 'settings' } as const, DEFAULTS)
22const tickAtom = atom({ plugin: 'cachebeat', key: 'tick' } as const, 0)
23const pageAtom = atom({ plugin: 'cachebeat', key: 'page' } as const, { tab: 'beating', picker: null } as PanePage)
24const focusAtom = atom({ plugin: 'cachebeat', key: 'focus' } as const, '')
25
26const fresh: Saved = {
27 enabled: false, idle: null, lastReal: null, lastWarm: null, lastRead: null, nextAt: null, beats: 0, row: null, small: null,
28 isCacheShort: false, paused: null, warmModel: null,
29}
30let s: Saved = { ...fresh }
31let cfg: BeatSettings = DEFAULTS
32let timer: { cancel: () => void } | undefined
33let ticker: { cancel: () => void } | undefined // the prompt heart's animation clock
34let lineTicker: { cancel: () => void } | undefined // the turn line's, at its own speed
35let paneTicker: { cancel: () => void } | undefined
36let paneTick = 0
37let ring: string[] = [] // the pane's focusable keys as last drawn, in order
38let notice = '' // the pane's footer line
39let isResetArmed = false
40let frame = 0
41let lineFrame = 0
42let blast = -1 // the blast frame showing after a beat, or -1
43let busy = false // a main-thread turn is running
44let beating = false
45let rowPending = false // the turn just ended: its closing row is the next new one drawn
46let isStatusOnBand = false // the desktop draws no closing row: the band above the prompt carries the countdown
47let paneSurface = 'terminal' // where the settings pane last drew: the terminal's keys are walked here, a desktop's its own
48let ttl: Ttl = '1h' // the main conversation's cache lifetime, as last read
49let scheduling = 0 // counts schedule() calls: one still awaiting when a newer starts leaves the timer to it
50const rowsSeen = new Set<string>()
51
52export const fmt = (ms: number) => {
53 const m = Math.max(1, Math.ceil(ms / MIN))
54 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${m % 60 ? ` ${m % 60}m` : ''}`
55}
56
57/** This session's interval: its own, else the setting's, `auto` read off the cache's lifetime. */
58const idle = () => s.idle ?? (cfg.interval === 'auto' ? AUTO[s.isCacheShort ? '5m' : ttl] : cfg.interval) * MIN
59
60const isSet = (v: string | undefined) => v !== undefined && /^(1|true|yes|on)$/i.test(v)
61
62/**
63 * The main conversation's cache lifetime, as Claude Code picks it: the first of the variables and the
64 * setting that choose one, else an hour on a subscription within its plan's usage and five minutes on
65 * usage credits, an API key or a cloud provider (code.claude.com/docs/en/prompt-caching).
66 */
67async function cacheTtl($: EngineInterface): Promise<Ttl> {
68 if (isSet(await $.env.get('FORCE_PROMPT_CACHING_5M'))) return '5m'
69 const chosen = (v: unknown) => (v === '5m' || v === '1h' ? v : undefined)
70 const fromEnv = chosen(await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'))
71 if (fromEnv) return fromEnv
72 const merged = await $.settings.read()
73 const fromSettings = chosen(merged.promptCacheTtl)
74 if (fromSettings) return fromSettings
75 if (isSet(await $.env.get('ENABLE_PROMPT_CACHING_1H'))) return '1h'
76 // rate-limit windows are reported on a subscription alone; one used up means usage credits are paying
77 const { rateLimits } = await $.session.usage()
78 return rateLimits.length > 0 && rateLimits.every(l => l.percentUsed < 100) ? '1h' : '5m'
79}
80
81function save($: EngineInterface) {
82 const copy = { ...s }
83 void update($, saved, () => copy)
84}
85
86/**
87 * The custom value each setting last took, in its own store key: a preset or a reset leaves it in the
88 * setting's list, one Enter from coming back.
89 */
90let customs: Partial<Record<keyof BeatSettings, Value>> = {}
91
92async function keepCustoms($: EngineInterface, patch: Partial<BeatSettings>) {
93 const kept = Object.entries(patch).filter(([k, v]) => {
94 const row = rowOf(k as keyof BeatSettings)?.row
95 return row?.custom && !row.values.includes(v as Value)
96 })
97 if (!kept.length) return
98 const stored = await $.store.get('customs')
99 customs = { ...(stored as typeof customs | undefined), ...Object.fromEntries(kept) }
100 await $.store.set('customs', customs)
101}
102
103async function setSettings($: EngineInterface, patch: Partial<BeatSettings>) {
104 await keepCustoms($, patch)
105 // from the store, not this session's copy: another session may have changed it since
106 cfg = { ...normalize(await $.store.get('settings')), ...patch }
107 const copy = { ...cfg }
108 await $.store.set('settings', changed(copy))
109 await update($, settingsAtom, () => copy)
110 restartClocks($)
111 if (s.enabled && !busy && !beating) await schedule($)
112 else await refreshLine($)
113}
114
115/**
116 * Picks up settings another session changed since this one last looked, and publishes them where
117 * the drawings read them, which a /clear empties.
118 */
119async function syncSettings($: EngineInterface) {
120 const now = normalize(await $.store.get('settings'))
121 const same = (x: unknown) => JSON.stringify(x) === JSON.stringify(now)
122 if (same(cfg) && same(await read($, settingsAtom))) return
123 cfg = now
124 await update($, settingsAtom, () => now)
125 restartClocks($)
126}
127
128/** The animation clocks restart at the settings' speed, or stay stopped, as the next drawing finds them. */
129function restartClocks($: EngineInterface) {
130 stopClocks()
131 stopPaneTicker()
132 void update($, frameAtom, f => f + 1)
133 void update($, lineFrameAtom, f => f + 1)
134 void update($, tickAtom, t => t + 1)
135}
136
137/** The status line under the latest turn's closing row, or '' while there is nothing to say. */
138async function refreshLine($: EngineInterface) {
139 let text = ''
140 if (s.enabled && s.small) text = `♡ beats skip · ${s.small}`
141 else if (s.enabled && s.paused) text = s.beats > 0 ? `${statusText(cfg, s.beats, s.lastRead, null)} · waits for your next turn` : '♡ beats wait for your next turn'
142 else if (s.enabled || s.beats > 0) {
143 let next: string | null = null
144 const at = s.nextAt
145 if (s.enabled && at !== null) {
146 const now = await $.clock.now()
147 next = fmt(at - now)
148 }
149 text = statusText(cfg, s.beats, s.lastRead, next)
150 }
151 if (text !== (await read($, lineAtom))) await update($, lineAtom, () => text)
152}
153
154/** The prompt heart's animation clock: runs while the hint row draws the heart beating. */
155function animate($: EngineInterface) {
156 if (ticker) return
157 const ms = frameMs(lookOf(cfg, 'heart'))
158 const perSecond = Math.round(1000 / ms)
159 ticker = $.clock.every(ms, () => {
160 frame++
161 if (blast >= 0 && ++blast >= variant(cfg.variant).blast.length) blast = -1
162 void update($, frameAtom, () => frame)
163 if (frame % perSecond === 0) void refreshLine($) // the countdown, about once a second
164 })
165}
166
167/** The turn line's animation clock, at the line's own speed: runs while the line is drawn moving. */
168function animateLine($: EngineInterface) {
169 if (lineTicker) return
170 const ms = frameMs(lookOf(cfg, 'line'))
171 const perSecond = Math.round(1000 / ms)
172 lineTicker = $.clock.every(ms, () => {
173 lineFrame++
174 void update($, lineFrameAtom, () => lineFrame)
175 if (lineFrame % perSecond === 0) void refreshLine($)
176 })
177}
178
179function stopClocks() {
180 ticker?.cancel()
181 ticker = undefined
182 lineTicker?.cancel()
183 lineTicker = undefined
184}
185
186/** The heart as drawn now: the blast after a beat, the loop while armed, else the loop's first frame. */
187function heartFrame(c: BeatSettings, p: Pulse, f: number) {
188 const x = variant(c.variant)
189 if (!c.heartAnimate) return x.loop[0]!
190 if (blast >= 0) return x.blast[blast]!
191 return p === 'armed' ? x.loop[loopIndex(x, f, c.heartTiming)]! : x.loop[0]!
192}
193
194/**
195 * The status line as drawn, in its effect, its leading heart playing the status line's own animation:
196 * the heart's spans, then the rest's, and the cells the heart takes at its widest.
197 */
198function liveLine(line: string, c: BeatSettings, p: Pulse, f: number): { head: Span[]; rest: Span[]; width: number } {
199 const look = lookOf(c, 'line')
200 const isPlaying = look.animate && p === 'armed' && c.statusHeart !== 'off'
201 const lead = /^[♥♡]/.test(line) ? line[0]! : ''
202 const heart = lead && isPlaying ? statusFrame(c.statusHeart, f, look.timing) : lead
203 // a flash lights the line as its heart fills, or as one would beat, the heart still or none
204 const lit = isLit(isPlaying ? heart : statusFrame('beat', f, look.timing))
205 const all = spans(heart + line.slice(lead.length), look.animate && look.effect !== 'steady' ? look : { ...look, effect: 'steady' }, f, lit)
206 // the spans split where the heart ends, so it can sit in a box of its own
207 const head: Span[] = []
208 const rest: Span[] = []
209 let left = heart.length
210 for (const sp of all) {
211 const take = Math.min(left, sp.text.length)
212 if (take > 0) head.push({ ...sp, text: sp.text.slice(0, take) })
213 if (take < sp.text.length) rest.push({ ...sp, text: sp.text.slice(take) })
214 left -= take
215 }
216 return { head, rest, width: cells(heart) }
217}
218
219/** Spans in a row joined where they look alike, so a line draws as few runs of text as it can. */
220const joined = (list: Span[]) => list.reduce<Span[]>((out, sp) => {
221 const last = out.at(-1)
222 if (last && last.color === sp.color && last.dim === sp.dim) last.text += sp.text
223 else out.push({ ...sp })
224 return out
225}, [])
226
227function setPulse($: EngineInterface, p: Pulse) {
228 if (p !== 'armed') {
229 stopClocks()
230 blast = -1
231 }
232 void update($, pulse, () => p)
233}
234
235/** With Skip small contexts on, why this context is under the minimum; null when it is not. */
236async function smallContext($: EngineInterface) {
237 if (!cfg.skipSmall) return null
238 const size = await chatSize($)
239 if (size === undefined || size >= cfg.skipSmallTokens) return null
240 return `this chat is ${tokens(size)} tokens, under your ${tokens(cfg.skipSmallTokens)} minimum`
241}
242
243/**
244 * What a beat would read: the last response's whole prompt and its reply, which the next request
245 * carries too. The window's own figure counts the prompt alone, short by a long last answer.
246 */
247async function chatSize($: EngineInterface) {
248 const { context } = await $.session.usage({ breakdown: 'summary' }) // estimated here, no request sent
249 const u = context.breakdown?.apiUsage
250 if (!u) return context.tokens
251 return u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens
252}
253
254/** Notes whether this chat is under the minimum, saying so in the log as it goes under. */
255async function checkSmall($: EngineInterface) {
256 const small = await smallContext($)
257 if (small && !s.small) $.ui.log(`beats will skip: ${small}`)
258 s.small = small
259 return small !== null
260}
261
262/** Sets the next beat from the last cache read; resolves its delay, or undefined when none is set. */
263async function schedule($: EngineInterface): Promise<number | undefined> {
264 const call = ++scheduling
265 timer?.cancel()
266 timer = undefined
267 s.nextAt = null
268 let ms: number | undefined
269 let p: Pulse = 'armed'
270 if (!s.enabled) s.small = null
271 if (!s.enabled || busy) p = 'hidden' // a turn keeps the last word on the context; its end checks again
272 else if (await checkSmall($)) p = 'waiting' // nothing to beat for until a turn grows it
273 else {
274 const now = await $.clock.now()
275 const since = s.lastWarm === null ? Infinity : now - s.lastWarm
276 if (since >= MAX_TTL || s.paused) p = 'waiting' // nothing warm to keep, or none of use: the next turn starts it
277 else {
278 if (cfg.interval === 'auto' && s.idle === null) ttl = await cacheTtl($) // usage can cross into credits
279 if (call !== scheduling) return undefined
280 ms = Math.max(0, idle() - since)
281 s.nextAt = now + ms
282 timer = $.clock.after(ms, () => void scheduledBeat($))
283 }
284 }
285 // a newer call, begun while this one awaited, sets the timer and the heart: two would each arm one
286 if (call !== scheduling) return undefined
287 setPulse($, p)
288 save($)
289 await refreshLine($)
290 return ms
291}
292
293const logs = (how: Alert) => how === 'log' || how === 'both'
294const toasts = (how: Alert) => how === 'toast' || how === 'both'
295
296function announce($: EngineInterface, text: string) {
297 if (logs(cfg.onStop)) $.ui.log(text)
298 if (toasts(cfg.onStop)) $.ui.toast(`cachebeat ${text}`)
299}
300
301/** Beats wait for the next turn, which will cache what a beat would have to write: says why, once. */
302async function pause($: EngineInterface, why: string, said: string) {
303 if (s.enabled && s.paused !== why) $.ui.log(`beats wait for your next turn: ${said}`)
304 s.paused = why
305 await schedule($)
306}
307
308const MODEL_CHANGED = 'the model changed'
309const SETTLE = 1000 // ms: a /model switch is applied once the hooks on it have settled
310
311/** Beats paused for a model switch go on once the model is back to the one the cache was warmed on. */
312async function resumeIfWarmed($: EngineInterface) {
313 if (s.paused !== MODEL_CHANGED || busy) return
314 const model = await $.session.model()
315 if (model !== s.warmModel) return
316 s.paused = null
317 if (s.enabled) $.ui.log('beats go on: back on the model the cache was warmed on')
318 await schedule($)
319}
320
321function stop($: EngineInterface, why?: string) {
322 s.enabled = false
323 void schedule($)
324 if (!why) return 'off'
325 announce($, `stopped: ${why}`)
326 return `stopped: ${why}`
327}
328
329/** One beat: checks it is worth it, forks, and resolves what happened, in words. */
330async function beat($: EngineInterface): Promise<string> {
331 if (busy) return 'a turn is running; the beat waits for it to end'
332 const now = await $.clock.now()
333 if (now - (s.lastReal ?? now) >= cfg.stopAfterHours * 60 * MIN) return stop($, `${cfg.stopAfterHours}h since your last turn`)
334 if (now - (s.lastWarm ?? now) >= MAX_TTL) return stop($, 'over an hour since the cache was last read, so it has expired')
335 const { rateLimits } = await $.session.usage()
336 const full = rateLimits.find(l => l.percentUsed >= cfg.stopAtUsage)
337 if (full) {
338 const kind = full.kind.replace('_', '-')
339 return stop($, cfg.stopAtUsage >= 100 ? `${kind} usage limit reached` : `${kind} usage at ${full.percentUsed}%, past ${cfg.stopAtUsage}%`)
340 }
341 if (await smallContext($)) {
342 await schedule($) // says so, and waits for a turn to grow it
343 return `beats skip: ${s.small}`
344 }
345 // a beat goes to the current model, whose cache is not the one warmed if the model changed since
346 const model = await $.session.model()
347 const isNewModel = s.warmModel !== null && model !== s.warmModel
348 if (isNewModel && cfg.onModelSwitch === 'wait') {
349 await pause($, MODEL_CHANGED, 'the new model has no cache yet')
350 return `beats wait for your next turn: ${MODEL_CHANGED}`
351 }
352
353 beating = true
354 const forking = $.model.fork({ prompt: 'Reply with a single period.' })
355 const r = await forking.finally(() => (beating = false))
356 if (!r.isAnswered && r.reason !== 'empty-reply') {
357 if (r.reason === 'nothing-to-fork') return stop($, 'nothing to keep warm')
358 const isTransient = r.reason === 'aborted' || r.error === 'overloaded' || r.error === 'server_error' || r.status === null
359 if (!isTransient) return stop($, r.error === 'rate_limit' ? 'rate limited' : `API error (${r.error})`)
360 if (!busy && s.enabled) timer = $.clock.after(RETRY, () => void scheduledBeat($))
361 return `${'error' in r ? r.error : r.reason}; ${s.enabled ? 'retrying in a minute' : 'not retried while off'}`
362 }
363 const { cache_read_input_tokens: got, cache_creation_input_tokens: wrote } = r.usage
364 if (isNewModel) {
365 // the first beat on a new model writes its cache, as the next turn would have: kept warm from here
366 s.warmModel = model
367 s.lastWarm = await $.clock.now()
368 const warmed = `♥ warmed the new model's cache (${wrote.toLocaleString()} written)`
369 $.ui.log(warmed)
370 await schedule($)
371 return warmed
372 }
373 if (got < wrote) {
374 const why = `the cache was not served (${got.toLocaleString()} read, ${wrote.toLocaleString()} written)`
375 // under auto, a cache gone before an hour's interval lives five minutes, whatever the signs said:
376 // this beat wrote it afresh, so beat at that from here
377 if (cfg.interval !== 'auto' || s.idle !== null || s.isCacheShort || ttl === '5m') return stop($, why)
378 s.isCacheShort = true
379 s.lastWarm = await $.clock.now()
380 announce($, `${why}: this session's cache lasts 5 minutes, so beats come every ${AUTO['5m']}m`)
381 await schedule($)
382 return `${why}; beating every ${AUTO['5m']}m`
383 }
384 s.lastWarm = await $.clock.now()
385 s.lastRead = got
386 s.beats++
387 if (cfg.heartAnimate) blast = 0
388 const renewed = `♥ cache renewed (${got.toLocaleString()} read, ${wrote.toLocaleString()} written)`
389 if (logs(cfg.onBeat) || (s.row === null && !isStatusOnBand)) $.ui.log(renewed) // else no line at all says it
390 if (toasts(cfg.onBeat)) $.ui.toast(`♥ cache kept warm · ${tokens(got)} read`)
391 await schedule($)
392 return renewed
393}
394
395/** A beat its timer brought: none once beating was turned off, whatever timer was still set. */
396function scheduledBeat($: EngineInterface) {
397 return s.enabled ? beat($) : Promise.resolve('off')
398}
399
400/** A beat asked for by hand: never one that would switch cachebeat off for having nothing to fork. */
401function beatNow($: EngineInterface) {
402 return s.lastReal === null ? Promise.resolve('nothing to keep warm until this session has a turn') : beat($)
403}
404
405const every = () => `every ${fmt(idle())} idle`
406const when = (ms: number | undefined) =>
407 s.small ? `beats skip: ${s.small}`
408 : ms === undefined ? 'starts after your next turn' : ms === 0 ? 'beating now' : `next beat in ${fmt(ms)}`
409
410async function turnOn($: EngineInterface, minutes: number | undefined) {
411 const wasOn = s.enabled
412 const before = idle()
413 if (minutes !== undefined) {
414 const m = Math.min(55, Math.max(1, Math.round(minutes)))
415 if (cfg.intervalScope === 'global') {
416 s.idle = null
417 await setSettings($, { interval: m })
418 } else s.idle = m * MIN
419 }
420 s.enabled = true
421 if (busy) {
422 save($)
423 return { text: `on, ${every()}; starts after this turn` }
424 }
425 if (wasOn && idle() === before) {
426 let ms: number | undefined
427 const at = s.nextAt
428 if (at !== null) {
429 const now = await $.clock.now()
430 ms = Math.max(0, at - now)
431 }
432 return { text: `already on, ${every()} · ${when(ms)}` }
433 }
434 const ms = await schedule($)
435 if (wasOn) return { text: `interval ${fmt(before)} → ${fmt(idle())} · ${when(ms)}` }
436 return { text: `on, ${every()} · ${when(ms)}` }
437}
438
439/** What the tools answer: this session's beating, and the settings every session shares. */
440async function stateOf($: EngineInterface) {
441 await syncSettings($)
442 ttl = await cacheTtl($) // usage can cross into credits between turns
443 const now = await $.clock.now()
444 const nextBeat = !s.enabled || s.small ? null
445 : s.nextAt !== null ? `in ${fmt(s.nextAt - now)}`
446 : busy ? `${fmt(idle())} after this turn ends`
447 : 'after the next turn'
448 const session = {
449 enabled: s.enabled, intervalMinutes: idle() / MIN, intervalFrom: s.idle !== null ? 'session' : cfg.interval === 'auto' ? 'auto' : 'default',
450 cacheTtl: s.isCacheShort ? '5m' : ttl,
451 beats: s.beats, lastBeatReadTokens: s.lastRead, nextBeat, skipping: s.small, paused: s.paused,
452 }
453 const defaults = Object.fromEntries(Object.keys(changed(cfg)).map(k => [k, DEFAULTS[k as keyof BeatSettings]]))
454 return { session, settings: cfg, defaults }
455}
456
457type Before = { enabled: boolean; idle: number | null; settings: BeatSettings }
458
459/**
460 * `set`'s answer: the fields the call changed (this session's own switch and interval, not what follows
461 * from the settings; its own patch, not what another session saved meanwhile), this session's state,
462 * and the settings it changed.
463 */
464async function answerSet($: EngineInterface, before: Before, patch: Partial<BeatSettings>) {
465 const settings = Object.entries(patch).filter(([k, v]) => before.settings[k as keyof BeatSettings] !== v)
466 const changed = [
467 ...(before.enabled !== s.enabled ? ['session.enabled'] : []),
468 ...(before.idle !== s.idle ? ['session.intervalMinutes'] : []),
469 ...settings.map(([k]) => `settings.${k}`),
470 ]
471 return { changed, session: (await stateOf($)).session, settings: Object.fromEntries(settings) }
472}
473
474const json = (x: unknown) => JSON.stringify(x, null, 2)
475
476async function openSettings($: EngineInterface) {
477 await syncSettings($)
478 const stored = await $.store.get('customs')
479 customs = (stored as typeof customs | undefined) ?? {}
480 ttl = await cacheTtl($)
481 notice = ''
482 isResetArmed = false
483 await update($, pageAtom, () => ({ tab: 'beating', picker: null }))
484 await $.ui.open({ id: PANE, title: 'cachebeat', focus: true, closeOnEscape: true, holdToasts: true, rows: 24 })
485 await focusOn($, 'tab:beating', true)
486}
487
488/**
489 * Moves the pane's focus; a move this plugin makes skips its own ui.focus hook, so it notes it here.
490 * A desktop's Tab and clicks move its own focus, and a move made for it after a press lands where the
491 * person didn't look: there it moves only to land somewhere new, the pane or a list of choices opening.
492 */
493async function focusOn($: EngineInterface, key: string, isLanding = false) {
494 if (paneSurface !== 'terminal' && !isLanding) return
495 // a focus that cannot move leaves the page as it is (`claude plugin test` has no focus to move)
496 const focusing = $.ui.focus({ requestId: PANE, key })
497 const moved = await focusing.catch((err: unknown) => ({ deny: String(err) }))
498 if (moved.deny) {
499 $.ui.log(`focus ${key}: ${moved.deny}`, { to: 'debug' })
500 return
501 }
502 await update($, focusAtom, () => key)
503 const scrolling = $.ui.scroll({ to: { key }, in: PANE })
504 const scrolled = await scrolling.catch((err: unknown) => ({ deny: String(err) }))
505 if (scrolled.deny) $.ui.log(`scroll to ${key}: ${scrolled.deny}`, { to: 'debug' })
506}
507
508function stopPaneTicker() {
509 paneTicker?.cancel()
510 paneTicker = undefined
511}
512
513/** Shows a page of the pane and puts the focus on `key` there; `isLanding` as `focusOn` takes it. */
514async function goTo($: EngineInterface, page: PanePage, key: string, isLanding = false) {
515 isResetArmed = false
516 notice = '' // the last outcome was about where the person was, not where they go
517 await update($, pageAtom, () => page)
518 await focusOn($, key, isLanding)
519}
520
521/** The first element of a tab's settings, where going into it lands. */
522const firstOf = (id: string, c: BeatSettings) =>
523 id === 'beating' ? 'session' : `row:${TABS.find(t => t.id === id)!.rows.find(r => !r.show || r.show(c))!.key}`
524
525/** Puts a line in the pane's footer. */
526function say($: EngineInterface, text: string) {
527 notice = text
528 void update($, tickAtom, t => t + 1)
529}
530
531const USAGE = 'usage: /cachebeat [on|off|<minutes>|now|global on|off|settings]'
532
533export const register: Register = on => {
534 // also runs after every reload: pick up where the last load left off
535 on('session.start', async ($, e, next) => {
536 isStatusOnBand = false // until the band is drawn on a desktop
537 paneSurface = 'terminal' // until the pane draws, on whichever surface
538 cfg = normalize(await $.store.get('settings'))
539 await update($, settingsAtom, () => cfg)
540 const prev = await read($, saved)
541 // a reload keeps this session's choice; a new session takes the global default
542 s = prev ? { ...fresh, ...prev } : { ...fresh, enabled: cfg.defaultOn }
543 await schedule($)
544 await $.command.register({
545 name: 'cachebeat',
546 description: 'Keep the prompt cache warm while idle',
547 argumentHint: '[on|off|<minutes>|now|global on|off|settings]',
548 immediate: true,
549 })
550 await $.tool.register(STATE)
551 await $.tool.register(SET)
552 return next(e)
553 })
554
555 // a /clear goes on under a new session, with no session.start and its state empty: a new
556 // conversation, so it starts its count afresh, keeping whether this session beats and how often
557 on('session.end', { reason: 'clear' }, async ($, e, next) => {
558 const r = await next(e)
559 s = { ...fresh, enabled: s.enabled, idle: s.idle, isCacheShort: s.isCacheShort }
560 await syncSettings($)
561 await schedule($)
562 return r
563 })
564
565 on('command.run', { command: 'cachebeat' }, async ($, e) => {
566 ttl = await cacheTtl($) // what `auto` comes to, for the answer
567 const words = e.args.trim().toLowerCase().split(/\s+/).filter(Boolean)
568 const minutes = words.find(w => /^\d+$/.test(w))
569 if (words[0] === 'settings' || words[0] === 'config') {
570 await openSettings($)
571 return { text: 'settings opened' }
572 }
573 if (words[0] === 'now') return { text: await beatNow($) }
574 if (words[0] === 'global') {
575 if (words[1] !== 'on' && words[1] !== 'off') return { text: `new sessions start ${cfg.defaultOn ? 'on' : 'off'} · ${USAGE}` }
576 await setSettings($, { defaultOn: words[1] === 'on' })
577 let here = 'already off'
578 if (words[1] === 'on') {
579 const turned = await turnOn($, undefined)
580 here = turned.text
581 } else if (s.enabled) here = stop($)
582 return { text: `new sessions start ${words[1]} · this session: ${here}` }
583 }
584 if (words[0] === 'off') {
585 if (!s.enabled) return { text: 'already off' }
586 return { text: stop($) }
587 }
588 if (words[0] === 'on' || minutes) return turnOn($, minutes === undefined ? undefined : Number(minutes))
589 if (words.length) return { text: USAGE }
590 if (!s.enabled) return { text: 'off' }
591 let ms: number | undefined
592 const at = s.nextAt
593 if (at !== null) {
594 const now = await $.clock.now()
595 ms = Math.max(0, at - now)
596 }
597 return { text: `on, ${every()} · ${s.beats} beats · ${when(ms)}` }
598 })
599
600 on('tool.call', { tool: 'mcp__cachebeat__state' }, async $ => ({ result: json(await stateOf($)) }))
601
602 on('tool.call', { tool: 'mcp__cachebeat__set' }, async ($, e) => {
603 if (e.agentId) return { deny: 'Only the main conversation changes cachebeat.' }
604 const change = parseSet({ session: e.session, settings: e.settings })
605 if ('error' in change) return { deny: change.error }
606 await syncSettings($)
607 const before: Before = { enabled: s.enabled, idle: s.idle, settings: cfg }
608 if (Object.keys(change.patch).length) await setSettings($, change.patch)
609 if (change.intervalMinutes !== undefined) s.idle = change.intervalMinutes === null ? null : change.intervalMinutes * MIN
610 if (change.enabled !== undefined) s.enabled = change.enabled
611 // always: setSettings reschedules only while on, and this is what cancels a beat when turned off
612 await schedule($)
613 return { result: json(await answerSet($, before, change.patch)) }
614 })
615
616 // a call is one dim line in the transcript, and its answer, for the model alone, is not drawn
617 on('ui.render', { component: 'ToolUse', props: { tool: 'mcp__cachebeat__state' } }, async ($, e, next) => {
618 if (e.props.isErrored) return next(e)
619 const { Text } = $.ui.resolve(e)
620 return <Text dimColor>cachebeat: read the state</Text>
621 })
622
623 on('ui.render', { component: 'ToolUse', props: { tool: 'mcp__cachebeat__set' } }, async ($, e, next) => {
624 const change = parseSet((e.props.input ?? {}) as { session?: unknown; settings?: unknown })
625 if (e.props.isErrored || 'error' in change) return next(e)
626 const { Text } = $.ui.resolve(e)
627 return <Text dimColor>{`cachebeat: ${summary(change, await read($, settingsAtom))}`}</Text>
628 })
629
630 on('ui.render', { component: 'ToolResult', props: { tool: 'mcp__cachebeat__state' } }, async ($, e, next) => {
631 if (e.props.isErrored) return next(e)
632 const { Text } = $.ui.resolve(e)
633 return <Text>{''}</Text>
634 })
635
636 on('ui.render', { component: 'ToolResult', props: { tool: 'mcp__cachebeat__set' } }, async ($, e, next) => {
637 if (e.props.isErrored) return next(e)
638 const { Text } = $.ui.resolve(e)
639 return <Text>{''}</Text>
640 })
641
642 // each model has its own cache, and a beat goes to the current one: after a switch the old cache is
643 // out of reach and the new model has none, so beats wait for the next turn, or the next writes it
644 on('classic.PostModelSwitch', async ($, e, next) => {
645 const r = await next(e)
646 if (!busy && s.lastWarm !== null) {
647 if (cfg.onModelSwitch === 'wait') {
648 await pause($, MODEL_CHANGED, 'the new model has no cache yet')
649 // the switch takes effect once this hook settles: a switch back finds the warmed model then
650 $.clock.after(SETTLE, () => void resumeIfWarmed($))
651 } else if (s.enabled) $.ui.log("the next beat writes the new model's cache")
652 }
653 return r
654 })
655
656 // a compaction replaces the conversation, so the prefix a beat would warm is gone; the next turn
657 // caches the new one. One within a turn needs nothing: the turn's end starts the count from there
658 on('session.compact', async ($, e, next) => {
659 const r = await next(e)
660 const isDone = e.agentId === undefined && e.trigger !== 'precompute' && r.skip === undefined
661 if (isDone && !busy && s.lastWarm !== null) await pause($, 'compaction replaced the conversation', 'compaction replaced the conversation')
662 return r
663 })
664
665 on('turn.start', async ($, e, next) => {
666 if (!beating) {
667 busy = true
668 s.row = null // the line moves to this turn's row once it ends
669 await schedule($)
670 }
671 return next(e)
672 })
673
674 on('turn.complete', async ($, e, next) => {
675 if (e.agentId === undefined && !beating) {
676 busy = false
677 rowPending = true
678 s.lastReal = await $.clock.now()
679 // a turn that sent no request (interrupted before one, refused by the API) warmed nothing
680 if (e.usage) {
681 s.lastWarm = s.lastReal
682 s.warmModel = await $.session.model()
683 s.paused = null
684 }
685 await syncSettings($)
686 await schedule($)
687 }
688 return next(e)
689 })
690
691 // the status line, under the closing row of the latest turn ("✻ Cogitated for 2s"), counting down.
692 // The engine hands that row over whole and full width, so nothing can sit beside it on its line
693 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
694 if (!rowsSeen.has(e.requestId)) {
695 rowsSeen.add(e.requestId)
696 if (rowPending) {
697 rowPending = false
698 s.row = e.requestId // saved with the next beat: drawing never writes state
699 }
700 }
701 const isLive = e.requestId === s.row
702 const line = isLive ? await read($, lineAtom) : '' // one line, under the latest turn alone
703 const c = await read($, settingsAtom)
704 if (!line || c.statusLine === 'off') return next(e)
705 const moving = c.lineAnimate && (c.lineEffect !== 'steady' || c.statusHeart !== 'off')
706 const f = moving ? await read($, lineFrameAtom) : 0
707 const p = moving ? await read($, pulse) : 'hidden'
708 if (isLive && p === 'armed') animateLine($)
709 const { Box, Text } = $.ui.resolve(e)
710 const { head, rest } = liveLine(line, c, p, f)
711 const painted = paint(Text, joined([...head, ...rest])) // monospace: the heart needs no box
712 return (
713 <Box flexDirection="column">
714 {await next(e)}
715 <Box marginTop={c.statusLine === 'spaced' ? 1 : 0}>{painted}</Box>
716 </Box>
717 )
718 })
719
720 // the heart and beat count. The hint row ("⏸ manual mode on · ...") takes added text only as its dim
721 // tail, so the heart rides it dim, or gets its own line under that row to take color. The desktop
722 // draws nothing added to the hint row: there the band above the prompt carries it
723 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
724 const p = await read($, pulse)
725 if (e.surface === 'desktop' || e.props.isWorking || p === 'hidden') return next(e)
726 const c = await read($, settingsAtom)
727 if (p === 'armed' && c.heartAnimate) animate($)
728 const f = await read($, frameAtom)
729 const heart = heartFrame(c, p, f)
730 // a space past the frame's own blank edge: the heart sits as far from the count as from the ' · ' before it
731 const count = c.showCount ? ` ×${s.beats}` : ''
732 if (c.heartPlacement === 'tail') return next({ ...e, props: { ...e.props, tail: `${heart}${count}` } })
733 const { Box, Text } = $.ui.resolve(e)
734 const look = p === 'waiting' ? [{ text: heart, dim: true }] : spans(heart, lookOf(c, 'heart'), f, isLit(heart))
735 return (
736 <Box flexDirection="column">
737 {await next(e)}
738 <Box>
739 {paint(Text, look)}
740 <Text dimColor>{count}</Text>
741 </Box>
742 </Box>
743 )
744 })
745
746 // the desktop has neither the hint row's tail nor the closing row: there the status line stays above
747 // the prompt, as it reads under the latest turn on the terminal
748 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
749 if (e.surface !== 'desktop') return next(e)
750 const c = await read($, settingsAtom)
751 isStatusOnBand = c.statusLine !== 'off'
752 const line = await read($, lineAtom)
753 if (!isStatusOnBand || !line || e.props.hasSurvey || e.props.isWorking) return next(e)
754 const p = await read($, pulse)
755 if (p === 'armed' && c.lineAnimate) animateLine($)
756 const f = c.lineAnimate ? await read($, lineFrameAtom) : 0
757 const { Box, Text } = $.ui.resolve(e)
758 // the band's font is proportional: the heart's frames differ in width, so it gets a box of its own
759 // and the words after it never move
760 const { head, rest, width } = liveLine(line, c, p, f)
761 // one glyph keeps its width as it beats: the line is one run of text, spaced as written
762 if (width <= 1) return <Box>{paint(Text, joined([...head, ...rest]))}</Box>
763 // a wider animation, on this proportional font, would change width from frame to frame and carry
764 // the words after it along: each of its characters gets a cell of its own, centered in it, as the
765 // terminal's grid has them, so it is as wide as its frames' cells. A cell's margin, not the space,
766 // keeps the words off it: that space would collapse at the next box's start, as in HTML
767 if (rest[0]) rest[0] = { ...rest[0], text: rest[0].text.replace(/^ /, '') }
768 const slots = head.flatMap(sp => glyphs(sp.text).map(ch => ({ ...sp, text: ch })))
769 const widths = slotWidths(variant(c.statusHeart)) // a heart's slot is wider, wherever any frame has one
770 return (
771 <Box flexDirection="row">
772 <Box flexDirection="row" flexShrink={0} marginLeft={1} marginRight={1}>
773 {slots.map((sp, i) => (
774 <Box width={widths[i] ?? 1} flexShrink={0} justifyContent="center">
775 {paint(Text, [{ ...sp, text: slotText(sp.text, widths[i] ?? 1) }])}
776 </Box>
777 ))}
778 </Box>
779 {paint(Text, rest)}
780 </Box>
781 )
782 })
783
784 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
785 const c = await read($, settingsAtom)
786 const page = await read($, pageAtom)
787 const tick = await read($, tickAtom)
788 const focus = await read($, focusAtom)
789 await read($, pulse) // redraws "This session" as it turns on or off
790 // the preview's clock runs only while there is a preview to move
791 const picked = page.picker ? rowOf(page.picker) : undefined
792 const isMoving = picked ? picked.row.key === 'variant' || !!picked.tab.preview : !!TABS.find(t => t.id === page.tab)?.preview
793 // at the speed of the part on show: the turn line's on its tab, the heart's elsewhere
794 const part = (picked?.tab.id ?? page.tab) === 'status' ? 'line' : 'heart'
795 if (!isMoving) stopPaneTicker()
796 else if (!paneTicker) paneTicker = $.clock.every(frameMs(lookOf(c, part)), () => void update($, tickAtom, () => ++paneTick))
797 const els = $.ui.resolve(e)
798 paneSurface = e.surface
799 if (!('Input' in els)) return <els.Text>Open cachebeat's settings in the terminal.</els.Text>
800 const view = {
801 page, tick, focus, columns: e.props.bodyColumns, isOn: s.enabled, sessionMinutes: s.idle === null ? null : s.idle / MIN,
802 autoMinutes: AUTO[s.isCacheShort ? '5m' : ttl], notice, isResetArmed, isTerminal: e.surface === 'terminal', customs,
803 }
804 const { tree, ring: walk } = settingsPane(els, c, view, {
805 set: patch => void setSettings($, patch),
806 // a press on a tab (Enter, 1-5, a click) shows it and goes into its settings
807 tab: id => void goTo($, { tab: id, picker: null }, firstOf(id, c)),
808 at: key => void focusOn($, key),
809 open: key => {
810 // the focus starts on the value set: one of the choices, or the custom field
811 const { row } = rowOf(key)!
812 const i = row.values.indexOf(c[key])
813 void goTo($, { ...page, picker: key }, i >= 0 ? `opt:${i}` : row.custom ? 'custom' : 'opt:0', true)
814 },
815 pick: (key, value) => void setSettings($, { [key]: value }).then(() => (say($, ''), goTo($, { ...page, picker: null }, `row:${key}`))),
816 refuse: text => say($, text),
817 resetRow: key => void setSettings($, { [key]: DEFAULTS[key] }).then(() => say($, `${rowOf(key)!.row.label.trim()} back to its default`)),
818 back: () => void goTo($, { ...page, picker: null }, `row:${page.picker}`),
819 toggleSession: () => void (s.enabled ? Promise.resolve(stop($)) : turnOn($, undefined).then(r => r.text)).then(t => say($, `this session: ${t}`)),
820 beatNow: () => void beatNow($).then(t => say($, t)),
821 reset: () => {
822 if (!isResetArmed) {
823 isResetArmed = true
824 return say($, '')
825 }
826 isResetArmed = false
827 void setSettings($, DEFAULTS).then(() => say($, 'settings reset to the defaults'))
828 },
829 })
830 ring = walk
831 return tree
832 })
833
834 on('ui.focus', { requestId: PANE }, async ($, e, next) => {
835 // a desktop moves its own focus (Tab, a click): the pane notes where, and a tab opens when pressed
836 if (paneSurface !== 'terminal') {
837 const r = await next(e)
838 if (!r.deny) await update($, focusAtom, () => e.element ?? '')
839 return r
840 }
841 // the tab bar and a tab's settings are two levels: the arrows never cross between them
842 let to = e.element
843 if (e.origin.kind === 'person' && to) {
844 const from = await read($, focusAtom)
845 const isToTab = to.startsWith('tab:')
846 // with nothing focused yet, the first move goes anywhere
847 if (from && from.startsWith('tab:') !== isToTab) {
848 // off either end of the tab bar, round to the other end; between the levels, Enter and Esc
849 const at = TABS.findIndex(t => `tab:${t.id}` === from)
850 const end = !isToTab && at === TABS.length - 1 ? TABS[0] : !isToTab && at === 0 ? TABS.at(-1) : undefined
851 if (!end) return { deny: 'Enter goes into a tab, Esc back out' }
852 to = `tab:${end.id}`
853 }
854 const tab = to.startsWith('tab:') ? to.slice(4) : undefined
855 if (tab) await update($, pageAtom, () => ({ tab, picker: null })) // the page follows the tabs
856 }
857 const r = await next(to === e.element ? e : { ...e, element: to })
858 if (!r.deny) await update($, focusAtom, () => to ?? '')
859 return r
860 })
861
862 // the arrows walk the focus ring, and the window follows it; the wheel and page keys scroll
863 on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
864 if (paneSurface !== 'terminal' || e.origin.kind !== 'person' || e.pointer || Math.abs(e.by) !== 1) return next(e)
865 const focus = await read($, focusAtom)
866 if (focus.startsWith('tab:')) {
867 const t = TABS[(TABS.findIndex(x => `tab:${x.id}` === focus) + e.by + TABS.length) % TABS.length]!
868 await goTo($, { tab: t.id, picker: null }, `tab:${t.id}`)
869 return {}
870 }
871 const i = ring.indexOf(focus)
872 const j = i < 0 ? (e.by > 0 ? 0 : ring.length - 1) : i + e.by
873 if (j < 0 || j >= ring.length) return next(e) // past either end, the window scrolls on to its edge
874 await focusOn($, ring[j]!)
875 return {}
876 })
877
878 on('ui.close', { id: PANE }, async ($, e, next) => {
879 const page = await read($, pageAtom)
880 // Esc steps back a level: a picker to its row, a tab's settings to the tab bar; the tab bar closes.
881 // A desktop has no levels to its focus: there Esc leaves a picker, and otherwise closes
882 const focus = await read($, focusAtom)
883 const isInTab = paneSurface === 'terminal' && !focus.startsWith('tab:')
884 if (e.origin.kind === 'person' && (page.picker || isInTab)) {
885 // Esc has handed the keys back to the prompt: open asks for them again
886 await $.ui.open({ id: PANE, title: 'cachebeat', focus: true, closeOnEscape: true, holdToasts: true, rows: 24 })
887 await goTo($, { ...page, picker: null }, page.picker ? `row:${page.picker}` : `tab:${page.tab}`, true)
888 return { value: undefined }
889 }
890 stopPaneTicker()
891 return next(e)
892 })
893}
894hooks/animations.ts 141 lines1// The heart's animations: each a loop it plays while armed and a blast it plays once a beat lands.
2// Frames are written as '|'-joined strings, as the bash previews they came from had them.
3
4/** `isEndless`: a loop with no resting frame, its motion running round (a scrolling line, an orbit). */
5export type Variant = { id: string; name: string; loop: string[]; blast: string[]; isEndless: boolean }
6export type Timing = 'linear' | 'lubdub'
7
8// padded with U+2800 (blank, but not whitespace a surface could collapse) so a frame keeps its width
9const pad = (frames: string) => frames.split('|').map(f => f.replaceAll(' ', '\u2800'))
10const ENDLESS = ['ecg', 'beam', 'dash', 'sine', 'orbit', 'garland', 'bounce']
11const v = (id: string, name: string, loop: string, blast: string): Variant =>
12 ({ id, name, loop: pad(loop), blast: pad(blast), isEndless: ENDLESS.includes(id) })
13
14export const VARIANTS: readonly Variant[] = [
15 v("classic", "Classic",
16 " ♡ | ♥ | (❤︎) |( ♥ )|⋅ ♡ ⋅| ♥ | (♥) | ⋅♡⋅ | ♡ | ♡ | ♡ ",
17 " ♥ | (❤︎) | ♥♥♥ |♥ ♥ ♥|♥ ♡ ♥|♡ ♡|⋅ ⋅| | ⋅ "),
18 v("pulse", "Pulse",
19 " ♡ | ♥ | ‹❤︎› |« ♥ »|⋅ ♡ ⋅| ♥ | ‹♥› | ⋅♡⋅ | ♡ | ♡ | ♡ ",
20 "› ♡ ‹| ›♥‹ | ❤︎ | ❤︎ | «❤︎» |«❥❤︎❥»|❥«♥»❥|« ♥ »|❥ ♡ ❥|› ♡ ‹|⋅ ⋅ ⋅|⋅ ⋅| | | ⋅ "),
21 v("triplet", "Triplet",
22 " ♡ | ♥ | ♡❤︎♡ |♡ ♥ ♡|⋅ ♡ ⋅| ♥ | ♡♥♡ | ⋅♡⋅ | ♡ | ♡ | ♡ ",
23 "♡ ♡ ♡| ♡♥♡ | ❤︎ | ❤︎ | ♥❤︎♥ |♥♥❤︎♥♥|❤︎♥♥♥❤︎|♥♡❤︎♡♥|♡♥♡♥♡|♥♡ ♡♥|♡ ♡ ♡|⋅ ♡ ⋅|⋅ ⋅ ⋅| | | ⋅ "),
24 v("sparkle", "Sparkle",
25 " ♡ | ♥ | ✧❤︎✧ |✦ ♥ ✦|✧ ♡ ✧|⋅ ♥ ⋅| ✧♥✧ | ⋅♡⋅ | ♡ | ♡ | ♡ ",
26 "✧ ♡ ✧| ✧♥✧ | ❤︎ | ❤︎ | ✦❤︎✦ |✦✧❤︎✧✦|✧✦❤︎✦✧|✦✧♥✧✦|✧✦♡✦✧|✦ ✧ ✦|✧ ✦ ✧|⋅ ✧ ⋅|✧ ⋅ ✧|⋅ ⋅| | | ⋅ "),
27 v("fleuron", "Fleuron",
28 " ♡ | ♥ | ☙❤︎❧ |☙ ♥ ❧|⋅ ♡ ⋅| ♥ | ☙♥❧ | ⋅♡⋅ | ♡ | ♡ | ♡ ",
29 "☙ ♡ ❧| ☙♥❧ | ❤︎ | ❤︎ | (❤︎) |(♥❤︎♥)|♥♥❤︎♥♥|❤︎❤︎❤︎❤︎❤︎|♥♥♥♥♥|♥♥ ♥♥|♥ ♡ ♥|☙ ♡ ❧|♡ ♡|⋅ ⋅| | | ⋅ "),
30 v("wave", "Wave",
31 "♡♡♡♡♡|♥♡♡♡♡|♥♥♡♡♡|♡♥♥♡♡|♡♡♥♥♡|♡♡♡♥♥|♡♡♡♡♥|♡♡♡♡♡|♡♡♡♡♡",
32 "♡♡♥♡♡|♡♥♥♥♡|♥♥♥♥♥|❤︎❤︎❤︎❤︎❤︎|❤︎❤︎❤︎❤︎❤︎|♥❤︎♥❤︎♥|❤︎♥❤︎♥❤︎|♥♡♥♡♥|♡♥♡♥♡|♥♡♥♡♥|♡ ♡ ♡| ♡ ♡ |⋅ ⋅ ⋅| ⋅ ⋅ | | |⋅ ⋅ ⋅|♡⋅♡⋅♡"),
33 v("ecg", "ECG",
34 "⎼⎺⎽────⎼⎺⎽────❤︎|─⎼⎺⎽────⎼⎺⎽───♥|──⎼⎺⎽────⎼⎺⎽──❤︎|───⎼⎺⎽────⎼⎺⎽─❤︎|────⎼⎺⎽────⎼⎺⎽❤︎|⎽────⎼⎺⎽────⎼⎺❤︎|⎺⎽────⎼⎺⎽────⎼♥",
35 "┄┄┄┄──────────♥|┄┄┄┄┄┄┄┄┄─────❤︎|┄┄┄┄┄┄┄┄┄┄┄┄┄┄❤︎|┄┄┄┄┄┄┄┄┄┄┄┄┄┄❤︎|┄┄┄┄┄┄┄┄┄┄┄━❥♥❤︎|┄┄┄┄┄┄┄┄━❥──❥♥♥|┄┄┄┄┄━❥──❥──❥♥❤︎|┄┄━❥──❥──❥───♥♥|❥──❥──♡──────♡♥|♡──♡──⋅───────♡|⋅──⋅──────────♡|──────────────⋅|──────────────♡|──────────────♥"),
36 v("beam", "Beam",
37 "╼━╾────╼━╾────❤︎|─╼━╾────╼━╾───♥|──╼━╾────╼━╾──❤︎|───╼━╾────╼━╾─❤︎|────╼━╾────╼━╾❤︎|╾────╼━╾────╼━❤︎|━╾────╼━╾────╼♥",
38 "──────────────♥|──────────────❤︎|──────────━━━━❤︎|──────━━━━━━━━♥|──━━━━━━━━━━━━❤︎|━━━━━━━━━━━━━━♥|══════════════❤︎|━━━━━━━━━━━━━━❥|══════════════❤︎|╍╍╍╍╍╍╍╍╍╍╍╍╍╍♥|┅┅┅┅┅┅┅┅┅┅┅┅┅┅♥|┉┉┉┉┉┉┉┉┉┉┉┉┉┉♡|┈┈┈┈┈┈┈┈┈┈┈┈┈┈♡| ⋅| ⋅|──────────────♡"),
39 v("dash", "Dash",
40 "╌─━┄┄┄┄╌─━┄┄┄┄♥|┄╌─━┄┄┄┄╌─━┄┄┄❤︎|┄┄╌─━┄┄┄┄╌─━┄┄❤︎|┄┄┄╌─━┄┄┄┄╌─━┄❤︎|┄┄┄┄╌─━┄┄┄┄╌─━❤︎|━┄┄┄┄╌─━┄┄┄┄╌─♥|─━┄┄┄┄╌─━┄┄┄┄╌❤︎",
41 "━━━━┄┄┄┄┄┄┄┄┄┄♥|━━━━━━━━━┄┄┄┄┄❤︎|━━━━━━━━━━━━━━❤︎|━━━━━━━━━━━━━━❤︎|━━━━━━━━━━━━❥♥❤︎|━━━━━━━━━❥┄┄❥♥♥|━━━━━━❥┄┄❥┄┄❥♥❤︎|━━━❥┄┄❥┄┄❥┄┄┄♥♥|❥┄┄❥┄┄♡┄┄┄┄┄┄♡♥|♡┄┄♡┄┄⋅┄┄┄┄┄┄┄♡|⋅┄┄⋅┄┄┄┄┄┄┄┄┄┄♡|┄┄┄┄┄┄┄┄┄┄┄┄┄┄⋅|┄┄┄┄┄┄┄┄┄┄┄┄┄┄♡|┄┄┄┄┄┄┄┄┄┄┄┄┄┄♥"),
42 v("sine", "Sine",
43 "⣀⠤⠒⠉⠒⠤⣀⠤⠒⠉⠒⠤⣀⠤❤︎|⠤⣀⠤⠒⠉⠒⠤⣀⠤⠒⠉⠒⠤⣀♥|⠒⠤⣀⠤⠒⠉⠒⠤⣀⠤⠒⠉⠒⠤❤︎|⠉⠒⠤⣀⠤⠒⠉⠒⠤⣀⠤⠒⠉⠒❤︎|⠒⠉⠒⠤⣀⠤⠒⠉⠒⠤⣀⠤⠒⠉❤︎|⠤⠒⠉⠒⠤⣀⠤⠒⠉⠒⠤⣀⠤⠒♥",
44 "⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤♥|⠶⠶⠶⠶⠶⠶⠶⠶⠶⠶⠶⠶⠶⠶❤︎|⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿❤︎|⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿❥|⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿❤︎|⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿❥|⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿❤︎|⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿♥|⠶⠶⠶⠶⠶⠶⠶⠶⠶⠶⠶⠶⠶⠶♥|⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒♡|⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤♡|⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⋅| ⋅|⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀♡"),
45 v("charge", "Charge",
46 "──────────────♡|━━────────────♡|━━━━──────────♡|━━━━━━────────♡|━━━━━━━━──────♡|━━━━━━━━━━────♡|━━━━━━━━━━━━──♡|━━━━━━━━━━━━━━♥|──━━━━━━━━━━━━❤︎|─────━━━━━━━━━♥|────────━━━━━━❤︎|───────────━━━♥|──────────────❤︎|──────────────♡",
47 "━━━━━━━━━━━━━━♥|══════════════❤︎|━━━━━━━━━━━━━━❤︎|══════════════❥|━━━━━━━━━━━━❥♥❤︎|━━━━━━━━━❥──❥♥♥|━━━━━━❥──❥──❥♥❤︎|━━━❥──❥──❥───♥♥|❥──❥──♡──────♡♥|♡──♡──⋅───────♡|⋅──⋅──────────♡|──────────────⋅|──────────────♡"),
48 v("converge", "Converge",
49 "⎽──────♡──────⎽|⎺⎽─────♡─────⎽⎺|⎼⎺⎽────♡────⎽⎺⎼|─⎼⎺⎽───♡───⎽⎺⎼─|──⎼⎺⎽──♡──⎽⎺⎼──|───⎼⎺⎽─♡─⎽⎺⎼───|────⎼⎺⎽♡⎽⎺⎼────|─────⎼⎺♥⎺⎼─────|──────⎼❤︎⎼──────|──────(♥)──────|─────( ♡ )─────|───────♥───────|───────♡───────",
50 "───────♥───────|──────›♥‹──────|───────❤︎───────|───────❤︎───────|──────(❤︎)──────|─────♥♥❤︎♥♥─────|───♥♥♥♥❤︎♥♥♥♥───|─♥♥──♥♥❤︎♥♥──♥♥─|♥♥───♥─♥─♥───♥♥|♥───♡──♥──♡───♥|♡──⋅───♡───⋅──♡|⋅──────♡──────⋅|───────⋅───────|───────♡───────"),
51 v("twins", "Twins",
52 "♡ ♡|♡ ♡| ♡ ♡ | ♥ ♥ | ♡ ♡ |♡ ♡",
53 "♡ ♡| ♡ ♡ | ♥ ♥ | ❤︎ | ❤︎ | ✦❤︎✦ |✧♥❤︎♥✧|♥✦❤︎✦♥|♥ ❤︎ ♥|♡ ♥ ♡|✧ ♡ ✧|⋅ ♡ ⋅| ♡ | ⋅ | |⋅ ⋅"),
54 v("orbit", "Orbit",
55 "• ♥ | •♥ | ♥• | ♥ •| ♥ ⋅| ♥⋅ | ⋅♥ |⋅ ♥ ",
56 "• ♥ ⋅| •♥⋅ | ❤︎ | ❤︎ | ◦❤︎◦ |◦•❤︎•◦|•◦♥◦•|◦ ♥ ◦|⋅ ♡ ⋅|⋅ ⋅| | | ⋅ | ♡ "),
57 v("static", "Static",
58 " ♥ | ♥ | ♥ | ░♥ | ♡▒ | ♥ | ♥ |▒ ♥ ░| ♥ | ❥ | ♥ ",
59 " ♥ | ░♥▒ |▒▓♥░▒|▓░❥▓█|█▓▒░▓|░█▓█▒|▓▒█▓░|▒░▓▒▓|░▒❤︎░▒| ░❤︎▒ | ❤︎ | ❤︎ | ♥ "),
60 v("garland", "Garland",
61 " ❥♡❦ | ♡❦♥ | ❦♥❧ | ♥❧❥ | ❧❥♡ ",
62 " ♥♡❦ | ♥❦♥ | ♥♥❧ | ♥♥❥ | ♥♥♡ | ♥♥♥ | ♥♥♥ |✦♥♥♥✦|✧❤︎❤︎❤︎✧|✦♥♥♥✦|✧❤︎❤︎❤︎✧|✦♥♥♥✦|✧❤︎❤︎❤︎✧|⋅♥♥♥⋅| ♡♡♡ | ⋅⋅⋅ | "),
63 v("equalizer", "Equalizer",
64 "▁▁▁▁▁▁▇❤︎▇▁▁▁▁▁▁|▁▁▁▁▁▆▃♥▃▆▁▁▁▁▁|▁▁▁▁▅▂▁♥▁▂▅▁▁▁▁|▁▁▁▄▁▁▅❤︎▅▁▁▄▁▁▁|▁▁▃▁▁▄▁♥▁▄▁▁▃▁▁|▁▂▁▁▃▁▁♡▁▁▃▁▁▂▁|▁▁▁▂▁▁▁♡▁▁▁▂▁▁▁|▁▁▁▁▁▁▁♡▁▁▁▁▁▁▁|▁▁▁▁▁▁▁♡▁▁▁▁▁▁▁",
65 "▁▁▁▁▁▁▁♥▁▁▁▁▁▁▁|▁▁▁▁▃▅▇❤︎▇▅▃▁▁▁▁|▁▁▃▅▇██❤︎██▇▅▃▁▁|▅▇█████❥█████▇▅|███████❤︎███████|▇█▇█▇█▇❥▇█▇█▇█▇|█▇█▇█▇█❤︎█▇█▇█▇█|▆▅▆▅▆▅▆♥▆▅▆▅▆▅▆|▄▅▄▃▄▅▄♥▄▅▄▃▄▅▄|▃▂▃▂▃▂▃♡▃▂▃▂▃▂▃|▂▁▂▁▂▁▂♡▂▁▂▁▂▁▂|▁▁▁▁▁▁▁⋅▁▁▁▁▁▁▁|▁▁▁▁▁▁▁♡▁▁▁▁▁▁▁"),
66 v("cupid", "Cupid",
67 "─➤ ♡ | ─➤ ♡ | ─➤ ♡ | ─♥➤ | ♥ ─➤ | ♡ ─➤ | ♡ ─➤| ♡ | ♡ ",
68 "»─➤ ♡ | »─➤ ♡ | »─➤♡ | »─♥─➤ | »─❤︎─➤ | »─❤︎─➤ | ✦»─❤︎─➤✦ | ♥ ✧»♥➤✧ ♥ | ♥ ♥ ♡ ♥ ♥ | ♥ ♡ ⋅ ♡ ♥ |♡ ⋅ ⋅ ♡|⋅ ⋅| | ⋅ "),
69 v("bounce", "Bounce",
70 "▌❤︎ ▐|▌ ♥ ▐|▌ ♥ ▐|▌ ♥ ▐|▌ ❤︎▐|▌ ♥ ▐|▌ ♥ ▐|▌ ♥ ▐",
71 "▌❤︎ ▐|▌❤︎ ▐|▌ ⋅⋅❥ ▐|▌ ⋅⋅❥ ▐|▌ ⋅⋅❥ ▐|▌ ⋅❤︎|▌ ✦❤︎✦|▌ ✧ ♥ ✧|▌ ⋅ ♡ |▌ ⋅ |▌ ▐|▌⋅ ▐"),
72]
73
74export const variant = (id: string) => VARIANTS.find(x => x.id === id) ?? VARIANTS[0]!
75
76/** Cells a frame takes: its characters, less the text-presentation selector riding a heart. */
77export const cells = (frame: string) => [...frame.replaceAll('\ufe0e', '')].length
78
79/**
80 * The loop's frame index at `tick`. Linear plays the loop evenly. Lub-dub plays it twice back to
81 * back, then rests as long as one pass took: on the first frame, a heart at rest, or for an endless
82 * loop, which has none and would only freeze, by a third pass at half speed.
83 */
84export function loopIndex(x: Variant, tick: number, timing: Timing) {
85 const n = x.loop.length
86 if (timing === 'linear') return tick % n
87 const k = tick % loopTicks(x, timing)
88 if (k < 2 * n) return k % n
89 return x.isEndless ? Math.floor((k - 2 * n) / 2) : 0
90}
91
92const loopTicks = (x: Variant, timing: Timing) => (timing === 'linear' ? 1 : x.isEndless ? 4 : 3) * x.loop.length
93
94/** The settings preview, as the bash previews ran: five rounds of the loop, then the blast. */
95export function previewFrame(x: Variant, tick: number, timing: Timing) {
96 const span = 5 * loopTicks(x, timing)
97 const k = tick % (span + x.blast.length)
98 return k < span ? x.loop[loopIndex(x, k, timing)]! : x.blast[k - span]!
99}
100
101/** What the status line's own heart plays: one heart beating, a still one, or any of the animations. */
102export const STATUS_HEARTS: readonly string[] = ['beat', 'off', ...VARIANTS.map(x => x.id)]
103
104/**
105 * The turn line's heart at `tick`, by the line's own timing. `beat` is one glyph, filled as Classic's
106 * loop lights, so the line never shifts; an animation plays its own loop.
107 */
108export function statusFrame(id: string, tick: number, timing: Timing) {
109 if (id === 'off') return '♥'
110 const x = variant(id === 'beat' ? 'classic' : id)
111 const frame = x.loop[loopIndex(x, tick, timing)]!
112 return id === 'beat' ? (/[♥❤]/.test(frame) ? '♥' : '♡') : frame
113}
114
115const HEARTS = /[♥♡❤❥❦❧☙]/
116const BLANK = /[ \u2800]/
117// the line and bar characters a terminal draws touching: box drawing, scan lines, blocks, braille
118const JOINED = /[\u2500-\u257f\u23ba-\u23bd\u2580-\u259f\u2801-\u28ff]/
119
120/**
121 * How many cells each of an animation's slots takes where a font is proportional, from every frame's
122 * character there. A heart, wider than a cell, gets half again one. A slot of line or bar characters
123 * alone gets less than they are wide, so neighbors overlap into one line, as a terminal draws them.
124 */
125export function slotWidths(x: Variant): number[] {
126 const frames = [...x.loop, ...x.blast].map(f => [...f.replaceAll('\ufe0e', '')])
127 return frames[0]!.map((_, i) => {
128 const seen = frames.map(f => f[i] ?? ' ').filter(ch => !BLANK.test(ch))
129 if (seen.some(ch => HEARTS.test(ch))) return 1.5
130 return seen.length > 0 && seen.every(ch => JOINED.test(ch)) ? JOIN : 1
131 })
132}
133
134const JOIN = 0.6 // cells a line character's slot takes: under its width, so neighbors overlap
135
136/**
137 * A character as its slot draws it: a line character in a slot wider than it, there for a heart that
138 * other frames put in it, repeats enough to span the slot and meet the lines either side.
139 */
140export const slotText = (ch: string, width: number) => (JOINED.test(ch) ? ch.repeat(Math.ceil(width / JOIN)) : ch)
141hooks/pane.tsx 269 lines1import type { Elements, RenderElement } from 'claude-code'
2import type { BeatSettings, PanePage } from '../types'
3import { previewFrame, statusFrame, variant } from './animations'
4import type { Preview, Row, Span, Value } from './settings'
5import { DEFAULTS, HEX_OF, SESSION_HELP, TABS, isFlip, isHex, isLit, lookOf, rowOf, spans, tokens } from './settings'
6
7export const PANE = 'cachebeat-settings'
8
9type Els = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Input'>
10export type Actions = {
11 set: (patch: Partial<BeatSettings>) => void
12 tab: (id: string) => void
13 at: (key: string) => void // a press lands the focus on what was pressed (a click on a row from the tabs)
14 open: (key: keyof BeatSettings) => void
15 pick: (key: keyof BeatSettings, value: Value) => void
16 back: () => void
17 toggleSession: () => void
18 beatNow: () => void
19 reset: () => void
20 refuse: (text: string) => void // a typed value the setting doesn't take: says why in the footer
21 resetRow: (key: keyof BeatSettings) => void // one setting back to its default
22}
23/** What the pane draws from, besides the settings. */
24export type View = {
25 page: PanePage
26 tick: number
27 focus: string
28 columns: number
29 isOn: boolean // this session beats
30 sessionMinutes: number | null // this session's own interval, set by /cachebeat <minutes>
31 autoMinutes: number // what `auto` beats at for this session's cache
32 isTerminal: boolean // the terminal's keys walk the pane; a desktop's Tab and clicks move its own focus
33 notice: string // the last action's outcome, shown in the footer
34 isResetArmed: boolean
35 customs: Partial<Record<keyof BeatSettings, Value>> // the custom value each setting last took, kept past a preset
36}
37
38const LABEL = 22
39
40/** What the pane's buttons do, for the help line while one is focused. */
41const BUTTON_HELP: Record<string, string> = {
42 beatNow: 'Beats now, and counts the next from there.',
43 reset: 'Puts every setting back to its default; press twice.',
44}
45
46export function paint(Text: Els['Text'], list: Span[]) {
47 return list.map(sp => (sp.color ? <Text color={sp.color} dimColor={sp.dim}>{sp.text}</Text> : <Text dimColor={sp.dim}>{sp.text}</Text>))
48}
49
50/**
51 * The status line from the session's figures: its beats, what the last one read, the time to the
52 * next; before the first beat, the time to it alone. '' when there is nothing to say.
53 */
54export function statusText(s: BeatSettings, beats: number, read: number | null, next: string | null) {
55 const countdown = s.showCountdown && next
56 if (beats === 0) return countdown ? `♡ next beat in ${next}` : ''
57 let text = `♥ cache kept warm ×${beats}`
58 if (s.showTokens && read) text += ` · ${tokens(read)} cached`
59 if (countdown) text += ` · next in ${next}`
60 return text
61}
62
63/** A value as its row shows it. */
64export const shown = (row: Row, v: Value, s: BeatSettings) => (row.fmt ? row.fmt(v, s) : String(v))
65
66function preview(els: Els, s: BeatSettings, kind: Preview, tick: number) {
67 const { Box, Text } = els
68 const x = variant(s.variant)
69 const heartLook = lookOf(s, 'heart')
70 const lineLook = lookOf(s, 'line')
71 const frame = heartLook.animate ? previewFrame(x, tick, heartLook.timing) : x.loop[0]!
72 // the hint line takes text alone, drawn dim
73 const heart = s.heartPlacement === 'tail' ? [{ text: frame, dim: true }] : spans(frame, heartLook, tick, isLit(frame))
74 const sample = statusText(s, 3, 184_000, '42m')
75 const lead = lineLook.animate && s.statusHeart !== 'off' ? statusFrame(s.statusHeart, tick, lineLook.timing) : ''
76 const lineLit = isLit(lead || statusFrame('beat', tick, lineLook.timing))
77 const line = paint(Text, spans(lead ? sample.replace(/^[♥♡]/, lead) : sample, lineLook, tick, lineLit))
78 const turn = <Text dimColor>✻ Brewed for 2s</Text>
79 const status = s.statusLine === 'off' ? turn
80 : (
81 <Box flexDirection="column">
82 {turn}
83 <Box marginTop={s.statusLine === 'spaced' ? 1 : 0}>{line}</Box>
84 </Box>
85 )
86 return (
87 <Box flexDirection="column">
88 {kind !== 'status' && (
89 <Box>
90 <Text dimColor>{'heart'.padEnd(8)}</Text>
91 {paint(Text, heart)}
92 {s.showCount && <Text dimColor> ×3</Text>}
93 </Box>
94 )}
95 {kind !== 'heart' && (
96 <Box>
97 <Text dimColor>{'status'.padEnd(8)}</Text>
98 {status}
99 </Box>
100 )}
101 </Box>
102 )
103}
104
105/** What `Beat after idle` adds to its value: what `auto` comes to now, and this session's own interval. */
106const intervalTail = (s: BeatSettings, v: View) =>
107 (s.interval === 'auto' ? ` · ${v.autoMinutes}m now` : '') + (v.sessionMinutes !== null ? ` · this session ${v.sessionMinutes}m` : '')
108
109/** The pane and its focus ring: the keys of its elements, in the order the arrows walk them. */
110export function settingsPane(els: Els, s: BeatSettings, v: View, act: Actions): { tree: RenderElement; ring: string[] } {
111 const { Box, Text, Button, Input } = els
112 const ring: string[] = []
113 const rule = <Text dimColor>{'─'.repeat(Math.max(10, v.columns))}</Text>
114 const at = v.page.picker ? rowOf(v.page.picker) : undefined
115
116 if (at) {
117 const { row, tab } = at
118 const key = row.key
119 const isVariant = key === 'variant'
120 const isStatusHeart = key === 'statusHeart'
121 // the focused option stands in for the setting in the preview, before it is picked
122 const focused = /^opt:(\d+)$/.exec(v.focus)
123 const trial = focused ? { ...s, [key]: row.values[Number(focused[1])] } : s
124 ring.push('back', ...row.values.map((_, i) => `opt:${i}`))
125 if (row.custom) ring.push('custom')
126 const isCustom = !row.values.includes(s[key])
127 const tree = (
128 <Box flexDirection="column">
129 <Box gap={1}>
130 <Button plain key="back" onPress={() => act.back()}>‹</Button>
131 <Text bold>{row.label.trim()}</Text>
132 </Box>
133 <Text dimColor>{row.help}</Text>
134 {rule}
135 {row.values.map((val, i) => {
136 const look = lookOf(s, key === 'statusHeart' || key === 'lineColor' ? 'line' : 'heart')
137 const frame = isVariant ? previewFrame(variant(String(val)), v.tick, look.timing)
138 : isStatusHeart ? statusFrame(String(val), v.tick, look.timing)
139 : ''
140 const sample = frame
141 ? spans(frame, { ...look, animate: true }, v.tick, isLit(frame))
142 : HEX_OF[key] && val !== 'dim'
143 ? spans('♥ ♥ ♥', { ...look, color: String(val), effect: 'steady' }, 0, false)
144 : []
145 return (
146 <Box>
147 <Button plain key={`opt:${i}`} onPress={() => act.pick(key, val)}>
148 {`${val === s[key] ? '●' : ' '} ${shown(row, val, s).padEnd(isVariant ? 11 : LABEL)}`}
149 </Button>
150 {sample.length > 0 && <Text> </Text>}
151 {paint(Text, sample)}
152 </Box>
153 )
154 })}
155 {row.custom && (
156 <Input
157 key="custom"
158 label={`${isCustom ? '●' : ' '} ${'custom'.padEnd(LABEL)}`}
159 placeholder={row.custom.placeholder}
160 value={isCustom ? shown(row, s[key], s) : v.customs[key] !== undefined ? shown(row, v.customs[key]!, s) : ''}
161 submitLabel="set"
162 onSubmit={text => {
163 const r = row.custom!.parse
164 const val = r(text)
165 if (val !== undefined) act.pick(key, val)
166 else act.refuse(`${row.label.trim()} takes ${r.min}–${r.max} ${r.unit}, not "${text.trim()}"`)
167 }}
168 />
169 )}
170 {tab.preview && !isVariant && rule}
171 {tab.preview && !isVariant && preview(els, trial, tab.preview, v.tick)}
172 {v.notice && <Text dimColor>{v.notice}</Text>}
173 <Text dimColor>{v.isTerminal ? '↑↓ move · enter picks · esc back' : 'enter or a click picks · esc back'}</Text>
174 </Box>
175 )
176 return { tree, ring }
177 }
178
179 const tab = TABS.find(t => t.id === v.page.tab) ?? TABS[0]!
180 const rows = tab.rows.filter(r => !r.show || r.show(s))
181 // the tab's own ring: the tab bar above it is a level of its own, reached by Esc
182 if (tab.id === 'beating') ring.push('session')
183 const hexOf = (r: Row) => (HEX_OF[r.key] && s[r.key] === 'custom' ? HEX_OF[r.key] : undefined)
184 for (const r of rows) {
185 ring.push(`row:${r.key}`)
186 const hex = hexOf(r)
187 if (hex) ring.push(hex)
188 }
189 if (tab.id === 'beating') ring.push('beatNow', 'reset') // the buttons sit with beating
190 const focusedRow = rows.find(r => v.focus === `row:${r.key}` || v.focus === hexOf(r))
191 const help = v.focus === 'session' ? SESSION_HELP : focusedRow?.help ?? BUTTON_HELP[v.focus] ?? ''
192 // r takes the focused setting back to its default, its custom value kept in its list
193 const resettable = focusedRow && s[focusedRow.key] !== DEFAULTS[focusedRow.key] ? focusedRow : undefined
194 const keys = `1-${TABS.length}`
195
196 const line = (label: string, value: string, more = '') => `${label.padEnd(LABEL)}${value}${more}`
197 const tree = (
198 <Box flexDirection="column">
199 <Box columnGap={1} flexWrap="wrap">
200 {TABS.map((t, i) => (
201 <Button
202 plain
203 hotkey={`${i + 1}`}
204 key={`tab:${t.id}`}
205 dimColor={t.id !== tab.id}
206 onPress={() => act.tab(t.id)}
207 >
208 {t.title}
209 </Button>
210 ))}
211 </Box>
212 {rule}
213 <Text dimColor>{tab.id === 'beating' ? 'This session is this session alone; every other setting applies to every session.' : 'These apply to every session.'}</Text>
214 {tab.id === 'beating' && (
215 <Button plain key="session" onPress={() => (act.at('session'), act.toggleSession())}>{line('This session', v.isOn ? 'on' : 'off')}</Button>
216 )}
217 {rows.map(r => (
218 <Box flexDirection="column">
219 <Button
220 plain
221 key={`row:${r.key}`}
222 onPress={() => (isFlip(r) ? (act.at(`row:${r.key}`), act.set({ [r.key]: r.values[r.values.indexOf(s[r.key]) === 0 ? 1 : 0] })) : act.open(r.key))}
223 >
224 {line(
225 r.label,
226 shown(r, s[r.key], s) + (r.key === 'interval' ? intervalTail(s, v) : ''),
227 isFlip(r) ? '' : ' ›',
228 )}
229 </Button>
230 {hexOf(r) && (
231 <Input
232 key={hexOf(r)!}
233 label={' hex'.padEnd(LABEL)}
234 placeholder="#rrggbb"
235 value={String(s[hexOf(r)!])}
236 submitLabel="set"
237 onSubmit={val => (isHex(val.trim()) ? act.set({ [hexOf(r)!]: val.trim().toLowerCase() }) : act.refuse(`hex takes #rrggbb, not "${val.trim()}"`))}
238 />
239 )}
240 </Box>
241 ))}
242 {tab.preview && rule}
243 {tab.preview && preview(els, s, tab.preview, v.tick)}
244 {rule}
245 {(tab.id === 'beating' || resettable) && (
246 <Box gap={1}>
247 {tab.id === 'beating' && <Button key="beatNow" onPress={() => (act.at('beatNow'), act.beatNow())}>Beat now</Button>}
248 {tab.id === 'beating' && (
249 <Button key="reset" onPress={() => (act.at('reset'), act.reset())}>{v.isResetArmed ? 'Press again to reset all' : 'Reset all'}</Button>
250 )}
251 {resettable && (
252 <Button plain dimColor hotkey="r" key="resetRow" onPress={() => act.resetRow(resettable.key)}>
253 {`back to ${shown(resettable, DEFAULTS[resettable.key], s)}`}
254 </Button>
255 )}
256 </Box>
257 )}
258 {help && <Text dimColor>{help}</Text>}
259 {v.notice && <Text dimColor>{v.notice}</Text>}
260 <Text dimColor>
261 {!v.isTerminal ? `tab moves · enter or a click changes · ${keys} tabs · esc closes`
262 : v.focus.startsWith('tab:') ? `↑↓ tabs · enter or ${keys} opens · esc closes`
263 : `↑↓ move · enter changes · ${keys} tabs · esc back to the tabs`}
264 </Text>
265 </Box>
266 )
267 return { tree, ring }
268}
269hooks/settings.ts 375 lines1import type { BeatSettings, Look, Ttl } from '../types'
2import { STATUS_HEARTS, VARIANTS } from './animations'
3
4export const DEFAULTS: BeatSettings = {
5 defaultOn: false,
6 interval: 'auto',
7 intervalScope: 'session',
8 stopAfterHours: 8,
9 stopAtUsage: 90,
10 skipSmall: false,
11 skipSmallTokens: 20_000,
12 heartPlacement: 'tail',
13 variant: 'classic',
14 showCount: true,
15 heartColor: 'claude',
16 heartHex: '#e6a8b9',
17 heartAnimate: true,
18 heartEffect: 'steady',
19 heartSpeed: 'normal',
20 heartTiming: 'lubdub',
21 statusLine: 'spaced',
22 showCountdown: true,
23 showTokens: true,
24 statusHeart: 'beat',
25 lineColor: 'claude',
26 lineHex: '#e6a8b9',
27 lineAnimate: true,
28 lineEffect: 'steady',
29 lineSpeed: 'normal',
30 lineTiming: 'lubdub',
31 onBeat: 'none',
32 onStop: 'log',
33 onModelSwitch: 'wait',
34}
35
36/** The interval `auto` beats at for each cache lifetime, in minutes: under it, with room for a slow request. */
37export const AUTO: Record<Ttl, number> = { '1h': 50, '5m': 4 }
38
39/** What the store keeps: the settings that differ from the defaults, so a new default reaches the rest. */
40export const changed = (s: BeatSettings): Partial<BeatSettings> =>
41 Object.fromEntries(Object.entries(s).filter(([k, v]) => v !== DEFAULTS[k as keyof BeatSettings]))
42
43/** The parts that draw, and the settings each of them looks by. */
44export type Part = 'heart' | 'line'
45const LOOK_KEYS = {
46 heart: { color: 'heartColor', customColor: 'heartHex', effect: 'heartEffect', animate: 'heartAnimate', speed: 'heartSpeed', timing: 'heartTiming' },
47 line: { color: 'lineColor', customColor: 'lineHex', effect: 'lineEffect', animate: 'lineAnimate', speed: 'lineSpeed', timing: 'lineTiming' },
48} as const satisfies Record<Part, Record<keyof Look, keyof BeatSettings>>
49
50/** How a part draws and moves: its own color, effect, animation, speed and timing. */
51export const lookOf = (s: BeatSettings, part: Part): Look => {
52 const k = LOOK_KEYS[part]
53 return { color: s[k.color], customColor: s[k.customColor], effect: s[k.effect], animate: s[k.animate], speed: s[k.speed], timing: s[k.timing] }
54}
55
56/** Store values from an older or hand-edited store fall back to the default one by one. */
57export function normalize(stored: unknown): BeatSettings {
58 const s: Record<string, unknown> = { ...DEFAULTS }
59 const from: Record<string, unknown> = stored && typeof stored === 'object' ? { ...stored } : {}
60 // before 0.7.0 one look served both parts: each part takes it, unless it has its own
61 for (const old of ['color', 'customColor', 'effect', 'animate', 'speed', 'timing'] as const) {
62 if (!(old in from)) continue
63 for (const part of ['heart', 'line'] as const) from[LOOK_KEYS[part][old]] ??= from[old]
64 delete from[old]
65 }
66 for (const [k, v] of Object.entries(from)) {
67 const isNumberFor = (...keys: string[]) => keys.includes(k) && typeof v === 'number'
68 if (k in DEFAULTS && (typeof v === typeof DEFAULTS[k as keyof BeatSettings] || isNumberFor('heartSpeed', 'lineSpeed', 'interval'))) s[k] = v
69 }
70 const out = s as BeatSettings
71 if (!VARIANTS.some(v => v.id === out.variant)) out.variant = DEFAULTS.variant
72 if (!STATUS_HEARTS.includes(out.statusHeart)) out.statusHeart = DEFAULTS.statusHeart
73 if (!['below', 'spaced', 'off'].includes(out.statusLine)) out.statusLine = DEFAULTS.statusLine
74 for (const key of ['heartSpeed', 'lineSpeed'] as const) {
75 if (typeof out[key] === 'number' && parseFrameMs(`${out[key]}`) === undefined) out[key] = DEFAULTS[key]
76 }
77 if (out.interval !== 'auto' && (typeof out.interval !== 'number' || parseMinutes(`${out.interval}`) === undefined)) out.interval = DEFAULTS.interval
78 if (!['wait', 'warm'].includes(out.onModelSwitch)) out.onModelSwitch = DEFAULTS.onModelSwitch
79 return out
80}
81
82export const FRAME_MS = { slow: 120, normal: 80, fast: 50 } as const
83/** A frame's time: a named speed's, or the milliseconds typed in. */
84export const frameMs = (look: Look) => (typeof look.speed === 'number' ? look.speed : FRAME_MS[look.speed])
85
86// theme keys with a shimmer pair in Claude Code's themes, so they follow the active theme
87export const THEME_COLORS = ['claude', 'permission', 'warning', 'fastMode', 'inactive'] as const
88// drawn as hex, a raw color every surface takes; the highlight is a lighter shade
89const PRESETS: Record<string, string> = {
90 red: '#e5534b', magenta: '#c678dd', yellow: '#e5c07b', green: '#98c379', cyan: '#56b6c2', white: '#d7d7d7',
91}
92export const PRESET_COLORS = Object.keys(PRESETS)
93export const COLORS = ['dim', ...THEME_COLORS, ...PRESET_COLORS, 'custom']
94
95const lighten = (hex: string) => {
96 const n = parseInt(hex.slice(1), 16)
97 const ch = (shift: number) => {
98 const c = (n >> shift) & 0xff
99 return Math.round(c + (255 - c) * 0.45).toString(16).padStart(2, '0')
100 }
101 return `#${ch(16)}${ch(8)}${ch(0)}`
102}
103export const isHex = (v: string) => /^#[0-9a-f]{6}$/i.test(v)
104
105/** A run of text in one style. `color` undefined with `dim` false is the terminal's own text color. */
106export type Span = { text: string; color?: string; dim: boolean }
107type Tone = { color?: string; dim: boolean }
108
109/** The color's resting tone and its highlight: the theme's shimmer, a bright preset, a lighter hex. */
110export function tones(s: Look): { base: Tone; hi: Tone } {
111 const c = s.color
112 if (c === 'dim') return { base: { dim: true }, hi: { dim: false } }
113 if ((THEME_COLORS as readonly string[]).includes(c)) return { base: { color: c, dim: false }, hi: { color: `${c}Shimmer`, dim: false } }
114 const hex = c === 'custom' ? (isHex(s.customColor) ? s.customColor : DEFAULTS.heartHex) : (PRESETS[c] ?? PRESETS.red!)
115 return { base: { color: hex, dim: false }, hi: { color: lighten(hex), dim: false } }
116}
117
118/** A frame with a filled heart in it: the beat of the animation, where `flash` lights up. */
119export const isLit = (frame: string) => /[♥❤]/.test(frame)
120
121/** Characters as a terminal cell sees them: a heart's text-presentation selector stays with its heart. */
122export const glyphs = (text: string) => [...text].reduce<string[]>((out, ch) => {
123 if (ch === '︎' && out.length) out[out.length - 1] += ch
124 else out.push(ch)
125 return out
126}, [])
127
128type Mode = 'flash' | 'flow' | 'both'
129const MODES: readonly Mode[] = ['flash', 'flow', 'both']
130export const EPISODE = 40 // ticks one mixed mode lasts: 3.2s at normal speed
131
132/**
133 * The mode `mixed` plays at `tick`: drawn at random for each episode, never twice in a row. A hash of
134 * the episode's number, not a stored roll, so every drawing at one tick agrees.
135 */
136export function mixedMode(tick: number): Mode {
137 const roll = (n: number) => {
138 let h = Math.imul(n ^ 0x9e3779b9, 0x85ebca6b)
139 h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35)
140 return ((h ^ (h >>> 16)) >>> 0) % 2
141 }
142 // each episode steps one or two modes on from the last, so it always changes
143 let i = 0
144 for (let n = 1; n <= Math.floor(tick / EPISODE); n++) i = (i + 1 + roll(n)) % 3
145 return MODES[i]!
146}
147
148/**
149 * Styles `text` by the color and effect: steady holds the resting tone, flash lights the whole
150 * text while `lit`, flow sweeps a three-glyph highlight across it with `tick`, as the spinner's
151 * shimmer does. Both does the two at once, the sweep dipping to the resting tone while lit;
152 * mixed plays flash, flow or both, a few seconds of each.
153 */
154export function spans(text: string, s: Look, tick: number, lit: boolean): Span[] {
155 const { base, hi } = tones(s)
156 if (s.effect === 'steady' || !s.animate) return [{ text, ...base }]
157 const mode = s.effect === 'mixed' ? mixedMode(tick) : s.effect
158 if (mode === 'flash') return [{ text, ...(lit ? hi : base) }]
159 const [rest, sweep] = mode === 'both' && lit ? [hi, base] : [base, hi]
160 const g = glyphs(text)
161 const at = (tick % (g.length + 6)) - 3
162 const out: Span[] = []
163 g.forEach((ch, i) => {
164 const tone = Math.abs(i - at) <= 1 ? sweep : rest
165 const last = out.at(-1)
166 if (last && last.color === tone.color && last.dim === tone.dim) last.text += ch
167 else out.push({ text: ch, ...tone })
168 })
169 return out
170}
171
172/** Reads a typed number into a setting's unit, undefined outside its range; it carries that unit and range. */
173export type Reader = ((text: string) => number | undefined) & { unit: string; min: number; max: number }
174
175/**
176 * Reads a typed number with an optional unit (`units` maps each to its scale; '' is none), kept
177 * when it lands within [min, max]: '35k' → 35000 tokens, '90m' → 1.5 hours.
178 */
179function reader(unit: string, units: Record<string, number>, min: number, max: number, isWhole: boolean): Reader {
180 const read = (text: string): number | undefined => {
181 const m = /^(\d+(?:\.\d+)?)\s*([a-z%]*)$/i.exec(text.trim().replaceAll(',', ''))
182 const scale = m ? units[m[2]!.toLowerCase()] : undefined
183 if (scale === undefined) return undefined
184 const n = Number(m![1]) * scale
185 const v = isWhole ? Math.round(n) : Math.round(n * 100) / 100
186 return v >= min && v <= max ? v : undefined
187 }
188 return Object.assign(read, { unit, min, max })
189}
190
191export const parseTokens = reader('tokens', { '': 1, k: 1e3, m: 1e6 }, 1_000, 1_000_000, true)
192export const parseMinutes = reader('minutes', { '': 1, m: 1, min: 1 }, 1, 55, true) // under the hour the cache lives
193export const parseHours = reader('hours', { '': 1, h: 1, m: 1 / 60, min: 1 / 60 }, 0.5, 48, false)
194export const parsePercent = reader('percent', { '': 1, '%': 1 }, 10, 100, true)
195export const parseFrameMs = reader('ms a frame', { '': 1, ms: 1 }, 20, 500, true)
196
197/** 184000 → 184k, 1250000 → 1.3M. */
198export const tokens = (n: number) =>
199 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
200
201export type Value = string | number | boolean
202/**
203 * One row of the settings pane. A row of two choices (on/off among them) flips in place on Enter;
204 * one of more, or that takes a typed value, opens a list, whose focused choice the preview shows
205 * before it is picked. `help` is what the row does, shown while it is focused and above its list.
206 */
207export type Row = {
208 key: keyof BeatSettings
209 label: string
210 name?: string // the label out of its tab, where it would be unclear: in Claude's transcript line
211 values: readonly Value[]
212 help: string
213 show?: (s: BeatSettings) => boolean
214 fmt?: (v: Value, s: BeatSettings) => string
215 custom?: { placeholder: string; parse: Reader } // a typed value besides the choices
216}
217
218export type Preview = 'heart' | 'status' | 'both'
219export type Tab = { id: string; title: string; rows: readonly Row[]; preview?: Preview }
220
221const onOff = (v: Value) => (v ? 'on' : 'off')
222const ALERTS = ['none', 'log', 'toast', 'both']
223const alert = (v: Value) => (v === 'both' ? 'log + toast' : `${v}`)
224
225/** The pane's own row on the Beating tab, not a setting: whether this session beats. */
226export const SESSION_HELP = "Beats for this session alone. New sessions start as 'New sessions start' says."
227
228const TERMINAL_ONLY = 'The desktop app draws no heart under the prompt: there the turn line carries it.'
229
230/** The settings a part looks by, each its own: color, animation, effect, speed, timing. */
231function lookRows(part: Part, what: string, shown: (s: BeatSettings) => boolean): Row[] {
232 const k = LOOK_KEYS[part]
233 const title = part === 'heart' ? 'Prompt heart' : 'Turn line'
234 const moving = (s: BeatSettings) => shown(s) && (s[k.animate] as boolean)
235 // the heart on the hint line is drawn dim, whatever its color
236 const colored = part === 'heart' ? shown : (s: BeatSettings) => s.statusLine !== 'off'
237 return [
238 {
239 key: k.color, label: 'Color', name: `${title} color`, values: COLORS, show: colored, fmt: (v, s) => (v === 'custom' ? `custom ${s[k.customColor]}` : `${v}`),
240 help: `The color of ${what}: dim, your theme's, a preset, or any hex color.`,
241 },
242 {
243 key: k.animate, label: 'Animate', name: `${title} animate`, values: [true, false], fmt: onOff,
244 show: part === 'heart' ? () => true : (s: BeatSettings) => s.statusLine !== 'off',
245 help: `Animates ${what}. Off, it holds still.`,
246 },
247 {
248 key: k.effect, label: 'Effect', name: `${title} effect`, values: ['steady', 'flash', 'flow', 'mixed'], show: s => moving(s) && colored(s),
249 help: 'steady; flash on each beat; flow, a shimmer sweeping across; mixed switches between them.',
250 },
251 {
252 key: k.speed, label: 'Speed', name: `${title} speed`, values: ['slow', 'normal', 'fast'], show: part === 'heart' ? (s: BeatSettings) => s[k.animate] as boolean : moving,
253 fmt: v => (typeof v === 'number' ? `${v}ms a frame` : `${v} · ${FRAME_MS[v as keyof typeof FRAME_MS]}ms`),
254 help: `How fast ${what} plays: a speed, or a frame time.`,
255 custom: { placeholder: 'e.g. 65ms', parse: parseFrameMs },
256 },
257 {
258 key: k.timing, label: 'Timing', name: `${title} timing`, values: ['lubdub', 'linear'], show: part === 'heart' ? (s: BeatSettings) => s[k.animate] as boolean : moving,
259 fmt: v => (v === 'lubdub' ? 'lub-dub' : 'linear'),
260 help: `lub-dub beats twice, then rests, and suits the hearts; linear plays evenly, and suits the line animations (${VARIANTS.filter(x => x.isEndless).map(x => x.name).join(', ')}).`,
261 },
262 ]
263}
264
265export const TABS: readonly Tab[] = [
266 {
267 id: 'beating', title: 'Beating',
268 rows: [
269 { key: 'defaultOn', label: 'New sessions start', values: [false, true], fmt: onOff, help: 'Whether new sessions start beating. Open sessions keep what they have.' },
270 {
271 key: 'interval', label: 'Beat after idle', values: ['auto', 4, 15, 30, 45, 55], fmt: v => (v === 'auto' ? 'auto' : `${v}m`),
272 help: `How long you're idle before a beat. auto fits your cache: every ${AUTO['1h']}m on a one-hour cache, every ${AUTO['5m']}m on a five-minute one.`,
273 custom: { placeholder: 'e.g. 35m', parse: parseMinutes },
274 },
275 {
276 key: 'onModelSwitch', label: 'After /model', values: ['wait', 'warm'],
277 fmt: v => (v === 'wait' ? 'wait for your next turn' : 'warm the new model'),
278 help: "After /model while you're idle: wait for your next message, or have the next beat write the new model's cache, at the cost your message would pay.",
279 },
280 {
281 key: 'intervalScope', label: '/cachebeat 30 changes', values: ['session', 'global'],
282 fmt: v => (v === 'session' ? 'this session' : 'the default'),
283 help: "What typing /cachebeat with a number changes: this session's interval, or the default for every session.",
284 },
285 ],
286 },
287 {
288 id: 'limits', title: 'Limits',
289 rows: [
290 {
291 key: 'stopAfterHours', label: 'Stop after idle', values: [1, 4, 8, 24], fmt: v => `${v}h`,
292 help: 'Beating stops after this long without a message from you.',
293 custom: { placeholder: 'e.g. 10h or 90m', parse: parseHours },
294 },
295 {
296 key: 'stopAtUsage', label: 'Stop at usage', values: [80, 90, 100], fmt: v => `${v}%`,
297 help: 'Beating stops once any of your usage limits reaches this.',
298 custom: { placeholder: 'e.g. 85%', parse: parsePercent },
299 },
300 { key: 'skipSmall', label: 'Skip small chats', values: [false, true], fmt: onOff, help: "Doesn't beat chats smaller than the size below." },
301 {
302 key: 'skipSmallTokens', label: ' smaller than', values: [10_000, 20_000, 50_000, 100_000],
303 show: s => s.skipSmall, fmt: v => `${tokens(Number(v))} tokens`,
304 help: 'The smallest chat kept warm.',
305 custom: { placeholder: 'e.g. 35k', parse: parseTokens },
306 },
307 ],
308 },
309 {
310 id: 'heart', title: 'Prompt heart', preview: 'heart',
311 rows: [
312 {
313 key: 'heartPlacement', label: 'Placement', name: 'Prompt heart placement', values: ['tail', 'line'], fmt: v => (v === 'tail' ? 'hint line · dim' : 'own line'),
314 help: `At the end of the hint line under the prompt, drawn dim, or on a line of its own, in color. ${TERMINAL_ONLY}`,
315 },
316 {
317 key: 'variant', label: 'Animation', name: 'Prompt heart animation', values: VARIANTS.map(v => v.id), fmt: v => VARIANTS.find(x => x.id === v)?.name ?? `${v}`,
318 help: `What the heart under the prompt plays. ${TERMINAL_ONLY}`,
319 },
320 { key: 'showCount', label: 'Beat count', values: [true, false], fmt: onOff, help: `Shows ×3, the beats so far, beside the heart. ${TERMINAL_ONLY}` },
321 ...lookRows('heart', 'the heart', s => s.heartPlacement === 'line'),
322 ],
323 },
324 {
325 id: 'status', title: 'Turn line', preview: 'status',
326 rows: [
327 {
328 key: 'statusLine', label: 'Show', name: 'Turn line', values: ['spaced', 'below', 'off'],
329 fmt: v => ({ spaced: 'after a blank line', below: 'right below the turn row', off: 'off' })[v as string]!,
330 help: 'Where the line goes under your latest turn, or off. In the desktop app it sits above the prompt.',
331 },
332 { key: 'showCountdown', label: 'Countdown', values: [true, false], show: s => s.statusLine !== 'off', fmt: onOff, help: 'Shows the time to the next beat.' },
333 { key: 'showTokens', label: 'Tokens kept', values: [true, false], show: s => s.statusLine !== 'off', fmt: onOff, help: 'Shows how much the last beat kept warm, as · 347k cached.' },
334 {
335 key: 'statusHeart', label: 'Its heart', name: 'Turn line heart', values: STATUS_HEARTS, show: s => s.statusLine !== 'off' && s.lineAnimate,
336 fmt: v => (v === 'beat' ? 'one heart, beating' : v === 'off' ? 'still' : VARIANTS.find(x => x.id === v)?.name ?? `${v}`),
337 help: 'What the heart starting the line plays. One heart beating keeps the words still; a wider animation moves them as it plays.',
338 },
339 ...lookRows('line', 'the line', s => s.statusLine !== 'off'),
340 ],
341 },
342 {
343 id: 'alerts', title: 'Alerts',
344 rows: [
345 { key: 'onBeat', label: 'On a beat', values: ALERTS, fmt: alert, help: 'How a beat landing is told: a line in the transcript (log), a toast, both, or nothing.' },
346 { key: 'onStop', label: 'On stop', values: ALERTS, fmt: alert, help: 'How beating stopping on its own is told, and why.' },
347 ],
348 },
349]
350
351export const rowOf = (key: keyof BeatSettings) => {
352 for (const tab of TABS) for (const row of tab.rows) if (row.key === key) return { tab, row }
353 return undefined
354}
355
356/** A row Enter flips in place: two choices and no typed value. Any other opens its list. */
357export const isFlip = (row: Row) => row.values.length === 2 && !row.custom
358
359/**
360 * `value` as `key` takes it, the way the pane does: one of the row's choices, or a custom value its
361 * reader accepts (35000, or '35k'); a hex for the custom color. Undefined when it takes no such value.
362 */
363/** The hex each color row takes when it is custom. */
364export const HEX_OF: Partial<Record<keyof BeatSettings, keyof BeatSettings>> = { heartColor: 'heartHex', lineColor: 'lineHex' }
365export const HEX_KEYS: readonly string[] = Object.values(HEX_OF)
366
367export function accept(key: string, value: unknown): Value | undefined {
368 if (HEX_KEYS.includes(key)) return typeof value === 'string' && isHex(value) ? value.toLowerCase() : undefined
369 const row = rowOf(key as keyof BeatSettings)?.row
370 if (!row) return undefined
371 if (row.values.includes(value as Value)) return value as Value
372 return row.custom && (typeof value === 'number' || typeof value === 'string') ? row.custom.parse(String(value)) : undefined
373}
374
375hooks/tools.ts 175 lines1// The tools Claude calls to read and change cachebeat: their specs, drawn from the pane's rows so a
2// range is written once, and the reading of a `set` call's input.
3import type { ToolSpec } from 'claude-code'
4import type { BeatSettings } from '../types'
5import { shown } from './pane'
6import type { Reader, Value } from './settings'
7import { AUTO, DEFAULTS, HEX_KEYS, accept, parseMinutes, rowOf } from './settings'
8
9/** Each setting, for the model; the unit and range are added from its reader. */
10const ABOUT: Record<keyof BeatSettings, string> = {
11 defaultOn: 'New sessions start beating; open sessions keep theirs.',
12 interval:
13 `Minutes idle before a beat, for sessions without their own; auto beats at ${AUTO['1h']} on a one-hour cache and ${AUTO['5m']} on a five-minute one, reading which the session has (state's cacheTtl).`,
14 intervalScope: "What the user's `/cachebeat <minutes>` changes: this session's interval, or settings.interval.",
15 stopAfterHours: 'Beating stops after this long without a message from the user.',
16 stopAtUsage: 'Beating stops once any usage limit reaches this.',
17 skipSmall: 'Skips beating chats smaller than skipSmallTokens.',
18 skipSmallTokens: "The smallest chat kept warm while skipSmall is on; setting it doesn't turn skipSmall on.",
19 heartPlacement: 'The prompt heart: tail, at the end of the hint line, always dim; line, on its own line, in color. Terminal only.',
20 variant: "The prompt heart's animation. Terminal only.",
21 showCount: 'Shows the beat count beside the prompt heart. Terminal only.',
22 heartColor: 'The prompt heart\'s color when it has its own line: dim, a theme color, a preset, or custom (heartHex).',
23 heartHex: "The prompt heart's color when heartColor is custom; setting it doesn't set heartColor to custom.",
24 heartAnimate: 'Animates the prompt heart.',
25 heartEffect: 'The prompt heart on its own line: steady; flash on each beat; flow, a shimmer sweeping across; mixed switches between them.',
26 heartSpeed: "The prompt heart's animation speed: a name, or a frame time.",
27 heartTiming: 'The prompt heart: lubdub beats twice, then rests; linear plays evenly.',
28 statusLine: 'The turn line, under the latest turn: spaced (after a blank line), below (right under it), or off. On desktop it sits above the prompt.',
29 showCountdown: 'Shows the time to the next beat in the turn line.',
30 showTokens: 'Shows the tokens the last beat kept warm in the turn line.',
31 statusHeart: "What the heart starting the turn line plays: beat (one heart, filling in time), off (still), or one of the prompt heart's animations.",
32 lineColor: 'The turn line\'s color: dim, a theme color, a preset, or custom (lineHex).',
33 lineHex: "The turn line's color when lineColor is custom; setting it doesn't set lineColor to custom.",
34 lineAnimate: "Animates the turn line's heart and effect.",
35 lineEffect: 'The turn line: steady; flash on each beat; flow, a shimmer sweeping across; mixed switches between them.',
36 lineSpeed: "The turn line's animation speed: a name, or a frame time.",
37 lineTiming: "The turn line's heart: lubdub beats twice, then rests; linear plays evenly.",
38 onBeat: 'How a beat is announced: a transcript line (log), a toast, both, or none.',
39 onStop: 'How beating stopping on its own is announced.',
40 onModelSwitch: "After /model while idle: wait for the user's next turn, or have the next beat write the new model's cache (a full cache write, the one that turn would make).",
41}
42
43const KEYS = Object.keys(ABOUT) as (keyof BeatSettings)[]
44const range = (r: Reader) => `${r.min}–${r.max} ${r.unit}`
45const names = (values: readonly Value[]) => values.filter(v => typeof v === 'string')
46
47/** What a setting takes, as schemas: on/off, its named choices, or a number in its reader's range, with or beside names. */
48function kinds(key: keyof BeatSettings): Record<string, unknown>[] {
49 if (HEX_KEYS.includes(key)) return [{ type: 'string', pattern: '^#[0-9a-fA-F]{6}$' }]
50 const { row } = rowOf(key)!
51 if (typeof row.values[0] === 'boolean') return [{ type: 'boolean' }]
52 if (!row.custom) return [{ enum: [...row.values] }]
53 const r = row.custom.parse
54 const named = names(row.values)
55 return [...(named.length ? [{ enum: named }] : []), { type: 'number', minimum: r.min, maximum: r.max }]
56}
57
58/** A setting's schema: what it takes, or null to reset it. */
59function field(key: keyof BeatSettings): Record<string, unknown> {
60 const r = HEX_KEYS.includes(key) ? undefined : rowOf(key)!.row.custom?.parse
61 return { anyOf: [...kinds(key), { type: 'null' }], description: r ? `${ABOUT[key]} (${range(r)})` : ABOUT[key] }
62}
63
64/** What a setting takes, each choice in words, for a refusal. */
65function takes(key: keyof BeatSettings): string[] {
66 if (HEX_KEYS.includes(key)) return ['a hex color (#rrggbb)']
67 const { row } = rowOf(key)!
68 if (typeof row.values[0] === 'boolean') return ['true', 'false']
69 if (!row.custom) return row.values.map(String)
70 return [...names(row.values), range(row.custom.parse)]
71}
72
73/** `a, b, or c`; `a or b`. */
74const either = (choices: string[]) =>
75 choices.length < 3 ? choices.join(' or ') : `${choices.slice(0, -1).join(', ')}, or ${choices.at(-1)}`
76
77const object = (properties: Record<string, unknown>) => ({ type: 'object', properties, additionalProperties: false })
78
79export const STATE: ToolSpec = {
80 name: 'state',
81 description: [
82 "cachebeat's state in this session and every setting with its value.",
83 [
84 "`nextBeat` is `in 42m`, `50m after this turn ends` (as seen mid-turn), `after the next turn` (nothing cached to keep warm yet), or null when off or skipping.",
85 '`intervalFrom` is `session` when this session has its own interval, `default` when it follows a settings.interval in minutes, and `auto` when it follows an auto one.',
86 "`cacheTtl` is how long this session's cache lives, `1h` or `5m`, worked out on each call from Claude Code's cache settings and whether a subscription is within its usage; an auto interval beats within it (50 or 4 minutes).",
87 '`skipping`, when set, says why beats skip this chat (under the skipSmallTokens minimum).',
88 "`paused`, when set, says why beats wait for the user's next turn (a model switch, a compaction); only that turn ends it, not `set`.",
89 '`lastBeatReadTokens` is how much the last beat read from the cache. `defaults` has the default of each setting that differs from it.',
90 ].join(' '),
91 ].join('\n\n'),
92 inputSchema: object({}),
93}
94
95export const SET: ToolSpec = {
96 name: 'set',
97 description: [
98 "Changes cachebeat, which keeps this session's prompt cache warm while it sits idle by sending a small background request, a beat, shortly before the cache would expire. Change only what the user asks for.",
99 '`session` is this session alone. `settings` is saved and used by every session, open ones included: `settings.interval` is the default interval, `settings.defaultOn` whether new sessions start on (open sessions keep theirs). The other settings (the stop rules, skipSmall and skipSmallTokens, the prompt heart, the turn line, alerts) exist only there; when you change one, say it applies to every session. `null` puts a setting back to its default.',
100 'A request that names no scope ("turn it on", "beat every 20 minutes") is for this session. "Default", "new sessions", "every session", "always" or "from now on" mean `settings`. If you can\'t tell which the user means, ask.',
101 "Each beat counts toward the user's usage. The first time in a conversation a change starts beating or changes how often a beating session beats, say so once.",
102 'Setting `intervalMinutes` turns beating on too, unless `enabled: false` comes with it. A session with its own interval keeps it when the default changes: tell the user, and `intervalMinutes: null` makes it follow the default.',
103 'Nothing changes if any value is invalid, and the refusal names each one. Subagents can read `state` but not call this. Returns what changed, this session\'s state, and the changed settings.',
104 ].join('\n\n'),
105 inputSchema: object({
106 session: object({
107 enabled: { type: 'boolean', description: 'Beat in this session.' },
108 intervalMinutes: {
109 anyOf: [{ type: 'number', minimum: parseMinutes.min, maximum: parseMinutes.max }, { type: 'null' }],
110 description: `This session's interval in minutes, which turns beating on; null follows settings.interval, auto included. (${range(parseMinutes)})`,
111 },
112 }),
113 settings: object(Object.fromEntries(KEYS.map(k => [k, field(k)]))),
114 }),
115}
116
117/** A `set` call's input, as read: this session's switch and interval (null follows the default), and the settings to save. */
118export type SetChange = { enabled?: boolean; intervalMinutes?: number | null; patch: Partial<BeatSettings> }
119
120const isObject = (x: unknown): x is Record<string, unknown> => typeof x === 'object' && x !== null && !Array.isArray(x)
121
122/** Reads a `set` call's input whole, or says every value it refuses. */
123export function parseSet(input: { session?: unknown; settings?: unknown }): SetChange | { error: string } {
124 const change: SetChange = { patch: {} }
125 const bad: string[] = []
126 const said = (v: unknown) => JSON.stringify(v)
127 for (const part of ['session', 'settings'] as const) {
128 if (input[part] !== undefined && !isObject(input[part])) bad.push(`${part} is an object, not ${said(input[part])}`)
129 }
130 const session = isObject(input.session) ? input.session : {}
131 const settings = isObject(input.settings) ? input.settings : {}
132 for (const [k, v] of Object.entries(session)) {
133 if (k === 'enabled') {
134 if (typeof v === 'boolean') change.enabled = v
135 else bad.push(`session.enabled takes true or false, not ${said(v)}`)
136 } else if (k === 'intervalMinutes') {
137 const m = v === null ? null : typeof v === 'number' || typeof v === 'string' ? parseMinutes(String(v)) : undefined
138 if (m !== undefined) change.intervalMinutes = m
139 else bad.push(`session.intervalMinutes takes ${range(parseMinutes)} or null, not ${said(v)}`)
140 } else bad.push(`session has no ${k}`)
141 }
142 for (const [k, v] of Object.entries(settings)) {
143 if (!(k in ABOUT)) {
144 bad.push(`settings has no ${k}`)
145 continue
146 }
147 const key = k as keyof BeatSettings
148 const value = v === null ? DEFAULTS[key] : accept(key, v)
149 if (value === undefined) bad.push(`settings.${key} takes ${either([...takes(key), 'null'])}, not ${said(v)}`)
150 else change.patch = { ...change.patch, [key]: value }
151 }
152 // an interval of its own is asked for to beat at, as `/cachebeat <minutes>` takes it
153 if (typeof change.intervalMinutes === 'number') change.enabled ??= true
154 return bad.length ? { error: `nothing changed: ${bad.join('; ')}` } : change
155}
156
157/** A setting's name as the pane labels it, or as it reads out of its tab. */
158function label(key: keyof BeatSettings) {
159 if (HEX_KEYS.includes(key)) return key === 'heartHex' ? 'Prompt heart color' : 'Turn line color'
160 const { row } = rowOf(key)!
161 return row.name ?? row.label.trim()
162}
163
164/** A `set` call in one line, as the transcript shows it: `this session on, every 20m · Beat after idle 30m`. */
165export function summary(change: SetChange, s: BeatSettings) {
166 const here = [
167 change.enabled !== undefined && (change.enabled ? 'on' : 'off'),
168 change.intervalMinutes !== undefined && (change.intervalMinutes === null ? 'at the default interval' : `every ${change.intervalMinutes}m`),
169 ].filter(Boolean)
170 const saved = (Object.entries(change.patch) as [keyof BeatSettings, Value][]).map(([key, v]) =>
171 `${label(key)} ${HEX_KEYS.includes(key) ? v : shown(rowOf(key)!.row, v, s)}`,
172 )
173 return [here.length ? `this session ${here.join(', ')}` : '', saved.join(', ')].filter(Boolean).join(' · ') || 'no change'
174}
175types/index.d.ts 93 lines1export type Pulse = 'hidden' | 'waiting' | 'armed'
2
3/** What survives a reload of the module (a file save). */
4export type Saved = {
5 enabled: boolean
6 idle: number | null // this session's interval in ms; null follows the global one
7 lastReal: number | null
8 lastWarm: number | null
9 lastRead: number | null // tokens the last beat read from the cache
10 nextAt: number | null
11 beats: number // in this session
12 row: string | null // the latest turn's closing row, which carries the status line
13 small: string | null // why the context is not kept warm, while it is under the minimum
14 isCacheShort: boolean // a beat found this session's cache lasts five minutes, whatever its settings say
15 paused: string | null // why beats wait for the next turn though the cache was warm: a model switch, a compaction
16 warmModel: string | null // the model the last request ran on, whose cache a beat keeps warm
17}
18
19/** How long the main conversation's prompt cache lives. */
20export type Ttl = '5m' | '1h'
21
22/** How an event is told: a line in the transcript, a toast, both, or not at all. */
23export type Alert = 'none' | 'log' | 'toast' | 'both'
24
25export type Effect = 'steady' | 'flash' | 'flow' | 'mixed'
26export type Speed = 'slow' | 'normal' | 'fast' | number // a number: ms a frame
27export type Timing = 'linear' | 'lubdub'
28
29/** How one part draws and moves: the heart under the prompt, or the turn line. */
30export type Look = { color: string; customColor: string; effect: Effect; animate: boolean; speed: Speed; timing: Timing }
31
32/** The settings, global: kept in the plugin's store, shared by every session. */
33export type BeatSettings = {
34 defaultOn: boolean // new sessions start beating
35 interval: number | 'auto' // minutes of idle before a beat; auto fits the cache's lifetime
36 intervalScope: 'session' | 'global' // what `/cachebeat <minutes>` changes
37 stopAfterHours: number // since the last real turn
38 stopAtUsage: number // percent of any rate-limit window
39 skipSmall: boolean
40 skipSmallTokens: number
41 // the heart under the prompt
42 heartPlacement: 'tail' | 'line'
43 variant: string
44 showCount: boolean
45 heartColor: string // 'dim', a theme key, a preset, or 'custom'
46 heartHex: string // #rrggbb, when heartColor is custom
47 heartAnimate: boolean
48 heartEffect: Effect
49 heartSpeed: Speed
50 heartTiming: Timing
51 // the line under the latest turn
52 statusLine: 'below' | 'spaced' | 'off'
53 showCountdown: boolean
54 showTokens: boolean
55 statusHeart: string // what the heart starting the line plays: beat, off, or an animation's id
56 lineColor: string
57 lineHex: string
58 lineAnimate: boolean
59 lineEffect: Effect
60 lineSpeed: Speed
61 lineTiming: Timing
62 onBeat: Alert
63 onStop: Alert
64 onModelSwitch: 'wait' | 'warm' // after /model while idle: wait for the next turn, or have the next beat write the new model's cache
65}
66
67/** Where the settings pane is: a tab, and the row whose picker is open over it. */
68export type PanePage = { tab: string; picker: keyof BeatSettings | null }
69
70declare module 'claude-code' {
71 /** The tools this plugin registers, as the model calls them; `parseSet` checks every value. */
72 interface McpToolInputs {
73 mcp__cachebeat__state: Record<string, never>
74 mcp__cachebeat__set: {
75 session?: { enabled?: boolean; intervalMinutes?: number | null }
76 settings?: { [K in keyof BeatSettings]?: BeatSettings[K] | null } // null resets to the default
77 }
78 }
79 interface PluginState {
80 cachebeat: {
81 pulse: Pulse
82 saved: Saved | null
83 frame: number
84 line: string
85 lineFrame: number // the turn line's own animation clock
86 settings: BeatSettings
87 tick: number // the settings pane's preview clock
88 page: PanePage
89 focus: string // the key of the pane's focused element, or ''
90 }
91 }
92}
93