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.

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
| Column | What it shows |
|---|---|
| Session | The 5-hour window: used %, countdown to its reset, ▲ full in … when your pace fills it before the reset |
| Weekly | The 7-day window across all models |
| Fable | A model's own weekly window, titled as the usage card titles it |
| Cache | The 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%.
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.
| Command | Effect | |
|---|---|---|
/hud | Toggle the band | |
| `/hud on | off` | Show or hide it |
/hud refresh | Re-read everything now and report the usage API's reply and the band's width |
$.session.usage().rateLimits).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.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.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.
hooks/register.tsx 251 lines1import { 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}
251hooks/model.ts 479 lines1import 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}
479hooks/view.tsx 122 lines1import 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}
122types/index.d.ts 37 lines1export 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