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

Claude Code の mod。入力欄の上に「現在のセッション」「今週」の使用率(円形ゲージ)とリセットまでの残り時間を常時表示します。
Claude Code 上で:
/plugin marketplace add MaeharaTakumi/claude-usage-band
/plugin install usage-band@usage-band-marketplace
hooks/register.tsx 116 lines1import 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}
116types/index.d.ts 8 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'usage-band': { limits: Limit[] }
6 }
7}
8