A panel above the prompt showing context usage, usage limits (session, weekly, Fable) and prompt-cache temperature, with Clear / Compact actions.

A Claude Code mod that puts your context window, your usage limits and your prompt cache on one card above the prompt. It shows in the terminal, in the desktop app's Code tab and, as a pane, on the phone.

That's a real session. 484k of a 1M context window, the session limit at 34% with 54% of its five hours gone, the weekly and Fable limits, and a prompt cache that is warm with 59 minutes left. The notice at the bottom says what the next message will cost you, with Clear and Compact beside it. This screenshot predates 0.1.5 and still shows a Later button.
Claude Code already tells you all of this, in three places. /context has the window, /usage has the limits, and the cache is in the usage figures if you know where to look. None of it is on screen while you work, and the one number that changes what you do next, whether your cache is about to expire, isn't anywhere.
The cache matters more than it looks. Every reply re-sends the whole conversation. With the cache warm, 98% of that is served from memory at a tenth of the price. Let it go cold, by walking away for an hour, and the next message pays full price to re-read everything. The panel counts that down and tells you before it happens.
The limits matter too, and in a particular way. Being at 60% of your weekly allowance is fine on Saturday and a problem on Tuesday. So each limit bar carries two layers. The dark one is how much of the window's time has passed, the bright one is how much you've used, and a white tick marks where time is. If the bright bar is past the tick you're ahead of pace, and the bar turns amber. At 90% it turns red.
The top row is the context window. Tokens used out of the window, in four groups (System, Tools, Files, Messages) with a legend under the bar and a tick where auto-compact will fire.
The middle row is three limits and the cache. Current session (5 hours), Weekly (7 days) and Fable (the model-specific weekly window), each with the double bar described above, the two percentages and the reset time. Then the cache. Warm, Cooling or Cold, a temperature bar, how warm it is as a percentage of its time-to-live, the last reply's cache hit rate, and minutes left.
The bottom row is a one-line notice about the cache with two buttons. Clear runs /clear. Compact compacts the conversation. The row never hides, whatever the cache holds, so the buttons are always there. With nothing cached yet, in a fresh session or after /clear or a compaction, it says how much the next message will write to the cache.

The card re-lays itself as the window changes. Columns down to about 500 px, rings below that, and the legend drops at about 360 px. All four were drawn first in a design file and the panel reproduces them to the pixel.




