Live session HUD: context, rate-limit pace, per-request and per-turn cost, cache countdown and miss detection, compaction advisor, dashboard pane and a budget…

A Claude Code mod port of statusline/, on steroids.
The shell status line re-runs a script on every render and only sees the JSON Claude Code hands it. As a mod, the HUD runs inside the session: it sees every model request as it completes (subagents included), keeps a live ledger, and can act: toasts, a dashboard pane, a one-key /compact, and a tool the model can call to check its own budget.
Mods are an early-access Claude Code API (function hooks). The API may change between releases; this mod was built and tested on Claude Code 2.1.294.
At the prompt of a terminal session:
/plugin install statusline-hud --marketplace rquintino/claude-code-xtras
Answer y to add the marketplace, then pick a scope (user is the usual). Hooks start in that session right away.
To run it from a clone instead, for one session:
claude --plugin-dir ./mods/statusline-hud
You can keep your script status line, or drop statusLine from your settings: the band carries everything it showed except the PR badge (see Gaps).
Terminal:
ctx: 15% █░░░░░░░ [150.0k/1.0M] · Opus 5.5 [high] · 5h:██████░░ 80%·3h00m proj:200% ⚠ 100% in 30m · 7d:██░░░░░░ 20%·5d0h proj:70%
sess: ⎇ main ↑1 ✎1 · +12/-4 · example-project · cost:$1.50 · ⏱ 30m · cache 1h warm 59m
Σ in: 2.0k cached:150.0k wr: 18.0k out: 4.0k ≈ $0.26 ████████████████████ · hit 88% · 1 miss $0.60 · agents 1 running $0.04 · today $4.12 · 7d $23.80
last in: 1.0k cached:140.0k wr: 9.0k out: 2.0k ≈ $0.14 ████████████████████ · ctx ▁▂
cmp warm (cold in 59m) cost $0.35 saves $0.02/req pays back in 16 req 150.0k→~40.0k
● Ubuntu 24.04 · 🕐 14:32 · v2.1.294 dashboard hide
Desktop (same session, minus what the Code tab already shows):
sess: cost:$1.50 · ⏱ 30m · cache 1h warm 59m · 5h pace →200% ⚠ 100% in 30m · 7d pace →70%
Σ in: 2.0k cached:150.0k wr: 18.0k out: 4.0k ≈ $0.26 ████████████████████ · hit 88% · 1 miss $0.60 · agents 1 running $0.04 · today $4.12 · 7d $23.80
last in: 1.0k cached:140.0k wr: 9.0k out: 2.0k ≈ $0.14 ████████████████████ · ctx ▁▂
cmp warm (cold in 59m) cost $0.35 saves $0.02/req pays back in 16 req 150.0k→~40.0k dashboard hide
| Row | What it shows | New vs the script |
|---|---|---|
| 1 | context fill, model + effort, 5h / 7d windows with reset countdown and end-of-window projection | ⚠ 100% in Xm: when the current pace exhausts a window before it resets |
| 2 | branch, ahead/behind, changed files, working-tree +/-, folder, engine cost, session time | cache countdown (warm/cold, TTL-aware); git ahead/behind and dirty count |
| 3 | Σ tokens per category, ≈$ at list price, stacked cost bar | cache hit %, unexpected misses, subagents (running now + spend), today / 7-day spend across sessions |
| 4 | last request's tokens and ≈$ | context sparkline per request |
| 5 | compaction advisor: cost, saving per request, payback, cold-cache verdict | [ Compact now ] (hotkey c) when it pays back within 10 requests or the cache is cold and compacting is cheaper |
| 6 | OS, clock, Claude Code version | dashboard / hide buttons |
Focus the band with ctrl+x tab (or a click) to use the hotkeys.
Desktop's Code tab already draws some of these figures, so on the desktop surface the band and the pane leave them out. Desktop has the usage ring (context and plan usage), the model and effort pickers, the branch, the +12 -1 diff stats and the PR/CI bar, and the OS draws the clock.
| On Desktop the HUD drops | It keeps (Desktop doesn't show these) |
|---|---|
model + effort, context bar, 5h/7d meters, branch/ahead/dirty, +/-, folder, OS/clock/version row | rate-limit pace (5h pace →200% ⚠ 100% in 30m), cost, session time, cache countdown, Σ/last breakdown, misses, subagents, daily spend, compaction advisor |
The band shrinks to 4 rows there (2 → 1 in compact density). The pane swaps "Rate limits" for "Rate-limit pace" and drops the context bar but keeps the per-request sparkline. $.ui.status (display status/both) follows the same rule when no terminal is attached. The table lives in hooks/present.ts (NATIVE), so another surface is one line.
Baking… $0.26 · 2 req while the turn runs, subagents included.Baked for 1m 4s · $0.42 · 7 req · ctx +18.0k./compact, auto-compact or button press toasts Compacted 300.0k → 40.0k tokens (−87%) for $0.07, and the pane lists the last three./hud command| Command | Does |
|---|---|
/hud | toggle the band |
/hud pane | open the dashboard pane |
/hud status | print a plain-text report into the transcript |
/hud reset | zero this session's ledger |
Context bar + per-request sparkline, spend by category with share %, burn rate ($/h), $/request sparkline, today / 7-day spend across sessions, budget meter, rate-limit windows with pace and time-to-100%, cache TTL, warmth and misses, compaction advice and log, turn count/avg/max with duration and $/turn sparklines, running subagents, top tools by call count. Buttons: Compact now (c), Copy report (y), Close (x).
mcp__statusline-hud__usage (deferred behind ToolSearch, so it costs nothing until used) returns the same report as /hud status. Ask Claude to "check your budget before starting" and it can decide to compact or delegate on real numbers.
/config (or /plugin configure statusline-hud@claude-code-xtras):
| Option | Values | Default |
|---|---|---|
display | band, status (one pinned line), both | band |
density | full (6 rows), compact (2 rows) | full |
cacheTtl | auto, 5m, 1h | auto |
budgetUsd | number, 0 = off | 0 |
alerts | toasts on/off | true |
turnCost | cost beside the spinner and on each turn's closing line | true |
auto TTL follows Claude Code's documented precedence: FORCE_PROMPT_CACHING_5M, then CLAUDE_CODE_PROMPT_CACHE_TTL, then ENABLE_PROMPT_CACHING_1H, then 1h for a subscription (OAuth) sign-in within plan usage and 5m otherwise. The promptCacheTtl setting isn't visible to the mod: if you use it, set cacheTtl to match.
/cost shows). ≈$ is the mod's list-price estimate from each response's token counts, so the two can differ: the engine also counts helper requests (titles, compaction).hooks/calc.ts. Includes Sonnet 5.5 (0.05x cache reads) and Haiku 5.5 (priced by prompt length).C current context, B the session's first request (what survives /compact), S = B + 20k. Warm: cost C·r + 8k·o + (S−B)·w, saving (C−S)·r per request. Cold: compacting vs re-writing C at w.--continue/--resume. Daily totals (list price, all sessions on the machine) keep 35 days.gh on a timer.speed isn't in the mod's usage figures, so fast-mode requests are estimated at standard rates.claude plugin validate ./mods/statusline-hud # what it hooks and calls, what the engine would refuse
claude plugin test ./mods/statusline-hud # 30 tests: math, per-surface rows, and hooks on terminal, desktop, vscode, mobile
Files: hooks/calc.ts (pure math), hooks/present.ts (what each surface shows, the report), hooks/register.tsx (hooks and drawing), types/index.d.ts ($.state contract), tests/.
hooks/register.tsx 627 lines1// statusline-hud: a Claude Code mod port of statusline/statusline-command.sh, plus what a
2// script status line can't do: live per-request accounting, subagent spend, rate-limit
3// burn ETAs, cache-cold countdown and miss detection, per-turn cost, a one-key /compact,
4// toasts, a dashboard pane and a tool the model can call to check its own budget.
5// Surface-aware: on Claude Code Desktop it leaves out what the Code tab already shows.
6
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, PluginOptions, Register } from 'claude-code'
9
10import type { HudCompaction, HudLedger, HudRate, HudView } from '../types'
11import { bar, cacheState, fmtDur, fmtTok, fmtUsd, outlook, parseGit, spark, spendOf, TTL_MS, WINDOW_MS, type Ttl } from './calc'
12import {
13 adviceFor,
14 adviceText,
15 allUsd,
16 bandRows,
17 compactLine,
18 costBar,
19 hitRatio,
20 missCause,
21 pctColor,
22 projColor,
23 report,
24 shouldCompact,
25 shows,
26 totals,
27 turnSoFar,
28 turnTail,
29 type Seg,
30 type Surface,
31} from './present'
32
33const PANE = 'hud'
34const TICK_MS = 15_000
35const HISTORY = 60
36const KEEP_SESSIONS = 40
37const KEEP_DAYS = 35
38
39const EMPTY: HudLedger = {
40 reqs: 0, in: 0, rd: 0, wr: 0, out: 0, usdIn: 0, usdRd: 0, usdWr: 0, usdOut: 0,
41 agentReqs: 0, agentUsd: 0, ctxHistory: [], usdHistory: [], tools: {}, turns: [],
42 misses: 0, missUsd: 0, compactions: [], savedUsd: 0,
43}
44
45const view = atom({ plugin: 'statusline-hud', key: 'view' } as const, null)
46const ledger = atom({ plugin: 'statusline-hud', key: 'ledger' } as const, EMPTY)
47const isHidden = atom({ plugin: 'statusline-hud', key: 'isHidden' } as const, false)
48const fired = atom({ plugin: 'statusline-hud', key: 'fired' } as const, [])
49const turn = atom({ plugin: 'statusline-hud', key: 'turn' } as const, null)
50
51type Opts = { display: string; density: string; cacheTtl: string; budgetUsd: number; alerts: boolean; turnCost: boolean }
52
53function readOptions(o: PluginOptions): Opts {
54 return {
55 display: typeof o.display === 'string' ? o.display : 'band',
56 density: typeof o.density === 'string' ? o.density : 'full',
57 cacheTtl: typeof o.cacheTtl === 'string' ? o.cacheTtl : 'auto',
58 budgetUsd: typeof o.budgetUsd === 'number' ? o.budgetUsd : 0,
59 alerts: typeof o.alerts === 'boolean' ? o.alerts : true,
60 turnCost: typeof o.turnCost === 'boolean' ? o.turnCost : true,
61 }
62}
63
64const push = <T,>(list: readonly T[], v: T) => [...list, v].slice(-HISTORY)
65
66/** Local calendar day, the key of the cross-session spend totals. */
67function dayKey(ms: number): string {
68 const d = new Date(ms)
69 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
70}
71
72function clockOf(ms: number): string {
73 const d = new Date(ms)
74 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
75}
76
77/** Which TTL the main conversation's cache writes get, by the precedence Claude Code documents. */
78async function resolveTtl($: EngineInterface, opts: Opts, auth: HudView['auth'], rates: readonly HudRate[]): Promise<Ttl> {
79 if (opts.cacheTtl === '5m' || opts.cacheTtl === '1h') return opts.cacheTtl
80 if ((await $.env.get('FORCE_PROMPT_CACHING_5M')) === '1') return '5m'
81 const pinned = await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL')
82 if (pinned === '5m' || pinned === '1h') return pinned
83 if ((await $.env.get('ENABLE_PROMPT_CACHING_1H')) === '1') return '1h'
84 // One hour only on a subscription (OAuth sign-in) within plan usage; past it, usage credits get 5m.
85 if (auth !== 'bearer' || rates.some(r => r.pct >= 100)) return '5m'
86 return '1h'
87}
88
89/** Subagents, workflows, forks and compaction: five minutes unless pinned. */
90async function resolveAgentTtl($: EngineInterface): Promise<Ttl> {
91 if ((await $.env.get('FORCE_PROMPT_CACHING_5M')) === '1') return '5m'
92 const pinned = await $.env.get('CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL')
93 if (pinned === '5m' || pinned === '1h') return pinned
94 return (await $.env.get('ENABLE_PROMPT_CACHING_1H')) === '1' ? '1h' : '5m'
95}
96
97async function osName($: EngineInterface): Promise<string> {
98 const wsl = await $.env.get('WSL_DISTRO_NAME')
99 if (wsl) return `WSL2 (${wsl})`
100 if ((await $.env.get('OS')) === 'Windows_NT') return 'Windows'
101 const uname = await $.process.run(['uname', '-s']).catch(() => undefined)
102 const kernel = uname?.stdout.trim() ?? ''
103 if (kernel === 'Darwin') {
104 const sw = await $.process.run(['sw_vers', '-productVersion']).catch(() => undefined)
105 return `macOS ${sw?.stdout.trim() ?? ''}`.trim()
106 }
107 const release = await $.fs.read('/etc/os-release').catch(() => '')
108 const pretty = /^PRETTY_NAME="?([^"\n]*)"?/m.exec(typeof release === 'string' ? release : '')?.[1]
109 return pretty || kernel || 'unknown OS'
110}
111
112async function readGit($: EngineInterface): Promise<HudView['git']> {
113 const status = await $.process.run(['git', 'status', '--porcelain=v2', '--branch'], { timeoutMs: 5000 }).catch(() => undefined)
114 if (!status || status.exitCode !== 0) return undefined
115 const stat = await $.process.run(['git', 'diff', '--shortstat', 'HEAD'], { timeoutMs: 5000 }).catch(() => undefined)
116 return parseGit(status.stdout, stat?.exitCode === 0 ? stat.stdout : '')
117}
118
119/** Subagents still at work, as the engine tracks them. */
120async function agentsRunning($: EngineInterface): Promise<number> {
121 const agents = await $.agent.list().catch(() => [])
122 return agents.filter(a => a.status === 'pending' || a.status === 'running' || a.status === 'waiting').length
123}
124
125/** List-price spend today and over the last 7 days, across this machine's sessions. */
126async function readSpend($: EngineInterface, now: number): Promise<HudView['spend']> {
127 const days = ((await $.store.get('days')) as Record<string, number> | undefined) ?? {}
128 let week = 0
129 for (let i = 0; i < 7; i++) week += days[dayKey(now - i * 86_400_000)] ?? 0
130 const today = days[dayKey(now)] ?? 0
131 return week > 0 ? { today, week } : undefined
132}
133
134/** The surface the one-line texts are written for: the terminal when one draws, else the first. */
135async function lineSurface($: EngineInterface): Promise<Surface> {
136 const all = await $.session.surfaces()
137 return all.includes('terminal') || all.length === 0 ? 'terminal' : all[0]!
138}
139
140/** Re-reads the engine's figures into `view`; git too when asked (it spawns processes). */
141async function refresh($: EngineInterface, opts: Opts, withGit: boolean): Promise<void> {
142 const [usage, model, now, cwd, running] = await Promise.all([
143 $.session.usage(),
144 $.session.model(),
145 $.clock.now(),
146 $.session.cwd(),
147 agentsRunning($),
148 ])
149 const rates: HudRate[] = usage.rateLimits.map(r => ({
150 kind: r.kind,
151 pct: r.percentUsed,
152 resetsAt: r.resetsAt ? Date.parse(r.resetsAt) : undefined,
153 }))
154 const prior = await read($, view)
155 const auth = prior?.auth ?? (await $.session.authorize().then(a => (a ? a.kind : 'none')).catch(() => 'none' as const))
156 const ttl = await resolveTtl($, opts, auth, rates)
157 const git = withGit ? await readGit($) : undefined
158 const spend = await readSpend($, now)
159 await update($, view, prev => ({
160 now,
161 model,
162 effort: prev?.effort,
163 ctxTokens: usage.context.tokens,
164 ctxWindow: usage.context.window,
165 ctxPct: usage.context.percent,
166 rates,
167 costUsd: usage.cost?.usd,
168 startedAt: usage.startedAt,
169 ttl,
170 auth,
171 git: withGit ? git : prev?.git,
172 cwdLeaf: cwd.split(/[\\/]/).filter(Boolean).pop(),
173 os: prev?.os,
174 version: prev?.version,
175 agentsRunning: running,
176 spend,
177 }))
178 if (opts.display !== 'band') await pushStatus($)
179 if (opts.alerts) await alert($, opts)
180}
181
182async function pushStatus($: EngineInterface): Promise<void> {
183 const [v, l] = await Promise.all([read($, view), read($, ledger)])
184 if (v) $.ui.status(compactLine(v, l, await lineSurface($)))
185}
186
187/** Toasts each crossing once per session. */
188async function alert($: EngineInterface, opts: Opts): Promise<void> {
189 const [v, l, seen] = await Promise.all([read($, view), read($, ledger), read($, fired)])
190 if (!v) return
191 const now = v.now
192 const due: [string, string][] = []
193 const ctx = v.ctxPct ?? 0
194 if (ctx >= 90) due.push(['ctx90', `Context ${ctx}% full: auto-compact is close. /compact at a natural break.`])
195 else if (ctx >= 80) due.push(['ctx80', `Context ${ctx}% full. Consider /compact between tasks.`])
196 if ((v.ctxTokens ?? 0) > 200_000) due.push(['cliff', 'Context past 200k tokens: long-context recall degrades.'])
197 for (const r of v.rates) {
198 const o = outlook(r.pct, r.resetsAt, WINDOW_MS[r.kind], now)
199 if (o.hitsLimitInMs !== undefined && o.resetsInMs !== undefined && r.pct < 100)
200 due.push([`pace:${r.kind}:${r.resetsAt}`, `${r.kind} limit: at this pace you hit 100% in ~${fmtDur(o.hitsLimitInMs)} (resets in ${fmtDur(o.resetsInMs)}).`])
201 }
202 if (opts.budgetUsd > 0 && (v.costUsd ?? 0) >= opts.budgetUsd)
203 due.push(['budget', `Session cost ${fmtUsd(v.costUsd ?? 0)} passed your ${fmtUsd(opts.budgetUsd)} budget.`])
204 const cache = cacheState(l.last?.at, v.ttl, now)
205 if (l.last && (v.ctxTokens ?? 0) >= 60_000) {
206 const rewrite = spendOf({ in: 0, rd: 0, wr: v.ctxTokens ?? 0, out: 0 }, v.model, v.ttl).total
207 if (cache.warm && cache.leftMs <= 60_000)
208 due.push([`cooling:${l.last.at}`, `Prompt cache goes cold in ${fmtDur(cache.leftMs)}: the next prompt after that re-writes ${fmtTok(v.ctxTokens ?? 0)} tokens (~${fmtUsd(rewrite)}).`])
209 else if (!cache.warm) {
210 const advice = adviceFor(v, l)
211 const tip = advice.kind === 'cold' && advice.netNowUsd > 0 ? ` /compact first is ~${fmtUsd(advice.netNowUsd)} cheaper.` : ''
212 due.push([`cold:${l.last.at}`, `Prompt cache went cold: the next prompt re-writes ${fmtTok(v.ctxTokens ?? 0)} tokens (~${fmtUsd(rewrite)}).${tip}`])
213 }
214 }
215 const fresh = due.filter(([key]) => !seen.includes(key))
216 if (fresh.length === 0) return
217 await update($, fired, list => [...list, ...fresh.map(([key]) => key)].slice(-200))
218 for (const [, text] of fresh) $.ui.toast(text, { timeoutMs: 8000 })
219}
220
221async function storeKey($: EngineInterface): Promise<string> {
222 return `ledger:${await $.session.id()}`
223}
224
225/**
226 * Keeps the ledger across reloads and resumes (newest sessions only, so the store stays small)
227 * and adds what was priced since the last save to today's cross-session total.
228 */
229async function saveLedger($: EngineInterface): Promise<void> {
230 const [l, now] = await Promise.all([read($, ledger), $.clock.now()])
231 const spent = allUsd(l)
232 if (spent > l.savedUsd) {
233 const days = ((await $.store.get('days')) as Record<string, number> | undefined) ?? {}
234 const today = dayKey(now)
235 days[today] = (days[today] ?? 0) + (spent - l.savedUsd)
236 const keep = Object.keys(days).sort().slice(-KEEP_DAYS)
237 await $.store.set('days', Object.fromEntries(keep.map(k => [k, days[k]!])))
238 }
239 const saved = await update($, ledger, x => ({ ...x, savedUsd: spent }))
240 const key = await storeKey($)
241 await $.store.set(key, saved)
242 const index = ((await $.store.get('ledgers')) as string[] | undefined) ?? []
243 const kept = [...index.filter(k => k !== key), key]
244 for (const old of kept.slice(0, -KEEP_SESSIONS)) await $.store.delete(old)
245 await $.store.set('ledgers', kept.slice(-KEEP_SESSIONS))
246}
247
248async function compactNow($: EngineInterface): Promise<void> {
249 try {
250 const r = await $.session.compact()
251 if (r.skip !== undefined) $.ui.toast('Compaction skipped by a hook.')
252 } catch {
253 $.ui.toast('Compaction runs between turns: try again when the turn ends.')
254 }
255}
256
257type Usage = { input_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number; output_tokens: number }
258
259/** Logs a main-conversation compaction: before/after, what the summarizer cost. */
260async function recordCompaction($: EngineInterface, opts: Opts, c: Omit<HudCompaction, 'usd' | 'at'>, usage: Usage | undefined): Promise<void> {
261 const [v, at] = await Promise.all([read($, view), $.clock.now()])
262 const usd = usage
263 ? spendOf(
264 { in: usage.input_tokens, rd: usage.cache_read_input_tokens, wr: usage.cache_creation_input_tokens, out: usage.output_tokens },
265 v?.model ?? '',
266 await resolveAgentTtl($),
267 ).total
268 : 0
269 await update($, ledger, l => ({ ...l, compactions: push(l.compactions, { ...c, usd, at }), compactedAt: at }))
270 if (opts.alerts && c.before !== undefined && c.after !== undefined && c.before > 0) {
271 const cut = Math.round((1 - c.after / c.before) * 100)
272 $.ui.toast(`Compacted ${fmtTok(c.before)} → ${fmtTok(c.after)} tokens (−${cut}%) for ${fmtUsd(usd)}.`, { timeoutMs: 6000 })
273 }
274}
275
276/** Books one main-loop response: ledger, the running turn, and a toast for an unexpected cache miss. */
277async function bookMain($: EngineInterface, opts: Opts, model: string, effort: string | undefined, t: { in: number; rd: number; wr: number; out: number }): Promise<void> {
278 const [v, l, at] = await Promise.all([read($, view), read($, ledger), $.clock.now()])
279 const ttl = v?.ttl ?? '5m'
280 const s = spendOf(t, model, ttl)
281 const ctx = t.in + t.rd + t.wr
282 const cause = missCause({ prev: l.last, cur: { model, rd: t.rd, wr: t.wr }, ttlMs: TTL_MS[ttl], now: at, compactedAt: l.compactedAt })
283 await update($, ledger, x => ({
284 ...x,
285 reqs: x.reqs + 1,
286 in: x.in + t.in, rd: x.rd + t.rd, wr: x.wr + t.wr, out: x.out + t.out,
287 usdIn: x.usdIn + s.in, usdRd: x.usdRd + s.rd, usdWr: x.usdWr + s.wr, usdOut: x.usdOut + s.out,
288 baseCtx: x.baseCtx ?? ctx,
289 last: { model, ...t, usd: s.total, at, effort },
290 ctxHistory: push(x.ctxHistory, ctx),
291 usdHistory: push(x.usdHistory, s.total),
292 misses: x.misses + (cause ? 1 : 0),
293 missUsd: x.missUsd + (cause ? s.wr : 0),
294 }))
295 await update($, turn, cur => (cur ? { ...cur, usd: cur.usd + s.total, reqs: cur.reqs + 1 } : cur))
296 await update($, view, prev => (prev ? { ...prev, effort } : prev))
297 if (cause && opts.alerts)
298 $.ui.toast(`Cache miss: re-wrote ${fmtTok(t.wr)} tokens (~${fmtUsd(s.wr)}) while the cache was warm. Likely cause: ${cause}.`, { timeoutMs: 8000 })
299}
300
301export const register: Register = (on, options) => {
302 const opts = readOptions(options)
303 const showBand = opts.display !== 'status'
304
305 on('session.start', async ($, e, next) => {
306 await $.command.register({
307 name: 'hud',
308 description: 'Status HUD: toggle the band, open the dashboard, print a report',
309 argumentHint: '[pane | status | reset]',
310 })
311 await $.tool.register({
312 name: 'usage',
313 description:
314 'Live budget for this Claude Code session: context fill, rate-limit windows with pace projections, ' +
315 'cost, prompt-cache warmth and misses, and whether /compact pays off now. Check before starting a large task.',
316 })
317 const saved = (await $.store.get(await storeKey($))) as Partial<HudLedger> | undefined
318 if (saved && typeof saved.reqs === 'number') await update($, ledger, () => ({ ...EMPTY, ...saved }))
319 const [os, version] = await Promise.all([osName($), $.session.version()])
320 await refresh($, opts, true)
321 await update($, view, prev => (prev ? { ...prev, os, version: version.version } : prev))
322 let ticks = 0
323 $.clock.every(TICK_MS, () => {
324 ticks += 1
325 void refresh($, opts, ticks % 4 === 0).catch(() => undefined)
326 })
327 return next(e)
328 })
329
330 on('session.end', async ($, e, next) => {
331 if (e.reason === 'clear') {
332 await update($, ledger, () => EMPTY)
333 await update($, fired, () => [])
334 await update($, turn, () => null)
335 }
336 return next(e)
337 })
338
339 on('turn.start', async ($, e, next) => {
340 const v = await read($, view)
341 await update($, turn, () => ({ id: e.turnId, usd: 0, reqs: 0, startCtx: v?.ctxTokens }))
342 return next(e)
343 })
344
345 // Every model request, main loop and subagents: the per-request ledger a script status line can't keep.
346 on('turn.step', async function* ($, e, next) {
347 const result = yield* next(e)
348 const usage = result.usage
349 if (!usage) return result
350 try {
351 const t = { in: usage.input_tokens, rd: usage.cache_read_input_tokens, wr: usage.cache_creation_input_tokens, out: usage.output_tokens }
352 if (e.agentId !== undefined) {
353 const s = spendOf(t, usage.model, await resolveAgentTtl($))
354 await update($, ledger, l => ({ ...l, agentReqs: l.agentReqs + 1, agentUsd: l.agentUsd + s.total }))
355 await update($, turn, cur => (cur ? { ...cur, usd: cur.usd + s.total } : cur))
356 return result
357 }
358 await bookMain($, opts, usage.model, e.effort === undefined ? undefined : String(e.effort), t)
359 await refresh($, opts, false)
360 } catch {
361 // bookkeeping never gets in the way of the response
362 }
363 return result
364 })
365
366 on('tool.call', async ($, e, next) => {
367 const name = String(e.tool)
368 await update($, ledger, l => ({ ...l, tools: { ...l.tools, [name]: (l.tools[name] ?? 0) + 1 } }))
369 return next(e)
370 }).catch(($, e, next) => next(e)) // a counter, not a guard: never stands in a call's way
371
372 on('turn.complete', async ($, e, next) => {
373 if (e.agentId === undefined) {
374 await refresh($, opts, true)
375 const [cur, v] = await Promise.all([read($, turn), read($, view)])
376 const ctxDelta = cur?.startCtx !== undefined && v?.ctxTokens !== undefined ? v.ctxTokens - cur.startCtx : 0
377 await update($, ledger, l => ({ ...l, turns: push(l.turns, { durationMs: e.durationMs, usd: cur?.usd ?? 0, reqs: cur?.reqs ?? 0, ctxDelta }) }))
378 await update($, turn, () => null)
379 await saveLedger($)
380 }
381 return next(e)
382 })
383
384 // Observes compactions (the person's /compact, auto-compact, the HUD's button) without steering them.
385 on('session.compact', async ($, e, next) => {
386 const r = await next(e)
387 if (e.agentId === undefined && r.skip === undefined)
388 await recordCompaction($, opts, { before: r.tokensBefore, after: r.tokensAfter, trigger: String(e.trigger) }, r.usage)
389 return r
390 }).catch(($, e, next) => next(e))
391
392 on('tool.call', { tool: 'mcp__statusline-hud__usage' }, async $ => {
393 await refresh($, opts, false)
394 const [v, l] = await Promise.all([read($, view), read($, ledger)])
395 return { result: v ? report(v, l) : 'No usage figures yet.' }
396 }).catch(() => ({ result: 'Usage figures are unavailable right now.' }))
397
398 on('command.run', { command: 'hud' }, async ($, e) => {
399 const arg = e.args.trim()
400 if (arg === 'pane') {
401 const opened = await $.ui.open({ id: PANE, title: 'Session HUD' })
402 return { text: opened.isPlaced ? 'HUD dashboard opened.' : 'HUD dashboard queued: widen the terminal to place it.' }
403 }
404 if (arg === 'status') {
405 await refresh($, opts, true)
406 const [v, l] = await Promise.all([read($, view), read($, ledger)])
407 return { text: v ? report(v, l) : 'No usage figures yet.' }
408 }
409 if (arg === 'reset') {
410 await update($, ledger, () => EMPTY)
411 await update($, fired, () => [])
412 return { text: 'HUD ledger reset for this session.' }
413 }
414 const hidden = await update($, isHidden, h => !h)
415 return { text: hidden ? 'HUD band hidden (/hud to show).' : 'HUD band shown.' }
416 })
417
418 // ---------- drawing ----------
419
420 type Els = ReturnType<EngineInterface['ui']['resolve']>
421
422 function Row(els: Els, segs: readonly Seg[]) {
423 const { Box, Text } = els
424 return (
425 <Box flexDirection="row" flexWrap="wrap">
426 {segs.map(s => (
427 <Text color={s.c} dimColor={s.dim} bold={s.bold}>
428 {s.t}
429 </Text>
430 ))}
431 </Box>
432 )
433 }
434
435 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
436 if (!showBand || e.props.hasSurvey) return next(e)
437 const [v, l, hidden] = await Promise.all([read($, view), read($, ledger), read($, isHidden)])
438 if (!v || hidden) return next(e)
439 const els = $.ui.resolve(e)
440 const { Box, Button } = els
441 const rows = bandRows(v, l, {
442 surface: e.surface,
443 density: opts.density,
444 budgetUsd: opts.budgetUsd,
445 narrow: e.props.bodyColumns < 110,
446 clock: clockOf(v.now),
447 })
448 const compact = shouldCompact(adviceFor(v, l)) && !e.props.isWorking
449 const last = rows.length - 1
450 return (
451 <Box flexDirection="column">
452 {rows.map((segs, i) => {
453 const isAdvice = segs[0]?.t.startsWith('cmp') === true
454 const isLast = i === last
455 if (!(isAdvice && compact) && !isLast) return Row(els, segs)
456 return (
457 <Box flexDirection="row" gap={1}>
458 {Row(els, segs)}
459 {isAdvice && compact && <Button key="compact" label="Compact now" hotkey="c" variant="primary" onPress={() => compactNow($)} />}
460 {isLast && <Button key="pane" label="dashboard" hotkey="d" plain onPress={() => void $.ui.open({ id: PANE, title: 'Session HUD' })} />}
461 {isLast && <Button key="hide" label="hide" hotkey="h" plain onPress={() => update($, isHidden, () => true)} />}
462 </Box>
463 )
464 })}
465 </Box>
466 )
467 })
468
469 // While a turn runs: what it has cost so far, beside the engine's own elapsed time and tokens.
470 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
471 if (!opts.turnCost) return next(e)
472 const text = turnSoFar(await read($, turn))
473 return text ? next({ ...e, props: { ...e.props, suffix: `${e.props.suffix} ${text}` } }) : next(e)
474 })
475
476 // The line that closes a turn (terminal only; Desktop draws its own footer): what the turn cost.
477 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
478 if (!opts.turnCost) return next(e)
479 const done = (await read($, ledger)).turns.findLast(t => t.durationMs === e.props.durationMs)
480 if (!done || done.reqs === 0) return next(e)
481 const { Box, Text } = $.ui.resolve(e)
482 return (
483 <Box flexDirection="row">
484 {await next(e)}
485 <Text dimColor> · {turnTail(done)}</Text>
486 </Box>
487 )
488 })
489
490 // With the band hidden or compact, the hint line under the prompt carries the essentials (terminal draws `tail`).
491 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
492 if (e.surface !== 'terminal' || e.props.isDraft || opts.display !== 'band') return next(e)
493 const [v, l, hidden] = await Promise.all([read($, view), read($, ledger), read($, isHidden)])
494 if (!v || (!hidden && opts.density === 'full')) return next(e)
495 return next({ ...e, props: { ...e.props, tail: compactLine(v, l, 'terminal') } })
496 })
497
498 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
499 const els = $.ui.resolve(e)
500 const { Box, Text, Button } = els
501 const [v, l] = await Promise.all([read($, view), read($, ledger)])
502 if (!v) return <Text dimColor>No usage figures yet: send a prompt.</Text>
503 const surface = e.surface
504 const now = v.now
505 const w = Math.max(20, Math.min(60, e.props.bodyColumns - 24))
506 const t = totals(l)
507 const hit = hitRatio(l)
508 const cache = cacheState(l.last?.at, v.ttl, now)
509 const advice = adviceFor(v, l)
510 const hours = v.startedAt ? (now - v.startedAt) / 3_600_000 : 0
511 const H = (title: string) => (
512 <Text bold color="cyan">
513 {title}
514 </Text>
515 )
516 const share = (x: number) => (t.total > 0 ? `${Math.round((x / t.total) * 100)}%`.padStart(4) : '')
517 const topTools = Object.entries(l.tools).sort((a, b) => b[1] - a[1]).slice(0, 8)
518 const durations = l.turns.map(x => x.durationMs)
519 const avgTurn = durations.length ? durations.reduce((a, b) => a + b, 0) / durations.length : 0
520 const turnUsd = l.turns.map(x => x.usd)
521 const paceRows = v.rates
522 .map(r => ({ r, o: outlook(r.pct, r.resetsAt, WINDOW_MS[r.kind], now) }))
523 .filter(({ o }) => shows(surface, 'planUsage') || o.projected !== undefined)
524
525 return (
526 <Box flexDirection="column" gap={1}>
527 <Box flexDirection="column">
528 {H('Context')}
529 {shows(surface, 'context') &&
530 Row(els, [{ t: bar(v.ctxPct ?? 0, w), c: pctColor(v.ctxPct ?? 0) }, { t: ` ${v.ctxPct ?? '--'}% ${fmtTok(v.ctxTokens ?? 0)} / ${fmtTok(v.ctxWindow)}` }])}
531 {l.ctxHistory.length > 1 && Row(els, [{ t: 'per request ', dim: true }, { t: spark(l.ctxHistory, w, v.ctxWindow), c: 'cyan' }])}
532 {Row(els, [{ t: `kept by /compact ≈ ${fmtTok(l.baseCtx ?? 0)} (system prompt + tools)`, dim: true }])}
533 </Box>
534 <Box flexDirection="column">
535 {H('Spend')}
536 {Row(els, [
537 { t: 'engine total ', dim: true },
538 { t: v.costUsd !== undefined ? fmtUsd(v.costUsd) : '?' },
539 { t: ' list-price estimate ', dim: true },
540 { t: fmtUsd(allUsd(l)) },
541 ...(hours > 0.05 ? [{ t: ` burn ${fmtUsd(allUsd(l) / hours)}/h`, dim: true }] : []),
542 ])}
543 {Row(els, [{ t: `in ${fmtTok(l.in).padStart(7)} ${fmtUsd(t.in).padStart(9)} ${share(t.in)}`, c: 'yellow' }])}
544 {Row(els, [{ t: `cached ${fmtTok(l.rd).padStart(7)} ${fmtUsd(t.rd).padStart(9)} ${share(t.rd)}`, c: 'green' }])}
545 {Row(els, [{ t: `write ${fmtTok(l.wr).padStart(7)} ${fmtUsd(t.wr).padStart(9)} ${share(t.wr)}`, c: 'red' }])}
546 {Row(els, [{ t: `out ${fmtTok(l.out).padStart(7)} ${fmtUsd(t.out).padStart(9)} ${share(t.out)}`, c: 'magenta' }])}
547 {Row(els, costBar(t, w))}
548 {l.usdHistory.length > 1 && Row(els, [{ t: '$/request ', dim: true }, { t: spark(l.usdHistory, w), c: 'magenta' }])}
549 {Row(els, [
550 {
551 t:
552 `${l.reqs} requests` +
553 (hit !== undefined ? ` · cache hit ${Math.round(hit * 100)}%` : '') +
554 (l.agentReqs ? ` · subagents ${fmtUsd(l.agentUsd)} over ${l.agentReqs} requests` : '') +
555 (v.agentsRunning ? ` (${v.agentsRunning} running)` : ''),
556 dim: true,
557 },
558 ])}
559 {v.spend && Row(els, [{ t: `today ${fmtUsd(v.spend.today)} · last 7 days ${fmtUsd(v.spend.week)} (all sessions, list price)`, dim: true }])}
560 {opts.budgetUsd > 0 &&
561 Row(els, [
562 { t: 'budget ', dim: true },
563 { t: bar(((v.costUsd ?? 0) / opts.budgetUsd) * 100, w), c: pctColor(((v.costUsd ?? 0) / opts.budgetUsd) * 100) },
564 { t: ` ${fmtUsd(opts.budgetUsd)}` },
565 ])}
566 </Box>
567 {paceRows.length > 0 && (
568 <Box flexDirection="column">
569 {H(shows(surface, 'planUsage') ? 'Rate limits' : 'Rate-limit pace')}
570 {paceRows.map(({ r, o }) =>
571 Row(els, [
572 { t: `${r.kind.padEnd(10)} `, dim: true },
573 ...(shows(surface, 'planUsage') ? [{ t: `${bar(r.pct, Math.min(w, 24))} ${r.pct}%`, c: pctColor(r.pct) }] : []),
574 ...(o.resetsInMs !== undefined ? [{ t: ` resets ${fmtDur(o.resetsInMs)}`, dim: true }] : []),
575 ...(o.projected !== undefined ? [{ t: ' pace → ', dim: true }, { t: `${o.projected}%`, c: projColor(o.projected) }] : []),
576 ...(o.hitsLimitInMs !== undefined && r.pct < 100 ? [{ t: ` 100% in ${fmtDur(o.hitsLimitInMs)}`, c: 'error' }] : []),
577 ]),
578 )}
579 </Box>
580 )}
581 <Box flexDirection="column">
582 {H('Prompt cache & compaction')}
583 {Row(els, [
584 { t: `TTL ${v.ttl} · `, dim: true },
585 l.last ? (cache.warm ? { t: `warm, cold in ${fmtDur(cache.leftMs)}`, c: 'success' } : { t: 'cold: next request re-writes the context', c: 'warning' }) : { t: 'no request yet', dim: true },
586 ...(l.misses > 0 ? [{ t: ` · ${l.misses} unexpected miss${l.misses > 1 ? 'es' : ''} ~${fmtUsd(l.missUsd)}`, c: 'warning' }] : []),
587 ])}
588 {Row(els, [{ t: adviceText(advice), c: shouldCompact(advice) ? 'success' : undefined }])}
589 {l.compactions.slice(-3).map(c =>
590 Row(els, [
591 { t: `compacted ${clockOf(c.at)} (${c.trigger}) `, dim: true },
592 { t: c.before !== undefined && c.after !== undefined ? `${fmtTok(c.before)} → ${fmtTok(c.after)} ` : '' },
593 { t: fmtUsd(c.usd), dim: true },
594 ]),
595 )}
596 </Box>
597 {(topTools.length > 0 || l.turns.length > 0) && (
598 <Box flexDirection="column">
599 {H('Activity')}
600 {l.turns.length > 0 &&
601 Row(els, [
602 { t: `${l.turns.length} turns · avg ${fmtDur(avgTurn)} · max ${fmtDur(Math.max(...durations))} `, dim: true },
603 { t: spark(durations, Math.min(w, 30)), c: 'cyan' },
604 ])}
605 {turnUsd.some(x => x > 0) &&
606 Row(els, [{ t: `$/turn avg ${fmtUsd(turnUsd.reduce((a, b) => a + b, 0) / turnUsd.length)} `, dim: true }, { t: spark(turnUsd, Math.min(w, 30)), c: 'magenta' }])}
607 {topTools.length > 0 && Row(els, [{ t: topTools.map(([n, c]) => `${n.replace(/^mcp__/, '')} ${c}`).join(' · '), dim: true }])}
608 </Box>
609 )}
610 <Box flexDirection="row" gap={1}>
611 <Button key="compact" label="Compact now" hotkey="c" variant={shouldCompact(advice) ? 'primary' : 'secondary'} onPress={() => compactNow($)} />
612 <Button
613 key="copy"
614 label="Copy report"
615 hotkey="y"
616 onPress={async press => {
617 const r = await $.ui.copy({ text: report(v, l), surface: press.surface })
618 $.ui.toast(r.isCopied ? 'HUD report copied.' : 'Copy not available here.')
619 }}
620 />
621 <Button key="close" label="Close" hotkey="x" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
622 </Box>
623 </Box>
624 )
625 })
626}
627hooks/calc.ts 217 lines1// Pure math for the HUD: pricing, formatting, projections, compaction advice.
2// No `$` here, so every function is unit-tested directly (tests/calc.test.ts).
3
4export type Ttl = '5m' | '1h'
5
6/** $/MTok: input, 5m cache write, 1h cache write, cache read, output. */
7export type Price = { i: number; w5: number; w1: number; r: number; o: number }
8
9/** One API response's token counts, as the API reports them. */
10export type Tokens = { in: number; rd: number; wr: number; out: number }
11
12/** What one response cost per category, in USD. */
13export type Spend = { in: number; rd: number; wr: number; out: number; total: number }
14
15// Source: platform.claude.com/docs/en/about-claude/pricing (checked 2026-10-08).
16// Order matters: more specific ids first ('opus-5-5' before 'opus-5', 'sonnet-5-5' before 'sonnet-5').
17const PRICES: ReadonlyArray<readonly [RegExp, Price]> = [
18 [/opus-5-5/, { i: 4, w5: 5, w1: 8, r: 0.2, o: 20 }],
19 [/(fable|mythos)-5-1/, { i: 10, w5: 12.5, w1: 20, r: 0.25, o: 50 }],
20 [/(fable|mythos)-5/, { i: 10, w5: 12.5, w1: 20, r: 1, o: 50 }],
21 [/opus-5|opus-4-[5-9]/, { i: 5, w5: 6.25, w1: 10, r: 0.5, o: 25 }],
22 [/opus-4/, { i: 15, w5: 18.75, w1: 30, r: 1.5, o: 75 }],
23 [/sonnet-5-5/, { i: 2, w5: 2.5, w1: 4, r: 0.1, o: 10 }],
24 [/sonnet-5/, { i: 2, w5: 2.5, w1: 4, r: 0.2, o: 10 }],
25 [/sonnet-4/, { i: 3, w5: 3.75, w1: 6, r: 0.3, o: 15 }],
26 [/haiku-4/, { i: 1, w5: 1.25, w1: 2, r: 0.1, o: 5 }],
27 [/haiku-3-5/, { i: 0.8, w5: 1, w1: 1.6, r: 0.08, o: 4 }],
28]
29const HAIKU_55_SHORT: Price = { i: 0.1, w5: 0.125, w1: 0.2, r: 0.01, o: 0.5 }
30const HAIKU_55_LONG: Price = { i: 0.5, w5: 0.625, w1: 1, r: 0.05, o: 2.5 }
31const DEFAULT_PRICE: Price = { i: 5, w5: 6.25, w1: 10, r: 0.5, o: 25 }
32
33/** List price for a model id. Haiku 5.5 is priced by prompt length (over 100k tokens costs more). */
34export function priceOf(model: string, promptTokens = 0): Price {
35 if (/haiku-5-5/.test(model)) return promptTokens > 100_000 ? HAIKU_55_LONG : HAIKU_55_SHORT
36 for (const [re, price] of PRICES) if (re.test(model)) return price
37 return DEFAULT_PRICE
38}
39
40/** Cost of one response. The API reports cache writes as one total, priced at the TTL in force. */
41export function spendOf(t: Tokens, model: string, ttl: Ttl): Spend {
42 const p = priceOf(model, t.in + t.rd + t.wr)
43 const w = ttl === '1h' ? p.w1 : p.w5
44 const s = { in: (t.in * p.i) / 1e6, rd: (t.rd * p.r) / 1e6, wr: (t.wr * w) / 1e6, out: (t.out * p.o) / 1e6 }
45 return { ...s, total: s.in + s.rd + s.wr + s.out }
46}
47
48// ---------- formatting ----------
49
50/** 1234 -> "1.2k", 1234567 -> "1.2M". */
51export function fmtTok(n: number): string {
52 if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`
53 if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`
54 return String(Math.round(n))
55}
56
57/** "$0.004", "$1.23", "$1,234.50". Sub-cent amounts keep three decimals so they don't read as zero. */
58export function fmtUsd(x: number): string {
59 if (x > 0 && x < 0.001) return "<$0.001"
60 if (x > 0 && x < 0.01) return `$${x.toFixed(3)}`
61 const [int = '0', frac = '00'] = x.toFixed(2).split('.')
62 return `$${int.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}.${frac}`
63}
64
65/** Compact duration: "45s", "12m", "3h12m", "2d3h". */
66export function fmtDur(ms: number): string {
67 const s = Math.max(0, Math.floor(ms / 1000))
68 if (s < 60) return `${s}s`
69 const m = Math.floor(s / 60)
70 if (m < 60) return `${m}m`
71 const h = Math.floor(m / 60)
72 if (h < 24) return `${h}h${String(m % 60).padStart(2, '0')}m`
73 return `${Math.floor(h / 24)}d${h % 24}h`
74}
75
76/** Filled/empty meter: bar(50, 8) -> "████░░░░". */
77export function bar(pct: number, width: number): string {
78 const filled = Math.min(width, Math.max(0, Math.round((pct * width) / 100)))
79 return '█'.repeat(filled) + '░'.repeat(width - filled)
80}
81
82const SPARKS = '▁▂▃▄▅▆▇█'
83
84/** Unicode sparkline of the last `width` values, scaled to their own max (or `max` when given). */
85export function spark(values: readonly number[], width: number, max?: number): string {
86 const tail = values.slice(-width)
87 const top = max ?? Math.max(0, ...tail)
88 if (tail.length === 0 || top <= 0) return ''
89 return tail.map(v => SPARKS[Math.min(7, Math.max(0, Math.round((v / top) * 7)))]).join('')
90}
91
92/**
93 * Stacked cost-share bar: each category's width is proportional to its $, any non-zero
94 * category gets at least one cell, remainder goes to the largest fractional parts.
95 */
96export function costSegments(s: Spend, width: number): { in: number; rd: number; wr: number; out: number } {
97 const keys = ['in', 'rd', 'wr', 'out'] as const
98 const tot = s.in + s.rd + s.wr + s.out
99 const n = { in: 0, rd: 0, wr: 0, out: 0 }
100 if (tot <= 0) return n
101 const raw = { in: (s.in / tot) * width, rd: (s.rd / tot) * width, wr: (s.wr / tot) * width, out: (s.out / tot) * width }
102 for (const k of keys) n[k] = s[k] > 0 ? Math.max(1, Math.floor(raw[k])) : 0
103 let used = n.in + n.rd + n.wr + n.out
104 const byFrac = keys.filter(k => s[k] > 0).sort((a, b) => (raw[b] % 1) - (raw[a] % 1))
105 for (let i = 0; used < width && byFrac.length > 0; i++, used++) n[byFrac[i % byFrac.length]!] += 1
106 for (const k of ['out', 'wr', 'rd', 'in'] as const) while (used > width && n[k] > 1) (n[k] -= 1), (used -= 1)
107 return n
108}
109
110// ---------- rate-limit windows ----------
111
112export const WINDOW_MS: Record<string, number> = { five_hour: 5 * 3600_000, seven_day: 7 * 86400_000 }
113
114export type WindowOutlook = {
115 /** Linear projection of usage % at the window's reset. */
116 projected?: number
117 /** At the current average pace, how long until 100%; absent when the pace stays under it. */
118 hitsLimitInMs?: number
119 /** Time until the window resets. */
120 resetsInMs?: number
121}
122
123/**
124 * Projects a rate-limit window from its % used and reset time, assuming the average pace so
125 * far continues. Too early in the window (first 5%) to say anything useful -> projection omitted.
126 */
127export function outlook(pct: number, resetsAtMs: number | undefined, windowMs: number | undefined, now: number): WindowOutlook {
128 if (resetsAtMs === undefined || Number.isNaN(resetsAtMs)) return {}
129 const remaining = resetsAtMs - now
130 if (remaining <= 0) return {}
131 if (windowMs === undefined) return { resetsInMs: remaining }
132 const elapsed = windowMs - remaining
133 if (elapsed <= windowMs * 0.05 || pct <= 0) return { resetsInMs: remaining }
134 const projected = Math.round(pct / (elapsed / windowMs))
135 const toFull = (elapsed * 100) / pct - elapsed
136 return { projected, resetsInMs: remaining, hitsLimitInMs: pct >= 100 ? 0 : toFull < remaining ? toFull : undefined }
137}
138
139// ---------- prompt cache ----------
140
141export const TTL_MS: Record<Ttl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
142
143/** Whether the main conversation's cache is still warm, and for how long. */
144export function cacheState(lastRequestAt: number | undefined, ttl: Ttl, now: number): { warm: boolean; leftMs: number } {
145 if (lastRequestAt === undefined) return { warm: false, leftMs: 0 }
146 const leftMs = lastRequestAt + TTL_MS[ttl] - now
147 return { warm: leftMs > 0, leftMs: Math.max(0, leftMs) }
148}
149
150// ---------- compaction advisor ----------
151// Counted per API REQUEST (an agentic prompt fans out into many, each re-reading the full context).
152// C = current context, B = prefix that survives compaction (≈ first request of the session),
153// S = estimated post-compact context = B + summary + re-attached files/skills.
154// Warm: upfront U = C*r + O*o + (S-B)*w; saving per later request D = (C-S)*r; payback N = U/D.
155// Cold: the next request re-writes C at w anyway; compacting instead costs C*w5 + O*o + S*w.
156// Sources: code.claude.com/docs/en/prompt-caching (#compacting-the-conversation, #cache-lifetime).
157
158export const CMP = { summaryOut: 8000, reattach: 20000, baseDefault: 20000, minCtx: 60000 } as const
159
160export type Advice =
161 | { kind: 'fresh' }
162 | { kind: 'small'; ctx: number }
163 | { kind: 'warm'; ctx: number; after: number; costUsd: number; savesPerReq: number; paybackReqs: number; cliff: boolean; nearAuto: boolean }
164 | { kind: 'cold'; ctx: number; after: number; netNowUsd: number; savesPerReq: number; cliff: boolean; nearAuto: boolean }
165
166export function advise(args: { ctx?: number; base?: number; window: number; model: string; ttl: Ttl; warm: boolean }): Advice {
167 const C = args.ctx
168 if (C === undefined || C <= 0) return { kind: 'fresh' }
169 const B = args.base && args.base > 0 ? args.base : CMP.baseDefault
170 const S = B + CMP.reattach
171 if (C < CMP.minCtx || C <= S) return { kind: 'small', ctx: C }
172 const p = priceOf(args.model, C)
173 const w = args.ttl === '1h' ? p.w1 : p.w5
174 const savesPerReq = ((C - S) * p.r) / 1e6
175 const cliff = C > 200_000
176 const nearAuto = args.window > 0 && C / args.window >= 0.85
177 if (!args.warm) {
178 const netNowUsd = (C * w - (C * p.w5 + CMP.summaryOut * p.o + S * w)) / 1e6
179 return { kind: 'cold', ctx: C, after: S, netNowUsd, savesPerReq, cliff, nearAuto }
180 }
181 const costUsd = (C * p.r + CMP.summaryOut * p.o + (S - B) * w) / 1e6
182 const paybackReqs = Math.ceil(costUsd / savesPerReq)
183 return { kind: 'warm', ctx: C, after: S, costUsd, savesPerReq, paybackReqs, cliff, nearAuto }
184}
185
186// ---------- git ----------
187
188export type Git = { branch?: string; ahead: number; behind: number; changed: number; added: number; removed: number }
189
190/** Parses `git status --porcelain=v2 --branch` and `git diff --shortstat HEAD` output. */
191export function parseGit(status: string, shortstat: string): Git {
192 const g: Git = { ahead: 0, behind: 0, changed: 0, added: 0, removed: 0 }
193 let oid = ''
194 for (const line of status.split('\n')) {
195 if (line.startsWith('# branch.head ')) g.branch = line.slice(14).trim()
196 else if (line.startsWith('# branch.oid ')) oid = line.slice(13).trim()
197 else if (line.startsWith('# branch.ab ')) {
198 const m = /\+(\d+) -(\d+)/.exec(line)
199 if (m) (g.ahead = Number(m[1])), (g.behind = Number(m[2]))
200 } else if (line.trim() !== '' && !line.startsWith('#')) g.changed += 1
201 }
202 if (g.branch === '(detached)') g.branch = oid.slice(0, 7) || undefined
203 g.added = Number(/(\d+) insertion/.exec(shortstat)?.[1] ?? 0)
204 g.removed = Number(/(\d+) deletion/.exec(shortstat)?.[1] ?? 0)
205 return g
206}
207
208/** "claude-opus-5-5[1m]" -> "Opus 5.5"; unknown ids pass through. */
209export function modelLabel(id: string): string {
210 const m = /(opus|sonnet|haiku|fable|mythos)-(\d+)(?:-(\d+))?(?!\d)/i.exec(id)
211 if (!m) return id
212 const name = m[1]!.charAt(0).toUpperCase() + m[1]!.slice(1).toLowerCase()
213 // a trailing date stamp (claude-sonnet-4-20250514) is not a minor version
214 const minor = m[3] && m[3].length <= 2 ? `.${m[3]}` : ''
215 return `${name} ${m[2]}${minor}`
216}
217hooks/present.ts 363 lines1// What the HUD says, as plain data: rows of colored segments per surface, the report, the
2// advisor's verdict. No `$` and no JSX here, so the per-surface choices are unit-tested.
3
4import type { HudLedger, HudView } from '../types'
5import {
6 advise,
7 bar,
8 cacheState,
9 costSegments,
10 fmtDur,
11 fmtTok,
12 fmtUsd,
13 modelLabel,
14 outlook,
15 spark,
16 spendOf,
17 WINDOW_MS,
18 type Advice,
19 type Spend,
20} from './calc'
21
22export type Seg = { t: string; c?: string; dim?: boolean; bold?: boolean }
23export type Surface = 'terminal' | 'desktop' | 'vscode' | 'mobile'
24
25/** Figures a surface already draws on its own; the HUD leaves them out there. */
26export type Native = 'model' | 'context' | 'planUsage' | 'branch' | 'gitDiff' | 'clock'
27
28// Desktop's Code tab: model and effort pickers, the usage ring (context window and plan usage),
29// the branch, the `+12 -1` diff stats indicator and the PR/CI bar; the OS draws the clock.
30// Source: code.claude.com/docs/en/desktop (#check-usage, #review-changes-with-diff-view).
31// The terminal shows none of them unless a statusLine script prints them.
32export const NATIVE: Record<Surface, readonly Native[]> = {
33 terminal: [],
34 desktop: ['model', 'context', 'planUsage', 'branch', 'gitDiff', 'clock'],
35 vscode: [],
36 mobile: [],
37}
38
39export const shows = (surface: Surface, what: Native): boolean => !NATIVE[surface].includes(what)
40
41export const SEP: Seg = { t: ' · ', dim: true }
42
43/** Joins non-empty groups with a dim separator. */
44export const join = (groups: readonly Seg[][]): Seg[] =>
45 groups.filter(g => g.length > 0).flatMap((g, i) => (i === 0 ? g : [SEP, ...g]))
46
47export function pctColor(p: number): string {
48 return p >= 80 ? 'error' : p >= 50 ? 'warning' : 'success'
49}
50
51export function projColor(p: number): string {
52 return p >= 115 ? 'error' : p >= 85 ? 'success' : 'cyan'
53}
54
55/** Main-loop spend per category. */
56export function totals(l: HudLedger): Spend {
57 return { in: l.usdIn, rd: l.usdRd, wr: l.usdWr, out: l.usdOut, total: l.usdIn + l.usdRd + l.usdWr + l.usdOut }
58}
59
60/** Everything the ledger priced: main loop, subagents and compactions. */
61export function allUsd(l: HudLedger): number {
62 return totals(l).total + l.agentUsd + l.compactions.reduce((a, c) => a + c.usd, 0)
63}
64
65/** Share of main-loop input tokens the cache served. */
66export function hitRatio(l: HudLedger): number | undefined {
67 const all = l.in + l.rd + l.wr
68 return all > 0 ? l.rd / all : undefined
69}
70
71export function adviceFor(v: HudView, l: HudLedger): Advice {
72 return advise({
73 ctx: v.ctxTokens,
74 base: l.baseCtx,
75 window: v.ctxWindow,
76 model: v.model,
77 ttl: v.ttl,
78 warm: cacheState(l.last?.at, v.ttl, v.now).warm,
79 })
80}
81
82/** Compacting clearly pays: cold and cheaper now, or warm and paid back within 10 requests. */
83export function shouldCompact(a: Advice): boolean {
84 return (a.kind === 'cold' && a.netNowUsd > 0) || (a.kind === 'warm' && a.paybackReqs <= 10)
85}
86
87export function adviceText(a: Advice): string {
88 switch (a.kind) {
89 case 'fresh':
90 return 'fresh context'
91 case 'small':
92 return 'context small, no benefit'
93 case 'cold':
94 return a.netNowUsd > 0
95 ? `cache cold: compacting now is ~${fmtUsd(a.netNowUsd)} cheaper than resuming, then saves ${fmtUsd(a.savesPerReq)}/request`
96 : `cache cold: compacting costs ~${fmtUsd(-a.netNowUsd)} extra, then saves ${fmtUsd(a.savesPerReq)}/request`
97 case 'warm':
98 return `costs ${fmtUsd(a.costUsd)}, saves ${fmtUsd(a.savesPerReq)}/request, pays back in ${a.paybackReqs} requests`
99 }
100}
101
102const windowLabel = (kind: string) => (kind === 'five_hour' ? '5h' : kind === 'seven_day' ? '7d' : kind)
103
104/** Rate-limit windows: the full meter where the surface has none, the pace alone where it does. */
105export function rateSegs(v: HudView, surface: Surface): Seg[][] {
106 const meters = shows(surface, 'planUsage')
107 return v.rates.flatMap(r => {
108 const o = outlook(r.pct, r.resetsAt, WINDOW_MS[r.kind], v.now)
109 const label = windowLabel(r.kind)
110 const warn: Seg[] = o.hitsLimitInMs !== undefined && r.pct < 100 ? [{ t: ` ⚠ 100% in ${fmtDur(o.hitsLimitInMs)}`, c: 'error' }] : []
111 if (!meters) {
112 // the usage ring has the % used; the pace is the HUD's own
113 if (o.projected === undefined) return []
114 return [[{ t: `${label} pace `, dim: true }, { t: `→${o.projected}%`, c: projColor(o.projected) }, ...warn]]
115 }
116 const segs: Seg[] = [{ t: `${label}:`, dim: true }, { t: `${bar(r.pct, 8)} ${Math.round(r.pct)}%`, c: pctColor(r.pct) }]
117 if (o.resetsInMs !== undefined) segs.push({ t: `·${fmtDur(o.resetsInMs)}`, dim: true })
118 if (o.projected !== undefined) segs.push({ t: ' proj:', dim: true }, { t: `${o.projected}%`, c: projColor(o.projected) })
119 return [[...segs, ...warn]]
120 })
121}
122
123function costBar(s: Spend, width: number): Seg[] {
124 const n = costSegments(s, width)
125 return [
126 { t: '█'.repeat(n.in), c: 'yellow' },
127 { t: '█'.repeat(n.rd), c: 'green' },
128 { t: '█'.repeat(n.wr), c: 'red' },
129 { t: '█'.repeat(n.out), c: 'magenta' },
130 ].filter(x => x.t.length > 0)
131}
132
133export { costBar }
134
135function tokenSegs(label: Seg, t: { in: number; rd: number; wr: number; out: number }, s: Spend, width: number): Seg[] {
136 return [
137 label,
138 { t: ` in:${fmtTok(t.in).padStart(6)}`, c: 'yellow' },
139 { t: ` cached:${fmtTok(t.rd).padStart(6)}`, c: 'green' },
140 { t: ` wr:${fmtTok(t.wr).padStart(6)}`, c: 'red' },
141 { t: ` out:${fmtTok(t.out).padStart(6)}`, c: 'magenta' },
142 { t: ' ≈', dim: true },
143 { t: fmtUsd(s.total).padStart(7) + ' ' },
144 ...costBar(s, width),
145 ]
146}
147
148function adviceSegs(a: Advice, leftMs: number): Seg[] {
149 const head: Seg = { t: 'cmp ', c: 'cyan' }
150 if (a.kind === 'fresh') return [head, { t: 'fresh context', dim: true }]
151 if (a.kind === 'small') return [head, { t: 'context small, no benefit', dim: true }]
152 const shrink: Seg = { t: ` ${fmtTok(a.ctx)}→~${fmtTok(a.after)}`, dim: true }
153 const tail: Seg[] = []
154 if (a.cliff) tail.push({ t: ' >200k: recall degrades', c: 'error' })
155 if (a.nearAuto) tail.push({ t: ' auto-compact near', c: 'warning' })
156 if (a.kind === 'cold') {
157 const good = a.netNowUsd > 0
158 return [
159 head,
160 {
161 t: good ? `cold, compact now: ~${fmtUsd(a.netNowUsd)} cheaper than resuming` : `cold, compact costs ~${fmtUsd(-a.netNowUsd)} extra`,
162 c: good ? 'success' : 'warning',
163 },
164 { t: ` then saves ${fmtUsd(a.savesPerReq)}/req`, dim: true },
165 shrink,
166 ...tail,
167 ]
168 }
169 const nc = a.paybackReqs <= 10 ? 'success' : a.paybackReqs <= 30 ? 'warning' : undefined
170 return [
171 head,
172 { t: `warm (cold in ${fmtDur(leftMs)})`, dim: true },
173 { t: ' cost ', dim: true },
174 { t: fmtUsd(a.costUsd) },
175 { t: ' saves ', dim: true },
176 { t: `${fmtUsd(a.savesPerReq)}/req ` },
177 { t: `pays back in ${a.paybackReqs} req`, c: nc, dim: nc === undefined },
178 shrink,
179 ...tail,
180 ]
181}
182
183export type BandOptions = { surface: Surface; density: string; budgetUsd: number; narrow: boolean; clock?: string }
184
185/**
186 * The band's rows, top to bottom. On the desktop it leaves out what the Code tab draws itself
187 * (model, usage ring, branch, diff stats, clock) and keeps what only the HUD knows.
188 */
189export function bandRows(v: HudView, l: HudLedger, o: BandOptions): Seg[][] {
190 const { surface } = o
191 const barW = o.narrow ? 10 : 20
192 const cache = cacheState(l.last?.at, v.ttl, v.now)
193 const big = (v.ctxTokens ?? 0) > 200_000
194
195 const ctx: Seg[] = !shows(surface, 'context')
196 ? []
197 : v.ctxPct !== undefined
198 ? [
199 ...(big ? [{ t: '⚠ ', c: 'error' }] : []),
200 { t: `ctx: ${v.ctxPct}% ${bar(v.ctxPct, 8)} [${fmtTok(v.ctxTokens ?? 0)}/${fmtTok(v.ctxWindow)}]`, c: big ? 'error' : pctColor(v.ctxPct) },
201 ]
202 : [{ t: 'ctx: --', dim: true }]
203 const model: Seg[] = shows(surface, 'model') ? [{ t: modelLabel(v.model), c: 'cyan', bold: true }] : []
204 if (model.length > 0 && v.effort) {
205 const e = v.effort
206 model.push({ t: ` [${e}]`, c: e === 'max' || e === 'xhigh' ? 'error' : e === 'high' ? 'warning' : undefined, dim: e === 'low' || e === 'medium' })
207 }
208
209 const g = v.git
210 const branch: Seg[] =
211 shows(surface, 'branch') && g?.branch
212 ? [
213 { t: `⎇ ${g.branch}`, bold: true },
214 ...(g.ahead ? [{ t: ` ↑${g.ahead}`, c: 'cyan' }] : []),
215 ...(g.behind ? [{ t: ` ↓${g.behind}`, c: 'warning' }] : []),
216 ...(g.changed ? [{ t: ` ✎${g.changed}`, c: 'warning' }] : []),
217 ]
218 : []
219 const diff: Seg[] =
220 shows(surface, 'gitDiff') && g && (g.added || g.removed)
221 ? [{ t: `+${g.added}`, c: 'success' }, { t: '/', dim: true }, { t: `-${g.removed}`, c: 'error' }]
222 : []
223 const folder: Seg[] = shows(surface, 'branch') && v.cwdLeaf ? [{ t: v.cwdLeaf, dim: true }] : []
224 const cost: Seg[] =
225 v.costUsd !== undefined ? [{ t: `cost:${fmtUsd(v.costUsd)}`, c: o.budgetUsd > 0 && v.costUsd >= o.budgetUsd ? 'error' : undefined }] : []
226 const dur: Seg[] = v.startedAt ? [{ t: `⏱ ${fmtDur(v.now - v.startedAt)}`, dim: true }] : []
227 const cacheSeg: Seg[] = l.last
228 ? cache.warm
229 ? [{ t: `cache ${v.ttl} warm ${fmtDur(cache.leftMs)}`, c: cache.leftMs <= 60_000 ? 'warning' : 'success' }]
230 : [{ t: `cache ${v.ttl} cold`, c: 'subtle' }]
231 : []
232
233 const rows: Seg[][] = []
234 const line1 = join([ctx, model, ...rateSegs(v, surface)])
235 const line2 = join([branch, diff, folder, cost, dur, cacheSeg])
236 if (shows(surface, 'context')) {
237 rows.push(line1)
238 rows.push([{ t: 'sess: ', c: 'cyan' }, ...line2])
239 } else {
240 // nothing of line 1 is left but the pace: fold it into the session row
241 rows.push([{ t: 'sess: ', c: 'cyan' }, ...join([line2, line1])])
242 }
243 if (o.density !== 'full') return rows
244
245 if (l.reqs > 0) {
246 const hit = hitRatio(l)
247 const spend = v.spend
248 rows.push([
249 ...tokenSegs({ t: 'Σ ', c: 'cyan' }, l, totals(l), barW),
250 ...(hit !== undefined ? [SEP, { t: `hit ${Math.round(hit * 100)}%`, dim: true }] : []),
251 ...(l.misses > 0 ? [SEP, { t: `${l.misses} miss ${fmtUsd(l.missUsd)}`, c: 'warning' }] : []),
252 ...(l.agentReqs > 0 || (v.agentsRunning ?? 0) > 0
253 ? [SEP, { t: `agents ${v.agentsRunning ? `${v.agentsRunning} running ` : ''}${fmtUsd(l.agentUsd)}`, dim: !v.agentsRunning, c: v.agentsRunning ? 'cyan' : undefined }]
254 : []),
255 ...(spend ? [SEP, { t: `today ${fmtUsd(spend.today)} · 7d ${fmtUsd(spend.week)}`, dim: true }] : []),
256 ])
257 }
258 if (l.last) {
259 rows.push([
260 ...tokenSegs({ t: 'last ', dim: true }, l.last, spendOf(l.last, l.last.model, v.ttl), barW),
261 ...(l.ctxHistory.length > 1 ? [SEP, { t: 'ctx ', dim: true }, { t: spark(l.ctxHistory, 16, v.ctxWindow), c: 'cyan' }] : []),
262 ])
263 }
264 rows.push(adviceSegs(adviceFor(v, l), cache.leftMs))
265 if (shows(surface, 'clock')) {
266 rows.push([
267 { t: '●', c: 'success' },
268 { t: ` ${v.os ?? ''}`, dim: true },
269 ...(o.clock ? [SEP, { t: `🕐 ${o.clock}`, dim: true }] : []),
270 ...(v.version ? [SEP, { t: `v${v.version}`, dim: true }] : []),
271 ])
272 }
273 return rows
274}
275
276/** One line for `$.ui.status` / the prompt hint: the full set on a terminal, the HUD's own figures elsewhere. */
277export function compactLine(v: HudView, l: HudLedger, surface: Surface): string {
278 const parts: string[] = []
279 if (shows(surface, 'context')) parts.push(`ctx ${v.ctxPct ?? '--'}%`)
280 if (shows(surface, 'model')) parts.push(modelLabel(v.model))
281 for (const r of v.rates) {
282 const o = outlook(r.pct, r.resetsAt, WINDOW_MS[r.kind], v.now)
283 if (shows(surface, 'planUsage')) parts.push(`${windowLabel(r.kind)} ${Math.round(r.pct)}%`)
284 else if (o.hitsLimitInMs !== undefined && r.pct < 100) parts.push(`${windowLabel(r.kind)} 100% in ${fmtDur(o.hitsLimitInMs)}`)
285 }
286 if (v.costUsd !== undefined) parts.push(fmtUsd(v.costUsd))
287 const cache = cacheState(l.last?.at, v.ttl, v.now)
288 if (l.last) parts.push(cache.warm ? `cache ${fmtDur(cache.leftMs)}` : 'cache cold')
289 return parts.join(' · ')
290}
291
292/** Spinner suffix while a turn runs: what the turn has cost so far. */
293export function turnSoFar(t: { usd: number; reqs: number } | null | undefined): string | undefined {
294 return t && t.reqs > 0 ? `${fmtUsd(t.usd)} · ${t.reqs} req` : undefined
295}
296
297/** What the turn-closing line adds: `$0.42 · 7 req · ctx +18.0k`. */
298export function turnTail(t: { usd: number; reqs: number; ctxDelta: number }): string {
299 const delta = t.ctxDelta === 0 ? '' : ` · ctx ${t.ctxDelta > 0 ? '+' : '−'}${fmtTok(Math.abs(t.ctxDelta))}`
300 return `${fmtUsd(t.usd)} · ${t.reqs} req${delta}`
301}
302
303/** Markdown report: /hud status, Copy report and the model's tool share it. */
304export function report(v: HudView, l: HudLedger): string {
305 const lines: string[] = []
306 const cache = cacheState(l.last?.at, v.ttl, v.now)
307 lines.push(
308 `- **model** ${modelLabel(v.model)}${v.effort ? ` [${v.effort}]` : ''}; **context** ${v.ctxPct ?? '?'}% ` +
309 `(${fmtTok(v.ctxTokens ?? 0)} of ${fmtTok(v.ctxWindow)})`,
310 )
311 for (const r of v.rates) {
312 const o = outlook(r.pct, r.resetsAt, WINDOW_MS[r.kind], v.now)
313 lines.push(
314 `- **${r.kind}** ${r.pct}% used` +
315 (o.resetsInMs !== undefined ? `, resets in ${fmtDur(o.resetsInMs)}` : '') +
316 (o.projected !== undefined ? `, on pace for ${o.projected}% at reset` : '') +
317 (o.hitsLimitInMs !== undefined && r.pct < 100 ? `, hits 100% in ~${fmtDur(o.hitsLimitInMs)}` : ''),
318 )
319 }
320 const t = totals(l)
321 const hit = hitRatio(l)
322 lines.push(
323 `- **cost** ${v.costUsd !== undefined ? fmtUsd(v.costUsd) : '?'} (engine); ${fmtUsd(t.total)} at list price over ${l.reqs} requests` +
324 (hit !== undefined ? `, cache hit ${Math.round(hit * 100)}%` : '') +
325 (l.agentReqs > 0 ? `; subagents ${fmtUsd(l.agentUsd)} over ${l.agentReqs} requests` : '') +
326 (v.spend ? `; today ${fmtUsd(v.spend.today)}, last 7 days ${fmtUsd(v.spend.week)}` : ''),
327 )
328 lines.push(
329 `- **prompt cache** (${v.ttl}): ${cache.warm ? `warm, cold in ${fmtDur(cache.leftMs)}` : 'cold'}` +
330 (l.misses > 0 ? `; ${l.misses} unexpected miss${l.misses > 1 ? 'es' : ''} cost ~${fmtUsd(l.missUsd)}` : ''),
331 )
332 lines.push(`- **compaction** ${adviceText(adviceFor(v, l))}`)
333 if (l.compactions.length > 0) {
334 const c = l.compactions[l.compactions.length - 1]!
335 lines.push(
336 `- **last compaction** ${c.before !== undefined && c.after !== undefined ? `${fmtTok(c.before)} → ${fmtTok(c.after)}, ` : ''}cost ${fmtUsd(c.usd)} (${l.compactions.length} this session)`,
337 )
338 }
339 return lines.join('\n')
340}
341
342/**
343 * An unexpected cache miss, and its likely cause: the cache should still have held the previous
344 * context (warm, no compaction since), yet most of it was written again instead of read.
345 * Each model has its own cache, so a switch (/model, opusplan, a fallback) is the usual cause.
346 * Source: code.claude.com/docs/en/prompt-caching (#actions-that-invalidate-the-cache).
347 */
348export function missCause(args: {
349 prev?: { model: string; in: number; rd: number; wr: number; at: number }
350 cur: { model: string; rd: number; wr: number }
351 ttlMs: number
352 now: number
353 compactedAt?: number
354}): string | undefined {
355 const { prev, cur } = args
356 if (!prev) return undefined
357 if (args.compactedAt !== undefined && args.compactedAt >= prev.at) return undefined
358 if (args.now - prev.at >= args.ttlMs) return undefined // expired: an expiry, not a miss
359 const prevCtx = prev.in + prev.rd + prev.wr
360 if (cur.wr < 20_000 || cur.rd >= prevCtx * 0.5) return undefined
361 return prev.model !== cur.model ? 'model switch' : 'prefix changed (tools, MCP servers, effort or system prompt)'
362}
363types/index.d.ts 89 lines1// State contract for the statusline-hud mod: every value it keeps in $.state.
2
3/** Running totals for one session, at list prices. */
4export type HudLedger = {
5 /** Main-conversation requests counted. */
6 reqs: number
7 in: number
8 rd: number
9 wr: number
10 out: number
11 usdIn: number
12 usdRd: number
13 usdWr: number
14 usdOut: number
15 /** Subagent / workflow requests (their own caches and loops). */
16 agentReqs: number
17 agentUsd: number
18 /** Context of the session's first request: the prefix that survives /compact. */
19 baseCtx?: number
20 /** Last main-loop response. */
21 last?: HudLast
22 /** Context tokens per main-loop request (newest last, capped). */
23 ctxHistory: number[]
24 /** USD per main-loop request (newest last, capped). */
25 usdHistory: number[]
26 /** Calls per tool name, main loop and subagents together. */
27 tools: Record<string, number>
28 /** Completed main-loop turns (newest last, capped). */
29 turns: HudTurnDone[]
30 /** Unexpected cache misses: re-writes while the cache should have been warm. */
31 misses: number
32 missUsd: number
33 /** Compactions this session, newest last. */
34 compactions: HudCompaction[]
35 /** When the last compaction finished (its next request re-writes by design). */
36 compactedAt?: number
37 /** USD already added to the cross-session daily totals. */
38 savedUsd: number
39}
40
41/** The running turn's spend so far. */
42export type HudTurn = { id: string; usd: number; reqs: number; startCtx?: number }
43
44export type HudTurnDone = { durationMs: number; usd: number; reqs: number; ctxDelta: number }
45
46export type HudCompaction = { at: number; before?: number; after?: number; usd: number; trigger: string }
47
48export type HudLast = { model: string; in: number; rd: number; wr: number; out: number; usd: number; at: number; effort?: string }
49
50export type HudRate = { kind: string; pct: number; resetsAt?: number }
51
52/** What the HUD draws, refreshed on each response, turn end and clock tick. */
53export type HudView = {
54 now: number
55 model: string
56 effort?: string
57 ctxTokens?: number
58 ctxWindow: number
59 ctxPct?: number
60 rates: HudRate[]
61 /** The engine's own session total, as /cost reports it. */
62 costUsd?: number
63 startedAt?: number
64 ttl: '5m' | '1h'
65 /** How the session signs in: OAuth (a subscription), an API key, or neither (cloud provider, gateway). */
66 auth?: 'bearer' | 'api-key' | 'none'
67 git?: { branch?: string; ahead: number; behind: number; changed: number; added: number; removed: number }
68 cwdLeaf?: string
69 os?: string
70 version?: string
71 /** Subagents pending, running or waiting right now. */
72 agentsRunning?: number
73 /** List-price spend across sessions on this machine. */
74 spend?: { today: number; week: number }
75}
76
77declare module 'claude-code' {
78 interface PluginState {
79 'statusline-hud': {
80 view: HudView | null
81 ledger: HudLedger
82 isHidden: boolean
83 turn: HudTurn | null
84 /** Alert keys already toasted this session. */
85 fired: string[]
86 }
87 }
88}
89