SLOPSHOPPER

usage-band

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

newbandtoastpromptprocesstimer
v0.4.0MITupdated 2026-10-06cmbaldwin/claude-usage-band/usage-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-band
› 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 ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ctx ▐ ▉ ▏ ▌ 49% │ 5h ▐ ▌ ▌ 31% ⟳NaNm │ $0.42 │ ⎇ feat/auth-refresh* #undefined │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ctx ▐ ▉ ▏ ▌ 49% │ 5h ▐ ▌ ▌ 31% ⟳NaNm │ $0.42 │ ⎇ feat/auth-refresh* #undefined │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

claude-usage-band

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.

What the band shows

SegmentMeaning
ctxContext window fill, a green→red ramp (one shade per 10%), a notch at 80%, and used/window when there is room
5h, 7dRate-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% 1h54mAt your current pace you reach 100% of that window in 1h54m, before it resets. Shown only after 15% of the window has passed
sparklineBurn 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
cachePrompt-cache lifetime left (green, yellow under 20%) and hit ratio, or cold with how many tokens the next message re-caches
pt, branch, PR, jevTiny 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.

Install

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.

Cache segment (optional)

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.

Options

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.

Notes

  • Colors are hex RGB. A true-color terminal draws them exactly; others approximate to 256 colors.
  • The sparkline uses the terminal Raster element. I have only used the band in a terminal.
  • Git and PR info need git and gh; PR lookups run only when the branch changes or every five minutes.
  • Segments show only what they can read: no ponytail file, no jev status, or no repo just means that piece is absent.

Develop

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.

Source 3 files
hooks/register.tsx 165 lines
1import { 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}
165
hooks/format.ts 358 lines
1import 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}
358
types/index.d.ts 28 lines
1export 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