Keeps the one-hour prompt cache warm during /keepwarm windows and holds a cold send once with its estimated rewrite cost. Pinging runs only after /keepwarm…

A Claude Code mod that keeps the one-hour prompt cache warm while you are away, and holds a cold send once to show its estimated rewrite cost. Pinging runs only after you arm it with /keepwarm.
/keepwarm arms a window (6 hours by default). After 50 idle minutes it sends one short fork of the conversation so the cache is read again before it expires. Each new turn of yours restarts the 50-minute countdown./cache-tax guard warn shows the price and sends straight away.warm, cold, unknown or stopped), a warmth meter, the next ping, the time left in the window and the last ping's cost. On desktop the row leads with the state word; the terminal puts cache-tax before it. When keepwarm has stopped, the row gives the reason and says /keepwarm starts it again. Other surfaces get the plain status text. NO_COLOR swaps the icon for a bold [>].pinging, and once the cache is cold it shows the rewrite price. Desktop draws it as an image on the right of the row. The terminal, and NO_COLOR anywhere, draws it as a row of text cells with Xm left, pinging or ≈$X to re-write. A terminal under 50 columns leaves the meter out. On desktop the fill drains as the minutes pass and a running ping pulses; under reduced motion it stays still. While a message is held, the card shows the price and the row leaves the meter out./clear and paste a summary. A short band gets a one-line card with the same buttons. The card shows whether or not a window is armed./cache-tax shows the model, cache state, context size, the cold rewrite price, a break-even figure for pings and the cold writes paid this session./clear and on a resume.| Command | What it does |
|---|---|
/keepwarm | Arms a 6-hour window. |
/keepwarm 90m | Arms a window of your own length (6h, 90m, 2h30m). |
/keepwarm 6h every 2m | Same, with a custom ping period (at least 1m). The period lasts for that window only. |
/keepwarm always | Arms 6 hours now and at every later session start, remembered across sessions. |
/keepwarm off | Stops the window and turns always off. |
/keepwarm status | Shows the window, next ping and last ping. |
/cache-tax or /cache-tax status | Shows the cost card. |
/cache-tax guard refuse | Drops a cold send once (the default). |
/cache-tax guard warn | Logs the price of a cold send and sends it. |
None. The mod declares no userConfig. The guard mode and the always switch are set with the commands above.
hooks/register.ts (PRICES, list prices per million tokens for Fable, Opus, Sonnet and Haiku families). Unknown models show n/a./keepwarm. It arms nothing on its own./clear resets the clock, context, tally and any held message. A compaction resets the state until the next turn. Subagent turns never move the timer.config.set (theme key), ui.render (AbovePrompt), session.start, classic.SessionStart, command.run (keepwarm, cache-tax), prompt.submit, turn.step, turn.complete, session.compact./keepwarm and /cache-tax. It registers no model tools.$.model.fork per ping, only inside an armed window and only while the cache counts as warm. The prompt is the fixed text Reply with the single word: warm. This is a normal Claude Code API request over the session's own transcript.$.prompt.submit per press of Send anyway, with your held message's text, sent as you. It runs only in refuse mode, only on your press, and only while the cache is still cold./, whether it has attachments or an @-mention, and whether you typed it. In refuse mode it can drop one message, and keeps the text of a message you typed in memory so Send anyway can submit it. The resend always goes through. Messages from plugins and slash commands pass untouched.$.clock.after for each ping and the window's end, plus a minute timer that redraws the band while a window is armed or a message is held.NO_COLOR once at session start, the session id, model, token usage, the band's width and height, and the command list (to warn if the separate hook form, cache-tax:status, is also installed).deadline:<session id>, every:<session id>, always and guard. Ended windows of this session and the old bare deadline/every keys are deleted at start. The cold-write tally and the held message live in memory.hooks/status-icons.ts, and the meter and the card are drawn as SVG by hooks/draw.ts.claude plugin marketplace add MrValraven/pulse-mods
claude plugin install pulse-cache-tax@pulse-mods --scope user
Then start a new Claude Code session.
Fork of karanb192/cache-tax by Karan Bansal, MIT licence. See LICENSE. This fork removes the automatic keepwarm window that the original arms after a paid cold write.
hooks/register.ts 702 lines1import type { EngineInterface, Register, RenderChildren, RenderElement } from 'claude-code'
2import { fmtDuration, fmtTok, fmtUsd, HOLD_TITLE_H, holdBarsAlt, holdBarsHeight, holdBarsSvg, holdTitleAlt, holdTitleSvg, isSoonCold, meterAlt, meterSvg, METER_H, minutesLeft, textMeter } from './draw'
3import type { Hold, Warmth } from './draw'
4import { statusIcons } from './status-icons'
5
6export { fmtDuration }
7
8const TTL_MS = 60 * 60 * 1000
9const PING_AFTER_MS = 50 * 60 * 1000
10const MIN_PING_MS = 60 * 1000
11const DEFAULT_WINDOW_MS = 6 * 60 * 60 * 1000
12const BIG_TOKENS = 50000
13const PING_PROMPT = 'Reply with the single word: warm'
14// The band redraws once a minute while a window is armed, so its countdowns stay true.
15const TICK_MS = 60 * 1000
16const KEY_DEADLINE = 'deadline'
17const KEY_EVERY = 'every'
18const KEY_GUARD = 'guard'
19const KEY_ALWAYS = 'always'
20
21// $ per million tokens, [cache read, 1h cache write, output], list prices September 2026.
22// Longer family names first: a model id matches the first row it contains.
23const PRICES: Array<[string, number, number, number]> = [
24 ['fable-5-1', 0.25, 20, 50],
25 ['fable-5', 1, 20, 50],
26 ['opus-5', 0.5, 10, 25],
27 ['opus-4', 0.5, 10, 25],
28 ['sonnet-5', 0.2, 4, 10],
29 ['sonnet', 0.3, 6, 15],
30 ['haiku', 0.1, 2, 5],
31]
32
33type PingRecord = { at: number; read: number; write: number; usd: number | null; warm: boolean }
34type Miss = { at: number; tokens: number; usd: number | null }
35type GuardMode = 'refuse' | 'warn'
36/** A cold send the guard dropped: kept in memory only, for the band's Send anyway, until the next turn. */
37type HeldPrompt = { text: string; isResendable: boolean }
38
39export type State = {
40 hasBand: boolean
41 sid: string
42 deadline: number
43 every: number
44 always: boolean
45 lastRequestAt: number
46 lastModel: string | null
47 ctx: number
48 compacted: boolean
49 guard: GuardMode
50 ackedAt: number
51 /** The guard's ack for prompts the person did not type (notifications, scheduled prompts): one drop, then the next goes through. */
52 notificationAckedAt: number
53 coldWritePending: boolean
54 misses: Miss[]
55 pending: { cancel: () => void } | null
56 last: PingRecord | null
57 stopped: string | null
58 isPinging: boolean
59 held: HeldPrompt | null
60 ticker: { cancel: () => void } | null
61 /** The last request the ticker's phase was set from. */
62 tickerAnchor: number
63}
64
65function priceOf(model: string | null): [number, number, number] | null {
66 const m = (model ?? '').toLowerCase().replace(/[\s.]+/g, '-')
67 for (const [family, read, write, output] of PRICES) if (m.includes(family)) return [read, write, output]
68 return null
69}
70
71export function parseDuration(text: string): number | null {
72 const m = /^(?:(\d+)h)?(?:(\d+)m)?$/.exec(text.trim())
73 if (!m || (m[1] === undefined && m[2] === undefined)) return null
74 return (Number(m[1] ?? 0) * 60 + Number(m[2] ?? 0)) * 60 * 1000
75}
76
77function coldUsd(s: State): number | null {
78 const price = priceOf(s.lastModel)
79 return price ? s.ctx * price[1] / 1e6 : null
80}
81
82function warmUsd(s: State): number | null {
83 const price = priceOf(s.lastModel)
84 return price ? s.ctx * price[0] / 1e6 : null
85}
86
87/** Everything a fork bills: the cache read, any cache write, uncached input at the base rate (half the 1h write rate), and the output. */
88function pingUsd(u: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }, price: [number, number, number]): number {
89 return (u.cache_read_input_tokens * price[0] + u.cache_creation_input_tokens * price[1] + u.input_tokens * price[1] / 2 + u.output_tokens * price[2]) / 1e6
90}
91
92/** The read-only upper bound: pings at the cache-read rate that cost as much as one cold write of the same context. */
93function breakEvenPings(s: State): number | null {
94 const price = priceOf(s.lastModel)
95 if (!price || s.ctx <= 0) return null
96 return Math.floor(price[1] / price[0])
97}
98
99function isCold(s: State, now: number): boolean {
100 return s.lastRequestAt > 0 && !s.compacted && now - s.lastRequestAt >= TTL_MS
101}
102
103function guardText(s: State, now: number): string {
104 const price = priceOf(s.lastModel)
105 const rate = price ? `$${price[1]}/MTok` : 'the cache-write rate'
106 const warm = warmUsd(s)
107 return `the prompt cache went cold ${fmtDuration(now - s.lastRequestAt - TTL_MS)} ago. Sending this re-writes ` +
108 `up to ${s.ctx.toLocaleString('en-US')} tokens at ${rate} = ${fmtUsd(coldUsd(s))}` +
109 (warm == null ? '' : ` (a warm turn would have cost ${fmtUsd(warm)})`) + '.'
110}
111
112export type ResumeFields = {
113 source: string
114 model?: string
115 context_tokens?: number
116 seconds_since_last_response?: number
117 prompt_cache_likely_expired?: boolean
118 estimated_cache_write_usd?: number
119}
120
121/** /clear starts a new conversation in the same process; nothing priced before it still exists. */
122export function resetForClear(s: State) {
123 s.ctx = 0
124 s.lastRequestAt = 0
125 s.compacted = false
126 s.ackedAt = 0
127 s.notificationAckedAt = 0
128 s.coldWritePending = false
129 s.misses = []
130 s.held = null
131 disarm(s)
132}
133
134/** Applies a resumed session's fields to the state; returns the line to log, if any. */
135export function seedFromResume(s: State, e: ResumeFields, now: number): string | null {
136 if (e.source !== 'resume' && e.source !== 'fork') return null
137 if (typeof e.context_tokens === 'number' && e.context_tokens > 0) s.ctx = e.context_tokens
138 if (typeof e.seconds_since_last_response === 'number') s.lastRequestAt = now - e.seconds_since_last_response * 1000
139 if (typeof e.model === 'string') s.lastModel = e.model
140 s.compacted = false
141 if (e.prompt_cache_likely_expired !== true || s.ctx < BIG_TOKENS) return null
142 const usd = typeof e.estimated_cache_write_usd === 'number' ? fmtUsd(e.estimated_cache_write_usd) : fmtUsd(coldUsd(s))
143 return `resuming cold. The first message re-writes ${s.ctx.toLocaleString('en-US')} tokens, about ${usd}. /clear and paste a summary if you only need the conclusions.`
144}
145
146function statusText(s: State, now: number): string | undefined {
147 if (s.stopped) return `keepwarm stopped: ${s.stopped}`
148 if (!s.deadline) return undefined
149 const pingText = s.last ? ` · last ping read ${fmtTok(s.last.read)} ${fmtUsd(s.last.usd)}` : ''
150 const nextText = !s.lastRequestAt ? ' · waiting for the first turn'
151 : s.compacted ? ' · waiting for the first turn after compaction'
152 : isCold(s, now) ? ` · cold now, first ping ${fmtDuration(s.every)} after the next turn`
153 : ` · ping in ${fmtDuration(s.lastRequestAt + s.every - now)}`
154 return `keepwarm ${fmtDuration(s.deadline - now)} left${nextText}${pingText}`
155}
156
157function updateStatus($: EngineInterface, s: State, now: number) {
158 if (s.hasBand) $.ui.invalidate('ui.render')
159 else $.ui.status(statusText(s, now))
160 syncTicker($, s, now)
161}
162
163function redraw($: EngineInterface, s: State) {
164 if (s.hasBand) $.ui.invalidate('ui.render')
165}
166
167/** Redraws the band each minute while a window is armed or a send is held; the status line keeps its own updates. */
168function syncTicker($: EngineInterface, s: State, now: number) {
169 const shouldTick = s.hasBand && (s.deadline > now || isHeld(s, now))
170 // A new request moves the minute boundary, so a ticker set from the old one starts again.
171 if (s.ticker && (!shouldTick || s.tickerAnchor !== s.lastRequestAt)) {
172 s.ticker.cancel()
173 s.ticker = null
174 }
175 if (!shouldTick || s.ticker) return
176 // The meter counts whole minutes from the last request, so the redraw lands as its minute turns.
177 s.tickerAnchor = s.lastRequestAt
178 const intoMinute = s.lastRequestAt ? (((now - s.lastRequestAt) % TICK_MS) + TICK_MS) % TICK_MS : 0
179 s.ticker = $.clock.after(Math.max(1000, TICK_MS - intoMinute), () => {
180 s.ticker = null
181 $.ui.invalidate('ui.render')
182 void $.clock.now().then(at => syncTicker($, s, at))
183 })
184}
185
186/** A cold send the guard dropped, still waiting for the person to send it again or cancel. */
187function isHeld(s: State, now: number): boolean {
188 return s.held !== null && s.guard === 'refuse' && s.ackedAt !== 0 && s.ackedAt === s.lastRequestAt && isCold(s, now)
189}
190
191/** What the band's meter shows; null while nothing is known about the cache, or keepwarm stopped. */
192function warmthOf(s: State, now: number): Warmth | null {
193 if (s.stopped || !s.lastRequestAt || s.compacted) return null
194 const usd = coldUsd(s)
195 const coldPrice = usd == null ? null : fmtUsd(usd)
196 if (isCold(s, now)) return { tone: 'cold', msLeft: 0, ttlMs: TTL_MS, pingMsLeft: null, coldPrice }
197 const isPingAhead = s.deadline > 0 && s.every < TTL_MS && s.lastRequestAt + s.every < s.deadline
198 return { tone: s.isPinging ? 'pinging' : 'warm', msLeft: s.lastRequestAt + TTL_MS - now, ttlMs: TTL_MS, pingMsLeft: isPingAhead ? TTL_MS - s.every : null, coldPrice }
199}
200
201/** The band's words after the state: what happens next, the window, the last ping. The status line's phrases, reordered; the state word already says cold. */
202function bandDetails(s: State, now: number): string {
203 if (s.stopped) return `${s.stopped} · /keepwarm starts it again`
204 const next = !s.lastRequestAt ? 'waiting for the first turn'
205 : s.compacted ? 'waiting for the first turn after compaction'
206 : isCold(s, now) ? `first ping ${fmtDuration(s.every)} after the next turn`
207 : s.isPinging ? 'pinging now'
208 : `ping in ${fmtDuration(s.lastRequestAt + s.every - now)}`
209 const pingText = s.last ? ` · last ping read ${fmtTok(s.last.read)} ${fmtUsd(s.last.usd)}` : ''
210 return `${next} · keepwarm ${fmtDuration(s.deadline - now)} left${pingText}`
211}
212
213/** The band's Send anyway: the same as sending the held message again from the prompt. */
214async function sendHeld($: EngineInterface, s: State) {
215 const held = s.held
216 if (!held) return
217 // Taken at once, so a second press finds nothing left to send.
218 s.held = null
219 if (!isCold(s, await $.clock.now()) || s.guard !== 'refuse' || s.ackedAt === 0 || s.ackedAt !== s.lastRequestAt) return
220 const ackedAt = s.ackedAt
221 s.ackedAt = 0
222 s.coldWritePending = true
223 redraw($, s)
224 let failure: string | null = null
225 try {
226 const result = await $.prompt.submit({ text: held.text, asUser: true })
227 if (result.drop !== undefined) failure = result.drop
228 } catch (err) {
229 failure = err instanceof Error ? err.message : String(err)
230 }
231 if (failure === null) return
232 // Nothing entered: the card comes back as it was.
233 s.held = held
234 s.ackedAt = ackedAt
235 s.coldWritePending = false
236 $.ui.log(`cache-tax: the resend did not go through, ${failure}`)
237 redraw($, s)
238}
239
240/** The band's Discard message: the message stays unsent and the guard holds the next cold send again. */
241function cancelHeld($: EngineInterface, s: State) {
242 s.held = null
243 s.ackedAt = 0
244 redraw($, s)
245}
246
247function disarm(s: State) {
248 if (s.pending) s.pending.cancel()
249 s.pending = null
250}
251
252// The window and its ping period belong to the session that armed them, so a
253// second session, or one resumed from another transcript, never inherits them
254// and cannot turn them off. The always switch and the guard mode stay global.
255function deadlineKey(s: State): string {
256 return `${KEY_DEADLINE}:${s.sid}`
257}
258
259function everyKey(s: State): string {
260 return `${KEY_EVERY}:${s.sid}`
261}
262
263/** Clears this session's own dead window and the bare keys a store written before 2.1.1 still holds. Other sessions' keys are never touched: a read followed by a delete cannot be made atomic against their renewal. */
264async function prune($: EngineInterface, s: State, now: number) {
265 for (const key of [KEY_DEADLINE, deadlineKey(s)]) {
266 const deadline = await $.store.get(key)
267 if (deadline === undefined) continue
268 if (typeof deadline === 'number' && deadline > now) continue
269 await $.store.delete(key)
270 await $.store.delete(KEY_EVERY + key.slice(KEY_DEADLINE.length))
271 }
272}
273
274async function stop($: EngineInterface, s: State, why: string | null, forgetAlways = false) {
275 s.deadline = 0
276 s.every = PING_AFTER_MS
277 s.stopped = why
278 disarm(s)
279 await $.store.delete(deadlineKey(s))
280 await $.store.delete(everyKey(s))
281 if (forgetAlways) {
282 s.always = false
283 await $.store.delete(KEY_ALWAYS)
284 }
285 updateStatus($, s, await $.clock.now())
286}
287
288async function arm($: EngineInterface, s: State) {
289 disarm(s)
290 if (!s.deadline) return
291 const now = await $.clock.now()
292 if (now >= s.deadline) return stop($, s, null)
293 // A cold window still needs expiry cleanup, but must not send a model request.
294 if (s.lastRequestAt && !s.compacted && !isCold(s, now)) {
295 const untilCold = s.lastRequestAt + TTL_MS - now
296 const delay = Math.min(s.deadline - now, untilCold, Math.max(1000, s.lastRequestAt + s.every - now))
297 s.pending = $.clock.after(delay, () => { void ping($, s) })
298 } else {
299 s.pending = $.clock.after(s.deadline - now, () => { void arm($, s) })
300 }
301 updateStatus($, s, now)
302}
303
304async function ping($: EngineInterface, s: State) {
305 s.pending = null
306 if (!s.deadline) return
307 const now = await $.clock.now()
308 if (now >= s.deadline) return arm($, s)
309 // A turn in the meantime re-armed the timer; this callback is stale.
310 if (isCold(s, now)) return arm($, s)
311 if (now - s.lastRequestAt < s.every - 1000) return
312 s.isPinging = true
313 redraw($, s)
314 let reply
315 try {
316 reply = await $.model.fork({ prompt: PING_PROMPT })
317 } catch (err) {
318 s.isPinging = false
319 return stop($, s, `the ping failed, ${err instanceof Error ? err.message : String(err)}`)
320 }
321 s.isPinging = false
322 if (reply === null) return stop($, s, 'the engine did not send the ping, either the snapshot was cold or the API call failed')
323 if (reply.isAnswered === false) {
324 const reason = reply.reason === 'nothing-to-fork' ? 'no conversation to warm yet'
325 : reply.reason === 'api-error' ? `the API call failed${reply.status === null ? '' : ` (${reply.status})`}`
326 : reply.reason === 'aborted' ? 'the ping was interrupted'
327 : 'the ping returned no text'
328 return stop($, s, reason)
329 }
330 const u = reply.usage
331 const price = priceOf(s.lastModel)
332 // A warm ping reads the prefix and writes only its own message; a write of a tenth of the read or more means the prefix broke.
333 const warm = u.cache_read_input_tokens > 0 && u.cache_creation_input_tokens < 0.1 * u.cache_read_input_tokens
334 const usd = price ? pingUsd(u, price) : null
335 s.last = { at: now, read: u.cache_read_input_tokens, write: u.cache_creation_input_tokens, usd, warm }
336 if (!warm) return stop($, s, `the ping read ${fmtTok(u.cache_read_input_tokens)} and wrote ${fmtTok(u.cache_creation_input_tokens)} tokens (${fmtUsd(usd)}), the cache was already gone`)
337 s.lastRequestAt = now
338 await arm($, s)
339}
340
341/** The reply to an arming command; on a cold cache it says when the first ping can come. */
342function armedText(s: State, now: number, windowMs: number): string {
343 if (isCold(s, now)) return `keepwarm on for ${fmtDuration(windowMs)}. The cache is cold now, so the first ping comes ${fmtDuration(s.every)} after the next turn`
344 return `keepwarm on for ${fmtDuration(windowMs)}, a ping ${fmtDuration(s.every)} after each idle stretch keeps the cache warm, so the next turn reads it at the cache-read rate`
345}
346
347async function startWindow($: EngineInterface, s: State, windowMs: number, every: number) {
348 const now = await $.clock.now()
349 s.every = every
350 if (every === PING_AFTER_MS) await $.store.delete(everyKey(s))
351 else await $.store.set(everyKey(s), every)
352 s.deadline = now + windowMs
353 s.stopped = null
354 await $.store.set(deadlineKey(s), s.deadline)
355 await arm($, s)
356}
357
358function card(s: State, now: number): string {
359 const lines: string[] = []
360 lines.push(`${s.lastModel ?? 'model not seen yet'}`)
361 if (s.compacted) lines.push('state reset by compaction, waiting for the first turn')
362 else if (!s.lastRequestAt) lines.push('state no request yet this session')
363 else if (isCold(s, now)) lines.push(`state COLD, last request ${fmtDuration(now - s.lastRequestAt)} ago`)
364 else lines.push(`state warm, ${fmtDuration(s.lastRequestAt + TTL_MS - now)} left`)
365 lines.push(`context ${s.ctx.toLocaleString('en-US')} tokens`)
366 lines.push(`cold cost ${fmtUsd(coldUsd(s))} to re-write it (warm turn ${fmtUsd(warmUsd(s))})`)
367 const always = s.always ? ' (always)' : ''
368 const idle = s.always ? 'off until the next session start, which arms 6h00m (always)' : 'off (/keepwarm to arm it for 6h00m)'
369 lines.push(`keepwarm ${s.deadline ? (statusText(s, now) ?? '').replace(/^keepwarm /, 'on, ') + always : s.stopped ? `stopped, ${s.stopped}${always}` : idle}`)
370 const pings = breakEvenPings(s)
371 if (pings != null) lines.push(`break-even up to ${pings} pings at the read rate cost one cold write, about ${fmtDuration(pings * s.every)} of idle at one ping per ${fmtDuration(s.every)}`)
372 lines.push(`guard ${s.guard === 'refuse' ? 'refuse once (/cache-tax guard warn to only show the price)' : 'warn only (/cache-tax guard refuse to be stopped once)'}`)
373 const paid = s.misses.reduce((a, m) => a + (m.usd ?? 0), 0)
374 lines.push(`session ${s.misses.length} cold write${s.misses.length === 1 ? '' : 's'} paid, ${fmtUsd(paid)}`)
375 return lines.join('\n')
376}
377
378export function freshState(): State {
379 return {
380 hasBand: false, sid: '', deadline: 0, every: PING_AFTER_MS, always: false, lastRequestAt: 0, lastModel: null, ctx: 0, compacted: false,
381 guard: 'refuse', ackedAt: 0, notificationAckedAt: 0, coldWritePending: false, misses: [], pending: null, last: null, stopped: null,
382 isPinging: false, held: null, ticker: null, tickerAnchor: 0,
383 }
384}
385
386export const register: Register = on => {
387 const s = freshState()
388 let light = false
389 let noColor = false
390
391 on('config.set', { key: 'theme' }, async ($, e, next) => {
392 const result = await next(e)
393 if (result.deny === undefined) {
394 light = typeof result.value === 'string' && result.value.startsWith('light')
395 if (s.hasBand) $.ui.invalidate('ui.render')
396 }
397 return result
398 })
399
400 // The band: one row for the cache (icon, state, what comes next, a warmth meter), and a decision card while a cold send is held.
401 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
402 const rest = await next(e)
403 if (!s.hasBand || e.props.hasSurvey) return rest
404 const now = await $.clock.now()
405 const hasRow = Boolean(statusText(s, now)) && !(s.deadline && now >= s.deadline)
406 const isHeldNow = isHeld(s, now)
407 if (!hasRow && !isHeldNow) return rest
408 const { Box, Text, Button } = $.ui.resolve(e)
409 const coral = noColor ? undefined : light ? '#c15f3c' : '#d97757'
410 // Desktop draws the meter and the card as images; the terminal, and NO_COLOR anywhere, draws them as text.
411 const isGraphic = e.surface === 'desktop' && !noColor
412 const drawSvg = (source: string, alt: string, width: number, height: number): RenderElement | null =>
413 e.surface === 'desktop' && !noColor ? $.ui.resolve(e).Svg({ source, alt, width, height }) : null
414 const bandWidth = Math.max(240, Math.min(1600, (e.props.bodyColumns || 80) * 8 - 8))
415 const children: RenderChildren[] = [rest]
416
417 if (hasRow) {
418 const state = s.stopped ? 'stopped' : !s.lastRequestAt || s.compacted ? 'unknown' : isCold(s, now) ? 'cold' : 'warm'
419 const color = noColor ? undefined : state === 'warm' ? 'success' : state === 'cold' ? coral : undefined
420 const asset = statusIcons[light ? 'light' : 'dark'][state === 'warm' || state === 'cold' ? state : 'neutral']
421 const icon = noColor ? Text({ bold: true, children: ['[>]'] })
422 : e.surface === 'terminal' ? $.ui.resolve(e).Image({ source: { png: asset.png }, columns: 3, rows: 1, alt: '[>]' })
423 : e.surface === 'desktop' ? $.ui.resolve(e).Svg({ source: asset.svg, width: 18, height: 18, alt: 'Cache Tax' })
424 : Text({ color, bold: true, children: ['[>]'] })
425 // While a send is held its card shows the price, so the row leaves the meter out.
426 const warmth = isHeldNow ? null : warmthOf(s, now)
427 const meterWidth = Math.max(bandWidth < 300 ? 88 : 104, Math.min(190, Math.round(bandWidth * 0.3)))
428 const meterImage = warmth ? drawSvg(meterSvg(warmth, meterWidth), meterAlt(warmth), meterWidth, METER_H) : null
429 const meterCells = e.props.bodyColumns >= 70 ? 10 : e.props.bodyColumns >= 50 ? 6 : 0
430 const meterText: RenderChildren[] = warmth && !meterImage && meterCells ? (() => {
431 const cells = textMeter(warmth, meterCells)
432 const fillColor = noColor ? undefined : isSoonCold(warmth) ? coral : 'success'
433 const label = warmth.tone === 'pinging' ? 'pinging'
434 : warmth.tone === 'warm' ? `${minutesLeft(warmth.msLeft)}m left`
435 : warmth.coldPrice ? `≈${warmth.coldPrice} to re-write` : 'price unknown'
436 return [
437 ' ',
438 ...(cells.filled ? [Text({ color: fillColor, children: [cells.filled] }), Text({ dimColor: true, children: [cells.empty] }), ' '] : []),
439 Text({ bold: true, color: warmth.tone === 'cold' ? coral : undefined, children: [label] }),
440 ]
441 })() : []
442 // On desktop the icon's alt names the mod, so the row leads with the state and keeps it in view when narrow.
443 const line = Text({ wrap: 'truncate-end', children: [
444 ...(isGraphic ? [] : [Text({ dimColor: true, children: ['cache-tax'] }), ' ']),
445 Text({ color, bold: true, children: [state] }),
446 ...meterText,
447 Text({ dimColor: true, children: [` · ${bandDetails(s, now)}`] }),
448 ] })
449 children.push(Box({ flexDirection: 'row', alignItems: 'center', gap: 1, children: [
450 Box({ flexShrink: 0, children: [icon] }),
451 Box({ flexGrow: 1, flexShrink: 1, children: [line] }),
452 ...(meterImage ? [Box({ flexShrink: 0, children: [meterImage] })] : []),
453 ] }))
454 }
455
456 if (isHeldNow && s.held) {
457 const hold: Hold = { coldFor: fmtDuration(now - s.lastRequestAt - TTL_MS), rewriteUsd: coldUsd(s), warmUsd: warmUsd(s), tokens: s.ctx.toLocaleString('en-US') }
458 const rewrite = hold.rewriteUsd == null ? null : fmtUsd(hold.rewriteUsd)
459 const buttons = Box({ flexDirection: 'row', alignItems: 'center', gap: 1, children: [
460 ...(s.held.isResendable
461 ? [Button({ key: 'cache-tax-send', label: rewrite ? `Send anyway ≈${rewrite}` : 'Send anyway', variant: 'primary', onPress: () => { void sendHeld($, s) } })]
462 : []),
463 Button({ key: 'cache-tax-cancel', label: 'Discard message', onPress: () => cancelHeld($, s) }),
464 Text({ dimColor: true, wrap: 'truncate-end', children: [s.held.isResendable
465 ? 'Or /clear and paste a summary. /keepwarm after sending keeps it warm.'
466 : 'It had attachments or @-mentions, so send it again from the prompt.'] }),
467 ] })
468 // The full card needs about seven rows; a shorter band gets the one-line card.
469 const cardWidth = Math.min(bandWidth, 560)
470 const title = e.props.maxRows >= 7 ? drawSvg(holdTitleSvg(hold, cardWidth), holdTitleAlt(hold), cardWidth, HOLD_TITLE_H) : null
471 const bars = title ? drawSvg(holdBarsSvg(hold, cardWidth), holdBarsAlt(hold), cardWidth, holdBarsHeight(hold)) : null
472 if (title && bars) {
473 children.push(Box({ flexDirection: 'column', gap: 1, marginTop: hasRow ? 1 : 0, children: [title, buttons, bars] }))
474 } else {
475 const cost = rewrite
476 ? `sending re-writes ≈${rewrite}${hold.warmUsd == null ? '' : `, a warm turn ≈${fmtUsd(hold.warmUsd)}`}`
477 : `sending re-writes up to ${hold.tokens} tokens, no price for this model`
478 children.push(Box({ flexDirection: 'column', children: [
479 Text({ wrap: 'truncate-end', children: [
480 Text({ bold: true, color: coral, children: ['Message held'] }),
481 Text({ dimColor: true, children: [` · ${cost} · cold for ${hold.coldFor}`] }),
482 ] }),
483 buttons,
484 ] }))
485 }
486 }
487
488 return Box({ flexDirection: 'column', children })
489 })
490
491 on('session.start', async ($, e, next) => {
492 const r = await next(e)
493 s.hasBand = e.surface === 'terminal' || e.surface === 'desktop'
494 if (s.hasBand) {
495 const theme = (await $.config.list()).find(row => row.key === 'theme')?.value
496 light = typeof theme === 'string' && theme.startsWith('light')
497 noColor = Boolean(await $.env.get('NO_COLOR'))
498 $.ui.status(undefined)
499 }
500 s.sid = await $.session.id()
501 const now = await $.clock.now()
502 await prune($, s, now)
503 const saved = await $.store.get(deadlineKey(s))
504 const savedEvery = await $.store.get(everyKey(s))
505 const savedGuard = await $.store.get(KEY_GUARD)
506 s.deadline = typeof saved === 'number' && saved > now ? saved : 0
507 s.every = typeof savedEvery === 'number' && savedEvery >= MIN_PING_MS ? savedEvery : PING_AFTER_MS
508 s.guard = savedGuard === 'warn' ? 'warn' : 'refuse'
509 s.always = (await $.store.get(KEY_ALWAYS)) === true
510 // Always means a fresh default window every session, whatever the last one left behind.
511 if (s.always) await startWindow($, s, DEFAULT_WINDOW_MS, PING_AFTER_MS)
512 const usage = await $.session.usage()
513 if (usage.context.tokens) s.ctx = usage.context.tokens
514 await $.command.register({
515 name: 'keepwarm',
516 description: 'Keep the prompt cache warm: bare for 6h, a window such as 90m, always, off, or status (cache-tax)',
517 argumentHint: '[6h | always | off | status]',
518 immediate: true,
519 })
520 await $.command.register({
521 name: 'cache-tax',
522 description: 'Prompt cache state, cold price and this session\'s cold writes; guard warn|refuse (cache-tax, the Mod)',
523 argumentHint: '[status | guard warn | guard refuse]',
524 immediate: true,
525 })
526 // The hook form of cache-tax ships a /cache-tax:status skill; both installed means two guards.
527 const commands = await $.command.list()
528 if (commands.some(c => c.name === 'cache-tax:status')) {
529 $.ui.log('the hook form (cache-tax@claude-code-hooks) is also installed, so a cold send is warned about or refused twice. Uninstall it, or /cache-tax guard warn here.')
530 }
531 updateStatus($, s, now)
532 return r
533 })
534
535 // Resume fields seed the guard before any turn of the resumed session has run.
536 on('classic.SessionStart', async ($, e, next) => {
537 const r = await next(e)
538 if (e.source === 'clear') {
539 await stop($, s, null)
540 s.stopped = null
541 resetForClear(s)
542 s.sid = await $.session.id()
543 updateStatus($, s, await $.clock.now())
544 return r
545 }
546 // A held message belongs to the conversation before the resume.
547 s.held = null
548 s.ackedAt = 0
549 s.notificationAckedAt = 0
550 const line = seedFromResume(s, e, await $.clock.now())
551 // The resume payload may omit the model; without it the guard cannot price the cold write.
552 if (!s.lastModel) s.lastModel = await $.session.model()
553 if (line) $.ui.log(line)
554 // The seeded clock decides whether a restored or always window pings before the first turn: never when it is cold.
555 await arm($, s)
556 return r
557 })
558
559 on('command.run', { command: 'keepwarm' }, async ($, e) => {
560 const words = String(e.args ?? '').trim().split(/\s+/).filter(Boolean)
561 const now = await $.clock.now()
562 if (words[0] === 'off') {
563 const wasAlways = s.always
564 await stop($, s, null, true)
565 return { text: wasAlways ? 'keepwarm is off, and no longer arms itself at session start' : 'keepwarm is off' }
566 }
567 if (words[0] === 'always') {
568 s.always = true
569 await $.store.set(KEY_ALWAYS, true)
570 await startWindow($, s, DEFAULT_WINDOW_MS, PING_AFTER_MS)
571 const cold = isCold(s, now) ? `. The cache is cold now, so the first ping comes ${fmtDuration(s.every)} after the next turn` : ''
572 return { text: `keepwarm always on: every session starts with a ${fmtDuration(DEFAULT_WINDOW_MS)} window; /keepwarm off turns it off for good${cold}` }
573 }
574 if (!words.length) {
575 await startWindow($, s, DEFAULT_WINDOW_MS, PING_AFTER_MS)
576 return { text: armedText(s, now, DEFAULT_WINDOW_MS) }
577 }
578 if (words[0] !== 'status') {
579 const window = parseDuration(words[0] ?? '')
580 if (window == null) return { text: 'keepwarm takes a window such as 6h or 90m, or always, off, or status' }
581 // "every 2m" is a testing knob and lasts only for the window it was given with.
582 let every = PING_AFTER_MS
583 if (words[1] === 'every') {
584 const period = parseDuration(words[2] ?? '')
585 if (period == null || period < MIN_PING_MS) return { text: 'every takes a period of at least 1m' }
586 every = period
587 }
588 await startWindow($, s, window, every)
589 return { text: armedText(s, now, window) }
590 }
591 return { text: statusText(s, now) ?? 'keepwarm is off' }
592 })
593
594 on('command.run', { command: 'cache-tax' }, async ($, e) => {
595 const words = String(e.args ?? '').trim().split(/\s+/).filter(Boolean)
596 const now = await $.clock.now()
597 if (words[0] === 'guard') {
598 if (words[1] !== 'warn' && words[1] !== 'refuse') return { text: '/cache-tax guard takes warn or refuse' }
599 s.guard = words[1]
600 await $.store.set(KEY_GUARD, s.guard)
601 return { text: s.guard === 'refuse' ? 'guard set to refuse once: a cold send is dropped with its price, the resend goes through' : 'guard set to warn: a cold send goes through with its price shown' }
602 }
603 return { text: card(s, now) }
604 })
605
606 // The message that pays. Its text is read for a leading slash; a held one stays in memory for the band's Send anyway until the next turn.
607 on('prompt.submit', async ($, e, next) => {
608 if (e.origin.kind === 'plugin') return next(e)
609 if (typeof e.text !== 'string' || e.text.trimStart().startsWith('/')) return next(e)
610 const now = await $.clock.now()
611 if (!isCold(s, now) || s.ctx < BIG_TOKENS) return next(e)
612 if (s.guard === 'warn') {
613 $.ui.log(`${guardText(s, now)} Sending anyway; /keepwarm holds the cache once it lands.`)
614 s.coldWritePending = true
615 return next(e)
616 }
617 // Only the person's own message is held or let through on a resend. A prompt they did not type keeps the
618 // guard's old rule on its own ack (dropped once, the next goes through), so it never touches the held card.
619 const isTyped = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
620 if (!isTyped) {
621 if (s.notificationAckedAt === s.lastRequestAt) {
622 s.notificationAckedAt = 0
623 s.coldWritePending = true
624 return next(e)
625 }
626 s.notificationAckedAt = s.lastRequestAt
627 return { drop: `cache-tax: ${guardText(s, now)} Send it again to pay it, then /keepwarm holds the cache. Or /clear and start from a note.` }
628 }
629 if (s.ackedAt === s.lastRequestAt) {
630 s.ackedAt = 0
631 s.coldWritePending = true
632 s.held = null
633 redraw($, s)
634 return next(e)
635 }
636 s.ackedAt = s.lastRequestAt
637 // A plugin's resend expands no attachments or @-mentions, so those are left for the person to send again.
638 s.held = { text: e.text, isResendable: !e.attachments?.length && !/(^|\s)@\S/.test(e.text) }
639 redraw($, s)
640 syncTicker($, s, now)
641 const nextStep = !s.hasBand ? 'Send it again to pay it, then /keepwarm holds the cache. Or /clear and start from a note.'
642 : s.held.isResendable ? 'Send it again, or use Send anyway below.'
643 : 'Send it again from the prompt to pay it.'
644 return { drop: `cache-tax: ${guardText(s, now)} ${nextStep}` }
645 })
646
647 on('turn.step', async function* ($, e, next) {
648 if (!e.agentId) s.lastRequestAt = await $.clock.now()
649 yield* next(e)
650 })
651
652 on('turn.complete', async ($, e, next) => {
653 const r = await next(e)
654 if (e.agentId) return r
655 const now = await $.clock.now()
656 // A sleeping host may deliver this turn before the expired window's timer.
657 if (s.deadline && now >= s.deadline) await stop($, s, null)
658 // turn.step stamps the exact request time; when no step of this turn did, the turn's end is the floor.
659 if (now - s.lastRequestAt > e.durationMs) s.lastRequestAt = now
660 s.compacted = false
661 s.ackedAt = 0
662 s.notificationAckedAt = 0
663 s.held = null
664 const u = e.usage
665 if (u) {
666 if (u.model) s.lastModel = u.model
667 const prev = s.ctx
668 // A turn's usage is its responses summed, so a ten-step turn reports ten
669 // reads of the context. The live window is the engine's figure; the sum
670 // is only the fallback for a host that reports no tokens.
671 const write = u.cache_creation_input_tokens
672 const live = (await $.session.usage()).context.tokens
673 s.ctx = live && live > 0 ? live : u.input_tokens + u.cache_read_input_tokens + write
674 const full = prev > 20000 && write >= 0.5 * prev
675 if (full || s.coldWritePending) {
676 const price = priceOf(s.lastModel)
677 const usd = price ? write * price[1] / 1e6 : null
678 s.misses.push({ at: now, tokens: write, usd })
679 // pulse: no automatic window after a paid write; pinging runs only after /keepwarm.
680 if (s.deadline <= now) $.ui.log(`cold write of ${fmtTok(write)} tokens paid (${fmtUsd(usd)}). /keepwarm holds the cache so it is not paid again.`)
681 }
682 }
683 s.coldWritePending = false
684 await arm($, s)
685 return r
686 })
687
688 on('session.compact', async ($, e, next) => {
689 const r = await next(e)
690 if (!e.agentId) {
691 s.compacted = true
692 s.ctx = 0
693 s.ackedAt = 0
694 s.notificationAckedAt = 0
695 s.held = null
696 disarm(s)
697 await arm($, s)
698 }
699 return r
700 })
701}
702hooks/draw.ts 241 lines1// pulse-cache-tax drawing: pure functions that turn the cache's state into SVG and text.
2//
3// No runtime import from 'claude-code': a preview script can import this file as it is.
4
5// QueerPulse tokens, copied as hex: a mod cannot read CSS variables. The same palette as pulse-progress.
6export const CORAL = '#e8775a'
7export const JADE = '#4a8c6f'
8export const LILAC = '#8a72cf'
9const FONT = "'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif"
10const SERIF = 'Fraunces,Georgia,serif'
11const TABULAR = ' style="font-variant-numeric:tabular-nums"'
12
13// ---------------------------------------------------------------------------
14// Numbers.
15
16export function fmtDuration(ms: number): string {
17 const total = Math.max(0, Math.round(ms / 60000))
18 const hours = Math.floor(total / 60)
19 const minutes = total % 60
20 if (hours >= 48) return `${Math.floor(hours / 24)}d ${hours % 24}h`
21 return hours > 0 ? `${hours}h${String(minutes).padStart(2, '0')}m` : `${minutes}m`
22}
23
24export function fmtUsd(usd: number | null): string {
25 return usd == null ? 'n/a' : '$' + (usd >= 100 ? usd.toFixed(0) : usd.toFixed(2))
26}
27
28export function fmtTok(count: number): string {
29 return count >= 1e6 ? (count / 1e6).toFixed(1) + 'M' : count >= 1000 ? Math.round(count / 1000) + 'k' : String(count)
30}
31
32/** Whole minutes left, rounded up: a countdown reads 1m through its last minute. */
33export const minutesLeft = (ms: number): number => Math.max(0, Math.ceil(ms / 60000))
34
35// ---------------------------------------------------------------------------
36// Text in SVG.
37
38const ENTITIES: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"' }
39export const xml = (text: string): string => text.replace(/[&<>"]/g, ch => ENTITIES[ch] ?? ch)
40
41const charEm = (ch: string): number =>
42 /[0-9$]/.test(ch) ? 0.6 : /[\s.,:;'|!il()[\]]/.test(ch) ? 0.3 : /[A-Zmw@%≈]/.test(ch) ? 0.75 : 0.56
43export const textWidth = (text: string, size: number): number => [...text].reduce((width, ch) => width + charEm(ch) * size, 0)
44
45export const fitText = (text: string, size: number, maxWidth: number): string => {
46 if (textWidth(text, size) <= maxWidth) return text
47 let out = ''
48 for (const ch of text) {
49 if (textWidth(out + ch + '…', size) > maxWidth) break
50 out += ch
51 }
52 return out.trimEnd() + '…'
53}
54
55// ---------------------------------------------------------------------------
56// Light and dark. Text and tracks are neutral; jade means warm, coral means it costs, lilac marks the ping.
57
58const TEXT_LIGHT = '45,27,61'
59const TEXT_DARK = '243,239,233'
60// Inks hold 4.5:1 as small text on cream (#faf6f0) and on near-black (#141414).
61const CORAL_INK_LIGHT = '#9e472c'
62const CORAL_INK_DARK = '#f0907a'
63const LILAC_INK_LIGHT = '#5d47a8'
64const LILAC_INK_DARK = '#a996e0'
65const JADE_DARK = '#5aa784'
66// A deeper coral for fills on cream; the brand coral stays on near-black.
67const CORAL_FILL_LIGHT = '#d4603f'
68
69const css = (motion: string): string => `<style>
70.tx{fill:rgb(${TEXT_LIGHT})}.m{fill:rgba(${TEXT_LIGHT},.63)}.k{fill:rgba(${TEXT_LIGHT},.09)}
71.fw{fill:${JADE}}.fc{fill:${CORAL_FILL_LIGHT}}.ci{fill:${CORAL_INK_LIGHT}}.pt{fill:${LILAC}}.li{fill:${LILAC_INK_LIGHT}}
72@media (prefers-color-scheme: dark){.tx{fill:rgb(${TEXT_DARK})}.m{fill:rgba(${TEXT_DARK},.49)}.k{fill:rgba(255,255,255,.08)}
73.fw{fill:${JADE_DARK}}.fc{fill:${CORAL}}.ci{fill:${CORAL_INK_DARK}}.pt{fill:${LILAC_INK_DARK}}.li{fill:${LILAC_INK_DARK}}}
74${motion}</style>`
75
76const svg = (width: number, height: number, body: string, motion = ''): string =>
77 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${css(motion)}${body}</svg>`
78
79const textAt = (x: number, y: number, cls: string, size: number, content: string, extra = ''): string =>
80 `<text class="${cls}" x="${x}" y="${y}" font-family="${FONT}" font-size="${size}"${extra}>${xml(content)}</text>`
81
82// ---------------------------------------------------------------------------
83// The warmth meter: a track as long as the cache's one-hour life, filled with what is left of it.
84
85export type Warmth = {
86 /** warm: the fill drains; pinging: keepwarm's ping is in flight; cold: only the price shows. */
87 tone: 'warm' | 'pinging' | 'cold'
88 /** Until the cache goes cold; 0 once it has. */
89 msLeft: number
90 ttlMs: number
91 /** The warmth left when keepwarm pings and refills the meter; null when no ping lands before it goes cold. */
92 pingMsLeft: number | null
93 /** What the next send re-writes, as `$4.01`; null when the model has no price. */
94 coldPrice: string | null
95}
96
97export const METER_H = 20
98const TRACK_H = 6
99// Below this the fill reads as nearly gone, so it turns coral: the last ten minutes of the hour.
100const SOON_COLD_MS = 10 * 60 * 1000
101// Narrower than this, a meter drops its label's small words and keeps the number.
102const COMPACT_W = 130
103
104type Label = { parts: Array<{ text: string; cls: string; size: number; weight?: number }> }
105
106const labelWidth = (label: Label): number => label.parts.reduce((width, part) => width + textWidth(part.text, part.size), 0)
107
108const meterLabel = (warmth: Warmth, width: number): Label => {
109 if (warmth.tone === 'pinging') return { parts: [{ text: 'pinging', cls: 'li', size: 12, weight: 600 }] }
110 if (warmth.tone === 'warm') {
111 const minutes = { text: `${minutesLeft(warmth.msLeft)}m`, cls: 'tx', size: 12, weight: 600 }
112 return { parts: width >= COMPACT_W ? [minutes, { text: ' left', cls: 'm', size: 11 }] : [minutes] }
113 }
114 if (!warmth.coldPrice) return { parts: [{ text: width >= 170 ? 'price unknown' : 'no price', cls: 'm', size: 11 }] }
115 const price = { text: `≈${warmth.coldPrice}`, cls: 'ci', size: 12, weight: 600 }
116 return { parts: width >= 170 ? [price, { text: ' to re-write', cls: 'm', size: 11 }] : [price] }
117}
118
119// The widest warm label, so the track keeps one length while the minutes count down.
120const warmLabelWidth = (width: number): number => Math.ceil(textWidth('60m', 12) + (width >= COMPACT_W ? textWidth(' left', 11) : 0))
121
122export const meterSvg = (given: Warmth, width: number): string => {
123 // Whole seconds, so two draws in the same second give the same source.
124 const warmth = { ...given, msLeft: Math.round(given.msLeft / 1000) * 1000 }
125 const label = meterLabel(warmth, width)
126 const labelW = Math.max(warmth.tone === 'cold' ? 0 : warmLabelWidth(width), Math.ceil(labelWidth(label)))
127 const trackW = Math.max(24, width - labelW - 14)
128 const trackY = (METER_H - TRACK_H) / 2
129 const radius = TRACK_H / 2
130 const isLive = warmth.tone !== 'cold'
131 const fraction = isLive ? Math.min(1, Math.max(0, warmth.msLeft / warmth.ttlMs)) : 0
132 const fillW = Math.max(isLive ? TRACK_H : 0, Math.round(trackW * fraction))
133 const fillClass = isSoonCold(warmth) ? 'fc' : 'fw'
134
135 // The fill drains to empty over the time that is left, and a ping in flight breathes.
136 // Every motion is off under reduced motion; the band redraws each minute either way.
137 const motion = isLive
138 ? `@media (prefers-reduced-motion: no-preference){${
139 warmth.tone === 'pinging'
140 ? '.pu{animation:pu 1.4s ease-in-out infinite}@keyframes pu{0%,100%{opacity:1}50%{opacity:.4}}'
141 : `.dr{animation:dr ${Math.round(warmth.msLeft)}ms linear both}@keyframes dr{to{width:${TRACK_H}px}}`
142 }}`
143 : ''
144 const fill = isLive
145 ? `<rect class="${fillClass} ${warmth.tone === 'pinging' ? 'pu' : 'dr'}" x="0" y="${trackY}" width="${fillW}" height="${TRACK_H}" rx="${radius}"/>`
146 : ''
147 // The ping's mark: where on the hour keepwarm refills it, while that point is still ahead.
148 const pingX = warmth.pingMsLeft != null && warmth.tone === 'warm' && warmth.pingMsLeft < warmth.msLeft
149 ? Math.round(trackW * Math.min(1, Math.max(0, warmth.pingMsLeft / warmth.ttlMs)))
150 : null
151 const pingMark = pingX == null ? '' : `<rect class="pt" x="${Math.max(0, pingX - 1)}" y="${trackY - 4}" width="2" height="${TRACK_H + 8}" rx="1"/>`
152
153 // One text anchored at its end, so the parts flow in the real font and never collide.
154 const labelText = `<text x="${width}" y="14" text-anchor="end" font-family="${FONT}"${TABULAR}>${label.parts
155 .map(part => `<tspan class="${part.cls}" font-size="${part.size}"${part.weight ? ` font-weight="${part.weight}"` : ''}>${xml(part.text)}</tspan>`)
156 .join('')}</text>`
157 // Cold has nothing left to fill, so it shows only the price.
158 const track = isLive ? `<rect class="k" x="0" y="${trackY}" width="${trackW}" height="${TRACK_H}" rx="${radius}"/>` : ''
159 return svg(width, METER_H, `${track}${fill}${pingMark}${labelText}`, motion)
160}
161
162export const meterAlt = (warmth: Warmth): string => {
163 if (warmth.tone === 'pinging') return 'Prompt cache warm, keepwarm is pinging it now'
164 if (warmth.tone === 'cold') {
165 return warmth.coldPrice ? `Prompt cache cold, the next message re-writes it for about ${warmth.coldPrice}` : 'Prompt cache cold, no price for this model'
166 }
167 const minutes = minutesLeft(warmth.msLeft)
168 const ping = warmth.pingMsLeft != null && warmth.pingMsLeft < warmth.msLeft
169 ? `, keepwarm pings it with ${minutesLeft(warmth.pingMsLeft)} minutes left`
170 : ''
171 return `Prompt cache warm for ${minutes} more minute${minutes === 1 ? '' : 's'}${ping}`
172}
173
174/** The terminal's meter: a row of cells standing for the hour, filled with what is left; none once cold. */
175export const textMeter = (warmth: Warmth, cells: number): { filled: string; empty: string } => {
176 if (warmth.tone === 'cold') return { filled: '', empty: '' }
177 const filledCells = Math.max(1, Math.round(cells * Math.min(1, Math.max(0, warmth.msLeft / warmth.ttlMs))))
178 return { filled: '━'.repeat(filledCells), empty: '─'.repeat(cells - filledCells) }
179}
180
181export const isSoonCold = (warmth: Warmth): boolean => warmth.tone === 'warm' && warmth.msLeft <= SOON_COLD_MS
182
183// ---------------------------------------------------------------------------
184// The held send: what sending now re-writes, beside what a warm turn costs.
185
186export type Hold = {
187 /** How long the cache has been cold, as `2h00m`. */
188 coldFor: string
189 rewriteUsd: number | null
190 warmUsd: number | null
191 /** The context the send re-writes, as `200,502`. */
192 tokens: string
193}
194
195const CARD_TITLE = 'Message held'
196const ROW_LABEL = 11.5
197const ROW_VALUE = 12
198
199export const HOLD_TITLE_H = 22
200
201/** The card's head: the title, and how long the cache has been cold. */
202export const holdTitleSvg = (hold: Hold, width: number): string => {
203 const titleW = textWidth(CARD_TITLE, 15) + 10
204 const subtitle = fitText(`cold for ${hold.coldFor}`, 12, width - titleW)
205 return svg(width, HOLD_TITLE_H, `<text x="0" y="16"><tspan class="tx" font-family="${SERIF}" font-size="15" font-style="italic" font-weight="600">${CARD_TITLE}</tspan><tspan class="m" dx="10" font-family="${FONT}" font-size="12">${xml(subtitle)}</tspan></text>`)
206}
207
208export const holdTitleAlt = (hold: Hold): string => `Message held, the cache has been cold for ${hold.coldFor}`
209
210export const holdBarsHeight = (hold: Hold): number => (hold.rewriteUsd == null ? 20 : hold.warmUsd == null ? 20 : 36)
211
212/** The card's evidence: what sending now re-writes, on one scale with what a warm turn would have cost. */
213export const holdBarsSvg = (hold: Hold, width: number): string => {
214 if (hold.rewriteUsd == null) {
215 return svg(width, holdBarsHeight(hold), textAt(0, 14, 'm', 12, fitText('No price for this model, so the cost is unknown.', 12, width)))
216 }
217 // The re-write fills its track; a warm turn is the sliver it would have cost.
218 const rows: Array<{ label: string; usd: number; fillClass: string; inkClass: string }> = [
219 { label: 'Send now', usd: hold.rewriteUsd, fillClass: 'fc', inkClass: 'ci' },
220 ...(hold.warmUsd == null ? [] : [{ label: 'Warm turn', usd: hold.warmUsd, fillClass: 'fw', inkClass: 'tx' }]),
221 ]
222 const labelW = Math.ceil(Math.max(...rows.map(row => textWidth(row.label, ROW_LABEL)))) + 12
223 const valueW = Math.ceil(Math.max(...rows.map(row => textWidth(fmtUsd(row.usd), ROW_VALUE)))) + 12
224 const trackW = Math.max(40, width - labelW - valueW)
225 const scale = Math.max(...rows.map(row => row.usd), 1e-9)
226 const body = rows
227 .map((row, index) => {
228 const centerY = 10 + index * 16
229 const fillW = Math.max(TRACK_H, Math.round((trackW * row.usd) / scale))
230 return `${textAt(0, centerY + 4, 'm', ROW_LABEL, row.label)}<rect class="k" x="${labelW}" y="${centerY - TRACK_H / 2}" width="${trackW}" height="${TRACK_H}" rx="${TRACK_H / 2}"/><rect class="${row.fillClass}" x="${labelW}" y="${centerY - TRACK_H / 2}" width="${fillW}" height="${TRACK_H}" rx="${TRACK_H / 2}"/>${textAt(width, centerY + 4, row.inkClass, ROW_VALUE, fmtUsd(row.usd), `${TABULAR} text-anchor="end" font-weight="600"`)}`
231 })
232 .join('')
233 return svg(width, holdBarsHeight(hold), body)
234}
235
236export const holdBarsAlt = (hold: Hold): string =>
237 hold.rewriteUsd == null
238 ? `No price for this model; sending re-writes up to ${hold.tokens} tokens.`
239 : `Sending now re-writes up to ${hold.tokens} tokens for about ${fmtUsd(hold.rewriteUsd)}` +
240 (hold.warmUsd == null ? '.' : `; a warm turn would have cost ${fmtUsd(hold.warmUsd)}.`)
241hooks/status-icons.ts 32 lines1// Generated by tools/generate-status-icons.py from the official SVG assets.
2export const statusIcons = {
3 "dark": {
4 "warm": {
5 "svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"64\" height=\"64\" viewBox=\"0 0 64 64\" fill=\"none\">\n <title>Cache Tax</title>\n <g stroke=\"#faf9f5\" stroke-width=\"4\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path fill=\"#78b85a\" d=\"M8 18 32 5 56 18 32 31Z\"/>\n <path d=\"M8 18v28l24 13 24-13V18M32 31v28\"/>\n <path d=\"m40 32 8 5-8 5\"/>\n </g>\n</svg>\n",
6 "png": "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"
7 },
8 "cold": {
9 "svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"64\" height=\"64\" viewBox=\"0 0 64 64\" fill=\"none\">\n <title>Cache Tax</title>\n <g stroke=\"#faf9f5\" stroke-width=\"4\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path fill=\"#d97757\" d=\"M8 18 32 5 56 18 32 31Z\"/>\n <path d=\"M8 18v28l24 13 24-13V18M32 31v28\"/>\n <path d=\"m40 32 8 5-8 5\"/>\n </g>\n</svg>\n",
10 "png": "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"
11 },
12 "neutral": {
13 "svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"64\" height=\"64\" viewBox=\"0 0 64 64\" fill=\"none\">\n <title>Cache Tax</title>\n <g stroke=\"#faf9f5\" stroke-width=\"4\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path fill=\"#92928b\" d=\"M8 18 32 5 56 18 32 31Z\"/>\n <path d=\"M8 18v28l24 13 24-13V18M32 31v28\"/>\n <path d=\"m40 32 8 5-8 5\"/>\n </g>\n</svg>\n",
14 "png": "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"
15 }
16 },
17 "light": {
18 "warm": {
19 "svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"64\" height=\"64\" viewBox=\"0 0 64 64\" fill=\"none\">\n <title>Cache Tax</title>\n <g stroke=\"#141413\" stroke-width=\"4\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path fill=\"#397527\" d=\"M8 18 32 5 56 18 32 31Z\"/>\n <path d=\"M8 18v28l24 13 24-13V18M32 31v28\"/>\n <path d=\"m40 32 8 5-8 5\"/>\n </g>\n</svg>\n",
20 "png": "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"
21 },
22 "cold": {
23 "svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"64\" height=\"64\" viewBox=\"0 0 64 64\" fill=\"none\">\n <title>Cache Tax</title>\n <g stroke=\"#141413\" stroke-width=\"4\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path fill=\"#c15f3c\" d=\"M8 18 32 5 56 18 32 31Z\"/>\n <path d=\"M8 18v28l24 13 24-13V18M32 31v28\"/>\n <path d=\"m40 32 8 5-8 5\"/>\n </g>\n</svg>\n",
24 "png": "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"
25 },
26 "neutral": {
27 "svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"64\" height=\"64\" viewBox=\"0 0 64 64\" fill=\"none\">\n <title>Cache Tax</title>\n <g stroke=\"#141413\" stroke-width=\"4\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path fill=\"#686861\" d=\"M8 18 32 5 56 18 32 31Z\"/>\n <path d=\"M8 18v28l24 13 24-13V18M32 31v28\"/>\n <path d=\"m40 32 8 5-8 5\"/>\n </g>\n</svg>\n",
28 "png": "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"
29 }
30 }
31} as const
32