claude plugin marketplace add Christandoh/context-cache
claude plugin install context-cache@chris-mods
Inside a session, /plugin marketplace add Christandoh/context-cache and then /plugin install context-cache@chris-mods do the same. Installed at the user scope, it loads in every Claude Code session, including the desktop app's Code tab. Start a new session and the card is there.
Installing puts a copy in your plugin cache. claude plugin update context-cache@chris-mods fetches a new release whenever version in plugin.json has changed.
Two limits of the platform worth knowing. The desktop app's ordinary chat has no plugin surface, so the card can't appear there. And the panel's figures for the weekly and Fable limits come from an endpoint Anthropic hasn't documented (/usage reads the same one). If that endpoint changes, the Fable column shows "No data" and everything else keeps working.
/cache hides or shows the card.
/cache-status prints a diagnostics report. Which apps are attached, what the mod has drawn, where each figure came from, and how the last account usage request went (auth kind, HTTP status, the windows in the response and which one matched Fable). The full report also lands in context-cache-status.json in the session's working directory.
/cache-pane opens the card as a pane, on any device.
/cache-refresh re-reads usage now instead of waiting for the next poll.
/cache-scale sets how many CSS pixels the desktop draws per band cell. The default is 8. /cache-scale 8.5 sets a value; a bare /cache-scale steps it up by a half, from 7 round to 9.5. You only need it if the card comes out narrower or wider than the composer.
These are five separate commands rather than one with arguments because the desktop composer drops anything typed after a slash command's name.
Every figure has a named source. One is worked out rather than reported: Files is the share of the conversation that is Read output, by character count, because Claude Code folds file reads into Messages.
| Figure | Source |
|---|---|
| Context used, window, auto-compact point | $.session.usage(), the same figures as the status line and /context |
| System / Tools / Messages split | the /context breakdown, scaled to the real token count |
| Files | the share of Messages that is Read output, by character count |
| Session and weekly limits | the rate-limit headers of every reply, so they move with each message. The headers can trail the account endpoint by a point, so when both describe the same window the panel shows the higher of the two, which matches /usage |
| Fable, and any reset time a header lacks | Anthropic's account usage endpoint (api.anthropic.com/api/oauth/usage), called through Claude Code with your session's own login, so the mod never sees the token. Asked once at session start, then every 5 minutes, and on /cache-refresh. Fable is the limits[] item whose scope names Fable, matched by name so a moved key still works |
| Time % | worked out from each window's reset time and its length (5h or 7d) |
| Cache TTL (5m or 60m) | read from the cache_creation usage of your last response in the transcript, or from Claude Code's own report when you switch model; remembered across sessions |
| Last cache write | each reply in the main conversation as it arrives, mid-turn included (or, on resume, how long ago the last response was) |
| Hit % | cache reads divided by (cache reads + cache writes + uncached input), for the last reply during a turn and for the whole turn once it ends |
Warmth is remaining TTL divided by TTL. Warm above 25%, Cooling between 1 and 25%, Cold at 0%. A /clear, a compaction or a model switch empties the cache, and the card shows "Nothing cached yet" until the first reply of the next turn arrives.
git clone https://github.com/Christandoh/context-cache
claude plugin marketplace add ./context-cache
claude plugin install context-cache@chris-mods
A warning from experience. claude plugin list will say the plugin is read from your folder, but the engine runs a copy under ~/.claude/plugins/cache/chris-mods/context-cache/<version> taken at install time. Edits to the folder never reach the running mod, and /reload-plugins only reloads the stale copy. After editing, refresh the copy:
claude plugin uninstall context-cache@chris-mods
claude plugin install context-cache@chris-mods
then /reload-plugins in an open session. Slash commands register when a session starts, so a brand-new command needs a new session. For a single terminal session, claude --plugin-dir ./context-cache skips the install.
Check it with:
claude plugin validate .
hooks/register.tsx holds the hooks: data collection, the commands and the band. hooks/model.ts is the maths, thresholds and copy, with no drawing in it. hooks/view.tsx draws the terminal version in cells and the desktop version as the SVG panel plus the host's buttons. hooks/panel-svg.ts lays the design out as SVG at its real pixel sizes, measuring text with the font widths in hooks/metrics.ts. types/index.d.ts is the mod's $.state contract.
MIT. See LICENSE.
The installs badge and the chart below count clones of this repo. Adding the marketplace clones it, so each install shows up as one. Reinstalls and updates clone it too, so the number runs a little high. Nothing is sent from your machine.
<img alt="Installs over time" src="https://raw.githubusercontent.com/Christandoh/context-cache/traffic/line-light.svg" width="100%">
hooks/register.tsx 588 lines1// context-cache: a panel above the prompt with context usage, usage limits
2// (session, weekly, Fable) and prompt-cache temperature, plus Clear / Compact.
3//
4// Where each number comes from (all real, nothing sampled):
5// - Context: $.session.usage() (the status line's figures) and its /context
6// breakdown; Files is the share of Messages that is file contents read.
7// - Limits: the account usage endpoint (api.anthropic.com/api/oauth/usage),
8// called with the session's own credential through the host; falls back to
9// the rate-limit headers of the last response.
10// - Cache: the time of the last main-thread response, the TTL the responses
11// were cached with (read from the transcript's usage, or the engine's own
12// model-switch report), and the last turn's cache-read share.
13
14import { atom, read, update } from 'claude-code'
15import type { EngineInterface, Register } from 'claude-code'
16
17import type { ContextCacheCache, ContextCacheLimit, ContextCacheSnapshot } from '../types'
18import {
19 buildContext,
20 buildView,
21 cacheView,
22 noticeText,
23 pct,
24 type ViewModel,
25 fileShareOf,
26 limitsFromResponse,
27 parseAccountUsage,
28 TTL_1H,
29 TTL_5M,
30 usageWindowsOf,
31} from './model'
32import { renderDesktop, renderTerminal, type Actions } from './view'
33
34const snapshotAtom = atom({ plugin: 'context-cache', key: 'snapshot' } as const, null)
35const hiddenAtom = atom({ plugin: 'context-cache', key: 'isHidden' } as const, false)
36const nowAtom = atom({ plugin: 'context-cache', key: 'now' } as const, 0)
37// Desktop and mobile draw the design in CSS px, but the band is measured in
38// cells; this is the conversion. 8 fits the desktop app's default font;
39// /cache scale <px> adjusts it and the value is kept across sessions.
40const pxPerCellAtom = atom({ plugin: 'context-cache', key: 'pxPerCell' } as const, 8)
41const DEFAULT_PX_PER_CELL = 8
42
43const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
44// The account endpoint answers 429 to anyone who leans on it; the status-line
45// tools that use it hold to one request per 5 minutes, so this does too. The
46// 5h/7d figures still move every response through the rate-limit headers.
47const ACCOUNT_EVERY_MS = 5 * 60_000
48// Two reset times this close describe the same window (the sources round differently).
49const SAME_WINDOW_MS = 30 * 60_000
50const MAX_TRANSCRIPT_BYTES = 4 * 1024 * 1024 - 1024
51
52const emptyCache = (ttlMs: number, isTtlKnown: boolean): ContextCacheCache => ({
53 lastAt: null,
54 ttlMs,
55 isTtlKnown,
56 hitRate: null,
57})
58
59/** The TTL a response was cached with, from a transcript line's usage. */
60export function ttlFromTranscript(text: string): number | null {
61 const lines = text.split('\n')
62 for (let i = lines.length - 1, seen = 0; i >= 0 && seen < 400; i--, seen++) {
63 const line = lines[i]
64 if (!line || !line.includes('cache_creation')) continue
65 try {
66 const row = JSON.parse(line) as { message?: { usage?: { cache_creation?: Record<string, number> } } }
67 const cc = row.message?.usage?.cache_creation
68 if (!cc) continue
69 if ((cc['ephemeral_1h_input_tokens'] ?? 0) > 0) return TTL_1H
70 if ((cc['ephemeral_5m_input_tokens'] ?? 0) > 0) return TTL_5M
71 } catch {
72 // a partial line; keep looking
73 }
74 }
75 return null
76}
77
78// Module state: rebuilt on reload; what the drawing needs lives in $.state.
79let accountLimits: ContextCacheLimit[] | null = null
80let accountAt = 0
81let accountInFlight = false
82let refreshing: Promise<void> | null = null
83// Diagnostics for /cache status: what asked the mod to draw, and where, and
84// how the last account usage request went.
85const draws: Record<string, number> = {}
86const attached: string[] = []
87let cwd = ''
88const DIAG_FILE = 'context-cache-status.json' // written in the session's working directory
89type AccountDiag = {
90 at: number
91 authKind: string
92 status: number | null
93 ok: boolean | null
94 topLevelKeys: string[]
95 /** JSON paths of every node with a utilization figure, and which matched Fable. */
96 windows: string[]
97 fableMatched: string[]
98 bodyPreview: string
99 error: string | null
100}
101let lastAccount: AccountDiag | null = null
102
103function noteDraw(component: string, surface: string) {
104 const k = `${component}@${surface}`
105 draws[k] = (draws[k] ?? 0) + 1
106}
107
108async function statusReport($: EngineInterface): Promise<string> {
109 let surfaces: readonly string[] = []
110 try {
111 surfaces = await $.session.surfaces()
112 } catch {
113 surfaces = []
114 }
115 const [snap, hidden, pxPerCell] = await Promise.all([read($, snapshotAtom), read($, hiddenAtom), read($, pxPerCellAtom)])
116 const drawn = Object.entries(draws).map(([k, n]) => `${k} ×${n}`).join(', ') || 'nothing yet'
117 const a = lastAccount
118 const account = a
119 ? `auth ${a.authKind}, HTTP ${a.status ?? '—'}${a.ok === false ? ' (not ok)' : ''}` +
120 `${a.error ? `, error: ${a.error}` : ''}; keys: ${a.topLevelKeys.join(', ') || '—'}; ` +
121 `windows: ${a.windows.join(', ') || 'none'}; Fable matched: ${a.fableMatched.join(', ') || 'none'}`
122 : 'not attempted yet'
123 const lines = [
124 `Surfaces attached now: ${surfaces.length ? surfaces.join(', ') : 'none'}`,
125 `Clients that attached since load: ${attached.length ? attached.join(', ') : 'none seen'}`,
126 `Draw requests received: ${drawn}`,
127 `Panel hidden: ${hidden ? 'yes' : 'no'}; px per cell: ${pxPerCell} (/cache scale <px> changes it)`,
128 `Context: ${snap?.context ? `${snap.context.used} / ${snap.context.window}` : 'no reading'}`,
129 `Limits: ${snap?.limits.length ? snap.limits.map(l => `${l.kind} ${l.usage}%`).join(', ') : 'none'} (source: ${snap?.limitsSource ?? 'none'})`,
130 `Account usage request: ${account}`,
131 `Cache: last write ${snap?.cache.lastAt ? new Date(snap.cache.lastAt).toISOString() : 'none'}, TTL ${snap ? snap.cache.ttlMs / 60000 : '?'}m${snap?.cache.isTtlKnown ? '' : ' (default)'}`,
132 `Full report: ${cwd ? `${cwd}/` : ''}${DIAG_FILE}`,
133 ]
134 const text = lines.join('\n')
135 try {
136 await $.fs.write(
137 DIAG_FILE,
138 JSON.stringify({ at: await $.clock.now(), surfaces, attached, draws, hidden, pxPerCell, account: lastAccount, snap }, null, 2),
139 )
140 } catch {
141 // diagnostics only
142 }
143 return text
144}
145
146async function current($: EngineInterface): Promise<ContextCacheSnapshot> {
147 const snap = await read($, snapshotAtom)
148 if (snap) return snap
149 const stored = await $.store.get('ttlMs')
150 const ttl = stored === TTL_5M || stored === TTL_1H ? stored : TTL_1H
151 return {
152 context: null,
153 limits: [],
154 limitsSource: 'none',
155 cache: emptyCache(ttl, stored === TTL_5M || stored === TTL_1H),
156 updatedAt: await $.clock.now(),
157 }
158}
159
160async function patchCache($: EngineInterface, patch: Partial<ContextCacheCache>) {
161 const base = await current($)
162 await update($, snapshotAtom, prev => {
163 const s = prev ?? base
164 return { ...s, cache: { ...s.cache, ...patch } }
165 })
166}
167
168async function setTtl($: EngineInterface, ttlMs: number) {
169 await $.store.set('ttlMs', ttlMs)
170 await patchCache($, { ttlMs, isTtlKnown: true })
171}
172
173async function fetchAccount($: EngineInterface, now: number, force: boolean) {
174 if (accountInFlight) return
175 if (!force && now - accountAt < ACCOUNT_EVERY_MS) return
176 accountInFlight = true
177 const diag: AccountDiag = { at: now, authKind: 'none', status: null, ok: null, topLevelKeys: [], windows: [], fableMatched: [], bodyPreview: '', error: null }
178 lastAccount = diag
179 try {
180 const auth = await $.session.authorize()
181 diag.authKind = auth ? auth.kind : 'none'
182 if (!auth || auth.kind !== 'bearer') return
183 const res = await $.http.fetch(USAGE_URL, {
184 auth: auth.handle,
185 headers: { 'anthropic-beta': 'oauth-2025-04-20', accept: 'application/json' },
186 })
187 diag.status = res.status
188 diag.ok = res.ok
189 diag.bodyPreview = res.text.slice(0, 2000)
190 if (res.status === 429) {
191 // Back off a whole interval (or what retry-after asks) before trying again.
192 const retry = Number(res.headers['retry-after'])
193 accountAt = now + (Number.isFinite(retry) && retry > 0 ? Math.max(0, retry * 1000 - ACCOUNT_EVERY_MS) : 0)
194 return
195 }
196 if (!res.ok) return
197 const json: unknown = JSON.parse(res.text)
198 if (json && typeof json === 'object') diag.topLevelKeys = Object.keys(json as object)
199 const found = usageWindowsOf(json)
200 diag.windows = found.map(f => `${f.path}=${f.usage}%`)
201 diag.fableMatched = found.filter(f => f.isFable).map(f => f.path)
202 const limits = parseAccountUsage(json)
203 if (limits.length > 0) {
204 accountLimits = limits
205 accountAt = now
206 }
207 } catch (err) {
208 // keep the last good reading; the response headers cover the gap
209 diag.error = err instanceof Error ? err.message : String(err)
210 } finally {
211 accountInFlight = false
212 }
213}
214
215// The Files share of the conversation: reading the whole transcript is the
216// dear part of a refresh, so it is done once per turn and reused between.
217let fileShare = 0
218let fileShareTurn = -1
219let turnNo = 0
220
221async function doRefresh($: EngineInterface, forceAccount: boolean) {
222 const now = await $.clock.now()
223 const prev = await current($)
224
225 let context = prev.context
226 let responseLimits: ContextCacheLimit[] = []
227 try {
228 const usage = await $.session.usage({ breakdown: 'summary' })
229 responseLimits = limitsFromResponse(usage.rateLimits)
230 const b = usage.context.breakdown
231 if (fileShareTurn !== turnNo) {
232 try {
233 const msgs = await $.session.messages()
234 if (Array.isArray(msgs)) fileShare = fileShareOf(msgs)
235 fileShareTurn = turnNo
236 } catch {
237 // keep the last share
238 }
239 }
240 context = buildContext({
241 categories: b?.categories ?? [],
242 realTokens: usage.context.tokens ?? b?.totalTokens,
243 window: usage.context.window,
244 autoCompactAt: b?.autoCompactThreshold,
245 isAutoCompactEnabled: b?.isAutoCompactEnabled ?? false,
246 fileShare,
247 })
248 } catch {
249 // keep the last context reading
250 }
251
252 await fetchAccount($, now, forceAccount)
253 // A reload empties module memory but not the session's snapshot: a recent
254 // account reading there still stands, so Fable does not blink out meanwhile.
255 if (accountLimits === null && prev.limitsSource === 'account' && now - prev.updatedAt < 10 * ACCOUNT_EVERY_MS) {
256 accountLimits = prev.limits
257 accountAt = prev.updatedAt
258 }
259 const isAccountFresh = accountLimits !== null && now - accountAt < 10 * ACCOUNT_EVERY_MS
260 let limits: ContextCacheLimit[]
261 let limitsSource: ContextCacheSnapshot['limitsSource']
262 if (isAccountFresh && accountLimits) {
263 // The headers arrive with every reply and the account reading is up to 5
264 // minutes old, yet the headers can trail it (weekly 25% against the
265 // account's and /usage's 26%). Usage only climbs inside a window, so for
266 // the same window the higher reading is the newer one; across a reset the
267 // later window wins. The account adds what the headers lack (Fable).
268 limits = [
269 ...responseLimits.map(r => {
270 const a = accountLimits!.find(x => x.kind === r.kind)
271 if (!a) return r
272 const resetsAt = r.resetsAt ?? a.resetsAt
273 if (resetsAt !== null && a.resetsAt !== null && Math.abs(resetsAt - a.resetsAt) > SAME_WINDOW_MS) {
274 return a.resetsAt > resetsAt ? a : { ...r, resetsAt }
275 }
276 return { ...r, resetsAt, usage: Math.max(r.usage, a.usage) }
277 }),
278 ...accountLimits.filter(a => !responseLimits.some(r => r.kind === a.kind)),
279 ]
280 limitsSource = 'account'
281 } else if (responseLimits.length > 0) {
282 limits = responseLimits
283 limitsSource = 'response'
284 } else {
285 limits = prev.limits
286 limitsSource = prev.limitsSource
287 }
288
289 await update($, snapshotAtom, latest => {
290 const s = latest ?? prev
291 return { ...s, context, limits, limitsSource, updatedAt: now }
292 })
293 await update($, nowAtom, () => now)
294}
295
296function refresh($: EngineInterface, forceAccount = false): Promise<void> {
297 if (refreshing) return refreshing
298 refreshing = doRefresh($, forceAccount)
299 .catch(() => undefined) // a refresh outliving its session, or a refused write: the next one retries
300 .finally(() => {
301 refreshing = null
302 })
303 return refreshing
304}
305
306async function learnTtl($: EngineInterface, transcriptPath: string) {
307 if (!transcriptPath) return
308 try {
309 const stat = await $.fs.stat(transcriptPath)
310 if (stat.size > MAX_TRANSCRIPT_BYTES) return
311 const text = await $.fs.read(transcriptPath)
312 const ttl = ttlFromTranscript(typeof text === 'string' ? text : '')
313 if (ttl !== null) await setTtl($, ttl)
314 } catch {
315 // unreadable: the stored TTL stands
316 }
317}
318
319const PANE = 'context-cache'
320
321const COMMANDS = [
322 { name: 'cache', description: 'Context & cache panel: show or hide it' },
323 { name: 'cache-status', description: 'Context & cache panel: where each figure comes from (surfaces, draws, the account usage request, Fable)' },
324 { name: 'cache-pane', description: 'Context & cache panel: open it as a pane (the way it shows on mobile)' },
325 { name: 'cache-refresh', description: 'Context & cache panel: re-read usage now' },
326 { name: 'cache-scale', description: 'Context & cache panel: px per cell the desktop draws at (default 8); a number sets it, none steps it', argumentHint: '[px]' },
327] as const
328
329async function hasMobile($: EngineInterface): Promise<boolean> {
330 try {
331 return (await $.session.surfaces()).includes('mobile')
332 } catch {
333 return false
334 }
335}
336
337/** The card's inner width in CSS px on a remote surface: the band's cells × px per cell, less the border. */
338async function widthPx($: EngineInterface, columns: number): Promise<number> {
339 return columns * (await read($, pxPerCellAtom)) - 2
340}
341
342/** The size classes are set in the design's cells (8px each): a px width as that many. */
343function designCells(px: number): number {
344 return px / DEFAULT_PX_PER_CELL
345}
346
347/** The view model for a drawing, or null while hidden or before any reading. */
348async function panelModel($: EngineInterface, columns: number, maxRows: number): Promise<ViewModel | null> {
349 const [snap, hidden, tick] = await Promise.all([read($, snapshotAtom), read($, hiddenAtom), read($, nowAtom)])
350 if (hidden || !snap) return null
351 return buildView(snap, Math.max(tick, snap.updatedAt), columns, maxRows)
352}
353
354/** Clear and Compact. Neither hides the notice row: it stays on whatever the cache holds. */
355function actionsFor($: EngineInterface, isWorking: boolean): Actions {
356 return {
357 clear: async () => {
358 if (isWorking) $.ui.toast('Clear runs once the current turn finishes.')
359 try {
360 await $.command.run({ command: 'clear' })
361 } catch {
362 $.ui.toast('Could not run /clear right now.')
363 }
364 },
365 compact: async () => {
366 try {
367 const r = await $.session.compact()
368 if ('skip' in r) {
369 $.ui.toast('Compaction was skipped.')
370 } else {
371 // Our own session.compact hook does not see our own call: reset here.
372 await patchCache($, { lastAt: null, hitRate: null })
373 void refresh($)
374 }
375 } catch {
376 $.ui.toast('Compact runs between turns: try again when this one finishes.')
377 }
378 },
379 }
380}
381
382export const register: Register = on => {
383 // ── Session lifecycle ──────────────────────────────────────────────────
384
385 on('session.start', async ($, e, next) => {
386 cwd = e.cwd
387 // One command each: the desktop composer drops anything typed after a slash command's name.
388 for (const c of COMMANDS) await $.command.register(c)
389 const storedPx = await $.store.get('pxPerCell')
390 if (typeof storedPx === 'number' && storedPx > 0) await update($, pxPerCellAtom, () => storedPx)
391 const initial = await current($)
392 await update($, snapshotAtom, prev => prev ?? initial)
393 void refresh($, true)
394 // Started from the phone: the pane is how the panel shows there.
395 if (await hasMobile($)) void $.ui.open({ id: PANE, title: 'Context & cache' }).catch(() => undefined)
396 // The countdown: checked once a second, redrawn only when a figure the
397 // panel shows would change (a 60m TTL moves a whole percent every 36s; a
398 // 5m one shows seconds, so it redraws every second).
399 let lastShown = ''
400 $.clock.every(1000, () => {
401 void (async () => {
402 const [snap, hidden] = await Promise.all([read($, snapshotAtom), read($, hiddenAtom)])
403 if (hidden || !snap || snap.cache.lastAt === null) return
404 const now = await $.clock.now()
405 const c = cacheView(snap.cache, now)
406 const shown = `${c.state}|${pct(c.warmth * 100)}|${c.detail}|${c.state === 'cooling' ? noticeText(c, snap.context, snap.cache.ttlMs) : ''}`
407 if (shown === lastShown) return
408 lastShown = shown
409 await update($, nowAtom, () => now)
410 })().catch(() => undefined)
411 })
412 $.clock.every(ACCOUNT_EVERY_MS, () => void refresh($, false))
413 return next(e)
414 })
415
416 // Resumed: the transcript says how long ago the last response was.
417 on('classic.SessionStart', async ($, e, next) => {
418 const now = await $.clock.now()
419 if ((e.source === 'resume' || e.source === 'fork') && typeof e.seconds_since_last_response === 'number') {
420 await patchCache($, { lastAt: now - e.seconds_since_last_response * 1000 })
421 } else if (e.source === 'clear' || e.source === 'compact') {
422 await patchCache($, { lastAt: null, hitRate: null })
423 }
424 void learnTtl($, e.transcript_path)
425 return next(e)
426 }).catch((_$, e, next) => next(e))
427
428 // Each main-thread response writes/reads the cache as it lands, so a long
429 // turn (or the first one after /clear) does not sit at "Empty" until it ends.
430 on('turn.step', async function* ($, e, next) {
431 const r = yield* next(e)
432 const u = r.usage
433 if (e.agentId === undefined && u) {
434 const total = u.cache_read_input_tokens + u.cache_creation_input_tokens + u.input_tokens
435 void $.clock
436 .now()
437 .then(now => patchCache($, { lastAt: now, hitRate: total > 0 ? u.cache_read_input_tokens / total : null }))
438 .catch(() => undefined)
439 }
440 return r
441 })
442
443 // A turn of the main conversation ended: its requests just wrote/read the cache.
444 on('turn.complete', async ($, e, next) => {
445 const result = await next(e)
446 if (e.agentId === undefined) {
447 turnNo += 1
448 const now = await $.clock.now()
449 const u = e.usage
450 const total = u ? u.cache_read_input_tokens + u.cache_creation_input_tokens + u.input_tokens : 0
451 await patchCache($, {
452 lastAt: u ? now : (await current($)).cache.lastAt,
453 hitRate: u && total > 0 ? u.cache_read_input_tokens / total : (await current($)).cache.hitRate,
454 })
455 void refresh($)
456 }
457 return result
458 })
459
460 // After each turn, read which TTL the responses were cached with.
461 on('classic.Stop', ($, e, next) => {
462 void learnTtl($, e.transcript_path)
463 return next(e)
464 }).catch((_$, e, next) => next(e))
465
466 // The engine reports the TTL itself on a model switch; a switch also forfeits the cache.
467 on('classic.PostModelSwitch', async ($, e, next) => {
468 await setTtl($, e.cache_ttl === '5m' ? TTL_5M : TTL_1H)
469 await patchCache($, { lastAt: null, hitRate: null })
470 void refresh($)
471 return next(e)
472 }).catch((_$, e, next) => next(e))
473
474 // Usage figures moved (context fill, a limit window).
475 on('session.measure', ($, e, next) => {
476 void refresh($)
477 return next(e)
478 })
479
480 // A compaction replaces the conversation: nothing of the new one is cached yet.
481 on('session.compact', async ($, e, next) => {
482 const result = await next(e)
483 if (e.trigger !== 'precompute' && !('skip' in result)) {
484 await patchCache($, { lastAt: null, hitRate: null })
485 void refresh($)
486 }
487 return result
488 }).catch((_$, e, next) => next(e))
489
490 // /clear starts a fresh conversation in the same process.
491 on('session.end', async ($, e, next) => {
492 const result = await next(e)
493 if (e.reason === 'clear') {
494 await patchCache($, { lastAt: null, hitRate: null })
495 void refresh($)
496 }
497 return result
498 })
499
500 // ── /cache, /cache-status, /cache-pane, /cache-refresh, /cache-scale ────
501
502 on('command.run', { command: 'cache-status' }, async $ => ({ text: await statusReport($) }))
503
504 on('command.run', { command: 'cache-refresh' }, async $ => {
505 await refresh($, true)
506 return { text: 'Context & cache panel refreshed.' }
507 })
508
509 on('command.run', { command: 'cache-scale' }, async ($, e) => {
510 const was = await read($, pxPerCellAtom)
511 const typed = Number(e.args.trim())
512 // No number typed (or none delivered): step through 7 … 9.5 and round.
513 const n = Number.isFinite(typed) && typed > 0 ? typed : was >= 9.5 ? 7 : Math.round((was + 0.5) * 2) / 2
514 await $.store.set('pxPerCell', n)
515 await update($, pxPerCellAtom, () => n)
516 return { text: `Desktop now draws at ${n} px per cell (was ${was}). Run it again to step up; /cache-scale <px> sets it outright.` }
517 })
518
519 on('command.run', { command: 'cache-pane' }, async $ => {
520 await update($, hiddenAtom, () => false)
521 void refresh($)
522 await $.ui.open({ id: PANE, title: 'Context & cache' })
523 return { text: 'Context & cache panel opened.' }
524 })
525
526 on('command.run', { command: 'cache' }, async $ => {
527 const isHidden = await read($, hiddenAtom)
528 await update($, hiddenAtom, () => !isHidden)
529 if (isHidden) {
530 void refresh($)
531 // Phones have no band above the prompt: show it there as a pane.
532 if (await hasMobile($)) await $.ui.open({ id: PANE, title: 'Context & cache' })
533 return { text: 'Context & cache panel shown.' }
534 }
535 await $.ui.close({ id: PANE }).catch(() => undefined)
536 return { text: 'Context & cache panel hidden. /cache shows it again.' }
537 })
538
539 // A phone joined the session: open the panel as a pane there.
540 on('session.attach', async ($, e, next) => {
541 const result = await next(e)
542 attached.push(e.surface)
543 if (e.surface === 'mobile' && !(await read($, hiddenAtom))) {
544 void $.ui.open({ id: PANE, title: 'Context & cache' }).catch(() => undefined)
545 }
546 return result
547 })
548
549 // ── The band above the prompt (terminal, desktop) ──────────────────────
550
551 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
552 noteDraw('AbovePrompt', e.surface)
553 if (e.props.hasSurvey) return next(e)
554 if (e.props.view.agentId !== undefined) return next(e)
555 const E = $.ui.resolve(e)
556 if (e.surface === 'terminal') {
557 const vm = await panelModel($, e.props.bodyColumns, e.props.maxRows)
558 if (!vm) return next(e)
559 return renderTerminal(E as Parameters<typeof renderTerminal>[0], vm, e.props.bodyColumns, actionsFor($, e.props.isWorking))
560 }
561 if (e.surface === 'desktop' || e.surface === 'vscode') {
562 const px = await widthPx($, e.props.bodyColumns)
563 const vm = await panelModel($, designCells(px), e.props.maxRows)
564 if (!vm) return next(e)
565 return renderDesktop(E as Parameters<typeof renderDesktop>[0], vm, px, actionsFor($, e.props.isWorking))
566 }
567 return next(e)
568 })
569
570 // ── The same panel as a pane (every surface; the phone's way to see it) ─
571
572 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
573 noteDraw('Pane', e.surface)
574 const E = $.ui.resolve(e)
575 const { Text } = E
576 const rows = Math.max(e.props.scroll.bodyRows, 12)
577 if (e.surface === 'terminal') {
578 const vm = await panelModel($, e.props.bodyColumns, rows)
579 if (!vm) return <Text dimColor>Reading usage…</Text>
580 return renderTerminal(E as Parameters<typeof renderTerminal>[0], vm, e.props.bodyColumns, actionsFor($, false))
581 }
582 const px = await widthPx($, e.props.bodyColumns)
583 const vm = await panelModel($, designCells(px), rows)
584 if (!vm) return <Text dimColor>Reading usage…</Text>
585 return renderDesktop(E as Parameters<typeof renderDesktop>[0], vm, px, actionsFor($, false))
586 })
587}
588hooks/model.ts 511 lines1// Pure maths and copy for the context-cache panel: no `$`, no drawing.
2// Everything here takes real figures in and returns what the panel shows.
3
4import type { ContextCategory, SessionMessage, SessionRateLimit } from 'claude-code'
5
6import type {
7 ContextCacheCache,
8 ContextCacheContext,
9 ContextCacheGroup,
10 ContextCacheLimit,
11 ContextCacheLimitKind,
12 ContextCacheSnapshot,
13} from '../types'
14
15// ── Design tokens ──────────────────────────────────────────────────────────
16
17export const TOKENS = {
18 card: '#222120',
19 border: '#34322e',
20 borderStrong: '#4a4741',
21 track: '#2f2d2a',
22 ringTrack: '#3a3835',
23 text: '#ece9e2',
24 text2: '#b9b5ab',
25 text3: '#8a867d',
26 text4: '#7d7970',
27 tick: '#ece9e2',
28 buttonText: '#1b1a18',
29} as const
30
31export const GROUP_COLORS: Record<ContextCacheGroup, string> = {
32 system: '#6f6b64',
33 tools: '#7050b8',
34 files: '#2f6bd6',
35 messages: '#c2562f',
36}
37
38export const GROUP_LABELS: Record<ContextCacheGroup, string> = {
39 system: 'System',
40 tools: 'Tools',
41 files: 'Files',
42 messages: 'Messages',
43}
44
45export const GROUPS: readonly ContextCacheGroup[] = ['system', 'tools', 'files', 'messages']
46
47export const LIMIT_COLORS: Record<ContextCacheLimitKind, { bright: string; dark: string }> = {
48 session: { bright: '#4fae6a', dark: '#2c5a3a' },
49 weekly: { bright: '#3b7be0', dark: '#2b4a80' },
50 fable: { bright: '#8a6fc0', dark: '#4a4160' },
51}
52export const AMBER = { bright: '#e0a84f', dark: '#6b5428' }
53export const RED = { bright: '#e5604f', dark: '#6b2f2a' }
54
55export const LIMIT_TITLES: Record<ContextCacheLimitKind, string> = {
56 session: 'Current session',
57 weekly: 'Weekly limits',
58 fable: 'Fable',
59}
60
61export const CACHE_COLORS = {
62 warm: '#e8833a',
63 cooling: '#e0a84f',
64 cold: '#4f9be8',
65 empty: '#7d7970',
66} as const
67
68export const HOUR = 3_600_000
69export const FIVE_HOURS = 5 * HOUR
70export const SEVEN_DAYS = 7 * 24 * HOUR
71export const TTL_5M = 5 * 60_000
72export const TTL_1H = HOUR
73
74// ── Formatting ─────────────────────────────────────────────────────────────
75
76/** 452000 → "452k", 1000000 → "1M", 1500000 → "1.5M", 640 → "640". */
77export function fmtTokens(n: number): string {
78 if (n >= 1_000_000) {
79 const m = n / 1_000_000
80 return `${Number.isInteger(m) ? m : m.toFixed(1).replace(/\.0$/, '')}M`
81 }
82 if (n >= 1000) return `${Math.round(n / 1000)}k`
83 return String(Math.max(0, Math.round(n)))
84}
85
86/** Whole percent, never NaN. */
87export function pct(n: number): number {
88 return Number.isFinite(n) ? Math.round(n) : 0
89}
90
91/** 7_920_000 → "2h 12m"; 200_000 → "3m 20s"; 45_000 → "45s"; days when ≥ 24h. */
92export function fmtSpan(ms: number, withSeconds = false): string {
93 const s = Math.max(0, Math.round(ms / 1000))
94 const d = Math.floor(s / 86400)
95 const h = Math.floor((s % 86400) / 3600)
96 const m = Math.floor((s % 3600) / 60)
97 const sec = s % 60
98 if (d > 0) return `${d}d ${h}h`
99 if (h > 0) return `${h}h ${m}m`
100 if (withSeconds) return m > 0 ? `${m}m ${sec}s` : `${sec}s`
101 return `${m}m`
102}
103
104const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
105
106/** "Resets in 2h 12m" inside a day, else "Resets Sun 11:00 pm" (local time). */
107export function fmtReset(resetsAt: number | null, now: number): string {
108 if (resetsAt === null) return 'Reset time not reported'
109 const left = resetsAt - now
110 if (left <= 0) return 'Resetting now'
111 if (left < 24 * HOUR) return `Resets in ${fmtSpan(left)}`
112 const d = new Date(resetsAt)
113 const h24 = d.getHours()
114 const h12 = h24 % 12 === 0 ? 12 : h24 % 12
115 const mm = String(d.getMinutes()).padStart(2, '0')
116 return `Resets ${DAYS[d.getDay()]} ${h12}:${mm} ${h24 < 12 ? 'am' : 'pm'}`
117}
118
119// ── Context ────────────────────────────────────────────────────────────────
120
121/** Tools whose results are file contents, for the Files share of Messages. */
122const FILE_TOOLS = new Set(['Read', 'NotebookRead'])
123
124/**
125 * The share of the conversation's text that is file contents the model read,
126 * 0 to 1: the Files segment is that share of the Messages row's real tokens.
127 */
128export function fileShareOf(messages: readonly SessionMessage[]): number {
129 let total = 0
130 let files = 0
131 for (const m of messages) {
132 total += m.text.length
133 for (const use of m.toolUses) {
134 const len = (use.text ?? '').length + JSON.stringify(use.input ?? {}).length
135 total += len
136 if (FILE_TOOLS.has(use.tool)) files += (use.text ?? '').length
137 }
138 }
139 return total > 0 ? Math.min(1, files / total) : 0
140}
141
142/** Which of the four groups a /context row belongs to. */
143export function groupOf(name: string): ContextCacheGroup {
144 if (/message/i.test(name)) return 'messages'
145 if (/tool|mcp|agent|skill|command/i.test(name)) return 'tools'
146 return 'system'
147}
148
149/**
150 * The context row's figures: /context's categories grouped into four and
151 * scaled so they sum to the real token count the last response reported.
152 */
153export function buildContext(args: {
154 categories: readonly ContextCategory[]
155 realTokens: number | undefined
156 window: number
157 autoCompactAt: number | undefined
158 isAutoCompactEnabled: boolean
159 fileShare: number
160}): ContextCacheContext {
161 const raw: Record<ContextCacheGroup, number> = { system: 0, tools: 0, files: 0, messages: 0 }
162 for (const c of args.categories) {
163 if (c.kind !== 'used') continue
164 raw[groupOf(c.name)] += c.tokens
165 }
166 const fileTokens = Math.round(raw.messages * args.fileShare)
167 raw.files = fileTokens
168 raw.messages -= fileTokens
169 const estimate = GROUPS.reduce((s, g) => s + raw[g], 0)
170 const used = args.realTokens ?? estimate
171 const k = estimate > 0 ? used / estimate : 0
172 const parts = { system: 0, tools: 0, files: 0, messages: 0 } as Record<ContextCacheGroup, number>
173 for (const g of GROUPS) parts[g] = Math.round(raw[g] * k)
174 return {
175 used,
176 window: args.window,
177 autoCompactAt: args.isAutoCompactEnabled && args.autoCompactAt ? args.autoCompactAt : null,
178 parts,
179 }
180}
181
182// ── Limits ─────────────────────────────────────────────────────────────────
183
184type Found = { path: string[]; node: Record<string, unknown>; usage: number }
185
186function walk(node: unknown, path: string[], out: Found[]): void {
187 if (Array.isArray(node)) {
188 node.forEach((n, i) => walk(n, [...path, String(i)], out))
189 return
190 }
191 if (node === null || typeof node !== 'object') return
192 const obj = node as Record<string, unknown>
193 // five_hour / seven_day say `utilization`; the limits[] items (the per-model windows) say `percent`.
194 const u = obj['utilization'] ?? obj['percent'] ?? obj['percent_used'] ?? obj['used_percent']
195 if (typeof u === 'number') {
196 out.push({ path, node: obj, usage: u })
197 return
198 }
199 for (const [k, v] of Object.entries(obj)) walk(v, [...path, k], out)
200}
201
202const isFable = (f: Found) => /fable/i.test(f.path.join('.')) || /fable/i.test(JSON.stringify(f.node))
203
204/** Every window with a utilization figure in the account usage JSON, for /cache status. */
205export function usageWindowsOf(json: unknown): { path: string; usage: number; isFable: boolean }[] {
206 const found: Found[] = []
207 walk(json, [], found)
208 return found.map(f => ({ path: f.path.join('.'), usage: f.usage, isFable: isFable(f) }))
209}
210
211function toEpochMs(v: unknown): number | null {
212 if (typeof v === 'number') return v < 1e12 ? v * 1000 : v
213 if (typeof v === 'string') {
214 const t = Date.parse(v)
215 return Number.isNaN(t) ? null : t
216 }
217 return null
218}
219
220/**
221 * Reads the account usage endpoint's JSON (api.anthropic.com/api/oauth/usage),
222 * the same source /usage draws on: `five_hour` is the current session,
223 * `seven_day` the weekly limit, and Fable is the `limits[]` item of kind
224 * `weekly_scoped` whose `scope.model.display_name` is "Fable" (an item with
225 * `scope: null` repeats `seven_day`). Matching by name rather than a fixed
226 * key keeps it working if the key moves.
227 */
228export function parseAccountUsage(json: unknown): ContextCacheLimit[] {
229 const found: Found[] = []
230 walk(json, [], found)
231 const out: ContextCacheLimit[] = []
232 const take = (kind: ContextCacheLimitKind, f: Found | undefined, windowMs: number) => {
233 if (!f) return
234 out.push({
235 kind,
236 usage: Math.max(0, Math.min(100, f.usage)),
237 resetsAt: toEpochMs(f.node['resets_at'] ?? f.node['resetsAt']),
238 windowMs,
239 })
240 }
241 const keyOf = (f: Found) => f.path[f.path.length - 1] ?? ''
242 take('session', found.find(f => keyOf(f) === 'five_hour'), FIVE_HOURS)
243 take('weekly', found.find(f => keyOf(f) === 'seven_day'), SEVEN_DAYS)
244 const fable = found.filter(isFable).sort((a, b) => b.usage - a.usage)[0]
245 take('fable', fable, SEVEN_DAYS)
246 return out
247}
248
249/** The engine's own per-response rate-limit readings, as a fallback. */
250export function limitsFromResponse(rateLimits: readonly SessionRateLimit[]): ContextCacheLimit[] {
251 const out: ContextCacheLimit[] = []
252 for (const r of rateLimits) {
253 const kind: ContextCacheLimitKind | null =
254 r.kind === 'five_hour' ? 'session' : r.kind === 'seven_day' ? 'weekly' : /fable/i.test(r.kind) ? 'fable' : null
255 if (!kind) continue
256 out.push({
257 kind,
258 usage: Math.max(0, Math.min(100, r.percentUsed)),
259 resetsAt: r.resetsAt ? toEpochMs(r.resetsAt) : null,
260 windowMs: kind === 'session' ? FIVE_HOURS : SEVEN_DAYS,
261 })
262 }
263 return out
264}
265
266export type LimitTone = 'normal' | 'amber' | 'red'
267
268export type LimitView = {
269 kind: ContextCacheLimitKind
270 title: string
271 /** Null when this limit has no reading at all. */
272 usage: number | null
273 time: number | null
274 tone: LimitTone
275 bright: string
276 dark: string
277 reset: string
278}
279
280/** Percent of the window that has elapsed, from its reset time. */
281export function timePct(limit: ContextCacheLimit, now: number): number | null {
282 if (limit.resetsAt === null) return null
283 const elapsed = limit.windowMs - (limit.resetsAt - now)
284 return Math.max(0, Math.min(100, (elapsed / limit.windowMs) * 100))
285}
286
287/** Red at ≥ 90% used; amber when > 15 points and > 1.4× ahead of time. */
288export function toneOf(usage: number, time: number | null): LimitTone {
289 if (usage >= 90) return 'red'
290 const t = time ?? 0
291 if (usage - t > 15 && (t === 0 || usage / t > 1.4)) return 'amber'
292 return 'normal'
293}
294
295export function limitView(kind: ContextCacheLimitKind, limit: ContextCacheLimit | undefined, now: number): LimitView {
296 if (!limit) {
297 const base = LIMIT_COLORS[kind]
298 return { kind, title: LIMIT_TITLES[kind], usage: null, time: null, tone: 'normal', ...base, reset: 'No reading yet' }
299 }
300 const time = timePct(limit, now)
301 const tone = toneOf(limit.usage, time)
302 const colors = tone === 'red' ? RED : tone === 'amber' ? AMBER : LIMIT_COLORS[kind]
303 return {
304 kind,
305 title: LIMIT_TITLES[kind],
306 usage: limit.usage,
307 time,
308 tone,
309 ...colors,
310 reset: fmtReset(limit.resetsAt, now),
311 }
312}
313
314// ── Cache ──────────────────────────────────────────────────────────────────
315
316export type CacheState = 'warm' | 'cooling' | 'cold' | 'empty'
317
318export type CacheView = {
319 state: CacheState
320 /** Remaining TTL ÷ TTL, 0 to 1. */
321 warmth: number
322 remainingMs: number
323 color: string
324 label: string
325 hit: number | null
326 detail: string
327}
328
329/** Warm > 25% of the TTL left, cooling 1–25%, cold at 0; empty with nothing cached. */
330export function cacheView(cache: ContextCacheCache, now: number): CacheView {
331 const ttl = cache.ttlMs
332 const hit = cache.hitRate === null ? null : cache.hitRate * 100
333 if (cache.lastAt === null) {
334 return {
335 state: 'empty',
336 warmth: 0,
337 remainingMs: 0,
338 color: CACHE_COLORS.empty,
339 label: 'Empty',
340 hit,
341 detail: 'Nothing cached yet',
342 }
343 }
344 const remainingMs = Math.max(0, cache.lastAt + ttl - now)
345 const warmth = ttl > 0 ? remainingMs / ttl : 0
346 const state: CacheState = warmth <= 0 ? 'cold' : warmth > 0.25 ? 'warm' : 'cooling'
347 const short = ttl < 10 * 60_000
348 const ttlText = `${Math.round(ttl / 60_000)}m`
349 const detail =
350 state === 'cold'
351 ? (() => {
352 const ago = now - (cache.lastAt + ttl)
353 return ago < 60_000 ? 'Expired just now' : `Expired ${fmtSpan(ago)} ago`
354 })()
355 : short
356 ? `${fmtSpan(remainingMs, true)} left of ${ttlText}`
357 : `${Math.max(1, Math.round(remainingMs / 60_000))}m left of ${ttlText}`
358 return {
359 state,
360 warmth,
361 remainingMs,
362 color: CACHE_COLORS[state],
363 label: state === 'warm' ? 'Warm' : state === 'cooling' ? 'Cooling' : 'Cold',
364 hit,
365 detail,
366 }
367}
368
369/** The notice line's copy. Always a line: the row with Clear / Compact never hides. */
370export function noticeText(cache: CacheView, context: ContextCacheContext | null, ttlMs: number): string {
371 if (!context) return cache.state === 'empty' ? 'Cache empty: nothing cached yet.' : `Cache ${cache.label.toLowerCase()}: reading context size…`
372 const tokens = fmtTokens(context.used)
373 if (cache.state === 'empty') return `Cache empty: next message writes ${tokens} to cache.`
374 if (cache.state === 'warm') {
375 const usedPct = (context.used / context.window) * 100
376 if (context.autoCompactAt !== null && usedPct >= 85) {
377 const away = Math.max(0, pct((context.autoCompactAt / context.window) * 100 - usedPct))
378 return `Cache warm: next message reuses ${tokens}. Auto-compact is ${away}% away.`
379 }
380 return `Cache warm: next message reuses ${tokens} from cache.`
381 }
382 if (cache.state === 'cooling') {
383 const within =
384 ttlMs < 10 * 60_000 ? fmtSpan(cache.remainingMs, true) : `${Math.max(1, Math.round(cache.remainingMs / 60_000))}m`
385 return `Cache cooling: send within ${within} to keep ${tokens} cached.`
386 }
387 return `Cache cold: next message re-reads ${tokens}. Clear is free.`
388}
389
390// ── Layout ─────────────────────────────────────────────────────────────────
391
392/**
393 * The design's four widths, in the cells the band is laid out in (≈ 8px a
394 * cell): a ≈ 900px, b ≈ 620px (columns from ~500px), c ≈ 420px (rings),
395 * d ≈ 320px (rings, no legend or detail lines).
396 */
397export type SizeClass = 'a' | 'b' | 'c' | 'd'
398
399export function sizeClassOf(columns: number): SizeClass {
400 if (columns >= 100) return 'a'
401 if (columns >= 62) return 'b'
402 if (columns >= 45) return 'c'
403 return 'd'
404}
405
406/** Rows the terminal card takes in each class (border included). */
407export function rowsFor(size: SizeClass, hasNotice: boolean): number {
408 const body = size === 'a' || size === 'b' ? 1 + 1 + 4 + (hasNotice ? 1 : 0) : size === 'c' ? 1 + 1 + 2 + (hasNotice ? 2 : 0) : 1 + 1 + (hasNotice ? 2 : 0)
409 return body + 2
410}
411
412// ── The whole view ─────────────────────────────────────────────────────────
413
414export type ViewModel = {
415 size: SizeClass
416 context: {
417 used: number
418 window: number
419 usedLabel: string
420 compactPct: number | null
421 rightLabel: string
422 segments: { group: ContextCacheGroup; tokens: number; color: string; label: string }[]
423 free: number
424 } | null
425 limits: LimitView[]
426 cache: CacheView
427 notice: string
428 showLegend: boolean
429 showDetail: boolean
430}
431
432export function buildView(
433 snap: ContextCacheSnapshot,
434 now: number,
435 columns: number,
436 maxRows: number,
437): ViewModel {
438 let size = sizeClassOf(columns)
439 const cache = cacheView(snap.cache, now)
440 const notice = noticeText(cache, snap.context, snap.cache.ttlMs)
441 // Fall back to a shorter layout when the band has fewer rows than it needs.
442 while (size !== 'd' && rowsFor(size, true) > maxRows) {
443 size = size === 'a' ? 'c' : size === 'b' ? 'c' : 'd'
444 }
445
446 const ctx = snap.context
447 const context = ctx
448 ? (() => {
449 const compactPct = ctx.autoCompactAt === null ? null : pct((ctx.autoCompactAt / ctx.window) * 100)
450 const wide = size === 'a' || size === 'b'
451 const rightLabel =
452 compactPct === null
453 ? `${pct((ctx.used / ctx.window) * 100)}%`
454 : wide
455 ? `Compact at ${compactPct}%`
456 : `${compactPct}%`
457 return {
458 used: ctx.used,
459 window: ctx.window,
460 usedLabel: `${fmtTokens(ctx.used)} / ${fmtTokens(ctx.window)} (${pct((ctx.used / ctx.window) * 100)}%)`,
461 compactPct,
462 rightLabel,
463 segments: GROUPS.map(g => ({
464 group: g,
465 tokens: ctx.parts[g],
466 color: GROUP_COLORS[g],
467 label: GROUP_LABELS[g],
468 })),
469 free: Math.max(0, ctx.window - ctx.used),
470 }
471 })()
472 : null
473
474 const byKind = (k: ContextCacheLimitKind) => snap.limits.find(l => l.kind === k)
475 const limits = (['session', 'weekly', 'fable'] as const).map(k => limitView(k, byKind(k), now))
476
477 return {
478 size,
479 context,
480 limits,
481 cache,
482 notice,
483 showLegend: size !== 'd',
484 showDetail: size === 'a' || size === 'b',
485 }
486}
487
488// ── Colour helpers ─────────────────────────────────────────────────────────
489
490function hexToRgb(hex: string): [number, number, number] {
491 const h = hex.replace('#', '')
492 return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]
493}
494
495function rgbToHex([r, g, b]: [number, number, number]): string {
496 return `#${[r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('')}`
497}
498
499/** Mixes `a` toward `b` by `t` (0 → a, 1 → b). */
500export function mix(a: string, b: string, t: number): string {
501 const x = hexToRgb(a)
502 const y = hexToRgb(b)
503 return rgbToHex([x[0] + (y[0] - x[0]) * t, x[1] + (y[1] - x[1]) * t, x[2] + (y[2] - x[2]) * t])
504}
505
506/** The temperature gradient: blue → amber (50%) → orange. */
507export function tempColorAt(x: number): string {
508 const t = Math.max(0, Math.min(1, x))
509 return t < 0.5 ? mix(CACHE_COLORS.cold, CACHE_COLORS.cooling, t / 0.5) : mix(CACHE_COLORS.cooling, CACHE_COLORS.warm, (t - 0.5) / 0.5)
510}
511hooks/view.tsx 312 lines1// Drawing for the context-cache panel: one view model, two renderers.
2// Terminal: block-character bars in the design's colours, text glyphs for rings.
3// Desktop (and the editor, the phone's pane): the design drawn as SVG, host buttons.
4
5import type { Elements } from 'claude-code'
6
7import { fmtTokens, pct, TOKENS, tempColorAt, mix, type CacheView, type LimitView, type ViewModel } from './model'
8import { panelSvg } from './panel-svg'
9
10export type Actions = {
11 clear: () => void | Promise<void>
12 compact: () => void | Promise<void>
13}
14
15type TermEls = Elements['terminal']
16type DeskEls = Elements['desktop'] | Elements['vscode'] | Elements['mobile']
17
18const BAR = '━'
19const TICK = '┃'
20
21// ── Shared bits ────────────────────────────────────────────────────────────
22
23function usageLine(l: LimitView): { main: string; time: string } {
24 if (l.usage === null) return { main: 'No data', time: '' }
25 return { main: `Usage ${pct(l.usage)}%`, time: l.time === null ? '' : ` · Time ${pct(l.time)}%` }
26}
27
28function cacheLine(c: CacheView): { main: string; hit: string } {
29 if (c.state === 'empty') return { main: 'Warmth —', hit: c.hit === null ? '' : ` · Hit ${pct(c.hit)}%` }
30 return { main: `Warmth ${pct(c.warmth * 100)}%`, hit: ` · Hit ${c.hit === null ? '—' : `${pct(c.hit)}%`}` }
31}
32
33// ── Terminal ───────────────────────────────────────────────────────────────
34
35type Cell = { ch: string; color: string }
36
37/** Context bar: one cell run per group, the auto-compact tick over it. */
38export function contextCells(vm: NonNullable<ViewModel['context']>, n: number): Cell[] {
39 const cells: Cell[] = Array.from({ length: n }, () => ({ ch: BAR, color: TOKENS.track }))
40 let acc = 0
41 for (const s of vm.segments) {
42 const start = Math.round((acc / vm.window) * n)
43 acc += s.tokens
44 const end = Math.min(n, Math.round((acc / vm.window) * n))
45 for (let i = start; i < end; i++) cells[i] = { ch: BAR, color: s.color }
46 }
47 if (vm.compactPct !== null) {
48 const i = Math.min(n - 1, Math.floor((vm.compactPct / 100) * n))
49 cells[i] = { ch: TICK, color: TOKENS.tick }
50 }
51 return cells
52}
53
54/** Double bar: dark = time (to max of both), bright = usage, tick at time. */
55export function limitCells(l: LimitView, n: number): Cell[] {
56 const u = l.usage ?? 0
57 const t = l.time ?? 0
58 // Any usage at all shows as at least one cell.
59 const brightEnd = u > 0 ? Math.max(1, Math.round((u / 100) * n)) : 0
60 const darkEnd = Math.max(brightEnd, Math.round((Math.max(u, t) / 100) * n))
61 const cells: Cell[] = Array.from({ length: n }, (_, i) => ({
62 ch: BAR,
63 color: i < brightEnd ? l.bright : i < darkEnd ? l.dark : TOKENS.track,
64 }))
65 // The time tick; at the very start of a window it would hide the bar's first cell.
66 const tickAt = Math.min(n - 1, Math.round((t / 100) * n))
67 if (l.usage !== null && l.time !== null && tickAt >= 1) {
68 cells[tickAt] = { ch: TICK, color: TOKENS.tick }
69 }
70 return cells
71}
72
73/** Temperature bar: the gradient, covered right of the current warmth, a tick at it. */
74export function tempCells(c: CacheView, n: number): Cell[] {
75 const cells: Cell[] = Array.from({ length: n }, (_, i) => {
76 const x = (i + 0.5) / n
77 const color = tempColorAt(x)
78 return { ch: BAR, color: x > c.warmth ? mix(color, TOKENS.card, 0.74) : color }
79 })
80 if (c.state !== 'empty' && c.warmth > 0) {
81 cells[Math.min(n - 1, Math.floor(c.warmth * n))] = { ch: TICK, color: TOKENS.tick }
82 }
83 return cells
84}
85
86function runsOf(cells: Cell[]): { text: string; color: string }[] {
87 const runs: { text: string; color: string }[] = []
88 for (const c of cells) {
89 const last = runs[runs.length - 1]
90 if (last && last.color === c.color) last.text += c.ch
91 else runs.push({ text: c.ch, color: c.color })
92 }
93 return runs
94}
95
96const TERM_GLYPH = { session: '◷', weekly: '▦', fable: '✦' } as const
97function cacheGlyph(c: CacheView): string {
98 return c.state === 'cold' ? '❄' : c.state === 'empty' ? '○' : '♨'
99}
100
101export function renderTerminal(E: TermEls, vm: ViewModel, columns: number, actions: Actions) {
102 const { Box, Text, Button } = E
103 const inner = Math.max(20, columns - 4)
104 const wide = vm.size === 'a' || vm.size === 'b'
105
106 const bar = (cells: Cell[]) => (
107 <Text wrap="truncate">
108 {runsOf(cells).map(r => (
109 <Text color={r.color}>{r.text}</Text>
110 ))}
111 </Text>
112 )
113
114 // Row 1: context
115 const ctx = vm.context
116 const contextRow = ctx ? (
117 <Box flexDirection="row">
118 <Text bold color={TOKENS.text}>Context </Text>
119 <Text color={TOKENS.text3}>{ctx.usedLabel} </Text>
120 {bar(contextCells(ctx, Math.max(6, inner - 8 - ctx.usedLabel.length - 1 - ctx.rightLabel.length - 1)))}
121 <Text color={TOKENS.text4}> {ctx.rightLabel}</Text>
122 </Box>
123 ) : (
124 <Text color={TOKENS.text4}>Context: waiting for the first reading</Text>
125 )
126
127 // Row 2: legend
128 const legend =
129 ctx && vm.showLegend ? (
130 <Text wrap="wrap">
131 {ctx.segments.map(s => (
132 <Text>
133 <Text color={s.color}>● </Text>
134 <Text color={TOKENS.text2}>{`${s.label} ${fmtTokens(s.tokens)} `}</Text>
135 </Text>
136 ))}
137 <Text color={TOKENS.text4}>{`Free ${fmtTokens(ctx.free)}`}</Text>
138 </Text>
139 ) : null
140
141 // Row 3: limits + cache
142 let limitsRow
143 if (wide) {
144 const gap = vm.size === 'a' ? 3 : 2
145 const colW = Math.max(8, Math.floor((inner - 3 * gap) / 4))
146 const limitCol = (l: LimitView) => {
147 const line = usageLine(l)
148 return (
149 <Box key={`limit-${l.kind}`} flexDirection="column" width={colW}>
150 <Text bold wrap="truncate" color={l.usage === null ? TOKENS.text4 : TOKENS.text}>
151 {l.title}
152 </Text>
153 {bar(limitCells(l, colW))}
154 <Text wrap="truncate">
155 <Text color={TOKENS.text}>{line.main}</Text>
156 <Text color={TOKENS.text3}>{line.time}</Text>
157 </Text>
158 <Text wrap="truncate" color={TOKENS.text4}>
159 {l.reset}
160 </Text>
161 </Box>
162 )
163 }
164 const c = vm.cache
165 const cl = cacheLine(c)
166 limitsRow = (
167 <Box flexDirection="row" columnGap={gap}>
168 {vm.limits.map(limitCol)}
169 <Box key="cache" flexDirection="column" width={colW}>
170 <Text bold wrap="truncate">
171 <Text color={TOKENS.text}>Cache · </Text>
172 <Text color={c.color}>{c.label}</Text>
173 </Text>
174 {bar(tempCells(c, colW))}
175 <Text wrap="truncate">
176 <Text color={TOKENS.text}>{cl.main}</Text>
177 <Text color={TOKENS.text3}>{cl.hit}</Text>
178 </Text>
179 <Text wrap="truncate" color={TOKENS.text4}>
180 {c.detail}
181 </Text>
182 </Box>
183 </Box>
184 )
185 } else {
186 const showTime = vm.size === 'c'
187 const ring = (l: LimitView) => (
188 <Box key={`limit-${l.kind}`} flexDirection="column">
189 <Text>
190 <Text color={l.bright}>{TERM_GLYPH[l.kind]} </Text>
191 <Text bold color={l.usage === null ? TOKENS.text4 : TOKENS.text}>
192 {l.usage === null ? '—' : `${pct(l.usage)}%`}
193 </Text>
194 </Text>
195 {showTime && <Text color={TOKENS.text3}>{l.time === null ? ' ' : `Time ${pct(l.time)}%`}</Text>}
196 </Box>
197 )
198 const c = vm.cache
199 const cacheMain =
200 c.state === 'cold' || c.state === 'empty' ? (showTime ? c.label : c.label) : showTime ? c.label : `${pct(c.warmth * 100)}%`
201 limitsRow = (
202 <Box flexDirection="row" justifyContent="space-between">
203 {vm.limits.map(ring)}
204 <Box key="cache" flexDirection="column">
205 <Text>
206 <Text color={c.color}>{cacheGlyph(c)} </Text>
207 <Text bold color={TOKENS.text}>
208 {cacheMain}
209 </Text>
210 </Text>
211 {showTime && (
212 <Text color={TOKENS.text3}>
213 {c.state === 'warm' || c.state === 'cooling' ? `${pct(c.warmth * 100)}% warm` : c.detail}
214 </Text>
215 )}
216 </Box>
217 </Box>
218 )
219 }
220
221 // Row 4: notice + actions
222 const buttons = (
223 <Box flexDirection="row" columnGap={1}>
224 <Button key="clear" label="Clear" variant="primary" hotkey="c" onPress={actions.clear} />
225 <Button key="compact" label="Compact" hotkey="k" onPress={actions.compact} />
226 </Box>
227 )
228 const notice = vm.notice
229 ? wide ? (
230 <Box flexDirection="row" columnGap={1}>
231 <Box flexGrow={1} flexShrink={1}>
232 <Text wrap="truncate">
233 <Text color={vm.cache.color}>● </Text>
234 <Text color={TOKENS.text2}>{vm.notice}</Text>
235 </Text>
236 </Box>
237 {buttons}
238 </Box>
239 ) : (
240 <Box flexDirection="column">
241 <Text wrap="wrap">
242 <Text color={vm.cache.color}>● </Text>
243 <Text color={TOKENS.text2}>{vm.notice}</Text>
244 </Text>
245 {buttons}
246 </Box>
247 )
248 : null
249
250 return (
251 <Box
252 key="context-cache"
253 flexDirection="column"
254 borderStyle="round"
255 borderColor={TOKENS.border}
256 paddingX={1}
257 >
258 {contextRow}
259 {legend}
260 {limitsRow}
261 {notice}
262 </Box>
263 )
264}
265
266// ── Desktop (and the editor, and the phone's pane) ─────────────────────────
267//
268// The design at its real pixel sizes: hooks/panel-svg.ts lays rows 1–3 (and a
269// narrow layout's notice) out as one SVG, the host draws the card around it
270// and its own buttons for Clear / Compact. The SVG is drawn at the
271// width the band is believed to be (cells × px per cell, see /cache scale);
272// where the slot is narrower the host scales it down to fit, so it always
273// fills the width whole and resizes without reflowing.
274
275export function renderDesktop(E: DeskEls, vm: ViewModel, widthPx: number, actions: Actions) {
276 const { Box, Button, Svg } = E
277 const p = panelSvg(vm, Math.max(240, widthPx))
278 const wide = p.size === 'a' || p.size === 'b'
279 const padX = p.size === 'd' ? 1 : 2
280 const button = (key: 'clear' | 'compact', label: string, hotkey: string, extra: Record<string, unknown>) => (
281 <Button key={key} label={label} hotkey={hotkey} onPress={actions[key]} {...extra} />
282 )
283 const buttons = (
284 <Box flexDirection="row" columnGap={1}>
285 {button('clear', 'Clear', 'c', { variant: 'primary' })}
286 {button('compact', 'Compact', 'k', {})}
287 </Box>
288 )
289 let actionsRow = null
290 if (vm.notice) {
291 actionsRow =
292 wide && p.notice ? (
293 <Box flexDirection="row" alignItems="center" columnGap={1} paddingRight={padX} paddingBottom={1}>
294 <Box flexGrow={1} flexShrink={1} minWidth={0}>
295 <Svg source={p.notice.svg} alt={vm.notice} />
296 </Box>
297 {buttons}
298 </Box>
299 ) : (
300 // The notice is in the SVG above; the buttons sit together under it.
301 // (The design's equal-width buttons need stretchable controls, which the host's native buttons are not.)
302 <Box flexDirection="row" paddingX={padX} paddingBottom={1}>{buttons}</Box>
303 )
304 }
305 return (
306 <Box key="context-cache" flexDirection="column" borderStyle="round" borderColor={TOKENS.border} backgroundColor={TOKENS.card}>
307 <Svg source={p.body.svg} alt={p.alt} />
308 {actionsRow}
309 </Box>
310 )
311}
312hooks/panel-svg.ts 436 lines1// The desktop/mobile panel drawn as SVG at the design's exact pixel sizes.
2//
3// The host's Text has no font size, so every line of the design (13px titles,
4// 12.5px labels, 11.5px detail, 10.5px ring captions) is laid out here with
5// the design's own font stack and Arial-compatible metrics, positioned the
6// way the browser positions the design's HTML (line-height normal, flex gaps,
7// ellipsis). The host draws the card, and its own buttons for the actions.
8
9import { ellipsize, textWidth } from './metrics'
10import { CACHE_COLORS, fmtTokens, pct, TOKENS, type CacheView, type LimitView, type ViewModel } from './model'
11
12const FONT = `'Helvetica Neue', Helvetica, Arial, sans-serif`
13// Arial / Helvetica vertical metrics per em. Chrome rounds each to whole
14// pixels before stacking them into a line box, so this does too.
15const ASC = 0.905
16const DESC = 0.212
17const GAP = 0.0327
18const A = (size: number) => Math.round(ASC * size)
19const D = (size: number) => Math.round(DESC * size)
20const TICK = 'rgba(236,233,226,.85)'
21const NS = 'http://www.w3.org/2000/svg'
22
23/** The design's four widths: a ≈ 900px, b ≈ 620px (columns from ~500px), c ≈ 420px (rings), d ≈ 320px (no legend). */
24export type DesignSize = ViewModel['size']
25
26const PAD: Record<DesignSize, { y: number; x: number }> = {
27 a: { y: 14, x: 18 },
28 b: { y: 11, x: 14 },
29 c: { y: 11, x: 14 },
30 d: { y: 10, x: 12 },
31}
32const ROW_GAP = 11
33/** A design button: 12.5px text, 6px padding, a 1px border on Compact. */
34export const BUTTON_H = lhOf(12.5) + 12 + 2
35
36function lhOf(size: number): number {
37 return A(size) + D(size) + Math.round(GAP * size)
38}
39
40const lh = lhOf
41/** Baseline of a line box of height `box` (default: line-height normal) with its top at `top`. */
42const baseline = (top: number, size: number, box = lhOf(size)) => top + (box - A(size) - D(size)) / 2 + A(size)
43const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
44const r2 = (n: number) => Math.round(n * 100) / 100
45
46type Run = { text: string; color: string; bold?: boolean }
47
48/** One line of runs starting at x, its line box's top at `top`. */
49function line(x: number, top: number, size: number, runs: Run[], anchor: 'start' | 'end' = 'start', clipId?: string): string {
50 const y = r2(baseline(top, size))
51 const spans = runs
52 .map(r => `<tspan fill="${r.color}"${r.bold ? ' font-weight="700"' : ''}>${esc(r.text)}</tspan>`)
53 .join('')
54 const clip = clipId ? ` clip-path="url(#${clipId})"` : ''
55 return `<text x="${r2(x)}" y="${y}" font-size="${size}" text-anchor="${anchor}"${clip} xml:space="preserve">${spans}</text>`
56}
57
58function runsWidth(runs: Run[], size: number): number {
59 return runs.reduce((w, r) => w + textWidth(r.text, size, r.bold), 0)
60}
61
62/** Cuts a run list to `max` px with an ellipsis on the run that overflows. */
63function fitRuns(runs: Run[], size: number, max: number): Run[] {
64 if (runsWidth(runs, size) <= max) return runs
65 const out: Run[] = []
66 let used = 0
67 for (const r of runs) {
68 const w = textWidth(r.text, size, r.bold)
69 if (used + w <= max - textWidth('…', size, r.bold)) {
70 out.push(r)
71 used += w
72 continue
73 }
74 out.push({ ...r, text: ellipsize(r.text, size, max - used, r.bold) })
75 break
76 }
77 return out
78}
79
80/** A rounded 8px bar track, clipped, with whatever is drawn inside. */
81function bar(id: string, x: number, y: number, w: number, inner: string, track: string = TOKENS.track): string {
82 return (
83 `<clipPath id="${id}"><rect x="${r2(x)}" y="${r2(y)}" width="${r2(Math.max(0, w))}" height="8" rx="4"/></clipPath>` +
84 `<g clip-path="url(#${id})"><rect x="${r2(x)}" y="${r2(y)}" width="${r2(Math.max(0, w))}" height="8" fill="${track}"/>${inner}</g>`
85 )
86}
87
88function tick(x: number, y: number): string {
89 return `<rect x="${r2(x)}" y="${r2(y)}" width="2" height="8" fill="${TICK}"/>`
90}
91
92/** Context: segments as flex children with 1px gaps; the auto-compact tick. */
93function contextBar(id: string, ctx: NonNullable<ViewModel['context']>, x: number, y: number, w: number): string {
94 let inner = ''
95 let at = x
96 for (const s of ctx.segments) {
97 const sw = (s.tokens / ctx.window) * w
98 if (s.tokens > 0) inner += `<rect x="${r2(at)}" y="${r2(y)}" width="${r2(sw)}" height="8" fill="${s.color}"/>`
99 at += sw + 1
100 }
101 if (ctx.compactPct !== null) inner += tick(x + (ctx.compactPct / 100) * w, y)
102 return bar(id, x, y, w, inner)
103}
104
105/** Limit: dark = time (to the larger of both), bright = usage, tick at time. */
106function limitBar(id: string, l: LimitView, x: number, y: number, w: number): string {
107 if (l.usage === null) return bar(id, x, y, w, '')
108 const u = l.usage
109 const t = l.time ?? 0
110 const darkW = (Math.max(u, t) / 100) * w
111 let inner = `<rect x="${r2(x)}" y="${r2(y)}" width="${r2(darkW)}" height="8" fill="${l.dark}"/>`
112 inner += `<rect x="${r2(x)}" y="${r2(y)}" width="${r2((u / 100) * w)}" height="8" fill="${l.bright}"/>`
113 if (l.time !== null) inner += tick(x + (t / 100) * w, y)
114 return bar(id, x, y, w, inner)
115}
116
117/** Cache: the temperature gradient, covered right of the warmth, tick at it. */
118function tempBar(id: string, c: CacheView, x: number, y: number, w: number): string {
119 const gid = `${id}g`
120 const wx = x + c.warmth * w
121 let inner =
122 `<linearGradient id="${gid}" gradientUnits="userSpaceOnUse" x1="${r2(x)}" x2="${r2(x + w)}" y1="0" y2="0">` +
123 `<stop offset="0" stop-color="${CACHE_COLORS.cold}"/><stop offset="0.5" stop-color="${CACHE_COLORS.cooling}"/>` +
124 `<stop offset="1" stop-color="${CACHE_COLORS.warm}"/></linearGradient>` +
125 `<rect x="${r2(x)}" y="${r2(y)}" width="${r2(w)}" height="8" fill="url(#${gid})"/>` +
126 `<rect x="${r2(wx)}" y="${r2(y)}" width="${r2(x + w - wx)}" height="8" fill="rgba(34,33,32,.74)"/>`
127 if (c.state !== 'empty') inner += tick(wx, y)
128 return bar(id, x, y, w, inner, 'transparent')
129}
130
131// ── Icons and rings ────────────────────────────────────────────────────────
132
133type IconName = 'clock' | 'calendar' | 'fable' | 'flame' | 'snow' | 'empty'
134
135/** 24-unit icons standing in for the design's text glyphs (5h, 7d, F, ◆). */
136function icon(name: IconName, color: string): string {
137 const s = `fill="none" stroke="${color}" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"`
138 switch (name) {
139 case 'clock':
140 return `<circle cx="12" cy="12" r="9" ${s}/><path d="M12 7v5l3.5 2" ${s}/>`
141 case 'calendar':
142 return `<rect x="3.5" y="5" width="17" height="15" rx="2.5" ${s}/><path d="M3.5 10h17M8.5 3v4M15.5 3v4" ${s}/>`
143 case 'fable':
144 return `<path d="M12 2.5l2.2 6.6 6.8 2.4-6.8 2.4L12 20.5l-2.2-6.6L3 11.5l6.8-2.4z" fill="${color}"/>`
145 case 'flame':
146 return `<path d="M12 2.5c.8 3.4 5 5.6 5 10.5a5 5 0 0 1-10 0c0-2.6 1.4-4 2.6-5.2.2 2 1 3.2 2.4 3.6-.6-3.2-.8-6.2 0-8.9z" fill="${color}"/>`
147 case 'snow':
148 return `<path d="M12 2.5v19M3.8 7.2l16.4 9.6M20.2 7.2L3.8 16.8" ${s}/>`
149 case 'empty':
150 return `<circle cx="12" cy="12" r="7" ${s}/>`
151 }
152}
153
154/** An arc of the 30px ring (centre radius 13, 4 wide) from deg a to deg b, clockwise from 12 o'clock. */
155function arc(cx: number, cy: number, a: number, b: number, color: string): string {
156 if (b - a <= 0) return ''
157 if (b - a >= 359.99) return `<circle cx="${cx}" cy="${cy}" r="13" fill="none" stroke="${color}" stroke-width="4"/>`
158 const p = (deg: number) => {
159 const rad = ((deg - 90) * Math.PI) / 180
160 return `${r2(cx + 13 * Math.cos(rad))} ${r2(cy + 13 * Math.sin(rad))}`
161 }
162 const large = b - a > 180 ? 1 : 0
163 return `<path d="M${p(a)} A13 13 0 ${large} 1 ${p(b)}" fill="none" stroke="${color}" stroke-width="4"/>`
164}
165
166/** The design's conic ring: bright = usage, dark to the larger of usage and time, a 4° notch at time. */
167function ring(x: number, y: number, a: { usage: number | null; time: number | null; bright: string; dark: string; icon: IconName; iconColor: string }): string {
168 const cx = x + 15
169 const cy = y + 15
170 let s = `<circle cx="${cx}" cy="${cy}" r="13" fill="none" stroke="${TOKENS.ringTrack}" stroke-width="4"/>`
171 if (a.usage !== null) {
172 const u = a.usage * 3.6
173 const t = (a.time ?? 0) * 3.6
174 s += arc(cx, cy, 0, Math.max(u, t), a.dark)
175 s += arc(cx, cy, 0, u, a.bright)
176 if (a.time !== null) s += arc(cx, cy, Math.max(0, t - 2), Math.min(360, t + 2), TICK)
177 }
178 s += `<circle cx="${cx}" cy="${cy}" r="11" fill="${TOKENS.card}"/>`
179 s += `<g transform="translate(${cx - 6} ${cy - 6}) scale(0.5)">${icon(a.icon, a.iconColor)}</g>`
180 return s
181}
182
183const LIMIT_ICON = { session: 'clock', weekly: 'calendar', fable: 'fable' } as const
184const cacheIcon = (c: CacheView): IconName => (c.state === 'cold' ? 'snow' : c.state === 'empty' ? 'empty' : 'flame')
185
186// ── The panel ──────────────────────────────────────────────────────────────
187
188export type PanelSvg = {
189 size: DesignSize
190 /** What the whole panel says, for a reader that cannot see it. */
191 alt: string
192 /** Everything above the buttons: rows 1–3 (and, narrow, the notice text), padded as the card. */
193 body: { svg: string; width: number; height: number }
194 /** Wide layouts: the notice line beside the buttons. */
195 notice: { svg: string; width: number; height: number } | null
196 /** The width the host's buttons take on wide layouts, right padding included. */
197 buttonsWidth: number
198 /** Wide, but the notice is too long to sit beside the buttons: they wrap below it, left-aligned. */
199 noticeWraps: boolean
200 pad: { x: number; y: number }
201}
202
203function svgDoc(width: number, height: number, body: string, alt: string): string {
204 return (
205 `<svg xmlns="${NS}" width="${r2(width)}" height="${r2(height)}" viewBox="0 0 ${r2(width)} ${r2(height)}" ` +
206 `font-family="${FONT}" role="img" aria-label="${esc(alt)}">${body}</svg>`
207 )
208}
209
210/** Width of the design's two buttons and their gap. */
211function buttonsWidthOf(): number {
212 const b = (label: string, padX: number, border: number) => textWidth(label, 12.5) + padX * 2 + border * 2
213 return b('Clear', 12, 0) + b('Compact', 12, 1) + 6
214}
215
216/**
217 * Lays the panel out at `width` px (the card's inner width, border excluded)
218 * in the view model's size class. Ids are prefixed so several panels can share a page.
219 */
220export function panelSvg(vm: ViewModel, width: number, idPrefix = 'cc'): PanelSvg {
221 const size = vm.size
222 const pad = PAD[size]
223 const W = width
224 const inner = W - pad.x * 2
225 const wide = size === 'a' || size === 'b'
226 let y = pad.y
227 let out = ''
228 let n = 0
229 const id = () => `${idPrefix}${n++}`
230
231 // Row 1: Context
232 const ctx = vm.context
233 const h1 = lh(13)
234 if (ctx) {
235 const right = ctx.compactPct === null ? `${pct((ctx.used / ctx.window) * 100)}%` : wide ? `Compact at ${ctx.compactPct}%` : `${ctx.compactPct}%`
236 const cy = y + h1 / 2
237 let x = pad.x
238 out += line(x, cy - lh(13) / 2, 13, [{ text: 'Context', color: TOKENS.text, bold: true }])
239 x += textWidth('Context', 13, true) + 10
240 const used = ctx.usedLabel
241 out += line(x, cy - lh(12.5) / 2, 12.5, [{ text: used, color: TOKENS.text3 }])
242 x += textWidth(used, 12.5) + 10
243 const rightX = W - pad.x
244 out += line(rightX, cy - lh(12.5) / 2, 12.5, [{ text: right, color: TOKENS.text4 }], 'end')
245 const barW = Math.max(40, rightX - textWidth(right, 12.5) - 10 - x)
246 out += contextBar(id(), ctx, x, cy - 4, barW)
247 } else {
248 out += line(pad.x, y, 12.5, [{ text: 'Context: waiting for the first reading', color: TOKENS.text4 }])
249 }
250 y += h1 + ROW_GAP
251
252 // Row 2: legend (wraps; 12px between items, 4px between lines)
253 if (ctx && size !== 'd') {
254 const items: Run[][] = ctx.segments.map(s => [
255 { text: '●', color: s.color },
256 { text: ` ${s.label} ${fmtTokens(s.tokens)}`, color: TOKENS.text2 },
257 ])
258 items.push([{ text: `Free ${fmtTokens(ctx.free)}`, color: TOKENS.text4 }])
259 // Lines decided with the metrics; within a line the font itself flows the runs.
260 const lines: Run[][][] = [[]]
261 let x = pad.x
262 for (const it of items) {
263 const w = runsWidth(it, 11.5)
264 if (x > pad.x && x + w > pad.x + inner) {
265 lines.push([])
266 x = pad.x
267 }
268 lines[lines.length - 1]!.push(it)
269 x += w + 12
270 }
271 let lineTop = y
272 lines.forEach((ln, i) => {
273 if (i > 0) lineTop += lh(11.5) + 4
274 const base = r2(baseline(lineTop, 11.5))
275 const spans = ln
276 .map((it, j) =>
277 it
278 .map((r, k) => `<tspan${j > 0 && k === 0 ? ' dx="12"' : ''} fill="${r.color}">${esc(r.text)}</tspan>`)
279 .join(''),
280 )
281 .join('')
282 out += `<text x="${pad.x}" y="${base}" font-size="11.5" xml:space="preserve">${spans}</text>`
283 })
284 y = lineTop + lh(11.5) + ROW_GAP
285 }
286
287 // Row 3: limits + cache
288 const c = vm.cache
289 if (wide) {
290 const gap = size === 'a' ? 18 : 12
291 const colW = (inner - gap * 3) / 4
292 const ts = size === 'a' ? 13 : 12.5
293 const col = (i: number) => pad.x + i * (colW + gap)
294 vm.limits.forEach((l, i) => {
295 const x = col(i)
296 const clip = id()
297 out += `<clipPath id="${clip}"><rect x="${r2(x)}" y="${r2(y)}" width="${r2(colW)}" height="80"/></clipPath>`
298 let yy = y
299 out += line(x, yy, ts, fitRuns([{ text: l.title, color: l.usage === null ? TOKENS.text4 : TOKENS.text, bold: true }], ts, colW), 'start', clip)
300 yy += lh(ts) + 5
301 out += limitBar(id(), l, x, yy, colW)
302 yy += 8 + 5
303 const usage: Run[] =
304 l.usage === null
305 ? [{ text: 'No data', color: TOKENS.text4 }]
306 : [
307 { text: `Usage ${pct(l.usage)}% · `, color: TOKENS.text },
308 { text: l.time === null ? '' : `Time ${pct(l.time)}%`, color: TOKENS.text3 },
309 ]
310 out += line(x, yy, 11.5, fitRuns(usage, 11.5, colW), 'start', clip)
311 yy += lh(11.5) + 5
312 out += line(x, yy, 11.5, fitRuns([{ text: l.reset, color: TOKENS.text4 }], 11.5, colW), 'start', clip)
313 })
314 {
315 const x = col(3)
316 const clip = id()
317 out += `<clipPath id="${clip}"><rect x="${r2(x)}" y="${r2(y)}" width="${r2(colW)}" height="80"/></clipPath>`
318 let yy = y
319 out += line(
320 x,
321 yy,
322 ts,
323 fitRuns(
324 [
325 { text: 'Cache · ', color: TOKENS.text, bold: true },
326 { text: c.label, color: c.color, bold: true },
327 ],
328 ts,
329 colW,
330 ),
331 'start',
332 clip,
333 )
334 yy += lh(ts) + 5
335 out += tempBar(id(), c, x, yy, colW)
336 yy += 8 + 5
337 const warm: Run[] = [
338 { text: c.state === 'empty' ? 'Warmth — · ' : `Warmth ${pct(c.warmth * 100)}% · `, color: TOKENS.text },
339 { text: `Hit ${c.hit === null ? '—' : `${pct(c.hit)}%`}`, color: TOKENS.text3 },
340 ]
341 out += line(x, yy, 11.5, fitRuns(warm, 11.5, colW), 'start', clip)
342 yy += lh(11.5) + 5
343 out += line(x, yy, 11.5, fitRuns([{ text: c.detail, color: TOKENS.text4 }], 11.5, colW), 'start', clip)
344 }
345 y += lh(ts) + 5 + 8 + 5 + lh(11.5) + 5 + lh(11.5) + ROW_GAP
346 } else {
347 // Rings: four items spread with space-between, each a 30px ring, 6px, then text.
348 const twoLines = size === 'c'
349 const items = [
350 ...vm.limits.map(l => ({
351 ring: { usage: l.usage, time: l.time, bright: l.bright, dark: l.dark, icon: LIMIT_ICON[l.kind], iconColor: l.bright },
352 main: { text: l.usage === null ? '—' : `${pct(l.usage)}%`, color: TOKENS.text },
353 sub: l.time === null ? '' : `Time ${pct(l.time)}%`,
354 })),
355 {
356 ring: {
357 usage: c.state === 'empty' ? null : c.warmth * 100,
358 time: null,
359 bright: c.color,
360 dark: c.color,
361 icon: cacheIcon(c),
362 iconColor: c.color,
363 },
364 main: {
365 text: twoLines || c.state === 'cold' || c.state === 'empty' ? c.label : `${pct(c.warmth * 100)}%`,
366 color: c.color,
367 },
368 sub: c.state === 'empty' ? 'Nothing cached' : `${pct(c.warmth * 100)}% warm`,
369 },
370 ]
371 const widths = items.map(it => 30 + 6 + Math.max(textWidth(it.main.text, 13, true), twoLines ? textWidth(it.sub, 10.5) : 0))
372 const free = Math.max(0, inner - widths.reduce((a, b) => a + b, 0))
373 const step = free / (items.length - 1)
374 const textH = twoLines ? 13 * 1.25 + 10.5 * 1.25 : 13 * 1.25
375 const rowH = Math.max(30, textH)
376 let x = pad.x
377 items.forEach((it, i) => {
378 out += ring(x, y + (rowH - 30) / 2, it.ring)
379 const tx = x + 36
380 const ty = y + (rowH - textH) / 2
381 // line-height 1.25: the 13px line is 16.25 tall, the 10.5px one 13.125
382 out += `<text x="${r2(tx)}" y="${r2(baseline(ty, 13, 16.25))}" font-size="13" font-weight="700" fill="${it.main.color}">${esc(it.main.text)}</text>`
383 if (twoLines) {
384 out += `<text x="${r2(tx)}" y="${r2(baseline(ty + 16.25, 10.5, 13.125))}" font-size="10.5" fill="${TOKENS.text3}">${esc(it.sub)}</text>`
385 }
386 x += (widths[i] ?? 0) + step
387 })
388 y += rowH + ROW_GAP
389 }
390
391 // Notice: beside the buttons when wide; above them, wrapped, when narrow.
392 let notice: PanelSvg['notice'] = null
393 let noticeWraps = false
394 const buttonsWidth = buttonsWidthOf() + pad.x
395 if (vm.notice) {
396 noticeWraps = wide && 15 + textWidth(vm.notice, 12.5) + 12 + buttonsWidthOf() > inner
397 if (wide && !noticeWraps) {
398 const nw = Math.max(80, W - buttonsWidth - 12)
399 const top = (BUTTON_H - lh(12.5)) / 2
400 const body =
401 `<circle cx="${pad.x + 3.5}" cy="${r2(BUTTON_H / 2)}" r="3.5" fill="${c.color}"/>` +
402 line(pad.x + 15, top, 12.5, [{ text: ellipsize(vm.notice, 12.5, nw - pad.x - 15), color: TOKENS.text2 }])
403 notice = { svg: svgDoc(nw, BUTTON_H, body, vm.notice), width: nw, height: BUTTON_H }
404 } else {
405 const maxW = inner - 15 // narrow, or wide with the buttons wrapped below
406 // Break where the browser would: after spaces, and after hyphens.
407 const words = vm.notice.match(/[^\s-]*-|[^\s-]+\s*|\s+/g) ?? [vm.notice]
408 const lines: string[] = []
409 let cur = ''
410 for (const w of words) {
411 const next = cur + w
412 if (cur && textWidth(next.trimEnd(), 12.5) > maxW) {
413 lines.push(cur.trimEnd())
414 cur = w.trimStart()
415 } else cur = next
416 }
417 if (cur.trim()) lines.push(cur.trimEnd())
418 const blockH = lines.length * lh(12.5)
419 out += `<circle cx="${pad.x + 3.5}" cy="${r2(y + blockH / 2)}" r="3.5" fill="${c.color}"/>`
420 lines.forEach((t, i) => {
421 out += line(pad.x + 15, y + i * lh(12.5), 12.5, [{ text: t, color: TOKENS.text2 }])
422 })
423 y += blockH + (noticeWraps ? 12 : ROW_GAP)
424 }
425 }
426
427 const alt = [
428 ctx ? `Context ${ctx.usedLabel}` : 'Context: no reading',
429 ...vm.limits.map(l => `${l.title}: ${l.usage === null ? 'no data' : `${pct(l.usage)}% used, ${pct(l.time ?? 0)}% of time`}`),
430 `Cache ${c.label}, ${c.detail}`,
431 ].join('. ')
432 // The bottom gap belongs to the buttons row when there is one.
433 const height = vm.notice ? y : y - ROW_GAP + pad.y
434 return { size, alt, body: { svg: svgDoc(W, height, out, alt), width: W, height }, notice, buttonsWidth, noticeWraps, pad }
435}
436hooks/metrics.ts 35 lines1// Advance widths (per 1000 em) of Arial / Helvetica-compatible metrics
2// (Liberation Sans), for laying out SVG text the way the browser lays out
3// the design's "Helvetica Neue", Helvetica, Arial stack. Generated.
4
5const CHARS = ' !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~·●—…◆–'
6const REGULAR = [278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, 1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, 333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, 333, 604, 1000, 1000, 600, 556]
7const BOLD = [278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611, 975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556, 333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611, 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584, 333, 604, 1000, 1000, 600, 556]
8
9const reg = new Map<string, number>()
10const bold = new Map<string, number>()
11for (let i = 0; i < CHARS.length; i++) {
12 reg.set(CHARS[i]!, REGULAR[i]!)
13 bold.set(CHARS[i]!, BOLD[i]!)
14}
15
16/** Width in px of `text` at `size` px, regular or bold. */
17export function textWidth(text: string, size: number, isBold = false): number {
18 const table = isBold ? bold : reg
19 let units = 0
20 for (const ch of text) units += table.get(ch) ?? 556
21 return (units / 1000) * size
22}
23
24/** `text` cut to fit `max` px, with an ellipsis, as CSS text-overflow does. */
25export function ellipsize(text: string, size: number, max: number, isBold = false): string {
26 if (textWidth(text, size, isBold) <= max) return text
27 const ell = '…'
28 let out = ''
29 for (const ch of text) {
30 if (textWidth(out + ch + ell, size, isBold) > max) break
31 out += ch
32 }
33 return out.replace(/\s+$/, '') + ell
34}
35types/index.d.ts 65 lines1// Contract for the context-cache mod: the values it keeps in $.state.
2
3/** The four groups the context bar is split into. */
4export type ContextCacheGroup = 'system' | 'tools' | 'files' | 'messages'
5
6/** The live context window, as measured after the last response. */
7export type ContextCacheContext = {
8 /** Tokens in the window now (the last response's input side, or the local estimate before one). */
9 used: number
10 /** The model's context window in tokens. */
11 window: number
12 /** Token count at which auto-compact runs; null when auto-compact is off. */
13 autoCompactAt: number | null
14 /** Tokens per group, scaled so they sum to `used`. */
15 parts: Record<ContextCacheGroup, number>
16}
17
18export type ContextCacheLimitKind = 'session' | 'weekly' | 'fable'
19
20/** One usage-limit window. */
21export type ContextCacheLimit = {
22 kind: ContextCacheLimitKind
23 /** Percent of the window used, 0 to 100. */
24 usage: number
25 /** When the window resets, epoch milliseconds; null when not reported. */
26 resetsAt: number | null
27 /** The window's length in milliseconds (5h or 7d). */
28 windowMs: number
29}
30
31/** What is known about the prompt cache. */
32export type ContextCacheCache = {
33 /** When the main conversation's cache was last written or read (end of the last turn); null when nothing is cached. */
34 lastAt: number | null
35 /** The cache's time to live in milliseconds (300000 or 3600000). */
36 ttlMs: number
37 /** True once the TTL was read from a real response or the engine. */
38 isTtlKnown: boolean
39 /** cache_read ÷ (cache_read + cache_creation + uncached input) of the last turn, 0 to 1. */
40 hitRate: number | null
41}
42
43export type ContextCacheSnapshot = {
44 context: ContextCacheContext | null
45 limits: ContextCacheLimit[]
46 /** Where the limits came from: the account usage endpoint, the last response's headers, or nowhere. */
47 limitsSource: 'account' | 'response' | 'none'
48 cache: ContextCacheCache
49 /** When this snapshot was taken, epoch milliseconds. */
50 updatedAt: number
51}
52
53declare module 'claude-code' {
54 interface PluginState {
55 'context-cache': {
56 snapshot: ContextCacheSnapshot | null
57 isHidden: boolean
58 /** The clock, written once a second so the countdown redraws. */
59 now: number
60 /** Desktop/mobile: CSS px per host cell, to draw the design at its real px size (/cache scale). */
61 pxPerCell: number
62 }
63 }
64}
65