A Claude Code mod that draws a band above the prompt with the whole session's prompt cache reads and writes, token usage, cost and subscription rate limits.

token-usage is a plugin for Claude Code. It draws a band above the prompt that shows the token usage of your whole session. The band shows the prompt cache reads and writes with the cache hit rate, the session cost, and your 5-hour and weekly rate limits. It counts the main loop and every subagent.

Run one command. It adds the marketplace and installs the plugin:
curl -fsSL https://raw.githubusercontent.com/ejklock/claude-usage-mod/main/scripts/install.sh | bash
To use the band in Brazilian Portuguese (pt-BR), pass the locale:
curl -fsSL https://raw.githubusercontent.com/ejklock/claude-usage-mod/main/scripts/install.sh | bash -s -- --locale pt-BR
The script also accepts --scope user|project|local (default: user) and --help.
You can also install from inside Claude Code. Type this at the prompt:
/plugin install token-usage --marketplace ejklock/claude-usage-mod
Claude Code asks Add marketplace?. Answer y. Then choose a scope with Enter. The first scope is user.
Or run the two claude plugin commands yourself:
claude plugin marketplace add ejklock/claude-usage-mod
claude plugin install token-usage@token-usage
Start a new Claude Code session after the install to see the band.
The band adapts to the width of the terminal.

