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

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

◔ 5h 72% ⚠ resets 1:19 PM │ ◑ Week 50% resets Sat 2:30 PM │ ○ Context 40% 404k / 1M │ Opus 5.5 · $13.24 (+$0.41)
It also helps you stay ahead of your limits:
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.
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.
| Figure | Source |
|---|---|
| 5h / Week | Claude 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, cost | Claude 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 ~:
weekly_reset (below).The band only reads local files. It makes no network requests and never touches your credentials.
| Option | What it does |
|---|---|
weekly_reset | Recommended. 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. |
alerts | Pop-ups at 80% and 95% and when a limit resets. On by default. |
pace | The ⚠ pace warning. On by default. |
compact_button | The Compact button once the context is over 70% full. On by default. |
reply_cost | The last reply's cost next to the session cost. On by default. |
Set it with:
/plugin configure usage-band@claude-usage-mod
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:
/plugin.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.
/plugin marketplace update claude-usage-mod
/plugin uninstall usage-band@claude-usage-mod
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.
hooks/register.tsx 511 lines1import 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