SLOPSHOPPER

usage-band

Shows context and rate-limit usage in the prompt footer

newspinnernetworktimer
v0.2.3no licenseupdated 2026-10-02WoBok/claude-mods/usage-band
A shopper browsing a rack in a slop shop
README

claude-mods

Claude Code Mod 合集,以插件市场 wobok-mods 的形式发布。

要求:Claude Code v2.1.287 或更高版本。

添加市场

首次使用前执行一次:

claude plugin marketplace add WoBok/claude-mods

Mod 列表

Mod说明安装
usage-band在底栏显示上下文与用量限额claude plugin install usage-band@wobok-mods
reply-timer在每轮回复末尾显示耗时与完成时间claude plugin install reply-timer@wobok-mods

安装后新开会话即可生效。

usage-band

在输入框下方的底栏中显示:

⛁ 16% | ⏱ 15% 1.5h | ▦ 90%

usage-band

字段含义
⛁上下文已用比例
⏱5 小时限额已用比例及距重置的剩余小时数
▦每周限额已用比例

限额数据仅在使用 Pro / Max 订阅登录时可用。

reply-timer

在每轮回复的末尾显示:

1m 23s · 14:05

reply-timer

字段含义
1m 23s本轮耗时
14:05本轮完成时间

更新与卸载

claude plugin marketplace update wobok-mods        # 获取最新版本
claude plugin update <mod>@wobok-mods              # 更新
claude plugin uninstall <mod>@wobok-mods           # 卸载
Source 1 files
hooks/register.ts 157 lines
1import 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