SLOPSHOPPER

ultimate-hud

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

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

ultimate-hud

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

What each item means

ItemMeaning
🤖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
🎯 hitcache read ÷ (cache read + input)
✍️ writecache 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.

Settings

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

Install

claude plugin marketplace add RaDeleon/Claude-Code-Ultimate-Setup
claude plugin install ultimate-hud@ultimate-setup

Develop

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.

Source 3 files
hooks/register.tsx 230 lines
1import { 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}
230
hooks/format.ts 120 lines
1// 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}
120
types/index.d.ts 42 lines
1/** 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