SLOPSHOPPER

context-hud

One-line usage HUD above the prompt: 5-hour session, weekly, per-model (Fable) windows and the prompt cache, each a ring beside a short line of text.

newbandcommandprocessnetworktimer
v1.1.0MITupdated 2026-10-10MiCat-S/context-hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-hud
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /hud ⎿ context-hud: Context HUD: off. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

context-hud

English | 繁體中文 | 简体中文 | 日本語

A Claude Code mod for the Claude desktop app (and the terminal): one line above the prompt with your plan's usage windows and the prompt cache, each a ring beside a short line of text.

◔ Session 22% ↻ 3h39m   ◑ Weekly 69% ↻ 7h39m   ◑ Fable 53% ↻ 7h39m   ● Cache · 59m · Hit 97%   Cost $6.04
ColumnWhat it shows
SessionThe 5-hour window: used %, countdown to its reset, ▲ full in … when your pace fills it before the reset
WeeklyThe 7-day window across all models
FableA model's own weekly window, titled as the usage card titles it
CacheThe prompt cache: minutes left of its TTL, hit rate of the last response, warmth
$The session's cost so far at API prices, as /cost totals it, at the right end

The ring is the window's use; a fainter run after it is the share of the window already gone by. Where the band is wide enough, each column adds · Time 54%, the cache its · Warm word and · Warmth 98%.

Install

In a terminal session of Claude Code:

/plugin install context-hud --marketplace MiCat-S/context-hud

Answer y to add the marketplace, then pick the user scope. The mod then runs in every session, the desktop app's included.

Commands

CommandEffect
/hudToggle the band
`/hud onoff`Show or hide it
/hud refreshRe-read everything now and report the usage API's reply and the band's width

Where the figures come from

  • Session and Weekly come with every API response ($.session.usage().rateLimits).
  • A model's weekly window (Fable) is not in the response headers. The mod asks the same usage endpoint the usage card reads, https://api.anthropic.com/api/oauth/usage, through $.session.authorize() and $.http.fetch(url, { auth }): the engine adds the credential itself, the mod never sees it. It is asked at most once a minute, after a turn or when a window moves.
  • With CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 the engine refuses that call. The mod then falls back to the engine's own last reading in ~/.claude.json (cachedUsageUtilization), shows the figure as ~53% in amber and, where there is room, how old it is.
  • Cache is read from the session transcript: the last main-thread response's cache reads and which TTL it wrote.

Development

claude plugin validate .
claude plugin test .
npx -p typescript tsc -p . --noEmit   # after the engine has loaded the mod once (it writes .claude-plugin/types)

Run it from the folder without installing: claude --plugin-dir ., or name the folder in CLAUDE_CODE_PLUGIN_DIRS for sessions the desktop app starts.

