Prompt-cache countdown, 5h / 7d usage and context fill as rings in one slim row by the prompt

<img src="assets/banner.png" alt="usage-line:一行看清缓存倒计时、5 小时 / 7 天额度与上下文占用" width="100%">
<b>一行,全看清。</b> Claude Code 插件:提示词缓存倒计时、5 小时 / 7 天额度与重置时间、上下文占用,<br> 以原生风格的进度圆环,排成输入框上方细细的一行。
<a href="https://github.com/sundyme/usage-line/actions/workflows/ci.yml"><img src="https://github.com/sundyme/usage-line/actions/workflows/ci.yml/badge.svg" alt="ci"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-4e8ff7" alt="MIT"></a> <a href="README.en.md"><img src="https://img.shields.io/badge/docs-English-8a8a8a" alt="English"></a>
<a href="assets/usage-line-trailer.mp4"><img src="assets/trailer-poster.png" alt="▶ 观看发布预告片(39 秒)" width="88%"></a><br> <sub>▶ 点击观看 39 秒 3D 发布预告片(<a href="trailer/">trailer/</a>) · 另有 44 秒的 <a href="assets/usage-line.mp4">透视版影片</a>(<a href="film/BRIEF.md">简报</a>) · 每一帧与每一个音符都由代码生成</sub>
◕ 42:17 缓存 ◑ 47% 5h ↻2h31m ◕ 74% 7d ↻4d5h ◔ 25% 上下文
| 圆环 | 含义 | 细节 |
|---|---|---|
| 缓存 | 提示词缓存还剩多久过期 | mm:ss 逐秒倒计时;最后一分钟变琥珀色,过期显示红色「过期」 |
| 5h | 5 小时额度已用百分比 | ↻2h54m 是距离重置的时间 |
| 7d | 7 天额度已用百分比 | ↻4d5h 是距离重置的时间 |
| 上下文 | 上下文窗口占用 | /clear、压缩之后自动重读 |
圆环颜色:低于 70% 为蓝色,≥ 70% 琥珀色,≥ 90% 红色。
在 Claude Code 里:
/plugin marketplace add sundyme/usage-line
/plugin install usage-line@usage-line
或者在终端:
claude plugin marketplace add sundyme/usage-line
claude plugin install usage-line@usage-line
更新:claude plugin marketplace update usage-line,然后 claude plugin update usage-line@usage-line。
○ ◔ ◑ ◕ ● 表示进度,颜色与圆环一致。窗口变窄时先收起重置倒计时,再收起标签,再窄就换行,不会被隐藏。Claude Code 不直接告诉插件缓存何时过期,usage-line 根据请求本身推算:
cache_read / cache_creation 大于 0)才算数;请求失败或没有缓存时,倒计时回到上一次。| 选项 | 默认 | 说明 |
|---|---|---|
cacheTtl | 1h | 缓存时长基准,1h 或 5m。订阅保持 1h;超额时插件会自动按 5 分钟算,无需改动。 |
默认即可;修改用 /plugin configure usage-line@usage-line。
claude -p 等非交互运行需要设置 CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1。–。.claude-plugin/marketplace.json 本仓库即插件市场
plugins/usage-line/ 插件本体
hooks/register.tsx 全部逻辑(约 300 行)
types/index.d.ts $.state 的类型契约
tests/usage-line.test.ts 18 个测试
trailer/ 3D 发布预告片的源码(three.js)
film/ 影片的简报、配乐与 banner 源码(v1 的 2D 渲染器也在这里)
assets/ banner、海报、影片
claude plugin validate plugins/usage-line
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test plugins/usage-line
本地试用改动:claude --plugin-dir plugins/usage-line。Claude Code 加载插件时会在 plugins/usage-line/.claude-plugin/types/ 写出接口类型,之后可以用 tsc -p plugins/usage-line --noEmit 做严格类型检查。
没有剪辑软件,没有模板和素材。44 秒的影片按 film/BRIEF.md 的分镜,在预告片同一套 three.js 引擎里渲染(trailer/src/film2.js 及 f2*.js)。每块界面都是透视相机拍摄的 3D 平面,因此有真实的运镜与视差;转场都在镜头内完成:圆环接圆环、窗口翻面成终端、从对勾处展开光圈。v1 的 2D 版本仍在 film/render.mjs(Skia 逐帧绘制)。音乐是一段 ElevenLabs 生成的 120 BPM 纯器乐,film/fit_music.py 在它的 drop 上找到小节线,只在整乐句处剪辑,让 drop 落在镜头冲进缓存圆环的第 16 秒、收尾和弦落在片尾。film/score.py 只用 numpy 合成全部音效(噪声扫频、正弦重击、读秒滴答、打字声和卷积混响),每一个 UI 音效都对齐画面里的那一帧,音乐在音效下自动让位;不给 MUSIC 时它也能合成整段配乐。
cd film && npm install && python3 score.py && node render.mjs
39 秒的 3D 预告片在 trailer/:每个镜头是一个 three.js 场景,在无头 Chrome 里用 GPU 渲染。片中 Claude Code 自己的界面按 App 原样画成平面。影片的动态元素用的是自写的液态玻璃着色器:先把背后的画面多级模糊,只在边缘一圈很窄的弧面上折射并分出色散虹彩,再加一条发丝高光。几块玻璃之间可以平滑融合,所以一条消息气泡能流进缓存镜片。大字是逐帧重绘的屏幕层,与 3D 画面一起获得真实的运动模糊;转场(光圈、穿越变焦、推移、马赛克、故障、模糊缩放、闪白)是一个合成着色器,之后依次经过 bloom、8 个子帧的运动模糊累积和调色;原始像素经 WebSocket 送进 ffmpeg,4 个页面并行渲染。trailer/score.py 同样只用 numpy,合成 120 BPM 的电子配乐(抗锯齿超级锯齿波和弦、侧链、贝斯、琶音、两次 drop,缓存过期时音乐会停下),每一次切换和界面事件都有落在同一帧上的音效。分镜与文案见 trailer/STORYBOARD.md。
cd trailer && npm install && python3 score.py && node render.mjs
MIT。usage-line 是社区插件,与 Anthropic 无关联。
hooks/register.tsx 328 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Ctx, Limit } from '../types'
5
6const cacheAt = atom({ plugin: 'usage-line', key: 'cacheAt' } as const, null as number | null)
7const cacheTtl = atom({ plugin: 'usage-line', key: 'cacheTtl' } as const, 0)
8// The TTL Claude Code itself reports (on a model switch); 0 until it has said.
9const knownTtl = atom({ plugin: 'usage-line', key: 'knownTtl' } as const, 0)
10const now = atom({ plugin: 'usage-line', key: 'now' } as const, 0)
11const ctx = atom({ plugin: 'usage-line', key: 'ctx' } as const, null as Ctx | null)
12const limits = atom({ plugin: 'usage-line', key: 'limits' } as const, [] as Limit[])
13
14const MINUTE = 60_000
15const HOUR = 60 * MINUTE
16
17// The footer's own progress ring: a thin grey track with a blue arc from 12 o'clock.
18const BLUE = '#4e8ff7'
19const AMBER = '#e5a33a'
20const RED = '#e5534b'
21const GREY = '#8a8a8a'
22const ringColor = (p: number) => (p >= 90 ? RED : p >= 70 ? AMBER : BLUE)
23
24const WINDOWS = [
25 { kind: 'five_hour', label: '5h', name: '5 小时额度' },
26 { kind: 'seven_day', label: '7d', name: '7 天额度' },
27]
28
29function span(ms: number): string {
30 const m = Math.max(0, Math.round(ms / MINUTE))
31 const d = Math.floor(m / 1440)
32 const h = Math.floor((m % 1440) / 60)
33 if (d) return `${d}d${h}h`
34 if (h) return `${h}h${m % 60}m`
35 return `${m}m`
36}
37
38// Always mm:ss, so the row does not shift when the minutes drop below ten.
39function clockText(ms: number): string {
40 const s = Math.max(0, Math.ceil(ms / 1000))
41 return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
42}
43
44const resetTime = (l: Limit) => (l.resetsAt ? Date.parse(l.resetsAt) : NaN)
45const isReset = (l: Limit, t: number) => resetTime(l) <= t
46
47// Text pies for the terminal, which draws no Svg. Any non-zero amount shows at least a quarter.
48const pie = (frac: number) => {
49 const f = Math.min(1, Math.max(0, frac))
50 return ['○', '◔', '◑', '◕', '●'][f === 0 ? 0 : Math.max(1, Math.round(f * 4))]
51}
52
53// An empty ring takes `color` on its track, so an expired cache reads red rather than idle grey.
54function ringSvg(frac: number, color: string): string {
55 const r = 6
56 const C = 2 * Math.PI * r
57 const p = Math.min(1, Math.max(0, frac))
58 const track = p === 0 && color !== GREY ? `stroke="${color}" stroke-opacity="0.6"` : `stroke="${GREY}" stroke-opacity="0.35"`
59 return (
60 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">` +
61 `<circle cx="8" cy="8" r="${r}" fill="none" ${track} stroke-width="2"/>` +
62 (p > 0
63 ? `<circle cx="8" cy="8" r="${r}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" ` +
64 `stroke-dasharray="${Math.max(1.5, C * p).toFixed(2)} ${C.toFixed(2)}" transform="rotate(-90 8 8)"/>`
65 : '') +
66 `</svg>`
67 )
68}
69
70type Item = {
71 key: string
72 frac: number
73 color: string
74 value: string
75 label: string
76 shortLabel: string
77 alt: string
78 // Cells the value keeps however its digits run, so a ticking clock does not shift the row.
79 minCells?: number
80}
81
82// The four figures, in order: cache left, 5h used, 7d used, context used.
83async function items($: EngineInterface, baseTtl: number): Promise<Item[]> {
84 const rl = await read($, limits)
85 const at = await read($, cacheAt)
86 const ttl = (await read($, cacheTtl)) || baseTtl
87 const t = await read($, now)
88 const c = await read($, ctx)
89
90 const left = at === null ? 0 : ttl - (t - at)
91 const isWarm = at !== null && left > 0
92 const cacheValue = at === null ? '--:--' : isWarm ? clockText(left) : '过期'
93 const out: Item[] = [
94 {
95 key: 'cache',
96 frac: isWarm ? left / ttl : 0,
97 color: at === null ? GREY : !isWarm ? RED : left < MINUTE ? AMBER : BLUE,
98 value: cacheValue,
99 label: '缓存',
100 shortLabel: '缓存',
101 alt: at === null ? '缓存:等待第一次请求' : isWarm ? `缓存剩余 ${cacheValue}` : '缓存已过期',
102 minCells: 5,
103 },
104 ]
105 for (const w of WINDOWS) {
106 const lim = rl.find(l => l.kind === w.kind)
107 // A window whose reset has passed starts over at zero until the next reading.
108 const isPast = !!lim && isReset(lim, t)
109 const p = !lim || isPast ? 0 : Math.min(100, lim.percentUsed)
110 const reset = lim && !isPast && !Number.isNaN(resetTime(lim)) ? span(resetTime(lim) - t) : ''
111 const value = lim ? `${Math.round(p)}%` : '–'
112 out.push({
113 key: w.kind,
114 frac: p / 100,
115 color: lim ? ringColor(p) : GREY,
116 value,
117 label: reset ? `${w.label} ↻${reset}` : w.label,
118 shortLabel: w.label,
119 alt: lim ? `${w.name}已用 ${value}${reset ? `,${reset} 后重置` : ''}` : `${w.name}:等待数据`,
120 })
121 }
122 const cp = c?.percent
123 out.push({
124 key: 'ctx',
125 frac: (cp ?? 0) / 100,
126 color: cp === undefined ? GREY : ringColor(cp),
127 value: cp === undefined ? '–' : `${cp}%`,
128 label: '上下文',
129 shortLabel: '上下文',
130 alt: cp === undefined ? '上下文:等待数据' : `上下文已用 ${cp}%`,
131 })
132 return out
133}
134
135// Width in the surface's cells: a CJK character takes two, the ring two, a gap one.
136const WIDE = /[⺀-鿿豈--]/
137const cells = (s: string) => [...s].reduce((n, ch) => n + (WIDE.test(ch) ? 2 : 1), 0)
138const rowCells = (row: Item[], labelOf: (it: Item) => string) =>
139 row.reduce((n, it) => {
140 const label = labelOf(it)
141 return n + 3 + Math.max(cells(it.value), it.minCells ?? 0) + (label ? 1 + cells(label) : 0)
142 }, 2 * (row.length - 1) + 2)
143
144// The fullest labels that fit: reset countdowns go first, then the labels themselves.
145const fullLabel = (it: Item) => it.label
146const shortLabel = (it: Item) => it.shortLabel
147const noLabel = () => ''
148function labelsFor(row: Item[], columns: number): (it: Item) => string {
149 if (!(columns > 0)) return fullLabel
150 return [fullLabel, shortLabel].find(labelOf => rowCells(row, labelOf) <= columns) ?? noLabel
151}
152
153export const register: Register = (on, options) => {
154 const baseTtl = options.cacheTtl === '5m' ? 5 * MINUTE : HOUR
155
156 on('session.start', async ($, e, next) => {
157 try {
158 const u = await $.session.usage()
159 await update($, ctx, () => u.context)
160 if (u.rateLimits.length) await update($, limits, () => u.rateLimits)
161 const t0 = await $.clock.now()
162 await update($, now, () => t0)
163 } catch {}
164
165 // One clock: every second while the cache is counting down, otherwise once a minute
166 // (enough for the reset countdowns), so an idle session redraws almost never.
167 let healedAt = 0
168 $.clock.every(1_000, async () => {
169 try {
170 const t = await $.clock.now()
171 const at = await read($, cacheAt)
172 const ttl = (await read($, cacheTtl)) || baseTtl
173 const isCounting = at !== null && t - at < ttl + 2_000
174 if (!isCounting && t - (await read($, now)) < MINUTE) return
175 await update($, now, () => t)
176 if (t - healedAt < MINUTE) return
177 healedAt = t
178 // Refill figures the session dropped (a /clear goes on under a new session id).
179 const u = await $.session.usage()
180 if (u.rateLimits.length && !(await read($, limits)).length) await update($, limits, () => u.rateLimits)
181 if ((await read($, ctx)) === null) await update($, ctx, () => u.context)
182 } catch {}
183 })
184 return next(e)
185 })
186
187 on('session.measure', async ($, e, next) => {
188 try {
189 if (e.changed.includes('context')) await update($, ctx, () => e.context)
190 if (e.rateLimits.length && e.changed.includes('rateLimits')) await update($, limits, () => e.rateLimits)
191 } catch {}
192 return next(e)
193 })
194
195 // Each main-thread model request reads, and so refreshes, the conversation's prompt cache;
196 // a subagent's request has a cache of its own. The countdown restarts as the request goes
197 // out and goes back if it never reached the cache (no response, or caching off).
198 on('turn.step', async function* ($, e, next) {
199 if (e.agentId) return yield* next(e)
200 // The bookkeeping never stands in the request's way: if it fails, the step just passes.
201 let sent: { t: number; prevAt: number | null; prevTtl: number } | null = null
202 try {
203 const t = await $.clock.now()
204 const prevAt = await read($, cacheAt)
205 const prevTtl = await read($, cacheTtl)
206 // Requests on extra usage past a spent window are cached for five minutes, not an hour.
207 const isOverage = (await read($, limits)).some(
208 l => WINDOWS.some(w => w.kind === l.kind) && l.percentUsed >= 100 && !isReset(l, t),
209 )
210 const ttl = isOverage ? 5 * MINUTE : (await read($, knownTtl)) || baseTtl
211 await update($, cacheTtl, () => ttl)
212 await update($, cacheAt, () => t)
213 await update($, now, () => t)
214 sent = { t, prevAt, prevTtl }
215 } catch {}
216 let isCached = false
217 try {
218 const r = yield* next(e)
219 isCached = !!r.usage && r.usage.cache_read_input_tokens + r.usage.cache_creation_input_tokens > 0
220 return r
221 } finally {
222 if (sent && !isCached) {
223 const { t, prevAt, prevTtl } = sent
224 try {
225 let isReverted = false
226 await update($, cacheAt, cur => {
227 isReverted = cur === t
228 return isReverted ? prevAt : cur
229 })
230 if (isReverted) await update($, cacheTtl, () => prevTtl)
231 } catch {}
232 }
233 }
234 })
235
236 // A resumed or forked session carries how long ago its last response was, and whether
237 // Claude Code reckons the cache lapsed; a /clear or compaction changes the context fill.
238 on('classic.SessionStart', async ($, e, next) => {
239 const r = await next(e)
240 try {
241 const t = await $.clock.now()
242 const s = e.seconds_since_last_response
243 if ((e.source === 'resume' || e.source === 'fork') && typeof s === 'number' && s >= 0) {
244 // Claude Code knows the real TTL: a cache it calls live past five minutes is the hour one,
245 // and one it calls lapsed stays lapsed whatever TTL is learned later.
246 const base = (await read($, knownTtl)) || baseTtl
247 const ttl = e.prompt_cache_likely_expired === false && s * 1000 >= base ? HOUR : base
248 await update($, cacheTtl, () => ttl)
249 await update($, cacheAt, () => (e.prompt_cache_likely_expired ? t - HOUR - 1000 : t - s * 1000))
250 await update($, now, () => t)
251 }
252 if (e.source === 'clear' || e.source === 'compact') {
253 const u = await $.session.usage()
254 await update($, ctx, () => u.context)
255 }
256 } catch {}
257 return r
258 })
259
260 // The prompt cache is per model: after a switch the next request starts a new one. A switch
261 // also says the TTL Claude Code caches with, which beats the setting from then on.
262 on('classic.PostModelSwitch', async ($, e, next) => {
263 const r = await next(e)
264 try {
265 const ttl = e.cache_ttl === '5m' ? 5 * MINUTE : HOUR
266 await update($, knownTtl, () => ttl)
267 await update($, cacheTtl, () => ttl)
268 if (e.source !== 'resume' && e.from_model !== e.to_model) await update($, cacheAt, () => null)
269 } catch {}
270 return r
271 })
272
273 // One slim row directly above the prompt, on its own line so nothing else crowds it out.
274 // The desktop draws rings (its footer slot by the model picker draws text only); the
275 // terminal draws text pies in the ring colours. As the band narrows the reset countdowns
276 // go first, then the labels; past that the row wraps rather than hides.
277 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
278 if (e.props.hasSurvey) return next(e)
279 const row = await items($, baseTtl)
280 const labelOf = labelsFor(row, e.props.bodyColumns)
281 if (e.surface === 'terminal') {
282 const { Box, Text } = $.ui.resolve(e as typeof e & { surface: 'terminal' })
283 return (
284 <Box flexDirection="row" flexWrap="wrap" columnGap={2} rowGap={0} paddingX={1}>
285 {row.map(it => {
286 const label = labelOf(it)
287 return (
288 <Box key={it.key} flexDirection="row" gap={1} flexShrink={0}>
289 <Text color={it.color}>{pie(it.frac)}</Text>
290 {it.minCells ? (
291 <Box minWidth={it.minCells}>
292 <Text>{it.value}</Text>
293 </Box>
294 ) : (
295 <Text>{it.value}</Text>
296 )}
297 {label ? <Text dimColor>{label}</Text> : null}
298 </Box>
299 )
300 })}
301 </Box>
302 )
303 }
304 if (e.surface !== 'desktop' && e.surface !== 'vscode') return next(e)
305 const { Box, Text, Svg } = $.ui.resolve(e as typeof e & { surface: 'desktop' })
306 return (
307 <Box flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={2} rowGap={0} paddingX={1}>
308 {row.map(it => {
309 const label = labelOf(it)
310 return (
311 <Box key={it.key} flexDirection="row" alignItems="center" gap={1} flexShrink={0}>
312 <Svg source={ringSvg(it.frac, it.color)} alt={it.alt} width={16} height={16} />
313 {it.minCells ? (
314 <Box minWidth={it.minCells}>
315 <Text>{it.value}</Text>
316 </Box>
317 ) : (
318 <Text>{it.value}</Text>
319 )}
320 {label ? <Text dimColor>{label}</Text> : null}
321 </Box>
322 )
323 })}
324 </Box>
325 )
326 })
327}
328types/index.d.ts 16 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2export type Ctx = { tokens?: number; window: number; percent?: number }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'usage-line': {
7 cacheAt: number | null
8 cacheTtl: number
9 knownTtl: number
10 now: number
11 ctx: Ctx | null
12 limits: Limit[]
13 }
14 }
15}
16