Context fill, cost and rate limits in a band above the prompt, each turn's tokens and cost on the line that closes it, and a /spend pane.

Usage shows what the session is spending while you work. A line above the prompt shows how full the context is, what the session has cost and how much of each rate-limit window you have used. The line that closes each turn adds that turn's tokens, cache hits and cost. The /spend command opens a pane with the details. It draws the same way in the terminal and in the desktop app.
Once the session has figures, a line above the prompt shows them:
context 42% · $1.23 · 5h 31% · 7d 12%
The figures update after each reply. In a new session they appear after the first reply. The rate-limit windows are the ones your account reports, such as 5h, 7d or spend. The cost is left out where Claude Code keeps no cost ledger. The line is hidden while a survey is showing.
Context and each rate-limit window turn yellow at 80% and red at 90%.
A toast warns you once when context reaches 85%, so you have time to /compact. Another warns you once when a rate-limit window reaches 90%. If a figure drops back below its line, it warns again the next time it crosses.
The line that closes a turn adds what the turn took after its length:
✻ Baked for 12s · 10k in · 1.2k out · 80% cached · $0.04
<$0.01.Turns from before Usage loaded keep Claude Code's own line.
/spend paneRun /spend to open the Usage pane. (/usage is Claude Code's own command.) The top of the pane shows:
Below that is a table of your last 50 turns, newest first. Each row shows the model, uncached input, cache reads, cache writes, output, cache hit rate, cost and how long the turn ran. A turn you stopped is dimmed and says stopped. The last row adds up the turn count, the four token columns and the overall hit rate.
The table lists only your own turns. Subagent turns are left out, but their cost is still in the session total.
Usage needs Claude Code v2.1.293 or later. Mods are an early access part of Claude Code, so a Claude Code release can break the plugin until it is updated.
/plugin marketplace add astrosteveo/claude-plugins
/plugin install usage@astrosteveo-plugins
/reload-pluginshooks/register.tsx 182 lines1import { atom, read, update } from 'claude-code'
2import type { Caught, EngineInterface, HookFailure, Register } from 'claude-code'
3
4import type { Turn } from '../types'
5import { HEADER, bandParts, colorOf, columns, crossings, duration, levelOf, limitName, measureOf, resetIn, rowCells, tokens, totals, turnFigures, turnOf, usd, withCost, withTurn } from './format'
6
7const PANE = 'usage'
8const COMMAND = 'spend'
9const measure = atom({ plugin: 'usage', key: 'measure' } as const, null)
10const turns = atom({ plugin: 'usage', key: 'turns' } as const, [])
11const warned = atom({ plugin: 'usage', key: 'warned' } as const, [])
12const start = atom({ plugin: 'usage', key: 'start' } as const, null)
13
14const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Usage' })
15
16const failureOf = (error: HookFailure): string => (error.kind === 'timeout' ? 'ran out of time' : (error.message ?? 'threw'))
17
18// A fault in this mod never stands in the way: the site does what it would
19// without it, and the debug log says why.
20const fallBack = <E, R>($: EngineInterface, e: E, next: ((e: E) => R) & Caught, site: string): R => {
21 $.ui.log(`usage: ${site} failed and was left to the default: ${failureOf(next.error)}`, { to: 'debug' })
22 return next(e)
23}
24
25export const register: Register = on => {
26 on('session.start', async ($, e, next) => {
27 // `/usage` is a built-in, so the pane's command is `/spend`. A refused
28 // name must not stop the figures below from loading.
29 await $.command
30 .register({ name: COMMAND, description: 'Show this session’s tokens, cache hits, cost and rate limits in a pane' })
31 .catch((cause: unknown) => $.ui.log(`usage: /${COMMAND} was not registered: ${String(cause)}`, { to: 'debug' }))
32 // A reload starts the module over, but the figures are already there to read.
33 const now = await $.session.usage()
34 await update($, measure, () => measureOf(now))
35
36 return next(e)
37 })
38
39 on('command.run', { command: COMMAND }, async $ => {
40 await openPane($)
41
42 return { text: 'Usage pane opened.' }
43 }).catch(($, e, next) => fallBack($, e, next, 'command.run'))
44
45 on('session.measure', async ($, e, next) => {
46 const now = measureOf(e)
47 await update($, measure, () => now)
48 const crossed = crossings(now, await read($, warned))
49 await update($, warned, () => crossed.warned)
50 for (const text of crossed.toasts) $.ui.toast(text)
51 // The first measurement after a turn prices it.
52 const priced = withCost(await read($, turns), await read($, start), now.usd)
53 if (priced !== null) {
54 await update($, turns, () => priced)
55 await update($, start, () => null)
56 }
57
58 return next(e)
59 })
60
61 // Only the main loop raises turn.start, so this is the cost before one of
62 // the person's own turns.
63 on('turn.start', async ($, e, next) => {
64 const { cost } = await $.session.usage()
65 if (cost !== undefined) await update($, start, () => ({ turnId: e.turnId, usd: cost.usd }))
66
67 return next(e)
68 })
69
70 // Only the main loop's turns: a subagent's turns are its own work, and
71 // their cost already reaches the session total through `session.measure`.
72 on('turn.complete', async ($, e, next) => {
73 if (e.agentId === undefined && e.usage !== undefined) {
74 const turn: Turn = {
75 turnId: e.turnId,
76 at: await $.clock.now(),
77 model: e.usage.model,
78 input: e.usage.input_tokens,
79 cacheRead: e.usage.cache_read_input_tokens,
80 cacheWrite: e.usage.cache_creation_input_tokens,
81 output: e.usage.output_tokens,
82 ms: e.durationMs,
83 isAborted: e.isAborted,
84 }
85 await update($, turns, list => withTurn(list, turn))
86 }
87
88 return next(e)
89 })
90
91 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
92 if (e.props.hasSurvey) return next(e)
93 const parts = bandParts(await read($, measure))
94 if (parts.length === 0) return next(e)
95 // What the plugins beneath draw stays under the figures, so another mod's
96 // band is not hidden by this one.
97 const below = await next(e)
98 const { Box, Text } = $.ui.resolve(e)
99
100 return (
101 <Box key="usage-band" flexDirection="column">
102 <Box key="usage" flexWrap="wrap">
103 {parts.map((part, i) => (
104 <Text key={part.key} dimColor={part.level === 'ok'} color={colorOf(part.level)}>
105 {i === 0 ? '' : ' · '}
106 {part.text}
107 </Text>
108 ))}
109 </Box>
110 {below}
111 </Box>
112 )
113 })
114
115 // The line that closes a turn, `Baked for 12s`, with what the turn took.
116 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
117 const turn = turnOf(await read($, turns), e.props.durationMs)
118 if (turn === undefined) return next(e)
119 const { Box, Text } = $.ui.resolve(e)
120
121 return (
122 <Box key="turn" flexDirection="row" flexWrap="wrap">
123 <Text dimColor>
124 ✻ {e.props.word} for {duration(e.props.durationMs)}
125 </Text>
126 {turnFigures(turn).map(figure => (
127 <Text key={figure.key} color={figure.key === 'usd' ? 'claude' : 'subtle'} dimColor={figure.key !== 'usd'}>
128 {' · '}
129 {figure.text}
130 </Text>
131 ))}
132 </Box>
133 )
134 })
135
136 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
137 const { Box, Text } = $.ui.resolve(e)
138 const now = await read($, measure)
139 const list = await read($, turns)
140 const sum = totals(list)
141 const at = await $.clock.now()
142
143 return (
144 <Box flexDirection="column">
145 <Box key="summary" flexDirection="column" marginBottom={1}>
146 {now?.context.percent !== undefined && (
147 <Text key="context" color={colorOf(levelOf(now.context.percent))}>
148 Context: {now.context.percent}% of {tokens(now.context.window)}
149 {now.context.tokens === undefined ? '' : ` (${tokens(now.context.tokens)} used)`}
150 </Text>
151 )}
152 {now?.usd !== undefined && <Text key="cost">Cost: {usd(now.usd)}</Text>}
153 {(now?.limits ?? []).map(limit => {
154 const resets = resetIn(limit.resetsAt, at)
155 return (
156 <Text key={`limit-${limit.kind}`} color={colorOf(levelOf(limit.percentUsed))}>
157 {limitName(limit.kind)} limit: {limit.percentUsed}% used{resets === null ? '' : `, resets in ${resets}`}
158 </Text>
159 )
160 })}
161 {now === null && <Text key="waiting" dimColor>No measurement yet. It comes after the first reply.</Text>}
162 </Box>
163 {list.length === 0 ? (
164 <Text key="empty" dimColor>No turns yet.</Text>
165 ) : (
166 <Box key="turns" flexDirection="column">
167 <Text key="header" bold>{columns(HEADER)}</Text>
168 {[...list].reverse().map((turn, i) => (
169 <Text key={`turn-${list.length - 1 - i}`} dimColor={turn.isAborted}>
170 {columns(rowCells(turn))}
171 </Text>
172 ))}
173 <Text key="totals" bold>
174 {columns([`${sum.turns} turns`, tokens(sum.input), tokens(sum.cacheRead), tokens(sum.cacheWrite), tokens(sum.output), sum.hit === null ? '-' : `${sum.hit}%`, '', ''])}
175 </Text>
176 </Box>
177 )}
178 </Box>
179 )
180 })
181}
182hooks/format.ts 175 lines1import type { Level, Limit, Measure, Start, Turn } from '../types'
2
3// The pane keeps this many turns. Older ones drop off the front.
4export const KEEP = 50
5
6// Context toasts a little before the band turns red, so there is time to
7// compact. A rate limit toasts once it is red.
8export const CONTEXT_TOAST = 85
9export const LIMIT_TOAST = 90
10
11export const levelOf = (percent: number | undefined): Level =>
12 percent === undefined ? 'ok' : percent >= 90 ? 'high' : percent >= 80 ? 'warn' : 'ok'
13
14export const colorOf = (level: Level): 'warning' | 'error' | undefined =>
15 level === 'high' ? 'error' : level === 'warn' ? 'warning' : undefined
16
17export function tokens(n: number): string {
18 if (n < 1000) return String(n)
19 if (n < 1_000_000) return `${trim(n / 1000)}k`
20 return `${trim(n / 1_000_000)}M`
21}
22
23// One decimal under 100, none above, and never a trailing ".0".
24const trim = (n: number): string => (n >= 100 ? String(Math.round(n)) : n.toFixed(1).replace(/\.0$/, ''))
25
26export const usd = (n: number): string => (n >= 100 ? `$${Math.round(n)}` : `$${n.toFixed(2)}`)
27
28export function duration(ms: number): string {
29 const seconds = Math.round(ms / 1000)
30 if (seconds < 60) return `${seconds}s`
31 const minutes = Math.floor(seconds / 60)
32 return seconds % 60 === 0 ? `${minutes}m` : `${minutes}m ${seconds % 60}s`
33}
34
35const LIMIT_NAMES: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
36
37export const limitName = (kind: string): string => LIMIT_NAMES[kind] ?? kind.replaceAll('_', ' ')
38
39// How long until a window resets, from an ISO time: "2h 10m", "4d 3h", or
40// null when the time is missing, unreadable or already past.
41export function resetIn(resetsAt: string | undefined, now: number): string | null {
42 const at = resetsAt === undefined ? NaN : Date.parse(resetsAt)
43 if (Number.isNaN(at) || at <= now) return null
44 const minutes = Math.ceil((at - now) / 60_000)
45 if (minutes < 60) return `${minutes}m`
46 const hours = Math.floor(minutes / 60)
47 if (hours < 24) return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`
48 const days = Math.floor(hours / 24)
49 return hours % 24 === 0 ? `${days}d` : `${days}d ${hours % 24}h`
50}
51
52// The share of the turn's input the prompt cache served, as a whole percent;
53// null when the turn had no input at all.
54export function hitRate(turn: Turn): number | null {
55 const input = turn.input + turn.cacheRead + turn.cacheWrite
56 return input === 0 ? null : Math.round((turn.cacheRead / input) * 100)
57}
58
59export const shortModel = (model: string): string => model.replace(/^claude-/, '')
60
61export const withTurn = (turns: Turn[], turn: Turn): Turn[] => [...turns, turn].slice(-KEEP)
62
63// One piece of the band, with how close it is to its limit.
64export type Part = { key: string; text: string; level: Level }
65
66// The band: the session's figures. The last turn's own figures are on the
67// line that closes it in the transcript, so the band leaves them out.
68export function bandParts(measure: Measure | null): Part[] {
69 const parts: Part[] = []
70 if (measure === null) return parts
71 const { percent } = measure.context
72 if (percent !== undefined) parts.push({ key: 'context', text: `context ${percent}%`, level: levelOf(percent) })
73 if (measure.usd !== undefined) parts.push({ key: 'cost', text: usd(measure.usd), level: 'ok' })
74 for (const limit of measure.limits) {
75 parts.push({ key: `limit-${limit.kind}`, text: `${limitName(limit.kind)} ${limit.percentUsed}%`, level: levelOf(limit.percentUsed) })
76 }
77 return parts
78}
79
80// What follows `Baked for 12s` on the line that closes a turn.
81export function turnFigures(turn: Turn): { key: string; text: string }[] {
82 const input = turn.input + turn.cacheRead + turn.cacheWrite
83 const rate = hitRate(turn)
84 return [
85 { key: 'in', text: `${tokens(input)} in` },
86 { key: 'out', text: `${tokens(turn.output)} out` },
87 ...(rate === null ? [] : [{ key: 'cached', text: `${rate}% cached` }]),
88 ...(turn.usd === undefined ? [] : [{ key: 'usd', text: cents(turn.usd) }]),
89 ]
90}
91
92// A turn's cost is often under a cent, which `$0.00` would hide.
93export const cents = (n: number): string => (n > 0 && n < 0.01 ? '<$0.01' : usd(n))
94
95// The turn a closing line belongs to. The line carries only the turn's
96// length, which `turn.complete` carried too, so the newest turn of that
97// length is the one.
98export const turnOf = (turns: readonly Turn[], durationMs: number): Turn | undefined =>
99 turns.findLast(turn => turn.ms === durationMs)
100
101// The turns with the newest one's cost filled in, from the session's cost
102// now and as that turn started; null when there is nothing to fill.
103export function withCost(turns: readonly Turn[], start: Start | null, usdNow: number | undefined): Turn[] | null {
104 const last = turns.at(-1)
105 if (last === undefined || start === null || usdNow === undefined) return null
106 if (last.turnId !== start.turnId || last.usd !== undefined) return null
107 return [...turns.slice(0, -1), { ...last, usd: Math.max(0, usdNow - start.usd) }]
108}
109
110export type Totals = { turns: number; input: number; cacheRead: number; cacheWrite: number; output: number; hit: number | null }
111
112export function totals(turns: Turn[]): Totals {
113 const sum = { turns: turns.length, input: 0, cacheRead: 0, cacheWrite: 0, output: 0 }
114 for (const turn of turns) {
115 sum.input += turn.input
116 sum.cacheRead += turn.cacheRead
117 sum.cacheWrite += turn.cacheWrite
118 sum.output += turn.output
119 }
120 return { ...sum, hit: hitRate({ ...sum, turnId: '', at: 0, model: '', ms: 0, isAborted: false }) }
121}
122
123// The pane's columns. Each row is padded to these widths so the figures line up.
124export const HEADER = ['model', 'in', 'read', 'write', 'out', 'hit', 'cost', 'time'] as const
125const WIDTHS: readonly number[] = [16, 7, 7, 7, 7, 5, 7, 8]
126
127export function columns(cells: readonly string[]): string {
128 return cells
129 .map((cell, i) => {
130 const width = WIDTHS[i] ?? 0
131 return i === 0 ? cell.slice(0, width).padEnd(width) : cell.padStart(width)
132 })
133 .join(' ')
134}
135
136export function rowCells(turn: Turn): string[] {
137 const rate = hitRate(turn)
138 return [
139 shortModel(turn.model),
140 tokens(turn.input),
141 tokens(turn.cacheRead),
142 tokens(turn.cacheWrite),
143 tokens(turn.output),
144 rate === null ? '-' : `${rate}%`,
145 turn.usd === undefined ? '-' : cents(turn.usd),
146 turn.isAborted ? 'stopped' : duration(turn.ms),
147 ]
148}
149
150// The figures past their toast threshold in this measurement, by key, and
151// the toasts for the ones that were not past it before. A figure that falls
152// back under its threshold leaves the list, so its next crossing toasts again.
153export function crossings(measure: Measure, warned: readonly string[]): { warned: string[]; toasts: string[] } {
154 const now: string[] = []
155 const toasts: string[] = []
156 const { percent } = measure.context
157 if (percent !== undefined && percent >= CONTEXT_TOAST) {
158 now.push('context')
159 if (!warned.includes('context')) toasts.push(`Context is ${percent}% full. Consider /compact.`)
160 }
161 for (const limit of measure.limits) {
162 if (limit.percentUsed < LIMIT_TOAST) continue
163 const key = `limit-${limit.kind}`
164 now.push(key)
165 if (!warned.includes(key)) toasts.push(`${limitName(limit.kind)} rate limit is ${limit.percentUsed}% used.`)
166 }
167 return { warned: now, toasts }
168}
169
170export const measureOf = (e: { context: Measure['context']; rateLimits: readonly Limit[]; cost?: { usd: number } }): Measure => ({
171 context: { tokens: e.context.tokens, window: e.context.window, percent: e.context.percent },
172 limits: e.rateLimits.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt })),
173 usd: e.cost?.usd,
174})
175types/index.d.ts 47 lines1// How close a figure is to its limit: under 80%, from 80%, from 90%.
2export type Level = 'ok' | 'warn' | 'high'
3
4// One rate-limit window as the last response reported it.
5export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
6
7// The session's latest measurement: the context window, the rate-limit
8// windows and the cost so far. `usd` is absent where the host keeps no ledger.
9export type Measure = {
10 context: { tokens?: number; window: number; percent?: number }
11 limits: Limit[]
12 usd?: number
13}
14
15// One main-loop turn: who answered, the four token counts, how long it ran,
16// and what it cost. `usd` comes from the first measurement after the turn,
17// so it is absent until then, and where the host keeps no ledger.
18export type Turn = {
19 turnId: string
20 at: number
21 model: string
22 input: number
23 cacheRead: number
24 cacheWrite: number
25 output: number
26 ms: number
27 isAborted: boolean
28 usd?: number
29}
30
31// The session's cost as a turn started, to tell what that turn added.
32export type Start = { turnId: string; usd: number }
33
34declare module 'claude-code' {
35 interface PluginState {
36 usage: {
37 measure: Measure | null
38 // The last turns, oldest first.
39 turns: Turn[]
40 // The figures that are past their toast threshold now, so each
41 // crossing toasts once.
42 warned: string[]
43 start: Start | null
44 }
45 }
46}
47