SLOPSHOPPER

usage-meter

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

newpanecommandstatus
v0.1.0MITupdated 2026-10-09yut0takagi/mods/usage-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-meter
│ ┃ トークン ✕ › fix the failing auth test and add an audit log call │ ┃ █ │ ┃ █ ⏺ Read(src/auth.ts) │ ┃ █ ⎿ Read 6 lines │ ┃ █ ⏺ Update(src/auth.ts) │ ┃ █ ⎿ Added 2 lines, removed 1 line │ ┃ █ ⏺ Bash(bun test) │ ┃ █ ⎿ 3 pass, 1 fail │ ┃ █ │ ┃ ■ キャッシュから読んだ入力 ■ それ以外の入力と ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 1 ターン · 入力 97.4k · 出力 1.5k │ ┃ キャッシュから読んだ入力 93% ✻ Worked for 42s · done 4:20 PM │ │ › /usage-meter │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ usage-meter: 入力 97.4k / 出力 1.5k · キャッシュ 93% · 計 98.9k

Draws

Pane · トークン
█ █ █ █ █ █ █ █ ■ キャッシュから読んだ入力 ■ それ以外の入力と出力 1 ターン · 入力 97.4k · 出力 1.5k キャッシュから読んだ入力 93%
README

mods

English | 日本語

A marketplace of mods for Claude Code. A mod is a plugin that adds features to Claude Code through function hooks.

ModWhat it does
branch-watchStops commits on a branch another session switched to, and lists each session's worktree and branch
push-gateHolds git push and similar commands until a person approves
auto-worktreeCreates a worktree on the first prompt and starts the work there
session-diffShows the files this session edited and their diffs
pr-paneShows open pull requests and their CI results
side-question/btw asks a side question without stopping the work
done-chimePlays a sound when a long turn ends and when Claude waits for permission
ja-checkTells you when a reply starts in English
usage-meterShows each turn's token counts and prompt cache rate

Install

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.

Docs

To change a mod, read CONTRIBUTING. Changes are listed in the CHANGELOG. Report security problems as described in SECURITY.

License

MIT

Source 3 files
hooks/register.tsx 91 lines
1import { 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}
91
hooks/chart.ts 150 lines
1/** 数の書き方と、グラフのマスの組み立て。$ に触れない部分。 */
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}
150
types/index.d.ts 14 lines
1/** 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