Context usage, usage limits (session, weekly, Fable) and prompt-cache warmth above the prompt, drawn in your theme, with Clear / Compact; collapses to one line.

A Claude Code mod that puts your context window, your usage limits and your prompt cache above the prompt: a context bar with its legend, a ring per limit (session, weekly, Fable) and one for the cache, a notice about what the next message costs, and Clear / Compact. No card or background; text follows your light or dark theme. Hover a ring on the desktop for its reset time and details. The chevron folds it to one line.
Adapted from Christandoh/context-cache (commit
2afa4e8) by Chris Tandoh, MIT licensed. See LICENSE. Changes here: the ring layout at every width (upstream switched to bar columns above ~500 px), drawn without the card and theme-aware, a collapse chevron and/cache-collapse, hover details on the rings, and the band keeps whatever the host or other mods draw below it.
Terminal, expanded:
Context 92k / 1M (9%) ━━━━━────────────────────────────│── 92% ▾
● System 6k ● Tools 21k ● Files 15k ● Messages 50k Free 908k
◷ 9% ▦ 14% ✦ 2% ♨ Warm
Time 12% Time 14% Time 14% 95% warm
● Cache warm: next message reuses 92k from cache.
[ Clear ] [ Compact ]
Collapsed:
Context 9% ━━──────│─ ◷ 9% ▦ 14% ✦ 2% ♨ Warm 57m left ▸
/plugin install context-cache@arhun-plugins
If you installed the upstream copy (context-cache@chris-mods), uninstall it first so the two don't both draw.
/clear) and Compact beside it.Below about 360 px the legend and the ring captions drop; a band too short for the panel shows the one-line summary.
/cache hides or shows the panel./cache-collapse folds it to one line or opens it again (the same as the chevron). The choice is kept across sessions./cache-pane opens the panel as a pane, on any device (how it shows on the phone)./cache-refresh re-reads usage now./cache-status prints where each figure came from and writes the full report to context-cache-status.json in the working directory.| 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; when the account endpoint describes the same window, the higher reading |
| Fable, and reset times a header lacks | the account usage endpoint (api.anthropic.com/api/oauth/usage), called through Claude Code with the session's own login (the mod never sees the token); at session start, every 5 minutes, and on /cache-refresh |
| Cache TTL (5m or 60m) | the cache_creation usage of the last response in the transcript, or Claude Code's report on a model switch; remembered across sessions |
| Last cache write, hit % | each main-conversation reply as it arrives |
The weekly and Fable figures rely on an endpoint Anthropic hasn't documented. If it changes, Fable shows "no data" and the rest keeps working.
hooks/register.tsx: data collection, commands, the band and the pane.hooks/model.ts: the maths, thresholds and copy; no drawing.hooks/panel-svg.ts: the desktop panel and collapsed line as SVG, measured with hooks/metrics.ts.hooks/view.tsx: the terminal layout, and the desktop's SVG with the host's buttons and chevron.tests/view.test.tsx: claude plugin test . mounts the band on terminal and desktop.types/index.d.ts: the mod's $.state contract.hooks/register.tsx 581 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// Folded to one line (the chevron, /cache-collapse); kept across sessions in $.store.
38const collapsedAtom = atom({ plugin: 'context-cache', key: 'isCollapsed' } as const, false)
39
40const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
41// The account endpoint answers 429 to anyone who leans on it; the status-line
42// tools that use it hold to one request per 5 minutes, so this does too. The
43// 5h/7d figures still move every response through the rate-limit headers.
44const ACCOUNT_EVERY_MS = 5 * 60_000
45// Two reset times this close describe the same window (the sources round differently).
46const SAME_WINDOW_MS = 30 * 60_000
47const MAX_TRANSCRIPT_BYTES = 4 * 1024 * 1024 - 1024
48
49const emptyCache = (ttlMs: number, isTtlKnown: boolean): ContextCacheCache => ({
50 lastAt: null,
51 ttlMs,
52 isTtlKnown,
53 hitRate: null,
54})
55
56/** The TTL a response was cached with, from a transcript line's usage. */
57export function ttlFromTranscript(text: string): number | null {
58 const lines = text.split('\n')
59 for (let i = lines.length - 1, seen = 0; i >= 0 && seen < 400; i--, seen++) {
60 const line = lines[i]
61 if (!line || !line.includes('cache_creation')) continue
62 try {
63 const row = JSON.parse(line) as { message?: { usage?: { cache_creation?: Record<string, number> } } }
64 const cc = row.message?.usage?.cache_creation
65 if (!cc) continue
66 if ((cc['ephemeral_1h_input_tokens'] ?? 0) > 0) return TTL_1H
67 if ((cc['ephemeral_5m_input_tokens'] ?? 0) > 0) return TTL_5M
68 } catch {
69 // a partial line; keep looking
70 }
71 }
72 return null
73}
74
75// Module state: rebuilt on reload; what the drawing needs lives in $.state.
76let accountLimits: ContextCacheLimit[] | null = null
77let accountAt = 0
78let accountInFlight = false
79let refreshing: Promise<void> | null = null
80// Diagnostics for /cache status: what asked the mod to draw, and where, and
81// how the last account usage request went.
82const draws: Record<string, number> = {}
83const attached: string[] = []
84let cwd = ''
85const DIAG_FILE = 'context-cache-status.json' // written in the session's working directory
86type AccountDiag = {
87 at: number
88 authKind: string
89 status: number | null
90 ok: boolean | null
91 topLevelKeys: string[]
92 /** JSON paths of every node with a utilization figure, and which matched Fable. */
93 windows: string[]
94 fableMatched: string[]
95 bodyPreview: string
96 error: string | null
97}
98let lastAccount: AccountDiag | null = null
99
100function noteDraw(component: string, surface: string) {
101 const k = `${component}@${surface}`
102 draws[k] = (draws[k] ?? 0) + 1
103}
104
105async function statusReport($: EngineInterface): Promise<string> {
106 let surfaces: readonly string[] = []
107 try {
108 surfaces = await $.session.surfaces()
109 } catch {
110 surfaces = []
111 }
112 const [snap, hidden, isCollapsed] = await Promise.all([read($, snapshotAtom), read($, hiddenAtom), read($, collapsedAtom)])
113 const drawn = Object.entries(draws).map(([k, n]) => `${k} ×${n}`).join(', ') || 'nothing yet'
114 const a = lastAccount
115 const account = a
116 ? `auth ${a.authKind}, HTTP ${a.status ?? '—'}${a.ok === false ? ' (not ok)' : ''}` +
117 `${a.error ? `, error: ${a.error}` : ''}; keys: ${a.topLevelKeys.join(', ') || '—'}; ` +
118 `windows: ${a.windows.join(', ') || 'none'}; Fable matched: ${a.fableMatched.join(', ') || 'none'}`
119 : 'not attempted yet'
120 const lines = [
121 `Surfaces attached now: ${surfaces.length ? surfaces.join(', ') : 'none'}`,
122 `Clients that attached since load: ${attached.length ? attached.join(', ') : 'none seen'}`,
123 `Draw requests received: ${drawn}`,
124 `Panel hidden: ${hidden ? 'yes' : 'no'}; collapsed: ${isCollapsed ? 'yes' : 'no'}`,
125 `Context: ${snap?.context ? `${snap.context.used} / ${snap.context.window}` : 'no reading'}`,
126 `Limits: ${snap?.limits.length ? snap.limits.map(l => `${l.kind} ${l.usage}%`).join(', ') : 'none'} (source: ${snap?.limitsSource ?? 'none'})`,
127 `Account usage request: ${account}`,
128 `Cache: last write ${snap?.cache.lastAt ? new Date(snap.cache.lastAt).toISOString() : 'none'}, TTL ${snap ? snap.cache.ttlMs / 60000 : '?'}m${snap?.cache.isTtlKnown ? '' : ' (default)'}`,
129 `Full report: ${cwd ? `${cwd}/` : ''}${DIAG_FILE}`,
130 ]
131 const text = lines.join('\n')
132 try {
133 await $.fs.write(
134 DIAG_FILE,
135 JSON.stringify({ at: await $.clock.now(), surfaces, attached, draws, hidden, isCollapsed, account: lastAccount, snap }, null, 2),
136 )
137 } catch {
138 // diagnostics only
139 }
140 return text
141}
142
143async function current($: EngineInterface): Promise<ContextCacheSnapshot> {
144 const snap = await read($, snapshotAtom)
145 if (snap) return snap
146 const stored = await $.store.get('ttlMs')
147 const ttl = stored === TTL_5M || stored === TTL_1H ? stored : TTL_1H
148 return {
149 context: null,
150 limits: [],
151 limitsSource: 'none',
152 cache: emptyCache(ttl, stored === TTL_5M || stored === TTL_1H),
153 updatedAt: await $.clock.now(),
154 }
155}
156
157async function patchCache($: EngineInterface, patch: Partial<ContextCacheCache>) {
158 const base = await current($)
159 await update($, snapshotAtom, prev => {
160 const s = prev ?? base
161 return { ...s, cache: { ...s.cache, ...patch } }
162 })
163}
164
165async function setTtl($: EngineInterface, ttlMs: number) {
166 await $.store.set('ttlMs', ttlMs)
167 await patchCache($, { ttlMs, isTtlKnown: true })
168}
169
170async function fetchAccount($: EngineInterface, now: number, force: boolean) {
171 if (accountInFlight) return
172 if (!force && now - accountAt < ACCOUNT_EVERY_MS) return
173 accountInFlight = true
174 const diag: AccountDiag = { at: now, authKind: 'none', status: null, ok: null, topLevelKeys: [], windows: [], fableMatched: [], bodyPreview: '', error: null }
175 lastAccount = diag
176 try {
177 const auth = await $.session.authorize()
178 diag.authKind = auth ? auth.kind : 'none'
179 if (!auth || auth.kind !== 'bearer') return
180 const res = await $.http.fetch(USAGE_URL, {
181 auth: auth.handle,
182 headers: { 'anthropic-beta': 'oauth-2025-04-20', accept: 'application/json' },
183 })
184 diag.status = res.status
185 diag.ok = res.ok
186 diag.bodyPreview = res.text.slice(0, 2000)
187 if (res.status === 429) {
188 // Back off a whole interval (or what retry-after asks) before trying again.
189 const retry = Number(res.headers['retry-after'])
190 accountAt = now + (Number.isFinite(retry) && retry > 0 ? Math.max(0, retry * 1000 - ACCOUNT_EVERY_MS) : 0)
191 return
192 }
193 if (!res.ok) return
194 const json: unknown = JSON.parse(res.text)
195 if (json && typeof json === 'object') diag.topLevelKeys = Object.keys(json as object)
196 const found = usageWindowsOf(json)
197 diag.windows = found.map(f => `${f.path}=${f.usage}%`)
198 diag.fableMatched = found.filter(f => f.isFable).map(f => f.path)
199 const limits = parseAccountUsage(json)
200 if (limits.length > 0) {
201 accountLimits = limits
202 accountAt = now
203 }
204 } catch (err) {
205 // keep the last good reading; the response headers cover the gap
206 diag.error = err instanceof Error ? err.message : String(err)
207 } finally {
208 accountInFlight = false
209 }
210}
211
212// The Files share of the conversation: reading the whole transcript is the
213// dear part of a refresh, so it is done once per turn and reused between.
214let fileShare = 0
215let fileShareTurn = -1
216let turnNo = 0
217
218async function doRefresh($: EngineInterface, forceAccount: boolean) {
219 const now = await $.clock.now()
220 const prev = await current($)
221
222 let context = prev.context
223 let responseLimits: ContextCacheLimit[] = []
224 try {
225 const usage = await $.session.usage({ breakdown: 'summary' })
226 responseLimits = limitsFromResponse(usage.rateLimits)
227 const b = usage.context.breakdown
228 if (fileShareTurn !== turnNo) {
229 try {
230 const msgs = await $.session.messages()
231 if (Array.isArray(msgs)) fileShare = fileShareOf(msgs)
232 fileShareTurn = turnNo
233 } catch {
234 // keep the last share
235 }
236 }
237 context = buildContext({
238 categories: b?.categories ?? [],
239 realTokens: usage.context.tokens ?? b?.totalTokens,
240 window: usage.context.window,
241 autoCompactAt: b?.autoCompactThreshold,
242 isAutoCompactEnabled: b?.isAutoCompactEnabled ?? false,
243 fileShare,
244 })
245 } catch {
246 // keep the last context reading
247 }
248
249 await fetchAccount($, now, forceAccount)
250 // A reload empties module memory but not the session's snapshot: a recent
251 // account reading there still stands, so Fable does not blink out meanwhile.
252 if (accountLimits === null && prev.limitsSource === 'account' && now - prev.updatedAt < 10 * ACCOUNT_EVERY_MS) {
253 accountLimits = prev.limits
254 accountAt = prev.updatedAt
255 }
256 const isAccountFresh = accountLimits !== null && now - accountAt < 10 * ACCOUNT_EVERY_MS
257 let limits: ContextCacheLimit[]
258 let limitsSource: ContextCacheSnapshot['limitsSource']
259 if (isAccountFresh && accountLimits) {
260 // The headers arrive with every reply and the account reading is up to 5
261 // minutes old, yet the headers can trail it (weekly 25% against the
262 // account's and /usage's 26%). Usage only climbs inside a window, so for
263 // the same window the higher reading is the newer one; across a reset the
264 // later window wins. The account adds what the headers lack (Fable).
265 limits = [
266 ...responseLimits.map(r => {
267 const a = accountLimits!.find(x => x.kind === r.kind)
268 if (!a) return r
269 const resetsAt = r.resetsAt ?? a.resetsAt
270 if (resetsAt !== null && a.resetsAt !== null && Math.abs(resetsAt - a.resetsAt) > SAME_WINDOW_MS) {
271 return a.resetsAt > resetsAt ? a : { ...r, resetsAt }
272 }
273 return { ...r, resetsAt, usage: Math.max(r.usage, a.usage) }
274 }),
275 ...accountLimits.filter(a => !responseLimits.some(r => r.kind === a.kind)),
276 ]
277 limitsSource = 'account'
278 } else if (responseLimits.length > 0) {
279 limits = responseLimits
280 limitsSource = 'response'
281 } else {
282 limits = prev.limits
283 limitsSource = prev.limitsSource
284 }
285
286 await update($, snapshotAtom, latest => {
287 const s = latest ?? prev
288 return { ...s, context, limits, limitsSource, updatedAt: now }
289 })
290 await update($, nowAtom, () => now)
291}
292
293function refresh($: EngineInterface, forceAccount = false): Promise<void> {
294 if (refreshing) return refreshing
295 refreshing = doRefresh($, forceAccount)
296 .catch(() => undefined) // a refresh outliving its session, or a refused write: the next one retries
297 .finally(() => {
298 refreshing = null
299 })
300 return refreshing
301}
302
303async function learnTtl($: EngineInterface, transcriptPath: string) {
304 if (!transcriptPath) return
305 try {
306 const stat = await $.fs.stat(transcriptPath)
307 if (stat.size > MAX_TRANSCRIPT_BYTES) return
308 const text = await $.fs.read(transcriptPath)
309 const ttl = ttlFromTranscript(typeof text === 'string' ? text : '')
310 if (ttl !== null) await setTtl($, ttl)
311 } catch {
312 // unreadable: the stored TTL stands
313 }
314}
315
316const PANE = 'context-cache'
317
318const COMMANDS = [
319 { name: 'cache', description: 'Context & cache panel: show or hide it' },
320 { name: 'cache-status', description: 'Context & cache panel: where each figure comes from (surfaces, draws, the account usage request, Fable)' },
321 { name: 'cache-pane', description: 'Context & cache panel: open it as a pane (the way it shows on mobile)' },
322 { name: 'cache-refresh', description: 'Context & cache panel: re-read usage now' },
323 { name: 'cache-collapse', description: 'Context & cache panel: fold it to one line, or open it again' },
324] as const
325
326async function hasMobile($: EngineInterface): Promise<boolean> {
327 try {
328 return (await $.session.surfaces()).includes('mobile')
329 } catch {
330 return false
331 }
332}
333
334/** The view model for a drawing, or null while hidden or before any reading. */
335async function panelModel($: EngineInterface, columns: number, maxRows: number): Promise<ViewModel | null> {
336 const [snap, hidden, tick] = await Promise.all([read($, snapshotAtom), read($, hiddenAtom), read($, nowAtom)])
337 if (hidden || !snap) return null
338 return buildView(snap, Math.max(tick, snap.updatedAt), columns, maxRows)
339}
340
341async function toggleCollapsed($: EngineInterface): Promise<boolean> {
342 const isCollapsed = !(await read($, collapsedAtom))
343 await update($, collapsedAtom, () => isCollapsed)
344 await $.store.set('isCollapsed', isCollapsed)
345 return isCollapsed
346}
347
348/** Clear, Compact and, in the band, the fold chevron. Neither Clear nor Compact hides the notice row. */
349function actionsFor($: EngineInterface, isWorking: boolean, canFold: boolean): Actions {
350 return {
351 toggle: canFold ? () => void toggleCollapsed($) : undefined,
352 clear: async () => {
353 if (isWorking) $.ui.toast('Clear runs once the current turn finishes.')
354 try {
355 await $.command.run({ command: 'clear' })
356 } catch {
357 $.ui.toast('Could not run /clear right now.')
358 }
359 },
360 compact: async () => {
361 try {
362 const r = await $.session.compact()
363 if ('skip' in r) {
364 $.ui.toast('Compaction was skipped.')
365 } else {
366 // Our own session.compact hook does not see our own call: reset here.
367 await patchCache($, { lastAt: null, hitRate: null })
368 void refresh($)
369 }
370 } catch {
371 $.ui.toast('Compact runs between turns: try again when this one finishes.')
372 }
373 },
374 }
375}
376
377export const register: Register = on => {
378 // ── Session lifecycle ──────────────────────────────────────────────────
379
380 on('session.start', async ($, e, next) => {
381 cwd = e.cwd
382 // One command each: the desktop composer drops anything typed after a slash command's name.
383 for (const c of COMMANDS) await $.command.register(c)
384 if ((await $.store.get('isCollapsed')) === true) await update($, collapsedAtom, () => true)
385 const initial = await current($)
386 await update($, snapshotAtom, prev => prev ?? initial)
387 void refresh($, true)
388 // Started from the phone: the pane is how the panel shows there.
389 if (await hasMobile($)) void $.ui.open({ id: PANE, title: 'Context & cache' }).catch(() => undefined)
390 // The countdown: checked once a second, redrawn only when a figure the
391 // panel shows would change (a 60m TTL moves a whole percent every 36s; a
392 // 5m one shows seconds, so it redraws every second).
393 let lastShown = ''
394 $.clock.every(1000, () => {
395 void (async () => {
396 const [snap, hidden] = await Promise.all([read($, snapshotAtom), read($, hiddenAtom)])
397 if (hidden || !snap || snap.cache.lastAt === null) return
398 const now = await $.clock.now()
399 const c = cacheView(snap.cache, now)
400 const shown = `${c.state}|${pct(c.warmth * 100)}|${c.detail}|${c.state === 'cooling' ? noticeText(c, snap.context, snap.cache.ttlMs) : ''}`
401 if (shown === lastShown) return
402 lastShown = shown
403 await update($, nowAtom, () => now)
404 })().catch(() => undefined)
405 })
406 $.clock.every(ACCOUNT_EVERY_MS, () => void refresh($, false))
407 return next(e)
408 })
409
410 // Resumed: the transcript says how long ago the last response was.
411 on('classic.SessionStart', async ($, e, next) => {
412 const now = await $.clock.now()
413 if ((e.source === 'resume' || e.source === 'fork') && typeof e.seconds_since_last_response === 'number') {
414 await patchCache($, { lastAt: now - e.seconds_since_last_response * 1000 })
415 } else if (e.source === 'clear' || e.source === 'compact') {
416 await patchCache($, { lastAt: null, hitRate: null })
417 }
418 void learnTtl($, e.transcript_path)
419 return next(e)
420 }).catch((_$, e, next) => next(e))
421
422 // Each main-thread response writes/reads the cache as it lands, so a long
423 // turn (or the first one after /clear) does not sit at "Empty" until it ends.
424 on('turn.step', async function* ($, e, next) {
425 const r = yield* next(e)
426 const u = r.usage
427 if (e.agentId === undefined && u) {
428 const total = u.cache_read_input_tokens + u.cache_creation_input_tokens + u.input_tokens
429 void $.clock
430 .now()
431 .then(now => patchCache($, { lastAt: now, hitRate: total > 0 ? u.cache_read_input_tokens / total : null }))
432 .catch(() => undefined)
433 }
434 return r
435 })
436
437 // A turn of the main conversation ended: its requests just wrote/read the cache.
438 on('turn.complete', async ($, e, next) => {
439 const result = await next(e)
440 if (e.agentId === undefined) {
441 turnNo += 1
442 const now = await $.clock.now()
443 const u = e.usage
444 const total = u ? u.cache_read_input_tokens + u.cache_creation_input_tokens + u.input_tokens : 0
445 await patchCache($, {
446 lastAt: u ? now : (await current($)).cache.lastAt,
447 hitRate: u && total > 0 ? u.cache_read_input_tokens / total : (await current($)).cache.hitRate,
448 })
449 void refresh($)
450 }
451 return result
452 })
453
454 // After each turn, read which TTL the responses were cached with.
455 on('classic.Stop', ($, e, next) => {
456 void learnTtl($, e.transcript_path)
457 return next(e)
458 }).catch((_$, e, next) => next(e))
459
460 // The engine reports the TTL itself on a model switch; a switch also forfeits the cache.
461 on('classic.PostModelSwitch', async ($, e, next) => {
462 await setTtl($, e.cache_ttl === '5m' ? TTL_5M : TTL_1H)
463 await patchCache($, { lastAt: null, hitRate: null })
464 void refresh($)
465 return next(e)
466 }).catch((_$, e, next) => next(e))
467
468 // Usage figures moved (context fill, a limit window).
469 on('session.measure', ($, e, next) => {
470 void refresh($)
471 return next(e)
472 })
473
474 // A compaction replaces the conversation: nothing of the new one is cached yet.
475 on('session.compact', async ($, e, next) => {
476 const result = await next(e)
477 if (e.trigger !== 'precompute' && !('skip' in result)) {
478 await patchCache($, { lastAt: null, hitRate: null })
479 void refresh($)
480 }
481 return result
482 }).catch((_$, e, next) => next(e))
483
484 // /clear starts a fresh conversation in the same process.
485 on('session.end', async ($, e, next) => {
486 const result = await next(e)
487 if (e.reason === 'clear') {
488 await patchCache($, { lastAt: null, hitRate: null })
489 void refresh($)
490 }
491 return result
492 })
493
494 // ── /cache, /cache-status, /cache-pane, /cache-refresh, /cache-collapse ─
495
496 on('command.run', { command: 'cache-status' }, async $ => ({ text: await statusReport($) }))
497
498 on('command.run', { command: 'cache-refresh' }, async $ => {
499 await refresh($, true)
500 return { text: 'Context & cache panel refreshed.' }
501 })
502
503 on('command.run', { command: 'cache-collapse' }, async $ => {
504 const isCollapsed = await toggleCollapsed($)
505 return { text: isCollapsed ? 'Context & cache panel collapsed to one line.' : 'Context & cache panel expanded.' }
506 })
507
508 on('command.run', { command: 'cache-pane' }, async $ => {
509 await update($, hiddenAtom, () => false)
510 void refresh($)
511 await $.ui.open({ id: PANE, title: 'Context & cache' })
512 return { text: 'Context & cache panel opened.' }
513 })
514
515 on('command.run', { command: 'cache' }, async $ => {
516 const isHidden = await read($, hiddenAtom)
517 await update($, hiddenAtom, () => !isHidden)
518 if (isHidden) {
519 void refresh($)
520 // Phones have no band above the prompt: show it there as a pane.
521 if (await hasMobile($)) await $.ui.open({ id: PANE, title: 'Context & cache' })
522 return { text: 'Context & cache panel shown.' }
523 }
524 await $.ui.close({ id: PANE }).catch(() => undefined)
525 return { text: 'Context & cache panel hidden. /cache shows it again.' }
526 })
527
528 // A phone joined the session: open the panel as a pane there.
529 on('session.attach', async ($, e, next) => {
530 const result = await next(e)
531 attached.push(e.surface)
532 if (e.surface === 'mobile' && !(await read($, hiddenAtom))) {
533 void $.ui.open({ id: PANE, title: 'Context & cache' }).catch(() => undefined)
534 }
535 return result
536 })
537
538 // ── The band above the prompt (terminal, desktop) ──────────────────────
539
540 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
541 noteDraw('AbovePrompt', e.surface)
542 if (e.props.hasSurvey) return next(e)
543 if (e.props.view.agentId !== undefined) return next(e)
544 if (e.surface !== 'terminal' && e.surface !== 'desktop' && e.surface !== 'vscode') return next(e)
545 const vm = await panelModel($, e.props.bodyColumns, e.props.maxRows)
546 if (!vm) return next(e)
547 const isCollapsed = await read($, collapsedAtom)
548 const actions = actionsFor($, e.props.isWorking, true)
549 const E = $.ui.resolve(e)
550 const mine =
551 e.surface === 'terminal'
552 ? renderTerminal(E as Parameters<typeof renderTerminal>[0], vm, e.props.bodyColumns, actions, isCollapsed)
553 : renderDesktop(E as Parameters<typeof renderDesktop>[0], vm, e.props.bodyColumns, actions, isCollapsed)
554 // What the host and the plugins after this one draw here stays, under the panel.
555 const below = await next(e)
556 if (!below) return mine
557 const { Box } = E
558 return (
559 <Box flexDirection="column" gap={1}>
560 {mine}
561 {below}
562 </Box>
563 )
564 })
565
566 // ── The same panel as a pane (every surface; the phone's way to see it) ─
567
568 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
569 noteDraw('Pane', e.surface)
570 const E = $.ui.resolve(e)
571 const { Text } = E
572 const rows = Math.max(e.props.scroll.bodyRows, 12)
573 const vm = await panelModel($, e.props.bodyColumns, rows)
574 if (!vm) return <Text dimColor>Reading usage…</Text>
575 const actions = actionsFor($, false, false)
576 return e.surface === 'terminal'
577 ? renderTerminal(E as Parameters<typeof renderTerminal>[0], vm, e.props.bodyColumns, actions, false)
578 : renderDesktop(E as Parameters<typeof renderDesktop>[0], vm, e.props.bodyColumns, actions, false)
579 })
580}
581hooks/model.ts 441 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// ── Colours ────────────────────────────────────────────────────────────────
16
17// Theme keys, so the panel takes the person's Claude Code theme on every surface.
18export type Tone = 'claude' | 'suggestion' | 'merged' | 'inactive' | 'success' | 'warning' | 'error'
19
20export const GROUP_COLORS: Record<ContextCacheGroup, Tone> = {
21 system: 'inactive',
22 tools: 'merged',
23 files: 'suggestion',
24 messages: 'claude',
25}
26
27export const GROUP_LABELS: Record<ContextCacheGroup, string> = {
28 system: 'System',
29 tools: 'Tools',
30 files: 'Files',
31 messages: 'Messages',
32}
33
34export const GROUPS: readonly ContextCacheGroup[] = ['system', 'tools', 'files', 'messages']
35
36export const LIMIT_TITLES: Record<ContextCacheLimitKind, string> = {
37 session: 'Session',
38 weekly: 'Weekly',
39 fable: 'Fable',
40}
41
42export const CACHE_COLORS: Record<CacheState, Tone> = {
43 warm: 'claude',
44 cooling: 'warning',
45 cold: 'suggestion',
46 empty: 'inactive',
47}
48
49export const HOUR = 3_600_000
50export const FIVE_HOURS = 5 * HOUR
51export const SEVEN_DAYS = 7 * 24 * HOUR
52export const TTL_5M = 5 * 60_000
53export const TTL_1H = HOUR
54
55// ── Formatting ─────────────────────────────────────────────────────────────
56
57/** 452000 → "452k", 1000000 → "1M", 1500000 → "1.5M", 640 → "640". */
58export function fmtTokens(n: number): string {
59 if (n >= 1_000_000) {
60 const m = n / 1_000_000
61 return `${Number.isInteger(m) ? m : m.toFixed(1).replace(/\.0$/, '')}M`
62 }
63 if (n >= 1000) return `${Math.round(n / 1000)}k`
64 return String(Math.max(0, Math.round(n)))
65}
66
67/** Whole percent, never NaN. */
68export function pct(n: number): number {
69 return Number.isFinite(n) ? Math.round(n) : 0
70}
71
72/** 7_920_000 → "2h 12m"; 200_000 → "3m 20s"; 45_000 → "45s"; days when ≥ 24h. */
73export function fmtSpan(ms: number, withSeconds = false): string {
74 const s = Math.max(0, Math.round(ms / 1000))
75 const d = Math.floor(s / 86400)
76 const h = Math.floor((s % 86400) / 3600)
77 const m = Math.floor((s % 3600) / 60)
78 const sec = s % 60
79 if (d > 0) return `${d}d ${h}h`
80 if (h > 0) return `${h}h ${m}m`
81 if (withSeconds) return m > 0 ? `${m}m ${sec}s` : `${sec}s`
82 return `${m}m`
83}
84
85const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
86
87/** "Resets in 2h 12m" inside a day, else "Resets Sun 11:00 pm" (local time). */
88export function fmtReset(resetsAt: number | null, now: number): string {
89 if (resetsAt === null) return 'Reset time not reported'
90 const left = resetsAt - now
91 if (left <= 0) return 'Resetting now'
92 if (left < 24 * HOUR) return `Resets in ${fmtSpan(left)}`
93 const d = new Date(resetsAt)
94 const h24 = d.getHours()
95 const h12 = h24 % 12 === 0 ? 12 : h24 % 12
96 const mm = String(d.getMinutes()).padStart(2, '0')
97 return `Resets ${DAYS[d.getDay()]} ${h12}:${mm} ${h24 < 12 ? 'am' : 'pm'}`
98}
99
100// ── Context ────────────────────────────────────────────────────────────────
101
102/** Tools whose results are file contents, for the Files share of Messages. */
103const FILE_TOOLS = new Set(['Read', 'NotebookRead'])
104
105/**
106 * The share of the conversation's text that is file contents the model read,
107 * 0 to 1: the Files segment is that share of the Messages row's real tokens.
108 */
109export function fileShareOf(messages: readonly SessionMessage[]): number {
110 let total = 0
111 let files = 0
112 for (const m of messages) {
113 total += m.text.length
114 for (const use of m.toolUses) {
115 const len = (use.text ?? '').length + JSON.stringify(use.input ?? {}).length
116 total += len
117 if (FILE_TOOLS.has(use.tool)) files += (use.text ?? '').length
118 }
119 }
120 return total > 0 ? Math.min(1, files / total) : 0
121}
122
123/** Which of the four groups a /context row belongs to. */
124export function groupOf(name: string): ContextCacheGroup {
125 if (/message/i.test(name)) return 'messages'
126 if (/tool|mcp|agent|skill|command/i.test(name)) return 'tools'
127 return 'system'
128}
129
130/**
131 * The context row's figures: /context's categories grouped into four and
132 * scaled so they sum to the real token count the last response reported.
133 */
134export function buildContext(args: {
135 categories: readonly ContextCategory[]
136 realTokens: number | undefined
137 window: number
138 autoCompactAt: number | undefined
139 isAutoCompactEnabled: boolean
140 fileShare: number
141}): ContextCacheContext {
142 const raw: Record<ContextCacheGroup, number> = { system: 0, tools: 0, files: 0, messages: 0 }
143 for (const c of args.categories) {
144 if (c.kind !== 'used') continue
145 raw[groupOf(c.name)] += c.tokens
146 }
147 const fileTokens = Math.round(raw.messages * args.fileShare)
148 raw.files = fileTokens
149 raw.messages -= fileTokens
150 const estimate = GROUPS.reduce((s, g) => s + raw[g], 0)
151 const used = args.realTokens ?? estimate
152 const k = estimate > 0 ? used / estimate : 0
153 const parts = { system: 0, tools: 0, files: 0, messages: 0 } as Record<ContextCacheGroup, number>
154 for (const g of GROUPS) parts[g] = Math.round(raw[g] * k)
155 return {
156 used,
157 window: args.window,
158 autoCompactAt: args.isAutoCompactEnabled && args.autoCompactAt ? args.autoCompactAt : null,
159 parts,
160 }
161}
162
163// ── Limits ─────────────────────────────────────────────────────────────────
164
165type Found = { path: string[]; node: Record<string, unknown>; usage: number }
166
167function walk(node: unknown, path: string[], out: Found[]): void {
168 if (Array.isArray(node)) {
169 node.forEach((n, i) => walk(n, [...path, String(i)], out))
170 return
171 }
172 if (node === null || typeof node !== 'object') return
173 const obj = node as Record<string, unknown>
174 // five_hour / seven_day say `utilization`; the limits[] items (the per-model windows) say `percent`.
175 const u = obj['utilization'] ?? obj['percent'] ?? obj['percent_used'] ?? obj['used_percent']
176 if (typeof u === 'number') {
177 out.push({ path, node: obj, usage: u })
178 return
179 }
180 for (const [k, v] of Object.entries(obj)) walk(v, [...path, k], out)
181}
182
183const isFable = (f: Found) => /fable/i.test(f.path.join('.')) || /fable/i.test(JSON.stringify(f.node))
184
185/** Every window with a utilization figure in the account usage JSON, for /cache status. */
186export function usageWindowsOf(json: unknown): { path: string; usage: number; isFable: boolean }[] {
187 const found: Found[] = []
188 walk(json, [], found)
189 return found.map(f => ({ path: f.path.join('.'), usage: f.usage, isFable: isFable(f) }))
190}
191
192function toEpochMs(v: unknown): number | null {
193 if (typeof v === 'number') return v < 1e12 ? v * 1000 : v
194 if (typeof v === 'string') {
195 const t = Date.parse(v)
196 return Number.isNaN(t) ? null : t
197 }
198 return null
199}
200
201/**
202 * Reads the account usage endpoint's JSON (api.anthropic.com/api/oauth/usage),
203 * the same source /usage draws on: `five_hour` is the current session,
204 * `seven_day` the weekly limit, and Fable is the `limits[]` item of kind
205 * `weekly_scoped` whose `scope.model.display_name` is "Fable" (an item with
206 * `scope: null` repeats `seven_day`). Matching by name rather than a fixed
207 * key keeps it working if the key moves.
208 */
209export function parseAccountUsage(json: unknown): ContextCacheLimit[] {
210 const found: Found[] = []
211 walk(json, [], found)
212 const out: ContextCacheLimit[] = []
213 const take = (kind: ContextCacheLimitKind, f: Found | undefined, windowMs: number) => {
214 if (!f) return
215 out.push({
216 kind,
217 usage: Math.max(0, Math.min(100, f.usage)),
218 resetsAt: toEpochMs(f.node['resets_at'] ?? f.node['resetsAt']),
219 windowMs,
220 })
221 }
222 const keyOf = (f: Found) => f.path[f.path.length - 1] ?? ''
223 take('session', found.find(f => keyOf(f) === 'five_hour'), FIVE_HOURS)
224 take('weekly', found.find(f => keyOf(f) === 'seven_day'), SEVEN_DAYS)
225 const fable = found.filter(isFable).sort((a, b) => b.usage - a.usage)[0]
226 take('fable', fable, SEVEN_DAYS)
227 return out
228}
229
230/** The engine's own per-response rate-limit readings, as a fallback. */
231export function limitsFromResponse(rateLimits: readonly SessionRateLimit[]): ContextCacheLimit[] {
232 const out: ContextCacheLimit[] = []
233 for (const r of rateLimits) {
234 const kind: ContextCacheLimitKind | null =
235 r.kind === 'five_hour' ? 'session' : r.kind === 'seven_day' ? 'weekly' : /fable/i.test(r.kind) ? 'fable' : null
236 if (!kind) continue
237 out.push({
238 kind,
239 usage: Math.max(0, Math.min(100, r.percentUsed)),
240 resetsAt: r.resetsAt ? toEpochMs(r.resetsAt) : null,
241 windowMs: kind === 'session' ? FIVE_HOURS : SEVEN_DAYS,
242 })
243 }
244 return out
245}
246
247export type LimitTone = 'normal' | 'amber' | 'red'
248
249export type LimitView = {
250 kind: ContextCacheLimitKind
251 title: string
252 /** Null when this limit has no reading at all. */
253 usage: number | null
254 time: number | null
255 tone: LimitTone
256 color: Tone
257 reset: string
258}
259
260// Each limit keeps its own colour while on pace; amber when well ahead of the time, red from 90%.
261const KIND_COLORS: Record<ContextCacheLimitKind, Tone> = { session: 'success', weekly: 'suggestion', fable: 'merged' }
262const colorOf = (kind: ContextCacheLimitKind, tone: LimitTone): Tone =>
263 tone === 'red' ? 'error' : tone === 'amber' ? 'warning' : KIND_COLORS[kind]
264
265/** Percent of the window that has elapsed, from its reset time. */
266export function timePct(limit: ContextCacheLimit, now: number): number | null {
267 if (limit.resetsAt === null) return null
268 const elapsed = limit.windowMs - (limit.resetsAt - now)
269 return Math.max(0, Math.min(100, (elapsed / limit.windowMs) * 100))
270}
271
272/** Red at ≥ 90% used; amber when > 15 points and > 1.4× ahead of time. */
273export function toneOf(usage: number, time: number | null): LimitTone {
274 if (usage >= 90) return 'red'
275 const t = time ?? 0
276 if (usage - t > 15 && (t === 0 || usage / t > 1.4)) return 'amber'
277 return 'normal'
278}
279
280export function limitView(kind: ContextCacheLimitKind, limit: ContextCacheLimit | undefined, now: number): LimitView {
281 if (!limit) {
282 return { kind, title: LIMIT_TITLES[kind], usage: null, time: null, tone: 'normal', color: 'inactive', reset: 'No reading yet' }
283 }
284 const time = timePct(limit, now)
285 const tone = toneOf(limit.usage, time)
286 return {
287 kind,
288 title: LIMIT_TITLES[kind],
289 usage: limit.usage,
290 time,
291 tone,
292 color: colorOf(kind, tone),
293 reset: fmtReset(limit.resetsAt, now),
294 }
295}
296
297// ── Cache ──────────────────────────────────────────────────────────────────
298
299export type CacheState = 'warm' | 'cooling' | 'cold' | 'empty'
300
301export type CacheView = {
302 state: CacheState
303 /** Remaining TTL ÷ TTL, 0 to 1. */
304 warmth: number
305 remainingMs: number
306 color: Tone
307 label: string
308 hit: number | null
309 detail: string
310}
311
312/** Warm > 25% of the TTL left, cooling 1–25%, cold at 0; empty with nothing cached. */
313export function cacheView(cache: ContextCacheCache, now: number): CacheView {
314 const ttl = cache.ttlMs
315 const hit = cache.hitRate === null ? null : cache.hitRate * 100
316 if (cache.lastAt === null) {
317 return {
318 state: 'empty',
319 warmth: 0,
320 remainingMs: 0,
321 color: CACHE_COLORS.empty,
322 label: 'Empty',
323 hit,
324 detail: 'Nothing cached yet',
325 }
326 }
327 const remainingMs = Math.max(0, cache.lastAt + ttl - now)
328 const warmth = ttl > 0 ? remainingMs / ttl : 0
329 const state: CacheState = warmth <= 0 ? 'cold' : warmth > 0.25 ? 'warm' : 'cooling'
330 const short = ttl < 10 * 60_000
331 const ttlText = `${Math.round(ttl / 60_000)}m`
332 const detail =
333 state === 'cold'
334 ? (() => {
335 const ago = now - (cache.lastAt + ttl)
336 return ago < 60_000 ? 'Expired just now' : `Expired ${fmtSpan(ago)} ago`
337 })()
338 : short
339 ? `${fmtSpan(remainingMs, true)} left of ${ttlText}`
340 : `${Math.max(1, Math.round(remainingMs / 60_000))}m left of ${ttlText}`
341 return {
342 state,
343 warmth,
344 remainingMs,
345 color: CACHE_COLORS[state],
346 label: state === 'warm' ? 'Warm' : state === 'cooling' ? 'Cooling' : 'Cold',
347 hit,
348 detail,
349 }
350}
351
352/** The notice line's copy. Always a line: the row with Clear / Compact never hides. */
353export function noticeText(cache: CacheView, context: ContextCacheContext | null, ttlMs: number): string {
354 if (!context) return cache.state === 'empty' ? 'Cache empty: nothing cached yet.' : `Cache ${cache.label.toLowerCase()}: reading context size…`
355 const tokens = fmtTokens(context.used)
356 if (cache.state === 'empty') return `Cache empty: next message writes ${tokens} to cache.`
357 if (cache.state === 'warm') {
358 const usedPct = (context.used / context.window) * 100
359 if (context.autoCompactAt !== null && usedPct >= 85) {
360 const away = Math.max(0, pct((context.autoCompactAt / context.window) * 100 - usedPct))
361 return `Cache warm: next message reuses ${tokens}. Auto-compact is ${away}% away.`
362 }
363 return `Cache warm: next message reuses ${tokens} from cache.`
364 }
365 if (cache.state === 'cooling') {
366 const within =
367 ttlMs < 10 * 60_000 ? fmtSpan(cache.remainingMs, true) : `${Math.max(1, Math.round(cache.remainingMs / 60_000))}m`
368 return `Cache cooling: send within ${within} to keep ${tokens} cached.`
369 }
370 return `Cache cold: next message re-reads ${tokens}. Clear is free.`
371}
372
373// ── Layout ─────────────────────────────────────────────────────────────────
374
375export type ViewModel = {
376 context: {
377 used: number
378 window: number
379 usedPct: number
380 compactPct: number | null
381 segments: { group: ContextCacheGroup; tokens: number; color: Tone; label: string }[]
382 free: number
383 } | null
384 limits: LimitView[]
385 cache: CacheView
386 notice: string
387 showLegend: boolean
388 showNotice: boolean
389 /** False when the band has too few rows for the expanded panel: draw the one-line summary instead. */
390 fits: boolean
391}
392
393/** Rows the expanded terminal panel takes: context, legend, a gap, the rings' two lines, a gap, notice and buttons. */
394export function rowsOf(vm: Pick<ViewModel, 'showLegend' | 'showNotice'>): number {
395 return 1 + (vm.showLegend ? 1 : 0) + 1 + 2 + (vm.showNotice ? 3 : 0)
396}
397
398export function buildView(
399 snap: ContextCacheSnapshot,
400 now: number,
401 columns: number,
402 maxRows: number,
403): ViewModel {
404 const cache = cacheView(snap.cache, now)
405 const notice = noticeText(cache, snap.context, snap.cache.ttlMs)
406
407 const ctx = snap.context
408 const context = ctx
409 ? {
410 used: ctx.used,
411 window: ctx.window,
412 usedPct: pct((ctx.used / ctx.window) * 100),
413 compactPct: ctx.autoCompactAt === null ? null : pct((ctx.autoCompactAt / ctx.window) * 100),
414 segments: GROUPS.map(g => ({
415 group: g,
416 tokens: ctx.parts[g],
417 color: GROUP_COLORS[g],
418 label: GROUP_LABELS[g],
419 })),
420 free: Math.max(0, ctx.window - ctx.used),
421 }
422 : null
423
424 const byKind = (k: ContextCacheLimitKind) => snap.limits.find(l => l.kind === k)
425 const limits = (['session', 'weekly', 'fable'] as const).map(k => limitView(k, byKind(k), now))
426
427 // Drop the legend, then the notice row, when the band is short of rows.
428 const fit = { showLegend: context !== null && columns >= 50, showNotice: true }
429 if (rowsOf(fit) > maxRows) fit.showLegend = false
430 if (rowsOf(fit) > maxRows) fit.showNotice = false
431
432 return {
433 context,
434 limits,
435 cache,
436 notice,
437 ...fit,
438 fits: rowsOf(fit) <= maxRows,
439 }
440}
441hooks/view.tsx 250 lines1// Drawing for the context-cache panel. One design in two media: the desktop
2// (and the editor, and the phone's pane) draws it as SVG at real pixel sizes
3// (hooks/panel-svg.ts) with the host's own buttons; the terminal draws the
4// same rows in text, with glyphs for the rings. Neither has a card or a
5// background: text takes the theme, data colours read on light and dark.
6
7import type { Elements, RenderElement } from 'claude-code'
8
9import { fmtTokens, pct, type CacheView, type LimitView, type Tone, type ViewModel } from './model'
10import { collapsedSvg, panelSvg } from './panel-svg'
11
12export type Actions = {
13 clear: () => void | Promise<void>
14 compact: () => void | Promise<void>
15 /** Folds the panel to one line or opens it again; absent where it cannot fold (the pane). */
16 toggle?: () => void | Promise<void>
17}
18
19type TermEls = Elements['terminal']
20type DeskEls = Elements['desktop'] | Elements['vscode'] | Elements['mobile']
21
22const PX_PER_CELL = 8
23
24// ── Terminal ───────────────────────────────────────────────────────────────
25
26/** The terminal panel is laid out at most this wide, as the desktop's is at its design width. */
27const MAX_COLUMNS = 76
28const FILLED = '━'
29const TRACK = '─'
30const TICK = '│'
31
32type Cell = { ch: string; color?: Tone; isDim: boolean }
33
34/** The context bar as cells: a run per group, the auto-compact tick over it. */
35export function contextCells(ctx: NonNullable<ViewModel['context']>, n: number): Cell[] {
36 const cells: Cell[] = Array.from({ length: n }, () => ({ ch: TRACK, isDim: true }))
37 let acc = 0
38 for (const s of ctx.segments) {
39 const start = Math.round((acc / ctx.window) * n)
40 acc += s.tokens
41 const end = Math.min(n, Math.round((acc / ctx.window) * n))
42 for (let i = start; i < end; i++) cells[i] = { ch: FILLED, color: s.color, isDim: false }
43 }
44 if (ctx.compactPct !== null) cells[Math.min(n - 1, Math.floor((ctx.compactPct / 100) * n))] = { ch: TICK, isDim: false }
45 return cells
46}
47
48const LIMIT_GLYPH = { session: '◷', weekly: '▦', fable: '✦' } as const
49const cacheGlyph = (c: CacheView) => (c.state === 'cold' ? '❄' : c.state === 'empty' ? '○' : '♨')
50
51export function renderTerminal(E: TermEls, vm: ViewModel, columns: number, actions: Actions, isCollapsed: boolean): RenderElement {
52 const { Box, Text, Button } = E
53 const cols = Math.max(24, Math.min(MAX_COLUMNS, columns))
54 const ctx = vm.context
55 const c = vm.cache
56
57 const bar = (key: string, n: number) => {
58 if (!ctx) return null
59 const runs: Cell[] = []
60 for (const cell of contextCells(ctx, n)) {
61 const last = runs[runs.length - 1]
62 if (last && last.color === cell.color && last.isDim === cell.isDim) last.ch += cell.ch
63 else runs.push({ ...cell })
64 }
65 return (
66 <Text key={key}>
67 {runs.map(r => (
68 <Text color={r.color} dimColor={r.isDim}>
69 {r.ch}
70 </Text>
71 ))}
72 </Text>
73 )
74 }
75 const chevron = actions.toggle ? (
76 <Button key="collapse" plain dimColor label={isCollapsed || !vm.fits ? '▸' : '▾'} onPress={actions.toggle} />
77 ) : null
78 const limitFigure = (l: LimitView) => (l.usage === null ? '—' : `${pct(l.usage)}%`)
79
80 if (isCollapsed || !vm.fits) {
81 return (
82 <Box key="context-cache" flexDirection="row" alignItems="center" columnGap={1}>
83 <Text>
84 <Text bold>Context </Text>
85 <Text dimColor>{ctx ? `${ctx.usedPct}%` : '—'}</Text>
86 </Text>
87 {cols >= 60 ? bar('bar-context', 10) : null}
88 <Box flexShrink={1} minWidth={0}>
89 <Text wrap="truncate">
90 {vm.limits.map(l => (
91 <Text>
92 <Text color={l.color}>{` ${LIMIT_GLYPH[l.kind]} `}</Text>
93 <Text bold dimColor={l.usage === null}>
94 {limitFigure(l)}
95 </Text>
96 </Text>
97 ))}
98 <Text color={c.color}>{` ${cacheGlyph(c)} ${c.label}`}</Text>
99 {c.state === 'warm' || c.state === 'cooling' ? <Text dimColor>{` ${c.detail.split(' of ')[0]}`}</Text> : null}
100 </Text>
101 </Box>
102 {chevron}
103 </Box>
104 )
105 }
106
107 // Row 1: Context, used, the bar, where auto-compact fires; the chevron at the end.
108 const used = ctx ? `${fmtTokens(ctx.used)} / ${fmtTokens(ctx.window)} (${ctx.usedPct}%)` : ''
109 const right = ctx?.compactPct == null ? '' : `${ctx.compactPct}%`
110 const barW = Math.max(6, cols - 8 - used.length - 1 - (right ? right.length + 1 : 0) - (chevron ? 2 : 0) - 1)
111 const contextRow = (
112 <Box key="context" flexDirection="row" columnGap={1}>
113 <Text bold>Context</Text>
114 {ctx ? <Text dimColor>{used}</Text> : <Text dimColor>waiting for the first reading</Text>}
115 {bar('bar-context', barW)}
116 {right ? <Text dimColor>{right}</Text> : null}
117 {ctx ? null : <Box flexGrow={1} />}
118 {chevron}
119 </Box>
120 )
121
122 // Row 2: the legend.
123 const legend =
124 ctx && vm.showLegend ? (
125 <Text key="legend" wrap="truncate">
126 {ctx.segments.map(s => (
127 <Text>
128 <Text color={s.color}>● </Text>
129 <Text>{`${s.label} ${fmtTokens(s.tokens)} `}</Text>
130 </Text>
131 ))}
132 <Text dimColor>{`Free ${fmtTokens(ctx.free)}`}</Text>
133 </Text>
134 ) : null
135
136 // Row 3: a "ring" per limit and one for the cache: glyph and figure, the time or warmth under it.
137 const twoLines = cols >= 44
138 const item = (key: string, glyph: string, color: Tone, main: RenderElement, sub: string) => (
139 <Box key={key} flexDirection="column">
140 <Text>
141 <Text color={color}>{`${glyph} `}</Text>
142 {main}
143 </Text>
144 {twoLines ? <Text dimColor>{` ${sub}`}</Text> : null}
145 </Box>
146 )
147 const rings = (
148 <Box key="rings" flexDirection="row" justifyContent="space-between" width={cols}>
149 {vm.limits.map(l =>
150 item(
151 `ring-${l.kind}`,
152 LIMIT_GLYPH[l.kind],
153 l.color,
154 <Text bold dimColor={l.usage === null}>
155 {limitFigure(l)}
156 </Text>,
157 l.time === null ? (l.usage === null ? 'No data' : ' ') : `Time ${pct(l.time)}%`,
158 ),
159 )}
160 {item(
161 'ring-cache',
162 cacheGlyph(c),
163 c.color,
164 <Text bold color={c.color}>
165 {c.label}
166 </Text>,
167 c.state === 'empty' ? 'Nothing cached' : `${pct(c.warmth * 100)}% warm`,
168 )}
169 </Box>
170 )
171
172 // Rows 4 and 5: the notice, then Clear and Compact.
173 const notice = vm.showNotice ? (
174 <Box key="notice" flexDirection="column">
175 <Text wrap="wrap">
176 <Text color={c.color}>● </Text>
177 <Text>{vm.notice}</Text>
178 </Text>
179 <Box flexDirection="row" columnGap={1}>
180 <Button key="clear" label="Clear" variant="primary" hotkey="c" onPress={actions.clear} />
181 <Button key="compact" label="Compact" hotkey="k" onPress={actions.compact} />
182 </Box>
183 </Box>
184 ) : null
185
186 return (
187 <Box key="context-cache" flexDirection="column" rowGap={1} width={cols}>
188 <Box flexDirection="column">
189 {contextRow}
190 {legend}
191 </Box>
192 {rings}
193 {notice}
194 </Box>
195 )
196}
197
198// ── Desktop, editor, phone ─────────────────────────────────────────────────
199
200// Lucide chevron-up (fold) / chevron-down (open), as plan-progress draws its fold control: the picture centred in
201// its cell and a blank Button over it, so the host draws its own hover and focus ring and takes the press.
202const CHIP = 16
203const chevronSvg = (isCollapsed: boolean) =>
204 `<svg xmlns="http://www.w3.org/2000/svg" width="${CHIP}" height="${CHIP}" viewBox="0 0 24 24"><style>.c{stroke:#C2C0B6}` +
205 `@media (prefers-color-scheme:light){.c{stroke:#3D3D3A}}</style>` +
206 `<path class="c" fill="none" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" d="${isCollapsed ? 'm6 9 6 6 6-6' : 'm18 15-6-6-6 6'}"/></svg>`
207
208export function renderDesktop(E: DeskEls, vm: ViewModel, columns: number, actions: Actions, isCollapsed: boolean): RenderElement {
209 const { Box, Button, Svg } = E
210 // The chevron's cell comes off the width the drawing may take.
211 const px = columns * PX_PER_CELL - (actions.toggle ? 32 : 0)
212 const folded = isCollapsed || !vm.fits
213 const chevron = actions.toggle ? (
214 <Box key="collapse-cell" width={3} height={folded ? 1 : 2} flexShrink={0} justifyContent="center" alignItems="center">
215 <Svg source={chevronSvg(folded)} alt={folded ? 'expand the panel' : 'collapse the panel'} width={CHIP} height={CHIP} />
216 <Box position="absolute" top={0} left={0} right={0} bottom={0} justifyContent="center" alignItems="center">
217 <Button key="collapse" plain label={' '} onPress={actions.toggle} />
218 </Box>
219 </Box>
220 ) : null
221
222 if (folded) {
223 const p = collapsedSvg(vm, px)
224 return (
225 <Box key="context-cache" flexDirection="row" alignItems="center" columnGap={1}>
226 <Svg source={p.svg} alt={p.alt} width={p.width} height={p.height} isInteractive />
227 <Box flexGrow={1} />
228 {chevron}
229 </Box>
230 )
231 }
232
233 const p = panelSvg(vm, px)
234 return (
235 <Box key="context-cache" flexDirection="column" rowGap={1}>
236 <Box flexDirection="row" alignItems="flex-start" columnGap={1}>
237 <Svg source={p.svg} alt={p.alt} width={p.width} height={p.height} isInteractive />
238 <Box flexGrow={1} />
239 {chevron}
240 </Box>
241 {vm.showNotice ? (
242 <Box flexDirection="row" columnGap={1}>
243 <Button key="clear" label="Clear" variant="primary" hotkey="c" onPress={actions.clear} />
244 <Button key="compact" label="Compact" hotkey="k" onPress={actions.compact} />
245 </Box>
246 ) : null}
247 </Box>
248 )
249}
250hooks/panel-svg.ts 319 lines1// The desktop/editor/phone panel, drawn as SVG at real pixel sizes: the
2// context bar and legend, a ring per limit and one for the cache, the notice.
3// No card or background: text and tracks take the light or dark theme through
4// prefers-color-scheme, data colours read on both. The host draws the buttons.
5// Adapted from Christandoh/context-cache's design (MIT).
6
7import { ellipsize, textWidth } from './metrics'
8import { fmtTokens, pct, type CacheView, type LimitView, type Tone, type ViewModel } from './model'
9
10const FONT = `'Helvetica Neue', Helvetica, Arial, sans-serif`
11const NS = 'http://www.w3.org/2000/svg'
12
13/** The design's own width; wider bands leave the rest of the row empty rather than spreading the rings apart. */
14export const MAX_WIDTH = 520
15
16const HEX: Record<Tone, string> = {
17 claude: '#E8833A',
18 suggestion: '#3B7BE0',
19 merged: '#8A6FC0',
20 inactive: '#7D7970',
21 success: '#4FAE6A',
22 warning: '#E0A84F',
23 error: '#E5604F',
24}
25
26// t1…t4: text from strongest to faintest; tr: tracks; tk: ticks. Dark first, light under the media query.
27const STYLE =
28 `<style>` +
29 `.t1{fill:#ECE9E2}.t2{fill:#B9B5AB}.t3{fill:#8A867D}.t4{fill:#7D7970}.tr{fill:#3A3835}.trs{stroke:#3A3835}.tk{fill:#ECE9E2}.tks{stroke:#ECE9E2}` +
30 `@media (prefers-color-scheme:light){` +
31 `.t1{fill:#1F1E1D}.t2{fill:#3D3D3A}.t3{fill:#6B6A65}.t4{fill:#8A8983}.tr{fill:#E3E1DA}.trs{stroke:#E3E1DA}.tk{fill:#3D3D3A}.tks{stroke:#3D3D3A}}` +
32 `</style>`
33
34const DIM = 0.38
35const SLACK = 1.1
36const ROW_GAP = 11
37const LH = (size: number) => Math.round(size * 1.25)
38const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
39const r2 = (n: number) => Math.round(n * 100) / 100
40/** Baseline of a line of `size` px whose box starts at `top`. */
41const base = (top: number, size: number) => r2(top + LH(size) / 2 + size * 0.35)
42
43type Run = { text: string; cls?: string; fill?: string; bold?: boolean }
44
45function text(x: number, top: number, size: number, runs: Run[], anchor: 'start' | 'end' = 'start'): string {
46 const spans = runs
47 .map(r => `<tspan${r.cls ? ` class="${r.cls}"` : ''}${r.fill ? ` fill="${r.fill}"` : ''}${r.bold ? ' font-weight="700"' : ''}>${esc(r.text)}</tspan>`)
48 .join('')
49 return `<text x="${r2(x)}" y="${base(top, size)}" font-size="${size}" text-anchor="${anchor}" xml:space="preserve">${spans}</text>`
50}
51
52function doc(width: number, height: number, body: string, alt: string): string {
53 return (
54 `<svg xmlns="${NS}" width="${r2(width)}" height="${r2(height)}" viewBox="0 0 ${r2(width)} ${r2(height)}" ` +
55 `font-family="${FONT}" role="img" aria-label="${esc(alt)}">${STYLE}${body}</svg>`
56 )
57}
58
59// ── Bars and rings ─────────────────────────────────────────────────────────
60
61/** The context bar: a rounded track, a run per group with 1px between, the auto-compact tick. */
62function contextBar(id: string, ctx: NonNullable<ViewModel['context']>, x: number, y: number, w: number, h: number): string {
63 let inner = ''
64 let at = x
65 for (const s of ctx.segments) {
66 const sw = (s.tokens / ctx.window) * w
67 if (s.tokens > 0) inner += `<rect x="${r2(at)}" y="${r2(y)}" width="${r2(sw)}" height="${h}" fill="${HEX[s.color]}"/>`
68 at += sw + (s.tokens > 0 ? 1 : 0)
69 }
70 if (ctx.compactPct !== null) inner += `<rect class="tk" x="${r2(x + (ctx.compactPct / 100) * w - 1)}" y="${r2(y)}" width="2" height="${h}"/>`
71 return (
72 `<clipPath id="${id}"><rect x="${r2(x)}" y="${r2(y)}" width="${r2(w)}" height="${h}" rx="${h / 2}"/></clipPath>` +
73 `<g clip-path="url(#${id})"><rect class="tr" x="${r2(x)}" y="${r2(y)}" width="${r2(w)}" height="${h}"/>${inner}</g>`
74 )
75}
76
77type IconName = 'clock' | 'calendar' | 'fable' | 'flame' | 'snow' | 'empty'
78
79/** 24-unit icons: session clock, weekly calendar, Fable sparkle, cache flame / snowflake / empty. */
80function icon(name: IconName, color: string): string {
81 const s = `fill="none" stroke="${color}" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"`
82 switch (name) {
83 case 'clock':
84 return `<circle cx="12" cy="12" r="9" ${s}/><path d="M12 7v5l3.5 2" ${s}/>`
85 case 'calendar':
86 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}/>`
87 case 'fable':
88 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}"/>`
89 case 'flame':
90 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}"/>`
91 case 'snow':
92 return `<path d="M12 2.5v19M3.8 7.2l16.4 9.6M20.2 7.2L3.8 16.8" ${s}/>`
93 case 'empty':
94 return `<circle cx="12" cy="12" r="7" ${s}/>`
95 }
96}
97
98/** An arc of radius r from deg a to deg b, clockwise from 12 o'clock. */
99function arc(cx: number, cy: number, r: number, width: number, a: number, b: number, paint: string): string {
100 if (b - a <= 0) return ''
101 if (b - a >= 359.99) return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke-width="${width}" ${paint}/>`
102 const p = (deg: number) => {
103 const rad = ((deg - 90) * Math.PI) / 180
104 return `${r2(cx + r * Math.cos(rad))} ${r2(cy + r * Math.sin(rad))}`
105 }
106 return `<path d="M${p(a)} A${r} ${r} 0 ${b - a > 180 ? 1 : 0} 1 ${p(b)}" fill="none" stroke-width="${width}" ${paint}/>`
107}
108
109type RingData = { usage: number | null; time: number | null; color: string; icon: IconName }
110
111/** A ring: faint to the larger of usage and time, bright to usage, a notch at time; the icon inside. */
112function ring(cx: number, cy: number, size: 'full' | 'mini', d: RingData): string {
113 const r = size === 'full' ? 13 : 6.5
114 const w = size === 'full' ? 4 : 2.5
115 let s = `<circle class="trs" cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke-width="${w}"/>`
116 if (d.usage !== null) {
117 const u = d.usage * 3.6
118 const t = (d.time ?? 0) * 3.6
119 s += arc(cx, cy, r, w, 0, Math.max(u, t), `stroke="${d.color}" stroke-opacity="${DIM}"`)
120 s += arc(cx, cy, r, w, 0, u, `stroke="${d.color}"`)
121 if (d.time !== null && size === 'full') s += arc(cx, cy, r, w, Math.max(0, t - 2), Math.min(360, t + 2), 'class="tks"')
122 }
123 if (size === 'full') s += `<g transform="translate(${cx - 6} ${cy - 6}) scale(0.5)">${icon(d.icon, d.color)}</g>`
124 return s
125}
126
127const LIMIT_ICON = { session: 'clock', weekly: 'calendar', fable: 'fable' } as const
128const cacheIcon = (c: CacheView): IconName => (c.state === 'cold' ? 'snow' : c.state === 'empty' ? 'empty' : 'flame')
129
130const limitRing = (l: LimitView): RingData => ({ usage: l.usage, time: l.time, color: HEX[l.color], icon: LIMIT_ICON[l.kind] })
131const cacheRing = (c: CacheView): RingData => ({
132 usage: c.state === 'empty' ? null : c.warmth * 100,
133 time: null,
134 color: HEX[c.color],
135 icon: cacheIcon(c),
136})
137
138function limitTitle(l: LimitView): string {
139 if (l.usage === null) return `${l.title}: no data`
140 const time = l.time === null ? '' : `, ${pct(l.time)}% of the window gone`
141 return `${l.title}: ${pct(l.usage)}% used${time}. ${l.reset}.`
142}
143
144function cacheTitle(c: CacheView): string {
145 const hit = c.hit === null ? '' : ` Last hit rate ${pct(c.hit)}%.`
146 return `Prompt cache: ${c.label}, ${c.detail.toLowerCase()}.${hit}`
147}
148
149// ── The panel ──────────────────────────────────────────────────────────────
150
151export type PanelSvg = { svg: string; width: number; height: number; alt: string }
152
153function altOf(vm: ViewModel): string {
154 const ctx = vm.context
155 return [
156 ctx ? `Context ${fmtTokens(ctx.used)} of ${fmtTokens(ctx.window)} (${ctx.usedPct}%)` : 'Context: no reading',
157 ...vm.limits.map(limitTitle),
158 cacheTitle(vm.cache),
159 vm.notice,
160 ].join(' ')
161}
162
163/** The expanded panel at `width` px (capped at MAX_WIDTH). */
164export function panelSvg(vm: ViewModel, width: number, idPrefix = 'cc'): PanelSvg {
165 const W = Math.max(240, Math.min(MAX_WIDTH, width))
166 const narrow = W < 360
167 let y = 2
168 let out = ''
169
170 // Row 1: Context, used, the bar, where auto-compact fires.
171 const ctx = vm.context
172 if (ctx) {
173 const top = y
174 let x = 0
175 out += text(x, top, 13, [{ text: 'Context', cls: 't1', bold: true }])
176 x += textWidth('Context', 13, true) + 10
177 const used = `${fmtTokens(ctx.used)} / ${fmtTokens(ctx.window)} (${ctx.usedPct}%)`
178 out += text(x, top + 0.5, 12.5, [{ text: used, cls: 't3' }])
179 x += textWidth(used, 12.5) + 10
180 const right = ctx.compactPct === null ? '' : narrow ? `${ctx.compactPct}%` : `compacts at ${ctx.compactPct}%`
181 const barEnd = right ? W - textWidth(right, 12.5) - 10 : W
182 if (right) out += text(W, top + 0.5, 12.5, [{ text: right, cls: 't4' }], 'end')
183 out += contextBar(`${idPrefix}c`, ctx, x, top + LH(13) / 2 - 4, Math.max(40, barEnd - x), 8)
184 } else {
185 out += text(0, y, 12.5, [{ text: 'Context: waiting for the first reading', cls: 't4' }])
186 }
187 y += LH(13) + ROW_GAP
188
189 // Row 2: the legend, wrapping between items.
190 if (ctx && !narrow) {
191 const items: Run[][] = ctx.segments.map(s => [
192 { text: '●', fill: HEX[s.color] },
193 { text: ` ${s.label} ${fmtTokens(s.tokens)}`, cls: 't2' },
194 ])
195 items.push([{ text: `Free ${fmtTokens(ctx.free)}`, cls: 't4' }])
196 const widthOf = (it: Run[]) => it.reduce((w, r) => w + textWidth(r.text, 11.5), 0)
197 let x = 0
198 for (const it of items) {
199 const w = widthOf(it)
200 if (x > 0 && x + w > W) {
201 x = 0
202 y += LH(11.5) + 4
203 }
204 out += text(x, y, 11.5, it)
205 x += w + 12
206 }
207 y += LH(11.5) + ROW_GAP
208 }
209
210 // Row 3: a ring per limit and one for the cache, spread across the row; each says more on hover.
211 const c = vm.cache
212 const items = [
213 ...vm.limits.map(l => ({
214 ring: limitRing(l),
215 main: { text: l.usage === null ? '—' : `${pct(l.usage)}%`, cls: l.usage === null ? 't4' : 't1' } as Run,
216 sub: l.time === null ? (l.usage === null ? 'No data' : '') : `Time ${pct(l.time)}%`,
217 title: limitTitle(l),
218 })),
219 {
220 ring: cacheRing(c),
221 main: { text: c.label, fill: HEX[c.color] } as Run,
222 sub: c.state === 'empty' ? 'Nothing cached' : `${pct(c.warmth * 100)}% warm`,
223 title: cacheTitle(c),
224 },
225 ]
226 const twoLines = !narrow
227 // Measured with a little slack: the host's font can run wider than these metrics.
228 const widths = items.map(it => 30 + 6 + SLACK * Math.max(textWidth(it.main.text, 13, true), twoLines ? textWidth(it.sub, 10.5) : 0))
229 const step = Math.max(8, (W - widths.reduce((a, b) => a + b, 0)) / (items.length - 1))
230 const textH = twoLines ? LH(13) + LH(10.5) : LH(13)
231 const rowH = Math.max(30, textH)
232 let x = 0
233 items.forEach((it, i) => {
234 const ty = y + (rowH - textH) / 2
235 out +=
236 `<g><title>${esc(it.title)}</title>` +
237 `<rect x="${r2(x)}" y="${r2(y)}" width="${r2(widths[i] ?? 0)}" height="${rowH}" fill="transparent"/>` +
238 ring(x + 15, y + rowH / 2, 'full', it.ring) +
239 text(x + 36, ty, 13, [{ ...it.main, bold: true }]) +
240 (twoLines ? text(x + 36, ty + LH(13), 10.5, [{ text: it.sub, cls: 't3' }]) : '') +
241 `</g>`
242 x += (widths[i] ?? 0) + step
243 })
244 y += rowH + ROW_GAP
245
246 // Row 4: the notice, wrapped under a dot in the cache's colour.
247 if (vm.showNotice) {
248 const maxW = W - 15
249 const words = vm.notice.split(/(?<=\s)/)
250 const lines: string[] = []
251 let cur = ''
252 for (const w of words) {
253 if (cur && textWidth((cur + w).trimEnd(), 12.5) > maxW) {
254 lines.push(cur.trimEnd())
255 cur = w
256 } else cur += w
257 }
258 if (cur.trim()) lines.push(cur.trimEnd())
259 out += `<circle cx="3.5" cy="${r2(y + LH(12.5) / 2)}" r="3.5" fill="${HEX[c.color]}"/>`
260 lines.forEach((t, i) => {
261 out += text(15, y + i * LH(12.5), 12.5, [{ text: ellipsize(t, 12.5, maxW), cls: 't2' }])
262 })
263 y += lines.length * LH(12.5)
264 } else {
265 y -= ROW_GAP
266 }
267
268 const height = Math.ceil(y + 2)
269 const alt = altOf(vm)
270 return { svg: doc(W, height, out, alt), width: W, height, alt }
271}
272
273/** The collapsed panel: one line of context and a mini ring per limit and for the cache. */
274export function collapsedSvg(vm: ViewModel, width: number, idPrefix = 'cl'): PanelSvg {
275 const W = Math.max(200, Math.min(MAX_WIDTH, width))
276 const H = 20
277 const mid = H / 2
278 const top = (H - LH(12.5)) / 2
279 const ctx = vm.context
280 const c = vm.cache
281 const parts: { w: number; svg: (x: number) => string }[] = []
282
283 const ctxRuns: Run[] = [
284 { text: 'Context ', cls: 't1', bold: true },
285 { text: ctx ? `${ctx.usedPct}%` : '—', cls: 't2' },
286 ]
287 const ctxW = textWidth('Context ', 12.5, true) + textWidth(ctxRuns[1]!.text, 12.5)
288 parts.push({ w: ctxW, svg: x => text(x, top, 12.5, ctxRuns) })
289 if (ctx && W >= 380) parts.push({ w: 64, svg: x => contextBar(`${idPrefix}c`, ctx, x, mid - 3, 64, 6) })
290
291 const ringItem = (d: RingData, label: Run, title: string) => {
292 const w = 16 + 5 + textWidth(label.text, 12.5, true)
293 parts.push({
294 w,
295 svg: x =>
296 `<g><title>${esc(title)}</title><rect x="${r2(x)}" y="0" width="${r2(w)}" height="${H}" fill="transparent"/>` +
297 ring(x + 8, mid, 'mini', d) +
298 text(x + 21, top, 12.5, [{ ...label, bold: true }]) +
299 `</g>`,
300 })
301 }
302 for (const l of vm.limits) {
303 ringItem(limitRing(l), { text: l.usage === null ? '—' : `${pct(l.usage)}%`, cls: l.usage === null ? 't4' : 't1' }, limitTitle(l))
304 }
305 const cacheText = c.state === 'warm' || c.state === 'cooling' ? `${c.label} ${c.detail.split(' of ')[0]}` : c.label
306 ringItem(cacheRing(c), { text: cacheText, fill: HEX[c.color] }, cacheTitle(c))
307
308 const GAP = 16
309 let out = ''
310 let x = 0
311 for (const p of parts) {
312 if (x + p.w > W) break
313 out += p.svg(x)
314 x += p.w + GAP
315 }
316 const used = Math.ceil(x - GAP)
317 return { svg: doc(used, H, out, altOf(vm)), width: used, height: H, alt: altOf(vm) }
318}
319hooks/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 /** Folded to one summary line (the chevron, /cache-collapse); mirrored to $.store across sessions. */
61 isCollapsed: boolean
62 }
63 }
64}
65