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

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
설치 후 새 세션부터 적용됩니다.
hooks/register.tsx 173 lines1import { 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}
173types/index.d.ts 9 lines1export 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