SLOPSHOPPER

Statusline HUD

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…

newpanebandspinnerguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · statusline-hud
│ ┃ hud ✕ › fix the failing auth test and add an audit log call │ ┃ Context │ ┃ ████████████████░░░░░░░░░░░░░░░░ 49% 97.4k ⏺ Read(src/auth.ts) │ ┃ kept by /compact ≈ 0 (system prompt + tools) ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Spend ⎿ Added 2 lines, removed 1 line │ ┃ engine total $0.42 list-price estimate $0. ⏺ Bash(bun test) │ ┃ in 0 $0.00 ⎿ 3 pass, 1 fail │ ┃ cached 0 $0.00 │ ┃ write 0 $0.00 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ out 0 $0.00 │ ┃ 0 requests ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Rate limits › /hud │ ┃ five_hour ███████░░░░░░░░░░░░░░░░░ 31% ⎿ statusline-hud: HUD band hidden (/hud to show). │ ┃ │ ┃ Prompt cache & compaction │ ┃ TTL 5m · no request yet │ ┃ cache cold: compacting costs ~$0.36 extra, │ ┃ then saves $0.01/request │ ┃ │ ┃ Activity │ ┃ 1 turns · avg 42s · max 42s █ │ ┃ Bash 4 · Write 2 · Read 1 · Grep 1 · Edit 1 │ ┃ │ ┃ [ Compact now ] [ Copy report ] [ Close ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · hud
Context ████████████████░░░░░░░░░░░░░░░░ 49% 97.4k / 200.0k kept by /compact ≈ 0 (system prompt + tools) Spend engine total $0.42 list-price estimate $0.00 burn $0.00/ in 0 $0.00 cached 0 $0.00 write 0 $0.00 out 0 $0.00 0 requests Rate limits five_hour ███████░░░░░░░░░░░░░░░░░ 31% Prompt cache & compaction TTL 5m · no request yet cache cold: compacting costs ~$0.36 extra, then saves $0.01/request Activity 1 turns · avg 42s · max 42s █ Bash 4 · Write 2 · Read 1 · Grep 1 · Edit 1 [ Compact now ] [ Copy report ] [ Close ]
README

statusline-hud

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.

Install

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

What you get

The band (above the prompt)

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
RowWhat it showsNew vs the script
1context 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
2branch, ahead/behind, changed files, working-tree +/-, folder, engine cost, session timecache countdown (warm/cold, TTL-aware); git ahead/behind and dirty count
3Σ tokens per category, ≈$ at list price, stacked cost barcache hit %, unexpected misses, subagents (running now + spend), today / 7-day spend across sessions
4last request's tokens and ≈$context sparkline per request
5compaction 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
6OS, clock, Claude Code versiondashboard / hide buttons

Focus the band with ctrl+x tab (or a click) to use the hotkeys.

On Claude Code Desktop: no duplicates

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 dropsIt keeps (Desktop doesn't show these)
model + effort, context bar, 5h/7d meters, branch/ahead/dirty, +/-, folder, OS/clock/version rowrate-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.

Per-turn cost

  • Spinner (terminal + Desktop): Baking… $0.26 · 2 req while the turn runs, subagents included.
  • Turn line (terminal; Desktop draws its own footer): Baked for 1m 4s · $0.42 · 7 req · ctx +18.0k.

Cache misses and compactions

  • Unexpected miss: a request re-writes most of the context while the cache should still be warm. You get a toast with what it cost and the likely cause (a model switch, or a prefix change: tools, MCP servers, effort, system prompt). The running count and cost show in Σ and in the report.
  • Compaction log: every /compact, auto-compact or button press toasts Compacted 300.0k → 40.0k tokens (−87%) for $0.07, and the pane lists the last three.
  • Cache went cold: one toast with what the next prompt will cost, plus the advisor's verdict if compacting first is cheaper.

/hud command

CommandDoes
/hudtoggle the band
/hud paneopen the dashboard pane
/hud statusprint a plain-text report into the transcript
/hud resetzero this session's ledger

Dashboard pane

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

Toasts (once per crossing)

  • context 80% / 90%, and past 200k tokens
  • a rate-limit window on pace to hit 100% before it resets
  • prompt cache about to go cold (≤ 60s left with ≥ 60k context), and once it has gone cold, with what the next prompt will cost
  • an unexpected cache miss, with its cost and likely cause
  • each compaction: before → after, and what it cost
  • session cost past your budget

A tool for the model

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.

Settings

/config (or /plugin configure statusline-hud@claude-code-xtras):

OptionValuesDefault
displayband, status (one pinned line), bothband
densityfull (6 rows), compact (2 rows)full
cacheTtlauto, 5m, 1hauto
budgetUsdnumber, 0 = off0
alertstoasts on/offtrue
turnCostcost beside the spinner and on each turn's closing linetrue

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.

How the numbers are made

  • Engine cost is Claude Code's own total (what /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).
  • Prices: platform.claude.com pricing, checked 2026-10-08, in hooks/calc.ts. Includes Sonnet 5.5 (0.05x cache reads) and Haiku 5.5 (priced by prompt length).
  • The API reports cache writes as one number, so they're priced at the TTL in force (main loop) or 5m (subagents, unless pinned).
  • Compaction advisor: same model as the script, per API request. 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.
  • The ledger is per session, saved at each turn end (last 40 sessions), so it survives reloads and --continue/--resume. Daily totals (list price, all sessions on the machine) keep 35 days.
  • Miss detection: same TTL window, no compaction since, ≥ 20k tokens written and less than half the previous context read back.

Gaps vs the script

  • PR badge: the status-line JSON carries the branch's PR; the mod API doesn't. Left out rather than calling gh on a timer.
  • Fast mode pricing: per-response speed isn't in the mod's usage figures, so fast-mode requests are estimated at standard rates.
  • Thinking flag: not exposed to mods.
  • Clock / day boundaries: local time as the mod's runtime reports it.
  • Rate-limit windows appear once the session's first API response reports them.

Develop

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

Source 4 files
hooks/register.tsx 627 lines
1// 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}
627
hooks/calc.ts 217 lines
1// 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}
217
hooks/present.ts 363 lines
1// 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}
363
types/index.d.ts 89 lines
1// 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