SLOPSHOPPER

context-cache

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

newpanebandcommandtoastnetwork
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%) ─────────────────── │ ┃ ● System 0 ● Tools 0 ● Files 0 ● Messages ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ◷ 31% ▦ — ✦ — ♨ ⏺ Update(src/auth.ts) │ ┃ Time 80% No data No data ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ● Cache warm: next message reuses 97k from c ⎿ 3 pass, 1 fail │ ┃ [ 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 49% ────────── ◷ 31% ▦ — ✦ — ♨ Warm 60m left ▸ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Context 49% ────────── ◷ 31% ▦ — ✦ — ♨ Warm 60m left ▸ ⟨Claude Code's own drawing⟩
Pane · Context & cache
Context 97k / 200k (49%) ────────────────────────────── ● System 0 ● Tools 0 ● Files 0 ● Messages 0 Free 10… ◷ 31% ▦ — ✦ — ♨ Warm Time 80% No data No data 100% warm ● Cache warm: next message reuses 97k from cache. [ Clear ] [ Compact ]
README

context-cache

A Claude Code mod that puts your context window, your usage limits and your prompt cache above the prompt: a context bar with its legend, a ring per limit (session, weekly, Fable) and one for the cache, a notice about what the next message costs, and Clear / Compact. No card or background; text follows your light or dark theme. Hover a ring on the desktop for its reset time and details. The chevron folds it to one line.

Adapted from Christandoh/context-cache (commit 2afa4e8) by Chris Tandoh, MIT licensed. See LICENSE. Changes here: the ring layout at every width (upstream switched to bar columns above ~500 px), drawn without the card and theme-aware, a collapse chevron and /cache-collapse, hover details on the rings, and the band keeps whatever the host or other mods draw below it.

Terminal, expanded:

Context 92k / 1M (9%) ━━━━━────────────────────────────│── 92%   ▾
● System 6k  ● Tools 21k  ● Files 15k  ● Messages 50k  Free 908k

◷ 9%              ▦ 14%             ✦ 2%              ♨ Warm
  Time 12%          Time 14%          Time 14%          95% warm

● Cache warm: next message reuses 92k from cache.
[ Clear ] [ Compact ]

Collapsed:

Context 9% ━━──────│─   ◷ 9%  ▦ 14%  ✦ 2%  ♨ Warm 57m left   ▸

Install

/plugin install context-cache@arhun-plugins

If you installed the upstream copy (context-cache@chris-mods), uninstall it first so the two don't both draw.

Reading it

  • Context: tokens used out of the window, split into System, Tools, Files and Messages, with a tick where auto-compact fires.
  • Session / Weekly / Fable rings: the bright arc is how much of the limit you've used, the faint arc and the notch how much of the window's time has passed ("Time"). Each keeps its colour (green, blue, purple) while on pace, turns amber when you're well ahead of the time, red from 90%.
  • Cache ring: how much of the prompt cache's time-to-live is left (Warm above 25%, Cooling, Cold); hover for minutes left and the last hit rate.
  • The last row says what the next message will cost the cache, with Clear (/clear) and Compact beside it.

Below about 360 px the legend and the ring captions drop; a band too short for the panel shows the one-line summary.

Commands

  • /cache hides or shows the panel.
  • /cache-collapse folds it to one line or opens it again (the same as the chevron). The choice is kept across sessions.
  • /cache-pane opens the panel as a pane, on any device (how it shows on the phone).
  • /cache-refresh re-reads usage now.
  • /cache-status prints where each figure came from and writes the full report to context-cache-status.json in the working directory.

Where the numbers come from

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; when the account endpoint describes the same window, the higher reading
Fable, and reset times a header lacksthe account usage endpoint (api.anthropic.com/api/oauth/usage), called through Claude Code with the session's own login (the mod never sees the token); at session start, every 5 minutes, and on /cache-refresh
Cache TTL (5m or 60m)the cache_creation usage of the last response in the transcript, or Claude Code's report on a model switch; remembered across sessions
Last cache write, hit %each main-conversation reply as it arrives

The weekly and Fable figures rely on an endpoint Anthropic hasn't documented. If it changes, Fable shows "no data" and the rest keeps working.

Files

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