Emoji HUD above the prompt: model, repo, branch, context, tokens in/out, cache hit/write %, cost and rate limits

An emoji HUD drawn above the Claude Code prompt, in the style of this repo's statusline. It runs inside Claude Code as a mod, so it needs no bash scripts and no settings.json changes, and it works in the terminal and in the Code tab of the Claude Desktop app.
🤖 Opus 5.5 | 📁 Claude-Code-Ultimate-Setup | 🌿 main | ⏱️ 42m | 💰 $1.87
📊 Context [████░░░░░░░░░░░░░░░░] 21% | 🪙 210k/1M
⬆️ 120k | ⬇️ 8.4k | 🧊 1.2M | 🏗️ 85k | 🎯 91% hit | ✍️ 6% write
🚦 5h [██████░░░░░░░░░░░░░░] 31% 🔄 1:42pm | 🚦 7d [██░░░░░░░░░░░░░░░░░░] 12% 🔄 Mon
| Item | Meaning |
|---|---|
| 🤖 | Model that answered the last turn |
| 📁 / 🌿 | Folder the session runs in, and its git branch |
| ⏱️ | Session duration |
| 💰 | Session cost, as /cost totals it |
| 📊 / 🪙 | Context window fill, and tokens used / window size |
| ⬆️ / ⬇️ | Uncached input tokens / output tokens, summed over the session |
| 🧊 / 🏗️ | Cache read tokens / cache write tokens, summed over the session |
| 🎯 hit | cache read ÷ (cache read + input) |
| ✍️ write | cache write ÷ (input + cache read + cache write) |
| 🚦 / 🔄 | 5-hour and 7-day rate limits and when they reset (subscription plans only) |
Bars are green under 70%, yellow from 70%, and red from 90%. A field with no data yet is hidden. Token totals count the main conversation only (not subagents) and start over on /clear. Bars shrink to 10 cells when the terminal is narrower than 100 columns.
Run /hud to hide or show the band.
| Setting | Values | Default |
|---|---|---|
Show HUD in (showIn) | everywhere, desktop (the Code tab of the Claude Desktop app only), terminal (the terminal only) | everywhere |
Change it in /config. Set it to desktop if you already have a terminal status line and only want the HUD in the Desktop app.
claude plugin marketplace add RaDeleon/Claude-Code-Ultimate-Setup
claude plugin install ultimate-hud@ultimate-setup
claude --plugin-dir ./mods/ultimate-hud # loads it for one session, hot-reloads on save
claude plugin validate ./mods/ultimate-hud
cd mods/ultimate-hud && claude plugin test
Tested with Claude Code 2.1.288. Mods need Claude Code 2.1.287 or later.
hooks/register.tsx 230 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Measure, Totals, Where } from '../types'
5import {
6 bar,
7 basename,
8 branchFromHead,
9 cacheHitPercent,
10 cacheWritePercent,
11 dirname,
12 fmtCost,
13 fmtDuration,
14 fmtReset,
15 fmtTokens,
16 level,
17 limitLabel,
18 prettyModel,
19} from './format'
20
21const EMPTY_TOTALS: Totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
22
23// Session state lives in $.state, so it survives a hot reload of this module.
24const measureAtom = atom({ plugin: 'ultimate-hud', key: 'measure' } as const, null)
25const totalsAtom = atom({ plugin: 'ultimate-hud', key: 'totals' } as const, EMPTY_TOTALS)
26const whereAtom = atom({ plugin: 'ultimate-hud', key: 'where' } as const, {})
27const startedAtAtom = atom({ plugin: 'ultimate-hud', key: 'startedAt' } as const, null)
28const isHiddenAtom = atom({ plugin: 'ultimate-hud', key: 'isHidden' } as const, false)
29
30type UsageFigures = {
31 context: { tokens?: number; window: number; percent?: number }
32 rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]
33 cost?: { usd: number }
34}
35
36function toMeasure(u: UsageFigures): Measure {
37 return {
38 contextTokens: u.context.tokens,
39 contextWindow: u.context.window,
40 contextPercent: u.context.percent,
41 rateLimits: u.rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt })),
42 costUsd: u.cost?.usd,
43 }
44}
45
46/** Reads the branch from .git/HEAD in `cwd` or the nearest parent that has one. */
47async function readBranch($: EngineInterface, cwd: string): Promise<string | undefined> {
48 let dir: string | undefined = cwd
49 for (let depth = 0; dir !== undefined && depth < 8; depth += 1) {
50 try {
51 return branchFromHead(await $.fs.read(`${dir}/.git/HEAD`))
52 } catch {
53 // Not here, or .git is a file (a worktree or submodule): follow its gitdir.
54 }
55 try {
56 const pointer = /^gitdir:\s*(.+)$/m.exec(await $.fs.read(`${dir}/.git`))
57 if (pointer) {
58 const gitDir = pointer[1]!.trim()
59 const absolute = gitDir.startsWith('/') ? gitDir : `${dir}/${gitDir}`
60 return branchFromHead(await $.fs.read(`${absolute}/HEAD`))
61 }
62 } catch {
63 // Keep walking up.
64 }
65 dir = dirname(dir)
66 }
67 return undefined
68}
69
70export const register: Register = (on, options) => {
71 let cwd = ''
72 // The showIn setting: 'everywhere', or the one surface ('desktop' or 'terminal') the band draws on.
73 const showIn = typeof options.showIn === 'string' ? options.showIn : 'everywhere'
74
75 on('session.start', async ($, e, next) => {
76 cwd = e.cwd
77 await $.command.register({ name: 'hud', description: 'Show or hide the Ultimate HUD above the prompt' })
78
79 const branch = await readBranch($, cwd)
80 await update($, whereAtom, () => ({ project: basename(cwd), branch }))
81 try {
82 const usage = await $.session.usage()
83 await update($, startedAtAtom, () => usage.startedAt)
84 await update($, measureAtom, () => toMeasure(usage))
85 } catch {
86 // No figures yet: session.measure fills them in after the first turn.
87 }
88
89 // Redraw once a minute so the session clock moves between turns.
90 $.clock.every(60_000, () => $.ui.invalidate('ui.render'))
91
92 return next(e)
93 })
94
95 // Claude Code pushes its status-line figures after each turn and when a
96 // rate-limit window moves: context, rate limits and cost.
97 on('session.measure', async ($, e, next) => {
98 await update($, measureAtom, () => toMeasure(e))
99 return next(e)
100 })
101
102 // Each main-thread turn reports its token usage; add it to the session totals.
103 on('turn.complete', async ($, e, next) => {
104 const result = await next(e)
105 const usage = e.usage
106 if (e.agentId === undefined && usage) {
107 await update($, totalsAtom, t => ({
108 input: t.input + usage.input_tokens,
109 output: t.output + usage.output_tokens,
110 cacheRead: t.cacheRead + usage.cache_read_input_tokens,
111 cacheWrite: t.cacheWrite + usage.cache_creation_input_tokens,
112 model: usage.model,
113 }))
114 }
115 if (cwd) {
116 const branch = await readBranch($, cwd)
117 await update($, whereAtom, w => ({ ...w, branch }))
118 }
119 return result
120 })
121
122 // /clear starts the session over, so the totals start over too.
123 on('session.end', async ($, e, next) => {
124 if (e.reason === 'clear') {
125 await update($, totalsAtom, () => EMPTY_TOTALS)
126 }
127 return next(e)
128 })
129
130 on('command.run', { command: 'hud' }, async $ => {
131 const isHidden = await update($, isHiddenAtom, hidden => !hidden)
132 if (isHidden) return { text: 'HUD hidden. Run /hud to show it again.' }
133 if (showIn === 'everywhere') return { text: 'HUD shown.' }
134 const where = showIn === 'desktop' ? "the Desktop app's Code tab" : 'the terminal'
135 return { text: `HUD shown. The "Show HUD in" setting limits it to ${where}.` }
136 })
137
138 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
139 const isOffSurface = showIn !== 'everywhere' && e.surface !== showIn
140 if (e.props.hasSurvey || isOffSurface || (await read($, isHiddenAtom))) {
141 return next(e)
142 }
143
144 const measure = await read($, measureAtom)
145 const totals = await read($, totalsAtom)
146 const where = await read($, whereAtom)
147 const startedAt = await read($, startedAtAtom)
148 const now = await $.clock.now()
149 const width = e.props.bodyColumns >= 100 ? 20 : 10
150
151 const { Box, Text } = $.ui.resolve(e)
152 const sep = <Text dimColor> | </Text>
153 const join = (items: JSX.Element[]) => items.flatMap((item, i) => (i === 0 ? [item] : [sep, item]))
154
155 // Line 1: 🤖 model | 📁 repo | 🌿 branch | ⏱️ duration | 💰 cost
156 const line1: JSX.Element[] = []
157 const model = prettyModel(totals.model)
158 if (model) line1.push(<Text>🤖 {model}</Text>)
159 if (where.project) line1.push(<Text>📁 {where.project}</Text>)
160 if (where.branch) line1.push(<Text>🌿 {where.branch}</Text>)
161 if (startedAt !== null) line1.push(<Text>⏱️ {fmtDuration(now - startedAt)}</Text>)
162 if (measure?.costUsd !== undefined) line1.push(<Text>💰 {fmtCost(measure.costUsd)}</Text>)
163
164 // Line 2: 📊 Context [bar] % | 🪙 tokens/window
165 const line2: JSX.Element[] = []
166 if (measure?.contextPercent !== undefined) {
167 const pct = measure.contextPercent
168 line2.push(
169 <Text>
170 📊 Context <Text color={level(pct)}>{bar(pct, width)}</Text> {pct}%
171 </Text>,
172 )
173 if (measure.contextTokens !== undefined) {
174 line2.push(
175 <Text>
176 🪙 {fmtTokens(measure.contextTokens)}/{fmtTokens(measure.contextWindow)}
177 </Text>,
178 )
179 }
180 }
181
182 // Line 3: ⬆️ in | ⬇️ out | 🧊 cache read | 🏗️ cache write | 🎯 hit % | ✍️ write %
183 const line3: JSX.Element[] = []
184 const hasTokens = totals.input + totals.output + totals.cacheRead + totals.cacheWrite > 0
185 if (hasTokens) {
186 line3.push(<Text>⬆️ {fmtTokens(totals.input)}</Text>)
187 line3.push(<Text>⬇️ {fmtTokens(totals.output)}</Text>)
188 line3.push(<Text>🧊 {fmtTokens(totals.cacheRead)}</Text>)
189 line3.push(<Text>🏗️ {fmtTokens(totals.cacheWrite)}</Text>)
190 const hit = cacheHitPercent(totals.cacheRead, totals.input)
191 if (hit !== undefined) line3.push(<Text>🎯 {hit}% hit</Text>)
192 const write = cacheWritePercent(totals.input, totals.cacheRead, totals.cacheWrite)
193 if (write !== undefined) line3.push(<Text>✍️ {write}% write</Text>)
194 }
195
196 // Line 4: 🚦 5h [bar] % 🔄 reset | 7d [bar] % 🔄 reset
197 const line4: JSX.Element[] = []
198 for (const limit of measure?.rateLimits ?? []) {
199 const reset = fmtReset(limit.resetsAt, now)
200 line4.push(
201 <Text>
202 🚦 {limitLabel(limit.kind)} <Text color={level(limit.percentUsed)}>{bar(limit.percentUsed, width)}</Text>{' '}
203 {limit.percentUsed}%{reset ? ` 🔄 ${reset}` : ''}
204 </Text>,
205 )
206 }
207
208 const rows = [
209 { key: 'hud-where', items: line1 },
210 { key: 'hud-context', items: line2 },
211 { key: 'hud-tokens', items: line3 },
212 { key: 'hud-limits', items: line4 },
213 ].filter(row => row.items.length > 0)
214
215 if (rows.length === 0) {
216 return next(e)
217 }
218
219 return (
220 <Box flexDirection="column">
221 {rows.map(row => (
222 <Box key={row.key} flexDirection="row" flexWrap="wrap">
223 {join(row.items)}
224 </Box>
225 ))}
226 </Box>
227 )
228 })
229}
230hooks/format.ts 120 lines1// Pure formatting helpers for the HUD. No `$` here, so every function is
2// easy to test and safe to import from the hooks module.
3
4/** 950 → 950, 1200 → 1.2k, 155100 → 155.1k, 1000000 → 1M, 1250000 → 1.25M */
5export function fmtTokens(n: number): string {
6 if (!Number.isFinite(n) || n < 0) return '0'
7 if (n < 1000) return String(Math.round(n))
8 const k = Math.round(n / 100) / 10
9 if (k < 1000) return `${trimZeros(k.toFixed(1))}k`
10 const m = Math.round(n / 10_000) / 100
11 return `${trimZeros(m.toFixed(2))}M`
12}
13
14function trimZeros(s: string): string {
15 return s.includes('.') ? s.replace(/\.?0+$/, '') : s
16}
17
18/** A progress bar of `width` cells: [████░░░░] */
19export function bar(percent: number, width: number): string {
20 const p = clamp(percent, 0, 100)
21 const filled = Math.round((p / 100) * width)
22 return `[${'█'.repeat(filled)}${'░'.repeat(width - filled)}]`
23}
24
25/** Green under 70%, yellow from 70% to 89%, red at 90%+. */
26export function level(percent: number): 'green' | 'yellow' | 'red' {
27 if (percent >= 90) return 'red'
28 if (percent >= 70) return 'yellow'
29 return 'green'
30}
31
32/** cache_read / (cache_read + input) × 100, the formula from setup.md. */
33export function cacheHitPercent(cacheRead: number, input: number): number | undefined {
34 const denominator = cacheRead + input
35 return denominator > 0 ? Math.round((cacheRead / denominator) * 100) : undefined
36}
37
38/** Share of all input tokens that were written to the cache. */
39export function cacheWritePercent(input: number, cacheRead: number, cacheWrite: number): number | undefined {
40 const denominator = input + cacheRead + cacheWrite
41 return denominator > 0 ? Math.round((cacheWrite / denominator) * 100) : undefined
42}
43
44/** claude-opus-5-5 → Opus 5.5, claude-sonnet-4-5-20250929 → Sonnet 4.5 */
45export function prettyModel(id: string | undefined): string | undefined {
46 if (!id) return undefined
47 const parts = id
48 .replace(/\[.*\]$/, '')
49 .replace(/^claude-/, '')
50 .replace(/-\d{8}$/, '')
51 .split('-')
52 .filter(Boolean)
53 const words = parts.filter(p => !/^\d+$/.test(p))
54 const numbers = parts.filter(p => /^\d+$/.test(p))
55 if (words.length === 0) return id
56 const name = words.map(w => w[0]!.toUpperCase() + w.slice(1)).join(' ')
57 return numbers.length > 0 ? `${name} ${numbers.join('.')}` : name
58}
59
60/** 45s → <1m, 42m → 42m, 2h 14m → 2h 14m */
61export function fmtDuration(ms: number): string {
62 const minutes = Math.floor(Math.max(0, ms) / 60_000)
63 if (minutes < 1) return '<1m'
64 if (minutes < 60) return `${minutes}m`
65 return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
66}
67
68export function fmtCost(usd: number): string {
69 return `$${usd.toFixed(2)}`
70}
71
72/** five_hour → 5h, seven_day → 7d, spend_limit → spend */
73export function limitLabel(kind: string): string {
74 if (kind === 'five_hour') return '5h'
75 if (kind === 'seven_day') return '7d'
76 if (kind === 'spend_limit') return 'spend'
77 return kind.replace(/_/g, ' ')
78}
79
80const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
81
82/** Within a day: 1:42pm. Further out: the weekday, e.g. Mon. */
83export function fmtReset(iso: string | undefined, nowMs: number): string | undefined {
84 if (!iso) return undefined
85 const at = new Date(iso)
86 const ms = at.getTime()
87 if (!Number.isFinite(ms)) return undefined
88 if (ms - nowMs >= 24 * 60 * 60 * 1000) return DAYS[at.getDay()]
89 const h = at.getHours()
90 const m = String(at.getMinutes()).padStart(2, '0')
91 return `${h % 12 === 0 ? 12 : h % 12}:${m}${h < 12 ? 'am' : 'pm'}`
92}
93
94/** The last path segment: /Users/a/Claude-Code-Ultimate-Setup → Claude-Code-Ultimate-Setup */
95export function basename(path: string): string {
96 const trimmed = path.replace(/[\\/]+$/, '')
97 return trimmed.split(/[\\/]/).pop() || trimmed
98}
99
100/** The parent directory, or undefined at the root. */
101export function dirname(path: string): string | undefined {
102 const trimmed = path.replace(/[\\/]+$/, '')
103 const i = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
104 if (i <= 0) return undefined
105 return trimmed.slice(0, i)
106}
107
108/** "ref: refs/heads/main" → main; a detached HEAD → its short sha. */
109export function branchFromHead(head: string): string | undefined {
110 const text = head.trim()
111 const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(text)
112 if (ref) return ref[1]
113 if (/^[0-9a-f]{7,}$/i.test(text)) return text.slice(0, 7)
114 return undefined
115}
116
117function clamp(n: number, min: number, max: number): number {
118 return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : min
119}
120types/index.d.ts 42 lines1/** Claude Code's own status-line figures, as the last `session.measure` pushed them. */
2export type Measure = {
3 /** Input tokens the last response was answered over. */
4 contextTokens?: number
5 /** The model's context window, in tokens. */
6 contextWindow: number
7 /** contextTokens over contextWindow, 0-100. */
8 contextPercent?: number
9 /** The rate-limit windows (five_hour, seven_day, ...). Empty off a subscription. */
10 rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[]
11 /** Session cost in US dollars, when the host keeps a ledger. */
12 costUsd?: number
13}
14
15/** Token counts summed over every main-thread turn this session. */
16export type Totals = {
17 input: number
18 output: number
19 cacheRead: number
20 cacheWrite: number
21 /** The API id of the model that answered last, e.g. claude-opus-5-5. */
22 model?: string
23}
24
25/** Where the session runs. */
26export type Where = {
27 project?: string
28 branch?: string
29}
30
31declare module 'claude-code' {
32 interface PluginState {
33 'ultimate-hud': {
34 measure: Measure | null
35 totals: Totals
36 where: Where
37 startedAt: number | null
38 isHidden: boolean
39 }
40 }
41}
42