SLOPSHOPPER

usage-band

입력창 위에 5시간·주간·Fable 사용량 게이지를 표시

newbandnetworktimer
v0.1.0no licenseupdated 2026-10-03nemokoala/claude-mods/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 5h ██▌░░░░░ 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5h ██▌░░░░░ 31%
README

nemokoala-mods

Claude Code 모드 모음 (CLI · 데스크톱 앱 Code 탭 공용)

플러그인설명
usage-band입력창 위에 5시간 / 주간 / Fable 주간 사용량 게이지 표시 (claude.ai Pro·Max 로그인 필요)
git-graph접었다 펼 수 있는 깃 커밋 그래프 패널. /git-graph [저장소 경로]

설치

Claude Code에서:

/plugin marketplace add nemokoala/claude-mods
/plugin install usage-band@nemokoala-mods
/plugin install git-graph@nemokoala-mods

또는 터미널에서:

claude plugin marketplace add nemokoala/claude-mods
claude plugin install usage-band@nemokoala-mods
claude plugin install git-graph@nemokoala-mods

설치 후 새 세션부터 적용됩니다.

Source 2 files
hooks/register.tsx 173 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { Gauge, Snapshot } from '../types'
5
6const usage = atom({ plugin: 'usage-band', key: 'usage' } as const, null)
7const now = atom({ plugin: 'usage-band', key: 'now' } as const, 0)
8
9const CELLS = 8
10const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
11
12function toSnapshot(limits: SessionRateLimit[]): Snapshot {
13  const pick = (match: (kind: string) => boolean): Gauge | undefined => {
14    const l = limits.find(x => match(x.kind))
15    return l ? { percent: l.percentUsed, resetsAt: l.resetsAt } : undefined
16  }
17  return {
18    fiveHour: pick(k => k === 'five_hour'),
19    sevenDay: pick(k => k === 'seven_day'),
20  }
21}
22
23let lastAccountFetch = 0
24
25type AccountLimit = {
26  kind?: string
27  percent?: number
28  resets_at?: string | null
29  scope?: { model?: { display_name?: string | null } | null } | null
30}
31
32// The per-model weekly windows (Fable) are not among the session's rate
33// limits, so read them from the account usage endpoint the app's usage card
34// uses. The host attaches the credential; the module never sees it.
35async function fetchFable($: EngineInterface, force = false): Promise<void> {
36  const t = await $.clock.now()
37  if (!force && t - lastAccountFetch < 120_000) return
38  lastAccountFetch = t
39  try {
40    const auth = await $.session.authorize()
41    if (!auth || auth.kind !== 'bearer') return
42    const res = await $.http.fetch('https://api.anthropic.com/api/oauth/usage', {
43      auth: auth.handle,
44      headers: { 'anthropic-beta': 'oauth-2025-04-20' },
45    })
46    if (!res.ok) return
47    const body = JSON.parse(res.text) as { limits?: AccountLimit[] }
48    // The per-model weekly window is a `weekly_scoped` limit naming its model.
49    const w = (body.limits ?? []).find(
50      l => l.kind === 'weekly_scoped' && /fable/i.test(l.scope?.model?.display_name ?? ''),
51    )
52    if (!w || typeof w.percent !== 'number') return
53    const fable: Gauge = { percent: w.percent, resetsAt: w.resets_at ?? undefined }
54    await update($, usage, prev => ({ ...(prev ?? {}), fable }))
55  } catch {
56    // Off a claude.ai login or offline: the gauge just stays absent.
57  }
58}
59
60function bar(percent: number): { filled: string; track: string } {
61  const eighths = Math.round((percent / 100) * CELLS * 8)
62  const full = Math.min(Math.floor(eighths / 8), CELLS)
63  const part = full < CELLS ? EIGHTHS[eighths % 8] : ''
64  const used = full + (part ? 1 : 0)
65  return { filled: '█'.repeat(full) + part, track: '░'.repeat(CELLS - used) }
66}
67
68function remaining(resetsAt: string | undefined, nowMs: number): string {
69  if (!resetsAt) return ''
70  let s = Math.floor((Date.parse(resetsAt) - nowMs) / 1000)
71  if (isNaN(s)) return ''
72  if (s <= 0) return 'now'
73  const d = Math.floor(s / 86400); s -= d * 86400
74  const h = Math.floor(s / 3600); s -= h * 3600
75  const m = Math.floor(s / 60)
76  if (d > 0) return `${d}d ${h}h`
77  if (h > 0) return `${h}h ${m}m`
78  return `${m}m`
79}
80
81type Tone = { main: string; light: string }
82const toneFor = (p: number): Tone =>
83  p >= 80 ? { main: '#ef4444', light: '#fb7185' }
84  : p >= 50 ? { main: '#f59e0b', light: '#fcd34d' }
85  : { main: '#10b981', light: '#5eead4' }
86
87// A rounded pill gauge with a soft gradient fill, for surfaces that draw SVG.
88function pill(percent: number, tone: Tone, w = 88, h = 8): string {
89  const r = h / 2
90  const fw = percent <= 0 ? 0 : Math.max(h, (percent / 100) * w)
91  const id = `g${Math.round(percent)}${tone.main.slice(1)}`
92  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">`
93    + `<defs><linearGradient id="${id}" x1="0" x2="1" y1="0" y2="0">`
94    + `<stop offset="0" stop-color="${tone.light}"/><stop offset="1" stop-color="${tone.main}"/>`
95    + `</linearGradient></defs>`
96    + `<rect width="${w}" height="${h}" rx="${r}" fill="#8b8b8b" fill-opacity="0.22"/>`
97    + (fw > 0 ? `<rect width="${fw.toFixed(1)}" height="${h}" rx="${r}" fill="url(#${id})"/>` : '')
98    + `</svg>`
99}
100
101export const register: Register = on => {
102  on('session.start', async ($, e, next) => {
103    const result = await next(e)
104    const u = await $.session.usage()
105    await update($, usage, () => toSnapshot(u.rateLimits))
106    await fetchFable($, true)
107    const t = await $.clock.now()
108    await update($, now, () => t)
109    // Keep the reset countdowns moving while the session sits idle.
110    $.clock.every(30_000, () => {
111      void $.clock.now().then(t => update($, now, () => t))
112      void fetchFable($)
113    })
114    return result
115  })
116
117  on('session.measure', async ($, e, next) => {
118    await update($, usage, prev => {
119      const s = toSnapshot(e.rateLimits)
120      // Keep the last known rate limits when a measurement carries none.
121      return {
122        fiveHour: s.fiveHour ?? prev?.fiveHour,
123        sevenDay: s.sevenDay ?? prev?.sevenDay,
124        fable: prev?.fable,
125      }
126    })
127    void fetchFable($)
128    const t = await $.clock.now()
129    await update($, now, () => t)
130    return next(e)
131  })
132
133  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
134    const snap = await read($, usage)
135    if (e.props.hasSurvey || !snap) return next(e)
136
137    const nowMs = (await read($, now)) || (await $.clock.now())
138    const ui = $.ui.resolve(e)
139    const { Box, Text } = ui
140    const Svg = 'Svg' in ui ? ui.Svg : undefined
141
142    const gauge = (label: string, percent: number | undefined, resetsAt?: string) => {
143      if (percent === undefined) return null
144      const p = Math.max(0, Math.min(100, Math.round(percent)))
145      const tone = toneFor(p)
146      const left = remaining(resetsAt, nowMs)
147      const meter = Svg
148        ? <Svg source={pill(p, tone)} alt={`${label} ${p}%`} width={88} height={8} />
149        : (() => {
150            const { filled, track } = bar(p)
151            return <Text><Text color={tone.main}>{filled}</Text><Text dimColor>{track}</Text></Text>
152          })()
153      return (
154        <Box key={label} flexDirection="row" alignItems="center" gap={1} marginRight={3}>
155          <Text dimColor bold>{label}</Text>
156          {meter}
157          <Text color={tone.main} bold>{p}%</Text>
158          {left ? <Text dimColor>· {left}</Text> : null}
159        </Box>
160      )
161    }
162
163    const items = [
164      gauge('5h', snap.fiveHour?.percent, snap.fiveHour?.resetsAt),
165      gauge('7d', snap.sevenDay?.percent, snap.sevenDay?.resetsAt),
166      gauge('Fable', snap.fable?.percent, snap.fable?.resetsAt),
167    ].filter(Boolean)
168    if (items.length === 0) return next(e)
169
170    return <Box flexDirection="row" alignItems="center" flexWrap="wrap">{items}</Box>
171  })
172}
173
types/index.d.ts 9 lines
1export type Gauge = { percent: number; resetsAt?: string }
2export type Snapshot = { fiveHour?: Gauge; sevenDay?: Gauge; fable?: Gauge }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'usage-band': { usage: Snapshot | null; now: number }
7  }
8}
9