SLOPSHOPPER

plan-limits

A weather forecast for your context window and your plan's 5-hour and weekly limits, with reset times and this session's cost, above the prompt

newbandtimer
v0.6.0no licenseupdated 2026-10-09fs666666/plan-limits
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · plan-limits
› 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 ☁ Ctx 🌓 49% used ☁ 5h 🌔 69% left ↻ 09:53 Session $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
☁ Ctx 🌓 49% used ☁ 5h 🌔 69% left ↻ 09:53 Session $0.42
README

plan-limits

一个 Claude Code mod:在输入框上方用天气图标显示上下文窗口占用了多少、订阅的 5 小时和每周额度还剩多少,以及额度的重置时间(本机时间)和本会话按 API 价格算的费用。

☁  Ctx 34% used   ☁  5h 57% left ↻ 22:13   ☁  Week 66% left ↻ Thu 11:00   Session $15.98

每个数字都标明了含义:上下文是容量,显示占用量(used),进度条越满越接近自动压缩;额度是预算,显示剩余量(left),进度条像电池,满格表示还剩很多。上下文越满、额度剩得越少,天气越差:

上下文占用额度剩余显示
25% 以下75% 以上☀ Clear
25%–50%50%–75%☁ Cloudy
50%–75%25%–50%☂ Showers
75%–90%10%–25%☇ Storm
90% 以上10% 以下↯ 红色:上下文显示 Compact soon(快要自动压缩),额度显示 Limit soon
  • 5 小时额度的重置时间在今天时只显示时间(22:13),跨过午夜会带上星期(Thu 01:30);每周额度总是带星期。
  • 和 token-weather 一起装时,两者并排在同一行。终端宽度不够时自动减少细节:先把进度条和天气文字换成一个进度圆环(越满数值越大,样式见下),再去掉重置时间。
  • 终端够宽时,上下文还会显示进度条和 340k/1M 这样的用量。
  • Session $15.98 是本会话按 API 价格折算的费用,和 /cost 显示的一样。订阅用户不会真的被扣这笔钱,只是参考值。
  • 额度数据只在订阅账号登录、且会话收到过第一次回复后才有。

进度圆环样式

窄屏时代替进度条的圆环有三种样式,在 /config 里改 Ring style:

值图标档位跟随天气颜色
moon(默认)🌑 🌒 🌓 🌔 🌕5 档否,emoji 自带颜色
nerdNerd Font 圆形分片9 档是,需要终端字体是 Nerd Font,否则显示成方框
circle○ ◔ ◑ ◕ ●5 档是,但在多数字体里个头偏小

安装

需要 Claude Code 2.1.287 及以上。这个仓库本身就是一个插件市场:

claude plugin marketplace add fs666666/plan-limits
claude plugin install plan-limits@plan-limits

装好后在会话里运行 /reload-plugins,或者重启 Claude Code。

更新

改完代码后,把 .claude-plugin/plugin.json 里的 version 加一,提交并推送。各台设备运行:

claude plugin marketplace update plan-limits      # 拉取市场的最新清单
claude plugin update plan-limits@plan-limits      # 把已安装的插件升级到新版本

只运行第一条不会升级已安装的插件。升级后重启 Claude Code,或者在会话里运行 /reload-plugins。

开发

claude plugin validate .   # 检查清单和 mod 代码
claude plugin test .       # 运行 plan-limits.test.ts

本地调试时可以不安装,直接加载这个目录:claude --plugin-dir ~/plan-limits。

