SLOPSHOPPER

usage-line

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

newbandtimer
v1.1.0MITupdated 2026-10-05sundyme/usage-line/plugins/usage-line
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-line
› 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 ○ --:-- 缓存 ◔ 31% 5h ○ – 7d ◑ 49% 上下文 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
○ --:-- 缓存 ◔ 31% 5h ○ – 7d ◑ 49% 上下文
README

<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 逐秒倒计时;最后一分钟变琥珀色,过期显示红色「过期」
5h5 小时额度已用百分比↻2h54m 是距离重置的时间
7d7 天额度已用百分比↻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。

在哪里显示

  • 桌面端 / VS Code:输入框正上方的一行,带 SVG 圆环。窗口变窄时先收起重置倒计时,再收起文字标签,只留圆环和数字;从不挤压、截断。出现问卷时自动让位。
  • 终端:同样在输入框正上方独占一行,用 ○ ◔ ◑ ◕ ● 表示进度,颜色与圆环一致。窗口变窄时先收起重置倒计时,再收起标签,再窄就换行,不会被隐藏。

缓存倒计时是怎么算的

Claude Code 不直接告诉插件缓存何时过期,usage-line 根据请求本身推算:

  • 主线程每次发出模型请求,都会读取并刷新对话的提示词缓存,倒计时从那一刻重新开始;子代理的请求有自己的缓存,不影响倒计时。
  • 只有响应里确实读写了缓存(cache_read / cache_creation 大于 0)才算数;请求失败或没有缓存时,倒计时回到上一次。
  • 订阅的缓存时长是 1 小时;当某个额度窗口已用满、按超额用量计费时,缓存时长是 5 分钟,插件会自动识别。
  • 切换模型时会清空倒计时(缓存按模型分开),并采用 Claude Code 报告的 TTL;恢复(resume)会话时,按上次响应的时间接着算。

设置

选项默认说明
cacheTtl1h缓存时长基准,1h 或 5m。订阅保持 1h;超额时插件会自动按 5 分钟算,无需改动。

默认即可;修改用 /plugin configure usage-line@usage-line。

要求与限制

  • 基于 Claude Code 的 function hooks 插件接口(早期功能,需较新的 Claude Code,已在 2.1.28x 上验证)。交互式会话默认启用;claude -p 等非交互运行需要设置 CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1。
  • 额度数据来自 Claude Code 本身;使用 API Key 而非订阅时没有 5h / 7d 额度,对应圆环显示 –。
  • 缓存倒计时是推算值,与服务端实际的缓存淘汰可能有出入。
  • 界面文案目前为中文。

开发

.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 无关联。

Source 2 files
hooks/register.tsx 328 lines
1import { 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}
328
types/index.d.ts 16 lines
1export 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