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.

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
● handoff from 60% (or 50% while the 5h burn is past 30 %/h), ● handoff NOW from 85%.━ 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).
| Part | Session value | Config value |
|---|---|---|
| model | The main loop's model, as /model shows it | model in your settings |
| effort | What /effort set, or the effort the last main-loop request asked for | The session model's modelSettings.<model>.effortLevel (where /effort saves it), else the top-level effortLevel |
| advisor | How many times the advisor ran this session | advisorModel 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.
/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.
claude plugin validate ./plugins/claudecodemonitor-hud-mod
claude plugin test ./plugins/claudecodemonitor-hud-modhooks/register.ts 223 lines1import 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}
223hooks/meters.ts 90 lines1// 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