SLOPSHOPPER

band-meter

Draws one row above the prompt: context fill, rate-limit windows and session cost.

newbandcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · band-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. ✻ Worked for 42s · done 4:20 PM › /meter ⎿ band-meter: band-meter: hidden ⟨Claude Code's own drawing⟩ ctx ████░░░░ 49% 5h 31% 7d -- $0.42 [ hide ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ctx ████░░░░ 49% 5h 31% 7d -- $0.42 [ hide ]
README

band-meter

プロンプトの真上(AbovePrompt バンド)に 1 行だけ描く Claude Code の function-hooks mod です。

ctx ███░░░░░ 42%  5h 61%  7d 18%  $0.42   [ hide ]
  • ctx … コンテキストウィンドウの使用率(8 セルのバー付き)
  • 5h / 7d … 5 時間・7 日のレート制限ウィンドウの使用率
  • $ … このセッションの累計コスト(ホストが記録している場合のみ)
  • [ hide ] … 押すとバンドを消す

数値は session.start で $.session.usage() を 1 回読み、以降は session.measure イベントで更新します。描画のたびに計算はしません。

使い方

claude --plugin-dir /path/to/band-meter

/meter で表示・非表示を切り替えます。状態は $.store の shown キーに残るので、 次のセッションでも消えたままです(既定は表示)。

関数フックが有効な対話セッションでのみ描画されます。claude -p では ui.render 自体が発生しないため、何も描かれません(フック自体は動きます)。

開発

claude plugin validate .   # フックと $ 呼び出しの棚卸し
claude plugin test .       # tests/register.test.tsx
npx tsc -p .               # 型チェック

型は /plugin-types が .claude/types/ に書き出します(git 管理外)。

到達範囲

Claude Code 2.1.278 で検証:

❯ ./register.tsx hooks: session.start, command.run{command=meter}, session.measure, ui.render{component=AbovePrompt} ❯ ./register.tsx calls: $.command.register, $.session.usage, $.store.get, $.store.set, $.ui.invalidate, $.ui.log, $.ui.resolve

ファイル・ネットワーク・プロセスには一切触りません。読むのはこのセッション自身の 使用量だけ、書くのは自分の $.store だけです。

MIT

Source 1 files
hooks/register.tsx 117 lines
1/* @jsx h */
2import type { Register, SessionRateLimit } from 'claude-code'
3
4// band-meter draws one row above the prompt: how full the context window is,
5// how much of the 5 hour and 7 day rate-limit windows is used, and what the
6// session has cost. `/meter` hides the row and shows it again.
7
8const CELLS = 8
9
10/** `████░░░░` for a percentage, eight cells wide. */
11function bar(percent: number): string {
12  const filled = Math.round((Math.max(0, Math.min(100, percent)) / 100) * CELLS)
13  return '█'.repeat(filled) + '░'.repeat(CELLS - filled)
14}
15
16/** `42%`, or `--` for a figure the engine has not reported yet. */
17const asPercent = (value: number | undefined) =>
18  value === undefined ? '--' : `${Math.round(value)}%`
19
20const windowOf = (limits: readonly SessionRateLimit[], kind: string) =>
21  limits.find(limit => limit.kind === kind)?.percentUsed
22
23// The last measurement, kept in the module so every redraw is free.
24const meter = {
25  context: 0,
26  fiveHour: undefined as number | undefined,
27  sevenDay: undefined as number | undefined,
28  usd: undefined as number | undefined,
29}
30
31// Whether the row is drawn; `/meter` toggles it and $.store remembers it.
32let shown = true
33
34/** `ctx ███░░░░░ 42%  5h 61%  7d 18%  $0.42` (42% → round(42/100*8) = 3 cells) */
35function line(): string {
36  const parts = [
37    `ctx ${bar(meter.context)} ${asPercent(meter.context)}`,
38    `5h ${asPercent(meter.fiveHour)}`,
39    `7d ${asPercent(meter.sevenDay)}`,
40  ]
41  if (meter.usd !== undefined) parts.push(`$${meter.usd.toFixed(2)}`)
42  return parts.join('  ')
43}
44
45export const register: Register = on => {
46  // One reading at the start, so the row has figures before the first turn.
47  on('session.start', async ($, e, next) => {
48    const started = await next(e)
49
50    shown = (await $.store.get('shown')) !== false
51
52    const usage = await $.session.usage()
53    meter.context = usage.context.percent ?? 0
54    meter.fiveHour = windowOf(usage.rateLimits, 'five_hour')
55    meter.sevenDay = windowOf(usage.rateLimits, 'seven_day')
56    meter.usd = usage.cost?.usd
57
58    await $.command.register({
59      name: 'meter',
60      description: 'Show or hide the band-meter row above the prompt.',
61    })
62
63    return started
64  })
65
66  // `/meter`: no core command answers this name, so the hook answers alone.
67  on('command.run', { command: 'meter' }, async $ => {
68    shown = !shown
69    await $.store.set('shown', shown)
70    $.ui.invalidate('ui.render')
71
72    return { text: `band-meter: ${shown ? 'shown' : 'hidden'}` }
73  })
74
75  // The engine measures the session whenever a unit moves; observe and redraw.
76  on('session.measure', ($, e, next) => {
77    meter.context = e.context.percent ?? meter.context
78    meter.fiveHour = windowOf(e.rateLimits, 'five_hour') ?? meter.fiveHour
79    meter.sevenDay = windowOf(e.rateLimits, 'seven_day') ?? meter.sevenDay
80    meter.usd = e.cost?.usd ?? meter.usd
81    $.ui.invalidate('ui.render')
82
83    return next(e)
84  })
85
86  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
87    // Whatever drew beneath is kept and wrapped; a survey owns the band alone.
88    const drawn = await next(e)
89    if (!shown || e.props.hasSurvey || e.props.maxRows < 1) return drawn
90
91    const { Box, Button, Text } = $.ui.resolve(e)
92    // One row: the text sized to the band's own width, less room for [ hide ].
93    const text = line().slice(0, Math.max(12, e.props.bodyColumns - 10))
94
95    return (
96      <Box flexDirection="column">
97        {drawn}
98        <Box flexDirection="row" columnGap={1}>
99          <Text dimColor>{text}</Text>
100          <Button
101            key="hide"
102            label="hide"
103            onPress={() => {
104              // The press reaches this closure last, under `ui.press`.
105              shown = false
106              $.ui.invalidate('ui.render')
107              $.store
108                .set('shown', false)
109                .catch(() => $.ui.log('band-meter: could not store `shown`', { to: 'debug' }))
110            }}
111          />
112        </Box>
113      </Box>
114    )
115  }).catch(($, e, next) => next(e))
116}
117