SLOPSHOPPER

usage-band

Your Claude plan usage in one line above the prompt: 5-hour and weekly limits with reset times, context window, model and session cost.

newbandguardtoasttimer
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 ◔ 5h 31% resets Invalid Date Invalid Date │ ◑ Context 49% 97k / 200k │ Opus 5.5 · $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◔ 5h 31% resets Invalid Date Invalid Date │ ◑ Context 49% 97k / 200k │ Opus 5.5 · $0.42
README

claude-usage-mod

Your Claude plan usage, always visible: a one-line band directly above the prompt in Claude Code.

The usage band above the prompt in the Claude desktop app

◔ 5h 72% ⚠ resets 1:19 PM  │  ◑ Week 50% resets Sat 2:30 PM  │  ○ Context 40% 404k / 1M  │  Opus 5.5 · $13.24 (+$0.41)
  • 5h: your 5-hour session limit, with the time it resets
  • Week: your weekly limit, with its reset time
  • Context: how full this chat's context window is, in tokens
  • Model and this session's cost at API prices, with what the last reply added (you aren't charged this on a Pro/Max plan)

It also helps you stay ahead of your limits:

  • Pace warning ⚠: shown next to a limit you're using faster than its window is passing, so you know you'll run out before it resets.
  • Limit alerts: a pop-up when the 5-hour or weekly limit reaches 80% and 95%, and when it resets. Each shows once.
  • Compact button: appears in the band once the context window is over 70% full. Click it to compact the conversation.

Each of these can be turned off in Settings.

Each figure has a small progress ring (pie glyphs ○ ◔ ◑ ◕ ● in the terminal) that turns yellow at 70% and red at 90%. The band updates live: every 15 seconds, after each step Claude takes, and after every reply. It always stays on one line: in a narrow window it shortens the reset times to ↻ 1:19 PM, then drops the token count, then the model. The cost always stays.

Works in the Claude desktop app (Code tab) on macOS and Windows, and in claude in the terminal.

Install

In a Claude Code chat:

/plugin marketplace add KhadeerBasha1232/claude-usage-mod
/plugin install usage-band@claude-usage-mod

Then fully quit and reopen Claude (in the terminal, /reload-plugins is enough). In a new chat the band appears after your first message, once Claude Code has started.

Or from your shell:

claude plugin marketplace add KhadeerBasha1232/claude-usage-mod
claude plugin install usage-band@claude-usage-mod

Requires Claude Code with mod support (v2.1.286 or later).

Tip: turn on auto-update so you get new versions without doing anything.

Where the numbers come from

FigureSource
5h / WeekClaude Code's own rate-limit figures, which give exact reset times. Claude Code only reports them in some sessions, and only after a reply, so the band saves the last exact figures and every chat on your computer reuses them. Until it has seen exact figures once, it falls back to the usage history the Claude desktop app keeps on your computer (plan-usage-history.json).
Context, model, costClaude Code's session figures

Saved reset times stay exact: the weekly one rolls on a week at a time, and the percentage always comes from the newest reading. Only when the band has never seen exact figures are the reset times worked out from the app's history, marked with ~:

  • 5-hour reset: five hours from when the current window started filling (usually within a few minutes of the real time).
  • Weekly reset: detected from when your weekly figure dropped back down. The app only records usage while it's open, so a reset is often only noticed hours or days later. The band shows a detected time only once it can pin it down to within about 3 hours (it gets better as more weeks of history build up). Until then the weekly figure shows without a time. For an exact time from day one, set weekly_reset (below).

The band only reads local files. It makes no network requests and never touches your credentials.

Settings

OptionWhat it does
weekly_resetRecommended. Your weekly limit's reset time in local time, e.g. Sat 14:30 or Saturday 2:30 PM. You can find it in Claude Settings → Usage. Leave empty to detect it automatically.
alertsPop-ups at 80% and 95% and when a limit resets. On by default.
paceThe ⚠ pace warning. On by default.
compact_buttonThe Compact button once the context is over 70% full. On by default.
reply_costThe last reply's cost next to the session cost. On by default.

Set it with:

/plugin configure usage-band@claude-usage-mod

Update

Turn on auto-update (recommended)

