SLOPSHOPPER

usage-panel

Shows context window, plan rate limits, and session cost in a band above the prompt

bandcommandtimer
v0.4.0no licenseupdated 2026-10-18bbabbico/usage-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-panel
› 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 › /usage-panel ⎿ usage-panel: 사용량 줄을 껐어요. /usage-panel 로 다시 켤 수 있어요. 사용량 보기 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
사용량 보기
README

usage-panel

Claude Code 모드: 현재 모델과 effort, 컨텍스트 사용량, 요금제 한도(5시간/주간), 세션 비용을 프롬프트 위 한 줄로 보여줍니다.

사용량 줄 예시: Opus 5.5 · xhigh, 컨텍스트 19%, 5시간 17%, 주간 33%, $6.53

Claude Code v2.1.302에서 테스트했습니다.

줄 끝의 끄기 버튼으로 접고, 남는 사용량 보기 버튼으로 다시 켭니다. /usage-panel 로도 전환됩니다 (상태는 다음 세션에도 유지). effort는 시작할 때 저장된 기본값을, /effort·/model 직후엔 바뀐 값을, 응답이 오면 실제 적용된 값을 보여줍니다.

설치

claude plugin marketplace add bbabbico/usage-panel
claude plugin install usage-panel@usage-panel

업데이트

claude plugin update usage-panel@usage-panel
Source 1 files
hooks/register.js 148 lines
1const LABELS = { five_hour: '5시간', seven_day: '주간', spend_limit: '지출' }
2const BAR = 8
3
4// 0-100 as a bar of BAR cells
5function bar(percent) {
6  const filled = Math.max(0, Math.min(BAR, Math.round((percent / 100) * BAR)))
7  return '█'.repeat(filled) + '░'.repeat(BAR - filled)
8}
9
10function colorFor(percent) {
11  return percent >= 90 ? 'red' : percent >= 70 ? 'yellow' : 'green'
12}
13
14// "2h13m" from an ISO timestamp
15function untilReset(iso, now) {
16  if (!iso) return ''
17  const mins = Math.max(0, Math.round((Date.parse(iso) - now) / 60000))
18  const h = Math.floor(mins / 60)
19  const d = Math.floor(h / 24)
20  if (d > 0) return d + 'd' + (h % 24) + 'h'
21  if (h > 0) return h + 'h' + (mins % 60) + 'm'
22  return mins + 'm'
23}
24
25// Whether the person turned the band off; kept in $.store across sessions
26let isHidden = false
27// The session's effort: from settings on the first draw, from /effort and /model,
28// and from each main-loop request, which is what the model actually got
29let effort = null
30let isEffortRead = false
31
32// "claude-opus-5-5" -> "Opus 5.5", anything else as given
33function modelName(id) {
34  const m = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?!\d)/.exec(id)
35  if (!m) return id
36  return m[1][0].toUpperCase() + m[1].slice(1) + ' ' + m[2] + (m[3] ? '.' + m[3] : '')
37}
38
39// The saved default for the session's model, as /effort and /model leave it
40async function effortFromSettings($) {
41  try {
42    const model = await $.session.model()
43    const s = await $.settings.read()
44    return s.modelSettings?.[model]?.effortLevel ?? s.effortLevel ?? null
45  } catch {
46    return null
47  }
48}
49
50async function setHidden($, value) {
51  isHidden = value
52  await $.store.set('hidden', value)
53  $.ui.invalidate('ui.render')
54}
55
56export function register(on) {
57  on('session.start', async ($, e, next) => {
58    await $.command.register({ name: 'usage-panel', description: '사용량 줄 켜기/끄기' })
59    isHidden = (await $.store.get('hidden')) === true
60    // Keep the reset countdowns current between measurements
61    $.clock.every(60000, () => $.ui.invalidate('ui.render'))
62    return next(e)
63  })
64
65  // Pushed after each turn and when a rate-limit window moves a point
66  on('session.measure', async ($, e, next) => {
67    $.ui.invalidate('ui.render')
68    return next(e)
69  })
70
71  // Each request the main loop sends carries the effort it asks for
72  on('turn.step', async function* ($, e, next) {
73    if (!e.agentId && e.effort !== undefined && e.effort !== effort) {
74      effort = e.effort
75      $.ui.invalidate('ui.render')
76    }
77    return yield* next(e)
78  })
79
80  // A session-only /effort is never saved, so read the level off its answer
81  on('command.run', { command: 'effort' }, async ($, e, next) => {
82    const r = await next(e)
83    const m = /effort level to (low|medium|high|xhigh|max)\b/i.exec(r?.text ?? '')
84    effort = m ? m[1].toLowerCase() : await effortFromSettings($)
85    $.ui.invalidate('ui.render')
86    return r
87  })
88
89  // Each model keeps its own saved effort
90  on('command.run', { command: 'model' }, async ($, e, next) => {
91    const r = await next(e)
92    effort = await effortFromSettings($)
93    $.ui.invalidate('ui.render')
94    return r
95  })
96
97  on('command.run', { command: 'usage-panel' }, async ($) => {
98    await setHidden($, !isHidden)
99    return { text: isHidden ? '사용량 줄을 껐어요. /usage-panel 로 다시 켤 수 있어요.' : '사용량 줄을 켰어요.' }
100  })
101
102  // One row in the band above the prompt
103  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
104    if (e.props.hasSurvey) return next(e)
105    const { Box, Text, Button } = $.ui.resolve(e)
106    if (isHidden) {
107      return Box({
108        marginTop: 1,
109        children: [Button({ key: 'show', label: '사용량 보기', plain: true, dimColor: true, onPress: () => setHidden($, false) })],
110      })
111    }
112    // The plain call costs nothing, so read it fresh on each draw
113    const usage = await $.session.usage()
114    const now = await $.clock.now()
115
116    const meter = (key, label, percent, extra) =>
117      Box({
118        key,
119        flexDirection: 'row',
120        columnGap: 1,
121        children: [
122          Text({ dimColor: true, children: [label] }),
123          Text({ color: colorFor(percent), children: [bar(percent)] }),
124          Text({ children: [Math.round(percent) + '%' + (extra ? ' (' + extra + ')' : '')] }),
125        ],
126      })
127
128    const { context, rateLimits = [], cost } = usage
129    if (!isEffortRead) {
130      isEffortRead = true
131      effort = effort ?? (await effortFromSettings($))
132    }
133    const model = modelName(await $.session.model())
134    const items = [
135      Text({ children: [model + (effort !== null ? ' · ' + effort : '')] }),
136      meter('context', '컨텍스트', context.percent ?? 0),
137    ]
138    for (const r of rateLimits) {
139      items.push(meter('rl-' + r.kind, LABELS[r.kind] || r.kind, r.percentUsed, untilReset(r.resetsAt, now)))
140    }
141    if (cost) items.push(Text({ children: ['$' + cost.usd.toFixed(2)] }))
142    items.push(Button({ key: 'hide', label: '끄기', plain: true, dimColor: true, onPress: () => setHidden($, true) }))
143
144    // One blank row above, matching the gap Claude Code leaves below the band
145    return Box({ flexDirection: 'row', columnGap: 3, marginTop: 1, children: items })
146  })
147}
148