SLOPSHOPPER

usage-band

Always-visible usage rings and time until reset above the prompt

newband
v0.1.1no licenseupdated 2026-10-03MaeharaTakumi/claude-usage-band/plugins/usage-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-band
› 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 現在のセッション ◔ 31% リセットまで NaN分 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
現在のセッション ◔ 31% リセットまで NaN分
README

usage-band

Claude Code の mod。入力欄の上に「現在のセッション」「今週」の使用率(円形ゲージ)とリセットまでの残り時間を常時表示します。

インストール

Claude Code 上で:

/plugin marketplace add MaeharaTakumi/claude-usage-band
/plugin install usage-band@usage-band-marketplace

注意

  • mod はサンドボックスなしで動作します。このコードは使用率の取得と表示のみを行い、外部通信やファイル操作はしません。
  • Claude Code 2.1.287 以降が必要です。
Source 2 files
hooks/register.tsx 116 lines
1import type { Register } from 'claude-code'
2
3import type { Limit } from '../types'
4
5const limits = { plugin: 'usage-band', key: 'limits' } as const
6
7const PHASES = ['○', '◔', '◑', '◕', '●']
8const BLUE = '#3b82f6'
9const GREY = '#8a8a8a'
10
11const NAMES = {
12  en: { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' } as Record<string, string>,
13  ja: { five_hour: '現在のセッション', seven_day: '今週', spend_limit: '利用上限' } as Record<string, string>,
14}
15
16const remaining = (resetsAt: string | undefined, t: number, ja: boolean) => {
17  if (!resetsAt) return '?'
18  const mins = Math.max(0, Math.round((Date.parse(resetsAt) - t) / 60000))
19  const d = Math.floor(mins / 1440)
20  const h = Math.floor((mins % 1440) / 60)
21  const m = mins % 60
22  if (ja) return d > 0 ? `${d}日${h}時間` : h > 0 ? `${h}時間${m}分` : `${m}分`
23  return d > 0 ? `${d}d${h}h` : h > 0 ? `${h}h${m}m` : `${m}m`
24}
25
26const DIVIDER =
27  '<svg xmlns="http://www.w3.org/2000/svg" width="8" height="28" viewBox="0 0 8 28">' +
28  '<rect x="3" y="0" width="2" height="28" fill="' + GREY + '"/></svg>'
29
30// A ring gauge: grey track plus a blue arc starting at 12 o'clock.
31const ring = (percent: number) => {
32  const p = Math.min(100, Math.max(0, percent))
33  const r = 14
34  const c = 2 * Math.PI * r
35  return (
36    `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36">` +
37    `<circle cx="18" cy="18" r="${r}" fill="none" stroke="#888" stroke-opacity="0.3" stroke-width="5"/>` +
38    `<circle cx="18" cy="18" r="${r}" fill="none" stroke="${BLUE}" stroke-width="5" stroke-linecap="round" ` +
39    `stroke-dasharray="${((c * p) / 100).toFixed(2)} ${c.toFixed(2)}" transform="rotate(-90 18 18)"/>` +
40    `</svg>`
41  )
42}
43
44export const register: Register = on => {
45  on('session.start', async ($, e, next) => {
46    // Fresh sessions have no rate-limit data until the first response, so fall back to the last saved values.
47    const usage = await $.session.usage()
48    const saved = (await $.store.get('limits')) as Limit[] | undefined
49    const initial = usage.rateLimits.length > 0 ? (usage.rateLimits as Limit[]) : (saved ?? [])
50    await $.state.set(limits, initial)
51    return next(e)
52  })
53
54  on('session.measure', async ($, e, next) => {
55    await $.state.set(limits, e.rateLimits as Limit[])
56    if (e.rateLimits.length > 0) await $.store.set('limits', e.rateLimits as Limit[])
57    return next(e)
58  })
59
60  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
61    const { value: stored = [] } = await $.state.get(limits)
62    if (stored.length === 0) return next(e)
63    const t = await $.clock.now()
64    // A window whose reset time has passed starts over: show 0% and no countdown until fresh data arrives.
65    const value = stored.map(l =>
66      l.resetsAt && Date.parse(l.resetsAt) <= t ? { kind: l.kind, percentUsed: 0 } : l,
67    )
68    const ja = true
69    const names = ja ? NAMES.ja : NAMES.en
70    const label = (l: Limit) => names[l.kind] ?? l.kind
71    const reset = (l: Limit) =>
72      !l.resetsAt
73        ? ''
74        : ja
75          ? `リセットまで ${remaining(l.resetsAt, t, true)}`
76          : `reset in ${remaining(l.resetsAt, t, false)}`
77    const ui = $.ui.resolve(e)
78    const { Box, Text } = ui
79
80    if ('Svg' in ui) {
81      const { Svg } = ui
82      return (
83        <Box flexDirection="row" gap={2} alignItems="center">
84          {value.flatMap((l, i) => [
85            ...(i > 0 ? [<Svg key={`sep-${l.kind}`} source={DIVIDER} alt="区切り" width={8} height={28} />] : []),
86            <Box key={l.kind} flexDirection="row" gap={1} alignItems="center">
87              <Text color={GREY}>{label(l)}</Text>
88              <Svg
89                source={ring(l.percentUsed)}
90                alt={ja ? `${label(l)} ${l.percentUsed}% 使用` : `${label(l)} ${l.percentUsed}% used`}
91                width={28}
92                height={28}
93              />
94              <Text color={GREY}>{l.percentUsed}%</Text>
95              <Text dimColor>{reset(l)}</Text>
96            </Box>,
97          ])}
98        </Box>
99      )
100    }
101
102    // Terminal: no vector element, so a quarter-step circle glyph stands in for the ring.
103    const text = value
104      .map(l => {
105        const glyph = PHASES[Math.min(4, Math.round(l.percentUsed / 25))]
106        return `${label(l)} ${glyph} ${l.percentUsed}% ${reset(l)}`
107      })
108      .join('  |  ')
109    return (
110      <Box>
111        <Text dimColor>{text}</Text>
112      </Box>
113    )
114  })
115}
116
types/index.d.ts 8 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'usage-band': { limits: Limit[] }
6  }
7}
8