SLOPSHOPPER

token-usage

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.

newbandprompt
v0.2.0MITupdated 2026-10-09ejklock/claude-usage-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-usage
› 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% ↻ cache R 0 · W 0 · ◎ — $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◔ ctx ■■■■■■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□□ 49% ◷ 5h ■■■■■■■■■■■□□□□□□□□□□□□□□□□□□□□□□□□ 31% ↻ cache R 0 · W 0 · ◎ — $0.42
README

token-usage: token usage, prompt cache, cost and rate limits for Claude Code

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.

The token-usage band above the Claude Code prompt

Quick Start

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.

What it shows

The band adapts to the width of the terminal.

The band in a short recording

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.
  • The next part is the session cost in US dollars.
  • The last part is the email of the signed-in account, shown only when the 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.
  • Each window shows a bar, the percent used, and ↺ 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.
  • The ┃ 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:

  • The filled cells of a bar fade from soft green through amber (at 80% of the bar) to soft red at the end. The empty cells are dim.
  • Every percent (context and rate limits) is green below 80%, amber from 80% and red from 90%.
  • The hit rate ◎ is green from 70% and has the default color below 70% or when it shows —.
  • The marker ┃ 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.
  • Each part has its own color: blue for the context, teal for the cache and R, amber for W, violet for the cost and light blue for the windows. The reset times and separators are dim.
  • The colors are fixed hex colors. A terminal that cannot show them draws the nearest color it can.

Configuration

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.

How the numbers work

  • The numbers are cumulative. Every model request of the main loop and of each subagent is added to the totals. The totals only grow during a session.
  • A session that started before the plugin was loaded starts from zero.
  • The cache hit rate is read / (read + write + uncached input).
  • The percent colors are green below 80%, amber from 80% and red from 90%. This holds for the context and for every rate-limit window. The bars fade from green to amber to red along their length. The hit rate is green from 70%, judged on the number the band shows.
  • The two-column layout starts at 100 columns. Under that the band stacks, and under 80 columns the bars are dropped.
  • A percent above 100 shows as 100%+. With show set to left it shows 0% left. The percent left is round(100 − percent used).
  • The countdown is the reset time minus now, with each unit rounded down: a day or more is 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.
  • The marker and the forecast need the length of the window: 5 hours for the 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.
  • The forecast uses the average pace of the window: 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.

Cost

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.

Requirements

  • A Claude Code build with function hooks. It was tested on 2.1.292.
  • The 5-hour and weekly limits show only on subscription plans that report them.

Development

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/.

Uninstall

claude plugin uninstall token-usage@token-usage
claude plugin marketplace remove token-usage

License

MIT. See LICENSE.

Source 3 files
hooks/register.tsx 90 lines
1import 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}
90
hooks/usage.ts 529 lines
1import 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}
529
types/index.d.ts 35 lines
1export 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