SLOPSHOPPER

tool-stats

ツールの使い方を集計して /tools のペインに表示する

newpaneguardcommand
v0.1.0no licenseupdated 2026-10-05tocomi/dotfiles/.claude/mods/tool-stats
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tool-stats
│ ┃ ツール統計 ✕ › fix the failing auth test and add an audit log call │ ┃ [ このセッション ] [ 累計 ] 計 9 回 │ ┃ ツール ⏺ Read(src/auth.ts) │ ┃ Bash ██████████████████████████ 4 · 平均 ⎿ Read 6 lines │ ┃ 20ms · 失敗 1 ⏺ Update(src/auth.ts) │ ┃ Write █████████████░░░░░░░░░░░░░ 2 · 平均 ⎿ Added 2 lines, removed 1 line │ ┃ 20ms ⏺ Bash(bun test) │ ┃ Read ███████░░░░░░░░░░░░░░░░░░░ 1 · 平均 ⎿ 3 pass, 1 fail │ ┃ 20ms │ ┃ Grep ███████░░░░░░░░░░░░░░░░░░░ 1 · 平均 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 20ms │ ┃ Edit ███████░░░░░░░░░░░░░░░░░░░ 1 · 平均 ✻ Worked for 42s · done 4:20 PM │ ┃ 20ms │ ┃ よく読んだファイル › /tools │ ┃ 1 src/auth.ts ⎿ tool-stats: ツール統計を開きました │ ┃ よく使った Bash │ ┃ 1 bun test │ ┃ 1 git status │ ┃ 1 rm │ ┃ 1 cat │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ツール統計
[ このセッション ] [ 累計 ] 計 9 回 ツール Bash ██████████████████████████ 4 · 平均 20ms · 失敗 1 Write █████████████░░░░░░░░░░░░░ 2 · 平均 20ms Read ███████░░░░░░░░░░░░░░░░░░░ 1 · 平均 20ms Grep ███████░░░░░░░░░░░░░░░░░░░ 1 · 平均 20ms Edit ███████░░░░░░░░░░░░░░░░░░░ 1 · 平均 20ms よく読んだファイル 1 src/auth.ts よく使った Bash 1 bun test 1 git status 1 rm 1 cat
README

dotfiles

usage

$ pwd
# -> /Users/hoge

$ git clone git@github.com:tocomi/dotfiles.git

