ターンごとのトークン数とキャッシュの割合をステータス行に出し、セッション中の推移をペインにグラフで描く

English | 日本語
A marketplace of mods for Claude Code. A mod is a plugin that adds features to Claude Code through function hooks.
| Mod | What it does |
|---|---|
| branch-watch | Stops commits on a branch another session switched to, and lists each session's worktree and branch |
| push-gate | Holds git push and similar commands until a person approves |
| auto-worktree | Creates a worktree on the first prompt and starts the work there |
| session-diff | Shows the files this session edited and their diffs |
| pr-pane | Shows open pull requests and their CI results |
| side-question | /btw asks a side question without stopping the work |
| done-chime | Plays a sound when a long turn ends and when Claude waits for permission |
| ja-check | Tells you when a reply starts in English |
| usage-meter | Shows each turn's token counts and prompt cache rate |
claude plugin marketplace add yut0takagi/mods
claude plugin install branch-watch@yut0takagi-mods # one line per mod you want
Developed with Claude Code 2.1.289. Mods are an early access feature, so a Claude Code update may break them. The VS Code and Cursor extension draws no mod UI, so some mods are used differently there.
To change a mod, read CONTRIBUTING. Changes are listed in the CHANGELOG. Report security problems as described in SECURITY.
hooks/register.tsx 91 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { cellsOf, COLORS, KEEP, replyOf, sparkline, statusOf, summaryOf, turnOf } from './chart'
5
6const PANE = 'usage-meter'
7const turns = atom({ plugin: 'usage-meter', key: 'turns' } as const, [])
8
9// グラフの高さ(行)
10const ROWS = 8
11
12export const register: Register = on => {
13 on('session.start', async ($, e, next) => {
14 // 画面がない(VS Code の拡張機能など)と、推移は返答に出す。説明もそれに合わせる
15 const isShown = (await $.session.surfaces().catch(() => undefined))?.length !== 0
16 await $.command.register({
17 name: 'usage-meter',
18 description: isShown ? 'このセッションのトークン数の推移をペインに出す' : 'このセッションのトークン数の推移を返答に出す',
19 })
20 $.ui.status(statusOf(await read($, turns)))
21
22 return next(e)
23 })
24
25 on('command.run', { command: 'usage-meter' }, async $ => {
26 // 画面がないセッション(VS Code・Cursor の拡張機能、-p)では、ステータス行もペインも描かれない
27 // (ui.open は isPlaced: true を返すので、それでは見分けられない)。中身を返答の文で返す。この文はモデルも読む
28 if ((await $.session.surfaces()).length === 0) {
29 return { text: replyOf(await read($, turns)) }
30 }
31
32 await $.ui.open({ id: PANE, title: 'トークン' })
33
34 // 会話に何も足さないよう、出力の文は返さない
35 return {}
36 })
37
38 on('turn.complete', async ($, e, next) => {
39 const done = await next(e)
40
41 // サブエージェントのターンは数えない
42 if (e.agentId === undefined && e.usage !== undefined) {
43 const turn = turnOf(e.usage)
44 await update($, turns, list => [...list, turn].slice(-KEEP))
45 $.ui.status(statusOf(await read($, turns)))
46 }
47
48 return done
49 })
50
51 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
52 const list = await read($, turns)
53 const width = Math.max(1, Math.min(list.length, e.props.bodyColumns))
54 const [totals = '', cache = ''] = summaryOf(list)
55
56 if (e.surface === 'terminal') {
57 const { Box, Raster, Text } = $.ui.resolve(e)
58
59 return (
60 <Box flexDirection="column">
61 {list.length === 0 ? (
62 <Text dimColor>ターンが終わると、ここにトークン数の推移が出ます。</Text>
63 ) : (
64 <Raster key="chart" columns={width} rows={ROWS} cells={cellsOf(list, width, ROWS)} />
65 )}
66 <Box gap={1}>
67 <Text color={`#${COLORS.cached.toString(16)}`}>■ キャッシュから読んだ入力</Text>
68 <Text color={`#${COLORS.fresh.toString(16)}`}>■ それ以外の入力と出力</Text>
69 </Box>
70 <Text>{totals}</Text>
71 <Text dimColor>{cache}</Text>
72 </Box>
73 )
74 }
75
76 const { Box, Text } = $.ui.resolve(e)
77
78 return (
79 <Box flexDirection="column">
80 {list.length === 0 ? (
81 <Text dimColor>ターンが終わると、ここにトークン数の推移が出ます。</Text>
82 ) : (
83 <Text>{sparkline(list, width)}</Text>
84 )}
85 <Text>{totals}</Text>
86 <Text dimColor>{cache}</Text>
87 </Box>
88 )
89 })
90}
91hooks/chart.ts 150 lines1/** 数の書き方と、グラフのマスの組み立て。$ に触れない部分。 */
2
3import type { TurnUsage } from 'claude-code'
4
5import type { Turn } from '../types'
6
7/** 覚えておくターンの数。古いものから外す。 */
8export const KEEP = 200
9
10export const turnOf = (usage: TurnUsage): Turn => ({
11 input: usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens,
12 output: usage.output_tokens,
13 cacheRead: usage.cache_read_input_tokens,
14 model: usage.model,
15})
16
17/** 1234 → 1.2k、1250000 → 1.25M。 */
18export function compact(n: number): string {
19 if (n >= 1_000_000) {
20 return `${(n / 1_000_000).toFixed(2)}M`
21 }
22
23 return n >= 1_000 ? `${(n / 1_000).toFixed(1)}k` : String(n)
24}
25
26const rateOf = (turns: readonly Turn[]) => {
27 const input = turns.reduce((sum, turn) => sum + turn.input, 0)
28
29 return input === 0 ? 0 : turns.reduce((sum, turn) => sum + turn.cacheRead, 0) / input
30}
31
32export const percent = (rate: number) => `${Math.round(rate * 100)}%`
33
34/** ステータス行。直近のターンと、セッションの合計。 */
35export function statusOf(turns: readonly Turn[]): string | undefined {
36 const last = turns.at(-1)
37
38 if (last === undefined) {
39 return undefined
40 }
41
42 const total = turns.reduce((sum, turn) => sum + turn.input + turn.output, 0)
43
44 return `入力 ${compact(last.input)} / 出力 ${compact(last.output)} · キャッシュ ${percent(rateOf([last]))} · 計 ${compact(total)}`
45}
46
47export function summaryOf(turns: readonly Turn[]): string[] {
48 const input = turns.reduce((sum, turn) => sum + turn.input, 0)
49 const output = turns.reduce((sum, turn) => sum + turn.output, 0)
50
51 return [
52 `${turns.length} ターン · 入力 ${compact(input)} · 出力 ${compact(output)}`,
53 `キャッシュから読んだ入力 ${percent(rateOf(turns))}`,
54 ]
55}
56
57const BLOCKS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
58
59/** ターンごとの合計を 1 文字ずつの棒にした行。Raster のない画面で使う。 */
60export function sparkline(turns: readonly Turn[], width: number): string {
61 const shown = turns.slice(-width)
62 const max = Math.max(1, ...shown.map(turn => turn.input + turn.output))
63
64 return shown.map(turn => BLOCKS[Math.min(7, Math.floor(((turn.input + turn.output) / max) * 7.999))]).join('')
65}
66
67/** 返答の文で棒を並べるターンの数。会話欄の幅は分からないので、狭くても折り返しにくい数に抑える。 */
68export const REPLY_TURNS = 40
69
70/**
71 * 画面がないセッションで、ステータス行とペインの代わりにコマンドの返答として出す文。
72 * 直近のターンの要約(ステータス行と同じ)、推移の棒、セッションの合計の順に並べる。
73 */
74export function replyOf(turns: readonly Turn[]): string {
75 const status = statusOf(turns)
76
77 if (status === undefined) {
78 return 'まだターンの記録がありません。ターンが終わってから /usage-meter を打つと、トークン数の推移が出ます。'
79 }
80
81 const width = Math.min(turns.length, REPLY_TURNS)
82 const [totals = '', cache = ''] = summaryOf(turns)
83
84 return [
85 `直近のターン: ${status}`,
86 `推移(直近 ${width} ターン、入力と出力の合計): ${sparkline(turns, width)}`,
87 `${totals} · ${cache}`,
88 ].join('\n')
89}
90
91const FULL = 0x2588
92// ▁ から ▇ まで。高さ 1/8〜7/8 のマス
93const PARTIAL = [0x2581, 0x2582, 0x2583, 0x2584, 0x2585, 0x2586, 0x2587]
94const DEFAULT = 0x01000000
95
96/** キャッシュから読んだ分と、それ以外の色。 */
97export const COLORS = { cached: 0x5f87af, fresh: 0xff8700 }
98
99/**
100 * ターンごとの棒グラフのマス。下からキャッシュから読んだ分、その上にそれ以外(キャッシュしていない入力と出力)を積む。
101 * 戻り値は Raster の cells にそのまま渡せる base64。
102 */
103export function cellsOf(turns: readonly Turn[], columns: number, rows: number): string {
104 const shown = turns.slice(-columns)
105 const max = Math.max(1, ...shown.map(turn => turn.input + turn.output))
106 const words = new Uint32Array(columns * rows * 3)
107
108 // 空白で埋める
109 for (let i = 0; i < columns * rows; i += 1) {
110 words.set([0x20, DEFAULT, DEFAULT], i * 3)
111 }
112
113 shown.forEach((turn, column) => {
114 // 高さは 1/8 マス単位
115 const eighths = Math.round(((turn.input + turn.output) / max) * rows * 8)
116 const cachedEighths = Math.round((turn.cacheRead / max) * rows * 8)
117
118 for (let row = 0; row < rows; row += 1) {
119 const bottom = row * 8
120 const filled = Math.min(8, Math.max(0, eighths - bottom))
121
122 if (filled === 0) {
123 continue
124 }
125
126 const color = bottom + filled <= cachedEighths ? COLORS.cached : COLORS.fresh
127 const glyph = filled === 8 ? FULL : (PARTIAL[filled - 1] ?? FULL)
128 words.set([glyph, color, DEFAULT], ((rows - 1 - row) * columns + column) * 3)
129 }
130 })
131
132 return base64Of(new Uint8Array(words.buffer))
133}
134
135const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
136
137export function base64Of(bytes: Uint8Array): string {
138 let out = ''
139
140 for (let i = 0; i < bytes.length; i += 3) {
141 const [a = 0, b = 0, c = 0] = [bytes[i], bytes[i + 1], bytes[i + 2]]
142 const n = (a << 16) | (b << 8) | c
143 out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]!
144 out += i + 1 < bytes.length ? ALPHABET[(n >> 6) & 63]! : '='
145 out += i + 2 < bytes.length ? ALPHABET[n & 63]! : '='
146 }
147
148 return out
149}
150types/index.d.ts 14 lines1/** 1 ターンのトークン数。入力はキャッシュから読んだ分と書いた分を含む。 */
2export type Turn = {
3 input: number
4 output: number
5 cacheRead: number
6 model: string
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'usage-meter': { turns: Turn[] }
12 }
13}
14