Shows context and rate-limit usage in the prompt footer

Claude Code Mod 合集,以插件市场 wobok-mods 的形式发布。
要求:Claude Code v2.1.287 或更高版本。
首次使用前执行一次:
claude plugin marketplace add WoBok/claude-mods
| Mod | 说明 | 安装 |
|---|---|---|
| usage-band | 在底栏显示上下文与用量限额 | claude plugin install usage-band@wobok-mods |
| reply-timer | 在每轮回复末尾显示耗时与完成时间 | claude plugin install reply-timer@wobok-mods |
安装后新开会话即可生效。
在输入框下方的底栏中显示:
⛁ 16% | ⏱ 15% 1.5h | ▦ 90%

| 字段 | 含义 |
|---|---|
⛁ | 上下文已用比例 |
⏱ | 5 小时限额已用比例及距重置的剩余小时数 |
▦ | 每周限额已用比例 |
限额数据仅在使用 Pro / Max 订阅登录时可用。
在每轮回复的末尾显示:
1m 23s · 14:05

| 字段 | 含义 |
|---|---|
1m 23s | 本轮耗时 |
14:05 | 本轮完成时间 |
claude plugin marketplace update wobok-mods # 获取最新版本
claude plugin update <mod>@wobok-mods # 更新
claude plugin uninstall <mod>@wobok-mods # 卸载hooks/register.ts 157 lines1import type { Register, SessionRateLimit } from 'claude-code'
2
3const findLimit = (limits: SessionRateLimit[], kind: string) =>
4 limits.find(limit => limit.kind === kind)
5
6// Hours until a window resets, to one decimal: 4.9h, 0.3h; nothing once it has passed
7const hoursLeft = (resetsAt: string, now: number) => {
8 const ms = Date.parse(resetsAt) - now
9 return ms > 0 ? `${(ms / 3_600_000).toFixed(1)}h` : undefined
10}
11
12// The last reading any session saw, shown until this session gets its own
13const CACHE_KEY = 'rateLimits'
14let cachedLimits: SessionRateLimit[] = []
15
16// The usage endpoint the app's own meter reads; the host attaches the credential
17const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
18
19// Windows draws the stopwatch large, so it gets a clock face instead; told apart by its drive or share path
20let isWindows = false
21
22// The endpoint answers { five_hour: { utilization, resets_at }, seven_day: { ... } }
23const parseUsage = (body: unknown): SessionRateLimit[] => {
24 const limits: SessionRateLimit[] = []
25 if (!body || typeof body !== 'object') {
26 return limits
27 }
28 for (const kind of ['five_hour', 'seven_day']) {
29 const window = (body as Record<string, unknown>)[kind] as Record<string, unknown> | null | undefined
30 if (window && typeof window.utilization === 'number') {
31 const limit: SessionRateLimit = { kind, percentUsed: window.utilization }
32 if (typeof window.resets_at === 'string') {
33 limit.resetsAt = window.resets_at
34 }
35 limits.push(limit)
36 }
37 }
38 return limits
39}
40
41// A cached window whose reset has passed starts over at zero, its next reset unknown
42const fresh = (limits: SessionRateLimit[], now: number): SessionRateLimit[] =>
43 limits.map(limit =>
44 limit.resetsAt && Date.parse(limit.resetsAt) <= now ? { kind: limit.kind, percentUsed: 0 } : limit,
45 )
46
47export const register: Register = on => {
48 on('session.start', async ($, e, next) => {
49 isWindows = /^([A-Za-z]:|\\\\)/.test(e.cwd)
50
51 try {
52 const stored = await $.store.get(CACHE_KEY)
53 if (Array.isArray(stored)) {
54 cachedLimits = stored as SessionRateLimit[]
55 }
56 } catch {
57 // No cache yet
58 }
59 $.ui.invalidate('ui.render')
60
61 // The hours left count down between replies
62 $.clock.every(60_000, () => $.ui.invalidate('ui.render'))
63
64 // Read the limits before the first reply carries any; the secret stays with the host.
65 // Not awaited, so the session starts without waiting on the network
66 void (async () => {
67 try {
68 const auth = await $.session.authorize()
69 if (auth) {
70 const response = await $.http.fetch(USAGE_URL, {
71 auth: auth.handle,
72 headers: { 'anthropic-beta': 'oauth-2025-04-20' },
73 })
74 const limits = response.ok ? parseUsage(JSON.parse(response.text)) : []
75 if (limits.length > 0) {
76 cachedLimits = limits
77 await $.store.set(CACHE_KEY, limits)
78 $.ui.invalidate('ui.render')
79 }
80 }
81 } catch {
82 // Keep the cached reading
83 }
84 })()
85
86 return next(e)
87 })
88
89 on('session.measure', async ($, e, next) => {
90 if (e.rateLimits.length > 0 && e.changed.includes('rateLimits')) {
91 cachedLimits = e.rateLimits
92 try {
93 await $.store.set(CACHE_KEY, e.rateLimits)
94 } catch {
95 // The live reading still shows
96 }
97 }
98 $.ui.invalidate('ui.render')
99
100 return next(e)
101 })
102
103 // A compaction clears the context reading; redraw so the estimate replaces the old figure
104 on('session.compact', async ($, e, next) => {
105 const result = await next(e)
106 $.ui.invalidate('ui.render')
107 return result
108 })
109
110 // The desktop footer strip draws this site only from a tree of Text, at most 24ch wide
111 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
112 const now = Date.now()
113 const usage = await $.session.usage()
114 const { context } = usage
115 const cached = fresh(cachedLimits, now)
116 // Each window from the live reading, else the cache; once either has a reading, a window
117 // left out has not started since its reset, so nothing of it is used yet
118 const hasReading = usage.rateLimits.length > 0 || cached.length > 0
119 const limitOf = (kind: string): SessionRateLimit | undefined =>
120 findLimit(usage.rateLimits, kind) ?? findLimit(cached, kind) ?? (hasReading ? { kind, percentUsed: 0 } : undefined)
121 const parts: string[] = []
122
123 // No reading until the first reply after a start or a compaction; estimate it locally as /context does
124 const contextPercent = context.percent ?? (await $.session.usage({ breakdown: 'summary' })).context.breakdown?.percentage
125 if (contextPercent !== undefined) {
126 parts.push(`⛁ ${contextPercent}%`)
127 }
128
129 const fiveHour = limitOf('five_hour')
130 if (fiveHour) {
131 // The stopwatch, asked for in its text style (U+FE0E) so it is never the colour emoji
132 const icon = isWindows ? '◷' : '⏱\uFE0E'
133 let text = `${icon} ${Math.round(fiveHour.percentUsed)}%`
134 const left = fiveHour.resetsAt ? hoursLeft(fiveHour.resetsAt, now) : undefined
135 if (left) {
136 text += ` ${left}`
137 }
138 parts.push(text)
139 }
140
141 const weekly = limitOf('seven_day')
142 if (weekly) {
143 parts.push(`▦ ${Math.round(weekly.percentUsed)}%`)
144 }
145
146 if (parts.length === 0) {
147 return next(e)
148 }
149
150 const { Text } = $.ui.resolve(e)
151 const modes = e.props.modes.length > 0 ? ` & ${e.props.modes.join(' & ')}` : ''
152
153 // The theme's primary ink, as the model picker beside it
154 return Text({ color: 'text', children: [parts.join(' | ') + modes] })
155 })
156}
157