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

プロンプトの真上(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
hooks/register.tsx 117 lines1/* @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