SLOPSHOPPER

Cache Maxxer

Shows the prompt cache's countdown and hit rate above the input box, explains every cache break and cache expiry, and can keep the cache warm to cut Claude…

newbandcommandtoastmodelprocess
★ 1v1.0.0MITupdated 2026-10-09vayaan-labs/cache-maxxer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-maxxer
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /cache-maxxer ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ This session No requests yet │ │ │ │ ○ No cache yet · it starts with your next message [ Keep warm: off ] [ Less ▾ ] │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ This session No requests yet │ │ │ │ ○ No cache yet · it starts with your next message [ Keep warm: off ] [ Less ▾ ] │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ⟨Claude Code's own drawing⟩
README

Cache Maxxer icon: a green countdown ring around a dot

Cache Maxxer

A Claude Code mod that keeps your cache warm, so you pay 96% less to re-read your conversation.

Install · Guide · Privacy · Changelog · Report a bug

CI Latest release Claude Code 2.1.289 or later MIT licence GitHub stars

Cache Maxxer in the Claude desktop app with keep warm on: the countdown runs down into amber with "Expiring soon", the cache is warmed automatically with a "Cache warmed" notice showing the tokens read and the cost saved, and a fresh hour starts.

Keep warm in the Claude desktop app: the cache is warmed just before it expires, and a fresh hour starts.

Claude Code keeps a saved copy of your conversation, the prompt cache, so each new message doesn't pay full price to re-read everything before it. That copy quietly expires after five minutes or an hour without a message, or when the agent is idle, and your next one pays to write the whole conversation again: slower, and extremely expensive.

Cache Maxxer adds a thin band right above your input box with the time left on that cache and your hit rate, which is the share of each request read from the cache instead of paid for again. When the cache does get rebuilt, it tells you why. And if you want, it keeps the cache alive while you step away (saving you money when you return!).

Get started

In the Claude desktop app

  1. Add the Vayaan Labs catalogue. Open the Directory, choose Plugins, press + and choose Add marketplace, then Add from a repository, and enter vayaan-labs/plugins.

The Add marketplace dialog in the Claude desktop app, with two choices: Browse Anthropic sources, and Add from a repository, which syncs a plugin marketplace from a GitHub repository or Git URL.

  1. Install Cache Maxxer. Find it in the Vayaan Labs catalogue under Plugins and install it.
  1. Send a message. The band shows up above the input box once your conversation has a cache, which is after your first reply. If it doesn't, run /reload-plugins.

The Cache Maxxer band in the Claude desktop app: a large 58:08 countdown over a draining bar, a one-hour cache, hit rates of 99.91% and 99.58% with rings, and the Keep warm, Warm now, More and Hide buttons.

In a terminal

  1. Add the Vayaan Labs catalogue.
   claude plugin marketplace add vayaan-labs/plugins
  1. Install Cache Maxxer.
   claude plugin install cache-maxxer@vayaan-labs
  1. Send a message. The band shows up once your conversation has a cache, which is after your first reply. If Claude Code was already open, run /reload-plugins first.

Cache Maxxer in a terminal: a 59:47 countdown with a draining bar, a one-hour cache, hit rates of 95.33% and 68.19%, and the Keep warm, Warm now and More buttons.

Or paste this prompt to your agent

