Responsive band above the prompt: context fill, 5h/7d rate limits, session tokens and cost

Two Claude Code mods:
usage-band: a compact, responsive band above the prompt with your context fill, 5h and 7d rate limits, prompt-cache state, cost, and a few extras.sessions: /sessions opens a pane listing this project's past sessions (short id, age, prompt count, first prompt), read from your prompt history.Looks best with a Nerd Font. The icons and rounded bar ends need one, and they are off by default: without a Nerd Font the band still works, with words for labels and half-block ends. A mod cannot install or select a font, so you do that yourself:
brew install --cask font-jetbrains-mono-nerd-font # macOS; any Nerd Font works
then set your terminal's font to it (for Terminal.app: Settings → Profiles → Font) and turn on the icons option below.
Built and tested on Claude Code 2.1.289, macOS, in a terminal. Mods are a new API, so expect it to move.
| Segment | Meaning |
|---|---|
ctx | Context window fill, a green→red ramp (one shade per 10%), a notch at 80%, and used/window when there is room |
5h, 7d | Rate-limit use as one solid bar each. The track behind is shaded by elapsed time: days (or hours) already gone are lighter, the current one lightest. ⟳ is time to reset |
→100% 1h54m | At your current pace you reach 100% of that window in 1h54m, before it resets. Shown only after 15% of the window has passed |
| sparkline | Burn rate: the 5h window in 12 slices, each as tall as the share of the limit used in it. Kept between sessions in the plugin store |
$ | Session cost |
| cache | Prompt-cache lifetime left (green, yellow under 20%) and hit ratio, or cold with how many tokens the next message re-caches |
pt, branch, PR, jev | Tiny group: ponytail mode, git branch (* = uncommitted) and PR number, jev-model-router status, last skill |
↓ ◈ | Output tokens and cached tokens since the mod loaded |
The band measures itself. It tries the richest layout first and, when the terminal is narrow, folds branch/PR/jev/skill into the tiny group, drops the token counts, and finally simplifies the bars. Core segments (ctx, 5h, 7d, cost, cache) stay as long as they can.
git clone https://github.com/cmbaldwin/claude-usage-band ~/claude-usage-band
In ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1",
"CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/claude-usage-band/usage-band:/Users/you/claude-usage-band/sessions"
}
}
Use absolute paths; CLAUDE_CODE_PLUGIN_DIRS is colon-separated. (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS is in my own settings; I have not checked whether newer builds still need it.) Start a new session.
Claude Code only sends prompt_cache to a status line command, not to mods, so the band reads it from a small file written by a status line script. Needs jq.
{
"statusLine": {
"type": "command",
"command": "bash /Users/you/claude-usage-band/scripts/cache-bridge.sh",
"refreshInterval": 30
}
}
The script prints nothing; it writes ~/.claude/state/cache/<session-id>.json (files older than two days are deleted). If you already have a status line, read stdin once and feed both:
input=$(cat)
printf '%s' "$input" | bash /Users/you/claude-usage-band/scripts/cache-bridge.sh
printf '%s' "$input" | your-existing-command
The cache segment needs Claude Code 2.1.251 or later. Without the script it is simply absent.
Set under pluginConfigs in settings.json, keyed by plugin name:
{
"pluginConfigs": {
"usage-band": { "options": { "icons": true, "hideStatus": true } }
}
}
icons (default false): Nerd Font glyphs and rounded bar ends. Set your terminal font to a Nerd Font first, or you get boxes. Without it the labels are words and the ends are half blocks.hideStatus (default false): clear every status line other plugins set ($.ui.status), so the bottom row shows only the mode labels. The band still shows the jev status either way.Raster element. I have only used the band in a terminal.git and gh; PR lookups run only when the branch changes or every five minutes.Each mod has claude plugin validate <folder> and claude plugin test <folder>. Layout, color and history logic are covered by tests; the visuals are not.
MIT licensed.
hooks/register.tsx 165 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Usage } from '../types'
5import { CARD, PAL, advance, fit, nowIdx, sparkWords } from './format'
6import type { Hist } from './format'
7
8const usage = atom(
9 { plugin: 'usage-band', key: 'usage' } as const,
10 { ctxPct: null, ctxTokens: null, ctxWindow: null, limits: [], usd: null, out: 0, cached: 0, now: 0, jev: null, skill: null, pt: null, spark: null, cache: null, branch: null, dirty: false, pr: null } as Usage
11)
12
13// Claude Code's config folder: CLAUDE_CONFIG_DIR, else ~/.claude
14async function claudeDir($: EngineInterface) {
15 return (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
16}
17
18// git is cheap, gh is a network call: refresh git at most every 10s, the PR only when the branch changes or after 5 min
19let gitAt = 0
20let prAt = 0
21let prBranch = ''
22let isWarned = false // context toast fires once per climb past 80%, re-arms under 60%
23
24async function refreshGit($: EngineInterface, now: number) {
25 if (now - gitAt < 10_000) return
26 gitAt = now
27 const cwd = await $.session.cwd()
28 const git = (args: string[]) => $.process.run(['git', ...args], { cwd, timeoutMs: 3000 }).catch(() => null)
29 const head = await git(['rev-parse', '--abbrev-ref', 'HEAD'])
30 if (!head || head.exitCode !== 0) {
31 await update($, usage, u => ({ ...u, branch: null, dirty: false, pr: null }))
32 return
33 }
34 const branch = head.stdout.trim()
35 const dirty = !!(await git(['status', '--porcelain']))?.stdout.trim()
36 let pr: Usage['pr'] | undefined
37 if (branch !== prBranch || now - prAt > 300_000) {
38 prBranch = branch
39 prAt = now
40 const r = await $.process
41 .run(['gh', 'pr', 'view', '--json', 'number,state,isDraft'], { cwd, timeoutMs: 5000 })
42 .catch(() => null)
43 try { pr = r && r.exitCode === 0 ? JSON.parse(r.stdout) : null } catch { pr = null }
44 }
45 await update($, usage, u => ({ ...u, branch, dirty, pr: pr === undefined ? u.pr : pr }))
46}
47
48// cache-bridge.sh writes prompt_cache here, one file per session (the mod API has no live cache numbers)
49async function refreshCache($: EngineInterface) {
50 const id = await $.session.id()
51 const text = await $.fs.read(`${await claudeDir($)}/state/cache/${id}.json`).catch(() => '')
52 let c: Usage['cache'] = null
53 try {
54 const j = text.trim() ? JSON.parse(text) : null
55 if (j && j.caching_observed !== false) {
56 c = { warm: !!j.warm, ttl: j.ttl ?? '1h', expiresAt: j.expires_at ?? null, hit: j.hit_ratio ?? null, misses: j.misses ?? 0, recache: j.recache_tokens_if_cold ?? null }
57 }
58 } catch { c = null }
59 const now = await $.clock.now()
60 await update($, usage, u => ({ ...u, cache: c, now }))
61}
62
63export const register: Register = (on, options) => {
64 const icons = options.icons === true // Nerd Font glyphs; set the terminal font first
65 const hideStatus = options.hideStatus === true
66 on('session.start', async ($, e, next) => {
67 $.clock.every(30_000, () => refreshCache($))
68 return next(e)
69 })
70
71 on('session.measure', async ($, e, next) => {
72 const now = await $.clock.now()
73 await refreshGit($, now)
74 await refreshCache($)
75 const five = e.rateLimits.find(l => l.kind === 'five_hour')
76 let spark: Usage['spark'] = null
77 if (five?.resetsAt) {
78 const prev = ((await $.store.get('five')) as Hist | undefined) ?? null
79 const hist = advance(prev, five.percentUsed, Date.parse(five.resetsAt), now)
80 if (JSON.stringify(hist) !== JSON.stringify(prev)) await $.store.set('five', hist)
81 spark = { buckets: hist.buckets, nowIdx: nowIdx(hist, now) }
82 }
83 const pct = e.context.percent ?? 0
84 if (pct >= 80 && !isWarned) {
85 isWarned = true
86 $.ui.toast(`Context ${Math.round(pct)}% full. Consider /compact.`, { timeoutMs: 8000 })
87 } else if (pct < 60) isWarned = false
88 // the ponytail plugin writes its mode here; no file = no tag
89 const mode = await $.fs.read(`${await claudeDir($)}/.ponytail-active`).then(t => t.trim().split('\n')[0]?.trim() ?? '', () => null)
90 await update($, usage, u => ({
91 ...u,
92 ctxPct: e.context.percent ?? null,
93 ctxTokens: e.context.tokens ?? null,
94 ctxWindow: e.context.window,
95 limits: e.rateLimits,
96 spark,
97 usd: e.cost?.usd ?? null,
98 now,
99 pt: mode === null ? null : mode === '' || mode === 'full' ? 'pt' : `pt:${mode}`
100 }))
101 return next(e)
102 })
103
104 // jev-model-router reports through $.ui.status: show its answer in the band. With hideStatus on, every
105 // status line is also cleared, which leaves the bottom row to the mode labels.
106 on('ui.status', async ($, e, next) => {
107 if (e.text?.startsWith('jev')) {
108 const text = e.text.replace(/^jev\s*·\s*/, '')
109 await update($, usage, u => ({ ...u, jev: text }))
110 }
111 return hideStatus ? next({ ...e, text: undefined }) : next(e)
112 })
113
114 on('skill.prompt', async ($, e, next) => {
115 await update($, usage, u => ({ ...u, skill: e.skill }))
116 return next(e)
117 })
118
119 on('turn.complete', async ($, e, next) => {
120 const t = e.usage
121 if (t) {
122 await update($, usage, u => ({
123 ...u,
124 out: u.out + t.output_tokens,
125 cached: u.cached + t.cache_read_input_tokens + t.cache_creation_input_tokens
126 }))
127 }
128 return next(e)
129 })
130
131 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
132 const u = await read($, usage)
133 const hasData = u.ctxPct !== null || u.limits.length > 0 || u.usd !== null || u.jev !== null || u.branch !== null || u.cache !== null
134 if (e.props.hasSurvey || !hasData) return next(e)
135
136 const { Box, Text, Raster } = $.ui.resolve(e)
137 const segs = fit(u, u.now, e.props.bodyColumns, icons)
138
139 // one rounded, left-aligned box; dim dividers between segments; shrinks to its content
140 const cells = segs.flatMap((seg, i) => {
141 const body = (
142 <Box key={seg.key} gap={1}>
143 {seg.groups.map((g, gi) => (
144 <Text key={gi}>
145 {g.map((r, ri) => (
146 <Text key={ri} color={r.c && (PAL[r.c] ?? r.c)} backgroundColor={r.bg ? (PAL[r.bg] ?? r.bg) : CARD} dimColor={r.d} bold={r.b}>{r.t}</Text>
147 ))}
148 </Text>
149 ))}
150 {seg.spark ? (
151 <Raster key={`${seg.key}-spark`} columns={seg.spark.buckets.length} rows={1} cells={new Uint8Array(sparkWords(seg.spark).buffer).toBase64()} />
152 ) : null}
153 </Box>
154 )
155 return i ? [<Text key={`d${i}`} color="#3f3f46" backgroundColor={CARD}>│</Text>, body] : [body]
156 })
157
158 return (
159 <Box borderStyle="round" borderColor="#3f3f46" backgroundColor={CARD} paddingX={1} gap={1} alignSelf="flex-start">
160 {cells}
161 </Box>
162 )
163 })
164}
165hooks/format.ts 358 lines1import type { Spark, Usage } from '../types'
2
3export type Run = { t: string; c?: string; bg?: string; d?: boolean; b?: boolean }
4// groups are separated by one space; runs inside a group touch
5export type Seg = { key: string; prio: number; groups: Run[][]; spark?: Spark }
6
7export type Mode = {
8 split: 0 | 1 | 2 // cells per day/hour on the 7d and 5h bars; 0 = plain bar
9 bar: number
10 reset: boolean
11 abs: boolean
12 tokens: boolean
13 skill: boolean
14 hit: boolean
15 cbar: number
16 jev: number
17 git: number
18 spark: number
19 eta: boolean
20}
21
22// Richest first; fit() takes the first one whose core segments fit.
23export const MODES: Mode[] = [
24 { split: 2, bar: 8, reset: true, abs: true, tokens: true, skill: true, hit: true, cbar: 6, jev: 26, git: 24, spark: 12, eta: true },
25 { split: 1, bar: 8, reset: true, abs: false, tokens: true, skill: false, hit: true, cbar: 6, jev: 20, git: 20, spark: 8, eta: true },
26 { split: 1, bar: 5, reset: true, abs: false, tokens: false, skill: false, hit: true, cbar: 0, jev: 16, git: 14, spark: 0, eta: false },
27 { split: 0, bar: 5, reset: true, abs: false, tokens: false, skill: false, hit: true, cbar: 0, jev: 16, git: 14, spark: 0, eta: false },
28 { split: 0, bar: 0, reset: false, abs: false, tokens: false, skill: false, hit: false, cbar: 0, jev: 0, git: 0, spark: 0, eta: false },
29]
30
31// Named colors in the data, hex at draw time. Tracks: elapsed / now / still ahead.
32export const PAL: Record<string, string> = {
33 green: '#4ade80', yellow: '#facc15', red: '#f87171', blue: '#60a5fa',
34 cyan: '#22d3ee', magenta: '#c084fc', gray: '#a1a1aa',
35}
36export const CARD = '#141417' // the band's own background
37export const TRACK = { plain: '#3f3f46', past: '#3f3f46', now: '#71717a', future: '#27272a' }
38
39export const k = (n: number) =>
40 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
41
42export const tone = (pct: number) => (pct > 75 ? 'red' : pct > 50 ? 'yellow' : 'green')
43
44export const bar = (pct: number, cells: number) => {
45 const filled = Math.round((Math.min(Math.max(pct, 0), 100) / 100) * cells)
46 return [filled, cells - filled] as const
47}
48
49export const dur = (ms: number) => {
50 const m = Math.max(0, Math.round(ms / 60000))
51 const d = Math.floor(m / 1440)
52 const h = Math.floor((m % 1440) / 60)
53 return d ? `${d}d${h}h` : h ? `${h}h${m % 60}m` : `${m}m`
54}
55
56export const left = (iso: string | undefined, now: number) => (iso ? dur(Date.parse(iso) - now) : '')
57
58export const label = (kind: string) => (kind === 'five_hour' ? '5h' : kind === 'seven_day' ? '7d' : kind)
59
60export const trunc = (s: string, n: number) => (s.length > n ? `${s.slice(0, Math.max(n - 1, 1))}…` : s)
61
62export const prTone = (state: string, isDraft: boolean) =>
63 isDraft ? 'gray' : state === 'MERGED' ? 'magenta' : state === 'CLOSED' ? 'red' : 'green'
64
65const TTL_S: Record<string, number> = { '5m': 300, '1h': 3600 }
66
67// nowS is epoch seconds
68export const cacheView = (c: { warm: boolean; ttl: string; expiresAt: number | null; recache: number | null }, nowS: number) => {
69 const left = c.expiresAt === null ? 0 : c.expiresAt - nowS
70 if (!c.warm || left <= 0) return { warm: false as const, recache: c.recache === null ? '' : k(c.recache) }
71 const total = TTL_S[c.ttl] ?? 3600
72 const mins = Math.max(1, Math.round(left / 60))
73 return {
74 warm: true as const,
75 frac: Math.min(left / total, 1),
76 low: left / total < 0.2,
77 left: mins >= 60 ? `${Math.floor(mins / 60)}h${mins % 60 ? `${mins % 60}m` : ''}` : `${mins}m`,
78 }
79}
80
81// ---- segmented limit bars: one cell group per day (7d) or hour (5h), eighth-blocks for the fill ----
82
83const EIGHTHS = ' ▏▎▍▌▋▊▉'
84const WINDOW: Record<string, { n: number; ms: number }> = {
85 five_hour: { n: 5, ms: 5 * 3600e3 },
86 seven_day: { n: 7, ms: 7 * 86400e3 },
87}
88
89// ---- gradient, pace, icons ----
90
91// green to 50%, yellow to 75%, red beyond; sampled in ten steps (one shade per 10%)
92const STOPS: [number, number][] = [
93 [0, 0x22c55e], [0.45, 0x84cc16], [0.55, 0xeab308], [0.75, 0xf59e0b], [0.85, 0xf97316], [1, 0xef4444],
94]
95const hex = (n: number) => `#${n.toString(16).padStart(6, '0')}`
96
97const ramp = (x: number) => {
98 let i = 0
99 while (i < STOPS.length - 2 && x > STOPS[i + 1][0]) i++
100 const [f0, c0] = STOPS[i]
101 const [f1, c1] = STOPS[i + 1]
102 const t = Math.min(Math.max((x - f0) / (f1 - f0), 0), 1)
103 const ch = (sh: number) => Math.round(((c0 >> sh) & 255) * (1 - t) + ((c1 >> sh) & 255) * t)
104 return hex((ch(16) << 16) | (ch(8) << 8) | ch(0))
105}
106
107// the shade for a position (or a fill) 0..1: ten steps
108export const grad = (f: number) => ramp((Math.min(Math.floor(Math.min(Math.max(f, 0), 0.9999) * 10), 9) + 0.5) / 10)
109
110const RANK: Record<string, number> = { green: 0, yellow: 1, red: 2 }
111
112// Color by pace: where the window is headed, not only where it is. null elapsed = no pace known.
113export const pace = (pct: number, elapsed: number | null, winMs: number, remainingMs: number) => {
114 const base = tone(pct)
115 if (elapsed === null || elapsed < 0.15 || pct < 5) return { tone: base, etaMs: null as number | null }
116 const u = pct / 100
117 const projected = u / elapsed
118 const byPace = projected >= 1 ? 'red' : projected >= 0.85 ? 'yellow' : 'green'
119 const msFull = ((1 - u) * elapsed * winMs) / u
120 return {
121 tone: RANK[byPace] > RANK[base] ? byPace : base,
122 etaMs: projected >= 1 && msFull < remainingMs ? msFull : null,
123 }
124}
125
126// Nerd Font glyphs, used only when the `icons` option is on
127const ICON = {
128 ctx: '\uf2db', five_hour: '\uf017', seven_day: '\uf073', cache: '\uf1c0',
129 git: '\ue725', pr: '\uf407', jev: '\uf0e7', skill: '\uf0eb',
130}
131
132// ---- solid bars: cells are background color; the edge cell is an eighth-block over the track ----
133
134type ColorAt = (pos: number) => string // pos 0..1 along the bar
135
136const cell = (e: number, color: string, track: string): Run =>
137 e >= 8 ? { t: ' ', bg: color } : e === 0 ? { t: ' ', bg: track } : { t: EIGHTHS[e], c: color, bg: track }
138
139// Rounded ends: Nerd Font half circles, else half blocks. Colored like the end cells.
140export const withCaps = (runs: Run[], icons: boolean): Run[] => {
141 if (!runs.length) return runs
142 const first = runs[0]
143 const last = runs[runs.length - 1]
144 return [
145 { t: icons ? '\ue0b6' : '▐', c: first.bg ?? first.c },
146 ...runs,
147 { t: icons ? '\ue0b4' : '▌', c: last.bg ?? last.c },
148 ]
149}
150
151const TICK = '▏'
152
153const barRuns = (frac: number, cells: number, color: ColorAt, track: string, tick?: number): Run[] => {
154 const total = Math.round(Math.min(Math.max(frac, 0), 1) * 8 * cells)
155 const tickAt = tick === undefined ? -1 : Math.min(cells - 1, Math.floor(tick * cells))
156 return Array.from({ length: cells }, (_, i) => {
157 const e = Math.min(Math.max(total - i * 8, 0), 8)
158 const col = color((i + 0.5) / cells)
159 const run = cell(e, col, track)
160 if (i !== tickAt) return run
161 // a thin notch at the threshold: dark on a filled cell, light on an empty one
162 return { t: TICK, c: e >= 8 ? CARD : '#e4e4e7', bg: e >= 8 ? col : track }
163 })
164}
165
166// `n` segments of `w` cells drawn as one solid bar; the track behind it is shaded by elapsed time:
167// past segments, the current one (lightest), and the ones still ahead (darkest).
168export const splitBar = (pct: number, n: number, w: number, nowIdx: number | null, color: string | ColorAt): Run[] => {
169 const at: ColorAt = typeof color === 'string' ? () => color : color
170 const runs: Run[] = []
171 for (let i = 0; i < n; i++) {
172 const f = Math.min(Math.max((pct / 100) * n - i, 0), 1)
173 const track = nowIdx === null ? TRACK.plain : i < nowIdx ? TRACK.past : i === nowIdx ? TRACK.now : TRACK.future
174 for (let j = 0; j < w; j++) {
175 const e = Math.round(Math.min(Math.max(f * w - j, 0), 1) * 8)
176 runs.push(cell(e, at((i * w + j + 0.5) / (n * w)), track))
177 }
178 }
179 return runs
180}
181
182const plainBar = (pct: number, cells: number, color: ColorAt, tick?: number): Run[] =>
183 barRuns(pct / 100, cells, color, TRACK.plain, tick)
184
185const meter = (key: string, prio: number, name: string, pct: number, c: string, barRuns: Run[], extras: Run[][]): Seg => {
186 const groups: Run[][] = [[{ t: name, d: true }]]
187 if (barRuns.length) groups.push(barRuns)
188 groups.push([{ t: `${Math.round(pct)}%`, c, b: true }])
189 groups.push(...extras)
190 return { key, prio, groups }
191}
192
193// "balanced 0.82 → sonnet/high" or "balanced 82% → sonnet/high" -> "bal82→s/h"; "fast 0.30 · unchanged" -> "fas30"; else "?"
194export const jevShort = (text: string) => {
195 const m = /^(\w+)\s+(\d*\.?\d+)(%?)/.exec(text)
196 if (!m) return '?'
197 const n = Number(m[2])
198 const pct = m[3] || n > 1 ? Math.round(n) : Math.round(n * 100) // 0.30 -> 30, 82% -> 82
199 const base = `${m[1].slice(0, 3)}${pct}`
200 const to = /→\s*(\w+)(?:\/(\w+))?/.exec(text)
201 return to ? `${base}→${to[1][0]}${to[2] ? `/${to[2][0]}` : ''}` : base
202}
203
204// misc: 0 = pt, git, jev and skill as separate segments; 1-4 = one tiny group (pt, +git, +jev, +skill, richest at 1)
205export const buildSegs = (u: Usage, m: Mode, now: number, icons = false, misc = 0): Seg[] => {
206 const segs: Seg[] = []
207 const lab = (text: string, key: keyof typeof ICON) => (icons ? ICON[key] : text)
208
209 if (u.ctxPct !== null) {
210 const abs = m.abs && u.ctxTokens !== null && u.ctxWindow ? `${k(u.ctxTokens)}/${k(u.ctxWindow)}` : ''
211 // gradient bar, with a notch where the compact toast fires (80%)
212 const bar = m.bar ? withCaps(plainBar(u.ctxPct, m.bar, grad, m.bar >= 6 ? 0.8 : undefined), icons) : []
213 segs.push(meter('ctx', 1, lab('ctx', 'ctx'), u.ctxPct, tone(u.ctxPct), bar, abs ? [[{ t: abs, d: true }]] : []))
214 }
215
216 u.limits.forEach(l => {
217 const win = WINDOW[l.kind]
218 const resetMs = l.resetsAt ? Date.parse(l.resetsAt) : null
219 const elapsed = win && resetMs !== null ? 1 - (resetMs - now) / win.ms : null
220 const p = pace(l.percentUsed, elapsed, win?.ms ?? 0, resetMs === null ? 0 : resetMs - now)
221 let runs: Run[] = []
222 if (m.split && win) {
223 const nowIdx = elapsed === null ? null : Math.min(win.n - 1, Math.max(0, Math.floor(elapsed * win.n)))
224 runs = withCaps(splitBar(l.percentUsed, win.n, m.split, nowIdx, grad), icons)
225 } else if (m.bar) runs = withCaps(plainBar(l.percentUsed, m.bar, grad), icons)
226 const extras: Run[][] = []
227 if (m.reset && l.resetsAt) extras.push([{ t: `⟳${left(l.resetsAt, now)}`, d: true }])
228 if (m.eta && p.etaMs !== null) extras.push([{ t: `→100% ${dur(p.etaMs)}`, c: 'yellow' }])
229 const seg = meter(l.kind, l.kind === 'five_hour' ? 2 : 3, lab(label(l.kind), l.kind as keyof typeof ICON), l.percentUsed, tone(l.percentUsed), runs, extras)
230 if (l.kind === 'five_hour' && m.spark && u.spark) seg.spark = { ...u.spark, buckets: u.spark.buckets.slice(0, m.spark) }
231 segs.push(seg)
232 })
233
234 if (u.usd !== null) segs.push({ key: 'usd', prio: 4, groups: [[{ t: `$${u.usd.toFixed(2)}`, c: 'yellow' }]] })
235
236 if (u.cache) {
237 const v = cacheView(u.cache, now / 1000)
238 const groups: Run[][] = [[{ t: lab('cache', 'cache'), d: true }]]
239 if (v.warm) {
240 const c = v.low ? 'yellow' : 'green'
241 groups.push([{ t: '●', c }])
242 if (m.cbar) groups.push(withCaps(plainBar(v.frac * 100, m.cbar, () => c), icons))
243 groups.push([{ t: v.left, c, b: true }])
244 if (m.hit && u.cache.hit !== null) groups.push([{ t: `hit ${Math.round(u.cache.hit * 100)}%`, d: true }])
245 } else {
246 groups.push([{ t: `○ cold${v.recache ? ` · re-caches ${v.recache}` : ''}`, c: 'red' }])
247 }
248 segs.push({ key: 'cache', prio: 5, groups })
249 }
250
251 if (misc === 0) {
252 if (u.branch && m.git) {
253 const groups: Run[][] = [[{ t: `${icons ? ICON.git : '⎇'} ${trunc(u.branch, m.git)}`, c: 'blue' }, ...(u.dirty ? [{ t: '*', c: 'yellow' }] : [])]]
254 if (u.pr) groups.push([{ t: icons ? `${ICON.pr} ${u.pr.number}` : `#${u.pr.number}`, c: prTone(u.pr.state, u.pr.isDraft) }])
255 segs.push({ key: 'git', prio: 6, groups })
256 }
257
258 if (u.pt) segs.push({ key: 'pt', prio: 7, groups: [[{ t: u.pt, d: true }]] })
259
260 if (u.jev && m.jev) segs.push({ key: 'jev', prio: 8, groups: [[{ t: lab('jev', 'jev'), d: true }], [{ t: trunc(u.jev, m.jev), c: 'cyan' }]] })
261
262 if (u.skill && m.skill) segs.push({ key: 'skill', prio: 9, groups: [[{ t: lab('skill', 'skill'), d: true }], [{ t: trunc(u.skill, 14), c: 'magenta' }]] })
263 } else {
264 const groups: Run[][] = []
265 if (u.pt) groups.push([{ t: u.pt, d: true }])
266 if (u.branch && misc <= 3) {
267 // last path part of the branch, and the PR number stuck to it: "⎇login-to…*218"
268 const name = trunc(u.branch.split('/').pop() || u.branch, 9)
269 groups.push([
270 { t: `${icons ? ICON.git : '⎇'}${name}`, c: 'blue' },
271 ...(u.dirty ? [{ t: '*', c: 'yellow' }] : []),
272 ...(u.pr ? [{ t: `${u.pr.number}`, c: prTone(u.pr.state, u.pr.isDraft) }] : []),
273 ])
274 }
275 if (u.jev && misc <= 2) groups.push([{ t: jevShort(u.jev), c: 'cyan' }])
276 if (u.skill && misc <= 1) groups.push([{ t: `/${trunc(u.skill, 8)}`, c: 'magenta' }])
277 if (groups.length) segs.push({ key: 'misc', prio: 6, groups })
278 }
279
280 if (m.tokens) {
281 segs.push({ key: 'tok', prio: 10, groups: [[{ t: `↓${k(u.out)}`, c: 'green' }], [{ t: `◈${k(u.cached)}`, c: 'cyan' }]] })
282 }
283
284 return segs
285}
286
287// ---- fitting ----
288
289export const width = (s: Seg) =>
290 s.groups.reduce((n, g, i) => n + (i ? 1 : 0) + g.reduce((m, r) => m + [...r.t].length, 0), 0) +
291 (s.spark ? s.spark.buckets.length + 1 : 0)
292
293// " │ " between segments, plus border and padding
294export const total = (segs: Seg[]) => segs.reduce((n, s, i) => n + width(s) + (i ? 3 : 0), 0) + 4
295
296// Order of attempts: keep the rich split bars (modes 0-1) and give the tiny group as much as fits, richest
297// first; only then fall back to simpler bars. Each try is also made without the token counts.
298const ATTEMPTS: [number, number][] = [
299 ...[0, 1, 2, 3].flatMap(misc => [0, 1].map(m => [m, misc] as [number, number])),
300 ...MODES.flatMap((_, m) => [4, 3, 2, 1, 0].map(misc => [m, misc] as [number, number])).filter(([m]) => m >= 1),
301 [0, 4],
302]
303
304export const fit = (u: Usage, now: number, cols: number, icons = false): Seg[] => {
305 for (const [m, misc] of ATTEMPTS) {
306 const full = buildSegs(u, MODES[m], now, icons, misc)
307 // token counts only show where nothing else was squeezed for them: rich bars, richest group
308 const richest = m <= 1 && misc <= 3
309 for (const segs of richest ? [full, full.filter(s => s.key !== 'tok')] : [full.filter(s => s.key !== 'tok')]) {
310 if (total(segs) <= cols) return segs
311 }
312 }
313 // nothing fit even in the plainest mode: shed everything but the most important until it does
314 let segs = buildSegs(u, MODES[MODES.length - 1], now, icons, 4).filter(s => s.key !== 'tok')
315 while (total(segs) > cols && segs.length > 1) {
316 const worst = segs.reduce((a, s) => (s.prio > a.prio ? s : a))
317 segs = segs.filter(s => s !== worst)
318 }
319 return segs
320}
321
322// ---- burn-rate sparkline: % of the 5h limit used in each time slice of the window ----
323
324export const SLICES = 12
325const FIVE_H = 5 * 3600e3
326
327export type Hist = { reset: number; last: number; buckets: number[] }
328
329// Fold one reading into the window's history. reset = resetsAt in ms (rounded to the minute: it jitters).
330export const advance = (h: Hist | null, pct: number, resetMs: number, nowMs: number): Hist => {
331 const reset = Math.round(resetMs / 60000) * 60000
332 const fresh = !h || h.reset !== reset
333 const base: Hist = fresh ? { reset, last: pct, buckets: Array(SLICES).fill(0) } : { ...h!, buckets: [...h!.buckets] }
334 const start = reset - FIVE_H
335 const idx = Math.min(SLICES - 1, Math.max(0, Math.floor(((nowMs - start) / FIVE_H) * SLICES)))
336 base.buckets[idx] += Math.max(0, pct - base.last)
337 base.last = pct
338 return base
339}
340
341export const nowIdx = (h: Hist, nowMs: number) =>
342 Math.min(SLICES - 1, Math.max(0, Math.floor(((nowMs - (h.reset - FIVE_H)) / FIVE_H) * SLICES)))
343
344const LOWER = ' ▁▂▃▄▅▆▇█'
345const rgb = (hex: string) => parseInt(hex.slice(1), 16)
346
347// Raster words [glyph, fg, bg] per cell, one row. Height = share of the busiest slice (at least 5%).
348export const sparkWords = (sp: Spark): Uint32Array => {
349 const top = Math.max(5, ...sp.buckets)
350 const out = new Uint32Array(sp.buckets.length * 3)
351 sp.buckets.forEach((v, i) => {
352 const level = v <= 0 ? 0 : Math.max(1, Math.round((v / top) * 8))
353 const track = i < sp.nowIdx ? TRACK.past : i === sp.nowIdx ? TRACK.now : TRACK.future
354 out.set([LOWER.codePointAt(level)!, rgb(grad(v / top)), rgb(track)], i * 3)
355 })
356 return out
357}
358types/index.d.ts 28 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2export type Cache = { warm: boolean; ttl: string; expiresAt: number | null; hit: number | null; misses: number; recache: number | null }
3export type Spark = { buckets: number[]; nowIdx: number }
4export type Usage = {
5 ctxPct: number | null
6 ctxTokens: number | null
7 ctxWindow: number | null
8 limits: Limit[]
9 usd: number | null
10 out: number
11 cached: number
12 now: number
13 jev: string | null
14 skill: string | null
15 pt: string | null
16 spark: Spark | null
17 cache: Cache | null
18 branch: string | null
19 dirty: boolean
20 pr: { number: number; state: string; isDraft: boolean } | null
21}
22
23declare module 'claude-code' {
24 interface PluginState {
25 'usage-band': { usage: Usage }
26 }
27}
28