SLOPSHOPPER

claudecodemonitor-hud-mod

Mod: a band above the prompt with the session's model and effort beside the ones your settings set, and the advisor model with its call count.

newbandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · claudecodemonitor-hud-mod
› 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 ◆ Opus 5.5 default effort ▱▱▱▱▱ — default advisor off · 0 calls ctx ━━━─── 49% of 200k · 103k safe │ 5h ▸ ━━──── 31% ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◆ Opus 5.5 default effort ▱▱▱▱▱ — default advisor off · 0 calls ctx ━━━─── 49% of 200k · 103k safe │ 5h ▸ ━━──── 31% ⟨Claude Code's own drawing⟩
README

claudecodemonitor-hud-mod

A Claude Code mod that draws one line above the prompt:

 ◆ Opus 5.5  Haiku   effort ▰▰▰▰▱ xhigh xhigh   advisor Fable · 2 calls

Under it, once the session has a reading, the meters line: token-weather-usage's bars with the old status-line HUD's text.

ctx ━━━─── 47% of 1M · 530k safe │ 5h ▸ ━╍╍─── 21% · resets 14:20   7d ▸ ━━━─── 58% · resets Fri 09:00
  • ctx: context filled (green, yellow from 60%, red from 85%), the window, and the tokens left. Then the handoff banner: ● handoff from 60% (or 50% while the 5h burn is past 30 %/h), ● handoff NOW from 85%.
  • 5h / 7d: each limit against the clock. ━ is what you used, ╍ the gap between that and the time elapsed in the window, ─ the rest; green while usage keeps behind the clock (the gap dim), yellow ahead of it (the gap in color), red more than 15 points ahead or past 90%. Then the percent used and the reset time.

The line wraps when the terminal is too narrow for it.

Readings come from Claude Code after each turn and whenever a limit moves a point; the 5h burn-rate samples behind the handoff banner live in the plugin's store so a reload keeps them. Rate limits appear only on a claude.ai subscription, after the first response.

The model is a pill colored by family (Opus magenta, Sonnet blue, Haiku green, Fable yellow). Effort is a five-step meter from green (low) to red (max). Beside each, in gray, is the value your settings hold (default when unset).

PartSession valueConfig value
modelThe main loop's model, as /model shows itmodel in your settings
effortWhat /effort set, or the effort the last main-loop request asked forThe session model's modelSettings.<model>.effortLevel (where /effort saves it), else the top-level effortLevel
advisorHow many times the advisor ran this sessionadvisorModel in your settings, or off

Config values are your settings files merged the way Claude Code reads them (user, project, local, managed). default means the setting isn't set. The band updates right after /model, /effort or /advisor, when a settings file changes, and at most a second later otherwise, so changes show up while idle. Effort and advisor calls also update after each request. Subagents' requests don't count.

Other mods' bands stay visible under this line.

Install

/plugin install claudecodemonitor-hud-mod@xve-claude-marketplace
/reload-plugins

Needs Claude Code 2.1.287 or later. Works in the terminal and the Desktop app's Code tab, on Windows and macOS.

Check

