Side pane listing the tools used in the session (built-in, MCP, skill, agent, plugin, hook) with calls and token consumption.

A side pane listing every tool the session used, grouped by kind, with calls and token consumption.
◆ Tools 42 calls · ≈118k tok
████████████████▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▆▆▆▆▆▆▅▅▅▅
■ builtin 61k ■ mcp 38k ■ skill 12k ■ hook 7k
Sort tokens calls name
────────────────────────────────────────────────
● Read builtin 14× 31k
▰▰▰▰▰▰▰▰▰▰ ↑1.2k ↓30k ⌂2.1k
● search_code github mcp 6× 22k
▰▰▰▰▰▰▰▱▱▱ ↑0.4k ↓21k ⌂0.6k
| Kind | What it counts |
|---|---|
builtin | Claude Code's own tools (Bash, Read, Edit, …) |
mcp | mcp__<server>__<tool>, labelled with the server |
skill | Each skill invoked through the Skill tool |
agent | Each subagent type launched through the Agent tool |
plugin | Tools a plugin registered |
hook | Context that settings hooks injected into the conversation, per hook event |
Token counts are estimates (about 4 characters per token). Subagents' tool calls are included.
Commands: /tools-usage opens the pane, /tools-usage reset clears the counts.
hooks/register.tsx 357 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ToolKind, ToolStat, ToolsSort } from '../types'
5import { LOCALES, pickLocale } from './i18n'
6import type { Locale } from './i18n'
7
8const PANE = 'tools-usage'
9const PANE_COLUMNS = 58
10
11const tools = atom({ plugin: 'tools-usage', key: 'tools' } as const, [])
12const sort = atom({ plugin: 'tools-usage', key: 'sort' } as const, 'tokens')
13
14const KINDS: ToolKind[] = ['builtin', 'mcp', 'skill', 'agent', 'plugin', 'hook']
15const COLORS: Record<ToolKind, string> = {
16 builtin: '#58A6FF',
17 mcp: '#BC8CFF',
18 skill: '#3FB950',
19 agent: '#D97757',
20 plugin: '#39C5CF',
21 hook: '#E5A50A',
22}
23const ACCENT = '#D97757'
24
25/** Rough count: about four characters per token. */
26function estimate(text: string): number {
27 return Math.ceil(text.length / 4)
28}
29
30function argumentsOf(e: Record<string, unknown>): string {
31 const { tool: _tool, tool_use_id: _id, agentId: _agent, ...args } = e
32
33 return JSON.stringify(args)
34}
35
36/** Which row a call counts under, and what kind of tool it is. */
37function classify(
38 tool: string,
39 e: Record<string, unknown>,
40 providers: Record<string, string>,
41): Pick<ToolStat, 'key' | 'label' | 'kind' | 'source'> {
42 if (tool === 'Skill') {
43 const skill = String(e.skill ?? e.command ?? 'skill')
44 const [namespace, name] = skill.includes(':') ? skill.split(':', 2) : [null, skill]
45
46 return { key: `skill:${skill}`, label: name ?? skill, kind: 'skill', source: namespace ?? null }
47 }
48 if (tool === 'Agent' || tool === 'Task') {
49 const type = String(e.subagent_type ?? 'general-purpose')
50
51 return { key: `agent:${type}`, label: type, kind: 'agent', source: null }
52 }
53 if (tool.startsWith('mcp__')) {
54 const [, server = '', ...rest] = tool.split('__')
55 const label = rest.join('__') || tool
56 const provider = providers[tool]
57 const isPlugin = provider !== undefined && provider !== 'engine'
58
59 return { key: tool, label, kind: isPlugin ? 'plugin' : 'mcp', source: server }
60 }
61 const provider = providers[tool]
62 if (provider !== undefined && provider !== 'engine') {
63 return { key: tool, label: tool, kind: 'plugin', source: provider }
64 }
65
66 return { key: tool, label: tool, kind: 'builtin', source: null }
67}
68
69function blank(identity: Pick<ToolStat, 'key' | 'label' | 'kind' | 'source'>): ToolStat {
70 return { ...identity, calls: 0, errors: 0, running: 0, inTokens: 0, outTokens: 0, defTokens: 0, lastAt: 0 }
71}
72
73async function patch(
74 $: EngineInterface,
75 identity: Pick<ToolStat, 'key' | 'label' | 'kind' | 'source'>,
76 fn: (stat: ToolStat) => ToolStat,
77): Promise<void> {
78 await update($, tools, list => {
79 const rows = list ?? []
80 const found = rows.some(row => row.key === identity.key)
81 const next = found ? rows : [...rows, blank(identity)]
82
83 return next.map(row => (row.key === identity.key ? fn(row) : row))
84 })
85}
86
87function total(stat: ToolStat): number {
88 return stat.inTokens + stat.outTokens
89}
90
91function compact(n: number): string {
92 if (n >= 1_000_000) {
93 return `${(n / 1_000_000).toFixed(1)}M`
94 }
95 if (n >= 10_000) {
96 return `${Math.round(n / 1000)}k`
97 }
98 if (n >= 1000) {
99 return `${(n / 1000).toFixed(1)}k`
100 }
101
102 return String(n)
103}
104
105function sorted(list: ToolStat[], by: ToolsSort): ToolStat[] {
106 return [...list].sort((a, b) =>
107 by === 'calls'
108 ? b.calls - a.calls || total(b) - total(a)
109 : by === 'name'
110 ? a.label.localeCompare(b.label)
111 : total(b) - total(a) || b.calls - a.calls,
112 )
113}
114
115/** The plugin's option, Claude Code's `language` setting, then the process locale. */
116async function resolveLocale($: EngineInterface, option: unknown): Promise<Locale> {
117 const settings = await $.settings.read()
118
119 return pickLocale(
120 option,
121 settings.language,
122 await $.env.get('LC_ALL'),
123 await $.env.get('LC_MESSAGES'),
124 await $.env.get('LANG'),
125 )
126}
127
128export const register: Register = (on, options) => {
129 let locale: Locale = 'en'
130 const t = () => LOCALES[locale]
131 /** Who provides each tool (`engine` for built-ins and MCP servers), from its description. */
132 const providers: Record<string, string> = {}
133 const definitions: Record<string, number> = {}
134
135 on('session.start', async ($, e, next) => {
136 locale = await resolveLocale($, options.language)
137 await $.command.register({
138 name: 'tools-usage',
139 description: t().commandDescription,
140 })
141 void $.ui.open({ id: PANE, title: t().title, columns: PANE_COLUMNS })
142
143 return next(e)
144 })
145
146 on('command.run', { command: 'tools-usage' }, async ($, e) => {
147 if (e.args.trim() === 'reset') {
148 await update($, tools, () => [])
149
150 return { text: t().cleared }
151 }
152 await $.ui.open({ id: PANE, title: t().title, columns: PANE_COLUMNS })
153
154 return { text: t().opened }
155 })
156
157 on('tool.describe', async ($, e, next) => {
158 const result = await next(e)
159 providers[e.tool] = e.provider.plugin
160 const tokens = estimate(result.description)
161 if (definitions[e.tool] !== tokens && !result.isDeferred) {
162 definitions[e.tool] = tokens
163 const args = { tool: e.tool } as Record<string, unknown>
164 const identity = classify(e.tool, args, providers)
165 if (identity.kind !== 'skill' && identity.kind !== 'agent') {
166 await patch($, identity, stat => ({ ...stat, defTokens: tokens }))
167 }
168 }
169
170 return result
171 })
172
173 on('tool.call', async ($, e, next) => {
174 const args = e as unknown as Record<string, unknown>
175 const identity = classify(e.tool, args, providers)
176 const inTokens = estimate(argumentsOf(args))
177 await patch($, identity, stat => ({
178 ...stat,
179 calls: stat.calls + 1,
180 running: stat.running + 1,
181 inTokens: stat.inTokens + inTokens,
182 }))
183
184 let outTokens = 0
185 let isError = false
186 try {
187 const result = await next(e)
188 isError = result.isError === true || result.deny !== undefined
189 outTokens = estimate(result.text ?? result.deny ?? '')
190
191 return result
192 } catch (error) {
193 isError = true
194 throw error
195 } finally {
196 const at = await $.clock.now()
197 await patch($, identity, stat => ({
198 ...stat,
199 running: Math.max(0, stat.running - 1),
200 outTokens: stat.outTokens + outTokens,
201 errors: stat.errors + (isError ? 1 : 0),
202 lastAt: at,
203 }))
204 }
205 })
206
207 // What a settings hook adds to the conversation is context the model reads: count it per event.
208 on('session.append', async ($, e, next) => {
209 const result = await next(e)
210 if (e.origin.kind === 'hook' || e.door === 'hook-context') {
211 const event = e.origin.kind === 'hook' ? e.origin.event : (e.message.name ?? 'hook')
212 const text = e.message.content
213 .map(block => (typeof block.text === 'string' ? block.text : JSON.stringify(block)))
214 .join('\n')
215 const at = await $.clock.now()
216 await patch($, { key: `hook:${event}`, label: event, kind: 'hook', source: null }, stat => ({
217 ...stat,
218 calls: stat.calls + 1,
219 outTokens: stat.outTokens + estimate(text),
220 lastAt: at,
221 }))
222 }
223
224 return result
225 })
226
227 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
228 const { Box, Text, Button } = $.ui.resolve(e)
229 const m = t()
230 const list = (await read($, tools)) ?? []
231 const by = await read($, sort)
232 const width = Math.max(30, e.props.bodyColumns)
233 const isNarrow = width < 48
234
235 const used = list.filter(stat => stat.calls > 0)
236 const grand = used.reduce((sum, stat) => sum + total(stat), 0)
237 const calls = used.reduce((sum, stat) => sum + stat.calls, 0)
238 const definitionsTotal = list.reduce((sum, stat) => sum + stat.defTokens, 0)
239 const byKind = KINDS.map(kind => {
240 const rows = used.filter(stat => stat.kind === kind)
241
242 return { kind, tokens: rows.reduce((sum, stat) => sum + total(stat), 0), calls: rows.reduce((s, r) => s + r.calls, 0) }
243 }).filter(group => group.calls > 0)
244
245 const barWidth = width
246 const cells = byKind.map(group => ({
247 kind: group.kind,
248 cells: grand === 0 ? 0 : Math.max(1, Math.round((group.tokens / grand) * barWidth)),
249 }))
250 const overflow = cells.reduce((sum, cell) => sum + cell.cells, 0) - barWidth
251 const widest = cells.reduce((best, cell) => (cell.cells > (best?.cells ?? -1) ? cell : best), cells[0])
252 if (widest && overflow > 0) {
253 widest.cells -= overflow
254 }
255
256 const max = Math.max(1, ...used.map(total))
257 const nameWidth = isNarrow ? width - 16 : width - 30
258 const rows = sorted(used, by)
259
260 return (
261 <Box flexDirection="column" width={width}>
262 <Box flexDirection="row" justifyContent="space-between">
263 <Text bold color={ACCENT}>
264 {m.heading}
265 </Text>
266 <Text>
267 <Text bold>{calls}</Text>
268 <Text dimColor> {m.calls} · </Text>
269 <Text bold>≈{compact(grand)}</Text>
270 <Text dimColor> {m.tokens}</Text>
271 </Text>
272 </Box>
273
274 {grand > 0 && (
275 <Box flexDirection="row" marginTop={1}>
276 {cells.map(cell => (
277 <Text color={COLORS[cell.kind]}>{'█'.repeat(Math.max(0, cell.cells))}</Text>
278 ))}
279 </Box>
280 )}
281 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
282 {byKind.map(group => (
283 <Text>
284 <Text color={COLORS[group.kind]}>■ </Text>
285 <Text>{group.kind}</Text>
286 <Text dimColor> {compact(group.tokens)}</Text>
287 </Text>
288 ))}
289 </Box>
290
291 <Box flexDirection="row" gap={1} marginTop={1}>
292 <Text dimColor>{m.sort}</Text>
293 {(['tokens', 'calls', 'name'] as const).map(option => (
294 <Button
295 key={`sort-${option}`}
296 label={m.sortBy[option]}
297 plain
298 dimColor={option !== by}
299 onPress={() => update($, sort, () => option)}
300 />
301 ))}
302 </Box>
303 <Text dimColor>{'─'.repeat(width)}</Text>
304
305 {rows.length === 0 && <Text dimColor>{m.empty}</Text>}
306
307 {rows.map(stat => {
308 const color = COLORS[stat.kind]
309 const share = Math.max(1, Math.round((total(stat) / max) * 10))
310
311 return (
312 <Box key={`tool-${stat.key}`} flexDirection="column" marginBottom={isNarrow ? 1 : 0}>
313 <Box flexDirection="row" justifyContent="space-between">
314 <Box flexDirection="row" width={nameWidth} flexShrink={1}>
315 <Text color={color}>{stat.running > 0 ? '◐ ' : '● '}</Text>
316 <Text bold wrap="truncate-end">
317 {stat.label}
318 </Text>
319 {stat.source && (
320 <Text dimColor wrap="truncate-end">
321 {' '}
322 {stat.source}
323 </Text>
324 )}
325 </Box>
326 <Box flexDirection="row" gap={1}>
327 <Text color={color}>{stat.kind}</Text>
328 <Text>{String(stat.calls).padStart(3)}×</Text>
329 {!isNarrow && <Text bold>{compact(total(stat)).padStart(5)}</Text>}
330 </Box>
331 </Box>
332 <Box flexDirection="row" justifyContent="space-between" paddingLeft={2}>
333 <Text>
334 <Text color={color}>{'▰'.repeat(share)}</Text>
335 <Text dimColor>{'▱'.repeat(10 - share)}</Text>
336 </Text>
337 <Text dimColor>
338 ↑{compact(stat.inTokens)} ↓{compact(stat.outTokens)}
339 {stat.defTokens > 0 ? ` ⌂${compact(stat.defTokens)}` : ''}
340 </Text>
341 {stat.errors > 0 && <Text color="#F85149">✗{stat.errors}</Text>}
342 </Box>
343 </Box>
344 )
345 })}
346
347 <Text dimColor>{'─'.repeat(width)}</Text>
348 <Text dimColor>{m.legend}</Text>
349 {definitionsTotal > 0 && (
350 <Text dimColor>{m.schemas(compact(definitionsTotal))}</Text>
351 )}
352 <Text dimColor>{m.footer}</Text>
353 </Box>
354 )
355 })
356}
357hooks/i18n.ts 40 lines1import { en } from '../locales/en'
2import { fr } from '../locales/fr'
3
4export type { Messages } from '../locales/en'
5
6export const LOCALES = { en, fr }
7
8export type Locale = keyof typeof LOCALES
9
10function match(value: unknown): Locale | null {
11 if (typeof value !== 'string') {
12 return null
13 }
14 const tag = value.trim().toLowerCase()
15 if (tag.startsWith('fr')) {
16 return 'fr'
17 }
18 if (tag.startsWith('en')) {
19 return 'en'
20 }
21
22 return null
23}
24
25/**
26 * The first candidate naming a supported locale, English otherwise. Callers pass,
27 * in order: the plugin's `language` option, Claude Code's `language` setting,
28 * then LC_ALL, LC_MESSAGES and LANG.
29 */
30export function pickLocale(...candidates: unknown[]): Locale {
31 for (const candidate of candidates) {
32 const locale = match(candidate)
33 if (locale) {
34 return locale
35 }
36 }
37
38 return 'en'
39}
40locales/en.ts 18 lines1export const en = {
2 commandDescription: 'Show the tools pane (`/tools-usage reset` clears the counts)',
3 cleared: 'Tool counts cleared.',
4 opened: 'Tools pane opened.',
5 title: 'Tools',
6 heading: '◆ Tools',
7 calls: 'calls',
8 tokens: 'tok',
9 sort: 'Sort',
10 sortBy: { tokens: 'tokens', calls: 'calls', name: 'name' },
11 empty: 'No tool calls yet.',
12 legend: '↑ args written · ↓ results read · ⌂ schema per request',
13 schemas: (tokens: string) => `Tool schemas ≈${tokens} tok sent with every request`,
14 footer: 'Estimates (~4 chars/token) · /tools-usage reset',
15}
16
17export type Messages = typeof en
18locales/fr.ts 18 lines1import type { Messages } from './en'
2
3export const fr: Messages = {
4 commandDescription: 'Affiche le panneau des outils (`/tools-usage reset` remet les compteurs à zéro)',
5 cleared: 'Compteurs des outils remis à zéro.',
6 opened: 'Panneau des outils ouvert.',
7 title: 'Outils',
8 heading: '◆ Outils',
9 calls: 'appels',
10 tokens: 'tok',
11 sort: 'Tri',
12 sortBy: { tokens: 'tokens', calls: 'appels', name: 'nom' },
13 empty: 'Aucun appel d’outil pour le moment.',
14 legend: '↑ arguments écrits · ↓ résultats lus · ⌂ schéma par requête',
15 schemas: (tokens: string) => `Schémas d’outils ≈${tokens} tok envoyés à chaque requête`,
16 footer: 'Estimations (~4 caractères/token) · /tools-usage reset',
17}
18types/index.d.ts 33 lines1export type ToolKind = 'builtin' | 'mcp' | 'skill' | 'agent' | 'plugin' | 'hook'
2
3export type ToolStat = {
4 /** `Bash`, `mcp__github__search`, `skill:pdf`, `agent:Explore`, `hook:PreToolUse`. */
5 key: string
6 /** What the row shows. */
7 label: string
8 kind: ToolKind
9 /** The MCP server, plugin or skill namespace the tool belongs to, when it has one. */
10 source: string | null
11 calls: number
12 errors: number
13 running: number
14 /** Estimated tokens of the arguments the model wrote (output tokens). */
15 inTokens: number
16 /** Estimated tokens of the results the model read back (input tokens). */
17 outTokens: number
18 /** Estimated tokens of the tool's description sent with each request. */
19 defTokens: number
20 lastAt: number
21}
22
23export type ToolsSort = 'tokens' | 'calls' | 'name'
24
25declare module 'claude-code' {
26 interface PluginState {
27 'tools-usage': {
28 tools: ToolStat[]
29 sort: ToolsSort
30 }
31 }
32}
33