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

$ pwd
# -> /Users/hoge
$ git clone git@github.com:tocomi/dotfiles.git
$ cd dotfiles
$ ./setup.shhooks/register.tsx 169 lines1import { 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}
169types/index.d.ts 18 lines1export 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