$ cd dotfiles
$ ./setup.sh
Source 2 files
hooks/register.tsx 169 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Stats } from '../types'
5
6const EMPTY: Stats = { tools: {}, files: {}, bash: {} }
7
8const sessionAtom = atom({ plugin: 'tool-stats', key: 'session' } as const, EMPTY)
9const totalsAtom = atom({ plugin: 'tool-stats', key: 'totals' } as const, EMPTY)
10const scopeAtom = atom({ plugin: 'tool-stats', key: 'scope' } as const, 'session')
11
12const PANE = 'tool-stats'
13const TOP = 8
14// サブコマンドまで含めて数えるコマンド
15const SUBCOMMANDS = new Set(['git', 'gh', 'npm', 'pnpm', 'yarn', 'bun', 'npx', 'claude', 'docker', 'brew', 'cargo', 'go', 'kubectl'])
16
17const C = {
18  title: '#22d3ee',
19  bar: '#60a5fa',
20  err: '#f87171',
21  label: '#e5e7eb',
22  muted: '#9ca3af',
23  track: '#374151',
24}
25
26const str = (v: unknown) => (typeof v === 'string' ? v : '')
27
28// Bash のコマンドを集計用のキーにする。先頭の cd ... && は飛ばす
29function commandKey(command: string): string {
30  const segment =
31    command
32      .split(/&&|;|\|\|/)
33      .map(s => s.trim())
34      .find(s => s !== '' && !s.startsWith('cd ')) ?? command.trim()
35  const [first = '', second = ''] = segment.split(/\s+/)
36  const name = first.split('/').pop() ?? first
37  return SUBCOMMANDS.has(name) && second !== '' && !second.startsWith('-') ? `${name} ${second}` : name
38}
39
40const shortName = (name: string) => (name.startsWith('mcp__') ? (name.split('__').slice(1).join(':') ?? name) : name)
41
42function add(stats: Stats, tool: string, ms: number, isError: boolean, file: string, bash: string): Stats {
43  const t = stats.tools[tool] ?? { n: 0, ms: 0, err: 0 }
44  return {
45    tools: { ...stats.tools, [tool]: { n: t.n + 1, ms: t.ms + ms, err: t.err + (isError ? 1 : 0) } },
46    files: file === '' ? stats.files : { ...stats.files, [file]: (stats.files[file] ?? 0) + 1 },
47    bash: bash === '' ? stats.bash : { ...stats.bash, [bash]: (stats.bash[bash] ?? 0) + 1 },
48  }
49}
50
51function top(record: Record<string, number>): [string, number][] {
52  return Object.entries(record)
53    .sort((a, b) => b[1] - a[1])
54    .slice(0, TOP)
55}
56
57function avg(ms: number, n: number): string {
58  const v = ms / Math.max(1, n)
59  return v >= 1000 ? `${(v / 1000).toFixed(1)}s` : `${Math.round(v)}ms`
60}
61
62async function recordCall($: EngineInterface, tool: string, ms: number, isError: boolean, file: string, bash: string) {
63  await update($, sessionAtom, s => add(s ?? EMPTY, tool, ms, isError, file, bash))
64  const totals = await update($, totalsAtom, s => add(s ?? EMPTY, tool, ms, isError, file, bash))
65  await $.store.set('totals', totals)
66}
67
68export const register: Register = on => {
69  on('session.start', async ($, e, next) => {
70    const result = await next(e)
71    await $.command.register({ name: 'tools', description: 'ツールの使い方の統計を開く' })
72    const saved = await $.store.get('totals')
73    if (saved !== undefined && typeof saved === 'object') await update($, totalsAtom, () => saved as Stats)
74    return result
75  })
76
77  on('tool.call', async ($, e, next) => {
78    const startedAt = await $.clock.now()
79    const result = await next(e)
80    const ms = (await $.clock.now()) - startedAt
81    const args = e as unknown as Record<string, unknown>
82    const tool = shortName(e.tool)
83    const isError = result.deny !== undefined || result.isError === true
84    // 読んだファイルは Read だけ数える。パスはセッションの場所からの相対にする
85    const cwd = await $.session.cwd()
86    const path = e.tool === 'Read' ? str(args.file_path) : ''
87    const file = path.startsWith(cwd + '/') ? path.slice(cwd.length + 1) : path
88    const bash = e.tool === 'Bash' ? commandKey(str(args.command)) : ''
89    await recordCall($, tool, ms, isError, file, bash)
90    return result
91  })
92
93  on('command.run', { command: 'tools' }, async ($, e) => {
94    await $.ui.open({ id: PANE, title: 'ツール統計', focus: true, closeOnEscape: true })
95    return { text: 'ツール統計を開きました' }
96  })
97
98  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
99    const scope = await read($, scopeAtom)
100    const stats = scope === 'all' ? await read($, totalsAtom) : await read($, sessionAtom)
101    const { Box, Button, Text } = $.ui.resolve(e)
102
103    const tools = Object.entries(stats.tools).sort((a, b) => b[1].n - a[1].n)
104    const max = Math.max(1, ...tools.map(([, t]) => t.n))
105    const total = tools.reduce((sum, [, t]) => sum + t.n, 0)
106    const nameWidth = Math.min(18, Math.max(6, ...tools.map(([name]) => name.length)))
107    const barWidth = Math.max(8, Math.min(30, e.props.bodyColumns - nameWidth - 24))
108
109    const section = (title: string) => (
110      <Text color={C.title} bold>
111        {'\n'}
112        {title}
113      </Text>
114    )
115    const ranking = (rows: [string, number][]) =>
116      rows.map(([key, n]) => (
117        <Text>
118          <Text color={C.muted}>{String(n).padStart(4)} </Text>
119          <Text color={C.label}>{key}</Text>
120        </Text>
121      ))
122
123    return (
124      <Box flexDirection="column">
125        <Box>
126          <Button
127            key="session"
128            label="このセッション"
129            hotkey="1"
130            variant={scope === 'session' ? 'primary' : 'secondary'}
131            onPress={() => update($, scopeAtom, () => 'session')}
132          />
133          <Text> </Text>
134          <Button
135            key="all"
136            label="累計"
137            hotkey="2"
138            variant={scope === 'all' ? 'primary' : 'secondary'}
139            onPress={() => update($, scopeAtom, () => 'all')}
140          />
141          <Text color={C.muted}> 計 {total} 回</Text>
142        </Box>
143
144        {section('ツール')}
145        {tools.length === 0 && <Text color={C.muted}>まだツールは使われていません</Text>}
146        {tools.map(([name, t]) => {
147          const filled = Math.max(1, Math.round((t.n / max) * barWidth))
148          return (
149            <Text>
150              <Text color={C.label}>{name.padEnd(nameWidth).slice(0, nameWidth)} </Text>
151              <Text color={C.bar}>{'█'.repeat(filled)}</Text>
152              <Text color={C.track}>{'░'.repeat(barWidth - filled)}</Text>
153              <Text color={C.label}> {t.n}</Text>
154              <Text color={C.muted}> · 平均 {avg(t.ms, t.n)}</Text>
155              {t.err > 0 && <Text color={C.err}> · 失敗 {t.err}</Text>}
156            </Text>
157          )
158        })}
159
160        {Object.keys(stats.files).length > 0 && section('よく読んだファイル')}
161        {ranking(top(stats.files))}
162
163        {Object.keys(stats.bash).length > 0 && section('よく使った Bash')}
164        {ranking(top(stats.bash))}
165      </Box>
166    )
167  })
168}
169
types/index.d.ts 18 lines
1export type ToolStat = { n: number; ms: number; err: number }
2export type Stats = {
3  tools: Record<string, ToolStat>
4  files: Record<string, number>
5  bash: Record<string, number>
6}
7export type Scope = 'session' | 'all'
8
9declare module 'claude-code' {
10  interface PluginState {
11    'tool-stats': {
12      session: Stats
13      totals: Stats
14      scope: Scope
15    }
16  }
17}
18