SLOPSHOPPER

tools-usage

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

newpaneguardcommand
★ 1v0.2.0MITupdated 2026-10-05Fazzani/claude-mods/plugins/tools-usage
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tools-usage
│ ┃ Tools ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Tools 9 call │ ┃ ⏺ Read(src/auth.ts) │ ┃ ████████████████████████████████████████████ ⎿ Read 6 lines │ ┃ ■ builtin 444 ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Sort tokens calls name ⏺ Bash(bun test) │ ┃ ──────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ ● Bash builti │ ┃ ▰▰▰▰▰▰▰▰▰▰ ↑76 ↓76 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ● Write builti │ ┃ ▰▰▰▰▰▰▰▰▰▱ ✻ Worked for 42s · done 4:20 PM │ ┃ ● Edit builti │ ┃ ▰▰▰▰▰▱▱▱▱▱ › /tools-usage │ ┃ ● Read builti ⎿ tools-usage: Tools pane opened. │ ┃ ▰▰▰▰▱▱▱▱▱▱ │ ┃ ● Grep builti │ ┃ ▰▱▱▱▱▱▱▱▱▱ │ ┃ ──────────────────────────────────────────── │ ┃ ↑ args written · ↓ results read · ⌂ schema p │ ┃ Estimates (~4 chars/token) · /tools-usage re │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Tools
◆ Tools 9 calls · ≈444 tok ████████████████████████████████████████████████████████ ■ builtin 444 Sort tokens calls name ──────────────────────────────────────────────────────── ● Bash builtin 4× 152 ▰▰▰▰▰▰▰▰▰▰ ↑76 ↓76 ✗1 ● Write builtin 2× 143 ▰▰▰▰▰▰▰▰▰▱ ↑117 ↓26 ● Edit builtin 1× 70 ▰▰▰▰▰▱▱▱▱▱ ↑58 ↓12 ● Read builtin 1× 59 ▰▰▰▰▱▱▱▱▱▱ ↑10 ↓49 ● Grep builtin 1× 20 ▰▱▱▱▱▱▱▱▱▱ ↑12 ↓8 ──────────────────────────────────────────────────────── ↑ args written · ↓ results read · ⌂ schema per request Estimates (~4 chars/token) · /tools-usage reset
README

tools-usage

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
KindWhat it counts
builtinClaude Code's own tools (Bash, Read, Edit, …)
mcpmcp__<server>__<tool>, labelled with the server
skillEach skill invoked through the Skill tool
agentEach subagent type launched through the Agent tool
pluginTools a plugin registered
hookContext that settings hooks injected into the conversation, per hook event
  • ↑ the arguments the model wrote (output tokens)
  • ↓ the result the model read back (input tokens)
  • ⌂ the tool's description, which is sent with every request

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.

Source 5 files
hooks/register.tsx 357 lines
1import { 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}
357
hooks/i18n.ts 40 lines
1import { 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}
40
locales/en.ts 18 lines
1export 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
18
locales/fr.ts 18 lines
1import 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}
18
types/index.d.ts 33 lines
1export 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