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

一个 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 |
22:13),跨过午夜会带上星期(Thu 01:30);每周额度总是带星期。340k/1M 这样的用量。Session $15.98 是本会话按 API 价格折算的费用,和 /cost 显示的一样。订阅用户不会真的被扣这笔钱,只是参考值。窄屏时代替进度条的圆环有三种样式,在 /config 里改 Ring style:
| 值 | 图标 | 档位 | 跟随天气颜色 |
|---|---|---|---|
moon(默认) | 🌑 🌒 🌓 🌔 🌕 | 5 档 | 否,emoji 自带颜色 |
nerd | Nerd 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。
hooks/register.tsx 258 lines1// 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