Install the Cache Maxxer plugin for Claude Code
(https://github.com/vayaan-labs/cache-maxxer).
Run these two commands:
claude plugin marketplace add vayaan-labs/plugins
claude plugin install cache-maxxer@vayaan-labs
Check that `claude plugin list` shows it enabled,
then tell me to run /reload-plugins. If the claude
command isn't found, tell me to add it from the
Directory instead, as the README shows.

Press More for the session's numbers, the last 10 requests with any rebuild marked, and the latest rebuild with its cost and cause. Press Keep warm to have it ping just before the cache expires. Everything else, from the settings to the /cache-maxxer command, is in the guide.

What you get

  • A countdown you can see. Time left on the cache, a bar that drains, and a colour that turns amber, then red, as expiry gets close.
  • Your real cache hit rate. The last request's and the whole session's, to two decimals and never rounded up, so 99.86% doesn't show as 100%.
  • Every rebuild explained. One notice saying how much was re-written and why: you were away too long, you switched model, compacted or cleared, or Claude Code's setup changed (its system prompt, tools or MCP servers).
  • Keep warm. One tiny request just before expiry resets the timer for a few cents. It stops after three hours without a message from you, or whatever limit you set.
  • What the cache is worth. Tokens read from the cache and written to it, what the writes cost and what the reads saved, priced from Anthropic's own price table, which it checks once a day so a new model gets prices as soon as Anthropic lists it.
  • Terminal and desktop app. A one-line band in the terminal. In the Claude desktop app, a drawn band with a large countdown, hit-rate rings and native menus, which matches your light or dark appearance and fits a narrow window.

The desktop band opened with keep warm on: 547 requests this session at a 99.58% hit rate, about $10 spent writing the cache and about $1,136 saved by reading it, the last 10 requests with no rebuild, the last rebuild after compacting, and the keep-warm settings with what its three pings saved.

Opened with More and keep warm on: what the cache cost this session, what it saved, and what the pings saved.

Supported

  • Claude Code 2.1.289 or later, in the terminal and in the Claude desktop app, on macOS.
  • Every current Claude model. Prices come from Anthropic's public price table; a model not in it gets token counts and no dollar figures.
  • One-hour and five-minute caches, read from your session or set by you.

Privacy

Cache Maxxer has no server, account or analytics. It makes one network request of its own: it reads Anthropic's public pricing page (platform.claude.com/docs/en/about-claude/pricing.md) at most once a day, sending nothing about you or your session. Set live_prices to off (settings) and it never does.

Two of its buttons send a request to Claude, the same place your messages already go, and each counts toward your usage like any other request. Keep warm and Warm now ask for a one-word reply over your conversation. Compact, which takes Warm now's place once the cache has expired, asks Claude Code to compact the conversation.

On your machine, while the cache length is set to auto, it reads the end of your session's transcript file to learn how long the cache lives, looking only at the cache-write token counts in it. It saves four things in the plugin's own data: the keep-warm toggle, whether the detail is open, whether the desktop band is tucked away, and the last price table with the time it last asked for the page. So that sessions starting together ask for the page only once, it also keeps one empty folder for the current day in cache-maxxer under your Claude config folder. Everything else lives in memory for the session.

Contributing

Found a bug? Open an issue. Want to send a change? CONTRIBUTING.md has the two checks to run and how pull requests are merged. To report a security problem privately, see SECURITY.md.

Licence

MIT. Built by @YaanFPV.

Source 11 files
hooks/register.tsx 560 lines
1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, PluginOptions, Register, Timer, TurnUsage } from 'claude-code'
3
4import type { CacheSettings } from '../types'
5
6import { terminalBand, type Actions } from './band'
7import { desktopBand } from './desktop'
8import { fmtApprox, fmtTokens } from './format'
9import { applyRequest, DEFAULT_SETTINGS, EMPTY_CACHE, EMPTY_PINGS, EMPTY_TOTALS, type Pending } from './model'
10import { isPriceTable, parsePricing, PRICING_URL, REFRESH_MS } from './live-prices'
11import { keptWarmUsd, requestCostUsd, setLivePrices, type PriceEntry } from './pricing'
12import { idleCapMs, leadLabel, leadMs, parseTtl, ttlInfo } from './ttl'
13import { makeView, summaryText, type View } from './view'
14
15// Cache Maxxer shows the conversation's prompt cache: how long it has left, how well it is hitting,
16// why it broke, and (when asked) keeps it warm with a short request before it lapses.
17
18const COMMAND = 'cache-maxxer'
19
20// Asks for one word so the reply costs next to nothing; the request is there to read the cache.
21const PING_PROMPT = 'Reply with exactly one word: ok. Do not use any tools.'
22
23const TAIL_BYTES = 262_144
24const RECHECK_MS = 10 * 60_000
25const RETRY_MS = 15_000
26
27// What the band draws. A value here survives a reload of the module; /clear, /resume
28// and /branch put every one back to its default (a /clear keeps only what the cache last held).
29const cacheAtom = atom({ plugin: 'cache-maxxer', key: 'cache' } as const, EMPTY_CACHE)
30const historyAtom = atom({ plugin: 'cache-maxxer', key: 'history' } as const, [])
31const totalsAtom = atom({ plugin: 'cache-maxxer', key: 'totals' } as const, EMPTY_TOTALS)
32const breaksAtom = atom({ plugin: 'cache-maxxer', key: 'breaks' } as const, [])
33const settingsAtom = atom({ plugin: 'cache-maxxer', key: 'settings' } as const, DEFAULT_SETTINGS)
34const pingsAtom = atom({ plugin: 'cache-maxxer', key: 'pings' } as const, EMPTY_PINGS)
35const activityAtom = atom({ plugin: 'cache-maxxer', key: 'activity' } as const, 0)
36const pausedAtom = atom({ plugin: 'cache-maxxer', key: 'paused' } as const, '')
37const pickerAtom = atom({ plugin: 'cache-maxxer', key: 'picker' } as const, '')
38const expandedAtom = atom({ plugin: 'cache-maxxer', key: 'expanded' } as const, false)
39const tickAtom = atom({ plugin: 'cache-maxxer', key: 'tick' } as const, 0)
40const pingingAtom = atom({ plugin: 'cache-maxxer', key: 'pinging' } as const, false)
41const noticeAtom = atom({ plugin: 'cache-maxxer', key: 'notice' } as const, { text: '', until: 0 })
42const hiddenAtom = atom({ plugin: 'cache-maxxer', key: 'hidden' } as const, false)
43
44// How long the Desktop band shows a notice.
45const NOTICE_MS = 8000
46
47type Cfg = { ttl: string; lead: string; idleCap: string; livePrices: boolean }
48
49const pick = (value: unknown, allowed: readonly string[], fallback: string) =>
50  typeof value === 'string' && allowed.includes(value) ? value : fallback
51
52const readCfg = (options: PluginOptions): Cfg => ({
53  ttl: pick(options.ttl, ['auto', '1h', '5m'], 'auto'),
54  lead: pick(options.lead, ['auto', '1m', '2m', '4m', '8m'], 'auto'),
55  idleCap: pick(options.idle_cap, ['1h', '3h', '8h', 'none'], '3h'),
56  livePrices: pick(options.live_prices, ['on', 'off'], 'on') === 'on',
57})
58
59// What the hooks share between events. A reload of the module starts it over.
60const rt = {
61  timer: null as Timer | null,
62  isTurnRunning: false,
63  isPinging: false,
64  // What happened to the conversation that can explain its next big cache write
65  pending: null as Pending,
66  // The cache entry (by its start time) that already got its warning
67  warnedFor: 0,
68  // The cache entry whose keep-warm ping failed, so it is not tried again every second
69  skippedFor: 0,
70  lookup: { at: 0, isRunning: false, id: '', path: '' },
71}
72
73// ---- Reading what to draw ----
74
75async function viewOf($: EngineInterface, cfg: Cfg): Promise<View> {
76  // Reading the tick subscribes the drawing to it, so the countdown redraws as it moves.
77  await read($, tickAtom)
78  const now = await $.clock.now()
79  const notice = await read($, noticeAtom)
80  return makeView({
81    now,
82    ttlSetting: cfg.ttl,
83    cache: await read($, cacheAtom),
84    history: await read($, historyAtom),
85    totals: await read($, totalsAtom),
86    breaks: await read($, breaksAtom),
87    settings: await read($, settingsAtom),
88    pings: await read($, pingsAtom),
89    paused: await read($, pausedAtom),
90    picker: await read($, pickerAtom),
91    expanded: await read($, expandedAtom),
92    isPinging: await read($, pingingAtom),
93    notice: notice.until > now ? notice.text : '',
94    hidden: await read($, hiddenAtom),
95  })
96}
97
98// ---- Telling the person ----
99
100// What Cache Maxxer has to say goes where the person is looking. The Desktop app stacks a plugin's
101// notices at its window's corner, away from this session's pane in a split, so there the band says
102// it for a few seconds instead; every other surface, the terminal included, gets the notice.
103async function notify($: EngineInterface, text: string) {
104  const surfaces = await $.session.surfaces()
105  if (surfaces.length === 0 || surfaces.some(s => s !== 'desktop')) $.ui.toast(text)
106  if (!surfaces.includes('desktop')) return
107  const until = (await $.clock.now()) + NOTICE_MS
108  await update($, noticeAtom, () => ({ text, until }))
109  // The countdown's ticks redraw the band; with no cache ticking, one more redraw takes the line away.
110  $.clock.after(NOTICE_MS, () => void update($, tickAtom, t => t + 1))
111}
112
113// ---- The cache's life ----
114
115// Folds a main-conversation request into the state, and says so when it rebuilt the cache.
116async function noteRequest($: EngineInterface, cfg: Cfg, startedAt: number, usage: TurnUsage | null) {
117  if (!usage) return
118  const cache = await read($, cacheAtom)
119  const pending = rt.pending
120  rt.pending = null
121  const { next, brk } = applyRequest(
122    {
123      cache,
124      history: await read($, historyAtom),
125      totals: await read($, totalsAtom),
126      breaks: await read($, breaksAtom),
127    },
128    { startedAt, usage, ttlMs: ttlInfo(cfg.ttl, cache.ttlMs).ms },
129    pending,
130  )
131  // The entry's length may have been learned meanwhile, so it is kept as it stands now.
132  await update($, cacheAtom, current => ({ ...next.cache, ttlMs: current.ttlMs }))
133  await update($, historyAtom, () => next.history)
134  await update($, totalsAtom, () => next.totals)
135  await update($, breaksAtom, () => next.breaks)
136  // A request that wrote may have put its split in the transcript: look again at the next tick.
137  if (usage.cache_creation_input_tokens > 0) rt.lookup.at = 0
138  startTicker($, cfg)
139  if (brk) await notify($, `Cache rebuilt · ${fmtTokens(brk.written)} tokens re-written · ${brk.cause}`)
140}
141
142// Reads how long an entry lives from the newest cache write in the session transcript. The transcript
143// may not hold the write yet when a turn ends, so the ticker asks again (at most every RETRY_MS)
144// until it has been seen.
145async function learnTtl($: EngineInterface, cfg: Cfg) {
146  if (cfg.ttl !== 'auto') return
147  const now = await $.clock.now()
148  const known = (await read($, cacheAtom)).ttlMs !== null
149  if (rt.lookup.isRunning || now - rt.lookup.at < (known ? RECHECK_MS : RETRY_MS)) return
150  rt.lookup.isRunning = true
151  rt.lookup.at = now
152  try {
153    const id = await $.session.id()
154    if (rt.lookup.id !== id || rt.lookup.path === '') {
155      const root = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`
156      const found = await $.process.run(['/usr/bin/find', `${root}/projects`, '-maxdepth', '2', '-name', `${id}.jsonl`], { timeoutMs: 15_000 })
157      rt.lookup.path = found.stdout.split('\n').find(line => line.trim() !== '') ?? ''
158      rt.lookup.id = id
159    }
160    if (rt.lookup.path === '') return
161    // Only the end of the file is read: a long session's transcript is large.
162    const tail = await $.process.run(['/usr/bin/tail', '-c', String(TAIL_BYTES), rt.lookup.path], { timeoutMs: 15_000 })
163    const ms = tail.exitCode === 0 ? parseTtl(tail.stdout) : null
164    if (ms !== null) await update($, cacheAtom, c => (c.ttlMs === ms ? c : { ...c, ttlMs: ms }))
165  } catch {
166    // The length stays assumed until a later look succeeds
167  } finally {
168    rt.lookup.isRunning = false
169  }
170}
171
172// Seconds while an entry is warm; once it has expired (and the band has said so) the ticking stops
173// until the next request starts another.
174function startTicker($: EngineInterface, cfg: Cfg) {
175  if (rt.timer) return
176  rt.timer = $.clock.every(1000, () => void tick($, cfg))
177}
178
179function stopTicker() {
180  rt.timer?.cancel()
181  rt.timer = null
182}
183
184async function tick($: EngineInterface, cfg: Cfg) {
185  const cache = await read($, cacheAtom)
186  if (cache.startedAt === 0) return stopTicker()
187  if (cfg.ttl === 'auto' && cache.ttlMs === null) void learnTtl($, cfg)
188  const now = await $.clock.now()
189  const ttl = ttlInfo(cfg.ttl, cache.ttlMs).ms
190  const left = cache.startedAt + ttl - now
191  const second = now - (now % 1000)
192  if ((await read($, tickAtom)) !== second) await update($, tickAtom, () => second)
193  if (left <= 0) return stopTicker()
194
195  const settings = await read($, settingsAtom)
196  const lead = leadMs(settings.lead, ttl)
197  if (left > lead) return
198  if (!settings.keepWarm) {
199    if (rt.warnedFor !== cache.startedAt) {
200      rt.warnedFor = cache.startedAt
201      await notify($, `Cache expires in ${leadLabel(lead)} · Warm now to keep it`)
202    }
203    return
204  }
205  if (rt.isTurnRunning || rt.isPinging || rt.skippedFor === cache.startedAt) return
206  const cap = idleCapMs(settings.idleCap)
207  if (cap !== null && now - (await read($, activityAtom)) > cap) {
208    if ((await read($, pausedAtom)) !== settings.idleCap) await update($, pausedAtom, () => settings.idleCap)
209    return
210  }
211  const result = await ping($, cfg)
212  if (!result.ok) {
213    rt.skippedFor = cache.startedAt
214    await notify($, `Keep warm skipped: ${result.reason}`)
215  } else {
216    await notify($, pingText(result))
217  }
218}
219
220// Why a ping did not go out while Claude is replying: a turn reads the cache itself.
221const BUSY = 'Claude is working'
222
223type PingResult =
224  | { ok: true; read: number; written: number; costUsd: number | null; savedUsd: number | null; hasLapsed: boolean }
225  | { ok: false; reason: string }
226
227// One short request over the conversation. Its cache read restarts the entry's life from the
228// moment the request started. When it read less than half of what the cache last held, the entry
229// had already gone and the request rebuilt it: it is warm now, but that was a rebuild, not a ping
230// that kept it warm.
231async function ping($: EngineInterface, cfg: Cfg): Promise<PingResult> {
232  if (rt.isPinging) return { ok: false, reason: 'a ping is already running' }
233  if (rt.isTurnRunning) return { ok: false, reason: BUSY }
234  const cache = await read($, cacheAtom)
235  const startedAt = await $.clock.now()
236  const ttl = ttlInfo(cfg.ttl, cache.ttlMs).ms
237  if (cache.startedAt === 0 || cache.startedAt + ttl <= startedAt) return { ok: false, reason: 'the cache has already expired' }
238
239  rt.isPinging = true
240  await update($, pingingAtom, () => true)
241  try {
242    const r = await $.model.fork({ prompt: PING_PROMPT })
243    if (!('usage' in r)) return { ok: false, reason: 'nothing has been said in this conversation yet' }
244    const usage = r.usage
245    if (!r.isAnswered && r.reason === 'api-error') {
246      return { ok: false, reason: `the API answered ${r.status ?? 'with an error'} (${r.error.replace(/_/g, ' ')})` }
247    }
248    if (!r.isAnswered && r.reason === 'aborted') return { ok: false, reason: 'the request was cut short' }
249    if (usage.cache_read_input_tokens + usage.cache_creation_input_tokens === 0) {
250      return { ok: false, reason: 'the request did not touch the cache' }
251    }
252    const model = cache.model || (await $.session.model())
253    const costUsd = requestCostUsd(
254      model,
255      { read: usage.cache_read_input_tokens, written: usage.cache_creation_input_tokens, uncached: usage.input_tokens, output: usage.output_tokens },
256      ttl,
257    )
258    const hasLapsed = cache.cached > 0 && usage.cache_read_input_tokens < cache.cached / 2
259    await update($, cacheAtom, c => (startedAt > c.startedAt ? { ...c, startedAt } : c))
260    await update($, pingsAtom, p => ({
261      count: hasLapsed ? p.count : p.count + 1,
262      read: hasLapsed ? p.read : p.read + usage.cache_read_input_tokens,
263      rebuilds: hasLapsed ? p.rebuilds + 1 : p.rebuilds,
264      costUsd: p.costUsd + (costUsd ?? 0),
265      isPriced: p.isPriced || costUsd !== null,
266    }))
267    startTicker($, cfg)
268    const savedUsd = keptWarmUsd(model, usage.cache_read_input_tokens, ttl)
269    return { ok: true, read: usage.cache_read_input_tokens, written: usage.cache_creation_input_tokens, costUsd, savedUsd, hasLapsed }
270  } catch (error) {
271    return { ok: false, reason: error instanceof Error ? error.message : String(error) }
272  } finally {
273    rt.isPinging = false
274    await update($, pingingAtom, () => false)
275  }
276}
277
278// What the person is told about a ping, in plain words. A ping that kept the cache warm says what it
279// read and what that saved, the same as the keep-warm row: those tokens at the write price less the
280// read price. A rebuild is named as one, with what it cost.
281const pingText = (r: PingResult) => {
282  // A turn reads the cache on its own, so a press mid-turn has nothing to do, and says so lightly.
283  if (!r.ok && r.reason === BUSY) return 'Claude is working bro. No point warming cache 😎'
284  if (!r.ok) return `Could not warm the cache: ${r.reason}`
285  if (r.hasLapsed) {
286    const cost = r.costUsd === null ? '' : ` · ${fmtApprox(r.costUsd)}`
287    return `The cache had already expired, so the background request rebuilt it · ${fmtTokens(r.written)} tokens written, timer restarted${cost}`
288  }
289  return `Cache warmed · ${fmtTokens(r.read)} tokens read${r.savedUsd === null ? '' : ` · cost saved ${fmtApprox(r.savedUsd)}`}`
290}
291
292// A press while a ping is on its way does nothing: the button already says Warming….
293async function warmNow($: EngineInterface, cfg: Cfg) {
294  if (rt.isPinging) return
295  await notify($, pingText(await ping($, cfg)))
296}
297
298// The one place Keep warm changes, whichever button or command asks. A toggle flips the setting
299// where it stands now, so it never works from a stale reading; the pickers close with it.
300async function setKeepWarm($: EngineInterface, cfg: Cfg, change: boolean | 'toggle') {
301  const next = await update($, settingsAtom, s => ({ ...s, keepWarm: change === 'toggle' ? !s.keepWarm : change }))
302  await $.store.set('keepWarm', next.keepWarm)
303  await update($, pausedAtom, () => '')
304  await update($, pickerAtom, () => '')
305  rt.skippedFor = 0
306  if (next.keepWarm) {
307    const now = await $.clock.now()
308    await update($, activityAtom, () => now)
309    startTicker($, cfg)
310  }
311  return next.keepWarm
312}
313
314// The price table Anthropic publishes, read at most once a day and kept in the plugin's store, so a
315// new model or a changed price is known without a new release. The last good table is used meanwhile,
316// and a page that cannot be read or does not parse leaves it as it was. Never blocks the session.
317// The day counts from the last attempt, not the last success: the time is saved before the page is
318// asked for, so a page that keeps failing is still asked once a day, however many sessions start.
319// Sessions that start together all read the store before any of them writes it, so the store alone
320// cannot stop each of them asking: the one that may ask today is the one whose `mkdir` of today's
321// claim folder succeeds, which the file system grants to exactly one process.
322async function refreshPrices($: EngineInterface) {
323  try {
324    const stored = (await $.store.get('prices')) as { at?: unknown; entries?: unknown; triedAt?: unknown } | undefined
325    const saved = stored && typeof stored.at === 'number' && isPriceTable(stored.entries) ? stored : undefined
326    if (saved) setLivePrices(saved.entries as PriceEntry[])
327    const triedAt = typeof stored?.triedAt === 'number' ? stored.triedAt : saved?.at
328    const now = await $.clock.now()
329    if (typeof triedAt === 'number' && now - triedAt < REFRESH_MS) return
330    if (!(await claimToday($, now))) return
331    await $.store.set('prices', saved ? { at: saved.at, entries: saved.entries, triedAt: now } : { triedAt: now })
332    const res = await $.http.fetch(PRICING_URL)
333    const entries = res.ok ? parsePricing(res.text) : null
334    if (!entries) return
335    setLivePrices(entries)
336    await $.store.set('prices', { at: now, entries, triedAt: now })
337  } catch {
338    // The built-in table, or the last good one, stays in use
339  }
340}
341
342// Claims today's read of the price page for this session: true only for the one process whose `mkdir`
343// of today's empty claim folder succeeds. A day is a UTC day. Folders of earlier days are removed. Any
344// failure (the folder cannot be made, `mkdir` cannot run) means no claim, so nobody asks rather than two.
345async function claimToday($: EngineInterface, now: number): Promise<boolean> {
346  const root = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`
347  if (!root.startsWith('/')) return false
348  const dir = `${root}/cache-maxxer`
349  const today = Math.floor(now / REFRESH_MS)
350  await $.process.run(['/bin/mkdir', '-p', dir], { timeoutMs: 5_000 })
351  const claim = await $.process.run(['/bin/mkdir', `${dir}/price-read-${today}`], { timeoutMs: 5_000 })
352  if (claim.exitCode !== 0) return false
353  for (const entry of await $.fs.list(dir).catch(() => [])) {
354    const day = /^price-read-([0-9]+)$/.exec(entry.name)
355    if (day && Number(day[1]) < today) await $.process.run(['/bin/rmdir', `${dir}/${entry.name}`], { timeoutMs: 5_000 }).catch(() => undefined)
356  }
357  return true
358}
359
360async function compact($: EngineInterface) {
361  try {
362    const r = await $.session.compact()
363    if (r.skip) await notify($, `Compact skipped: ${r.skip}`)
364  } catch (error) {
365    await notify($, error instanceof Error ? error.message : String(error))
366  }
367}
368
369// Opens the band's detail or closes it. Whether it is open persists for new sessions, as keep warm does.
370async function setExpanded($: EngineInterface, change: boolean | 'toggle') {
371  const next = await update($, expandedAtom, open => (change === 'toggle' ? !open : change))
372  await $.store.set('expanded', next)
373  return next
374}
375
376// Tucks the Desktop band away to its chip or brings it back; remembered for new sessions.
377async function setHidden($: EngineInterface, hidden: boolean) {
378  await update($, hiddenAtom, () => hidden)
379  await $.store.set('hidden', hidden)
380}
381
382// The keep-warm toggle, whether the detail is open and whether the band is tucked away persist for
383// new sessions; the rest start from the plugin's settings.
384async function seedSettings($: EngineInterface, cfg: Cfg) {
385  const saved = await $.store.get('keepWarm')
386  await update($, settingsAtom, () => ({ ...DEFAULT_SETTINGS, keepWarm: saved === true, lead: cfg.lead, idleCap: cfg.idleCap }))
387  const expanded = await $.store.get('expanded')
388  await update($, expandedAtom, () => expanded === true)
389  const hidden = await $.store.get('hidden')
390  await update($, hiddenAtom, () => hidden === true)
391  const now = await $.clock.now()
392  await update($, activityAtom, () => now)
393}
394
395// A new conversation has no cache of its own yet. The keep-warm settings stay.
396async function resetConversation($: EngineInterface) {
397  // What was cached stays, so the first request after /clear can still be told apart from a fresh session's.
398  await update($, cacheAtom, c => ({ ...EMPTY_CACHE, cached: c.cached }))
399  await update($, historyAtom, () => [])
400  await update($, totalsAtom, () => EMPTY_TOTALS)
401  await update($, breaksAtom, () => [])
402  await update($, pingsAtom, () => EMPTY_PINGS)
403  await update($, pausedAtom, () => '')
404  await update($, pickerAtom, () => '')
405  await update($, tickAtom, () => 0)
406}
407
408// ---- What the controls do ----
409
410function bandActions($: EngineInterface, cfg: Cfg): Actions {
411  // Choosing an option sets it and closes the list.
412  const choose = async (set: (s: CacheSettings) => CacheSettings) => {
413    await update($, settingsAtom, set)
414    await update($, pickerAtom, () => '')
415  }
416  return {
417    toggleKeepWarm: () => void setKeepWarm($, cfg, 'toggle'),
418    warmNow: () => void warmNow($, cfg),
419    compact: () => void compact($),
420    toggleExpanded: () => void setExpanded($, 'toggle'),
421    hide: () => void setHidden($, true),
422    show: () => void setHidden($, false),
423    togglePicker: which => void update($, pickerAtom, open => (open === which ? '' : which)),
424    setLead: value => void choose(s => ({ ...s, lead: value })),
425    setIdleCap: value => void choose(s => ({ ...s, idleCap: value })),
426  }
427}
428
429async function runCommand($: EngineInterface, cfg: Cfg, args: string): Promise<{ text: string } | Record<string, never>> {
430  const [word = '', value = '', ...extra] = args.trim().split(/\s+/)
431  const usage = { text: `Usage: /${COMMAND} (the detail), /${COMMAND} more|less, /${COMMAND} hide|show, /${COMMAND} warm, /${COMMAND} keep on|off` }
432  // A word the command does not take after an action is a mistake to say, never something to act on.
433  if (extra.length > 0 || (value !== '' && word !== 'keep')) return usage
434  if (word === '') {
435    // With nothing to draw on (a -p run) the answer is text.
436    if ((await $.session.surfaces()).length === 0) return { text: summaryText(await viewOf($, cfg)) }
437    await setHidden($, false)
438    await setExpanded($, true)
439    return {}
440  }
441  if (word === 'hide' || word === 'show') {
442    await setHidden($, word === 'hide')
443    return {}
444  }
445  if (word === 'less' || word === 'more') {
446    if (word === 'more') await setHidden($, false)
447    await setExpanded($, word === 'more')
448    return {}
449  }
450  if (word === 'warm') return { text: pingText(await ping($, cfg)) }
451  if (word === 'keep' && (value === 'on' || value === 'off')) {
452    await setKeepWarm($, cfg, value === 'on')
453    return { text: `Keep warm is ${value}.` }
454  }
455  return usage
456}
457
458export const register: Register = (on, options) => {
459  const cfg = readCfg(options)
460
461  on('session.start', async ($, e, next) => {
462    const started = await next(e)
463    await seedSettings($, cfg)
464    if (cfg.livePrices) void refreshPrices($)
465    // After a reload the entry may still be running, and a resumed session has writes to read the length from.
466    if ((await read($, cacheAtom)).startedAt > 0) startTicker($, cfg)
467    void learnTtl($, cfg)
468    try {
469      await $.command.register({
470        name: COMMAND,
471        description: 'Show the prompt cache, or keep it warm',
472        argumentHint: '[more | less | hide | show | warm | keep on|off]',
473        immediate: true,
474      })
475    } catch {
476      // The command stays unavailable if another mod took the name
477    }
478    return started
479  })
480
481  // /clear, /resume and /branch put the state back to its defaults, so what the person chose is read again.
482  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
483    await seedSettings($, cfg)
484    if (e.source !== 'clear' && e.seconds_since_last_response !== undefined) {
485      const idleMs = e.seconds_since_last_response * 1000
486      const startedAt = (await $.clock.now()) - idleMs
487      await update($, cacheAtom, c => ({ ...c, startedAt, model: e.model ?? '', ctx: e.context_tokens ?? 0, cached: e.context_tokens ?? 0 }))
488      rt.pending = e.prompt_cache_likely_expired ? { kind: 'idle', idleMs } : null
489      startTicker($, cfg)
490      void learnTtl($, cfg)
491    }
492    return next(e)
493  })
494
495  on('session.end', async ($, e, next) => {
496    rt.pending = e.reason === 'clear' ? { kind: 'cleared' } : null
497    rt.isTurnRunning = false
498    if (e.reason === 'clear') await resetConversation($)
499    return next(e)
500  })
501
502  on('session.compact', async ($, e, next) => {
503    const result = await next(e)
504    if (!e.agentId && e.trigger !== 'precompute' && !result.skip) rt.pending = { kind: 'compacted' }
505    return result
506  })
507
508  on('classic.PostModelSwitch', async ($, e, next) => {
509    const ms = e.cache_ttl === '5m' ? 300_000 : 3_600_000
510    if (cfg.ttl === 'auto') await update($, cacheAtom, c => (c.ttlMs === ms ? c : { ...c, ttlMs: ms }))
511    return next(e)
512  })
513
514  on('turn.start', async ($, e, next) => {
515    rt.isTurnRunning = true
516    const now = await $.clock.now()
517    await update($, activityAtom, () => now)
518    await update($, pausedAtom, () => '')
519    return next(e)
520  })
521
522  on('turn.complete', async ($, e, next) => {
523    const done = await next(e)
524    if (!e.agentId) {
525      rt.isTurnRunning = false
526      void learnTtl($, cfg)
527    }
528    return done
529  })
530
531  // Each request of the main conversation: when it started and what it read and wrote. The stream
532  // passes through untouched.
533  on('turn.step', async function* ($, e, next) {
534    const startedAt = e.agentId ? 0 : await $.clock.now()
535    const result = yield* next(e)
536    if (!e.agentId) {
537      try {
538        await noteRequest($, cfg, startedAt, result.usage)
539      } catch {
540        // Keeping the numbers must never break the turn
541      }
542    }
543    return result
544  })
545
546  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
547    const v = await viewOf($, cfg)
548    // Before the first request the band draws only once asked for, to say there is no cache yet.
549    if (e.props.hasSurvey || e.props.view.agentId || (v.cache.startedAt === 0 && !v.expanded)) return next(e)
550    // What the mods after this one draw here stays, under the band.
551    const rest = await next(e)
552    const actions = bandActions($, cfg)
553    const el = $.ui.resolve(e)
554    if (e.surface === 'desktop') return desktopBand(el as ElementTable<'desktop'>, v, actions, rest)
555    return terminalBand(el as ElementTable<'terminal'>, v, e.props.bodyColumns, actions, rest)
556  })
557
558  on('command.run', { command: COMMAND }, async ($, e) => runCommand($, cfg, e.args))
559}
560
hooks/band.tsx 532 lines
1import type { ElementTable } from 'claude-code'
2
3import type { Req } from '../types'
4import { fmtApprox, fmtClock, fmtLocalTime, fmtPct, fmtTokens } from './format'
5import { flowRows, wrapWords } from './layout'
6import { hitRate, lastRequest, pingsItems, rewriteCost, sessionHitRate, stateTone, stateWord, ttlLabel, type Tone, type View } from './view'
7
8// Everything Cache Maxxer shows lives in the band above the prompt: one line by default, at the
9// bottom by the prompt, with the session's detail above it once opened and the keep-warm choices
10// above it whenever keep warm is on. This file draws it in the terminal; desktop.tsx in the app.
11
12// The terminal draws in the person's own theme: each tone is a theme key, and plain text keeps the
13// terminal's own color.
14const THEME: Record<Tone, string | null> = {
15  fg: null,
16  muted: 'inactive',
17  warm: 'success',
18  warn: 'warning',
19  danger: 'error',
20  accent: 'claude',
21}
22
23const colorProps = (tone: Tone) => {
24  const key = THEME[tone]
25  return key === null ? {} : { color: key }
26}
27
28const fillProps = (tone: Tone) => ({ backgroundColor: THEME[tone] ?? 'text' })
29
30export type Actions = {
31  toggleKeepWarm: () => void
32  warmNow: () => void
33  compact: () => void
34  toggleExpanded: () => void
35  // Tucks the Desktop band away to a small chip, and brings it back
36  hide: () => void
37  show: () => void
38  // Opens a keep-warm choice's options, or closes them if that one is open
39  togglePicker: (which: 'lead' | 'cap') => void
40  setLead: (value: string) => void
41  setIdleCap: (value: string) => void
42}
43
44// A run with `fill` is drawn as cells of solid color, its text only spaces.
45type Run = { text: string; tone: Tone; bold?: boolean; fill?: boolean }
46
47const muted = (text: string): Run => ({ text, tone: 'muted' })
48const fig = (text: string, bold?: boolean): Run => ({ text, tone: 'fg', ...(bold ? { bold } : {}) })
49
50const runsLength = (runs: readonly Run[]) => runs.reduce((n, r) => n + r.text.length, 0)
51
52// Neighbouring runs of one tone draw as one piece of text.
53function mergeRuns(runs: readonly Run[]): Run[] {
54  const out: Run[] = []
55  for (const r of runs) {
56    const prev = out[out.length - 1]
57    if (prev && prev.tone === r.tone && prev.bold === r.bold && prev.fill === r.fill) prev.text += r.text
58    else out.push({ ...r })
59  }
60  return out
61}
62
63// ---- The controls ----
64
65export type ButtonSpec = { key: string; label: string; isPrimary: boolean; isDim?: boolean; hotkey?: string; press: () => void }
66
67// The buttons on the first line: keep warm, the action the state calls for, and the one that opens
68// or closes the detail. Each has a key that presses it while the band has the focus.
69export function mainButtons(v: View, a: Actions): ButtonSpec[] {
70  const hasCache = v.cache.startedAt > 0
71  const specs: ButtonSpec[] = [
72    { key: 'keep', label: `Keep warm: ${v.settings.keepWarm ? 'on' : 'off'}`, isPrimary: v.settings.keepWarm, hotkey: 'k', press: a.toggleKeepWarm },
73  ]
74  // A ping takes a few seconds to come back; meanwhile the button says it is on its way.
75  if (hasCache && !v.isExpired) specs.push({ key: 'warm', label: v.isPinging ? 'Warming…' : 'Warm now', isPrimary: false, isDim: v.isPinging, hotkey: 'w', press: a.warmNow })
76  if (hasCache && v.isExpired) specs.push({ key: 'compact', label: 'Compact', isPrimary: false, hotkey: 'c', press: a.compact })
77  // The detail opens above the first line, so the arrows point up to open and down to close.
78  specs.push({ key: 'more', label: v.expanded ? 'Less ▾' : 'More ▴', isPrimary: false, hotkey: 'm', press: a.toggleExpanded })
79  return specs
80}
81
82// ---- The first line: the countdown, how long the cache lives and how well it hit ----
83
84// One piece of the first line. A piece with `alts` has shorter wordings, longest first; one marked
85// `isShrunkLast` gives up room only once every wording has. No piece is ever left out: a band too
86// narrow for them all beside the buttons gives them rows of their own.
87type Item = { key: string; runs: Run[]; alts?: Run[][]; isShrunkLast?: boolean }
88
89const TRACK = 10
90const SHORT_TRACK = 6
91
92const stateDot = (tone: Tone) => (tone === 'muted' ? '○' : '●')
93
94function statusItems(v: View): Item[] {
95  const tone = stateTone(v)
96  if (v.cache.startedAt === 0) {
97    return [{ key: 'state', runs: [muted('○ '), muted('No cache yet'), muted(' · it starts with your next message')], alts: [[muted('○ No cache yet')]] }]
98  }
99  if (v.isExpired) {
100    const cost = rewriteCost(v)
101    const price = cost === null ? '' : ` (${fmtApprox(cost)})`
102    const tokens = v.cache.ctx > 0 ? fmtTokens(v.cache.ctx) : 'everything'
103    const what = v.cache.ctx > 0 ? `${tokens} tokens` : 'the whole context'
104    return [
105      { key: 'state', runs: [muted('○ '), { text: 'expired', tone: 'muted', bold: true }] },
106      {
107        key: 'rewrite',
108        runs: [muted(`next message re-writes ${what}${price}`)],
109        alts: [[muted(`next message re-writes ${tokens}${price}`)], [muted(`re-writes ${tokens}${price}`)]],
110      },
111    ]
112  }
113  const items: Item[] = [{ key: 'state', runs: [{ text: `${stateDot(tone)} `, tone }, { text: fmtClock(v.leftMs, v.ttl.ms), tone, bold: true }] }]
114  // A solid bar: the life left in the state's color over a dim track. Colored cells, not block
115  // characters, so no font draws seams between them. It shortens only to keep the line whole.
116  const bar = (cells: number): Run[] => {
117    const filled = Math.round(Math.min(1, Math.max(0, v.leftMs / v.ttl.ms)) * cells)
118    return [{ text: ' '.repeat(filled), tone, fill: true }, { text: ' '.repeat(cells - filled), tone: 'muted', fill: true }]
119  }
120  items.push({ key: 'track', runs: bar(TRACK), alts: [bar(SHORT_TRACK)], isShrunkLast: true })
121  if (tone !== 'warm') items.push({ key: 'word', runs: [{ text: stateWord(v).toLowerCase(), tone }] })
122  const isHour = v.ttl.ms >= 600_000
123  items.push({ key: 'length', runs: [muted(ttlLabel(v))], alts: [[muted(v.ttl.isKnown ? (isHour ? '1h cache' : '5m cache') : '1h, assumed')]] })
124  const last = lastRequest(v)
125  if (last) items.push({ key: 'hit', runs: hitRuns(v, last), alts: [hitRuns(v, last, true)] })
126  return items
127}
128
129// The last request's hit rate and the session's, side by side: "hit 99.94% last request · 94.12%
130// this session", or in a narrow band "request 99.94% · session 94.12%".
131function hitRuns(v: View, last: Req, isShort = false): Run[] {
132  const now = fig(fmtPct(hitRate(last)), true)
133  const session = fig(fmtPct(sessionHitRate(v)), true)
134  return isShort
135    ? [muted('request '), now, muted(' · session '), session]
136    : [muted('hit '), now, muted(' last request · '), session, muted(' this session')]
137}
138
139const ITEM_GAP = 2
140const itemsWidth = (items: readonly Item[]) => items.reduce((n, it) => n + runsLength(it.runs), 0) + ITEM_GAP * Math.max(0, items.length - 1)
141
142// The first line in `room`, every piece in the fullest wording that fits: the full wordings, else
143// pieces shortened in order, left to right, the bar last, until the line fits. Null when even the
144// shortest wordings do not fit.
145function fitItems(items: readonly Item[], room: number): Item[] | null {
146  const out = [...items]
147  const order = [...out.keys()].sort((x, y) => Number(Boolean(out[x]!.isShrunkLast)) - Number(Boolean(out[y]!.isShrunkLast)) || x - y)
148  for (const i of order) {
149    if (itemsWidth(out) <= room) break
150    const it = out[i]!
151    for (const runs of it.alts ?? []) {
152      out[i] = { ...it, runs }
153      if (itemsWidth(out) <= room) break
154    }
155  }
156  return itemsWidth(out) <= room ? out : null
157}
158
159// ---- The detail rows ----
160
161// One thing on a detail row: text, or a button.
162type Cell = { key: string; width: number; runs?: Run[]; button?: ButtonSpec }
163
164const textCell = (key: string, runs: Run[]): Cell => ({ key, width: runsLength(runs), runs })
165
166// Text wider than the room is cut at its spaces into cells of a row each.
167function textCells(key: string, runs: Run[], room: number): Cell[] {
168  if (runsLength(runs) <= room) return [textCell(key, runs)]
169  const tone = runs[0]?.tone ?? 'muted'
170  return wrapWords(runs.map(r => r.text).join(''), room).map((line, i) => textCell(`${key}-${i}`, [{ text: line, tone }]))
171}
172
173const SEP = ' · '
174// Two texts side by side are joined by a dot; a button keeps two spaces from what is beside it.
175const gapBetween = (a: Cell, b: Cell) => (a.runs && b.runs ? SEP.length : 2)
176
177function flowCells(cells: readonly Cell[], room: number): Cell[][] {
178  const rows: Cell[][] = []
179  let used = 0
180  for (const c of cells) {
181    const row = rows[rows.length - 1]
182    const gap = row ? gapBetween(row[row.length - 1]!, c) : 0
183    if (row && used + gap + c.width <= room) {
184      row.push(c)
185      used += gap + c.width
186    } else {
187      rows.push([c])
188      used = c.width
189    }
190  }
191  return rows
192}
193
194// What one surface draws differently: how wide a button is, in the columns the room is measured in.
195type Surface = { buttonWidth: (label: string) => number }
196
197// The common part of both surfaces' element tables; the rows use nothing else.
198type Common = ElementTable<'terminal'>
199
200function buttonEl(el: Common, b: ButtonSpec) {
201  const { Button } = el
202  return (
203    <Button
204      key={b.key}
205      label={b.label}
206      onPress={b.press}
207      {...(b.hotkey ? { hotkey: b.hotkey } : {})}
208      {...(b.isPrimary ? { variant: 'primary' as const } : {})}
209      {...(b.isDim ? { dimColor: true } : {})}
210    />
211  )
212}
213
214// Runs side by side, each in its own tone.
215function textEl(el: Common, runs: readonly Run[], key: string) {
216  const { Box, Text } = el
217  return (
218    <Box key={key} flexDirection="row">
219      {mergeRuns(runs).map((r, i) => (
220        <Text key={`${key}-${i}`} {...(r.fill ? fillProps(r.tone) : colorProps(r.tone))} {...(r.bold ? { bold: true } : {})}>
221          {r.text}
222        </Text>
223      ))}
224    </Box>
225  )
226}
227
228// A section's label in a column of fixed width, so the values of every section start in one place.
229type Label = { text: string; width: number }
230
231function labelEl(el: Common, label: Label, key: string) {
232  const { Box, Text } = el
233  return (
234    <Box key={key} width={label.width} flexShrink={0}>
235      <Text {...colorProps('muted')}>{label.text}</Text>
236    </Box>
237  )
238}
239
240// One drawn row: its cells left to right, texts joined by a dot, buttons two spaces from their
241// neighbours, after the section's label column (`label` null: no column).
242function cellRow(el: Common, cells: readonly Cell[], key: string, label: Label | null = null) {
243  const { Box } = el
244  const parts: JSX.Element[] = label === null ? [] : [labelEl(el, label, `${key}-label`)]
245  let text: Run[] = []
246  const flush = (k: string) => {
247    if (text.length > 0) parts.push(textEl(el, text, k))
248    text = []
249  }
250  cells.forEach((c, i) => {
251    const prev = cells[i - 1]
252    if (prev) text.push(muted(prev.runs && c.runs ? SEP : '  '))
253    if (c.button) {
254      flush(`${key}-t${i}`)
255      parts.push(buttonEl(el, c.button))
256    } else {
257      text.push(...c.runs!)
258    }
259  })
260  flush(`${key}-end`)
261  return (
262    <Box key={key} flexDirection="row" alignItems="center">
263      {parts}
264    </Box>
265  )
266}
267
268// The rows of one labelled section: the label in a column of its own where the band is wide enough,
269// else on a line above its values.
270const LABEL_COLUMN = 18
271const MIN_LABELLED = 56
272
273type Section = { key: string; label: string; cells: (room: number) => Cell[] }
274
275// Narrow, the values sit under their label, indented this much.
276const INDENT = 2
277
278function sectionRows(el: Common, s: Section, columns: number): JSX.Element[] {
279  const isBeside = columns >= MIN_LABELLED
280  const room = isBeside ? columns - LABEL_COLUMN : columns - INDENT
281  const rows = flowCells(s.cells(room), room)
282  const out: JSX.Element[] = []
283  if (!isBeside) out.push(textEl(el, [{ text: s.label, tone: 'muted', bold: true }], `${s.key}-heading`))
284  // Beside: the label on the section's first row, an empty column under it. Under: an indent.
285  const labelOf = (i: number): Label => (isBeside ? { text: i === 0 ? s.label : '', width: LABEL_COLUMN } : { text: '', width: INDENT })
286  rows.forEach((row, i) => out.push(cellRow(el, row, `${s.key}${i}`, labelOf(i))))
287  return out
288}
289
290// ---- The session's numbers ----
291
292function sessionCells(v: View): Cell[] {
293  const t = v.totals
294  if (t.requests === 0) return [textCell('none', [muted('No requests yet')])]
295  const cells = [
296    textCell('rate', [fig(fmtPct(sessionHitRate(v)), true), muted(' hit')]),
297    textCell('requests', [fig(String(t.requests)), muted(t.requests === 1 ? ' request' : ' requests')]),
298    textCell('read', [fig(fmtTokens(t.read)), muted(' read')]),
299    textCell('written', [fig(fmtTokens(t.written)), muted(' written')]),
300  ]
301  // The write cost, then what the cache saved, the session's key figure, last.
302  if (t.isPriced) cells.push(textCell('writes', [muted('write cost '), fig(fmtApprox(t.writeCostUsd))]), textCell('saved', [muted('saved '), fig(fmtApprox(t.savingsUsd))]))
303  return cells
304}
305
306// ---- The last requests: a dot each, a red mark where the cache read dropped ----
307
308const RECENT = 10
309
310function recentCells(v: View, room: number): Cell[] {
311  const shown = v.history.slice(-RECENT)
312  const marks = shown.flatMap((r, i): Run[] => [...(i > 0 ? [muted(' ')] : []), r.isBreak ? { text: '▲', tone: 'danger' } : { text: '●', tone: 'warm' }])
313  const breaks = shown.filter(r => r.isBreak).length
314  const words = breaks === 0 ? 'no cache break' : breaks === 1 ? '1 cache break' : `${breaks} cache breaks`
315  return [textCell('marks', marks), ...textCells('recent-key', [muted(words)], room)]
316}
317
318// The section's label counts the requests its dots stand for.
319const recentLabel = (v: View) => {
320  const n = Math.min(RECENT, v.history.length)
321  return n === 1 ? 'Last request' : `Last ${n} requests`
322}
323
324// ---- The latest break ----
325
326function breakCells(v: View, room: number): Cell[] {
327  const latest = v.breaks[v.breaks.length - 1]
328  if (!latest) return []
329  const more = v.breaks.length - 1
330  return [
331    textCell('at', [fig(fmtLocalTime(latest.at))]),
332    textCell('rewritten', [fig(fmtTokens(latest.written)), muted(' re-written')]),
333    ...(latest.costUsd !== null ? [textCell('cost', [fig(fmtApprox(latest.costUsd))])] : []),
334    ...textCells('cause', [muted(latest.cause)], room),
335    ...(more > 0 ? [textCell('more', [muted(`and ${more} more`)])] : []),
336  ]
337}
338
339// ---- Keep warm: its two choices and what it has done ----
340
341type Option = { value: string; label: string }
342
343export const OPTIONS: Record<'lead' | 'cap', readonly Option[]> = {
344  lead: [
345    { value: 'auto', label: 'automatic' },
346    { value: '1m', label: '1 minute' },
347    { value: '2m', label: '2 minutes' },
348    { value: '4m', label: '4 minutes' },
349    { value: '8m', label: '8 minutes' },
350  ],
351  cap: [
352    { value: '1h', label: '1 hour' },
353    { value: '3h', label: '3 hours' },
354    { value: '8h', label: '8 hours' },
355    { value: 'none', label: 'never' },
356  ],
357}
358
359// What each choice is called, in full and, for a band where the full names take a row more, short.
360type Names = Record<'lead' | 'cap', string>
361export const FULL_NAMES: Names = { lead: 'Warm before expiry', cap: 'Stop after idle' }
362const SHORT_NAMES: Names = { lead: 'Warm', cap: 'Stop' }
363const CHOICE_HINT = 'click to change'
364
365export const currentOf = (v: View, which: 'lead' | 'cap') => (which === 'lead' ? v.settings.lead : v.settings.idleCap)
366
367// The two choices as buttons that open their options, then whether keep warm has paused. Each ping
368// already gets its own notice, so the running count waits for the detail.
369function choiceCells(v: View, a: Actions, s: Surface, names: Names, room: number): Cell[] {
370  const choice = (which: 'lead' | 'cap'): Cell => {
371    const label = `${names[which]}: ${OPTIONS[which].find(o => o.value === currentOf(v, which))?.label ?? ''} ${v.picker === which ? '▾' : '▴'}`
372    return { key: which, width: s.buttonWidth(label), button: { key: which, label, isPrimary: false, press: () => a.togglePicker(which) } }
373  }
374  const items = v.expanded ? pingsItems(v) : []
375  if (v.paused !== '') items.push(names === FULL_NAMES ? `Paused: you have been idle for ${v.paused}.` : `Paused: idle for ${v.paused}.`)
376  return [choice('lead'), choice('cap'), ...items.flatMap((text, i) => textCells(`ping${i}`, [muted(text)], room))]
377}
378
379function keepWarmCells(v: View, a: Actions, s: Surface, room: number): Cell[] {
380  // The full names unless the short ones save a row.
381  const full = choiceCells(v, a, s, FULL_NAMES, room)
382  const short = choiceCells(v, a, s, SHORT_NAMES, room)
383  const cost = (cells: Cell[]) => (cells.some(c => c.width > room) ? 1000 : 0) + flowCells(cells, room).length
384  const cells = cost(short) < cost(full) ? short : full
385  // The hint rides on the last row only where it fits, so it never costs a row, and goes while a
386  // choice is open.
387  const hint = textCell('hint', [muted(CHOICE_HINT)])
388  if (v.picker !== '') return cells
389  return flowCells([...cells, hint], room).length === flowCells(cells, room).length ? [...cells, hint] : cells
390}
391
392// The keep-warm section, with an open choice's options listed one per row directly above the button
393// that opened them, starting in its column, the current one highlighted. Picking one sets it and
394// closes the list; the choice's own button closes it unchanged. It carries no label: it shows only
395// while keep warm is on, and its choices say what they are.
396function keepWarmRows(el: Common, v: View, a: Actions, s: Surface, columns: number): JSX.Element[] {
397  const { Box } = el
398  const isBeside = columns >= MIN_LABELLED
399  const room = isBeside ? columns - LABEL_COLUMN : columns - INDENT
400  const rows = flowCells(keepWarmCells(v, a, s, room), room)
401  const blank: Label = { text: '', width: isBeside ? LABEL_COLUMN : INDENT }
402  const drawn = rows.map((row, i) => cellRow(el, row, `keepwarm${i}`, blank))
403  const out: JSX.Element[] = []
404  const which = v.picker
405  const at = which === '' ? -1 : rows.findIndex(row => row.some(c => c.key === which))
406  if (at < 0) return [...out, ...drawn]
407  // Where the open choice starts in its row, in the columns its cells and their gaps take.
408  const row = rows[at]!
409  let x = 0
410  for (let i = 0; row[i]!.key !== which; i++) x += row[i]!.width + gapBetween(row[i]!, row[i + 1]!)
411  const choose = which === 'lead' ? a.setLead : a.setIdleCap
412  const options = OPTIONS[which].map(o => (
413    <Box key={`${which}-option-${o.value}`} flexDirection="row" alignItems="center">
414      {labelEl(el, blank, `${which}-option-${o.value}-label`)}
415      {x > 0 ? <Box width={x} flexShrink={0} /> : null}
416      {buttonEl(el, { key: `${which}:${o.value}`, label: o.label, isPrimary: o.value === currentOf(v, which), press: () => choose(o.value) })}
417    </Box>
418  ))
419  return [...out, ...drawn.slice(0, at), ...options, ...drawn.slice(at)]
420}
421
422// ---- The band ----
423
424type Parts = {
425  // The first line's status, fitted to a room
426  status: (room: number) => { node: JSX.Element; width: number } | null
427  // The status on rows of its own, for a band too narrow to hold it beside the buttons
428  statusRows: (room: number) => JSX.Element[]
429}
430
431const FRAME_COLUMNS = 4
432
433function bandBody(el: Common, v: View, columns: number, a: Actions, s: Surface, p: Parts, rest: JSX.Element) {
434  const { Box } = el
435  // The band sits in a rounded frame, one cell of padding inside it.
436  const room = Math.max(20, columns - FRAME_COLUMNS)
437  const buttons = mainButtons(v, a)
438  const buttonCells: Cell[] = buttons.map(b => ({ key: b.key, width: s.buttonWidth(b.label), button: b }))
439  const buttonsWidth = buttonCells.reduce((n, c) => n + c.width, 0) + 2 * Math.max(0, buttonCells.length - 1)
440
441  // The status and the buttons on one line where they fit, three columns apart; else the status on
442  // its own line or lines, and the buttons under it.
443  const beside = p.status(room - buttonsWidth - 3)
444  const buttonRow = (row: readonly Cell[], key: string) => (
445    <Box key={key} flexDirection="row" columnGap={2} alignItems="center">
446      {row.map(c => buttonEl(el, c.button!))}
447    </Box>
448  )
449  const main: JSX.Element[] = beside
450    ? [
451        <Box key="top" flexDirection="row" justifyContent="space-between" alignItems="center">
452          {beside.node}
453          {buttonRow(buttonCells, 'buttons')}
454        </Box>,
455      ]
456    : [...p.statusRows(room), ...flowRows(buttonCells, c => c.width, 2, room).map((row, i) => buttonRow(row, `buttons${i}`))]
457
458  // The first line stays at the bottom, next to the prompt: the detail, the keep-warm row and an open
459  // choice's options all open above it.
460  const lines: JSX.Element[] = []
461  if (v.expanded) {
462    lines.push(...sectionRows(el, { key: 'session', label: 'This session', cells: () => sessionCells(v) }, room))
463    if (v.history.length > 0) lines.push(...sectionRows(el, { key: 'recent', label: recentLabel(v), cells: r => recentCells(v, r) }, room))
464    if (v.breaks.length > 0) lines.push(...sectionRows(el, { key: 'break', label: 'Last break', cells: r => breakCells(v, r) }, room))
465  }
466  if (v.settings.keepWarm) lines.push(...keepWarmRows(el, v, a, s, room))
467  // A blank row keeps the solid bar of the first line clear of the row above it.
468  if (lines.length > 0) lines.push(<Box key="above-main" height={1} />)
469  lines.push(...main)
470  // The frame keeps the band apart from the conversation and from Claude's working lines over it.
471  return (
472    <Box flexDirection="column">
473      <Box flexDirection="column" borderStyle="round" borderColor={THEME.muted ?? 'inactive'} paddingX={1}>
474        {lines}
475      </Box>
476      {rest}
477    </Box>
478  )
479}
480
481// ---- Terminal ----
482
483export function terminalBand(el: ElementTable<'terminal'>, v: View, columns: number, a: Actions, rest: JSX.Element) {
484  const { Box } = el
485  const items = statusItems(v)
486  const itemsEl = (list: readonly Item[], key: string) => (
487    <Box key={key} flexDirection="row">
488      {textEl(el, list.flatMap((it, i) => (i === 0 ? it.runs : [muted(' '.repeat(ITEM_GAP)), ...it.runs])), `${key}-t`)}
489    </Box>
490  )
491  return bandBody(el, v, columns, a, { buttonWidth: label => label.length + 4 }, {
492    status: room => {
493      const fit = fitItems(items, room)
494      return fit ? { node: itemsEl(fit, 'status'), width: itemsWidth(fit) } : null
495    },
496    // Too narrow for the status beside the buttons: the pieces flow onto rows of their own, each in
497    // the fullest wording its row holds, nothing dropped.
498    statusRows: room => {
499      // One row of its own where the pieces fit it shortened; else as many rows as they need.
500      const one = fitItems(items, room)
501      if (one) return [itemsEl(one, 'status0')]
502      const fitted = items.map(it => {
503        if (runsLength(it.runs) <= room || !it.alts) return it
504        return { ...it, runs: it.alts.find(r => runsLength(r) <= room) ?? it.alts[it.alts.length - 1]! }
505      })
506      return flowRows(fitted, it => runsLength(it.runs), ITEM_GAP, room).map((row, i) => itemsEl(row, `status${i}`))
507    },
508  }, rest)
509}
510
511// ---- Shared with the Desktop band (desktop.tsx) ----
512
513// Text width in pixels, estimated by character class (measured against the system font): an SVG
514// cannot measure its own text, and a little too wide is safer than overlapping.
515export function textPx(text: string, bold = false): number {
516  let w = 0
517  for (const c of text) {
518    if (/[0-9$~]/.test(c)) w += 0.58
519    else if (c === ' ' || /[.:,'|]/.test(c)) w += 0.26
520    else if (/[ilj]/.test(c)) w += 0.24
521    else if (/[trf]/.test(c)) w += 0.32
522    else if (c === 'm') w += 0.75
523    else if (c === 'w') w += 0.62
524    else if (c === '%') w += 0.8
525    else if (c === '·') w += 0.3
526    else if (/[MW]/.test(c)) w += 0.85
527    else if (/[A-Z]/.test(c)) w += 0.62
528    else w += 0.48
529  }
530  return w * 12 * (bold ? 1.06 : 1) * 1.03
531}
532
hooks/desktop.tsx 436 lines
1import type { ElementTable } from 'claude-code'
2
3import { currentOf, FULL_NAMES, mainButtons, OPTIONS, textPx, type Actions, type ButtonSpec } from './band'
4import { fmtApprox, fmtClock, fmtLocalTime, fmtPct, fmtTokens } from './format'
5import { wrapWords } from './layout'
6import { hitRate, lastRequest, pingsItems, rewriteCost, sessionHitRate, stateTone, stateWord, ttlLabel, type Tone, type View } from './view'
7
8// The Desktop app's band. The same pieces as the terminal's, drawn the way a window can: the
9// countdown as a large light numeral over a track that drains, the two hit rates beside it with a
10// ring each, the session's numbers as tiles, the last requests as a row of marks, and the keep-warm
11// choices as the app's own menus. The first line stays at the bottom, by the input box; what opens,
12// opens above it.
13
14type Desktop = ElementTable<'desktop'>
15
16// The drawings are images, so they cannot name the app's theme: they carry a light and a dark
17// palette of their own and follow the system's appearance.
18const STYLE =
19  '<style>' +
20  'svg{--fg:#1d1d1f;--muted:#6e6e73;--faint:rgba(0,0,0,.09);--warm:#2f9e6a;--warn:#c47a14;--danger:#d93f3f;--accent:#c96442}' +
21  '@media (prefers-color-scheme:dark){svg{--fg:#f2f2f2;--muted:#9b9ba1;--faint:rgba(255,255,255,.13);--warm:#4cc98f;--warn:#f0a73a;--danger:#ff6b6b;--accent:#e08a6c}}' +
22  'text{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","Segoe UI",system-ui,sans-serif;font-variant-numeric:tabular-nums;fill:var(--fg)}' +
23  '.label{font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace;font-size:9px;letter-spacing:.14em;fill:var(--muted)}' +
24  '.muted{fill:var(--muted)}' +
25  '</style>'
26
27const TONE_VAR: Record<Tone, string> = {
28  fg: 'var(--fg)',
29  muted: 'var(--muted)',
30  warm: 'var(--warm)',
31  warn: 'var(--warn)',
32  danger: 'var(--danger)',
33  accent: 'var(--accent)',
34}
35
36const esc = (t: string) => t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
37
38const svg = (width: number, height: number, body: string) =>
39  `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${STYLE}${body}</svg>`
40
41// Pixels a run of text takes at a size, by the same estimate the drawings are laid out with, with
42// room to spare: a drawing's text cannot reflow, so too wide is safe and too narrow cuts it off.
43const px = (text: string, size: number, bold = false) => textPx(text, bold) * (size / 12) * 1.15
44
45// The ink a spaced label actually takes: the monospace advance is 0.6 of the size, plus the spacing
46// between characters. The first line spaces its blocks by this, so it is the real width, not padded.
47const labelInk = (text: string) => text.length * 9 * 0.6 + Math.max(0, text.length - 1) * 9 * 0.14
48
49// Each character's advance in the drawings' type, measured from the font itself at every size and
50// weight the drawings use, in the order of ADVANCE_CHARS; a drawing's text cannot reflow, so its
51// width comes from these rather than an estimate.
52const ADVANCE_CHARS = "0123456789 !\"#$%&'()*+,-./:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]_abcdefghijklmnopqrstuvwxyz~·…"
53const ADVANCES: Record<string, number[]> = {
54  '12/400': [7.56, 7.56, 7.56, 7.56, 7.56, 7.56, 7.56, 7.56, 7.56, 7.56, 3.38, 3.73, 5.73, 7.56, 7.56, 11.11, 8.48, 3.56, 4.58, 4.58, 5.66, 7.09, 3.56, 5.19, 3.56, 3.66, 3.56, 3.56, 7.2, 6.73, 7.2, 6.16, 11.02, 7.97, 7.89, 8.47, 8.59, 7.14, 6.86, 8.84, 8.91, 3.2, 6.45, 7.91, 6.81, 10.48, 8.91, 9.02, 7.62, 9.02, 7.84, 7.64, 6.2, 8.84, 7.73, 11.27, 8.14, 7.05, 7.94, 4.58, 4.58, 7, 6.62, 7.08, 6.42, 7.08, 6.33, 4.58, 7.02, 7.06, 2.97, 2.95, 6.52, 3.03, 10.44, 7, 6.5, 7.03, 7.02, 4.56, 6.05, 4.36, 7, 6.5, 9.3, 6.3, 6.52, 6.47, 7.56, 3.2, 9.66],
55  '12/500': [7.73, 7.73, 7.73, 7.73, 7.73, 7.73, 7.73, 7.73, 7.73, 7.73, 3.28, 3.92, 6.09, 7.73, 7.73, 11.55, 8.55, 3.78, 4.78, 4.78, 5.72, 7.27, 3.78, 5.25, 3.78, 3.78, 3.78, 3.78, 7.39, 6.95, 7.39, 6.33, 10.98, 8.19, 8.02, 8.58, 8.69, 7.27, 6.97, 8.91, 9.08, 3.42, 6.69, 8.08, 6.92, 10.62, 9.02, 9.09, 7.78, 9.09, 8.02, 7.81, 6.55, 8.89, 7.97, 11.47, 8.36, 7.2, 8, 4.78, 4.78, 7.19, 6.75, 7.22, 6.48, 7.22, 6.42, 4.77, 7.16, 7.25, 3.12, 3.12, 6.75, 3.22, 10.69, 7.19, 6.59, 7.17, 7.16, 4.8, 6.17, 4.56, 7.19, 6.67, 9.59, 6.52, 6.73, 6.59, 7.73, 3.36, 10.27],
56  '13/500': [8.3, 8.3, 8.3, 8.3, 8.3, 8.3, 8.3, 8.3, 8.3, 8.3, 3.48, 4.17, 6.53, 8.3, 8.3, 12.44, 9.17, 4.02, 5.09, 5.09, 6.11, 7.78, 4.02, 5.61, 4.02, 4.02, 4.02, 4.02, 7.92, 7.45, 7.92, 6.78, 11.81, 8.8, 8.61, 9.22, 9.33, 7.78, 7.47, 9.56, 9.75, 3.62, 7.16, 8.67, 7.42, 11.42, 9.69, 9.77, 8.34, 9.77, 8.59, 8.38, 7.02, 9.55, 8.55, 12.34, 8.97, 7.72, 8.59, 5.09, 5.09, 7.7, 7.23, 7.73, 6.94, 7.73, 6.88, 5.08, 7.67, 7.77, 3.31, 3.31, 7.23, 3.41, 11.5, 7.7, 7.06, 7.69, 7.67, 5.11, 6.59, 4.86, 7.7, 7.16, 10.31, 6.98, 7.22, 7.06, 8.3, 3.56, 11.03],
57  '16/500': [9.98, 9.98, 9.98, 9.98, 9.98, 9.98, 9.98, 9.98, 9.98, 9.98, 4.06, 4.91, 7.81, 9.98, 9.98, 15.08, 11.08, 4.72, 6.05, 6.05, 7.31, 9.36, 4.72, 6.69, 4.72, 4.72, 4.72, 4.72, 9.53, 8.95, 9.53, 8.12, 14.33, 10.61, 10.38, 11.12, 11.27, 9.36, 8.98, 11.55, 11.78, 4.25, 8.59, 10.45, 8.92, 13.84, 11.7, 11.8, 10.06, 11.8, 10.36, 10.09, 8.42, 11.53, 10.31, 14.98, 10.83, 9.28, 10.36, 6.05, 6.05, 9.27, 8.69, 9.3, 8.33, 9.3, 8.25, 6.03, 9.22, 9.34, 3.86, 3.86, 8.69, 3.97, 13.94, 9.27, 8.47, 9.23, 9.23, 6.08, 7.91, 5.77, 9.27, 8.58, 12.47, 8.38, 8.67, 8.47, 9.98, 4.17, 13.36],
58  '17/500': [10.5, 10.5, 10.5, 10.5, 10.5, 10.5, 10.5, 10.5, 10.5, 10.5, 4.22, 5.11, 8.2, 10.5, 10.5, 15.92, 11.66, 4.91, 6.33, 6.33, 7.66, 9.84, 4.91, 7, 4.91, 4.91, 4.91, 4.91, 10.02, 9.39, 10.02, 8.53, 15.11, 11.16, 10.92, 11.7, 11.86, 9.84, 9.44, 12.17, 12.41, 4.41, 9.03, 11, 9.38, 14.61, 12.33, 12.44, 10.58, 12.44, 10.91, 10.61, 8.83, 12.14, 10.84, 15.81, 11.39, 9.75, 10.89, 6.33, 6.33, 9.73, 9.12, 9.77, 8.73, 9.77, 8.66, 6.3, 9.69, 9.81, 3.98, 3.98, 9.12, 4.11, 14.7, 9.73, 8.89, 9.7, 9.7, 6.34, 8.28, 6.02, 9.73, 9.02, 13.14, 8.8, 9.11, 8.89, 10.5, 4.33, 14.09],
59}
60
61// What a run of text takes at a size and weight the table holds; a character it lacks, or a size it
62// lacks, falls back to the padded estimate.
63const textInk = (text: string, size: number, weight: 400 | 500) => {
64  const row = ADVANCES[`${size}/${weight}`]
65  if (!row) return px(text, size, weight === 500)
66  return [...text].reduce((w, c) => {
67    const i = ADVANCE_CHARS.indexOf(c)
68    return w + (i >= 0 ? row[i]! : px(c, size, weight === 500))
69  }, 0)
70}
71
72// The Last break heading, which names the cause, wraps at this many characters, about 300 pixels.
73const BREAK_CHARS = 44
74
75// ---- The first line ----
76
77// The ink gap between neighbouring blocks on the first line, between the session's tiles, and between
78// the last requests and the latest break.
79const GAP = 24
80const TILE_GAP = 16
81const REQUESTS_GAP = 40
82
83// A drawn row before it is placed: its markup, its own size and what it says in words.
84type Piece = { body: string; width: number; height: number; alt: string }
85
86// The first line is 44 high. The label with its state word, and each figure's ring, value and label,
87// have the centroid of their filled pixels on one line; the numeral with its track is placed by its
88// box instead, the numeral's top to the track's bottom half a pixel below the first ring's centre,
89// which reads level, with the track on a whole pixel so it stays sharp. Measured in the app itself.
90const GRID = { numeral: 31, track: 38, sideLabel: 19.85, sideWord: 34.35, value: 24, valueLabel: 37, middle: 24.5 }
91
92// The countdown: one large light numeral for the one number that matters, a track under it that
93// drains with the entry's life, and beside it what kind of cache this is and how it stands.
94function instrument(v: View) {
95  const tone = stateTone(v)
96  const hasCache = v.cache.startedAt > 0
97  const live = hasCache && !v.isExpired
98  const big = !hasCache ? 'No cache yet' : v.isExpired ? 'Expired' : fmtClock(v.leftMs, v.ttl.ms)
99  const size = live ? 30 : 22
100  const bigW = Math.ceil(px(big, size) * 0.96)
101  const label = hasCache ? ttlLabel(v).toUpperCase() : 'STARTS WITH YOUR NEXT MESSAGE'
102  const word = live ? stateWord(v) : hasCache ? 'Your next message re-writes it' : ''
103  // The track ends at bigW - 1, so the label and dot start one gap after it.
104  const sideX = bigW - 1 + GAP
105  const inkEnd = sideX + Math.max(labelInk(label), word ? (live ? 12 : 0) + textInk(word, 12, 500) : 0)
106  const width = Math.ceil(inkEnd) + 1
107  const height = 44
108  const numeralFill = live && tone !== 'warm' ? TONE_VAR[tone] : live ? 'var(--fg)' : 'var(--muted)'
109  const frac = live ? Math.min(1, Math.max(0, v.leftMs / v.ttl.ms)) : 0
110  const track = live
111    ? `<rect x="1" y="${GRID.track}" width="${bigW - 2}" height="3" rx="1.5" fill="var(--faint)"/>` +
112      `<rect x="1" y="${GRID.track}" width="${Math.max(0, Math.round((bigW - 2) * frac))}" height="3" rx="1.5" fill="${TONE_VAR[tone]}"/>`
113    : ''
114  const dot = live ? `<circle cx="${sideX + 3.5}" cy="${GRID.sideWord - 4}" r="3.5" fill="${TONE_VAR[tone]}"/>` : ''
115  const body =
116    `<text x="${bigW / 2}" y="${GRID.numeral}" text-anchor="middle" font-size="${size}" font-weight="250" letter-spacing="-.5" fill="${numeralFill}">${esc(big)}</text>` +
117    track +
118    `<text class="label" x="${sideX}" y="${GRID.sideLabel}">${esc(label)}</text>` +
119    dot +
120    (word ? `<text x="${sideX + (live ? 12 : 0)}" y="${GRID.sideWord}" font-size="12" font-weight="500" fill="${live ? TONE_VAR[tone] : 'var(--muted)'}">${esc(word)}</text>` : '')
121  const alt = [live ? `${big} left` : big, live ? stateWord(v) : '', hasCache ? ttlLabel(v) : ''].filter(Boolean).join(', ')
122  return { body, width, height, alt, inkEnd }
123}
124
125// A ring that fills with a share, drawn around (cx, cy).
126function ring(cx: number, cy: number, r: number, share: number, color: string) {
127  const c = 2 * Math.PI * r
128  const on = Math.max(0, Math.min(1, share)) * c
129  return (
130    `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="var(--faint)" stroke-width="2.5"/>` +
131    `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round" ` +
132    `stroke-dasharray="${on.toFixed(2)} ${c.toFixed(2)}" transform="rotate(-90 ${cx} ${cy})"/>`
133  )
134}
135
136// Two figures with their labels under them, side by side: the hit rates, or once the entry has lapsed
137// what the next message re-writes and roughly what that costs. `lead` is where the first figure's ink
138// starts, so the gap before it matches the others.
139function figures(v: View, lead: number): Piece | null {
140  // Before the first request there is nothing to re-write and no rate yet.
141  if (v.cache.startedAt === 0) return null
142  const last = lastRequest(v)
143  let blocks: { value: string; label: string; share?: number }[] = []
144  let alt = ''
145  if (v.isExpired) {
146    const cost = rewriteCost(v)
147    const tokens = v.cache.ctx > 0 ? fmtTokens(v.cache.ctx) : 'all'
148    blocks = [{ value: tokens, label: 'TOKENS TO RE-WRITE' }, ...(cost === null ? [] : [{ value: fmtApprox(cost), label: 'NEXT MESSAGE COSTS' }])]
149    alt = `next message re-writes ${v.cache.ctx > 0 ? `${tokens} tokens` : 'the whole context'}${cost === null ? '' : ` (${fmtApprox(cost)})`}`
150  } else if (last) {
151    const now = hitRate(last)
152    const session = sessionHitRate(v)
153    blocks = [
154      { value: fmtPct(now), label: 'LAST REQUEST', share: now / 100 },
155      { value: fmtPct(session), label: 'THIS SESSION', share: session / 100 },
156    ]
157    alt = `hit ${fmtPct(now)} last request · ${fmtPct(session)} this session`
158  }
159  if (blocks.length === 0) return null
160  const height = 44
161  // A ring's stroke starts 0.75 inside its drawn box; each block's ink starts one gap after the last
162  // one's ends.
163  const parts: string[] = []
164  let inkStart = lead
165  let end = 0
166  blocks.forEach(b => {
167    const x = b.share === undefined ? inkStart : inkStart - 0.75
168    const textX = b.share === undefined ? x : x + 30
169    if (b.share !== undefined) parts.push(ring(x + 11, GRID.middle, 9, b.share, 'var(--warm)'))
170    parts.push(`<text x="${textX}" y="${GRID.value}" font-size="17" font-weight="500">${esc(b.value)}</text>`)
171    parts.push(`<text class="label" x="${textX}" y="${GRID.valueLabel}">${esc(b.label)}</text>`)
172    end = textX + Math.max(labelInk(b.label), textInk(b.value, 17, 500))
173    inkStart = end + GAP
174  })
175  const width = Math.ceil(end) + 1
176  return { body: parts.join(''), width, height, alt }
177}
178
179// ---- The detail ----
180
181// The session's numbers as tiles in one row under one heading.
182function sessionTiles(v: View): Piece {
183  const t = v.totals
184  const tiles: [string, string][] =
185    t.requests === 0
186      ? []
187      : [
188          [fmtPct(sessionHitRate(v)), 'HIT RATE'],
189          [String(t.requests), t.requests === 1 ? 'REQUEST' : 'REQUESTS'],
190          [fmtTokens(t.read), 'READ'],
191          [fmtTokens(t.written), 'WRITTEN'],
192          ...(t.isPriced ? ([[fmtApprox(t.writeCostUsd), 'WRITE COST'], [fmtApprox(t.savingsUsd), 'SAVED']] as [string, string][]) : []),
193        ]
194  const head = '<text class="label" x="0" y="10">THIS SESSION</text>'
195  if (tiles.length === 0)
196    return { body: head + '<text class="muted" x="0" y="30" font-size="13">No requests yet</text>', width: 160, height: 36, alt: 'This session: No requests yet' }
197  // Each tile is as wide as its wider line, with the figure and its label centred on one another, and
198  // one tile gap from the next. A spaced label's advance ends in one letter space past its ink, so its
199  // centre sits half that to the right.
200  let x = 0
201  const parts = [head]
202  tiles.forEach(([value, label], i) => {
203    if (i > 0) x += TILE_GAP
204    const inner = Math.max(textInk(value, 16, 500), labelInk(label))
205    const mid = x + inner / 2
206    parts.push(
207      `<text x="${mid}" y="31" text-anchor="middle" font-size="16" font-weight="500">${esc(value)}</text>`,
208      `<text class="label" x="${mid + 0.63}" y="45" text-anchor="middle">${esc(label)}</text>`,
209    )
210    x += inner
211  })
212  const alt = `This session: ${tiles.map(([value, label]) => `${value} ${label.toLowerCase()}`).join(', ')}`
213  return { body: parts.join(''), width: Math.max(x, labelInk('THIS SESSION')), height: 47, alt }
214}
215
216const RECENT = 10
217
218// The last ten requests as ten places, filled from the left as requests arrive: a mark each, red
219// where the cache read dropped, under a heading that counts the breaks. An image like the other
220// drawings, so it follows the appearance.
221function recentStrip(v: View): Piece {
222  const shown = v.history.slice(-RECENT)
223  const breaks = shown.filter(r => r.isBreak).length
224  const words = `${breaks === 0 ? 'No cache break' : breaks === 1 ? '1 cache break' : `${breaks} cache breaks`} in the last ${shown.length}`
225  const heading = `${shown.length === 1 ? 'LAST REQUEST' : `LAST ${shown.length} REQUESTS`}: ${breaks === 0 ? 'NO CACHE BREAK' : breaks === 1 ? '1 CACHE BREAK' : `${breaks} CACHE BREAKS`}`
226  const STEP = 18
227  const parts = [`<text class="label" x="0" y="10">${esc(heading)}</text>`]
228  for (let i = 0; i < RECENT; i++) {
229    const cx = 6 + i * STEP
230    const r = shown[i]
231    if (!r) parts.push(`<circle cx="${cx}" cy="25" r="4.5" fill="none" stroke="var(--faint)" stroke-width="1.5"/>`)
232    else if (r.isBreak) parts.push(`<path d="M${cx} 19.5 L${cx + 6} 30 L${cx - 6} 30 Z" fill="var(--danger)"/>`)
233    else parts.push(`<circle cx="${cx}" cy="25" r="4.5" fill="var(--warm)"/>`)
234  }
235  const width = Math.ceil(Math.max(6 + (RECENT - 1) * STEP + 6, labelInk(heading)) + 1)
236  const height = 31
237  const alt = `Last ${shown.length} requests: ${shown.map(r => (r.isBreak ? '▲' : '●')).join(' ')}, ${words.charAt(0).toLowerCase()}${words.slice(1)}`
238  return { body: parts.join(''), width, height, alt }
239}
240
241// The latest break: when, how much it re-wrote and what that cost, and why, in words.
242function lastBreak(v: View): Piece | null {
243  const latest = v.breaks[v.breaks.length - 1]
244  if (!latest) return null
245  const more = v.breaks.length - 1
246  const facts = [fmtLocalTime(latest.at), `${fmtTokens(latest.written)} re-written`, ...(latest.costUsd !== null ? [fmtApprox(latest.costUsd)] : [])].join('  ·  ')
247  // The heading names the cause, and a drawing cannot reflow, so it wraps at a width that fits a
248  // narrow band; the time, what was re-written and its cost sit under it.
249  const lines = wrapWords(`LAST BREAK: ${latest.cause.toUpperCase()}` + (more > 0 ? ` (AND ${more} MORE BEFORE IT)` : ''), BREAK_CHARS)
250  const factsY = 10 + (lines.length - 1) * 12 + 17
251  const height = factsY + 3
252  const width = Math.ceil(Math.max(textInk(facts, 13, 500), ...lines.map(labelInk)) + 1)
253  const parts = [
254    ...lines.map((l, i) => `<text class="label" x="0" y="${10 + i * 12}">${esc(l)}</text>`),
255    `<text x="0" y="${factsY}" font-size="13" font-weight="500">${esc(facts)}</text>`,
256  ]
257  const alt = `Last break: ${latest.cause}, ${fmtLocalTime(latest.at)}, ${fmtTokens(latest.written)} re-written${latest.costUsd !== null ? `, ${fmtApprox(latest.costUsd)}` : ''}${more > 0 ? `, and ${more} more` : ''}`
258  return { body: parts.join(''), width, height, alt }
259}
260
261// ---- Keep warm: the app's own menus ----
262
263function keepWarmRow(el: Desktop, v: View, a: Actions, notes: JSX.Element | null) {
264  const { Box, Select } = el
265  const choice = (which: 'lead' | 'cap') => (
266    <Select
267      key={which}
268      label={FULL_NAMES[which]}
269      options={OPTIONS[which].map(o => ({ value: o.value, label: o.label }))}
270      value={currentOf(v, which)}
271      onSelect={value => (which === 'lead' ? a.setLead : a.setIdleCap)(value)}
272    />
273  )
274  return (
275    <Box key="keepwarm" flexDirection="column">
276      <Box key="choices" flexDirection="row" alignItems="center" columnGap={2} flexWrap="wrap">
277        {choice('lead')}
278        {choice('cap')}
279      </Box>
280      {notes}
281    </Box>
282  )
283}
284
285// What the pings have done and why keep warm paused, a drawing like the other rows so it shrinks
286// with them; the choices above it are the app's own menus.
287function keepWarmNotes(v: View): Piece | null {
288  const notes = v.expanded ? pingsItems(v) : []
289  if (v.paused !== '') notes.push(`Paused: you have been idle for ${v.paused}.`)
290  if (notes.length === 0) return null
291  const text = notes.join('  ·  ')
292  return { body: `<text class="muted" x="0" y="13" font-size="12">${esc(text)}</text>`, width: Math.ceil(textInk(text, 12, 400)) + 1, height: 21, alt: text }
293}
294
295// A notice, drawn like the rows so it shrinks with them, its words wrapped to the rows' width so a
296// long one never widens the band.
297function noticePiece(text: string, width: number): Piece {
298  const lines: string[] = []
299  for (const word of text.split(' ')) {
300    const last = lines[lines.length - 1]
301    if (last !== undefined && textInk(`${last} ${word}`, 12, 400) <= width) lines[lines.length - 1] = `${last} ${word}`
302    else lines.push(word)
303  }
304  const STEP = 16
305  const body = lines.map((l, i) => `<text class="muted" x="0" y="${13 + i * STEP}" font-size="12">${esc(l)}</text>`).join('')
306  return { body, width: Math.ceil(Math.max(...lines.map(l => textInk(l, 12, 400)))) + 1, height: 18 + (lines.length - 1) * STEP, alt: text }
307}
308
309// ---- The band ----
310
311function button(el: Desktop, b: ButtonSpec) {
312  const { Button } = el
313  return (
314    <Button
315      key={b.key}
316      label={b.label}
317      onPress={b.press}
318      {...(b.hotkey ? { hotkey: b.hotkey } : {})}
319      {...(b.isPrimary ? { variant: 'primary' as const } : {})}
320      {...(b.isDim ? { dimColor: true } : {})}
321    />
322  )
323}
324
325// Tucked away, the band is one small chip: a dot in the countdown's color, the time left and a
326// button to bring the band back. The dot is a drawing in the band's own palette, so it is the same
327// green as the expanded band's; the words are the app's own text, so they read in whatever font the
328// app uses. Every piece, the separator dot included, is its own element with the same layout gap on
329// each side, and the app centres them all on one line.
330const CHIP_DOT = 10
331// How wide a notice under the chip may run before its words wrap.
332const CHIP_NOTICE_WIDTH = 360
333
334function chip(el: Desktop, v: View, a: Actions, rest: JSX.Element) {
335  const { Box, Button, Svg, Text } = el
336  const hasCache = v.cache.startedAt > 0
337  const live = hasCache && !v.isExpired
338  const lead = !hasCache ? 'Cache Maxxer' : !live ? 'Cache expired' : stateWord(v) === 'Warm' ? 'Cache warm' : 'Cache expiring'
339  const tail = !hasCache ? ['no cache yet'] : live ? [fmtClock(v.leftMs, v.ttl.ms), 'left'] : []
340  const color = live ? TONE_VAR[stateTone(v)] : 'var(--muted)'
341  // A notice still says itself while the band is tucked away, under the chip, as it would in the band.
342  const notice = v.notice !== '' ? noticePiece(v.notice, CHIP_NOTICE_WIDTH) : null
343  const dot = `<circle cx="${CHIP_DOT / 2}" cy="${CHIP_DOT / 2}" r="${CHIP_DOT / 2 - 0.5}" fill="${color}"/>`
344  return (
345    <Box flexDirection="column">
346      <Box key="chip" flexDirection="row" alignItems="center" columnGap={1}>
347        <Svg key="chip-dot" source={svg(CHIP_DOT, CHIP_DOT, dot)} alt={live ? stateWord(v) : lead} width={CHIP_DOT} height={CHIP_DOT} />
348        <Text key="chip-lead" color="inactive">
349          {lead}
350        </Text>
351        {tail.length > 0 ? (
352          <Text key="chip-sep" color="inactive">
353            ·
354          </Text>
355        ) : null}
356        {tail.map((word, i) => (
357          <Text key={`chip-${i}`} color="inactive">
358            {word}
359          </Text>
360        ))}
361        <Button key="show" label="Show" hotkey="h" onPress={a.show} />
362      </Box>
363      {notice ? <Svg key="chip-notice" source={svg(notice.width, notice.height, notice.body)} alt={notice.alt} width={notice.width} /> : null}
364      {rest}
365    </Box>
366  )
367}
368
369// Two pieces side by side, the second `gap` after the first.
370function beside(a: Piece, b: Piece | null, gap: number): Piece {
371  if (!b) return a
372  return {
373    body: a.body + `<g transform="translate(${a.width + gap} 0)">${b.body}</g>`,
374    width: a.width + gap + b.width,
375    height: Math.max(a.height, b.height),
376    alt: `${a.alt}; ${b.alt}`,
377  }
378}
379
380// Each drawn row is one drawing, and every row is given one width: the widest row's. The app draws a
381// drawing at its width and shrinks it to fit a narrower band, with no height given so its height
382// follows, so every row shrinks by the same factor, text and marks together. A drawing given no width
383// at all is stretched to fill the band instead. The buttons and the keep-warm menus are the app's own
384// and sit beside or under the drawings where there is room.
385export function desktopBand(el: Desktop, v: View, a: Actions, rest: JSX.Element) {
386  const { Box, Svg } = el
387  if (v.hidden) return chip(el, v, a, rest)
388  const clock = instrument(v)
389  const line = beside(clock, figures(v, GAP - (clock.width - clock.inkEnd)), 0)
390  const recent = v.expanded && v.history.length > 0 ? recentStrip(v) : null
391  const brk = v.expanded ? lastBreak(v) : null
392  const requests = recent ? beside(recent, brk, REQUESTS_GAP) : brk
393  const notes = v.settings.keepWarm ? keepWarmNotes(v) : null
394  const rows = [line, ...(v.expanded ? [sessionTiles(v)] : []), ...(requests ? [requests] : []), ...(notes ? [notes] : [])]
395  const W = Math.ceil(Math.max(...rows.map(r => r.width)))
396  const draw = (key: string, p: Piece) => <Svg key={key} source={svg(W, p.height, p.body)} alt={p.alt} width={W} />
397  const notice = v.notice !== '' ? noticePiece(v.notice, W) : null
398  // Hide sits with the band's own buttons, after More.
399  const buttons: ButtonSpec[] = [...mainButtons(v, a), { key: 'hide', label: 'Hide', isPrimary: false, hotkey: 'h', press: a.hide }]
400  const main = (
401    <Box key="main" flexDirection="row" flexWrap="wrap" justifyContent="space-between" alignItems="center" columnGap={4} rowGap={1}>
402      {draw('status', line)}
403      <Box key="buttons" flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={1} rowGap={1}>
404        {buttons.map(b => button(el, b))}
405      </Box>
406    </Box>
407  )
408
409  const above: JSX.Element[] = []
410  if (v.expanded) {
411    above.push(draw('session', rows[1]!))
412    if (requests) above.push(draw('requests', requests))
413  }
414  // The pings line sits close under the choices and the first line close under it: each drawing
415  // brings a few pixels of its own room, so neither takes the band's full row gap.
416  const warm = v.settings.keepWarm ? keepWarmRow(el, v, a, notes ? draw('notes', notes) : null) : null
417  const bottom = warm && notes ? (
418    <Box key="warm-and-main" flexDirection="column">
419      {warm}
420      {main}
421    </Box>
422  ) : null
423  if (warm && !bottom) above.push(warm)
424  // The frame takes the input box's own border color, so the band sits in the app like the box under it.
425  return (
426    <Box flexDirection="column">
427      <Box flexDirection="column" borderStyle="round" borderColor="promptBorder" paddingX={1} rowGap={1}>
428        {above}
429        {bottom ?? main}
430        {notice ? draw('notice', notice) : null}
431      </Box>
432      {rest}
433    </Box>
434  )
435}
436
hooks/format.ts 51 lines
1const trimmed = (n: number) => n.toFixed(1).replace(/\.0$/, '')
2
3// 950, 1.2K, 182K, 3.1M
4export function fmtTokens(n: number): string {
5  if (n >= 1_000_000 || Math.round(n / 1000) >= 1000) return `${trimmed(n / 1e6)}M`
6  if (n >= 10_000) return `${Math.round(n / 1000)}K`
7  if (n >= 1000) return `${trimmed(n / 1000)}K`
8  return String(Math.round(n))
9}
10
11export function fmtUsd(n: number): string {
12  if (n < 0) return `-${fmtUsd(-n)}`
13  if (n >= 100) return `$${Math.round(n)}`
14  if (n > 0 && n < 0.01) return '< $0.01'
15  return `$${n.toFixed(2)}`
16}
17
18// A dollar figure that is an estimate: ~$0.31, < $0.01 for a smaller cost, a loss as -$0.06,
19// and a loss too small to show in cents as ~$0.00.
20export function fmtApprox(n: number): string {
21  if (n < 0) return n > -0.005 ? '~$0.00' : `-$${-n >= 100 ? Math.round(-n) : (-n).toFixed(2)}`
22  if (n > 0 && n < 0.01) return '< $0.01'
23  return `~${fmtUsd(n)}`
24}
25
26// mm:ss, with two-digit minutes for an hour-long entry so the band keeps its width.
27export function fmtClock(ms: number, ttlMs: number): string {
28  const seconds = Math.max(0, Math.ceil(ms / 1000))
29  const minutes = String(Math.floor(seconds / 60))
30  return `${ttlMs >= 600_000 ? minutes.padStart(2, '0') : minutes}:${String(seconds % 60).padStart(2, '0')}`
31}
32
33// 63m, 2h 5m
34export function fmtIdle(ms: number): string {
35  const minutes = Math.max(1, Math.round(ms / 60_000))
36  if (minutes < 120) return `${minutes}m`
37  const h = Math.floor(minutes / 60)
38  const m = minutes % 60
39  return m === 0 ? `${h}h` : `${h}h ${m}m`
40}
41
42export function fmtLocalTime(ms: number): string {
43  const d = new Date(ms)
44  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
45}
46
47export const pct = (part: number, whole: number): number => (whole > 0 ? (part / whole) * 100 : 0)
48
49// A share to two decimals, cut rather than rounded, so a rate short of 100% never reads 100.00%.
50export const fmtPct = (n: number): string => `${(Math.floor(n * 100 + 1e-9) / 100).toFixed(2)}%`
51
hooks/model.ts 119 lines
1import type { TurnUsage } from 'claude-code'
2
3import type { BreakInfo, CacheSettings, CacheState, Pings, Req, Totals } from '../types'
4import { fmtIdle } from './format'
5import { savingsUsd, writeCostUsd } from './pricing'
6
7export const EMPTY_CACHE: CacheState = { startedAt: 0, ttlMs: null, model: '', ctx: 0, cached: 0 }
8export const EMPTY_TOTALS: Totals = {
9  requests: 0,
10  read: 0,
11  written: 0,
12  uncached: 0,
13  savingsUsd: 0,
14  writeCostUsd: 0,
15  isPriced: false,
16}
17
18export const EMPTY_PINGS: Pings = { count: 0, read: 0, rebuilds: 0, costUsd: 0, isPriced: false }
19export const DEFAULT_SETTINGS: CacheSettings = { keepWarm: false, lead: 'auto', idleCap: '3h' }
20
21export const HISTORY_LIMIT = 48
22export const BREAKS_KEPT = 20
23const BREAK_MIN_WRITE = 20_000
24
25// What happened to the conversation since its last request that can explain a rebuild.
26export type Pending = { kind: 'cleared' } | { kind: 'compacted' } | { kind: 'idle'; idleMs: number } | null
27
28// The cached prefix is lost when a request writes a lot and reads less than half of what the
29// previous request cached. Nothing was lost when that total is 0 (no earlier request), or when a big
30// new message is written on top of a prefix that was read.
31export const isBreak = (written: number, read: number, previouslyCached: number): boolean =>
32  written > BREAK_MIN_WRITE && read < previouslyCached / 2
33
34export function inferCause(a: {
35  gapMs: number | null
36  ttlMs: number
37  prevModel: string
38  model: string
39  pending: Pending
40}): string {
41  if (a.gapMs !== null && a.gapMs > a.ttlMs) return `expired after ${fmtIdle(a.gapMs)} idle`
42  if (a.pending?.kind === 'idle') return `expired after ${fmtIdle(a.pending.idleMs)} idle`
43  if (a.prevModel && a.model && a.prevModel !== a.model) return 'model switched'
44  if (a.pending?.kind === 'compacted') return 'compacted'
45  if (a.pending?.kind === 'cleared') return 'cleared'
46  return 'prefix changed (system prompt, tools or MCP servers)'
47}
48
49export type Snapshot = {
50  cache: CacheState
51  history: readonly Req[]
52  totals: Totals
53  breaks: readonly BreakInfo[]
54}
55
56// Folds one main-conversation request into what the band reads. The request's own start
57// restarts the entry's life when it read or wrote the cache.
58export function applyRequest(
59  s: Snapshot,
60  r: { startedAt: number; usage: TurnUsage; ttlMs: number },
61  pending: Pending,
62): { next: Snapshot; brk: BreakInfo | null } {
63  const { usage } = r
64  const tokens = {
65    read: usage.cache_read_input_tokens,
66    written: usage.cache_creation_input_tokens,
67    uncached: usage.input_tokens,
68    output: usage.output_tokens,
69  }
70  const totalInput = tokens.read + tokens.written + tokens.uncached
71
72  let brk: BreakInfo | null = null
73  if (isBreak(tokens.written, tokens.read, s.cache.cached)) {
74    brk = {
75      at: r.startedAt,
76      written: tokens.written,
77      costUsd: writeCostUsd(usage.model, tokens.written, r.ttlMs, totalInput),
78      cause: inferCause({
79        gapMs: s.cache.startedAt > 0 ? r.startedAt - s.cache.startedAt : null,
80        ttlMs: r.ttlMs,
81        prevModel: s.cache.model,
82        model: usage.model,
83        pending,
84      }),
85    }
86  }
87
88  const touched = tokens.read > 0 || tokens.written > 0
89  const cache: CacheState = {
90    startedAt: touched && r.startedAt > s.cache.startedAt ? r.startedAt : s.cache.startedAt,
91    ttlMs: s.cache.ttlMs,
92    model: usage.model,
93    ctx: totalInput + tokens.output,
94    cached: tokens.read + tokens.written,
95  }
96  const saved = savingsUsd(usage.model, tokens, r.ttlMs)
97  const cost = writeCostUsd(usage.model, tokens.written, r.ttlMs, totalInput)
98  const t = s.totals
99  const totals: Totals = {
100    requests: t.requests + 1,
101    read: t.read + tokens.read,
102    written: t.written + tokens.written,
103    uncached: t.uncached + tokens.uncached,
104    savingsUsd: t.savingsUsd + (saved ?? 0),
105    writeCostUsd: t.writeCostUsd + (cost ?? 0),
106    isPriced: t.isPriced || saved !== null,
107  }
108  const req: Req = { read: tokens.read, written: tokens.written, uncached: tokens.uncached, isBreak: brk !== null }
109  return {
110    next: {
111      cache,
112      history: [...s.history, req].slice(-HISTORY_LIMIT),
113      totals,
114      breaks: brk ? [...s.breaks, brk].slice(-BREAKS_KEPT) : s.breaks,
115    },
116    brk,
117  }
118}
119
hooks/live-prices.ts 104 lines
1// The price table from Anthropic's own pricing page, read when Claude Code starts and at most once a
2// day, so a new model or a changed price shows up without a new release of this plugin. What it
3// returns replaces the built-in table in pricing.ts; when the page cannot be read or does not parse,
4// the last good copy (or the built-in table) stays.
5
6import type { Price, PriceEntry } from './pricing'
7
8export const PRICING_URL = 'https://platform.claude.com/docs/en/about-claude/pricing.md'
9export const REFRESH_MS = 24 * 60 * 60_000
10
11// The money in a cell such as "$0.20 / MTok<sup>2</sup>".
12const dollars = (cell: string): number | null => {
13  const m = /\$\s*([0-9]+(?:\.[0-9]+)?)\s*\/\s*MTok/i.exec(cell)
14  return m ? Number(m[1]) : null
15}
16
17// "Claude Opus 5.5" is `claude-opus-5-5`; a model older than 4 puts its version first, as
18// "Claude Haiku 3.5" is `claude-3-5-haiku`. A note after the name is not part of it, including one
19// holding a link, as in "Claude Opus 4 ([retired](https://...))": the link goes first, whole.
20export const idOf = (name: string): string | null => {
21  const plain = name.replace(/\[[^\]]*\]\([^)]*\)/g, ' ').replace(/\([^)]*\)/g, ' ').replace(/\[[^\]]*\]/g, ' ').trim()
22  const m = /^Claude\s+([A-Za-z]+)\s+([0-9]+(?:\.[0-9]+)?)$/.exec(plain.replace(/\s+/g, ' '))
23  if (!m) return null
24  const family = m[1].toLowerCase()
25  const version = m[2].replace('.', '-')
26  return Number(m[2].split('.')[0]) < 4 ? `claude-${version}-${family}` : `claude-${family}-${version}`
27}
28
29// The tier a row names, for a model priced by prompt length: "(for prompts up to 100,000 tokens)"
30// or "(for prompts over 100,000 tokens)".
31const tierOf = (name: string): { kind: 'upTo' | 'over'; tokens: number } | null => {
32  const m = /for prompts (up to|over) ([0-9][0-9,]*) tokens/i.exec(name)
33  return m ? { kind: m[1].toLowerCase() === 'over' ? 'over' : 'upTo', tokens: Number(m[2].replace(/,/g, '')) } : null
34}
35
36const isPrice = (p: unknown): p is Price =>
37  typeof p === 'object' && p !== null &&
38  (['input', 'write5m', 'write1h', 'read', 'output'] as const).every(k => {
39    const v = (p as Record<string, unknown>)[k]
40    return typeof v === 'number' && Number.isFinite(v) && v >= 0
41  })
42
43// A table kept from an earlier read is used only if it has the shape parsePricing makes, so one saved
44// by another version, or damaged, can never break pricing.
45export const isPriceTable = (entries: unknown): entries is PriceEntry[] =>
46  Array.isArray(entries) && entries.length > 0 &&
47  entries.every(e => {
48    if (!Array.isArray(e) || e.length !== 2 || typeof e[0] !== 'string') return false
49    const p = e[1] as Record<string, unknown> | null
50    if (typeof p === 'object' && p !== null && 'upTo' in p) {
51      return typeof p.upTo === 'number' && p.upTo > 0 && isPrice(p.small) && isPrice(p.large)
52    }
53    return isPrice(p)
54  })
55
56// Reads the "Model pricing" table: one row per model with base input, 5 minute write, 1 hour write,
57// cache read and output. Null unless every row it keeps is whole and the table holds at least three
58// models, so a page that changed its shape never replaces a good table with a broken one.
59export function parsePricing(markdown: string): PriceEntry[] | null {
60  const start = markdown.search(/^##\s+Model pricing\s*$/m)
61  if (start < 0) return null
62  const section = markdown.slice(start).split(/^##\s/m)[1] ?? ''
63  const rows = section.split('\n').filter(line => line.trim().startsWith('|'))
64  const header = rows[0]?.split('|').map(c => c.trim().toLowerCase()) ?? []
65  const wanted = ['base input', '5m cache write', '1h cache write', 'cache hit', 'output']
66  const columns = wanted.map(w => header.findIndex(h => h.startsWith(w)))
67  if (columns.some(c => c < 0)) return null
68
69  const flat = new Map<string, Price>()
70  const tiers = new Map<string, { upTo?: { tokens: number; price: Price }; over?: Price }>()
71  for (const row of rows.slice(2)) {
72    const cells = row.split('|').map(c => c.trim())
73    const name = cells[1] ?? ''
74    const id = idOf(name)
75    // A model row this cannot name would drop that model's price without a word, so it doubts the page.
76    if (!id) {
77      if (/^claude\b/i.test(name)) return null
78      continue
79    }
80    const [input, write5m, write1h, read, output] = columns.map(c => dollars(cells[c] ?? ''))
81    if ([input, write5m, write1h, read, output].some(v => v === null || !Number.isFinite(v) || v < 0)) return null
82    const price: Price = { input: input!, write5m: write5m!, write1h: write1h!, read: read!, output: output! }
83    if (price.read > price.input) return null
84    const tier = tierOf(name)
85    if (!tier) {
86      if (!flat.has(id)) flat.set(id, price)
87      continue
88    }
89    const t = tiers.get(id) ?? {}
90    if (tier.kind === 'upTo') t.upTo = { tokens: tier.tokens, price }
91    else t.over = price
92    tiers.set(id, t)
93  }
94
95  const entries: PriceEntry[] = [...flat.entries()]
96  for (const [id, t] of tiers) {
97    if (!t.upTo || !t.over) return null
98    entries.push([id, { upTo: t.upTo.tokens, small: t.upTo.price, large: t.over }])
99  }
100  if (entries.length < 3) return null
101  // A longer id stands before any id it begins with, so `claude-opus-4-5` is matched before `claude-opus-4`.
102  return entries.sort((a, b) => b[0].length - a[0].length)
103}
104
hooks/pricing.ts 106 lines
1// Dollars per million tokens. The table Anthropic publishes is read when Claude Code starts
2// (live-prices.ts), so a new model or a changed price needs no new release of this plugin; until that
3// has worked, the table below stands in, copied from the same page
4// (https://platform.claude.com/docs/en/about-claude/pricing). A model in neither
5// has no price, and nothing here guesses one.
6export type Price = { input: number; write5m: number; write1h: number; read: number; output: number }
7
8// A model priced by prompt length (Claude Haiku 5.5): a prompt of more than `upTo` tokens, cache reads
9// and writes included, pays the second price for every token of that request.
10export type Tiered = { upTo: number; small: Price; large: Price }
11
12export type PriceEntry = readonly [string, Price | Tiered]
13
14const flat = (input: number, read: number, output: number): Price =>
15  ({ input, write5m: input * 1.25, write1h: input * 2, read, output })
16
17// First match wins, so a longer id stands before any id it begins with (`claude-opus-4-5` before
18// `claude-opus-4`).
19const BUILT_IN: readonly PriceEntry[] = [
20  ['claude-fable-5-1', flat(10, 0.25, 50)],
21  ['claude-mythos-5-1', flat(10, 0.25, 50)],
22  ['claude-fable-5', flat(10, 1, 50)],
23  ['claude-mythos-5', flat(10, 1, 50)],
24  ['claude-opus-5-5', flat(4, 0.2, 20)],
25  ['claude-opus-5', flat(5, 0.5, 25)],
26  ['claude-opus-4-8', flat(5, 0.5, 25)],
27  ['claude-opus-4-7', flat(5, 0.5, 25)],
28  ['claude-opus-4-6', flat(5, 0.5, 25)],
29  ['claude-opus-4-5', flat(5, 0.5, 25)],
30  ['claude-opus-4-1', flat(15, 1.5, 75)],
31  ['claude-opus-4', flat(15, 1.5, 75)],
32  ['claude-sonnet-5-5', flat(2, 0.1, 10)],
33  ['claude-sonnet-5', flat(2, 0.2, 10)],
34  ['claude-sonnet-4-6', flat(3, 0.3, 15)],
35  ['claude-sonnet-4-5', flat(3, 0.3, 15)],
36  ['claude-sonnet-4', flat(3, 0.3, 15)],
37  ['claude-haiku-5-5', { upTo: 100_000, small: flat(0.1, 0.01, 0.5), large: flat(0.5, 0.05, 2.5) }],
38  ['claude-haiku-4-5', flat(1, 0.1, 5)],
39  ['claude-3-5-haiku', flat(0.8, 0.08, 4)],
40]
41
42// The table read from Anthropic's page, once one has been; null until then.
43let live: readonly PriceEntry[] | null = null
44
45export const setLivePrices = (entries: readonly PriceEntry[] | null) => {
46  live = entries
47}
48
49// An id matches the model's name when it is the name, or the name begins with it followed by a dash,
50// a dot or a bracket, which covers dated ids and suffixes such as `[1m]`.
51const matches = (model: string, id: string) =>
52  model === id || (model.startsWith(id) && '-.['.includes(model[id.length]))
53
54const find = (table: readonly PriceEntry[] | null, model: string) =>
55  table?.find(([id]) => matches(model, id))?.[1] ?? null
56
57// The price one request pays, given how many tokens its prompt held. The page's table is asked first;
58// a model it does not list (or before it has been read) falls back to the built-in table.
59const priceOf = (model: string, promptTokens: number): Price | null => {
60  const entry = find(live, model) ?? find(BUILT_IN, model)
61  if (!entry) return null
62  return 'upTo' in entry ? (promptTokens > entry.upTo ? entry.large : entry.small) : entry
63}
64
65export const isPriced = (model: string) => priceOf(model, 0) !== null
66
67export type Tokens = {
68  read: number
69  written: number
70  uncached: number
71  output: number
72}
73
74const promptOf = (t: Tokens) => t.read + t.written + t.uncached
75
76const writePrice = (p: Price, ttlMs: number) => (ttlMs <= 600_000 ? p.write5m : p.write1h)
77
78// What a cache write costs, for a cache entry living ttlMs, in a request whose prompt held
79// promptTokens (a re-write of the whole context is its own prompt, so that is the default).
80export const writeCostUsd = (model: string, written: number, ttlMs: number, promptTokens = written): number | null => {
81  const p = priceOf(model, promptTokens)
82  return p ? (written * writePrice(p, ttlMs)) / 1e6 : null
83}
84
85// What reading tokens from a warm cache saved over re-writing them: each paid the read price instead
86// of the write price, in requests whose prompt held promptTokens.
87export const keptWarmUsd = (model: string, read: number, ttlMs: number, promptTokens = read): number | null => {
88  const p = priceOf(model, promptTokens)
89  return p ? (read * (writePrice(p, ttlMs) - p.read)) / 1e6 : null
90}
91
92// What one request cost in all, or null when the model has no price.
93export const requestCostUsd = (model: string, t: Tokens, ttlMs: number): number | null => {
94  const p = priceOf(model, promptOf(t))
95  if (!p) return null
96  return (t.uncached * p.input + t.read * p.read + t.written * writePrice(p, ttlMs) + t.output * p.output) / 1e6
97}
98
99// What the cache saved on one request: the reads at the plain input price, less what they cost as
100// reads, less the premium the writes paid over plain input.
101export const savingsUsd = (model: string, t: Tokens, ttlMs: number): number | null => {
102  const p = priceOf(model, promptOf(t))
103  if (!p) return null
104  return (t.read * (p.input - p.read) - t.written * (writePrice(p, ttlMs) - p.input)) / 1e6
105}
106
hooks/ttl.ts 55 lines
1const HOUR_MS = 3_600_000
2const FIVE_MIN_MS = 300_000
3
4// How long a cache entry lives, and whether that is known or assumed.
5export type TtlInfo = { ms: number; isKnown: boolean }
6
7// The setting wins; on auto, what the transcript showed; until then an hour is assumed.
8export function ttlInfo(setting: string, learnedMs: number | null): TtlInfo {
9  if (setting === '1h') return { ms: HOUR_MS, isKnown: true }
10  if (setting === '5m') return { ms: FIVE_MIN_MS, isKnown: true }
11  return learnedMs === null ? { ms: HOUR_MS, isKnown: false } : { ms: learnedMs, isKnown: true }
12}
13
14type Split = { ephemeral_1h_input_tokens?: unknown; ephemeral_5m_input_tokens?: unknown }
15
16const count = (n: unknown) => (typeof n === 'number' && n > 0 ? n : 0)
17
18// Reads the newest cache write's split out of the tail of a session transcript: the entry lives as
19// long as the larger part of what the request wrote. Null when no line shows a write.
20export function parseTtl(tail: string): number | null {
21  const lines = tail.split('\n')
22  for (let i = lines.length - 1; i >= 0; i--) {
23    const line = lines[i]
24    if (!line || !line.includes('cache_creation')) continue
25    try {
26      const entry = JSON.parse(line) as { message?: { usage?: { cache_creation?: Split } }; usage?: { cache_creation?: Split } }
27      const split = entry.message?.usage?.cache_creation ?? entry.usage?.cache_creation
28      if (!split) continue
29      const h = count(split.ephemeral_1h_input_tokens)
30      const m = count(split.ephemeral_5m_input_tokens)
31      if (h + m === 0) continue
32      return m > h ? FIVE_MIN_MS : HOUR_MS
33    } catch {
34      // A line cut by the start of the tail, or one that is not a request
35    }
36  }
37  return null
38}
39
40const LEADS: Record<string, number> = { '1m': 60_000, '2m': 120_000, '4m': 240_000, '8m': 480_000 }
41
42// How long before expiry keep warm acts (and the warning shows): 4 minutes of an hour, 40 seconds
43// of five minutes, and never more than half the entry's life.
44export function leadMs(setting: string, ttlMs: number): number {
45  const auto = ttlMs >= 600_000 ? 240_000 : 40_000
46  return Math.min(LEADS[setting] ?? auto, ttlMs / 2)
47}
48
49export const leadLabel = (ms: number): string => (ms >= 60_000 ? `${Math.round(ms / 60_000)}m` : `${Math.round(ms / 1000)}s`)
50
51const CAPS: Record<string, number> = { '1h': 3_600_000, '3h': 10_800_000, '8h': 28_800_000 }
52
53// How long the person may be idle before keep warm stops; null is no cap.
54export const idleCapMs = (setting: string): number | null => CAPS[setting] ?? null
55
hooks/view.ts 109 lines
1import type { BreakInfo, CacheSettings, CacheState, Pings, Req, Totals } from '../types'
2import { fmtApprox, fmtClock, fmtTokens, pct, fmtPct } from './format'
3import { keptWarmUsd, writeCostUsd } from './pricing'
4import { ttlInfo, type TtlInfo } from './ttl'
5
6// Everything the band draws, read once per redraw.
7export type View = {
8  cache: CacheState
9  ttl: TtlInfo
10  leftMs: number
11  isExpired: boolean
12  history: readonly Req[]
13  totals: Totals
14  breaks: readonly BreakInfo[]
15  settings: CacheSettings
16  pings: Pings
17  paused: string
18  // Which keep-warm picker is open ('lead' or 'cap'), else empty; always empty while keep warm is off
19  picker: Picker
20  // Whether the band shows the session's detail above its first line
21  expanded: boolean
22  // A ping is on its way, so Warm now says so and does nothing more until it is back
23  isPinging: boolean
24  // What the last Warm now did, for a few seconds after it came back; else empty
25  notice: string
26  // Whether the Desktop band is tucked away to its chip
27  hidden: boolean
28}
29
30export type Picker = '' | 'lead' | 'cap'
31
32export type Tone = 'fg' | 'muted' | 'warm' | 'warn' | 'danger' | 'accent'
33
34export function makeView(a: {
35  now: number
36  ttlSetting: string
37  cache: CacheState
38  history: readonly Req[]
39  totals: Totals
40  breaks: readonly BreakInfo[]
41  settings: CacheSettings
42  pings: Pings
43  paused: string
44  picker: string
45  expanded: boolean
46  isPinging: boolean
47  notice: string
48  hidden: boolean
49}): View {
50  const ttl = ttlInfo(a.ttlSetting, a.cache.ttlMs)
51  const { now, picker, ...rest } = a
52  const leftMs = a.cache.startedAt + ttl.ms - now
53  const isPickerOpen = a.settings.keepWarm && (picker === 'lead' || picker === 'cap')
54  return { ...rest, ttl, leftMs, isExpired: leftMs <= 0, picker: isPickerOpen ? (picker as Picker) : '' }
55}
56
57// Green while warm, amber in the last sixth of the entry's life, red in the last thirtieth, grey once expired.
58export function stateTone(v: View): Tone {
59  if (v.isExpired) return 'muted'
60  if (v.leftMs <= v.ttl.ms / 30) return 'danger'
61  if (v.leftMs <= v.ttl.ms / 6) return 'warn'
62  return 'warm'
63}
64
65export const stateWord = (v: View): string =>
66  v.isExpired ? 'Expired' : v.leftMs <= v.ttl.ms / 6 ? 'Expiring soon' : 'Warm'
67
68export const hitRate = (r: { read: number; written: number; uncached: number }): number =>
69  pct(r.read, r.read + r.written + r.uncached)
70
71export const lastRequest = (v: View): Req | null => v.history[v.history.length - 1] ?? null
72
73export const sessionHitRate = (v: View): number => hitRate(v.totals)
74
75// What the next message re-writes once the entry has expired, and what that write costs.
76export function rewriteCost(v: View): number | null {
77  return v.cache.ctx > 0 ? writeCostUsd(v.cache.model, v.cache.ctx, v.ttl.ms) : null
78}
79
80// What the person sees of how long an entry lives: the length, or the assumed hour.
81export const ttlLabel = (v: View): string =>
82  v.ttl.ms >= 600_000 ? (v.ttl.isKnown ? '1 hour cache' : '1 hour cache, assumed') : '5 minute cache'
83
84// The pings so far, in the band's keep-warm row, piece by piece. A ping is a background request that
85// reads the cache and so keeps it warm; one that rebuilt a lapsed cache is told apart from one that
86// kept it warm. What they saved is the tokens they read times the write price less the read price:
87// each read cost a read instead of a re-write, priced at the size of one ping's context.
88export function pingsItems(v: View): string[] {
89  const p = v.pings
90  if (p.count === 0 && p.rebuilds === 0) return ['No pings yet.']
91  const parts = [p.count > 0 ? `${p.count} warm ping${p.count === 1 ? '' : 's'}` : 'No ping has kept it warm yet']
92  if (p.count > 0) parts.push(`${fmtTokens(p.read)} tokens read`)
93  if (p.rebuilds > 0) parts.push(`${p.rebuilds} rebuilt a lapsed cache`)
94  const saved = p.count > 0 && p.isPriced ? keptWarmUsd(v.cache.model, p.read, v.ttl.ms, p.read / p.count) : null
95  if (saved !== null) parts.push(`cost saved ${fmtApprox(saved)}`)
96  // With nothing kept warm there is no saving to weigh a rebuild against, so its cost stands alone.
97  else if (p.count === 0 && p.isPriced) parts.push(`cost ${fmtApprox(p.costUsd)}`)
98  return parts
99}
100
101// What /cache-maxxer says where nothing draws (a -p run).
102export function summaryText(v: View): string {
103  const keep = `Keep warm is ${v.settings.keepWarm ? 'on' : 'off'}.`
104  if (v.cache.startedAt === 0) return `Cache Maxxer: no cache yet. ${keep}`
105  const state = v.isExpired ? 'expired' : `${stateWord(v).toLowerCase()}, ${fmtClock(v.leftMs, v.ttl.ms)} left`
106  const hit = v.totals.requests > 0 ? ` ${fmtPct(sessionHitRate(v))} hit rate over ${v.totals.requests} requests.` : ''
107  return `Cache Maxxer: ${ttlLabel(v)}, ${state}.${hit} ${keep}`
108}
109
hooks/layout.ts 30 lines
1// Items placed left to right with `gap` columns between them, onto a new row when the next one would
2// pass `room`. An item wider than a row takes a row of its own.
3export function flowRows<T>(items: readonly T[], widthOf: (item: T) => number, gap: number, room: number): T[][] {
4  const rows: T[][] = []
5  let used = 0
6  for (const item of items) {
7    const w = widthOf(item)
8    const row = rows[rows.length - 1]
9    if (row && used + gap + w <= room) {
10      row.push(item)
11      used += gap + w
12    } else {
13      rows.push([item])
14      used = w
15    }
16  }
17  return rows
18}
19
20// Text cut at spaces into lines of at most `room` columns (a word longer than a line takes its own).
21export function wrapWords(text: string, room: number): string[] {
22  const lines: string[] = []
23  for (const word of text.split(' ')) {
24    const last = lines[lines.length - 1]
25    if (last !== undefined && last.length + 1 + word.length <= room) lines[lines.length - 1] = `${last} ${word}`
26    else lines.push(word)
27  }
28  return lines
29}
30
types/index.d.ts 87 lines
1// What the band draws from. Every value is plain JSON, so it survives a reload of the module.
2
3// One request of the main conversation.
4export type Req = {
5  // Tokens read from the cache, written to it, and sent uncached
6  read: number
7  written: number
8  uncached: number
9  isBreak: boolean
10}
11
12// The conversation's cache: when the last request that read or wrote it started (0 before one),
13// how long an entry lives (null until the transcript has shown a write), the model that wrote it,
14// the size of the context its next request sends, and what the last main request read plus wrote
15// (0 before one), which is what a later request is compared with to tell whether the prefix was lost.
16export type CacheState = {
17  startedAt: number
18  ttlMs: number | null
19  model: string
20  ctx: number
21  cached: number
22}
23
24// Session totals. The dollar figures count only requests whose model has a known price.
25export type Totals = {
26  requests: number
27  read: number
28  written: number
29  uncached: number
30  savingsUsd: number
31  writeCostUsd: number
32  isPriced: boolean
33}
34
35export type BreakInfo = {
36  at: number
37  written: number
38  costUsd: number | null
39  cause: string
40}
41
42// What the person can change while the session runs. The toggle persists as the default for new sessions.
43export type CacheSettings = {
44  keepWarm: boolean
45  lead: string
46  idleCap: string
47}
48
49// Keep-warm pings made so far this session. A ping that found the cache already gone and rebuilt it
50// is counted in `rebuilds`, not in `count` or `read`; its cost is in `costUsd` with the rest.
51export type Pings = {
52  count: number
53  read: number
54  rebuilds: number
55  costUsd: number
56  isPriced: boolean
57}
58
59declare module 'claude-code' {
60  interface PluginState {
61    'cache-maxxer': {
62      cache: CacheState
63      history: readonly Req[]
64      totals: Totals
65      breaks: readonly BreakInfo[]
66      settings: CacheSettings
67      pings: Pings
68      // When the person last sent something (ms since the epoch), for the idle cap
69      activity: number
70      // The idle cap's label once keep warm has stopped for lack of activity, else empty
71      paused: string
72      // Which keep-warm choice has its options open in the band ('lead' or 'cap'), else empty
73      picker: string
74      // Whether the band shows the session's detail above its first line
75      expanded: boolean
76      // The second the countdown last moved, written each second so the band redraws
77      tick: number
78      // Whether a ping is on its way, so Warm now says Warming… until it is back
79      pinging: boolean
80      // What the last Warm now did, and until when (ms since the epoch) the Desktop band says so
81      notice: { text: string; until: number }
82      // Whether the Desktop band is tucked away to its chip
83      hidden: boolean
84    }
85  }
86}
87