Claude Code doesn't auto-update third-party marketplaces unless you ask it to. Turn it on once and you'll get new versions by yourself:

  1. In a Claude Code chat, run /plugin.
  2. Go to the Marketplaces tab and select claude-usage-mod.
  3. Select Enable auto-update.

In the desktop app, use the same /plugin command in a Code tab chat.

Or add "autoUpdate": true to the marketplace's entry in ~/.claude/settings.json:

"extraKnownMarketplaces": {
  "claude-usage-mod": {
    "source": { "source": "github", "repo": "KhadeerBasha1232/claude-usage-mod" },
    "autoUpdate": true
  }
}

With auto-update on, Claude Code checks for a new version a few minutes after your first message in a chat. When it finds one, it shows Plugin updated: usage-band · Run /reload-plugins to apply, and your next chat loads it.

Update by hand

/plugin marketplace update claude-usage-mod

Uninstall

/plugin uninstall usage-band@claude-usage-mod

Development

claude plugin validate --strict plugins/usage-band
claude plugin test plugins/usage-band

To try changes live, run claude --plugin-dir plugins/usage-band. Saving a file reloads the mod.

License

MIT

Source 1 files
hooks/register.tsx 511 lines
1import type { Register } from 'claude-code'
2
3// One line above the prompt with every usage figure:
4//
5//   ◔ 5h 7% resets 1:19 PM  │  ◑ Week 46% resets Sat 2:30 PM  │  ○ Context 16% 164k / 1M  │  Opus 5.5 · $2.14
6//
7// The rings are SVG on the desktop and pie glyphs in the terminal.
8//
9// Plan limits come from the engine when it has them (exact reset times). Desktop
10// sessions often get none, so we fall back to the app's own usage samples and
11// work the reset times out: the 5-hour window runs five hours from when it
12// started filling, and the weekly window resets at the same moment every week,
13// taken from the `weekly_reset` option or detected from where the weekly figure
14// last dropped.
15
16const HOUR = 3_600_000
17const FIVE_HOURS = 5 * HOUR
18const WEEK = 7 * 24 * HOUR
19const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
20
21
22type Limit = { key: string; label: string; pct: number; resetsAt?: number; isEstimate?: boolean; readAt?: number }
23type AppSample = { t: number; org?: string; u: { fh?: number; sd?: number } }
24
25function colorFor(pct: number) {
26  if (pct >= 90) return 'red'
27  if (pct >= 70) return 'yellow'
28  return 'green'
29}
30
31const RING_HEX: Record<string, string> = { green: '#3fb950', yellow: '#d29922', red: '#f85149' }
32
33// A small progress ring for the desktop, which draws SVG. It's drawn as a plain
34// image: an interactive one reloads, and flickers, every time the band redraws.
35function ringSvg(pct: number) {
36  const r = 5.5
37  const c = 2 * Math.PI * r
38  const on = (Math.max(0, Math.min(pct, 100)) / 100) * c
39  const stroke = RING_HEX[colorFor(pct)]
40  return (
41    `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14">` +
42    `<circle cx="7" cy="7" r="${r}" fill="none" stroke="#8b949e" stroke-opacity="0.3" stroke-width="2"/>` +
43    `<circle cx="7" cy="7" r="${r}" fill="none" stroke="${stroke}" stroke-width="2" stroke-linecap="round" ` +
44    `stroke-dasharray="${on.toFixed(2)} ${c.toFixed(2)}" transform="rotate(-90 7 7)"/>` +
45    `</svg>`
46  )
47}
48
49// The terminal can't draw SVG, so it gets a pie glyph.
50function pieGlyph(pct: number) {
51  if (pct < 12.5) return '○'
52  if (pct < 37.5) return '◔'
53  if (pct < 62.5) return '◑'
54  if (pct < 87.5) return '◕'
55  return '●'
56}
57
58function clock(at: number, now: number) {
59  const d = new Date(at)
60  const time = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
61  if (d.toDateString() === new Date(now).toDateString()) return time
62  return `${d.toLocaleDateString([], { weekday: 'short' })} ${time}`
63}
64
65function compact(n: number) {
66  if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
67  if (n >= 1000) return `${Math.round(n / 1000)}k`
68  return `${n}`
69}
70
71// The next moment matching a weekly time such as "Sat 14:30", "saturday 2:30 pm"
72// or "Sat 2pm", in local time; undefined when the text doesn't read as one.
73function prettyModel(id: string) {
74  const m = id.replace(/\[.*\]$/, '').match(/claude-([a-z]+)-(\d+)(?:-(\d+))?/)
75  if (!m) return id
76  const name = m[1][0].toUpperCase() + m[1].slice(1)
77  return m[3] && m[3].length <= 2 ? `${name} ${m[2]}.${m[3]}` : `${name} ${m[2]}`
78}
79
80function nextConfiguredReset(text: string, now: number) {
81  const m = text.trim().toLowerCase().match(/^([a-z]{3})[a-z]*\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/)
82  if (!m) return undefined
83  const day = DAYS.indexOf(m[1])
84  let hour = Number(m[2])
85  const minute = Number(m[3] ?? 0)
86  if (day < 0 || minute > 59) return undefined
87  if (m[4]) {
88    if (hour < 1 || hour > 12) return undefined
89    hour = (hour % 12) + (m[4] === 'pm' ? 12 : 0)
90  } else if (hour > 23) {
91    return undefined
92  }
93  const at = new Date(now)
94  at.setHours(hour, minute, 0, 0)
95  at.setDate(at.getDate() + ((day - at.getDay() + 7) % 7))
96  if (at.getTime() <= now) at.setDate(at.getDate() + 7)
97  return at.getTime()
98}
99
100// The weekly figure falls back to near zero when its window resets, somewhere
101// between the reading before the drop and the one after. The app only samples
102// while it's open, so that gap can be days: each drop gives a window the reset
103// fell in, and since the reset repeats weekly, overlapping the windows of every
104// drop narrows it down. A time is only given once that's within a few hours.
105const DETECT_PRECISION = 3 * HOUR
106
107function nextDetectedReset(samples: AppSample[], now: number) {
108  const windows: Array<[number, number]> = []
109  for (let i = 1; i < samples.length; i++) {
110    const prev = samples[i - 1].u.sd, cur = samples[i].u.sd
111    if (prev != null && cur != null && prev - cur >= 5 && cur <= prev / 2) {
112      windows.push([samples[i - 1].t, samples[i].t])
113    }
114  }
115  if (!windows.length) return undefined
116
117  // Start from the latest drop and narrow it with the earlier ones, each moved
118  // forward by whole weeks; one that doesn't overlap is ignored.
119  let [lo, hi] = windows[windows.length - 1]
120  for (const [l, h] of windows.slice(0, -1)) {
121    const shift = Math.round((hi - h) / WEEK) * WEEK
122    const nlo = Math.max(lo, l + shift), nhi = Math.min(hi, h + shift)
123    if (nlo <= nhi) [lo, hi] = [nlo, nhi]
124  }
125  if (hi - lo > DETECT_PRECISION) return undefined
126
127  const resetAt = (lo + hi) / 2
128  return resetAt + Math.ceil((now - resetAt) / WEEK) * WEEK
129}
130
131// The current 5-hour window started where the latest run of non-zero readings
132// began: after a zero reading, a drop, or a gap longer than a window.
133function fiveHourStart(samples: AppSample[]) {
134  let i = samples.length - 1
135  if (!(samples[i]?.u.fh! > 0)) return undefined
136  while (i > 0) {
137    const prev = samples[i - 1], cur = samples[i]
138    const restarted = !(prev.u.fh! > 0) || prev.u.fh! > cur.u.fh! || cur.t - prev.t > FIVE_HOURS
139    if (restarted) {
140      // A zero reading shortly before is the closest we have to the first message.
141      return !(prev.u.fh! > 0) && cur.t - prev.t < 30 * 60_000 ? prev.t : cur.t
142    }
143    i--
144  }
145  return samples[0].t
146}
147
148// Where the desktop app keeps its usage samples: %APPDATA%\Claude on Windows,
149// ~/Library/Application Support/Claude on macOS, ~/.config/Claude on Linux.
150async function usageFilePath($: any) {
151  const appData = await $.env.get('APPDATA').catch(() => undefined)
152  if (appData) return `${appData}\\Claude\\plan-usage-history.json`
153  const home = await $.env.get('HOME').catch(() => undefined)
154  if (!home) throw new Error('no home directory')
155  for (const dir of [`${home}/Library/Application Support/Claude`, `${home}/.config/Claude`]) {
156    if (await $.fs.exists(`${dir}/plan-usage-history.json`)) return `${dir}/plan-usage-history.json`
157  }
158  throw new Error('no usage file')
159}
160
161async function appLimits($: any, now: number, weeklyReset: string): Promise<Limit[]> {
162  try {
163    const json = JSON.parse(await $.fs.read(await usageFilePath($)))
164    const all: AppSample[] = (json.samples ?? []).filter((s: AppSample) => s && s.u)
165    const last = all[all.length - 1]
166    if (!last) return []
167    // The file can hold readings from more than one account: keep the current one's.
168    const samples = all.filter(s => s.org === last.org)
169
170    const limits: Limit[] = []
171
172    if (last.u.fh != null) {
173      const start = fiveHourStart(samples)
174      const resetsAt = start != null ? start + FIVE_HOURS : undefined
175      const isExpired = resetsAt == null || resetsAt <= now
176      limits.push({
177        key: 'five_hour',
178        label: '5h',
179        pct: isExpired ? 0 : last.u.fh,
180        resetsAt: isExpired ? undefined : resetsAt,
181        isEstimate: true,
182        readAt: last.t,
183      })
184    }
185
186    if (last.u.sd != null) {
187      const configured = weeklyReset ? nextConfiguredReset(weeklyReset, now) : undefined
188      const resetsAt = configured ?? nextDetectedReset(samples, now)
189      // A reading from before the last weekly reset belongs to the old week.
190      const isStale = resetsAt != null && last.t < resetsAt - WEEK
191      limits.push({
192        key: 'seven_day',
193        label: 'Week',
194        pct: isStale ? 0 : last.u.sd,
195        resetsAt,
196        isEstimate: configured == null,
197        readAt: last.t,
198      })
199    }
200
201    return limits
202  } catch {
203    return []
204  }
205}
206
207// The last exact figures Claude Code reported, kept in the store every session
208// on this machine shares: Claude Code only reports them in some sessions, and
209// only after a reply, so the others borrow them.
210type Saved = { at: number; limits: Array<{ kind: string; percentUsed: number; resetsAt?: string }> }
211const SAVED_KEY = 'rateLimits'
212const PERIOD: Record<string, number> = { five_hour: FIVE_HOURS, seven_day: WEEK }
213
214// An exact reset that has passed moves on by whole windows for the weekly
215// limit; a passed 5-hour reset says nothing about the next window.
216function rollForward(kind: string, resetsAt: number, now: number) {
217  if (resetsAt > now) return resetsAt
218  return kind === 'seven_day' ? resetsAt + Math.ceil((now - resetsAt) / WEEK) * WEEK : undefined
219}
220
221// Saved exact reset times win over estimated ones. The percentage is the newest
222// reading of the current window: the saved one, or the app's if it's newer, or
223// zero when the window has reset since either was taken.
224function withSaved(saved: Saved | undefined, app: Limit[], now: number): Limit[] {
225  if (!saved?.limits?.length) return app
226  const kinds = ORDER.filter(k => app.some(a => a.key === k) || saved.limits.some(l => l.kind === k))
227  const out: Limit[] = []
228  for (const kind of kinds) {
229    const a = app.find(l => l.key === kind)
230    const s = saved.limits.find(l => l.kind === kind)
231    const exact = s?.resetsAt ? rollForward(kind, Date.parse(s.resetsAt), now) : undefined
232    if (s == null || exact == null) {
233      if (a) out.push(a)
234      continue
235    }
236    const windowStart = exact - (PERIOD[kind] ?? WEEK)
237    const readings = [
238      { pct: s.percentUsed, at: saved.at },
239      ...(a?.readAt != null ? [{ pct: a.pct, at: a.readAt }] : []),
240    ].filter(r => r.at >= windowStart)
241    const newest = readings.sort((x, y) => y.at - x.at)[0]
242    out.push({ key: kind, label: LABELS[kind] ?? kind, pct: newest ? newest.pct : 0, resetsAt: exact })
243  }
244  return out
245}
246
247// What the band shows can change between turns only when the app's usage file
248// changes, another chat saves exact figures, or a reset time passes.
249async function changeSignature($: any, nextResetAt: number) {
250  let file = 0
251  try {
252    file = (await $.fs.stat(await usageFilePath($))).mtimeMs
253  } catch {}
254  const saved = ((await $.store.get(SAVED_KEY).catch(() => undefined)) as Saved | undefined)?.at ?? 0
255  return `${file}:${saved}:${Date.now() >= nextResetAt}`
256}
257
258const LABELS: Record<string, string> = { five_hour: '5h', seven_day: 'Week', spend_limit: 'Spend' }
259const ORDER = ['five_hour', 'seven_day', 'spend_limit']
260const NAMES: Record<string, string> = { five_hour: '5-hour limit', seven_day: 'Weekly limit', spend_limit: 'Spend limit' }
261
262// Using a limit faster than its window is passing: at least 20% used, and more
263// than 15 points ahead of the share of the window that has gone by.
264function isAhead(l: Limit, now: number) {
265  const period = PERIOD[l.key]
266  if (l.resetsAt == null || period == null || l.pct < 20) return false
267  const elapsed = Math.min(1, Math.max(0, 1 - (l.resetsAt - now) / period))
268  return l.pct / 100 > elapsed + 0.15
269}
270
271// Pop-ups when a limit crosses 80% and 95%, and when one resets. Each fires once
272// per window, remembered in the store every session shares so two open chats
273// don't both show it.
274type Alerts = { fired: string[]; windows: Record<string, number> }
275const ALERTS_KEY = 'alerts'
276const THRESHOLDS = [95, 80]
277
278async function raiseAlerts($: any, limits: Limit[], now: number) {
279  const state = ((await $.store.get(ALERTS_KEY).catch(() => undefined)) ?? { fired: [], windows: {} }) as Alerts
280  let changed = false
281  for (const l of limits) {
282    if (l.resetsAt == null || PERIOD[l.key] == null) continue
283    const name = NAMES[l.key] ?? l.label
284
285    // The window moved on since we last looked: it has reset.
286    const prev = state.windows[l.key]
287    if (prev == null || Math.abs(prev - l.resetsAt) > 30 * 60_000) {
288      if (prev != null && prev <= now && l.resetsAt > prev) $.ui.toast(`${name} has reset`)
289      state.windows[l.key] = l.resetsAt
290      changed = true
291    }
292
293    // Name the window by the hour it resets in, so small shifts in an estimate don't re-fire.
294    const crossed = THRESHOLDS.find(t => l.pct >= t)
295    const id = crossed && `${l.key}:${Math.round(l.resetsAt / HOUR)}:${crossed}`
296    if (id && !state.fired.includes(id)) {
297      $.ui.toast(`${name} at ${Math.round(l.pct)}% · resets ${clock(l.resetsAt, now)}`, { timeoutMs: 8000 })
298      state.fired = [...state.fired, id].slice(-40)
299      changed = true
300    }
301  }
302  if (changed) await $.store.set(ALERTS_KEY, state).catch(() => {})
303}
304
305export const register: Register = (on, options) => {
306  // What this session last saved, so an unchanged reading isn't written again.
307  let lastSaved = ''
308  const weeklyReset = String((options as any)?.weekly_reset ?? '')
309  // Every extra is on unless its option is set to false.
310  const isOn = (name: string) => (options as any)?.[name] !== false
311  const showAlerts = isOn('alerts')
312  const showPace = isOn('pace')
313  const showCompact = isOn('compact_button')
314  const showReplyCost = isOn('reply_cost')
315
316  // What the session had cost when the current reply started, and what the last
317  // finished reply added.
318  let turnStartCost: number | undefined
319  let lastReplyCost: number | undefined
320
321  on('turn.start', async ($, e, next) => {
322    turnStartCost = (await $.session.usage()).cost?.usd
323    return next(e)
324  })
325
326  // Between turns, check every 15 seconds whether anything the band shows has
327  // changed, and redraw only then: a redraw rebuilds the rings, which flickers
328  // on the desktop. What can change without a turn: the app's usage file, the
329  // exact figures another chat saved, and a reset time passing.
330  let lastSignature = ''
331  let nextResetAt = Infinity
332
333  on('session.start', async ($, e, next) => {
334    lastSignature = await changeSignature($, nextResetAt)
335    $.clock.every(15_000, async () => {
336      const now = await changeSignature($, nextResetAt)
337      if (now !== lastSignature) {
338        lastSignature = now
339        $.ui.invalidate('ui.render')
340      }
341    })
342    return next(e)
343  })
344
345  // Pushed by the engine after each turn and whenever a limit moves a point.
346  on('session.measure', async ($, e, next) => {
347    $.ui.invalidate('ui.render')
348    return next(e)
349  })
350
351  // Mid-turn, each tool call follows a fresh model response, so the context
352  // figure has moved: redraw then too instead of waiting for the turn to end.
353  on('tool.call', async ($, e, next) => {
354    const result = await next(e)
355    $.ui.invalidate('ui.render')
356    return result
357  })
358
359  on('turn.complete', async ($, e, next) => {
360    const cost = (await $.session.usage()).cost?.usd
361    if (cost != null && turnStartCost != null && cost > turnStartCost) lastReplyCost = cost - turnStartCost
362    $.ui.invalidate('ui.render')
363    return next(e)
364  })
365
366  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
367    // A survey owns the band while it's up.
368    if (e.props.hasSurvey) return next(e)
369
370    const now = Date.now()
371    const usage = await $.session.usage()
372
373    let limits: Limit[] = [...(usage.rateLimits ?? [])]
374      .sort((a, b) => {
375        const ia = ORDER.indexOf(a.kind), ib = ORDER.indexOf(b.kind)
376        return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib)
377      })
378      .map(l => ({
379        key: l.kind,
380        label: LABELS[l.kind] ?? l.kind,
381        pct: l.percentUsed,
382        resetsAt: l.resetsAt ? Date.parse(l.resetsAt) : undefined,
383      }))
384    if (usage.rateLimits?.length) {
385      const fresh = JSON.stringify(usage.rateLimits)
386      if (fresh !== lastSaved) {
387        lastSaved = fresh
388        await $.store.set(SAVED_KEY, { at: now, limits: usage.rateLimits } satisfies Saved).catch(() => {})
389      }
390    } else {
391      const saved = (await $.store.get(SAVED_KEY).catch(() => undefined)) as Saved | undefined
392      limits = withSaved(saved, await appLimits($, now, weeklyReset), now)
393    }
394    if (showAlerts) await raiseAlerts($, limits, now)
395    // The soonest reset still ahead, so the timer redraws when it passes.
396    nextResetAt = Math.min(Infinity, ...limits.map(l => l.resetsAt ?? Infinity).filter(t => t > now))
397
398    const ctx = usage.context
399    const cost = usage.cost?.usd
400    let model = ""
401    try {
402      model = prettyModel(await $.session.model())
403    } catch {}
404
405    type Segment = { key: string; label: string; pct: number; details: string[]; ahead?: boolean; tooltip: string } // tooltip: the ring's screen-reader label
406    const segments: Segment[] = []
407
408    for (const l of limits) {
409      const tilde = l.isEstimate ? "~" : ""
410      let details: string[] = []
411      if (l.resetsAt != null) {
412        // The time it resets at: just the time today, with the day when it's later.
413        details = [`resets ${tilde}${clock(l.resetsAt, now)}`, `↻ ${tilde}${clock(l.resetsAt, now)}`]
414      } else if (l.key === "five_hour") {
415        details = ["starts on your next message", "next message"]
416      }
417      const ahead = showPace && isAhead(l, now)
418      const tooltip = [
419        `${NAMES[l.key] ?? l.label}: ${Math.round(l.pct)}% used`,
420        l.resetsAt != null ? `resets ${l.isEstimate ? "about " : ""}${clock(l.resetsAt, now)}` : "",
421        ahead ? "using it faster than the window is passing" : "",
422      ].filter(Boolean).join(" · ")
423      segments.push({ key: l.key, label: l.label, pct: l.pct, details, ahead, tooltip })
424    }
425
426    if (ctx?.percent != null) {
427      const tokens = ctx.tokens != null ? `${compact(ctx.tokens)} / ${compact(ctx.window)}` : ""
428      const tooltip = `Context window: ${ctx.percent}% full${ctx.tokens != null ? ` · ${ctx.tokens.toLocaleString()} of ${ctx.window.toLocaleString()} tokens` : ""}`
429      segments.push({ key: "context", label: "Context", pct: ctx.percent, details: tokens ? [tokens] : [], tooltip })
430    }
431    // Offer to compact once the context is nearly full.
432    const offerCompact = showCompact && (ctx?.percent ?? 0) > 70
433
434    const els = $.ui.resolve(e) as any
435    const { Box, Text } = els
436
437    if (!segments.length) {
438      return (
439        <Box paddingX={1} width="100%" justifyContent="center">
440          <Text dimColor>Usage appears after the first reply</Text>
441        </Box>
442      )
443    }
444
445    // Fit one line, always keeping the cost: shorten the reset phrases to "↻",
446    // then drop the context tokens, then the model, then the reset phrases.
447    const GAP = 2
448    const SEP_WIDTH = 1 + 2 * GAP
449    const replyCost = showReplyCost && lastReplyCost != null && lastReplyCost >= 0.005 ? ` (+$${lastReplyCost.toFixed(2)})` : ""
450    const costText = cost != null ? `$${cost.toFixed(2)}${replyCost}` : ""
451    const COMPACT_WIDTH = "Compact".length + GAP
452    type Plan = { detail: number; cost: boolean; tokens: boolean; model: boolean }
453    const tailText = (p: Plan) => [p.model ? model : "", p.cost ? costText : ""].filter(Boolean).join(" · ")
454    const width = (p: Plan) => {
455      const segs = segments.reduce((sum, s, i) => {
456        const detail = s.key === "context" ? (p.tokens ? s.details[0] ?? "" : "") : s.details[p.detail] ?? ""
457        return sum + (i ? SEP_WIDTH : 0) + 2 + s.label.length + 1 + `${Math.round(s.pct)}%`.length + (s.ahead ? 2 : 0) + (detail ? 1 + detail.length : 0)
458      }, 0)
459      const tail = tailText(p)
460      return segs + (offerCompact ? COMPACT_WIDTH : 0) + (tail ? SEP_WIDTH + tail.length : 0) + 2
461    }
462    const cols = e.props.bodyColumns ?? 200
463    const plans: Plan[] = [
464      { detail: 0, cost: true, tokens: true, model: true },
465      { detail: 1, cost: true, tokens: true, model: true },
466      { detail: 1, cost: true, tokens: false, model: true },
467      { detail: 1, cost: true, tokens: false, model: false },
468      { detail: 2, cost: true, tokens: false, model: false },
469    ]
470    const plan = plans.find(p => width(p) <= cols) ?? plans[plans.length - 1]
471
472    const Svg = e.surface === "desktop" ? els.Svg : undefined
473    // A quiet grey divider; dimColor alone tints oddly on some surfaces.
474    const sep = (key: string) => <Text key={key} color="#6e7681">│</Text>
475
476    const { Button } = els
477    const parts: any[] = []
478    segments.forEach((s, i) => {
479      const color = colorFor(s.pct)
480      const detail = s.key === "context" ? (plan.tokens ? s.details[0] : undefined) : s.details[plan.detail]
481      const ring = Svg
482        ? <Svg key="ring" source={ringSvg(s.pct)} alt={s.tooltip} width={14} height={14} />
483        : <Text key="ring" color={color}>{pieGlyph(s.pct)}</Text>
484      if (i) parts.push(sep(`sep-${s.key}`))
485      parts.push(
486        <Box key={s.key} flexDirection="row" gap={1} flexShrink={0} alignItems="center">
487          {ring}
488          <Text dimColor>{s.label}</Text>
489          <Text color={color} bold>{`${Math.round(s.pct)}%`}</Text>
490          {s.ahead ? <Text key="ahead" color="yellow">⚠</Text> : null}
491          {detail ? <Text dimColor>{detail}</Text> : null}
492          {s.key === "context" && offerCompact && Button
493            ? <Button key="compact" label="Compact" plain onPress={() => { $.session.compact().catch(() => {}) }} />
494            : null}
495        </Box>,
496      )
497    })
498    const tail = tailText(plan)
499    if (tail) {
500      parts.push(sep("sep-tail"))
501      parts.push(<Text key="tail" dimColor wrap="truncate-end">{tail}</Text>)
502    }
503
504    return (
505      <Box flexDirection="row" flexWrap="nowrap" justifyContent="center" alignItems="center" width="100%" gap={GAP} paddingX={1} overflow="hidden">
506        {parts}
507      </Box>
508    )
509  })
510}
511