SLOPSHOPPER

context-cache

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

newpanebandcommandtoastnetwork
★ 1v0.1.5MITupdated 2026-10-09Christandoh/context-cache
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-cache
│ ┃ Context & cache ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ Context 97k / 200k (49%) ━━━━━━━━━━━━━━━━━ ⏺ Read(src/auth.ts) │ ┃ │ ● System 0 ● Tools 0 ● Files 0 ● ⎿ Read 6 lines │ ┃ │ Messages 0 Free 103k ⏺ Update(src/auth.ts) │ ┃ │ ◷ 31% ▦ — ✦ — ♨ Warm ⎿ Added 2 lines, removed 1 line │ ┃ │ Time 80% 100% warm ⏺ Bash(bun test) │ ┃ │ ● Cache warm: next message reuses 97k ⎿ 3 pass, 1 fail │ ┃ │ from cache. │ ┃ │ [ Clear ] [ Compact ] ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰─────────────────────────────────────────── │ ✻ Worked for 42s · done 4:20 PM │ │ › /cache │ ⎿ context-cache: Context & cache panel hidden. /cache shows it aga │ │ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Context 97k / 200k (49%) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 49% │ │ ● System 0 ● Tools 0 ● Files 0 ● Messages 0 Free 103k │ │ ◷ 31% ▦ — ✦ — ♨ Warm │ │ Time 80% 100% warm │ │ ● Cache warm: next message reuses 97k from cache. │ │ [ Clear ] [ Compact ] │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Context 97k / 200k (49%) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 49% │ │ ● System 0 ● Tools 0 ● Files 0 ● Messages 0 Free 103k │ │ ◷ 31% ▦ — ✦ — ♨ Warm │ │ Time 80% 100% warm │ │ ● Cache warm: next message reuses 97k from cache. │ │ [ Clear ] [ Compact ] │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Pane · Context & cache
╭──────────────────────────────────────────────────────────╮ │ Context 97k / 200k (49%) ━━━━━━━━━━━━━━━━━━━━━━━ 49% │ │ ● System 0 ● Tools 0 ● Files 0 ● Messages 0 Free │ │ 103k │ │ ◷ 31% ▦ — ✦ — ♨ Warm │ │ Time 80% 100% warm │ │ ● Cache warm: next message reuses 97k from cache. │ │ [ Clear ] [ Compact ] │ ╰──────────────────────────────────────────────────────────╯
README

Context-cache

version installs stars license Claude Code plugin

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.

The panel in the desktop app, live data

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.

Why

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.

What's on the card

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.

900 px, nothing cached yet

Four widths

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.

900 px, a cooling cache

620 px, two limits in the red and an auto-compact warning

420 px, rings

320 px, a cold cache

Install

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.

Commands

/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.

Where the numbers come from

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.

FigureSource
Context used, window, auto-compact point$.session.usage(), the same figures as the status line and /context
System / Tools / Messages splitthe /context breakdown, scaled to the real token count
Filesthe share of Messages that is Read output, by character count
Session and weekly limitsthe 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 lacksAnthropic'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 writeeach 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.

Working on it

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.

Licence

MIT. See LICENSE.

Installs

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%">

Source 6 files
hooks/register.tsx 588 lines
1// 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}
588
hooks/model.ts 511 lines
1// 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}
511
hooks/view.tsx 312 lines
1// 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}
312
hooks/panel-svg.ts 436 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
436
hooks/metrics.ts 35 lines
1// 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}
35
types/index.d.ts 65 lines
1// 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