Source 1 files
hooks/register.tsx 258 lines
1// Plan limits band: a weather forecast for the context window and the plan's 5-hour and weekly
2// rate-limit windows, with each window's reset time and this session's API-equivalent cost,
3// above the prompt.
4//
5// The figures are $.session.usage()'s, the same the status line has: context once a response
6// reported its fill; rateLimits only on a subscription and after the first response; cost is
7// Claude Code's own estimate at API prices (what /cost shows). session.measure pushes a redraw
8// when any of them moves; a one-minute timer redraws so a 5-hour reset gains its weekday after
9// midnight.
10import type { RenderElement, Register, SessionRateLimit } from 'claude-code'
11
12const NAMES: Record<string, string> = {
13  five_hour: '5h',
14  seven_day: 'Week',
15  spend_limit: 'Spend',
16}
17
18// Forecast bands by percent of the window used, in token-weather's style.
19// Single-width text symbols, not emoji: they line up in every terminal font.
20const FORECAST = [
21  { upTo: 25, icon: '☀', word: 'Clear', color: 'yellow' },
22  { upTo: 50, icon: '☁', word: 'Cloudy', color: 'cyan' },
23  { upTo: 75, icon: '☂', word: 'Showers', color: 'blue' },
24  { upTo: 90, icon: '☇', word: 'Storm', color: 'magenta' },
25  { upTo: Infinity, icon: '↯', word: 'Limit soon', color: 'red' },
26] as const
27
28const BAR_CELLS = 10
29
30// The last band's word, per gauge: a full context window gets compacted, a full limit stops you
31function forecastOf(percent: number, fullWord = 'Limit soon') {
32  const f = FORECAST.find(f => percent <= f.upTo) ?? FORECAST[FORECAST.length - 1]!
33  return { ...f, word: f.upTo === Infinity ? fullWord : f.word, isAlarm: f.upTo === Infinity }
34}
35
36function short(n: number): string {
37  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`
38  if (n >= 1_000) return `${Math.round(n / 1_000)}k`
39  return String(n)
40}
41
42// A rate limit is a budget, so it shows what is left, like a battery; the context window is a
43// fill, so it shows what is used. Each number says which it is. The weather always follows what
44// is used: a fuller window or less budget left reads as worse weather.
45function shownOf(used: number, shows: 'used' | 'left'): number {
46  const value = shows === 'used' ? used : 100 - used
47  return Math.max(0, Math.round(value * 10) / 10)
48}
49
50function bar(percent: number): string {
51  const filled = Math.max(0, Math.min(BAR_CELLS, Math.round((percent / 100) * BAR_CELLS)))
52  return '█'.repeat(filled) + '░'.repeat(BAR_CELLS - filled)
53}
54
55// The bar's stand-in where the line has no room for it, following the shown value as the bar
56// does, in the style the ringStyle option picks:
57// - nerd: Nerd Font circle slices, nine steps, take the forecast's colour (needs a Nerd Font)
58// - moon: moon-phase emoji, five steps, at the weather icon's size but in their own colours
59// - circle: plain Unicode quarter circles, five steps, take the colour, but small in most fonts
60type RingStyle = 'nerd' | 'moon' | 'circle'
61const RINGS: Record<RingStyle, readonly string[]> = {
62  nerd: ['\u{F0766}', '\u{F0A9E}', '\u{F0A9F}', '\u{F0AA0}', '\u{F0AA1}', '\u{F0AA2}', '\u{F0AA3}', '\u{F0AA4}', '\u{F0AA5}'],
63  moon: ['🌑', '🌒', '🌓', '🌔', '🌕'],
64  circle: ['○', '◔', '◑', '◕', '●'],
65}
66// Emoji take their own colours and every terminal draws them two columns wide
67const COLOURED: Record<RingStyle, boolean> = { nerd: true, moon: false, circle: true }
68
69function ringStyleOf(value: unknown): RingStyle {
70  // Moon unless asked otherwise: emoji draw in every terminal, Nerd Font glyphs only with that font
71  return value === 'nerd' || value === 'circle' ? value : 'moon'
72}
73
74function ring(style: RingStyle, percent: number): string {
75  const steps = RINGS[style]
76  const step = Math.round((percent / 100) * (steps.length - 1))
77  return steps[Math.max(0, Math.min(steps.length - 1, step))]!
78}
79
80const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
81const pad = (n: number) => String(n).padStart(2, '0')
82
83// The reset as a local time (the environment keeps the machine's time zone): 22:13 when it
84// falls today, Thu 01:30 on another day, and always with the weekday for the weekly window
85function resetText(limit: SessionRateLimit, now: number): string {
86  if (!limit.resetsAt) return ''
87  const d = new Date(limit.resetsAt)
88  if (Number.isNaN(d.getTime())) return ''
89  const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`
90  const isToday = d.toDateString() === new Date(now).toDateString()
91  return limit.kind === 'seven_day' || !isToday ? `${WEEKDAYS[d.getDay()]} ${time}` : time
92}
93
94function sortLimits(limits: SessionRateLimit[]): SessionRateLimit[] {
95  const order = ['five_hour', 'seven_day']
96  const rank = (kind: string) => (order.includes(kind) ? order.indexOf(kind) : order.length)
97  return [...limits].sort((a, b) => rank(a.kind) - rank(b.kind))
98}
99
100// Whether the mods after this one left the band empty. With none drawing there, next(e)
101// resolves to the engine's own drawing, { type: 'engine' }, and in the band the engine
102// draws nothing; an empty Box is nothing too.
103function isEmpty(el: RenderElement | null | undefined): boolean {
104  if (!el || el.type === 'engine') return true
105  const children = (el as { children?: unknown[] }).children
106  return el.type === 'Box' && (!children || children.length === 0)
107}
108
109// Roughly how many columns a tree takes on one line: its texts, plus a row's gaps and padding.
110// The weather and reset symbols count two columns: many terminals draw them as wide emoji, and
111// overestimating only costs a little detail, while underestimating wraps the line.
112const WIDE = new Set(['☀', '☁', '☂', '☇', '↯', '↻', ...RINGS.moon])
113
114function textWidth(s: string): number {
115  let width = 0
116  for (const ch of s) width += WIDE.has(ch) ? 2 : 1
117  return width
118}
119
120function widthOf(node: unknown): number {
121  if (typeof node === 'string' || typeof node === 'number') return textWidth(String(node))
122  if (!node || typeof node !== 'object') return 0
123  const el = node as { type?: string; props?: Record<string, unknown>; children?: unknown[] }
124  const children = (el.children ?? []).filter(c => c !== null && c !== undefined && c !== false)
125  let width = children.reduce<number>((sum, c) => sum + widthOf(c), 0)
126  if (el.type === 'Box') {
127    const props = el.props ?? {}
128    const gap = Number(props.columnGap ?? props.gap ?? 0)
129    if (props.flexDirection !== 'column') width += gap * Math.max(0, children.length - 1)
130    width += 2 * Number(props.paddingX ?? props.padding ?? 0)
131  }
132  return width
133}
134
135// One gauge on the line: the context window or a rate-limit window; percent is how much is used
136type Gauge = {
137  key: string
138  label: string
139  percent: number
140  shows: 'used' | 'left'
141  fullWord?: string
142  reset?: string
143  size?: string
144}
145
146// How much each window shows, from most to least, picked by what fits on the line
147type Detail = 'full' | 'compact' | 'minimal'
148const DETAILS: Detail[] = ['full', 'compact', 'minimal']
149
150export const register: Register = (on, options) => {
151  const ringStyle = ringStyleOf(options.ringStyle)
152
153  on('session.start', async ($, e, next) => {
154    const result = await next(e)
155    // Redraw once a minute; a reload stops the old timer by itself
156    $.clock.every(60_000, () => $.ui.invalidate('ui.render'))
157    return result
158  })
159
160  on('session.measure', async ($, e, next) => {
161    $.ui.invalidate('ui.render')
162    return next(e)
163  })
164
165  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
166    if (e.props.hasSurvey) return next(e)
167
168    const { context, rateLimits, cost } = await $.session.usage()
169    const now = await $.clock.now()
170
171    const gauges: Gauge[] = []
172    // The context window has a fill once a response reported one
173    if (context.window > 0 && (context.percent !== undefined || (context.tokens ?? 0) > 0)) {
174      const tokens = context.tokens ?? 0
175      gauges.push({
176        key: 'context',
177        label: 'Ctx',
178        percent: Math.round(context.percent ?? (tokens / context.window) * 100),
179        shows: 'used',
180        fullWord: 'Compact soon',
181        size: `${short(tokens)}/${short(context.window)}`,
182      })
183    }
184    for (const limit of sortLimits(rateLimits)) {
185      gauges.push({
186        key: limit.kind,
187        label: NAMES[limit.kind] ?? limit.kind,
188        percent: limit.percentUsed,
189        shows: 'left',
190        reset: resetText(limit, now),
191      })
192    }
193    if (gauges.length === 0 && cost === undefined) return next(e)
194
195    const { Box, Text } = $.ui.resolve(e)
196
197    const ours = (detail: Detail) => {
198      const items = gauges.map(g => {
199        const f = forecastOf(g.percent, g.fullWord)
200        const shown = shownOf(g.percent, g.shows)
201        const reset = detail === 'minimal' ? '' : g.reset
202        return (
203          <Box key={g.key} flexDirection="row" columnGap={1}>
204            {/* A trailing space here and after ↻: many terminals draw these symbols two columns wide */}
205            <Text color={f.color}>{`${f.icon} `}</Text>
206            <Text bold>{g.label}</Text>
207            {detail === 'full' ? null : COLOURED[ringStyle] ? (
208              // Trailing space as for the icon: a glyph may draw wider than its one column
209              <Text color={f.color}>{`${ring(ringStyle, shown)} `}</Text>
210            ) : (
211              <Text>{ring(ringStyle, shown)}</Text>
212            )}
213            <Text color={f.color} bold={f.isAlarm}>{`${shown}% ${g.shows}`}</Text>
214            {detail === 'full' ? <Text color={f.color}>{bar(shown)}</Text> : null}
215            {detail === 'full' ? <Text color={f.color} bold={f.isAlarm}>{f.word}</Text> : null}
216            {detail === 'full' && g.size ? <Text dimColor>{g.size}</Text> : null}
217            {reset ? (
218              <Text dimColor>
219                {detail === 'full' ? `· resets ${reset}` : `↻ ${reset}`}
220              </Text>
221            ) : null}
222          </Box>
223        )
224      })
225      if (cost !== undefined) {
226        items.push(
227          <Box key="cost" flexDirection="row" columnGap={1}>
228            {/* Always labelled: a bare dollar figure reads as nothing in particular */}
229            <Text dimColor>Session</Text>
230            <Text>{`$${cost.usd.toFixed(2)}`}</Text>
231          </Box>,
232        )
233      }
234      return (
235        <Box key="plan-limits" flexDirection="row" columnGap={3}>
236          {items}
237        </Box>
238      )
239    }
240
241    // Every mod shares the band: put what the mods after this one drew (token-weather)
242    // first on the same line, a divider, then ours in as much detail as still fits
243    const theirs = await next(e)
244    const hasTheirs = !isEmpty(theirs)
245    const room = e.props.bodyColumns - (hasTheirs ? widthOf(theirs) + 5 : 0)
246    const detail = DETAILS.find(d => widthOf(ours(d)) <= room) ?? 'minimal'
247    const line = ours(detail)
248    if (!hasTheirs) return line
249    return (
250      <Box flexDirection="row" columnGap={2}>
251        {theirs}
252        <Text dimColor>│</Text>
253        {line}
254      </Box>
255    )
256  })
257}
258