On a wide terminal (100 columns or more) the band fills the full width in two columns. Session usage is on the left. Your rate-limit windows are on the right and follow the left column with exactly 4 spaces, at any width. They are laid out as a table: the labels, the bars, the percents, the forecasts and the countdowns line up, so every bar starts and ends at the same column and the lines are padded to the right edge:
◔ ctx ■□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□□ 4% ◷ 5h ■┃■■□□□□□□□□□□□ 24% ↯ 2h 16m ↺ 4h 17m
↻ cache R 56.6k · W 15.5k · ◎ 79% $0.14 ◷ 7d ■■■■■┃■■■■□□□□□ 69% ↯ 1d 4h ↺ 4d 8h
Under 100 columns the band stacks: line 1 is session usage and line 2 is the rate limits, with each part separated by a thin bar. The bars are 6 cells wide down to 80 columns. Under 80 columns the bars are dropped, the icons stay, and the parts that do not fit are left out. A line never goes past the width of the terminal.
Session usage:
◔ ctx is how full the context window is. It shows ◔ ctx — until the first measure arrives.↻ cache R … W … ◎ … is the prompt cache. R is tokens read from the cache. W is tokens written to the cache. ◎ is the cache hit rate. Token counts use k for thousands and M for millions.account option is masked or full (see Configuration). It follows the cost, after 2 spaces, in a soft blue-gray. It appears after your first message, because Claude Code hands the email over only then. On a wide terminal it ends the cache line, and it is left out, with the two columns kept, when it does not fit. When the band stacks it ends line 1 and is the first part left out.Rate limits. They show only when your plan reports them. Without them the band shows the left column only:
◷ 5h is the 5-hour window. ◷ 7d is the weekly window. Any other window kind follows on its own line.↺ with the time left until the window resets, as a countdown: 1d 17h, 4h 17m, 12m or <1m. The same text is used in English and in Brazilian Portuguese. A window with no reset time shows no countdown.┃ on a bar of the 5-hour or weekly window marks how much of the window has passed. It replaces one cell and the bar keeps its length. The spend limit of a gateway has no known length, so it has no marker.↯ in red is a warning. It shows the time left until the limit, when your pace so far in this window would reach the limit before the window resets. It sits between the percent and the countdown, and the column is left out when no window has a warning.The bars use ■ for the used part and □ for the rest. With the show option set to left, they use ■ for the part still free. A reading above 0 always shows at least one ■. On a wide terminal each bar fills its column after the text around it, from 8 up to 60 cells; the context bar stops at 60, and the window bars may grow into the room that frees. If the parts do not fit the two columns, the band stacks.
How the colors work:
◎ is green from 70% and has the default color below 70% or when it shows —.┃ is light (#c0caf5) and the ↯ warning is red. With show set to left, the filled cells have the color of the used percent, with no fade.R, amber for W, violet for the cost and light blue for the windows. The reset times and separators are dim.There are three options.
locale is en (default) or pt-BR. It sets the number format (1.5k or 1,5k) and the word in 76% left / 76% livre.show is used (default) or left. With left, every bar and percent, for the context and for each window, shows the share still free instead of the share used. The percent reads 76% left (76% livre in pt-BR) and the bar fills with the free part. The colors still follow how much is used, so a nearly full window stays red. An unknown value works as used.account is off (default), masked or full. With masked the band shows the account email partly hidden, so it is safe in a screen recording: the local part keeps its first 2 characters and the domain keeps its first character and its last suffix, so neto.nemesis@gmail.com reads ne*@g*.com. With full the whole address shows, so it appears in screenshots. With off or an unknown value nothing shows. The email appears after your first message, and again after /clear or a compaction. It is kept only in memory and is never saved.Change it in Claude Code with /config, or go straight to the plugin options:
/plugin configure token-usage@token-usage
From a terminal, claude plugin configure token-usage@token-usage shows the options and which are unset.
You can also set it at install time:
claude plugin install token-usage@token-usage --config locale=pt-BR --config show=left --config account=masked
The quick start script only passes --locale; set show and account with claude plugin configure or the install command above.
read / (read + write + uncached input).100%+. With show set to left it shows 0% left. The percent left is round(100 − percent used).Xd Yh, an hour or more is Xh Ym, a minute or more is Xm, and less is <1m. A reset that is missing or already past shows nothing.5h window and 7 days for the 7d window. The window started at the reset time minus its length. f is the time since then divided by the length. The marker is cell clamp(round(f × cells), 1, cells) of the bar, or with show set to left, cell round((1 − f) × cells). When the bar has a single filled cell and the marker would land on cell 1, the marker moves to cell 2, so a bar with any usage always shows one ■. Without a usable f (no reset time, or a reset more than one length away) there is no marker.pace = percent used / time elapsed, and the limit is reached at now + (100 − percent used) / pace. It shows only when that is strictly before the reset, and only for a percent above 0 and below 100. It does not use any earlier reading, so a window that was idle and then bursts gets its warning late. See the decision record.Why not a statusLine? See the decision record. The reasons for the counting method are in this record.
The session cost is the official figure from Claude Code. A cost for each subagent is planned, together with a /usage pane. That figure will be an estimate from a pricing table and will be labeled as an estimate. See the decision record.
Run the plugin from this folder:
claude --plugin-dir .
Run the tests and validate the manifest:
claude plugin test .
claude plugin validate .
Test the install script. It uses a throwaway Claude config directory and a local marketplace, with no network:
bash scripts/install.test.sh
To record the screenshot and the recording again, run vhs from the repository root. It needs an authenticated Claude Code session:
vhs demo/token-usage.tape
The design is in docs/.
claude plugin uninstall token-usage@token-usage
claude plugin marketplace remove token-usage
MIT. See LICENSE.
hooks/register.tsx 90 lines1import type { Register } from 'claude-code'
2
3import { accountText, addStep, emailFrom, emptyTally, layoutBand, resolveLocale, resolveShow, snapshotOf } from './usage'
4import type { Tone } from './usage'
5
6const tallyRef = { plugin: 'token-usage', key: 'tally' } as const
7const snapshotRef = { plugin: 'token-usage', key: 'snapshot' } as const
8
9export const register: Register = (on, options) => {
10 const locale = resolveLocale(options.locale)
11 const show = resolveShow(options.show)
12 let email: string | undefined
13
14 on('prompt.context', async (_$, e, next) => {
15 const block = e.blocks.find(candidate => candidate.name === 'userEmail')
16 email = block === undefined ? undefined : emailFrom(block.text)
17 return next(e)
18 })
19
20 on('turn.step', async function* ($, e, next) {
21 const result = yield* next(e)
22 const { usage } = result
23 if (usage === null) {
24 return result
25 }
26
27 let isLanded = false
28 while (!isLanded) {
29 const { value, version } = await $.state.get(tallyRef)
30 const written = await $.state.set(
31 tallyRef,
32 addStep(value ?? emptyTally(), { agentId: e.agentId, model: e.model, usage }),
33 { ifVersion: version },
34 )
35 isLanded = written.isSet
36 }
37 return result
38 })
39
40 on('session.start', async ($, e, next) => {
41 await $.state.set(snapshotRef, snapshotOf(await $.session.usage()))
42 return next(e)
43 })
44
45 on('session.measure', async ($, e, next) => {
46 await $.state.set(snapshotRef, snapshotOf(e))
47 return next(e)
48 })
49
50 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
51 const { value: tally } = await $.state.get(tallyRef)
52 const { value: snapshot } = await $.state.get(snapshotRef)
53 const account = accountText(email, options.account)
54 const lines = layoutBand(tally, snapshot, {
55 columns: e.props.bodyColumns,
56 locale,
57 show,
58 now: await $.clock.now(),
59 ...(account === undefined ? {} : { account }),
60 })
61
62 if (e.props.hasSurvey || lines.length === 0) {
63 return next(e)
64 }
65
66 const { Box, Text } = $.ui.resolve(e)
67
68 return (
69 <Box key="band" flexDirection="column">
70 {lines.map((line, row) => (
71 <Box key={`line${row + 1}`}>
72 {line.map((segment, column) => (
73 <Text key={column} {...colorOf(segment.tone)}>
74 {segment.text}
75 </Text>
76 ))}
77 </Box>
78 ))}
79 </Box>
80 )
81 })
82}
83
84function colorOf(tone: Tone): { color: string } | { dimColor: boolean } | Record<string, never> {
85 if (tone === 'dim') {
86 return { dimColor: true }
87 }
88 return tone === 'default' ? {} : { color: tone }
89}
90hooks/usage.ts 529 lines1import type {
2 ClaudeUsageBucket,
3 ClaudeUsageRateLimit,
4 ClaudeUsageSnapshot,
5 ClaudeUsageTally,
6 ClaudeUsageTokens,
7} from '../types'
8
9export type Locale = 'en' | 'pt-BR'
10export type Show = 'used' | 'left'
11export type Severity = 'normal' | 'warn' | 'danger'
12export type Tone = 'default' | 'dim' | `#${string}`
13export type Segment = { text: string; tone: Tone }
14export type BandLine = Segment[]
15
16export type StepUsage = {
17 input_tokens: number
18 output_tokens: number
19 cache_read_input_tokens: number
20 cache_creation_input_tokens: number
21}
22
23export type RateLimitView = {
24 label: string
25 severity: Severity
26 percentText: string
27 fill: number
28 reset?: string
29 forecast?: string
30 elapsed?: number
31}
32
33/** `account` is the display text, already masked or whole; the layout never decides what to reveal. */
34export type BandView = { columns: number; locale: string; now: number; show?: string; account?: string }
35
36export type BarStyle = { mark?: number; tone?: Tone }
37
38const MAIN_BUCKET = 'main'
39const MINUTE_MS = 60 * 1000
40const HOUR_MS = 60 * MINUTE_MS
41const DAY_MS = 24 * HOUR_MS
42const BAR_COLUMNS = 80
43const TWO_COLUMN_COLUMNS = 100
44const SEPARATOR = ' │ '
45const STACKED_BAR = 6
46const MIN_BAR = 8
47const MAX_BAR = 60
48const COLUMN_GAP = 4
49const PERCENT_WIDTH = 5
50const GOOD_HIT_RATE = 70
51
52const GREEN = '#9ece6a'
53const AMBER = '#e0af68'
54const RED = '#f7768e'
55const AMBER_AT = 80
56const MARKER = '#c0caf5'
57
58const ROLE = {
59 context: '#7aa2f7',
60 cache: '#73daca',
61 write: AMBER,
62 cost: '#c099ff',
63 window: '#7dcfff',
64} as const
65
66const FREE_WORD: Record<Locale, string> = { en: 'left', 'pt-BR': 'livre' }
67
68const SEVERITY_TONES: Record<Severity, Tone> = { normal: GREEN, warn: AMBER, danger: RED }
69
70const WINDOW_LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d' }
71
72const WINDOW_LENGTHS: Record<string, number> = { five_hour: 5 * HOUR_MS, seven_day: 7 * DAY_MS }
73
74export const resolveLocale = (value: unknown): Locale => (value === 'pt-BR' ? 'pt-BR' : 'en')
75
76export const resolveShow = (value: unknown): Show => (value === 'left' ? 'left' : 'used')
77
78const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+/
79const MASK = '***'
80const ACCOUNT_TONE: Tone = '#a9b1d6'
81
82export const emailFrom = (text: string): string | undefined => EMAIL.exec(text)?.[0]
83
84export const maskEmail = (email: string): string => {
85 const at = email.lastIndexOf('@')
86 const domain = email.slice(at + 1)
87 const suffix = domain.slice(domain.lastIndexOf('.'))
88 return `${email.slice(0, Math.min(2, at))}${MASK}@${domain.slice(0, 1)}${MASK}${suffix}`
89}
90
91export const accountText = (email: string | undefined, mode: unknown): string | undefined => {
92 if (email === undefined || (mode !== 'masked' && mode !== 'full')) {
93 return undefined
94 }
95 return mode === 'full' ? email : maskEmail(email)
96}
97
98const count = (value: number): number => (Number.isFinite(value) ? Math.max(0, value) : 0)
99
100const emptyTokens = (): ClaudeUsageTokens => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 })
101
102export const emptyTally = (): ClaudeUsageTally => ({ total: emptyTokens(), buckets: {} })
103
104const plus = (tokens: ClaudeUsageTokens, usage: StepUsage): ClaudeUsageTokens => ({
105 input: tokens.input + count(usage.input_tokens),
106 output: tokens.output + count(usage.output_tokens),
107 cacheRead: tokens.cacheRead + count(usage.cache_read_input_tokens),
108 cacheWrite: tokens.cacheWrite + count(usage.cache_creation_input_tokens),
109})
110
111export const addStep = (
112 tally: ClaudeUsageTally,
113 step: { agentId?: string | undefined; model: string; usage: StepUsage },
114): ClaudeUsageTally => {
115 const id = step.agentId ?? MAIN_BUCKET
116 const bucket: ClaudeUsageBucket = { ...plus(tally.buckets[id] ?? emptyTokens(), step.usage), model: step.model }
117
118 return { total: plus(tally.total, step.usage), buckets: { ...tally.buckets, [id]: bucket } }
119}
120
121export const snapshotOf = (measure: {
122 context: { percent?: number | undefined }
123 rateLimits: readonly ClaudeUsageRateLimit[]
124 cost?: { usd: number } | undefined
125}): ClaudeUsageSnapshot => ({
126 ...(measure.context.percent === undefined ? {} : { contextPercent: measure.context.percent }),
127 ...(measure.cost === undefined ? {} : { costUsd: measure.cost.usd }),
128 rateLimits: measure.rateLimits.map(limit => ({
129 kind: limit.kind,
130 percentUsed: limit.percentUsed,
131 ...(limit.resetsAt === undefined ? {} : { resetsAt: limit.resetsAt }),
132 })),
133})
134
135const withDecimal = (tenths: number, locale: Locale): string =>
136 `${Math.floor(tenths / 10)}${locale === 'pt-BR' ? ',' : '.'}${tenths % 10}`
137
138export const formatTokens = (value: number, locale: string): string => {
139 const tokens = Math.round(count(value))
140 if (tokens < 1000) {
141 return String(tokens)
142 }
143 const thousandsTenths = Math.round(tokens / 100)
144 if (thousandsTenths < 10000) {
145 return `${withDecimal(thousandsTenths, resolveLocale(locale))}k`
146 }
147 return `${withDecimal(Math.round(tokens / 100000), resolveLocale(locale))}M`
148}
149
150export const formatCost = (usd: number | undefined, locale: string): string | undefined => {
151 if (usd === undefined) {
152 return undefined
153 }
154 const text = `$${count(usd).toFixed(2)}`
155 return resolveLocale(locale) === 'pt-BR' ? text.replace('.', ',') : text
156}
157
158export const formatHitRate = (tokens: ClaudeUsageTokens): string => {
159 const served = tokens.cacheRead + tokens.cacheWrite + tokens.input
160 return served === 0 ? '—' : `${Math.round((100 * tokens.cacheRead) / served)}%`
161}
162
163export const severityOf = (percent: number): Severity => {
164 if (percent >= 90) {
165 return 'danger'
166 }
167 return percent >= 80 ? 'warn' : 'normal'
168}
169
170const formatCountdown = (ms: number): string => {
171 if (ms >= DAY_MS) {
172 return `${Math.floor(ms / DAY_MS)}d ${Math.floor((ms % DAY_MS) / HOUR_MS)}h`
173 }
174 if (ms >= HOUR_MS) {
175 return `${Math.floor(ms / HOUR_MS)}h ${Math.floor((ms % HOUR_MS) / MINUTE_MS)}m`
176 }
177 return ms >= MINUTE_MS ? `${Math.floor(ms / MINUTE_MS)}m` : '<1m'
178}
179
180const parseInstant = (value: string | undefined): number | undefined => {
181 const at = value === undefined ? Number.NaN : Date.parse(value)
182 return Number.isNaN(at) ? undefined : at
183}
184
185export const formatReset = (resetsAt: string | undefined, now: number): string | undefined => {
186 const at = parseInstant(resetsAt)
187 return at === undefined || at <= now ? undefined : formatCountdown(at - now)
188}
189
190type WindowTiming = { elapsed: number; remaining: number; length: number }
191
192const timingOf = (limit: ClaudeUsageRateLimit, now: number): WindowTiming | undefined => {
193 const length = WINDOW_LENGTHS[limit.kind]
194 const at = parseInstant(limit.resetsAt)
195 if (length === undefined || at === undefined || at <= now) {
196 return undefined
197 }
198 const elapsed = length - (at - now)
199 return elapsed > 0 ? { elapsed, remaining: at - now, length } : undefined
200}
201
202/** The pace is the window's average so far; it needs no stored history. */
203const forecastOf = (percent: number, timing: WindowTiming): string | undefined => {
204 if (!(percent > 0 && percent < 100)) {
205 return undefined
206 }
207 const untilHit = ((100 - percent) * timing.elapsed) / percent
208 return untilHit < timing.remaining ? formatCountdown(untilHit) : undefined
209}
210
211const leftText = (percent: number, locale: Locale): string =>
212 `${Math.max(0, Math.round(100 - percent))}% ${FREE_WORD[locale]}`
213
214export const markerCell = (fraction: number, size: number, show: string): number =>
215 Math.min(size, Math.max(1, Math.round((show === 'left' ? 1 - fraction : fraction) * size)))
216
217export const describeRateLimit = (
218 limit: ClaudeUsageRateLimit,
219 now: number,
220 locale: string,
221 show: string = 'used',
222): RateLimitView => {
223 const reset = formatReset(limit.resetsAt, now)
224 const timing = timingOf(limit, now)
225 const forecast = timing === undefined ? undefined : forecastOf(limit.percentUsed, timing)
226 const used = Math.min(1, Math.max(0, limit.percentUsed / 100))
227 const isLeft = resolveShow(show) === 'left'
228
229 return {
230 label: WINDOW_LABELS[limit.kind] ?? limit.kind,
231 severity: severityOf(limit.percentUsed),
232 percentText: isLeft
233 ? leftText(limit.percentUsed, resolveLocale(locale))
234 : limit.percentUsed > 100
235 ? '100%+'
236 : `${limit.percentUsed}%`,
237 fill: isLeft ? 1 - used : used,
238 ...(reset === undefined ? {} : { reset }),
239 ...(forecast === undefined ? {} : { forecast }),
240 ...(timing === undefined ? {} : { elapsed: timing.elapsed / timing.length }),
241 }
242}
243
244const channel = (hex: string, index: number): number => Number.parseInt(hex.slice(1 + 2 * index, 3 + 2 * index), 16)
245
246const mix = (from: string, to: string, ratio: number): Tone =>
247 `#${[0, 1, 2]
248 .map(index => Math.round(channel(from, index) + (channel(to, index) - channel(from, index)) * ratio))
249 .map(value => value.toString(16).padStart(2, '0'))
250 .join('')}`
251
252const gradientAt = (index: number, size: number): Tone => {
253 const position = (index / size) * 100
254 return position <= AMBER_AT
255 ? mix(GREEN, AMBER, position / AMBER_AT)
256 : mix(AMBER, RED, (position - AMBER_AT) / (100 - AMBER_AT))
257}
258
259/** A `mark` is the 1-based cell drawn as the marker, moved to cell 2 when it would hide the bar's only filled cell; a `tone` colors every filled cell instead of the gradient. */
260export const meterBar = (fill: number, size: number, style: BarStyle = {}): BandLine => {
261 const filled = fill > 0 ? Math.min(size, Math.max(1, Math.round(fill * size))) : 0
262 const mark = style.mark === 1 && filled === 1 && size >= 2 ? 2 : style.mark
263 const cells: BandLine = Array.from({ length: size }, (_, index) => {
264 if (index + 1 === mark) {
265 return { text: '┃', tone: MARKER }
266 }
267 return index < filled ? { text: '■', tone: style.tone ?? gradientAt(index + 1, size) } : { text: '□', tone: 'dim' }
268 })
269
270 return cells.reduce<BandLine>((line, cell) => {
271 const last = line[line.length - 1]
272 if (cell.text === '□' && last?.text.startsWith('□')) {
273 return [...line.slice(0, -1), { ...last, text: `${last.text}□` }]
274 }
275 return [...line, cell]
276 }, [])
277}
278
279const barFor = (fill: number, severity: Severity, size: number, show: Show, elapsed?: number): BandLine =>
280 meterBar(fill, size, {
281 ...(show === 'left' ? { tone: SEVERITY_TONES[severity] } : {}),
282 ...(elapsed === undefined ? {} : { mark: markerCell(elapsed, size, show) }),
283 })
284
285const gap = (size: number): Segment => ({ text: ' '.repeat(size), tone: 'dim' })
286
287const clamp = (value: number, low: number, high: number): number => Math.min(high, Math.max(low, value))
288
289const width = (text: string): number => [...text].length
290
291const groupWidth = (group: BandLine): number => group.reduce((sum, part) => sum + width(part.text), 0)
292
293const truncate = (group: BandLine, limit: number): BandLine => {
294 let room = Math.max(0, limit - 1)
295 const kept: BandLine = []
296 for (const part of group) {
297 const text = [...part.text].slice(0, room).join('')
298 room -= width(text)
299 kept.push({ ...part, text })
300 }
301 const last = kept[kept.length - 1]
302 return last === undefined ? [] : [...kept.slice(0, -1), { ...last, text: `${last.text}…` }]
303}
304
305const fit = (groups: BandLine[], columns: number): BandLine => {
306 const kept = [...groups]
307 const total = (): number => kept.reduce((sum, group) => sum + groupWidth(group), 0) + SEPARATOR.length * (kept.length - 1)
308 while (kept.length > 1 && total() > columns) {
309 kept.pop()
310 }
311 const first = kept[0]
312 if (first !== undefined && kept.length === 1 && groupWidth(first) > columns) {
313 kept[0] = truncate(first, columns)
314 }
315 return kept.flatMap((group, index) => (index === 0 ? group : [{ text: SEPARATOR, tone: 'dim' as const }, ...group]))
316}
317
318const hitTone = (tokens: ClaudeUsageTokens): Tone => {
319 const served = tokens.cacheRead + tokens.cacheWrite + tokens.input
320 return served > 0 && Math.round((100 * tokens.cacheRead) / served) >= GOOD_HIT_RATE ? GREEN : 'default'
321}
322
323/** A bar size of `undefined` draws no bar; 0 keeps the bar's spacing so the caller can measure the fixed text. */
324const meter = (
325 bar: (size: number) => BandLine,
326 percentText: string,
327 tone: Tone,
328 size: number | undefined,
329 space: number,
330): BandLine => [...(size === undefined ? [] : [gap(space), ...bar(size)]), gap(space), { text: percentText, tone }]
331
332const contextParts = (
333 percent: number | undefined,
334 size: number | undefined,
335 space: number,
336 show: Show,
337 locale: Locale,
338): BandLine => {
339 const label: Segment = { text: '◔ ctx', tone: ROLE.context }
340 if (percent === undefined) {
341 return [label, gap(1), { text: '—', tone: 'default' }]
342 }
343 const severity = severityOf(percent)
344 const isLeft = show === 'left'
345 const usedText = percent > 100 ? '100%+' : `${Math.round(percent)}%`
346 const fill = (isLeft ? 100 - percent : percent) / 100
347
348 return [
349 label,
350 ...meter(
351 length => barFor(fill, severity, length, show),
352 isLeft ? leftText(percent, locale) : usedText,
353 SEVERITY_TONES[severity],
354 size,
355 space,
356 ),
357 ]
358}
359
360const cacheParts = (tokens: ClaudeUsageTokens, locale: Locale, isDotted: boolean): BandLine => {
361 const joiner: Segment = isDotted ? { text: ' · ', tone: 'dim' } : gap(1)
362 return [
363 { text: '↻ cache', tone: ROLE.cache },
364 gap(1),
365 { text: `R ${formatTokens(tokens.cacheRead, locale)}`, tone: ROLE.cache },
366 joiner,
367 { text: `W ${formatTokens(tokens.cacheWrite, locale)}`, tone: ROLE.write },
368 joiner,
369 { text: `◎ ${formatHitRate(tokens)}`, tone: hitTone(tokens) },
370 ]
371}
372
373const windowParts = (
374 limit: ClaudeUsageRateLimit,
375 view: BandView,
376 show: Show,
377 size: number | undefined,
378 space: number,
379): BandLine => {
380 const described = describeRateLimit(limit, view.now, view.locale, show)
381
382 return [
383 { text: `◷ ${described.label}`, tone: ROLE.window },
384 ...meter(
385 length => barFor(described.fill, described.severity, length, show, described.elapsed),
386 described.percentText,
387 SEVERITY_TONES[described.severity],
388 size,
389 space,
390 ),
391 ...(described.forecast === undefined ? [] : [gap(space), { text: `↯ ${described.forecast}`, tone: RED }]),
392 ...(described.reset === undefined ? [] : [gap(space), { text: `↺ ${described.reset}`, tone: 'dim' as const }]),
393 ]
394}
395
396const stacked = (
397 tally: ClaudeUsageTally,
398 snapshot: ClaudeUsageSnapshot | undefined,
399 view: BandView,
400 show: Show,
401): BandLine[] => {
402 const locale = resolveLocale(view.locale)
403 const size = view.columns >= BAR_COLUMNS ? STACKED_BAR : undefined
404 const cost = formatCost(snapshot?.costUsd, locale)
405 const usage: BandLine[] = [
406 contextParts(snapshot?.contextPercent, size, 1, show, locale),
407 cacheParts(tally.total, locale, false),
408 ...(cost === undefined ? [] : [[{ text: cost, tone: ROLE.cost }]]),
409 ...(view.account === undefined ? [] : [[{ text: view.account, tone: ACCOUNT_TONE }]]),
410 ]
411 const limits = snapshot?.rateLimits ?? []
412
413 return [
414 fit(usage, view.columns),
415 ...(limits.length === 0 ? [] : [fit(limits.map(limit => windowParts(limit, view, show, size, 1)), view.columns)]),
416 ]
417}
418
419const textWidth = (text: string): number => [...text].length
420
421const widest = (texts: readonly string[]): number => Math.max(0, ...texts.map(textWidth))
422
423const optionalColumn = (columnWidth: number, text: string, tone: Tone): BandLine => {
424 if (columnWidth === 0) {
425 return []
426 }
427 return text === '' ? [gap(2 + columnWidth)] : [gap(2), { text, tone }, gap(columnWidth - textWidth(text))]
428}
429
430const windowTable = (
431 limits: readonly ClaudeUsageRateLimit[],
432 view: BandView,
433 show: Show,
434 room: number,
435): BandLine[] | undefined => {
436 const rows = limits.map(limit => describeRateLimit(limit, view.now, view.locale, show))
437 const labels = rows.map(row => `◷ ${row.label}`)
438 const forecasts = rows.map(row => (row.forecast === undefined ? '' : `↯ ${row.forecast}`))
439 const resets = rows.map(row => (row.reset === undefined ? '' : `↺ ${row.reset}`))
440 const labelWidth = widest(labels)
441 const percentWidth = Math.max(PERCENT_WIDTH, widest(rows.map(row => row.percentText)))
442 const forecastWidth = widest(forecasts)
443 const resetWidth = widest(resets)
444 const fixed =
445 labelWidth +
446 2 +
447 2 +
448 percentWidth +
449 (forecastWidth === 0 ? 0 : 2 + forecastWidth) +
450 (resetWidth === 0 ? 0 : 2 + resetWidth)
451 if (rows.length > 0 && room - fixed < MIN_BAR) {
452 return undefined
453 }
454 const size = clamp(room - fixed, MIN_BAR, MAX_BAR)
455
456 return rows.map((row, index) => [
457 { text: labels[index] ?? '', tone: ROLE.window },
458 gap(labelWidth - textWidth(labels[index] ?? '') + 2),
459 ...barFor(row.fill, row.severity, size, show, row.elapsed),
460 gap(2 + percentWidth - textWidth(row.percentText)),
461 { text: row.percentText, tone: SEVERITY_TONES[row.severity] },
462 ...optionalColumn(forecastWidth, forecasts[index] ?? '', RED),
463 ...optionalColumn(resetWidth, resets[index] ?? '', 'dim'),
464 ])
465}
466
467const twoColumns = (
468 tally: ClaudeUsageTally,
469 snapshot: ClaudeUsageSnapshot | undefined,
470 view: BandView,
471 show: Show,
472 account: string | undefined,
473): BandLine[] | undefined => {
474 const { columns } = view
475 const locale = resolveLocale(view.locale)
476 const percent = snapshot?.contextPercent
477 const limits = snapshot?.rateLimits ?? []
478 const cost = formatCost(snapshot?.costUsd, locale)
479
480 const contextFixed = groupWidth(contextParts(percent, percent === undefined ? undefined : 0, 2, show, locale))
481 const cacheLine: BandLine = [
482 ...cacheParts(tally.total, locale, true),
483 ...(cost === undefined ? [] : [gap(2), { text: cost, tone: ROLE.cost }]),
484 ...(account === undefined ? [] : [gap(2), { text: account, tone: ACCOUNT_TONE }]),
485 ]
486 const leftWidth = Math.floor((columns - COLUMN_GAP) / 2)
487 const needed = Math.max(contextFixed + (percent === undefined ? 0 : MIN_BAR), groupWidth(cacheLine))
488 if (needed > leftWidth) {
489 return undefined
490 }
491
492 const contextBar = percent === undefined ? undefined : clamp(leftWidth - contextFixed, MIN_BAR, MAX_BAR)
493 const lefts = [contextParts(percent, contextBar, 2, show, locale), cacheLine]
494 const leftEnd = Math.max(...lefts.map(groupWidth))
495 const rights = windowTable(limits, view, show, columns - COLUMN_GAP - leftEnd)
496 if (rights === undefined) {
497 return undefined
498 }
499
500 return Array.from({ length: Math.max(lefts.length, rights.length) }, (_, row) => {
501 const left = lefts[row] ?? []
502 const right = rights[row]
503 if (right === undefined) {
504 return left
505 }
506 const trailing = columns - leftEnd - COLUMN_GAP - groupWidth(right)
507 return [...left, gap(leftEnd - groupWidth(left) + COLUMN_GAP), ...right, ...(trailing > 0 ? [gap(trailing)] : [])]
508 })
509}
510
511export const layoutBand = (
512 tally: ClaudeUsageTally | undefined,
513 snapshot: ClaudeUsageSnapshot | undefined,
514 view: BandView,
515): BandLine[] => {
516 const hasData = snapshot !== undefined || Object.keys(tally?.buckets ?? {}).length > 0
517 if (!hasData) {
518 return []
519 }
520 const held = tally ?? emptyTally()
521 const show = resolveShow(view.show)
522 const isWide = view.columns >= TWO_COLUMN_COLUMNS
523 return (
524 (isWide ? twoColumns(held, snapshot, view, show, view.account) : undefined) ??
525 (isWide && view.account !== undefined ? twoColumns(held, snapshot, view, show, undefined) : undefined) ??
526 stacked(held, snapshot, view, show)
527 )
528}
529types/index.d.ts 35 lines1export type ClaudeUsageTokens = {
2 input: number
3 output: number
4 cacheRead: number
5 cacheWrite: number
6}
7
8export type ClaudeUsageBucket = ClaudeUsageTokens & { model: string }
9
10export type ClaudeUsageTally = {
11 total: ClaudeUsageTokens
12 buckets: Record<string, ClaudeUsageBucket>
13}
14
15export type ClaudeUsageRateLimit = {
16 kind: string
17 percentUsed: number
18 resetsAt?: string
19}
20
21export type ClaudeUsageSnapshot = {
22 contextPercent?: number
23 costUsd?: number
24 rateLimits: ClaudeUsageRateLimit[]
25}
26
27declare module 'claude-code' {
28 interface PluginState {
29 'token-usage': {
30 tally: ClaudeUsageTally
31 snapshot: ClaudeUsageSnapshot
32 }
33 }
34}
35