Source 4 files
hooks/register.tsx 251 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { HudLimit } from '../types'
5
6import {
7  USAGE_URL,
8  buildCells,
9  formatDuration,
10  limitTitle,
11  mergeLimits,
12  parseEngineCache,
13  parseLastRequest,
14  parseUsageLimits,
15  toLimits,
16} from './model'
17import { desktopBand, terminalBand } from './view'
18
19/** The cache countdown moves with the clock, not with state. */
20const TICK_MS = 5_000
21const DEFAULT_TTL_MS = 60 * 60_000
22/** The usage endpoint is asked at most this often, short of /hud refresh. */
23const USAGE_MIN_INTERVAL_MS = 60_000
24
25const isHidden = atom({ plugin: 'context-hud', key: 'isHidden' } as const, false)
26const limits = atom({ plugin: 'context-hud', key: 'limits' } as const, null)
27const usageLimits = atom({ plugin: 'context-hud', key: 'usageLimits' } as const, null)
28const usageStatus = atom({ plugin: 'context-hud', key: 'usageStatus' } as const, null)
29const lastRequest = atom({ plugin: 'context-hud', key: 'lastRequest' } as const, null)
30const cost = atom({ plugin: 'context-hud', key: 'cost' } as const, null)
31
32async function refreshLimits($: EngineInterface) {
33  const usage = await $.session.usage()
34  await update($, limits, () => toLimits(usage.rateLimits))
35  if (usage.cost !== undefined) {
36    const usd = usage.cost.usd
37    await update($, cost, () => usd)
38  }
39}
40
41let usageAskedAt = -Infinity
42let isAskingUsage = false
43
44/** The live reading, or why there is none. */
45async function askUsage($: EngineInterface): Promise<{ windows: HudLimit[] } | { reason: string }> {
46  try {
47    const auth = await $.session.authorize()
48    if (auth === null) {
49      return { reason: 'no first-party credential' }
50    }
51    const reply = await $.http.fetch(USAGE_URL, {
52      auth: auth.handle,
53      headers: { accept: 'application/json', 'anthropic-beta': 'oauth-2025-04-20' },
54    })
55    if (!reply.ok) {
56      return { reason: `HTTP ${reply.status}` }
57    }
58    const windows = parseUsageLimits(reply.text)
59
60    return windows.length === 0 ? { reason: 'no windows in the reply' } : { windows }
61  } catch (error) {
62    const message = error instanceof Error ? error.message : String(error)
63
64    return { reason: message.replace(/^.*?refused: /, 'refused: ') }
65  }
66}
67
68// The engine's own last reading, kept in .claude.json beside the config dir.
69async function readEngineCache($: EngineInterface) {
70  try {
71    const run = await $.process.run(['sh', '-c', 'cat "${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json"'])
72
73    return parseEngineCache(run.stdout)
74  } catch {
75    return null
76  }
77}
78
79const describe = (windows: readonly HudLimit[]) =>
80  windows.map(limit => `${limitTitle(limit)} ${Math.round(limit.percentUsed)}%`).join(' · ')
81
82// The response headers carry the session and all-models windows alone. A
83// model's weekly window (Fable) is the usage endpoint's, as the usage card
84// reads it: asked with the session's own credential, which stays with the
85// host. Refused (nonessential traffic disabled), the engine's cache stands in,
86// its age said.
87async function refreshUsage($: EngineInterface, force = false) {
88  const now = await $.clock.now()
89  if (isAskingUsage || (!force && now - usageAskedAt < USAGE_MIN_INTERVAL_MS)) {
90    return
91  }
92  isAskingUsage = true
93  usageAskedAt = now
94  try {
95    const live = await askUsage($)
96    if ('windows' in live) {
97      await update($, usageLimits, () => live.windows)
98      await update($, usageStatus, () => describe(live.windows))
99      return
100    }
101    const cached = await readEngineCache($)
102    if (cached === null) {
103      await update($, usageStatus, () => live.reason)
104      return
105    }
106    await update($, usageLimits, () => cached.windows)
107    await update($, usageStatus, () => `${live.reason}; showing the engine's cache of ${formatDuration(now - cached.fetchedAt)} ago`)
108  } finally {
109    isAskingUsage = false
110  }
111}
112
113/** The band's width as the surface last reported it, for /hud refresh. */
114let bandColumns = 0
115
116let isReadingTranscript = false
117
118// The plugin API reports no prompt-cache state, but every API response in the
119// session transcript does: when it was answered, what it read from the cache
120// and which TTL it wrote.
121async function refreshCache($: EngineInterface) {
122  if (isReadingTranscript) {
123    return
124  }
125  isReadingTranscript = true
126  try {
127    const id = await $.session.id()
128    if (!/^[0-9a-f-]{36}$/.test(id)) {
129      return
130    }
131    const run = await $.process.run([
132      'sh',
133      '-c',
134      `tail -n 300 "\${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/projects/*/${id}.jsonl 2>/dev/null | grep '"cache_creation"' | tail -n 20`,
135    ])
136    const prev = await read($, lastRequest)
137    const next = parseLastRequest(run.stdout.split('\n'), prev?.ttlMs ?? DEFAULT_TTL_MS)
138    if (next !== null) {
139      await update($, lastRequest, () => next)
140    }
141  } finally {
142    isReadingTranscript = false
143  }
144}
145
146async function refresh($: EngineInterface, force = false) {
147  await Promise.all([
148    refreshLimits($).catch(() => undefined),
149    refreshUsage($, force).catch(() => undefined),
150    refreshCache($).catch(() => undefined),
151  ])
152}
153
154export const register: Register = on => {
155  on('session.start', async ($, e, next) => {
156    await $.command.register({
157      name: 'hud',
158      description: 'Context HUD: show or hide the plan and cache columns above the prompt',
159      argumentHint: '[on|off|refresh]',
160      immediate: true,
161    })
162    $.clock.every(TICK_MS, () => $.ui.invalidate('ui.render'))
163    const started = await next(e)
164    await refresh($)
165
166    return started
167  })
168
169  // Every priced response moves the cost, so this is also when a new response
170  // has landed in the transcript; a window that moved is when the model's did too.
171  on('session.measure', async ($, e, next) => {
172    const measured = await next(e)
173    const windows = toLimits(e.rateLimits)
174    await update($, limits, () => windows)
175    if (e.cost !== undefined) {
176      const usd = e.cost.usd
177      await update($, cost, () => usd)
178    }
179    if (e.changed.includes('rateLimits')) {
180      await refreshUsage($).catch(() => undefined)
181    }
182    if (e.changed.includes('cost') || e.changed.includes('context')) {
183      await refreshCache($).catch(() => undefined)
184    }
185
186    return measured
187  })
188
189  on('turn.complete', async ($, e, next) => {
190    const completed = await next(e)
191    if (e.agentId === undefined) {
192      await Promise.all([refreshCache($).catch(() => undefined), refreshUsage($).catch(() => undefined)])
193    }
194
195    return completed
196  })
197
198  // /clear goes on under a new session id, with a transcript of its own.
199  on('session.end', async ($, e, next) => {
200    if (e.reason === 'clear') {
201      await update($, limits, () => null)
202      await update($, usageLimits, () => null)
203      await update($, lastRequest, () => null)
204      await update($, cost, () => null)
205      $.clock.after(1_000, () => void refresh($, true))
206    }
207
208    return next(e)
209  })
210
211  on('command.run', { command: 'hud' }, async ($, e) => {
212    const arg = e.args.trim().toLowerCase()
213    if (arg === 'refresh') {
214      await refresh($, true)
215      const band = bandColumns > 0 ? ` Band: ${bandColumns} columns.` : ''
216      return { text: `Context HUD refreshed. Usage API: ${(await read($, usageStatus)) ?? 'not asked'}.${band}` }
217    }
218    let hide: boolean
219    if (arg === 'on' || arg === 'off') {
220      hide = arg === 'off'
221    } else if (arg === '') {
222      hide = !(await read($, isHidden))
223    } else {
224      return { text: 'Usage: /hud [on|off|refresh]' }
225    }
226    await update($, isHidden, () => hide)
227    if (!hide) {
228      await refresh($)
229    }
230
231    return { text: `Context HUD: ${hide ? 'off' : 'on'}.` }
232  })
233
234  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
235    if (e.props.hasSurvey || (await read($, isHidden))) {
236      return next(e)
237    }
238    const windows = mergeLimits(await read($, limits), await read($, usageLimits))
239    const cells = buildCells(windows, await read($, lastRequest), await $.clock.now(), await read($, cost))
240    if (cells.length === 0) {
241      return next(e)
242    }
243    bandColumns = e.props.bodyColumns
244    if (e.surface === 'terminal') {
245      return terminalBand($.ui.resolve(e), cells, e.props.bodyColumns)
246    }
247
248    return desktopBand($.ui.resolve(e), cells, e.props.bodyColumns)
249  })
250}
251
hooks/model.ts 479 lines
1import type { SessionRateLimit } from 'claude-code'
2
3import type { HudLimit, HudRequest } from '../types'
4
5const MINUTE = 60_000
6const HOUR = 60 * MINUTE
7const DAY = 24 * HOUR
8const TTL_1H = HOUR
9const TTL_5M = 5 * MINUTE
10/** Too early in the window, one burst projects a false alarm. */
11const PACE_MIN_ELAPSED = 5 * MINUTE
12/** A reading from the engine's cache older than this says how old it is. */
13const STALE_AFTER = 10 * MINUTE
14
15/** Cells kept clear at the right of each column, between it and the next. */
16export const SLOT_GAP = 1
17/** Cells the terminal's ring takes at the left of a column's line, its space included. */
18export const RING_CELLS = 2
19/**
20 * What the desktop's ring takes, in the band's cells: the drawing and its
21 * margin, and the slack a proportional face needs over the band's count.
22 */
23export const DESKTOP_RING_CELLS = 6
24
25/** Each window's bar, as the card draws them: session green, weekly blue, a model's purple. */
26export const LIMIT_COLOR = {
27  five_hour: '#5aa65a',
28  seven_day: '#4f7fe0',
29  model: '#8a6fd6',
30  other: '#c9a23c',
31}
32
33export const CACHE_COLOR = {
34  warm: '#e0883a',
35  cooling: '#d8b13c',
36  cold: '#5b8fd9',
37}
38
39/** The bars' marks: how far a window has gone, how warm the cache still is. */
40export const MARK_COLOR = '#d0d0d0'
41export const AMBER = '#d8b13c'
42export const RED = '#e05252'
43
44/**
45 * Where the usage card reads the account's windows. The response headers
46 * carry the session and all-models windows alone; a model's weekly window
47 * (Fable) is only here, a `limits` row of kind `weekly_scoped`.
48 */
49export const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
50
51function limitRank(kind: string): number {
52  if (kind === 'five_hour') return 0
53  if (kind === 'seven_day') return 1
54  if (kind.startsWith('seven_day_')) return 2
55
56  return 3
57}
58
59export function toLimits(rateLimits: readonly SessionRateLimit[]): HudLimit[] {
60  return rateLimits
61    .map(limit => ({
62      kind: limit.kind,
63      percentUsed: limit.percentUsed,
64      ...(limit.resetsAt === undefined ? {} : { resetsAt: limit.resetsAt }),
65    }))
66    .sort((a, b) => limitRank(a.kind) - limitRank(b.kind))
67}
68
69type UsageRow = {
70  kind?: string
71  percent?: number
72  resets_at?: string | null
73  scope?: { model?: { id?: string | null; display_name?: string | null } | null } | null
74}
75
76/** The usage endpoint's body; the engine's own cache of it wraps it in `utilization`. */
77type UsageBody = { limits?: UsageRow[]; utilization?: { limits?: UsageRow[] } }
78
79const slug = (name: string) =>
80  name
81    .toLowerCase()
82    .replace(/[^a-z0-9]+/g, '_')
83    .replace(/^_+|_+$/g, '')
84
85/**
86 * The windows the usage endpoint's `limits` rows report, in the card's kinds:
87 * `session` as `five_hour`, `weekly_all` as `seven_day`, and each
88 * `weekly_scoped` row as `seven_day_<model>`, titled as the card titles it
89 * (`Fable`). Empty for a body that is not JSON or has no such rows.
90 */
91export function parseUsageLimits(text: string, asOf?: number): HudLimit[] {
92  let body: UsageBody | null
93  try {
94    body = JSON.parse(text) as UsageBody | null
95  } catch {
96    return []
97  }
98
99  return usageWindows(body, asOf)
100}
101
102function usageWindows(body: UsageBody | null, asOf?: number): HudLimit[] {
103  const rows = Array.isArray(body?.limits) ? body.limits : Array.isArray(body?.utilization?.limits) ? body.utilization.limits : []
104  const windows: HudLimit[] = []
105  for (const row of rows) {
106    if (typeof row?.kind !== 'string' || typeof row.percent !== 'number') {
107      continue
108    }
109    let kind: string
110    let title: string | undefined
111    if (row.kind === 'session') {
112      kind = 'five_hour'
113    } else if (row.kind === 'weekly_all') {
114      kind = 'seven_day'
115    } else if (row.kind === 'weekly_scoped') {
116      title = row.scope?.model?.display_name ?? row.scope?.model?.id ?? 'Model'
117      kind = `seven_day_${slug(title)}`
118    } else {
119      continue
120    }
121    windows.push({
122      kind,
123      percentUsed: row.percent,
124      ...(typeof row.resets_at === 'string' ? { resetsAt: row.resets_at } : {}),
125      ...(title === undefined ? {} : { title }),
126      ...(asOf === undefined ? {} : { asOf }),
127    })
128  }
129
130  return windows.sort((a, b) => limitRank(a.kind) - limitRank(b.kind))
131}
132
133/**
134 * The engine's own last reading of the usage endpoint, as `.claude.json` keeps
135 * it under `cachedUsageUtilization`: its windows stamped with when it was
136 * fetched. Null when the file has none. The fallback when the live call is
137 * refused, as it is while nonessential traffic is disabled.
138 */
139export function parseEngineCache(text: string): { windows: HudLimit[]; fetchedAt: number } | null {
140  let cached: { fetchedAtMs?: number; utilization?: { limits?: UsageRow[] } } | undefined
141  try {
142    cached = (JSON.parse(text) as { cachedUsageUtilization?: typeof cached } | null)?.cachedUsageUtilization
143  } catch {
144    return null
145  }
146  if (typeof cached?.fetchedAtMs !== 'number') {
147    return null
148  }
149  const windows = usageWindows(cached, cached.fetchedAtMs)
150
151  return windows.length === 0 ? null : { windows, fetchedAt: cached.fetchedAtMs }
152}
153
154/**
155 * The response headers' windows, then the usage endpoint's that they lack
156 * (a model's weekly window; all of them before the first response), in the
157 * card's order. Null before either has a reading.
158 */
159export function mergeLimits(header: readonly HudLimit[] | null, usage: readonly HudLimit[] | null): HudLimit[] | null {
160  if (header === null && usage === null) {
161    return null
162  }
163  const known = new Set((header ?? []).map(limit => limit.kind))
164
165  return [...(header ?? []), ...(usage ?? []).filter(limit => !known.has(limit.kind))].sort(
166    (a, b) => limitRank(a.kind) - limitRank(b.kind),
167  )
168}
169
170type TranscriptRow = {
171  type?: string
172  isSidechain?: boolean
173  timestamp?: string
174  message?: {
175    usage?: {
176      input_tokens?: number
177      cache_read_input_tokens?: number
178      cache_creation_input_tokens?: number
179      cache_creation?: { ephemeral_1h_input_tokens?: number; ephemeral_5m_input_tokens?: number }
180    }
181  }
182}
183
184/**
185 * The newest main-thread response among transcript lines (oldest first). A pure
186 * cache hit writes nothing, so the TTL is the newest write's; `fallbackTtlMs`
187 * when none of the lines wrote.
188 */
189export function parseLastRequest(lines: readonly string[], fallbackTtlMs: number): HudRequest | null {
190  let last: TranscriptRow | null = null
191  let ttlMs: number | null = null
192  for (let i = lines.length - 1; i >= 0 && ttlMs === null; i--) {
193    let row: TranscriptRow | null
194    try {
195      row = JSON.parse(lines[i]!) as TranscriptRow | null
196    } catch {
197      continue
198    }
199    const usage = row?.message?.usage
200    if (row?.type !== 'assistant' || row.isSidechain === true || usage === undefined) {
201      continue
202    }
203    last ??= row
204    if ((usage.cache_creation?.ephemeral_1h_input_tokens ?? 0) > 0) {
205      ttlMs = TTL_1H
206    } else if ((usage.cache_creation?.ephemeral_5m_input_tokens ?? 0) > 0) {
207      ttlMs = TTL_5M
208    }
209  }
210  const usage = last?.message?.usage
211  const at = Date.parse(last?.timestamp ?? '')
212  if (usage === undefined || Number.isNaN(at)) {
213    return null
214  }
215  const read = usage.cache_read_input_tokens ?? 0
216  const total = (usage.input_tokens ?? 0) + read + (usage.cache_creation_input_tokens ?? 0)
217
218  return { at, ttlMs: ttlMs ?? fallbackTtlMs, hitPct: total > 0 ? (read * 100) / total : 0 }
219}
220
221export function formatDuration(ms: number): string {
222  const total = Math.max(0, ms)
223  if (total >= DAY) {
224    const d = Math.floor(total / DAY)
225    const h = Math.floor((total % DAY) / HOUR)
226    return h === 0 ? `${d}d` : `${d}d${h}h`
227  }
228  if (total >= HOUR) {
229    const h = Math.floor(total / HOUR)
230    const m = Math.floor((total % HOUR) / MINUTE)
231    return m === 0 ? `${h}h` : `${h}h${m}m`
232  }
233  if (total >= MINUTE) {
234    return `${Math.floor(total / MINUTE)}m`
235  }
236
237  return `${Math.ceil(total / 1000)}s`
238}
239
240/** The countdown to a reset: `2h18m`, `5d3h`. */
241export function formatReset(resetsAt: number, now: number): string {
242  return formatDuration(resetsAt - now)
243}
244
245const capitalize = (word: string) => (word.length === 0 ? word : word[0]!.toUpperCase() + word.slice(1))
246
247function windowMsOf(kind: string): number | null {
248  if (kind === 'five_hour') return 5 * HOUR
249  if (kind === 'seven_day' || kind.startsWith('seven_day_')) return 7 * DAY
250
251  return null
252}
253
254function titleOfKind(kind: string): string {
255  if (kind === 'five_hour') return 'Session'
256  if (kind === 'seven_day') return 'Weekly'
257  if (kind.startsWith('seven_day_')) return kind.slice('seven_day_'.length).split('_').map(capitalize).join(' ')
258  if (kind === 'spend_limit') return 'Spend'
259
260  return kind.split('_').map(capitalize).join(' ')
261}
262
263/** The card's title for a window: the server's (`Fable`) when it gave one. */
264export function limitTitle(limit: HudLimit): string {
265  return limit.title ?? titleOfKind(limit.kind)
266}
267
268/** `full in 48m` when the 5-hour window fills at the pace so far before it resets. */
269export function paceWarning(limit: HudLimit, now: number): string | null {
270  if (limit.percentUsed >= 100) {
271    return 'full'
272  }
273  const resetsAt = Date.parse(limit.resetsAt ?? '')
274  if (Number.isNaN(resetsAt) || limit.percentUsed <= 0) {
275    return null
276  }
277  const left = resetsAt - now
278  const elapsed = 5 * HOUR - left
279  if (left <= 0 || elapsed < PACE_MIN_ELAPSED) {
280    return null
281  }
282  const toFull = ((100 - limit.percentUsed) * elapsed) / limit.percentUsed
283
284  return toFull < left ? `full in ${formatDuration(toFull)}` : null
285}
286
287export type BarModel = {
288  /** Filled runs from the left, in order; a shade is the same hue, fainter. */
289  parts: { share: number; color: string; isShade?: boolean }[]
290  /** A mark across the bar, 0 to 1; null for none. */
291  mark: number | null
292  /** Fill the first run with this left-to-right gradient, laid over the whole bar. */
293  gradient?: { from: string; to: string }
294}
295
296/**
297 * One run of a column's line. Pieces carry a rank: short of room the
298 * highest rank goes first; rank 0 always stays, and the line is cut at the edge.
299 */
300export type TextPiece = {
301  text: string
302  color?: string
303  isBold?: boolean
304  isDim?: boolean
305  rank?: number
306}
307
308/** One column of the card: its line of text, beside its ring where it has one (the cost has none). */
309export type Cell = { key: string; pieces: TextPiece[]; bar?: BarModel }
310
311const piece = (text: string, style: Omit<TextPiece, 'text'> = {}): TextPiece => ({ text, ...style })
312
313const clamp01 = (n: number) => Math.min(1, Math.max(0, n))
314
315function limitColor(kind: string): string {
316  if (kind === 'five_hour') return LIMIT_COLOR.five_hour
317  if (kind === 'seven_day') return LIMIT_COLOR.seven_day
318  if (kind.startsWith('seven_day_')) return LIMIT_COLOR.model
319
320  return LIMIT_COLOR.other
321}
322
323/**
324 * `Session 34% ↻ 2h18m · Time 54%` beside the window's ring: the ring is the
325 * usage, its fainter run the time gone by; the Time figure only where there
326 * is room. A figure from the engine's cache reads `~53%` in amber, its age at
327 * the end.
328 */
329function limitCell(limit: HudLimit, now: number): Cell {
330  const color = limitColor(limit.kind)
331  const percent = Math.max(0, Math.round(limit.percentUsed))
332  const used = clamp01(limit.percentUsed / 100)
333  const resetsAt = Date.parse(limit.resetsAt ?? '')
334  const windowMs = windowMsOf(limit.kind)
335  const time = Number.isNaN(resetsAt) || windowMs === null ? null : clamp01(1 - (resetsAt - now) / windowMs)
336  const staleMs = limit.asOf === undefined ? 0 : now - limit.asOf
337  const isStale = staleMs >= STALE_AFTER
338
339  const pieces = [
340    piece(limitTitle(limit), { isBold: true }),
341    piece(
342      `${isStale ? '~' : ''}${percent}%`,
343      isStale ? { color: AMBER } : percent >= 90 ? { color: RED } : percent >= 75 ? { color: AMBER } : {},
344    ),
345  ]
346  if (!Number.isNaN(resetsAt)) {
347    pieces.push(piece(`↻ ${formatReset(resetsAt, now)}`, { isDim: true }))
348  }
349  if (time !== null) {
350    pieces.push(piece(`· Time ${Math.round(time * 100)}%`, { isDim: true, rank: 2 }))
351  }
352  if (isStale) {
353    pieces.push(piece(`· ${formatDuration(staleMs)} ago`, { color: AMBER, rank: 2 }))
354  }
355  if (limit.kind === 'five_hour') {
356    const warning = paceWarning(limit, now)
357    if (warning !== null) {
358      pieces.push(piece(`▲ ${warning}`, { color: RED }))
359    }
360  }
361
362  // Used, then the stretch of the window gone by beyond it, fainter; the mark is the time.
363  const bar: BarModel = {
364    parts: [{ share: used, color }, ...(time !== null && time > used ? [{ share: time - used, color, isShade: true }] : [])],
365    mark: time,
366  }
367
368  return { key: limit.kind, pieces, bar }
369}
370
371/**
372 * `Cache · Warm · 59m · Hit 99% · Warmth 98%` beside the warmth ring. Short
373 * of room Warmth goes first (the ring says it), then the status word (the
374 * ring's color and the minutes left say it).
375 */
376function cacheCell(request: HudRequest, now: number): Cell {
377  const leftMs = Math.max(0, request.at + request.ttlMs - now)
378  const warmth = request.ttlMs > 0 ? leftMs / request.ttlMs : 0
379  const status = leftMs <= 0 ? 'cold' : warmth < 0.25 ? 'cooling' : 'warm'
380  const left = leftMs >= MINUTE || leftMs === 0 ? `${Math.floor(leftMs / MINUTE)}m` : `${Math.ceil(leftMs / 1000)}s`
381
382  return {
383    key: 'cache',
384    pieces: [
385      piece('Cache', { isBold: true }),
386      piece(`· ${capitalize(status)}`, { isBold: true, color: CACHE_COLOR[status], rank: 1 }),
387      piece(`· ${left}`, { isDim: true }),
388      piece(`· Hit ${Math.round(request.hitPct)}%`, { isDim: true }),
389      piece(`· Warmth ${Math.round(warmth * 100)}%`, { isDim: true, rank: 2 }),
390    ],
391    // The ring's fill is the warmth itself: no mark across it.
392    bar: {
393      parts: [{ share: warmth, color: CACHE_COLOR[status] }],
394      mark: null,
395      gradient: { from: CACHE_COLOR.cold, to: CACHE_COLOR.warm },
396    },
397  }
398}
399
400/** `Cost $1.23`: the session so far at API prices, as /cost totals it. */
401function costCell(usd: number): Cell {
402  return { key: 'cost', pieces: [piece('Cost', { isBold: true }), piece(formatCost(usd))] }
403}
404
405/**
406 * The columns in the card's order: the session, the week, a model's week,
407 * the cache, then the cost, which stands only beside the others.
408 */
409export function buildCells(
410  limits: readonly HudLimit[] | null,
411  request: HudRequest | null,
412  now: number,
413  costUsd: number | null = null,
414): Cell[] {
415  const windows = limits ?? []
416  const pick = (match: (kind: string) => boolean) => windows.find(limit => match(limit.kind))
417  const cells = [
418    pick(kind => kind === 'five_hour'),
419    pick(kind => kind === 'seven_day'),
420    pick(kind => kind !== 'five_hour' && kind !== 'seven_day'),
421  ].flatMap(limit => (limit === undefined ? [] : [limitCell(limit, now)]))
422  if (request !== null) {
423    cells.push(cacheCell(request, now))
424  }
425  if (costUsd !== null && cells.length > 0) {
426    cells.push(costCell(costUsd))
427  }
428
429  return cells
430}
431
432/** How many character cells a column's line takes, a space between pieces. */
433export function lineWidth(pieces: readonly TextPiece[]): number {
434  return pieces.reduce((sum, one) => sum + [...one.text].length, 0) + Math.max(0, pieces.length - 1)
435}
436
437const keepBelow = (pieces: readonly TextPiece[], rank: number) => pieces.filter(one => (one.rank ?? 0) < rank)
438
439/** The session's cost at API prices, as /cost totals it: `$1.23`. */
440export const formatCost = (usd: number) => `$${Math.max(0, usd).toFixed(2)}`
441
442/** The terminal's ring: a circle filled by quarters with the window's use. */
443export function ringGlyph(bar: BarModel): string {
444  const used = bar.parts.filter(part => part.isShade !== true).reduce((sum, part) => sum + part.share, 0)
445  if (used <= 0) return '○'
446  if (used < 0.375) return '◔'
447  if (used < 0.625) return '◑'
448  if (used < 0.875) return '◕'
449
450  return '●'
451}
452
453/**
454 * The columns fitted to the band as a whole: each takes its own width, with
455 * its ring and a gap, and when together they run over, every column drops
456 * its highest-ranked pieces at once, then the next rank, so they read alike.
457 * What still runs over is cut at the band's edge.
458 */
459export function fitCells(cells: readonly Cell[], width: number, ringCells = RING_CELLS): Cell[] {
460  const budget = width - cells.reduce((sum, cell) => sum + SLOT_GAP + (cell.bar === undefined ? 0 : ringCells), 0)
461  const total = (from: number) => cells.reduce((sum, cell) => sum + lineWidth(keepBelow(cell.pieces, from)), 0)
462  const ranks = [...new Set(cells.flatMap(cell => cell.pieces.map(one => one.rank ?? 0)))]
463    .filter(rank => rank > 0)
464    .sort((a, b) => b - a)
465  let from = Infinity
466  for (const rank of ranks) {
467    if (total(from) <= budget) {
468      break
469    }
470    from = rank
471  }
472
473  return cells.map(cell => ({ ...cell, pieces: keepBelow(cell.pieces, from) }))
474}
475
476export function lineText(pieces: readonly TextPiece[]): string {
477  return pieces.map(one => one.text).join(' ')
478}
479
hooks/view.tsx 122 lines
1import type { ElementTable } from 'claude-code'
2
3import { DESKTOP_RING_CELLS, RING_CELLS, SLOT_GAP, fitCells, ringGlyph } from './model'
4import type { BarModel, Cell } from './model'
5
6type Basic = Pick<ElementTable<'terminal'>, 'Box' | 'Text'>
7type WithSvg = Pick<ElementTable<'desktop'>, 'Box' | 'Text' | 'Svg'>
8
9/** The ring's side, in CSS pixels: about a text line's height. */
10const RING_PX = 16
11const RING_VIEW = 20
12const RING_R = 7
13const RING_STROKE = 3
14const RING_ROUND = 2 * Math.PI * RING_R
15/** When the band reports no width of its own. */
16const FALLBACK_COLUMNS = 120
17
18/**
19 * A column's line: its pieces side by side, none of them shrinking or
20 * wrapping, so a line too long for its column is cut at the edge, never
21 * continued under it.
22 */
23function line({ Box, Text }: Basic, cell: Cell) {
24  return (
25    <Box flexDirection="row" flexWrap="nowrap" overflow="hidden">
26      {cell.pieces.map((one, i) => (
27        <Box flexShrink={0}>
28          <Text
29            {...(one.isBold === true ? { bold: true } : {})}
30            {...(one.isDim === true ? { dimColor: true } : {})}
31            {...(one.color === undefined ? {} : { color: one.color })}
32          >
33            {i === 0 ? one.text : ` ${one.text}`}
34          </Text>
35        </Box>
36      ))}
37    </Box>
38  )
39}
40
41/**
42 * The card's columns spread evenly across one row, each as wide as its ring
43 * and line. Short of room the cache column alone gives way, cut at its edge,
44 * so the windows and the cost stay whole.
45 */
46function columns(els: Basic, cells: Cell[], width: number, ringCells: number, drawRing: (bar: BarModel) => JSX.Element) {
47  const { Box } = els
48
49  return (
50    <Box flexDirection="row" flexWrap="nowrap" justifyContent="space-between" overflow="hidden">
51      {fitCells(cells, width, ringCells).map(cell => (
52        <Box
53          paddingRight={SLOT_GAP}
54          flexDirection="row"
55          flexWrap="nowrap"
56          flexShrink={cell.key === 'cache' ? 1 : 0}
57          alignItems="center"
58          overflow="hidden"
59        >
60          {cell.bar === undefined ? null : drawRing(cell.bar)}
61          {line(els, cell)}
62        </Box>
63      ))}
64    </Box>
65  )
66}
67
68const widthOf = (bodyColumns: number) =>
69  Number.isFinite(bodyColumns) && bodyColumns > 0 ? bodyColumns : FALLBACK_COLUMNS
70
71const usedShare = (bar: BarModel) =>
72  bar.parts.filter(part => part.isShade !== true).reduce((sum, part) => sum + part.share, 0)
73
74// Desktop, editor and phone: each ring a donut a text line tall.
75
76/** A ring filled clockwise from the top by the bar's runs, round-ended, over a faint track. */
77function ringSvg(bar: BarModel): string {
78  const c = RING_VIEW / 2
79  const arcs: string[] = []
80  let from = 0
81  for (const part of bar.parts) {
82    const share = Math.max(0, Math.min(1 - from, part.share))
83    if (share > 0) {
84      const shade = part.isShade === true ? ' stroke-opacity="0.3"' : ''
85      arcs.push(
86        `<circle cx="${c}" cy="${c}" r="${RING_R}" fill="none" stroke="${part.color}" stroke-width="${RING_STROKE}"${shade}` +
87          ` stroke-linecap="round" stroke-dasharray="${(share * RING_ROUND).toFixed(2)} ${RING_ROUND.toFixed(2)}"` +
88          ` stroke-dashoffset="${(-from * RING_ROUND).toFixed(2)}" transform="rotate(-90 ${c} ${c})"/>`,
89      )
90      from += share
91    }
92  }
93
94  return (
95    `<svg xmlns="http://www.w3.org/2000/svg" width="${RING_PX}" height="${RING_PX}" viewBox="0 0 ${RING_VIEW} ${RING_VIEW}">` +
96    `<circle cx="${c}" cy="${c}" r="${RING_R}" fill="none" stroke="#8f8f8f" stroke-opacity="0.22" stroke-width="${RING_STROKE}"/>` +
97    `${arcs.join('')}</svg>`
98  )
99}
100
101export function desktopBand(els: WithSvg, cells: Cell[], bodyColumns: number) {
102  const { Box, Svg } = els
103
104  return columns(els, cells, widthOf(bodyColumns), DESKTOP_RING_CELLS, bar => (
105    <Box marginRight={1} flexShrink={0}>
106      <Svg source={ringSvg(bar)} alt={`${Math.round(usedShare(bar) * 100)}%`} width={RING_PX} height={RING_PX} />
107    </Box>
108  ))
109}
110
111// Terminal: each ring a circle glyph filled by quarters, in the window's color.
112
113export function terminalBand(els: Basic, cells: Cell[], bodyColumns: number) {
114  const { Box, Text } = els
115
116  return columns(els, cells, widthOf(bodyColumns), RING_CELLS, bar => (
117    <Box flexShrink={0}>
118      <Text color={bar.parts[0]?.color ?? 'inactive'}>{`${ringGlyph(bar)} `}</Text>
119    </Box>
120  ))
121}
122
types/index.d.ts 37 lines
1export type HudLimit = {
2  kind: string
3  percentUsed: number
4  resetsAt?: string
5  /** The card's title when the server gave one: `Fable` for a model's weekly window. */
6  title?: string
7  /** When the figure was read, in ms since the epoch, when it is not live: the engine's cache. */
8  asOf?: number
9}
10
11/** The last main-thread API response, as the session transcript records it. */
12export type HudRequest = {
13  /** When it was answered, in ms since the epoch. */
14  at: number
15  /** How long the prompt cache it wrote lives: 1 hour or 5 minutes. */
16  ttlMs: number
17  /** Share of its input the prompt cache served, 0 to 100. */
18  hitPct: number
19}
20
21declare module 'claude-code' {
22  interface PluginState {
23    'context-hud': {
24      isHidden: boolean
25      /** The response headers' windows: five_hour, seven_day; null before a reading. */
26      limits: HudLimit[] | null
27      /** The usage endpoint's windows, a model's weekly one (seven_day_fable) included; null before a reading. */
28      usageLimits: HudLimit[] | null
29      /** How the last usage endpoint call went, as /hud refresh reports it; null before one. */
30      usageStatus: string | null
31      lastRequest: HudRequest | null
32      /** The session's cost so far in USD at API prices, as the engine totals it; null before a reading. */
33      cost: number | null
34    }
35  }
36}
37