SLOPSHOPPER

cachebeat

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…

newpanebandspinnerrowsguard
★ 1v0.7.5MITupdated 2026-10-09404Mayank/cachebeat
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cachebeat
│ ┃ cachebeat-settings ✕ › fix the failing auth test and add an audit log call │ ┃ 1: Beating 2: Limits 3: Prompt heart 4: Turn │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ──────────── ⎿ Read 6 lines │ ┃ This session is this session alone; every ⏺ Update(src/auth.ts) │ ┃ other setting applies to every session. ⎿ Added 2 lines, removed 1 line │ ┃ This session off ⏺ Bash(bun test) │ ┃ New sessions start off ⎿ 3 pass, 1 fail │ ┃ Beat after idle auto · 50m now › │ ┃ After /model wait for your next tur ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ /cachebeat 30 changes this session │ ┃ ──────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ ──────────── │ ┃ [ Beat now ] [ Reset all ] › /cachebeat │ ┃ ⎿ cachebeat: off │ ┃ │ ┃ ↑↓ move · enter changes · 1-5 tabs · esc │ ┃ back to the tabs │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · cachebeat-settings
1: Beating 2: Limits 3: Prompt heart 4: Turn line 5: Alerts ──────────────────────────────────────────────────────── This session is this session alone; every other setting applies to every session. This session off New sessions start off Beat after idle auto · 50m now › After /model wait for your next turn /cachebeat 30 changes this session ──────────────────────────────────────────────────────── [ Beat now ] [ Reset all ] ↑↓ move · enter changes · 1-5 tabs · esc back to the tabs
README

cachebeat

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

cachebeat keeping an idle session's cache warm: the countdown runs down and beats land on their own, then the animation picker

CI Claude Code 2.1.287+ License: MIT

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.

Features

  • Background beats keep the cache warm while you're idle, timed to how long your cache lasts, and stay out of the conversation
  • A live heart under the prompt, with 19 animations to choose from
  • A turn line under your latest turn shows the beats so far and the time until the next one
  • A settings menu with live previews, driven by keyboard or mouse
  • Ask Claude to turn it on, change the interval, or change any setting, in your own words
  • Safety stops for long idle stretches, usage limits, errors, and chats too small to bother with
  • Per session or everywhere: turn it on for one session, or have every new session start with it

Requirements

  • Claude Code 2.1.287 or later. Check your version with claude --version and update with claude update.
  • Claude Code in the terminal, which cachebeat is built for. In the Claude desktop app beating works the same, the turn line sits above the prompt and the settings menu opens beside the chat, but the wider heart animations and the menu's keyboard controls are rougher there: you can just ask Claude to change settings for you. Nothing is drawn in claude -p.

Install

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

Quick start

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.

What it sends, and what it costs

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.

  • A beat counts toward your usage like any other request. Because it reads from the cache, it costs far less than rebuilding the cache would.
  • Beats happen only while the session is idle, at most once per interval (50 minutes by default).
  • cachebeat sends nothing anywhere else. It has no telemetry and makes no other network calls.
  • To tell how long your cache lasts, cachebeat reads your 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.
  • cachebeat also sees /model switches, without changing them, so beats can wait for your next message or warm the new model, as After /model says.

Privacy

  • cachebeat collects no personal data and has no telemetry. It sends nothing to its author or anyone else.
  • A beat is a request to Anthropic's API, made by Claude Code with your account, the same way your own messages are sent.
  • Your settings stay on your machine, in cachebeat's own file in Claude Code's config folder. What it reads to time beats (your cache settings, three environment variables, your usage limits) is read there and never sent anywhere.

Commands

CommandWhat it does
/cachebeatShows the status: on or off, the interval, beats so far, the next beat
/cachebeat onTurns it on for this session
/cachebeat offTurns it off for this session
/cachebeat <minutes>Turns it on with a different interval, e.g. /cachebeat 30 (1–55)
/cachebeat nowBeats right away
/cachebeat global on / offSets whether new sessions start on, and applies it here too
/cachebeat settingsOpens the settings menu

What you'll see

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

Settings

Open the menu with /cachebeat settings, or ask Claude to change a setting. Settings are saved once and apply to every session.

  • On the tab bar, ↑↓ switch tabs and Enter goes into one. 1–5 jump straight into a tab.
  • Inside a tab, ↑↓ move between settings. Enter flips a setting with two choices, or opens the list of one with more. A dim line at the bottom says what the focused setting does.
  • r puts the focused setting back to its default.
  • In a list, ↑↓ preview each choice and Enter picks it.
  • Esc goes back one step, and closes the menu from the tab bar.
  • The mouse works too: click to pick, scroll to scroll.

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.

SettingWhat it doesDefault
This sessionTurns beating on or off right hereoff
New sessions startWhether new sessions start with it onoff
Beat after idleHow long the session sits idle before a beat: auto fits your cache, or a number of minutesauto: 50 minutes on a one-hour cache, 4 on a five-minute one
After /modelWait for your next message, or have the next beat warm the new model's cachewait
/cachebeat 30 changesWhether /cachebeat 30 changes this session only, or the default for allthis session

Beat now sends a beat right away, and Reset all puts every setting back to its default.

SettingWhat it doesDefault
Stop after idleStops beating after this long without a message from you8 hours
Stop at usageStops once any of your usage limits reaches this percentage90%
Skip small chatsSkips beating chats under a minimum sizeoff, 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.

SettingWhat it doesDefault
PlacementAt the end of the hint line, drawn dim, or on its own line, in colorhint line
AnimationWhich of the 19 animations the heart playsClassic
Beat countShows the ×3 beside the hearton
ColorOn its own line: dim, a color from your theme, a preset, or any hex colorclaude, your theme's accent
AnimateTurns its animation on or offon
EffectOn its own line: steady; flash on each beat; flow, a shimmer sweeping across; or mixed, which switches between themsteady
SpeedSlow, normal, fast, or a custom frame timenormal
TimingLub-dub beats twice, then rests, and suits the hearts; linear plays evenly, and suits the line animationslub-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.

SettingWhat it doesDefault
ShowUnder the turn after a blank line, directly under it, or offafter a blank line
CountdownShows the time to the next beaton
Tokens keptShows how much the last beat kept warm, e.g. · 184k cachedon
Its heartWhat the heart at the start of the line plays: one heart beating, a still one, or any of the 19 animationsone heart, beating
ColorDim, a color from your theme, a preset, or any hex colorclaude, your theme's accent
Animate · Effect · Speed · TimingAs for the prompt heart, for the line on its ownon · 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.

SettingWhat it doesDefault
On a beatLog, toast, both, or nothing when a beat landsnothing
On stopLog, toast, both, or nothing when beating stops by itselflog

When it stops on its own

cachebeat turns itself off for the session when:

  • you haven't sent a message for longer than Stop after idle
  • a usage limit reaches Stop at usage
  • the API rate-limits it, or returns an error that doesn't clear up (a passing hiccup is retried a minute later)
  • the cache is already gone, so there's nothing left to keep warm

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.

Good to know

  • Sending a message restarts the countdown.
  • After /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.
  • Settings you change in one session reach your other open sessions at their next turn.

Contributing

Contributions are welcome. Read the contributing guide to get started.

License

MIT

Disclaimer

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.

Source 6 files
hooks/register.tsx 894 lines
1import { 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}
894
hooks/animations.ts 141 lines
1// 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)
141
hooks/pane.tsx 269 lines
1import 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}
269
hooks/settings.ts 375 lines
1import 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
375
hooks/tools.ts 175 lines
1// 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}
175
types/index.d.ts 93 lines
1export 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