claude plugin validate ./plugins/claudecodemonitor-hud-mod
claude plugin test ./plugins/claudecodemonitor-hud-mod
Source 2 files
hooks/register.ts 223 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { addSample, burnRate, compact, contextColor, handoff, paceBar, resetLabel, PACE_CELLS } from './meters'
4import type { Reading, Sample } from './meters'
5
6// The meters: the latest reading and the limit samples behind the burn rate.
7// Samples live in $.store so a reload or a new session keeps the rate.
8const SAMPLES_KEY = 'samples'
9let reading: Reading | undefined
10let samples: Sample[] = []
11
12// What the band shows. Session values come from the engine as the session
13// runs; config values from the settings files, merged as the engine reads them.
14let sessionModel: string | undefined
15let sessionEffort: string | undefined
16let configModel: string | undefined
17let configEffort: string | undefined
18let advisorModel: string | undefined
19let advisorCalls = 0
20
21const nonEmpty = (value: unknown) => (typeof value === 'string' && value !== '' ? value : undefined)
22
23const LEVELS = ['low', 'medium', 'high', 'xhigh', 'max']
24const LEVEL_COLORS = ['green', 'cyan', 'yellow', 'magenta', 'red']
25const FAMILY_COLORS: Record<string, string> = { opus: 'magenta', sonnet: 'blue', haiku: 'green', fable: 'yellow' }
26
27const family = (name: string) => /opus|sonnet|haiku|fable/.exec(name.toLowerCase())?.[0]
28const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
29
30// "claude-opus-5-5[1m]" reads "Opus 5.5 [1m]"; an alias such as "haiku" reads "Haiku".
31function pretty(name: string) {
32  const id = /^(?:claude-)?([a-z]+)-(\d+)-(\d+)(.*)$/.exec(name)
33  if (!id) return capitalize(name)
34  const [, base = '', major, minor, rest = ''] = id
35  return `${capitalize(base)} ${major}.${minor}${rest ? ` ${rest}` : ''}`
36}
37
38
39// Re-reads what can change with no event this mod receives: /model and edits
40// to the settings files. Redraws only when something moved.
41async function refresh($: EngineInterface) {
42  const settings = await $.settings.read()
43  const fullModel = await $.session.model()
44  // /effort saves per model, under modelSettings.<model id>.effortLevel; the
45  // top-level effortLevel applies to models without an entry of their own.
46  const perModel = settings.modelSettings as Record<string, { effortLevel?: unknown }> | undefined
47  const effort = nonEmpty(perModel?.[fullModel.replace(/\[.*\]$/, '')]?.effortLevel) ?? nonEmpty(settings.effortLevel)
48  const next = [fullModel, nonEmpty(settings.model), effort, nonEmpty(settings.advisorModel)]
49  const now = [sessionModel, configModel, configEffort, advisorModel]
50  if (next.every((value, i) => value === now[i])) return
51  // /effort saves to the settings: take a new level now, not at the next
52  // request. Not on a model switch: the session keeps its level, and the
53  // new model's saved entry is just what now shows beside it.
54  const sameModel = sessionModel === undefined || fullModel === sessionModel
55  if (sameModel && next[2] !== configEffort && next[2] !== undefined) sessionEffort = next[2]
56  ;[sessionModel, configModel, configEffort, advisorModel] = next
57  $.ui.invalidate('ui.render')
58}
59
60// Takes a measurement into the reading and the samples, and redraws.
61async function measure($: EngineInterface, usage: { context: { tokens?: number; window: number; percent?: number }; rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[] }) {
62  const now = await $.clock.now()
63  reading = { ...usage.context, rateLimits: [...usage.rateLimits] }
64  const five = usage.rateLimits.find(l => l.kind === 'five_hour')?.percentUsed
65  const seven = usage.rateLimits.find(l => l.kind === 'seven_day')?.percentUsed
66  if (five !== undefined || seven !== undefined) {
67    const next = addSample(samples, { at: now, five, seven })
68    if (next !== samples) {
69      samples = next
70      await $.store.set(SAMPLES_KEY, samples).catch(() => undefined)
71    }
72  }
73  $.ui.invalidate('ui.render')
74}
75
76export const register: Register = on => {
77  on('session.start', async ($, e, next) => {
78    sessionEffort = undefined
79    advisorCalls = 0
80    const saved = await $.store.get(SAMPLES_KEY).catch(() => undefined)
81    if (Array.isArray(saved)) samples = saved as Sample[]
82    await refresh($)
83    const usage = await $.session.usage().catch(() => undefined)
84    if (usage) await measure($, usage)
85    // The countdowns and the burn rate move with the clock.
86    $.clock.every(30_000, () => $.ui.invalidate('ui.render'))
87    // A safety net for anything the two hooks below miss. Both reads are
88    // in-process and cheap, and refresh redraws only when a value moved.
89    $.clock.every(1000, () => refresh($))
90    return next(e)
91  })
92
93  // The commands that change what the band shows: re-read as soon as one is done.
94  on('command.run', async ($, e, next) => {
95    const result = await next(e)
96    // `/effort <level>` applies at once: show it without waiting for the save.
97    const level = e.args.trim().toLowerCase()
98    if (e.command === 'effort' && ['low', 'medium', 'high', 'xhigh', 'max'].includes(level) && level !== sessionEffort) {
99      sessionEffort = level
100      $.ui.invalidate('ui.render')
101    }
102    if (['effort', 'model', 'advisor', 'config'].includes(e.command)) {
103      await refresh($)
104      // The settings may reach the engine a moment after the command saved them.
105      $.clock.after(250, () => refresh($))
106    }
107    return result
108  })
109
110  // A settings file changed: /effort saving, or an edit by hand.
111  on('classic.ConfigChange', async ($, e, next) => {
112    const result = await next(e)
113    await refresh($)
114    return result
115  })
116
117  // The engine pushes a measurement after each turn and when a limit moves a
118  // point; a compaction changes the context at once.
119  on('session.measure', async ($, e, next) => {
120    await measure($, e)
121    return next(e)
122  })
123  on('session.compact', async ($, e, next) => {
124    const result = await next(e)
125    if (!e.agentId) await measure($, await $.session.usage()).catch(() => undefined)
126    return result
127  })
128
129  // Each main-loop request carries the effort it asks for, and lists the
130  // advisor calls the API ran inside it. Subagents' requests are skipped.
131  on('turn.step', async function* ($, e, next) {
132    const result = yield* next(e)
133    if (e.agentId) return result
134    const effort = e.effort === undefined ? undefined : String(e.effort)
135    const calls = (result?.serverToolUses ?? []).filter(use => use.name === 'advisor').length
136    if (effort !== sessionEffort || calls > 0) {
137      sessionEffort = effort
138      advisorCalls += calls
139      $.ui.invalidate('ui.render')
140    }
141    return result
142  })
143
144  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
145    if (e.props.hasSurvey || sessionModel === undefined) return next(e)
146    const { Box, Text } = $.ui.resolve(e)
147    const dim = (s: string) => Text({ dimColor: true, children: [s] })
148    // What the settings hold, in gray beside the session's value.
149    const saved = (value: string | undefined) => dim(` ${value ?? 'default'}`)
150
151    const modelColor = FAMILY_COLORS[family(sessionModel) ?? ''] ?? 'white'
152    const level = LEVELS.indexOf(sessionEffort ?? '')
153    const meter = '▰'.repeat(level + 1) + '▱'.repeat(LEVELS.length - level - 1)
154    // An unknown level draws dimmed rather than in a level's color.
155    const effortStyle = level >= 0 ? { color: LEVEL_COLORS[level] } : { dimColor: true }
156
157    const line = Box({
158      flexDirection: 'row',
159      children: [
160        Text({ backgroundColor: modelColor, color: 'black', bold: true, children: [` ◆ ${pretty(sessionModel)} `] }),
161        saved(configModel && pretty(configModel)),
162        dim('   effort '),
163        Text({ ...effortStyle, children: [meter] }),
164        Text({ ...effortStyle, bold: true, children: [` ${sessionEffort ?? '—'}`] }),
165        saved(configEffort),
166        dim('   advisor '),
167        advisorModel
168          ? Text({ color: 'cyan', bold: true, children: [pretty(advisorModel)] })
169          : dim('off'),
170        dim(` · ${advisorCalls} ${advisorCalls === 1 ? 'call' : 'calls'}`),
171      ],
172    })
173
174    const rows = [line]
175    if (reading) {
176      const now = await $.clock.now()
177      const pct = reading.percent ?? (reading.tokens !== undefined ? Math.round((100 * reading.tokens) / reading.window) : 0)
178      const color = contextColor(pct)
179      const sep = () => dim(' │ ')
180      // The context bar in the limits' glyphs: `━` filled, `─` free.
181      const filled = Math.min(PACE_CELLS, Math.round((pct / 100) * PACE_CELLS))
182      // The text follows the old status-line HUD: "47% of 1M · 530k safe",
183      // then its handoff banner; the bar is the token-weather style.
184      const banner = handoff(pct, burnRate(samples, 'five', now))
185      const left = reading.tokens !== undefined ? reading.window - reading.tokens : Math.round(((100 - pct) / 100) * reading.window)
186      const context = [
187        dim('ctx '),
188        Text({ color, children: ['━'.repeat(filled)] }),
189        dim('─'.repeat(PACE_CELLS - filled)),
190        Text({ bold: true, children: [` ${pct}%`] }),
191        dim(` of ${compact(reading.window)} · ${compact(left)} safe`),
192        ...(banner ? [Text({ color: banner.color, bold: banner.bold, children: [`  ${banner.text}`] })] : []),
193      ]
194
195      // One limit, as the old HUD wrote it: "5h ▸ 21% · resets 14:20",
196      // with the pace bar in front of the percent.
197      const limit = (label: string, kind: string, lead: ReturnType<typeof dim>) => {
198        const l = reading!.rateLimits.find(x => x.kind === kind)
199        if (!l) return []
200        const p = paceBar(l.percentUsed, kind, l.resetsAt, now)
201        const usedColor = l.percentUsed >= 90 ? 'red' : l.percentUsed >= 70 ? 'yellow' : 'green'
202        const reset = resetLabel(l.resetsAt, now)
203        return [
204          lead,
205          dim(`${label} ▸ `),
206          Text({ color: p.color, children: [p.used] }),
207          p.ahead ? Text({ color: p.color, children: [p.gap] }) : dim(p.gap),
208          dim(p.rest),
209          Text({ color: usedColor, bold: true, children: [` ${Math.round(l.percentUsed)}%`] }),
210          ...(reset ? [dim(` · ${reset}`)] : []),
211        ]
212      }
213
214      rows.push(Box({ flexDirection: 'row', flexWrap: 'wrap', children: [...context, ...limit('5h', 'five_hour', sep()), ...limit('7d', 'seven_day', dim('   '))] }))
215    }
216
217    // Keep what the mods after this one draw in the band, under these lines.
218    const below = await next(e)
219    if (below) rows.push(below)
220    return rows.length === 1 ? line : Box({ flexDirection: 'column', children: rows })
221  })
222}
223
hooks/meters.ts 90 lines
1// The meters line: context, 5h and 7d limits against the clock. Pure
2// functions over readings; register.ts feeds them.
3
4export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
5export type Sample = { at: number; five?: number; seven?: number }
6export type Reading = { tokens?: number; window: number; percent?: number; rateLimits: Limit[] }
7
8export const MINUTE = 60_000
9export const HOUR = 60 * MINUTE
10// Burn rate over the last ten minutes of samples, behind the handoff banner;
11// it reacts within a few renders where the whole-window average would hide a burst.
12export const RATE_SPAN = 10 * MINUTE
13// One sample per 20 s at most, kept twice as long as the rate looks back.
14export const SAMPLE_GAP = 20_000
15export const KEEP_MS = 2 * RATE_SPAN
16
17export const CONTEXT_WARN = 60
18export const CONTEXT_HANDOFF = 85
19
20// Appends a sample unless the last one is too recent, and drops what is older
21// than KEEP_MS. Returns the new history.
22export function addSample(history: Sample[], sample: Sample): Sample[] {
23  const last = history[history.length - 1]
24  if (last && sample.at - last.at < SAMPLE_GAP) return history
25  return [...history, sample].filter(s => sample.at - s.at <= KEEP_MS)
26}
27
28// Percent per hour over the last RATE_SPAN, from the oldest sample inside
29// it to the newest; undefined with fewer than two samples or no movement.
30export function burnRate(history: Sample[], key: 'five' | 'seven', now: number): number | undefined {
31  const recent = history.filter(s => now - s.at <= RATE_SPAN && s[key] !== undefined)
32  const first = recent[0]
33  const last = recent[recent.length - 1]
34  if (!first || !last || first === last || last.at === first.at) return undefined
35  const delta = (last[key] as number) - (first[key] as number)
36  if (delta <= 0) return 0
37  return (delta * HOUR) / (last.at - first.at)
38}
39
40export const SPANS: Record<string, number> = { five_hour: 5 * HOUR, seven_day: 7 * 24 * HOUR }
41export const PACE_CELLS = 6
42export const PACE_ALERT = 15
43
44export type PaceBar = { used: string; gap: string; rest: string; color: string; ahead: boolean }
45
46// A limit against the clock, after token-weather-usage: `━` what is used,
47// `╍` the gap between usage and the time elapsed in the window, `─` the rest.
48// Behind the clock the gap is dim; ahead of it, it takes the bar's color.
49// Green while usage keeps behind time, yellow ahead, red more than 15 points
50// ahead or past 90%.
51export function paceBar(percentUsed: number, kind: string, resetsAt: string | undefined, now: number, cells = PACE_CELLS): PaceBar {
52  const span = SPANS[kind]
53  const reset = resetsAt ? Date.parse(resetsAt) : NaN
54  const elapsedPct = span && Number.isFinite(reset) ? Math.min(100, Math.max(0, (100 * (span - (reset - now))) / span)) : percentUsed
55  const toCells = (p: number) => Math.min(cells, Math.max(0, Math.round((p / 100) * cells)))
56  const used = toCells(percentUsed)
57  const elapsed = toCells(elapsedPct)
58  const ahead = percentUsed > elapsedPct
59  const color = percentUsed >= 90 || percentUsed - elapsedPct > PACE_ALERT ? 'red' : ahead ? 'yellow' : 'green'
60  const low = Math.min(used, elapsed)
61  const high = Math.max(used, elapsed)
62  return { used: '━'.repeat(low), gap: '╍'.repeat(high - low), rest: '─'.repeat(cells - high), color, ahead }
63}
64
65// "resets 14:20" today, "resets Fri 09:00" on another day; "" when unknown.
66export function resetLabel(iso: string | undefined, now: number): string {
67  const at = iso ? Date.parse(iso) : NaN
68  if (!Number.isFinite(at)) return ''
69  const sameDay = new Date(at).toDateString() === new Date(now).toDateString()
70  const day = new Date(at).toLocaleDateString('en-US', { weekday: 'short' })
71  return `resets ${sameDay ? '' : `${day} `}${clock(at)}`
72}
73
74// The old HUD's handoff banner: soon from 60% (or 50% on a hot 5h burn), now from 85%.
75export function handoff(percent: number, fiveHourRate: number | undefined): { text: string; color: string; bold?: boolean } | undefined {
76  if (percent >= CONTEXT_HANDOFF) return { text: '● handoff NOW', color: 'red', bold: true }
77  if (percent >= CONTEXT_WARN) return { text: '● handoff', color: 'yellow' }
78  if (percent >= 50 && (fiveHourRate ?? 0) > 30) return { text: '● handoff', color: 'yellow' }
79  return undefined
80}
81
82export const contextColor = (percent: number) => (percent >= CONTEXT_HANDOFF ? 'red' : percent >= CONTEXT_WARN ? 'yellow' : 'green')
83
84export const compact = (n: number) => (n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
85
86export function clock(at: number): string {
87  const d = new Date(at)
88  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
89}
90