Side pane with the session's tokens, context, tools and time

A mod for Claude Code: a side pane with what the session has spent so far.
12 min 5 s
this session · $3.51
5-hour limit 9%
━━━────────────────────────────
resets in 4 h 0 min
7-day limit 79%
━━━━━━━━━━━━━━━━━━━━━━━━━──────
resets in 3 d 2 h
Context 180k of 1M
messages 142k
tools 21k
system 12k
Last turn
output 1.2k
Tokens 45k
output 12k
input 33k
served from cache 92%
Tools 18
Read 7
Edit 5
Bash 4
/compact, and it warns about each limit that passes 80% (the 5-hour one has its own bar, and is on the band too).The pane opens by itself when the session starts; /usage-pane opens it again.
bandThis pane does not repeat what the band mod already shows above the prompt: the context percentage and the last turn's length and cost are there. Here is the detail the band does not have.
At the prompt of a terminal session:
/plugin install usage --marketplace zrdqns/claude-code-usage
Answer y to add the marketplace and choose the scope (the user scope loads it in every session, including the desktop app's).
To try it from a local copy, without installing it:
claude --plugin-dir ./claude-code-usage
claude plugin validate .
claude plugin test .
The module is in hooks/register.tsx, its state contract in types/index.d.ts and the tests in tests/.
hooks/register.tsx 317 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4const PANE = 'usage'
5const TITLE = 'Usage'
6
7const tokens = atom({ plugin: 'usage', key: 'tokens' } as const, {
8 input: 0,
9 output: 0,
10 cacheRead: 0,
11 cacheWrite: 0,
12})
13const tools = atom({ plugin: 'usage', key: 'tools' } as const, {})
14const parts = atom({ plugin: 'usage', key: 'parts' } as const, [])
15const last = atom({ plugin: 'usage', key: 'last' } as const, null)
16// The converter mod's last notice, when it is loaded.
17const converted = { plugin: 'converter', key: 'notice' } as const
18// From this share of the context window or of a rate limit, the pane says so.
19const ALERT_PERCENT = 80
20// How long another mod's notice stays in the pane.
21const NOTICE_MS = 60000
22
23const LIMITS: Record<string, string> = {
24 five_hour: '5-hour',
25 seven_day: '7-day',
26 spend_limit: 'Spend',
27}
28
29const PARTS: Record<string, string> = {
30 'System prompt': 'system',
31 'System tools': 'tools',
32 'MCP tools': 'MCP tools',
33 'Custom agents': 'agents',
34 'Memory files': 'memory',
35 Messages: 'messages',
36}
37
38// The desktop draws the meter as an image, outside the theme: these read on
39// a dark and on a light pane alike.
40const METER = { calm: '#9c9a92', warning: '#d4a037', error: '#d65f4a' }
41
42const trim = (n: number, digits: number) =>
43 n.toFixed(digits).replace(/\.?0+$/, '')
44
45const compact = (n: number) =>
46 n >= 1e6
47 ? `${trim(n / 1e6, 2)}M`
48 : n >= 1e3
49 ? `${trim(n / 1e3, 1)}k`
50 : `${n}`
51
52const span = (ms: number) => {
53 const s = Math.max(0, Math.floor(ms / 1000))
54 const m = Math.floor(s / 60)
55
56 return m >= 60
57 ? `${Math.floor(m / 60)} h ${m % 60} min`
58 : m > 0
59 ? `${m} min ${s % 60} s`
60 : `${s} s`
61}
62
63const until = (ms: number) => {
64 const m = Math.max(0, Math.round(ms / 60000))
65
66 return m >= 1440
67 ? `${Math.floor(m / 1440)} d ${Math.floor((m % 1440) / 60)} h`
68 : m >= 60
69 ? `${Math.floor(m / 60)} h ${m % 60} min`
70 : `${m} min`
71}
72
73const tone = (percent: number) =>
74 percent >= 85 ? 'error' : percent >= 60 ? 'warning' : undefined
75
76const shortName = (tool: string) => tool.replace(/^mcp__.*__/, '')
77
78const meter = (percent: number, color: string) =>
79 `<svg xmlns="http://www.w3.org/2000/svg" width="2000" height="12" viewBox="0 0 100 12" preserveAspectRatio="none">` +
80 `<rect y="4" width="100" height="4" fill="${METER.calm}" fill-opacity="0.3"/>` +
81 `<rect y="4" width="${Math.min(100, Math.max(0, percent))}" height="4" fill="${color}"/>` +
82 `</svg>`
83
84const refresh = async ($: EngineInterface) => {
85 try {
86 const { context } = await $.session.usage({ breakdown: 'summary' })
87 const rows = (context.breakdown?.categories ?? [])
88 .filter(row => row.kind === 'used' && row.tokens > 0)
89 .sort((a, b) => b.tokens - a.tokens)
90 .slice(0, 3)
91 .map(row => ({ name: row.name, tokens: row.tokens }))
92 await update($, parts, () => rows)
93 } catch {
94 // No breakdown yet (no session bound): the pane draws without it.
95 }
96}
97
98export const register: Register = on => {
99 on('session.start', async ($, e, next) => {
100 await $.command.register({
101 name: 'usage-pane',
102 description: "Opens the session's usage pane",
103 })
104 void $.ui.open({ id: PANE, title: TITLE })
105 $.clock.every(1000, () => $.ui.invalidate('ui.render'))
106
107 await refresh($)
108
109 return next(e)
110 })
111
112 on('command.run', { command: 'usage-pane' }, async $ => {
113 await $.ui.open({ id: PANE, title: TITLE })
114
115 return { text: 'Usage pane opened.' }
116 })
117
118 on('tool.call', async ($, e, next) => {
119 await update($, tools, counts => ({
120 ...counts,
121 [e.tool]: (counts[e.tool] ?? 0) + 1,
122 }))
123
124 return next(e)
125 })
126
127 on('turn.complete', async ($, e, next) => {
128 const { usage } = e
129 if (usage) {
130 await update($, tokens, sum => ({
131 input: sum.input + usage.input_tokens,
132 output: sum.output + usage.output_tokens,
133 cacheRead: sum.cacheRead + usage.cache_read_input_tokens,
134 cacheWrite: sum.cacheWrite + usage.cache_creation_input_tokens,
135 }))
136 }
137 if (e.agentId === undefined) {
138 await update($, last, () => ({ output: usage?.output_tokens ?? 0 }))
139 await refresh($)
140 }
141
142 return next(e)
143 })
144
145 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
146 const { Box, Text } = $.ui.resolve(e)
147 const [spent, counts, rows, turn, notice, usage, now] =
148 await Promise.all([
149 read($, tokens),
150 read($, tools),
151 read($, parts),
152 read($, last),
153 $.state.get(converted),
154 $.session.usage(),
155 $.clock.now(),
156 ])
157
158 const { context, cost, rateLimits } = usage
159 const { percent } = context
160 const fresh = spent.input + spent.cacheWrite
161 const incoming = fresh + spent.cacheRead
162 const ranked = Object.entries(counts).sort((a, b) => b[1] - a[1])
163 const calls = ranked.reduce((sum, [, count]) => sum + count, 0)
164 // The band mod has the context's figure and the five-hour one:
165 // the pane keeps the advice, and warns of the limits the band does not show.
166 const warnings = [
167 ...(percent !== undefined && percent >= ALERT_PERCENT
168 ? [
169 {
170 text: 'Context is high: consider /compact',
171 color: tone(percent) ?? 'warning',
172 },
173 ]
174 : []),
175 ...rateLimits
176 .filter(
177 limit =>
178 limit.kind !== 'five_hour' && limit.percentUsed >= ALERT_PERCENT,
179 )
180 .map(limit => ({
181 text: `${LIMITS[limit.kind] ?? limit.kind} limit at ${Math.round(limit.percentUsed)}%`,
182 color: tone(limit.percentUsed) ?? 'warning',
183 })),
184 ]
185 const news = [notice.value].flatMap(said =>
186 said !== undefined && said !== null && now - said.at < NOTICE_MS
187 ? [said.text]
188 : [],
189 )
190
191 const head = (label: string, value: string) => (
192 <Box flexDirection="row" justifyContent="space-between">
193 <Text>{label}</Text>
194 <Text bold>{value}</Text>
195 </Box>
196 )
197
198 const sub = (label: string, value: string) => (
199 <Box flexDirection="row" justifyContent="space-between">
200 <Text dimColor wrap="truncate-end">
201 {label}
202 </Text>
203 <Text dimColor>{value}</Text>
204 </Box>
205 )
206
207 const gauge = (label: string, value: number) => {
208 const alert = tone(value)
209 if (e.surface === 'terminal') {
210 const columns = Math.max(16, e.props.bodyColumns)
211 const full = Math.round((Math.min(100, value) / 100) * columns)
212
213 return (
214 <Box flexDirection="row">
215 {full > 0 && alert === undefined && <Text>{'━'.repeat(full)}</Text>}
216 {full > 0 && alert !== undefined && (
217 <Text color={alert}>{'━'.repeat(full)}</Text>
218 )}
219 {full < columns && (
220 <Text dimColor>{'─'.repeat(columns - full)}</Text>
221 )}
222 </Box>
223 )
224 }
225 const { Svg } = $.ui.resolve(e)
226
227 return (
228 <Svg
229 source={meter(value, METER[alert ?? 'calm'])}
230 alt={`${label} at ${value}%`}
231 height={12}
232 />
233 )
234 }
235
236 return (
237 <Box flexDirection="column" gap={1}>
238 <Box flexDirection="column">
239 <Text bold>{span(now - usage.startedAt)}</Text>
240 <Text dimColor>
241 {cost === undefined
242 ? 'this session'
243 : `this session · $${cost.usd.toFixed(2)}`}
244 </Text>
245 </Box>
246
247 {(warnings.length > 0 || news.length > 0) && (
248 <Box flexDirection="column">
249 <Text>Notices</Text>
250 {warnings.map(warning => (
251 <Text color={warning.color}>{warning.text}</Text>
252 ))}
253 {news.map(text => (
254 <Text dimColor>{text}</Text>
255 ))}
256 </Box>
257 )}
258
259 {rateLimits.map(limit => {
260 const label = `${LIMITS[limit.kind] ?? limit.kind} limit`
261 const value = Math.round(limit.percentUsed)
262
263 return (
264 <Box flexDirection="column">
265 {head(label, `${value}%`)}
266 {gauge(label, value)}
267 {limit.resetsAt !== undefined &&
268 sub('resets in', until(Date.parse(limit.resetsAt) - now))}
269 </Box>
270 )
271 })}
272
273 {context.tokens !== undefined && (
274 <Box flexDirection="column">
275 {head(
276 'Context',
277 `${compact(context.tokens)} of ${compact(context.window)}`,
278 )}
279 {rows.map(row =>
280 sub(PARTS[row.name] ?? row.name.toLowerCase(), compact(row.tokens)),
281 )}
282 </Box>
283 )}
284
285 {/* Its length and its cost are on the band mod's line. */}
286 {turn !== null && (
287 <Box flexDirection="column">
288 <Text>Last turn</Text>
289 {sub('output', compact(turn.output))}
290 </Box>
291 )}
292
293 <Box flexDirection="column">
294 {head('Tokens', compact(spent.output + fresh))}
295 {sub('output', compact(spent.output))}
296 {sub('input', compact(fresh))}
297 {incoming > 0 &&
298 sub(
299 'served from cache',
300 `${Math.round((spent.cacheRead / incoming) * 100)}%`,
301 )}
302 </Box>
303
304 <Box flexDirection="column">
305 {head('Tools', `${calls}`)}
306 {ranked
307 .slice(0, 5)
308 .map(([tool, count]) => sub(shortName(tool), `${count}`))}
309 {ranked.length > 5 && (
310 <Text dimColor>+{ranked.length - 5} more</Text>
311 )}
312 </Box>
313 </Box>
314 )
315 })
316}
317types/index.d.ts 24 lines1export type Tokens = {
2 input: number
3 output: number
4 cacheRead: number
5 cacheWrite: number
6}
7
8export type Tools = Record<string, number>
9
10export type Part = { name: string; tokens: number }
11
12export type LastTurn = { output: number }
13
14declare module 'claude-code' {
15 interface PluginState {
16 usage: {
17 tokens: Tokens
18 tools: Tools
19 parts: Part[]
20 last: LastTurn | null
21 }
22 }
23}
24